# omdsh-plughub [English](README.md) | 中文 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 设置里的插 件中心:在自带的 Plugins 页签旁边再加一个,列出可以从上游安装的插件,并 把已经装上的插件都配置好——或者先停用,文件还留着。插件中心和模式系统始 终保持启用。 以前装一个插件,是在终端里敲 `dsh plugin --profile web add `;配一 个插件,是手改 profile 的 `cordis.patch.yml`。现在这两件事都在页面上。 ## 它提供什么 | 界面 | 从哪来 | |---|---| | 设置 → 插件 下的第三个页签 **插件中心** | `settings.plugins.tab` 里的一个条目——ui-settings 为"清单类"和"配置类"插件留的那个座位 | | 合并后的目录,以及每个来源的结果 | `GET /api/plughub/catalog`,由 `local`、`registry`、`github` 三个来源解析而来 | | 可安装里的安装或更新;已安装里的更新和卸载 | `POST /api/plughub/install`、`/update`、`/uninstall`,各自 shell 出去执行 `dsh plugin --profile ` | | 已安装里的启用/停用 | `POST /api/plughub/enabled`,改写 `dsh.profile.bundles` 和一份停用名单;包仍留在 `node_modules`。插件中心和模式系统可以更新,不能停用,也不能卸载。 | | 每个已安装插件的配置表单 | `GET`/`POST /api/plughub/settings`,传递 `ctx.settings.describe({ redactSecrets: true })` 与 `ctx.settings.mutate` | | 操作进度与重启提示 | `GET /api/plughub/events`,一条事件流 | | `omdsh.plugin.card` slot | `ctx.slots`——通用表单画不出它的控件的插件,在这里注册自己的那张脸 | | 它自己的 settings 命名空间 `omdsh-plughub` | `ctx.settings.register`,用的就是每个插件都用的那套通用表单 | | 终端上的 `omdsh-plughub` | 一个 `bin`,解析的是同一份目录,跑的是路由用的同一个 `Installer` | 这个页面上有两个字符串指向这个包:中文里写法相同,英文里只差一个字母, 而这个差别出自约定,不是疏忽。页签叫 **插件中心**(英文 **Plugin hub**): 它属于设置界面的 chrome,所以和 harness 摆在它旁边的页签 **Plugin list** 一样用 sentence case。另一个 **Plugin Hub** 是这个包自己的 `dsh.plughub.displayName`,只给已安装列表里的一张卡片当标题——就是这个 插件自己的那张。所有卡片的标题都由同一段代码、从同一个字段取出,而 Title Case 正是[规则 5](https://omdsh-plugins.github.io/conventions/#rule-5) 对每个 `displayName` 的要求。harness 本身没有任何改动:那个页签是一个已 经发布的座位,移除这一行就原样交还。 ## 思路 插件中心难的不是那个"安装"按钮,而是后半截:插件装上之后,它的配置界面 从哪儿来? 有两种答案。要么每个插件都为这个面板附一张卡片——但那样一来,比这个包 晚发布的插件装上后什么也点不了,而这个包还得随着插件数量一直长下去。要 么,面板去读插件**本来就已经声明**的东西。 harness 让第二种答案成为可能,因为它本来就有一条 user-settings 接缝。插 件用一份 [schemastery] schema 注册一个命名空间; `ctx.settings.describe({ redactSecrets: true })` 会把这份 schema 连同当 前取值、组装层 base、原始 user 层、脱敏后的密钥槽位和一个 revision 一起 交出来。这正是一个配置表单需要的全部输入。 所以这个包只做两件事——渲染表单、安装包——并且不认识任何一个具体的插 件。明年才写出来的插件,只要遵守[约定](https://omdsh-plugins.github.io/conventions/), 装上的当天就有配置页。 ``` 插件(host 半边) plughub(host 半边) plughub(浏览器半边) ──────────────── ─────────────────── ──────────────────── ctx.settings.register( describe(脱敏) ──────────→ rehydrateSchema 'omdsh-shortcuts', ─→ plan ─→ 控件 Config, settings.mutate ←───────── 一次按路径寻址的修改 { base: entryConfig }) ``` 左边一列没有提到这个包,右边一列没有提到快捷键。 ### 中间那一列,以及它为什么还在 那一步曾经必须存在:harness 自己的 settings 线路被一张写死的命名空间名单 挡住,任何 out-of-tree 插件都过不去。`0.1.0-rc.7` 把那道闸拆了:Host 现 在会把每一个已注册的命名空间都送到浏览器,想在官方「可配置」页签上放一 张卡片的插件,往 `settings.plugin.item` 里注册一张即可。 这条路由还在,是为了另一件事。官方页签只渲染认领了那个槽位的命名空间; 这个中心则会从每个 omdsh 插件已经注册好的 schema 画出一张通用表单,包括 那些从没写过卡片的插件。而且它画出的边界比 Host 的更窄——一个命名空间只 有在某个**已安装**的 bundle 用 `dsh.plughub.settings` 声明了它时才可达, 所以同一个进程里注册着的 `shell` 和 `agent-loop` 在这里够不到。 它只承担传输,别的什么都不做:校验、分层、脱敏、并发冲突、提交,全都还 在 `ctx.settings` 里。 ## 这个页签里有什么 **目录来源**——这个插件自己的配置,放在最上面,因为这些字段回答的正是 "下面这个列表从哪儿来"。它就是一个普通的 settings 命名空间,用的是每个 插件都用的那套通用表单;只是它待在这里,而不是待在已安装列表里——这样 盯着空目录的人,不用去翻找那个能修好它的开关。当所有来源都失败、或者什 么都没拉到时,它会自己展开一次。 **可安装**——合并后的目录,每张卡片一个按钮。没装的时候是"安装";装了 且没有新版本时是灰色的"更新";有了新版本,同一个按钮就亮起来。卡片的标 题、简介和文档链接都是插件自己的,从它的 `dsh.plughub` 声明里读出来,按 当前语言解析。 **已安装**——这个 profile 里的每个插件一行,无论当前是否在组装栈上。启 用和停用共用一个按钮:停用把依赖从组装栈上拿下来,但不碰 `node_modules`,再想用时按启用即可,不必重新安装。模板自带的 bundle、插 件中心和模式系统都不能从这里停用。展开后是按该插件的 settings schema 生 成的表单;没有注册命名空间的插件会明确说"没有可配置项"——这是一个真实 的回答,而不是一个空盒子。 profile 有变化时,顶部会出现重启提示。插件层是在启动时组装的,被监听的 只有用户 patch 层,所以新装的 bundle 确实没法热挂载——说"需要重启"是实 话,不是拿话术盖住一个限制。 ## 更新 "可安装"里已安装的卡片上只有"更新"这一个按钮,没东西可拉的时候是灰的。 到底是哪种状态,由 Host 用它手上已有的两个数字判断——获胜来源声明的版 本,和磁盘上那个包的版本——按 semver 比,不是按字符串比(`0.10.0` 比 `0.9.0` 新,`1.0.0` 比 `1.0.0-rc.2` 新)。 | 状态 | 卡片上显示 | 按钮 | |---|---|---| | `available` | `0.1.0 → 0.2.0` | 高亮 | | `current` | 单个版本号 | 灰:已是最新 | | `linked` | 单个版本号 | 灰:从本机目录链接安装,文件本身就是来源 | | `unknown` | 已知的那个版本号 | 灰:该来源没有提供可比较的版本号 | 更新跑的就是安装跑的那条 `dsh plugin add`,只是 specifier 会说清楚要去 哪儿。git specifier 原样不动,因为重新解析 ref 本来就是它要做的全部事 情;registry specifier 则会补上目录里那个版本—— `pnpm add @scope/name@0.2.0`——因为对一个清单已经满足的依赖跑光秃秃的 `pnpm add `,pnpm 会打印 `Already up to date`,什么都不改,然后以 0 退出:那次操作会报成功,卡片上却照旧挂着同一个更新。 点名版本除了正确,还换来两件事。按钮装的是它上面印着的那个版本,而不是 按下去那一刻 `latest` 恰好指向的东西;而且显式版本不受 pnpm `minimumReleaseAge` 约束——它会把新发布藏上一整天,否则发布之后第一次 按下按钮,就又是那个静默的空操作。代价是 profile 里记的是确切版本,而不 是一个范围;对一个"更新靠按按钮"的清单来说,这反而是诚实的。 它单独占一条路由,是因为前置条件正好相反:安装拒绝 profile 已经有的包, 更新则要求它已经有。 更新完会出现重启提示。更新不会改变 bundle **列表**——同一个包名,只是 后面换了一份代码——所以按列表比对会漏掉这个唯一换掉运行中代码的操作, runtime 因此额外记了一笔。这里故意保守:更新拉到同一个版本时,代价是白 重启一次;反过来的代价,是有人以为自己换了代码,其实跑的还是旧的。 `linked` 就是 checkout 安装的样子,`dsh plugin add <路径>` 记的是 `link:`。所以一个全部由本地目录拼起来的 profile,每个更新按钮都会是灰的 ——而这是对的:改 checkout 就已经是在改插件了。 ## 目录从哪里来 三个来源,按包名合并,优先级从高到低: | 来源 | 是什么 | 为什么存在 | |---|---|---| | `local` | 本地插件 checkout 目录 | 你正在改的那份,胜过别人发布的那份 | | `registry` | 一份人工维护的 JSON 清单 | 一次请求拿到全部元数据,也是上游表达"我推荐哪些"的地方 | | `github` | 枚举账号下的仓库 | 零维护:把插件仓库推上去它就出现了 | 优先级低的来源仍然会补上赢家缺少的 `repo`——本地 checkout 很少知道自己 发布在哪里,卡片上的链接因此更好用。 开箱即用时,目录就是这个集合发布的那份策展清单,从缓存 GitHub 的 CDN 上 取: `https://cdn.jsdmirror.com/gh/omdsh-plugins/registry/registry.json` GitHub 枚举默认关掉——`upstream` 是空的——因为那一份文件已经列出了每 一个插件;而先问 GitHub 这个账号有哪些仓库、再对每个仓库去拉 `package.json`,正是页签第一次打开会变慢的原因。把 `upstream` 指到一个 账号,就会连同枚举一起打开;把 `registryUrl` 清空,则会改由账号推导 `https://raw.githubusercontent.com//registry/HEAD/registry.json`。 两个都清空,则只用 `localSources`。 本地来源只往下扫**一层目录**,所以把可安装的那一半放在 `packages/` 里的 monorepo 不会被提供。这通常是对的——这里的 monorepo 装着的多半是**另一 种 surface** 的 bundle,而一个 profile 只组装一种 surface。确实想让它出 现时,把 `localSources` 直接指向里面那一层目录。 失败的来源会被**报告出来**,而不是藏起来。空列表上,"这里没有插件"和 "GitHub 对这个账号限流了"长得一模一样,但只有其中一个会自己恢复。 只有一个例外,而且是反方向的:**推导出来的清单地址返回 404** 算"没有", 不算"坏了"。上游账号不发布策展清单是常态,仓库枚举本来就覆盖这种情况; 要是每个默认安装底下都挂一行红字,指着一个谁也没承诺过的文件,只会训练 人忽略那个报告失败的位置。而人手填进 `registryUrl` 的地址是相反的情形: 他本来就认为那里该有一份清单,所以它的 404 和其他失败一样会被报告出来。 清单格式是 `{ "plugins": [...] }`(裸数组也行): ```json { "plugins": [ { "name": "@omdsh-plugins/omdsh-shortcuts", "repo": "omdsh-plugins/omdsh-shortcuts", "version": "0.1.0", "plughub": { "displayName": { "": "Shortcuts", "zh": "快捷键" }, "order": 10 } } ] } ``` `spec` 可以显式写;不写时按 `github:` 推导。 这个账号发布的清单在 [`omdsh-plugins/registry`](https://github.com/omdsh-plugins/registry),由 各插件自己的 `package.json` 生成,而不是手工维护。 ## 它持有的路由 | 路由 | 方法 | 作用 | |---|---|---| | `/api/plughub/catalog` | GET | 合并后的目录,`?refresh=1` 重新拉取每个来源 | | `/api/plughub/installed` | GET | 这个 profile 的插件,哪些可以卸载,哪些在组装栈上 | | `/api/plughub/install` | POST | `{ id }`——安装一个目录条目 | | `/api/plughub/update` | POST | `{ name }`——按目录当前提供的 specifier 重装一个已安装插件 | | `/api/plughub/uninstall` | POST | `{ name }`——卸载一个依赖管理的 bundle | | `/api/plughub/enabled` | POST | `{ name, enabled }`——组装或停用一个依赖管理的插件 | | `/api/plughub/events` | GET | 操作进度、重启标记与设置失效通知,事件流 | | `/api/plughub/settings` | GET | 已安装插件持有的每个命名空间,已脱敏 | | `/api/plughub/settings` | POST | `{ ns, ops, expectedRevision }`——一次按路径寻址的修改 | ## 可达范围 读路由用的是 `/api` 同款栅栏:Host 头指向我们(loopback,或这个部署被明 确告知要服务的 authority),加上同源的浏览器标记。它们和渲染它们的设置 面板一样可达,不多不少。 写路由是 **loopback only**,不管 `--trusted-host` 说了什么。它们每一个 都会改变这台机器:安装会执行那个包的 `prepare` 脚本,写设置会落盘到 Host 的文档。"这个部署把 `/api` 开放到了局域网",不等于同意了这两件事 里的任何一件。真的想在一个已发布的 `dsh web` 上装插件的人,仍然可以从 终端装——在那里,这个决定明显是他自己做的。 而且写请求指的都是 Host 已经解析出来的东西。安装指的是一个目录**条目**, 永远不是包 specifier——Host 在自己解析出来的目录里查 specifier,所以任 何请求都够不到所配置上游没有提供的包,也根本不存在能携带 specifier 的 请求形式。写设置指的是某个**已安装**插件声明自己持有的命名空间。两张白 名单都是结构性的,而不是"需要有人记得写"的检查。 ## 一次安装实际怎么跑 它 shell 出去执行 `dsh plugin --profile add `。 `pnpm add` 只是安装的一半;另一半是把 `dsh.profile.bundles` 与磁盘上的 现状对账,而这份对账逻辑属于启动这个 runtime 的那个 launcher。在这里重 新实现一遍,就得维护一份必须紧跟 launcher 的副本——而 launcher 是用户 自己独立升级的;一旦写错,一个"刚装好"的插件下次启动时就不在树里。 有一件事这个包必须知道:pnpm ≥10 在未加入白名单前拒绝执行依赖的安装脚 本,而一个 git 来源的 dsh 插件是**在 `prepare` 里构建自己**的——它发布 出去的树里没有 `lib/`。于是一次没加白名单的 git 安装会"成功",写入依 赖,完成 bundle 对账,然后下次启动死在 `Cannot find module .../lib/index.js`。所以 `allowBuilds` 条目是在安装 **之前**写进 profile 的 `pnpm-workspace.yaml` 的——因为那个错误要比犯 错晚一次重启才出现。 写**包名**对 registry 依赖是对的,对 git 依赖则不够。pnpm 给 git 来源的 包用的 key 是它实际解析到的那个 tarball——`@scope/name@https://codeload.github.com/ owner/repo/tar.gz/`——而且拒绝任何别的写法,所以那条提前写好的条 目形式正确、实际无效。那个 commit 在安装前不可知,除非把 pnpm 自己的解 析重写一遍,而且插件每推一次它就变。 所以先写名字;如果 pnpm 仍然拒绝,就去问它。它的拒绝信息里印着它想要的 那个精确 key,把这个 key 读出来写进去,再跑一次安装。 **只要还在学到新东西,就继续跑**——因为 pnpm 报的是它**实际撞到**的那 个拒绝,而不是接下来会撞到的那些。一个有原生依赖的插件,会先卡在那个依 赖上,等它被放行之后才轮到插件自己的 `prepare`:`omdsh-remdev` 要三轮, 正是这个原因。循环的条件是"有没有进展",而不是次数:一个已经是 `true` 的条目没有新东西可写,所以某一轮什么都没教给它时,循环就结束;上限四次 只是兜底,而不是会拦住一次正常安装的东西。 有一件事必须"回答"而不是"读取":pnpm 会**自己**把被拦的包写进那个文 件,值是 `set this to true or false`。那是一个问句,而按下 Install 的人 已经回答过了,所以那个值会被改写,而不是被当成"条目已存在"。 操作串行执行:同一个目录里两个 `pnpm` 会抢同一把 lockfile,输的那个给出 的报错描述的是这场竞争,而不是人做错了什么。 ## 同样的安装,在终端里 这个包带一个 `bin`。它就是上面那条安装路径,只是入口从路由换成了 argv: ```sh omdsh-plughub list # 目录里有什么,已经装了什么 omdsh-plughub add omdsh-status # 装一个 omdsh-plughub update omdsh-status # 挪到目录里那个版本 omdsh-plughub remove omdsh-status # 卸一个 ``` 它存在的理由就是上一节。集合里从 npm 装的有两个——这个包,以及 `omdsh-basemode`。其余每个插件都从 GitHub 装,而 git 安装没有一条能用的 `dsh plugin add`——pnpm 要的那个 allowlist key 里带着它解析出来的 commit,只能从报错里抄,事先写不出来。这个包一直知道该怎么应付,只是在 此之前,它只应答一个按钮。 这里没有第二套实现。命令解析的是同一份目录,从里面取出同一个 specifier, 交给同一个 `Installer`——所以从终端装上的插件和从页签装上的插件,是同 一条依赖、同一行 bundle、同一次重启。 ### 是名字,还是 specifier 一个参数属于哪一种,决定了要不要查目录;也正因为有这个区分,你可以点名 某个账号下的仓库,而不必把整份目录搬过去: | 你敲的 | 它装的 | |---|---| | `omdsh-status` | 目录里名字以这一段结尾的那个条目;匹配到两个会如实报出来,而不是替你猜 | | `@omdsh-plugins/omdsh-status` | 就是那个条目,点名点全 | | `github:someone/omdsh-status` | 就那个仓库,照字面装,完全不查目录 | | `@omdsh-plugins/omdsh-status@0.1.2` | 同上,并锁到某个版本 | | `/checkouts/omdsh-status` | 同上,从一份 checkout 装——这里允许,而路由里拒绝,这正是 `isInstallableSpec` 的 `allowPath` 一直以来的用途:一条在键盘上敲出来的路径,和一条从别人清单里送进来的路径,不是一回事 | `--upstream <账号>` 把整份目录挪到另一个账号上,只对这一次运行有效。这 是同一个问题的另一半——参数说的是**取什么**,upstream 说的是**目录去哪 儿找**——也正因为如此,一个光秃秃的名字不必自己背上一个账号。 **它不读这个插件存下来的设置。** 命名空间是由 harness 的 settings 服务在 一棵运行中的树里解析的,而这个程序不是那样一棵树,所以 `--upstream`、 `--github-token`、`--registry-url` 和几个超时都是命令行参数,默认值与 schema 里声明的那一套相同。`--help` 会把它们列出来。 ## 表单会画哪些控件 | schema 节点 | 控件 | |---|---| | `string` | 文本框 | | 带 `role('secret')` 的 `string` | 只写输入框,存过值以后显示为掩码;Host 只报告有没有存过 | | `number` | 数字框,遵守 `min` / `max` / `step` | | `boolean` | 复选框 | | 常量 `union` | 下拉选择 | | `array(string)` | 可编辑列表 | | `dict(string)` | 可编辑键值行 | | `object` | 标题加缩进的子项,最多三层 | | 其他 | 只读 JSON,并提示去改设置文件 | 最后一行是刻意的。一个对任意 schema 硬猜的通用表单,产出的控件会悄悄写 进错误的形状;而一次通过了校验、含义却已经变了的设置写入,比没有控件更 糟。如果插件需要的控件这个表单画不出来,就改为往 `omdsh.plugin.card` 注 册一张卡片——见[约定](https://omdsh-plugins.github.io/conventions/#rule-6)第 6 条。 每次写入都是一次按路径寻址的修改,并带上这个面板读到的 revision。按路径 而不是整体替换,是因为面板收到的内容是脱敏过的:用屏幕上的内容重建一个 `replace`,会把线上从来没送过来的密钥全部删掉。带 revision,是因为同一 个面板可能被两个界面同时打开:没有它,第二个写入者会静默覆盖第一个;有 了它,第二个会被拒绝、重读,然后显示当前的取值。 字段的**标题**由属性名推导(`maxRepos` → `Max repos`),schema 的 description 放在它下面。schemastery 的 description 是一句话,拿一句话当 标签并不好读;这样 schema 作者只写一份,它落在读起来合适的位置上。 而属性名是个英文标识符——中文界面上的表单因此总是只翻译了一半。所以 schema 可以自己写标题,写在 `meta.extra` 里(schemastery 自己留给表单渲 染器的那个槽位),形式和本地化的 description 一样,是一张语言映射表: ```ts Schema.string().extra('extra', { label: { '': 'Model route', zh: '模型路由' } }) Schema.string().role('secret', { label: { '': 'API key', zh: '密钥' } }) ``` 第二种写法不是花样:`role(text, extra)` 写的是同一个槽位,而且只传一个 参数时会把 `undefined` 写进去,所以带 role 的字段必须**通过 role** 声明 标题。写了标题之后,属性名会作为一枚 code 小标签回到标题旁边——那正是 要去设置文档里改的人需要的。这个集合里每一个拥有设置命名空间的插件都这 样声明标题,包括下面这张表里的字段:一个渲染表单的插件,自己的面板却只 翻译了一半,那是在跟自己较劲。 ## 配置它自己 这个插件遵守它自己定的约定,所以它在自己的面板里就能配——在页签顶部的 **目录来源**里。除了一个字段以外,下面这些都能在那里改,不用碰文件;同 样也仍然可以写在 `cordis.patch.yml` 的组装配置里,那就是面板写入所覆盖 的 base 层。命名空间 `omdsh-plughub`: | 字段 | 默认值 | 作用 | |---|---|---| | `upstream` | (空) | 作为兜底来源被枚举的 GitHub 账号;留空则关闭 | | `registryUrl` | `https://cdn.jsdmirror.com/gh/omdsh-plugins/registry/registry.json` | 人工维护的清单;留空则由 `upstream` 推导 | | `localSources` | `[]` | 作为可安装条目提供的本地 checkout 目录 | | `githubToken` | — | 解除匿名枚举每小时 60 次的限流(密钥) | | `maxRepos` | `100` | 枚举时最多检查的仓库数 | | `timeoutMs` | `10000` | 远程来源的单次请求超时 | | `cacheTtlMs` | `300000` | 已解析的目录复用多久 | | `profileDir` | 推导 | 要管理的 profile;留空则取当前运行的那个。只在组装处生效——见下 | | `launcher` | 推导 | `dsh` 可执行文件路径;留空则取当前运行的 runtime,再走 `PATH` | | `pnpmPath` | 推导 | `pnpm` 可执行文件路径;留空则依次在 runtime、profile 和常见安装位置里找 | `profileDir` 就是面板不提供的那一个字段。这个 runtime 管哪个 profile, 在插件挂载时就定下来了——installer、路由、以及判断是否需要重启所比对 的 bundle 列表,全都绑在它上面,而 settings 层是在那之后才解析的。所以 它对表单 `.hidden()`,只在组装这个插件的地方设置——这正是 `omdsh-shortcuts` 在 `items` 和 `bindings` 之间画的那条线。 如果这套组装里根本没有 settings 服务——无头的 surface、一个测试台—— 插件中心就只按组装配置跑:**目录来源**会直接说明这一点,而不是画出表 单;每个已安装插件都显示为没有声明可配置项;目录、安装和卸载都和平时一 样。注册走的是 `ctx.inject(['settings'], …)`,所以"可配置"在这里是附加 的,而不是前提。 ## 安装 ```sh dsh plugin --profile web add @omdsh-plugins/omdsh-plughub dsh web ``` 然后打开 **设置 → 插件 → 插件中心**,集合里其余插件已经列在那里了—— 目录清单是默认值,所以需要在终端里装的只有这一个插件。也可以按插件中心 自己在卡片上用的写法,直接从账号装一个发布版: ```sh dsh plugin --profile web add github:omdsh-plugins/omdsh-plughub ``` 这一条**第一次跑一定失败**,而且失败的是 pnpm,不是这个包:git 依赖靠 `prepare` 自建,而 pnpm ≥10 在包进入 `allowBuilds` 之前不会跑任何这类脚 本。pnpm 和 `dsh` 都会把该往 `$DSH_HOME/profiles/web/pnpm-workspace.yaml` 里加的那一条打印出来,而且 是完整的 specifier——`'@omdsh-plugins/omdsh-plughub@https://codeload.github.com/…/': true`——不 是包名;一旦有过一次被拒绝的尝试,光写包名就不够了。上面那条 npm 写法 完全不需要这些,**通过**插件中心装的东西也不需要:这个包在跑安装之前会 自己把那一条写好,这就是按一个按钮和粘一段 YAML 的区别。 或者从一份 checkout 装,改插件中心本身时要的就是这种: ```sh pnpm install && pnpm run build dsh plugin --profile web add /path/to/omdsh-plughub ``` 想让本地 checkout 和上游并列出现,把 `localSources` 设成存放它们的目 录;同名包下,checkout 胜过任何已发布的版本。 ### `omdsh-plughub` 这条命令从哪儿来 `bin` 是随这个包一起发的,所以把它装进一个 profile,命令会落在那个 profile 的 `node_modules/.bin` 里——你 `PATH` 上的任何东西都找不到它, 下面第一种写法就是为这个准备的: ```sh npx @omdsh-plugins/omdsh-plughub add omdsh-status # 什么都不用先装 npm install -g @omdsh-plugins/omdsh-plughub # 然后:omdsh-plughub add … "$DSH_HOME"/profiles/web/node_modules/.bin/omdsh-plughub add omdsh-status ``` 三种写法跑的是同一个程序、对着同一个 profile:它读的是 `$DSH_HOME` 和 `--profile`,而不是自己是怎么被启动的,所以二进制来自哪里,从不改变它 写进哪个 profile。 移除也是同一种写法: ```sh dsh plugin --profile web remove @omdsh-plugins/omdsh-plughub ``` 这会同时带走页签、路由和 settings 网关。Plugins 区回到**插件配置**和 **插件列表**两个页签,而**通过**插件中心装的每个插件都还在——那些是 profile 自己的 bundle 行,由 launcher 写入,并不由这个包持有。 它旁边不需要再组装别的东西。宿主半边只 inject `webServer`,settings 注 册又挂在 `ctx.inject(['settings'], …)` 上,所以一个完全没有 settings provider 的 profile 照样有页签、有目录、能装能卸——只是每个已安装插件 都显示为没有声明可配置项。 ## 命令 ```sh pnpm install pnpm run build # tsc → lib/types,再 tsdown → lib/{index,contract,client,cli}.js pnpm test pnpm run typecheck pnpm run harness:local ../../deepseek-harness # 对着一份 checkout 编译 pnpm run harness:npm # 切回提交下来的版本号 pnpm run check:harness-pin # 只要还链着就失败 ``` ## 它从哪儿来 harness 声明 `settings.plugins.tab` 的原话,就是为了让"清单类插件和配置 类插件在互不依赖的前提下协作" (`packages/client/ui-settings/src/client/contract/slots.ts`)。这个包是 那个座位上的第三位占用者,和 harness 自带的两个页签并列:**插件配置** (`configurable` 条目,出厂的 Bash、Agent loop、Web search 三张卡片就归 它)和**插件列表**(`all` 条目,把组装进来的每个 bundle 列一遍)。它没 有给 harness 加任何 slot,没有打任何补丁;把它移除,Plugins 区就回到这 两个页签。 ## 已知限制 - **每一次安装、更新、卸载、启用和停用都需要重启。** 插件层是在启动时组 装的,被监听的只有用户 patch 层,所以新装的或刚停用的 bundle 没法热挂 载。提示条说的就是这件事,它背后没有一个"以后版本会悄悄修好"的东 西。 - **本地来源只往下扫一层目录。** 配置的根目录里放的是插件 checkout,凡 是自己的 `package.json` 里没有 `dsh.bundle.patch` 的都会被跳过——所 以把可安装的那一半放在 `packages/` 里的 monorepo 不会被提供。想让它出 现,就把 `localSources` 指向里面那一层。 - **匿名的 GitHub 枚举有限流。** 不带令牌时每小时 60 次,而且无论如何 `maxRepos` 最多只看 100 个仓库。失败会显示在来源那一行,而不是藏起 来;`githubToken` 可以解除限流。 - **`profileDir` 不能在面板里改。** 这个 runtime 管哪个 profile,在插件 挂载时、settings 层解析之前就定下来了,所以这个字段对表单 `.hidden()`,只属于组装配置。 - **写路由只限回环。** 一个开放到局域网的 `dsh web` 可以浏览目录、读面 板,但安装、更新、卸载、启用、停用和写设置都会被拒绝——把 `/api` 发 布出去,并不等于同意在这台机器上执行某个包的 `prepare` 脚本。 - **一个命名空间只有在被某个已安装 bundle 声明时才可达。** 网关是按 `dsh.plughub.settings` 判断归属的,所以那些注册者并非 profile 所含 bundle 的命名空间——比如 harness 自己的 `shell` 和 `agent-loop`——在 这里天然看不到。 - **通用表单画不出来的 schema 会被拒绝。** 字符串、数字、布尔、常量 union、字符串列表、字符串字典和嵌套对象之外的东西,一律渲染成只读 JSON,并提示去改设置文件。需要更多控件的插件,改为往 `omdsh.plugin.card` 注册一张卡片。 [schemastery]: https://github.com/shigma/schemastery