@rhize/skill-forge 0.7.0 → 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,7 +5,7 @@ import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
6
 
7
7
  // src/commands/add.ts
8
- import { join as join15 } from "path";
8
+ import { join as join16 } from "path";
9
9
 
10
10
  // src/config.ts
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -295,16 +295,132 @@ function moveDir(src, dest) {
295
295
  }
296
296
 
297
297
  // src/install/promoteMcp.ts
298
- 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";
299
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
300
416
  function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
301
417
  const force = options.force ?? false;
302
418
  const serverName = profile.name;
303
- const { command, args, envVarsToFill } = deriveServerEntry(profile);
419
+ const { command, args, envVarsToFill, autoPinned } = deriveServerEntry(profile);
304
420
  let config = {};
305
421
  let rawExisting;
306
422
  if (existsSync5(targetConfigPath)) {
307
- rawExisting = readFileSync2(targetConfigPath, "utf-8");
423
+ rawExisting = readFileSync3(targetConfigPath, "utf-8");
308
424
  try {
309
425
  config = JSON.parse(rawExisting);
310
426
  } catch (err) {
@@ -329,7 +445,7 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
329
445
  mkdirSync4(dirname(targetConfigPath), { recursive: true });
330
446
  writeFileSync2(targetConfigPath, `${JSON.stringify(config, null, 2)}
331
447
  `);
332
- return { target: targetConfigPath, serverName, envVarsToFill, backupPath };
448
+ return { target: targetConfigPath, serverName, envVarsToFill, backupPath, autoPinned };
333
449
  }
334
450
  function deriveServerEntry(profile) {
335
451
  const envNames = new Set(profile.envVars ?? []);
@@ -337,9 +453,41 @@ function deriveServerEntry(profile) {
337
453
  for (const key of Object.keys(profile.candidateEntry.env)) envNames.add(key);
338
454
  }
339
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
+ }
340
488
  return {
341
- command: profile.candidateEntry.command,
342
- args: profile.candidateEntry.args ?? [],
489
+ command,
490
+ args,
343
491
  envVarsToFill: Array.from(envNames)
344
492
  };
345
493
  }
@@ -356,18 +504,22 @@ function deriveServerEntry(profile) {
356
504
  }
357
505
 
358
506
  // src/install/mcpCandidate.ts
359
- import { readFileSync as readFileSync3 } from "fs";
360
- function readPackageVersion(pkgPath) {
507
+ import { readFileSync as readFileSync4 } from "fs";
508
+ import { dirname as dirname2, join as join6 } from "path";
509
+ function readPackageInfo(pkgPath) {
361
510
  try {
362
- const pkg2 = JSON.parse(readFileSync3(pkgPath, "utf-8"));
363
- 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
+ };
364
516
  } catch {
365
- return void 0;
517
+ return {};
366
518
  }
367
519
  }
