ARTICLE · 1304467
从 Skill 到 dsh 插件:自己动手做一个
从 Skill 到 dsh 插件:自己动手做一个
这份教程教你把「一份 SKILL.md」升级成「一个有页面、有数据、有工具的 dsh 插件」——方法完全提炼自本仓库(dsh-guizang-design v0.1.3)的真实源码,每段代码都可以直接抄去改。
配套阅读:说明书.md 讲「这个项目是什么」;本教程讲「怎么照着做一个自己的」。
0. 这份教程解决什么问题
一份好的 skill(SKILL.md + 脚本 + 模板)已经能让 AI 干活了,但它有三个天然的局限:
~/.dsh 下的数据 |
歸藏设计的做法可以浓缩成一张图(也是本教程的路线图):
┌─────────────────────────── 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-pluginname: '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 nullconst 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() 时靠它找回这份 skillresourceBase: { 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 undefinedconst 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 三条设计守则(都来自歸藏设计的实战)
工具只干「写不进去的」那件事。能复制文件、能跑脚本的活留在 skill 正文里让模型干;工具只负责碰沙箱外的数据目录。工具越瘦越好审批、越好测。 输出双通道: execute返回结构化数据(给程序),output.render返回一句话(给模型,再由模型转告用户)。render 的话术要包含「用户接下来在哪能看到结果」。空状态不是错误。比如「还没有档案」时返回一段说明「这不是错误:照常写,不用向用户报告或提问」——否则模型会把空状态当故障汇报给用户(真实踩过的坑,见第 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.resconst 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 nulllet basetry { base = realpathSync(dir) } catch { return null }const file = normalize(join(base, rel))if (!file.startsWith(base + sep) || !existsSync(file)) return nullconst 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.originif (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/ 就是普通的前端三件套,无框架也可以。核心模式两句话:
load(): fetch('api/catalog')拿全部数据 → 按location.hash渲染对应视图;所有按钮走事件委托:点「开始」→ 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) || 18return 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.currentif (!frame || e.source !== frame.contentWindow || e.origin !== location.origin) returnconst m = e.dataif (!m || m.type !== 'my-plugin:start' || typeof m.prompt !== 'string') returntry {// 开一个新对话并把草稿填进输入框(不直接发送!)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.documentElementconst set = (dark) => { root.dataset.scheme = dark ? 'dark' : 'light' }let host = nulltry { 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) returnif (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.pidwriteFileSync(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;桌面版的 desktopprofile 由 App 独占管理,命令行不可用,只能走插件页面。
升级语义
插件里的 skill 快照随插件版本更新(这就是 rewrite 掉「git 更新步骤」的理由)。第三方插件的升级 = 卸载重装,所以版本号和 files 清单要管理好。
11. 踩坑清单(来自真实桌面版实测)
以下是歸藏设计在真实环境(桌面版 + 真实模型)里实际翻过的车,每一条都值得在新插件里预先防住:
skill 里的 git 更新步骤必败。插件里的 skill 是快照,没有 .git,模型每次都跑git fetch报错,再花一轮向用户解释。→ 加载时 rewrite 掉(§3.3),并用测试保证替换规则仍然命中。空状态被当成故障汇报。语气档案为空时,模型把「还没有档案」当成检查结果报告给用户。→ 所有「空但正常」的状态文案明确写「这不是错误,照常继续,不用向用户报告」。 模型写工作区外的数据被沙箱拦下(EPERM)。→ 一切数据读写走 Host 工具(§4),skill 附注里明确「不要用 bash 或文件工具直接改数据目录」。 附注里不要让模型跑 .mjs脚本。理由同上;歸藏设计的测试专门断言附注里不出现.mjs。桌面版 iframe 的新窗口链接会交给系统浏览器,那边没有 dsh 登录态,打不开。→ 页面内做查看器(弹层 iframe),不 target=_blank;保留中键/Cmd+点击的新标签行为给网页版。headless Chrome 在沙箱里启动就崩。→ Playwright 出图默认带 --no-sandbox;用 puppeteer 时记得自己加--no-sandbox --disable-gpu;无 GPU 环境加--use-angle=swiftshader保证 WebGL 截图一致。主题切换闪白。→ scheme.js 放 <head>同步执行,首帧就是对的颜色。模型调用工具时 JSON 写错(比如中文引语里用了未转义的英文双引号),参数在半截断开——这不是插件的问题,用户重发即可。→ 工具的 check()把错误信息写清楚(「缺少 title」「path 要用绝对路径」),模型下一轮能自我修正。重复登记产生重复作品。改一版再登记是高频操作。→ 同一文件/目录重复登记只更新不新增(用 root+entry 判重)。 数据目录的默认值要和宿主一致。桌面版的 home 就是 ~/.dsh——用DSH_HOME || ~/.dsh的默认值正好吻合;改用工具后,「模型写的目录」和「页面读的目录」由同一个进程决定,不会分叉。
12. 通用心法:skill → 插件的五个「加法」
把本教程压缩成五句话,按顺序做:
加 Provider:skill 从「你手动喂给 AI」变成「dsh 分发给模型和用户」。核心工作量在 description 和 rewrite/addendum——前者决定分发,后者决定在插件环境里不翻车。 加 Tools:把「沙箱做不到的事」(工作区外读写)提炼成一两个瘦工具,带手写参数校验、结构化输出 + 给模型的一句话 render。 加 WebServer:一个 prefix 路由 = 插件页面 + 一个 /api/catalog读接口 + 一两个写接口。safeFile 和同源校验照抄,动态内容只放行登记过的资源。加 Client:侧栏入口 + iframe 面板 + postMessage 开新对话(只填不发)+ 主题跟随。来源校验三连和失败回路别省。 加数据目录: $DSH_HOME/<插件名>/,原子写,一份逻辑两个入口(工具给模型、CLI 给人),空状态文案当错误处理一样认真写。
做完这五步,你的 skill 就有了入口、有了记忆、有了权限——从「一段提示词」变成了 dsh 里一个真正意义上的「功能」。
附:本仓库对应文件速查
package.jsoncordis.patch.yml、lib/index.js | |
lib/skills.js | |
lib/tools.js | |
lib/index.jsgallery/app.js | |
lib/client.jsgallery/scheme.js | |
lib/library.jslib/voice.js、scripts/library.mjs、scripts/voice.mjs | |
test/plugin.test.mjs | |
tools/vendor-skills.mjstools/vendor-examples.mjs、skills/manifest.json | |
docs/0.1.1-todo.md |















































































