oliblue-evan/dsh-cli-inventory ↗★ 0

dsh-cli-inventory

DSH 环境与能力:在「设置」里列出这个 Agent 能操作什么 —— 工具注册表、技能、MCP 服务器,以及你自己安装的命令行工具与运行时;只读、容错,系统目录不列出 适合需要盘点、核对当前 Agent 拥有何种本地能力的用户。

패키지
dsh-cli-inventory
호환성
미검증
버전
0.1.2
라이선스
MIT
최근 업데이트
2026. 9. 29.

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:oliblue-evan/dsh-cli-inventory

dsh-cli-inventory 🧰

English quick start — A DSH plugin that answers "what can this agent actually operate?" It adds one page to Settings: the tool registry (the tools really handed to the model), the skill registry, MCP servers and their contributed tools, then the machine side (CLIs on PATH, bundled runtimes, system commands folded away). Everything is read-only and fault-tolerant; the command list only enumerates PATH directories and executes a built-in allowlist to read versions. No network, no credentials, no telemetry. Install: plugin_manager(action: "install_bundle", target: "github:oliblue-evan/dsh-cli-inventory"), then restart DSH and reload the page. MIT.

DSH 的设置里只有账户 / 通用 / 模型 / 插件 / 智能体预设五页 —— 它不会告诉你这个 Agent 手里有什么。 这个插件补上那一页:设置 → 环境与能力。

这一页的定位改过一次,缘由值得写下来。 第一版做的是"这台机器上装了哪些命令行工具", 结果列出 1270 个命令、其中 1263 个是 macOS 内部工具(AssetCacheLocatorUtil、BTLEServer…), 既没用也没信息量。而清单本来就不是能力边界 —— 有 bash 就能跑 PATH 里任何东西, 真正的边界是权限策略。所以改成先答"我能操作什么"(工具 / 技能 / MCP), 机器清单退到后面,系统目录干脆不列(占了实测里 96% 的条目,却毫无参考价值)。

版式照官方「内置插件」页做(见下一节):带放大镜的搜索框 → 可折叠的组 → 两列卡片网格。

🔍 搜索工具、技能、命令…
──────────────────────────────────────────────────────────

