@tanlinfeng/project-docs-workflow 2.3.0 → 3.0.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/bin/install.js CHANGED
@@ -15,13 +15,14 @@ const MANIFEST_NAME = ".project-docs-workflow.install.json";
15
15
  const PKG_VERSION = require(path.join(PKG_ROOT, "package.json")).version;
16
16
 
17
17
  // 可选上游:mattpocock/skills 的完整版依赖 skill。
18
- // 官方安装器是 vercel-labs/skills 提供的 `skills` CLI(npm 包 skills),
19
- // 固定以 `-a <agent> -g` 安装到该 agent 的全局目录(codex 为 ~/.agents/skills)。
18
+ // 官方安装器是 vercel-labs/skills 提供的 `skills` CLI(npm 包 skills),以 `-a <agent> -g`
19
+ // 安装到该 agent 的全局目录(codex 为 ~/.agents/skills);它不支持 --dir,因此只有能证明目标
20
+ // 就是 codex 默认全局目录时才允许调用上游。
20
21
  const UPSTREAM_REPO = "mattpocock/skills";
21
22
  const UPSTREAM_DEFAULT_AGENT = "codex";
22
23
  // 上游安装器要求 Node >= 22.20(本项目不引入 semver 依赖,手工比较 major/minor)。
23
24
  const UPSTREAM_MIN_NODE = { major: 22, minor: 20 };
