写一个最小的树外插件
一个 prompt section 和一个工具,装在 profile 里,仓库零改动。
这一课让你亲手写一个树外插件——住在你自己的 profile 里,跟仓库没有任何关系,但在运行时跟 227 个官方包完全平级。
两个产出:一段每次请求都会出现在 system prompt 里的文字,和一个模型能调的工具。写完你就理解了 M2 那句「你通过在其他插件旁边挂载一个插件来扩展 dsh」的字面含义。
1 建一个包
就是一个普通的 ESM npm 包。最小骨架:
{
"name": "my-dsh-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"peerDependencies": {
"@deepseek-ai/cordis": "*"
}
}
必须是 ESM。仓库到处写着 "type": "module";dsh 的源码启动路径走的是 tsx 的 ESM-only hook,它能到达的模块不能是 CJS-only 的导出。
不要 default export。用具名导出 name / inject / apply。docs/postmortem/0001 记录的就是「default export 导致 inject 被丢掉」这个事故——所以现在几乎每个官方包的 README 里都有一节「Export shape」写明这件事。
2 先写最简单的:一段 prompt section
/** Cordis 插件名,loader 诊断用。 */ export const name = 'my-dsh-plugin' /** 我们要用的服务。声明了它,Cordis 会等它就绪再启动本插件。 */ export const inject = ['systemPrompt'] export function apply(ctx) { ctx.systemPrompt.section({ name: 'my:house-rules', order: 500, text: '本工作区的约定:提交信息用中文,且必须引用一个 issue 编号。', }) }
三件事值得注意:
inject就是依赖声明。加载顺序是从服务依赖推导出来的,不是手写的启动序列。order决定它在 prompt 里的位置——这直接关系到 KV cache(见 M4.4):越靠前的段落越稳定越好,因为改动它会让后面所有 token 的缓存作废。- 这里没有一句注册清理代码。section 是通过 effect 注册的,插件卸载时自动撤销。
3 装上它,验证它真的进了 prompt
$ dsh plugin --profile web add ~/my-dsh-plugin
相对路径规格(.、../plugin,以及它们的 file: / link: 形式)锚定在你的调用目录,不是 profile 目录——所以在插件 checkout 里跑 dsh plugin --profile web add . 装的是那个 checkout。
然后在 profile 的补丁层里把它挂进树:
- insert: - id: my-plugin name: my-dsh-plugin
$ dsh --profile web --dump-config | grep -A2 my-plugin
看见它了,说明它已经和官方那 200 多个包排在同一棵树上了。
4 再写一个工具
工具要用 defineTool,来自 @deepseek-ai/dsh-tools:
import { defineTool } from '@deepseek-ai/dsh-tools' export const inject = ['systemPrompt', 'tools'] export function apply(ctx) { // …上面那段 section 不变… ctx.tools.register(defineTool({ name: 'house_rule_check', description: '检查一条提交信息是否符合本工作区约定。', // ← 模型看见的就是这句 parameters: { message: { type: 'string', required: true, description: '待检查的提交信息' }, }, output: { schema: { type: 'object', properties: { ok: { type: 'boolean', required: true }, reason: { type: 'string' }, } }, render: (_args, value) => [{ type: 'text', text: value.ok ? '符合约定' : value.reason }], }, execute(args, _exec) { // args 已经按 schema 校验过了,这里它的类型是 { message: string } const ok = /#\d+/.test(args.message) return ok ? { ok } : { ok, reason: '缺少 issue 编号,例如 #123' } }, })) }
5 读懂这段代码背后的四条契约
cookbook 里的 execute() 契约有一长串,挑四条最容易踩的:
- 参数已经替你校验过了。
defineTool在execute跑之前,就按统一的ParameterSchemaSpec校验了模型生成的arguments——类型、必填键、字面量约束、恰一联合、嵌套值。但 DSL 表达不了的约束(非空字符串、正数、跨字段规则)还得你自己查。 - 只返回一个规范的 JSON 值,别返回内容块。注册表会把它快照成无损 JSON、校验、冻结,再交给
output.render(args, value)。不要让调用方从散文里解析 id 和字段。 - 抛错 =
isError。基础设施失败就抛。但一个成功的领域结果即使不理想(比如进程非零退出),也应该表达在规范值里,而不是抛出来。 - 尊重
exec.signal。它一触发,就取消在飞的工作。
Code Mode 免费接上。每一个可见的已注册工具,在 Code Mode 里都能直接 await tools.house_rule_check({ message }),不用做任何额外集成;参数与返回类型从同一份 schema 推出来。
UI 卡片有默认值。没写 presentCall / presentResult 的工具会落到一个通用卡片(标题=工具名,原始参数当输入)。但 AGENTS.md 提醒:「一个工具的 UI 渲染意图是它设计的一部分,要提前决定」,别等到最后再补。
同样这件事,在别家要几步?
待核对
待核对
@deepseek-ai/dsh-mcp-client 给补丁层用,只是默认不启用任何 MCP server,因为每个 server 命令都是沙箱之外的可信可执行代码。Lab 4 · 复刻一个 web search provider——从「加一个工具」升级到「往 seam 里加一个实现」。60 分钟。