@netpilot/skills 0.3.2 → 0.6.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.
Files changed (84) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +25 -9
  5. package/CHANGELOG.md +27 -0
  6. package/README.md +78 -112
  7. package/THIRD_PARTY_NOTICES.md +1 -1
  8. package/agents/codex/architecture-designer.toml +2 -1
  9. package/agents/codex/backend-reviewer.toml +3 -1
  10. package/agents/codex/frontend-reviewer.toml +3 -1
  11. package/agents/codex/test-verifier.toml +4 -1
  12. package/bin/netpilot-skills.mjs +130 -6
  13. package/docs/agent-authoring.md +15 -5
  14. package/package.json +1 -1
  15. package/scripts/sync.mjs +1304 -101
  16. package/scripts/validate.mjs +68 -14
  17. package/skills/ask/SKILL.md +51 -47
  18. package/skills/ask/agents/openai.yaml +3 -3
  19. package/skills/code-review/SKILL.md +68 -52
  20. package/skills/code-review/agents/openai.yaml +2 -2
  21. package/skills/codebase-design/SKILL.md +87 -50
  22. package/skills/codebase-design/agents/openai.yaml +2 -2
  23. package/skills/codebase-design/references/deepening.md +60 -0
  24. package/skills/codebase-design/references/design-it-twice.md +54 -0
  25. package/skills/diagnosing-bugs/SKILL.md +124 -54
  26. package/skills/diagnosing-bugs/agents/openai.yaml +2 -2
  27. package/skills/diagnosing-bugs/scripts/hitl-loop.template.mjs +52 -0
  28. package/skills/domain-modeling/SKILL.md +65 -55
  29. package/skills/domain-modeling/agents/openai.yaml +2 -2
  30. package/skills/domain-modeling/references/adr-format.md +47 -0
  31. package/skills/domain-modeling/references/context-format.md +60 -0
  32. package/skills/domain-modeling/references/domain-docs.md +53 -0
  33. package/skills/grill-me/SKILL.md +13 -0
  34. package/skills/grill-me/agents/openai.yaml +6 -0
  35. package/skills/grill-with-docs/SKILL.md +16 -63
  36. package/skills/grill-with-docs/agents/openai.yaml +3 -3
  37. package/skills/grilling/SKILL.md +10 -54
  38. package/skills/grilling/agents/openai.yaml +2 -2
  39. package/skills/handoff/SKILL.md +24 -42
  40. package/skills/handoff/agents/openai.yaml +3 -3
  41. package/skills/implement/SKILL.md +18 -55
  42. package/skills/implement/agents/openai.yaml +3 -3
  43. package/skills/improve-codebase-architecture/SKILL.md +88 -0
  44. package/skills/improve-codebase-architecture/agents/openai.yaml +6 -0
  45. package/skills/improve-codebase-architecture/references/html-report.md +158 -0
  46. package/skills/prototype/SKILL.md +21 -53
  47. package/skills/prototype/agents/openai.yaml +2 -2
  48. package/skills/prototype/references/logic.md +87 -0
  49. package/skills/prototype/references/ui.md +108 -0
  50. package/skills/research/SKILL.md +9 -66
  51. package/skills/research/agents/openai.yaml +2 -2
  52. package/skills/resolving-merge-conflicts/SKILL.md +94 -0
  53. package/skills/resolving-merge-conflicts/agents/openai.yaml +6 -0
  54. package/skills/tdd/SKILL.md +30 -46
  55. package/skills/tdd/agents/openai.yaml +2 -2
  56. package/skills/tdd/references/mocking.md +70 -0
  57. package/skills/tdd/references/tests.md +95 -0
  58. package/skills/teach/SKILL.md +115 -47
  59. package/skills/teach/agents/openai.yaml +3 -3
  60. package/skills/teach/references/glossary-format.md +35 -10
  61. package/skills/teach/references/learning-record-format.md +41 -11
  62. package/skills/teach/references/mission-format.md +20 -17
  63. package/skills/teach/references/resources-format.md +34 -16
  64. package/skills/to-spec/SKILL.md +56 -51
  65. package/skills/to-spec/agents/openai.yaml +3 -3
  66. package/skills/to-tickets/SKILL.md +84 -45
  67. package/skills/to-tickets/agents/openai.yaml +3 -3
  68. package/skills/triage/SKILL.md +171 -0
  69. package/skills/triage/agents/openai.yaml +6 -0
  70. package/skills/triage/references/agent-brief.md +168 -0
  71. package/skills/triage/references/issue-tracker-github.md +42 -0
  72. package/skills/triage/references/issue-tracker-gitlab.md +42 -0
  73. package/skills/triage/references/issue-tracker-local.md +28 -0
  74. package/skills/triage/references/out-of-scope.md +113 -0
  75. package/skills/triage/references/project-config.md +57 -0
  76. package/skills/triage/references/triage-labels.md +13 -0
  77. package/skills/wayfinder/SKILL.md +158 -51
  78. package/skills/wayfinder/agents/openai.yaml +3 -3
  79. package/skills/writing-great-skills/SKILL.md +96 -54
  80. package/skills/writing-great-skills/agents/openai.yaml +3 -3
  81. package/skills/writing-great-skills/references/glossary.md +279 -0
  82. package/agents/codex/code-reader.toml +0 -11
  83. package/skills/grill/SKILL.md +0 -54
  84. package/skills/grill/agents/openai.yaml +0 -6
