很多程序员第一次学编程,都是从一句 Hello, World! 开始。屏幕上打印出的那行字看似简单,却意味着编辑、编译和运行这条最小链路终于真正走通了。
看过不少 DeepSeek Harness 的分析文章和架构介绍,但只要没有亲手把插件跑起来,对它的理解就总像隔着一层皮。所以这次我不再停在“看懂了”,而是沿着官方文档亲手做了一个 Hello Plugin:从最小插件、工具注册和配置校验,到组合包安装、profile 加载、发布与卸载,把插件开发的完整链路实操了一遍。
为了让这次实践不只停留在文章里,我把相关代码、案例和学习记录整理到了自己的开源项目 FlyAIBox/dsh101[1]。你可以一边阅读本文,一边对照仓库中的实际文件动手验证。
DeepSeek Harness 仍处于 Developer Preview(开发者预览)阶段,接口、包名和配置方式未来可能发生破坏性变化。实操时请优先以你当前 checkout 中的文档和 TypeScript 类型为准。
这篇文章的目标不是再讲一遍概念,而是让你能够跟着步骤把东西做出来。它覆盖本地插件、工具注册、配置校验、组合包安装、生命周期、服务和事件系统,并说明以下信息:
- 每一段代码在框架中扮演什么角色;
- 为什么官方要求这样配置;
- 每一步做完后如何确认成功;
- 容易混淆或直接导致失败的地方;
- 哪些是官方文档明确说明的机制,哪些只是本文给出的实操建议。
一、完成这篇教程后,你会得到什么
你将依次完成四个可验证结果:
- 一个能够被 DeepSeek Harness Web UI 加载的最小插件;
- 一个可以被模型调用的
greet工具; - 一个接受
cordis.yml配置并在加载时完成校验的插件; - 一个能够安装进独立 profile 的本地组合包。
在完成代码练习后,你还会理解三组底层机制:
- 插件为什么会等待依赖、自动卸载和自动清理;
- 插件如何通过 Service 提供和消费能力;
- 插件如何通过事件广播、短路和包装系统行为。
如果不想一开始就陷入实现细节,可以先用下面这张图浏览全文脉络:上层是最终做出的四个成果,中间是从源码启动到 npm 发布的八步动手路径,下层是每个关键阶段可以直接观察到的验收信号。

