赞助商LobeHubLobeHub了解更多
ddshfind
GitHub

第 5 课:配置与发布:可配置、可分发

一句话版:插件做好了自己用不算完——把「不同部署可能不同」的参数全部声明成可配置的 schema、把插件打包成可安装的组合包发布出去,别人就能一条命令装上、在配置里按需调整;本课讲完「可配置、可分发」,你的插件就正式「出师」了。


1. 用户故事:从「自用」到「能用、能改」

小 D 用前三课学到的本事,写了一个「仓库总结」插件:给它一个仓库路径,agent 就会自动读 README、统计代码量、生成一份总结。他在自己的机器上跑得很爽。

周五下午,同事小 H 跑过来:「你这个总结插件太有用了,给我也装一个!」

小 D 立刻发现三个问题:

  1. 小 H 用的模型不一样、超时时间也不一样——但 TIMEOUT = 30000写死在源码里的,他没法改;
  2. 总不能把整个源码文件夹拷过去,以后每次修 bug 再手动同步一遍;
  3. 小 H 想自己调参,但千万别把核心逻辑改坏。

传统框架的答案是「复制源码 + 改代码」——fork 一份、改死参数、各改各的,升级时痛苦合并。DSH 的答案是两个词:可配置可分发

  • 可配置:把「不同部署可能需要不同值」的参数,全部声明成配置字段,由用户在配置里传值——小 H 想改超时,改配置就行,不用碰代码(第 2 节);
  • 可分发:把插件打包成一个标准的组合包发布出去,别人一条命令装上、在配置里装配(第 3、4 节)。

🎁 打比方:前几课你做的是「一把好用的螺丝刀」;本课你要做的是「能把螺丝刀放进标准工具箱、别人买回去还能换手柄」——配置是手柄,发布是装箱。


2. 给插件加配置:schema、默认值与装配

定义 Config 类型,默认值写在 schema 里

Cordis 的约定是:插件里导出一个 Config 类型,以及一个同名的 Schemastery schema——默认值直接写在 schema 中(来源:docs/user/develop/basic/config.zh.md):

import type { Context } from 'cordis'
import Schema from 'schemastery'

export const name = 'my-plugin'

export interface Config {
  greeting: string
  maxRetries: number
  verbose?: boolean
}

export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  maxRetries: Schema.number().default(3),
  verbose: Schema.boolean().default(false),
})

export function apply(ctx: Context, config: Config) {
  console.log(config.greeting)  // User value or schema default.
}

逐段看懂它:

  • export interface Config:声明插件需要的配置长什么样——TypeScript 类型,写代码时就有补全和提示;
  • export const Config = Schema.object({ ... })同名 schema,既描述每个字段的类型,又给出默认值.default(...));
  • apply(ctx, config):第二个参数就是装配后的配置——用户传了就用用户的,没传就用 schema 里的默认值。

⚠️ 不要导出普通对象作为 Config:它不满足 Cordis 要求的 Standard Schema 接口,插件无法校验。类型与 schema 同名,是 Cordis 的约定,别写成两个名字。

装配时传入 config

配置写在哪?还是那个老朋友 cordis.yml。在插件条目里加一个 config 键(来源:docs/user/develop/basic/config.zh.md):

- insert:
    - id: hello
      name: './src/my-plugin.ts'
      config:
        greeting: 'Hi there'
        maxRetries: 5

插件加载时,Cordis 会用导出的 schema 校验这份配置,并填充未提供字段的默认值——这里 verbose 没写,就取 false。校验不过怎么办?「配置错误要响亮」:schema 在插件加载时执行校验,配置不合法,插件就加载失败并给出明确错误信息,而不是带病运行。

config 变更 → 增量重载

用户改完配置之后呢?不需要重启整个程序。文档原话(来源:docs/user/develop/basic/config.zh.md「配合 HMR」):

配置变更会触发插件热替换:修改 cordis.yml 中某个插件的 config 后,框架会卸载旧实例并加载新实例。由于注册都属于 effect 并会自动清理,替换后不会保留旧实例的注册。

这正是第二章论文里「时间可组合性」的现场:卸载旧实例 = 把它的效应全部回退;加载新实例 = 重新注册。只有被改的那个插件经历这次重装配,其他插件完全不受影响——这就是图里写的「增量重载」:按字段协调,只动该动的。

两条设计原则

来自官方文档(来源:docs/user/develop/basic/config.zh.md「设计原则」):

  • 无硬编码可调参数:凡是不同部署可能需要采用不同值的参数,都必须定义为配置字段。检验标准只有一句话——能否在 cordis.yml 中改变这个值,而不需要修改代码? 不能,就把它提成配置;
  • 配置错误要响亮:在 schema 里表达自身完备的约束,让无效配置在插件加载时就失败,而不是悄悄用错值跑起来。

