读完这篇你能:把自己写的插件打包成同事一条命令就能装上的配置包,知道
dsh.bundle与dsh.profile两份清单各管什么、四层 patch 谁先谁后,以及从 GitHub 安装时那道构建脚本的坎怎么过。前置:17-编写 LLM adapter 接入自有模型。约 25 分钟。
示例代码:dsh-team-kit
DSH 版本:0.2.0-rc.2
17-编写 LLM adapter 接入自有模型那个 Ollama 插件是自己机器上跑通的。想让同事也用上,可以把 patch.yaml、install.sh 连同源码目录整个拷给他——但那样每来一个人就多一份要维护的副本,而且绝对路径、依赖版本、构建步骤全靠口头交代。
正规的做法是把插件包做成配置包(bundle):同事一条 dsh plugin add 命令,包自己带着”该往插件树里贴哪一层”的说明进去。
这篇的 demo 是一个团队配置包 dsh-team-kit:装上它,同事的 profile 会多一层配置——关掉自动起标题,并把团队约定写进每个会话的系统提示。
目标
- 一份能被
dsh plugin add认出来的配置包,本地能装、从 GitHub 也能装。 - 把一个包该带的清单、构建、说明配齐:别人不需要问你任何问题就能装上。
- 弄清配置包与 profile 各自声明什么、四层 patch 的叠加顺序,以及装上去的那一层从哪来。
DSH 设计
要把插件交给别人装,先要知道交出去的到底是什么。背景是这样的:运行中的 DSH 是一棵插件清单树,启动时从一张空清单开始,逐层叠加补丁叠出来。「补丁」(patch)是一份 YAML 文件,每一行声明一件事:插入一个插件、改一个插件的配置,或者停用一个插件。理解了补丁,就能看懂架构文档对「组合包」(bundle)的定义原话:「Cordis 配置项及其挂载代码的分发格式,因此它插入的内容始终可被其上各层 patch」。后半句是关键:包装进别人的机器之后,它贡献的每一行都必须能被别人的补丁再改。所以包里交出去的是一份补丁文件,行里按包名引用插件代码。写本机绝对路径的话,发出去就废了。「用户永远能覆盖你钉的默认值」是这套设计的承诺,不是副作用。
接下来是一对最容易记反的概念。「包贡献什么」和「这套配置由哪些包组成」是两种声明。两者都写在 package.json 的 dsh 字段下,但回答的问题不同。dsh.bundle 说「我贡献的补丁文件在哪」;dsh.profile.bundles 说「这套配置按什么顺序叠哪些包」。「profile」是一套具名的配置方案,比如官方自带的 web,它就是后一种声明。没有任何东西同时是两者:安装命令只认 dsh.bundle,没声明的包只当普通代码依赖装上。DSH 自己的核心功能层也遵守同一形态。它的 README 自称是「一份静态 patch 文档:一个应用到空 profile 根之上的 insert 列表」,不挂载服务、不持有状态。
官方自带的几套 profile 就是这套机制的模板实例。web、headless、sdk 等随发行版交付的 profile,清单里第一个组合包都是官方核心层,其余的按顺序叠在后面。想自定义一套配置,就是建一个自己的 profile,把核心层列在首位。官方核心层的 README 也写明:基于它构建的自定义 profile 要把它放在第一位。之后的定制改自己的补丁或加后续组合包,而不是改它。
安装命令本身做的事很少:把包名追加进某个 profile 的组合包清单,连带把依赖装进 profile 目录。它不改任何正在运行的进程。真正把这包的内容叠进插件清单树,永远发生在启动时的叠加过程中。所以「装没装上」看清单,「生没生效」看启动。
启动时的叠加从空清单开始,层序固定:先按清单顺序叠各组合包的补丁,再叠这套 profile 自己的补丁文件,再叠用户主目录的那份,最后叠命令行临时指定的。叠加规则只有一条:一条补丁按 id 找到某一行,替换它的整个 config。核心层的 README 把后果写给读者:「请重述每个想保留的设置;不会自动合并」。看个小例子。假设前面一层写了这样一行:
- id: my-plugin
config:
greeting: 你好
times: 3
后面一层只想换掉问候语:
- id: my-plugin
config:
greeting: 早上好
生效的结果是 greeting 变成 早上好,而 times 不是「保持为 3」,是跟着整个 config 一起没了。后写的层整行胜出。停用一行倒是简单,只写 disabled: true 就够。它是行级开关,不在被整体替换的 config 里。
最后一条关于官方包的名字。官方那几个组合包的名字始终从 dsh 安装目录解析,包管理器只管装进 profile 的树外包。所以第三方包可以放心假设官方核心层存在,但没必要、也不应该把它写进自己的依赖声明里。
把两个角色记反是写分发包最容易犯的错:以为包里要回答「最终装成什么样」,那是 profile 的事;或以为 profile 里要写「包的内容是什么」,那是组合包的事。
两个 manifest 的分工
安装机制建立在两个概念上,它们的清单都写在 package.json 的 dsh 键下,但回答的问题不同:
| 声明 | 回答的问题 | 谁来写 | |
|---|---|---|---|
| 配置包(bundle) | dsh.bundle |
这个包贡献什么?——一个 patch 文件 | 你写,你分发 |
| profile | dsh.profile |
这套配置由哪些配置包、按什么顺序组成? | 用户(或 dsh plugin 代劳) |
配置包清单最小形态就一行:
{
"name": "dsh-team-kit",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"files": ["lib", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
dsh.bundle.patch 指向包里的 patch 文件。profile 那边则是:
{
"name": "dsh-profile-demo",
"private": true,
"dependencies": { "dsh-team-kit": "link:/path/to/dsh-team-kit" },
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "dsh-team-kit"] } }
}
参考卡片里那张”配置清单”讲的就是后者。两边的分工可以直接背下来:dependencies 管物理上装了哪些包,dsh.profile.bundles 管逻辑上按什么顺序贴层;而 dsh.bundle.patch 是包自己交出来的那一层。
没有东西同时是两者。 一个包要么贡献一层(bundle),要么组织一堆层(profile)。dsh plugin add 也只认前者:它装完依赖后检查每个新依赖的 manifest,有 dsh.bundle 的追加进 bundles,没有的只当普通依赖并打印一条警告——库包(给插件 import 用的,不是给用户启用的)就走这条路。
有个细节值得留意:警告是按”这次新装”给的。以后某个版本给这个包补上了 dsh.bundle,再跑一次 dsh plugin update 它就会自动激活。判断依据是已安装状态,不是依赖差异——所以升级能让一个包”从不带层变成带层”。
加载顺序
安装完之后,生效配置是四层叠出来的:
(03-用 patch 覆盖 DSH 的默认配置讲的 patch 合成规则在这里补全了最后两层:原来只写了”bundles → profile patch → home patch → --patch“,其中 home 级那一层是给跨 profile 的机器本地偏好用的。)
两条推论对写包的人很要紧:
后应用的层按行胜出,且 patch 替换整行 config,不做深合并。 所以你想改前一层某个行的配置,必须重述这一行需要的每一个键,而不是只写你改的那个。内置的 dsh-web-app 覆盖 dsh-base 的行时就是这么写的——它的 cordis.patch.yml 开头专门有一句注释提醒读者这件事。反过来说,disabled 是行级字段、不属于 config,所以开关一行只写 disabled: true 就够了。
用户永远能覆盖你。 profile 自己的那一层排在你后面,用户不改你的包也能把你钉的默认值换掉。这带来一条写包的建议:默认值给用户大概率会保留的那个,其余交给配置 schema 的默认值去管。
还有一条边界:内置配置包的名字始终从 dsh 安装目录解析,pnpm 只管理树外的包。所以你的包可以放心依赖 @deepseek-ai/dsh-base 存在(它一定在),但没必要把它写进 dependencies 里。
配置包的结构
dsh-team-kit/
├── src/
│ └── index.ts ← 包自带的插件
├── cordis.patch.yml ← 本包贡献的那一层
├── build.mjs ← 从源码构建 lib/ 的脚本
├── package.json
├── tsconfig.json
├── install.sh ← 本地开发用
└── README.md ← 给同事看的安装说明
先说最要紧的一处差别。前几篇 demo 的 patch.yaml 长这样:
- insert:
- id: dsh-tool-guard
name: '/绝对路径/dsh-tool-guard/src/dsh-tool-guard.ts'
那是本地源码路径,只对”源码就在这台机器上”成立——发出去就废了。配置包里的 patch 要按包名引用:
# 自动起标题会额外花一次模型调用;团队里统一关掉,标题留给人写。
- id: session-title-llm
disabled: true
- insert:
- id: team-kit
name: 'dsh-team-kit'
config:
conventions: |
这个仓库的约定:
- 提交信息用中文写,一次提交只做一件事。
- 改动前先跑一遍相关测试,别把没验证的东西留在工作区。
name: 'dsh-team-kit' 解析的是包的主入口(main)。包一旦被 pnpm 装进 profile,Node 的模块解析就能按名字找到它;同一层里也顺手覆盖了一个内置行(关掉自动起标题)。
包自带的插件只有一个作用——把团队约定塞进系统提示:
export function apply(ctx: Context, config: Config): void {
// section 注册即 effect:重复的 name 会抛错,卸载时自动摘掉。
// order 小的排在前面,这里给大值,让团队约定落在提示靠后的位置。
ctx.systemPrompt.section({
name: 'team:conventions',
order: 900,
text: config.conventions,
})
}
约定文本写在 cordis.patch.yml 的 config 里而不是代码里,于是同事想改自己那一条,不用碰你的代码——这正是上一节”给用户留出覆盖空间”的做法。
files 字段里有两项,缺一不可:lib(构建产物)和 cordis.patch.yml(那个层)。只写 lib 的话,包能装上,但 dsh.bundle.patch 指向的文件不在包里,装完什么都不会发生——这种错很难从日志里看出来,因为安装本身是成功的。
包名建议带 dsh- 前缀(内置包的正式名字是 @deepseek-ai/dsh-*),profile 的日志里一眼能看出哪个是插件。
构建与 prepare
demo 用 TypeScript 写,main 指向 lib/index.js:
{
"main": "lib/index.js",
"scripts": {
"build": "node build.mjs",
"prepare": "node build.mjs"
}
}
build 是给你自己用的,prepare 是给”从 git 安装”用的——下一节讲它的必要性。构建脚本要自包含:不能假设旁边有一份 monorepo checkout、不能依赖项目引用、最好也不做类型检查,只做转译:
await build({
entryPoints: ['src/index.ts'],
outdir: 'lib',
bundle: false,
format: 'esm',
platform: 'node',
target: 'node22',
sourcemap: false,
})
依赖保持 external(这里用 bundle: false 达到同样效果):包在别人的 profile 里运行时,@deepseek-ai/* 由 profile 的 node_modules 解析,不该被打进你的产物。
表层组合包的命令行
有些配置包不只是贡献插件行,它定义的是一个可运行的应用:网页版、headless、SDK 各自是一个这样的包。它们要处理自己的命令行参数(--port、--host……),机制出人意料地朴素:挂一个普通的提供方插件。
以网页版为例,它在自己的 patch 里插了这么一行:
- id: web-startup
name: '@deepseek-ai/dsh-web-app/startup'
/startup 是包的一个子路径导出,内容是一个普通的 Cordis 插件:inject = ['cmdlineArgs'],用 commander 定义自己的 flag,解析完在 action 里把结果提供成一个服务(webStartup)。受这些参数配置的行则注入那个服务,在 !!js 表达式里读:
- id: my-app
name: '@example/my-app'
inject: [myAppStartup]
config:
port: !!js ctx.myAppStartup.port ?? 8080
没有”启动钩子”这类特殊机制,只有服务依赖排序:Loader 会等每一行的普通注入就绪,再基于它求值那一行的 !!js 配置。启动器把自身 flag 之后的同一份参数交给每个插件,所以两个插件可以各自解析自己关心的那部分。
--help 时值得一提:提供方不发布那个服务,于是依赖它的行干脆不激活——所以你打 dsh --profile web --help 不会真的去绑端口。
要让自己的应用包有这套能力,你不需要动启动器:加一个 /startup 子路径导出,再在自己的 patch 里插一行引用它。
从 GitHub 安装
发布到 npm 不是必须的,同事可以直接从 git 装:
dsh plugin --profile demo add github:you/dsh-team-kit
但这条路有个坎,而且两边各要出一半力:
git 安装拉的是源码,不是构建产物。 没有任何环节会跑你的 build 脚本,所以一个 TypeScript 包到手时没有 lib/,加载会失败。这是作者的责任:给包加上 prepare 脚本(pnpm 在 git 安装后会运行它),并且保证它自包含。
用户要为构建授权。 pnpm ≥10 在得到显式允许之前,拒绝运行 git 依赖的 prepare 脚本,所以第一次 add 会失败。这时 dsh 会指路:它认出你装的是 git 依赖,提示你把 pnpm 打印的那个包键加进 profile 的 pnpm-workspace.yaml:
allowBuilds:
dsh-team-kit: true
加完重跑即可。
这一步要当成安全决定来对待。 授权等于允许那个包的代码在安装时于你的机器上执行,而且不在 agent 运行的任何沙箱之内——沙箱管的是 agent 的读写,管不了 pnpm 的安装脚本。所以:只对源码可信的包授权,并且锁 commit(github:you/dsh-team-kit#<sha>),让作者后续的推送没法悄悄换掉你实际运行的东西。
不想让用户做这个决定,就改成分发构建产物,两种形式都不需要任何构建授权:
- 发布到 npm:
pnpm publish时把lib/构建好,dsh plugin add dsh-team-kit装到的就是预构建代码。 - 交付 tarball:
pnpm pack打出.tgz,同事dsh plugin add ./dsh-team-kit-0.1.0.tgz。
验证
本地开发时用 install.sh(装依赖 → 构建 → 装进 test profile),先看层有没有挂上:
dsh --profile test --dump-config | grep -A6 '== dsh-team-kit'
# == dsh-team-kit
- id: session-title-llm
disabled: true
- id: team-kit
name: dsh-team-kit
config:
conventions: |-
这个仓库的约定:
...
--dump-config 把各层合成之后的结果打出来,按层分段(== 包名),所以同一行被谁覆盖过、最终长什么样,一眼能看出来。装之前也值得跑一次:grep 一下你要覆盖的行,确认它在前一层叫什么 id、config 里有哪些键——patch 整块替换 config,重述漏了键就是静默改行为。
模拟同事的安装路径,从 git 装一遍:
dsh plugin --profile demo add github:anthonyzhao-4926/dsh-team-kit
第一次应当失败,并打印 pnpm 拒绝构建脚本的提示;按提示往 $DSH_HOME/profiles/demo/pnpm-workspace.yaml 加 allowBuilds 后重跑,成功。然后再 dsh --profile demo --dump-config 确认层在。这一遍走下来,你对”同事会遇到什么”就有数了。
卸载验证:
dsh plugin --profile demo remove dsh-team-kit
依赖和被它带进来的层一起消失,profile 自己的 cordis.patch.yml 不动。
注意事项
- 重复的
insert会把插件挂两遍。 如果你的包在自己 patch 里插了行,用户又在 profile 的cordis.patch.yml里照着插一遍,同一行会出现两次——注册两遍路由或服务,其中一份必然在竞争里失败,整棵树可能起不来。所以推荐包自带 patch,用户那边就不要重复写。 files里漏了 patch 文件,装上是静默无效的。 安装成功、bundles里有名字,但 patch 指向的文件不在包里,那一层是空的。发布前用pnpm pack看一眼 tarball 里到底有什么。- 不要把你包的 patch 写成绝对路径。 本地 link 时它能跑,发出去同事那边指向一个不存在的目录。打包分发的包一律按包名引用。
prepare里别做重活。 它在每次安装(包括同事装依赖)时都会跑:装 devDependencies、跑类型检查、调 monorepo 里的脚本,都会让安装变慢或者直接失败。只做转译。- 版本要和 dsh 对得上。 你的包依赖
@deepseek-ai/dsh-*的某个版本区间,profile 里的内核是另一批版本。把这类依赖写进peerDependencies才有校验:对不上时安装会被拒,或者 profile 启动时跳过这个配置包(提示里给出dsh plugin allow-version的放行办法);只写在dependencies里就没有这道检查——那时不匹配不是启动失败,而是某个ctx上的服务不存在、等到用的时候才炸。--dump-config加上启动日志是排查这条线最快的地方。 - 表层包记得处理
--help。 提供方在--help时不发布服务,依赖它的行就不会激活——这是刻意设计,别用”启动时先绑端口再说”的写法绕过它。