@rhize/skill-forge 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,6 +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 join15 } from "path";
9
10
 
10
11
  // src/config.ts
11
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -41,7 +42,8 @@ function loadConfig() {
41
42
  licenseKey: parsed.licenseKey ?? defaults.licenseKey,
42
43
  defaultTarget: parsed.defaultTarget ?? defaults.defaultTarget,
43
44
  agents: parsed.agents ?? defaults.agents,
44
- handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand
45
+ handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand,
46
+ mcpTargets: parsed.mcpTargets ?? defaults.mcpTargets
45
47
  };
46
48
  }
47
49
  function saveConfig(config) {
@@ -86,7 +88,8 @@ function isDir(p) {
86
88
  // src/install/resolve.ts
87
89
  var GIT_URL_PATTERN = /^(git@|https?:\/\/|ssh:\/\/)/;
88
90
  var LOCAL_PATH_PATTERN = /^(\.{1,2}\/|\/|~\/|~$)/;
89
- function resolve(source) {
91
+ var NPM_SCOPED_PATTERN = /^@[^/]+\/[^/]+$/;
92
+ function resolve(source, artifact = "skill") {
90
93
  const value = source.trim();
91
94
  if (value.startsWith("-")) {
92
95
  throw new UserError(`skill source cannot start with "-" (looks like a flag, not a source): ${value}`);
@@ -98,6 +101,9 @@ function resolve(source) {
98
101
  if (existsSync2(localCandidate) || LOCAL_PATH_PATTERN.test(value)) {
99
102
  return { type: "local", value: localCandidate };
100
103
  }
104
+ if (artifact === "mcp" && (NPM_SCOPED_PATTERN.test(value) || !value.includes("/"))) {
105
+ return { type: "npm", value };
106
+ }
101
107
  return { type: "skills.sh", value };
102
108
  }
103
109
 
@@ -113,6 +119,7 @@ async function quarantine(source, quarantineDir, deps = {}) {
113
119
  const id = randomUUID();
114
120
  const quarantinePath = join3(quarantineDir, id);
115
121
  mkdirSync2(quarantinePath, { recursive: true });
122
+ let packageDir;
116
123
  switch (source.type) {
117
124
  case "skills.sh": {
118
125
  const result = await exec("npx", ["-y", "skills@latest", "add", source.value], {
@@ -145,11 +152,37 @@ async function quarantine(source, quarantineDir, deps = {}) {
145
152
  cpSync(source.value, join3(quarantinePath, dirName), { recursive: true });
146
153
  break;
147
154
  }
155
+ case "npm": {
156
+ const packResult = await exec("npm", ["pack", source.value, "--pack-destination", quarantinePath], {
157
+ cwd: quarantinePath
158
+ });
159
+ if (packResult.code !== 0) {
160
+ throw new UserError(
161
+ `npm pack failed for "${source.value}" (exit ${packResult.code}): ${packResult.stderr || packResult.stdout}`
162
+ );
163
+ }
164
+ const tarballPath = join3(quarantinePath, findTarball(quarantinePath, source.value));
165
+ const extractResult = await exec("tar", ["-xzf", tarballPath, "-C", quarantinePath], {
166
+ cwd: quarantinePath
167
+ });
168
+ if (extractResult.code !== 0) {
169
+ throw new UserError(
170
+ `tarball extraction failed for "${source.value}" (exit ${extractResult.code}): ${extractResult.stderr || extractResult.stdout}`
171
+ );
172
+ }
173
+ packageDir = join3(quarantinePath, "package");
174
+ if (!existsSync3(packageDir)) {
175
+ throw new UserError(
176
+ `npm pack for "${source.value}" did not produce the expected package/ dir after extraction`
177
+ );
178
+ }
179
+ break;
180
+ }
148
181
  default: {
149
182
  throw new UserError(`unknown source type: ${source.type}`);
150
183
  }
151
184
  }
152
- return { id, quarantinePath, skillDirs: findSkillDirs(quarantinePath), source };
185
+ return { id, quarantinePath, skillDirs: findSkillDirs(quarantinePath), packageDir, source };
153
186
  }
154
187
  function listQuarantineIds(quarantineDir) {
155
188
  if (!existsSync3(quarantineDir)) return [];
@@ -157,7 +190,7 @@ function listQuarantineIds(quarantineDir) {
157
190
  }
158
191
  function findSkillDirs(root) {
159
192
  const found = [];
160
- function walk2(dir) {
193
+ function walk3(dir) {
161
194
  let entries;
162
195
  try {
163
196
  entries = readdirSync(dir, { withFileTypes: true });
@@ -169,13 +202,20 @@ function findSkillDirs(root) {
169
202
  }
170
203
  for (const entry of entries) {
171
204
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
172
- walk2(join3(dir, entry.name));
205
+ walk3(join3(dir, entry.name));
173
206
  }
174
207
  }
175
208
  }
176
- walk2(root);
209
+ walk3(root);
177
210
  return found;
178
211
  }
212
+ function findTarball(dir, sourceValue) {
213
+ const tarball = readdirSync(dir).find((name) => name.endsWith(".tgz"));
214
+ if (!tarball) {
215
+ throw new UserError(`npm pack for "${sourceValue}" did not produce a .tgz file in ${dir}`);
216
+ }
217
+ return tarball;
218
+ }
179
219
  function deriveGitDirName(url) {
180
220
  const stripped = url.replace(/\.git$/, "");
181
221
  const segments = stripped.split(/[/:]/).filter(Boolean);
@@ -235,8 +275,97 @@ function moveDir(src, dest) {
235
275
  }
236
276
  }
237
277
 
278
+ // src/install/promoteMcp.ts
279
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
280
+ import { dirname } from "path";
281
+ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
282
+ const force = options.force ?? false;
283
+ const serverName = profile.name;
284
+ let config = {};
285
+ let rawExisting;
286
+ if (existsSync5(targetConfigPath)) {
287
+ rawExisting = readFileSync2(targetConfigPath, "utf-8");
288
+ try {
289
+ config = JSON.parse(rawExisting);
290
+ } catch (err) {
291
+ throw new UserError(`could not parse existing MCP config ${targetConfigPath}: ${err.message}`);
292
+ }
293
+ }
294
+ const servers = { ...config.mcpServers ?? {} };
295
+ if (Object.prototype.hasOwnProperty.call(servers, serverName) && !force) {
296
+ throw new UserError(
297
+ `Refusing to overwrite existing MCP server entry "${serverName}" in ${targetConfigPath} (pass force to override)`
298
+ );
299
+ }
300
+ let backupPath;
301
+ if (rawExisting !== void 0) {
302
+ backupPath = `${targetConfigPath}.bak-${Date.now()}`;
303
+ writeFileSync2(backupPath, rawExisting);
304
+ }
305
+ const { command, args, envVarsToFill } = deriveServerEntry(profile);
306
+ const env = {};
307
+ for (const name of envVarsToFill) env[name] = "";
308
+ servers[serverName] = { command, args, env };
309
+ config.mcpServers = servers;
310
+ mkdirSync4(dirname(targetConfigPath), { recursive: true });
311
+ writeFileSync2(targetConfigPath, `${JSON.stringify(config, null, 2)}
312
+ `);
313
+ return { target: targetConfigPath, serverName, envVarsToFill, backupPath };
314
+ }
315
+ function deriveServerEntry(profile) {
316
+ const envNames = new Set(profile.envVars ?? []);
317
+ if (profile.candidateEntry?.env) {
318
+ for (const key of Object.keys(profile.candidateEntry.env)) envNames.add(key);
319
+ }
320
+ if (profile.candidateEntry) {
321
+ return {
322
+ command: profile.candidateEntry.command,
323
+ args: profile.candidateEntry.args ?? [],
324
+ envVarsToFill: Array.from(envNames)
325
+ };
326
+ }
327
+ return {
328
+ command: "npx",
329
+ args: ["-y", `${profile.name}@${profile.version}`],
330
+ envVarsToFill: Array.from(envNames)
331
+ };
332
+ }
333
+
334
+ // src/install/mcpCandidate.ts
335
+ import { readFileSync as readFileSync3 } from "fs";
336
+ function readPackageVersion(pkgPath) {
337
+ try {
338
+ const pkg2 = JSON.parse(readFileSync3(pkgPath, "utf-8"));
339
+ return typeof pkg2.version === "string" ? pkg2.version : void 0;
340
+ } catch {
341
+ return void 0;
342
+ }
343
+ }
344
+ function readConfigEntry(configPath, name) {
345
+ try {
346
+ const cfg = JSON.parse(readFileSync3(configPath, "utf-8"));
347
+ const servers = cfg.mcpServers ?? cfg.servers ?? {};
348
+ const raw = servers[name] ?? {};
349
+ return {
350
+ command: typeof raw.command === "string" ? raw.command : void 0,
351
+ args: Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === "string") : void 0,
352
+ env: raw.env && typeof raw.env === "object" && !Array.isArray(raw.env) ? raw.env : void 0
353
+ };
354
+ } catch {
355
+ return void 0;
356
+ }
357
+ }
358
+ function resolveMcpCandidateDetail(profile) {
359
+ const name = profile.servers[0];
360
+ if (profile.config.endsWith("package.json")) {
361
+ return { name, version: readPackageVersion(profile.config) };
362
+ }
363
+ const entry = readConfigEntry(profile.config, name);
364
+ return { name, command: entry?.command, args: entry?.args, env: entry?.env };
365
+ }
366
+
238
367
  // src/provenance.ts
239
- import { appendFileSync, existsSync as existsSync5, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
368
+ import { appendFileSync, existsSync as existsSync6, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
240
369
  import { join as join5 } from "path";
241
370
  var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
242
371
  var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
@@ -251,7 +380,7 @@ function appendProvenance(entry, skillsRoot) {
251
380
  if (!FORGE_VERBS.includes(entry.verb)) {
252
381
  throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
253
382
  }
254
- if (!existsSync5(skillsRoot) || !statSync2(skillsRoot).isDirectory()) {
383
+ if (!existsSync6(skillsRoot) || !statSync2(skillsRoot).isDirectory()) {
255
384
  throw new Error(`skills-root not a directory: ${skillsRoot}`);
256
385
  }
257
386
  const today = todayLocalIsoDate();
@@ -269,28 +398,212 @@ function appendProvenance(entry, skillsRoot) {
269
398
  - **Notes:** ${entry.notes ?? ""}
270
399
  `;
271
400
  const ledgerPath = join5(skillsRoot, "SOURCES.md");
272
- if (!existsSync5(ledgerPath)) {
273
- writeFileSync2(ledgerPath, LEDGER_HEADER, "utf-8");
401
+ if (!existsSync6(ledgerPath)) {
402
+ writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
274
403
  }
275
404
  appendFileSync(ledgerPath, block, "utf-8");
276
405
  return ledgerPath;
277
406
  }
278
407
 
408
+ // src/commands/gatePipeline.ts
409
+ import { readdirSync as readdirSync7 } from "fs";
410
+ import { join as join11 } from "path";
411
+
279
412
  // src/gate/profile.ts
280
413
  import { execFileSync } from "child_process";
281
414
  import {
282
415
  closeSync,
283
- existsSync as existsSync6,
416
+ existsSync as existsSync8,
284
417
  mkdtempSync,
285
418
  openSync,
286
- readFileSync as readFileSync2,
287
- readdirSync as readdirSync2,
419
+ readFileSync as readFileSync5,
420
+ readdirSync as readdirSync3,
288
421
  readSync,
289
422
  rmSync as rmSync2,
290
- statSync as statSync3
423
+ statSync as statSync4
291
424
  } from "fs";
292
425
  import { tmpdir } from "os";
293
- import { basename as basename3, dirname, extname, join as join6, resolve as resolve2 } from "path";
426
+ import { basename as basename3, dirname as dirname2, extname as extname2, join as join7, resolve as resolve2 } from "path";
427
+
428
+ // src/gate/mcpCapabilities.ts
429
+ import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
430
+ import { extname, join as join6 } from "path";
431
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
432
+ var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
433
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
434
+ var MAX_ITEMS_PER_CATEGORY = 200;
435
+ function* walkSourceFiles(dir) {
436
+ let entries;
437
+ try {
438
+ entries = readdirSync2(dir, { withFileTypes: true });
439
+ } catch {
440
+ return;
441
+ }
442
+ for (const entry of entries) {
443
+ const full = join6(dir, entry.name);
444
+ if (entry.isDirectory()) {
445
+ if (SKIP_DIRS2.has(entry.name)) continue;
446
+ yield* walkSourceFiles(full);
447
+ } else if (entry.isFile() && SOURCE_EXT.has(extname(entry.name))) {
448
+ yield full;
449
+ }
450
+ }
451
+ }
452
+ function readTextFileSafe(file) {
453
+ try {
454
+ if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
455
+ return readFileSync4(file, "utf-8");
456
+ } catch {
457
+ return void 0;
458
+ }
459
+ }
460
+ function readJsonSafe(file) {
461
+ try {
462
+ return JSON.parse(readFileSync4(file, "utf-8"));
463
+ } catch {
464
+ return void 0;
465
+ }
466
+ }
467
+ var MCP_SDK_DEP = "@modelcontextprotocol/sdk";
468
+ var MCP_KEYWORD = "mcp-server";
469
+ var MCP_BIN_PREFIX = /^mcp-/i;
470
+ function hasPackageJsonMcpMarkers(pkg2) {
471
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
472
+ const deps = pkg2[section];
473
+ if (deps && typeof deps === "object" && MCP_SDK_DEP in deps) return true;
474
+ }
475
+ const keywords = pkg2.keywords;
476
+ if (Array.isArray(keywords) && keywords.some((k) => k === MCP_KEYWORD)) return true;
477
+ const bin = pkg2.bin;
478
+ if (typeof bin === "string") {
479
+ if (MCP_BIN_PREFIX.test(bin.split("/").pop() ?? bin)) return true;
480
+ } else if (bin && typeof bin === "object") {
481
+ if (Object.keys(bin).some((k) => MCP_BIN_PREFIX.test(k))) return true;
482
+ }
483
+ if (pkg2.mcp && typeof pkg2.mcp === "object") return true;
484
+ return false;
485
+ }
486
+ function collectDeclaredFromManifest(node, buckets) {
487
+ if (Array.isArray(node)) {
488
+ for (const item of node) collectDeclaredFromManifest(item, buckets);
489
+ return;
490
+ }
491
+ if (!node || typeof node !== "object") return;
492
+ const obj = node;
493
+ for (const [key, value] of Object.entries(obj)) {
494
+ if ((key === "tools" || key === "resources" || key === "prompts") && Array.isArray(value)) {
495
+ const target = buckets[key];
496
+ for (const item of value) {
497
+ if (typeof item === "string") {
498
+ target.add(item);
499
+ } else if (item && typeof item === "object") {
500
+ const name = item.name ?? item.uri;
501
+ if (typeof name === "string") target.add(name);
502
+ }
503
+ }
504
+ } else {
505
+ collectDeclaredFromManifest(value, buckets);
506
+ }
507
+ }
508
+ }
509
+ var REGISTRATION_CALL_RE = /\bserver\.(tool|registerTool|resource|prompt)\(\s*['"]([^'"]+)['"]/g;
510
+ var REGISTRATION_METHOD_CATEGORY = {
511
+ tool: "tools",
512
+ registerTool: "tools",
513
+ resource: "resources",
514
+ prompt: "prompts"
515
+ };
516
+ function captureRegistrations(text, buckets) {
517
+ REGISTRATION_CALL_RE.lastIndex = 0;
518
+ let m;
519
+ while ((m = REGISTRATION_CALL_RE.exec(text)) !== null) {
520
+ buckets[REGISTRATION_METHOD_CATEGORY[m[1]]].add(m[2]);
521
+ }
522
+ }
523
+ var LIST_HANDLER_SCHEMAS = {
524
+ ListToolsRequestSchema: "tools",
525
+ ListResourcesRequestSchema: "resources",
526
+ ListPromptsRequestSchema: "prompts"
527
+ };
528
+ var SET_REQUEST_HANDLER_RE = /setRequestHandler\(\s*(ListToolsRequestSchema|ListResourcesRequestSchema|ListPromptsRequestSchema)\s*,/g;
529
+ var NAME_OR_URI_FIELD_RE = /\b(?:name|uri)\s*:\s*['"]([^'"]+)['"]/g;
530
+ function extractBalancedCall(text, openParenIndex) {
531
+ let depth = 0;
532
+ for (let i = openParenIndex; i < text.length; i++) {
533
+ const ch = text[i];
534
+ if (ch === "(") depth++;
535
+ else if (ch === ")") {
536
+ depth--;
537
+ if (depth === 0) return text.slice(openParenIndex, i + 1);
538
+ }
539
+ }
540
+ return text.slice(openParenIndex, Math.min(text.length, openParenIndex + 2e4));
541
+ }
542
+ function extractFromListHandlers(text, buckets) {
543
+ SET_REQUEST_HANDLER_RE.lastIndex = 0;
544
+ let m;
545
+ while ((m = SET_REQUEST_HANDLER_RE.exec(text)) !== null) {
546
+ const openParenIdx = text.indexOf("(", m.index);
547
+ if (openParenIdx === -1) continue;
548
+ const body = extractBalancedCall(text, openParenIdx);
549
+ const target = buckets[LIST_HANDLER_SCHEMAS[m[1]]];
550
+ NAME_OR_URI_FIELD_RE.lastIndex = 0;
551
+ let fieldMatch;
552
+ while ((fieldMatch = NAME_OR_URI_FIELD_RE.exec(body)) !== null) {
553
+ target.add(fieldMatch[1]);
554
+ }
555
+ }
556
+ }
557
+ var STDIO_TRANSPORT_RE = /StdioServerTransport/;
558
+ var HTTP_TRANSPORT_RE = /StreamableHTTPServerTransport|SSEServerTransport|WebSocketServerTransport/;
559
+ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
560
+ function extractMcpCapabilities(packageDir) {
561
+ const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
562
+ let hasPackageJsonMarker = false;
563
+ const pkgPath = join6(packageDir, "package.json");
564
+ if (existsSync7(pkgPath)) {
565
+ const pkg2 = readJsonSafe(pkgPath);
566
+ if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
567
+ hasPackageJsonMarker = hasPackageJsonMcpMarkers(pkg2);
568
+ }
569
+ }
570
+ for (const manifestName of [".mcp.json", "mcp.json"]) {
571
+ const manifestPath = join6(packageDir, manifestName);
572
+ if (!existsSync7(manifestPath)) continue;
573
+ const parsed = readJsonSafe(manifestPath);
574
+ if (parsed !== void 0) {
575
+ collectDeclaredFromManifest(parsed, buckets);
576
+ }
577
+ }
578
+ let sawStdioTransport = false;
579
+ let sawHttpTransport = false;
580
+ let sawMcpSdkImport = false;
581
+ for (const file of walkSourceFiles(packageDir)) {
582
+ const text = readTextFileSafe(file);
583
+ if (text === void 0) continue;
584
+ captureRegistrations(text, buckets);
585
+ extractFromListHandlers(text, buckets);
586
+ if (STDIO_TRANSPORT_RE.test(text)) sawStdioTransport = true;
587
+ if (HTTP_TRANSPORT_RE.test(text)) sawHttpTransport = true;
588
+ if (MCP_SDK_IMPORT_RE.test(text)) sawMcpSdkImport = true;
589
+ }
590
+ const transport = sawStdioTransport ? "stdio" : sawHttpTransport ? "http" : sawMcpSdkImport ? "unknown" : void 0;
591
+ const sortCap = (set) => Array.from(set).sort().slice(0, MAX_ITEMS_PER_CATEGORY);
592
+ const tools = sortCap(buckets.tools);
593
+ const resources = sortCap(buckets.resources);
594
+ const prompts = sortCap(buckets.prompts);
595
+ const hasExplicitSignal = tools.length > 0 || resources.length > 0 || prompts.length > 0;
596
+ const declaredConfidence = hasExplicitSignal ? "high" : hasPackageJsonMarker ? "partial" : "none";
597
+ return {
598
+ tools,
599
+ resources,
600
+ prompts,
601
+ transport,
602
+ declaredConfidence
603
+ };
604
+ }
605
+
606
+ // src/gate/profile.ts
294
607
  var SPDX_HINTS = {
295
608
  mit: "MIT (permissive)",
296
609
  apache: "Apache-2.0 (permissive)",
@@ -332,10 +645,10 @@ var PYTHON_STDLIB = /* @__PURE__ */ new Set([
332
645
  ]);
333
646
  function profileSkill(inputPath) {
334
647
  const p = resolve2(expandHome(inputPath));
335
- if (!existsSync6(p)) {
648
+ if (!existsSync8(p)) {
336
649
  throw new Error(`path does not exist: ${p}`);
337
650
  }
338
- const stat = statSync3(p);
651
+ const stat = statSync4(p);
339
652
  const kind = stat.isDirectory() ? classifyInput(p) : "skill";
340
653
  if (kind === "plugin") {
341
654
  return profilePlugin(p);
@@ -346,31 +659,64 @@ function profileSkill(inputPath) {
346
659
  return profileSkillDir(p);
347
660
  }
348
661
  function classifyInput(p) {
349
- if (existsSync6(join6(p, ".claude-plugin", "plugin.json"))) return "plugin";
350
- if (existsSync6(join6(p, ".mcp.json")) || existsSync6(join6(p, "mcp.json"))) return "mcp";
662
+ if (existsSync8(join7(p, ".claude-plugin", "plugin.json"))) return "plugin";
663
+ if (detectArtifactType(p) === "mcp") return "mcp";
664
+ return "skill";
665
+ }
666
+ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
667
+ var MCP_KEYWORD2 = "mcp-server";
668
+ var MCP_BIN_PREFIX2 = /^mcp-/i;
669
+ function readPackageJsonSafe(root) {
670
+ const pkgPath = join7(root, "package.json");
671
+ if (!existsSync8(pkgPath)) return void 0;
672
+ try {
673
+ return JSON.parse(readFileSync5(pkgPath, "utf-8"));
674
+ } catch {
675
+ return void 0;
676
+ }
677
+ }
678
+ function hasMcpPackageMarkers(pkg2) {
679
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
680
+ const deps = pkg2[section];
681
+ if (deps && typeof deps === "object" && MCP_SDK_DEP2 in deps) return true;
682
+ }
683
+ const keywords = pkg2.keywords;
684
+ if (Array.isArray(keywords) && keywords.some((k) => k === MCP_KEYWORD2)) return true;
685
+ const bin = pkg2.bin;
686
+ if (typeof bin === "string") {
687
+ if (MCP_BIN_PREFIX2.test(basename3(bin))) return true;
688
+ } else if (bin && typeof bin === "object") {
689
+ if (Object.keys(bin).some((k) => MCP_BIN_PREFIX2.test(k))) return true;
690
+ }
691
+ return false;
692
+ }
693
+ function detectArtifactType(root) {
694
+ if (existsSync8(join7(root, ".mcp.json")) || existsSync8(join7(root, "mcp.json"))) return "mcp";
695
+ const pkg2 = readPackageJsonSafe(root);
696
+ if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
351
697
  return "skill";
352
698
  }
353
699
  function resolveSkillMd(inputPath) {
354
700
  let currentPath = inputPath;
355
701
  let tmpDir;
356
- const initialStat = statSync3(currentPath);
357
- if (extname(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
358
- tmpDir = mkdtempSync(join6(tmpdir(), "forge_"));
702
+ const initialStat = statSync4(currentPath);
703
+ if (extname2(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
704
+ tmpDir = mkdtempSync(join7(tmpdir(), "forge_"));
359
705
  extractZip(currentPath, tmpDir);
360
706
  currentPath = tmpDir;
361
707
  }
362
- const stat = statSync3(currentPath);
708
+ const stat = statSync4(currentPath);
363
709
  if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
364
- return { skillMd: currentPath, root: dirname(currentPath), tmpDir };
710
+ return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
365
711
  }
366
712
  if (stat.isDirectory()) {
367
- const direct = join6(currentPath, "SKILL.md");
368
- if (existsSync6(direct)) {
713
+ const direct = join7(currentPath, "SKILL.md");
714
+ if (existsSync8(direct)) {
369
715
  return { skillMd: direct, root: currentPath, tmpDir };
370
716
  }
371
717
  const matches = findSkillMdOneLevelDown(currentPath);
372
718
  if (matches.length === 1) {
373
- return { skillMd: matches[0], root: dirname(matches[0]), tmpDir };
719
+ return { skillMd: matches[0], root: dirname2(matches[0]), tmpDir };
374
720
  }
375
721
  if (matches.length > 1) {
376
722
  cleanupTmp(tmpDir);
@@ -388,18 +734,18 @@ function cleanupTmp(tmpDir) {
388
734
  }
389
735
  function findSkillMdOneLevelDown(dir) {
390
736
  const matches = /* @__PURE__ */ new Set();
391
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
737
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
392
738
  if (entry.isDirectory()) {
393
- const candidate = join6(dir, entry.name, "SKILL.md");
394
- if (existsSync6(candidate)) matches.add(candidate);
739
+ const candidate = join7(dir, entry.name, "SKILL.md");
740
+ if (existsSync8(candidate)) matches.add(candidate);
395
741
  }
396
742
  }
397
- const skillsDir = join6(dir, "skills");
743
+ const skillsDir = join7(dir, "skills");
398
744
  if (isDir(skillsDir)) {
399
- for (const entry of readdirSync2(skillsDir, { withFileTypes: true })) {
745
+ for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
400
746
  if (entry.isDirectory()) {
401
- const candidate = join6(skillsDir, entry.name, "SKILL.md");
402
- if (existsSync6(candidate)) matches.add(candidate);
747
+ const candidate = join7(skillsDir, entry.name, "SKILL.md");
748
+ if (existsSync8(candidate)) matches.add(candidate);
403
749
  }
404
750
  }
405
751
  }
@@ -482,9 +828,9 @@ function detectLicense(root, fm) {
482
828
  if (fm.license) return fm.license;
483
829
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
484
830
  for (const name of names) {
485
- const p = join6(root, name);
486
- if (existsSync6(p) && statSync3(p).isFile()) {
487
- const head = readFileSync2(p, "utf-8").slice(0, 2e3).toLowerCase();
831
+ const p = join7(root, name);
832
+ if (existsSync8(p) && statSync4(p).isFile()) {
833
+ const head = readFileSync5(p, "utf-8").slice(0, 2e3).toLowerCase();
488
834
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
489
835
  if (head.includes(hint)) return label;
490
836
  }
@@ -495,8 +841,8 @@ function detectLicense(root, fm) {
495
841
  }
496
842
  function walkFiles(dir) {
497
843
  const out = [];
498
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
499
- const full = join6(dir, entry.name);
844
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
845
+ const full = join7(dir, entry.name);
500
846
  if (entry.isDirectory()) {
501
847
  out.push(...walkFiles(full));
502
848
  } else if (entry.isFile()) {
@@ -508,7 +854,7 @@ function walkFiles(dir) {
508
854
  function countResources(root) {
509
855
  const resources = {};
510
856
  for (const d of RESOURCE_DIRS) {
511
- const dp = join6(root, d);
857
+ const dp = join7(root, d);
512
858
  if (isDir(dp)) {
513
859
  resources[d] = walkFiles(dp).length;
514
860
  }
@@ -519,11 +865,11 @@ function scanMcpAndImports(text, root) {
519
865
  const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
520
866
  const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
521
867
  const imports = /* @__PURE__ */ new Set();
522
- const scriptsDir = join6(root, "scripts");
868
+ const scriptsDir = join7(root, "scripts");
523
869
  if (isDir(scriptsDir)) {
524
870
  for (const f of walkFiles(scriptsDir)) {
525
871
  if (!f.endsWith(".py")) continue;
526
- const ft = readFileSync2(f, "utf-8");
872
+ const ft = readFileSync5(f, "utf-8");
527
873
  for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
528
874
  const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
529
875
  let m;
@@ -540,7 +886,7 @@ function scanMcpAndImports(text, root) {
540
886
  function profileSkillDir(inputPath) {
541
887
  const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
542
888
  try {
543
- const text = readFileSync2(skillMd, "utf-8");
889
+ const text = readFileSync5(skillMd, "utf-8");
544
890
  const fm = parseFrontmatter(text);
545
891
  const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
546
892
  const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
@@ -560,27 +906,28 @@ function profileSkillDir(inputPath) {
560
906
  topHeaders: headers.slice(0, 12),
561
907
  resources,
562
908
  mcpDependencies: mcpDeps,
563
- externalPythonDeps: externalDeps
909
+ externalPythonDeps: externalDeps,
910
+ artifactType: detectArtifactType(root)
564
911
  };
565
912
  } finally {
566
913
  cleanupTmp(tmpDir);
567
914
  }
568
915
  }
569
916
  function profilePlugin(p) {
570
- const manifestPath = join6(p, ".claude-plugin", "plugin.json");
917
+ const manifestPath = join7(p, ".claude-plugin", "plugin.json");
571
918
  let manifest;
572
919
  try {
573
- manifest = JSON.parse(readFileSync2(manifestPath, "utf-8"));
920
+ manifest = JSON.parse(readFileSync5(manifestPath, "utf-8"));
574
921
  } catch (err) {
575
922
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
576
923
  }
577
- const skillsDir = join6(p, "skills");
924
+ const skillsDir = join7(p, "skills");
578
925
  let skills = [];
579
926
  if (isDir(skillsDir)) {
580
- skills = readdirSync2(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync6(join6(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
927
+ skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join7(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
581
928
  }
582
- const commandsDir = join6(p, "commands");
583
- const commands = isDir(commandsDir) ? readdirSync2(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
929
+ const commandsDir = join7(p, "commands");
930
+ const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
584
931
  const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
585
932
  const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
586
933
  return {
@@ -592,32 +939,54 @@ function profilePlugin(p) {
592
939
  skills,
593
940
  skillCount: skills.length,
594
941
  commands,
595
- mcpServers
942
+ mcpServers,
943
+ artifactType: detectArtifactType(p)
596
944
  };
597
945
  }
598
946
  function profileMcp(p) {
599
- const cfgPath = existsSync6(join6(p, ".mcp.json")) ? join6(p, ".mcp.json") : join6(p, "mcp.json");
600
- let cfg;
947
+ const explicitConfig = existsSync8(join7(p, ".mcp.json")) ? join7(p, ".mcp.json") : existsSync8(join7(p, "mcp.json")) ? join7(p, "mcp.json") : void 0;
948
+ if (explicitConfig) {
949
+ let cfg;
950
+ try {
951
+ cfg = JSON.parse(readFileSync5(explicitConfig, "utf-8"));
952
+ } catch (err) {
953
+ throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
954
+ }
955
+ const servers = cfg.mcpServers ?? cfg.servers ?? {};
956
+ const names = typeof servers === "object" && servers !== null ? Object.keys(servers).sort() : [];
957
+ return {
958
+ kind: "mcp",
959
+ inputPath: p,
960
+ config: explicitConfig,
961
+ servers: names,
962
+ serverCount: names.length,
963
+ artifactType: "mcp",
964
+ capabilities: extractMcpCapabilities(p)
965
+ };
966
+ }
967
+ const pkgPath = join7(p, "package.json");
968
+ let pkg2;
601
969
  try {
602
- cfg = JSON.parse(readFileSync2(cfgPath, "utf-8"));
970
+ pkg2 = JSON.parse(readFileSync5(pkgPath, "utf-8"));
603
971
  } catch (err) {
604
- throw new Error(`could not parse MCP config ${cfgPath}: ${err.message}`);
972
+ throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
605
973
  }
606
- const servers = cfg.mcpServers ?? cfg.servers ?? {};
607
- const names = typeof servers === "object" && servers !== null ? Object.keys(servers).sort() : [];
974
+ const name = typeof pkg2.name === "string" && pkg2.name.length > 0 ? pkg2.name : basename3(p);
608
975
  return {
609
976
  kind: "mcp",
610
977
  inputPath: p,
611
- config: cfgPath,
612
- servers: names,
613
- serverCount: names.length
978
+ config: pkgPath,
979
+ servers: [name],
980
+ serverCount: 1,
981
+ artifactType: "mcp",
982
+ capabilities: extractMcpCapabilities(p)
614
983
  };
615
984
  }
616
985
 
617
986
  // src/gate/safety.ts
618
- import { readFileSync as readFileSync3, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
987
+ import { readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
619
988
  import { execFile } from "child_process";
620
- import { delimiter, extname as extname2, join as join7 } from "path";
989
+ import { delimiter, extname as extname3, join as join8 } from "path";
621
990
  import { promisify } from "util";
622
991
  var execFileAsync = promisify(execFile);
623
992
  var BLOCK_SEVERITIES_BY_STRICTNESS = {
@@ -687,7 +1056,7 @@ var DENY_RULES = [
687
1056
  message: "invokes sudo \u2014 review for unnecessary privilege escalation"
688
1057
  }
689
1058
  ];
690
- var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
1059
+ var SKIP_DIRS3 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
691
1060
  var BINARY_EXT = /* @__PURE__ */ new Set([
692
1061
  ".png",
693
1062
  ".jpg",
@@ -709,18 +1078,18 @@ var BINARY_EXT = /* @__PURE__ */ new Set([
709
1078
  ".bin",
710
1079
  ".exe"
711
1080
  ]);
712
- var MAX_FILE_BYTES = 2 * 1024 * 1024;
1081
+ var MAX_FILE_BYTES2 = 2 * 1024 * 1024;
713
1082
  function* walk(dir) {
714
1083
  let entries;
715
1084
  try {
716
- entries = readdirSync3(dir, { withFileTypes: true });
1085
+ entries = readdirSync4(dir, { withFileTypes: true });
717
1086
  } catch {
718
1087
  return;
719
1088
  }
720
1089
  for (const entry of entries) {
721
- const full = join7(dir, entry.name);
1090
+ const full = join8(dir, entry.name);
722
1091
  if (entry.isDirectory()) {
723
- if (SKIP_DIRS2.has(entry.name)) continue;
1092
+ if (SKIP_DIRS3.has(entry.name)) continue;
724
1093
  yield* walk(full);
725
1094
  } else if (entry.isFile()) {
726
1095
  yield full;
@@ -728,10 +1097,10 @@ function* walk(dir) {
728
1097
  }
729
1098
  }
730
1099
  function readTextFile(file) {
731
- if (BINARY_EXT.has(extname2(file).toLowerCase())) return void 0;
1100
+ if (BINARY_EXT.has(extname3(file).toLowerCase())) return void 0;
732
1101
  try {
733
- if (statSync4(file).size > MAX_FILE_BYTES) return void 0;
734
- const content = readFileSync3(file, "utf-8");
1102
+ if (statSync5(file).size > MAX_FILE_BYTES2) return void 0;
1103
+ const content = readFileSync6(file, "utf-8");
735
1104
  if (content.slice(0, 512).includes("\0")) return void 0;
736
1105
  return content;
737
1106
  } catch {
@@ -742,7 +1111,7 @@ function scanStaticRules(targetPath) {
742
1111
  const findings = [];
743
1112
  let root;
744
1113
  try {
745
- root = statSync4(targetPath);
1114
+ root = statSync5(targetPath);
746
1115
  } catch {
747
1116
  return findings;
748
1117
  }
@@ -772,9 +1141,9 @@ function which(cmd) {
772
1141
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
773
1142
  for (const dir of dirs) {
774
1143
  for (const ext of exts) {
775
- const candidate = join7(dir, cmd + ext);
1144
+ const candidate = join8(dir, cmd + ext);
776
1145
  try {
777
- if (statSync4(candidate).isFile()) return candidate;
1146
+ if (statSync5(candidate).isFile()) return candidate;
778
1147
  } catch {
779
1148
  }
780
1149
  }
@@ -818,8 +1187,8 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
818
1187
  }
819
1188
 
820
1189
  // src/gate/overlap.ts
821
- import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync4 } from "fs";
822
- import { basename as basename4, dirname as dirname2, join as join8, resolve as resolve3 } from "path";
1190
+ import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "fs";
1191
+ import { basename as basename4, dirname as dirname3, join as join9, resolve as resolve3 } from "path";
823
1192
  var STOPWORDS = new Set(
824
1193
  `
825
1194
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -840,8 +1209,8 @@ function splitLimited(text, sep, maxSplit) {
840
1209
  return parts;
841
1210
  }
842
1211
  function readFrontmatter(skillMdPath) {
843
- const text = readFileSync4(skillMdPath, "utf-8");
844
- const meta = { name: basename4(dirname2(skillMdPath)), description: "" };
1212
+ const text = readFileSync7(skillMdPath, "utf-8");
1213
+ const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
845
1214
  if (text.startsWith("---\n")) {
846
1215
  const block = splitLimited(text, "---", 2)[1] ?? "";
847
1216
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -935,29 +1304,29 @@ function suggestVerbCode(topScore) {
935
1304
  return verbTier(topScore).verb;
936
1305
  }
937
1306
  function listSubdirs(dir) {
938
- if (!existsSync7(dir)) return [];
1307
+ if (!existsSync9(dir)) return [];
939
1308
  let entries;
940
1309
  try {
941
- entries = readdirSync4(dir, { withFileTypes: true });
1310
+ entries = readdirSync5(dir, { withFileTypes: true });
942
1311
  } catch {
943
1312
  return [];
944
1313
  }
945
- return entries.filter((e) => e.isDirectory()).map((e) => join8(dir, e.name));
1314
+ return entries.filter((e) => e.isDirectory()).map((e) => join9(dir, e.name));
946
1315
  }
947
1316
  function findSkillMdFiles(root) {
948
1317
  const found = /* @__PURE__ */ new Set();
949
1318
  for (const dir of listSubdirs(root)) {
950
- const candidate = join8(dir, "SKILL.md");
951
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1319
+ const candidate = join9(dir, "SKILL.md");
1320
+ if (existsSync9(candidate)) found.add(resolve3(candidate));
952
1321
  }
953
- for (const dir of listSubdirs(join8(root, "skills"))) {
954
- const candidate = join8(dir, "SKILL.md");
955
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1322
+ for (const dir of listSubdirs(join9(root, "skills"))) {
1323
+ const candidate = join9(dir, "SKILL.md");
1324
+ if (existsSync9(candidate)) found.add(resolve3(candidate));
956
1325
  }
957
1326
  for (const topDir of listSubdirs(root)) {
958
- for (const dir of listSubdirs(join8(topDir, "skills"))) {
959
- const candidate = join8(dir, "SKILL.md");
960
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1327
+ for (const dir of listSubdirs(join9(topDir, "skills"))) {
1328
+ const candidate = join9(dir, "SKILL.md");
1329
+ if (existsSync9(candidate)) found.add(resolve3(candidate));
961
1330
  }
962
1331
  }
963
1332
  return Array.from(found).filter((p) => !p.includes("/.git/")).sort();
@@ -969,9 +1338,9 @@ function scanOverlap(candidatePath, skillsRoot) {
969
1338
  }
970
1339
  let cpath = resolve3(expandHome(candidatePath));
971
1340
  if (isDir(cpath)) {
972
- cpath = join8(cpath, "SKILL.md");
1341
+ cpath = join9(cpath, "SKILL.md");
973
1342
  }
974
- if (!existsSync7(cpath)) {
1343
+ if (!existsSync9(cpath)) {
975
1344
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
976
1345
  }
977
1346
  const cand = readFrontmatter(cpath);
@@ -994,6 +1363,338 @@ function scanOverlap(candidatePath, skillsRoot) {
994
1363
  };
995
1364
  }
996
1365
 
1366
+ // src/gate/mcpSafety.ts
1367
+ import { readFileSync as readFileSync8, readdirSync as readdirSync6, statSync as statSync6 } from "fs";
1368
+ import { basename as basename5, extname as extname4, join as join10 } from "path";
1369
+ var SKIP_DIRS4 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
1370
+ var BINARY_EXT2 = /* @__PURE__ */ new Set([
1371
+ ".png",
1372
+ ".jpg",
1373
+ ".jpeg",
1374
+ ".gif",
1375
+ ".webp",
1376
+ ".ico",
1377
+ ".pdf",
1378
+ ".zip",
1379
+ ".tar",
1380
+ ".gz",
1381
+ ".woff",
1382
+ ".woff2",
1383
+ ".ttf",
1384
+ ".eot",
1385
+ ".mp4",
1386
+ ".mp3",
1387
+ ".wasm",
1388
+ ".bin",
1389
+ ".exe"
1390
+ ]);
1391
+ var MAX_FILE_BYTES3 = 2 * 1024 * 1024;
1392
+ function* walk2(dir) {
1393
+ let entries;
1394
+ try {
1395
+ entries = readdirSync6(dir, { withFileTypes: true });
1396
+ } catch {
1397
+ return;
1398
+ }
1399
+ for (const entry of entries) {
1400
+ const full = join10(dir, entry.name);
1401
+ if (entry.isDirectory()) {
1402
+ if (SKIP_DIRS4.has(entry.name)) continue;
1403
+ yield* walk2(full);
1404
+ } else if (entry.isFile()) {
1405
+ yield full;
1406
+ }
1407
+ }
1408
+ }
1409
+ function readTextFile2(file) {
1410
+ if (BINARY_EXT2.has(extname4(file).toLowerCase())) return void 0;
1411
+ try {
1412
+ if (statSync6(file).size > MAX_FILE_BYTES3) return void 0;
1413
+ const content = readFileSync8(file, "utf-8");
1414
+ if (content.slice(0, 512).includes("\0")) return void 0;
1415
+ return content;
1416
+ } catch {
1417
+ return void 0;
1418
+ }
1419
+ }
1420
+ function isConfigOrExampleFile(file) {
1421
+ const base = basename5(file).toLowerCase();
1422
+ if (base.startsWith(".env")) return true;
1423
+ if (/\.(json|ya?ml)$/.test(base)) return true;
1424
+ return /(example|sample|config)/.test(base);
1425
+ }
1426
+ var CREDENTIAL_KEY_RE = /(API[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i;
1427
+ function looksCredentialKey(name) {
1428
+ return CREDENTIAL_KEY_RE.test(name);
1429
+ }
1430
+ var PLACEHOLDER_VALUE_RE = /^(\$\{.*\}|\$[A-Z0-9_]+|your[_-]?.*|<.*>|xxx+|changeme|replace[_-]?me|example|placeholder|todo|fill[_-]?me|none|null|redacted|\.\.\.)$/i;
1431
+ function isPlaceholderValue(value) {
1432
+ const v = value.trim();
1433
+ if (v === "") return true;
1434
+ if (v.startsWith("process.env")) return true;
1435
+ return PLACEHOLDER_VALUE_RE.test(v);
1436
+ }
1437
+ function looksHighEntropy(value) {
1438
+ if (/\s/.test(value)) return false;
1439
+ if (value.length < 20) return false;
1440
+ const hasLower = /[a-z]/.test(value);
1441
+ const hasUpper = /[A-Z]/.test(value);
1442
+ const hasDigit = /[0-9]/.test(value);
1443
+ const varietyCount = [hasLower, hasUpper, hasDigit].filter(Boolean).length;
1444
+ const uniqueChars = new Set(value).size;
1445
+ return varietyCount >= 2 && uniqueChars / value.length > 0.4;
1446
+ }
1447
+ function isPinnedPackageSpec(spec) {
1448
+ const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
1449
+ return withoutScope.includes("@");
1450
+ }
1451
+ function collectServerNodes(node, out) {
1452
+ if (Array.isArray(node)) {
1453
+ for (const item of node) collectServerNodes(item, out);
1454
+ return;
1455
+ }
1456
+ if (node && typeof node === "object") {
1457
+ const obj = node;
1458
+ if (typeof obj.command === "string" || Array.isArray(obj.args) || obj.env && typeof obj.env === "object") {
1459
+ out.push(obj);
1460
+ }
1461
+ for (const value of Object.values(obj)) collectServerNodes(value, out);
1462
+ }
1463
+ }
1464
+ function tryParseJson(text) {
1465
+ try {
1466
+ return JSON.parse(text);
1467
+ } catch {
1468
+ return void 0;
1469
+ }
1470
+ }
1471
+ function scanJsonConfig(file, parsed) {
1472
+ const findings = [];
1473
+ const nodes = [];
1474
+ collectServerNodes(parsed, nodes);
1475
+ for (const node of nodes) {
1476
+ const command = typeof node.command === "string" ? node.command : void 0;
1477
+ const args = Array.isArray(node.args) ? node.args.filter((a) => typeof a === "string") : [];
1478
+ if (command === "npx") {
1479
+ const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1480
+ if (flagIdx !== -1) {
1481
+ const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
1482
+ if (pkgSpec && !isPinnedPackageSpec(pkgSpec)) {
1483
+ findings.push({
1484
+ severity: "MEDIUM",
1485
+ rule: "mcp-unpinned-npx",
1486
+ file,
1487
+ detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>) so the resolved server code can't change silently`
1488
+ });
1489
+ }
1490
+ }
1491
+ }
1492
+ for (const a of args) {
1493
+ if (/^--dangerously-/i.test(a) || a === "--no-sandbox") {
1494
+ findings.push({
1495
+ severity: "HIGH",
1496
+ rule: "mcp-dangerous-flag",
1497
+ file,
1498
+ detail: `launch arg ${a} disables a safety guard`
1499
+ });
1500
+ }
1501
+ if (a === "/" || a === "~") {
1502
+ findings.push({
1503
+ severity: "MEDIUM",
1504
+ rule: "mcp-filesystem-root-arg",
1505
+ file,
1506
+ detail: `launch arg "${a}" grants filesystem access at the root/home directory \u2014 scope this to a specific directory`
1507
+ });
1508
+ }
1509
+ }
1510
+ if (node.env && typeof node.env === "object" && !Array.isArray(node.env)) {
1511
+ for (const [key, value] of Object.entries(node.env)) {
1512
+ if (typeof value !== "string" || isPlaceholderValue(value)) continue;
1513
+ if (looksCredentialKey(key) || looksHighEntropy(value)) {
1514
+ findings.push({
1515
+ severity: "HIGH",
1516
+ rule: "mcp-inline-credential-value",
1517
+ file,
1518
+ detail: `env.${key} has an inline credential value \u2014 emit an empty string and document that the user must fill it in`
1519
+ });
1520
+ }
1521
+ }
1522
+ }
1523
+ }
1524
+ return findings;
1525
+ }
1526
+ var NPX_SHELL_RE = /\bnpx\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/g;
1527
+ var DANGEROUS_FLAG_RE = /--dangerously-[\w-]+|--no-sandbox\b/g;
1528
+ var CREDENTIAL_ASSIGNMENT_RE = /["']?([A-Za-z][A-Za-z0-9_]*)["']?\s*[:=]\s*["']([^"']{6,})["']/g;
1529
+ function scanTextFile(file, content, allowCredentialCheck) {
1530
+ const findings = [];
1531
+ const lines = content.split("\n");
1532
+ for (let i = 0; i < lines.length; i++) {
1533
+ const line = lines[i];
1534
+ const lineNo = i + 1;
1535
+ NPX_SHELL_RE.lastIndex = 0;
1536
+ let m;
1537
+ while ((m = NPX_SHELL_RE.exec(line)) !== null) {
1538
+ const pkgSpec = m[1];
1539
+ const pinned = m[2] !== void 0;
1540
+ if (!pinned) {
1541
+ findings.push({
1542
+ severity: "MEDIUM",
1543
+ rule: "mcp-unpinned-npx",
1544
+ file,
1545
+ detail: `line ${lineNo}: npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>)`
1546
+ });
1547
+ }
1548
+ }
1549
+ DANGEROUS_FLAG_RE.lastIndex = 0;
1550
+ while ((m = DANGEROUS_FLAG_RE.exec(line)) !== null) {
1551
+ findings.push({
1552
+ severity: "HIGH",
1553
+ rule: "mcp-dangerous-flag",
1554
+ file,
1555
+ detail: `line ${lineNo}: launch arg ${m[0]} disables a safety guard`
1556
+ });
1557
+ }
1558
+ if (allowCredentialCheck) {
1559
+ CREDENTIAL_ASSIGNMENT_RE.lastIndex = 0;
1560
+ while ((m = CREDENTIAL_ASSIGNMENT_RE.exec(line)) !== null) {
1561
+ const varName = m[1];
1562
+ const value = m[2];
1563
+ if (isPlaceholderValue(value)) continue;
1564
+ if (looksCredentialKey(varName) || looksHighEntropy(value)) {
1565
+ findings.push({
1566
+ severity: "HIGH",
1567
+ rule: "mcp-inline-credential-value",
1568
+ file,
1569
+ detail: `line ${lineNo}: inline credential value assigned to ${varName}`
1570
+ });
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+ return findings;
1576
+ }
1577
+ function scanFileForMcpFindings(file) {
1578
+ const content = readTextFile2(file);
1579
+ if (content === void 0) return [];
1580
+ if (extname4(file).toLowerCase() === ".json") {
1581
+ const parsed = tryParseJson(content);
1582
+ if (parsed !== void 0) return scanJsonConfig(file, parsed);
1583
+ }
1584
+ return scanTextFile(file, content, isConfigOrExampleFile(file));
1585
+ }
1586
+ function scanMcpFindings(targetPath) {
1587
+ let root;
1588
+ try {
1589
+ root = statSync6(targetPath);
1590
+ } catch {
1591
+ return [];
1592
+ }
1593
+ const files = root.isDirectory() ? Array.from(walk2(targetPath)) : [targetPath];
1594
+ const findings = [];
1595
+ for (const file of files) {
1596
+ findings.push(...scanFileForMcpFindings(file));
1597
+ }
1598
+ return findings;
1599
+ }
1600
+ var BLOCK_SEVERITIES_BY_STRICTNESS2 = {
1601
+ "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
1602
+ "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
1603
+ "warn-only": /* @__PURE__ */ new Set()
1604
+ };
1605
+ function verdictFor2(findings, strictness) {
1606
+ const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS2[strictness];
1607
+ if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
1608
+ if (findings.length > 0) return "warn";
1609
+ return "pass";
1610
+ }
1611
+ async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
1612
+ const base = await scanSafety(targetPath, strictness);
1613
+ const mcpFindings = scanMcpFindings(targetPath);
1614
+ const findings = [...base.findings, ...mcpFindings];
1615
+ return { findings, verdict: verdictFor2(findings, strictness) };
1616
+ }
1617
+
1618
+ // src/gate/mcpOverlap.ts
1619
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
1620
+ import { resolve as resolve4 } from "path";
1621
+ function readTargetServers(targetPath) {
1622
+ const path = resolve4(expandHome(targetPath));
1623
+ if (!existsSync10(path)) return [];
1624
+ let cfg;
1625
+ try {
1626
+ cfg = JSON.parse(readFileSync9(path, "utf-8"));
1627
+ } catch {
1628
+ return [];
1629
+ }
1630
+ const servers = cfg.mcpServers ?? cfg.servers ?? {};
1631
+ if (typeof servers !== "object" || servers === null) return [];
1632
+ return Object.entries(servers).map(([name, raw]) => {
1633
+ const v = raw ?? {};
1634
+ return {
1635
+ name,
1636
+ command: typeof v.command === "string" ? v.command : void 0,
1637
+ args: Array.isArray(v.args) ? v.args.filter((a) => typeof a === "string") : void 0
1638
+ };
1639
+ });
1640
+ }
1641
+ function tokenizeName(name) {
1642
+ return new Set(name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
1643
+ }
1644
+ function nameSimilarity(a, b) {
1645
+ if (a.toLowerCase() === b.toLowerCase()) return 1;
1646
+ const ta = tokenizeName(a);
1647
+ const tb = tokenizeName(b);
1648
+ if (ta.size === 0 || tb.size === 0) return 0;
1649
+ let inter = 0;
1650
+ for (const t of ta) if (tb.has(t)) inter += 1;
1651
+ const union = (/* @__PURE__ */ new Set([...ta, ...tb])).size;
1652
+ return union ? inter / union : 0;
1653
+ }
1654
+ function packageBaseName(spec) {
1655
+ const searchFrom = spec.startsWith("@") ? 1 : 0;
1656
+ const atIdx = spec.indexOf("@", searchFrom);
1657
+ return atIdx === -1 ? spec : spec.slice(0, atIdx);
1658
+ }
1659
+ function extractPackageSpec(command, args = []) {
1660
+ if (!command) return void 0;
1661
+ const base = command.split("/").pop() ?? command;
1662
+ if (["npx", "npm", "pnpm", "yarn", "uvx"].includes(base)) {
1663
+ return args.find((a) => !a.startsWith("-"));
1664
+ }
1665
+ return base;
1666
+ }
1667
+ function commandSimilarity(candidate, target) {
1668
+ const candSpec = candidate.packageName ?? extractPackageSpec(candidate.command, candidate.args ?? []);
1669
+ const targetSpec = extractPackageSpec(target.command, target.args ?? []);
1670
+ if (!candSpec || !targetSpec) return 0;
1671
+ return packageBaseName(candSpec).toLowerCase() === packageBaseName(targetSpec).toLowerCase() ? 1 : 0;
1672
+ }
1673
+ function round32(n) {
1674
+ return Math.round(n * 1e3) / 1e3;
1675
+ }
1676
+ function score2(candidate, target) {
1677
+ return round32(0.6 * nameSimilarity(candidate.name, target.name) + 0.4 * commandSimilarity(candidate, target));
1678
+ }
1679
+ function scanMcpOverlap(candidate, mcpTargetPaths) {
1680
+ const ranking = [];
1681
+ for (const targetPath of mcpTargetPaths) {
1682
+ const resolvedPath = resolve4(expandHome(targetPath));
1683
+ for (const target of readTargetServers(targetPath)) {
1684
+ ranking.push({ skill: target.name, path: resolvedPath, score: score2(candidate, target) });
1685
+ }
1686
+ }
1687
+ ranking.sort((a, b) => b.score - a.score);
1688
+ const top = ranking[0];
1689
+ return {
1690
+ candidate: candidate.name,
1691
+ nearest: top?.skill ?? "",
1692
+ nearestScore: top?.score ?? 0,
1693
+ suggestedVerb: suggestVerb(top?.score ?? 0),
1694
+ ranking
1695
+ };
1696
+ }
1697
+
997
1698
  // src/license.ts
998
1699
  import { verify as cryptoVerify } from "crypto";
999
1700
  var LICENSE_PREFIX = "skf1";
@@ -1109,11 +1810,30 @@ function renderFindingsLines(findings) {
1109
1810
  if (!findings?.length) return [];
1110
1811
  return [`${padLabel("Findings")}:`, ...findings.map(renderFindingLine)];
1111
1812
  }
1813
+ function profileHeaderFields(profile) {
1814
+ if (profile.kind === "mcp") {
1815
+ const name = profile.serverCount === 1 ? profile.servers[0] : `${profile.serverCount} servers`;
1816
+ return { name, version: "n/a", license: "n/a" };
1817
+ }
1818
+ return {
1819
+ name: profile.name ?? "unknown",
1820
+ version: profile.version ?? "unknown",
1821
+ license: profile.kind === "skill" ? profile.license : "UNKNOWN"
1822
+ };
1823
+ }
1824
+ function pluralize(count, noun) {
1825
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
1826
+ }
1827
+ function renderCapabilitiesLine(profile) {
1828
+ const { tools, resources, prompts, declaredConfidence } = profile.capabilities;
1829
+ const counts = [pluralize(tools.length, "tool"), pluralize(resources.length, "resource"), pluralize(prompts.length, "prompt")].join(
1830
+ ", "
1831
+ );
1832
+ return `${padLabel("Capabilities")}: ${counts} (declaredConfidence: ${declaredConfidence})`;
1833
+ }
1112
1834
  function renderReport(result) {
1113
1835
  const { profile, overlap, overlapLocked, safety, worthTaking, leaveBehind } = result;
1114
- const name = profile.name ?? "unknown";
1115
- const version = profile.version ?? "unknown";
1116
- const license = profile.license ?? "UNKNOWN";
1836
+ const { name, version, license } = profileHeaderFields(profile);
1117
1837
  const overlapLines = overlapLocked ? [`${padLabel("Overlap analysis")}: ${upgradeNotice("overlap analysis")}`] : overlap ? [
1118
1838
  `${padLabel("Nearest existing skill")}: ${overlap.nearest} (overlap ${overlap.nearestScore})`,
1119
1839
  `${padLabel("Recommended verb")}: ${overlap.suggestedVerb}`
@@ -1122,6 +1842,8 @@ function renderReport(result) {
1122
1842
  BORDER,
1123
1843
  `\u{1F528} Forge: ${name} (v${version}, ${license})`,
1124
1844
  BORDER,
1845
+ `${padLabel("Artifact type")}: ${profile.artifactType}`,
1846
+ ...profile.kind === "mcp" ? [renderCapabilitiesLine(profile)] : [],
1125
1847
  ...overlapLines,
1126
1848
  renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
1127
1849
  renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
@@ -1147,10 +1869,13 @@ function printGateOutput(gateResult, json) {
1147
1869
  }
1148
1870
  console.log(json ? renderJson(gateResult) : renderReport(gateResult));
1149
1871
  }
1150
- function asSkillProfile(candidatePath) {
1872
+ function asProfile(candidatePath, artifact) {
1151
1873
  const profile = profileSkill(candidatePath);
1152
- if (profile.kind !== "skill") {
1153
- throw new Error(`expected a skill candidate at ${candidatePath}, found a ${profile.kind}`);
1874
+ const expectedKind = artifact === "mcp" ? "mcp" : "skill";
1875
+ if (profile.kind !== expectedKind) {
1876
+ throw new Error(
1877
+ artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
1878
+ );
1154
1879
  }
1155
1880
  return profile;
1156
1881
  }
@@ -1162,14 +1887,42 @@ function tryOverlap(candidatePath, skillsRoot) {
1162
1887
  return void 0;
1163
1888
  }
1164
1889
  }
1890
+ function tryMcpOverlap(profile, mcpTargets) {
1891
+ try {
1892
+ const detail = resolveMcpCandidateDetail(profile);
1893
+ return scanMcpOverlap(
1894
+ {
1895
+ name: detail.name,
1896
+ command: detail.command,
1897
+ args: detail.args,
1898
+ packageName: detail.command ? void 0 : detail.name
1899
+ },
1900
+ mcpTargets
1901
+ );
1902
+ } catch (err) {
1903
+ console.error(`[skill-forge] mcp overlap scan skipped: ${err.message}`);
1904
+ return void 0;
1905
+ }
1906
+ }
1907
+ function resolveMcpCandidatePath(entry) {
1908
+ if (entry.packageDir) return entry.packageDir;
1909
+ let children = [];
1910
+ try {
1911
+ children = readdirSync7(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1912
+ } catch {
1913
+ }
1914
+ return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
1915
+ }
1165
1916
  async function runGate(candidatePath, skillsRoot, {
1166
1917
  proFeatureLabel = "overlap analysis",
1167
- strictness = DEFAULT_STRICTNESS
1918
+ strictness = DEFAULT_STRICTNESS,
1919
+ artifact = "skill",
1920
+ mcpTargets = []
1168
1921
  } = {}) {
1169
- const profile = asSkillProfile(candidatePath);
1170
- const safety = await scanSafety(candidatePath, strictness);
1922
+ const profile = asProfile(candidatePath, artifact);
1171
1923
  const proStatus = proFeatureStatus(proFeatureLabel);
1172
- const overlap = proStatus.enabled ? tryOverlap(candidatePath, skillsRoot) : void 0;
1924
+ const safety = artifact === "mcp" ? await mcpScan(candidatePath, strictness) : await scanSafety(candidatePath, strictness);
1925
+ const overlap = !proStatus.enabled ? void 0 : artifact === "mcp" ? tryMcpOverlap(profile, mcpTargets) : tryOverlap(candidatePath, skillsRoot);
1173
1926
  const gateResult = {
1174
1927
  profile,
1175
1928
  overlap,
@@ -1189,23 +1942,23 @@ async function runGate(candidatePath, skillsRoot, {
1189
1942
 
1190
1943
  // src/queue.ts
1191
1944
  import { randomUUID as randomUUID2 } from "crypto";
1192
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1193
- import { join as join9 } from "path";
1945
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "fs";
1946
+ import { join as join12 } from "path";
1194
1947
  function getQueuePath() {
1195
- return join9(getHomeDir(), "queue.json");
1948
+ return join12(getHomeDir(), "queue.json");
1196
1949
  }
1197
1950
  function readQueue() {
1198
1951
  const path = getQueuePath();
1199
- if (!existsSync8(path)) {
1952
+ if (!existsSync11(path)) {
1200
1953
  return { version: 1, entries: [] };
1201
1954
  }
1202
- const raw = readFileSync5(path, "utf-8");
1955
+ const raw = readFileSync10(path, "utf-8");
1203
1956
  const parsed = JSON.parse(raw);
1204
1957
  return { version: 1, entries: parsed.entries ?? [] };
1205
1958
  }
1206
1959
  function writeQueue(queue) {
1207
- mkdirSync4(getHomeDir(), { recursive: true });
1208
- writeFileSync3(getQueuePath(), JSON.stringify(queue, null, 2) + "\n", "utf-8");
1960
+ mkdirSync5(getHomeDir(), { recursive: true });
1961
+ writeFileSync4(getQueuePath(), JSON.stringify(queue, null, 2) + "\n", "utf-8");
1209
1962
  }
1210
1963
  function appendQueueEntry(entry) {
1211
1964
  const queue = readQueue();
@@ -1229,16 +1982,16 @@ function generateId(existingIds) {
1229
1982
 
1230
1983
  // src/handoff.ts
1231
1984
  import { spawnSync } from "child_process";
1232
- import { existsSync as existsSync9 } from "fs";
1233
- import { dirname as dirname3, join as join10 } from "path";
1985
+ import { existsSync as existsSync12 } from "fs";
1986
+ import { dirname as dirname4, join as join13 } from "path";
1234
1987
  import { fileURLToPath } from "url";
1235
1988
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
1236
1989
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
1237
1990
  function resolvePromptPath() {
1238
- const here = dirname3(fileURLToPath(import.meta.url));
1239
- const builtCandidate = join10(here, "ingest-prompt.md");
1240
- if (existsSync9(builtCandidate)) return builtCandidate;
1241
- return join10(here, "..", "assets", "ingest-prompt.md");
1991
+ const here = dirname4(fileURLToPath(import.meta.url));
1992
+ const builtCandidate = join13(here, "ingest-prompt.md");
1993
+ if (existsSync12(builtCandidate)) return builtCandidate;
1994
+ return join13(here, "..", "assets", "ingest-prompt.md");
1242
1995
  }
1243
1996
  function defaultIsOnPath(bin) {
1244
1997
  const finder = process.platform === "win32" ? "where" : "which";
@@ -1293,20 +2046,28 @@ function runHandoff(config, installedPath, options = {}) {
1293
2046
  }
1294
2047
 
1295
2048
  // src/commands/init.ts
1296
- import { existsSync as existsSync10 } from "fs";
2049
+ import { existsSync as existsSync13 } from "fs";
1297
2050
  import { homedir as homedir4 } from "os";
1298
2051
  import { createInterface } from "readline/promises";
1299
2052
 
1300
2053
  // src/agents.ts
2054
+ import { statSync as statSync7 } from "fs";
1301
2055
  import { homedir as homedir3 } from "os";
1302
- import { join as join11, resolve as resolve4 } from "path";
2056
+ import { join as join14, resolve as resolve5 } from "path";
2057
+ function isFile(p) {
2058
+ try {
2059
+ return statSync7(p).isFile();
2060
+ } catch {
2061
+ return false;
2062
+ }
2063
+ }
1303
2064
  function projectPath(root, cwd) {
1304
- return root ? resolve4(cwd, root) : void 0;
2065
+ return root ? resolve5(cwd, root) : void 0;
1305
2066
  }
1306
2067
  function globalPath(root, home) {
1307
2068
  if (!root) return void 0;
1308
2069
  if (root === "~") return home;
1309
- if (root.startsWith("~/")) return join11(home, root.slice(2));
2070
+ if (root.startsWith("~/")) return join14(home, root.slice(2));
1310
2071
  return root;
1311
2072
  }
1312
2073
  function makeAgent(spec) {
@@ -1317,6 +2078,7 @@ function makeAgent(spec) {
1317
2078
  globalRoot: spec.globalRoot,
1318
2079
  verified: spec.verified ?? true,
1319
2080
  defaultHandoffTemplate: spec.defaultHandoffTemplate,
2081
+ mcpConfig: spec.mcpConfig,
1320
2082
  detect: (cwd, home) => detectAgentRoot(agent, cwd, home) !== void 0
1321
2083
  };
1322
2084
  return agent;
@@ -1331,6 +2093,19 @@ function detectAgentRoot(agent, cwd = process.cwd(), home = homedir3()) {
1331
2093
  }
1332
2094
  return void 0;
1333
2095
  }
2096
+ function resolveMcpConfigPath(agent, scope, cwd = process.cwd(), home = homedir3()) {
2097
+ if (!agent.mcpConfig) return void 0;
2098
+ return scope === "project" ? projectPath(agent.mcpConfig.project, cwd) : globalPath(agent.mcpConfig.global, home);
2099
+ }
2100
+ function detectMcpConfigFiles(agent, cwd = process.cwd(), home = homedir3()) {
2101
+ if (!agent.mcpConfig) return [];
2102
+ const found = [];
2103
+ for (const scope of ["project", "global"]) {
2104
+ const path = resolveMcpConfigPath(agent, scope, cwd, home);
2105
+ if (path && isFile(path)) found.push({ scope, path });
2106
+ }
2107
+ return found;
2108
+ }
1334
2109
  var AGENTS = [
1335
2110
  { id: "aider-desk", displayName: "AiderDesk", projectRoot: ".aider-desk/skills", globalRoot: "~/.aider-desk/skills" },
1336
2111
  { id: "amp", displayName: "Amp", projectRoot: ".agents/skills", globalRoot: "~/.config/agents/skills" },
@@ -1354,7 +2129,12 @@ var AGENTS = [
1354
2129
  globalRoot: "~/.claude/skills",
1355
2130
  // `claude -p "<prompt>"` runs one non-interactive turn and exits. Verified 2026-07-11:
1356
2131
  // https://code.claude.com/docs/en/headless
1357
- defaultHandoffTemplate: ["claude", "-p", HANDOFF_INSTRUCTION]
2132
+ defaultHandoffTemplate: ["claude", "-p", HANDOFF_INSTRUCTION],
2133
+ // Project scope writes to `.mcp.json` at the project root (shared via version control).
2134
+ // User scope writes to a top-level `mcpServers` key in `~/.claude.json` (not exclusively
2135
+ // an MCP file — it's Claude Code's broader state file — but it's the correct read/write
2136
+ // target for user-scoped servers). Verified 2026-07-12: https://code.claude.com/docs/en/mcp
2137
+ mcpConfig: { project: ".mcp.json", global: "~/.claude.json", verified: true }
1358
2138
  },
1359
2139
  { id: "openclaw", displayName: "OpenClaw", projectRoot: "skills", globalRoot: "~/.openclaw/skills" },
1360
2140
  { id: "cline", displayName: "Cline", projectRoot: ".agents/skills", globalRoot: "~/.agents/skills" },
@@ -1379,7 +2159,12 @@ var AGENTS = [
1379
2159
  globalRoot: "~/.codex/skills",
1380
2160
  // `codex exec "<prompt>"` runs one non-interactive task and prints the final message.
1381
2161
  // Verified 2026-07-11: https://developers.openai.com/codex/noninteractive
1382
- defaultHandoffTemplate: ["codex", "exec", HANDOFF_INSTRUCTION]
2162
+ defaultHandoffTemplate: ["codex", "exec", HANDOFF_INSTRUCTION],
2163
+ // TOML, not JSON: `[mcp_servers.<name>]` tables. User config at `~/.codex/config.toml`;
2164
+ // project config at `.codex/config.toml` (only honored for trusted projects). Verified
2165
+ // 2026-07-12: https://learn.chatgpt.com/docs/extend/mcp?surface=cli — detect/report-only
2166
+ // per the v0.5 contract (promote only writes JSON targets).
2167
+ mcpConfig: { project: ".codex/config.toml", global: "~/.codex/config.toml", format: "toml", verified: true }
1383
2168
  },
1384
2169
  {
1385
2170
  id: "command-code",
@@ -1390,7 +2175,16 @@ var AGENTS = [
1390
2175
  { id: "continue", displayName: "Continue", projectRoot: ".continue/skills", globalRoot: "~/.continue/skills" },
1391
2176
  { id: "cortex", displayName: "Cortex Code", projectRoot: ".cortex/skills", globalRoot: "~/.snowflake/cortex/skills" },
1392
2177
  { id: "crush", displayName: "Crush", projectRoot: ".crush/skills", globalRoot: "~/.config/crush/skills" },
1393
- { id: "cursor", displayName: "Cursor", projectRoot: ".agents/skills", globalRoot: "~/.cursor/skills" },
2178
+ {
2179
+ id: "cursor",
2180
+ displayName: "Cursor",
2181
+ projectRoot: ".agents/skills",
2182
+ globalRoot: "~/.cursor/skills",
2183
+ // Both scopes use a top-level `mcpServers` key. Verified 2026-07-12:
2184
+ // https://cursor.com/docs/context/mcp ("Create `.cursor/mcp.json` in your project ...";
2185
+ // "Create `~/.cursor/mcp.json` in your home directory ...").
2186
+ mcpConfig: { project: ".cursor/mcp.json", global: "~/.cursor/mcp.json", verified: true }
2187
+ },
1394
2188
  {
1395
2189
  id: "deepagents",
1396
2190
  displayName: "Deep Agents",
@@ -1409,13 +2203,24 @@ var AGENTS = [
1409
2203
  globalRoot: "~/.gemini/skills",
1410
2204
  // `gemini -p "<prompt>"` executes a single turn headlessly and exits. Verified 2026-07-11:
1411
2205
  // https://google-gemini.github.io/gemini-cli/docs/cli/headless.html
1412
- defaultHandoffTemplate: ["gemini", "-p", HANDOFF_INSTRUCTION]
2206
+ defaultHandoffTemplate: ["gemini", "-p", HANDOFF_INSTRUCTION],
2207
+ // `mcpServers` key inside `settings.json` (shared with other CLI settings, not an
2208
+ // MCP-exclusive file, but the correct read/write target). User config at
2209
+ // `~/.gemini/settings.json`, project config at `.gemini/settings.json`. Verified
2210
+ // 2026-07-12: https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html
2211
+ mcpConfig: { project: ".gemini/settings.json", global: "~/.gemini/settings.json", verified: true }
1413
2212
  },
1414
2213
  {
1415
2214
  id: "github-copilot",
1416
2215
  displayName: "GitHub Copilot",
1417
2216
  projectRoot: ".agents/skills",
1418
- globalRoot: "~/.copilot/skills"
2217
+ globalRoot: "~/.copilot/skills",
2218
+ // Copilot CLI (matches the `~/.copilot` skills convention above, not the VS Code
2219
+ // extension — which uses a different `servers`-keyed `.vscode/mcp.json` and isn't covered
2220
+ // by this entry). User config `~/.copilot/mcp-config.json` (`mcpServers` key), repo-level
2221
+ // config `.github/mcp.json`. Verified 2026-07-12:
2222
+ // https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers
2223
+ mcpConfig: { project: ".github/mcp.json", global: "~/.copilot/mcp-config.json", verified: true }
1419
2224
  },
1420
2225
  { id: "goose", displayName: "Goose", projectRoot: ".goose/skills", globalRoot: "~/.config/goose/skills" },
1421
2226
  { id: "hermes-agent", displayName: "Hermes Agent", projectRoot: ".hermes/skills", globalRoot: "~/.hermes/skills" },
@@ -1464,7 +2269,15 @@ var AGENTS = [
1464
2269
  { id: "tinycloud", displayName: "Tinycloud", projectRoot: ".tinycloud/skills", globalRoot: "~/.tinycloud/skills" },
1465
2270
  { id: "trae", displayName: "Trae", projectRoot: ".trae/skills", globalRoot: "~/.trae/skills" },
1466
2271
  { id: "trae-cn", displayName: "Trae CN", projectRoot: ".trae/skills", globalRoot: "~/.trae-cn/skills" },
1467
- { id: "windsurf", displayName: "Windsurf", projectRoot: ".windsurf/skills", globalRoot: "~/.codeium/windsurf/skills" },
2272
+ {
2273
+ id: "windsurf",
2274
+ displayName: "Windsurf",
2275
+ projectRoot: ".windsurf/skills",
2276
+ globalRoot: "~/.codeium/windsurf/skills",
2277
+ // `mcpServers` key; global only — no project-scoped MCP config is documented. Verified
2278
+ // 2026-07-12: https://docs.windsurf.com/windsurf/cascade/mcp
2279
+ mcpConfig: { global: "~/.codeium/windsurf/mcp_config.json", verified: true }
2280
+ },
1468
2281
  { id: "zcode", displayName: "ZCode", projectRoot: ".zcode/skills", globalRoot: "~/.zcode/skills" },
1469
2282
  { id: "zencoder", displayName: "Zencoder", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
1470
2283
  { id: "zenflow", displayName: "Zenflow", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
@@ -1483,6 +2296,15 @@ function detectAgents(cwd, home) {
1483
2296
  }
1484
2297
  return found;
1485
2298
  }
2299
+ function detectMcpTargets(cwd, home) {
2300
+ const found = [];
2301
+ for (const agent of AGENTS) {
2302
+ for (const hit of detectMcpConfigFiles(agent, cwd, home)) {
2303
+ found.push({ agent, ...hit });
2304
+ }
2305
+ }
2306
+ return found;
2307
+ }
1486
2308
  function printDetectionResults(detected) {
1487
2309
  console.log(`[skill-forge] init \u2014 agent detection (${AGENTS.length} known agents)`);
1488
2310
  if (detected.length === 0) {
@@ -1493,17 +2315,27 @@ function printDetectionResults(detected) {
1493
2315
  console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.root}`);
1494
2316
  }
1495
2317
  }
2318
+ function printMcpDetectionResults(detectedMcp) {
2319
+ if (detectedMcp.length === 0) return;
2320
+ console.log("\n[skill-forge] init \u2014 MCP config detection");
2321
+ for (const d of detectedMcp) {
2322
+ console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.path}`);
2323
+ }
2324
+ }
1496
2325
  function handoffTemplateFor(agent) {
1497
2326
  return agent.defaultHandoffTemplate ?? [agent.id, "-p", HANDOFF_INSTRUCTION];
1498
2327
  }
1499
- function buildConfig(selected, defaultTargetEntry, handoffAgent) {
2328
+ function buildConfig(selected, defaultTargetEntry, handoffAgent, selectedMcp = []) {
1500
2329
  const existing = loadConfig();
1501
2330
  return {
1502
2331
  ...existing,
1503
- skillsRoots: selected.map((s) => s.root),
1504
- agents: selected.map((s) => ({ id: s.agent.id, skillsRoot: s.root })),
2332
+ // Skill-root selections are only overwritten when this run actually offered a choice —
2333
+ // an mcp-only run (no skill roots detected) must not blank out an existing skillsRoots/agents.
2334
+ skillsRoots: selected.length > 0 ? selected.map((s) => s.root) : existing.skillsRoots,
2335
+ agents: selected.length > 0 ? selected.map((s) => ({ id: s.agent.id, skillsRoot: s.root })) : existing.agents,
1505
2336
  defaultTarget: defaultTargetEntry?.root ?? existing.defaultTarget,
1506
- handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand
2337
+ handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand,
2338
+ mcpTargets: selectedMcp.length > 0 ? selectedMcp.map((m) => m.path) : existing.mcpTargets
1507
2339
  };
1508
2340
  }
1509
2341
  function printSummary(config) {
@@ -1512,6 +2344,7 @@ function printSummary(config) {
1512
2344
  console.log(` gate targets: ${config.skillsRoots.join(", ")}`);
1513
2345
  if (config.defaultTarget) console.log(` default target: ${config.defaultTarget}`);
1514
2346
  if (config.handoffCommand?.length) console.log(` handoff command: ${config.handoffCommand.join(" ")}`);
2347
+ if (config.mcpTargets?.length) console.log(` mcp targets: ${config.mcpTargets.join(", ")}`);
1515
2348
  }
1516
2349
  function parseChoice(answer, max) {
1517
2350
  const n = Number(answer);
@@ -1537,6 +2370,26 @@ async function promptMultiSelect(rl, detected) {
1537
2370
  }
1538
2371
  return detected.filter((_, i) => chosen.has(i));
1539
2372
  }
2373
+ async function promptMcpMultiSelect(rl, detectedMcp) {
2374
+ const chosen = new Set(detectedMcp.map((_, i) => i));
2375
+ for (; ; ) {
2376
+ console.log("\nMCP targets (all detected config files pre-selected \u2014 type a number to toggle, Enter to confirm):");
2377
+ detectedMcp.forEach((d, i) => {
2378
+ const mark = chosen.has(i) ? "[x]" : "[ ]";
2379
+ console.log(` ${mark} ${i + 1}. ${d.agent.displayName} (${d.scope}) \u2014 ${d.path}`);
2380
+ });
2381
+ const answer = (await rl.question("> ")).trim();
2382
+ if (answer === "") break;
2383
+ const idx = parseChoice(answer, detectedMcp.length);
2384
+ if (idx !== void 0) {
2385
+ if (chosen.has(idx)) chosen.delete(idx);
2386
+ else chosen.add(idx);
2387
+ } else {
2388
+ console.log(" (invalid selection \u2014 enter a listed number, or press Enter to confirm)");
2389
+ }
2390
+ }
2391
+ return detectedMcp.filter((_, i) => chosen.has(i));
2392
+ }
1540
2393
  async function promptDefaultTarget(rl, selected) {
1541
2394
  console.log("\nDefault promotion target:");
1542
2395
  selected.forEach((d, i) => console.log(` ${i + 1}. ${d.agent.displayName} \u2014 ${d.root}`));
@@ -1559,17 +2412,19 @@ async function init(options = {}) {
1559
2412
  const cwd = options.cwd ?? process.cwd();
1560
2413
  const home = options.home ?? homedir4();
1561
2414
  const detected = detectAgents(cwd, home);
2415
+ const detectedMcp = detectMcpTargets(cwd, home);
1562
2416
  if (options.list) {
1563
2417
  printDetectionResults(detected);
2418
+ printMcpDetectionResults(detectedMcp);
1564
2419
  return;
1565
2420
  }
1566
- if (detected.length === 0) {
1567
- console.log("[skill-forge] No known agent skill directories were found.");
2421
+ if (detected.length === 0 && detectedMcp.length === 0) {
2422
+ console.log("[skill-forge] No known agent skill directories or MCP config files were found.");
1568
2423
  console.log(" Install a coding agent (Claude Code, Codex, Cursor, ...) or create a skills root, then re-run `skill-forge init`.");
1569
2424
  return;
1570
2425
  }
1571
2426
  if (options.defaults) {
1572
- const config = buildConfig(detected, detected[0], void 0);
2427
+ const config = buildConfig(detected, detected[0], void 0, detectedMcp);
1573
2428
  saveConfig(config);
1574
2429
  printSummary(config);
1575
2430
  return;
@@ -1579,16 +2434,18 @@ async function init(options = {}) {
1579
2434
  return;
1580
2435
  }
1581
2436
  printDetectionResults(detected);
2437
+ printMcpDetectionResults(detectedMcp);
1582
2438
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1583
2439
  try {
1584
- const selected = await promptMultiSelect(rl, detected);
1585
- if (selected.length === 0) {
2440
+ const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
2441
+ const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
2442
+ if (selected.length === 0 && selectedMcp.length === 0) {
1586
2443
  console.log("[skill-forge] No targets selected \u2014 aborting, nothing written.");
1587
2444
  return;
1588
2445
  }
1589
- const target = await promptDefaultTarget(rl, selected);
1590
- const handoffAgent = await promptHandoffAgent(rl, detected);
1591
- const config = buildConfig(selected, target, handoffAgent);
2446
+ const target = selected.length > 0 ? await promptDefaultTarget(rl, selected) : void 0;
2447
+ const handoffAgent = detected.length > 0 ? await promptHandoffAgent(rl, detected) : void 0;
2448
+ const config = buildConfig(selected, target, handoffAgent, selectedMcp);
1592
2449
  saveConfig(config);
1593
2450
  printSummary(config);
1594
2451
  } finally {
@@ -1597,7 +2454,7 @@ async function init(options = {}) {
1597
2454
  }
1598
2455
  async function maybeOfferInit(options = {}) {
1599
2456
  if (options.json || options.yes || !process.stdin.isTTY) return;
1600
- if (existsSync10(getConfigPath())) return;
2457
+ if (existsSync13(getConfigPath())) return;
1601
2458
  console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
1602
2459
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1603
2460
  let answer = "";
@@ -1613,6 +2470,7 @@ async function maybeOfferInit(options = {}) {
1613
2470
 
1614
2471
  // src/commands/add.ts
1615
2472
  var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
2473
+ var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
1616
2474
  function logOutcome(message, json) {
1617
2475
  if (json) console.error(message);
1618
2476
  else console.log(message);
@@ -1620,11 +2478,16 @@ function logOutcome(message, json) {
1620
2478
  async function add(source, options = {}) {
1621
2479
  await maybeOfferInit({ json: options.json, yes: options.yes });
1622
2480
  const config = loadConfig();
2481
+ const artifact = options.artifact ?? "skill";
2482
+ if (artifact === "mcp") {
2483
+ await addMcp(source, config, options);
2484
+ return;
2485
+ }
1623
2486
  const targetRoot = options.target ?? config.defaultTarget ?? config.skillsRoots[0];
1624
2487
  if (!targetRoot) {
1625
2488
  throw new Error("no target skills root configured; pass --target <dir> or set skillsRoots in the config");
1626
2489
  }
1627
- const resolved = resolve(source);
2490
+ const resolved = resolve(source, artifact);
1628
2491
  const entry = await quarantine(resolved, config.quarantineDir);
1629
2492
  if (entry.skillDirs.length === 0) {
1630
2493
  console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
@@ -1640,9 +2503,13 @@ async function add(source, options = {}) {
1640
2503
  }
1641
2504
  const { safety, gateResult, proEnabled } = await runGate(entry.skillDirs[0], targetRoot, {
1642
2505
  proFeatureLabel: PRO_FEATURE_LABEL,
1643
- strictness: config.strictness
2506
+ strictness: config.strictness,
2507
+ artifact
1644
2508
  });
1645
2509
  const { profile, overlap } = gateResult;
2510
+ if (profile.kind !== "skill") {
2511
+ throw new Error(`expected a skill candidate, found a ${profile.kind}`);
2512
+ }
1646
2513
  printGateOutput(gateResult, options.json);
1647
2514
  const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
1648
2515
  let installedPath = null;
@@ -1695,6 +2562,79 @@ async function add(source, options = {}) {
1695
2562
  }
1696
2563
  }
1697
2564
  }
2565
+ async function addMcp(source, config, options) {
2566
+ const resolved = resolve(source, "mcp");
2567
+ const entry = await quarantine(resolved, config.quarantineDir);
2568
+ const candidatePath = resolveMcpCandidatePath(entry);
2569
+ const { safety, gateResult, proEnabled } = await runGate(candidatePath, "", {
2570
+ proFeatureLabel: MCP_PRO_FEATURE_LABEL,
2571
+ strictness: config.strictness,
2572
+ artifact: "mcp",
2573
+ mcpTargets: config.mcpTargets ?? []
2574
+ });
2575
+ const { profile, overlap } = gateResult;
2576
+ if (profile.kind !== "mcp") {
2577
+ throw new Error(`expected an MCP candidate, found a ${profile.kind}`);
2578
+ }
2579
+ if (profile.serverCount > 1) {
2580
+ console.error(
2581
+ `[skill-forge] ${profile.serverCount} MCP servers found in ${source}; gating/promoting the first \u2014 the whole quarantine entry is promoted/rejected together:
2582
+ ${profile.servers.join(", ")}`
2583
+ );
2584
+ }
2585
+ printGateOutput(gateResult, options.json);
2586
+ const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
2587
+ let installedPath = null;
2588
+ if (decision === "promote") {
2589
+ const detail = resolveMcpCandidateDetail(profile);
2590
+ const serverProfile = {
2591
+ name: detail.name,
2592
+ version: detail.version ?? "unknown",
2593
+ envVars: detail.env ? Object.keys(detail.env) : void 0,
2594
+ candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2595
+ };
2596
+ const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join15(process.cwd(), ".mcp.json");
2597
+ const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
2598
+ reject(entry);
2599
+ logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
2600
+ if (result.backupPath) {
2601
+ logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
2602
+ }
2603
+ if (result.envVarsToFill.length > 0) {
2604
+ logOutcome(`[skill-forge] fill in these env vars before use: ${result.envVarsToFill.join(", ")}`, options.json);
2605
+ }
2606
+ installedPath = result.target;
2607
+ } else if (decision === "reject") {
2608
+ reject(entry);
2609
+ logOutcome("[skill-forge] rejected \u2014 quarantine sandbox removed", options.json);
2610
+ if (safety.verdict === "block") process.exitCode = 1;
2611
+ return;
2612
+ } else {
2613
+ hold(entry);
2614
+ logOutcome(`[skill-forge] held in quarantine -> ${entry.quarantinePath}`, options.json);
2615
+ }
2616
+ if (proEnabled) {
2617
+ appendQueueEntry({
2618
+ source,
2619
+ sourceType: resolved.type,
2620
+ installedPath,
2621
+ quarantinePath: entry.quarantinePath,
2622
+ gate: {
2623
+ license: null,
2624
+ safetyVerdict: safety.verdict,
2625
+ safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
2626
+ overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
2627
+ suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
2628
+ },
2629
+ status: "pending",
2630
+ artifactType: "mcp",
2631
+ capabilities: profile.capabilities
2632
+ });
2633
+ if (options.ingest && installedPath) {
2634
+ runHandoff(config, installedPath);
2635
+ }
2636
+ }
2637
+ }
1698
2638
  function yesDecision(verdict) {
1699
2639
  return verdict === "block" ? "reject" : "promote";
1700
2640
  }
@@ -1722,25 +2662,41 @@ ${prompt}`);
1722
2662
  async function scan(source, options = {}) {
1723
2663
  await maybeOfferInit({ json: options.json });
1724
2664
  const config = loadConfig();
2665
+ const artifact = options.artifact ?? "skill";
1725
2666
  const skillsRoot = config.defaultTarget ?? config.skillsRoots[0];
1726
- if (!skillsRoot) {
2667
+ if (artifact === "skill" && !skillsRoot) {
1727
2668
  throw new Error("no skills root configured; set skillsRoots in the config");
1728
2669
  }
1729
- const resolved = resolve(source);
2670
+ const resolved = resolve(source, artifact);
1730
2671
  const entry = await quarantine(resolved, config.quarantineDir);
1731
2672
  try {
1732
- if (entry.skillDirs.length === 0) {
1733
- console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
1734
- process.exitCode = 1;
1735
- return;
2673
+ let candidatePath;
2674
+ if (artifact === "mcp") {
2675
+ candidatePath = resolveMcpCandidatePath(entry);
2676
+ } else {
2677
+ if (entry.skillDirs.length === 0) {
2678
+ console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
2679
+ process.exitCode = 1;
2680
+ return;
2681
+ }
2682
+ if (entry.skillDirs.length > 1) {
2683
+ console.error(
2684
+ `[skill-forge] ${entry.skillDirs.length} skills found in ${source}; scanning the first:
2685
+ ` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
2686
+ );
2687
+ }
2688
+ candidatePath = entry.skillDirs[0];
1736
2689
  }
1737
- if (entry.skillDirs.length > 1) {
2690
+ const { safety, gateResult } = await runGate(candidatePath, skillsRoot ?? "", {
2691
+ strictness: config.strictness,
2692
+ artifact,
2693
+ mcpTargets: config.mcpTargets ?? []
2694
+ });
2695
+ if (artifact === "mcp" && gateResult.profile.kind === "mcp" && gateResult.profile.serverCount > 1) {
1738
2696
  console.error(
1739
- `[skill-forge] ${entry.skillDirs.length} skills found in ${source}; scanning the first:
1740
- ` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
2697
+ `[skill-forge] ${gateResult.profile.serverCount} MCP servers found in ${source}; scanning the first \u2014 ` + gateResult.profile.servers.join(", ")
1741
2698
  );
1742
2699
  }
1743
- const { safety, gateResult } = await runGate(entry.skillDirs[0], skillsRoot, { strictness: config.strictness });
1744
2700
  printGateOutput(gateResult, options.json);
1745
2701
  if (safety.verdict === "block") {
1746
2702
  process.exitCode = 1;
@@ -1751,7 +2707,7 @@ async function scan(source, options = {}) {
1751
2707
  }
1752
2708
 
1753
2709
  // src/commands/list.ts
1754
- import { join as join12 } from "path";
2710
+ import { join as join16 } from "path";
1755
2711
  async function list() {
1756
2712
  await maybeOfferInit();
1757
2713
  const config = loadConfig();
@@ -1764,14 +2720,14 @@ async function list() {
1764
2720
  }
1765
2721
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
1766
2722
  for (const id of ids) {
1767
- const skillDirs = findSkillDirs(join12(quarantineDir, id));
2723
+ const skillDirs = findSkillDirs(join16(quarantineDir, id));
1768
2724
  if (skillDirs.length === 0) {
1769
2725
  console.log(` ${id} (no SKILL.md found)`);
1770
2726
  continue;
1771
2727
  }
1772
2728
  const names = skillDirs.map((dir) => {
1773
2729
  try {
1774
- return readFrontmatter(join12(dir, "SKILL.md")).name;
2730
+ return readFrontmatter(join16(dir, "SKILL.md")).name;
1775
2731
  } catch {
1776
2732
  return dir;
1777
2733
  }
@@ -1795,10 +2751,19 @@ async function status() {
1795
2751
  // src/cli.ts
1796
2752
  var require2 = createRequire(import.meta.url);
1797
2753
  var pkg = require2("../package.json");
2754
+ function parseArtifact(value) {
2755
+ if (value !== "skill" && value !== "mcp") {
2756
+ throw new UserError(`--artifact must be "skill" or "mcp" (got "${value}")`);
2757
+ }
2758
+ return value;
2759
+ }
1798
2760
  var program = new Command();
1799
2761
  program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
1800
- program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").action(add);
1801
- program.command("scan <source>").description("Run the safety gate against a skill without installing it").option("--json", "print the gate result as JSON instead of the terminal report").action(scan);
2762
+ program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).option(
2763
+ "--mcp-target <file>",
2764
+ "MCP config file to promote into (mcp artifacts only; defaults to config.mcpTargets[0], then <cwd>/.mcp.json)"
2765
+ ).option("--force", "overwrite an existing same-name MCP server entry when promoting (mcp artifacts only)").action(add);
2766
+ program.command("scan <source>").description("Run the safety gate against a skill without installing it").option("--json", "print the gate result as JSON instead of the terminal report").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).action(scan);
1802
2767
  program.command("list").description("List skills currently in quarantine").action(list);
1803
2768
  program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
1804
2769
  program.command("init").description("Detect installed agents and set gate targets, default promotion target, and handoff agent").option("--defaults", "non-interactive: use all detected agents as gate targets, the first as the default target").option("--list", "print detected agent skill roots and exit \u2014 no writes").action(init);