3. 发布:把插件变成可安装的「组合包」

插件可配置了,怎么让别人装上?先分清两个概念(来源:docs/user/develop/basic/publish.zh.md):

  • 组合包(bundle):附带一个配置层的 npm 包。它的 manifest 声明 dsh.bundle,回答「这个包贡献什么?」——一份插入或覆盖插件行的 patch 文件;
  • profile:位于 $DSH_HOME/profiles/name 下、描述一份可启动组合的目录。它的 manifest 声明 dsh.profile,回答「这套配置由哪些组合包按什么顺序组成?」。

一句话记牢:组合包是你编写并分发的东西;profile 是用户启动的东西。没有东西同时是两者。

包结构:三件套

一个组合包通常长这样(来源:docs/user/develop/basic/publish.zh.md):

hello-plugin/
├── package.json       # declares dsh.bundle
├── cordis.patch.yml   # the layer applied when a profile lists this bundle
└── index.js           # plugin modules the patch rows reference

它的 package.json 通过 dsh.bundle 声明自己是一个组合包:

{
  "name": "dsh-hello-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "index.js",
  "files": ["index.js", "cordis.patch.yml"],
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}

patch 文件的形状和你之前写的 --patch overlay 一样——一个 patch 条目的 YAML 数组——只是插件行按包名引用这个包,而不是相对源码路径,这样 Node 的模块解析才能找到已安装的代码(来源:docs/user/develop/basic/publish.zh.md):

- insert:
    - id: hello
      name: dsh-hello-plugin

构建与三种分发途径

发布前先构建:build 脚本(如 tsdown)把 TypeScript 编译成 lib/ 产物。分发途径有三条(来源:docs/user/develop/basic/publish.zh.md):

途径命令用户拿到的
发布到 npmpnpm publish(发布时构建好 lib/预构建代码,dsh plugin add your-package 直接装
交付 tarballpnpm pack一个 hello-plugin-0.1.0.tgz 文件
GitHub 直装推送到 git 仓库源码——见下面的坑

⚠️ git 安装这道坎:git 安装拉取的是源码,不是构建产物——没有任何环节运行你的 build 脚本,TypeScript 包到手时没有 lib/ 输出,加载会失败。所以两边各要做一件事(来源:docs/user/develop/basic/publish.zh.md):

  • 作者:提供一个 prepare 脚本——pnpm 在 git 安装后运行它,从源码构建出发布入口,且必须自包含(不能假设只有开发环境才有的上下文,比如旁边有一份 monorepo checkout);
  • 用户:为构建授权。pnpm ≥ 10 在得到显式允许之前拒绝运行 git 依赖的 prepare 脚本,所以第一次 add 会失败;dsh 会指出修法——把 pnpm 打印的确切包键复制进该 profile 的 pnpm-workspace.yaml
allowBuilds:
  dsh-hello-plugin: true

请如实看待这项授权:它允许该包的代码在安装时于你的机器上执行,且不在 agent 运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(github:you/hello-plugin#sha),让后续推送无法悄悄改变实际运行的内容。不想让用户做这项授权?就分发构建产物——npm 或 tarball 都不需要任何构建权限。

版本管理:可分发的地基

package.json 里的 version语义化版本(SemVer):0.1.0 = 主版本.次版本.补丁版本。升级要守规矩——破坏性变更升主版本、加功能升次版本、修 bug 升补丁版本。为什么这么重要?因为版本是「发现与兼容」的基石,第 4 节马上讲到:一旦别人装上了 0.1.0,你悄悄改了接口,后果就是接口漂移。

① 写包src + 配置② 构建构建产物③ 发布npm 等注册表④ 安装dsh plugin add配置:插件可按字段协调(config 变更 → 增量重载)

发布即分发:别人一条命令装上你的插件,配置变化自动协调

发布即分发:别人一条命令装上你的插件,配置变化自动协调。


4. 别人怎么用:安装、装配、按需配置,以及命名与发现

一条命令装进 profile

别人拿到你的包(或 checkout),在自己的机器上执行(来源:docs/user/develop/basic/publish.zh.md):

cd hello-plugin
dsh plugin --profile demo add .

拆开看这条命令:

  • dsh plugin --profile demo add . 会在 profile 目录内转发给 pnpm,所以所有 pnpm 子命令都可用;
  • 首次使用会初始化 profile——@deepseek-ai/dsh-base 作为它的第一个组合包;
  • 因为你的包声明了 dsh.bundledsh 会把它追加进 dsh.profile.bundles
{
  "name": "dsh-profile-demo",
  "private": true,
  "dependencies": {
    "dsh-hello-plugin": "link:/path/to/hello-plugin"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "dsh-hello-plugin"
      ]
    }
  }
}