368
520
  function readConfigEntry(configPath, name) {
369
521
  try {
370
- const cfg = JSON.parse(readFileSync3(configPath, "utf-8"));
522
+ const cfg = JSON.parse(readFileSync4(configPath, "utf-8"));
371
523
  const servers = cfg.mcpServers ?? cfg.servers ?? {};
372
524
  const raw = servers[name] ?? {};
373
525
  return {
@@ -382,15 +534,23 @@ function readConfigEntry(configPath, name) {
382
534
  function resolveMcpCandidateDetail(profile) {
383
535
  const name = profile.servers[0];
384
536
  if (profile.config.endsWith("package.json")) {
385
- return { name, version: readPackageVersion(profile.config) };
537
+ return { name, version: readPackageInfo(profile.config).version };
386
538
  }
387
539
  const entry = readConfigEntry(profile.config, name);
388
- 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
+ };
389
549
  }
390
550
 
391
551
  // src/provenance.ts
392
- import { appendFileSync, existsSync as existsSync6, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
393
- 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";
394
554
  var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
395
555
  var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
396
556
  function todayLocalIsoDate() {
@@ -404,7 +564,7 @@ function appendProvenance(entry, skillsRoot) {
404
564
  if (!FORGE_VERBS.includes(entry.verb)) {
405
565
  throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
406
566
  }
407
- if (!existsSync6(skillsRoot) || !statSync2(skillsRoot).isDirectory()) {
567
+ if (!existsSync6(skillsRoot) || !statSync3(skillsRoot).isDirectory()) {
408
568
  throw new Error(`skills-root not a directory: ${skillsRoot}`);
409
569
  }
410
570
  const today = todayLocalIsoDate();
@@ -421,7 +581,7 @@ function appendProvenance(entry, skillsRoot) {
421
581
  - **Drift check:** \`${driftCheck}\`
422
582
  - **Notes:** ${entry.notes ?? ""}
423
583
  `;
424
- const ledgerPath = join5(skillsRoot, "SOURCES.md");
584
+ const ledgerPath = join7(skillsRoot, "SOURCES.md");
425
585
  if (!existsSync6(ledgerPath)) {
426
586
  writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
427
587
  }
@@ -431,7 +591,7 @@ function appendProvenance(entry, skillsRoot) {
431
591
 
432
592
  // src/commands/gatePipeline.ts
433
593
  import { readdirSync as readdirSync5 } from "fs";
434
- import { join as join11 } from "path";
594
+ import { join as join12 } from "path";
435
595
 
436
596
  // src/gate/profile.ts
437
597
  import { execFileSync } from "child_process";
@@ -447,82 +607,11 @@ import {
447
607
  statSync as statSync4
448
608
  } from "fs";
449
609
  import { tmpdir } from "os";
450
- 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";
451
611
 
452
612
  // src/gate/mcpCapabilities.ts
453
613
  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";
458
- import { extname, join as join6 } from "path";
459
- var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
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
- ]);
481
- var MAX_FILE_BYTES = 2 * 1024 * 1024;
482
- function* walk(dir) {
483
- let entries;
484
- try {
485
- entries = readdirSync2(dir, { withFileTypes: true });
486
- } catch {
487
- return;
488
- }
489
- for (const entry of entries) {
490
- const full = join6(dir, entry.name);
491
- if (entry.isDirectory()) {
492
- if (SKIP_DIRS2.has(entry.name)) continue;
493
- yield* walk(full);
494
- } else if (entry.isFile()) {
495
- yield full;
496
- }
497
- }
498
- }
499
- function readTextFile(file) {
500
- if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
501
- try {
502
- if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
503
- const content = readFileSync4(file, "utf-8");
504
- if (content.slice(0, 512).includes("\0")) return void 0;
505
- return content;
506
- } catch {
507
- return void 0;
508
- }
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
614
+ import { extname as extname2, join as join8 } from "path";
526
615
  var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
527
616
  var MAX_ITEMS_PER_CATEGORY = 200;
528
617
  function readJsonSafe(file) {
@@ -628,7 +717,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
628
717
  function extractMcpCapabilities(packageDir) {
629
718
  const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
630
719
  let hasPackageJsonMarker = false;
631
- const pkgPath = join7(packageDir, "package.json");
720
+ const pkgPath = join8(packageDir, "package.json");
632
721
  if (existsSync7(pkgPath)) {
633
722
  const pkg2 = readJsonSafe(pkgPath);
634
723
  if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
@@ -636,7 +725,7 @@ function extractMcpCapabilities(packageDir) {
636
725
  }
637
726
  }
638
727
  for (const manifestName of [".mcp.json", "mcp.json"]) {
639
- const manifestPath = join7(packageDir, manifestName);
728
+ const manifestPath = join8(packageDir, manifestName);
640
729
  if (!existsSync7(manifestPath)) continue;
641
730
  const parsed = readJsonSafe(manifestPath);
642
731
  if (parsed !== void 0) {
@@ -744,7 +833,7 @@ function asProfile(candidatePath, artifact) {
744
833
  return asSkillProfile(profile, `at ${candidatePath}`);
745
834
  }
746
835
  function classifyInput(p) {
747
- if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
836
+ if (existsSync8(join9(p, ".claude-plugin", "plugin.json"))) return "plugin";
748
837
  if (detectArtifactType(p) === "mcp") return "mcp";
749
838
  return "skill";
750
839
  }
@@ -752,7 +841,7 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
752
841
  var MCP_KEYWORD2 = "mcp-server";
753
842
  var MCP_BIN_PREFIX2 = /^mcp-/i;
754
843
  function readPackageJsonSafe(root) {
755
- const pkgPath = join8(root, "package.json");
844
+ const pkgPath = join9(root, "package.json");
756
845
  if (!existsSync8(pkgPath)) return void 0;
757
846
  try {
758
847
  return JSON.parse(readFileSync6(pkgPath, "utf-8"));
@@ -776,7 +865,7 @@ function hasMcpPackageMarkers(pkg2) {
776
865
  return false;
777
866
  }
778
867
  function detectArtifactType(root) {
779
- 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";
780
869
  const pkg2 = readPackageJsonSafe(root);
781
870
  if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
782
871
  return "skill";
@@ -786,22 +875,22 @@ function resolveSkillMd(inputPath) {
786
875
  let tmpDir;
787
876
  const initialStat = statSync4(currentPath);
788
877
  if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
789
- tmpDir = mkdtempSync(join8(tmpdir(), "forge_"));
878
+ tmpDir = mkdtempSync(join9(tmpdir(), "forge_"));
790
879
  extractZip(currentPath, tmpDir);
791
880
  currentPath = tmpDir;
792
881
  }
793
882
  const stat = statSync4(currentPath);
794
883
  if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
795
- return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
884
+ return { skillMd: currentPath, root: dirname3(currentPath), tmpDir };
796
885
  }
797
886
  if (stat.isDirectory()) {
798
- const direct = join8(currentPath, "SKILL.md");
887
+ const direct = join9(currentPath, "SKILL.md");
799
888
  if (existsSync8(direct)) {
800
889
  return { skillMd: direct, root: currentPath, tmpDir };
801
890
  }
802
891
  const matches = findSkillMdOneLevelDown(currentPath);
803
892
  if (matches.length === 1) {
804
- return { skillMd: matches[0], root: dirname2(matches[0]), tmpDir };
893
+ return { skillMd: matches[0], root: dirname3(matches[0]), tmpDir };
805
894
  }
806
895
  if (matches.length > 1) {
807
896
  cleanupTmp(tmpDir);
@@ -821,15 +910,15 @@ function findSkillMdOneLevelDown(dir) {
821
910
  const matches = /* @__PURE__ */ new Set();
822
911
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
823
912
  if (entry.isDirectory()) {
824
- const candidate = join8(dir, entry.name, "SKILL.md");
913
+ const candidate = join9(dir, entry.name, "SKILL.md");
825
914
  if (existsSync8(candidate)) matches.add(candidate);
826
915
  }
827
916
  }
828
- const skillsDir = join8(dir, "skills");
917
+ const skillsDir = join9(dir, "skills");
829
918
  if (isDir(skillsDir)) {
830
919
  for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
831
920
  if (entry.isDirectory()) {
832
- const candidate = join8(skillsDir, entry.name, "SKILL.md");
921
+ const candidate = join9(skillsDir, entry.name, "SKILL.md");
833
922
  if (existsSync8(candidate)) matches.add(candidate);
834
923
  }
835
924
  }
@@ -913,7 +1002,7 @@ function detectLicense(root, fm) {
913
1002
  if (fm.license) return fm.license;
914
1003
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
915
1004
  for (const name of names) {
916
- const p = join8(root, name);
1005
+ const p = join9(root, name);
917
1006
  if (existsSync8(p) && statSync4(p).isFile()) {
918
1007
  const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
919
1008
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
@@ -927,7 +1016,7 @@ function detectLicense(root, fm) {
927
1016
  function walkFiles(dir) {
928
1017
  const out = [];
929
1018
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
930
- const full = join8(dir, entry.name);
1019
+ const full = join9(dir, entry.name);
931
1020
  if (entry.isDirectory()) {
932
1021
  out.push(...walkFiles(full));
933
1022
  } else if (entry.isFile()) {
@@ -939,7 +1028,7 @@ function walkFiles(dir) {
939
1028
  function countResources(root) {
940
1029
  const resources = {};
941
1030
  for (const d of RESOURCE_DIRS) {
942
- const dp = join8(root, d);
1031
+ const dp = join9(root, d);
943
1032
  if (isDir(dp)) {
944
1033
  resources[d] = walkFiles(dp).length;
945
1034
  }
@@ -950,7 +1039,7 @@ function scanMcpAndImports(text, root) {
950
1039
  const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
951
1040
  const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
952
1041
  const imports = /* @__PURE__ */ new Set();
953
- const scriptsDir = join8(root, "scripts");
1042
+ const scriptsDir = join9(root, "scripts");
954
1043
  if (isDir(scriptsDir)) {
955
1044
  for (const f of walkFiles(scriptsDir)) {
956
1045
  if (!f.endsWith(".py")) continue;
@@ -999,19 +1088,19 @@ function profileSkillDir(inputPath) {
999
1088
  }
1000
1089
  }
1001
1090
  function profilePlugin(p) {
1002
- const manifestPath = join8(p, ".claude-plugin", "plugin.json");
1091
+ const manifestPath = join9(p, ".claude-plugin", "plugin.json");
1003
1092
  let manifest;
1004
1093
  try {
1005
1094
  manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
1006
1095
  } catch (err) {
1007
1096
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
1008
1097
  }
1009
- const skillsDir = join8(p, "skills");
1098
+ const skillsDir = join9(p, "skills");
1010
1099
  let skills = [];
1011
1100
  if (isDir(skillsDir)) {
1012
- 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();
1013
1102
  }
1014
- const commandsDir = join8(p, "commands");
1103
+ const commandsDir = join9(p, "commands");
1015
1104
  const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
1016
1105
  const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
1017
1106
  const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
@@ -1029,7 +1118,7 @@ function profilePlugin(p) {
1029
1118
  };
1030
1119
  }
1031
1120
  function profileMcp(p) {
1032
- 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;
1033
1122
  if (explicitConfig) {
1034
1123
  let cfg;
1035
1124
  try {
@@ -1049,7 +1138,7 @@ function profileMcp(p) {
1049
1138
  capabilities: extractMcpCapabilities(p)
1050
1139
  };
1051
1140
  }
1052
- const pkgPath = join8(p, "package.json");
1141
+ const pkgPath = join9(p, "package.json");
1053
1142
  let pkg2;
1054
1143
  try {
1055
1144
  pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
@@ -1071,7 +1160,7 @@ function profileMcp(p) {
1071
1160
  // src/gate/safety.ts
1072
1161
  import { statSync as statSync5 } from "fs";
1073
1162
  import { execFile } from "child_process";
1074
- import { delimiter, join as join9 } from "path";
1163
+ import { delimiter, join as join10 } from "path";
1075
1164
  import { promisify } from "util";
1076
1165
  var execFileAsync = promisify(execFile);
1077
1166
  var DENY_RULES = [
@@ -1170,7 +1259,7 @@ function which(cmd) {
1170
1259
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
1171
1260
  for (const dir of dirs) {
1172
1261
  for (const ext of exts) {
1173
- const candidate = join9(dir, cmd + ext);
1262
+ const candidate = join10(dir, cmd + ext);
1174
1263
  try {
1175
1264
  if (statSync5(candidate).isFile()) return candidate;
1176
1265
  } catch {
@@ -1211,7 +1300,7 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
1211
1300
 
1212
1301
  // src/gate/overlap.ts
1213
1302
  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";
1303
+ import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve3 } from "path";
1215
1304
  var STOPWORDS = new Set(
1216
1305
  `
1217
1306
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -1233,7 +1322,7 @@ function splitLimited(text, sep2, maxSplit) {
1233
1322
  }
1234
1323
  function readFrontmatter(skillMdPath) {
1235
1324
  const text = readFileSync7(skillMdPath, "utf-8");
1236
- const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
1325
+ const meta = { name: basename4(dirname4(skillMdPath)), description: "" };
1237
1326
  if (text.startsWith("---\n")) {
1238
1327
  const block = splitLimited(text, "---", 2)[1] ?? "";
1239
1328
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -1334,21 +1423,21 @@ function listSubdirs(dir) {
1334
1423
  } catch {
1335
1424
  return [];
1336
1425
  }
1337
- 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));
1338
1427
  }
1339
1428
  function findSkillMdFiles(root) {
1340
1429
  const found = /* @__PURE__ */ new Set();
1341
1430
  for (const dir of listSubdirs(root)) {
1342
- const candidate = join10(dir, "SKILL.md");
1431
+ const candidate = join11(dir, "SKILL.md");
1343
1432
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1344
1433
  }
1345
- for (const dir of listSubdirs(join10(root, "skills"))) {
1346
- const candidate = join10(dir, "SKILL.md");
1434
+ for (const dir of listSubdirs(join11(root, "skills"))) {
1435
+ const candidate = join11(dir, "SKILL.md");
1347
1436
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1348
1437
  }
1349
1438
  for (const topDir of listSubdirs(root)) {
1350
- for (const dir of listSubdirs(join10(topDir, "skills"))) {
1351
- const candidate = join10(dir, "SKILL.md");
1439
+ for (const dir of listSubdirs(join11(topDir, "skills"))) {
1440
+ const candidate = join11(dir, "SKILL.md");
1352
1441
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1353
1442
  }
1354
1443
  }
@@ -1361,7 +1450,7 @@ function scanOverlap(candidatePath, skillsRoot) {
1361
1450
  }
1362
1451
  let cpath = resolve3(expandHome(candidatePath));
1363
1452
  if (isDir(cpath)) {
1364
- cpath = join10(cpath, "SKILL.md");
1453
+ cpath = join11(cpath, "SKILL.md");
1365
1454
  }
1366
1455
  if (!existsSync9(cpath)) {
1367
1456
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
@@ -1416,21 +1505,6 @@ function looksHighEntropy(value) {
1416
1505
  const uniqueChars = new Set(value).size;
1417
1506
  return varietyCount >= 2 && uniqueChars / value.length > 0.4;
1418
1507
  }
1419
- function isPinnedPackageSpec(spec) {
1420
- const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
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;
1433
- }
1434
1508
  function collectServerNodes(node, out) {
1435
1509
  if (Array.isArray(node)) {
1436
1510
  for (const item of node) collectServerNodes(item, out);
@@ -1461,14 +1535,17 @@ function scanJsonConfig(file, parsed) {
1461
1535
  if (command && isNpxCommand(command)) {
1462
1536
  const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1463
1537
  if (flagIdx !== -1) {
1464
- const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
1465
- if (pkgSpec && !isPinnedPackageSpec(pkgSpec)) {
1466
- findings.push({
1467
- severity: "MEDIUM",
1468
- rule: "mcp-unpinned-npx",
1469
- file,
1470
- detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1471
- });
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
+ }
1472
1549
  }
1473
1550
  }
1474
1551
  }
@@ -1883,7 +1960,7 @@ function resolveMcpCandidatePath(entry) {
1883
1960
  children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1884
1961
  } catch {
1885
1962
  }
1886
- return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
1963
+ return children.length === 1 ? join12(entry.quarantinePath, children[0]) : entry.quarantinePath;
1887
1964
  }
1888
1965
  async function runGate(candidatePath, skillsRoot, {
1889
1966
  proFeatureLabel = "overlap analysis",
@@ -1911,9 +1988,9 @@ async function runGate(candidatePath, skillsRoot, {
1911
1988
  // src/queue.ts
1912
1989
  import { randomUUID as randomUUID2 } from "crypto";
1913
1990
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1914
- import { join as join12 } from "path";
1991
+ import { join as join13 } from "path";
1915
1992
  function getQueuePath() {
1916
- return join12(getHomeDir(), "queue.json");
1993
+ return join13(getHomeDir(), "queue.json");
1917
1994
  }
1918
1995
  function readQueue() {
1919
1996
  const path = getQueuePath();
@@ -1951,15 +2028,15 @@ function generateId(existingIds) {
1951
2028
  // src/handoff.ts
1952
2029
  import { spawnSync } from "child_process";
1953
2030
  import { existsSync as existsSync12 } from "fs";
1954
- import { dirname as dirname4, join as join13 } from "path";
2031
+ import { dirname as dirname5, join as join14 } from "path";
1955
2032
  import { fileURLToPath } from "url";
1956
2033
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
1957
2034
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
1958
2035
  function resolvePromptPath() {
1959
- const here = dirname4(fileURLToPath(import.meta.url));
1960
- const builtCandidate = join13(here, "ingest-prompt.md");
2036
+ const here = dirname5(fileURLToPath(import.meta.url));
2037
+ const builtCandidate = join14(here, "ingest-prompt.md");
1961
2038
  if (existsSync12(builtCandidate)) return builtCandidate;
1962
- return join13(here, "..", "assets", "ingest-prompt.md");
2039
+ return join14(here, "..", "assets", "ingest-prompt.md");
1963
2040
  }
1964
2041
  function defaultIsOnPath(bin) {
1965
2042
  const finder = process.platform === "win32" ? "where" : "which";
@@ -2021,7 +2098,7 @@ import { createInterface } from "readline/promises";
2021
2098
  // src/agents.ts
2022
2099
  import { statSync as statSync7 } from "fs";
2023
2100
  import { homedir as homedir3 } from "os";
2024
- import { join as join14, resolve as resolve5 } from "path";
2101
+ import { join as join15, resolve as resolve5 } from "path";
2025
2102
  function isFile(p) {
2026
2103
  try {
2027
2104
  return statSync7(p).isFile();
@@ -2035,7 +2112,7 @@ function projectPath(root, cwd) {
2035
2112
  function globalPath(root, home) {
2036
2113
  if (!root) return void 0;
2037
2114
  if (root === "~") return home;
2038
- if (root.startsWith("~/")) return join14(home, root.slice(2));
2115
+ if (root.startsWith("~/")) return join15(home, root.slice(2));
2039
2116
  return root;
2040
2117
  }
2041
2118
  function makeAgent(spec) {
@@ -2585,13 +2662,20 @@ async function addMcp(source, config, options) {
2585
2662
  // version may be absent — promoteMcp refuses the unpinnable npx fallback
2586
2663
  // rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
2587
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,
2588
2669
  envVars: detail.env ? Object.keys(detail.env) : void 0,
2589
2670
  candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2590
2671
  };
2591
- 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");
2592
2673
  const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
2593
2674
  reject(entry);
2594
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
+ }
2595
2679
  if (result.backupPath) {
2596
2680
  logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
2597
2681
  }
@@ -2680,7 +2764,7 @@ async function scan(source, options = {}) {
2680
2764
  }
2681
2765
 
2682
2766
  // src/commands/list.ts
2683
- import { join as join16 } from "path";
2767
+ import { join as join17 } from "path";
2684
2768
  async function list() {
2685
2769
  await maybeOfferInit();
2686
2770
  const config = loadConfig();
@@ -2693,14 +2777,14 @@ async function list() {
2693
2777
  }
2694
2778
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
2695
2779
  for (const id of ids) {
2696
- const skillDirs = findSkillDirs(join16(quarantineDir, id));
2780
+ const skillDirs = findSkillDirs(join17(quarantineDir, id));
2697
2781
  if (skillDirs.length === 0) {
2698
2782
  console.log(` ${id} (no SKILL.md found)`);
2699
2783
  continue;
2700
2784
  }
2701
2785
  const names = skillDirs.map((dir) => {
2702
2786
  try {
2703
- return readFrontmatter(join16(dir, "SKILL.md")).name;
2787
+ return readFrontmatter(join17(dir, "SKILL.md")).name;
2704
2788
  } catch {
2705
2789
  return dir;
2706
2790
  }
@@ -2726,14 +2810,14 @@ import { createInterface as createInterface3 } from "readline/promises";
2726
2810
  import { createHash } from "crypto";
2727
2811
  import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
2728
2812
  import { tmpdir as tmpdir2 } from "os";
2729
- import { basename as basename6, dirname as dirname5, join as join17, resolve as resolvePath2, sep } from "path";
2813
+ import { basename as basename6, dirname as dirname6, join as join18, resolve as resolvePath2, sep } from "path";
2730
2814
  var SKILLOPT_BIN = "skillopt-sleep";
2731
2815
  var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
2732
2816
  async function evolve(targetSkillDir, options = {}, deps = {}) {
2733
2817
  const exec = deps.exec ?? defaultExec;
2734
2818
  const json = options.json;
2735
2819
  const resolvedTargetDir = resolvePath2(targetSkillDir);
2736
- const skillMdPath = join17(resolvedTargetDir, "SKILL.md");
2820
+ const skillMdPath = join18(resolvedTargetDir, "SKILL.md");
2737
2821
  if (!existsSync14(skillMdPath)) {
2738
2822
  throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
2739
2823
  }
@@ -2792,16 +2876,16 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2792
2876
  }
2793
2877
  return;
2794
2878
  }
2795
- const stagingRoot = join17(project, ".skillopt-sleep", "staging");
2879
+ const stagingRoot = join18(project, ".skillopt-sleep", "staging");
2796
2880
  const stagingDir = resolvePath2(parsed.staging_dir);
2797
2881
  if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
2798
2882
  throw new UserError(
2799
2883
  `skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
2800
2884
  );
2801
2885
  }
2802
- const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
2803
- const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
2804
- const reportMdPath = join17(stagingDir, "report.md");
2886
+ const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
2887
+ const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
2888
+ const reportMdPath = join18(stagingDir, "report.md");
2805
2889
  if (!existsSync14(proposedSkillMd)) {
2806
2890
  throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
2807
2891
  }
@@ -2812,13 +2896,13 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2812
2896
  assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
2813
2897
  const scannedSkillHash = sha256File(proposedSkillMd);
2814
2898
  const scannedClaudeHash = sha256File(proposedClaudeMd);
2815
- const tempDir = mkdtempSync2(join17(tmpdir2(), "skill-forge-evolve-"));
2899
+ const tempDir = mkdtempSync2(join18(tmpdir2(), "skill-forge-evolve-"));
2816
2900
  let profile;
2817
2901
  let safety;
2818
2902
  try {
2819
- cpSync3(proposedSkillMd, join17(tempDir, "SKILL.md"));
2903
+ cpSync3(proposedSkillMd, join18(tempDir, "SKILL.md"));
2820
2904
  if (existsSync14(proposedClaudeMd)) {
2821
- cpSync3(proposedClaudeMd, join17(tempDir, "CLAUDE.md"));
2905
+ cpSync3(proposedClaudeMd, join18(tempDir, "CLAUDE.md"));
2822
2906
  }
2823
2907
  profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
2824
2908
  const strictness = loadConfig().strictness;
@@ -2860,7 +2944,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2860
2944
  throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
2861
2945
  }
2862
2946
  logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
2863
- const skillsRootForProvenance = dirname5(resolvedTargetDir);
2947
+ const skillsRootForProvenance = dirname6(resolvedTargetDir);
2864
2948
  let ledgerPath;
2865
2949
  try {
2866
2950
  ledgerPath = appendProvenance(
@@ -2959,7 +3043,7 @@ function validateRunOutput(raw) {
2959
3043
  return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
2960
3044
  }
2961
3045
  function readManifest(stagingDir) {
2962
- const manifestPath = join17(stagingDir, "manifest.json");
3046
+ const manifestPath = join18(stagingDir, "manifest.json");
2963
3047
  let raw;
2964
3048
  try {
2965
3049
  raw = readFileSync10(manifestPath, "utf-8");
@@ -3000,8 +3084,8 @@ function filesIdentical(a, b) {
3000
3084
  return readFileSync10(a).equals(readFileSync10(b));
3001
3085
  }
3002
3086
  function isAlreadyAdopted(stagingDir, manifest) {
3003
- const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
3004
- const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
3087
+ const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
3088
+ const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
3005
3089
  let sawComparison = false;
3006
3090
  if (existsSync14(proposedSkillMd)) {
3007
3091
  sawComparison = true;