package/scripts/sync.mjs CHANGED
@@ -1,5 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { copyFile, lstat, mkdir, open, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
2
+ import {
3
+ copyFile,
4
+ link,
5
+ lstat,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ rm,
12
+ rmdir,
13
+ writeFile,
14
+ } from "node:fs/promises";
3
15
  import os from "node:os";
4
16
  import path from "node:path";
5
17
  import { fileURLToPath } from "node:url";
@@ -12,10 +24,22 @@ const HOST_DESTINATIONS = {
12
24
  };
13
25
 
14
26
  const CODEX_AGENT_DESTINATION = [".codex", "agents"];
27
+ const CURRENT_STATE_DESTINATION = [".agents", ".state", "skills-installer"];
28
+ const LEGACY_STATE_DESTINATION = [".netpilot-skills"];
29
+ const LAYOUT_LOCK_DIRECTORY = [".agents", ".state"];
30
+ const LAYOUT_LOCK_NAME = "skills-installer.layout.lock";
15
31
 
16
32
  const ALL_HOSTS = Object.freeze(Object.keys(HOST_DESTINATIONS));
17
33
  const ALL_COMPONENTS = Object.freeze(["skills", "agents"]);
34
+ const COMPONENT_ALIASES = Object.freeze({
35
+ "system-agents": "agents",
36
+ });
18
37
  const SUPPORT_FILE_NAMES = Object.freeze(["LICENSE", "THIRD_PARTY_NOTICES.md"]);
38
+ const MANAGED_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
39
+
40
+ function canonicalComponent(component) {
41
+ return COMPONENT_ALIASES[component] ?? component;
42
+ }
19
43
 
20
44
  export class SyncConflictError extends Error {
21
45
  constructor(conflicts) {
@@ -27,16 +51,30 @@ export class SyncConflictError extends Error {
27
51
 
28
52
  export function parseArgs(argv) {
29
53
  let apply = false;
54
+ let requestedMode = null;
30
55
  let homeDir = os.homedir();
31
- let hosts = [...ALL_HOSTS];
32
- let components = ["skills"];
56
+ let hosts = ["codex"];
57
+ let components = [...ALL_COMPONENTS];
58
+ let componentSpecified = false;
33
59
 
34
60
  for (let index = 0; index < argv.length; index += 1) {
35
61
  const argument = argv[index];
36
62
  if (argument === "--apply") {
63
+ if (requestedMode === "dry-run") {
64
+ throw new Error("--apply 与 --dry-run 不能同时使用");
65
+ }
66
+ requestedMode = "apply";
37
67
  apply = true;
38
68
  continue;
39
69
  }
70
+ if (argument === "--dry-run") {
71
+ if (requestedMode === "apply") {
72
+ throw new Error("--apply 与 --dry-run 不能同时使用");
73
+ }
74
+ requestedMode = "dry-run";
75
+ apply = false;
76
+ continue;
77
+ }
40
78
  if (argument === "--host") {
41
79
  const host = argv[index + 1];
42
80
  if (!host) throw new Error("--host 需要 codex、claude 或 all");
@@ -52,12 +90,15 @@ export function parseArgs(argv) {
52
90
  }
53
91
  if (argument === "--component") {
54
92
  const component = argv[index + 1];
55
- if (!component) throw new Error("--component 需要 skills、agents 或 all");
93
+ if (!component) {
94
+ throw new Error("--component 需要 skills、system-agents、agents 或 all");
95
+ }
56
96
  index += 1;
97
+ componentSpecified = true;
57
98
  if (component === "all") {
58
99
  components = [...ALL_COMPONENTS];
59
- } else if (ALL_COMPONENTS.includes(component)) {
60
- components = [component];
100
+ } else if (ALL_COMPONENTS.includes(canonicalComponent(component))) {
101
+ components = [canonicalComponent(component)];
61
102
  } else {
62
103
  throw new Error(`不支持的组件:${component}`);
63
104
  }
@@ -75,6 +116,10 @@ export function parseArgs(argv) {
75
116
  throw new Error(`未知参数:${argument}`);
76
117
  }
77
118
 
119
+ if (!componentSpecified && !hosts.includes("codex")) {
120
+ components = ["skills"];
121
+ }
122
+
78
123
  return { apply, homeDir, hosts, components };
79
124
  }
80
125
 
@@ -86,6 +131,35 @@ async function hashFile(filePath) {
86
131
  return sha256(await readFile(filePath));
87
132
  }
88
133
 
134
+ function projectCodexSkill(skillContent) {
135
+ const frontmatter = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/u);
136
+ if (!frontmatter) return skillContent;
137
+
138
+ const newline = frontmatter[0].includes("\r\n") ? "\r\n" : "\n";
139
+ const omittedKeys = new Set(["argument-hint", "disable-model-invocation"]);
140
+ const projectedLines = [];
141
+ let omittingValue = false;
142
+
143
+ for (const line of frontmatter[1].split(/\r?\n/u)) {
144
+ const key = /^([a-z0-9-]+):(?:\s|$)/iu.exec(line)?.[1] ?? null;
145
+ if (key) {
146
+ omittingValue = omittedKeys.has(key);
147
+ if (!omittingValue) projectedLines.push(line);
148
+ continue;
149
+ }
150
+ if (omittingValue && (/^[ \t]/u.test(line) || line.trim() === "")) continue;
151
+ omittingValue = false;
152
+ projectedLines.push(line);
153
+ }
154
+
155
+ const projectedFrontmatter = [
156
+ "---",
157
+ ...projectedLines,
158
+ "---",
159
+ ].join(newline);
160
+ return projectedFrontmatter + skillContent.slice(frontmatter[0].length);
161
+ }
162
+
89
163
  async function readManifest(manifestPath) {
90
164
  try {
91
165
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
@@ -228,12 +302,517 @@ async function inspectAncestorPath(basePath, targetDirectory) {
228
302
  return { safe: true };
229
303
  }
230
304
 
305
+ async function inspectHomeRoot(homeDir) {
306
+ const resolvedHome = path.resolve(homeDir);
307
+ const parsed = path.parse(resolvedHome);
308
+ let currentPath = parsed.root;
309
+ const segments = path.relative(parsed.root, resolvedHome).split(path.sep).filter(Boolean);
310
+
311
+ for (const segment of segments) {
312
+ currentPath = path.join(currentPath, segment);
313
+ try {
314
+ const stats = await lstat(currentPath);
315
+ if (stats.isSymbolicLink()) {
316
+ const isHomeRoot = path.resolve(currentPath) === resolvedHome;
317
+ return {
318
+ safe: false,
319
+ path: currentPath,
320
+ reason: isHomeRoot
321
+ ? "home 根目录是 symlink 或 junction"
322
+ : "home 路径的父目录是 symlink 或 junction",
323
+ };
324
+ }
325
+ if (!stats.isDirectory()) {
326
+ return {
327
+ safe: false,
328
+ path: currentPath,
329
+ reason:
330
+ path.resolve(currentPath) === resolvedHome
331
+ ? "home 根路径不是目录"
332
+ : "home 路径的父路径不是目录",
333
+ };
334
+ }
335
+ } catch (error) {
336
+ if (error.code === "ENOENT") return { safe: true };
337
+ throw error;
338
+ }
339
+ }
340
+
341
+ return { safe: true };
342
+ }
343
+
344
+ async function assertSafeHomeRoot(homeDir) {
345
+ const homeSafety = await inspectHomeRoot(homeDir);
346
+ if (homeSafety.safe) return;
347
+ throw new SyncConflictError([
348
+ {
349
+ host: "state",
350
+ component: "home",
351
+ assetName: "home",
352
+ skillName: "home",
353
+ relativePath: ".",
354
+ targetPath: homeDir,
355
+ action: "conflict",
356
+ reason: `${homeSafety.reason}:${homeSafety.path}`,
357
+ },
358
+ ]);
359
+ }
360
+
361
+ function resolveStatePaths(homeDir) {
362
+ const currentStateDir = path.join(homeDir, ...CURRENT_STATE_DESTINATION);
363
+ const legacyStateDir = path.join(homeDir, ...LEGACY_STATE_DESTINATION);
364
+ return {
365
+ currentStateDir,
366
+ currentManifestPath: path.join(currentStateDir, "manifest.json"),
367
+ legacyStateDir,
368
+ legacyManifestPath: path.join(legacyStateDir, "manifest.json"),
369
+ layoutLockDir: path.join(homeDir, ...LAYOUT_LOCK_DIRECTORY),
370
+ };
371
+ }
372
+
373
+ function stateLayoutConflict(homeDir, reason, targetPath = homeDir) {
374
+ return new SyncConflictError([
375
+ {
376
+ host: "state",
377
+ component: "layout",
378
+ assetName: "state-layout",
379
+ skillName: "state-layout",
380
+ relativePath: ".",
381
+ targetPath,
382
+ action: "conflict",
383
+ reason,
384
+ },
385
+ ]);
386
+ }
387
+
388
+ async function inspectStateDirectory(homeDir, targetPath) {
389
+ const ancestor = await inspectAncestorPath(homeDir, targetPath);
390
+ if (!ancestor.safe) {
391
+ throw stateLayoutConflict(
392
+ homeDir,
393
+ `${ancestor.reason}:${ancestor.path}`,
394
+ targetPath,
395
+ );
396
+ }
397
+ try {
398
+ const stats = await lstat(targetPath);
399
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
400
+ throw stateLayoutConflict(
401
+ homeDir,
402
+ "状态路径必须是非符号链接目录",
403
+ targetPath,
404
+ );
405
+ }
406
+ return {
407
+ exists: true,
408
+ path: targetPath,
409
+ identity: { dev: stats.dev, ino: stats.ino },
410
+ };
411
+ } catch (error) {
412
+ if (error.code === "ENOENT") return { exists: false, path: targetPath };
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ function sameStateDirectoryIdentity(left, right) {
418
+ return left?.dev === right?.dev && left?.ino === right?.ino;
419
+ }
420
+
421
+ async function inspectStateLayout(homeDir, paths = resolveStatePaths(homeDir)) {
422
+ const [current, legacy] = await Promise.all([
423
+ inspectStateDirectory(homeDir, paths.currentStateDir),
424
+ inspectStateDirectory(homeDir, paths.legacyStateDir),
425
+ ]);
426
+ if (current.exists && legacy.exists) {
427
+ const legacyEntries = await readdir(legacy.path);
428
+ const latestLegacy = await inspectStateDirectory(homeDir, legacy.path);
429
+ if (
430
+ legacyEntries.length === 0 &&
431
+ latestLegacy.exists &&
432
+ sameStateDirectoryIdentity(legacy.identity, latestLegacy.identity)
433
+ ) {
434
+ return {
435
+ kind: "current-with-empty-legacy",
436
+ current,
437
+ legacy: latestLegacy,
438
+ paths,
439
+ };
440
+ }
441
+ throw stateLayoutConflict(
442
+ homeDir,
443
+ "新旧状态目录同时存在,拒绝自动合并 ownership",
444
+ paths.currentStateDir,
445
+ );
446
+ }
447
+ return {
448
+ kind: current.exists ? "current" : legacy.exists ? "legacy" : "fresh",
449
+ current,
450
+ legacy,
451
+ paths,
452
+ };
453
+ }
454
+
455
+ function parseLockRecord(content) {
456
+ const trimmed = content.trim();
457
+ if (/^[1-9][0-9]*$/u.test(trimmed)) {
458
+ return { schemaVersion: 0, pid: Number(trimmed), owner: `legacy-${trimmed}` };
459
+ }
460
+ try {
461
+ const record = JSON.parse(trimmed);
462
+ if (
463
+ record.schemaVersion !== 1 ||
464
+ !Number.isSafeInteger(record.pid) ||
465
+ record.pid <= 0 ||
466
+ typeof record.owner !== "string" ||
467
+ record.owner.length < 8 ||
468
+ typeof record.createdAt !== "string"
469
+ ) {
470
+ return null;
471
+ }
472
+ return record;
473
+ } catch {
474
+ return null;
475
+ }
476
+ }
477
+
478
+ function isProcessAlive(pid) {
479
+ if (pid === process.pid) return true;
480
+ try {
481
+ process.kill(pid, 0);
482
+ return true;
483
+ } catch (error) {
484
+ if (error.code === "ESRCH" || error.code === "EINVAL") return false;
485
+ return true;
486
+ }
487
+ }
488
+
489
+ async function moveLockForCompare({ lockPath, expectedContent, stateDir, purpose }) {
490
+ const movedPath = path.join(stateDir, `${purpose}-${randomUUID()}.lock`);
491
+ try {
492
+ await rename(lockPath, movedPath);
493
+ } catch (error) {
494
+ if (error.code === "ENOENT") return null;
495
+ throw error;
496
+ }
497
+
498
+ const movedContent = await readFile(movedPath, "utf8");
499
+ if (movedContent === expectedContent) return movedPath;
500
+
501
+ try {
502
+ await restoreQuarantinedRemoval(lockPath, movedPath);
503
+ } catch (restoreError) {
504
+ restoreError.preserveEvidence = true;
505
+ throw restoreError;
506
+ }
507
+ const error = new Error(`同步锁在回收期间发生变化:${lockPath}`);
508
+ error.preserveEvidence = true;
509
+ throw error;
510
+ }
511
+
512
+ async function acquireSyncLock({
513
+ homeDir,
514
+ stateDir,
515
+ lockName = "sync.lock",
516
+ beforeLockPublish,
517
+ }) {
518
+ let stateSafety = await inspectAncestorPath(homeDir, stateDir);
519
+ if (!stateSafety.safe) {
520
+ throw new SyncConflictError([
521
+ {
522
+ host: "state",
523
+ component: "lock",
524
+ assetName: "sync",
525
+ skillName: "sync",
526
+ relativePath: lockName,
527
+ targetPath: path.join(stateDir, lockName),
528
+ action: "conflict",
529
+ reason: `${stateSafety.reason}:${stateSafety.path}`,
530
+ },
531
+ ]);
532
+ }
533
+ await mkdir(stateDir, { recursive: true });
534
+ stateSafety = await inspectAncestorPath(homeDir, stateDir);
535
+ if (!stateSafety.safe) {
536
+ throw stateLayoutConflict(
537
+ homeDir,
538
+ `${stateSafety.reason}:${stateSafety.path}`,
539
+ stateDir,
540
+ );
541
+ }
542
+
543
+ const lockPath = path.join(stateDir, lockName);
544
+ const owner = randomUUID();
545
+ const recordContent = `${JSON.stringify({
546
+ schemaVersion: 1,
547
+ pid: process.pid,
548
+ owner,
549
+ createdAt: new Date().toISOString(),
550
+ })}\n`;
551
+ const stagedLockPath = path.join(
552
+ stateDir,
553
+ `${lockName.replaceAll(".", "-")}-${owner}.tmp`,
554
+ );
555
+ await writeFile(stagedLockPath, recordContent, { encoding: "utf8", flag: "wx" });
556
+ if ((await readFile(stagedLockPath, "utf8")) !== recordContent) {
557
+ await rm(stagedLockPath, { force: true });
558
+ throw new Error(`同步锁记录暂存校验失败:${stagedLockPath}`);
559
+ }
560
+
561
+ try {
562
+ await beforeLockPublish?.();
563
+ for (let attempt = 0; attempt < 3; attempt += 1) {
564
+ try {
565
+ await link(stagedLockPath, lockPath);
566
+ await rm(stagedLockPath).catch(() => {});
567
+ return { lockPath, recordContent, stateDir };
568
+ } catch (error) {
569
+ if (error.code !== "EEXIST") throw error;
570
+
571
+ let existingContent;
572
+ try {
573
+ existingContent = await readFile(lockPath, "utf8");
574
+ } catch (readError) {
575
+ if (readError.code === "ENOENT") continue;
576
+ throw readError;
577
+ }
578
+ const existingRecord = parseLockRecord(existingContent);
579
+ if (!existingRecord) {
580
+ throw new Error(
581
+ `同步锁格式无法验证:${lockPath}。确认没有安装进程后再手工删除该文件。`,
582
+ );
583
+ }
584
+ if (isProcessAlive(existingRecord.pid)) {
585
+ throw new Error(
586
+ `已有同步进程持有锁:${lockPath}(PID ${existingRecord.pid})`,
587
+ );
588
+ }
589
+
590
+ const stalePath = await moveLockForCompare({
591
+ lockPath,
592
+ expectedContent: existingContent,
593
+ stateDir,
594
+ purpose: "stale-sync-lock",
595
+ });
596
+ if (!stalePath) continue;
597
+ await rm(stalePath);
598
+ }
599
+ }
600
+
601
+ throw new Error(`无法安全获取同步锁:${lockPath}`);
602
+ } catch (error) {
603
+ await rm(stagedLockPath, { force: true }).catch(() => {});
604
+ throw error;
605
+ }
606
+ }
607
+
608
+ async function releaseSyncLock(lock) {
609
+ const ownedPath = await moveLockForCompare({
610
+ lockPath: lock.lockPath,
611
+ expectedContent: lock.recordContent,
612
+ stateDir: lock.stateDir,
613
+ purpose: "released-sync-lock",
614
+ });
615
+ if (!ownedPath) {
616
+ const error = new Error(`同步锁在释放前已消失:${lock.lockPath}`);
617
+ error.preserveEvidence = true;
618
+ throw error;
619
+ }
620
+ await rm(ownedPath);
621
+ }
622
+
623
+ function conflictForOperation(operation, reason) {
624
+ return new SyncConflictError([
625
+ {
626
+ ...operation,
627
+ action: "conflict",
628
+ reason,
629
+ },
630
+ ]);
631
+ }
632
+
633
+ async function assertSafeOperationTarget(operation, homeDir) {
634
+ const targetParent = path.dirname(operation.targetPath);
635
+ const ancestor = await inspectAncestorPath(homeDir, targetParent);
636
+ if (!ancestor.safe) {
637
+ throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
638
+ }
639
+
640
+ const realHome = await realpath(homeDir);
641
+ const realParent = await realpath(targetParent);
642
+ const relativeParent = path.relative(realHome, realParent);
643
+ if (relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) {
644
+ throw conflictForOperation(operation, `目标父目录解析到 home 之外:${realParent}`);
645
+ }
646
+ }
647
+
648
+ async function prepareSafeTargetParent(operation, homeDir) {
649
+ const targetParent = path.dirname(operation.targetPath);
650
+ const ancestor = await inspectAncestorPath(homeDir, targetParent);
651
+ if (!ancestor.safe) {
652
+ throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
653
+ }
654
+ await mkdir(targetParent, { recursive: true });
655
+ await assertSafeOperationTarget(operation, homeDir);
656
+ }
657
+
658
+ async function assertSameTransactionDevice(operation, stageRoot) {
659
+ const [stageStats, targetParentStats] = await Promise.all([
660
+ lstat(stageRoot),
661
+ lstat(path.dirname(operation.targetPath)),
662
+ ]);
663
+ if (stageStats.dev !== targetParentStats.dev) {
664
+ throw conflictForOperation(
665
+ operation,
666
+ "目标与事务 staging 不在同一文件系统;无法保证原子提交与回滚",
667
+ );
668
+ }
669
+ }
670
+
671
+ async function linkStagedFileNoOverwrite({ operation, stagedPath }) {
672
+ try {
673
+ await link(stagedPath, operation.targetPath);
674
+ } catch (error) {
675
+ if (error.code === "EEXIST") {
676
+ throw conflictForOperation(operation, "目标在最终提交前被创建");
677
+ }
678
+ if (error.code === "EXDEV") {
679
+ throw conflictForOperation(
680
+ operation,
681
+ "目标与事务 staging 不在同一文件系统;无法原子发布",
682
+ );
683
+ }
684
+ throw error;
685
+ }
686
+ }
687
+
231
688
  function manifestKey(host, component, assetName, relativePath) {
232
689
  if (component === "skills") return `${host}/${assetName}/${relativePath}`;
233
690
  if (component === "agents") return `${host}/agents/${assetName}/${relativePath}`;
234
691
  return `${host}/${assetName}/${relativePath}`;
235
692
  }
236
693
 
694
+ function resolveManagedTarget(manifestKeyValue, homeDir, stateDir) {
695
+ const segments = manifestKeyValue.split("/");
696
+ if (
697
+ segments.some(
698
+ (segment) =>
699
+ segment.length === 0 ||
700
+ segment === "." ||
701
+ segment === ".." ||
702
+ segment.includes("\\") ||
703
+ /[\u0000-\u001f:*?"<>|]/u.test(segment) ||
704
+ /[. ]$/u.test(segment),
705
+ )
706
+ ) {
707
+ return null;
708
+ }
709
+
710
+ if (
711
+ segments[0] === "state" &&
712
+ segments[1] === "notices" &&
713
+ segments.length === 3 &&
714
+ SUPPORT_FILE_NAMES.includes(segments[2])
715
+ ) {
716
+ const relativeSegments = segments.slice(2);
717
+ return {
718
+ host: "state",
719
+ component: "support",
720
+ assetName: "notices",
721
+ skillName: "notices",
722
+ relativePath: relativeSegments.join("/"),
723
+ targetPath: path.join(stateDir, "notices", ...relativeSegments),
724
+ pruneRoot: path.join(stateDir, "notices"),
725
+ };
726
+ }
727
+
728
+ if (segments[0] === "codex" && segments[1] === "agents") {
729
+ if (
730
+ segments.length !== 4 ||
731
+ !MANAGED_NAME_PATTERN.test(segments[2]) ||
732
+ segments[3] !== `${segments[2]}.toml`
733
+ ) {
734
+ return null;
735
+ }
736
+ const relativeSegments = segments.slice(3);
737
+ return {
738
+ host: "codex",
739
+ component: "agents",
740
+ assetName: segments[2],
741
+ skillName: segments[2],
742
+ relativePath: relativeSegments.join("/"),
743
+ targetPath: path.join(homeDir, ...CODEX_AGENT_DESTINATION, ...relativeSegments),
744
+ pruneRoot: path.join(homeDir, ...CODEX_AGENT_DESTINATION),
745
+ };
746
+ }
747
+
748
+ if (
749
+ segments[0] in HOST_DESTINATIONS &&
750
+ segments.length >= 3 &&
751
+ MANAGED_NAME_PATTERN.test(segments[1])
752
+ ) {
753
+ const relativeSegments = segments.slice(2);
754
+ return {
755
+ host: segments[0],
756
+ component: "skills",
757
+ assetName: segments[1],
758
+ skillName: segments[1],
759
+ relativePath: relativeSegments.join("/"),
760
+ targetPath: path.join(
761
+ homeDir,
762
+ ...HOST_DESTINATIONS[segments[0]],
763
+ segments[1],
764
+ ...relativeSegments,
765
+ ),
766
+ pruneRoot: path.join(homeDir, ...HOST_DESTINATIONS[segments[0]]),
767
+ };
768
+ }
769
+
770
+ return null;
771
+ }
772
+
773
+ function targetIdentity(targetPath) {
774
+ const resolved = path.resolve(targetPath);
775
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
776
+ }
777
+
778
+ function collectManifestTargetConflicts(manifest, homeDir, stateDir) {
779
+ const conflicts = [];
780
+ const targetOwners = new Map();
781
+
782
+ for (const key of Object.keys(manifest.files)) {
783
+ const target = resolveManagedTarget(key, homeDir, stateDir);
784
+ if (!target) {
785
+ conflicts.push({
786
+ host: "state",
787
+ component: "manifest",
788
+ assetName: key,
789
+ skillName: key,
790
+ relativePath: key,
791
+ targetPath: path.join(stateDir, "manifest.json"),
792
+ action: "conflict",
793
+ reason: `manifest key 非规范或不受支持:${key}`,
794
+ manifestKey: key,
795
+ });
796
+ continue;
797
+ }
798
+
799
+ const identity = targetIdentity(target.targetPath);
800
+ const existingKey = targetOwners.get(identity);
801
+ if (existingKey && existingKey !== key) {
802
+ conflicts.push({
803
+ ...target,
804
+ action: "conflict",
805
+ reason: `manifest key 目标冲突:${existingKey} 与 ${key}`,
806
+ manifestKey: key,
807
+ });
808
+ continue;
809
+ }
810
+ targetOwners.set(identity, key);
811
+ }
812
+
813
+ return conflicts;
814
+ }
815
+
237
816
  async function planFileOperation({
238
817
  host,
239
818
  component,
@@ -241,12 +820,15 @@ async function planFileOperation({
241
820
  skillName = assetName,
242
821
  relativePath,
243
822
  sourcePath,
823
+ sourceContent = null,
244
824
  targetPath,
245
825
  manifest,
246
826
  homeDir,
247
827
  }) {
248
828
  const key = manifestKey(host, component, assetName, relativePath);
249
- const sourceHash = await hashFile(sourcePath);
829
+ const sourceHash = sourceContent === null
830
+ ? await hashFile(sourcePath)
831
+ : sha256(Buffer.from(sourceContent, "utf8"));
250
832
  const ancestor = await inspectAncestorPath(homeDir, path.dirname(targetPath));
251
833
  const target = ancestor.safe
252
834
  ? await inspectTarget(targetPath)
@@ -282,6 +864,7 @@ async function planFileOperation({
282
864
  skillName,
283
865
  relativePath,
284
866
  sourcePath,
867
+ sourceContent,
285
868
  targetPath,
286
869
  sourceHash,
287
870
  targetHash: target.hash,
@@ -292,6 +875,42 @@ async function planFileOperation({
292
875
  };
293
876
  }
294
877
 
878
+ async function planRemovalOperation({
879
+ manifestKeyValue,
880
+ previousHash,
881
+ target,
882
+ homeDir,
883
+ }) {
884
+ const ancestor = await inspectAncestorPath(homeDir, path.dirname(target.targetPath));
885
+ const installed = ancestor.safe
886
+ ? await inspectTarget(target.targetPath)
887
+ : { exists: false, regularFile: false, hash: null };
888
+ let action = "remove";
889
+ let reason = installed.exists ? "源中已移除" : "源中和安装目录中均已不存在,仅清理记录";
890
+
891
+ if (!ancestor.safe) {
892
+ action = "conflict";
893
+ reason = `${ancestor.reason}:${ancestor.path}`;
894
+ } else if (installed.exists && !installed.regularFile) {
895
+ action = "conflict";
896
+ reason = "源中已移除,但目标路径不是普通文件";
897
+ } else if (installed.exists && installed.hash !== previousHash) {
898
+ action = "conflict";
899
+ reason = "源中已移除,但文件在上次同步后被修改";
900
+ }
901
+
902
+ return {
903
+ ...target,
904
+ sourcePath: null,
905
+ sourceHash: null,
906
+ targetHash: installed.hash,
907
+ previousHash,
908
+ action,
909
+ reason,
910
+ manifestKey: manifestKeyValue,
911
+ };
912
+ }
913
+
295
914
  async function collectChangedTargetConflicts(operations, homeDir) {
296
915
  const conflicts = [];
297
916
  for (const operation of operations) {
@@ -318,28 +937,227 @@ async function collectChangedTargetConflicts(operations, homeDir) {
318
937
  (!target.regularFile || target.hash !== operation.sourceHash)
319
938
  ) {
320
939
  conflicts.push({ ...operation, action: "conflict", reason: "目标在预检后发生变化" });
940
+ } else if (
941
+ operation.action === "remove" &&
942
+ ((operation.targetHash === null && target.exists) ||
943
+ (operation.targetHash !== null &&
944
+ (!target.regularFile || target.hash !== operation.targetHash)))
945
+ ) {
946
+ conflicts.push({ ...operation, action: "conflict", reason: "待移除目标在预检后发生变化" });
321
947
  }
322
948
  }
323
949
  return conflicts;
324
950
  }
325
951
 
326
- async function applyOperationsTransactional({ operations, homeDir, manifestPath }) {
327
- const stateDir = path.dirname(manifestPath);
328
- await mkdir(stateDir, { recursive: true });
329
- const stateSafety = await inspectAncestorPath(homeDir, stateDir);
330
- if (!stateSafety.safe) {
952
+ async function collectFinalTargetConflicts(operations, homeDir) {
953
+ const conflicts = [];
954
+ for (const operation of operations) {
955
+ if (operation.action === "conflict") continue;
956
+ const ancestor = await inspectAncestorPath(homeDir, path.dirname(operation.targetPath));
957
+ if (!ancestor.safe) {
958
+ conflicts.push({
959
+ ...operation,
960
+ action: "conflict",
961
+ reason: `${ancestor.reason}:${ancestor.path}`,
962
+ });
963
+ continue;
964
+ }
965
+
966
+ const target = await inspectTarget(operation.targetPath);
967
+ if (operation.action === "remove") {
968
+ if (target.exists) {
969
+ conflicts.push({
970
+ ...operation,
971
+ action: "conflict",
972
+ reason: "已移除目标在 manifest 提交前重新出现",
973
+ });
974
+ }
975
+ continue;
976
+ }
977
+ if (!target.regularFile || target.hash !== operation.sourceHash) {
978
+ conflicts.push({
979
+ ...operation,
980
+ action: "conflict",
981
+ reason: "目标在发布后、manifest 提交前发生变化",
982
+ });
983
+ }
984
+ }
985
+ return conflicts;
986
+ }
987
+
988
+ async function removeEmptyParentDirectories(startDirectory, stopDirectory) {
989
+ const stop = path.resolve(stopDirectory);
990
+ let current = path.resolve(startDirectory);
991
+
992
+ while (current !== stop) {
993
+ const relative = path.relative(stop, current);
994
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
995
+ throw new Error(`拒绝清理受管理根目录之外的目录:${current}`);
996
+ }
997
+
998
+ try {
999
+ await rmdir(current);
1000
+ } catch (error) {
1001
+ if (error.code === "ENOENT") {
1002
+ current = path.dirname(current);
1003
+ continue;
1004
+ }
1005
+ if (error.code === "ENOTEMPTY" || error.code === "EEXIST") return;
1006
+ throw error;
1007
+ }
1008
+ current = path.dirname(current);
1009
+ }
1010
+ }
1011
+
1012
+ export async function restoreQuarantinedRemoval(targetPath, backupPath) {
1013
+ await mkdir(path.dirname(targetPath), { recursive: true });
1014
+ try {
1015
+ await link(backupPath, targetPath);
1016
+ } catch (error) {
1017
+ const restoreError = new Error(
1018
+ `无法无覆盖地恢复隔离文件;隔离副本保留在:${backupPath}`,
1019
+ { cause: error },
1020
+ );
1021
+ restoreError.preserveEvidence = true;
1022
+ throw restoreError;
1023
+ }
1024
+ try {
1025
+ await rm(backupPath);
1026
+ } catch (error) {
1027
+ const cleanupError = new Error(
1028
+ `目标已恢复,但隔离副本未能清理:${backupPath}`,
1029
+ { cause: error },
1030
+ );
1031
+ cleanupError.preserveEvidence = true;
1032
+ throw cleanupError;
1033
+ }
1034
+ }
1035
+
1036
+ export async function quarantineManagedRemoval({
1037
+ targetPath,
1038
+ backupPath,
1039
+ expectedHash,
1040
+ }) {
1041
+ await mkdir(path.dirname(backupPath), { recursive: true });
1042
+ try {
1043
+ await rename(targetPath, backupPath);
1044
+ } catch (error) {
331
1045
  throw new SyncConflictError([
332
1046
  {
333
1047
  host: "state",
334
- skillName: "manifest",
335
- relativePath: "manifest.json",
336
- targetPath: manifestPath,
1048
+ component: "removal",
1049
+ assetName: path.basename(targetPath),
1050
+ skillName: path.basename(targetPath),
1051
+ relativePath: path.basename(targetPath),
1052
+ targetPath,
337
1053
  action: "conflict",
338
- reason: `${stateSafety.reason}:${stateSafety.path}`,
1054
+ reason: `无法原子隔离待移除文件:${error.message}`,
1055
+ },
1056
+ ]);
1057
+ }
1058
+
1059
+ let actualHash;
1060
+ try {
1061
+ actualHash = await hashFile(backupPath);
1062
+ } catch (error) {
1063
+ try {
1064
+ await restoreQuarantinedRemoval(targetPath, backupPath);
1065
+ } catch (restoreError) {
1066
+ restoreError.cause = error;
1067
+ throw restoreError;
1068
+ }
1069
+ throw error;
1070
+ }
1071
+
1072
+ if (actualHash !== expectedHash) {
1073
+ try {
1074
+ await restoreQuarantinedRemoval(targetPath, backupPath);
1075
+ } catch (restoreError) {
1076
+ const conflict = new SyncConflictError([
1077
+ {
1078
+ host: "state",
1079
+ component: "removal",
1080
+ assetName: path.basename(targetPath),
1081
+ skillName: path.basename(targetPath),
1082
+ relativePath: path.basename(targetPath),
1083
+ targetPath,
1084
+ action: "conflict",
1085
+ reason: `待移除文件已变化且无法安全恢复;隔离副本保留在 ${backupPath}`,
1086
+ },
1087
+ ]);
1088
+ conflict.cause = restoreError;
1089
+ conflict.preserveEvidence = true;
1090
+ throw conflict;
1091
+ }
1092
+ throw new SyncConflictError([
1093
+ {
1094
+ host: "state",
1095
+ component: "removal",
1096
+ assetName: path.basename(targetPath),
1097
+ skillName: path.basename(targetPath),
1098
+ relativePath: path.basename(targetPath),
1099
+ targetPath,
1100
+ action: "conflict",
1101
+ reason: "待移除目标在最终原子隔离时发生变化",
339
1102
  },
340
1103
  ]);
341
1104
  }
342
1105
 
1106
+ return { backupPath, actualHash };
1107
+ }
1108
+
1109
+ async function rollbackAppliedOperation({ entry, stageRoot, homeDir }) {
1110
+ const { operation, backupPath, published } = entry;
1111
+ await prepareSafeTargetParent(operation, homeDir);
1112
+ await assertSameTransactionDevice(operation, stageRoot);
1113
+
1114
+ let publishedBackupPath = null;
1115
+ const target = await inspectTarget(operation.targetPath);
1116
+ if (published && target.exists) {
1117
+ if (!target.regularFile || target.hash !== operation.sourceHash) {
1118
+ const error = new Error(
1119
+ `回滚拒绝覆盖事务期间变化的目标:${operation.targetPath}`,
1120
+ );
1121
+ error.preserveEvidence = true;
1122
+ throw error;
1123
+ }
1124
+ publishedBackupPath = path.join(
1125
+ stageRoot,
1126
+ "rollback-current",
1127
+ ...operation.manifestKey.split("/"),
1128
+ );
1129
+ try {
1130
+ await quarantineManagedRemoval({
1131
+ targetPath: operation.targetPath,
1132
+ backupPath: publishedBackupPath,
1133
+ expectedHash: operation.sourceHash,
1134
+ });
1135
+ } catch (error) {
1136
+ error.preserveEvidence = true;
1137
+ throw error;
1138
+ }
1139
+ }
1140
+
1141
+ if (operation.action === "create") {
1142
+ if (publishedBackupPath) await rm(publishedBackupPath);
1143
+ return;
1144
+ }
1145
+
1146
+ if (backupPath) {
1147
+ await restoreQuarantinedRemoval(operation.targetPath, backupPath);
1148
+ }
1149
+ if (publishedBackupPath) await rm(publishedBackupPath);
1150
+ }
1151
+
1152
+ async function applyOperationsTransactional({
1153
+ operations,
1154
+ homeDir,
1155
+ manifestPath,
1156
+ expectedManifestHash,
1157
+ transactionHooks = {},
1158
+ }) {
1159
+ await assertSafeHomeRoot(homeDir);
1160
+ const stateDir = path.dirname(manifestPath);
343
1161
  const stagingDir = path.join(stateDir, "staging");
344
1162
  const stagingSafety = await inspectAncestorPath(homeDir, stagingDir);
345
1163
  if (!stagingSafety.safe) {
@@ -355,29 +1173,10 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
355
1173
  ]);
356
1174
  }
357
1175
 
358
- const lockPath = path.join(stateDir, "sync.lock");
359
- let lockHandle;
360
- try {
361
- lockHandle = await open(lockPath, "wx");
362
- } catch (error) {
363
- if (error.code === "EEXIST") throw new Error(`已有同步进程持有锁:${lockPath}`);
364
- throw error;
365
- }
366
- try {
367
- await lockHandle.writeFile(`${process.pid}\n`, "utf8");
368
- } catch (error) {
369
- await lockHandle.close();
370
- await rm(lockPath, { force: true });
371
- throw error;
372
- }
373
-
374
- const stageRoot = path.join(stateDir, "staging", randomUUID());
1176
+ const stageRoot = path.join(stagingDir, randomUUID());
375
1177
  const stagedPaths = new Map();
376
1178
  const applied = [];
377
1179
  const rollbackErrors = [];
378
- let manifestBackupPath = null;
379
- let manifestExisted = false;
380
- let manifestChanged = false;
381
1180
  let preserveStage = false;
382
1181
 
383
1182
  try {
@@ -408,90 +1207,205 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
408
1207
  if (operation.action !== "create" && operation.action !== "update") continue;
409
1208
  const stagedPath = path.join(stageRoot, "next", ...operation.manifestKey.split("/"));
410
1209
  await mkdir(path.dirname(stagedPath), { recursive: true });
411
- await copyFile(operation.sourcePath, stagedPath);
1210
+ if (operation.sourceContent === null) {
1211
+ await copyFile(operation.sourcePath, stagedPath);
1212
+ } else {
1213
+ await writeFile(stagedPath, operation.sourceContent, "utf8");
1214
+ }
412
1215
  if ((await hashFile(stagedPath)) !== operation.sourceHash) {
413
1216
  throw new Error(`暂存文件校验失败:${operation.sourcePath}`);
414
1217
  }
415
1218
  stagedPaths.set(operation.manifestKey, stagedPath);
416
1219
  }
417
1220
 
418
- const lockedManifest = await readManifest(manifestPath);
419
1221
  const manifestTarget = await inspectTarget(manifestPath);
420
1222
  if (manifestTarget.exists && !manifestTarget.regularFile) {
421
1223
  throw new Error(`同步 manifest 不是普通文件:${manifestPath}`);
422
1224
  }
423
- if (manifestTarget.exists) {
424
- manifestExisted = true;
425
- manifestBackupPath = path.join(stageRoot, "backups", "manifest.json");
426
- await mkdir(path.dirname(manifestBackupPath), { recursive: true });
427
- await copyFile(manifestPath, manifestBackupPath);
1225
+ if (manifestTarget.hash !== expectedManifestHash) {
1226
+ throw conflictForOperation(
1227
+ {
1228
+ host: "state",
1229
+ component: "manifest",
1230
+ assetName: "manifest",
1231
+ skillName: "manifest",
1232
+ relativePath: "manifest.json",
1233
+ targetPath: manifestPath,
1234
+ },
1235
+ "同步 manifest 在规划后发生变化",
1236
+ );
1237
+ }
1238
+ const lockedManifest = await readManifest(manifestPath);
1239
+ const lockedManifestConflicts = collectManifestTargetConflicts(
1240
+ lockedManifest,
1241
+ homeDir,
1242
+ stateDir,
1243
+ );
1244
+ if (lockedManifestConflicts.length > 0) {
1245
+ throw new SyncConflictError(lockedManifestConflicts);
428
1246
  }
429
1247
 
430
1248
  const changedTargetConflicts = await collectChangedTargetConflicts(operations, homeDir);
431
1249
  if (changedTargetConflicts.length > 0) throw new SyncConflictError(changedTargetConflicts);
432
1250
 
433
1251
  for (const operation of operations) {
434
- if (operation.action !== "create" && operation.action !== "update") continue;
1252
+ if (!["create", "update", "remove"].includes(operation.action)) continue;
1253
+ if (operation.action === "remove" && operation.targetHash === null) continue;
1254
+
1255
+ await prepareSafeTargetParent(operation, homeDir);
1256
+ await transactionHooks.beforeOperationCommit?.(operation);
1257
+ await assertSafeOperationTarget(operation, homeDir);
1258
+ await assertSameTransactionDevice(operation, stageRoot);
1259
+
435
1260
  let backupPath = null;
436
- if (operation.action === "update") {
1261
+ let entry = null;
1262
+ if (operation.action === "update" || operation.action === "remove") {
437
1263
  backupPath = path.join(stageRoot, "backups", ...operation.manifestKey.split("/"));
438
- await mkdir(path.dirname(backupPath), { recursive: true });
439
- await copyFile(operation.targetPath, backupPath);
440
- if ((await hashFile(backupPath)) !== operation.targetHash) {
441
- throw new Error(`目标备份校验失败:${operation.targetPath}`);
442
- }
1264
+ await quarantineManagedRemoval({
1265
+ targetPath: operation.targetPath,
1266
+ backupPath,
1267
+ expectedHash: operation.targetHash,
1268
+ });
1269
+ entry = { operation, backupPath, published: false };
1270
+ applied.push(entry);
1271
+ await transactionHooks.afterOperationQuarantine?.(operation);
443
1272
  }
1273
+ if (operation.action === "remove") continue;
444
1274
 
445
- applied.push({ operation, backupPath });
446
- await mkdir(path.dirname(operation.targetPath), { recursive: true });
447
- await copyFile(stagedPaths.get(operation.manifestKey), operation.targetPath);
1275
+ await assertSafeOperationTarget(operation, homeDir);
1276
+ await assertSameTransactionDevice(operation, stageRoot);
1277
+ await linkStagedFileNoOverwrite({
1278
+ operation,
1279
+ stagedPath: stagedPaths.get(operation.manifestKey),
1280
+ });
1281
+ if (!entry) {
1282
+ entry = { operation, backupPath: null, published: true };
1283
+ applied.push(entry);
1284
+ } else {
1285
+ entry.published = true;
1286
+ }
448
1287
  if ((await hashFile(operation.targetPath)) !== operation.sourceHash) {
449
1288
  throw new Error(`同步后校验失败:${operation.targetPath}`);
450
1289
  }
1290
+ await transactionHooks.afterOperationPublish?.(operation);
451
1291
  }
452
1292
 
453
1293
  const nextFiles = { ...lockedManifest.files };
454
- for (const operation of operations) nextFiles[operation.manifestKey] = operation.sourceHash;
1294
+ for (const operation of operations) {
1295
+ if (operation.action === "remove") {
1296
+ delete nextFiles[operation.manifestKey];
1297
+ } else {
1298
+ nextFiles[operation.manifestKey] = operation.sourceHash;
1299
+ }
1300
+ }
455
1301
  const nextManifest = {
456
1302
  schemaVersion: 1,
457
1303
  managedBy: "netpilot-skills",
458
1304
  updatedAt: new Date().toISOString(),
459
- files: Object.fromEntries(Object.entries(nextFiles).sort(([left], [right]) => left.localeCompare(right))),
1305
+ files: Object.fromEntries(
1306
+ Object.entries(nextFiles).sort(([left], [right]) => left.localeCompare(right)),
1307
+ ),
460
1308
  };
461
1309
  const stagedManifestPath = path.join(stageRoot, "next-manifest.json");
462
- await mkdir(path.dirname(stagedManifestPath), { recursive: true });
463
1310
  await writeFile(stagedManifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, "utf8");
464
1311
  JSON.parse(await readFile(stagedManifestPath, "utf8"));
465
- manifestChanged = true;
466
- await copyFile(stagedManifestPath, manifestPath);
1312
+ const manifestOperation = {
1313
+ host: "state",
1314
+ component: "manifest",
1315
+ assetName: "manifest",
1316
+ skillName: "manifest",
1317
+ relativePath: "manifest.json",
1318
+ targetPath: manifestPath,
1319
+ manifestKey: "state/manifest.json",
1320
+ targetHash: manifestTarget.hash,
1321
+ sourceHash: await hashFile(stagedManifestPath),
1322
+ action: manifestTarget.exists ? "update" : "create",
1323
+ };
1324
+ await prepareSafeTargetParent(manifestOperation, homeDir);
1325
+ await transactionHooks.beforeOperationCommit?.(manifestOperation);
1326
+ await assertSafeOperationTarget(manifestOperation, homeDir);
1327
+ await assertSameTransactionDevice(manifestOperation, stageRoot);
1328
+ const finalTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
1329
+ if (finalTargetConflicts.length > 0) {
1330
+ throw new SyncConflictError(finalTargetConflicts);
1331
+ }
1332
+
1333
+ let manifestEntry;
1334
+ if (manifestOperation.action === "update") {
1335
+ const manifestBackupPath = path.join(stageRoot, "backups", "manifest.json");
1336
+ await quarantineManagedRemoval({
1337
+ targetPath: manifestPath,
1338
+ backupPath: manifestBackupPath,
1339
+ expectedHash: manifestOperation.targetHash,
1340
+ });
1341
+ manifestEntry = {
1342
+ operation: manifestOperation,
1343
+ backupPath: manifestBackupPath,
1344
+ published: false,
1345
+ };
1346
+ applied.push(manifestEntry);
1347
+ await transactionHooks.afterOperationQuarantine?.(manifestOperation);
1348
+ }
1349
+ await assertSafeOperationTarget(manifestOperation, homeDir);
1350
+ await assertSameTransactionDevice(manifestOperation, stageRoot);
1351
+ await linkStagedFileNoOverwrite({
1352
+ operation: manifestOperation,
1353
+ stagedPath: stagedManifestPath,
1354
+ });
1355
+ if (!manifestEntry) {
1356
+ manifestEntry = {
1357
+ operation: manifestOperation,
1358
+ backupPath: null,
1359
+ published: true,
1360
+ };
1361
+ applied.push(manifestEntry);
1362
+ } else {
1363
+ manifestEntry.published = true;
1364
+ }
1365
+ if ((await hashFile(manifestPath)) !== manifestOperation.sourceHash) {
1366
+ throw new Error(`同步后校验失败:${manifestPath}`);
1367
+ }
467
1368
  JSON.parse(await readFile(manifestPath, "utf8"));
468
- } catch (error) {
469
- for (const { operation, backupPath } of [...applied].reverse()) {
470
- try {
471
- if (backupPath) {
472
- await copyFile(backupPath, operation.targetPath);
473
- } else {
474
- await rm(operation.targetPath, { force: true });
475
- }
476
- } catch (rollbackError) {
477
- rollbackErrors.push(`${operation.targetPath}: ${rollbackError.message}`);
478
- }
1369
+ await transactionHooks.afterOperationPublish?.(manifestOperation);
1370
+ const postManifestTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
1371
+ if (postManifestTargetConflicts.length > 0) {
1372
+ throw new SyncConflictError(postManifestTargetConflicts);
1373
+ }
1374
+ const committedManifest = await inspectTarget(manifestPath);
1375
+ if (
1376
+ !committedManifest.regularFile ||
1377
+ committedManifest.hash !== manifestOperation.sourceHash
1378
+ ) {
1379
+ throw conflictForOperation(
1380
+ manifestOperation,
1381
+ "manifest 在最终提交后发生变化",
1382
+ );
479
1383
  }
480
1384
 
481
- if (manifestChanged) {
1385
+ for (const operation of operations) {
1386
+ if (
1387
+ operation.action === "remove" &&
1388
+ operation.targetHash !== null &&
1389
+ operation.pruneRoot
1390
+ ) {
1391
+ await removeEmptyParentDirectories(
1392
+ path.dirname(operation.targetPath),
1393
+ operation.pruneRoot,
1394
+ );
1395
+ }
1396
+ }
1397
+ } catch (error) {
1398
+ if (error.preserveEvidence) preserveStage = true;
1399
+ for (const entry of [...applied].reverse()) {
482
1400
  try {
483
- if (manifestExisted) {
484
- await copyFile(manifestBackupPath, manifestPath);
485
- } else {
486
- await rm(manifestPath, { force: true });
487
- }
1401
+ await rollbackAppliedOperation({ entry, stageRoot, homeDir });
488
1402
  } catch (rollbackError) {
489
- rollbackErrors.push(`${manifestPath}: ${rollbackError.message}`);
1403
+ preserveStage = true;
1404
+ rollbackErrors.push(`${entry.operation.targetPath}: ${rollbackError.message}`);
490
1405
  }
491
1406
  }
492
1407
 
493
1408
  if (rollbackErrors.length > 0) {
494
- preserveStage = true;
495
1409
  throw new Error(
496
1410
  `同步失败且回滚不完整;暂存保留在 ${stageRoot}\n${rollbackErrors.join("\n")}`,
497
1411
  { cause: error },
@@ -499,24 +1413,125 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
499
1413
  }
500
1414
  throw error;
501
1415
  } finally {
1416
+ if (!preserveStage) await rm(stageRoot, { recursive: true, force: true });
1417
+ }
1418
+ }
1419
+
1420
+ async function migrateLegacyState({
1421
+ homeDir,
1422
+ paths,
1423
+ transactionHooks,
1424
+ }) {
1425
+ let syncLock = await acquireSyncLock({
1426
+ homeDir,
1427
+ stateDir: paths.legacyStateDir,
1428
+ beforeLockPublish: transactionHooks.beforeLockPublish,
1429
+ });
1430
+ let moved = false;
1431
+ try {
1432
+ const freshLayout = await inspectStateLayout(homeDir, paths);
1433
+ if (freshLayout.kind !== "legacy") {
1434
+ throw stateLayoutConflict(
1435
+ homeDir,
1436
+ "获取旧状态锁后布局发生变化,拒绝继续迁移",
1437
+ paths.legacyStateDir,
1438
+ );
1439
+ }
1440
+ const lockedSourceIdentity = freshLayout.legacy.identity;
1441
+ const legacyManifest = await inspectTarget(paths.legacyManifestPath);
1442
+ if (!legacyManifest.exists || !legacyManifest.regularFile) {
1443
+ throw stateLayoutConflict(
1444
+ homeDir,
1445
+ "旧状态目录缺少可信的普通 manifest.json",
1446
+ paths.legacyManifestPath,
1447
+ );
1448
+ }
1449
+ await readManifest(paths.legacyManifestPath);
1450
+ await transactionHooks.beforeStateMigrationRename?.();
1451
+ const beforeRename = await inspectStateLayout(homeDir, paths);
1452
+ if (
1453
+ beforeRename.kind !== "legacy" ||
1454
+ !sameStateDirectoryIdentity(
1455
+ lockedSourceIdentity,
1456
+ beforeRename.legacy.identity,
1457
+ )
1458
+ ) {
1459
+ throw stateLayoutConflict(
1460
+ homeDir,
1461
+ "旧状态目录身份在迁移提交前发生变化",
1462
+ paths.currentStateDir,
1463
+ );
1464
+ }
1465
+ try {
1466
+ await rename(paths.legacyStateDir, paths.currentStateDir);
1467
+ } catch (error) {
1468
+ if (error.code === "EXDEV") {
1469
+ throw stateLayoutConflict(
1470
+ homeDir,
1471
+ "新旧状态目录不在同一文件系统,拒绝退化为非原子复制",
1472
+ paths.currentStateDir,
1473
+ );
1474
+ }
1475
+ if (["EEXIST", "ENOTEMPTY"].includes(error.code)) {
1476
+ throw stateLayoutConflict(
1477
+ homeDir,
1478
+ "新状态目录在原子迁移前已出现",
1479
+ paths.currentStateDir,
1480
+ );
1481
+ }
1482
+ throw error;
1483
+ }
1484
+ moved = true;
1485
+ syncLock = {
1486
+ ...syncLock,
1487
+ stateDir: paths.currentStateDir,
1488
+ lockPath: path.join(paths.currentStateDir, "sync.lock"),
1489
+ };
1490
+ const migratedLayout = await inspectStateLayout(homeDir, paths);
1491
+ if (
1492
+ migratedLayout.kind !== "current" ||
1493
+ !sameStateDirectoryIdentity(
1494
+ lockedSourceIdentity,
1495
+ migratedLayout.current.identity,
1496
+ )
1497
+ ) {
1498
+ const error = new Error("状态目录迁移后的目标身份不一致");
1499
+ error.preserveEvidence = true;
1500
+ throw error;
1501
+ }
1502
+ const migratedManifest = await inspectTarget(paths.currentManifestPath);
1503
+ if (
1504
+ !migratedManifest.regularFile ||
1505
+ migratedManifest.hash !== legacyManifest.hash
1506
+ ) {
1507
+ const error = new Error("状态目录迁移后 manifest digest 不一致");
1508
+ error.preserveEvidence = true;
1509
+ throw error;
1510
+ }
1511
+ await transactionHooks.afterStateMigrationRename?.();
1512
+ return syncLock;
1513
+ } catch (error) {
502
1514
  try {
503
- await lockHandle.close();
504
- } finally {
505
- await rm(lockPath, { force: true });
1515
+ await releaseSyncLock(syncLock);
1516
+ } catch (releaseError) {
1517
+ error.releaseError = releaseError;
1518
+ error.preserveEvidence = true;
506
1519
  }
507
- if (!preserveStage) await rm(stageRoot, { recursive: true, force: true });
1520
+ if (moved) error.stateMigrationCommitted = true;
1521
+ throw error;
508
1522
  }
509
1523
  }
510
1524
 
511
1525
  export async function syncSkills({
512
1526
  rootDir,
513
1527
  homeDir,
514
- hosts = [...ALL_HOSTS],
515
- components = ["skills"],
1528
+ hosts = ["codex"],
1529
+ components = [...ALL_COMPONENTS],
516
1530
  apply = false,
1531
+ transactionHooks = {},
517
1532
  }) {
518
1533
  const selectedHosts = [...new Set(hosts)];
519
- const selectedComponents = [...new Set(components)];
1534
+ const selectedComponents = [...new Set(components.map(canonicalComponent))];
520
1535
  for (const host of selectedHosts) {
521
1536
  if (!(host in HOST_DESTINATIONS)) throw new Error(`不支持的宿主:${host}`);
522
1537
  }
@@ -525,15 +1540,123 @@ export async function syncSkills({
525
1540
  }
526
1541
  if (
527
1542
  selectedComponents.includes("agents") &&
528
- (selectedHosts.length !== 1 || selectedHosts[0] !== "codex")
1543
+ !selectedHosts.includes("codex")
529
1544
  ) {
530
- throw new Error("Codex agents 组件只支持显式选择 --host codex");
1545
+ throw new Error("Codex agents 组件要求 --host 包含 codex");
531
1546
  }
532
1547
 
533
1548
  const resolvedRoot = path.resolve(rootDir);
534
1549
  const resolvedHome = path.resolve(homeDir);
535
- const manifestPath = path.join(resolvedHome, ".netpilot-skills", "manifest.json");
536
- const stateDir = path.dirname(manifestPath);
1550
+ await assertSafeHomeRoot(resolvedHome);
1551
+ const statePaths = resolveStatePaths(resolvedHome);
1552
+ let stateLayout = await inspectStateLayout(resolvedHome, statePaths);
1553
+ let stateDir =
1554
+ stateLayout.kind === "legacy"
1555
+ ? statePaths.legacyStateDir
1556
+ : statePaths.currentStateDir;
1557
+ let manifestPath =
1558
+ stateLayout.kind === "legacy"
1559
+ ? statePaths.legacyManifestPath
1560
+ : statePaths.currentManifestPath;
1561
+ let stateMigration =
1562
+ stateLayout.kind === "legacy"
1563
+ ? {
1564
+ action: "move",
1565
+ from: statePaths.legacyStateDir,
1566
+ to: statePaths.currentStateDir,
1567
+ }
1568
+ : null;
1569
+ let stateRepair =
1570
+ stateLayout.kind === "current-with-empty-legacy"
1571
+ ? {
1572
+ action: "remove-empty-legacy-state",
1573
+ target: statePaths.legacyStateDir,
1574
+ }
1575
+ : null;
1576
+ let layoutLock = null;
1577
+ let syncLock = null;
1578
+ let syncError = null;
1579
+
1580
+ try {
1581
+ if (apply) {
1582
+ layoutLock = await acquireSyncLock({
1583
+ homeDir: resolvedHome,
1584
+ stateDir: statePaths.layoutLockDir,
1585
+ lockName: LAYOUT_LOCK_NAME,
1586
+ beforeLockPublish: transactionHooks.beforeLayoutLockPublish,
1587
+ });
1588
+ stateLayout = await inspectStateLayout(resolvedHome, statePaths);
1589
+ if (stateLayout.kind === "current-with-empty-legacy") {
1590
+ stateRepair = {
1591
+ action: "remove-empty-legacy-state",
1592
+ target: statePaths.legacyStateDir,
1593
+ };
1594
+ const expectedCurrentIdentity = stateLayout.current.identity;
1595
+ try {
1596
+ await rmdir(statePaths.legacyStateDir);
1597
+ } catch (error) {
1598
+ if (["ENOTEMPTY", "EEXIST"].includes(error.code)) {
1599
+ throw stateLayoutConflict(
1600
+ resolvedHome,
1601
+ "空旧状态目录在清理前出现内容,拒绝继续",
1602
+ statePaths.legacyStateDir,
1603
+ );
1604
+ }
1605
+ throw error;
1606
+ }
1607
+ const repairedLayout = await inspectStateLayout(
1608
+ resolvedHome,
1609
+ statePaths,
1610
+ );
1611
+ if (
1612
+ repairedLayout.kind !== "current" ||
1613
+ !sameStateDirectoryIdentity(
1614
+ expectedCurrentIdentity,
1615
+ repairedLayout.current.identity,
1616
+ )
1617
+ ) {
1618
+ throw stateLayoutConflict(
1619
+ resolvedHome,
1620
+ "清理旧状态残留后当前状态目录身份发生变化",
1621
+ statePaths.currentStateDir,
1622
+ );
1623
+ }
1624
+ stateLayout = repairedLayout;
1625
+ }
1626
+ if (stateLayout.kind === "legacy") {
1627
+ stateMigration = {
1628
+ action: "move",
1629
+ from: statePaths.legacyStateDir,
1630
+ to: statePaths.currentStateDir,
1631
+ };
1632
+ syncLock = await migrateLegacyState({
1633
+ homeDir: resolvedHome,
1634
+ paths: statePaths,
1635
+ transactionHooks,
1636
+ });
1637
+ } else {
1638
+ syncLock = await acquireSyncLock({
1639
+ homeDir: resolvedHome,
1640
+ stateDir: statePaths.currentStateDir,
1641
+ beforeLockPublish: transactionHooks.beforeLockPublish,
1642
+ });
1643
+ }
1644
+ const lockedLayout = await inspectStateLayout(
1645
+ resolvedHome,
1646
+ statePaths,
1647
+ );
1648
+ if (lockedLayout.kind !== "current") {
1649
+ throw stateLayoutConflict(
1650
+ resolvedHome,
1651
+ "获取同步锁后项目状态布局发生变化,拒绝继续",
1652
+ statePaths.currentStateDir,
1653
+ );
1654
+ }
1655
+ stateLayout = lockedLayout;
1656
+ stateDir = statePaths.currentStateDir;
1657
+ manifestPath = statePaths.currentManifestPath;
1658
+ }
1659
+ if (syncLock) await transactionHooks.afterLockAcquired?.();
537
1660
  const stateSafety = await inspectAncestorPath(resolvedHome, stateDir);
538
1661
  if (!stateSafety.safe) {
539
1662
  throw new SyncConflictError([
@@ -561,6 +1684,12 @@ export async function syncSkills({
561
1684
  ]);
562
1685
  }
563
1686
  const manifest = await readManifest(manifestPath);
1687
+ const manifestConflicts = collectManifestTargetConflicts(
1688
+ manifest,
1689
+ resolvedHome,
1690
+ stateDir,
1691
+ );
1692
+ if (manifestConflicts.length > 0) throw new SyncConflictError(manifestConflicts);
564
1693
  const sourceFiles = selectedComponents.includes("skills")
565
1694
  ? await discoverSourceFiles(resolvedRoot)
566
1695
  : [];
@@ -581,6 +1710,10 @@ export async function syncSkills({
581
1710
  const hostRoot = path.join(resolvedHome, ...HOST_DESTINATIONS[host]);
582
1711
  for (const source of sourceFiles) {
583
1712
  const targetPath = path.join(hostRoot, source.skillName, ...source.relativePath.split("/"));
1713
+ const sourceContent =
1714
+ host === "codex" && source.relativePath === "SKILL.md"
1715
+ ? projectCodexSkill(await readFile(source.absolutePath, "utf8"))
1716
+ : null;
584
1717
  operations.push(await planFileOperation({
585
1718
  host,
586
1719
  component: "skills",
@@ -588,6 +1721,7 @@ export async function syncSkills({
588
1721
  skillName: source.skillName,
589
1722
  relativePath: source.relativePath,
590
1723
  sourcePath: source.absolutePath,
1724
+ sourceContent,
591
1725
  targetPath,
592
1726
  manifest,
593
1727
  homeDir: resolvedHome,
@@ -627,46 +1761,114 @@ export async function syncSkills({
627
1761
  }));
628
1762
  }
629
1763
 
1764
+ const desiredManifestKeys = new Set(operations.map((operation) => operation.manifestKey));
1765
+ for (const [key, previousHash] of Object.entries(manifest.files)) {
1766
+ if (desiredManifestKeys.has(key)) continue;
1767
+ const target = resolveManagedTarget(key, resolvedHome, stateDir);
1768
+ if (!target) continue;
1769
+ const selected =
1770
+ target.component === "support" ||
1771
+ (target.component === "skills" &&
1772
+ selectedComponents.includes("skills") &&
1773
+ selectedHosts.includes(target.host)) ||
1774
+ (target.component === "agents" &&
1775
+ selectedComponents.includes("agents") &&
1776
+ selectedHosts.includes("codex"));
1777
+ if (!selected) continue;
1778
+ operations.push(await planRemovalOperation({
1779
+ manifestKeyValue: key,
1780
+ previousHash,
1781
+ target,
1782
+ homeDir: resolvedHome,
1783
+ }));
1784
+ }
1785
+
630
1786
  const conflicts = operations.filter((operation) => operation.action === "conflict");
631
1787
  const result = {
632
1788
  mode: apply ? "apply" : "dry-run",
633
1789
  homeDir: resolvedHome,
634
- manifestPath,
1790
+ manifestPath: statePaths.currentManifestPath,
1791
+ ...(manifestPath !== statePaths.currentManifestPath
1792
+ ? { sourceManifestPath: manifestPath }
1793
+ : {}),
1794
+ ...(stateMigration ? { stateMigration } : {}),
1795
+ ...(stateRepair ? { stateRepair } : {}),
635
1796
  operations,
636
1797
  conflicts,
637
1798
  };
638
1799
 
639
1800
  if (!apply) return result;
640
1801
  if (conflicts.length > 0) throw new SyncConflictError(conflicts);
641
- await applyOperationsTransactional({ operations, homeDir: resolvedHome, manifestPath });
1802
+ await applyOperationsTransactional({
1803
+ operations,
1804
+ homeDir: resolvedHome,
1805
+ manifestPath,
1806
+ expectedManifestHash: manifestTarget.hash,
1807
+ transactionHooks,
1808
+ });
642
1809
 
643
1810
  return result;
1811
+ } catch (error) {
1812
+ syncError = error;
1813
+ throw error;
1814
+ } finally {
1815
+ let releaseFailure = null;
1816
+ for (const lock of [syncLock, layoutLock]) {
1817
+ if (!lock) continue;
1818
+ try {
1819
+ await releaseSyncLock(lock);
1820
+ } catch (releaseError) {
1821
+ if (syncError) {
1822
+ syncError.releaseError = releaseError;
1823
+ syncError.preserveEvidence = true;
1824
+ } else {
1825
+ releaseFailure ??= releaseError;
1826
+ }
1827
+ }
1828
+ }
1829
+ if (!syncError && releaseFailure) throw releaseFailure;
1830
+ }
644
1831
  }
645
1832
 
646
1833
  function printHelp() {
647
1834
  console.log(`用法:
648
- netpilot-skills install [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
649
- npm run sync -- [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
1835
+ netpilot-skills sync [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
1836
+ npm run sync -- [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
650
1837
 
651
- 默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
652
- Codex 目标:~/.agents/skills
653
- Claude Code 目标:~/.claude/skills
654
- Codex agents 目标:~/.codex/agents(必须显式选择 --component agents 或 all)`);
1838
+ 底层 sync 默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
1839
+ 默认目标:Codex skills 与 agents
1840
+ Codex Skills 目标:~/.agents/skills
1841
+ Codex 系统级(用户全局)Agents 目标:~/.codex/agents
1842
+ system-agents 是 agents 的语义化别名,二者安装内容与 manifest 所有权相同。
1843
+ Claude Code 目标:~/.claude/skills(选择 --host claude 时默认只安装 skills)。
1844
+ 选择 --host all 时,Skills 同步到 Codex 与 Claude,Agents 只同步到 Codex。`);
655
1845
  }
656
1846
 
657
1847
  function printPlan(result) {
1848
+ if (result.stateRepair) {
1849
+ console.log(
1850
+ `[remove-empty] state ${result.stateRepair.target}`,
1851
+ );
1852
+ }
1853
+ if (result.stateMigration) {
1854
+ console.log(
1855
+ `[move] state ${result.stateMigration.from} -> ${result.stateMigration.to}`,
1856
+ );
1857
+ }
658
1858
  for (const operation of result.operations) {
659
1859
  const reason = operation.reason ? `(${operation.reason})` : "";
660
1860
  const component = operation.component === "skills" ? "skills" : operation.component;
661
1861
  console.log(`[${operation.action}] ${operation.host} ${component} ${operation.assetName}/${operation.relativePath}${reason}`);
662
1862
  }
663
1863
  const counts = Object.fromEntries(
664
- ["create", "update", "unchanged", "conflict"].map((action) => [
1864
+ ["create", "update", "remove", "unchanged", "conflict"].map((action) => [
665
1865
  action,
666
1866
  result.operations.filter((operation) => operation.action === action).length,
667
1867
  ]),
668
1868
  );
669
- console.log(`汇总:新增 ${counts.create},更新 ${counts.update},不变 ${counts.unchanged},冲突 ${counts.conflict}`);
1869
+ console.log(
1870
+ `汇总:新增 ${counts.create},更新 ${counts.update},移除 ${counts.remove},不变 ${counts.unchanged},冲突 ${counts.conflict}`,
1871
+ );
670
1872
  if (result.mode === "dry-run") console.log("当前为 dry-run;确认计划后追加 --apply。 ");
671
1873
  }
672
1874
 
@@ -675,9 +1877,9 @@ async function main() {
675
1877
  printHelp();
676
1878
  return;
677
1879
  }
678
- const options = parseArgs(process.argv.slice(2));
679
- const rootDir = fileURLToPath(new URL("..", import.meta.url));
680
1880
  try {
1881
+ const options = parseArgs(process.argv.slice(2));
1882
+ const rootDir = fileURLToPath(new URL("..", import.meta.url));
681
1883
  const result = await syncSkills({ rootDir, ...options });
682
1884
  printPlan(result);
683
1885
  if (result.conflicts.length > 0) process.exitCode = 2;
@@ -690,7 +1892,8 @@ async function main() {
690
1892
  process.exitCode = 2;
691
1893
  return;
692
1894
  }
693
- throw error;
1895
+ console.error(error instanceof Error ? error.message : String(error));
1896
+ process.exitCode = 1;
694
1897
  }
695
1898
  }
696
1899