24
- // 仅这 8 个依赖 skill 属于上游集合;doc-opt、docs-html-view 与主 skill 不属于。
25
+ // 仅这 8 个依赖 skill 受上游支持;doc-opt、docs-html-view 与主 skill 不属于。
25
26
  const MATT_DEP_NAMES = [
26
27
  "grilling",
27
28
  "grill-me",
@@ -32,7 +33,39 @@ const MATT_DEP_NAMES = [
32
33
  "retro",
33
34
  "improve-codebase-architecture",
34
35
  ];
35
- const MATT_DEP_SET = new Set(MATT_DEP_NAMES);
36
+
37
+ // 第一方 skill:本包自带、随包版本分发的 skill(仅此三项)。这是一份**显式名单**——未列出的
38
+ // 目录一律不是第一方,也不随包分发(未知包内目录仅提示忽略)。外部依赖(8 个上游名)不随包
39
+ // 分发:默认保留已存在的独立安装,缺失时使用主 skill 内部回退文档(references/deps);只有
40
+ // --with-matt-skills 才通过 npx 安装/更新独立完整版。kind 与名单同源。
41
+ const FIRST_PARTY_DEP_KINDS = {
42
+ "doc-opt": "doc-opt",
43
+ "docs-html-view": "docs-html-view",
44
+ };
45
+ const FIRST_PARTY_NAMES = [MAIN_SKILL].concat(Object.keys(FIRST_PARTY_DEP_KINDS));
46
+ const FIRST_PARTY_SET = new Set(FIRST_PARTY_NAMES);
47
+ // 清单里认可的「本包本地安装」kind(含历史 kind:分类前 doc-opt 可能被记作 lite)。
48
+ const KNOWN_LOCAL_KINDS = new Set(["main", "lite", "doc-opt", "docs-html-view"]);
49
+
50
+ function depKind(name) {
51
+ return FIRST_PARTY_DEP_KINDS[name] || "lite";
52
+ }
53
+
54
+ function kindFor(name) {
55
+ return name === MAIN_SKILL ? "main" : depKind(name);
56
+ }
57
+
58
+ // 保守判定清单归属:只有明确记录为本包本地安装(installed/forced 且 kind 为已知本地 kind,
59
+ // 非 upstream)才算「已接管」。缺失/损坏/未知/skipped 一律不接管(改为保留)。
60
+ function isOwnedLocal(entry) {
61
+ if (!entry || typeof entry !== "object") {
62
+ return false;
63
+ }
64
+ if (entry.action !== "installed" && entry.action !== "forced") {
65
+ return false;
66
+ }
67
+ return KNOWN_LOCAL_KINDS.has(entry.kind);
68
+ }
36
69
 
37
70
  // 依赖 skill 的按名解析与 ref 兜底映射。
38
71
  // 顺序:先按名解析 <target>/<name>/SKILL.md,其次尝试 ref 兜底文件。
@@ -57,28 +90,41 @@ function usage() {
57
90
  [
58
91
  "project-docs-workflow 安装器",
59
92
  "",
93
+ "用法说明:运行 `npx @tanlinfeng/project-docs-workflow@<version> install` 选择安装器/源码版本",
94
+ "(`npm i` 只是把包装进 node_modules,不会运行本工具)。所选旧版本自带它当时的安装器,",
95
+ "行为可能与当前不同,这里只保证兼容、不承诺升级。",
96
+ "普通安装只同步本包自带的第一方 skill(project-docs-workflow、doc-opt、docs-html-view):",
97
+ "缺失则安装;已由本包接管则用当前包内版本全量替换(会移除其中多余/过期文件);",
98
+ "未接管条目、符号链接(含悬空)默认保留。外部依赖不再随包分发:默认保留已存在的独立安装,",
99
+ "缺失时使用主 skill 内部回退文档(references/deps),功能可用、无需独立目录。",
100
+ "可选:--with-matt-skills 通过 npx 安装/更新独立完整版(默认不安装、不复制)。源项目文档不受影响。",
101
+ "",
60
102
  "用法:",
61
- " project-docs-workflow [install] [选项] 安装 skill 到目标目录(默认动作)",
103
+ " project-docs-workflow [install] [选项] 安装/同步包内第一方 skill(默认动作)",
62
104
  " project-docs-workflow check [选项] 检查依赖 skill 的解析状态",
63
105
  " project-docs-workflow uninstall [选项] 依据安装清单卸载本包安装的 skill",
64
106
  "",
65
107
  "选项:",
66
108
  " --dir <path> 指定目标 skills 根目录(默认 ~/.agents/skills)",
67
- " --force 覆盖已存在的 project-docs-workflow(仅主 skill;依赖仍不覆盖);",
68
- " uninstall 时为非交互确认(非 TTY 下必须显式传入)",
69
- " --dry-run 仅打印将执行的操作,不实际写入",
109
+ " --force 安装:覆盖并接管所有第一方 skill(含替换已存在的符号链接本身,不动其指向);",
110
+ " 不影响外部依赖;uninstall:非交互确认(非 TTY 下必须显式传入)",
111
+ " --dry-run 仅打印将执行的操作(与真实运行相同决策),不写入、不读 stdin、不调用上游",
70
112
  " --with-matt-skills",
71
- " 可选:改从上游 mattpocock/skills 安装依赖 skill 的完整版",
72
- " (需 Node >= 22.20;无此 flag 时在交互式终端会询问一次,默认否)",
113
+ " 可选增强(显式授权):通过 npx 安装缺失 + 更新已存在的外部依赖完整版。",
114
+ " 仅当可证明目标是 codex 默认全局目录 ~/.agents/skills 时可用(不支持 --dir 别名),",
115
+ " 需 Node >= 22.20;此 flag 下不询问。每项独立完成,非整套事务。",
116
+ " 成功项记入清单(kind=upstream),之后 uninstall 会一并移除;本地改动可能被覆盖。",
117
+ " 不加此 flag 时:不安装、不复制任何独立依赖;内部回退已可用。",
73
118
  " --matt-agent <name>",
74
- " 上游安装使用的 agent(默认 codex,落盘到其全局目录 ~/.agents/skills)",
119
+ " 上游使用的 agent(仅支持默认 codex;其它值无法证明目标,会拒绝)",
75
120
  " -h, --help 显示本帮助",
76
121
  "",
77
122
  "示例:",
78
- " project-docs-workflow # 安装到默认 ~/.agents/skills",
123
+ " project-docs-workflow # 安装/同步到默认 ~/.agents/skills(仅第一方)",
79
124
  " project-docs-workflow --dir <path> --dry-run # 预演到指定目录(不写入)",
80
- " project-docs-workflow --with-matt-skills # 依赖改从上游装完整版",
81
- " project-docs-workflow check # 检查依赖解析状态",
125
+ " project-docs-workflow --force # 接管/替换所有第一方 skill",
126
+ " project-docs-workflow --with-matt-skills # 可选:安装缺失 + 更新已存在的完整版依赖",
127
+ " project-docs-workflow check # 检查依赖解析状态(外部或内部回退)",
82
128
  " project-docs-workflow uninstall --dry-run # 卸载预演",
83
129
  " project-docs-workflow uninstall --force # 非交互卸载(只删本包装的)",
84
130
  ].join("\n")
@@ -161,6 +207,218 @@ function isFile(p) {
161
207
  }
162
208
  }
163
209
 
210
+ // 存在性用 lstat(不跟随符号链接),因此悬空链接也算「存在」,不会被误判为缺失而覆盖。
211
+ function lexists(p) {
212
+ try {
213
+ fs.lstatSync(p);
214
+ return true;
215
+ } catch (e) {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ function isSymlink(p) {
221
+ try {
222
+ return fs.lstatSync(p).isSymbolicLink();
223
+ } catch (e) {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ function readlinkSafe(p) {
229
+ try {
230
+ return fs.readlinkSync(p);
231
+ } catch (e) {
232
+ return "?";
233
+ }
234
+ }
235
+
236
+ // 解析真实路径;路径尚不存在时,解析“最近存在的祖先”再拼接剩余部分(保持符号链接语义)。
237
+ // 无法可靠解析(悬空/循环符号链接、权限等)时 fail closed,绝不退化为词法路径。
238
+ function resolveRealOrFail(p) {
239
+ const abs = path.resolve(p);
240
+ try {
241
+ return fs.realpathSync(abs);
242
+ } catch (e) {
243
+ if (!e || e.code !== "ENOENT" || isSymlink(abs)) {
244
+ fail("拒绝:无法可靠解析路径(" + abs + "):" + (e && (e.code || e.message)));
245
+ }
246
+ const parent = path.dirname(abs);
247
+ if (parent === abs) {
248
+ fail("拒绝:无法可靠解析路径(" + abs + ")。");
249
+ }
250
+ return path.join(resolveRealOrFail(parent), path.basename(abs));
251
+ }
252
+ }
253
+
254
+ // child 与 parent 相同或在 parent 之内(词法判定)。
255
+ function isSameOrInside(child, parent) {
256
+ const rel = path.relative(parent, child);
257
+ return rel === "" || (rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel));
258
+ }
259
+
260
+ // 源 / 目标重叠:目标不得与包内 skills 源目录相同、在其内、或包含它(含经符号链接解析后)。
261
+ function assertNoSourceTargetOverlap(target) {
262
+ const targetReal = resolveRealOrFail(target);
263
+ const skillsReal = resolveRealOrFail(SKILLS_DIR);
264
+ if (isSameOrInside(targetReal, skillsReal) || isSameOrInside(skillsReal, targetReal)) {
265
+ fail(
266
+ "拒绝:目标目录与包内 skills 源目录重叠(" +
267
+ target +
268
+ " vs " +
269
+ SKILLS_DIR +
270
+ ")。请选择包外目录(例如 ~/.agents/skills),避免复制/替换包自身源码。"
271
+ );
272
+ }
273
+ }
274
+
275
+ // 仅在目标目录内使用的唯一工具路径(前缀可辨识,便于只清理本工具产生的临时项)。
276
+ function uniqueToolPath(parent, name, tag) {
277
+ for (;;) {
278
+ const p = path.join(
279
+ parent,
280
+ ".pdw-" + name + "-" + tag + "-" + Math.random().toString(36).slice(2, 10)
281
+ );
282
+ if (!lexists(p)) {
283
+ return p;
284
+ }
285
+ }
286
+ }
287
+
288
+ function cleanupToolPath(p) {
289
+ try {
290
+ fs.rmSync(p, { recursive: true, force: true });
291
+ } catch (e) {
292
+ /* 尽力清理;不因清理失败中断 */
293
+ }
294
+ }
295
+
296
+ // 递归复制(用可捕获的 JS 调用,避免 Node 原生 cp 在源不可读时硬中止进程而漏清理)。
297
+ function copyTree(src, dest) {
298
+ const st = fs.lstatSync(src);
299
+ if (st.isSymbolicLink()) {
300
+ fs.symlinkSync(fs.readlinkSync(src), dest);
301
+ return;
302
+ }
303
+ if (st.isDirectory()) {
304
+ fs.mkdirSync(dest, { recursive: true });
305
+ for (const name of fs.readdirSync(src)) {
306
+ copyTree(path.join(src, name), path.join(dest, name));
307
+ }
308
+ return;
309
+ }
310
+ fs.copyFileSync(src, dest);
311
+ }
312
+
313
+ // 全目录替换(单项,非整套事务):先把源完整复制到同目录下的唯一临时路径,成功后才把旧
314
+ // 条目移开并换上准备好的副本;失败时恢复旧条目,无法恢复则保留备份并报告其路径。
315
+ // 绝不在复制成功之前删除现有条目。旧条目为符号链接时,rename/rm 只作用于链接本身。
316
+ function replaceFirstPartyDir(name, dest) {
317
+ const src = path.join(SKILLS_DIR, name);
318
+ const parent = path.dirname(dest);
319
+ const staged = uniqueToolPath(parent, name, "new");
320
+ try {
321
+ copyTree(src, staged);
322
+ } catch (e) {
323
+ cleanupToolPath(staged);
324
+ fail("准备 " + name + " 的新版本失败(原条目未改动):" + e.message);
325
+ }
326
+ let backup = null;
327
+ try {
328
+ if (lexists(dest)) {
329
+ backup = uniqueToolPath(parent, name, "old");
330
+ fs.renameSync(dest, backup);
331
+ }
332
+ fs.renameSync(staged, dest);
333
+ } catch (e) {
334
+ let restored = false;
335
+ if (!lexists(dest) && backup && lexists(backup)) {
336
+ try {
337
+ fs.renameSync(backup, dest);
338
+ restored = true;
339
+ } catch (e2) {
340
+ /* 保留备份以便人工恢复 */
341
+ }
342
+ }
343
+ cleanupToolPath(staged);
344
+ // 错误路径只保留备份:绝不因为 dest 恰好存在就删除备份(那样会丢失原条目)。
345
+ // 只有成功把备份重命名回 dest 时,备份才被“消耗”掉。
346
+ let note = "";
347
+ if (backup && lexists(backup)) {
348
+ note = ";原条目已保留在 " + backup + "(如需恢复请手动移回)";
349
+ }
350
+ fail("替换 " + name + " 失败" + (restored ? "(已恢复原条目)" : note) + ":" + e.message);
351
+ }
352
+ if (backup && lexists(backup)) {
353
+ cleanupToolPath(backup);
354
+ }
355
+ }
356
+
357
+ // 单个第一方 skill 的安装/同步决策。dry-run 走完全相同的分支,但不复制、不建临时项。
358
+ function installFirstParty(name, target, opts, prevEntries, record) {
359
+ const dest = path.join(target, name);
360
+ const exists = lexists(dest);
361
+ const symlink = exists && isSymlink(dest);
362
+ const prev = prevEntries[name];
363
+ const owned = isOwnedLocal(prev);
364
+
365
+ if (!exists) {
366
+ if (opts.dryRun) {
367
+ console.log(" [dry-run] 将安装 " + name + " → " + dest);
368
+ } else {
369
+ replaceFirstPartyDir(name, dest);
370
+ console.log(" ✓ 已安装 " + name + " → " + dest);
371
+ }
372
+ record(name, { kind: kindFor(name), action: "installed" });
373
+ return;
374
+ }
375
+
376
+ // 第一方符号链接:默认永远保留(即使清单记为本包安装)。--force 才替换链接本身。
377
+ if (symlink && !opts.force) {
378
+ console.log(
379
+ " ⚠ 保留第一方符号链接(未改动,指向 " +
380
+ readlinkSafe(dest) +
381
+ "):" +
382
+ name +
383
+ "(如需替换链接本身、不动其指向,请加 --force)"
384
+ );
385
+ record(name, { kind: kindFor(name), action: "skipped" });
386
+ return;
387
+ }
388
+
389
+ // 现有条目但未被本包接管(清单缺失/损坏/未知/skipped):默认保留并提示 --force。
390
+ if (!owned && !opts.force) {
391
+ console.log(
392
+ " ⚠ 保留现有条目(非本包安装,或清单缺失/损坏/未知):" +
393
+ name +
394
+ "(如需覆盖并接管,请加 --force)"
395
+ );
396
+ record(name, { kind: kindFor(name), action: "skipped" });
397
+ return;
398
+ }
399
+
400
+ const action = opts.force ? "forced" : prev && prev.action === "forced" ? "forced" : "installed";
401
+ if (owned && !opts.force) {
402
+ console.log(
403
+ " ⚠ 更新第一方 skill " +
404
+ name +
405
+ ":managed 安装副本内的本地改动将丢失;项目文档不受影响。"
406
+ );
407
+ } else {
408
+ console.log(
409
+ " ⚠ --force 接管并替换现有条目:" + name + (symlink ? "(仅替换符号链接本身)" : "")
410
+ );
411
+ }
412
+ if (opts.dryRun) {
413
+ console.log(" [dry-run] 将替换 " + name + " → " + dest + (symlink ? "(仅链接本身)" : ""));
414
+ record(name, { kind: kindFor(name), action });
415
+ return;
416
+ }
417
+ replaceFirstPartyDir(name, dest);
418
+ console.log(" ✓ 已" + (owned && !opts.force ? "更新" : "接管") + " " + name + " → " + dest);
419
+ record(name, { kind: kindFor(name), action });
420
+ }
421
+
164
422
  // —— 上游(mattpocock/skills)相关辅助 ——
165
423
 
166
424
  function existsAt(p) {
@@ -223,11 +481,12 @@ function npxCommand() {
223
481
  return process.platform === "win32" ? "npx.cmd" : "npx";
224
482
  }
225
483
 
226
- // 组装上游命令参数(不含可执行文件名),以便打印或执行。
484
+ // 组装上游命令参数(不含可执行文件名),以便打印或执行。--json 必需:非 JSON 输出可能在
485
+ // 部分失败时仍以 0 退出。窄命令,不使用 -a '*'/-s '*'/--all;单目标始终 copy,无需 --copy。
227
486
  function buildUpstreamArgs(opts, names) {
228
- return ["--yes", "skills@latest", "add", UPSTREAM_REPO, "-g", "-y", "-a", opts.mattAgent, "-s"].concat(
229
- names
230
- );
487
+ return ["--yes", "skills@latest", "add", UPSTREAM_REPO, "-g", "-y", "-a", opts.mattAgent, "-s"]
488
+ .concat(names)
489
+ .concat(["--json"]);
231
490
  }
232
491
 
233
492
  function quoteArg(arg) {
@@ -238,18 +497,275 @@ function upstreamCommandString(opts, names) {
238
497
  return [npxCommand()].concat(buildUpstreamArgs(opts, names)).map(quoteArg).join(" ");
239
498
  }
240
499
 
241
- // 决定是否启用上游安装。仅在 install 且非 dry-run、且 stdin/stdout 均为 TTY 时提示。
242
- function decideUpstream(opts) {
243
- if (opts.withMattSkills) {
244
- return true;
500
+ // 上游目标可行性:只有可证明目标就是 codex 默认全局目录(~/.agents/skills)时才允许调用上游。
501
+ // 其它 agent、或解析后不等于默认目录的 --dir,都无法证明上游落点,返回 ok:false。
502
+ function upstreamTargetCheck(opts) {
503
+ if (opts.mattAgent !== UPSTREAM_DEFAULT_AGENT) {
504
+ return {
505
+ ok: false,
506
+ reason:
507
+ "所选 agent '" +
508
+ opts.mattAgent +
509
+ "' 的全局 skills 目录未知(仅已知 codex → ~/.agents/skills)",
510
+ };
245
511
  }
246
- if (opts.command !== "install" || opts.dryRun) {
247
- return false;
512
+ const targetReal = resolveRealOrFail(opts.target);
513
+ const defaultReal = resolveRealOrFail(DEFAULT_TARGET);
514
+ if (targetReal !== defaultReal) {
515
+ return {
516
+ ok: false,
517
+ reason:
518
+ "目标目录 " +
519
+ opts.target +
520
+ " 解析后不等于 codex 全局目录 " +
521
+ DEFAULT_TARGET +
522
+ "(上游安装不支持 --dir)",
523
+ };
248
524
  }
249
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
250
- return false;
525
+ return { ok: true, reason: "" };
526
+ }
527
+
528
+ // 供用户手动执行的窄命令(不含 --json;不经本安装器)。
529
+ function manualUpstreamCommand(names) {
530
+ const list = names && names.length ? names.join(" ") : "<names>";
531
+ return (
532
+ "npx --yes skills@latest add " +
533
+ UPSTREAM_REPO +
534
+ " -g -y -a " +
535
+ UPSTREAM_DEFAULT_AGENT +
536
+ " -s " +
537
+ list
538
+ );
539
+ }
540
+
541
+ function parseUpstreamJson(stdout) {
542
+ if (typeof stdout !== "string") {
543
+ return null;
544
+ }
545
+ // 严格:官方 --json 输出应是完整的 JSON 数组;不从噪声里截取,避免歧义结果被当成成功。
546
+ try {
547
+ const v = JSON.parse(stdout.trim());
548
+ return Array.isArray(v) ? v : null;
549
+ } catch (e) {
550
+ return null;
551
+ }
552
+ }
553
+
554
+ function realpathSoft(p) {
555
+ try {
556
+ return fs.realpathSync(p);
557
+ } catch (e) {
558
+ return null;
559
+ }
560
+ }
561
+
562
+ // 校验 --json 结果:契约必须明确——数组、条目均为对象、name 非空且都在 requested 内、不重复;
563
+ // requested 中每个名字必须是 installed、source/scope/agents 正确、mode=copy、path 为非空字符串
564
+ // 且解析后等于目标目录、且 SKILL.md 实际存在。path 必填(缺 path 不因 SKILL.md 存在就放行)。
565
+ // 契约异常写入 reasons 的 "__contract__"。返回 {ok:Set, reasons:Map}。
566
+ function validateUpstreamResults(parsed, requested, target) {
567
+ const ok = new Set();
568
+ const reasons = new Map();
569
+ if (!Array.isArray(parsed)) {
570
+ for (const n of requested) {
571
+ reasons.set(n, "结果不可解析(非数组)");
572
+ }
573
+ return { ok, reasons };
251
574
  }
252
- process.stdout.write("是否从上游安装依赖 skill 的完整版(mattpocock/skills)?[y/N] ");
575
+ const reqSet = new Set(requested);
576
+ const byName = new Map();
577
+ for (const el of parsed) {
578
+ if (!el || typeof el !== "object") {
579
+ reasons.set("__contract__", "结果含非对象条目");
580
+ return { ok, reasons };
581
+ }
582
+ if (typeof el.name !== "string" || !el.name) {
583
+ reasons.set("__contract__", "结果含无 name 条目(status=" + String(el.status) + ")");
584
+ return { ok, reasons };
585
+ }
586
+ if (!reqSet.has(el.name)) {
587
+ reasons.set("__contract__", "结果含未请求的条目:" + el.name);
588
+ return { ok, reasons };
589
+ }
590
+ if (byName.has(el.name)) {
591
+ reasons.set("__contract__", "结果含重复条目:" + el.name);
592
+ return { ok, reasons };
593
+ }
594
+ byName.set(el.name, el);
595
+ }
596
+ for (const name of requested) {
597
+ const el = byName.get(name);
598
+ if (!el) {
599
+ reasons.set(name, "结果缺少该条目");
600
+ continue;
601
+ }
602
+ if (el.status !== "installed") {
603
+ reasons.set(name, "status=" + String(el.status) + (el.reason ? "(" + el.reason + ")" : ""));
604
+ continue;
605
+ }
606
+ if (el.source !== UPSTREAM_REPO) {
607
+ reasons.set(name, "source=" + String(el.source));
608
+ continue;
609
+ }
610
+ if (el.scope !== "global") {
611
+ reasons.set(name, "scope=" + String(el.scope));
612
+ continue;
613
+ }
614
+ const agents = Array.isArray(el.agents) ? el.agents.map((a) => String(a).toLowerCase()) : [];
615
+ if (agents.indexOf("codex") === -1) {
616
+ reasons.set(name, "agents=" + JSON.stringify(el.agents));
617
+ continue;
618
+ }
619
+ if (String(el.mode).toLowerCase() !== "copy") {
620
+ reasons.set(name, "mode=" + String(el.mode));
621
+ continue;
622
+ }
623
+ if (typeof el.path !== "string" || !el.path) {
624
+ reasons.set(name, "缺少 path");
625
+ continue;
626
+ }
627
+ const expected = path.join(target, name);
628
+ if (!isFile(path.join(expected, "SKILL.md"))) {
629
+ reasons.set(name, "目标缺少 SKILL.md(不声称成功)");
630
+ continue;
631
+ }
632
+ const p = realpathSoft(path.resolve(el.path));
633
+ const e = realpathSoft(expected);
634
+ if (!p || !e || p !== e) {
635
+ reasons.set(name, "path 与目标不符:" + el.path);
636
+ continue;
637
+ }
638
+ ok.add(name);
639
+ }
640
+ return { ok, reasons };
641
+ }
642
+
643
+ function printBounded(label, text) {
644
+ const lines = String(text || "").split(/\r?\n/).filter((l) => l.trim());
645
+ if (lines.length === 0) {
646
+ return;
647
+ }
648
+ const head = lines.slice(0, 8);
649
+ console.log(" " + label);
650
+ for (const l of head) {
651
+ console.log(" " + l.slice(0, 300));
652
+ }
653
+ if (lines.length > head.length) {
654
+ console.log(" …(其余 " + (lines.length - head.length) + " 行略)");
655
+ }
656
+ }
657
+
658
+ // 执行上游完整版安装并严格校验 --json,按名字分别处理(不再有任何独立轻量版回退/重建):
659
+ // - installed 且校验通过 → 记 upstream(成功项不清理);
660
+ // - 原本缺失的失败项 → 只清理「本次尝试才新建」的路径(阶段开始时不存在),报错;
661
+ // - 原本已存在的失败项 → 不删除、不重建,保留原归属,报错(可能已有上游部分写入)。
662
+ function runUpstreamPhase(opts, target, existingNames, missingNames, record) {
663
+ const requested = MATT_DEP_NAMES.filter(
664
+ (n) => existingNames.indexOf(n) !== -1 || missingNames.indexOf(n) !== -1
665
+ );
666
+ const externalHandled = new Set();
667
+ // 阶段开始时的存在性快照:仅用于判定「本次尝试新建」的路径,避免清理既有目录。
668
+ const preExisting = new Set(requested.filter((n) => lexists(path.join(target, n))));
669
+ if (opts.dryRun) {
670
+ console.log(" [dry-run] 将执行:" + upstreamCommandString(opts, requested));
671
+ console.log(
672
+ " [dry-run] 校验 --json 成功项记为 upstream;缺失失败项仅清理本次新建路径;已存在失败项报错;无独立轻量版回退。"
673
+ );
674
+ for (const n of requested) {
675
+ externalHandled.add(n);
676
+ }
677
+ return { externalHandled, hardFail: false };
678
+ }
679
+ console.log(" 将执行:" + upstreamCommandString(opts, requested));
680
+ let parsed = null;
681
+ let execFail = null;
682
+ let status = 0;
683
+ const res = spawnSync(npxCommand(), buildUpstreamArgs(opts, requested), {
684
+ encoding: "utf8",
685
+ stdio: ["ignore", "pipe", "pipe"],
686
+ maxBuffer: 32 * 1024 * 1024,
687
+ env: Object.assign({}, process.env, { DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" }),
688
+ });
689
+ if (res.error) {
690
+ execFail = "上游命令无法执行(" + (res.error.code || res.error.message) + ")";
691
+ } else {
692
+ status = typeof res.status === "number" ? res.status : 1;
693
+ if (res.stderr) {
694
+ printBounded("上游 stderr:", res.stderr);
695
+ }
696
+ parsed = parseUpstreamJson(res.stdout);
697
+ if (parsed === null) {
698
+ execFail = "上游 --json 输出无法严格解析为数组";
699
+ }
700
+ }
701
+ const validation = validateUpstreamResults(parsed, requested, target);
702
+ const contractErr = validation.reasons.get("__contract__") || null;
703
+ let hardFail = false;
704
+ for (const name of requested) {
705
+ if (validation.ok.has(name)) {
706
+ record(name, { kind: "upstream", action: "installed", agent: opts.mattAgent });
707
+ console.log(" ✓ 上游完成:" + name);
708
+ continue;
709
+ }
710
+ hardFail = true;
711
+ const why = validation.reasons.get(name) || contractErr || execFail || "未知原因";
712
+ if (missingNames.indexOf(name) !== -1) {
713
+ // 原本缺失:只清理本次尝试新建的路径(阶段开始时不存在),绝不触碰既有目录。
714
+ if (!preExisting.has(name) && lexists(path.join(target, name))) {
715
+ cleanupToolPath(path.join(target, name));
716
+ console.log(
717
+ " ✗ 可选上游安装未完成(" +
718
+ name +
719
+ ":" +
720
+ why +
721
+ ");已清理本次新建的部分安装路径;未安装独立依赖,内部回退文档仍可用。"
722
+ );
723
+ } else {
724
+ console.log(
725
+ " ✗ 可选上游安装未完成(" +
726
+ name +
727
+ ":" +
728
+ why +
729
+ ");未新建路径,未安装独立依赖,内部回退文档仍可用。"
730
+ );
731
+ }
732
+ } else {
733
+ console.log(
734
+ " ✗ 更新已存在依赖失败(" +
735
+ name +
736
+ ":" +
737
+ why +
738
+ ");不删除、不重建,保留原清单归属,不声称已更新。"
739
+ );
740
+ }
741
+ }
742
+ // 阶段处理过的名字一律标记为已处理:外层不得再对任何名字做复制/回退。
743
+ for (const name of requested) {
744
+ externalHandled.add(name);
745
+ }
746
+ // 整体失败:非零退出或契约异常时,即便有逐项成功也 hardFail;已确认成功项仍保留归属。
747
+ if (status !== 0) {
748
+ hardFail = true;
749
+ console.log(
750
+ " ⚠ 上游退出码 " + status + ":整体按未成功处理(已确认成功的项仍记录归属,不声称全部完成)。"
751
+ );
752
+ }
753
+ if (contractErr) {
754
+ hardFail = true;
755
+ console.log(" ⚠ 上游结果契约异常:" + contractErr + "(拒绝按歧义结果判定成功)。");
756
+ }
757
+ if (execFail) {
758
+ console.log(" ⚠ " + execFail);
759
+ }
760
+ return { externalHandled, hardFail };
761
+ }
762
+
763
+ function promptUpstreamMissing(missingNames) {
764
+ process.stdout.write(
765
+ "是否可选安装上游完整版依赖 skill(mattpocock/skills):" +
766
+ missingNames.join("、") +
767
+ "?[y/N](不加装时内部回退已可用) "
768
+ );
253
769
  let answer = "";
254
770
  try {
255
771
  answer = readLineSync().trim().toLowerCase();
@@ -271,16 +787,6 @@ function listSkillDirs() {
271
787
  .sort();
272
788
  }
273
789
 
274
- function copySkill(name, dest, dryRun) {
275
- const src = path.join(SKILLS_DIR, name);
276
- if (dryRun) {
277
- console.log(" [dry-run] 将复制 " + name + " → " + dest);
278
- return;
279
- }
280
- fs.cpSync(src, dest, { recursive: true });
281
- console.log(" ✓ 已安装 " + name + " → " + dest);
282
- }
283
-
284
790
  // —— 安装清单(manifest)——
285
791
 
286
792
  function manifestPath(target) {
@@ -303,6 +809,34 @@ function loadManifest(target) {
303
809
  return null;
304
810
  }
305
811
 
812
+ // 读取清单状态:ok / symlink / missing / invalid。symbolic 一律拒绝(不跟随);invalid(解析
813
+ // 失败或结构不对,entries 非普通对象)按未接管处理,不猜测。
814
+ function readManifest(target) {
815
+ const p = manifestPath(target);
816
+ if (!lexists(p)) {
817
+ return { status: "missing", entries: {} };
818
+ }
819
+ if (isSymlink(p)) {
820
+ return { status: "symlink", entries: {} };
821
+ }
822
+ try {
823
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
824
+ if (
825
+ data &&
826
+ typeof data === "object" &&
827
+ !Array.isArray(data) &&
828
+ data.entries &&
829
+ typeof data.entries === "object" &&
830
+ !Array.isArray(data.entries)
831
+ ) {
832
+ return { status: "ok", entries: data.entries };
833
+ }
834
+ } catch (e) {
835
+ /* 视为 invalid */
836
+ }
837
+ return { status: "invalid", entries: {} };
838
+ }
839
+
306
840
  // 合并新旧条目:归属一旦记在本包名下(installed/forced),后续因「已存在」而 skip 时不被降级;
307
841
  // 而初次即为 skipped(安装时已存在、非本包安装)的名字始终保持 skipped。
308
842
  function mergeEntries(prev, fresh) {
@@ -320,6 +854,10 @@ function mergeEntries(prev, fresh) {
320
854
 
321
855
  function writeManifest(target, entries) {
322
856
  const p = manifestPath(target);
857
+ // 写入前再核对一次:若中途出现符号链接,拒绝写入,绝不跟随覆盖其指向的文件。
858
+ if (isSymlink(p)) {
859
+ fail("拒绝:安装清单是符号链接(" + p + "),不写入(请先手动处理)。");
860
+ }
323
861
  const data = {
324
862
  version: PKG_VERSION,
325
863
  target: path.resolve(target),
@@ -343,17 +881,6 @@ function removeManifest(target) {
343
881
  }
344
882
  }
345
883
 
346
- // 依赖 skill 的 kind:本包附带的第一方 skill 各用独立 kind(doc-opt、docs-html-view),
347
- // 其余非上游目录视为轻量依赖 lite;这样不会把第一方 skill 误记为 lite(上游轻量)归属。
348
- const FIRST_PARTY_DEP_KINDS = {
349
- "doc-opt": "doc-opt",
350
- "docs-html-view": "docs-html-view",
351
- };
352
-
353
- function depKind(name) {
354
- return FIRST_PARTY_DEP_KINDS[name] || "lite";
355
- }
356
-
357
884
  // —— 上游卸载命令组装 ——
358
885
 
359
886
  function buildUpstreamRemoveArgs(names, agent) {
@@ -424,8 +951,53 @@ function runInstall(opts) {
424
951
  fail("缺少主 skill 目录:skills/" + MAIN_SKILL);
425
952
  }
426
953
 
427
- // 是否启用上游完整版安装(可能触发一次交互式询问,仅限 TTY + install + 非 dry-run)。
428
- const upstreamEnabled = decideUpstream(opts);
954
+ const target = path.resolve(opts.target);
955
+
956
+ // 源 / 目标重叠必须在任何写入、复制或建目录之前拒绝。
957
+ assertNoSourceTargetOverlap(target);
958
+
959
+ // 先读上一份清单:归属判定必须先于规划/更新。清单为符号链接时,任何情况下都拒绝(含
960
+ // --force / --dry-run),且不跟随、不删除该链接——避免覆盖其指向的无关文件。
961
+ const man = readManifest(target);
962
+ if (man.status === "symlink") {
963
+ fail(
964
+ "拒绝:安装清单是符号链接(" +
965
+ manifestPath(target) +
966
+ ");为避免覆盖其指向的文件,安装器不跟随也不删除该链接,已停止。请先手动处理该链接。"
967
+ );
968
+ }
969
+ if (man.status === "invalid") {
970
+ console.log(
971
+ " ⚠ 安装清单无效:" +
972
+ manifestPath(target) +
973
+ "(按未接管处理;现有条目保留,如需接管请 --force)"
974
+ );
975
+ }
976
+ const prevEntries = man.entries;
977
+
978
+ // 受支持外部依赖(8 个)现状:lstat(含符号链接/悬空)。
979
+ const existingDeps = MATT_DEP_NAMES.filter((name) => lexists(path.join(target, name)));
980
+ const missingDeps = MATT_DEP_NAMES.filter((name) => !lexists(path.join(target, name)));
981
+ const targetCheck = upstreamTargetCheck(opts);
982
+
983
+ // 显式 --with-matt-skills:目标/agent 不可证明或 Node 过低时,在任何写入之前拒绝(含自身更新)。
984
+ if (opts.withMattSkills) {
985
+ if (!targetCheck.ok) {
986
+ fail(
987
+ "拒绝 --with-matt-skills:" +
988
+ targetCheck.reason +
989
+ "。请在 codex 默认全局目录手动运行:" +
990
+ manualUpstreamCommand(existingDeps.concat(missingDeps))
991
+ );
992
+ }
993
+ if (!nodeMeetsUpstream()) {
994
+ fail(
995
+ "拒绝 --with-matt-skills:需要 Node >= 22.20(当前 " +
996
+ process.versions.node +
997
+ "),无法更新/安装上游依赖;已在任何改动之前停止。"
998
+ );
999
+ }
1000
+ }
429
1001
 
430
1002
  const nowIso = new Date().toISOString();
431
1003
  const freshEntries = {};
@@ -433,113 +1005,121 @@ function runInstall(opts) {
433
1005
  freshEntries[name] = Object.assign({ at: nowIso }, entry);
434
1006
  };
435
1007
 
436
- const target = opts.target;
437
1008
  console.log("目标目录:" + target + (opts.dryRun ? "(dry-run,不会写入)" : ""));
438
1009
  if (!opts.dryRun) {
439
1010
  fs.mkdirSync(target, { recursive: true });
440
1011
  }
441
1012
 
442
- // 主 skill:仅在 --force 时覆盖已存在的版本,否则同样保护用户版本。
443
- const mainDest = path.join(target, MAIN_SKILL);
444
- const mainExists = isDir(mainDest) || isFile(mainDest);
445
- console.log("主 skill:" + MAIN_SKILL);
446
- if (mainExists && !opts.force) {
447
- console.log(" 同名 skill 已存在,保留用户版本:" + MAIN_SKILL + "(如需覆盖请加 --force)");
448
- record(MAIN_SKILL, { kind: "main", action: "skipped" });
449
- } else if (mainExists && opts.force) {
450
- if (opts.dryRun) {
451
- console.log(" [dry-run] 将覆盖 " + MAIN_SKILL + " → " + mainDest);
452
- } else {
453
- fs.rmSync(mainDest, { recursive: true, force: true });
454
- copySkill(MAIN_SKILL, mainDest, false);
1013
+ // 第一方 skill:只同步本包显式提供的第一方集合(绝不推断未知目录);缺失则安装、已接管则
1014
+ // 全量替换为当前源;未接管或符号链接默认保留(--force 才接管/替换链接本身)。
1015
+ console.log("第一方 skill(同步包内版本):");
1016
+ const firstParty = FIRST_PARTY_NAMES.filter((name) => dirs.indexOf(name) !== -1);
1017
+ if (firstParty.length === 0) {
1018
+ console.log(" (本包未提供第一方 skill)");
1019
+ }
1020
+ for (const name of firstParty) {
1021
+ installFirstParty(name, target, opts, prevEntries, record);
1022
+ }
1023
+
1024
+ // 受支持外部依赖:先列出已存在/缺失集合,再决定是否/如何询问或调用上游。
1025
+ if (existingDeps.length > 0 || missingDeps.length > 0) {
1026
+ console.log("外部依赖 skill(可选上游受支持集合;默认不随包分发):");
1027
+ if (existingDeps.length > 0) {
1028
+ console.log(" 已存在:" + existingDeps.join("、"));
1029
+ }
1030
+ if (missingDeps.length > 0) {
1031
+ console.log(" 缺失:" + missingDeps.join("、"));
455
1032
  }
456
- record(MAIN_SKILL, { kind: "main", action: "forced" });
457
- } else {
458
- copySkill(MAIN_SKILL, mainDest, opts.dryRun);
459
- record(MAIN_SKILL, { kind: "main", action: "installed" });
460
1033
  }
461
1034
 
462
- // 可选:先从上游安装 8 个依赖 skill 的完整版。
463
- // upstreamHandled 记录「本次由上游负责」的名字,这些名字不再复制本包轻量版。
464
- let upstreamHandled = new Set();
465
- if (upstreamEnabled) {
466
- console.log("上游依赖 skill(mattpocock/skills 完整版):");
467
- if (opts.target !== DEFAULT_TARGET) {
1035
+ let hardFail = false;
1036
+ if (opts.withMattSkills) {
1037
+ console.log("可选上游完整版(mattpocock/skills;--with-matt-skills 已显式授权):");
1038
+ if (existingDeps.length > 0) {
468
1039
  console.log(
469
- " ⚠ 检测到自定义 --dir:上游安装固定使用 agent 全局目录(" +
470
- opts.mattAgent +
471
- " → ~/.agents/skills),无法跟随 --dir;--dir 下的轻量版仍按原逻辑处理。"
1040
+ " ⚠ 将更新已存在项,其目录内的本地改动可能被上游覆盖;每一项独立完成,非整套事务。"
472
1041
  );
473
1042
  }
474
- const preexisting = MATT_DEP_NAMES.filter((name) => existsAt(path.join(target, name)));
475
- const missing = MATT_DEP_NAMES.filter((name) => preexisting.indexOf(name) === -1);
476
- if (preexisting.length > 0) {
477
- console.log(" 已存在,保留且不提交上游:" + preexisting.join("、"));
478
- }
479
- if (missing.length === 0) {
480
- console.log(" 8 个依赖 skill 均已在目标目录,无需上游安装。");
481
- } else if (opts.dryRun) {
482
- console.log(" [dry-run] 将执行:" + upstreamCommandString(opts, missing));
483
- upstreamHandled = new Set(missing);
484
- } else if (!nodeMeetsUpstream()) {
1043
+ // 调用前按实时状态复核(第一方同步期间可能已有新目录出现):新出现者归入「已存在」,
1044
+ // 失败时按已存在项处理(不删除、不重建),绝不删除询问/同步期间才出现的第三方目录。
1045
+ const liveExisting = MATT_DEP_NAMES.filter((n) => lexists(path.join(target, n)));
1046
+ const liveMissing = MATT_DEP_NAMES.filter((n) => !lexists(path.join(target, n)));
1047
+ if (
1048
+ liveExisting.join(",") !== existingDeps.join(",") ||
1049
+ liveMissing.join(",") !== missingDeps.join(",")
1050
+ ) {
485
1051
  console.log(
486
- " ⚠ 当前 Node " +
487
- process.versions.node +
488
- " < 22.20,无法运行上游安装器;将对缺失项回退轻量版。"
1052
+ " (调用前复核:已存在 " +
1053
+ (liveExisting.join("、") || "(无)") +
1054
+ ";缺失 " +
1055
+ (liveMissing.join("、") || "(无)") +
1056
+ ")"
489
1057
  );
490
- } else {
491
- console.log(" 将执行:" + upstreamCommandString(opts, missing));
492
- const res = spawnSync(npxCommand(), buildUpstreamArgs(opts, missing), {
493
- stdio: "inherit",
494
- env: Object.assign({}, process.env, { DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" }),
495
- });
496
- if (res.error) {
1058
+ }
1059
+ const phase = runUpstreamPhase(opts, target, liveExisting, liveMissing, record);
1060
+ hardFail = phase.hardFail;
1061
+ } else {
1062
+ if (existingDeps.length > 0) {
1063
+ console.log(
1064
+ " 已存在独立依赖(保留,不判定完整版):" +
1065
+ existingDeps.join("、") +
1066
+ ";如需可选升级可用 --with-matt-skills 显式更新,或用户自行更新。"
1067
+ );
1068
+ }
1069
+ if (missingDeps.length > 0) {
1070
+ const fallbackNote = "未安装可选上游完整版/独立入口,内部回退(references/deps)已可用。";
1071
+ if (!targetCheck.ok) {
1072
+ console.log(" " + targetCheck.reason + ";" + fallbackNote);
1073
+ } else if (!nodeMeetsUpstream()) {
1074
+ console.log(" Node " + process.versions.node + " < 22.20,无法调用上游;" + fallbackNote);
1075
+ } else if (opts.dryRun) {
497
1076
  console.log(
498
- " ⚠ 上游安装无法执行(" +
499
- (res.error.code || res.error.message) +
500
- ");将对缺失项回退轻量版。"
1077
+ " [dry-run] 交互终端下会询问是否可选安装上游完整版(不读 stdin、不调用上游);" + fallbackNote
501
1078
  );
502
- } else if (res.status !== 0) {
503
- console.log(" ⚠ 上游安装失败(退出码 " + res.status + ");将对缺失项回退轻量版。");
1079
+ } else if (!process.stdin.isTTY || !process.stdout.isTTY) {
1080
+ console.log(
1081
+ " 非交互终端:跳过可选上游安装;" + fallbackNote + "(如需上游请 --with-matt-skills)"
1082
+ );
1083
+ } else if (promptUpstreamMissing(missingDeps)) {
1084
+ // 询问等待期间目录可能已出现:运行前按实时状态复核,只把「仍然缺失」的名字交给上游;
1085
+ // 新出现者保留不改动;全部出现则不再调用上游。
1086
+ const stillMissing = missingDeps.filter((n) => !lexists(path.join(target, n)));
1087
+ const appeared = missingDeps.filter((n) => lexists(path.join(target, n)));
1088
+ if (appeared.length > 0) {
1089
+ console.log(" 询问期间出现的新条目,保留不改动:" + appeared.join("、"));
1090
+ }
1091
+ if (stillMissing.length === 0) {
1092
+ console.log(" 询问期间缺失项均已出现,跳过可选上游安装。");
1093
+ } else {
1094
+ console.log("可选上游完整版(仅安装仍缺失的项,已按询问后状态复核):");
1095
+ const phase = runUpstreamPhase(opts, target, [], stillMissing, record);
1096
+ hardFail = phase.hardFail;
1097
+ }
1098
+ } else {
1099
+ console.log(" 已选择不安装可选上游完整版;" + fallbackNote);
504
1100
  }
505
- // 复核:仅把「本次新增」的名字标记为已由上游处理;已存在者走下面的保留逻辑。
506
- const nowPresent = MATT_DEP_NAMES.filter((name) => existsAt(path.join(target, name)));
507
- upstreamHandled = new Set(nowPresent.filter((name) => missing.indexOf(name) !== -1));
508
1101
  }
509
1102
  }
510
1103
 
511
- // 其他依赖 skill:永不覆盖。
512
- console.log("依赖 skill:");
513
- const others = dirs.filter((name) => name !== MAIN_SKILL);
514
- if (others.length === 0) {
515
- console.log(" (未发现其他目录)");
516
- }
517
- for (const name of others) {
518
- if (upstreamEnabled && MATT_DEP_SET.has(name) && upstreamHandled.has(name)) {
519
- console.log(
520
- (opts.dryRun
521
- ? " [dry-run] 将由上游安装完整版,跳过轻量版:"
522
- : " 已由上游安装完整版,跳过轻量版:") + name
523
- );
524
- record(name, { kind: "upstream", action: "installed", agent: opts.mattAgent });
525
- continue;
526
- }
527
- const dest = path.join(target, name);
528
- if (isDir(dest) || isFile(dest)) {
529
- console.log(" 同名 skill 已存在,保留用户版本:" + name);
530
- record(name, { kind: depKind(name), action: "skipped" });
531
- continue;
532
- }
533
- copySkill(name, dest, opts.dryRun);
534
- record(name, { kind: depKind(name), action: "installed" });
1104
+ // 包内非第一方目录:不再随包分发,也不做 firstparty 推断;仅提示忽略(不复制、不安装)。
1105
+ const unknownDirs = dirs.filter((name) => !FIRST_PARTY_SET.has(name));
1106
+ if (unknownDirs.length > 0) {
1107
+ console.log("忽略的包内目录(不再分发、不推断为第一方):" + unknownDirs.join("、"));
535
1108
  }
536
1109
 
537
1110
  if (opts.dryRun) {
538
1111
  console.log("dry-run 完成,未写入任何文件。");
539
1112
  } else {
540
- const prev = loadManifest(target);
541
- const merged = mergeEntries(prev ? prev.entries : {}, freshEntries);
1113
+ const merged = mergeEntries(prevEntries, freshEntries);
542
1114
  writeManifest(target, merged);
1115
+ }
1116
+ // 部分写入是真实的:先把确认成功项与保留的原归属落盘,再以非零退出报告可选上游未完成。
1117
+ if (hardFail) {
1118
+ fail(
1119
+ "可选上游完整版操作未全部成功;已记录确认成功项并保留原有归属;未安装独立轻量版,内部回退文档仍可用(详见上面输出)。"
1120
+ );
1121
+ }
1122
+ if (!opts.dryRun) {
543
1123
  console.log("安装完成。");
544
1124
  }
545
1125
  }
@@ -667,30 +1247,36 @@ function runCheck(opts) {
667
1247
 
668
1248
  let ok = true;
669
1249
  for (const name of REF_NAMES) {
670
- const installed = isFile(path.join(target, name, "SKILL.md"));
671
- if (installed) {
672
- console.log(" " + name + ":按名可解析(skill 已安装)");
1250
+ if (isFile(path.join(target, name, "SKILL.md"))) {
1251
+ console.log(" " + name + ":独立 skill 已安装(存在 SKILL.md;不判定来源/是否完整版)");
673
1252
  continue;
674
1253
  }
675
1254
  const fallbacks = REF_FALLBACKS[name] || [];
676
- const hit = fallbacks.find((file) => isFile(path.join(DEPS_DIR, file)));
677
- if (hit) {
678
- console.log(" " + name + ":ref 兜底可用(references/deps/" + hit + ")");
1255
+ // 组合能力要求「全部」引用文件都在;零引用不算可用。
1256
+ const missing = fallbacks.filter((file) => !isFile(path.join(DEPS_DIR, file)));
1257
+ if (fallbacks.length > 0 && missing.length === 0) {
1258
+ console.log(
1259
+ " " + name + ":内部回退可用(references/deps/" + fallbacks.join(" + ") + ")"
1260
+ );
679
1261
  continue;
680
1262
  }
681
1263
  ok = false;
682
- console.log(
683
- " " + name + ":无法解析(未安装,且缺少 ref 兜底:" +
684
- fallbacks.map((f) => "references/deps/" + f).join("、") +
685
- ")"
686
- );
1264
+ if (fallbacks.length === 0) {
1265
+ console.log(" " + name + ":不可用(无外部 SKILL.md,且没有内部回退映射)");
1266
+ } else {
1267
+ console.log(
1268
+ " " + name + ":不可用(无外部 SKILL.md,且内部回退不完整;缺少:" +
1269
+ missing.map((f) => "references/deps/" + f).join("、") +
1270
+ ")"
1271
+ );
1272
+ }
687
1273
  }
688
1274
 
689
1275
  if (ok) {
690
- console.log("全部依赖 skill 均已解析。");
1276
+ console.log("全部依赖均可解析(外部安装或内部回退)。");
691
1277
  process.exit(0);
692
1278
  } else {
693
- console.log("存在无法解析的依赖 skill。");
1279
+ console.log("存在无法解析的依赖。");
694
1280
  process.exit(1);
695
1281
  }
696
1282
  }