@rhize/skill-forge 0.5.0 → 0.6.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
@@ -6,7 +6,7 @@ import { createRequire } from "module";
6
6
 
7
7
  // src/commands/add.ts
8
8
  import { createInterface as createInterface2 } from "readline/promises";
9
- import { join as join14 } from "path";
9
+ import { join as join15 } from "path";
10
10
 
11
11
  // src/config.ts
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -153,15 +153,24 @@ async function quarantine(source, quarantineDir, deps = {}) {
153
153
  break;
154
154
  }
155
155
  case "npm": {
156
- const packResult = await exec("npm", ["pack", source.value, "--pack-destination", quarantinePath], {
157
- cwd: quarantinePath
158
- });
156
+ const packResult = await exec(
157
+ "npm",
158
+ ["pack", source.value, "--ignore-scripts", "--pack-destination", quarantinePath],
159
+ { cwd: quarantinePath }
160
+ );
159
161
  if (packResult.code !== 0) {
160
162
  throw new UserError(
161
163
  `npm pack failed for "${source.value}" (exit ${packResult.code}): ${packResult.stderr || packResult.stdout}`
162
164
  );
163
165
  }
164
166
  const tarballPath = join3(quarantinePath, findTarball(quarantinePath, source.value));
167
+ const listResult = await exec("tar", ["-tzf", tarballPath], { cwd: quarantinePath });
168
+ if (listResult.code !== 0) {
169
+ throw new UserError(
170
+ `could not list tarball contents for "${source.value}" (exit ${listResult.code}): ${listResult.stderr || listResult.stdout}`
171
+ );
172
+ }
173
+ assertSafeTarMembers(listResult.stdout, source.value);
165
174
  const extractResult = await exec("tar", ["-xzf", tarballPath, "-C", quarantinePath], {
166
175
  cwd: quarantinePath
167
176
  });
@@ -190,7 +199,7 @@ function listQuarantineIds(quarantineDir) {
190
199
  }
191
200
  function findSkillDirs(root) {
192
201
  const found = [];
193
- function walk3(dir) {
202
+ function walk2(dir) {
194
203
  let entries;
195
204
  try {
196
205
  entries = readdirSync(dir, { withFileTypes: true });
@@ -202,13 +211,24 @@ function findSkillDirs(root) {
202
211
  }
203
212
  for (const entry of entries) {
204
213
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
205
- walk3(join3(dir, entry.name));
214
+ walk2(join3(dir, entry.name));
206
215
  }
207
216
  }
208
217
  }
209
- walk3(root);
218
+ walk2(root);
210
219
  return found;
211
220
  }
221
+ function assertSafeTarMembers(tarListOutput, sourceValue) {
222
+ for (const raw of tarListOutput.split("\n")) {
223
+ const member = raw.trim();
224
+ if (!member) continue;
225
+ if (member.startsWith("/") || member.split("/").includes("..")) {
226
+ throw new UserError(
227
+ `refusing to extract tarball for "${sourceValue}": unsafe member path "${member}" (absolute path or ".." segment \u2014 possible path traversal)`
228
+ );
229
+ }
230
+ }
231
+ }
212
232
  function findTarball(dir, sourceValue) {
213
233
  const tarball = readdirSync(dir).find((name) => name.endsWith(".tgz"));
214
234
  if (!tarball) {
@@ -281,6 +301,7 @@ import { dirname } from "path";
281
301
  function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
282
302
  const force = options.force ?? false;
283
303
  const serverName = profile.name;
304
+ const { command, args, envVarsToFill } = deriveServerEntry(profile);
284
305
  let config = {};
285
306
  let rawExisting;
286
307
  if (existsSync5(targetConfigPath)) {
@@ -302,7 +323,6 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
302
323
  backupPath = `${targetConfigPath}.bak-${Date.now()}`;
303
324
  writeFileSync2(backupPath, rawExisting);
304
325
  }
305
- const { command, args, envVarsToFill } = deriveServerEntry(profile);
306
326
  const env = {};
307
327
  for (const name of envVarsToFill) env[name] = "";
308
328
  servers[serverName] = { command, args, env };
@@ -324,6 +344,11 @@ function deriveServerEntry(profile) {
324
344
  envVarsToFill: Array.from(envNames)
325
345
  };
326
346
  }
347
+ if (!profile.version) {
348
+ throw new UserError(
349
+ `cannot promote MCP server "${profile.name}": no version was found in its package.json, and there is no documented launch command to fall back to \u2014 refusing to write an unpinnable "npx ${profile.name}@unknown" command`
350
+ );
351
+ }
327
352
  return {
328
353
  command: "npx",
329
354
  args: ["-y", `${profile.name}@${profile.version}`],
@@ -406,24 +431,246 @@ function appendProvenance(entry, skillsRoot) {
406
431
  }
407
432
 
408
433
  // src/commands/gatePipeline.ts
409
- import { readdirSync as readdirSync6 } from "fs";
410
- import { join as join10 } from "path";
434
+ import { readdirSync as readdirSync5 } from "fs";
435
+ import { join as join11 } from "path";
411
436
 
412
437
  // src/gate/profile.ts
413
438
  import { execFileSync } from "child_process";
414
439
  import {
415
440
  closeSync,
416
- existsSync as existsSync7,
441
+ existsSync as existsSync8,
417
442
  mkdtempSync,
418
443
  openSync,
419
- readFileSync as readFileSync4,
420
- readdirSync as readdirSync2,
444
+ readFileSync as readFileSync6,
445
+ readdirSync as readdirSync3,
421
446
  readSync,
422
447
  rmSync as rmSync2,
423
- statSync as statSync3
448
+ statSync as statSync4
424
449
  } from "fs";
425
450
  import { tmpdir } from "os";
426
- import { basename as basename3, dirname as dirname2, extname, join as join6, resolve as resolve2 } from "path";
451
+ import { basename as basename3, dirname as dirname2, extname as extname3, join as join8, resolve as resolve2 } from "path";
452
+
453
+ // src/gate/mcpCapabilities.ts
454
+ 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
524
+ var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
525
+ var MAX_ITEMS_PER_CATEGORY = 200;
526
+ function readJsonSafe(file) {
527
+ try {
528
+ return JSON.parse(readFileSync5(file, "utf-8"));
529
+ } catch {
530
+ return void 0;
531
+ }
532
+ }
533
+ var MCP_SDK_DEP = "@modelcontextprotocol/sdk";
534
+ var MCP_KEYWORD = "mcp-server";
535
+ var MCP_BIN_PREFIX = /^mcp-/i;
536
+ function hasPackageJsonMcpMarkers(pkg2) {
537
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
538
+ const deps = pkg2[section];
539
+ if (deps && typeof deps === "object" && MCP_SDK_DEP in deps) return true;
540
+ }
541
+ const keywords = pkg2.keywords;
542
+ if (Array.isArray(keywords) && keywords.some((k) => k === MCP_KEYWORD)) return true;
543
+ const bin = pkg2.bin;
544
+ if (typeof bin === "string") {
545
+ if (MCP_BIN_PREFIX.test(bin.split("/").pop() ?? bin)) return true;
546
+ } else if (bin && typeof bin === "object") {
547
+ if (Object.keys(bin).some((k) => MCP_BIN_PREFIX.test(k))) return true;
548
+ }
549
+ if (pkg2.mcp && typeof pkg2.mcp === "object") return true;
550
+ return false;
551
+ }
552
+ function collectDeclaredFromManifest(node, buckets) {
553
+ if (Array.isArray(node)) {
554
+ for (const item of node) collectDeclaredFromManifest(item, buckets);
555
+ return;
556
+ }
557
+ if (!node || typeof node !== "object") return;
558
+ const obj = node;
559
+ for (const [key, value] of Object.entries(obj)) {
560
+ if ((key === "tools" || key === "resources" || key === "prompts") && Array.isArray(value)) {
561
+ const target = buckets[key];
562
+ for (const item of value) {
563
+ if (typeof item === "string") {
564
+ target.add(item);
565
+ } else if (item && typeof item === "object") {
566
+ const name = item.name ?? item.uri;
567
+ if (typeof name === "string") target.add(name);
568
+ }
569
+ }
570
+ } else {
571
+ collectDeclaredFromManifest(value, buckets);
572
+ }
573
+ }
574
+ }
575
+ var REGISTRATION_CALL_RE = /\bserver\.(tool|registerTool|resource|prompt)\(\s*['"]([^'"]+)['"]/g;
576
+ var REGISTRATION_METHOD_CATEGORY = {
577
+ tool: "tools",
578
+ registerTool: "tools",
579
+ resource: "resources",
580
+ prompt: "prompts"
581
+ };
582
+ function captureRegistrations(text, buckets) {
583
+ REGISTRATION_CALL_RE.lastIndex = 0;
584
+ let m;
585
+ while ((m = REGISTRATION_CALL_RE.exec(text)) !== null) {
586
+ buckets[REGISTRATION_METHOD_CATEGORY[m[1]]].add(m[2]);
587
+ }
588
+ }
589
+ var LIST_HANDLER_SCHEMAS = {
590
+ ListToolsRequestSchema: "tools",
591
+ ListResourcesRequestSchema: "resources",
592
+ ListPromptsRequestSchema: "prompts"
593
+ };
594
+ var SET_REQUEST_HANDLER_RE = /setRequestHandler\(\s*(ListToolsRequestSchema|ListResourcesRequestSchema|ListPromptsRequestSchema)\s*,/g;
595
+ var NAME_OR_URI_FIELD_RE = /\b(?:name|uri)\s*:\s*['"]([^'"]+)['"]/g;
596
+ function extractBalancedCall(text, openParenIndex) {
597
+ let depth = 0;
598
+ for (let i = openParenIndex; i < text.length; i++) {
599
+ const ch = text[i];
600
+ if (ch === "(") depth++;
601
+ else if (ch === ")") {
602
+ depth--;
603
+ if (depth === 0) return text.slice(openParenIndex, i + 1);
604
+ }
605
+ }
606
+ return text.slice(openParenIndex, Math.min(text.length, openParenIndex + 2e4));
607
+ }
608
+ function extractFromListHandlers(text, buckets) {
609
+ SET_REQUEST_HANDLER_RE.lastIndex = 0;
610
+ let m;
611
+ while ((m = SET_REQUEST_HANDLER_RE.exec(text)) !== null) {
612
+ const openParenIdx = text.indexOf("(", m.index);
613
+ if (openParenIdx === -1) continue;
614
+ const body = extractBalancedCall(text, openParenIdx);
615
+ const target = buckets[LIST_HANDLER_SCHEMAS[m[1]]];
616
+ NAME_OR_URI_FIELD_RE.lastIndex = 0;
617
+ let fieldMatch;
618
+ while ((fieldMatch = NAME_OR_URI_FIELD_RE.exec(body)) !== null) {
619
+ target.add(fieldMatch[1]);
620
+ }
621
+ }
622
+ }
623
+ var STDIO_TRANSPORT_RE = /StdioServerTransport/;
624
+ var HTTP_TRANSPORT_RE = /StreamableHTTPServerTransport|SSEServerTransport|WebSocketServerTransport/;
625
+ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
626
+ function extractMcpCapabilities(packageDir) {
627
+ const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
628
+ let hasPackageJsonMarker = false;
629
+ const pkgPath = join7(packageDir, "package.json");
630
+ if (existsSync7(pkgPath)) {
631
+ const pkg2 = readJsonSafe(pkgPath);
632
+ if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
633
+ hasPackageJsonMarker = hasPackageJsonMcpMarkers(pkg2);
634
+ }
635
+ }
636
+ for (const manifestName of [".mcp.json", "mcp.json"]) {
637
+ const manifestPath = join7(packageDir, manifestName);
638
+ if (!existsSync7(manifestPath)) continue;
639
+ const parsed = readJsonSafe(manifestPath);
640
+ if (parsed !== void 0) {
641
+ collectDeclaredFromManifest(parsed, buckets);
642
+ }
643
+ }
644
+ let sawStdioTransport = false;
645
+ let sawHttpTransport = false;
646
+ let sawMcpSdkImport = false;
647
+ for (const file of walk(packageDir)) {
648
+ if (!SOURCE_EXT.has(extname2(file))) continue;
649
+ const text = readTextFile(file);
650
+ if (text === void 0) continue;
651
+ captureRegistrations(text, buckets);
652
+ extractFromListHandlers(text, buckets);
653
+ if (STDIO_TRANSPORT_RE.test(text)) sawStdioTransport = true;
654
+ if (HTTP_TRANSPORT_RE.test(text)) sawHttpTransport = true;
655
+ if (MCP_SDK_IMPORT_RE.test(text)) sawMcpSdkImport = true;
656
+ }
657
+ const transport = sawStdioTransport ? "stdio" : sawHttpTransport ? "http" : sawMcpSdkImport ? "unknown" : void 0;
658
+ const sortCap = (set) => Array.from(set).sort().slice(0, MAX_ITEMS_PER_CATEGORY);
659
+ const tools = sortCap(buckets.tools);
660
+ const resources = sortCap(buckets.resources);
661
+ const prompts = sortCap(buckets.prompts);
662
+ const hasExplicitSignal = tools.length > 0 || resources.length > 0 || prompts.length > 0;
663
+ const declaredConfidence = hasExplicitSignal ? "high" : hasPackageJsonMarker ? "partial" : "none";
664
+ return {
665
+ tools,
666
+ resources,
667
+ prompts,
668
+ transport,
669
+ declaredConfidence
670
+ };
671
+ }
672
+
673
+ // src/gate/profile.ts
427
674
  var SPDX_HINTS = {
428
675
  mit: "MIT (permissive)",
429
676
  apache: "Apache-2.0 (permissive)",
@@ -465,10 +712,10 @@ var PYTHON_STDLIB = /* @__PURE__ */ new Set([
465
712
  ]);
466
713
  function profileSkill(inputPath) {
467
714
  const p = resolve2(expandHome(inputPath));
468
- if (!existsSync7(p)) {
715
+ if (!existsSync8(p)) {
469
716
  throw new Error(`path does not exist: ${p}`);
470
717
  }
471
- const stat = statSync3(p);
718
+ const stat = statSync4(p);
472
719
  const kind = stat.isDirectory() ? classifyInput(p) : "skill";
473
720
  if (kind === "plugin") {
474
721
  return profilePlugin(p);
@@ -479,18 +726,18 @@ function profileSkill(inputPath) {
479
726
  return profileSkillDir(p);
480
727
  }
481
728
  function classifyInput(p) {
482
- if (existsSync7(join6(p, ".claude-plugin", "plugin.json"))) return "plugin";
729
+ if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
483
730
  if (detectArtifactType(p) === "mcp") return "mcp";
484
731
  return "skill";
485
732
  }
486
- var MCP_SDK_DEP = "@modelcontextprotocol/sdk";
487
- var MCP_KEYWORD = "mcp-server";
488
- var MCP_BIN_PREFIX = /^mcp-/i;
733
+ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
734
+ var MCP_KEYWORD2 = "mcp-server";
735
+ var MCP_BIN_PREFIX2 = /^mcp-/i;
489
736
  function readPackageJsonSafe(root) {
490
- const pkgPath = join6(root, "package.json");
491
- if (!existsSync7(pkgPath)) return void 0;
737
+ const pkgPath = join8(root, "package.json");
738
+ if (!existsSync8(pkgPath)) return void 0;
492
739
  try {
493
- return JSON.parse(readFileSync4(pkgPath, "utf-8"));
740
+ return JSON.parse(readFileSync6(pkgPath, "utf-8"));
494
741
  } catch {
495
742
  return void 0;
496
743
  }
@@ -498,20 +745,20 @@ function readPackageJsonSafe(root) {
498
745
  function hasMcpPackageMarkers(pkg2) {
499
746
  for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
500
747
  const deps = pkg2[section];
501
- if (deps && typeof deps === "object" && MCP_SDK_DEP in deps) return true;
748
+ if (deps && typeof deps === "object" && MCP_SDK_DEP2 in deps) return true;
502
749
  }
503
750
  const keywords = pkg2.keywords;
504
- if (Array.isArray(keywords) && keywords.some((k) => k === MCP_KEYWORD)) return true;
751
+ if (Array.isArray(keywords) && keywords.some((k) => k === MCP_KEYWORD2)) return true;
505
752
  const bin = pkg2.bin;
506
753
  if (typeof bin === "string") {
507
- if (MCP_BIN_PREFIX.test(basename3(bin))) return true;
754
+ if (MCP_BIN_PREFIX2.test(basename3(bin))) return true;
508
755
  } else if (bin && typeof bin === "object") {
509
- if (Object.keys(bin).some((k) => MCP_BIN_PREFIX.test(k))) return true;
756
+ if (Object.keys(bin).some((k) => MCP_BIN_PREFIX2.test(k))) return true;
510
757
  }
511
758
  return false;
512
759
  }
513
760
  function detectArtifactType(root) {
514
- if (existsSync7(join6(root, ".mcp.json")) || existsSync7(join6(root, "mcp.json"))) return "mcp";
761
+ if (existsSync8(join8(root, ".mcp.json")) || existsSync8(join8(root, "mcp.json"))) return "mcp";
515
762
  const pkg2 = readPackageJsonSafe(root);
516
763
  if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
517
764
  return "skill";
@@ -519,19 +766,19 @@ function detectArtifactType(root) {
519
766
  function resolveSkillMd(inputPath) {
520
767
  let currentPath = inputPath;
521
768
  let tmpDir;
522
- const initialStat = statSync3(currentPath);
523
- if (extname(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
524
- tmpDir = mkdtempSync(join6(tmpdir(), "forge_"));
769
+ const initialStat = statSync4(currentPath);
770
+ if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
771
+ tmpDir = mkdtempSync(join8(tmpdir(), "forge_"));
525
772
  extractZip(currentPath, tmpDir);
526
773
  currentPath = tmpDir;
527
774
  }
528
- const stat = statSync3(currentPath);
775
+ const stat = statSync4(currentPath);
529
776
  if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
530
777
  return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
531
778
  }
532
779
  if (stat.isDirectory()) {
533
- const direct = join6(currentPath, "SKILL.md");
534
- if (existsSync7(direct)) {
780
+ const direct = join8(currentPath, "SKILL.md");
781
+ if (existsSync8(direct)) {
535
782
  return { skillMd: direct, root: currentPath, tmpDir };
536
783
  }
537
784
  const matches = findSkillMdOneLevelDown(currentPath);
@@ -554,18 +801,18 @@ function cleanupTmp(tmpDir) {
554
801
  }
555
802
  function findSkillMdOneLevelDown(dir) {
556
803
  const matches = /* @__PURE__ */ new Set();
557
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
804
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
558
805
  if (entry.isDirectory()) {
559
- const candidate = join6(dir, entry.name, "SKILL.md");
560
- if (existsSync7(candidate)) matches.add(candidate);
806
+ const candidate = join8(dir, entry.name, "SKILL.md");
807
+ if (existsSync8(candidate)) matches.add(candidate);
561
808
  }
562
809
  }
563
- const skillsDir = join6(dir, "skills");
810
+ const skillsDir = join8(dir, "skills");
564
811
  if (isDir(skillsDir)) {
565
- for (const entry of readdirSync2(skillsDir, { withFileTypes: true })) {
812
+ for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
566
813
  if (entry.isDirectory()) {
567
- const candidate = join6(skillsDir, entry.name, "SKILL.md");
568
- if (existsSync7(candidate)) matches.add(candidate);
814
+ const candidate = join8(skillsDir, entry.name, "SKILL.md");
815
+ if (existsSync8(candidate)) matches.add(candidate);
569
816
  }
570
817
  }
571
818
  }
@@ -648,9 +895,9 @@ function detectLicense(root, fm) {
648
895
  if (fm.license) return fm.license;
649
896
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
650
897
  for (const name of names) {
651
- const p = join6(root, name);
652
- if (existsSync7(p) && statSync3(p).isFile()) {
653
- const head = readFileSync4(p, "utf-8").slice(0, 2e3).toLowerCase();
898
+ const p = join8(root, name);
899
+ if (existsSync8(p) && statSync4(p).isFile()) {
900
+ const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
654
901
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
655
902
  if (head.includes(hint)) return label;
656
903
  }
@@ -661,8 +908,8 @@ function detectLicense(root, fm) {
661
908
  }
662
909
  function walkFiles(dir) {
663
910
  const out = [];
664
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
665
- const full = join6(dir, entry.name);
911
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
912
+ const full = join8(dir, entry.name);
666
913
  if (entry.isDirectory()) {
667
914
  out.push(...walkFiles(full));
668
915
  } else if (entry.isFile()) {
@@ -674,7 +921,7 @@ function walkFiles(dir) {
674
921
  function countResources(root) {
675
922
  const resources = {};
676
923
  for (const d of RESOURCE_DIRS) {
677
- const dp = join6(root, d);
924
+ const dp = join8(root, d);
678
925
  if (isDir(dp)) {
679
926
  resources[d] = walkFiles(dp).length;
680
927
  }
@@ -685,11 +932,11 @@ function scanMcpAndImports(text, root) {
685
932
  const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
686
933
  const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
687
934
  const imports = /* @__PURE__ */ new Set();
688
- const scriptsDir = join6(root, "scripts");
935
+ const scriptsDir = join8(root, "scripts");
689
936
  if (isDir(scriptsDir)) {
690
937
  for (const f of walkFiles(scriptsDir)) {
691
938
  if (!f.endsWith(".py")) continue;
692
- const ft = readFileSync4(f, "utf-8");
939
+ const ft = readFileSync6(f, "utf-8");
693
940
  for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
694
941
  const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
695
942
  let m;
@@ -706,7 +953,7 @@ function scanMcpAndImports(text, root) {
706
953
  function profileSkillDir(inputPath) {
707
954
  const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
708
955
  try {
709
- const text = readFileSync4(skillMd, "utf-8");
956
+ const text = readFileSync6(skillMd, "utf-8");
710
957
  const fm = parseFrontmatter(text);
711
958
  const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
712
959
  const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
@@ -734,20 +981,20 @@ function profileSkillDir(inputPath) {
734
981
  }
735
982
  }
736
983
  function profilePlugin(p) {
737
- const manifestPath = join6(p, ".claude-plugin", "plugin.json");
984
+ const manifestPath = join8(p, ".claude-plugin", "plugin.json");
738
985
  let manifest;
739
986
  try {
740
- manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
987
+ manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
741
988
  } catch (err) {
742
989
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
743
990
  }
744
- const skillsDir = join6(p, "skills");
991
+ const skillsDir = join8(p, "skills");
745
992
  let skills = [];
746
993
  if (isDir(skillsDir)) {
747
- skills = readdirSync2(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync7(join6(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
994
+ skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join8(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
748
995
  }
749
- const commandsDir = join6(p, "commands");
750
- const commands = isDir(commandsDir) ? readdirSync2(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
996
+ const commandsDir = join8(p, "commands");
997
+ const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
751
998
  const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
752
999
  const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
753
1000
  return {
@@ -764,11 +1011,11 @@ function profilePlugin(p) {
764
1011
  };
765
1012
  }
766
1013
  function profileMcp(p) {
767
- const explicitConfig = existsSync7(join6(p, ".mcp.json")) ? join6(p, ".mcp.json") : existsSync7(join6(p, "mcp.json")) ? join6(p, "mcp.json") : void 0;
1014
+ const explicitConfig = existsSync8(join8(p, ".mcp.json")) ? join8(p, ".mcp.json") : existsSync8(join8(p, "mcp.json")) ? join8(p, "mcp.json") : void 0;
768
1015
  if (explicitConfig) {
769
1016
  let cfg;
770
1017
  try {
771
- cfg = JSON.parse(readFileSync4(explicitConfig, "utf-8"));
1018
+ cfg = JSON.parse(readFileSync6(explicitConfig, "utf-8"));
772
1019
  } catch (err) {
773
1020
  throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
774
1021
  }
@@ -780,13 +1027,14 @@ function profileMcp(p) {
780
1027
  config: explicitConfig,
781
1028
  servers: names,
782
1029
  serverCount: names.length,
783
- artifactType: "mcp"
1030
+ artifactType: "mcp",
1031
+ capabilities: extractMcpCapabilities(p)
784
1032
  };
785
1033
  }
786
- const pkgPath = join6(p, "package.json");
1034
+ const pkgPath = join8(p, "package.json");
787
1035
  let pkg2;
788
1036
  try {
789
- pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
1037
+ pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
790
1038
  } catch (err) {
791
1039
  throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
792
1040
  }
@@ -797,21 +1045,17 @@ function profileMcp(p) {
797
1045
  config: pkgPath,
798
1046
  servers: [name],
799
1047
  serverCount: 1,
800
- artifactType: "mcp"
1048
+ artifactType: "mcp",
1049
+ capabilities: extractMcpCapabilities(p)
801
1050
  };
802
1051
  }
803
1052
 
804
1053
  // src/gate/safety.ts
805
- import { readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
1054
+ import { statSync as statSync5 } from "fs";
806
1055
  import { execFile } from "child_process";
807
- import { delimiter, extname as extname2, join as join7 } from "path";
1056
+ import { delimiter, join as join9 } from "path";
808
1057
  import { promisify } from "util";
809
1058
  var execFileAsync = promisify(execFile);
810
- var BLOCK_SEVERITIES_BY_STRICTNESS = {
811
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
812
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
813
- "warn-only": /* @__PURE__ */ new Set()
814
- };
815
1059
  var DENY_RULES = [
816
1060
  {
817
1061
  rule: "curl-pipe-shell",
@@ -874,62 +1118,11 @@ var DENY_RULES = [
874
1118
  message: "invokes sudo \u2014 review for unnecessary privilege escalation"
875
1119
  }
876
1120
  ];
877
- var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
878
- var BINARY_EXT = /* @__PURE__ */ new Set([
879
- ".png",
880
- ".jpg",
881
- ".jpeg",
882
- ".gif",
883
- ".webp",
884
- ".ico",
885
- ".pdf",
886
- ".zip",
887
- ".tar",
888
- ".gz",
889
- ".woff",
890
- ".woff2",
891
- ".ttf",
892
- ".eot",
893
- ".mp4",
894
- ".mp3",
895
- ".wasm",
896
- ".bin",
897
- ".exe"
898
- ]);
899
- var MAX_FILE_BYTES = 2 * 1024 * 1024;
900
- function* walk(dir) {
901
- let entries;
902
- try {
903
- entries = readdirSync3(dir, { withFileTypes: true });
904
- } catch {
905
- return;
906
- }
907
- for (const entry of entries) {
908
- const full = join7(dir, entry.name);
909
- if (entry.isDirectory()) {
910
- if (SKIP_DIRS2.has(entry.name)) continue;
911
- yield* walk(full);
912
- } else if (entry.isFile()) {
913
- yield full;
914
- }
915
- }
916
- }
917
- function readTextFile(file) {
918
- if (BINARY_EXT.has(extname2(file).toLowerCase())) return void 0;
919
- try {
920
- if (statSync4(file).size > MAX_FILE_BYTES) return void 0;
921
- const content = readFileSync5(file, "utf-8");
922
- if (content.slice(0, 512).includes("\0")) return void 0;
923
- return content;
924
- } catch {
925
- return void 0;
926
- }
927
- }
928
1121
  function scanStaticRules(targetPath) {
929
1122
  const findings = [];
930
1123
  let root;
931
1124
  try {
932
- root = statSync4(targetPath);
1125
+ root = statSync5(targetPath);
933
1126
  } catch {
934
1127
  return findings;
935
1128
  }
@@ -959,9 +1152,9 @@ function which(cmd) {
959
1152
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
960
1153
  for (const dir of dirs) {
961
1154
  for (const ext of exts) {
962
- const candidate = join7(dir, cmd + ext);
1155
+ const candidate = join9(dir, cmd + ext);
963
1156
  try {
964
- if (statSync4(candidate).isFile()) return candidate;
1157
+ if (statSync5(candidate).isFile()) return candidate;
965
1158
  } catch {
966
1159
  }
967
1160
  }
@@ -993,20 +1186,14 @@ async function scanWithSkillSpector(targetPath) {
993
1186
  return [];
994
1187
  }
995
1188
  }
996
- function verdictFor(findings, strictness) {
997
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
998
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
999
- if (findings.length > 0) return "warn";
1000
- return "pass";
1001
- }
1002
1189
  async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
1003
1190
  const findings = [...scanStaticRules(targetPath), ...await scanWithSkillSpector(targetPath)];
1004
1191
  return { findings, verdict: verdictFor(findings, strictness) };
1005
1192
  }
1006
1193
 
1007
1194
  // src/gate/overlap.ts
1008
- import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
1009
- import { basename as basename4, dirname as dirname3, join as join8, resolve as resolve3 } from "path";
1195
+ 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";
1010
1197
  var STOPWORDS = new Set(
1011
1198
  `
1012
1199
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -1027,7 +1214,7 @@ function splitLimited(text, sep, maxSplit) {
1027
1214
  return parts;
1028
1215
  }
1029
1216
  function readFrontmatter(skillMdPath) {
1030
- const text = readFileSync6(skillMdPath, "utf-8");
1217
+ const text = readFileSync7(skillMdPath, "utf-8");
1031
1218
  const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
1032
1219
  if (text.startsWith("---\n")) {
1033
1220
  const block = splitLimited(text, "---", 2)[1] ?? "";
@@ -1122,29 +1309,29 @@ function suggestVerbCode(topScore) {
1122
1309
  return verbTier(topScore).verb;
1123
1310
  }
1124
1311
  function listSubdirs(dir) {
1125
- if (!existsSync8(dir)) return [];
1312
+ if (!existsSync9(dir)) return [];
1126
1313
  let entries;
1127
1314
  try {
1128
1315
  entries = readdirSync4(dir, { withFileTypes: true });
1129
1316
  } catch {
1130
1317
  return [];
1131
1318
  }
1132
- return entries.filter((e) => e.isDirectory()).map((e) => join8(dir, e.name));
1319
+ return entries.filter((e) => e.isDirectory()).map((e) => join10(dir, e.name));
1133
1320
  }
1134
1321
  function findSkillMdFiles(root) {
1135
1322
  const found = /* @__PURE__ */ new Set();
1136
1323
  for (const dir of listSubdirs(root)) {
1137
- const candidate = join8(dir, "SKILL.md");
1138
- if (existsSync8(candidate)) found.add(resolve3(candidate));
1324
+ const candidate = join10(dir, "SKILL.md");
1325
+ if (existsSync9(candidate)) found.add(resolve3(candidate));
1139
1326
  }
1140
- for (const dir of listSubdirs(join8(root, "skills"))) {
1141
- const candidate = join8(dir, "SKILL.md");
1142
- if (existsSync8(candidate)) found.add(resolve3(candidate));
1327
+ for (const dir of listSubdirs(join10(root, "skills"))) {
1328
+ const candidate = join10(dir, "SKILL.md");
1329
+ if (existsSync9(candidate)) found.add(resolve3(candidate));
1143
1330
  }
1144
1331
  for (const topDir of listSubdirs(root)) {
1145
- for (const dir of listSubdirs(join8(topDir, "skills"))) {
1146
- const candidate = join8(dir, "SKILL.md");
1147
- if (existsSync8(candidate)) found.add(resolve3(candidate));
1332
+ for (const dir of listSubdirs(join10(topDir, "skills"))) {
1333
+ const candidate = join10(dir, "SKILL.md");
1334
+ if (existsSync9(candidate)) found.add(resolve3(candidate));
1148
1335
  }
1149
1336
  }
1150
1337
  return Array.from(found).filter((p) => !p.includes("/.git/")).sort();
@@ -1156,9 +1343,9 @@ function scanOverlap(candidatePath, skillsRoot) {
1156
1343
  }
1157
1344
  let cpath = resolve3(expandHome(candidatePath));
1158
1345
  if (isDir(cpath)) {
1159
- cpath = join8(cpath, "SKILL.md");
1346
+ cpath = join10(cpath, "SKILL.md");
1160
1347
  }
1161
- if (!existsSync8(cpath)) {
1348
+ if (!existsSync9(cpath)) {
1162
1349
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
1163
1350
  }
1164
1351
  const cand = readFrontmatter(cpath);
@@ -1182,59 +1369,8 @@ function scanOverlap(candidatePath, skillsRoot) {
1182
1369
  }
1183
1370
 
1184
1371
  // src/gate/mcpSafety.ts
1185
- import { readFileSync as readFileSync7, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
1186
- import { basename as basename5, extname as extname3, join as join9 } from "path";
1187
- var SKIP_DIRS3 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
1188
- var BINARY_EXT2 = /* @__PURE__ */ new Set([
1189
- ".png",
1190
- ".jpg",
1191
- ".jpeg",
1192
- ".gif",
1193
- ".webp",
1194
- ".ico",
1195
- ".pdf",
1196
- ".zip",
1197
- ".tar",
1198
- ".gz",
1199
- ".woff",
1200
- ".woff2",
1201
- ".ttf",
1202
- ".eot",
1203
- ".mp4",
1204
- ".mp3",
1205
- ".wasm",
1206
- ".bin",
1207
- ".exe"
1208
- ]);
1209
- var MAX_FILE_BYTES2 = 2 * 1024 * 1024;
1210
- function* walk2(dir) {
1211
- let entries;
1212
- try {
1213
- entries = readdirSync5(dir, { withFileTypes: true });
1214
- } catch {
1215
- return;
1216
- }
1217
- for (const entry of entries) {
1218
- const full = join9(dir, entry.name);
1219
- if (entry.isDirectory()) {
1220
- if (SKIP_DIRS3.has(entry.name)) continue;
1221
- yield* walk2(full);
1222
- } else if (entry.isFile()) {
1223
- yield full;
1224
- }
1225
- }
1226
- }
1227
- function readTextFile2(file) {
1228
- if (BINARY_EXT2.has(extname3(file).toLowerCase())) return void 0;
1229
- try {
1230
- if (statSync5(file).size > MAX_FILE_BYTES2) return void 0;
1231
- const content = readFileSync7(file, "utf-8");
1232
- if (content.slice(0, 512).includes("\0")) return void 0;
1233
- return content;
1234
- } catch {
1235
- return void 0;
1236
- }
1237
- }
1372
+ import { statSync as statSync6 } from "fs";
1373
+ import { basename as basename5, extname as extname4 } from "path";
1238
1374
  function isConfigOrExampleFile(file) {
1239
1375
  const base = basename5(file).toLowerCase();
1240
1376
  if (base.startsWith(".env")) return true;
@@ -1264,7 +1400,18 @@ function looksHighEntropy(value) {
1264
1400
  }
1265
1401
  function isPinnedPackageSpec(spec) {
1266
1402
  const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
1267
- return withoutScope.includes("@");
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;
1268
1415
  }
1269
1416
  function collectServerNodes(node, out) {
1270
1417
  if (Array.isArray(node)) {
@@ -1293,7 +1440,7 @@ function scanJsonConfig(file, parsed) {
1293
1440
  for (const node of nodes) {
1294
1441
  const command = typeof node.command === "string" ? node.command : void 0;
1295
1442
  const args = Array.isArray(node.args) ? node.args.filter((a) => typeof a === "string") : [];
1296
- if (command === "npx") {
1443
+ if (command && isNpxCommand(command)) {
1297
1444
  const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1298
1445
  if (flagIdx !== -1) {
1299
1446
  const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
@@ -1302,7 +1449,7 @@ function scanJsonConfig(file, parsed) {
1302
1449
  severity: "MEDIUM",
1303
1450
  rule: "mcp-unpinned-npx",
1304
1451
  file,
1305
- detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>) so the resolved server code can't change silently`
1452
+ detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1306
1453
  });
1307
1454
  }
1308
1455
  }
@@ -1341,9 +1488,22 @@ function scanJsonConfig(file, parsed) {
1341
1488
  }
1342
1489
  return findings;
1343
1490
  }
1344
- var NPX_SHELL_RE = /\bnpx\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/g;
1491
+ var NPX_SHELL_RE = /\bnpx(?:\.cmd|\.exe)?\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/gi;
1345
1492
  var DANGEROUS_FLAG_RE = /--dangerously-[\w-]+|--no-sandbox\b/g;
1346
1493
  var CREDENTIAL_ASSIGNMENT_RE = /["']?([A-Za-z][A-Za-z0-9_]*)["']?\s*[:=]\s*["']([^"']{6,})["']/g;
1494
+ var CREDENTIAL_ASSIGNMENT_UNQUOTED_RE = /^\s*(?:export\s+|-\s+)?([A-Za-z][A-Za-z0-9_]*)\s*[:=]\s*(\S+)/;
1495
+ var LEADING_QUOTE_RE = /^["']/;
1496
+ function pushCredentialFinding(findings, file, lineNo, varName, value) {
1497
+ if (isPlaceholderValue(value)) return;
1498
+ if (looksCredentialKey(varName) || looksHighEntropy(value)) {
1499
+ findings.push({
1500
+ severity: "HIGH",
1501
+ rule: "mcp-inline-credential-value",
1502
+ file,
1503
+ detail: `line ${lineNo}: inline credential value assigned to ${varName}`
1504
+ });
1505
+ }
1506
+ }
1347
1507
  function scanTextFile(file, content, allowCredentialCheck) {
1348
1508
  const findings = [];
1349
1509
  const lines = content.split("\n");
@@ -1354,8 +1514,8 @@ function scanTextFile(file, content, allowCredentialCheck) {
1354
1514
  let m;
1355
1515
  while ((m = NPX_SHELL_RE.exec(line)) !== null) {
1356
1516
  const pkgSpec = m[1];
1357
- const pinned = m[2] !== void 0;
1358
- if (!pinned) {
1517
+ const versionSuffix = m[2] ?? "";
1518
+ if (!isPinnedPackageSpec(pkgSpec + versionSuffix)) {
1359
1519
  findings.push({
1360
1520
  severity: "MEDIUM",
1361
1521
  rule: "mcp-unpinned-npx",
@@ -1376,26 +1536,20 @@ function scanTextFile(file, content, allowCredentialCheck) {
1376
1536
  if (allowCredentialCheck) {
1377
1537
  CREDENTIAL_ASSIGNMENT_RE.lastIndex = 0;
1378
1538
  while ((m = CREDENTIAL_ASSIGNMENT_RE.exec(line)) !== null) {
1379
- const varName = m[1];
1380
- const value = m[2];
1381
- if (isPlaceholderValue(value)) continue;
1382
- if (looksCredentialKey(varName) || looksHighEntropy(value)) {
1383
- findings.push({
1384
- severity: "HIGH",
1385
- rule: "mcp-inline-credential-value",
1386
- file,
1387
- detail: `line ${lineNo}: inline credential value assigned to ${varName}`
1388
- });
1389
- }
1539
+ pushCredentialFinding(findings, file, lineNo, m[1], m[2]);
1540
+ }
1541
+ const unquoted = CREDENTIAL_ASSIGNMENT_UNQUOTED_RE.exec(line);
1542
+ if (unquoted && !LEADING_QUOTE_RE.test(unquoted[2])) {
1543
+ pushCredentialFinding(findings, file, lineNo, unquoted[1], unquoted[2]);
1390
1544
  }
1391
1545
  }
1392
1546
  }
1393
1547
  return findings;
1394
1548
  }
1395
1549
  function scanFileForMcpFindings(file) {
1396
- const content = readTextFile2(file);
1550
+ const content = readTextFile(file);
1397
1551
  if (content === void 0) return [];
1398
- if (extname3(file).toLowerCase() === ".json") {
1552
+ if (extname4(file).toLowerCase() === ".json") {
1399
1553
  const parsed = tryParseJson(content);
1400
1554
  if (parsed !== void 0) return scanJsonConfig(file, parsed);
1401
1555
  }
@@ -1404,41 +1558,30 @@ function scanFileForMcpFindings(file) {
1404
1558
  function scanMcpFindings(targetPath) {
1405
1559
  let root;
1406
1560
  try {
1407
- root = statSync5(targetPath);
1561
+ root = statSync6(targetPath);
1408
1562
  } catch {
1409
1563
  return [];
1410
1564
  }
1411
- const files = root.isDirectory() ? Array.from(walk2(targetPath)) : [targetPath];
1565
+ const files = root.isDirectory() ? Array.from(walk(targetPath)) : [targetPath];
1412
1566
  const findings = [];
1413
1567
  for (const file of files) {
1414
1568
  findings.push(...scanFileForMcpFindings(file));
1415
1569
  }
1416
1570
  return findings;
1417
1571
  }
1418
- var BLOCK_SEVERITIES_BY_STRICTNESS2 = {
1419
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
1420
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
1421
- "warn-only": /* @__PURE__ */ new Set()
1422
- };
1423
- function verdictFor2(findings, strictness) {
1424
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS2[strictness];
1425
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
1426
- if (findings.length > 0) return "warn";
1427
- return "pass";
1428
- }
1429
1572
  async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
1430
1573
  const base = await scanSafety(targetPath, strictness);
1431
1574
  const mcpFindings = scanMcpFindings(targetPath);
1432
1575
  const findings = [...base.findings, ...mcpFindings];
1433
- return { findings, verdict: verdictFor2(findings, strictness) };
1576
+ return { findings, verdict: verdictFor(findings, strictness) };
1434
1577
  }
1435
1578
 
1436
1579
  // src/gate/mcpOverlap.ts
1437
- import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
1580
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
1438
1581
  import { resolve as resolve4 } from "path";
1439
1582
  function readTargetServers(targetPath) {
1440
1583
  const path = resolve4(expandHome(targetPath));
1441
- if (!existsSync9(path)) return [];
1584
+ if (!existsSync10(path)) return [];
1442
1585
  let cfg;
1443
1586
  try {
1444
1587
  cfg = JSON.parse(readFileSync8(path, "utf-8"));
@@ -1639,6 +1782,16 @@ function profileHeaderFields(profile) {
1639
1782
  license: profile.kind === "skill" ? profile.license : "UNKNOWN"
1640
1783
  };
1641
1784
  }
1785
+ function pluralize(count, noun) {
1786
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
1787
+ }
1788
+ function renderCapabilitiesLine(profile) {
1789
+ const { tools, resources, prompts, declaredConfidence } = profile.capabilities;
1790
+ const counts = [pluralize(tools.length, "tool"), pluralize(resources.length, "resource"), pluralize(prompts.length, "prompt")].join(
1791
+ ", "
1792
+ );
1793
+ return `${padLabel("Capabilities")}: ${counts} (declaredConfidence: ${declaredConfidence})`;
1794
+ }
1642
1795
  function renderReport(result) {
1643
1796
  const { profile, overlap, overlapLocked, safety, worthTaking, leaveBehind } = result;
1644
1797
  const { name, version, license } = profileHeaderFields(profile);
@@ -1651,6 +1804,7 @@ function renderReport(result) {
1651
1804
  `\u{1F528} Forge: ${name} (v${version}, ${license})`,
1652
1805
  BORDER,
1653
1806
  `${padLabel("Artifact type")}: ${profile.artifactType}`,
1807
+ ...profile.kind === "mcp" ? [renderCapabilitiesLine(profile)] : [],
1654
1808
  ...overlapLines,
1655
1809
  renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
1656
1810
  renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
@@ -1715,10 +1869,10 @@ function resolveMcpCandidatePath(entry) {
1715
1869
  if (entry.packageDir) return entry.packageDir;
1716
1870
  let children = [];
1717
1871
  try {
1718
- children = readdirSync6(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1872
+ children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1719
1873
  } catch {
1720
1874
  }
1721
- return children.length === 1 ? join10(entry.quarantinePath, children[0]) : entry.quarantinePath;
1875
+ return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
1722
1876
  }
1723
1877
  async function runGate(candidatePath, skillsRoot, {
1724
1878
  proFeatureLabel = "overlap analysis",
@@ -1749,14 +1903,14 @@ async function runGate(candidatePath, skillsRoot, {
1749
1903
 
1750
1904
  // src/queue.ts
1751
1905
  import { randomUUID as randomUUID2 } from "crypto";
1752
- import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1753
- import { join as join11 } from "path";
1906
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1907
+ import { join as join12 } from "path";
1754
1908
  function getQueuePath() {
1755
- return join11(getHomeDir(), "queue.json");
1909
+ return join12(getHomeDir(), "queue.json");
1756
1910
  }
1757
1911
  function readQueue() {
1758
1912
  const path = getQueuePath();
1759
- if (!existsSync10(path)) {
1913
+ if (!existsSync11(path)) {
1760
1914
  return { version: 1, entries: [] };
1761
1915
  }
1762
1916
  const raw = readFileSync9(path, "utf-8");
@@ -1789,16 +1943,16 @@ function generateId(existingIds) {
1789
1943
 
1790
1944
  // src/handoff.ts
1791
1945
  import { spawnSync } from "child_process";
1792
- import { existsSync as existsSync11 } from "fs";
1793
- import { dirname as dirname4, join as join12 } from "path";
1946
+ import { existsSync as existsSync12 } from "fs";
1947
+ import { dirname as dirname4, join as join13 } from "path";
1794
1948
  import { fileURLToPath } from "url";
1795
1949
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
1796
1950
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
1797
1951
  function resolvePromptPath() {
1798
1952
  const here = dirname4(fileURLToPath(import.meta.url));
1799
- const builtCandidate = join12(here, "ingest-prompt.md");
1800
- if (existsSync11(builtCandidate)) return builtCandidate;
1801
- return join12(here, "..", "assets", "ingest-prompt.md");
1953
+ const builtCandidate = join13(here, "ingest-prompt.md");
1954
+ if (existsSync12(builtCandidate)) return builtCandidate;
1955
+ return join13(here, "..", "assets", "ingest-prompt.md");
1802
1956
  }
1803
1957
  function defaultIsOnPath(bin) {
1804
1958
  const finder = process.platform === "win32" ? "where" : "which";
@@ -1853,17 +2007,17 @@ function runHandoff(config, installedPath, options = {}) {
1853
2007
  }
1854
2008
 
1855
2009
  // src/commands/init.ts
1856
- import { existsSync as existsSync12 } from "fs";
2010
+ import { existsSync as existsSync13 } from "fs";
1857
2011
  import { homedir as homedir4 } from "os";
1858
2012
  import { createInterface } from "readline/promises";
1859
2013
 
1860
2014
  // src/agents.ts
1861
- import { statSync as statSync6 } from "fs";
2015
+ import { statSync as statSync7 } from "fs";
1862
2016
  import { homedir as homedir3 } from "os";
1863
- import { join as join13, resolve as resolve5 } from "path";
2017
+ import { join as join14, resolve as resolve5 } from "path";
1864
2018
  function isFile(p) {
1865
2019
  try {
1866
- return statSync6(p).isFile();
2020
+ return statSync7(p).isFile();
1867
2021
  } catch {
1868
2022
  return false;
1869
2023
  }
@@ -1874,7 +2028,7 @@ function projectPath(root, cwd) {
1874
2028
  function globalPath(root, home) {
1875
2029
  if (!root) return void 0;
1876
2030
  if (root === "~") return home;
1877
- if (root.startsWith("~/")) return join13(home, root.slice(2));
2031
+ if (root.startsWith("~/")) return join14(home, root.slice(2));
1878
2032
  return root;
1879
2033
  }
1880
2034
  function makeAgent(spec) {
@@ -2261,7 +2415,7 @@ async function init(options = {}) {
2261
2415
  }
2262
2416
  async function maybeOfferInit(options = {}) {
2263
2417
  if (options.json || options.yes || !process.stdin.isTTY) return;
2264
- if (existsSync12(getConfigPath())) return;
2418
+ if (existsSync13(getConfigPath())) return;
2265
2419
  console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
2266
2420
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2267
2421
  let answer = "";
@@ -2396,11 +2550,13 @@ async function addMcp(source, config, options) {
2396
2550
  const detail = resolveMcpCandidateDetail(profile);
2397
2551
  const serverProfile = {
2398
2552
  name: detail.name,
2399
- version: detail.version ?? "unknown",
2553
+ // version may be absent — promoteMcp refuses the unpinnable npx fallback
2554
+ // rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
2555
+ version: detail.version,
2400
2556
  envVars: detail.env ? Object.keys(detail.env) : void 0,
2401
2557
  candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2402
2558
  };
2403
- const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join14(process.cwd(), ".mcp.json");
2559
+ const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join15(process.cwd(), ".mcp.json");
2404
2560
  const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
2405
2561
  reject(entry);
2406
2562
  logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
@@ -2434,7 +2590,8 @@ async function addMcp(source, config, options) {
2434
2590
  suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
2435
2591
  },
2436
2592
  status: "pending",
2437
- artifactType: "mcp"
2593
+ artifactType: "mcp",
2594
+ capabilities: profile.capabilities
2438
2595
  });
2439
2596
  if (options.ingest && installedPath) {
2440
2597
  runHandoff(config, installedPath);
@@ -2513,7 +2670,7 @@ async function scan(source, options = {}) {
2513
2670
  }
2514
2671
 
2515
2672
  // src/commands/list.ts
2516
- import { join as join15 } from "path";
2673
+ import { join as join16 } from "path";
2517
2674
  async function list() {
2518
2675
  await maybeOfferInit();
2519
2676
  const config = loadConfig();
@@ -2526,14 +2683,14 @@ async function list() {
2526
2683
  }
2527
2684
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
2528
2685
  for (const id of ids) {
2529
- const skillDirs = findSkillDirs(join15(quarantineDir, id));
2686
+ const skillDirs = findSkillDirs(join16(quarantineDir, id));
2530
2687
  if (skillDirs.length === 0) {
2531
2688
  console.log(` ${id} (no SKILL.md found)`);
2532
2689
  continue;
2533
2690
  }
2534
2691
  const names = skillDirs.map((dir) => {
2535
2692
  try {
2536
- return readFrontmatter(join15(dir, "SKILL.md")).name;
2693
+ return readFrontmatter(join16(dir, "SKILL.md")).name;
2537
2694
  } catch {
2538
2695
  return dir;
2539
2696
  }