dsh-host-plugin-kit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,57 +1,66 @@
1
1
  # dsh-host-plugin-kit
2
2
 
3
- 一条命令生成 **DSH host 侧插件骨架**,自带三层验证(离线单测 / 真 cordis 冒烟 / 发布路径说明)与审批钩子模板。
3
+ DSH(DeepSeek Harness)**host 侧插件脚手架**,0.2.0 起是 **CLI + 宿主插件双形态**:
4
+ 一条命令生成带三层验证的插件骨架;装进 DSH 后还能让 agent 直接用 `plugin_kit_new` 工具搭骨架,不用开 shell。
4
5
 
5
- 面向 **Android 上的 DSH**(没有 Web Client 层,插件只能贡献 host 侧:工具、服务、事件、SKILL、MCP 桥)。
6
+ ## 为什么要插件形态
6
7
 
7
- > 名字里的 host 是刻意的:npm 上的 `dsh-host-plugin-kit` 是另一套(客户端 bundle 生成 + verify),
8
- > 与这套「host 侧工具插件骨架 + 三层测试」不是一回事。
8
+ 市场(awesome-dsh-plugin.com)的收录门槛是 `package.json` 里的 `dsh.bundle.patch`(2026-11-11 查 contributing 核实)。
9
+ 0.1.x 只有 `bin`,是脚手架 CLI、不是插件形态,市场收不了。0.2.0 把同一份生成逻辑包成 host 侧工具:
9
10
 
10
- ## 用法
11
+ - 没有 `dsh.client` —— 不依赖任何 UI slot(Android 引擎没有 Web Client 层,带 client 的装了不渲染);
12
+ - `inject` 只有 `['tools']` —— 不碰本机没有的 `webServer`(否则 cordis 会静默挂起插件);
13
+ - 数据源是本地 fs —— 生成的是源码文件,不靠 git / 端口 / 外部服务。
11
14
 
12
- ```sh
13
- node new.mjs ~/work/my-plugin --name dsh-hello --desc "示例插件" [--prefix hello]
15
+ ## 工具(插件形态)
16
+
17
+ | 工具 | 风险 | 作用 |
18
+ |---|---|---|
19
+ | `plugin_kit_new` | 写盘(走审批) | 在指定目录生成插件骨架(10 个文件),返回三层验证的跑法 |
20
+
21
+ 写类工具在 `tools/pre-execute` 里把 `allow` 升级成 `ask`,**不改写上游 `deny`**。
14
22
 
15
- cd ~/work/my-plugin
16
- node dev/test.mjs # 6 项离线单测
17
- node host-test/rt.mjs # 5 项真 cordis 冒烟(用引擎自带的那份 cordis)
23
+ ```yaml
24
+ # 装好后写进 profile 的 cordis.patch.yml(name 用包名或绝对路径,不要写 !!js)
25
+ - insert:
26
+ - id: plugin_kit
27
+ name: dsh-host-plugin-kit
28
+ config: {}
18
29
  ```
19
30
 
20
- 生成物:
31
+ ## CLI 形态
21
32
 
22
- | 文件 | 作用 |
23
- |---|---|
24
- | `index.mjs` | 注册工具(`ctx.effect` 内)、挂 `tools/pre-execute` 审批钩子 |
25
- | `lib/core.mjs` | 业务逻辑,纯函数好测 |
26
- | `cordis.patch.yml` | profile 的 insert 片段 |
27
- | `package.json` | `dsh.bundle.patch` + 最小 `capabilities` |
28
- | `dev/test.mjs` | 假 ctx 单测:注册、execute、审批 allow→ask、deny 不改写 |
29
- | `host-test/rt.mjs` | 真 `@deepseek-ai/cordis`:inject、注册、execute、钩子、`dispose` 清理 |
30
- | `locale/`、`icon.svg`、`README.md` | 插件三件套 |
33
+ ```sh
34
+ node new.mjs <目标目录> --name dsh-hello [--desc "一句话"] [--prefix hello]
35
+ # 或装到干净目录后:node node_modules/dsh-host-plugin-kit/new.mjs <目标目录> --name dsh-hello
36
+ ```
31
37
 
32
- ## 生成的骨架遵守的约定
38
+ 生成的 10 个文件:`package.json`(含 `dsh.bundle`)/ `index.mjs`(工具注册 + 审批钩子)/ `lib/core.mjs` /
39
+ `cordis.patch.yml` / `icon.svg` / `locale/zh-CN.json` / `dev/test.mjs` / `host-test/rt.mjs` / `README.md` / `.gitignore`。
33
40
 
34
- 1. `export const name / inject / apply`;
35
- 2. 工具在 `ctx.effect` 里注册并返回 dispose(卸载要干净,有测试);
36
- 3. 写类工具把 `allow` 升级为 `ask`,**上游 `deny` 不许改写**(有测试);
37
- 4. 纯逻辑放 `lib/`,`index.mjs` 只做协议适配。
41
+ 生成内容只由 `{name, desc, prefix}` 决定 → **同参数必然同字节**,回归测试直接比文件。
38
42
 
39
- ## 在这台手机上发布
43
+ ## 三层验证(本包自己也按这个跑)
40
44
 
41
- ```sh
42
- # npm:新包首发先进 staged 状态,40–120 s 后才可见;发完务必下载 tarball 实际加载验证
43
- node $HOME/tools/npm/bin/npm-cli.js publish
45
+ | 层 | 文件 | 验什么 |
46
+ |---|---|---|
47
+ | L1 离线单测 | `node dev/test.mjs` | 纯逻辑、落盘结果、工具面、审批钩子、dispose(14 项) |
48
+ | L2 真 cordis | `node $HOME/work/host-test/rt-plugin-kit.mjs` | 用引擎那份 cordis 加载:注册 / 执行 / 钩子 / dispose(5 项) |
49
+ | L3 部署检查 | `node dev/verify-patch.mjs [patch]` | 引擎同款解析器(js-yaml + `!!js` 标量)读 patch,断言 `name` 是普通字符串、引用文件都在(7 项) |
50
+ | 脚手架自测 | `node dev/bin-selftest.mjs` | 生成一个临时插件,再跑它自己的 L1 / L2(4 项) |
44
51
 
