写 dsh 插件:从零到上手
我先给你讲清楚"插件"在 dsh 里到底有几种形态,然后按"使用频率"从高到低带你过一遍 — 每一种都给你能直接跑的代码。
0. 心智模型:插件 = 一个文件
在 dsh 里,插件就是一个 TS 文件,导出一个 apply(ctx, config?) 函数。文件怎么写、放在哪里、谁来加载,都用 cordis.yml 描述。
my-extension/
├── cordis.yml # 告诉 dsh 加载哪些插件 (按依赖,不按顺序)
├── plugins/
│ ├── hello.ts # 你的第一个插件
│ ├── greeter.ts # 提供服务
│ └── permission.ts # 拦截工具调用
cordis.yml 长得像:
- name: './plugins/hello.ts'
- name: './plugins/greeter.ts'
- name: './plugins/permission.ts'
config:
blocked: ['rm -rf']
重要: 配置文件里没有顺序概念。谁先加载由"inject 依赖"决定,跟文件位置无关。如果你写 apply 时需要的 service 还没出现,你的插件就处于 PENDING 状态,直到依赖就绪。
1. 形态 1:最简函数插件 (Hello world)
文件:plugins/hello.ts
import type { Context } from '@deepseek-ai/cordis'
/** 诊断显示名 (可选) */
export const name = 'hello'
/** 加载时调用一次。Cordis 帮你处理所有清理。 */
export function apply(ctx: Context) {
console.log('hello from my first plugin')
}
这就是插件的完整骨架。cordis.yml 里加一行就生效:
- name: './plugins/hello.ts'
跑 pnpm dsh --profile headless 时你会看到日志。没有框架代码、没有 main 入口、没有状态管理。
2. 形态 2:共享状态 — Service + ctx.foo 模式
普通变量插件之间没法共享,你要让它能被别人调用,就升级成 Service。
文件:plugins/greeter.ts
import { Service, type Context } from '@deepseek-ai/cordis'
// 关键:扩展 Context 类型,让 ctx.greeter 有类型
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter') // 第二个参数是 ctx 上的键名
}
greet(who: string) {
return `Hello, ${who}!`
}
}
export const name = 'greeter'
export function apply(ctx: Context) {
ctx.plugin(GreeterService) // 挂载 — Cordis 会自动管理生命周期
}
要点:
declare module '@deepseek-ai/cordis'是纯类型合并,运行时无副作用,但没有它ctx.greeter会没有类型提示Service子类本身就是插件 —ctx.plugin(MyService)等价于ctx.plugin({ apply })- 注册是可逆的 — provider 卸载时,服务自动消失,所有依赖它的插件也会被卸载
3. 形态 3:消费别人的 Service — inject 模式
文件:plugins/consumer.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'consumer'
export const inject = ['greeter'] // 关键:等这个服务就绪才执行 apply
export function apply(ctx: Context) {
// 走到这里 ctx.greeter 一定存在 (Cordis 帮你 guarantee)
console.log(ctx.greeter.greet('world'))
}
关键:
inject列出所有硬依赖,Cordis 把这个插件保持 PENDING 直到全部就绪- 没列出的服务可以用
ctx.get('optional')探测,可能是undefined(可选依赖) - 把
cordis.yml里两个插件条目交换位置再跑 — 输出一样,因为依赖决定顺序
4. 形态 4:配置 — Schema 校验
让插件可配置,用 Config 导出,字段是入参:
文件:plugins/counter.ts
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'counter'
export interface Config {
greeting: string
targets: string[]
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
targets: Schema.array(String).default(['world']),
})
export function apply(ctx: Context, config: Config) {
for (const target of config.targets) {
console.log(`${config.greeting}, ${target}!`)
}
}
cordis.yml:
- name: './plugins/counter.ts'
config:
targets: ['alpha', 'beta']
# greeting 缺省 → 用 schema default 'Hello'
关键:
- 同一个名字
Config既是 TS 类型,又是运行时 schema — 同时拿类型校验 - 配置错就启动失败,插件不会"半配置运行"
- 配置文件里
!!js标签让表达式求值 (注意是!!js,不是!js):
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
5. ⚡ 形态 5:真正最常用 — 注册工具 (让模型能用)
写 dsh 插件80% 的场景是给模型加工具。这就是注册一个 read_file 工具的样子:
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const inject = ['tools'] // 等工具注册表就绪
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // 模型能看到这个
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // optional 默认
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args 自动按 schema 校验过
// exec.signal 是可取消的超时信号
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}
插一句,这是 dsh 最有意思的设计:
register()返回 disposer → 你的插件卸载时工具自动注销- schema 自动并入 system prompt (你不用自己拼 prompt)
args在execute入口已经被校验过 (但defineTool不替你检查"非空"等业务规则)
还想让 UI 漂亮? 加 presentCall/presentResult:
ctx.tools.register(defineTool({
// ... 上面的 ...
presentCall(args) {
return { card: 'diff', title: `修改 ${args.path}`, diffs: [...] }
},
presentResult(args, { content, isError }) {
return { card: 'generic', title: '完成', content }
},
}))
UI 看到 card: 'diff' 就显示 diff 视图。所有渲染函数必须纯函数,因为它们在回放时也会跑。
6. 形态 6:拦截 — 监听事件 (waterfall)
有时候你不想做工具,而是想改变已有工具的行为。比如加一层权限检查:
import type { Context } from '@deepseek-ai/cordis'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
export const name = 'permission-gate'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.on('tools/pre-execute', async (exec, next) => {
if (exec.name === 'bash' && exec.arguments.command?.includes('rm -rf /')) {
return { allowed: false, reason: '禁止 rm -rf /' } // 拦截,不再 next()
}
return next() // 放行
})
}
关键观念:
ctx.on()注册的 listener 是副作用 — 插件卸载时自动移除next()是否调用 = 是否放行。这叫waterfall模式- 一共有 5 种 dispatch mode:
| 模式 | 谁用 | 拿到返回值? |
|---|---|---|
emit |
ctx.emit('event', ...) |
否 |
waterfall |
ctx.waterfall(...), listener 收 (args, next) |
是,中间件 |
parallel |
await ctx.parallel(...) |
否,并发 |
serial |
await ctx.serial(...) |
首个非 falsy |
bail |
ctx.bail(...) |
同步 serial |
最常用的 4 个事件 (抄下来):
ctx.on('tools/pre-execute', async (exec, next) => { ... }) // 拦截工具
ctx.on('agent/pre-step', async (step, next) => { ... }) // 拦截 step (返回 message 数组)
ctx.on('session/event', (session, event) => { ... }) // 监听所有 session 事件 (UI 桥常用)
ctx.on('approval/request', async (req, next) => { ... }) // 拦截授权请求
7. 形态 7:自己发事件 — Service + Events
文件:plugins/heartbeat.ts
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
heartbeat: HeartbeatService
}
interface Events {
'heartbeat/tick'(n: number): void // 监听签名
}
}
export class HeartbeatService extends Service {
private n = 0
constructor(ctx: Context) {
super(ctx, 'heartbeat')
ctx.setInterval(() => { // Cordis 自带,卸载时自动清
this.n++
this.ctx.emit('heartbeat/tick', this.n)
}, 1000)
}
}
export const name = 'heartbeat'
export function apply(ctx: Context) {
ctx.plugin(HeartbeatService)
// 监听自己发的事件
ctx.on('heartbeat/tick', (n) => console.log('tick', n))
}
关键观念:
- 事件名约定
namespace/action interface Events声明是全局合并的 — 写在谁的文件里效果都一样- 任何插件都能监听任何事件 — 这就是"插件不互相 import,但能协作"的关键
8. 形态 8:清理资源 — ctx.effect()
如果 Cordis 不知道的资源 (计时器、socket、文件句柄),包进 ctx.effect():
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => clearInterval(timer) // 卸载时被调用
})
}
但多数情况你不需要写 ctx.effect() — 因为:
ctx.on()已经是 effectctx.plugin(child)已经是 effectctx.tools.register()也是 effect- Service 的
ctx.setInterval/setTimeout也是 effect
ctx.effect() 只用于你自己创建的原始资源。
9. 实战工程结构
如果你写的插件不止一个、想打 npm 包分享出去,要这样组织:
packages/<group>/<my-plugin>/
├── package.json # name: "@deepseek-ai/dsh-<my-plugin>"
├── tsconfig.json # extends ../../../tsconfig.base.json
├── src/
│ └── index.ts # 唯一入口,导出 apply
├── README.md
└── tests/
└── index.spec.ts
package.json 关键字段 (从 dsh-anonymous-user-id 抄的):
{
"name": "@deepseek-ai/dsh-my-plugin",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/types/**/*.d.ts"],
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^"
}
}
tsconfig.json:
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}
几个铁规:
private: true(本工作区包不发布到 npm)@deepseek-ai/cordis既在peerDependencies又在devDependenciestype: module(全 ESM)- 不要再包独立
package.json文件 - 文件结尾恰好一个空白行
10. 速查:你可能想做的事 → 怎么写
| 想做的事 | 模式 | 关键 API |
|---|---|---|
| 给模型加工具 | 加工具 | ctx.tools.register(defineTool({...})) |
| 给模型加 system prompt 段落 | 注册 section | ctx.systemPrompt.section({...}) |
| 监听所有模型输出 | 听事件 | ctx.on('session/event', (s, e) => ...) |
| 拦截/改写 tool call | waterfall | ctx.on('tools/pre-execute', (e, next) => ...) |
| 注入上下文到下次请求 | inject | ctx.agents.inject(agent, content) |
| 给 Shell 加一种实现 | 注册 provider | ctx.shell.register({name, ...}) |
| 加持久终端 | 注册 terminal provider | ctx.terminals.register({...}) |
| 加文件系统后端 | 注册 fs provider | ctx.fs.register({...}) |
| 加 LLM provider | 注册 LLM 适配器 | ctx.llm.register({name, ...}) |
| 跨插件共享状态 | Service | class X extends Service + ctx.plugin(X) |
| 监听自己异步状态 | 事件 + Service | ctx.emit('ns/action', ...) + interface Events |
| 拿到插件可配置项 | Schemastery | export const Config: Schema<Config> = ... |
| 一次性清理资源 | effect | ctx.effect(() => () => cleanup()) |
11. 推荐的上手顺序
- 复制上面 §1 的
hello.ts到cordis.yml,跑通 — 确认你的环境能加载 - 改成 §2 的
greeter.ts,跑通 — 确认你理解了 Service - 加 §3 的
consumer.ts,把inject行注释掉看 PENDING 行为 - 改成 §4 的
config形式,试试错误的config看错误信息 - 试 §5 的"工具" — 这是写 dsh 插件最常见的理由
- 试 §6 的"拦截" — 你的工具可能不需要新增,只需要改写
- 看
docs/cookbook/下的具体 cookbook —adding-a-package.md是文件清单 - 想贡献回到仓库时,跑
pnpm run constraints && pnpm run typecheck && pnpm run lint

浙公网安备 33010602011771号