夜雨聆风学习资料网

ARTICLE · 1304467

从 Skill 到 dsh 插件:自己动手做一个

从 Skill 到 dsh 插件:自己动手做一个

从 Skill 到 dsh 插件:自己动手做一个

这份教程教你把「一份 SKILL.md」升级成「一个有页面、有数据、有工具的 dsh 插件」——方法完全提炼自本仓库(dsh-guizang-design v0.1.3)的真实源码,每段代码都可以直接抄去改。

配套阅读:说明书.md 讲「这个项目是什么」;本教程讲「怎么照着做一个自己的」。



0. 这份教程解决什么问题

一份好的 skill(SKILL.md + 脚本 + 模板)已经能让 AI 干活了,但它有三个天然的局限:

局限
表现
插件化之后
无入口
用户要自己想命令、自己传文件
侧栏有专属页面:点按钮、看作品、一键开始
无记忆
每次会话都是新的,做过的东西 AI 不知道
有数据目录:作品库、档案,跨会话留存
被沙箱困住
模型的 bash 只能写工作区,碰不了 ~/.dsh 下的数据
Host 工具替模型读写,免审批、权限可控

歸藏设计的做法可以浓缩成一张图(也是本教程的路线图):

┌─────────────────────────── dsh 桌面版 / 网页版 ───────────────────────────┐│                                                                           ││  【Host 半边】Node.js 主进程(lib/index.js)          【浏览器半边】(lib/client.js)││   · 注册 N 个 skill 给模型        ◄── postMessage ──►  · 侧栏入口图标            ││   · 注册 2 个工具(数据读写)                            · 主区域 iframe 加载插件页面      ││   · Web 路由:页面 + catalog API + 文件服务                              ││   · 明暗主题转发给 iframe                                                 ││                                                                           ││  【数据目录】$DSH_HOME/<你的插件名>/                                        ││   你的 JSON 库 · 你的档案 · 你的缩略图                                     │└───────────────────────────────────────────────────────────────────────────┘

dsh 基于 Cordis 框架,「一切皆插件」:你的插件是一个Service,宿主启动时调用它的 apply(ctx);插件用 inject 声明依赖的服务;所有注册都包在 ctx.effect() 里保证卸载时可逆回收。


1. 全景图:一个 dsh 插件由哪几块组成

对照本仓库的文件布局(每个文件只干一件事):

my-plugin/├── package.json          ① 声明:入口、客户端入口、版本门槛├── cordis.patch.yml      ① bundle 补丁(两行,照抄改个名)├── lib/                  ②③④ Host 半边(Node 主进程里跑)│   ├── index.js          ② skill 提供方 + 工具注册 + Web 路由│   ├── skills.js         ② 读 SKILL.md、适配插件环境│   ├── tools.js          ③ 给模型的工具│   ├── library.js        ⑦ 数据读写(Host 与 CLI 共用)│   └── thumbs.js         ③ 缩略图这类「工具的执行细节」├── client.js             ⑤ 浏览器半边(页面里跑)├── gallery/              ⑤ 插件页面本体(纯 HTML/CSS/JS)│   ├── index.html│   ├── app.js│   └── scheme.js         ⑤ 明暗主题跟随├── scripts/              ⑦ 命令行入口(给人在终端用)├── skills/               ⑨ 外部 skill 快照 + manifest.json└── test/                 ⑧ 测试

①⑤ 跑在两个完全不同的环境里:lib/index.js 是 CommonJS/ESM 的 Node 模块,由宿主进程 import;client.js 则通过 window.ModuleLoader.load() 在浏览器里注册,里面用 require('react') 拿 React。两边靠 postMessage(iframe ↔ 父页面)通信,靠同一个数据目录共享状态。


2. 第 0 步:起一个能被 dsh 识别的空插件

2.1 package.json

dsh 靠三个字段认出这是一个插件、以及怎么加载它:

