mingdao-harness 0.1.54

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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
package/src/session.js ADDED
@@ -0,0 +1,131 @@
1
+ // 会话持久化:JSONL 格式存放在 <mingdao-home>/sessions/。
2
+ // 每轮自动追加;mingdao --continue 载入最近一次会话。
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export function listSessions(home) {
8
+ const dir = path.join(home, 'sessions');
9
+ try {
10
+ return fs
11
+ .readdirSync(dir)
12
+ .filter((f) => f.endsWith('.jsonl'))
13
+ .map((f) => {
14
+ const file = path.join(dir, f);
15
+ return { file, name: f, mtime: fs.statSync(file).mtimeMs };
16
+ })
17
+ .sort((a, b) => b.mtime - a.mtime);
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ export function latestSession(home) {
24
+ return listSessions(home)[0] || null;
25
+ }
26
+
27
+ export function createSession(home) {
28
+ const dir = path.join(home, 'sessions');
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
31
+ const rand = Math.random().toString(36).slice(2, 6);
32
+ const file = path.join(dir, `${stamp}-${rand}.jsonl`);
33
+ return { file, name: path.basename(file) };
34
+ }
35
+
36
+ export function appendMessages(file, messages) {
37
+ if (!messages?.length) return;
38
+ const lines = messages.map((m) => JSON.stringify(m)).join('\n') + '\n';
39
+ fs.appendFileSync(file, lines);
40
+ }
41
+
42
+ // 整文件原子重写:自动压缩后把会话文件同步为压缩形态(否则每次加载历史都会重新触发压缩)
43
+ export function rewriteSession(file, messages) {
44
+ const lines = messages.map((m) => JSON.stringify(m)).join('\n');
45
+ const tmp = file + '.tmp';
46
+ fs.writeFileSync(tmp, lines + (lines ? '\n' : ''), { mode: 0o600 });
47
+ fs.renameSync(tmp, file);
48
+ }
49
+
50
+ export function loadSession(file) {
51
+ const raw = fs.readFileSync(file, 'utf8');
52
+ const messages = [];
53
+ for (const line of raw.split('\n')) {
54
+ if (!line.trim()) continue;
55
+ try {
56
+ const m = JSON.parse(line);
57
+ if (m && typeof m === 'object' && ['system', 'user', 'assistant', 'tool'].includes(m.role)) {
58
+ messages.push(m);
59
+ }
60
+ } catch {}
61
+ }
62
+ return { file, messages };
63
+ }
64
+
65
+ // 会话预览:第一条用户消息(用于会话选择器)
66
+ export function sessionPreview(file) {
67
+ try {
68
+ const raw = fs.readFileSync(file, 'utf8');
69
+ for (const line of raw.split('\n')) {
70
+ if (!line.trim()) continue;
71
+ const m = JSON.parse(line);
72
+ if (!m || typeof m !== 'object') continue; // 审计 B4:null/非对象行跳过而非中断预览
73
+ if (m.role === 'user') {
74
+ const s = String(m.content).split('\n')[0].trim().slice(0, 60);
75
+ return s || '(空消息)';
76
+ }
77
+ }
78
+ return '(无内容)';
79
+ } catch {
80
+ return '(无法读取)';
81
+ }
82
+ }
83
+
84
+ export function relativeTime(mtime) {
85
+ const diff = Date.now() - mtime;
86
+ if (diff < 60000) return '刚刚';
87
+ if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
88
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
89
+ return `${Math.floor(diff / 86400000)} 天前`;
90
+ }
91
+
92
+ // 全文检索历史会话(P3-2 索引化):增量词表倒排 + AND 匹配(中文 bigram),
93
+ // 只对命中的少数文件读原文生成片段;未命中/无关键词时回退列表。
94
+ import { syncSessionIndex, tokenize, extractSessionText } from './session-index.js';
95
+
96
+ export function searchSessions(home, keyword, { limit = 20 } = {}) {
97
+ const kw = String(keyword ?? '').trim();
98
+ if (!kw) return listSessions(home).slice(0, limit);
99
+ const sessions = listSessions(home);
100
+ const idx = syncSessionIndex(home, sessions);
101
+ const qTerms = [...tokenize(kw).keys()];
102
+ if (!qTerms.length) return [];
103
+ const lower = kw.toLowerCase();
104
+ const out = [];
105
+ for (const s of sessions) {
106
+ if (out.length >= limit) break;
107
+ const e = idx.files[s.name];
108
+ if (!e?.terms) continue;
109
+ let hit = true;
110
+ for (const t of qTerms) {
111
+ if (!e.terms[t]) {
112
+ hit = false;
113
+ break;
114
+ }
115
+ }
116
+ if (!hit) continue;
117
+ // 命中(数量受 limit 约束):读原文生成干净片段
118
+ let snippet = '';
119
+ try {
120
+ const text = extractSessionText(s.file);
121
+ const at = text.toLowerCase().indexOf(lower);
122
+ if (at !== -1) {
123
+ snippet = `…${text.slice(Math.max(0, at - 30), at + kw.length + 50).replace(/\s+/g, ' ').trim()}…`;
124
+ } else {
125
+ snippet = `…${text.slice(0, 80).replace(/\s+/g, ' ').trim()}…`;
126
+ }
127
+ } catch {}
128
+ out.push({ ...s, snippet });
129
+ }
130
+ return out;
131
+ }
@@ -0,0 +1,350 @@
1
+ // 技能库(借鉴 WorkBuddy 技能市场「搜索 + 一键安装」的思路):
2
+ // - 内置技能库 <安装包>/skills-lib/:预置常用技能,按需安装,不默认注入系统提示
3
+ // - 安装来源四选一(自动识别):
4
+ // · 库名 → 从内置库复制(mingdao skill install sql)
5
+ // · 本地目录 → 目录内含 SKILL.md 即整体复制
6
+ // · 远程 URL → http(s) 下载单个 SKILL.md(.md 结尾)
7
+ // · git 仓库 → git clone --depth 1 后扫描含 SKILL.md 的目录
8
+ // - 统一安装到用户级 <home>/skills/<name>/:可编辑、可删除、同名覆盖内置(用户级优先级最高)
9
+ // - 每个技能写 .mingdao-source.json 记录来源,供卸载 / 重新安装(update)使用
10
+ // - 安装后自动进入系统提示技能清单,Agent 按需用 skill 工具加载全文(渐进式披露)
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import crypto from 'node:crypto';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { mingdaoHome, ensureHome } from './config.js';
19
+
20
+ const LIB_DIR = fileURLToPath(new URL('../skills-lib', import.meta.url));
21
+
22
+ // ---------- 完整性(P3-3):技能目录联合哈希 ----------
23
+ // 对目录内全部文件(排除 .mingdao-source.json 自身)按相对路径排序,
24
+ // 逐文件 sha256 后拼成 "rel:hash\n" 再取一次 sha256,作为目录内容指纹。
25
+ export function skillDirHash(dir) {
26
+ const out = [];
27
+ const walk = (d, base) => {
28
+ let entries;
29
+ try {
30
+ entries = fs.readdirSync(d, { withFileTypes: true });
31
+ } catch {
32
+ return;
33
+ }
34
+ for (const e of entries) {
35
+ const full = path.join(d, e.name);
36
+ const rel = path.join(base, e.name).split(path.sep).join('/');
37
+ if (rel === '.mingdao-source.json') continue;
38
+ if (e.isDirectory()) walk(full, rel);
39
+ else if (e.isFile()) out.push([rel, crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')]);
40
+ }
41
+ };
42
+ walk(dir, '');
43
+ out.sort((a, b) => a[0].localeCompare(b[0]));
44
+ return crypto
45
+ .createHash('sha256')
46
+ .update(out.map(([rel, h]) => `${rel}:${h}`).join('\n'))
47
+ .digest('hex');
48
+ }
49
+
50
+ export function readSourceMeta(dir) {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(path.join(dir, '.mingdao-source.json'), 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export function writeSourceMeta(dir, meta) {
59
+ fs.writeFileSync(path.join(dir, '.mingdao-source.json'), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 });
60
+ }
61
+
62
+ // 显式信任当前内容(用户改过 registry/library 安装的技能后重新记录指纹)
63
+ export function trustSkill(name) {
64
+ const target = path.join(userSkillsDir(), name);
65
+ const meta = readSourceMeta(target);
66
+ if (!meta) return { error: `技能 ${name} 没有来源记录(无需 trust)` };
67
+ meta.sha256 = skillDirHash(target);
68
+ meta.trustedAt = Date.now();
69
+ writeSourceMeta(target, meta);
70
+ return { ok: true, name, sha256: meta.sha256.slice(0, 16) };
71
+ }
72
+
73
+ export function skillLibDir() {
74
+ return LIB_DIR;
75
+ }
76
+
77
+ export function userSkillsDir() {
78
+ return path.join(mingdaoHome(), 'skills');
79
+ }
80
+
81
+ // 从技能目录读取 name / description(frontmatter 优先,回退标题/目录名)
82
+ function readSkillMeta(dir) {
83
+ try {
84
+ const text = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8');
85
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
86
+ let name = '';
87
+ let description = '';
88
+ if (fm) {
89
+ name = fm[1].match(/^name:\s*(.+)$/m)?.[1]?.trim() || '';
90
+ description = fm[1].match(/^description:\s*(.+)$/m)?.[1]?.trim() || '';
91
+ }
92
+ if (!name) name = path.basename(dir);
93
+ if (!description) description = text.match(/^#\s+(.+)$/m)?.[1]?.trim() || '';
94
+ return { name, description };
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export function installedUserSkillNames() {
101
+ const out = new Set();
102
+ let entries;
103
+ try {
104
+ entries = fs.readdirSync(userSkillsDir(), { withFileTypes: true });
105
+ } catch {
106
+ return out;
107
+ }
108
+ for (const e of entries) {
109
+ if (!e.isDirectory()) continue;
110
+ try {
111
+ if (fs.statSync(path.join(userSkillsDir(), e.name, 'SKILL.md')).isFile()) out.add(e.name);
112
+ } catch {}
113
+ }
114
+ return out;
115
+ }
116
+
117
+ export function libraryList() {
118
+ const installed = installedUserSkillNames();
119
+ const out = [];
120
+ let entries;
121
+ try {
122
+ entries = fs.readdirSync(LIB_DIR, { withFileTypes: true });
123
+ } catch {
124
+ return out;
125
+ }
126
+ for (const e of entries) {
127
+ if (!e.isDirectory()) continue;
128
+ const meta = readSkillMeta(path.join(LIB_DIR, e.name));
129
+ if (!meta) continue;
130
+ out.push({ ...meta, dir: path.join(LIB_DIR, e.name), installed: installed.has(meta.name) });
131
+ }
132
+ return out.sort((a, b) => a.name.localeCompare(b.name));
133
+ }
134
+
135
+ export function searchLibrary(kw) {
136
+ const k = String(kw || '').trim().toLowerCase();
137
+ return libraryList().filter(
138
+ (s) => !k || s.name.toLowerCase().includes(k) || (s.description || '').toLowerCase().includes(k)
139
+ );
140
+ }
141
+
142
+ // 安装前 dry-run 校验 SKILL.md 格式(借鉴 npm 打包前的格式检查思路):
143
+ // - 必须含合法 frontmatter(--- 起止)
144
+ // - name:字母/数字/连字符/点/下划线,1–64 位
145
+ // - description:非空且 ≤ 200 字
146
+ // 校验失败返回具体错误,绝不装入技能目录。
147
+ export function validateSkillMarkdown(text, hint) {
148
+ const src = hint ? `(${hint})` : '';
149
+ if (!text || !String(text).trim()) return { error: `SKILL.md 为空${src}` };
150
+ const fm = String(text).match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
151
+ if (!fm) return { error: `缺少 frontmatter(应以 --- 开头并闭合)${src}` };
152
+ const name = fm[1].match(/^name:\s*(.+)$/m)?.[1]?.trim() || '';
153
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name)) {
154
+ return { error: `frontmatter.name 非法:${name || '(缺失)'}(允许字母/数字/点/连字符/下划线,1–64 位)${src}` };
155
+ }
156
+ const desc = fm[1].match(/^description:\s*(.+)$/m)?.[1]?.trim() || '';
157
+ if (!desc) return { error: `frontmatter.description 缺失或为空${src}` };
158
+ if (desc.length > 200) return { error: `frontmatter.description 超过 200 字(当前 ${desc.length})${src}` };
159
+ return { ok: true, name, description: desc };
160
+ }
161
+
162
+ export function validateSkillDir(dir, hint) {
163
+ const skillMd = path.join(dir, 'SKILL.md');
164
+ let text;
165
+ try {
166
+ text = fs.readFileSync(skillMd, 'utf8');
167
+ } catch {
168
+ return { error: `未找到 SKILL.md:${skillMd}` };
169
+ }
170
+ return validateSkillMarkdown(text, hint);
171
+ }
172
+
173
+ function copySkillIntoUser(dir, name, source, extra) {
174
+ ensureHome();
175
+ const target = path.join(userSkillsDir(), name);
176
+ if (path.resolve(dir) === path.resolve(target)) {
177
+ // 源目录就是用户级安装位置:视为已安装,保持现状
178
+ return { name, dir: target };
179
+ }
180
+ const check = validateSkillDir(dir, name);
181
+ if (check.error) return { error: check.error };
182
+ fs.rmSync(target, { recursive: true, force: true });
183
+ fs.mkdirSync(target, { recursive: true });
184
+ fs.cpSync(dir, target, { recursive: true });
185
+ // 安装即记录内容指纹(P3-3):加载时校验,被篡改则拒绝加载
186
+ const meta = { source, installedAt: Date.now(), sha256: skillDirHash(target), ...(extra || {}) };
187
+ fs.writeFileSync(path.join(target, '.mingdao-source.json'), JSON.stringify(meta, null, 2) + '\n');
188
+ return { name, dir: target };
189
+ }
190
+
191
+ export function installFromLibrary(name) {
192
+ const found = libraryList().find((s) => s.name === name);
193
+ if (!found) return { error: `技能库中没有 ${name}(mingdao skill search 查看全部)` };
194
+ return copySkillIntoUser(found.dir, found.name, 'library', {});
195
+ }
196
+
197
+ export function installFromDir(dir) {
198
+ const abs = path.resolve(dir);
199
+ if (!fs.existsSync(path.join(abs, 'SKILL.md'))) {
200
+ return { error: `目录内未找到 SKILL.md:${abs}` };
201
+ }
202
+ const meta = readSkillMeta(abs);
203
+ if (!meta) return { error: `无法解析 SKILL.md:${abs}` };
204
+ return copySkillIntoUser(abs, meta.name, 'dir', { from: abs });
205
+ }
206
+
207
+ export async function installFromUrl(url) {
208
+ let u;
209
+ try {
210
+ u = new URL(url);
211
+ } catch {
212
+ return { error: 'URL 无效' };
213
+ }
214
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
215
+ return { error: '仅支持 http/https URL(下载单个 SKILL.md)' };
216
+ }
217
+ const ctrl = new AbortController();
218
+ const timer = setTimeout(() => ctrl.abort(), 30000);
219
+ let text;
220
+ try {
221
+ const res = await fetch(u, { signal: ctrl.signal, redirect: 'follow' });
222
+ if (!res.ok) return { error: `下载失败:HTTP ${res.status}` };
223
+ text = await res.text();
224
+ if (text.length > 512 * 1024) return { error: 'SKILL.md 超过 512KB 上限' };
225
+ } catch (e) {
226
+ return { error: `下载失败:${e.name === 'AbortError' ? '30 秒超时' : e.message}` };
227
+ } finally {
228
+ clearTimeout(timer);
229
+ }
230
+ const head = text.trim();
231
+ if (!head.startsWith('---') && !head.startsWith('# ')) {
232
+ return { error: '内容不是合法的 SKILL.md(需 frontmatter 或 # 标题开头)' };
233
+ }
234
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mingdao-skill-'));
235
+ fs.writeFileSync(path.join(tmp, 'SKILL.md'), text);
236
+ const check = validateSkillDir(tmp, url);
237
+ if (check.error) {
238
+ fs.rmSync(tmp, { recursive: true, force: true });
239
+ return { error: check.error };
240
+ }
241
+ const meta = readSkillMeta(tmp);
242
+ const r = copySkillIntoUser(tmp, meta.name, 'url', { url });
243
+ fs.rmSync(tmp, { recursive: true, force: true });
244
+ return r;
245
+ }
246
+
247
+ export function installFromGit(gitUrl) {
248
+ if (typeof gitUrl !== 'string' || gitUrl.trim().startsWith('-')) {
249
+ return { error: 'git 地址不能以 - 开头(防选项注入)' };
250
+ }
251
+ const check = spawnSync('git', ['--version'], { stdio: 'ignore' });
252
+ if (check.error || check.status !== 0) {
253
+ return { error: '未找到 git(git 仓库安装需要系统 git,可用 URL 安装单文件技能)' };
254
+ }
255
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mingdao-skill-git-'));
256
+ // -- 分隔符:gitUrl 即使形似选项也只按路径处理
257
+ const r = spawnSync('git', ['clone', '--depth', '1', '--', gitUrl, tmp], { stdio: 'ignore', timeout: 120000 });
258
+ if (r.error || r.status !== 0) {
259
+ fs.rmSync(tmp, { recursive: true, force: true });
260
+ return { error: `git clone 失败:${r.error?.message || `退出码 ${r.status}`}` };
261
+ }
262
+ const found = [];
263
+ const stack = [tmp];
264
+ while (stack.length && found.length < 20) {
265
+ const dir = stack.pop();
266
+ let entries = [];
267
+ try {
268
+ entries = fs.readdirSync(dir, { withFileTypes: true });
269
+ } catch {
270
+ continue;
271
+ }
272
+ if (entries.some((e) => e.isFile() && e.name === 'SKILL.md')) {
273
+ found.push(dir);
274
+ continue;
275
+ }
276
+ for (const e of entries) {
277
+ if (e.isDirectory() && e.name !== '.git') stack.push(path.join(dir, e.name));
278
+ }
279
+ }
280
+ if (!found.length) {
281
+ fs.rmSync(tmp, { recursive: true, force: true });
282
+ return { error: '仓库中未找到含 SKILL.md 的技能目录' };
283
+ }
284
+ ensureHome();
285
+ const installed = [];
286
+ const skipped = [];
287
+ for (const d of found) {
288
+ const meta = readSkillMeta(d);
289
+ const r = copySkillIntoUser(d, meta.name, 'git', { url: gitUrl });
290
+ if (r.error) skipped.push(`${meta.name}:${r.error}`);
291
+ else installed.push(r);
292
+ }
293
+ fs.rmSync(tmp, { recursive: true, force: true });
294
+ if (!installed.length) {
295
+ return { error: `仓库中的技能均未通过校验${skipped.length ? '(' + skipped.join(';') + ')' : ''}` };
296
+ }
297
+ return { names: installed.map((i) => i.name), dirs: installed.map((i) => i.dir), skipped };
298
+ }
299
+
300
+ export function uninstallSkill(name) {
301
+ const key = String(name).trim();
302
+ // '.'/'..' 会拼出上级目录,必须硬拒绝(正则 [A-Za-z0-9_.-]+ 会放行纯点号名)
303
+ if (key === '.' || key === '..') return { error: `技能名非法:${key}` };
304
+ const target = path.join(userSkillsDir(), key);
305
+ if (!/^[A-Za-z0-9_.-]+$/.test(key) || !fs.existsSync(path.join(target, 'SKILL.md'))) {
306
+ return { error: `用户级未安装技能 ${name}(内置技能不可卸载,可同名覆盖)` };
307
+ }
308
+ fs.rmSync(target, { recursive: true, force: true });
309
+ return { name: String(name).trim() };
310
+ }
311
+
312
+ // 按元数据来源重新安装(库:重新复制;url:重新下载;git:重新克隆)
313
+ export async function reinstallSkill(name) {
314
+ const target = path.join(userSkillsDir(), name);
315
+ let meta = {};
316
+ try {
317
+ meta = JSON.parse(fs.readFileSync(path.join(target, '.mingdao-source.json'), 'utf8'));
318
+ } catch {
319
+ return { error: `未找到安装元数据(${name} 可能是手动创建或内置技能)` };
320
+ }
321
+ if (meta.source === 'library') return installFromLibrary(name);
322
+ if (meta.source === 'url') return installFromUrl(meta.url);
323
+ if (meta.source === 'git') return installFromGit(meta.url);
324
+ if (meta.source === 'dir') return installFromDir(meta.from);
325
+ if (meta.source === 'registry') {
326
+ const { installFromRegistry } = await import('./skill-registry.js');
327
+ return installFromRegistry(name);
328
+ }
329
+ return { error: `未知来源:${meta.source}` };
330
+ }
331
+
332
+ // 统一入口:自动识别 库名 | 本地目录 | SKILL.md URL | git 仓库
333
+ export async function installSkill(arg) {
334
+ const a = String(arg || '').trim();
335
+ if (!a) return { error: '缺少参数:mingdao skill install <库名|目录|SKILL.md URL|git 仓库地址>' };
336
+ const libHit = libraryList().find((s) => s.name === a);
337
+ if (libHit) return installFromLibrary(a);
338
+ if (fs.existsSync(path.resolve(a))) return installFromDir(a);
339
+ if (/^https?:\/\//i.test(a)) {
340
+ if (/\.md(#.*)?$/i.test(a.split('?')[0])) return installFromUrl(a);
341
+ return installFromGit(a);
342
+ }
343
+ if (/^git@/.test(a)) return installFromGit(a);
344
+ if (/^[A-Za-z0-9_.-]+$/.test(a)) {
345
+ // 本地库没有:回退线上 registry(离线时报错并给出提示)
346
+ const { installFromRegistry } = await import('./skill-registry.js');
347
+ return installFromRegistry(a);
348
+ }
349
+ return { error: `无法识别安装来源:${a}(支持:技能库名 / 本地目录 / SKILL.md 的 http(s) URL / git 仓库地址)` };
350
+ }
@@ -0,0 +1,165 @@
1
+ // 技能库线上 registry 客户端(零依赖,纯 fetch + 本地缓存):
2
+ // - 默认 registry:MingDao-Harness 仓库 registry/index.json(github/gitee/gitcode 三镜像自动回退)
3
+ // - 自建 registry:设置环境变量 MINGDAO_REGISTRY_URL 指向自己的 index.json(企业内网可用)
4
+ // - 本地缓存 <home>/skill-registry-cache.json,TTL 1 小时(force 可强制刷新)
5
+ // - 安装:按索引逐文件下载 → dry-run 校验 frontmatter → 写入用户级技能目录
6
+ // (来源元数据 source=registry,mingdao skill update 可重装)
7
+
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import crypto from 'node:crypto';
12
+ import { mingdaoHome, ensureHome } from './config.js';
13
+ import { userSkillsDir, validateSkillDir, installedUserSkillNames, skillDirHash, writeSourceMeta } from './skill-lib.js';
14
+
15
+ const DEFAULT_HOSTS = [
16
+ 'https://raw.githubusercontent.com/MingDaoTCM/MingDao-Harness/main',
17
+ 'https://gitee.com/MingDaoTCM/MingDao-harness/raw/main',
18
+ 'https://gitcode.com/MingDaoTCM/MingDao-Harness/raw/main',
19
+ ];
20
+
21
+ const TTL_MS = 60 * 60 * 1000;
22
+ const MAX_FILE = 512 * 1024;
23
+
24
+ function registryBase() {
25
+ const env = process.env.MINGDAO_REGISTRY_URL;
26
+ if (env) {
27
+ const u = String(env).replace(/\/index\.json$/, '').replace(/\/+$/, '');
28
+ return { hosts: [u], isCustom: true };
29
+ }
30
+ return { hosts: DEFAULT_HOSTS, isCustom: false };
31
+ }
32
+
33
+ function cacheFile() {
34
+ return path.join(mingdaoHome(), 'skill-registry-cache.json');
35
+ }
36
+
37
+ function loadCache() {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function saveCache(data, host) {
46
+ try {
47
+ ensureHome();
48
+ fs.writeFileSync(cacheFile(), JSON.stringify({ fetchedAt: Date.now(), host, data }, null, 2) + '\n', { mode: 0o600 });
49
+ } catch {}
50
+ }
51
+
52
+ async function fetchText(url, timeoutMs = 20000, maxBytes = 2 * 1024 * 1024) {
53
+ const ctrl = new AbortController();
54
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
55
+ try {
56
+ const res = await fetch(url, { signal: ctrl.signal, redirect: 'follow' });
57
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
58
+ // Content-Length 预检:超限直接拒绝,不再全量下载进内存
59
+ const len = Number(res.headers.get('content-length'));
60
+ if (Number.isFinite(len) && len > maxBytes) throw new Error('响应超过大小上限');
61
+ const text = await res.text();
62
+ if (text.length > maxBytes) throw new Error('响应超过大小上限');
63
+ return text;
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+
69
+ // 取远端索引(缓存优先;force 强制刷新;allowNetwork=false 时只读缓存,用于 WebUI 常规加载避免阻塞)
70
+ export async function fetchRegistryIndex({ force = false, allowNetwork = true } = {}) {
71
+ const cached = loadCache();
72
+ const fresh = cached && Date.now() - cached.fetchedAt < TTL_MS;
73
+ if (!force && (fresh || !allowNetwork)) {
74
+ if (cached?.data?.skills) return { data: cached.data, host: cached.host, fromCache: true, stale: !fresh };
75
+ if (!allowNetwork) {
76
+ return { error: '尚无线上技能库缓存(点击「刷新线上」拉取,或设置 MINGDAO_REGISTRY_URL 指向自建 registry)' };
77
+ }
78
+ }
79
+ const { hosts } = registryBase();
80
+ let lastErr = null;
81
+ for (const host of hosts) {
82
+ try {
83
+ const text = await fetchText(`${host}/registry/index.json`, 8000);
84
+ const data = JSON.parse(text);
85
+ if (!Array.isArray(data.skills)) throw new Error('索引缺少 skills 数组');
86
+ saveCache(data, host);
87
+ return { data, host, fromCache: false };
88
+ } catch (e) {
89
+ lastErr = e;
90
+ }
91
+ }
92
+ // 网络失败时回退旧缓存(过期也可用)
93
+ if (cached?.data?.skills) return { data: cached.data, host: cached.host, fromCache: true, stale: true };
94
+ return { error: `无法获取线上技能库:${lastErr?.message || '网络不可达'}(可用 MINGDAO_REGISTRY_URL 指向自建 registry)` };
95
+ }
96
+
97
+ // 远端搜索(合并展示:name/description/source/installed)
98
+ export async function searchRegistry(kw, { force = false, allowNetwork = true } = {}) {
99
+ const r = await fetchRegistryIndex({ force, allowNetwork });
100
+ if (r.error) return { error: r.error };
101
+ const k = String(kw || '').trim().toLowerCase();
102
+ const installed = installedUserSkillNames();
103
+ const skills = r.data.skills
104
+ .filter((s) => !k || s.name.toLowerCase().includes(k) || (s.description || '').toLowerCase().includes(k))
105
+ .map((s) => ({ name: s.name, description: s.description, source: 'registry', installed: installed.has(s.name) }));
106
+ return { skills, host: r.host, updatedAt: r.data.updatedAt, fromCache: r.fromCache, stale: r.stale || false };
107
+ }
108
+
109
+ // 按索引安装(逐文件下载 + dry-run 校验)
110
+ export async function installFromRegistry(name) {
111
+ const r = await fetchRegistryIndex();
112
+ if (r.error) return { error: r.error };
113
+ const entry = r.data.skills.find((s) => s.name === name);
114
+ if (!entry) return { error: `线上技能库中没有 ${name}(mingdao skill search 查看)` };
115
+ if (!Array.isArray(entry.files) || !entry.files.length) return { error: `技能 ${name} 的索引缺少文件清单` };
116
+
117
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mingdao-reg-'));
118
+ try {
119
+ let verified = false; // 是否至少一个文件做了 sha256 校验(索引声明了哈希才校验)
120
+ for (const f of entry.files) {
121
+ const rel = String(f.path || '').replace(/\\/g, '/');
122
+ if (!rel || rel.includes('..') || rel.startsWith('/')) {
123
+ return { error: `技能 ${name} 的文件路径非法:${f.path}` };
124
+ }
125
+ const dest = path.join(tmp, rel);
126
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
127
+ let text;
128
+ try {
129
+ text = await fetchText(`${r.host}/skills-lib/${encodeURI(name)}/${rel.split('/').map(encodeURIComponent).join('/')}`, 30000, MAX_FILE);
130
+ } catch (e) {
131
+ return { error: `下载 ${name}/${rel} 失败:${e.message}` };
132
+ }
133
+ if (text.length > MAX_FILE) return { error: `${name}/${rel} 超过 512KB 上限` };
134
+ // 完整性校验(P3-3):索引声明 sha256 时逐文件比对,不符即拒绝安装(供应链防护)
135
+ if (f.sha256 && typeof f.sha256 === 'string') {
136
+ const got = crypto.createHash('sha256').update(text).digest('hex');
137
+ if (got !== f.sha256.toLowerCase()) {
138
+ return { error: `完整性校验失败:${name}/${rel} 与 registry 声明的 sha256 不符(文件可能被篡改或索引过期),已拒绝安装` };
139
+ }
140
+ verified = true;
141
+ }
142
+ fs.writeFileSync(dest, text);
143
+ }
144
+ const check = validateSkillDir(tmp, name);
145
+ if (check.error) return { error: check.error };
146
+ const target = path.join(userSkillsDir(), name);
147
+ if (path.resolve(tmp) !== path.resolve(target)) {
148
+ ensureHome();
149
+ fs.rmSync(target, { recursive: true, force: true });
150
+ fs.mkdirSync(target, { recursive: true });
151
+ fs.cpSync(tmp, target, { recursive: true });
152
+ }
153
+ writeSourceMeta(target, {
154
+ source: 'registry',
155
+ installedAt: Date.now(),
156
+ host: r.host,
157
+ name,
158
+ sha256: skillDirHash(target), // 安装即记录指纹:加载时校验防本地篡改
159
+ verified: Boolean(verified),
160
+ });
161
+ return { name, dir: target, host: r.host, verified: Boolean(verified) };
162
+ } finally {
163
+ fs.rmSync(tmp, { recursive: true, force: true });
164
+ }
165
+ }