45
- # GitHub:本机没有 git,走 REST API 一次成 commit
46
- node $HOME/work/gitpub/publish-github.mjs --dir . --owner <you> --repo dsh-hello --token-file $HOME/.gh_token
47
- ```
52
+ ## 已知检查器偏差(别为绿灯改声明)
48
53
 
49
- 装插件时**带版本号**(`dsh-hello@0.1.0`),否则会遇到「裸名字 spec 永远没有更新徽标」的坑。
54
+ `dsh-standards` 的 `C-11.5` 只认 `ctx.fs` 触发的 capability 声明,本包写盘走 `node:fs`(在 `lib/scaffold.mjs`),
55
+ 所以 `fs:write` / `fs:read` **必然报 WARN**。实测:`--scope entry --entry-claim runtime` 下 10 项 PASS、
56
+ 2 项 WARN(C-11.5 上述盲点 + C-8.2 的 cordis 版本错位,本机 cordis 4.0.5 没有 `_revision` 字段)、0 项 FAIL。
57
+ 真实声明保留,不为了让检查器变绿而删。
50
58
 
51
- ## 自测
59
+ ## 契约
52
60
 
53
- ```sh
54
- node dev/bin-selftest.mjs # 生成一个临时插件并跑它的两套测试
55
- ```
61
+ - `inject: ['tools']`,工具在 `ctx.effect` 里注册并返回 dispose(写成 `() => off()` 会当场卸载);
62
+ - 写类工具只把 `allow` 升级成 `ask`,不改写 `deny`;
63
+ - `dsh.capabilities` 按实际用到的最小集合声明;
64
+ - `name` 必须是普通字符串,`!!js` 只能用在 entry 的 `config` 里求值。
56
65
 
57
66
  MIT
@@ -0,0 +1,6 @@
1
+ # 把本插件插进 profile 的层栈。App 里安装后重启引擎生效。
2
+ # name 用包名(装好后由 Loader 解析),不要写 !!js —— 表达式只在 entry 的 config 里求值。
3
+ - insert:
4
+ - id: plugin_kit
5
+ name: dsh-host-plugin-kit
6
+ config: {}
package/dev/test.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // L1 离线单测:不联网、不碰真引擎。验纯逻辑 + 落盘结果 + 插件工具面。
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { buildFiles, scaffold, NAME_RE } from '../lib/scaffold.mjs';
7
+ import * as plugin from '../index.mjs';
8
+
9
+ let pass = 0;
10
+ const fails = [];
11
+ const t = async (label, fn) => {
12
+ try { await fn(); pass++; console.log(' ok ' + label); }
13
+ catch (e) { fails.push(label); console.log(' FAIL ' + label + ' — ' + e.message); }
14
+ };
15
+
16
+ /** 找一个真能写的临时目录:先看环境变量,再退 home(手机上 /tmp 不可写)。 */
17
+ function mkTmp(tag) {
18
+ const cands = [process.env.KIT_TEST_TMP, process.env.TMPDIR, os.tmpdir(), path.join(os.homedir(), 'work', 'tmpdir')].filter(Boolean);
19
+ for (const c of cands) {
20
+ try { fs.mkdirSync(c, { recursive: true }); return fs.mkdtempSync(path.join(c, tag)); } catch {}
21
+ }
22
+ throw new Error('没有可写的临时目录');
23
+ }
24
+
25
+ const ARGS = { name: 'dsh-probe', desc: '探针插件', prefix: 'probe' };
26
+
27
+ await t('buildFiles 是纯函数:同参数两次逐字节相同', () => {
28
+ const a = buildFiles(ARGS), b = buildFiles(ARGS);
29
+ assert.deepEqual(Object.keys(a).sort(), Object.keys(b).sort());
30
+ for (const k of Object.keys(a)) assert.equal(a[k], b[k], k + ' 不一致');
31
+ });
32
+
33
+ await t('生成 10 个文件,关键文件齐全', () => {
34
+ const f = buildFiles(ARGS);
35
+ assert.equal(Object.keys(f).length, 10);
36
+ for (const k of ['package.json', 'index.mjs', 'lib/core.mjs', 'cordis.patch.yml', 'dev/test.mjs', 'host-test/rt.mjs', 'README.md', 'icon.svg', 'locale/zh-CN.json', '.gitignore']) {
37
+ assert.ok(k in f, '缺 ' + k);
38
+ }
39
+ });
40
+
41
+ await t('生成的 package.json 带 dsh.bundle.patch,且没有 dsh.client(手机渲染不了)', () => {
42
+ const pkg = JSON.parse(buildFiles(ARGS)['package.json']);
43
+ assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml');
44
+ assert.ok(Array.isArray(pkg.dsh.capabilities) && pkg.dsh.capabilities.length > 0);
45
+ assert.equal(pkg.dsh.client, undefined);
46
+ });
47
+
48
+ await t('scaffold 真落盘,并返回写出的文件清单', () => {
49
+ const dir = path.join(mkTmp('kit-l1-'), 'probe');
50
+ const r = scaffold({ target: dir, ...ARGS });
51
+ assert.equal(r.files.length, 10);
52
+ for (const rel of r.files) assert.ok(fs.existsSync(path.join(dir, rel)), rel + ' 没写出来');
53
+ assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name, 'dsh-probe');
54
+ });
55
+
56
+ await t('scaffold 拒绝覆盖已存在的插件目录', () => {
57
+ const dir = path.join(mkTmp('kit-l1-'), 'dup');
58
+ scaffold({ target: dir, ...ARGS });
59
+ assert.throws(() => scaffold({ target: dir, ...ARGS }), /已经有 package.json/);
60
+ });
61
+
62
+ await t('默认 desc / prefix:dsh-hello → 工具前缀 hello', () => {
63
+ const dir = path.join(mkTmp('kit-l1-'), 'dsh-hello');
64
+ scaffold({ target: dir, name: 'dsh-hello' });
65
+ const idx = fs.readFileSync(path.join(dir, 'index.mjs'), 'utf8');
66
+ assert.match(idx, /hello_hello/);
67
+ assert.match(idx, /hello_write/);
68
+ assert.match(fs.readFileSync(path.join(dir, 'README.md'), 'utf8'), /dsh-hello:一个 DSH 插件/);
69
+ });
70
+
71
+ await t('包名校验拒绝大写与空格', () => {
72
+ assert.equal(NAME_RE.test('dsh-good_1.2'), true);
73
+ assert.equal(NAME_RE.test('Bad Name'), false);
74
+ assert.throws(() => scaffold({ target: path.join(mkTmp('kit-l1-'), 'x'), name: 'Bad Name' }), /包名只能/);
75
+ });
76
+
77
+ await t('插件导出面:name / inject / apply', () => {
78
+ assert.equal(plugin.name, 'plugin_kit');
79
+ assert.deepEqual(plugin.inject, ['tools']);
80
+ assert.equal(typeof plugin.apply, 'function');
81
+ assert.equal(plugin.Config, undefined, '不能导出 Config');
82
+ });
83
+
84
+ const makeCtx = () => {
85
+ const registered = new Map();
86
+ const disposers = [];
87
+ const hooks = [];
88
+ const ctx = {
89
+ logger: () => ({ info() {}, warn() {} }),
90
+ effect: (fn) => { const d = fn(); if (typeof d === 'function') disposers.push(d); return d || (() => {}); },
91
+ on: (ev, fn) => { hooks.push([ev, fn]); },
92
+ tools: { register: (d) => { registered.set(d.name, d); return () => registered.delete(d.name); }, get: (n) => registered.get(n) },
93
+ };
94
+ plugin.apply(ctx, {});
95
+ return { ctx, registered, hooks, disposers };
96
+ };
97
+
98
+ await t('注册 1 个工具 plugin_kit_new,且带 risk 政策行', () => {
99
+ const { registered } = makeCtx();
100
+ assert.deepEqual([...registered.keys()], ['plugin_kit_new']);
101
+ assert.match(registered.get('plugin_kit_new').description, /\[risk: write/);
102
+ });
103
+
104
+ await t('工具真能执行:写到临时目录', async () => {
105
+ const { registered } = makeCtx();
106
+ const dir = path.join(mkTmp('kit-l1-'), 'dsh-from-tool');
107
+ const res = await registered.get('plugin_kit_new').execute({ dir, desc: '工具生成' });
108
+ const out = res.content[0].text;
109
+ assert.match(out, /已生成 10 个文件/);
110
+ assert.match(out, /dev\/test\.mjs/);
111
+ assert.ok(fs.existsSync(path.join(dir, 'cordis.patch.yml')));
112
+ });
113
+
114
+ await t('工具错误路径都变成可读文本,不抛', async () => {
115
+ const { registered } = makeCtx();
116
+ const tool = registered.get('plugin_kit_new');
117
+ assert.match((await tool.execute({})).content[0].text, /dir 不能为空/);
118
+ assert.match((await tool.execute({ dir: 'relative/path' })).content[0].text, /必须是绝对路径/);
119
+ const dir = path.join(mkTmp('kit-l1-'), 'again');
120
+ await tool.execute({ dir, desc: 'x' });
121
+ assert.match((await tool.execute({ dir, desc: 'x' })).content[0].text, /已经有 package.json/);
122
+ });
123
+
124
+ await t('~ 展开到 home', async () => {
125
+ const { registered } = makeCtx();
126
+ const rel = 'kit-l1-home-' + Date.now();
127
+ const res = await registered.get('plugin_kit_new').execute({ dir: '~/' + rel, desc: 'x' });
128
+ const dir = path.join(os.homedir(), rel);
129
+ assert.match(res.content[0].text, /已生成 10 个文件/);
130
+ assert.ok(fs.existsSync(path.join(dir, 'package.json')));
131
+ fs.rmSync(dir, { recursive: true, force: true });
132
+ });
133
+
134
+ await t('审批钩子:allow→ask(只对本工具),deny 不被动', async () => {
135
+ const { hooks } = makeCtx();
136
+ const [, fn] = hooks.find(([ev]) => ev === 'tools/pre-execute');
137
+ assert.ok(fn, '没有挂 pre-execute');
138
+ const allow = async () => ({ kind: 'allow' });
139
+ assert.equal((await fn({ name: 'plugin_kit_new', arguments: { dir: '/x' } }, allow)).kind, 'ask');
140
+ assert.equal((await fn({ name: 'plugin_kit_new', arguments: { dir: '/x' } }, async () => ({ kind: 'deny' }))).kind, 'deny');
141
+ assert.equal((await fn({ name: 'other_tool', arguments: {} }, allow)).kind, 'allow');
142
+ });
143
+
144
+ await t('dispose 后工具被摘掉', () => {
145
+ const { registered, disposers } = makeCtx();
146
+ assert.equal(registered.size, 1);
147
+ for (const d of disposers) d();
148
+ assert.equal(registered.size, 0);
149
+ });
150
+
151
+ console.log(`\n通过 ${pass} 项${fails.length ? `,失败 ${fails.length} 项:${fails.join(' / ')}` : ''}`);
152
+ process.exit(fails.length ? 1 : 0);
@@ -0,0 +1,95 @@
1
+ // L3 部署检查:用引擎同款解析器(js-yaml + !!js 标量)读本包的 cordis.patch.yml,
2
+ // 断言行里的 name 是普通字符串(写成 !!js 表达式会让引擎起不来)、引用的文件都在。
3
+ // 只读,不改任何文件。退出码:0 全过 / 1 有 FAIL / 2 环境不对(找不到引擎 js-yaml)。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+
8
+ const PKG = import.meta.dirname ? path.join(import.meta.dirname, '..') : process.cwd();
9
+ const HOME = process.env.HOME;
10
+ const DSH_HOME = process.env.DSH_HOME || (HOME ? path.join(HOME, '..', 'dsh-home') : null);
11
+ const patchPath = process.argv.slice(2).find((a) => !a.startsWith('--')) || path.join(PKG, 'cordis.patch.yml');
12
+
13
+ function engineModules() {
14
+ if (process.env.DSH_ENGINE_NODE_MODULES) return process.env.DSH_ENGINE_NODE_MODULES;
15
+ if (!DSH_HOME) return null;
16
+ const root = path.join(DSH_HOME, '..', 'engine');
17
+ let dirs = [];
18
+ try { dirs = fs.readdirSync(root); } catch { return null; }
19
+ for (const d of dirs) {
20
+ const cand = path.join(root, d, 'node_modules');
21
+ if (fs.existsSync(path.join(cand, 'js-yaml'))) return cand;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ const ENGINE = engineModules();
27
+ if (!ENGINE) {
28
+ console.error('verify-patch: 找不到引擎 node_modules(设 DSH_ENGINE_NODE_MODULES,或保证 DSH_HOME 旁边有 engine/<hash>/node_modules)');
29
+ process.exit(2);
30
+ }
31
+ const require = createRequire(path.join(ENGINE, 'js-yaml/package.json'));
32
+ const yaml = require(path.join(ENGINE, 'js-yaml'));
33
+
34
+ const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
35
+ kind: 'scalar',
36
+ resolve: (d) => typeof d === 'string',
37
+ construct: (d) => ({ __jsExpr: d }),
38
+ });
39
+
40
+ const R = [];
41
+ const ok = (name, detail = '') => R.push({ level: 'ok', name, detail });
42
+ const fail = (name, detail = '') => R.push({ level: 'fail', name, detail });
43
+
44
+ let doc = null;
45
+ try {
46
+ doc = yaml.load(fs.readFileSync(patchPath, 'utf8'), { schema: yaml.JSON_SCHEMA.extend(JsExpr) });
47
+ ok('patch 能被引擎同款解析器解析', path.basename(patchPath));
48
+ } catch (error) {
49
+ fail('patch 解析失败', String(error?.message ?? error));
50
+ }
51
+
52
+ const pkgPath = path.join(PKG, 'package.json');
53
+ let pkg = null;
54
+ try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); ok('package.json 可解析'); }
55
+ catch (error) { fail('package.json 解析失败', String(error?.message ?? error)); }
56
+
57
+ if (doc) {
58
+ if (!Array.isArray(doc)) fail('顶层必须是数组');
59
+ const rows = (Array.isArray(doc) ? doc : []).flatMap((e) => e?.insert ?? []);
60
+ if (!rows.length) fail('没有任何 insert 行');
61
+ for (const row of rows) {
62
+ const name = row?.name;
63
+ if (typeof name !== 'string') fail(`行 ${row?.id ?? '?'} 的 name 不是字符串`, JSON.stringify(name)?.slice(0, 40));
64
+ else ok(`行 ${row.id ?? '?'} 的 name 是字符串`, name);
65
+ if (row?.config && typeof row.config !== 'object') fail(`行 ${row?.id ?? '?'} 的 config 不是对象`);
66
+ }
67
+ }
68
+
69
+ if (pkg) {
70
+ const bundle = pkg.dsh?.bundle?.patch;
71
+ if (!bundle) fail('package.json 缺 dsh.bundle.patch(市场收录门槛)');
72
+ else if (!fs.existsSync(path.join(PKG, bundle))) fail('dsh.bundle.patch 指向的文件不存在', bundle);
73
+ else ok('dsh.bundle.patch 指向的文件存在', bundle);
74
+ if (pkg.dsh?.client) fail('dsh.client 存在:Android 引擎没有 Web Client 层,装了不渲染', JSON.stringify(pkg.dsh.client));
75
+ else ok('没有 dsh.client(手机侧纯 host)');
76
+ const entry = pkg.exports?.['.'] ?? pkg.main;
77
+ if (!entry) fail('没有 main / exports["."]');
78
+ else if (!fs.existsSync(path.join(PKG, entry))) fail('入口文件不存在', entry);
79
+ else ok('入口文件存在', entry);
80
+ for (const [k, v] of Object.entries(pkg.exports ?? {})) {
81
+ if (typeof v !== 'string' || k === '.' || v.startsWith('./package.json')) continue;
82
+ if (!fs.existsSync(path.join(PKG, v))) fail(`exports["${k}"] 指向的文件不存在`, v);
83
+ }
84
+ if (Array.isArray(pkg.dsh?.capabilities) && pkg.dsh.capabilities.includes('fs:write')) {
85
+ const src = fs.readFileSync(path.join(PKG, entry || 'index.mjs'), 'utf8');
86
+ if (!/writeFileSync|writeFile/.test(src) && !/writeFileSync/.test(fs.readFileSync(path.join(PKG, 'lib/scaffold.mjs'), 'utf8'))) {
87
+ fail('声明了 fs:write 但入口没看到写盘调用');
88
+ } else ok('fs:write 声明与实现一致');
89
+ }
90
+ }
91
+
92
+ for (const r of R) console.log(`${r.level === 'ok' ? 'ok ' : 'FAIL'} ${r.name}${r.detail ? ' · ' + r.detail : ''}`);
93
+ const bad = R.filter((r) => r.level === 'fail').length;
94
+ console.log(`\n${bad ? '❌' : '✅'} ${R.length - bad} 过 / ${bad} 挂`);
95
+ process.exit(bad ? 1 : 0);
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect width="64" height="64" rx="14" fill="#1f3b57"/>
3
+ <rect x="14" y="18" width="36" height="10" rx="3" fill="#fff" opacity="0.92"/>
4
+ <rect x="14" y="34" width="22" height="10" rx="3" fill="#7fd1a8"/>
5
+ <rect x="40" y="34" width="10" height="10" rx="3" fill="#f2c14e"/>
6
+ </svg>
package/index.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // dsh-host-plugin-kit —— 宿主插件形态(0.2.0 起)。
2
+ //
3
+ // 为什么要有这一层:市场收录的门槛是 package.json 里的 dsh.bundle.patch
4
+ // (2026-11-11 查 awesome-dsh-plugin 的 contributing 核实)。只有 bin CLI 的包不是插件形态,
5
+ // 市场收不了;插件化之后同一个能力在 agent 侧变成工具——不开 shell 也能"搭个插件骨架"。
6
+ //
7
+ // 为什么手机上能跑(Android 引擎没有 Web Client 层):
8
+ // · 没有 dsh.client —— 不依赖任何 UI slot,装了不会"看不见";
9
+ // · inject 只有 ['tools'] —— 不碰本机没有的 webServer(否则 cordis 会静默挂起);
10
+ // · 数据源是本地 fs —— 生成的是源码文件,不靠 git / 端口 / 外部服务。
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { scaffold } from './lib/scaffold.mjs'
14
+
15
+ export const name = 'plugin_kit'
16
+ export const inject = ['tools']
17
+
18
+ const WRITE = 'risk: write (creates files on the phone)'
19
+ const text = (t) => [{ type: 'text', text: t }]
20
+ const renderer = (_args, value) => (Array.isArray(value?.content) ? value.content : text(JSON.stringify(value, null, 2)))
21
+
22
+ /** 允许 ~ 展开;其余要求绝对路径(相对路径在引擎进程里落到哪儿不可预测)。 */
23
+ export function resolveDir(input) {
24
+ const raw = String(input ?? '').trim()
25
+ if (!raw) throw new Error('dir 不能为空(给绝对路径,或 ~/work/plugins/dsh-hello)')
26
+ if (raw === '~') return os.homedir()
27
+ if (raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(2))
28
+ if (!path.isAbsolute(raw)) throw new Error(`dir 必须是绝对路径或以 ~/ 开头(收到:${raw})`)
29
+ return raw
30
+ }
31
+
32
+ export function apply(ctx, config = {}) {
33
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('plugin_kit') : { info() {}, warn() {} }
34
+ // 不导出 Config / Schema:大写的会被 cordis 当配置 schema,注册会被静默拦下
35
+
36
+ const tool = {
37
+ name: 'plugin_kit_new',
38
+ description: `生成一个 DSH host 侧插件骨架:index.mjs(工具注册 + tools/pre-execute 审批钩子)、lib/core.mjs、cordis.patch.yml、package.json(含 dsh.bundle)、icon.svg、locale、dev/test.mjs、host-test/rt.mjs,并把三层验证的跑法打出来。\n[${WRITE}]`,
39
+ parameters: {
40
+ type: 'object',
41
+ properties: {
42
+ dir: { type: 'string', description: '目标目录:绝对路径或 ~/ 开头;目录里不能已有 package.json' },
43
+ packageName: { type: 'string', description: 'npm 包名(小写字母/数字/._-),默认取目录名' },
44
+ desc: { type: 'string', description: '一句话描述,写进 package.json 与 README' },
45
+ prefix: { type: 'string', description: '工具名前缀(生成 <prefix>_hello / <prefix>_write),默认从包名推' },
46
+ },
47
+ required: ['dir'],
48
+ },
49
+ timeoutMs: 60_000,
50
+ isConcurrencySafe: () => false,
51
+ output: { schema: {}, render: renderer },
52
+ async execute(args) {
53
+ try {
54
+ const dir = resolveDir(args?.dir)
55
+ const packageName = String(args?.packageName ?? '').trim() || path.basename(dir)
56
+ const r = scaffold({
57
+ target: dir,
58
+ name: packageName,
59
+ desc: args?.desc || undefined,
60
+ prefix: args?.prefix || undefined,
61
+ })
62
+ return {
63
+ content: text([
64
+ `已生成 ${r.files.length} 个文件 → ${r.dir}`,
65
+ `包名 ${r.packageName} · 工具前缀 ${r.prefix}`,
66
+ '',
67
+ '下一步(三层验证,缺一层就会在真机上炸):',
68
+ ` node ${r.dir}/dev/test.mjs # L1 离线单测`,
69
+ ` node ${r.dir}/host-test/rt.mjs # L2 真 cordis 冒烟`,
70
+ ' L3 改 profile patch 前跑一遍部署检查(parser 要引擎同款 js-yaml)',
71
+ ].join('\n')),
72
+ }
73
+ } catch (error) {
74
+ return { content: text(`生成失败:${error?.message ?? error}`) }
75
+ }
76
+ },
77
+ }
78
+
79
+ ctx.effect(() => {
80
+ if (ctx.tools.get?.(tool.name) !== undefined) {
81
+ logger.warn?.(`${tool.name} 已存在,跳过`)
82
+ return () => {}
83
+ }
84
+ const off = ctx.tools.register(tool)
85
+ logger.info?.(`plugin_kit: 注册 1 个工具(${tool.name})`)
86
+ return () => { try { off?.() } catch {} }
87
+ }, 'plugin_kit.tools')
88
+
89
+ // 写类工具一律走审批:只把 allow 升级成 ask,上游 deny 不许改写。
90
+ ctx.on('tools/pre-execute', async (exec, next) => {
91
+ const decision = await next()
92
+ if (exec?.name !== tool.name || decision?.kind !== 'allow') return decision
93
+ let argsText = ''
94
+ try { argsText = JSON.stringify(exec.arguments ?? {}) } catch {}
95
+ if (argsText.length > 200) argsText = argsText.slice(0, 200) + '…'
96
+ return { kind: 'ask', reason: `plugin_kit 要在手机上生成插件骨架:${argsText}` }
97
+ })
98
+ }
@@ -0,0 +1,330 @@
1
+ // dsh-plugin-kit · 脚手架核心(纯逻辑,CLI 与宿主插件共用同一份)
2
+ //
3
+ // 生成内容只由 { name, desc, prefix } 决定 → 同参数必然同字节,回归测试直接比文件。
4
+ // 这里只 import 两个 fs 函数,不读环境变量、不联网、不碰引擎,便于单测。
5
+
6
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
10
+
11
+ /** 只拼内容不落盘:返回 { 相对路径: 内容 }。 */
12
+ export function buildFiles({ name, desc, prefix }) {
13
+ const P = prefix;
14
+ const files = {};
15
+
16
+ files['package.json'] = JSON.stringify({
17
+ name, version: '0.1.0', description: desc, type: 'module', main: 'index.mjs',
18
+ exports: { '.': './index.mjs', './cordis.patch.yml': './cordis.patch.yml', './icon': './icon.svg', './package.json': './package.json' },
19
+ files: ['index.mjs', 'lib/', 'cordis.patch.yml', 'icon.svg', 'locale/', 'README.md'],
20
+ engines: { dsh: '>=0.2.0' },
21
+ license: 'MIT',
22
+ dsh: {
23
+ bundle: { patch: './cordis.patch.yml' },
24
+ capabilities: ['tools:register'],
25
+ labels: [],
26
+ },
27
+ }, null, 2) + '\n';
28
+
29
+ files['lib/core.mjs'] = `// 业务逻辑放这里,index.mjs 只做注册与协议适配(方便单测)。
30
+ export function hello(who = 'world') {
31
+ return \`hello, \${who}!\`;
32
+ }
33
+
34
+ export function echo(text) {
35
+ if (!text) throw new Error('text 不能为空');
36
+ return { echoed: String(text), length: String(text).length };
37
+ }
38
+ `;
39
+
40
+ files['index.mjs'] = `// ${name} —— 由 dsh-plugin-kit 生成。
41
+ //
42
+ // 约定(三层验证都靠它):
43
+ // 1. 导出 name / inject / apply;
44
+ // 2. 工具在 ctx.effect 里注册,返回 dispose 才能卸载干净;
45
+ // 3. 写类工具挂 tools/pre-execute 钩子,把 allow 升级成 ask(上游 deny 不许改写)。
46
+ import { hello, echo } from './lib/core.mjs';
47
+
48
+ export const name = '${P}';
49
+ export const inject = ['tools'];
50
+
51
+ const READ = 'risk: read (read-only)';
52
+ const WRITE = 'risk: write (changes the phone)';
53
+
54
+ const text = (t) => [{ type: 'text', text: t }];
55
+ const renderer = (_args, value) => (Array.isArray(value?.content) ? value.content : text(JSON.stringify(value, null, 2)));
56
+
57
+ export function apply(ctx, config = {}) {
58
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('${P}') : { info() {}, warn() {} };
59
+ const who = config.who || 'world';
60
+
61
+ const tools = [
62
+ {
63
+ name: '${P}_hello',
64
+ description: \`打个招呼(只读)。\\n[\${READ}]\`,
65
+ parameters: {
66
+ type: 'object',
67
+ properties: { who: { type: 'string', description: '称呼,默认取配置 who' } },
68
+ },
69
+ timeoutMs: 10_000,
70
+ isConcurrencySafe: () => true,
71
+ output: { schema: {}, render: renderer },
72
+ async execute(args) {
73
+ return { content: text(hello(args?.who || who)) };
74
+ },
75
+ },
76
+ {
77
+ name: '${P}_write',
78
+ description: \`示例写类工具:回显输入(真实插件里这里才真的写盘)。\\n[\${WRITE}]\`,
79
+ parameters: {
80
+ type: 'object',
81
+ properties: { text: { type: 'string', description: '要写入的文本' } },
82
+ required: ['text'],
83
+ },
84
+ timeoutMs: 30_000,
85
+ isConcurrencySafe: () => false,
86
+ output: { schema: {}, render: renderer },
87
+ async execute(args) {
88
+ const r = echo(args?.text);
89
+ return { content: text(\`写入 \${r.length} 字符:\${r.echoed}\`) };
90
+ },
91
+ },
92
+ ];
93
+
94
+ const disposeFns = [];
95
+ ctx.effect(() => {
96
+ for (const def of tools) {
97
+ if (ctx.tools.get?.(def.name) !== undefined) { logger.warn?.(\`\${def.name} 已存在,跳过\`); continue; }
98
+ try { disposeFns.push(ctx.tools.register(def)); }
99
+ catch (error) { logger.warn?.(\`注册 \${def.name} 失败:\${error?.message ?? error}\`); }
100
+ }
101
+ logger.info?.(\`${P}: 注册 \${disposeFns.length} 个工具\`);
102
+ return () => { for (const dispose of disposeFns.splice(0)) { try { dispose(); } catch {} } };
103
+ }, '${P}.tools');
104
+
105
+ const WRITE_TOOLS = new Set(['${P}_write']);
106
+ ctx.on('tools/pre-execute', async (exec, next) => {
107
+ const decision = await next();
108
+ if (!WRITE_TOOLS.has(exec.name) || decision?.kind !== 'allow') return decision;
109
+ let argsText = '';
110
+ try { argsText = JSON.stringify(exec.arguments ?? {}); } catch {}
111
+ if (argsText.length > 200) argsText = argsText.slice(0, 200) + '…';
112
+ return { kind: 'ask', reason: \`${P} 的写类工具 \${exec.name} 要改东西:\${argsText}\` };
113
+ });
114
+ }
115
+ `;
116
+
117
+ files['cordis.patch.yml'] = `# 把本插件插进 profile 的层栈。安装后重启引擎生效。
118
+ - insert:
119
+ - id: ${P}
120
+ name: ${name}
121
+ config:
122
+ who: ''
123
+ `;
124
+
125
+ files['icon.svg'] = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
126
+ <rect width="64" height="64" rx="14" fill="#2f6f4f"/>
127
+ <circle cx="32" cy="32" r="13" fill="none" stroke="#fff" stroke-width="5"/>
128
+ <circle cx="32" cy="32" r="4" fill="#fff"/>
129
+ </svg>
130
+ `;
131
+
132
+ files['locale/zh-CN.json'] = JSON.stringify({
133
+ [`${P}.name`]: desc,
134
+ [`${P}.hello`]: '打个招呼',
135
+ [`${P}.write`]: '示例写类工具',
136
+ }, null, 2) + '\n';
137
+
138
+ files['dev/test.mjs'] = `// 离线单测:不碰网络、不碰真引擎。\nimport assert from 'node:assert/strict';
139
+ import * as plugin from '../index.mjs';
140
+ import { hello, echo } from '../lib/core.mjs';
141
+
142
+ let pass = 0; const fails = [];
143
+ const t = async (label, fn) => {
144
+ try { await fn(); pass++; console.log(' ok ' + label); }
145
+ catch (e) { fails.push(label); console.log(' FAIL ' + label + ' — ' + e.message); }
146
+ };
147
+
148
+ const makeCtx = () => {
149
+ const registered = new Map();
150
+ const hooks = [];
151
+ const ctx = {
152
+ logger: () => ({ info() {}, warn() {} }),
153
+ effect: (fn) => { const d = fn(); return d || (() => {}); },
154
+ on: (ev, fn) => { hooks.push([ev, fn]); },
155
+ tools: { register: (d) => { registered.set(d.name, d); return () => registered.delete(d.name); }, get: (n) => registered.get(n) },
156
+ };
157
+ plugin.apply(ctx, {});
158
+ return { ctx, registered, hooks };
159
+ };
160
+
161
+ await t('导出 name / inject / apply', () => {
162
+ assert.equal(plugin.name, '${P}');
163
+ assert.deepEqual(plugin.inject, ['tools']);
164
+ assert.equal(typeof plugin.apply, 'function');
165
+ });
166
+
167
+ await t('纯函数可测(hello / echo)', () => {
168
+ assert.equal(hello('kit'), 'hello, kit!');
169
+ assert.deepEqual(echo('ab'), { echoed: 'ab', length: 2 });
170
+ assert.throws(() => echo(''), /不能为空/);
171
+ });
172
+
173
+ await t('注册 2 个工具', () => {
174
+ const { registered } = makeCtx();
175
+ assert.deepEqual([...registered.keys()].sort(), ['${P}_hello', '${P}_write']);
176
+ });
177
+
178
+ await t('hello 工具真能执行', async () => {
179
+ const { registered } = makeCtx();
180
+ const res = await registered.get('${P}_hello').execute({ who: 'world' });
181
+ assert.match(res.content[0].text, /hello, world!/);
182
+ });
183
+
184
+ await t('write 工具真能执行(含校验)', async () => {
185
+ const { registered } = makeCtx();
186
+ const res = await registered.get('${P}_write').execute({ text: 'abc' });
187
+ assert.match(res.content[0].text, /写入 3 字符/);
188
+ await assert.rejects(() => registered.get('${P}_write').execute({}), /不能为空/);
189
+ });
190
+
191
+ await t('写类工具走审批:allow → ask,deny 不被动', async () => {
192
+ const { hooks } = makeCtx();
193
+ const [, fn] = hooks.find(([ev]) => ev === 'tools/pre-execute');
194
+ assert.ok(fn, '没有挂 pre-execute');
195
+ assert.equal((await fn({ name: '${P}_write', arguments: { text: 'x' } }, async () => ({ kind: 'allow' }))).kind, 'ask');
196
+ assert.equal((await fn({ name: '${P}_hello', arguments: {} }, async () => ({ kind: 'allow' }))).kind, 'allow');
197
+ assert.equal((await fn({ name: '${P}_write', arguments: {} }, async () => ({ kind: 'deny' }))).kind, 'deny');
198
+ });
199
+
200
+ console.log(\`\\n通过 \${pass} 项\${fails.length ? \`,失败 \${fails.length} 项:\${fails.join(' / ')}\` : ''}\`);
201
+ process.exit(fails.length ? 1 : 0);
202
+ `;
203
+
204
+ files['host-test/rt.mjs'] = `// 真 cordis 冒烟:验证 inject 满足、注册、execute、审批钩子、dispose 清理。
205
+ // cordis 从引擎自带的 host-test 目录解析(可用 DSH_HOST_TEST_DIR 覆盖)。
206
+ import { createRequire } from 'node:module';
207
+ import { pathToFileURL } from 'node:url';
208
+ import assert from 'node:assert/strict';
209
+ import * as plugin from '../index.mjs';
210
+
211
+ const base = process.env.DSH_HOST_TEST_DIR || \`\${process.env.HOME}/work/host-test\`;
212
+ const require = createRequire(base + '/package.json');
213
+ const { Context } = await import(pathToFileURL(require.resolve('@deepseek-ai/cordis')).href);
214
+
215
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
216
+ let pass = 0, fail = 0;
217
+ const t = async (label, fn) => {
218
+ try { await fn(); pass++; console.log('PASS ' + label); }
219
+ catch (e) { fail++; console.log('FAIL ' + label + '\\n ' + e.message); }
220
+ };
221
+
222
+ const ctx = new Context();
223
+ const registered = new Map();
224
+ ctx.provide('tools', {
225
+ register(def) { registered.set(def.name, def); return () => registered.delete(def.name); },
226
+ get(n) { return registered.get(n); },
227
+ });
228
+ const hooks = [];
229
+ const origOn = ctx.on.bind(ctx);
230
+ ctx.on = (ev, fn, prepend) => { hooks.push([ev, fn]); return origOn(ev, fn, prepend); };
231
+
232
+ let fiber;
233
+ await t('真 cordis 加载插件', async () => {
234
+ fiber = ctx.plugin(plugin, {});
235
+ await sleep(120);
236
+ assert.ok(fiber);
237
+ assert.equal(plugin.inject[0], 'tools');
238
+ });
239
+
240
+ await t('2 个工具注册进 ctx.tools', () => {
241
+ assert.deepEqual([...registered.keys()].sort(), ['${P}_hello', '${P}_write']);
242
+ });
243
+
244
+ await t('hello 经 cordis 调 execute', async () => {
245
+ const res = await registered.get('${P}_hello').execute({});
246
+ assert.match(res.content[0].text, /hello,/);
247
+ });
248
+
249
+ await t('审批钩子生效', async () => {
250
+ const [, fn] = hooks.find(([ev]) => ev === 'tools/pre-execute');
251
+ assert.equal((await fn({ name: '${P}_write', arguments: { text: 'y' } }, async () => ({ kind: 'allow' }))).kind, 'ask');
252
+ });
253
+
254
+ await t('dispose 后工具被摘掉', async () => {
255
+ await fiber.dispose();
256
+ await sleep(60);
257
+ assert.equal(registered.size, 0);
258
+ });
259
+
260
+ console.log(\`\\n\${fail ? '❌' : '✅'} 通过 \${pass} 项\${fail ? \`, 失败 \${fail} 项\` : ''}\`);
261
+ process.exit(fail ? 1 : 0);
262
+ `;
263
+
264
+ files['README.md'] = `# ${name}
265
+
266
+ ${desc}
267
+
268
+ ## 工具
269
+
270
+ | 工具 | 风险 | 作用 |
271
+ |---|---|---|
272
+ | \`${P}_hello\` | 只读 | 打个招呼 |
273
+ | \`${P}_write\` | 写盘(走审批) | 示例写类工具 |
274
+
275
+ ## 开发流程(在手机上也能跑)
276
+
277
+ \`\`\`sh
278
+ node dev/test.mjs # 离线单测(不碰网络)
279
+ node host-test/rt.mjs # 真 cordis 冒烟(用引擎自带的那份 cordis)
280
+ \`\`\`
281
+
282
+ 发布:
283
+
284
+ \`\`\`sh
285
+ # npm(新包首发会先进 staged 状态,约 40–120 s 后可见;发完务必下载 tarball 实际加载验证)
286
+ node \$HOME/tools/npm/bin/npm-cli.js publish
287
+ # GitHub(本机没有 git,走 REST API)
288
+ node \$HOME/work/gitpub/publish-github.mjs --dir . --owner <you> --repo ${name} --token-file \$HOME/.gh_token
289
+ \`\`\`
290
+
291
+ ## 配置
292
+
293
+ \`\`\`yaml
294
+ - insert:
295
+ - id: ${P}
296
+ name: ${name}
297
+ config:
298
+ who: ''
299
+ \`\`\`
300
+
301
+ ## 契约
302
+
303
+ - \`inject: ['tools']\`;工具在 \`ctx.effect\` 里注册并返回 dispose;
304
+ - 写类工具在 \`tools/pre-execute\` 里把 \`allow\` 升级成 \`ask\`,**不改写上游 deny**;
305
+ - 包内 \`dsh.capabilities\` 按实际用到的最小集合声明。
306
+
307
+ MIT
308
+ `;
309
+
310
+ files['.gitignore'] = `node_modules/\n*.tgz\n`;
311
+ return files;
312
+ }
313
+
314
+ /** 校验 + 落盘。抛 Error 而不是 process.exit,好让工具层包成可读结果。 */
315
+ export function scaffold({ target, name, desc, prefix } = {}) {
316
+ if (!target || !name) throw new Error('用法: scaffold({ target, name, desc?, prefix? })');
317
+ if (!NAME_RE.test(name)) throw new Error('包名只能小写字母/数字/. _ -');
318
+ if (existsSync(join(target, 'package.json'))) throw new Error(`目标目录已经有 package.json:${target}`);
319
+ const P = prefix || name.replace(/^dsh[-/]?/, '').replace(/[-.]/g, '_') || 'demo';
320
+ const D = desc || `${name}:一个 DSH 插件`;
321
+ const files = buildFiles({ name, desc: D, prefix: P });
322
+ const written = [];
323
+ for (const [rel, content] of Object.entries(files)) {
324
+ const abs = join(target, rel);
325
+ mkdirSync(join(abs, '..'), { recursive: true });
326
+ writeFileSync(abs, content);
327
+ written.push(rel);
328
+ }
329
+ return { dir: target, packageName: name, prefix: P, files: written };
330
+ }
package/new.mjs CHANGED
@@ -1,329 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  // dsh-plugin-kit —— 一条命令生成 DSH 插件骨架(host 侧:工具 + 审批钩子 + 三层验证)。
3
3
  // 用法:node new.mjs <目标目录> --name dsh-hello [--desc "一句话"] [--prefix hello]
4
- import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
5
- import { join, basename } from 'node:path';
4
+ //
5
+ // 0.2.0 起生成逻辑在 lib/scaffold.mjs:CLI 与宿主插件(index.mjs 的 plugin_kit_new 工具)
6
+ // 共用同一份实现,避免"命令行能生成、插件里行为不一样"。
7
+ import { basename } from 'node:path';
8
+ import { scaffold } from './lib/scaffold.mjs';
6
9
 
7
10
  const argv = process.argv.slice(2);
8
11
  const arg = (k, d = null) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
9
12
  const positional = argv.filter((a, i) => !a.startsWith('--') && !(argv[i - 1] || '').startsWith('--'));
10
13
  const target = positional[0];
11
14
  const name = arg('--name') || (target ? basename(target) : null);
15
+
12
16
  if (!target || !name) {
13
17
  console.error('用法: node new.mjs <目标目录> --name dsh-hello [--desc "一句话"] [--prefix hello]');
14
18
  process.exit(2);
15
19
  }
16
- if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) { console.error('包名只能小写字母/数字/. _ -'); process.exit(2); }
17
- if (existsSync(join(target, 'package.json'))) { console.error(`目标目录已经有 package.json:${target}`); process.exit(2); }
18
-
19
- const prefix = arg('--prefix') || name.replace(/^dsh[-/]?/, '').replace(/[-.]/g, '_') || 'demo';
20
- const desc = arg('--desc') || `${name}:一个 DSH 插件`;
21
- const P = prefix;
22
- const files = {};
23
-
24
- files['package.json'] = JSON.stringify({
25
- name, version: '0.1.0', description: desc, type: 'module', main: 'index.mjs',
26
- exports: { '.': './index.mjs', './cordis.patch.yml': './cordis.patch.yml', './icon': './icon.svg', './package.json': './package.json' },
27
- files: ['index.mjs', 'lib/', 'cordis.patch.yml', 'icon.svg', 'locale/', 'README.md'],
28
- engines: { dsh: '>=0.2.0' },
29
- license: 'MIT',
30
- dsh: {
31
- bundle: { patch: './cordis.patch.yml' },
32
- capabilities: ['tools:register'],
33
- labels: [],
34
- },
35
- }, null, 2) + '\n';
36
-
37
- files['lib/core.mjs'] = `// 业务逻辑放这里,index.mjs 只做注册与协议适配(方便单测)。
38
- export function hello(who = 'world') {
39
- return \`hello, \${who}!\`;
40
- }
41
-
42
- export function echo(text) {
43
- if (!text) throw new Error('text 不能为空');
44
- return { echoed: String(text), length: String(text).length };
45
- }
46
- `;
47
-
48
- files['index.mjs'] = `// ${name} —— 由 dsh-plugin-kit 生成。
49
- //
50
- // 约定(三层验证都靠它):
51
- // 1. 导出 name / inject / apply;
52
- // 2. 工具在 ctx.effect 里注册,返回 dispose 才能卸载干净;
53
- // 3. 写类工具挂 tools/pre-execute 钩子,把 allow 升级成 ask(上游 deny 不许改写)。
54
- import { hello, echo } from './lib/core.mjs';
55
-
56
- export const name = '${P}';
57
- export const inject = ['tools'];
58
-
59
- const READ = 'risk: read (read-only)';
60
- const WRITE = 'risk: write (changes the phone)';
61
-
62
- const text = (t) => [{ type: 'text', text: t }];
63
- const renderer = (_args, value) => (Array.isArray(value?.content) ? value.content : text(JSON.stringify(value, null, 2)));
64
-
65
- export function apply(ctx, config = {}) {
66
- const logger = typeof ctx.logger === 'function' ? ctx.logger('${P}') : { info() {}, warn() {} };
67
- const who = config.who || 'world';
68
20
 
69
- const tools = [
70
- {
71
- name: '${P}_hello',
72
- description: \`打个招呼(只读)。\\n[\${READ}]\`,
73
- parameters: {
74
- type: 'object',
75
- properties: { who: { type: 'string', description: '称呼,默认取配置 who' } },
76
- },
77
- timeoutMs: 10_000,
78
- isConcurrencySafe: () => true,
79
- output: { schema: {}, render: renderer },
80
- async execute(args) {
81
- return { content: text(hello(args?.who || who)) };
82
- },
83
- },
84
- {
85
- name: '${P}_write',
86
- description: \`示例写类工具:回显输入(真实插件里这里才真的写盘)。\\n[\${WRITE}]\`,
87
- parameters: {
88
- type: 'object',
89
- properties: { text: { type: 'string', description: '要写入的文本' } },
90
- required: ['text'],
91
- },
92
- timeoutMs: 30_000,
93
- isConcurrencySafe: () => false,
94
- output: { schema: {}, render: renderer },
95
- async execute(args) {
96
- const r = echo(args?.text);
97
- return { content: text(\`写入 \${r.length} 字符:\${r.echoed}\`) };
98
- },
99
- },
100
- ];
101
-
102
- const disposeFns = [];
103
- ctx.effect(() => {
104
- for (const def of tools) {
105
- if (ctx.tools.get?.(def.name) !== undefined) { logger.warn?.(\`\${def.name} 已存在,跳过\`); continue; }
106
- try { disposeFns.push(ctx.tools.register(def)); }
107
- catch (error) { logger.warn?.(\`注册 \${def.name} 失败:\${error?.message ?? error}\`); }
108
- }
109
- logger.info?.(\`${P}: 注册 \${disposeFns.length} 个工具\`);
110
- return () => { for (const dispose of disposeFns.splice(0)) { try { dispose(); } catch {} } };
111
- }, '${P}.tools');
112
-
113
- const WRITE_TOOLS = new Set(['${P}_write']);
114
- ctx.on('tools/pre-execute', async (exec, next) => {
115
- const decision = await next();
116
- if (!WRITE_TOOLS.has(exec.name) || decision?.kind !== 'allow') return decision;
117
- let argsText = '';
118
- try { argsText = JSON.stringify(exec.arguments ?? {}); } catch {}
119
- if (argsText.length > 200) argsText = argsText.slice(0, 200) + '…';
120
- return { kind: 'ask', reason: \`${P} 的写类工具 \${exec.name} 要改东西:\${argsText}\` };
121
- });
21
+ let out;
22
+ try {
23
+ out = scaffold({ target, name, desc: arg('--desc'), prefix: arg('--prefix') });
24
+ } catch (error) {
25
+ console.error(String(error?.message ?? error));
26
+ process.exit(2);
122
27
  }
123
- `;
124
-
125
- files['cordis.patch.yml'] = `# 把本插件插进 profile 的层栈。安装后重启引擎生效。
126
- - insert:
127
- - id: ${P}
128
- name: ${name}
129
- config:
130
- who: ''
131
- `;
132
-
133
- files['icon.svg'] = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
134
- <rect width="64" height="64" rx="14" fill="#2f6f4f"/>
135
- <circle cx="32" cy="32" r="13" fill="none" stroke="#fff" stroke-width="5"/>
136
- <circle cx="32" cy="32" r="4" fill="#fff"/>
137
- </svg>
138
- `;
139
-
140
- files['locale/zh-CN.json'] = JSON.stringify({
141
- [`${P}.name`]: desc,
142
- [`${P}.hello`]: '打个招呼',
143
- [`${P}.write`]: '示例写类工具',
144
- }, null, 2) + '\n';
145
-
146
- files['dev/test.mjs'] = `// 离线单测:不碰网络、不碰真引擎。\nimport assert from 'node:assert/strict';
147
- import * as plugin from '../index.mjs';
148
- import { hello, echo } from '../lib/core.mjs';
149
-
150
- let pass = 0; const fails = [];
151
- const t = async (label, fn) => {
152
- try { await fn(); pass++; console.log(' ok ' + label); }
153
- catch (e) { fails.push(label); console.log(' FAIL ' + label + ' — ' + e.message); }
154
- };
155
-
156
- const makeCtx = () => {
157
- const registered = new Map();
158
- const hooks = [];
159
- const ctx = {
160
- logger: () => ({ info() {}, warn() {} }),
161
- effect: (fn) => { const d = fn(); return d || (() => {}); },
162
- on: (ev, fn) => { hooks.push([ev, fn]); },
163
- tools: { register: (d) => { registered.set(d.name, d); return () => registered.delete(d.name); }, get: (n) => registered.get(n) },
164
- };
165
- plugin.apply(ctx, {});
166
- return { ctx, registered, hooks };
167
- };
168
-
169
- await t('导出 name / inject / apply', () => {
170
- assert.equal(plugin.name, '${P}');
171
- assert.deepEqual(plugin.inject, ['tools']);
172
- assert.equal(typeof plugin.apply, 'function');
173
- });
174
-
175
- await t('纯函数可测(hello / echo)', () => {
176
- assert.equal(hello('kit'), 'hello, kit!');
177
- assert.deepEqual(echo('ab'), { echoed: 'ab', length: 2 });
178
- assert.throws(() => echo(''), /不能为空/);
179
- });
180
-
181
- await t('注册 2 个工具', () => {
182
- const { registered } = makeCtx();
183
- assert.deepEqual([...registered.keys()].sort(), ['${P}_hello', '${P}_write']);
184
- });
185
-
186
- await t('hello 工具真能执行', async () => {
187
- const { registered } = makeCtx();
188
- const res = await registered.get('${P}_hello').execute({ who: 'world' });
189
- assert.match(res.content[0].text, /hello, world!/);
190
- });
191
-
192
- await t('write 工具真能执行(含校验)', async () => {
193
- const { registered } = makeCtx();
194
- const res = await registered.get('${P}_write').execute({ text: 'abc' });
195
- assert.match(res.content[0].text, /写入 3 字符/);
196
- await assert.rejects(() => registered.get('${P}_write').execute({}), /不能为空/);
197
- });
198
28
 