可以先用下面这张关系图建立整体印象:
profile
└── 按顺序加载多个 bundle 的配置层
└── cordis.patch.yml 插入或覆盖插件行
└── loader 加载插件模块
└── Cordis 创建 Fiber
├── 等待 inject 声明的服务
├── 调用 apply(ctx, config)
├── 跟踪工具、事件与 effect
└── 卸载时统一 dispose
二、开始之前:准备官方源码运行环境
官方“第一个插件”教程不是从空目录开始,而是假设你已经完成“从源码运行”。如果还没有准备,请先执行:
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web官方明确说明:
pnpm run build用于准备仓库产物;pnpm dsh web会直接使用已经构建好的产物,不会在每次启动时自动重新构建;- Web UI 默认运行在
http://127.0.0.1:3080。
因此,如果你刚拉取新提交、切换了分支,或者修改了需要构建的前端和包产物,应该主动重新运行 pnpm run build。不要把“启动命令能执行”误认为“当前浏览器里一定是最新代码”。
2.1 建议先做三个检查
在 DeepSeek Harness 仓库根目录运行:
node --version
pnpm --version
pwd请记下 pwd 打印的绝对路径。后面注册本地插件时会用到它。例如:
/Users/your-name/code/deepseek-harness2.2 关于模型凭据
“插件被加载”只需要 Web 服务正常启动;但要在聊天中让模型实际调用 greet,还需要 Web UI 中存在可用的模型提供方和凭据。你可以在设置页面配置,也可以按当前官方提供方文档配置环境或凭据文件。
这是两个不同的验收点:
- 终端出现
[hello-plugin] plugin loaded!,证明插件加载成功; - 模型调用
greet并收到Hello, Ada!,证明工具注册、模型选择和凭据链路都正常。
如果第一个成功而第二个失败,不应先怀疑插件加载器,应先检查模型与凭据设置。
三、第一阶段:创建最小插件
3.1 创建练习目录
在 DeepSeek Harness 仓库根目录执行:
mkdir -p scratch-plugin/src此时目录结构应该是:
deepseek-harness/
└── scratch-plugin/
└── src/这里的 scratch-plugin 是临时练习项目,不是最终发布包。官方用它演示如何通过一个 patch overlay,把仓库外的自定义能力插入现有 Web profile。
3.2 插件到底是什么
官方给出的最小定义是:在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export function apply(ctx: Context) {
// Register capabilities here.
}逐项解释:
Context是 Cordis 提供的上下文类型;name是插件名称,主要用于识别与诊断;apply是插件入口;ctx是插件访问服务、注册工具、监听事件、创建子插件和登记清理逻辑的统一入口。
“插件加载”不是简单执行一个文件。更准确地说,Cordis 会为插件建立一个有生命周期的作用域,等待依赖满足,然后执行 apply,并记录插件通过 ctx 注册的资源,以便将来统一卸载。
3.3 创建插件文件
创建 scratch-plugin/src/my-plugin.ts:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
// Required dependencies are ready before apply runs.
console.log('[hello-plugin] plugin loaded!')
}这段代码虽然没有使用 ctx,但仍然保留参数,是为了展示标准插件入口。加载成功后,apply 会执行一次,并向启动终端打印日志。
3.4 用 cordis.yml 插入本地插件
先在仓库根目录运行:
pwd然后创建 scratch-plugin/cordis.yml,把下面的示例根路径换成 pwd 的真实输出:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'假设你的仓库位于 /Users/your-name/code/deepseek-harness,实际内容应类似:
- insert:
- id: hello
name: '/Users/fly/code/deepseek-harness/scratch-plugin/src/my-plugin.ts'这里有三个需要分开的概念:
id: hello是这条配置行的稳定标识,后续 patch 可以按id覆盖它;name是 loader 要加载的模块位置;insert表示把这条插件配置插入现有配置树。
官方特别强调,本地插件路径必须使用绝对路径。
原因是 --patch 文件只贡献配置层,它不会把 loader 解析模块时的 profile 目录改成 patch 文件所在目录。换句话说,cordis.yml 放在 scratch-plugin 目录中,不代表 ./src/my-plugin.ts 一定相对于 scratch-plugin 解析。
实操提示:后续官方“插件配置”页面的 YAML 示例写了name: './src/my-plugin.ts'。这与“必须使用绝对路径”的说明并不一致。为了让整条源码练习路径稳定可复现,本文后续继续使用绝对路径,只修改config部分。
3.5 启动 Web UI
在仓库根目录执行:
pnpm dsh web --patch ./scratch-plugin/cordis.yml然后打开:
http://127.0.0.1:3080成功时,启动终端会出现:
[hello-plugin] plugin loaded!![图片展示的是在终端执行命令启动Web UI时的输出结果。命令为“pnpm dsh web --patch ./scratch-plugin/cordis.yml”,成功后终端显示“\[hello-plugin\] plugin loaded!”,并提示“asn web: http://127.0.0.1:3080”。该图片与上文“3.5启动Web UI”内容相关,用于说明在仓库根目录执行相关命令后,成功启动Web UI时终端的输出情况。](https://media.fange-kb.cn/assets/f5/f534c4ded0c4b225a044b37c541a40ac8114c1150aeb5b22cf9384c9905dffee/original.png)
3.6 本阶段验收清单
scratch-plugin/src/my-plugin.ts已创建;scratch-plugin/cordis.yml中使用真实绝对路径;- Web UI 可以打开;
- 启动终端打印
[hello-plugin] plugin loaded!; - 终端没有模块解析或 TypeScript 加载错误。
如果提示找不到模块,优先检查:
name是否仍是示例路径;- 路径是否指向
my-plugin.ts文件本身; - 启动命令是否在 DeepSeek Harness 仓库根目录执行;
- 仓库是否已完成
pnpm install和pnpm run build。
四、插件为什么可以自动清理
官方文档说明,通过 ctx 注册的事件监听、工具等资源,会在插件卸载时自动清理。对于框架不知道如何关闭的外部资源,则使用 ctx.effect() 显式提供清理函数。
官方示例:
import type { Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => {
console.log('heartbeat')
}, 5000)
// The returned function runs when the plugin unloads.
return () => clearInterval(timer)
})
}这段代码的关键不在定时器,而在资源所有权:
- 插件加载时执行 effect,创建定时器;
- effect 返回 disposer,也就是资源释放函数;
- 插件卸载或被热替换时,Cordis 调用 disposer;
- 旧插件留下的定时器被清除,不会和新实例一起重复运行。
数据库连接、WebSocket、消息订阅和临时文件句柄也可以采用同样模式。判断是否要使用 `ctx.effect()` 的简单标准是:这个资源是否需要在插件失效时主动关闭,并且 Cordis 是否无法通过已有注册 API 自动知道该怎么关闭。
官方生命周期文档进一步明确:处置器会按注册顺序的逆序开始调用,但多个异步处置器可能并发执行,不保证一个完成后才执行下一个。如果两个清理步骤存在严格顺序依赖,应把它们放进同一个 disposer,并在内部串行 await。
五、声明插件依赖:inject
当插件需要 tools、llm 等服务时,必须声明依赖:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-tool-plugin'
export const inject = ['tools']
export function apply(ctx: Context) {
// ctx.tools is ready here.
ctx.tools.register(/* ... */)
}inject 不是一份供人阅读的依赖说明,而是加载时真正参与调度的契约。它告诉 Cordis:只有在 tools 服务可用时,这个插件才能进入 apply。
它带来两项官方保证:
apply执行时,声明的必需服务已经就绪;- 运行中如果必需服务消失,依赖插件会自动卸载;服务恢复后,插件会重新加载。
因此不要用“插件先加载,再在内部轮询等待服务”的方式绕过依赖系统。依赖关系应该被框架看见,Cordis 才能正确处理启动顺序、热替换和级联卸载。