先不启动、只验证这一层,再启动:

dsh --profile demo --dump-config   # shows a "# == dsh-hello-plugin" layer
dsh --profile demo

想卸掉?dsh plugin --profile demo remove dsh-hello-plugin 会同时移除依赖和对应的层。

装配与按需配置:后层覆盖前层

生效配置在空根之上按顺序逐层组合(来源:docs/user/develop/basic/publish.zh.md「加载顺序」):

  1. profile 的 dsh.profile.bundles 列表所列的各组合包 patch,按列表顺序;
  2. profile 自己的 cordis.patch.yml
  3. home 级 $DSH_HOME/cordis.patch.yml(各 profile 共享的机器本地偏好);
  4. 每个 --patch overlay,按 argv 顺序;
  5. 启动器 flag patch(例如 dsh web --port)。

后应用的层按行胜出。这给组合包作者带来两个推论:

  • 你的 patch 可以按 id 覆盖前面各层的行,但补丁会替换目标行的整个 config 值,而不是深度合并各键——覆盖时必须重述该行需要的每一个键,而不是只写改动的那个;
  • 用户可以在自己 profile 的 cordis.patch.yml 中覆盖你的行,无需改动你的包——所以发布时「优先给出用户大概率会保留的配置默认值,其余交给 schema 承担」。

换句话说:你把好用的默认值写进 schema,把选择权交给用户的配置层——这就是「可配置、可分发」合体的样子。

命名与发现:版本兼容与接口漂移(呼应论文 5.5)

一个插件要被人找到、装上、长期用下去,光能发布还不够,还要过「发现」这一关。还记得第二章第 13 课讲的论文第 5 章吗?其中 5.5 节专门提醒了两个坑:

问题是什么后果
接口漂移提供者改版时改了与键 k 关联的接口(加字段、改方法签名、改行为契约),而针对旧接口编译的消费者仍声明同一个键 k依赖在余效应层面「满足」了,但运行时值已不符合预期:类型错误、方法找不到、静默行为偏离
键冲突两个独立开发的提供者用了同一个键名 k 表示完全无关的接口消费者毫无兼容性检查地接受另一个提供者的值,故障不可预测、难诊断

论文给出三种弥补方法:键命名空间化(键身份带上定义接口的包标识,从构造上消灭键冲突)、对等依赖(Cordis 目前采用——用宿主语言包管理器声明版本约束,版本不兼容在安装时就发现,不会拖成运行时故障;代价是依赖提供者自觉遵守语义化版本约定,无法强制)、结构兼容性(按接口结构是否涵盖消费者预期判断,但行为契约复杂)。落到你的日常:

  • 包名要唯一:发布到 npm 时用好命名空间(平台惯例是 @deepseek-ai/dsh-* 这样的前缀);
  • 版本要守规矩:遵守语义化版本约定,别悄悄改接口;破坏性变更要升主版本、写变更记录;
  • 新能力用新键:给服务、工具注册键时避开已有插件的键名,把「接口漂移」挡在发布之前。

关键点回顾

  1. 可配置:插件导出 Config 类型 + 同名 schema,默认值写在 schema 里;用户在 cordis.ymlconfig 键传值,Cordis 校验并填充默认值;「能否在配置里改这个值而不改代码」是硬编码的检验标准。
  2. 增量重载:config 变更触发插件热替换——卸载旧实例、加载新实例,注册是 effect 会自动清理;呼应论文的「时间可组合性」。
  3. 组合包与 profile:组合包(dsh.bundle)是作者分发的东西,profile(dsh.profile)是用户启动的组合;分发途径有 npm、tarball、GitHub 三种,git 装源码需要 prepare 脚本 + allowBuilds 授权。
  4. 安装与装配dsh plugin --profile demo add . 装进 profile;生效配置按层组合、后层覆盖前层;补丁整行替换 config 而不是深度合并。
  5. 命名与发现:包名唯一、语义化版本、避免接口漂移与键冲突——这是论文 5.5 节给发布者的三张「免罚单」。

🚀 下一课「实战进阶:LLM 适配器与自指工具」我们看看真实世界的插件长什么样——怎么给智能体接一个新的模型提供方,以及一个能「调用自己」的插件是什么体验。

自测题 · 配置与发布

完成作答后点击「提交答案」,可以查看对错与解析。

1. 给插件加配置,正确的做法是?(依据 docs/user/develop/basic/config.zh.md)
2. 用户修改了 cordis.yml 里某插件的 config 后,会发生什么?
3. 关于分发插件,下列说法正确的是?
4. 同事想用你发布的插件,正确流程是?