199
- await t('写类工具走审批:allow → ask,deny 不被动', async () => {
200
- const { hooks } = makeCtx();
201
- const [, fn] = hooks.find(([ev]) => ev === 'tools/pre-execute');
202
- assert.ok(fn, '没有挂 pre-execute');
203
- assert.equal((await fn({ name: '${P}_write', arguments: { text: 'x' } }, async () => ({ kind: 'allow' }))).kind, 'ask');
204
- assert.equal((await fn({ name: '${P}_hello', arguments: {} }, async () => ({ kind: 'allow' }))).kind, 'allow');
205
- assert.equal((await fn({ name: '${P}_write', arguments: {} }, async () => ({ kind: 'deny' }))).kind, 'deny');
206
- });
207
-
208
- console.log(\`\\n通过 \${pass} 项\${fails.length ? \`,失败 \${fails.length} 项:\${fails.join(' / ')}\` : ''}\`);
209
- process.exit(fails.length ? 1 : 0);
210
- `;
211
-
212
- files['host-test/rt.mjs'] = `// 真 cordis 冒烟:验证 inject 满足、注册、execute、审批钩子、dispose 清理。
213
- // cordis 从引擎自带的 host-test 目录解析(可用 DSH_HOST_TEST_DIR 覆盖)。
214
- import { createRequire } from 'node:module';
215
- import { pathToFileURL } from 'node:url';
216
- import assert from 'node:assert/strict';
217
- import * as plugin from '../index.mjs';
218
-
219
- const base = process.env.DSH_HOST_TEST_DIR || \`\${process.env.HOME}/work/host-test\`;
220
- const require = createRequire(base + '/package.json');
221
- const { Context } = await import(pathToFileURL(require.resolve('@deepseek-ai/cordis')).href);
222
-
223
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
224
- let pass = 0, fail = 0;
225
- const t = async (label, fn) => {
226
- try { await fn(); pass++; console.log('PASS ' + label); }
227
- catch (e) { fail++; console.log('FAIL ' + label + '\\n ' + e.message); }
228
- };
229
-
230
- const ctx = new Context();
231
- const registered = new Map();
232
- ctx.provide('tools', {
233
- register(def) { registered.set(def.name, def); return () => registered.delete(def.name); },
234
- get(n) { return registered.get(n); },
235
- });
236
- const hooks = [];
237
- const origOn = ctx.on.bind(ctx);
238
- ctx.on = (ev, fn, prepend) => { hooks.push([ev, fn]); return origOn(ev, fn, prepend); };
239
-
240
- let fiber;
241
- await t('真 cordis 加载插件', async () => {
242
- fiber = ctx.plugin(plugin, {});
243
- await sleep(120);
244
- assert.ok(fiber);
245
- assert.equal(plugin.inject[0], 'tools');
246
- });
247
-
248
- await t('2 个工具注册进 ctx.tools', () => {
249
- assert.deepEqual([...registered.keys()].sort(), ['${P}_hello', '${P}_write']);
250
- });
251
-
252
- await t('hello 经 cordis 调 execute', async () => {
253
- const res = await registered.get('${P}_hello').execute({});
254
- assert.match(res.content[0].text, /hello,/);
255
- });
256
-
257
- await t('审批钩子生效', async () => {
258
- const [, fn] = hooks.find(([ev]) => ev === 'tools/pre-execute');
259
- assert.equal((await fn({ name: '${P}_write', arguments: { text: 'y' } }, async () => ({ kind: 'allow' }))).kind, 'ask');
260
- });
261
-
262
- await t('dispose 后工具被摘掉', async () => {
263
- await fiber.dispose();
264
- await sleep(60);
265
- assert.equal(registered.size, 0);
266
- });
267
-
268
- console.log(\`\\n\${fail ? '❌' : '✅'} 通过 \${pass} 项\${fail ? \`, 失败 \${fail} 项\` : ''}\`);
269
- process.exit(fail ? 1 : 0);
270
- `;
271
-
272
- files['README.md'] = `# ${name}
273
-
274
- ${desc}
275
-
276
- ## 工具
277
-
278
- | 工具 | 风险 | 作用 |
279
- |---|---|---|
280
- | \`${P}_hello\` | 只读 | 打个招呼 |
281
- | \`${P}_write\` | 写盘(走审批) | 示例写类工具 |
282
-
283
- ## 开发流程(在手机上也能跑)
284
-
285
- \`\`\`sh
286
- node dev/test.mjs # 离线单测(不碰网络)
287
- node host-test/rt.mjs # 真 cordis 冒烟(用引擎自带的那份 cordis)
288
- \`\`\`
289
-
290
- 发布:
291
-
292
- \`\`\`sh
293
- # npm(新包首发会先进 staged 状态,约 40–120 s 后可见;发完务必下载 tarball 实际加载验证)
294
- node \$HOME/tools/npm/bin/npm-cli.js publish
295
- # GitHub(本机没有 git,走 REST API)
296
- node \$HOME/work/gitpub/publish-github.mjs --dir . --owner <you> --repo ${name} --token-file \$HOME/.gh_token
297
- \`\`\`
298
-
299
- ## 配置
300
-
301
- \`\`\`yaml
302
- - insert:
303
- - id: ${P}
304
- name: ${name}
305
- config:
306
- who: ''
307
- \`\`\`
308
-
309
- ## 契约
310
-
311
- - \`inject: ['tools']\`;工具在 \`ctx.effect\` 里注册并返回 dispose;
312
- - 写类工具在 \`tools/pre-execute\` 里把 \`allow\` 升级成 \`ask\`,**不改写上游 deny**;
313
- - 包内 \`dsh.capabilities\` 按实际用到的最小集合声明。
314
-
315
- MIT
316
- `;
317
-
318
- files['.gitignore'] = `node_modules/\n*.tgz\n`;
319
-
320
- for (const [rel, content] of Object.entries(files)) {
321
- const abs = join(target, rel);
322
- mkdirSync(join(abs, '..'), { recursive: true });
323
- writeFileSync(abs, content);
324
- }
325
- console.log(`已生成 ${Object.keys(files).length} 个文件 → ${target}`);
29
+ console.log(`已生成 ${out.files.length} 个文件 → ${out.dir}`);
326
30
  console.log(`\n下一步:`);
327
- console.log(` cd ${target}`);
31
+ console.log(` cd ${out.dir}`);
328
32
  console.log(` node dev/test.mjs # 离线单测`);
329
33
  console.log(` node host-test/rt.mjs # 真 cordis 冒烟`);
package/package.json CHANGED
@@ -1,21 +1,44 @@
1
1
  {
2
2
  "name": "dsh-host-plugin-kit",
3
- "version": "0.1.1",
4
- "description": "DSH host 侧插件脚手架:一条命令生成带三层验证(离线单测 / 真 cordis 冒烟 / 发布脚本)的插件骨架,含审批钩子与 dispose 清理模板",
3
+ "version": "0.2.0",
4
+ "description": "DSH host 侧插件脚手架(CLI + 宿主插件双形态):一条命令生成带三层验证(离线单测 / 真 cordis 冒烟 / 部署检查)的插件骨架,并在 DSH 里提供 plugin_kit_new 工具,agent 不开 shell 也能搭骨架",
5
5
  "type": "module",
6
- "main": "new.mjs",
6
+ "main": "index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./scaffold": "./lib/scaffold.mjs",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./icon": "./icon.svg",
12
+ "./package.json": "./package.json"
13
+ },
7
14
  "bin": {
8
15
  "dsh-plugin-kit": "new.mjs"
9
16
  },
10
17
  "files": [
18
+ "index.mjs",
11
19
  "new.mjs",
12
- "dev/bin-selftest.mjs",
20
+ "lib/",
21
+ "dev/",
22
+ "cordis.patch.yml",
23
+ "icon.svg",
13
24
  "README.md"
14
25
  ],
15
26
  "engines": {
16
- "node": ">=20"
27
+ "node": ">=20",
28
+ "dsh": ">=0.2.0"
17
29
  },
18
30
  "license": "MIT",
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "capabilities": [
36
+ "tools:register",
37
+ "fs:write",
38
+ "fs:read"
39
+ ],
40
+ "labels": []
41
+ },
19
42
  "keywords": [
20
43
  "dsh",
21
44
  "dsh-plugin",
@@ -24,8 +47,8 @@
24
47
  ],
25
48
  "repository": {
26
49
  "type": "git",
27
- "url": "git+https://github.com/Verchiel666/dsh-plugin-kit.git"
50
+ "url": "git+https://github.com/Verchiel666/dsh-host-plugin-kit.git"
28
51
  },
29
- "homepage": "https://github.com/Verchiel666/dsh-plugin-kit#readme",
52
+ "homepage": "https://github.com/Verchiel666/dsh-host-plugin-kit#readme",
30
53
  "author": "Verchiel666"
31
54
  }