{  ”name”: ”my-dsh-plugin”,  ”version”: ”0.1.0”,  ”type”: ”module”,  ”main”: ”lib/index.js”,                    // Host 半边入口  ”exports”: {    ”.”: ”./lib/index.js”,    ”./client”: ”./client.js”                // 浏览器半边入口(固定约定路径)  },  ”engines”: {    ”node”: ”^22.19.0 || >=24.0.0”,    ”dsh”: ”>=0.2.1-rc.2 <0.3.0”             // 兼容的 dsh 版本区间  },  ”dsh”: {    ”bundle”: { ”patch”: ”./cordis.patch.yml” },    ”client”: { ”platform”: ”web”, ”inject”: [] }  }}

2.2 cordis.patch.yml

向 Cordis 内核的服务表里插入你的插件声明,整个文件就几行:

# Host 半边(skill 提供方、工具、页面和接口)。浏览器半边由 package.json 的 dsh.client 声明加载。- insert:    - id: my-plugin      name: 'my-dsh-plugin'

2.3 最小 Host 半边

lib/index.js 只要导出三样东西就能被加载:

// lib/index.js —— Host 半边最小骨架export const name = 'my-plugin'// 声明依赖的服务,宿主会等它们就绪再调用 applyexport const inject = ['skills', 'tools', 'webServer']export function apply(ctx) {  // 所有注册都包进 ctx.effect():插件被卸载/重载时自动撤销,不留脏东西  ctx.effect(() => console.log('my-plugin loaded'))}

到这里 dsh plugin --profile web add link:$PWD(本地调试)就已经能装上了——虽然什么功能都没有。先把骨架跑通,再逐块加功能,每加一块测一次。


3. 第 1 步:把 skill 装进 dsh(Provider)

3.1 Skill 的文件形态

一个 skill = 一个目录 + 一个带 frontmatter 的 SKILL.md(+ 可选的 scripts/assets/references):

---name: my-skilldescription: 一句话说清楚这个 skill 干什么、什么时候用。模型靠这段话决定要不要自主调用它,务必具体。---# 我的技能(写给 AI 的操作步骤……)

description 是唯一分发渠道:模型在成百上千个候选里靠它挑 skill。写「做什么 + 什么场景用」,别写成营销文案。

3.2 读取与注册(可直接抄的完整实现)

lib/skills.js 的核心逻辑:扫描目录 → 解析 frontmatter → 生成 candidate 列表:

import { existsSync, readFileSync, readdirSync } from 'node:fs'import { join } from 'node:path'function parse(file) {  const text = readFileSync(file, 'utf8')  const m = text.match(/^---\n([\s\S]*?)\n---\n?/)  if (!m) return null  const field = (k) => (m[1].match(new RegExp(`^${k}:\\s*(.+)$`, 'm')) || [])[1]?.trim().replace(/^([”'])(.*)\1$/, '$2')  const name = field('name'), description = field('description')  return name && description ? { name, description, body: text.slice(m[0].length) } : null}export function readSkills(dirs) {  return [].concat(dirs).flatMap((dir) =>    !existsSync(dir) ? [] :    readdirSync(dir, { withFileTypes: true })      .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, 'SKILL.md')))      .map((d) => ({ dir: join(dir, d.name), file: join(dir, d.name, 'SKILL.md'), ...parse(join(dir, d.name, 'SKILL.md')) }))      .filter((s) => s.name))}export function createSkillProvider(dirs) {  const candidate = (s) => ({    name: s.name,    description: s.description,    path: s.file,    invocation: { modelInvocable: true, userInvocable: true },   // 模型可自主调用 + 用户可 /命令 调用    source: 'bundled',    provider: 'my-plugin',    rank: 600,                                                    // 用户提供方排前面:同名时让位    locator: s.dir,                                               // get() 时靠它找回这份 skill    resourceBase: { kind: 'directory', path: s.dir },             // 告诉 dsh skill 的附属资源在哪  })  return {    name: 'my-plugin',    async list() { return readSkills(dirs).map(candidate) },    async get(c) {      const s = readSkills(dirs).find((x) => x.dir === c.locator && x.name === c.name)      if (!s) return undefined      const content = s.body.replaceAll('', s.dir)   // 正文里的占位符换成真实目录      const { rank, locator, ...summary } = candidate(s)      return { ...summary, content }    },  }}

在 apply 里挂上:

const SKILL_DIRS = [join(ROOT, 'skills')]   // ROOT = 插件安装目录ctx.effect(() => ctx.skills.registerProvider(() => createSkillProvider(SKILL_DIRS)))

就这两行,你的 skill 出现在了输入框的 / 列表里,模型也能自主调用。

3.3 关键细节:加载时改写正文(rewrite + addendum)

外部 skill 往往带着「git clone 安装」时代的步骤,在插件里必败(插件里是快照,没有 .git)。歸藏设计的做法是在 get() 时对正文做两类加工——不改快照文件,改的是注入给模型的内容:

// ① 替换表:每条规则必须命中原文(测试会断言,上游改版就报警,不悄悄失效)const REWRITES = {  'my-skill': [    [/### Step 0 · 检查更新[^\n]*\n[\s\S]*?(?=\n### Step 1)/,     '### Step 0 · 检查更新\n\n在插件里跳过这一步:skill 随插件版本更新,不是 git 仓库,不要运行 git 命令,也不用向用户提起。\n'],  ],}// ② 附注:把独立仓库的用法换成插件环境的用法function addendum(skill) {  return `---## 在 ${'插件名'} 插件(DSH)里使用- 本 skill 的目录是 \`${skill.dir}\`,上文的 scripts/、assets/ 都相对这个目录。- 依赖已随插件安装,跳过 npm install。- 交付以后调用 \`my_plugin_save\` 工具登记结果……`}

经验值:rewrite 负责「去掉必败步骤」,addendum 负责「补上插件环境才有的事情」(数据工具、路径、依赖状态)。


4. 第 2 步:注册工具——让模型能碰工作区外的数据(Tools)

4.1 为什么必须有这一步

模型跑的 bash 在沙箱里,写工作区以外的路径(如~/.dsh/my-plugin/)会被拦下或反复要授权。解法:插件在 Host 进程注册工具,模型的「调用」和「执行」分离——dsh 把工具 schema 放进系统提示,模型发出调用,宿主进程执行。这也是为什么歸藏设计的作品登记、语气档案读写全是工具而不是脚本。

4.2 工具的完整形态

// lib/tools.jsconst str = (description, extra = {}) => ({ type: 'string', description, ...extra })const PARAMS = {  type: 'object',  additionalProperties: false,          // 必须显式写!dsh 会校验  required: ['path', 'title'],  properties: {    path: str('交付物的绝对路径'),    title: str('标题'),  },}export const saveTool = {  name: 'my_plugin_save',               // 小写字母开头,[a-z][a-z0-9_]*  description: '把结果登记进我的插件,用户就能在侧栏页面看到它。同一个文件重复登记只会更新。',  parameters: PARAMS,  timeoutMs: 60_000,  output: {    schema: { type: 'object', additionalProperties: false, required: ['id'], properties: { id: { type: 'string' } } },    // render 把执行结果翻译成给模型看的自然语言——模型据此向用户汇报    render: (_args, v) => [{ type: 'text', text: `已登记(${v.id})。用户可以在侧栏看到它。` }],  },  async execute(args) {    // 注册的是原始 JSON Schema,没有帮手校验,自己挡住缺字段和类型错    if (!args?.path) throw new Error('缺少 path')    if (!isAbsolute(args.path)) throw new Error('path 要用绝对路径')    const id = addWork(args.path, { title: args.title })    return { id }  },}export const TOOLS = [saveTool]

在 apply 里:

for (const tool of TOOLS) ctx.tools.register(tool)

4.3 三条设计守则(都来自歸藏设计的实战)

  1. 工具只干「写不进去的」那件事。能复制文件、能跑脚本的活留在 skill 正文里让模型干;工具只负责碰沙箱外的数据目录。工具越瘦越好审批、越好测。
  2. 输出双通道:execute 返回结构化数据(给程序),output.render 返回一句话(给模型,再由模型转告用户)。render 的话术要包含「用户接下来在哪能看到结果」。
  3. 空状态不是错误。比如「还没有档案」时返回一段说明「这不是错误:照常写,不用向用户报告或提问」——否则模型会把空状态当故障汇报给用户(真实踩过的坑,见第 11 节)。

5. 第 3 步:挂一个插件页面(WebServer)

5.1 注册前缀路由

dsh 的 webServer 服务允许插件在宿主的 HTTP 服务上挂路由。歸藏设计用一个 prefix 路由包揽所有页面和 API:

// lib/index.jsconst BASE = '/guizang-design'ctx.effect(() =>  ctx.webServer.register({    kind: 'prefix',    path: BASE,    async handler(req, res) {          // 原生 Node http.req / http.res      const url = new URL(req.url || '/', 'http://x')      const rest = decodeURIComponent(url.pathname.slice(BASE.length)) || '/'      if (rest === '/api/catalog') return sendJson(res, 200, catalog())   // 页面数据接口      if (rest === '/api/works/remove') { /* 读 JSON body、处理 */ }      if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(res)      // 文件服务:/examples/*、/works//*、/thumbs/*、其余按静态文件处理      const f = safeFile(join(ROOT, 'gallery'), rest === '/' ? 'index.html' : rest.slice(1))      return f ? serveFile(req, res, f) : notFound(res)    },  }),)

5.2 文件服务的安全样板(务必照抄)

插件页面会 serve 用户数据和插件文件,目录穿越防护是底线。歸藏设计的 safeFile() 三层防护值得原样搬走:

import { createReadStream, existsSync, realpathSync, statSync } from 'node:fs'import { extname, join, normalize, sep } from 'node:path'// 只在 dir 里面找文件:拒绝点文件段、目录外的符号链接function safeFile(dir, rel) {  if (!dir || rel.split('/').some((p) => p.startsWith('.'))) return null  let base  try { base = realpathSync(dir) } catch { return null }  const file = normalize(join(base, rel))  if (!file.startsWith(base + sep) || !existsSync(file)) return null  const real = realpathSync(file)  return real.startsWith(base + sep) && statSync(real).isFile() ? real : null}const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', /* … */ }function serveFile(req, res, file) {  res.writeHead(200, { 'content-type': MIME[extname(file).toLowerCase()] || 'application/octet-stream',                       'content-length': statSync(file).size, 'cache-control': 'no-cache' })  if (req.method === 'HEAD') return res.end()  createReadStream(file).pipe(res)}// POST 接口的 JSON 读取:限 Content-Type、校验同源、限 16KBfunction readJson(req, max = 16 * 1024) {  return new Promise((resolve, reject) => {    const origin = req.headers.origin    if (req.method !== 'POST') return reject(Object.assign(new Error('method'), { status: 405 }))    if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return reject(Object.assign(new Error('type'), { status: 415 }))    if (origin && origin.replace(/^https?:\/\//, '') !== req.headers.host) return reject(Object.assign(new Error('origin'), { status: 403 }))    // …按 max 限量收 chunk,end 时 JSON.parse  })}

另外一个重要约束:动态内容(如works//*)只放行「登记过」的目录——歸藏设计先在作品库里查 id 再 safeFile 该作品的 root,而不是开放整个磁盘。

5.3 页面本体:一个读 API 的单页应用

gallery/ 就是普通的前端三件套,无框架也可以。核心模式两句话:

  1. load()
    :fetch('api/catalog') 拿全部数据 → 按 location.hash 渲染对应视图;
  2. 所有按钮走事件委托:点「开始」→ start(草稿)。
// gallery/app.js(节选)function start(prompt) {  if (window.parent !== window)    return window.parent.postMessage({ type: 'my-plugin:start', prompt }, location.origin)  // 嵌在 dsh 里:交给父页面  navigator.clipboard?.writeText(prompt)   // 单独打开页面:降级为复制草稿}

降级设计:页面要能在「嵌在 dsh iframe 里」和「单独浏览器打开」两种环境都可用——前者 postMessage 开新对话,后者复制草稿到剪贴板。


6. 第 4 步:浏览器半边——侧栏入口与开新对话(Client)

6.1 模块注册方式(固定写法)

client.js 通过 dsh 注入的模块加载器注册,工厂函数里 require('react'):

// client.js —— 浏览器半边window.__ModuleLoader__.load({  id: 'my-dsh-plugin',  factory: (require) => {    const React = require('react')    const h = React.createElement    // 侧栏图标:一个 24×24 线条 SVG 组件    function MyIcon(props) {      const s = (props && props.size) || 18      return h('svg', { width: s, height: s, viewBox: '0 0 24 24', fill: 'none',                        stroke: 'currentColor', strokeWidth: 1.5 },        h('path', { d: '…你的图标路径…' }))    }    function MyPanel() {      const ref = React.useRef(null)      React.useEffect(() => {        function onMessage(e) {          const frame = ref.current          if (!frame || e.source !== frame.contentWindow || e.origin !== location.origin) return          const m = e.data          if (!m || m.type !== 'my-plugin:start' || typeof m.prompt !== 'string') return          try {            // 开一个新对话并把草稿填进输入框(不直接发送!)            ctx.uiWorkspace.startSession(undefined, { prompt: m.prompt, whenDraftExists: 'replace' })          } catch (err) {            frame.contentWindow.postMessage({ type: 'my-plugin:start-failed', message: String(err) }, location.origin)          }        }        addEventListener('message', onMessage)        return () => removeEventListener('message', onMessage)      }, [])      return h('iframe', { ref, src: '/my-plugin/', title: '我的插件', allow: 'fullscreen',                           style: { width: '100%', height: '100%', border: 0 } })    }    function apply(ctx) {      // ① 侧栏列表里注册入口(order 决定排位)      ctx.slots.inject('sidebar.panellist', () =>        ctx.slots.register({ name: 'sidebar.panellist', id: 'my-plugin', order: 6, label: () => '我的插件' }, MyIcon))      // ② 主区域注册面板(点侧栏入口时渲染)      ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'my-plugin' }, MyPanel))    }    const exports = { isPlugin: true, inject: ['slots', 'uiWorkspace'], apply }    return exports  },})

注意浏览器半边的 inject: ['slots', 'uiWorkspace'] 是导出字段(和 Host 半边一样的作用:声明依赖)。

6.2 「点按钮 → 新对话」的完整回路

这是插件页面最有价值的一个交互,数据流是:

页面按钮 click → postMessage({type:'my-plugin:start', prompt:草稿})             → client.js 收到(校验 e.source / e.origin / e.data.type)             → ctx.uiWorkspace.startSession(undefined, { prompt, whenDraftExists:'replace' })             → dsh 开新会话,草稿进输入框,用户补完内容自己发送

三个细节别省:

  • 只填不发:草稿是半成品(含用户要补的空位),模型替用户发送是越权;
  • 失败要回路:startSession 抛错时 postMessage 回页面,页面弹 toast,而不是静默失败;
  • 来源校验三连:e.source 必须是你自己的 iframe、e.origin 必须同源、消息 type 必须精确匹配——防其它页面伪造。

6.3 明暗主题跟随(可选但体验加分)

两段配合:client.js 把宿主主题推给 iframe,页面在  里同步执行避免首帧闪白。

// client.js 里的 apply(节选)ctx.inject(['theme'], (c) => {  const push = (snap) => frames.forEach((f) => f.contentWindow?.postMessage(    { type: 'my-plugin:scheme', scheme: snap.active.colorScheme }, location.origin))  push(c.theme.getTheme())  c.on('theme/change', push)})
// gallery/scheme.js —— 放在  顶部同步执行(() => {  const root = document.documentElement  const set = (dark) => { root.dataset.scheme = dark ? 'dark' : 'light' }  let host = null  try { if (parent !== window) host = parent.document.body } catch {}  if (host) set(host.hasAttribute('data-ds-dark-theme'))          // 嵌在 dsh 里:读宿主  else {    const media = matchMedia('(prefers-color-scheme: dark)')       // 单独打开:跟随系统    set(media.matches); media.addEventListener('change', (e) => set(e.matches))  }  addEventListener('message', (e) => {    if (e.source !== parent || e.origin !== location.origin) return    if (e.data?.type === 'my-plugin:scheme') set(e.data.scheme === 'dark')  })})()

7. 第 5 步:数据目录与命令行入口

7.1 数据目录约定

跟 dsh 保持一致:$DSH_HOME 环境变量,没设就 ~/.dsh,你的数据放子目录:

// lib/library.jsimport { homedir } from 'node:os'import { join } from 'node:path'export const dataDir = () => join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'my-plugin')

写 JSON 用原子写(先 tmp 后 rename,防写一半崩溃留坏文件):

function writeLibrary(works) {  mkdirSync(dataDir(), { recursive: true })  const tmp = libraryFile() + '.' + process.pid  writeFileSync(tmp, JSON.stringify({ works }, null, 2) + '\n')  renameSync(tmp, libraryFile())}

7.2 一份逻辑,两个入口

把读写逻辑全部放进 lib/(被 Host 半边 import),scripts/*.mjs 只做参数解析然后调同一份函数:

#!/usr/bin/env node// scripts/library.mjs —— 终端手动处理用;模型在 dsh 里走工具,不走这里import { join } from 'node:path'import { addWork, listWorks, removeWork } from '../lib/library.js'const args = process.argv.slice(2)const [cmd, target] = argsconst opt = (k) => (args.includes(`--${k}`) ? args[args.indexOf(`--${k}`) + 1] : '')if (cmd === 'add' && target) {  const w = addWork(target, { skill: opt('skill'), title: opt('title') })  console.log(`已登记:${w.title}(${w.id})`)} else if (cmd === 'list') {  for (const w of listWorks()) console.log(`${w.id}  ${w.title}`)} else if (cmd === 'remove' && target) {  console.log(removeWork(target) ? '已移除' : '没有这个 id')}

好处:模型走工具、人走命令行,写的是同一份数据,永远不会分叉(歸藏设计的测试专门断言了这一点)。


8. 第 6 步:写测试

用 Node 内置测试器(零依赖),测试里把 DSH_HOME 指向临时目录,绝不碰真实数据:

// test/plugin.test.mjsimport assert from 'node:assert/strict'import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'import { tmpdir } from 'node:os'import { join } from 'node:path'import { test } from 'node:test'const home = mkdtempSync(join(tmpdir(), 'my-plugin-'))process.env.DSH_HOME = home                       // 必须在 import 库代码之前设置const { createSkillProvider, readSkills, REWRITES } = await import('../lib/skills.js')const { TOOLS } = await import('../lib/tools.js')test('skill 都注册了,名字合法、描述够长', async () => {  const provider = createSkillProvider([join(ROOT, 'skills')])  const names = (await provider.list({})).map((c) => c.name)  for (const c of await provider.list({})) {    assert.match(c.name, /^[a-z0-9]+(-[a-z0-9]+)*$/)    assert.ok(c.description.length > 20)  }})test('每条 rewrite 都命中原文(上游改版时这里失败,而不是替换悄悄失效)', () => {  for (const [name, rules] of Object.entries(REWRITES)) {    const s = readSkills([join(ROOT, 'skills')]).find((x) => x.name === name)    for (const [from] of rules) assert.match(s.body, from, `${name}:上游改了这一段,更新 REWRITES`)  }})test('工具定义符合 dsh 注册表要求', () => {  const walk = (n, at) => {    if (n.type === 'object') {      assert.equal(typeof n.additionalProperties, 'boolean', `${at}.additionalProperties 要写明`)      for (const k of n.required || []) assert.ok(n.properties[k], `${at}.required ${k}`)      for (const [k, p] of Object.entries(n.properties)) walk(p, `${at}.${k}`)    }  }  for (const t of TOOLS) {    assert.match(t.name, /^[a-z][a-z0-9_]*$/)    assert.equal(typeof t.output.render, 'function')    walk(t.parameters, `${t.name}.parameters`)  }})

package.json 里加:"scripts": { "test": "node --test test/*.test.mjs" }。

歸藏设计 10 个测试覆盖的高价值断言,建议照单全收:skill 注册齐全且命名合法、 已被替换、附注里不让模型跑 .mjs(沙箱写不了数据目录)、全部 skill 正文无 git 命令、数据库登记/去重/移除、工具与命令行写同一份数据、空状态文案、工具 schema 合规。


9. 第 7 步(可选):vendor——把外部 skill 仓库做成快照

如果像歸藏设计一样「skill 有自己的独立仓库,插件只带快照」,写一个 vendor 脚本(tools/vendor-skills.mjs 的简化版):

#!/usr/bin/env nodeimport { execFileSync } from 'node:child_process'import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'import { dirname, join } from 'node:path'import { fileURLToPath } from 'node:url'const ROOT = fileURLToPath(new URL('..', import.meta.url))const SOURCES = [  { name: 'my-skill', repo: 'https://github.com/you/my-skill.git' },]const SKIP = [/^README[^/]*$/i, /^docs?\//, /^tests?\//, /^\.github\//, /^package(-lock)?\.json$/]const git = (cwd, ...a) => execFileSync('git', a, { cwd, encoding: 'utf8' }).trim()rmSync(join(ROOT, 'skills'), { recursive: true, force: true })const manifest = { skills: [] }for (const s of SOURCES) {  const src = join(ROOT, '.vendor-cache', s.name)  execFileSync('git', ['clone', '-q', '--depth', '1', s.repo, src])  const commit = git(src, 'rev-parse', 'HEAD')  const files = git(src, 'ls-files').split('\n').filter((f) => f && !SKIP.some((re) => re.test(f)))  const dest = join(ROOT, 'skills', s.name)  for (const f of files) { mkdirSync(dirname(join(dest, f)), { recursive: true }); cpSync(join(src, f), join(dest, f)) }  manifest.skills.push({ name: s.name, repo: s.repo, commit, files: files.length })  console.log(`${s.name}  ${commit.slice(0, 7)}  ${files.length} 个文件`)}writeFileSync(join(ROOT, 'skills', 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')

三个关键设计:

  • manifest.json 记录来源 commit——插件页面可以显示「快照来自哪个提交」,问题可追溯;
  • 只复制 git 跟踪的文件、剔除 README/测试/CI 等运行时用不到的内容,保留各自的 LICENSE;
  • 快照文件永远不手改(文件头注释写明「由脚本生成,改上游仓库后重跑」),插件页面上把 commit 显示出来(歸藏设计放在左下角)。

10. 打包、发布与安装

发布为 npm 包

package.json 的 files 字段控制发布内容(别把没用的传上去):

”files”: [ ”lib”, ”client.js”, ”gallery”, ”skills”, ”scripts”, ”cordis.patch.yml” ],

然后 npm publish。安装方式(用户视角):

  • 插件页面:侧栏「插件」→「添加插件」→ 填包名 → 安装 → 点「立即启用」(只关弹窗 = 装了没开);
  • 命令行(自建网页版):dsh plugin --profile web add <包名或 github:you/your-repo>,也支持 @版本、update --latest、remove;
  • 本地调试:dsh plugin --profile web add link:$PWD;
  • 桌面版的 desktop profile 由 App 独占管理,命令行不可用,只能走插件页面。

升级语义

插件里的 skill 快照随插件版本更新(这就是 rewrite 掉「git 更新步骤」的理由)。第三方插件的升级 = 卸载重装,所以版本号和 files 清单要管理好。


11. 踩坑清单(来自真实桌面版实测)

以下是歸藏设计在真实环境(桌面版 + 真实模型)里实际翻过的车,每一条都值得在新插件里预先防住:

  1. skill 里的 git 更新步骤必败。插件里的 skill 是快照,没有 .git,模型每次都跑 git fetch 报错,再花一轮向用户解释。→ 加载时 rewrite 掉(§3.3),并用测试保证替换规则仍然命中。
  2. 空状态被当成故障汇报。语气档案为空时,模型把「还没有档案」当成检查结果报告给用户。→ 所有「空但正常」的状态文案明确写「这不是错误,照常继续,不用向用户报告」。
  3. 模型写工作区外的数据被沙箱拦下(EPERM)。→ 一切数据读写走 Host 工具(§4),skill 附注里明确「不要用 bash 或文件工具直接改数据目录」。
  4. 附注里不要让模型跑 .mjs 脚本。理由同上;歸藏设计的测试专门断言附注里不出现 .mjs。
  5. 桌面版 iframe 的新窗口链接会交给系统浏览器,那边没有 dsh 登录态,打不开。→ 页面内做查看器(弹层 iframe),不 target=_blank;保留中键/Cmd+点击的新标签行为给网页版。
  6. headless Chrome 在沙箱里启动就崩。→ Playwright 出图默认带 --no-sandbox;用 puppeteer 时记得自己加 --no-sandbox --disable-gpu;无 GPU 环境加 --use-angle=swiftshader 保证 WebGL 截图一致。
  7. 主题切换闪白。→ scheme.js 放 <head> 同步执行,首帧就是对的颜色。
  8. 模型调用工具时 JSON 写错(比如中文引语里用了未转义的英文双引号),参数在半截断开——这不是插件的问题,用户重发即可。→ 工具的 check() 把错误信息写清楚(「缺少 title」「path 要用绝对路径」),模型下一轮能自我修正。
  9. 重复登记产生重复作品。改一版再登记是高频操作。→ 同一文件/目录重复登记只更新不新增(用 root+entry 判重)。
  10. 数据目录的默认值要和宿主一致。桌面版的 home 就是 ~/.dsh——用 DSH_HOME || ~/.dsh 的默认值正好吻合;改用工具后,「模型写的目录」和「页面读的目录」由同一个进程决定,不会分叉。

12. 通用心法:skill → 插件的五个「加法」

把本教程压缩成五句话,按顺序做:

  1. 加 Provider:skill 从「你手动喂给 AI」变成「dsh 分发给模型和用户」。核心工作量在 description 和 rewrite/addendum——前者决定分发,后者决定在插件环境里不翻车。
  2. 加 Tools:把「沙箱做不到的事」(工作区外读写)提炼成一两个瘦工具,带手写参数校验、结构化输出 + 给模型的一句话 render。
  3. 加 WebServer:一个 prefix 路由 = 插件页面 + 一个 /api/catalog 读接口 + 一两个写接口。safeFile 和同源校验照抄,动态内容只放行登记过的资源。
  4. 加 Client:侧栏入口 + iframe 面板 + postMessage 开新对话(只填不发)+ 主题跟随。来源校验三连和失败回路别省。
  5. 加数据目录:$DSH_HOME/<插件名>/,原子写,一份逻辑两个入口(工具给模型、CLI 给人),空状态文案当错误处理一样认真写。

做完这五步,你的 skill 就有了入口、有了记忆、有了权限——从「一段提示词」变成了 dsh 里一个真正意义上的「功能」。


附:本仓库对应文件速查

教程章节
参考源码
§2 骨架
package.json
、cordis.patch.yml、lib/index.js
§3 Provider
lib/skills.js
(parse/readSkills/REWRITES/addendum/createSkillProvider)
§4 Tools
lib/tools.js
(check/LIBRARY_PARAMS/libraryTool/voiceTool)
§5 WebServer
lib/index.js
(safeFile/readJson/serveFile/handler)、gallery/app.js
§6 Client
lib/client.js
、gallery/scheme.js
§7 数据目录
lib/library.js
、lib/voice.js、scripts/library.mjs、scripts/voice.mjs
§8 测试
test/plugin.test.mjs
§9 vendor
tools/vendor-skills.mjs
、tools/vendor-examples.mjs、skills/manifest.json
§11 踩坑
docs/0.1.1-todo.md

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料