@rhize/skill-forge 0.6.1 → 0.7.1

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,8 +5,7 @@ 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
- import { join as join15 } from "path";
8
+ import { join as join16 } from "path";
10
9
 
11
10
  // src/config.ts
12
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -296,16 +295,132 @@ function moveDir(src, dest) {
296
295
  }
297
296
 
298
297
  // src/install/promoteMcp.ts
299
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
298
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
300
299
  import { dirname } from "path";
300
+
301
+ // src/gate/scanShared.ts
302
+ import { readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
303
+ import { extname, join as join5 } from "path";
304
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
305
+ var BINARY_EXT = /* @__PURE__ */ new Set([
306
+ ".png",
307
+ ".jpg",
308
+ ".jpeg",
309
+ ".gif",
310
+ ".webp",
311
+ ".ico",
312
+ ".pdf",
313
+ ".zip",
314
+ ".tar",
315
+ ".gz",
316
+ ".woff",
317
+ ".woff2",
318
+ ".ttf",
319
+ ".eot",
320
+ ".mp4",
321
+ ".mp3",
322
+ ".wasm",
323
+ ".bin",
324
+ ".exe"
325
+ ]);
326
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
327
+ function* walk(dir) {
328
+ let entries;
329
+ try {
330
+ entries = readdirSync2(dir, { withFileTypes: true });
331
+ } catch {
332
+ return;
333
+ }
334
+ for (const entry of entries) {
335
+ const full = join5(dir, entry.name);
336
+ if (entry.isDirectory()) {
337
+ if (SKIP_DIRS2.has(entry.name)) continue;
338
+ yield* walk(full);
339
+ } else if (entry.isFile()) {
340
+ yield full;
341
+ }
342
+ }
343
+ }
344
+ function readTextFile(file) {
345
+ if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
346
+ try {
347
+ if (statSync2(file).size > MAX_FILE_BYTES) return void 0;
348
+ const content = readFileSync2(file, "utf-8");
349
+ if (content.slice(0, 512).includes("\0")) return void 0;
350
+ return content;
351
+ } catch {
352
+ return void 0;
353
+ }
354
+ }
355
+ var BLOCK_SEVERITIES_BY_STRICTNESS = {
356
+ "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
357
+ "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
358
+ "warn-only": /* @__PURE__ */ new Set()
359
+ };
360
+ function verdictFor(findings, strictness) {
361
+ const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
362
+ if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
363
+ if (findings.length > 0) return "warn";
364
+ return "pass";
365
+ }
366
+ function formatSafetyFinding(f) {
367
+ return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
368
+ }
369
+ function isPinnedPackageSpec(spec) {
370
+ const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
371
+ const atIdx = withoutScope.lastIndexOf("@");
372
+ if (atIdx <= 0) return false;
373
+ return /^\d/.test(withoutScope.slice(atIdx + 1));
374
+ }
375
+ function isNpxCommand(command) {
376
+ const base = command.split(/[\\/]/).pop() ?? command;
377
+ return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
378
+ }
379
+ function packageSpecBase(spec) {
380
+ const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
381
+ const atIdx = spec.lastIndexOf("@");
382
+ return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
383
+ }
384
+ var NPX_VALUE_FLAGS = /* @__PURE__ */ new Set(["p", "package", "c", "call", "cache", "userconfig", "shell", "n", "node-arg", "npm", "registry"]);
385
+ var NPX_PACKAGE_SPEC_FLAGS = /* @__PURE__ */ new Set(["p", "package"]);
386
+ function collectNpxPackageSpecs(args) {
387
+ const packageFlagSpecs = [];
388
+ let positionalSpec;
389
+ for (let i = 0; i < args.length; i++) {
390
+ const arg = args[i];
391
+ if (arg === "--") break;
392
+ if (arg.startsWith("-")) {
393
+ const eqIdx = arg.indexOf("=");
394
+ const key = (eqIdx === -1 ? arg : arg.slice(0, eqIdx)).replace(/^-+/, "");
395
+ const attached = eqIdx === -1 ? void 0 : arg.slice(eqIdx + 1);
396
+ if (NPX_VALUE_FLAGS.has(key)) {
397
+ let value = attached;
398
+ if (value === void 0) {
399
+ value = args[i + 1];
400
+ i++;
401
+ }
402
+ if (NPX_PACKAGE_SPEC_FLAGS.has(key) && value !== void 0) {
403
+ packageFlagSpecs.push(value);
404
+ }
405
+ continue;
406
+ }
407
+ continue;
408
+ }
409
+ positionalSpec = arg;
410
+ break;
411
+ }
412
+ return { packageFlagSpecs, positionalSpec };
413
+ }
414
+
415
+ // src/install/promoteMcp.ts
301
416
  function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
302
417
  const force = options.force ?? false;
303
418
  const serverName = profile.name;
304
- const { command, args, envVarsToFill } = deriveServerEntry(profile);
419
+ const { command, args, envVarsToFill, autoPinned } = deriveServerEntry(profile);
305
420
  let config = {};
306
421
  let rawExisting;
307
422
  if (existsSync5(targetConfigPath)) {
308
- rawExisting = readFileSync2(targetConfigPath, "utf-8");
423
+ rawExisting = readFileSync3(targetConfigPath, "utf-8");
309
424
  try {
310
425
  config = JSON.parse(rawExisting);
311
426
  } catch (err) {
@@ -330,7 +445,7 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
330
445
  mkdirSync4(dirname(targetConfigPath), { recursive: true });
331
446
  writeFileSync2(targetConfigPath, `${JSON.stringify(config, null, 2)}
332
447
  `);
333
- return { target: targetConfigPath, serverName, envVarsToFill, backupPath };
448
+ return { target: targetConfigPath, serverName, envVarsToFill, backupPath, autoPinned };
334
449
  }
335
450
  function deriveServerEntry(profile) {
336
451
  const envNames = new Set(profile.envVars ?? []);
@@ -338,9 +453,41 @@ function deriveServerEntry(profile) {
338
453
  for (const key of Object.keys(profile.candidateEntry.env)) envNames.add(key);
339
454
  }
340
455
  if (profile.candidateEntry) {
456
+ const { command, args = [] } = profile.candidateEntry;
457
+ if (isNpxCommand(command)) {
458
+ const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
459
+ const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
460
+ const unpinned = allSpecs.filter((spec) => !isPinnedPackageSpec(spec));
461
+ if (unpinned.length > 0) {
462
+ const isSoleUnpinnedPositional = packageFlagSpecs.length === 0 && allSpecs.length === 1 && positionalSpec === unpinned[0];
463
+ if (isSoleUnpinnedPositional) {
464
+ const spec = unpinned[0];
465
+ const specPackage = packageSpecBase(spec);
466
+ if (profile.packageName && specPackage === profile.packageName && profile.version) {
467
+ const to = `${profile.packageName}@${profile.version}`;
468
+ const specIdx = args.indexOf(spec);
469
+ const pinnedArgs = [...args];
470
+ pinnedArgs[specIdx] = to;
471
+ return {
472
+ command,
473
+ args: pinnedArgs,
474
+ envVarsToFill: Array.from(envNames),
475
+ autoPinned: { from: spec, to }
476
+ };
477
+ }
478
+ const reason = profile.packageName && specPackage !== profile.packageName ? `the launch spec names package "${specPackage}", but the candidate's own package.json is "${profile.packageName}" \u2014 pinning it to a version scanned for a different package would be unsafe` : "no version was found in the candidate's own package.json to pin it to";
479
+ throw new UserError(
480
+ `cannot promote MCP server "${profile.name}": its documented launch command "npx ${spec}" is unpinned and can't be safely auto-pinned (${reason}). Pin the spec to an exact version in the candidate's config (e.g. \`${specPackage}@<version>\`) and try again \u2014 there is no override for this.`
481
+ );
482
+ }
483
+ throw new UserError(
484
+ `cannot promote MCP server "${profile.name}": its documented launch command "npx" has unpinned package spec(s) \u2014 ${unpinned.map((s) => `"${s}"`).join(", ")} \u2014 and this shape (a -p/--package dependency, or more than one spec) can't be safely auto-pinned. Pin every spec to an exact version in the candidate's config and try again \u2014 there is no override for this.`
485
+ );
486
+ }
487
+ }
341
488
  return {
342
- command: profile.candidateEntry.command,
343
- args: profile.candidateEntry.args ?? [],
489
+ command,
490
+ args,
344
491
  envVarsToFill: Array.from(envNames)
345
492
  };
346
493
  }
@@ -357,18 +504,22 @@ function deriveServerEntry(profile) {
357
504
  }
358
505
 
359
506
  // src/install/mcpCandidate.ts
360
- import { readFileSync as readFileSync3 } from "fs";
361
- function readPackageVersion(pkgPath) {
507
+ import { readFileSync as readFileSync4 } from "fs";
508
+ import { dirname as dirname2, join as join6 } from "path";
509
+ function readPackageInfo(pkgPath) {
362
510
  try {
363
- const pkg2 = JSON.parse(readFileSync3(pkgPath, "utf-8"));
364
- return typeof pkg2.version === "string" ? pkg2.version : void 0;
511
+ const pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
512
+ return {
513
+ name: typeof pkg2.name === "string" ? pkg2.name : void 0,
514
+ version: typeof pkg2.version === "string" ? pkg2.version : void 0
515
+ };
365
516
  } catch {
366
- return void 0;
517
+ return {};
367
518
  }
368
519
  }
369
520
  function readConfigEntry(configPath, name) {
370
521
  try {
371
- const cfg = JSON.parse(readFileSync3(configPath, "utf-8"));
522
+ const cfg = JSON.parse(readFileSync4(configPath, "utf-8"));
372
523
  const servers = cfg.mcpServers ?? cfg.servers ?? {};
373
524
  const raw = servers[name] ?? {};
374
525
  return {
@@ -383,15 +534,23 @@ function readConfigEntry(configPath, name) {
383
534
  function resolveMcpCandidateDetail(profile) {
384
535
  const name = profile.servers[0];
385
536
  if (profile.config.endsWith("package.json")) {
386
- return { name, version: readPackageVersion(profile.config) };
537
+ return { name, version: readPackageInfo(profile.config).version };
387
538
  }
388
539
  const entry = readConfigEntry(profile.config, name);
389
- return { name, command: entry?.command, args: entry?.args, env: entry?.env };
540
+ const sidecar = readPackageInfo(join6(dirname2(profile.config), "package.json"));
541
+ return {
542
+ name,
543
+ command: entry?.command,
544
+ args: entry?.args,
545
+ env: entry?.env,
546
+ version: sidecar.version,
547
+ packageName: sidecar.name
548
+ };
390
549
  }
391
550
 
392
551
  // src/provenance.ts
393
- import { appendFileSync, existsSync as existsSync6, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
394
- import { join as join5 } from "path";
552
+ import { appendFileSync, existsSync as existsSync6, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
553
+ import { join as join7 } from "path";
395
554
  var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
396
555
  var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
397
556
  function todayLocalIsoDate() {
@@ -405,7 +564,7 @@ function appendProvenance(entry, skillsRoot) {
405
564
  if (!FORGE_VERBS.includes(entry.verb)) {
406
565
  throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
407
566
  }
408
- if (!existsSync6(skillsRoot) || !statSync2(skillsRoot).isDirectory()) {
567
+ if (!existsSync6(skillsRoot) || !statSync3(skillsRoot).isDirectory()) {
409
568
  throw new Error(`skills-root not a directory: ${skillsRoot}`);
410
569
  }
411
570
  const today = todayLocalIsoDate();
@@ -422,7 +581,7 @@ function appendProvenance(entry, skillsRoot) {
422
581
  - **Drift check:** \`${driftCheck}\`
423
582
  - **Notes:** ${entry.notes ?? ""}
424
583
  `;
425
- const ledgerPath = join5(skillsRoot, "SOURCES.md");
584
+ const ledgerPath = join7(skillsRoot, "SOURCES.md");
426
585
  if (!existsSync6(ledgerPath)) {
427
586
  writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
428
587
  }
@@ -432,7 +591,7 @@ function appendProvenance(entry, skillsRoot) {
432
591
 
433
592
  // src/commands/gatePipeline.ts
434
593
  import { readdirSync as readdirSync5 } from "fs";
435
- import { join as join11 } from "path";
594
+ import { join as join12 } from "path";
436
595
 
437
596
  // src/gate/profile.ts
438
597
  import { execFileSync } from "child_process";
@@ -448,79 +607,11 @@ import {
448
607
  statSync as statSync4
449
608
  } from "fs";
450
609
  import { tmpdir } from "os";
451
- import { basename as basename3, dirname as dirname2, extname as extname3, join as join8, resolve as resolve2 } from "path";
610
+ import { basename as basename3, dirname as dirname3, extname as extname3, join as join9, resolve as resolve2 } from "path";
452
611
 
453
612
  // src/gate/mcpCapabilities.ts
454
613
  import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
455
- import { extname as extname2, join as join7 } from "path";
456
-
457
- // src/gate/scanShared.ts
458
- import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
459
- import { extname, join as join6 } from "path";
460
- var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
461
- var BINARY_EXT = /* @__PURE__ */ new Set([
462
- ".png",
463
- ".jpg",
464
- ".jpeg",
465
- ".gif",
466
- ".webp",
467
- ".ico",
468
- ".pdf",
469
- ".zip",
470
- ".tar",
471
- ".gz",
472
- ".woff",
473
- ".woff2",
474
- ".ttf",
475
- ".eot",
476
- ".mp4",
477
- ".mp3",
478
- ".wasm",
479
- ".bin",
480
- ".exe"
481
- ]);
482
- var MAX_FILE_BYTES = 2 * 1024 * 1024;
483
- function* walk(dir) {
484
- let entries;
485
- try {
486
- entries = readdirSync2(dir, { withFileTypes: true });
487
- } catch {
488
- return;
489
- }
490
- for (const entry of entries) {
491
- const full = join6(dir, entry.name);
492
- if (entry.isDirectory()) {
493
- if (SKIP_DIRS2.has(entry.name)) continue;
494
- yield* walk(full);
495
- } else if (entry.isFile()) {
496
- yield full;
497
- }
498
- }
499
- }
500
- function readTextFile(file) {
501
- if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
502
- try {
503
- if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
504
- const content = readFileSync4(file, "utf-8");
505
- if (content.slice(0, 512).includes("\0")) return void 0;
506
- return content;
507
- } catch {
508
- return void 0;
509
- }
510
- }
511
- var BLOCK_SEVERITIES_BY_STRICTNESS = {
512
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
513
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
514
- "warn-only": /* @__PURE__ */ new Set()
515
- };
516
- function verdictFor(findings, strictness) {
517
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
518
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
519
- if (findings.length > 0) return "warn";
520
- return "pass";
521
- }
522
-
523
- // src/gate/mcpCapabilities.ts
614
+ import { extname as extname2, join as join8 } from "path";
524
615
  var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
525
616
  var MAX_ITEMS_PER_CATEGORY = 200;
526
617
  function readJsonSafe(file) {
@@ -626,7 +717,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
626
717
  function extractMcpCapabilities(packageDir) {
627
718
  const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
628
719
  let hasPackageJsonMarker = false;
629
- const pkgPath = join7(packageDir, "package.json");
720
+ const pkgPath = join8(packageDir, "package.json");
630
721
  if (existsSync7(pkgPath)) {
631
722
  const pkg2 = readJsonSafe(pkgPath);
632
723
  if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
@@ -634,7 +725,7 @@ function extractMcpCapabilities(packageDir) {
634
725
  }
635
726
  }
636
727
  for (const manifestName of [".mcp.json", "mcp.json"]) {
637
- const manifestPath = join7(packageDir, manifestName);
728
+ const manifestPath = join8(packageDir, manifestName);
638
729
  if (!existsSync7(manifestPath)) continue;
639
730
  const parsed = readJsonSafe(manifestPath);
640
731
  if (parsed !== void 0) {
@@ -725,8 +816,24 @@ function profileSkill(inputPath) {
725
816
  }
726
817
  return profileSkillDir(p);
727
818
  }
819
+ function asSkillProfile(profile, context) {
820
+ if (profile.kind !== "skill") {
821
+ throw new Error(`expected a skill candidate ${context}, found a ${profile.kind}`);
822
+ }
823
+ return profile;
824
+ }
825
+ function asProfile(candidatePath, artifact) {
826
+ const profile = profileSkill(candidatePath);
827
+ if (artifact === "mcp") {
828
+ if (profile.kind !== "mcp") {
829
+ throw new Error(`expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}`);
830
+ }
831
+ return profile;
832
+ }
833
+ return asSkillProfile(profile, `at ${candidatePath}`);
834
+ }
728
835
  function classifyInput(p) {
729
- if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
836
+ if (existsSync8(join9(p, ".claude-plugin", "plugin.json"))) return "plugin";
730
837
  if (detectArtifactType(p) === "mcp") return "mcp";
731
838
  return "skill";
732
839
  }
@@ -734,7 +841,7 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
734
841
  var MCP_KEYWORD2 = "mcp-server";
735
842
  var MCP_BIN_PREFIX2 = /^mcp-/i;
736
843
  function readPackageJsonSafe(root) {
737
- const pkgPath = join8(root, "package.json");
844
+ const pkgPath = join9(root, "package.json");
738
845
  if (!existsSync8(pkgPath)) return void 0;
739
846
  try {
740
847
  return JSON.parse(readFileSync6(pkgPath, "utf-8"));
@@ -758,7 +865,7 @@ function hasMcpPackageMarkers(pkg2) {
758
865
  return false;
759
866
  }
760
867
  function detectArtifactType(root) {
761
- if (existsSync8(join8(root, ".mcp.json")) || existsSync8(join8(root, "mcp.json"))) return "mcp";
868
+ if (existsSync8(join9(root, ".mcp.json")) || existsSync8(join9(root, "mcp.json"))) return "mcp";
762
869
  const pkg2 = readPackageJsonSafe(root);
763
870
  if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
764
871
  return "skill";
@@ -768,22 +875,22 @@ function resolveSkillMd(inputPath) {
768
875
  let tmpDir;
769
876
  const initialStat = statSync4(currentPath);
770
877
  if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
771
- tmpDir = mkdtempSync(join8(tmpdir(), "forge_"));
878
+ tmpDir = mkdtempSync(join9(tmpdir(), "forge_"));
772
879
  extractZip(currentPath, tmpDir);
773
880
  currentPath = tmpDir;
774
881
  }
775
882
  const stat = statSync4(currentPath);
776
883
  if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
777
- return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
884
+ return { skillMd: currentPath, root: dirname3(currentPath), tmpDir };
778
885
  }
779
886
  if (stat.isDirectory()) {
780
- const direct = join8(currentPath, "SKILL.md");
887
+ const direct = join9(currentPath, "SKILL.md");
781
888
  if (existsSync8(direct)) {
782
889
  return { skillMd: direct, root: currentPath, tmpDir };
783
890
  }
784
891
  const matches = findSkillMdOneLevelDown(currentPath);
785
892
  if (matches.length === 1) {
786
- return { skillMd: matches[0], root: dirname2(matches[0]), tmpDir };
893
+ return { skillMd: matches[0], root: dirname3(matches[0]), tmpDir };
787
894
  }
788
895
  if (matches.length > 1) {
789
896
  cleanupTmp(tmpDir);
@@ -803,15 +910,15 @@ function findSkillMdOneLevelDown(dir) {
803
910
  const matches = /* @__PURE__ */ new Set();
804
911
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
805
912
  if (entry.isDirectory()) {
806
- const candidate = join8(dir, entry.name, "SKILL.md");
913
+ const candidate = join9(dir, entry.name, "SKILL.md");
807
914
  if (existsSync8(candidate)) matches.add(candidate);
808
915
  }
809
916
  }
810
- const skillsDir = join8(dir, "skills");
917
+ const skillsDir = join9(dir, "skills");
811
918
  if (isDir(skillsDir)) {
812
919
  for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
813
920
  if (entry.isDirectory()) {
814
- const candidate = join8(skillsDir, entry.name, "SKILL.md");
921
+ const candidate = join9(skillsDir, entry.name, "SKILL.md");
815
922
  if (existsSync8(candidate)) matches.add(candidate);
816
923
  }
817
924
  }
@@ -895,7 +1002,7 @@ function detectLicense(root, fm) {
895
1002
  if (fm.license) return fm.license;
896
1003
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
897
1004
  for (const name of names) {
898
- const p = join8(root, name);
1005
+ const p = join9(root, name);
899
1006
  if (existsSync8(p) && statSync4(p).isFile()) {
900
1007
  const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
901
1008
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
@@ -909,7 +1016,7 @@ function detectLicense(root, fm) {
909
1016
  function walkFiles(dir) {
910
1017
  const out = [];
911
1018
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
912
- const full = join8(dir, entry.name);
1019
+ const full = join9(dir, entry.name);
913
1020
  if (entry.isDirectory()) {
914
1021
  out.push(...walkFiles(full));
915
1022
  } else if (entry.isFile()) {
@@ -921,7 +1028,7 @@ function walkFiles(dir) {
921
1028
  function countResources(root) {
922
1029
  const resources = {};
923
1030
  for (const d of RESOURCE_DIRS) {
924
- const dp = join8(root, d);
1031
+ const dp = join9(root, d);
925
1032
  if (isDir(dp)) {
926
1033
  resources[d] = walkFiles(dp).length;
927
1034
  }
@@ -932,7 +1039,7 @@ function scanMcpAndImports(text, root) {
932
1039
  const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
933
1040
  const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
934
1041
  const imports = /* @__PURE__ */ new Set();
935
- const scriptsDir = join8(root, "scripts");
1042
+ const scriptsDir = join9(root, "scripts");
936
1043
  if (isDir(scriptsDir)) {
937
1044
  for (const f of walkFiles(scriptsDir)) {
938
1045
  if (!f.endsWith(".py")) continue;
@@ -981,19 +1088,19 @@ function profileSkillDir(inputPath) {
981
1088
  }
982
1089
  }
983
1090
  function profilePlugin(p) {
984
- const manifestPath = join8(p, ".claude-plugin", "plugin.json");
1091
+ const manifestPath = join9(p, ".claude-plugin", "plugin.json");
985
1092
  let manifest;
986
1093
  try {
987
1094
  manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
988
1095
  } catch (err) {
989
1096
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
990
1097
  }
991
- const skillsDir = join8(p, "skills");
1098
+ const skillsDir = join9(p, "skills");
992
1099
  let skills = [];
993
1100
  if (isDir(skillsDir)) {
994
- skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join8(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
1101
+ skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join9(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
995
1102
  }
996
- const commandsDir = join8(p, "commands");
1103
+ const commandsDir = join9(p, "commands");
997
1104
  const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
998
1105
  const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
999
1106
  const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
@@ -1011,7 +1118,7 @@ function profilePlugin(p) {
1011
1118
  };
1012
1119
  }
1013
1120
  function profileMcp(p) {
1014
- const explicitConfig = existsSync8(join8(p, ".mcp.json")) ? join8(p, ".mcp.json") : existsSync8(join8(p, "mcp.json")) ? join8(p, "mcp.json") : void 0;
1121
+ const explicitConfig = existsSync8(join9(p, ".mcp.json")) ? join9(p, ".mcp.json") : existsSync8(join9(p, "mcp.json")) ? join9(p, "mcp.json") : void 0;
1015
1122
  if (explicitConfig) {
1016
1123
  let cfg;
1017
1124
  try {
@@ -1031,7 +1138,7 @@ function profileMcp(p) {
1031
1138
  capabilities: extractMcpCapabilities(p)
1032
1139
  };
1033
1140
  }
1034
- const pkgPath = join8(p, "package.json");
1141
+ const pkgPath = join9(p, "package.json");
1035
1142
  let pkg2;
1036
1143
  try {
1037
1144
  pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
@@ -1053,7 +1160,7 @@ function profileMcp(p) {
1053
1160
  // src/gate/safety.ts
1054
1161
  import { statSync as statSync5 } from "fs";
1055
1162
  import { execFile } from "child_process";
1056
- import { delimiter, join as join9 } from "path";
1163
+ import { delimiter, join as join10 } from "path";
1057
1164
  import { promisify } from "util";
1058
1165
  var execFileAsync = promisify(execFile);
1059
1166
  var DENY_RULES = [
@@ -1152,7 +1259,7 @@ function which(cmd) {
1152
1259
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
1153
1260
  for (const dir of dirs) {
1154
1261
  for (const ext of exts) {
1155
- const candidate = join9(dir, cmd + ext);
1262
+ const candidate = join10(dir, cmd + ext);
1156
1263
  try {
1157
1264
  if (statSync5(candidate).isFile()) return candidate;
1158
1265
  } catch {
@@ -1193,7 +1300,7 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
1193
1300
 
1194
1301
  // src/gate/overlap.ts
1195
1302
  import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync4 } from "fs";
1196
- import { basename as basename4, dirname as dirname3, join as join10, resolve as resolve3 } from "path";
1303
+ import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve3 } from "path";
1197
1304
  var STOPWORDS = new Set(
1198
1305
  `
1199
1306
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -1201,21 +1308,21 @@ your you it its as at by from into via per also any all not no than then so if u
1201
1308
  skill skills user claude code based comprehensive enhanced practical including include includes
1202
1309
  `.split(/\s+/).filter(Boolean)
1203
1310
  );
1204
- function splitLimited(text, sep, maxSplit) {
1311
+ function splitLimited(text, sep2, maxSplit) {
1205
1312
  const parts = [];
1206
1313
  let rest = text;
1207
1314
  for (let i = 0; i < maxSplit; i++) {
1208
- const idx = rest.indexOf(sep);
1315
+ const idx = rest.indexOf(sep2);
1209
1316
  if (idx === -1) break;
1210
1317
  parts.push(rest.slice(0, idx));
1211
- rest = rest.slice(idx + sep.length);
1318
+ rest = rest.slice(idx + sep2.length);
1212
1319
  }
1213
1320
  parts.push(rest);
1214
1321
  return parts;
1215
1322
  }
1216
1323
  function readFrontmatter(skillMdPath) {
1217
1324
  const text = readFileSync7(skillMdPath, "utf-8");
1218
- const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
1325
+ const meta = { name: basename4(dirname4(skillMdPath)), description: "" };
1219
1326
  if (text.startsWith("---\n")) {
1220
1327
  const block = splitLimited(text, "---", 2)[1] ?? "";
1221
1328
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -1316,21 +1423,21 @@ function listSubdirs(dir) {
1316
1423
  } catch {
1317
1424
  return [];
1318
1425
  }
1319
- return entries.filter((e) => e.isDirectory()).map((e) => join10(dir, e.name));
1426
+ return entries.filter((e) => e.isDirectory()).map((e) => join11(dir, e.name));
1320
1427
  }
1321
1428
  function findSkillMdFiles(root) {
1322
1429
  const found = /* @__PURE__ */ new Set();
1323
1430
  for (const dir of listSubdirs(root)) {
1324
- const candidate = join10(dir, "SKILL.md");
1431
+ const candidate = join11(dir, "SKILL.md");
1325
1432
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1326
1433
  }
1327
- for (const dir of listSubdirs(join10(root, "skills"))) {
1328
- const candidate = join10(dir, "SKILL.md");
1434
+ for (const dir of listSubdirs(join11(root, "skills"))) {
1435
+ const candidate = join11(dir, "SKILL.md");
1329
1436
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1330
1437
  }
1331
1438
  for (const topDir of listSubdirs(root)) {
1332
- for (const dir of listSubdirs(join10(topDir, "skills"))) {
1333
- const candidate = join10(dir, "SKILL.md");
1439
+ for (const dir of listSubdirs(join11(topDir, "skills"))) {
1440
+ const candidate = join11(dir, "SKILL.md");
1334
1441
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1335
1442
  }
1336
1443
  }
@@ -1343,7 +1450,7 @@ function scanOverlap(candidatePath, skillsRoot) {
1343
1450
  }
1344
1451
  let cpath = resolve3(expandHome(candidatePath));
1345
1452
  if (isDir(cpath)) {
1346
- cpath = join10(cpath, "SKILL.md");
1453
+ cpath = join11(cpath, "SKILL.md");
1347
1454
  }
1348
1455
  if (!existsSync9(cpath)) {
1349
1456
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
@@ -1398,21 +1505,6 @@ function looksHighEntropy(value) {
1398
1505
  const uniqueChars = new Set(value).size;
1399
1506
  return varietyCount >= 2 && uniqueChars / value.length > 0.4;
1400
1507
  }
1401
- function isPinnedPackageSpec(spec) {
1402
- const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
1403
- const atIdx = withoutScope.lastIndexOf("@");
1404
- if (atIdx <= 0) return false;
1405
- return /^\d/.test(withoutScope.slice(atIdx + 1));
1406
- }
1407
- function isNpxCommand(command) {
1408
- const base = command.split(/[\\/]/).pop() ?? command;
1409
- return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
1410
- }
1411
- function packageSpecBase(spec) {
1412
- const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
1413
- const atIdx = spec.lastIndexOf("@");
1414
- return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
1415
- }
1416
1508
  function collectServerNodes(node, out) {
1417
1509
  if (Array.isArray(node)) {
1418
1510
  for (const item of node) collectServerNodes(item, out);
@@ -1443,14 +1535,17 @@ function scanJsonConfig(file, parsed) {
1443
1535
  if (command && isNpxCommand(command)) {
1444
1536
  const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1445
1537
  if (flagIdx !== -1) {
1446
- const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
1447
- if (pkgSpec && !isPinnedPackageSpec(pkgSpec)) {
1448
- findings.push({
1449
- severity: "MEDIUM",
1450
- rule: "mcp-unpinned-npx",
1451
- file,
1452
- detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1453
- });
1538
+ const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
1539
+ const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
1540
+ for (const pkgSpec of allSpecs) {
1541
+ if (!isPinnedPackageSpec(pkgSpec)) {
1542
+ findings.push({
1543
+ severity: "MEDIUM",
1544
+ rule: "mcp-unpinned-npx",
1545
+ file,
1546
+ detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1547
+ });
1548
+ }
1454
1549
  }
1455
1550
  }
1456
1551
  }
@@ -1743,6 +1838,14 @@ function proFeatureStatus(feature, betaFree = BETA_FREE) {
1743
1838
  }
1744
1839
 
1745
1840
  // src/report/render.ts
1841
+ var SAFETY_VERDICT_MAP = {
1842
+ pass: "ALLOW",
1843
+ warn: "CAUTION",
1844
+ block: "BLOCK"
1845
+ };
1846
+ function mapSafetyFindings(findings) {
1847
+ return findings.map((f) => ({ severity: f.severity, rule: f.rule, message: `${f.file}: ${f.detail}` }));
1848
+ }
1746
1849
  var BORDER = "\u2501".repeat(55);
1747
1850
  var LABEL_WIDTH = 20;
1748
1851
  function padLabel(text) {
@@ -1817,29 +1920,14 @@ function renderReport(result) {
1817
1920
  function renderJson(result) {
1818
1921
  return JSON.stringify(result, null, 2);
1819
1922
  }
1820
-
1821
- // src/commands/gatePipeline.ts
1822
- var SAFETY_VERDICT_MAP = {
1823
- pass: "ALLOW",
1824
- warn: "CAUTION",
1825
- block: "BLOCK"
1826
- };
1827
1923
  function printGateOutput(gateResult, json) {
1828
1924
  if (!json) {
1829
1925
  for (const notice of gateResult.notices ?? []) console.log(`[skill-forge] ${notice}`);
1830
1926
  }
1831
1927
  console.log(json ? renderJson(gateResult) : renderReport(gateResult));
1832
1928
  }
1833
- function asProfile(candidatePath, artifact) {
1834
- const profile = profileSkill(candidatePath);
1835
- const expectedKind = artifact === "mcp" ? "mcp" : "skill";
1836
- if (profile.kind !== expectedKind) {
1837
- throw new Error(
1838
- artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
1839
- );
1840
- }
1841
- return profile;
1842
- }
1929
+
1930
+ // src/commands/gatePipeline.ts
1843
1931
  function tryOverlap(candidatePath, skillsRoot) {
1844
1932
  try {
1845
1933
  return scanOverlap(candidatePath, skillsRoot);
@@ -1872,7 +1960,7 @@ function resolveMcpCandidatePath(entry) {
1872
1960
  children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1873
1961
  } catch {
1874
1962
  }
1875
- return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
1963
+ return children.length === 1 ? join12(entry.quarantinePath, children[0]) : entry.quarantinePath;
1876
1964
  }
1877
1965
  async function runGate(candidatePath, skillsRoot, {
1878
1966
  proFeatureLabel = "overlap analysis",
@@ -1891,11 +1979,7 @@ async function runGate(candidatePath, skillsRoot, {
1891
1979
  notices: proStatus.notice ? [proStatus.notice] : void 0,
1892
1980
  safety: {
1893
1981
  verdict: SAFETY_VERDICT_MAP[safety.verdict],
1894
- findings: safety.findings.map((f) => ({
1895
- severity: f.severity,
1896
- rule: f.rule,
1897
- message: `${f.file}: ${f.detail}`
1898
- }))
1982
+ findings: mapSafetyFindings(safety.findings)
1899
1983
  }
1900
1984
  };
1901
1985
  return { safety, gateResult, proEnabled: proStatus.enabled };
@@ -1904,9 +1988,9 @@ async function runGate(candidatePath, skillsRoot, {
1904
1988
  // src/queue.ts
1905
1989
  import { randomUUID as randomUUID2 } from "crypto";
1906
1990
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1907
- import { join as join12 } from "path";
1991
+ import { join as join13 } from "path";
1908
1992
  function getQueuePath() {
1909
- return join12(getHomeDir(), "queue.json");
1993
+ return join13(getHomeDir(), "queue.json");
1910
1994
  }
1911
1995
  function readQueue() {
1912
1996
  const path = getQueuePath();
@@ -1944,15 +2028,15 @@ function generateId(existingIds) {
1944
2028
  // src/handoff.ts
1945
2029
  import { spawnSync } from "child_process";
1946
2030
  import { existsSync as existsSync12 } from "fs";
1947
- import { dirname as dirname4, join as join13 } from "path";
2031
+ import { dirname as dirname5, join as join14 } from "path";
1948
2032
  import { fileURLToPath } from "url";
1949
2033
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
1950
2034
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
1951
2035
  function resolvePromptPath() {
1952
- const here = dirname4(fileURLToPath(import.meta.url));
1953
- const builtCandidate = join13(here, "ingest-prompt.md");
2036
+ const here = dirname5(fileURLToPath(import.meta.url));
2037
+ const builtCandidate = join14(here, "ingest-prompt.md");
1954
2038
  if (existsSync12(builtCandidate)) return builtCandidate;
1955
- return join13(here, "..", "assets", "ingest-prompt.md");
2039
+ return join14(here, "..", "assets", "ingest-prompt.md");
1956
2040
  }
1957
2041
  function defaultIsOnPath(bin) {
1958
2042
  const finder = process.platform === "win32" ? "where" : "which";
@@ -2014,7 +2098,7 @@ import { createInterface } from "readline/promises";
2014
2098
  // src/agents.ts
2015
2099
  import { statSync as statSync7 } from "fs";
2016
2100
  import { homedir as homedir3 } from "os";
2017
- import { join as join14, resolve as resolve5 } from "path";
2101
+ import { join as join15, resolve as resolve5 } from "path";
2018
2102
  function isFile(p) {
2019
2103
  try {
2020
2104
  return statSync7(p).isFile();
@@ -2028,7 +2112,7 @@ function projectPath(root, cwd) {
2028
2112
  function globalPath(root, home) {
2029
2113
  if (!root) return void 0;
2030
2114
  if (root === "~") return home;
2031
- if (root.startsWith("~/")) return join14(home, root.slice(2));
2115
+ if (root.startsWith("~/")) return join15(home, root.slice(2));
2032
2116
  return root;
2033
2117
  }
2034
2118
  function makeAgent(spec) {
@@ -2429,13 +2513,38 @@ async function maybeOfferInit(options = {}) {
2429
2513
  }
2430
2514
  }
2431
2515
 
2432
- // src/commands/add.ts
2433
- var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
2434
- var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
2516
+ // src/decision.ts
2517
+ import { createInterface as createInterface2 } from "readline/promises";
2435
2518
  function logOutcome(message, json) {
2436
2519
  if (json) console.error(message);
2437
2520
  else console.log(message);
2438
2521
  }
2522
+ function yesDecision(verdict) {
2523
+ return verdict === "block" ? "reject" : "promote";
2524
+ }
2525
+ async function promptDecision(verdict, subject) {
2526
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
2527
+ const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
2528
+ const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
2529
+ try {
2530
+ process.stdout.write(prompt);
2531
+ for await (const raw of rl) {
2532
+ const answer = raw.trim().toLowerCase() || defaultChoice;
2533
+ if (answer === "p" || answer === "promote") return "promote";
2534
+ if (answer === "h" || answer === "hold") return "hold";
2535
+ if (answer === "r" || answer === "reject") return "reject";
2536
+ process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
2537
+ ${prompt}`);
2538
+ }
2539
+ return defaultChoice;
2540
+ } finally {
2541
+ rl.close();
2542
+ }
2543
+ }
2544
+
2545
+ // src/commands/add.ts
2546
+ var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
2547
+ var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
2439
2548
  async function add(source, options = {}) {
2440
2549
  await maybeOfferInit({ json: options.json, yes: options.yes });
2441
2550
  const config = loadConfig();
@@ -2472,7 +2581,7 @@ async function add(source, options = {}) {
2472
2581
  throw new Error(`expected a skill candidate, found a ${profile.kind}`);
2473
2582
  }
2474
2583
  printGateOutput(gateResult, options.json);
2475
- const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
2584
+ const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
2476
2585
  let installedPath = null;
2477
2586
  if (decision === "promote") {
2478
2587
  const result = promote(entry, targetRoot);
@@ -2512,7 +2621,7 @@ async function add(source, options = {}) {
2512
2621
  gate: {
2513
2622
  license: profile.license ?? null,
2514
2623
  safetyVerdict: safety.verdict,
2515
- safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
2624
+ safetyFindings: safety.findings.map(formatSafetyFinding),
2516
2625
  overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
2517
2626
  suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
2518
2627
  },
@@ -2544,7 +2653,7 @@ async function addMcp(source, config, options) {
2544
2653
  );
2545
2654
  }
2546
2655
  printGateOutput(gateResult, options.json);
2547
- const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
2656
+ const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
2548
2657
  let installedPath = null;
2549
2658
  if (decision === "promote") {
2550
2659
  const detail = resolveMcpCandidateDetail(profile);
@@ -2553,13 +2662,20 @@ async function addMcp(source, config, options) {
2553
2662
  // version may be absent — promoteMcp refuses the unpinnable npx fallback
2554
2663
  // rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
2555
2664
  version: detail.version,
2665
+ // packageName (v0.7) — the candidate's own scanned package.json name, so
2666
+ // deriveServerEntry can auto-pin an unpinned npx spec in candidateEntry
2667
+ // only when it provably matches the package that was actually scanned.
2668
+ packageName: detail.packageName,
2556
2669
  envVars: detail.env ? Object.keys(detail.env) : void 0,
2557
2670
  candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2558
2671
  };
2559
- const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join15(process.cwd(), ".mcp.json");
2672
+ const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join16(process.cwd(), ".mcp.json");
2560
2673
  const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
2561
2674
  reject(entry);
2562
2675
  logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
2676
+ if (result.autoPinned) {
2677
+ logOutcome(`[skill-forge] pinned launch spec ${result.autoPinned.from} -> ${result.autoPinned.to}`, options.json);
2678
+ }
2563
2679
  if (result.backupPath) {
2564
2680
  logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
2565
2681
  }
@@ -2585,7 +2701,7 @@ async function addMcp(source, config, options) {
2585
2701
  gate: {
2586
2702
  license: null,
2587
2703
  safetyVerdict: safety.verdict,
2588
- safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
2704
+ safetyFindings: safety.findings.map(formatSafetyFinding),
2589
2705
  overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
2590
2706
  suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
2591
2707
  },
@@ -2598,28 +2714,6 @@ async function addMcp(source, config, options) {
2598
2714
  }
2599
2715
  }
2600
2716
  }
2601
- function yesDecision(verdict) {
2602
- return verdict === "block" ? "reject" : "promote";
2603
- }
2604
- async function promptDecision(verdict) {
2605
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
2606
- const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
2607
- const prompt = `Promote, hold, or reject this skill? [promote/hold/reject] (default: ${defaultChoice}) `;
2608
- try {
2609
- process.stdout.write(prompt);
2610
- for await (const raw of rl) {
2611
- const answer = raw.trim().toLowerCase() || defaultChoice;
2612
- if (answer === "p" || answer === "promote") return "promote";
2613
- if (answer === "h" || answer === "hold") return "hold";
2614
- if (answer === "r" || answer === "reject") return "reject";
2615
- process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
2616
- ${prompt}`);
2617
- }
2618
- return defaultChoice;
2619
- } finally {
2620
- rl.close();
2621
- }
2622
- }
2623
2717
 
2624
2718
  // src/commands/scan.ts
2625
2719
  async function scan(source, options = {}) {
@@ -2670,7 +2764,7 @@ async function scan(source, options = {}) {
2670
2764
  }
2671
2765
 
2672
2766
  // src/commands/list.ts
2673
- import { join as join16 } from "path";
2767
+ import { join as join17 } from "path";
2674
2768
  async function list() {
2675
2769
  await maybeOfferInit();
2676
2770
  const config = loadConfig();
@@ -2683,14 +2777,14 @@ async function list() {
2683
2777
  }
2684
2778
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
2685
2779
  for (const id of ids) {
2686
- const skillDirs = findSkillDirs(join16(quarantineDir, id));
2780
+ const skillDirs = findSkillDirs(join17(quarantineDir, id));
2687
2781
  if (skillDirs.length === 0) {
2688
2782
  console.log(` ${id} (no SKILL.md found)`);
2689
2783
  continue;
2690
2784
  }
2691
2785
  const names = skillDirs.map((dir) => {
2692
2786
  try {
2693
- return readFrontmatter(join16(dir, "SKILL.md")).name;
2787
+ return readFrontmatter(join17(dir, "SKILL.md")).name;
2694
2788
  } catch {
2695
2789
  return dir;
2696
2790
  }
@@ -2711,6 +2805,319 @@ async function status() {
2711
2805
  console.log(` held in quarantine: ${heldCount}`);
2712
2806
  }
2713
2807
 
2808
+ // src/evolve.ts
2809
+ import { createInterface as createInterface3 } from "readline/promises";
2810
+ import { createHash } from "crypto";
2811
+ import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
2812
+ import { tmpdir as tmpdir2 } from "os";
2813
+ import { basename as basename6, dirname as dirname6, join as join18, resolve as resolvePath2, sep } from "path";
2814
+ var SKILLOPT_BIN = "skillopt-sleep";
2815
+ var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
2816
+ async function evolve(targetSkillDir, options = {}, deps = {}) {
2817
+ const exec = deps.exec ?? defaultExec;
2818
+ const json = options.json;
2819
+ const resolvedTargetDir = resolvePath2(targetSkillDir);
2820
+ const skillMdPath = join18(resolvedTargetDir, "SKILL.md");
2821
+ if (!existsSync14(skillMdPath)) {
2822
+ throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
2823
+ }
2824
+ const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
2825
+ if (!proStatus.enabled) {
2826
+ throw new UserError(proStatus.notice ?? "evolve is a Pro feature \u2014 set SKILL_FORGE_LICENSE or config.licenseKey to a valid license to unlock.");
2827
+ }
2828
+ const notices = proStatus.notice ? [proStatus.notice] : [];
2829
+ const backend = options.backend ?? "mock";
2830
+ if (backend !== "mock") {
2831
+ 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.`;
2832
+ if (options.yes) {
2833
+ notices.push(`${disclosure} Proceeding (--yes).`);
2834
+ } else if (json) {
2835
+ throw new UserError(`${disclosure} Pass --yes to proceed \u2014 --json is non-interactive and cannot prompt for confirmation.`);
2836
+ } else {
2837
+ console.log(`[skill-forge] ${disclosure}`);
2838
+ const confirmFn = deps.confirm ?? defaultConfirm;
2839
+ const confirmed = await confirmFn("Proceed and send session data to this backend? [y/N] ");
2840
+ if (!confirmed) {
2841
+ throw new UserError("evolve cancelled \u2014 data-boundary disclosure was not accepted.");
2842
+ }
2843
+ notices.push(`${disclosure} Confirmed interactively.`);
2844
+ }
2845
+ } else {
2846
+ notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
2847
+ }
2848
+ const project = resolvePath2(options.project ?? process.cwd());
2849
+ const present = await detectSkillopt(exec, project);
2850
+ if (!present) {
2851
+ throw new UserError(
2852
+ "`skillopt-sleep` not found on PATH. Install it with `pip install skillopt` (https://github.com/microsoft/SkillOpt) and re-run `skill-forge evolve`."
2853
+ );
2854
+ }
2855
+ const subcommand = options.dryRun ? "dry-run" : "run";
2856
+ const runArgs = [subcommand, "--project", project, "--target-skill-path", skillMdPath, "--json", "--backend", backend];
2857
+ if (options.lookbackHours !== void 0) {
2858
+ runArgs.push("--lookback-hours", String(options.lookbackHours));
2859
+ }
2860
+ const runResult = await exec(SKILLOPT_BIN, runArgs, { cwd: project });
2861
+ if (runResult.code !== 0) {
2862
+ throw new UserError(`skillopt-sleep ${subcommand} failed (exit ${runResult.code}): ${tail(runResult.stderr || runResult.stdout)}`);
2863
+ }
2864
+ let rawOutput;
2865
+ try {
2866
+ rawOutput = JSON.parse(runResult.stdout);
2867
+ } catch (err) {
2868
+ throw new UserError(`could not parse skillopt-sleep JSON output: ${err.message}`);
2869
+ }
2870
+ const parsed = validateRunOutput(rawOutput);
2871
+ if (!parsed.accepted || !parsed.staging_dir) {
2872
+ if (json) {
2873
+ console.log(JSON.stringify({ ok: true, accepted: parsed.accepted, stagingDir: parsed.staging_dir }, null, 2));
2874
+ } else {
2875
+ console.log("[skill-forge] skillopt-sleep produced no accepted proposal \u2014 nothing to re-gate or adopt.");
2876
+ }
2877
+ return;
2878
+ }
2879
+ const stagingRoot = join18(project, ".skillopt-sleep", "staging");
2880
+ const stagingDir = resolvePath2(parsed.staging_dir);
2881
+ if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
2882
+ throw new UserError(
2883
+ `skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
2884
+ );
2885
+ }
2886
+ const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
2887
+ const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
2888
+ const reportMdPath = join18(stagingDir, "report.md");
2889
+ if (!existsSync14(proposedSkillMd)) {
2890
+ throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
2891
+ }
2892
+ if (existsSync14(reportMdPath)) {
2893
+ notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
2894
+ }
2895
+ assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
2896
+ assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
2897
+ const scannedSkillHash = sha256File(proposedSkillMd);
2898
+ const scannedClaudeHash = sha256File(proposedClaudeMd);
2899
+ const tempDir = mkdtempSync2(join18(tmpdir2(), "skill-forge-evolve-"));
2900
+ let profile;
2901
+ let safety;
2902
+ try {
2903
+ cpSync3(proposedSkillMd, join18(tempDir, "SKILL.md"));
2904
+ if (existsSync14(proposedClaudeMd)) {
2905
+ cpSync3(proposedClaudeMd, join18(tempDir, "CLAUDE.md"));
2906
+ }
2907
+ profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
2908
+ const strictness = loadConfig().strictness;
2909
+ safety = await scanSafety(tempDir, strictness);
2910
+ } finally {
2911
+ try {
2912
+ rmSync3(tempDir, { recursive: true, force: true });
2913
+ } catch (err) {
2914
+ console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
2915
+ }
2916
+ }
2917
+ const safetyVerdict = safety.verdict;
2918
+ const gateResult = {
2919
+ profile,
2920
+ safety: {
2921
+ verdict: SAFETY_VERDICT_MAP[safetyVerdict],
2922
+ findings: mapSafetyFindings(safety.findings)
2923
+ },
2924
+ notices
2925
+ };
2926
+ printGateOutput(gateResult, json);
2927
+ if (options.dryRun) {
2928
+ logOutcome("[skill-forge] --dry-run: report only, nothing staged or adopted.", json);
2929
+ return;
2930
+ }
2931
+ const decision = options.yes || json ? yesDecision(safetyVerdict) : await promptDecision(safetyVerdict, "this evolution proposal");
2932
+ if (decision === "promote") {
2933
+ const manifest = readManifest(stagingDir);
2934
+ if (isAlreadyAdopted(stagingDir, manifest) && !options.force) {
2935
+ throw new UserError(
2936
+ `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.`
2937
+ );
2938
+ }
2939
+ if (sha256File(proposedSkillMd) !== scannedSkillHash || sha256File(proposedClaudeMd) !== scannedClaudeHash) {
2940
+ throw new UserError(`proposal changed after it was re-gated (staging dir: ${stagingDir}) \u2014 refusing to adopt`);
2941
+ }
2942
+ const adoptResult = await exec(SKILLOPT_BIN, ["adopt", "--staging", stagingDir, "--project", project], { cwd: project });
2943
+ if (adoptResult.code !== 0) {
2944
+ throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
2945
+ }
2946
+ logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
2947
+ const skillsRootForProvenance = dirname6(resolvedTargetDir);
2948
+ let ledgerPath;
2949
+ try {
2950
+ ledgerPath = appendProvenance(
2951
+ {
2952
+ name: profile.name ?? basename6(resolvedTargetDir),
2953
+ source: resolvedTargetDir,
2954
+ version: profile.version,
2955
+ license: profile.license,
2956
+ verb: "DEFER",
2957
+ target: resolvedTargetDir,
2958
+ took: `self-evolved via SkillOpt-Sleep (staging: ${stagingDir})`,
2959
+ notes: `self-evolved via SkillOpt-Sleep; re-gated (verdict: ${safetyVerdict})`
2960
+ },
2961
+ skillsRootForProvenance
2962
+ );
2963
+ } catch (err) {
2964
+ throw new UserError(
2965
+ `adopt succeeded and the live skill at ${resolvedTargetDir} has already changed, but recording the provenance entry failed: ${err.message}`
2966
+ );
2967
+ }
2968
+ logOutcome(`[skill-forge] provenance recorded -> ${ledgerPath}`, json);
2969
+ try {
2970
+ appendQueueEntry({
2971
+ source: resolvedTargetDir,
2972
+ sourceType: "local",
2973
+ installedPath: resolvedTargetDir,
2974
+ quarantinePath: stagingDir,
2975
+ gate: {
2976
+ license: profile.license ?? null,
2977
+ safetyVerdict,
2978
+ safetyFindings: safety.findings.map(formatSafetyFinding),
2979
+ overlapTop: [],
2980
+ suggestedVerb: null
2981
+ },
2982
+ status: "pending",
2983
+ artifactType: "skill",
2984
+ origin: "evolve"
2985
+ });
2986
+ } catch (err) {
2987
+ throw new UserError(
2988
+ `adopt succeeded and the live skill at ${resolvedTargetDir} has already changed (provenance was recorded), but appending the pending-ingestion queue entry failed: ${err.message}`
2989
+ );
2990
+ }
2991
+ logOutcome("[skill-forge] queued for ingestion review", json);
2992
+ } else if (decision === "reject") {
2993
+ try {
2994
+ rmSync3(stagingDir, { recursive: true, force: true });
2995
+ } catch (err) {
2996
+ throw new UserError(
2997
+ `rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
2998
+ );
2999
+ }
3000
+ logOutcome("[skill-forge] rejected \u2014 staging dir removed", json);
3001
+ if (safetyVerdict === "block") process.exitCode = 1;
3002
+ } else {
3003
+ logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
3004
+ if (existsSync14(reportMdPath)) {
3005
+ logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
3006
+ }
3007
+ }
3008
+ }
3009
+ async function defaultConfirm(question) {
3010
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
3011
+ try {
3012
+ process.stdout.write(question);
3013
+ for await (const raw of rl) {
3014
+ return /^y(es)?$/i.test(raw.trim());
3015
+ }
3016
+ return false;
3017
+ } finally {
3018
+ rl.close();
3019
+ }
3020
+ }
3021
+ async function detectSkillopt(exec, cwd) {
3022
+ try {
3023
+ const result = await exec(SKILLOPT_BIN, ["--help"], { cwd });
3024
+ return result.code === 0;
3025
+ } catch {
3026
+ return false;
3027
+ }
3028
+ }
3029
+ function tail(text, maxLines = 20) {
3030
+ return text.trim().split("\n").slice(-maxLines).join("\n");
3031
+ }
3032
+ function validateRunOutput(raw) {
3033
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3034
+ throw new UserError(`skillopt-sleep produced unexpected JSON output (expected an object): ${JSON.stringify(raw)}`);
3035
+ }
3036
+ const obj = raw;
3037
+ const stagingDirRaw = obj.staging_dir;
3038
+ if (stagingDirRaw !== null && stagingDirRaw !== void 0 && typeof stagingDirRaw !== "string") {
3039
+ throw new UserError(
3040
+ `skillopt-sleep produced an unexpected "staging_dir" (expected a string or null): ${JSON.stringify(stagingDirRaw)}`
3041
+ );
3042
+ }
3043
+ return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
3044
+ }
3045
+ function readManifest(stagingDir) {
3046
+ const manifestPath = join18(stagingDir, "manifest.json");
3047
+ let raw;
3048
+ try {
3049
+ raw = readFileSync10(manifestPath, "utf-8");
3050
+ } catch (err) {
3051
+ throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
3052
+ }
3053
+ let parsed;
3054
+ try {
3055
+ parsed = JSON.parse(raw);
3056
+ } catch (err) {
3057
+ throw new UserError(`could not parse staging manifest ${manifestPath}: ${err.message}`);
3058
+ }
3059
+ return validateManifest(parsed, manifestPath);
3060
+ }
3061
+ function validateManifest(raw, manifestPath) {
3062
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3063
+ throw new UserError(`staging manifest ${manifestPath} has an unexpected shape (expected an object)`);
3064
+ }
3065
+ const obj = raw;
3066
+ if (typeof obj.live_skill_path !== "string") {
3067
+ throw new UserError(`staging manifest ${manifestPath} is missing a valid "live_skill_path" string`);
3068
+ }
3069
+ if (obj.live_memory_path !== void 0 && obj.live_memory_path !== null && typeof obj.live_memory_path !== "string") {
3070
+ throw new UserError(`staging manifest ${manifestPath} has an unexpected "live_memory_path" (expected a string or null)`);
3071
+ }
3072
+ if (typeof obj.has_skill !== "boolean" || typeof obj.has_memory !== "boolean") {
3073
+ throw new UserError(`staging manifest ${manifestPath} is missing valid "has_skill"/"has_memory" booleans`);
3074
+ }
3075
+ return {
3076
+ live_skill_path: obj.live_skill_path,
3077
+ live_memory_path: obj.live_memory_path ?? null,
3078
+ has_skill: obj.has_skill,
3079
+ has_memory: obj.has_memory
3080
+ };
3081
+ }
3082
+ function filesIdentical(a, b) {
3083
+ if (!existsSync14(a) || !existsSync14(b)) return false;
3084
+ return readFileSync10(a).equals(readFileSync10(b));
3085
+ }
3086
+ function isAlreadyAdopted(stagingDir, manifest) {
3087
+ const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
3088
+ const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
3089
+ let sawComparison = false;
3090
+ if (existsSync14(proposedSkillMd)) {
3091
+ sawComparison = true;
3092
+ if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
3093
+ }
3094
+ if (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
3095
+ sawComparison = true;
3096
+ if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
3097
+ }
3098
+ return sawComparison;
3099
+ }
3100
+ function assertNotSymlink(path, label) {
3101
+ let stat;
3102
+ try {
3103
+ stat = lstatSync(path);
3104
+ } catch {
3105
+ return;
3106
+ }
3107
+ if (stat.isSymbolicLink()) {
3108
+ throw new UserError(`refusing to re-gate ${label}: ${path} is a symlink (a staged proposal must be a plain file)`);
3109
+ }
3110
+ }
3111
+ function sha256File(path) {
3112
+ if (!existsSync14(path)) return null;
3113
+ return createHash("sha256").update(readFileSync10(path)).digest("hex");
3114
+ }
3115
+
3116
+ // src/commands/evolve.ts
3117
+ async function evolveCommand(targetSkillDir, options = {}) {
3118
+ await evolve(targetSkillDir, options);
3119
+ }
3120
+
2714
3121
  // src/cli.ts
2715
3122
  var require2 = createRequire(import.meta.url);
2716
3123
  var pkg = require2("../package.json");
@@ -2720,6 +3127,13 @@ function parseArtifact(value) {
2720
3127
  }
2721
3128
  return value;
2722
3129
  }
3130
+ function parseLookbackHours(value) {
3131
+ const n = Number(value);
3132
+ if (value.trim() === "" || !Number.isFinite(n) || n < 0) {
3133
+ throw new UserError(`--lookback-hours must be a non-negative number (got "${value}")`);
3134
+ }
3135
+ return n;
3136
+ }
2723
3137
  var program = new Command();
2724
3138
  program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
2725
3139
  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(
@@ -2729,6 +3143,7 @@ program.command("add <source>").description("Quarantine-install a skill and run
2729
3143
  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);
2730
3144
  program.command("list").description("List skills currently in quarantine").action(list);
2731
3145
  program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
3146
+ 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);
2732
3147
  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);
2733
3148
  try {
2734
3149
  await program.parseAsync();