六、插件支持的三种形态
6.1 函数形式
export const name = 'my-plugin'
export function apply(ctx: Context) {
// ...
}这是大多数业务扩展的首选,入口直接、状态少,也最接近本教程的代码。
6.2 对象形式
import type { Context } from '@deepseek-ai/cordis'
export default {
name: 'my-plugin',
inject: ['tools'],
apply(ctx: Context) {
// ...
},
}对象形式把 name、inject 和 apply 聚合在一个默认导出中,适合希望将插件元数据放在一起的情况。
6.3 类形式
import { Service, type Context } from '@deepseek-ai/cordis'
export default class MyService extends Service {
static inject = ['tools']
constructor(ctx: Context) {
super(ctx, 'myService')
// Perform synchronous initialization in the constructor.
}
}官方建议:大多数情况下函数形式已经足够;当插件需要向其他插件提供服务时,可以使用继承 `Service` 的类形式。这里的 `super(ctx, 'myService')` 会把服务以 `myService` 这个名字挂载到上下文能力体系中。
七、第二阶段:把插件升级为模型可调用的工具
现在将最小插件替换为一个 greet 工具。
7.1 替换插件代码
将 scratch-plugin/src/my-plugin.ts 完整替换为:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}7.2 逐项解释工具定义
export const inject = ['tools']
表示插件依赖工具注册表。只有 tools 服务准备好后,apply 才执行。
ctx.tools.register(...)
把一个工具定义注册到当前运行时。该注册属于插件作用域,插件卸载时会自动撤销。
defineTool({...})
创建规范工具定义。官方说明它会根据 parameters 推导并校验 args,因此 execute 里可以按声明后的结构访问 args.name。
name 与 description
这是提供给工具系统和模型的基本识别信息。工具名是调用标识,描述帮助模型判断什么时候应该选择它。
parameters
声明工具输入。这里要求一个必填字符串 name。模型如果发出不符合结构的参数,工具层可以在执行前发现问题。
execute
是真正的业务逻辑。它返回规范值 Hello, ${args.name}!。
output.schema
声明 execute 的规范返回值是字符串。规范值适合程序继续处理,不等同于最终直接展示给模型的内容块。
output.render
把规范值转换成面向模型的内容。这里生成一个文本内容块:
[{ type: 'text', text: value }]因此完整链路是:
模型生成工具调用
→ parameters 校验参数
→ execute 执行业务逻辑
→ output.schema 约束规范结果
→ output.render 转换为模型可见内容
→ 模型收到 Hello, Ada!
7.3 启动并调用工具
如果服务没有运行,重新执行:
pnpm dsh web --patch ./scratch-plugin/cordis.yml打开 http://127.0.0.1:3080,在对话中输入官方示例:

Use the greet tool to greet Ada.预期结果是:模型选择 greet,传入类似 {"name":"Ada"} 的参数,并收到:
Hello, Ada!
7.4 本阶段验收清单
- 插件声明了
inject = ['tools']; - Web 启动没有提示
tools服务或工具 schema 错误; - 当前 Agent 能看到
greet工具; - 模型发起了工具调用,而不是只用自然语言假装执行;
- 工具结果中出现
Hello, Ada!。
如果模型只回复了一句问候、没有真正调用工具,可以先用更明确的提示词,确认当前 Agent preset 是否暴露该工具,并检查 Web UI 中的工具调用记录。插件代码正确不代表模型必然在每次对话中选择调用工具。
八、第三阶段:为插件增加可校验配置
配置的目的不是简单把常量搬到 YAML,而是让不同部署可以改变行为,同时让无效配置在插件启动阶段立即失败。
8.1 官方基础 Config 示例
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'my-plugin'
export interface Config {
greeting: string
maxRetries: number
verbose?: boolean
}
export const Config: Schema = 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.
}这里有两个同名导出:
interface Config服务于 TypeScript,在开发阶段提供类型检查;const Config是运行时 schema,服务于 Cordis,在插件加载时校验真实配置并填充默认值。
只有 TypeScript 接口不够,因为接口在编译后不会作为运行时校验器存在;只有运行时 schema 也不理想,因为插件代码会失去明确的静态类型。两者同名,是官方采用的类型与运行时约束配对方式。
不要导出一个普通对象冒充 Config。官方明确指出,普通对象不满足 Cordis 要求的 Standard Schema 接口。
8.2 在 cordis.yml 中传入配置
官方配置页给出的结构是:
- insert:
- id: hello
name: './src/my-plugin.ts'
config:
greeting: 'Hi there'
maxRetries: 5但在当前“仓库根目录 + --patch ./scratch-plugin/cordis.yml”练习中,请保留第一阶段使用的绝对路径:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
config:
greeting: 'Hi there'
maxRetries: 5verbose 没有在 YAML 中提供,因此 schema 会填入默认值 false。greeting 和 maxRetries 则使用用户传入值。
8.3 严格校验示例
官方进一步给出:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'validated-plugin'
export interface Config {
apiKey: string
timeout: number
mode: 'fast' | 'accurate'
}
export const Config = Schema.object({
apiKey: Schema.string().required(),
timeout: Schema.number().default(30000),
mode: Schema.union(['fast', 'accurate']).default('fast'),
})
export function apply(ctx: Context, config: Config) {
// config is validated and type-safe.
}此时:
- 缺少
apiKey会导致加载失败; timeout未提供时会得到30000;mode只能是fast或accurate,其他值会在加载时失败;apply接收到的是已经完成校验和默认值填充的配置。
这种“尽早失败”非常重要。配置错误如果没有在加载阶段暴露,就可能在真正处理任务、调用外部服务甚至写入业务数据时才出现。
8.4 官方配置设计原则
第一条:不同部署可能采用不同值的参数,应该定义为配置字段。
// Wrong: hardcoded timeout.
const TIMEOUT = 30000
// Correct: configurable.
export interface Config {
timeoutMs: number // Defaults to 30000.
}检验方式是:能否只修改 cordis.yml 就改变这个值,而不需要修改插件源码。
第二条:配置错误应该“响亮”。
应尽量在 schema 中完整表达必填、类型、枚举和默认值约束,让无效部署在插件加载时失败,而不是进入运行状态后静默使用错误值。
第三条:服务或已经注册资源的引用不应伪装成普通配置。它们应通过依赖注入获得,这样框架才能管理可用性和生命周期。
8.5 配置与 HMR
HMR 是 Hot Module Replacement(热模块替换)。它指应用运行期间,在不重启整个 dsh web 进程的情况下,重新加载发生变化的插件代码或配置。可以把标题改得更直白:官方说明,修改 cordis.yml 中某个插件的 config 会触发热替换:
- 卸载旧插件实例;
- 清理旧实例注册的工具、事件与 effect;
- 使用新配置加载新实例;
- 再次执行
apply(ctx, config)。
这解释了为什么自动清理不是附属功能,而是插件开发的基础。如果旧注册无法可靠撤销,修改一次配置就可能得到两个工具、两个监听器或两条后台连接。
九、第四阶段:打包成可安装组合包
前面的 --patch 适合本地开发。要把插件交付给其他 profile 使用,需要理解 bundle 和 profile。
9.1 两个概念、两种 manifest
官方定义如下:
- bundle(组合包):附带一个配置层的 npm 包;在
package.json中声明dsh.bundle,回答“这个包贡献什么”; - profile:位于
$DSH_HOME/profiles/的可启动组合目录;在package.json中声明dsh.profile,回答“这套配置包含哪些 bundle,以及按什么顺序组合”。
一句话区分:
插件作者交付 bundle;用户启动 profile。官方还明确说明,没有一个东西同时既是 bundle 又是 profile。

