@yottameta/yotta-skills-plugin 0.19.16 → 0.19.18

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.
@@ -0,0 +1,419 @@
1
+ 'use strict';
2
+ /**
3
+ * yotta-skills 技能扫描核心(零依赖 Node.js 18+)
4
+ *
5
+ * 技能生态盘点:扫描各智能体技能目录,解析 SKILL.md frontmatter,
6
+ * 建立本地技能注册表(~/.yottaskills/registry.json)。
7
+ *
8
+ * 自包含原则:本模块只依赖 Node.js 标准库,不依赖任何元技能
9
+ * (不调用元信扫描、不调用元呈渲染、不调用元忆存储);输出为
10
+ * 结构化 JSON / 简单文本,由调用方(CLI / MCP)自行呈现。
11
+ *
12
+ * 用法(由 bin/yotta-skills.js 与 scripts/yotta-skills-mcp.py 复用):
13
+ * const scan = require('../lib/skills-scan');
14
+ * const result = scan.scanRoots(scan.defaultRoots({ extraDirs, project }));
15
+ * scan.saveRegistry(result); // 增量合并写 ~/.yottaskills/registry.json
16
+ */
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ const REGISTRY_DIR_NAME = '.yottaskills';
22
+ const REGISTRY_FILE_NAME = 'registry.json';
23
+ const SKILL_FILE = 'SKILL.md';
24
+
25
+ // 智能体 -> 用户级默认技能目录(与 bin/yotta-skills.js AGENT_DIRS 同源;
26
+ // 依据 agent-skill-dir-map 权威映射,2026-08-23 核实)
27
+ const AGENT_DIRS = {
28
+ claude: { label: 'Claude Code', dirs: ['.claude/skills'] },
29
+ cursor: { label: 'Cursor', dirs: ['.cursor/skills', '.agents/skills'] },
30
+ codex: { label: 'Codex', dirs: [] }, // 特判:$CODEX_HOME/skills
31
+ gemini: { label: 'Gemini CLI', dirs: ['.gemini/skills', '.agents/skills'] },
32
+ goose: { label: 'Goose', dirs: ['.config/goose/skills', '.agents/skills'] },
33
+ amp: { label: 'Amp', dirs: ['.config/agents/skills', '.agents/skills'] },
34
+ opencode: { label: 'OpenCode', dirs: [] }, // 特判:$XDG_CONFIG_HOME
35
+ windsurf: { label: 'Windsurf', dirs: ['.codeium/windsurf/skills'] },
36
+ workbuddy: { label: 'WorkBuddy', dirs: ['.workbuddy/skills'] },
37
+ kiro: { label: 'Kiro', dirs: ['.kiro/skills'] },
38
+ trae: { label: 'Trae Code CLI', dirs: ['.traecli/skills'] },
39
+ 'trae-cn': { label: 'Trae IDE(国内)', dirs: ['.trae-cn/skills'] },
40
+ qwen: { label: 'Qwen Code', dirs: ['.qwen/skills'] },
41
+ comate: { label: 'Comate 文心快码', dirs: ['.comate/skills'] },
42
+ codebuddy: { label: 'CodeBuddy Code', dirs: ['.codebuddy/skills'] },
43
+ kimi: { label: 'Kimi Code CLI', dirs: ['.kimi/skills'] },
44
+ openclaw: { label: 'OpenClaw / QClaw', dirs: ['.openclaw/skills'] }, // 特判:$OPENCLAW_STATE_DIR
45
+ agents: { label: '通用 AGENTS.md', dirs: ['.agents/skills'] },
46
+ };
47
+
48
+ /** 解析 SKILL.md 前部 YAML frontmatter(name/version/description 单行值)。 */
49
+ function parseFrontmatter(text) {
50
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
51
+ if (!m) return null;
52
+ const fm = {};
53
+ const lines = m[1].split(/\r?\n/);
54
+ let blockKey = null;
55
+ let blockChunks = [];
56
+ for (const raw of lines) {
57
+ if (blockKey) {
58
+ if (!raw.trim()) continue; // 空行:跳过并继续 block
59
+ if (/^\s/.test(raw)) { blockChunks.push(raw.trim()); continue; }
60
+ fm[blockKey] = blockChunks.join(' ');
61
+ blockKey = null;
62
+ blockChunks = [];
63
+ }
64
+ const t = raw.trim();
65
+ if (!t || t.startsWith('#') || t.startsWith('-')) continue;
66
+ const idx = t.indexOf(':');
67
+ if (idx <= 0) continue;
68
+ const key = t.slice(0, idx).trim();
69
+ let val = t.slice(idx + 1).trim();
70
+ if (!val) continue;
71
+ if (/^[>|][-+]?$/.test(val)) {
72
+ blockKey = key; // YAML block scalar(多行 description)
73
+ blockChunks = [];
74
+ continue;
75
+ }
76
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
77
+ val = val.slice(1, -1);
78
+ }
79
+ fm[key] = val;
80
+ }
81
+ if (blockKey) fm[blockKey] = blockChunks.join(' ');
82
+ return fm;
83
+ }
84
+
85
+ /** 解析 semver(用于多副本版本选择;无法解析时返回 null)。 */
86
+ function parseSemver(value) {
87
+ const match = String(value || '').trim().match(
88
+ /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
89
+ );
90
+ if (!match) return null;
91
+ return {
92
+ major: Number(match[1]),
93
+ minor: Number(match[2]),
94
+ patch: Number(match[3]),
95
+ prerelease: match[4] ? match[4].split('.') : [],
96
+ };
97
+ }
98
+
99
+ function comparePrerelease(left, right) {
100
+ if (!left.length && !right.length) return 0;
101
+ if (!left.length) return 1;
102
+ if (!right.length) return -1;
103
+ const length = Math.max(left.length, right.length);
104
+ for (let i = 0; i < length; i++) {
105
+ if (left[i] === undefined) return -1;
106
+ if (right[i] === undefined) return 1;
107
+ const a = left[i];
108
+ const b = right[i];
109
+ const aNumeric = /^\d+$/.test(a);
110
+ const bNumeric = /^\d+$/.test(b);
111
+ if (aNumeric && bNumeric) {
112
+ const diff = Number(a) - Number(b);
113
+ if (diff) return diff;
114
+ } else if (aNumeric !== bNumeric) {
115
+ return aNumeric ? -1 : 1;
116
+ } else if (a !== b) {
117
+ return a < b ? -1 : 1;
118
+ }
119
+ }
120
+ return 0;
121
+ }
122
+
123
+ /** semver 比较:left > right 返回正数,left < right 返回负数。 */
124
+ function compareSemver(left, right) {
125
+ const a = parseSemver(left);
126
+ const b = parseSemver(right);
127
+ if (!a || !b) return 0;
128
+ for (const key of ['major', 'minor', 'patch']) {
129
+ if (a[key] !== b[key]) return a[key] - b[key];
130
+ }
131
+ return comparePrerelease(a.prerelease, b.prerelease);
132
+ }
133
+
134
+ /** 从多个安装副本中选择代表版本:优先合法版本中的最高 semver。 */
135
+ function selectRepresentativeVariant(variants) {
136
+ let selected = null;
137
+ for (const variant of variants) {
138
+ if (!parseSemver(variant.version)) continue;
139
+ if (!selected || compareSemver(variant.version, selected.version) > 0) {
140
+ selected = variant;
141
+ }
142
+ }
143
+ return selected || variants.find((variant) => variant.version) || variants[0] || null;
144
+ }
145
+
146
+ /** 扫描单个技能目录:子目录含 SKILL.md 且 frontmatter 有 name 即视为技能。 */
147
+ function scanSkillDir(dir) {
148
+ const results = [];
149
+ let entries;
150
+ try {
151
+ entries = fs.readdirSync(dir, { withFileTypes: true });
152
+ } catch (_) {
153
+ return results;
154
+ }
155
+ for (const e of entries) {
156
+ if (!e.isDirectory()) continue;
157
+ const skillDir = path.join(dir, e.name);
158
+ let text;
159
+ try {
160
+ text = fs.readFileSync(path.join(skillDir, SKILL_FILE), 'utf8');
161
+ } catch (_) {
162
+ continue;
163
+ }
164
+ const fm = parseFrontmatter(text);
165
+ if (!fm || !fm.name) continue;
166
+ results.push({
167
+ slug: String(fm.name).trim(),
168
+ version: fm.version ? String(fm.version).trim() : '',
169
+ description: fm.description ? String(fm.description).trim() : '',
170
+ source_dir: skillDir,
171
+ });
172
+ }
173
+ return results;
174
+ }
175
+
176
+ /** Codex 用户级技能目录:$CODEX_HOME/skills(默认 ~/.codex/skills)。 */
177
+ function codexUserDir() {
178
+ const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
179
+ return path.join(base, 'skills');
180
+ }
181
+
182
+ /** OpenCode 用户级技能目录:$XDG_CONFIG_HOME/opencode/skills。 */
183
+ function opencodeUserDir() {
184
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
185
+ return path.join(base, 'opencode', 'skills');
186
+ }
187
+
188
+ /** OpenClaw / QClaw 用户级技能目录:$OPENCLAW_STATE_DIR/skills(默认 ~/.openclaw/skills)。 */
189
+ function openclawUserDir() {
190
+ const base = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
191
+ return path.join(base, 'skills');
192
+ }
193
+
194
+ function resolveUserDir(rel) {
195
+ if (rel === '.codex/skills') return codexUserDir();
196
+ if (rel === '.config/opencode/skills') return opencodeUserDir();
197
+ if (rel === '.openclaw/skills') return openclawUserDir();
198
+ return path.join(os.homedir(), rel);
199
+ }
200
+
201
+ /**
202
+ * 默认扫描根(agent-skill-dir-map 用户级目录)。
203
+ * opts: { extraDirs: string[], project: boolean }
204
+ * extraDirs 为任意额外技能目录;project 开启时附加当前项目
205
+ * .agents/skills / .codex/skills。
206
+ */
207
+ function defaultRoots(opts) {
208
+ opts = opts || {};
209
+ const roots = [];
210
+ const seen = new Set();
211
+ const add = (dir, label) => {
212
+ if (!dir) return;
213
+ const norm = path.resolve(dir);
214
+ if (seen.has(norm)) return;
215
+ seen.add(norm);
216
+ roots.push({ dir: norm, label });
217
+ };
218
+ const home = os.homedir();
219
+ for (const key of Object.keys(AGENT_DIRS)) {
220
+ const info = AGENT_DIRS[key];
221
+ for (const rel of info.dirs) add(resolveUserDir(rel), info.label);
222
+ }
223
+ add(codexUserDir(), 'Codex');
224
+ add(opencodeUserDir(), 'OpenCode');
225
+ if (Array.isArray(opts.extraDirs)) {
226
+ for (const d of opts.extraDirs) add(d, '指定目录');
227
+ }
228
+ if (opts.project) {
229
+ add(path.join(process.cwd(), '.agents', 'skills'), '项目 .agents/skills');
230
+ add(path.join(process.cwd(), '.codex', 'skills'), '项目 .codex/skills');
231
+ }
232
+ return roots;
233
+ }
234
+
235
+ /**
236
+ * 扫描多根目录并去重合并。
237
+ * 同名技能(slug)合并:记录多个来源;版本不一致时记录 conflicts。
238
+ */
239
+ function scanRoots(roots) {
240
+ const map = new Map();
241
+ const scanned = [];
242
+ const errors = [];
243
+ for (const r of roots) {
244
+ if (!fs.existsSync(r.dir)) {
245
+ errors.push({ dir: r.dir, label: r.label, reason: 'missing' });
246
+ continue;
247
+ }
248
+ scanned.push({ dir: r.dir, label: r.label });
249
+ for (const s of scanSkillDir(r.dir)) {
250
+ const key = s.slug;
251
+ const variant = {
252
+ source: r.label,
253
+ dir: s.source_dir,
254
+ version: s.version || '',
255
+ description: s.description || '',
256
+ };
257
+ const item = map.get(key);
258
+ if (item) {
259
+ if (!item.sources.includes(r.label)) item.sources.push(r.label);
260
+ if (!item.source_dirs.includes(s.source_dir)) item.source_dirs.push(s.source_dir);
261
+ item.variants.push(variant);
262
+ } else {
263
+ s.sources = [r.label];
264
+ s.source_dirs = [s.source_dir];
265
+ s.variants = [variant];
266
+ map.set(key, s);
267
+ }
268
+ }
269
+ }
270
+ for (const item of map.values()) {
271
+ const representative = selectRepresentativeVariant(item.variants);
272
+ if (!representative) continue;
273
+ item.version = representative.version || '';
274
+ if (representative.description) item.description = representative.description;
275
+ item.source_dir = representative.dir || item.source_dir;
276
+ const conflicts = item.variants
277
+ .filter((variant) => variant.version && variant.version !== item.version)
278
+ .map((variant) => ({
279
+ source: variant.source,
280
+ dir: variant.dir,
281
+ version: variant.version,
282
+ }));
283
+ if (conflicts.length) item.conflicts = conflicts;
284
+ else delete item.conflicts;
285
+ }
286
+ return {
287
+ generated_at: new Date().toISOString(),
288
+ skills: Array.from(map.values()).sort((a, b) => a.slug.localeCompare(b.slug)),
289
+ scanned,
290
+ errors,
291
+ };
292
+ }
293
+
294
+ /** 注册表文件路径:~/.yottaskills/registry.json。 */
295
+ function registryPath() {
296
+ const override = String(process.env.YOTTA_SKILLS_REGISTRY_FILE || '').trim();
297
+ if (override) return path.resolve(override);
298
+ return path.join(os.homedir(), REGISTRY_DIR_NAME, REGISTRY_FILE_NAME);
299
+ }
300
+
301
+ /** 读取注册表(不存在返回 null)。 */
302
+ function readRegistry(filePath) {
303
+ const p = filePath || registryPath();
304
+ try {
305
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
306
+ } catch (_) {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * 增量合并:把扫描结果合并进注册表。
313
+ * 新增(added) / 更新(updated,版本或来源变化) / 消失(gone,上次有本次无)。
314
+ * 幂等:技能仍存在且版本来源一致则保持原记录不动。
315
+ */
316
+ function mergeRegistry(scanResult, previous) {
317
+ const prev = (previous && previous.skills) ? previous.skills : {};
318
+ const next = {};
319
+ const changes = { added: [], updated: [], gone: [] };
320
+ const now = new Date().toISOString();
321
+ const seen = new Set();
322
+ for (const s of scanResult.skills) {
323
+ seen.add(s.slug);
324
+ const old = prev[s.slug];
325
+ const variants = Array.isArray(s.variants) ? s.variants : [];
326
+ const conflicts = Array.isArray(s.conflicts) ? s.conflicts : [];
327
+ const nextRecord = old ? { ...old } : {
328
+ slug: s.slug,
329
+ first_seen: now,
330
+ };
331
+ nextRecord.version = s.version;
332
+ nextRecord.description = s.description;
333
+ nextRecord.sources = s.sources;
334
+ nextRecord.source_dirs = s.source_dirs;
335
+ nextRecord.last_seen = now;
336
+ nextRecord.status = 'known';
337
+ if (variants.length) nextRecord.variants = variants;
338
+ else delete nextRecord.variants;
339
+ if (conflicts.length) nextRecord.conflicts = conflicts;
340
+ else delete nextRecord.conflicts;
341
+ if (!old) {
342
+ next[s.slug] = nextRecord;
343
+ changes.added.push(s.slug);
344
+ } else {
345
+ const changed = old.version !== s.version
346
+ || old.description !== s.description
347
+ || JSON.stringify(old.sources || []) !== JSON.stringify(s.sources)
348
+ || JSON.stringify(old.source_dirs || []) !== JSON.stringify(s.source_dirs)
349
+ || JSON.stringify(old.variants || []) !== JSON.stringify(variants)
350
+ || JSON.stringify(old.conflicts || []) !== JSON.stringify(conflicts);
351
+ next[s.slug] = nextRecord;
352
+ if (changed) changes.updated.push(s.slug);
353
+ }
354
+ }
355
+ for (const key of Object.keys(prev)) {
356
+ if (!seen.has(key)) {
357
+ const was = prev[key];
358
+ next[key] = { ...was, last_seen: now, status: 'gone' };
359
+ if (was.status !== 'gone') changes.gone.push(key);
360
+ }
361
+ }
362
+ const registry = {
363
+ updated: now,
364
+ note: '本地技能注册表(yotta-skills --inventory / --reindex 生成)。数据不出本机。',
365
+ skills: next,
366
+ };
367
+ return { registry, changes };
368
+ }
369
+
370
+ /** 原子写注册表(tmp + rename,防写一半损坏)。 */
371
+ function saveRegistry(registry, filePath) {
372
+ const p = filePath || registryPath();
373
+ const dir = path.dirname(p);
374
+ fs.mkdirSync(dir, { recursive: true });
375
+ const tmp = p + '.tmp';
376
+ fs.writeFileSync(tmp, JSON.stringify(registry, null, 2) + '\n', 'utf8');
377
+ fs.renameSync(tmp, p);
378
+ return p;
379
+ }
380
+
381
+ /** 把注册表渲染为自包含文本表格(不依赖元呈)。 */
382
+ function formatInventory(registry, filePath) {
383
+ const skills = Object.values(registry.skills || {})
384
+ .filter((s) => s.status !== 'gone')
385
+ .sort((a, b) => a.slug.localeCompare(b.slug));
386
+ const lines = [];
387
+ lines.push('已装技能盘点(yotta-skills --inventory)');
388
+ lines.push('技能名/版本/功能/来源');
389
+ for (const s of skills) {
390
+ const ver = s.version ? 'v' + s.version : '-';
391
+ const src = (s.sources || []).join(',');
392
+ const versions = new Set((s.variants || []).map((variant) => variant.version).filter(Boolean));
393
+ const variantHint = versions.size > 1
394
+ ? ' {多副本: ' + (s.variants || [])
395
+ .filter((variant) => variant.version)
396
+ .map((variant) => (variant.source || '其他') + ' v' + variant.version)
397
+ .join(', ') + '}'
398
+ : '';
399
+ lines.push(`${s.slug} ${ver} ${s.description || ''} [${src}]${variantHint}`);
400
+ }
401
+ const rp = filePath || registryPath();
402
+ lines.push(`共 ${skills.length} 个技能;注册表:${rp}`);
403
+ return lines.join('\n');
404
+ }
405
+
406
+ module.exports = {
407
+ AGENT_DIRS,
408
+ parseFrontmatter,
409
+ scanSkillDir,
410
+ defaultRoots,
411
+ scanRoots,
412
+ registryPath,
413
+ readRegistry,
414
+ mergeRegistry,
415
+ saveRegistry,
416
+ formatInventory,
417
+ REGISTRY_DIR_NAME,
418
+ REGISTRY_FILE_NAME,
419
+ };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+ /**
3
+ * yotta-skills 受信校验器(元信)记录与身份校验(v0.19.13)。
4
+ *
5
+ * 背景:旧实现用「本地技能注册表里 slug=yotta-verify 的 source_dirs」发现校验器,
6
+ * 而注册表身份来自被扫描技能自己 SKILL.md frontmatter 的 `name` —— 任意目录只要自称
7
+ * `yotta-verify` 并提供 `scripts/yotta_verify.py`,就会被当作扫描引擎执行
8
+ * (可执行任意代码,并可伪造 `SAFE TO INSTALL` 结论)。
9
+ *
10
+ * 新模型:
11
+ * 1. 只有「安装管线校验通过后写入的安装记录」才算受信来源(路径 + SHA-256 双绑定);
12
+ * 2. 记录缺失 / 摘要不符 / 身份不符 / 路径经符号链接跳转 → 一律 fail-closed,
13
+ * 交由上层走自举安装(从 npm 重新装一份干净的元信);
14
+ * 3. 用户显式指定(`--verify` / `YOTTA_SKILLS_VERIFY`)保留为人工可信路径,不参与本校验。
15
+ *
16
+ * 零依赖:只用 Node.js 标准库。
17
+ */
18
+ const crypto = require('crypto');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+
23
+ const RECORD_FILE_NAME = 'trusted-verifier.json';
24
+ const REGISTRY_DIR_NAME = '.yottaskills';
25
+ const EXPECTED = {
26
+ slug: 'yotta-verify',
27
+ pkg: '@yottameta/yotta-verify',
28
+ trust: 'yottameta',
29
+ };
30
+
31
+ /** 安装记录路径:与注册表同目录(支持 YOTTA_SKILLS_REGISTRY_FILE 多 agent 隔离)。 */
32
+ function recordPath(registryFile) {
33
+ if (registryFile) return path.join(path.dirname(path.resolve(registryFile)), RECORD_FILE_NAME);
34
+ const override = String(process.env.YOTTA_SKILLS_REGISTRY_FILE || '').trim();
35
+ if (override) return path.join(path.dirname(path.resolve(override)), RECORD_FILE_NAME);
36
+ return path.join(os.homedir(), REGISTRY_DIR_NAME, RECORD_FILE_NAME);
37
+ }
38
+
39
+ function sha256File(file) {
40
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
41
+ }
42
+
43
+ function loadRecord(registryFile) {
44
+ try {
45
+ const value = JSON.parse(fs.readFileSync(recordPath(registryFile), 'utf8'));
46
+ return (value && typeof value === 'object') ? value : null;
47
+ } catch (_) {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function saveRecord(record, registryFile) {
53
+ const file = recordPath(registryFile);
54
+ fs.mkdirSync(path.dirname(file), { recursive: true });
55
+ fs.writeFileSync(file, JSON.stringify(record, null, 2) + '\n', 'utf8');
56
+ return file;
57
+ }
58
+
59
+ function frontmatterValue(skillFile, key) {
60
+ let text;
61
+ try {
62
+ text = fs.readFileSync(skillFile, 'utf8');
63
+ } catch (_) {
64
+ return null;
65
+ }
66
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
67
+ if (!m) return null;
68
+ const re = new RegExp('^' + key + ':\\s*(.+?)\\s*$', 'm');
69
+ const hit = m[1].match(re);
70
+ return hit ? hit[1].replace(/^["']|["']$/g, '') : null;
71
+ }
72
+
73
+ /**
74
+ * 检查候选引擎是否「长得像」一份元信包(不做信任判定):
75
+ * 路径不能经符号链接跳转;包内必须有 SKILL.md + skill-manifest.json,
76
+ * 且 slug / package / trust / 版本与 SKILL.md 一致。
77
+ */
78
+ function inspectPackage(enginePath) {
79
+ if (!enginePath) return { ok: false, reason: '未指定引擎路径' };
80
+ const resolved = path.resolve(String(enginePath));
81
+ let real;
82
+ try {
83
+ if (!fs.statSync(resolved).isFile()) return { ok: false, reason: '引擎路径不是文件' };
84
+ real = fs.realpathSync(resolved);
85
+ } catch (_) {
86
+ return { ok: false, reason: '引擎文件不存在' };
87
+ }
88
+ if (real !== resolved) return { ok: false, reason: '引擎路径经符号链接跳转,拒绝' };
89
+
90
+ const skillDir = path.dirname(path.dirname(resolved));
91
+ const skillFile = path.join(skillDir, 'SKILL.md');
92
+ const manifestFile = path.join(skillDir, 'skill-manifest.json');
93
+ if (!fs.existsSync(skillFile)) return { ok: false, reason: '缺少 SKILL.md' };
94
+ if (!fs.existsSync(manifestFile)) return { ok: false, reason: '缺少 skill-manifest.json' };
95
+
96
+ const skillName = frontmatterValue(skillFile, 'name');
97
+ if (skillName !== EXPECTED.slug) {
98
+ return { ok: false, reason: 'SKILL.md name 不是 ' + EXPECTED.slug };
99
+ }
100
+ let manifest;
101
+ try {
102
+ manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
103
+ } catch (_) {
104
+ return { ok: false, reason: 'skill-manifest.json 解析失败' };
105
+ }
106
+ if (manifest.slug !== EXPECTED.slug) return { ok: false, reason: 'manifest slug 不符' };
107
+ if (manifest.package !== EXPECTED.pkg) return { ok: false, reason: 'manifest package 不符' };
108
+ if (manifest.trust !== EXPECTED.trust) return { ok: false, reason: 'manifest trust 不符' };
109
+ const skillVersion = frontmatterValue(skillFile, 'version');
110
+ if (!skillVersion || manifest.version !== skillVersion) {
111
+ return { ok: false, reason: 'manifest 与 SKILL.md 版本不一致' };
112
+ }
113
+
114
+ return {
115
+ ok: true,
116
+ path: resolved,
117
+ reason: null,
118
+ info: {
119
+ slug: manifest.slug,
120
+ package: manifest.package,
121
+ version: manifest.version,
122
+ skillDir: skillDir,
123
+ sha256: sha256File(resolved),
124
+ },
125
+ };
126
+ }
127
+
128
+ /** 身份 + 摘要双绑定校验:options.record 为安装记录(缺失即 fail-closed)。 */
129
+ function verifyEngine(enginePath, options) {
130
+ const opts = options || {};
131
+ const inspected = inspectPackage(enginePath);
132
+ if (!inspected.ok) return inspected;
133
+ const record = opts.record;
134
+ if (opts.requireRecord === false) return inspected;
135
+ if (!record || !record.path || !record.sha256) {
136
+ return { ok: false, reason: '缺少受信安装记录(fail-closed)' };
137
+ }
138
+ if (path.resolve(String(record.path)) !== inspected.path) {
139
+ return { ok: false, reason: '安装记录路径与候选引擎不一致' };
140
+ }
141
+ if (String(record.sha256).toLowerCase() !== inspected.info.sha256.toLowerCase()) {
142
+ return { ok: false, reason: '引擎摘要与安装记录不一致' };
143
+ }
144
+ return inspected;
145
+ }
146
+
147
+ module.exports = {
148
+ RECORD_FILE_NAME,
149
+ EXPECTED,
150
+ recordPath,
151
+ sha256File,
152
+ loadRecord,
153
+ saveRecord,
154
+ inspectPackage,
155
+ verifyEngine,
156
+ };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const CACHE_VERSION = 1;
9
+ const CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
10
+ const MAX_JITTER_MS = 24 * 60 * 60 * 1000;
11
+
12
+ function emptyCache() {
13
+ return { version: CACHE_VERSION, targets: {} };
14
+ }
15
+
16
+ function cachePath(homeDir) {
17
+ return path.join(homeDir || os.homedir(), '.yottaskills', 'update-check.json');
18
+ }
19
+
20
+ function targetKey(dest) {
21
+ return crypto.createHash('sha256').update(path.resolve(dest)).digest('hex').slice(0, 24);
22
+ }
23
+
24
+ function readCache(homeDir) {
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(cachePath(homeDir), 'utf8'));
27
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return emptyCache();
28
+ if (!parsed.targets || typeof parsed.targets !== 'object' || Array.isArray(parsed.targets)) {
29
+ return emptyCache();
30
+ }
31
+ return {
32
+ version: CACHE_VERSION,
33
+ targets: { ...parsed.targets },
34
+ };
35
+ } catch (_) {
36
+ return emptyCache();
37
+ }
38
+ }
39
+
40
+ function writeCache(homeDir, cache) {
41
+ const file = cachePath(homeDir);
42
+ const dir = path.dirname(file);
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ const tmp = file + '.tmp-' + process.pid + '-' + Date.now();
45
+ fs.writeFileSync(tmp, JSON.stringify(cache, null, 2) + '\n', 'utf8');
46
+ try {
47
+ fs.renameSync(tmp, file);
48
+ } catch (_) {
49
+ fs.rmSync(file, { force: true });
50
+ fs.renameSync(tmp, file);
51
+ }
52
+ return file;
53
+ }
54
+
55
+ function isDue(record, now) {
56
+ if (!record || typeof record.next_check !== 'string') return true;
57
+ const dueAt = Date.parse(record.next_check);
58
+ if (!Number.isFinite(dueAt)) return true;
59
+ return dueAt <= Number(now);
60
+ }
61
+
62
+ function nextCheckAt(now, random) {
63
+ const base = now instanceof Date ? now.getTime() : Number(now);
64
+ const value = Math.min(1, Math.max(0, Number(typeof random === 'function' ? random() : random)));
65
+ const jitter = Math.floor((Number.isFinite(value) ? value : 0) * MAX_JITTER_MS);
66
+ return new Date(base + CHECK_INTERVAL_MS + jitter).toISOString();
67
+ }
68
+
69
+ function recordCheck(homeDir, dest, options) {
70
+ const opts = options || {};
71
+ const now = opts.now === undefined ? Date.now() : opts.now;
72
+ const checkedAt = new Date(now).toISOString();
73
+ const cache = readCache(homeDir);
74
+ const key = targetKey(dest);
75
+ const record = {
76
+ dest: path.resolve(dest),
77
+ last_checked: checkedAt,
78
+ next_check: nextCheckAt(now, opts.random),
79
+ last_result: opts.result || null,
80
+ last_error: opts.error ? String(opts.error) : null,
81
+ };
82
+ cache.targets[key] = record;
83
+ writeCache(homeDir, cache);
84
+ return record;
85
+ }
86
+
87
+ module.exports = {
88
+ CACHE_VERSION,
89
+ CHECK_INTERVAL_MS,
90
+ MAX_JITTER_MS,
91
+ cachePath,
92
+ emptyCache,
93
+ isDue,
94
+ nextCheckAt,
95
+ readCache,
96
+ recordCheck,
97
+ targetKey,
98
+ writeCache,
99
+ };