写 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)
  • argsexecute 入口已经被校验过 (但 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() 已经是 effect
  • ctx.plugin(child) 已经是 effect
  • ctx.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 又在 devDependencies
  • type: 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. 复制上面 §1 的 hello.tscordis.yml,跑通 — 确认你的环境能加载
  2. 改成 §2 的 greeter.ts,跑通 — 确认你理解了 Service
  3. 加 §3 的 consumer.ts,把 inject 行注释掉看 PENDING 行为
  4. 改成 §4 的 config 形式,试试错误的 config 看错误信息
  5. 试 §5 的"工具" — 这是写 dsh 插件最常见的理由
  6. 试 §6 的"拦截" — 你的工具可能不需要新增,只需要改写
  7. docs/cookbook/ 下的具体 cookbook — adding-a-package.md 是文件清单
  8. 想贡献回到仓库时,跑 pnpm run constraints && pnpm run typecheck && pnpm run lint

posted @ 2026-08-20 14:03  刘阳-jokerleon1688  阅读(0)  评论(0)    收藏  举报