@rhize/skill-forge 0.6.0 → 0.7.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/dist/cli.js CHANGED
@@ -5,7 +5,6 @@ import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
6
 
7
7
  // src/commands/add.ts
8
- import { createInterface as createInterface2 } from "readline/promises";
9
8
  import { join as join15 } from "path";
10
9
 
11
10
  // src/config.ts
@@ -153,15 +152,24 @@ async function quarantine(source, quarantineDir, deps = {}) {
153
152
  break;
154
153
  }
155
154
  case "npm": {
156
- const packResult = await exec("npm", ["pack", source.value, "--pack-destination", quarantinePath], {
157
- cwd: quarantinePath
158
- });
155
+ const packResult = await exec(
156
+ "npm",
157
+ ["pack", source.value, "--ignore-scripts", "--pack-destination", quarantinePath],
158
+ { cwd: quarantinePath }
159
+ );
159
160
  if (packResult.code !== 0) {
160
161
  throw new UserError(
161
162
  `npm pack failed for "${source.value}" (exit ${packResult.code}): ${packResult.stderr || packResult.stdout}`
162
163
  );
163
164
  }
164
165
  const tarballPath = join3(quarantinePath, findTarball(quarantinePath, source.value));
166
+ const listResult = await exec("tar", ["-tzf", tarballPath], { cwd: quarantinePath });
167
+ if (listResult.code !== 0) {
168
+ throw new UserError(
169
+ `could not list tarball contents for "${source.value}" (exit ${listResult.code}): ${listResult.stderr || listResult.stdout}`
170
+ );
171
+ }
172
+ assertSafeTarMembers(listResult.stdout, source.value);
165
173
  const extractResult = await exec("tar", ["-xzf", tarballPath, "-C", quarantinePath], {
166
174
  cwd: quarantinePath
167
175
  });
@@ -190,7 +198,7 @@ function listQuarantineIds(quarantineDir) {
190
198
  }
191
199
  function findSkillDirs(root) {
192
200
  const found = [];
193
- function walk3(dir) {
201
+ function walk2(dir) {
194
202
  let entries;
195
203
  try {
196
204
  entries = readdirSync(dir, { withFileTypes: true });
@@ -202,13 +210,24 @@ function findSkillDirs(root) {
202
210
  }
203
211
  for (const entry of entries) {
204
212
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
205
- walk3(join3(dir, entry.name));
213
+ walk2(join3(dir, entry.name));
206
214
  }
207
215
  }
208
216
  }
209
- walk3(root);
217
+ walk2(root);
210
218
  return found;
211
219
  }
220
+ function assertSafeTarMembers(tarListOutput, sourceValue) {
221
+ for (const raw of tarListOutput.split("\n")) {
222
+ const member = raw.trim();
223
+ if (!member) continue;
224
+ if (member.startsWith("/") || member.split("/").includes("..")) {
225
+ throw new UserError(
226
+ `refusing to extract tarball for "${sourceValue}": unsafe member path "${member}" (absolute path or ".." segment \u2014 possible path traversal)`
227
+ );
228
+ }
229
+ }
230
+ }
212
231
  function findTarball(dir, sourceValue) {
213
232
  const tarball = readdirSync(dir).find((name) => name.endsWith(".tgz"));
214
233
  if (!tarball) {
@@ -281,6 +300,7 @@ import { dirname } from "path";
281
300
  function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
282
301
  const force = options.force ?? false;
283
302
  const serverName = profile.name;
303
+ const { command, args, envVarsToFill } = deriveServerEntry(profile);
284
304
  let config = {};
285
305
  let rawExisting;
286
306
  if (existsSync5(targetConfigPath)) {
@@ -302,7 +322,6 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
302
322
  backupPath = `${targetConfigPath}.bak-${Date.now()}`;
303
323
  writeFileSync2(backupPath, rawExisting);
304
324
  }
305
- const { command, args, envVarsToFill } = deriveServerEntry(profile);
306
325
  const env = {};
307
326
  for (const name of envVarsToFill) env[name] = "";
308
327
  servers[serverName] = { command, args, env };
@@ -324,6 +343,11 @@ function deriveServerEntry(profile) {
324
343
  envVarsToFill: Array.from(envNames)
325
344
  };
326
345
  }
346
+ if (!profile.version) {
347
+ throw new UserError(
348
+ `cannot promote MCP server "${profile.name}": no version was found in its package.json, and there is no documented launch command to fall back to \u2014 refusing to write an unpinnable "npx ${profile.name}@unknown" command`
349
+ );
350
+ }
327
351
  return {
328
352
  command: "npx",
329
353
  args: ["-y", `${profile.name}@${profile.version}`],
@@ -406,7 +430,7 @@ function appendProvenance(entry, skillsRoot) {
406
430
  }
407
431
 
408
432
  // src/commands/gatePipeline.ts
409
- import { readdirSync as readdirSync7 } from "fs";
433
+ import { readdirSync as readdirSync5 } from "fs";
410
434
  import { join as join11 } from "path";
411
435
 
412
436
  // src/gate/profile.ts
@@ -416,23 +440,46 @@ import {
416
440
  existsSync as existsSync8,
417
441
  mkdtempSync,
418
442
  openSync,
419
- readFileSync as readFileSync5,
443
+ readFileSync as readFileSync6,
420
444
  readdirSync as readdirSync3,
421
445
  readSync,
422
446
  rmSync as rmSync2,
423
447
  statSync as statSync4
424
448
  } from "fs";
425
449
  import { tmpdir } from "os";
426
- import { basename as basename3, dirname as dirname2, extname as extname2, join as join7, resolve as resolve2 } from "path";
450
+ import { basename as basename3, dirname as dirname2, extname as extname3, join as join8, resolve as resolve2 } from "path";
427
451
 
428
452
  // src/gate/mcpCapabilities.ts
429
- import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
453
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
454
+ import { extname as extname2, join as join7 } from "path";
455
+
456
+ // src/gate/scanShared.ts
457
+ import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
430
458
  import { extname, join as join6 } from "path";
431
459
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
432
- var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
460
+ var BINARY_EXT = /* @__PURE__ */ new Set([
461
+ ".png",
462
+ ".jpg",
463
+ ".jpeg",
464
+ ".gif",
465
+ ".webp",
466
+ ".ico",
467
+ ".pdf",
468
+ ".zip",
469
+ ".tar",
470
+ ".gz",
471
+ ".woff",
472
+ ".woff2",
473
+ ".ttf",
474
+ ".eot",
475
+ ".mp4",
476
+ ".mp3",
477
+ ".wasm",
478
+ ".bin",
479
+ ".exe"
480
+ ]);
433
481
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
434
- var MAX_ITEMS_PER_CATEGORY = 200;
435
- function* walkSourceFiles(dir) {
482
+ function* walk(dir) {
436
483
  let entries;
437
484
  try {
438
485
  entries = readdirSync2(dir, { withFileTypes: true });
@@ -443,23 +490,44 @@ function* walkSourceFiles(dir) {
443
490
  const full = join6(dir, entry.name);
444
491
  if (entry.isDirectory()) {
445
492
  if (SKIP_DIRS2.has(entry.name)) continue;
446
- yield* walkSourceFiles(full);
447
- } else if (entry.isFile() && SOURCE_EXT.has(extname(entry.name))) {
493
+ yield* walk(full);
494
+ } else if (entry.isFile()) {
448
495
  yield full;
449
496
  }
450
497
  }
451
498
  }
452
- function readTextFileSafe(file) {
499
+ function readTextFile(file) {
500
+ if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
453
501
  try {
454
502
  if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
455
- return readFileSync4(file, "utf-8");
503
+ const content = readFileSync4(file, "utf-8");
504
+ if (content.slice(0, 512).includes("\0")) return void 0;
505
+ return content;
456
506
  } catch {
457
507
  return void 0;
458
508
  }
459
509
  }
510
+ var BLOCK_SEVERITIES_BY_STRICTNESS = {
511
+ "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
512
+ "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
513
+ "warn-only": /* @__PURE__ */ new Set()
514
+ };
515
+ function verdictFor(findings, strictness) {
516
+ const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
517
+ if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
518
+ if (findings.length > 0) return "warn";
519
+ return "pass";
520
+ }
521
+ function formatSafetyFinding(f) {
522
+ return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
523
+ }
524
+
525
+ // src/gate/mcpCapabilities.ts
526
+ var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
527
+ var MAX_ITEMS_PER_CATEGORY = 200;
460
528
  function readJsonSafe(file) {
461
529
  try {
462
- return JSON.parse(readFileSync4(file, "utf-8"));
530
+ return JSON.parse(readFileSync5(file, "utf-8"));
463
531
  } catch {
464
532
  return void 0;
465
533
  }
@@ -560,7 +628,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
560
628
  function extractMcpCapabilities(packageDir) {
561
629
  const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
562
630
  let hasPackageJsonMarker = false;
563
- const pkgPath = join6(packageDir, "package.json");
631
+ const pkgPath = join7(packageDir, "package.json");
564
632
  if (existsSync7(pkgPath)) {
565
633
  const pkg2 = readJsonSafe(pkgPath);
566
634
  if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
@@ -568,7 +636,7 @@ function extractMcpCapabilities(packageDir) {
568
636
  }
569
637
  }
570
638
  for (const manifestName of [".mcp.json", "mcp.json"]) {
571
- const manifestPath = join6(packageDir, manifestName);
639
+ const manifestPath = join7(packageDir, manifestName);
572
640
  if (!existsSync7(manifestPath)) continue;
573
641
  const parsed = readJsonSafe(manifestPath);
574
642
  if (parsed !== void 0) {
@@ -578,8 +646,9 @@ function extractMcpCapabilities(packageDir) {
578
646
  let sawStdioTransport = false;
579
647
  let sawHttpTransport = false;
580
648
  let sawMcpSdkImport = false;
581
- for (const file of walkSourceFiles(packageDir)) {
582
- const text = readTextFileSafe(file);
649
+ for (const file of walk(packageDir)) {
650
+ if (!SOURCE_EXT.has(extname2(file))) continue;
651
+ const text = readTextFile(file);
583
652
  if (text === void 0) continue;
584
653
  captureRegistrations(text, buckets);
585
654
  extractFromListHandlers(text, buckets);
@@ -658,8 +727,24 @@ function profileSkill(inputPath) {
658
727
  }
659
728
  return profileSkillDir(p);
660
729
  }
730
+ function asSkillProfile(profile, context) {
731
+ if (profile.kind !== "skill") {
732
+ throw new Error(`expected a skill candidate ${context}, found a ${profile.kind}`);
733
+ }
734
+ return profile;
735
+ }
736
+ function asProfile(candidatePath, artifact) {
737
+ const profile = profileSkill(candidatePath);
738
+ if (artifact === "mcp") {
739
+ if (profile.kind !== "mcp") {
740
+ throw new Error(`expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}`);
741
+ }
742
+ return profile;
743
+ }
744
+ return asSkillProfile(profile, `at ${candidatePath}`);
745
+ }
661
746
  function classifyInput(p) {
662
- if (existsSync8(join7(p, ".claude-plugin", "plugin.json"))) return "plugin";
747
+ if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
663
748
  if (detectArtifactType(p) === "mcp") return "mcp";
664
749
  return "skill";
665
750
  }
@@ -667,10 +752,10 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
667
752
  var MCP_KEYWORD2 = "mcp-server";
668
753
  var MCP_BIN_PREFIX2 = /^mcp-/i;
669
754
  function readPackageJsonSafe(root) {
670
- const pkgPath = join7(root, "package.json");
755
+ const pkgPath = join8(root, "package.json");
671
756
  if (!existsSync8(pkgPath)) return void 0;
672
757
  try {
673
- return JSON.parse(readFileSync5(pkgPath, "utf-8"));
758
+ return JSON.parse(readFileSync6(pkgPath, "utf-8"));
674
759
  } catch {
675
760
  return void 0;
676
761
  }
@@ -691,7 +776,7 @@ function hasMcpPackageMarkers(pkg2) {
691
776
  return false;
692
777
  }
693
778
  function detectArtifactType(root) {
694
- if (existsSync8(join7(root, ".mcp.json")) || existsSync8(join7(root, "mcp.json"))) return "mcp";
779
+ if (existsSync8(join8(root, ".mcp.json")) || existsSync8(join8(root, "mcp.json"))) return "mcp";
695
780
  const pkg2 = readPackageJsonSafe(root);
696
781
  if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
697
782
  return "skill";
@@ -700,8 +785,8 @@ function resolveSkillMd(inputPath) {
700
785
  let currentPath = inputPath;
701
786
  let tmpDir;
702
787
  const initialStat = statSync4(currentPath);
703
- if (extname2(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
704
- tmpDir = mkdtempSync(join7(tmpdir(), "forge_"));
788
+ if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
789
+ tmpDir = mkdtempSync(join8(tmpdir(), "forge_"));
705
790
  extractZip(currentPath, tmpDir);
706
791
  currentPath = tmpDir;
707
792
  }
@@ -710,7 +795,7 @@ function resolveSkillMd(inputPath) {
710
795
  return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
711
796
  }
712
797
  if (stat.isDirectory()) {
713
- const direct = join7(currentPath, "SKILL.md");
798
+ const direct = join8(currentPath, "SKILL.md");
714
799
  if (existsSync8(direct)) {
715
800
  return { skillMd: direct, root: currentPath, tmpDir };
716
801
  }
@@ -736,15 +821,15 @@ function findSkillMdOneLevelDown(dir) {
736
821
  const matches = /* @__PURE__ */ new Set();
737
822
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
738
823
  if (entry.isDirectory()) {
739
- const candidate = join7(dir, entry.name, "SKILL.md");
824
+ const candidate = join8(dir, entry.name, "SKILL.md");
740
825
  if (existsSync8(candidate)) matches.add(candidate);
741
826
  }
742
827
  }
743
- const skillsDir = join7(dir, "skills");
828
+ const skillsDir = join8(dir, "skills");
744
829
  if (isDir(skillsDir)) {
745
830
  for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
746
831
  if (entry.isDirectory()) {
747
- const candidate = join7(skillsDir, entry.name, "SKILL.md");
832
+ const candidate = join8(skillsDir, entry.name, "SKILL.md");
748
833
  if (existsSync8(candidate)) matches.add(candidate);
749
834
  }
750
835
  }
@@ -828,9 +913,9 @@ function detectLicense(root, fm) {
828
913
  if (fm.license) return fm.license;
829
914
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
830
915
  for (const name of names) {
831
- const p = join7(root, name);
916
+ const p = join8(root, name);
832
917
  if (existsSync8(p) && statSync4(p).isFile()) {
833
- const head = readFileSync5(p, "utf-8").slice(0, 2e3).toLowerCase();
918
+ const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
834
919
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
835
920
  if (head.includes(hint)) return label;
836
921
  }
@@ -842,7 +927,7 @@ function detectLicense(root, fm) {
842
927
  function walkFiles(dir) {
843
928
  const out = [];
844
929
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
845
- const full = join7(dir, entry.name);
930
+ const full = join8(dir, entry.name);
846
931
  if (entry.isDirectory()) {
847
932
  out.push(...walkFiles(full));
848
933
  } else if (entry.isFile()) {
@@ -854,7 +939,7 @@ function walkFiles(dir) {
854
939
  function countResources(root) {
855
940
  const resources = {};
856
941
  for (const d of RESOURCE_DIRS) {
857
- const dp = join7(root, d);
942
+ const dp = join8(root, d);
858
943
  if (isDir(dp)) {
859
944
  resources[d] = walkFiles(dp).length;
860
945
  }
@@ -865,11 +950,11 @@ function scanMcpAndImports(text, root) {
865
950
  const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
866
951
  const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
867
952
  const imports = /* @__PURE__ */ new Set();
868
- const scriptsDir = join7(root, "scripts");
953
+ const scriptsDir = join8(root, "scripts");
869
954
  if (isDir(scriptsDir)) {
870
955
  for (const f of walkFiles(scriptsDir)) {
871
956
  if (!f.endsWith(".py")) continue;
872
- const ft = readFileSync5(f, "utf-8");
957
+ const ft = readFileSync6(f, "utf-8");
873
958
  for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
874
959
  const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
875
960
  let m;
@@ -886,7 +971,7 @@ function scanMcpAndImports(text, root) {
886
971
  function profileSkillDir(inputPath) {
887
972
  const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
888
973
  try {
889
- const text = readFileSync5(skillMd, "utf-8");
974
+ const text = readFileSync6(skillMd, "utf-8");
890
975
  const fm = parseFrontmatter(text);
891
976
  const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
892
977
  const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
@@ -914,19 +999,19 @@ function profileSkillDir(inputPath) {
914
999
  }
915
1000
  }
916
1001
  function profilePlugin(p) {
917
- const manifestPath = join7(p, ".claude-plugin", "plugin.json");
1002
+ const manifestPath = join8(p, ".claude-plugin", "plugin.json");
918
1003
  let manifest;
919
1004
  try {
920
- manifest = JSON.parse(readFileSync5(manifestPath, "utf-8"));
1005
+ manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
921
1006
  } catch (err) {
922
1007
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
923
1008
  }
924
- const skillsDir = join7(p, "skills");
1009
+ const skillsDir = join8(p, "skills");
925
1010
  let skills = [];
926
1011
  if (isDir(skillsDir)) {
927
- skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join7(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
1012
+ skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join8(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
928
1013
  }
929
- const commandsDir = join7(p, "commands");
1014
+ const commandsDir = join8(p, "commands");
930
1015
  const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
931
1016
  const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
932
1017
  const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
@@ -944,11 +1029,11 @@ function profilePlugin(p) {
944
1029
  };
945
1030
  }
946
1031
  function profileMcp(p) {
947
- const explicitConfig = existsSync8(join7(p, ".mcp.json")) ? join7(p, ".mcp.json") : existsSync8(join7(p, "mcp.json")) ? join7(p, "mcp.json") : void 0;
1032
+ const explicitConfig = existsSync8(join8(p, ".mcp.json")) ? join8(p, ".mcp.json") : existsSync8(join8(p, "mcp.json")) ? join8(p, "mcp.json") : void 0;
948
1033
  if (explicitConfig) {
949
1034
  let cfg;
950
1035
  try {
951
- cfg = JSON.parse(readFileSync5(explicitConfig, "utf-8"));
1036
+ cfg = JSON.parse(readFileSync6(explicitConfig, "utf-8"));
952
1037
  } catch (err) {
953
1038
  throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
954
1039
  }
@@ -964,10 +1049,10 @@ function profileMcp(p) {
964
1049
  capabilities: extractMcpCapabilities(p)
965
1050
  };
966
1051
  }
967
- const pkgPath = join7(p, "package.json");
1052
+ const pkgPath = join8(p, "package.json");
968
1053
  let pkg2;
969
1054
  try {
970
- pkg2 = JSON.parse(readFileSync5(pkgPath, "utf-8"));
1055
+ pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
971
1056
  } catch (err) {
972
1057
  throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
973
1058
  }
@@ -984,16 +1069,11 @@ function profileMcp(p) {
984
1069
  }
985
1070
 
986
1071
  // src/gate/safety.ts
987
- import { readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
1072
+ import { statSync as statSync5 } from "fs";
988
1073
  import { execFile } from "child_process";
989
- import { delimiter, extname as extname3, join as join8 } from "path";
1074
+ import { delimiter, join as join9 } from "path";
990
1075
  import { promisify } from "util";
991
1076
  var execFileAsync = promisify(execFile);
992
- var BLOCK_SEVERITIES_BY_STRICTNESS = {
993
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
994
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
995
- "warn-only": /* @__PURE__ */ new Set()
996
- };
997
1077
  var DENY_RULES = [
998
1078
  {
999
1079
  rule: "curl-pipe-shell",
@@ -1056,57 +1136,6 @@ var DENY_RULES = [
1056
1136
  message: "invokes sudo \u2014 review for unnecessary privilege escalation"
1057
1137
  }
1058
1138
  ];
1059
- var SKIP_DIRS3 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
1060
- var BINARY_EXT = /* @__PURE__ */ new Set([
1061
- ".png",
1062
- ".jpg",
1063
- ".jpeg",
1064
- ".gif",
1065
- ".webp",
1066
- ".ico",
1067
- ".pdf",
1068
- ".zip",
1069
- ".tar",
1070
- ".gz",
1071
- ".woff",
1072
- ".woff2",
1073
- ".ttf",
1074
- ".eot",
1075
- ".mp4",
1076
- ".mp3",
1077
- ".wasm",
1078
- ".bin",
1079
- ".exe"
1080
- ]);
1081
- var MAX_FILE_BYTES2 = 2 * 1024 * 1024;
1082
- function* walk(dir) {
1083
- let entries;
1084
- try {
1085
- entries = readdirSync4(dir, { withFileTypes: true });
1086
- } catch {
1087
- return;
1088
- }
1089
- for (const entry of entries) {
1090
- const full = join8(dir, entry.name);
1091
- if (entry.isDirectory()) {
1092
- if (SKIP_DIRS3.has(entry.name)) continue;
1093
- yield* walk(full);
1094
- } else if (entry.isFile()) {
1095
- yield full;
1096
- }
1097
- }
1098
- }
1099
- function readTextFile(file) {
1100
- if (BINARY_EXT.has(extname3(file).toLowerCase())) return void 0;
1101
- try {
1102
- if (statSync5(file).size > MAX_FILE_BYTES2) return void 0;
1103
- const content = readFileSync6(file, "utf-8");
1104
- if (content.slice(0, 512).includes("\0")) return void 0;
1105
- return content;
1106
- } catch {
1107
- return void 0;
1108
- }
1109
- }
1110
1139
  function scanStaticRules(targetPath) {
1111
1140
  const findings = [];
1112
1141
  let root;
@@ -1141,7 +1170,7 @@ function which(cmd) {
1141
1170
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
1142
1171
  for (const dir of dirs) {
1143
1172
  for (const ext of exts) {
1144
- const candidate = join8(dir, cmd + ext);
1173
+ const candidate = join9(dir, cmd + ext);
1145
1174
  try {
1146
1175
  if (statSync5(candidate).isFile()) return candidate;
1147
1176
  } catch {
@@ -1175,20 +1204,14 @@ async function scanWithSkillSpector(targetPath) {
1175
1204
  return [];
1176
1205
  }
1177
1206
  }
1178
- function verdictFor(findings, strictness) {
1179
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
1180
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
1181
- if (findings.length > 0) return "warn";
1182
- return "pass";
1183
- }
1184
1207
  async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
1185
1208
  const findings = [...scanStaticRules(targetPath), ...await scanWithSkillSpector(targetPath)];
1186
1209
  return { findings, verdict: verdictFor(findings, strictness) };
1187
1210
  }
1188
1211
 
1189
1212
  // src/gate/overlap.ts
1190
- import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "fs";
1191
- import { basename as basename4, dirname as dirname3, join as join9, resolve as resolve3 } from "path";
1213
+ import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync4 } from "fs";
1214
+ import { basename as basename4, dirname as dirname3, join as join10, resolve as resolve3 } from "path";
1192
1215
  var STOPWORDS = new Set(
1193
1216
  `
1194
1217
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -1196,14 +1219,14 @@ your you it its as at by from into via per also any all not no than then so if u
1196
1219
  skill skills user claude code based comprehensive enhanced practical including include includes
1197
1220
  `.split(/\s+/).filter(Boolean)
1198
1221
  );
1199
- function splitLimited(text, sep, maxSplit) {
1222
+ function splitLimited(text, sep2, maxSplit) {
1200
1223
  const parts = [];
1201
1224
  let rest = text;
1202
1225
  for (let i = 0; i < maxSplit; i++) {
1203
- const idx = rest.indexOf(sep);
1226
+ const idx = rest.indexOf(sep2);
1204
1227
  if (idx === -1) break;
1205
1228
  parts.push(rest.slice(0, idx));
1206
- rest = rest.slice(idx + sep.length);
1229
+ rest = rest.slice(idx + sep2.length);
1207
1230
  }
1208
1231
  parts.push(rest);
1209
1232
  return parts;
@@ -1307,25 +1330,25 @@ function listSubdirs(dir) {
1307
1330
  if (!existsSync9(dir)) return [];
1308
1331
  let entries;
1309
1332
  try {
1310
- entries = readdirSync5(dir, { withFileTypes: true });
1333
+ entries = readdirSync4(dir, { withFileTypes: true });
1311
1334
  } catch {
1312
1335
  return [];
1313
1336
  }
1314
- return entries.filter((e) => e.isDirectory()).map((e) => join9(dir, e.name));
1337
+ return entries.filter((e) => e.isDirectory()).map((e) => join10(dir, e.name));
1315
1338
  }
1316
1339
  function findSkillMdFiles(root) {
1317
1340
  const found = /* @__PURE__ */ new Set();
1318
1341
  for (const dir of listSubdirs(root)) {
1319
- const candidate = join9(dir, "SKILL.md");
1342
+ const candidate = join10(dir, "SKILL.md");
1320
1343
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1321
1344
  }
1322
- for (const dir of listSubdirs(join9(root, "skills"))) {
1323
- const candidate = join9(dir, "SKILL.md");
1345
+ for (const dir of listSubdirs(join10(root, "skills"))) {
1346
+ const candidate = join10(dir, "SKILL.md");
1324
1347
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1325
1348
  }
1326
1349
  for (const topDir of listSubdirs(root)) {
1327
- for (const dir of listSubdirs(join9(topDir, "skills"))) {
1328
- const candidate = join9(dir, "SKILL.md");
1350
+ for (const dir of listSubdirs(join10(topDir, "skills"))) {
1351
+ const candidate = join10(dir, "SKILL.md");
1329
1352
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1330
1353
  }
1331
1354
  }
@@ -1338,7 +1361,7 @@ function scanOverlap(candidatePath, skillsRoot) {
1338
1361
  }
1339
1362
  let cpath = resolve3(expandHome(candidatePath));
1340
1363
  if (isDir(cpath)) {
1341
- cpath = join9(cpath, "SKILL.md");
1364
+ cpath = join10(cpath, "SKILL.md");
1342
1365
  }
1343
1366
  if (!existsSync9(cpath)) {
1344
1367
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
@@ -1364,59 +1387,8 @@ function scanOverlap(candidatePath, skillsRoot) {
1364
1387
  }
1365
1388
 
1366
1389
  // src/gate/mcpSafety.ts
1367
- import { readFileSync as readFileSync8, readdirSync as readdirSync6, statSync as statSync6 } from "fs";
1368
- import { basename as basename5, extname as extname4, join as join10 } from "path";
1369
- var SKIP_DIRS4 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
1370
- var BINARY_EXT2 = /* @__PURE__ */ new Set([
1371
- ".png",
1372
- ".jpg",
1373
- ".jpeg",
1374
- ".gif",
1375
- ".webp",
1376
- ".ico",
1377
- ".pdf",
1378
- ".zip",
1379
- ".tar",
1380
- ".gz",
1381
- ".woff",
1382
- ".woff2",
1383
- ".ttf",
1384
- ".eot",
1385
- ".mp4",
1386
- ".mp3",
1387
- ".wasm",
1388
- ".bin",
1389
- ".exe"
1390
- ]);
1391
- var MAX_FILE_BYTES3 = 2 * 1024 * 1024;
1392
- function* walk2(dir) {
1393
- let entries;
1394
- try {
1395
- entries = readdirSync6(dir, { withFileTypes: true });
1396
- } catch {
1397
- return;
1398
- }
1399
- for (const entry of entries) {
1400
- const full = join10(dir, entry.name);
1401
- if (entry.isDirectory()) {
1402
- if (SKIP_DIRS4.has(entry.name)) continue;
1403
- yield* walk2(full);
1404
- } else if (entry.isFile()) {
1405
- yield full;
1406
- }
1407
- }
1408
- }
1409
- function readTextFile2(file) {
1410
- if (BINARY_EXT2.has(extname4(file).toLowerCase())) return void 0;
1411
- try {
1412
- if (statSync6(file).size > MAX_FILE_BYTES3) return void 0;
1413
- const content = readFileSync8(file, "utf-8");
1414
- if (content.slice(0, 512).includes("\0")) return void 0;
1415
- return content;
1416
- } catch {
1417
- return void 0;
1418
- }
1419
- }
1390
+ import { statSync as statSync6 } from "fs";
1391
+ import { basename as basename5, extname as extname4 } from "path";
1420
1392
  function isConfigOrExampleFile(file) {
1421
1393
  const base = basename5(file).toLowerCase();
1422
1394
  if (base.startsWith(".env")) return true;
@@ -1446,7 +1418,18 @@ function looksHighEntropy(value) {
1446
1418
  }
1447
1419
  function isPinnedPackageSpec(spec) {
1448
1420
  const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
1449
- return withoutScope.includes("@");
1421
+ const atIdx = withoutScope.lastIndexOf("@");
1422
+ if (atIdx <= 0) return false;
1423
+ return /^\d/.test(withoutScope.slice(atIdx + 1));
1424
+ }
1425
+ function isNpxCommand(command) {
1426
+ const base = command.split(/[\\/]/).pop() ?? command;
1427
+ return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
1428
+ }
1429
+ function packageSpecBase(spec) {
1430
+ const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
1431
+ const atIdx = spec.lastIndexOf("@");
1432
+ return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
1450
1433
  }
1451
1434
  function collectServerNodes(node, out) {
1452
1435
  if (Array.isArray(node)) {
@@ -1475,7 +1458,7 @@ function scanJsonConfig(file, parsed) {
1475
1458
  for (const node of nodes) {
1476
1459
  const command = typeof node.command === "string" ? node.command : void 0;
1477
1460
  const args = Array.isArray(node.args) ? node.args.filter((a) => typeof a === "string") : [];
1478
- if (command === "npx") {
1461
+ if (command && isNpxCommand(command)) {
1479
1462
  const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1480
1463
  if (flagIdx !== -1) {
1481
1464
  const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
@@ -1484,7 +1467,7 @@ function scanJsonConfig(file, parsed) {
1484
1467
  severity: "MEDIUM",
1485
1468
  rule: "mcp-unpinned-npx",
1486
1469
  file,
1487
- detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>) so the resolved server code can't change silently`
1470
+ detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1488
1471
  });
1489
1472
  }
1490
1473
  }
@@ -1523,9 +1506,22 @@ function scanJsonConfig(file, parsed) {
1523
1506
  }
1524
1507
  return findings;
1525
1508
  }
1526
- var NPX_SHELL_RE = /\bnpx\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/g;
1509
+ var NPX_SHELL_RE = /\bnpx(?:\.cmd|\.exe)?\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/gi;
1527
1510
  var DANGEROUS_FLAG_RE = /--dangerously-[\w-]+|--no-sandbox\b/g;
1528
1511
  var CREDENTIAL_ASSIGNMENT_RE = /["']?([A-Za-z][A-Za-z0-9_]*)["']?\s*[:=]\s*["']([^"']{6,})["']/g;
1512
+ var CREDENTIAL_ASSIGNMENT_UNQUOTED_RE = /^\s*(?:export\s+|-\s+)?([A-Za-z][A-Za-z0-9_]*)\s*[:=]\s*(\S+)/;
1513
+ var LEADING_QUOTE_RE = /^["']/;
1514
+ function pushCredentialFinding(findings, file, lineNo, varName, value) {
1515
+ if (isPlaceholderValue(value)) return;
1516
+ if (looksCredentialKey(varName) || looksHighEntropy(value)) {
1517
+ findings.push({
1518
+ severity: "HIGH",
1519
+ rule: "mcp-inline-credential-value",
1520
+ file,
1521
+ detail: `line ${lineNo}: inline credential value assigned to ${varName}`
1522
+ });
1523
+ }
1524
+ }
1529
1525
  function scanTextFile(file, content, allowCredentialCheck) {
1530
1526
  const findings = [];
1531
1527
  const lines = content.split("\n");
@@ -1536,8 +1532,8 @@ function scanTextFile(file, content, allowCredentialCheck) {
1536
1532
  let m;
1537
1533
  while ((m = NPX_SHELL_RE.exec(line)) !== null) {
1538
1534
  const pkgSpec = m[1];
1539
- const pinned = m[2] !== void 0;
1540
- if (!pinned) {
1535
+ const versionSuffix = m[2] ?? "";
1536
+ if (!isPinnedPackageSpec(pkgSpec + versionSuffix)) {
1541
1537
  findings.push({
1542
1538
  severity: "MEDIUM",
1543
1539
  rule: "mcp-unpinned-npx",
@@ -1558,24 +1554,18 @@ function scanTextFile(file, content, allowCredentialCheck) {
1558
1554
  if (allowCredentialCheck) {
1559
1555
  CREDENTIAL_ASSIGNMENT_RE.lastIndex = 0;
1560
1556
  while ((m = CREDENTIAL_ASSIGNMENT_RE.exec(line)) !== null) {
1561
- const varName = m[1];
1562
- const value = m[2];
1563
- if (isPlaceholderValue(value)) continue;
1564
- if (looksCredentialKey(varName) || looksHighEntropy(value)) {
1565
- findings.push({
1566
- severity: "HIGH",
1567
- rule: "mcp-inline-credential-value",
1568
- file,
1569
- detail: `line ${lineNo}: inline credential value assigned to ${varName}`
1570
- });
1571
- }
1557
+ pushCredentialFinding(findings, file, lineNo, m[1], m[2]);
1558
+ }
1559
+ const unquoted = CREDENTIAL_ASSIGNMENT_UNQUOTED_RE.exec(line);
1560
+ if (unquoted && !LEADING_QUOTE_RE.test(unquoted[2])) {
1561
+ pushCredentialFinding(findings, file, lineNo, unquoted[1], unquoted[2]);
1572
1562
  }
1573
1563
  }
1574
1564
  }
1575
1565
  return findings;
1576
1566
  }
1577
1567
  function scanFileForMcpFindings(file) {
1578
- const content = readTextFile2(file);
1568
+ const content = readTextFile(file);
1579
1569
  if (content === void 0) return [];
1580
1570
  if (extname4(file).toLowerCase() === ".json") {
1581
1571
  const parsed = tryParseJson(content);
@@ -1590,40 +1580,29 @@ function scanMcpFindings(targetPath) {
1590
1580
  } catch {
1591
1581
  return [];
1592
1582
  }
1593
- const files = root.isDirectory() ? Array.from(walk2(targetPath)) : [targetPath];
1583
+ const files = root.isDirectory() ? Array.from(walk(targetPath)) : [targetPath];
1594
1584
  const findings = [];
1595
1585
  for (const file of files) {
1596
1586
  findings.push(...scanFileForMcpFindings(file));
1597
1587
  }
1598
1588
  return findings;
1599
1589
  }
1600
- var BLOCK_SEVERITIES_BY_STRICTNESS2 = {
1601
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
1602
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
1603
- "warn-only": /* @__PURE__ */ new Set()
1604
- };
1605
- function verdictFor2(findings, strictness) {
1606
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS2[strictness];
1607
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
1608
- if (findings.length > 0) return "warn";
1609
- return "pass";
1610
- }
1611
1590
  async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
1612
1591
  const base = await scanSafety(targetPath, strictness);
1613
1592
  const mcpFindings = scanMcpFindings(targetPath);
1614
1593
  const findings = [...base.findings, ...mcpFindings];
1615
- return { findings, verdict: verdictFor2(findings, strictness) };
1594
+ return { findings, verdict: verdictFor(findings, strictness) };
1616
1595
  }
1617
1596
 
1618
1597
  // src/gate/mcpOverlap.ts
1619
- import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
1598
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
1620
1599
  import { resolve as resolve4 } from "path";
1621
1600
  function readTargetServers(targetPath) {
1622
1601
  const path = resolve4(expandHome(targetPath));
1623
1602
  if (!existsSync10(path)) return [];
1624
1603
  let cfg;
1625
1604
  try {
1626
- cfg = JSON.parse(readFileSync9(path, "utf-8"));
1605
+ cfg = JSON.parse(readFileSync8(path, "utf-8"));
1627
1606
  } catch {
1628
1607
  return [];
1629
1608
  }
@@ -1782,6 +1761,14 @@ function proFeatureStatus(feature, betaFree = BETA_FREE) {
1782
1761
  }
1783
1762
 
1784
1763
  // src/report/render.ts
1764
+ var SAFETY_VERDICT_MAP = {
1765
+ pass: "ALLOW",
1766
+ warn: "CAUTION",
1767
+ block: "BLOCK"
1768
+ };
1769
+ function mapSafetyFindings(findings) {
1770
+ return findings.map((f) => ({ severity: f.severity, rule: f.rule, message: `${f.file}: ${f.detail}` }));
1771
+ }
1785
1772
  var BORDER = "\u2501".repeat(55);
1786
1773
  var LABEL_WIDTH = 20;
1787
1774
  function padLabel(text) {
@@ -1856,29 +1843,14 @@ function renderReport(result) {
1856
1843
  function renderJson(result) {
1857
1844
  return JSON.stringify(result, null, 2);
1858
1845
  }
1859
-
1860
- // src/commands/gatePipeline.ts
1861
- var SAFETY_VERDICT_MAP = {
1862
- pass: "ALLOW",
1863
- warn: "CAUTION",
1864
- block: "BLOCK"
1865
- };
1866
1846
  function printGateOutput(gateResult, json) {
1867
1847
  if (!json) {
1868
1848
  for (const notice of gateResult.notices ?? []) console.log(`[skill-forge] ${notice}`);
1869
1849
  }
1870
1850
  console.log(json ? renderJson(gateResult) : renderReport(gateResult));
1871
1851
  }
1872
- function asProfile(candidatePath, artifact) {
1873
- const profile = profileSkill(candidatePath);
1874
- const expectedKind = artifact === "mcp" ? "mcp" : "skill";
1875
- if (profile.kind !== expectedKind) {
1876
- throw new Error(
1877
- artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
1878
- );
1879
- }
1880
- return profile;
1881
- }
1852
+
1853
+ // src/commands/gatePipeline.ts
1882
1854
  function tryOverlap(candidatePath, skillsRoot) {
1883
1855
  try {
1884
1856
  return scanOverlap(candidatePath, skillsRoot);
@@ -1908,7 +1880,7 @@ function resolveMcpCandidatePath(entry) {
1908
1880
  if (entry.packageDir) return entry.packageDir;
1909
1881
  let children = [];
1910
1882
  try {
1911
- children = readdirSync7(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1883
+ children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1912
1884
  } catch {
1913
1885
  }
1914
1886
  return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
@@ -1930,11 +1902,7 @@ async function runGate(candidatePath, skillsRoot, {
1930
1902
  notices: proStatus.notice ? [proStatus.notice] : void 0,
1931
1903
  safety: {
1932
1904
  verdict: SAFETY_VERDICT_MAP[safety.verdict],
1933
- findings: safety.findings.map((f) => ({
1934
- severity: f.severity,
1935
- rule: f.rule,
1936
- message: `${f.file}: ${f.detail}`
1937
- }))
1905
+ findings: mapSafetyFindings(safety.findings)
1938
1906
  }
1939
1907
  };
1940
1908
  return { safety, gateResult, proEnabled: proStatus.enabled };
@@ -1942,7 +1910,7 @@ async function runGate(candidatePath, skillsRoot, {
1942
1910
 
1943
1911
  // src/queue.ts
1944
1912
  import { randomUUID as randomUUID2 } from "crypto";
1945
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "fs";
1913
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1946
1914
  import { join as join12 } from "path";
1947
1915
  function getQueuePath() {
1948
1916
  return join12(getHomeDir(), "queue.json");
@@ -1952,7 +1920,7 @@ function readQueue() {
1952
1920
  if (!existsSync11(path)) {
1953
1921
  return { version: 1, entries: [] };
1954
1922
  }
1955
- const raw = readFileSync10(path, "utf-8");
1923
+ const raw = readFileSync9(path, "utf-8");
1956
1924
  const parsed = JSON.parse(raw);
1957
1925
  return { version: 1, entries: parsed.entries ?? [] };
1958
1926
  }
@@ -2468,13 +2436,38 @@ async function maybeOfferInit(options = {}) {
2468
2436
  }
2469
2437
  }
2470
2438
 
2471
- // src/commands/add.ts
2472
- var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
2473
- var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
2439
+ // src/decision.ts
2440
+ import { createInterface as createInterface2 } from "readline/promises";
2474
2441
  function logOutcome(message, json) {
2475
2442
  if (json) console.error(message);
2476
2443
  else console.log(message);
2477
2444
  }
2445
+ function yesDecision(verdict) {
2446
+ return verdict === "block" ? "reject" : "promote";
2447
+ }
2448
+ async function promptDecision(verdict, subject) {
2449
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
2450
+ const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
2451
+ const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
2452
+ try {
2453
+ process.stdout.write(prompt);
2454
+ for await (const raw of rl) {
2455
+ const answer = raw.trim().toLowerCase() || defaultChoice;
2456
+ if (answer === "p" || answer === "promote") return "promote";
2457
+ if (answer === "h" || answer === "hold") return "hold";
2458
+ if (answer === "r" || answer === "reject") return "reject";
2459
+ process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
2460
+ ${prompt}`);
2461
+ }
2462
+ return defaultChoice;
2463
+ } finally {
2464
+ rl.close();
2465
+ }
2466
+ }
2467
+
2468
+ // src/commands/add.ts
2469
+ var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
2470
+ var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
2478
2471
  async function add(source, options = {}) {
2479
2472
  await maybeOfferInit({ json: options.json, yes: options.yes });
2480
2473
  const config = loadConfig();
@@ -2511,7 +2504,7 @@ async function add(source, options = {}) {
2511
2504
  throw new Error(`expected a skill candidate, found a ${profile.kind}`);
2512
2505
  }
2513
2506
  printGateOutput(gateResult, options.json);
2514
- const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
2507
+ const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
2515
2508
  let installedPath = null;
2516
2509
  if (decision === "promote") {
2517
2510
  const result = promote(entry, targetRoot);
@@ -2551,7 +2544,7 @@ async function add(source, options = {}) {
2551
2544
  gate: {
2552
2545
  license: profile.license ?? null,
2553
2546
  safetyVerdict: safety.verdict,
2554
- safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
2547
+ safetyFindings: safety.findings.map(formatSafetyFinding),
2555
2548
  overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
2556
2549
  suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
2557
2550
  },
@@ -2583,13 +2576,15 @@ async function addMcp(source, config, options) {
2583
2576
  );
2584
2577
  }
2585
2578
  printGateOutput(gateResult, options.json);
2586
- const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
2579
+ const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
2587
2580
  let installedPath = null;
2588
2581
  if (decision === "promote") {
2589
2582
  const detail = resolveMcpCandidateDetail(profile);
2590
2583
  const serverProfile = {
2591
2584
  name: detail.name,
2592
- version: detail.version ?? "unknown",
2585
+ // version may be absent — promoteMcp refuses the unpinnable npx fallback
2586
+ // rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
2587
+ version: detail.version,
2593
2588
  envVars: detail.env ? Object.keys(detail.env) : void 0,
2594
2589
  candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2595
2590
  };
@@ -2622,7 +2617,7 @@ async function addMcp(source, config, options) {
2622
2617
  gate: {
2623
2618
  license: null,
2624
2619
  safetyVerdict: safety.verdict,
2625
- safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
2620
+ safetyFindings: safety.findings.map(formatSafetyFinding),
2626
2621
  overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
2627
2622
  suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
2628
2623
  },
@@ -2635,28 +2630,6 @@ async function addMcp(source, config, options) {
2635
2630
  }
2636
2631
  }
2637
2632
  }
2638
- function yesDecision(verdict) {
2639
- return verdict === "block" ? "reject" : "promote";
2640
- }
2641
- async function promptDecision(verdict) {
2642
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
2643
- const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
2644
- const prompt = `Promote, hold, or reject this skill? [promote/hold/reject] (default: ${defaultChoice}) `;
2645
- try {
2646
- process.stdout.write(prompt);
2647
- for await (const raw of rl) {
2648
- const answer = raw.trim().toLowerCase() || defaultChoice;
2649
- if (answer === "p" || answer === "promote") return "promote";
2650
- if (answer === "h" || answer === "hold") return "hold";
2651
- if (answer === "r" || answer === "reject") return "reject";
2652
- process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
2653
- ${prompt}`);
2654
- }
2655
- return defaultChoice;
2656
- } finally {
2657
- rl.close();
2658
- }
2659
- }
2660
2633
 
2661
2634
  // src/commands/scan.ts
2662
2635
  async function scan(source, options = {}) {
@@ -2748,6 +2721,319 @@ async function status() {
2748
2721
  console.log(` held in quarantine: ${heldCount}`);
2749
2722
  }
2750
2723
 
2724
+ // src/evolve.ts
2725
+ import { createInterface as createInterface3 } from "readline/promises";
2726
+ import { createHash } from "crypto";
2727
+ import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
2728
+ import { tmpdir as tmpdir2 } from "os";
2729
+ import { basename as basename6, dirname as dirname5, join as join17, resolve as resolvePath2, sep } from "path";
2730
+ var SKILLOPT_BIN = "skillopt-sleep";
2731
+ var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
2732
+ async function evolve(targetSkillDir, options = {}, deps = {}) {
2733
+ const exec = deps.exec ?? defaultExec;
2734
+ const json = options.json;
2735
+ const resolvedTargetDir = resolvePath2(targetSkillDir);
2736
+ const skillMdPath = join17(resolvedTargetDir, "SKILL.md");
2737
+ if (!existsSync14(skillMdPath)) {
2738
+ throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
2739
+ }
2740
+ const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
2741
+ if (!proStatus.enabled) {
2742
+ throw new UserError(proStatus.notice ?? "evolve is a Pro feature \u2014 set SKILL_FORGE_LICENSE or config.licenseKey to a valid license to unlock.");
2743
+ }
2744
+ const notices = proStatus.notice ? [proStatus.notice] : [];
2745
+ const backend = options.backend ?? "mock";
2746
+ if (backend !== "mock") {
2747
+ const disclosure = `data boundary: this run uses the "${backend}" backend. Per SkillOpt-Sleep's own docs, harvesting itself is local and read-only, but a real backend sends truncated excerpts from harvested sessions and derived tasks to the provider you selected \u2014 this is not currently guaranteed to be secret-free.`;
2748
+ if (options.yes) {
2749
+ notices.push(`${disclosure} Proceeding (--yes).`);
2750
+ } else if (json) {
2751
+ throw new UserError(`${disclosure} Pass --yes to proceed \u2014 --json is non-interactive and cannot prompt for confirmation.`);
2752
+ } else {
2753
+ console.log(`[skill-forge] ${disclosure}`);
2754
+ const confirmFn = deps.confirm ?? defaultConfirm;
2755
+ const confirmed = await confirmFn("Proceed and send session data to this backend? [y/N] ");
2756
+ if (!confirmed) {
2757
+ throw new UserError("evolve cancelled \u2014 data-boundary disclosure was not accepted.");
2758
+ }
2759
+ notices.push(`${disclosure} Confirmed interactively.`);
2760
+ }
2761
+ } else {
2762
+ notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
2763
+ }
2764
+ const project = resolvePath2(options.project ?? process.cwd());
2765
+ const present = await detectSkillopt(exec, project);
2766
+ if (!present) {
2767
+ throw new UserError(
2768
+ "`skillopt-sleep` not found on PATH. Install it with `pip install skillopt` (https://github.com/microsoft/SkillOpt) and re-run `skill-forge evolve`."
2769
+ );
2770
+ }
2771
+ const subcommand = options.dryRun ? "dry-run" : "run";
2772
+ const runArgs = [subcommand, "--project", project, "--target-skill-path", skillMdPath, "--json", "--backend", backend];
2773
+ if (options.lookbackHours !== void 0) {
2774
+ runArgs.push("--lookback-hours", String(options.lookbackHours));
2775
+ }
2776
+ const runResult = await exec(SKILLOPT_BIN, runArgs, { cwd: project });
2777
+ if (runResult.code !== 0) {
2778
+ throw new UserError(`skillopt-sleep ${subcommand} failed (exit ${runResult.code}): ${tail(runResult.stderr || runResult.stdout)}`);
2779
+ }
2780
+ let rawOutput;
2781
+ try {
2782
+ rawOutput = JSON.parse(runResult.stdout);
2783
+ } catch (err) {
2784
+ throw new UserError(`could not parse skillopt-sleep JSON output: ${err.message}`);
2785
+ }
2786
+ const parsed = validateRunOutput(rawOutput);
2787
+ if (!parsed.accepted || !parsed.staging_dir) {
2788
+ if (json) {
2789
+ console.log(JSON.stringify({ ok: true, accepted: parsed.accepted, stagingDir: parsed.staging_dir }, null, 2));
2790
+ } else {
2791
+ console.log("[skill-forge] skillopt-sleep produced no accepted proposal \u2014 nothing to re-gate or adopt.");
2792
+ }
2793
+ return;
2794
+ }
2795
+ const stagingRoot = join17(project, ".skillopt-sleep", "staging");
2796
+ const stagingDir = resolvePath2(parsed.staging_dir);
2797
+ if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
2798
+ throw new UserError(
2799
+ `skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
2800
+ );
2801
+ }
2802
+ const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
2803
+ const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
2804
+ const reportMdPath = join17(stagingDir, "report.md");
2805
+ if (!existsSync14(proposedSkillMd)) {
2806
+ throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
2807
+ }
2808
+ if (existsSync14(reportMdPath)) {
2809
+ notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
2810
+ }
2811
+ assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
2812
+ assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
2813
+ const scannedSkillHash = sha256File(proposedSkillMd);
2814
+ const scannedClaudeHash = sha256File(proposedClaudeMd);
2815
+ const tempDir = mkdtempSync2(join17(tmpdir2(), "skill-forge-evolve-"));
2816
+ let profile;
2817
+ let safety;
2818
+ try {
2819
+ cpSync3(proposedSkillMd, join17(tempDir, "SKILL.md"));
2820
+ if (existsSync14(proposedClaudeMd)) {
2821
+ cpSync3(proposedClaudeMd, join17(tempDir, "CLAUDE.md"));
2822
+ }
2823
+ profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
2824
+ const strictness = loadConfig().strictness;
2825
+ safety = await scanSafety(tempDir, strictness);
2826
+ } finally {
2827
+ try {
2828
+ rmSync3(tempDir, { recursive: true, force: true });
2829
+ } catch (err) {
2830
+ console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
2831
+ }
2832
+ }
2833
+ const safetyVerdict = safety.verdict;
2834
+ const gateResult = {
2835
+ profile,
2836
+ safety: {
2837
+ verdict: SAFETY_VERDICT_MAP[safetyVerdict],
2838
+ findings: mapSafetyFindings(safety.findings)
2839
+ },
2840
+ notices
2841
+ };
2842
+ printGateOutput(gateResult, json);
2843
+ if (options.dryRun) {
2844
+ logOutcome("[skill-forge] --dry-run: report only, nothing staged or adopted.", json);
2845
+ return;
2846
+ }
2847
+ const decision = options.yes || json ? yesDecision(safetyVerdict) : await promptDecision(safetyVerdict, "this evolution proposal");
2848
+ if (decision === "promote") {
2849
+ const manifest = readManifest(stagingDir);
2850
+ if (isAlreadyAdopted(stagingDir, manifest) && !options.force) {
2851
+ throw new UserError(
2852
+ `staging dir ${stagingDir} looks already adopted \u2014 its proposed content is byte-identical to the live skill (and memory, if present). Pass --force to re-adopt anyway.`
2853
+ );
2854
+ }
2855
+ if (sha256File(proposedSkillMd) !== scannedSkillHash || sha256File(proposedClaudeMd) !== scannedClaudeHash) {
2856
+ throw new UserError(`proposal changed after it was re-gated (staging dir: ${stagingDir}) \u2014 refusing to adopt`);
2857
+ }
2858
+ const adoptResult = await exec(SKILLOPT_BIN, ["adopt", "--staging", stagingDir, "--project", project], { cwd: project });
2859
+ if (adoptResult.code !== 0) {
2860
+ throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
2861
+ }
2862
+ logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
2863
+ const skillsRootForProvenance = dirname5(resolvedTargetDir);
2864
+ let ledgerPath;
2865
+ try {
2866
+ ledgerPath = appendProvenance(
2867
+ {
2868
+ name: profile.name ?? basename6(resolvedTargetDir),
2869
+ source: resolvedTargetDir,
2870
+ version: profile.version,
2871
+ license: profile.license,
2872
+ verb: "DEFER",
2873
+ target: resolvedTargetDir,
2874
+ took: `self-evolved via SkillOpt-Sleep (staging: ${stagingDir})`,
2875
+ notes: `self-evolved via SkillOpt-Sleep; re-gated (verdict: ${safetyVerdict})`
2876
+ },
2877
+ skillsRootForProvenance
2878
+ );
2879
+ } catch (err) {
2880
+ throw new UserError(
2881
+ `adopt succeeded and the live skill at ${resolvedTargetDir} has already changed, but recording the provenance entry failed: ${err.message}`
2882
+ );
2883
+ }
2884
+ logOutcome(`[skill-forge] provenance recorded -> ${ledgerPath}`, json);
2885
+ try {
2886
+ appendQueueEntry({
2887
+ source: resolvedTargetDir,
2888
+ sourceType: "local",
2889
+ installedPath: resolvedTargetDir,
2890
+ quarantinePath: stagingDir,
2891
+ gate: {
2892
+ license: profile.license ?? null,
2893
+ safetyVerdict,
2894
+ safetyFindings: safety.findings.map(formatSafetyFinding),
2895
+ overlapTop: [],
2896
+ suggestedVerb: null
2897
+ },
2898
+ status: "pending",
2899
+ artifactType: "skill",
2900
+ origin: "evolve"
2901
+ });
2902
+ } catch (err) {
2903
+ throw new UserError(
2904
+ `adopt succeeded and the live skill at ${resolvedTargetDir} has already changed (provenance was recorded), but appending the pending-ingestion queue entry failed: ${err.message}`
2905
+ );
2906
+ }
2907
+ logOutcome("[skill-forge] queued for ingestion review", json);
2908
+ } else if (decision === "reject") {
2909
+ try {
2910
+ rmSync3(stagingDir, { recursive: true, force: true });
2911
+ } catch (err) {
2912
+ throw new UserError(
2913
+ `rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
2914
+ );
2915
+ }
2916
+ logOutcome("[skill-forge] rejected \u2014 staging dir removed", json);
2917
+ if (safetyVerdict === "block") process.exitCode = 1;
2918
+ } else {
2919
+ logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
2920
+ if (existsSync14(reportMdPath)) {
2921
+ logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
2922
+ }
2923
+ }
2924
+ }
2925
+ async function defaultConfirm(question) {
2926
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
2927
+ try {
2928
+ process.stdout.write(question);
2929
+ for await (const raw of rl) {
2930
+ return /^y(es)?$/i.test(raw.trim());
2931
+ }
2932
+ return false;
2933
+ } finally {
2934
+ rl.close();
2935
+ }
2936
+ }
2937
+ async function detectSkillopt(exec, cwd) {
2938
+ try {
2939
+ const result = await exec(SKILLOPT_BIN, ["--help"], { cwd });
2940
+ return result.code === 0;
2941
+ } catch {
2942
+ return false;
2943
+ }
2944
+ }
2945
+ function tail(text, maxLines = 20) {
2946
+ return text.trim().split("\n").slice(-maxLines).join("\n");
2947
+ }
2948
+ function validateRunOutput(raw) {
2949
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
2950
+ throw new UserError(`skillopt-sleep produced unexpected JSON output (expected an object): ${JSON.stringify(raw)}`);
2951
+ }
2952
+ const obj = raw;
2953
+ const stagingDirRaw = obj.staging_dir;
2954
+ if (stagingDirRaw !== null && stagingDirRaw !== void 0 && typeof stagingDirRaw !== "string") {
2955
+ throw new UserError(
2956
+ `skillopt-sleep produced an unexpected "staging_dir" (expected a string or null): ${JSON.stringify(stagingDirRaw)}`
2957
+ );
2958
+ }
2959
+ return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
2960
+ }
2961
+ function readManifest(stagingDir) {
2962
+ const manifestPath = join17(stagingDir, "manifest.json");
2963
+ let raw;
2964
+ try {
2965
+ raw = readFileSync10(manifestPath, "utf-8");
2966
+ } catch (err) {
2967
+ throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
2968
+ }
2969
+ let parsed;
2970
+ try {
2971
+ parsed = JSON.parse(raw);
2972
+ } catch (err) {
2973
+ throw new UserError(`could not parse staging manifest ${manifestPath}: ${err.message}`);
2974
+ }
2975
+ return validateManifest(parsed, manifestPath);
2976
+ }
2977
+ function validateManifest(raw, manifestPath) {
2978
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
2979
+ throw new UserError(`staging manifest ${manifestPath} has an unexpected shape (expected an object)`);
2980
+ }
2981
+ const obj = raw;
2982
+ if (typeof obj.live_skill_path !== "string") {
2983
+ throw new UserError(`staging manifest ${manifestPath} is missing a valid "live_skill_path" string`);
2984
+ }
2985
+ if (obj.live_memory_path !== void 0 && obj.live_memory_path !== null && typeof obj.live_memory_path !== "string") {
2986
+ throw new UserError(`staging manifest ${manifestPath} has an unexpected "live_memory_path" (expected a string or null)`);
2987
+ }
2988
+ if (typeof obj.has_skill !== "boolean" || typeof obj.has_memory !== "boolean") {
2989
+ throw new UserError(`staging manifest ${manifestPath} is missing valid "has_skill"/"has_memory" booleans`);
2990
+ }
2991
+ return {
2992
+ live_skill_path: obj.live_skill_path,
2993
+ live_memory_path: obj.live_memory_path ?? null,
2994
+ has_skill: obj.has_skill,
2995
+ has_memory: obj.has_memory
2996
+ };
2997
+ }
2998
+ function filesIdentical(a, b) {
2999
+ if (!existsSync14(a) || !existsSync14(b)) return false;
3000
+ return readFileSync10(a).equals(readFileSync10(b));
3001
+ }
3002
+ function isAlreadyAdopted(stagingDir, manifest) {
3003
+ const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
3004
+ const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
3005
+ let sawComparison = false;
3006
+ if (existsSync14(proposedSkillMd)) {
3007
+ sawComparison = true;
3008
+ if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
3009
+ }
3010
+ if (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
3011
+ sawComparison = true;
3012
+ if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
3013
+ }
3014
+ return sawComparison;
3015
+ }
3016
+ function assertNotSymlink(path, label) {
3017
+ let stat;
3018
+ try {
3019
+ stat = lstatSync(path);
3020
+ } catch {
3021
+ return;
3022
+ }
3023
+ if (stat.isSymbolicLink()) {
3024
+ throw new UserError(`refusing to re-gate ${label}: ${path} is a symlink (a staged proposal must be a plain file)`);
3025
+ }
3026
+ }
3027
+ function sha256File(path) {
3028
+ if (!existsSync14(path)) return null;
3029
+ return createHash("sha256").update(readFileSync10(path)).digest("hex");
3030
+ }
3031
+
3032
+ // src/commands/evolve.ts
3033
+ async function evolveCommand(targetSkillDir, options = {}) {
3034
+ await evolve(targetSkillDir, options);
3035
+ }
3036
+
2751
3037
  // src/cli.ts
2752
3038
  var require2 = createRequire(import.meta.url);
2753
3039
  var pkg = require2("../package.json");
@@ -2757,6 +3043,13 @@ function parseArtifact(value) {
2757
3043
  }
2758
3044
  return value;
2759
3045
  }
3046
+ function parseLookbackHours(value) {
3047
+ const n = Number(value);
3048
+ if (value.trim() === "" || !Number.isFinite(n) || n < 0) {
3049
+ throw new UserError(`--lookback-hours must be a non-negative number (got "${value}")`);
3050
+ }
3051
+ return n;
3052
+ }
2760
3053
  var program = new Command();
2761
3054
  program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
2762
3055
  program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).option(
@@ -2766,6 +3059,7 @@ program.command("add <source>").description("Quarantine-install a skill and run
2766
3059
  program.command("scan <source>").description("Run the safety gate against a skill without installing it").option("--json", "print the gate result as JSON instead of the terminal report").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).action(scan);
2767
3060
  program.command("list").description("List skills currently in quarantine").action(list);
2768
3061
  program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
3062
+ program.command("evolve <target-skill-dir>").description("Self-evolve a skill via SkillOpt-Sleep, re-gate the proposal, then promote/hold/reject (Pro)").option("--project <dir>", "project dir passed to SkillOpt-Sleep as --project (defaults to cwd)").option("--dry-run", "SkillOpt-Sleep dry-run: report only, nothing staged").option("--backend <name>", "SkillOpt-Sleep backend (default: mock, offline \u2014 a real backend requires --yes or interactive confirmation)").option("--lookback-hours <n>", "hours of session history for SkillOpt-Sleep to harvest", parseLookbackHours).option("-y, --yes", "skip interactive prompts; honor the re-gate verdict automatically").option("--json", "print the re-gate result as JSON instead of the terminal report").option("--force", "allow re-adopting a staging dir whose proposed content already matches the live skill").action(evolveCommand);
2769
3063
  program.command("init").description("Detect installed agents and set gate targets, default promotion target, and handoff agent").option("--defaults", "non-interactive: use all detected agents as gate targets, the first as the default target").option("--list", "print detected agent skill roots and exit \u2014 no writes").action(init);
2770
3064
  try {
2771
3065
  await program.parseAsync();