▾ 工具   40        来自宿主工具注册表
  ┌───────────────────────┐ ┌───────────────────────┐
  │ bash                  │ │ read                  │
  │ 执行 bash 命令(bash… │ │ 读取 UTF-8 文本文件…  │
  └───────────────────────┘ └───────────────────────┘
  ┌───────────────────────┐ ┌───────────────────────┐
  │ grep                  │ │ write                 │
  │ 用 ripgrep 正则搜索…  │ │ 创建或完整替换文本…   │
  └───────────────────────┘ └───────────────────────┘
  …

▾ 技能   0         来自 ~/.dsh/skills 与技能提供者
  还没有技能。把技能放到 ~/.dsh/skills//SKILL.md 就会出现。

▾ MCP    0         未配置服务器
  还没有配置 MCP 服务器;配置后它们贡献的工具会出现在这里。

▾ DSH 自带运行时   2         由 harness 自带
  ┌──────────────────────────────────────────────────┐
  │ node                                             │
  │ v24.18.1                                         │
  │ ╭──────────────────────────────────────────────╮ │
  │ │ /Applications/DeepSeek Harness.app/…/node    │ │ ← 等宽「身份标签」放路径
  │ ╰──────────────────────────────────────────────╯ │
  └──────────────────────────────────────────────────┘

▾ 你自己安装的   7        扫了 PATH 中 7/12 个目录 · 取到版本 4 个
  gh · uv · dsh · tailscale · uvx · …

版式与交互照官方「内置插件」页

照的是 DSH 开源仓库里的真源码(不是打包产物反推):

  • packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  • 同目录 PluginInventorySettingsTab.module.css

对齐的东西(前两版做过两处明显错处,都在这里修掉了):

要素具体做法
顶部标题区section 自己渲染(框架不代劳):h2.heading(margin:0;18px;600)+ p.intro(margin:0;13px;三级色)。缺了它顶部就是一片空白
层级与间距根 .section(gap 12、max-width 760)→ heading / intro / .panel(padding-top:2px)→ .catalog(gap 12、container)→ 搜索框与各组
搜索框外层是 ``(点标签即聚焦),内嵌放大镜(absolute;left:12px)、visuallyHidden 文案、36px 高、.5px 描边、color-mix 焦点环
组.groupTitleRow(min-height:36px,chevron + 标题)+ p.groupSub(若干 `` 用 · 分隔,计数也是其中一项)+ .groupBody
卡片可点击展开li.card[data-open] 内是 button.cardContent(aria-expanded + aria-controls),右侧 .cardTrailing 放 chevron(展开时 rotate(180deg));同时只展开一张卡(官方 `expanded: string
展开后的详情.cardDetails 里:+ 键值表(grid-template-columns:76px minmax(0,1fr)、div{display:contents}、dt 11px 三级色 / dd 12px 二级色)
卡片内容标题是 ****(14/20、500)、描述 (12/18、两行截断,展开后不再截断)、等宽身份标签是 ``(--dsw-alias-bg-module-platform + --ds-font-family-code)
加载/错误骨架卡用同一个 .cards 网格(4 张)+ visuallyHidden 文案;错误态给「重试」按钮(官方也只有这里才有重试按钮)

用的是设置页专用 token(--dsw-alias-settings-card-stroke/-fill、--dsw-alias-bg-module-platform、 --dsw-radius-*、--ds-font-family-code),所以它在设置里看起来是原生的。

展开看什么:折叠时描述被截成两行,展开时取消截断(官方原样:.card[data-open='true'] .cardDescription{display:block}), 所以完整说明本来就出现在卡体里 —— 详情区因此只放卡片上看不到的事实:

卡片展开后的详情
工具无额外事实(展开即读完整说明)
技能提供者、何时使用
MCP服务器、原始工具名
命令行 / 运行时完整路径(顶部那个等宽芯片会截断,展开行不截断 —— 官方也是「chip 安静 / 展开行醒目」的分工)

不许重复:详情里不能再放说明或版本(版本就是命令卡的描述行)。早先两处都放了, 结果同一段话在展开后出现两遍。现在有专门的门禁盯着这件事(test/client-logic.test.mjs)。

一个事后拿到官方背书的巧合:官方注入样式的方式是「按 data-plugin-css 键 querySelector,已存在就复用、从不移除」。这与我在 dsh-usage-pill 上修完 「组件还在、样式没了」那个 bug 之后改成的写法完全一致 —— 当时是从失败模式反推出来的, 现在算是确认了这是官方约定。

为什么不列系统目录

一开始我把整条 PATH 都列出来,结果是 1270 个命令里 1263 个来自系统目录 (/usr/bin 932 个、/usr/sbin 222 个…),全是 AssetCacheLocatorUtil、BTLEServer 这类 macOS 内部工具 —— 既没有实用价值,也把真正要看的那几个(gh / uv / dsh)淹掉了。

所以现在宿主直接跳过系统目录。这带来两个结果:

  • 页面只剩"你自己装的"那几行,不再有上千行;
  • 扫描从 ~800 ms 降到 ~40 ms(少掉 96% 的 stat)—— 顺带把 15 秒缓存和 「重新扫描」按钮都变成了多余机关,于是也一并删掉了。

需要看某个系统命令时用终端 which / command -v 即可,那不是这一页的职责。

界面上做的几件事

做法为什么
能力段排在最前工具 / 技能 / MCP 才是"我能操作什么";机器清单退到后面
空段给出"怎么才会有"技能与 MCP 现在多半是 0,直接写清放哪里、配了就会出现在这里
有版本的排前面有版本信息的更能说明"它是什么",没取到版本的一律注明「未取版本」
每段独立限流某一段再长也不会挤掉别的段
摘要分两行一行能力计数、一行机器计数,各自一眼可读

分类规则(lib/path-scan.js 的 classifyDir):

归类目录处理
你自己安装的~/**(含 ~/.local/bin、~/.cargo/bin)、/usr/local/bin、/opt/**(含 /opt/homebrew/bin)扫描并列出
系统自带/bin、/sbin、/usr/bin、/usr/sbin、/usr/libexec、/System/**、/Library/Apple/**、/usr/lib/**跳过,不列(classifyDir 仍在,将来想列回随时可以)

路径会缩掉家目录前缀(/Users/you/.local/bin/gh → ~/.local/bin/gh)—— 界面更短, 你分享截图时也不会顺带暴露用户名。

安全边界(这一节最重要)

枚举 PATH 只是读目录,安全。取版本需要执行二进制,风险就在这里,所以:

  • 只对 lib/allowlist.js 里写死的清单执行(约 40 个常见工具:git / gh / node / python3 / uv / jq / docker / …),参数固定、不带任何用户输入、单次超时 5 秒、并发上限 6、 只取第一条输出行并去掉 ANSI 颜色;
  • 白名单之外的命令照常列出名称与路径,但绝不执行;
  • 宿主半边不写文件、不联网、不读凭据;
  • 数据路由带来源守卫:非回环 Host、跨站(Sec-Fetch-Site: cross-site)一律 403,非 GET 405。

想加白名单条目?必须是"会打印版本后退出、无副作用"的命令,直接在 lib/allowlist.js 里加一行, npm test 会检查清单结构(名称唯一、参数非空、白名单里不得出现 rm / bash 这类)。

数据从哪来

宿主半边注册一条只读路由 /api/cli-inventory/list,做四件事:

  1. 读能力:工具注册表、技能注册表、以及 MCP。三者全部容错:取不到或抛错 只如实显示 0,绝不让整页失败。

    工具必须带作用域读 —— 这是这一页准不准的关键。tools.schemas(scope?) 的契约写着 "scope 是 the viewing scope (the agent);省略 = 全局视图"。实测:

    视图工具数拿到什么
    省略 scope(全局)1只有 load_workspace_dependencies 这类不带作用域的
    预设作用域(acquireScope() 的 generation key)26静态组合的那批:read/write/edit/bash/grep/web_search…

    而当前会话实际有 40 个:多出的 14 个是随会话组合的(subagent、spawn_teammate、 team_task_*、schedule_*、cordis_inspect_*、plugin_manager,服务契约原话是 "delegation tools are composed for a Session")。

    那 14 个从插件侧读不到,这条路我试过并撤掉了。源码里 view(scope) 是 this.layers.chainLayers(scope) —— scope 必须是层注册表登记的键。插件能拿到的 只有 agentPresets.acquireScope() 给的 generation key;用 agent/created 事件里的 this(Scoped)当 scope 试过,注册表不认,直接退回全局视图(1 个)。 Tool inspect provider 之所以能看到 40,是因为它运行在请求方 agent 的作用域内。 所以这一页显示的是"该预设给 Agent 的组合",并在脚注里如实说明会话级工具不在其中。

    防退化规则:两个视图都读,取工具更多的那个。作用域解析一旦失配(比如宿主改了 scope 的形状),预设视图会退化成全局视图 —— 那时自动回落,页面绝不会反而变少。 这条规则是实测踩坑(26 → 1)之后加的,冒烟测试有专门用例。

    界面上如实标注:按预设 。 MCP 不需要翻配置:dsh-mcp-client 把工具注册成 mcp____, 服务器名直接从工具名反推(只按第一个 __ 切 —— 服务器名里出现单个下划线很常见, 按最后一段切会把名字拆坏)。

    工具参数也一并下发(ToolSchema.parameters 里的 properties 与 required)—— 折叠时卡片正文就是描述,展开后给"参数 / 必填"才有实质内容,否则说明短的工具点开等于没反应。

  2. 按 PATH 顺序读各目录 —— 只读"你自己装的"那些,系统目录直接跳过; stat 跟随符号链接(与 which 语义一致),先出现的目录赢;

  3. 对白名单里存在的命令取版本;

  4. 报出 DSH 自带的运行时,标为 harness。运行时取两个来源:当前宿主进程自己 (process.execPath,桌面版就是 app 内自带的 node),以及 $DSH_HOME/dsh-runtimes//dependencies/{node,python}/bin/*(CLI 侧安装的运行时)。

早先只从 process.execPath 旁边推 python 路径(../python/bin/python3)。那在 CLI 启动时成立, 但在桌面版下必然失败 —— app 自带的 node 旁边没有 python,界面上于是只有一个 node、 python 凭空消失。改成扫运行时目录后两种启动方式都能列全。

一次扫描只走"你自己装的"那几个目录,实测约 40 ms(跳过系统目录省掉了 96% 的 stat)。 所以这一页没有缓存、也没有刷新按钮:每次打开就是最新的。

文件

index.js              宿主:扫描 + 白名单探测 + 只读路由(零第三方依赖)
client.js             客户端:设置页(单文件 bundle,含可测的纯逻辑区块)
lib/capabilities.js   工具 / 技能 / MCP 的识别与归一化(纯函数)
lib/path-scan.js      PATH 解析 / 可执行判定 / 顺序合并 / 版本行清洗 / 目录分类(纯函数)
lib/allowlist.js      版本探测白名单(安全边界所在)
cordis.patch.yml      bundle 层:挂一行宿主条目
locale/{en,zh}.json   插件页标题与简介(官方 meta 约定)
assets/icon.svg       插件图标
test/                 37 项断言 + 宿主冒烟测试

安装 / 卸载

# 官方 CLI
dsh plugin --profile 
 add github:oliblue-evan/dsh-cli-inventory

或在 DSH 里用 plugin_manager:

plugin_manager(action: "install_bundle", target: "github:oliblue-evan/dsh-cli-inventory")

卸载:

dsh plugin --profile 
 remove dsh-cli-inventory

装完必须重启 DSH:宿主半边不会被热重载(Node 的 ESM 缓存),新路由要重启才注册。 重启后打开 设置 → 环境与 CLI。

本地开发用 link: 安装(改代码即时生效,宿主半边仍需重启):

plugin_manager(action: "install_bundle", target: "link:/path/to/dsh-cli-inventory")

隐私与数据披露

同样的内容以机器可读形式声明在 package.json 的 disclosure 字段(DSH 插件市场 §9 披露契约)。

项声明
云端依赖无 —— 插件不发起任何网络请求
完全离线是
凭据不读取任何凭据 / API Key
权限只读 PATH 目录;对白名单执行 --version 取版本
数据留存无 —— 不落盘、不外发,也没有缓存(每次请求都是当场扫描)
遥测 / 统计无
返回内容仅命令名、版本、缩略路径;不含绝对家目录路径

测试

npm test          # 37 项断言:能力识别 / PATH 解析 / 分类 / 白名单 / 客户端纯逻辑 / 打包契约 / 结构契约
node test/host-smoke.mjs   # 宿主冒烟:真的读 PATH、真的取版本、验证来源守卫、只扫用户目录、运行时识别、能力段容错

npm test 是 hermetic 的(不碰文件系统、不拉进程);冒烟测试单独跑,因为它确实会扫盘。

这些门禁都是踩过坑之后固化下来的:

  • 打包契约 —— exports 必须暴露 ./package.json 与 ./locale/*.json。宿主读插件页标题/简介 走 Node 的 ESM 解析器,exports 没声明就等于"资源不存在",而且不报错、静默退化成没有标题和图标。
  • 结构契约 —— 样式必须插件级注入(塞在组件树里的话,换个槽位渲染就一个样式都拿不到); 宿主半边不得出现裸模块名(link: 安装下解析失败会让整个宿主半边起不来); 用 999px / 50% 做圆角的地方必须写 corner-shape:round(宿主把全局圆角改成超椭圆了,不写就变形)。
  • slots.inject 必须由 ctx.effect 管住 —— 否则插件被 dispose 后注册会泄漏,而 `` 已被移除, 页面变成「组件还在、样式没了」。(这条在 dsh-usage-pill 上真实爆过一次。)
  • 词条与隐私 —— 中英词典必须键一致、且没有声明了却引用不到的词条(写成 t('scope.' + key) 拼字符串会被它抓出来);冒烟测试检查响应里不得出现绝对家目录,这条当场抓到过一个真 bug: 响应里漏了未缩略的 dir 字段。

门禁本身也做了变异测试:把修复回退、看门禁会不会报错。第一版 slots.inject 门禁就是这么被 证明是假的("往前 160 字符里出现过 ctx.effect( 就算过",多处注入时抓不到漏包的那处)。

已知取舍

  • 只扫"你自己装的"那部分 PATH:~/**、/usr/local/bin、/opt/**。系统目录 (/usr/bin 等上千项)不列出 —— 那是实测里 96% 的噪音。要看某个系统命令用终端的 which / command -v;/Applications 里的 GUI 程序也不算 CLI。
  • 版本只对白名单取(约 40 个常见工具)。白名单之外一律显示"未取版本" —— 这是有意的安全取舍, 不是功能缺失。
  • 单次扫描约 40 ms,每次打开这一页都会重新取数(没有缓存这层机关)。
  • 每段最多渲染 80 行;现在条目只有十几条,这个上限几乎用不到。
  • 白名单是代码里的静态清单,新工具不会自动出现版本;提 issue 或自己加一行都行。
  • 技能与 MCP 现在多半是 0(本机既没装技能、也没配 MCP 服务器)。这不是占位符: 接口已经接好,~/.dsh/skills/**/SKILL.md 或一个 MCP 服务器一出现,它们就会自动列出来。
  • 工具列表是"当前组合"的:换 Agent 预设、加 MCP 服务器、装带工具的插件,这里都会跟着变。
  • 没做「斜杠命令」:ctx.commands.list() 的签名要求传入一个 Agent,插件侧没有稳定入口; 而且那是你输入的命令,属于用户侧能力,不是 Agent 的。要做的话另开一段更合适。

协作说明

本插件由 李敖(oliblue) 与 DeepSeek(deepseek-flash) 协作完成:需求、设计与逐轮验收由作者负责, 代码实现与测试由模型完成;版权归人类作者所有(见 LICENSE)。