9.2 创建组合包目录
在适当的工作目录执行:
mkdir -p hello-plugin最终结构如下:
hello-plugin/
├── package.json
├── cordis.patch.yml
└── index.js三个文件的职责分别是:
package.json:声明 npm 包和dsh.bundlemanifest;cordis.patch.yml:声明这个 bundle 加入 profile 时贡献的配置层;index.js:真正的插件模块入口。
9.3 创建 package.json
{
"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" } }
}重点解释:
type: module表示该包使用 ESM(ECMAScript Modules)模块系统,代码通过import和export引入、导出功能;main指向插件入口;files决定打包发布时包含哪些文件;dsh.bundle.patch告诉 dsh:启用这个包时,应把哪个 patch 加入配置层栈。
9.4 创建插件入口
创建 hello-plugin/index.js:
export const name = 'hello-plugin'
export function apply() {
console.log('[hello-plugin] plugin loaded!')
}这里使用 JavaScript,是为了得到一个不需要额外构建步骤的最小可安装包。后面从 GitHub 安装 TypeScript 源码时,会看到为什么构建环节需要额外处理。
9.5 创建 bundle patch
创建 hello-plugin/cordis.patch.yml:
- insert:
- id: hello
name: dsh-hello-plugin与本地 --patch 不同,这里不再写源码绝对路径,而是写 npm 包名。安装进 profile 后,Node 模块解析会从 profile 的依赖中找到 dsh-hello-plugin。
如果一个 npm 包没有声明 dsh.bundle,它仍然可以被安装,但只会成为普通依赖;dsh plugin 会给出警告,也不会自动把它作为配置层激活。这种形式适合“只供其他插件 import 的库”,不适合“用户安装后就应启用的插件组合包”。
9.6 profile 中有什么
profile 目录主要包含:
package.json:记录 profile 的外部依赖、dsh.profilemanifest 和有序 bundles 列表;cordis.patch.yml:用户自己的 profile patch,应用在所有 bundle 层之后。
profile manifest 不需要用户手写,官方 dsh plugin 命令负责创建和维护。
9.7 安装进 demo profile
在包含 hello-plugin 目录的位置执行:
dsh plugin --profile demo add ./hello-plugin如果你正在 DeepSeek Harness 源码 checkout 中、没有使用全局安装的 CLI,则按官方提示改为:
pnpm dsh plugin --profile demo add ./hello-plugin
首次执行该命令pnpm dsh plugin --profile demo add ./hello-plugin时,如果 $DSH_HOME/profiles/demo 尚不存在,dsh 会创建这个 profile,并生成 package.json、cordis.patch.yml 和 pnpm-workspace.yaml。由于 demo 不是内置 profile,其 dsh.profile.bundles 初始只包含 @deepseek-ai/dsh-base。随后,pnpm 会把本地 hello-plugin checkout 链接到 profile 的 node_modules,并写入 dependencies 和 pnpm-lock.yaml。安装成功后,dsh 读取插件的 package.json;如果插件声明了 dsh.bundle.patch,就把插件的真实包名追加到 dsh.profile.bundles。下次运行 dsh --profile demo 时,dsh 会依次应用基础 bundle、插件 bundle 和 profile 自己的配置层。
生成结果类似:
{
"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"
]
}
}
}9.8 先检查配置,再启动
dsh --profile demo --dump-config
dsh --profile demo在源码 checkout 中对应为:
pnpm dsh --profile demo --dump-config
pnpm dsh --profile demo--dump-config 不启动应用,而是打印最终组合配置。输出中应看到类似 # == dsh-hello-plugin 的层标记。这是非常实用的排错方法:先证明 bundle 已进入配置层栈,再排查模块加载或插件运行。
移除命令是:
dsh plugin --profile demo remove dsh-hello-plugin官方说明,该命令会同时移除依赖和对应配置层。添加、移除或更新 bundle 后,需要重启正在运行的 profile,当前进程不会在运行中更换整套 bundle 集合。
十、配置层的加载顺序
上一节已经把第三方 bundle 安装进 demo profile,并通过 --dump-config 确认它进入了配置层栈。但一个 profile 的最终配置并不只来自刚安装的 bundle:基础 bundle、其他已安装 bundle、用户保存的配置覆盖,以及本次启动临时传入的覆盖都可能修改同一条插件配置。
要判断某个配置值最终从哪里来,首先需要明确这些配置层的应用顺序。
dsh 从空配置开始,依次应用以下配置层:
dsh.profile.bundles中每个 bundle 的 patch,按列表顺序应用;- profile 自己的
cordis.patch.yml; - home 级
$DSH_HOME/cordis.patch.yml; - 命令行中每个
--patchoverlay,按参数顺序应用。
可以把它理解为:
空配置
→ base bundle
→ 后续已安装 bundle
→ 当前 profile 的用户覆盖
→ 本机所有 profile 共享的覆盖
→ 本次启动命令临时传入的覆盖越晚应用的层优先级越高。
10.1 不是深度合并,而是整段替换
这是最容易踩坑的官方规则之一:patch 命中目标行时,会替换这一行的整个 config 值,不是只深度合并你写出的几个键。
假设前一层为:
- id: example
config:
host: 127.0.0.1
port: 3080
verbose: false后一层如果只写:
- id: example
config:
port: 8080不能想当然地认为最终 host 和 verbose 一定保留。组合包作者覆盖已有行时,应重述该行需要的全部配置键;其余默认值尽量交给插件导出的 schema 处理。
这项设计也给用户保留了控制权:用户可以在 profile 的 cordis.patch.yml 中覆盖第三方 bundle 的行,而不需要修改第三方包源码。

10.2 内置包与外部包的解析
官方说明,内置 bundle 名称从 dsh 安装目录解析;pnpm 主要管理 profile 的树外依赖。因此第三方 bundle 可以依赖与当前 dsh 安装保持一致的 @deepseek-ai/dsh-base。
十一、让应用型 bundle 拥有自己的命令行参数
如果 bundle 不只是增加能力,还定义了一套可运行应用,可以挂载一个普通的启动参数提供方插件:
- id: hello-startup
name: 'dsh-hello-plugin/startup'官方约定是:
- 提供方插件声明
inject = ['cmdlineArgs']; - 使用自己的 commander program 调用
@deepseek-ai/dsh-cmdline的parseCmdline; - 在自己的 action 中把解析结果作为应用自有服务提供;
- 依赖这些参数的其他插件行注入该服务,并在
!!js配置表达式中读取。
例如:
- id: my-app
name: '@example/my-app'
inject: [myAppStartup]
config:
port: !!js ctx.myAppStartup.port ?? 8080这表示命令行提供的端口优先,否则回退到部署默认值 8080。
启动器会把自身 flag 之后的同一份不可变参数快照交给每个插件,所以应用可以添加自己的参数,而不必修改 dsh 启动器。多个插件也可以分别解析同一份快照。
遇到 --help 时,提供方不会发布服务,因此依赖它的应用行不会激活。这仍然是普通服务依赖行为,不需要为 loader 增加特殊插件类型。
十二、让自定义插件可以通过一条命令安装
第九节已经创建并本地安装了 hello-plugin/ 组合包。本节不再创建另一套示例,而是在同一个项目上补齐 npm 发布信息,使其他用户可以直接从 registry 安装它。本文使用的 npm 账户名是 flyaibox,因此个人 scope 是 @flyaibox,下面把第九节的 dsh-hello-plugin 改为 @flyaibox/dsh-hello-plugin。
可以先登录并确认当前 npm 账户:
npm login
npm whoaminpm whoami 应输出:
flyaiboxnpm scope 来自 npm 用户名或组织名,不是自动读取的 GitHub 用户名。本节后面的 github:your-name/... 中,your-name 仍应替换为实际的 GitHub 仓库 owner;只有两个平台的账户名相同时,它才也写成 flyaibox。
发布后,用户可以执行:
dsh plugin --profile web add -w @flyaibox/dsh-hello-plugin第九节使用 demo profile,是为了在只有基础 bundle 的环境中验证本地组合包;本节改用 web profile,是为了演示普通用户把已发布插件加入 Web 应用的实际命令。profile 虽然不同,安装的仍是同一个 hello-plugin 案例。
其中:
--profile web指定插件安装到 Web profile,而不是安装到 dsh 程序本身;add让 pnpm 从 npm 下载包并写入 profile 的dependencies;-w是 pnpm 的--workspace-root简写,明确允许把依赖写入 profile 的 workspace 根包;- 安装成功后,dsh 会检查包的
package.json;只有声明了dsh.bundle的包才会自动加入dsh.profile.bundles,成为启动时应用的配置层。
下面沿用第九节的实操结果,完整走通“补充发布信息 → 本地打包验证 → 发布 npm → 用户安装 → 更新和卸载 → 提供 GitHub 源安装器”的交付流程。
12.1 准备一个可安装的组合包
继续使用第九节已经创建的目录,不需要重新创建 index.js 或另起一个插件项目:
hello-plugin/
├── package.json
├── index.js
└── cordis.patch.yml这一步包含两处修改,没有新增运行时文件:
- 修改 `hello-plugin/package.json`:把包名改为本文使用的 npm scope
@flyaibox,并补充公开发布所需的元数据和导出声明; - 修改 `hello-plugin/cordis.patch.yml`:把配置行中的模块名同步改为新的 scoped package 名;
- 保持 `hello-plugin/index.js` 不变:插件入口、日志和运行行为继续沿用第九节的实现。
下面先用 JSONC 注释标出相对于第九节的修改。JSONC 只用于讲解,真实的 package.json 不允许保留这些注释:
{
// 修改:第九节是 "dsh-hello-plugin";本文使用 npm 账户 flyaibox 的个人 scope。
"name": "@flyaibox/dsh-hello-plugin",
"version": "0.1.0",
// 新增:供 npm 包详情页和许可证识别使用。
"description": "A minimal installable DeepSeek Harness plugin.",
"license": "MIT",
"type": "module",
// 修改:仍指向第九节的 index.js,只把路径写成明确的相对形式。
"main": "./index.js",
// 新增:明确允许用户通过包根路径加载插件入口。
"exports": {
".": "./index.js",
"./package.json": "./package.json"
},
// 保持:继续发布第九节已有的入口和 bundle patch。
"files": [
"index.js",
"cordis.patch.yml"
],
// 新增:scoped package 默认可能是私有包,这里明确按公开包发布。
"publishConfig": {
"access": "public"
},
// 新增:声明插件要求的最低 Node.js 版本。
"engines": {
"node": ">=22.19"
},
// 保持:这是第九节已经添加的组合包声明,不能删除。
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}实际写入 hello-plugin/package.json 的有效 JSON 如下:
{
"name": "@flyaibox/dsh-hello-plugin",
"version": "0.1.0",
"description": "A minimal installable DeepSeek Harness plugin.",
"license": "MIT",
"type": "module",
"main": "./index.js",
"exports": {
".": "./index.js",
"./package.json": "./package.json"
},
"files": [
"index.js",
"cordis.patch.yml"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=22.19"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}与第九节相比,最终结果保留了原来的 type、入口文件、发布文件列表和 dsh.bundle.patch,并增加了:
- scoped npm 包名
@flyaibox/dsh-hello-plugin; description和license;- 显式的
exports; publishConfig.access: public允许公开发布 scoped package;engines.node最低版本声明。
如果使用 TypeScript,应先把源码构建为 lib/index.js,再把 main、exports 和 files 改为对应的 lib/ 路径。不要发布一个只包含 src/*.ts、安装后却没有运行时入口的包。
hello-plugin/index.js 不需要修改,继续使用第九节的代码:
export const name = 'hello-plugin'
export function apply() {
console.log('[hello-plugin] plugin loaded!')
}修改 hello-plugin/cordis.patch.yml。YAML 注释可以保留在真实文件中:
- insert:
# 保持:沿用第九节的稳定配置行 id,后续覆盖仍然可以按 hello 定位。
- id: hello
# 修改:第九节是 dsh-hello-plugin;发布 scoped package 后必须同步包名。
name: '@flyaibox/dsh-hello-plugin'package.json.name 与 patch 中的 name 必须一致,否则 pnpm 虽然能安装组合包,loader 却会继续查找第九节的旧模块名 dsh-hello-plugin。id: hello 是配置行身份,不是 npm 包名,所以改包名时不需要修改它。如果 patch 还引用了其他第三方 npm 包,应把那些包列入当前组合包的 dependencies,确保用户只安装这个组合包就能得到完整运行依赖。
第九节的 demo profile 已经记录了旧包名。修改名称后如果还要继续使用该 profile,应先删除旧依赖,再从同一个 checkout 重新安装:
dsh plugin --profile demo remove dsh-hello-plugin
dsh plugin --profile demo add ./hello-plugin重新安装后,$DSH_HOME/profiles/demo/package.json 中的 dependencies 和 dsh.profile.bundles 会改为 @flyaibox/dsh-hello-plugin;未设置 DSH_HOME 时,该文件位于 ~/.dsh/profiles/demo/package.json。
12.2 发布前先用 tarball 验证
先检查 npm 最终会收进哪些文件:
cd hello-plugin
npm pack --dry-run输出中至少应该包含:
package.json
index.js
cordis.patch.yml然后生成真实 tarball:
npm pack命令会生成类似 flyaibox-dsh-hello-plugin-0.1.0.tgz 的文件。先把它安装进 Web profile:
dsh plugin --profile web add -w ./flyaibox-dsh-hello-plugin-0.1.0.tgzdsh plugin 会把相对文件路径锚定到当前调用目录,因此不需要手工切换到 ~/.dsh/profiles/web。安装成功后检查最终组合配置:
dsh --profile web --dump-config输出中应出现模块名 @flyaibox/dsh-hello-plugin 和配置行 id: hello。再启动或重启 Web profile:
dsh web终端出现下面的日志,说明发布包中的入口和 patch 都能工作:
[hello-plugin] plugin loaded!如果 dsh plugin 警告包没有声明 dsh.bundle,说明 package.json 中缺少该字段,或者发布包中的 package.json 与本地文件不一致。如果 --dump-config 中已有插件行,但启动时报模块不存在,应检查 files、main、exports 和构建产物。
12.3 发布到 npm
前面已经通过 npm whoami 确认当前登录账户是 flyaibox。发布前检查该 scope 下是否已经存在同名包,并查看已发布版本:
npm view @flyaibox/dsh-hello-plugin version第一次发布时,npm view 返回 404 是正常现象。完成本地 tarball 验收后发布:
npm publish --access publicnpm 的同一版本不能覆盖发布。每次重新发布前都要先更新 package.json 中的版本,例如:
npm version patch
npm publish --access public
发布完成后,可以从一个干净的 profile 再执行一次正式安装验证:
dsh plugin --profile web add -w @flyaibox/dsh-hello-plugin
dsh --profile web --dump-config
这时的完整数据流是:
npm registry
→ pnpm 下载 @flyaibox/dsh-hello-plugin
→ 写入 ~/.dsh/profiles/web/package.json 的 dependencies
→ dsh 发现包声明了 dsh.bundle.patch
→ 把 @flyaibox/dsh-hello-plugin 追加到 dsh.profile.bundles
→ dsh web 启动时应用 cordis.patch.yml
→ loader 从 profile 的 node_modules 加载插件入口
如果设置了 DSH_HOME,profile 会位于 $DSH_HOME/profiles/web,而不是 ~/.dsh/profiles/web。
12.4 用户如何安装和验收
插件 README 应至少给出下面的操作:
dsh plugin --profile web add -w @flyaibox/dsh-hello-plugin
dsh --profile web --dump-config
dsh web如果 dsh web 已经在运行,应先停止旧进程,再重新启动。安装命令只修改下一次启动使用的 profile 依赖和组合包列表,不会把一个正在运行的进程替换为新配置。
还可以确认 pnpm 为什么安装了这个包:
dsh plugin --profile web why @flyaibox/dsh-hello-plugin“设置 → 插件”中的页面不是每个插件自动获得的。只有同时发布浏览器端 client bundle,并注册对应设置页的插件,才会在 Web UI 中出现专属入口。对于上面的最小插件,应以 --dump-config 中出现插件行和终端打印加载日志作为验收结果。
12.5 更新和卸载
更新当前版本范围内的依赖:
dsh plugin --profile web update -w @flyaibox/dsh-hello-plugin需要跨越当前版本范围并升级到 npm 的最新版本时,可以使用:
dsh plugin --profile web update -w --latest @flyaibox/dsh-hello-plugin更新后重启 dsh web。卸载命令是:
dsh plugin --profile web remove -w @flyaibox/dsh-hello-pluginpnpm 会从 profile 的 dependencies 删除该包,dsh 随后会把它从 dsh.profile.bundles 中移除。卸载后同样需要重启正在运行的 Web 进程。
12.6 直接安装 GitHub 源码
dsh 原生支持把 pnpm 能理解的 GitHub spec 转发给 profile:
dsh plugin --profile web add -w github:your-name/dsh-hello-plugin用于可复现安装时,应锁定 commit:
dsh plugin --profile web add -w github:your-name/dsh-hello-plugin#GitHub 仓库必须满足以下两种条件之一:
- 仓库提交了
main或exports指向的构建产物,下载后可以直接运行; package.json提供自包含的prepare脚本,安装时能够生成构建产物。
十三、写在最后:从 Hello World 到 Hello Plugin
跑通这个 Hello Plugin 之后,DeepSeek Harness 就不再只是一组架构名词。插件如何进入 profile、如何等待依赖、如何注册工具、如何清理副作用,以及最终如何被安装和启动,都变成了可以观察、修改和验证的真实链路。
如果你也觉得只看分析文章始终“隔着一层皮”,最有效的办法仍然是从一个足够小的插件开始:先让它被加载,再让模型调用一个工具,随后逐步加入配置、生命周期和发布流程。就像当年的 Hello, World! 一样,先把第一条链路跑通,后面的复杂机制才真正有了落点。
本文涉及的练习代码、配图案例和后续学习记录会继续整理在 FlyAIBox/dsh101[2]。如果你也在学习 DeepSeek Harness,可以直接从这个项目开始复现,并把自己的实验继续扩展下去。
备注:DeepSeek Harness 仍在快速演进,命令、接口和目录结构请以官方资料为准。DeepSeek Harness 官方基础教程[3]DeepSeek Harness GitHub 仓库[4]
引用链接
[1] FlyAIBox/dsh101: https://github.com/FlyAIBox/dsh101[2] FlyAIBox/dsh101: https://github.com/FlyAIBox/dsh101[3] DeepSeek Harness 官方基础教程: https://deepseek-harness.github.io/deepseek-harness/develop/basic/[4] DeepSeek Harness GitHub 仓库: https://github.com/deepseek-ai/deepseek-harness
