@rhize/skill-forge 0.4.0 → 0.5.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 join14 } 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,32 @@ 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 readdirSync6 } from "fs";
410
+ import { join as join10 } 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 existsSync7,
284
417
  mkdtempSync,
285
418
  openSync,
286
- readFileSync as readFileSync2,
419
+ readFileSync as readFileSync4,
287
420
  readdirSync as readdirSync2,
288
421
  readSync,
289
422
  rmSync as rmSync2,
290
423
  statSync as statSync3
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, join as join6, resolve as resolve2 } from "path";
294
427
  var SPDX_HINTS = {
295
428
  mit: "MIT (permissive)",
296
429
  apache: "Apache-2.0 (permissive)",
@@ -332,7 +465,7 @@ var PYTHON_STDLIB = /* @__PURE__ */ new Set([
332
465
  ]);
333
466
  function profileSkill(inputPath) {
334
467
  const p = resolve2(expandHome(inputPath));
335
- if (!existsSync6(p)) {
468
+ if (!existsSync7(p)) {
336
469
  throw new Error(`path does not exist: ${p}`);
337
470
  }
338
471
  const stat = statSync3(p);
@@ -346,8 +479,41 @@ function profileSkill(inputPath) {
346
479
  return profileSkillDir(p);
347
480
  }
348
481
  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";
482
+ if (existsSync7(join6(p, ".claude-plugin", "plugin.json"))) return "plugin";
483
+ if (detectArtifactType(p) === "mcp") return "mcp";
484
+ return "skill";
485
+ }
486
+ var MCP_SDK_DEP = "@modelcontextprotocol/sdk";
487
+ var MCP_KEYWORD = "mcp-server";
488
+ var MCP_BIN_PREFIX = /^mcp-/i;
489
+ function readPackageJsonSafe(root) {
490
+ const pkgPath = join6(root, "package.json");
491
+ if (!existsSync7(pkgPath)) return void 0;
492
+ try {
493
+ return JSON.parse(readFileSync4(pkgPath, "utf-8"));
494
+ } catch {
495
+ return void 0;
496
+ }
497
+ }
498
+ function hasMcpPackageMarkers(pkg2) {
499
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
500
+ const deps = pkg2[section];
501
+ if (deps && typeof deps === "object" && MCP_SDK_DEP in deps) return true;
502
+ }
503
+ const keywords = pkg2.keywords;
504
+ if (Array.isArray(keywords) && keywords.some((k) => k === MCP_KEYWORD)) return true;
505
+ const bin = pkg2.bin;
506
+ if (typeof bin === "string") {
507
+ if (MCP_BIN_PREFIX.test(basename3(bin))) return true;
508
+ } else if (bin && typeof bin === "object") {
509
+ if (Object.keys(bin).some((k) => MCP_BIN_PREFIX.test(k))) return true;
510
+ }
511
+ return false;
512
+ }
513
+ function detectArtifactType(root) {
514
+ if (existsSync7(join6(root, ".mcp.json")) || existsSync7(join6(root, "mcp.json"))) return "mcp";
515
+ const pkg2 = readPackageJsonSafe(root);
516
+ if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
351
517
  return "skill";
352
518
  }
353
519
  function resolveSkillMd(inputPath) {
@@ -361,16 +527,16 @@ function resolveSkillMd(inputPath) {
361
527
  }
362
528
  const stat = statSync3(currentPath);
363
529
  if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
364
- return { skillMd: currentPath, root: dirname(currentPath), tmpDir };
530
+ return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
365
531
  }
366
532
  if (stat.isDirectory()) {
367
533
  const direct = join6(currentPath, "SKILL.md");
368
- if (existsSync6(direct)) {
534
+ if (existsSync7(direct)) {
369
535
  return { skillMd: direct, root: currentPath, tmpDir };
370
536
  }
371
537
  const matches = findSkillMdOneLevelDown(currentPath);
372
538
  if (matches.length === 1) {
373
- return { skillMd: matches[0], root: dirname(matches[0]), tmpDir };
539
+ return { skillMd: matches[0], root: dirname2(matches[0]), tmpDir };
374
540
  }
375
541
  if (matches.length > 1) {
376
542
  cleanupTmp(tmpDir);
@@ -391,7 +557,7 @@ function findSkillMdOneLevelDown(dir) {
391
557
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
392
558
  if (entry.isDirectory()) {
393
559
  const candidate = join6(dir, entry.name, "SKILL.md");
394
- if (existsSync6(candidate)) matches.add(candidate);
560
+ if (existsSync7(candidate)) matches.add(candidate);
395
561
  }
396
562
  }
397
563
  const skillsDir = join6(dir, "skills");
@@ -399,7 +565,7 @@ function findSkillMdOneLevelDown(dir) {
399
565
  for (const entry of readdirSync2(skillsDir, { withFileTypes: true })) {
400
566
  if (entry.isDirectory()) {
401
567
  const candidate = join6(skillsDir, entry.name, "SKILL.md");
402
- if (existsSync6(candidate)) matches.add(candidate);
568
+ if (existsSync7(candidate)) matches.add(candidate);
403
569
  }
404
570
  }
405
571
  }
@@ -483,8 +649,8 @@ function detectLicense(root, fm) {
483
649
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
484
650
  for (const name of names) {
485
651
  const p = join6(root, name);
486
- if (existsSync6(p) && statSync3(p).isFile()) {
487
- const head = readFileSync2(p, "utf-8").slice(0, 2e3).toLowerCase();
652
+ if (existsSync7(p) && statSync3(p).isFile()) {
653
+ const head = readFileSync4(p, "utf-8").slice(0, 2e3).toLowerCase();
488
654
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
489
655
  if (head.includes(hint)) return label;
490
656
  }
@@ -523,7 +689,7 @@ function scanMcpAndImports(text, root) {
523
689
  if (isDir(scriptsDir)) {
524
690
  for (const f of walkFiles(scriptsDir)) {
525
691
  if (!f.endsWith(".py")) continue;
526
- const ft = readFileSync2(f, "utf-8");
692
+ const ft = readFileSync4(f, "utf-8");
527
693
  for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
528
694
  const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
529
695
  let m;
@@ -540,7 +706,7 @@ function scanMcpAndImports(text, root) {
540
706
  function profileSkillDir(inputPath) {
541
707
  const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
542
708
  try {
543
- const text = readFileSync2(skillMd, "utf-8");
709
+ const text = readFileSync4(skillMd, "utf-8");
544
710
  const fm = parseFrontmatter(text);
545
711
  const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
546
712
  const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
@@ -560,7 +726,8 @@ function profileSkillDir(inputPath) {
560
726
  topHeaders: headers.slice(0, 12),
561
727
  resources,
562
728
  mcpDependencies: mcpDeps,
563
- externalPythonDeps: externalDeps
729
+ externalPythonDeps: externalDeps,
730
+ artifactType: detectArtifactType(root)
564
731
  };
565
732
  } finally {
566
733
  cleanupTmp(tmpDir);
@@ -570,14 +737,14 @@ function profilePlugin(p) {
570
737
  const manifestPath = join6(p, ".claude-plugin", "plugin.json");
571
738
  let manifest;
572
739
  try {
573
- manifest = JSON.parse(readFileSync2(manifestPath, "utf-8"));
740
+ manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
574
741
  } catch (err) {
575
742
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
576
743
  }
577
744
  const skillsDir = join6(p, "skills");
578
745
  let skills = [];
579
746
  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();
747
+ skills = readdirSync2(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync7(join6(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
581
748
  }
582
749
  const commandsDir = join6(p, "commands");
583
750
  const commands = isDir(commandsDir) ? readdirSync2(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
@@ -592,30 +759,50 @@ function profilePlugin(p) {
592
759
  skills,
593
760
  skillCount: skills.length,
594
761
  commands,
595
- mcpServers
762
+ mcpServers,
763
+ artifactType: detectArtifactType(p)
596
764
  };
597
765
  }
598
766
  function profileMcp(p) {
599
- const cfgPath = existsSync6(join6(p, ".mcp.json")) ? join6(p, ".mcp.json") : join6(p, "mcp.json");
600
- let cfg;
767
+ const explicitConfig = existsSync7(join6(p, ".mcp.json")) ? join6(p, ".mcp.json") : existsSync7(join6(p, "mcp.json")) ? join6(p, "mcp.json") : void 0;
768
+ if (explicitConfig) {
769
+ let cfg;
770
+ try {
771
+ cfg = JSON.parse(readFileSync4(explicitConfig, "utf-8"));
772
+ } catch (err) {
773
+ throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
774
+ }
775
+ const servers = cfg.mcpServers ?? cfg.servers ?? {};
776
+ const names = typeof servers === "object" && servers !== null ? Object.keys(servers).sort() : [];
777
+ return {
778
+ kind: "mcp",
779
+ inputPath: p,
780
+ config: explicitConfig,
781
+ servers: names,
782
+ serverCount: names.length,
783
+ artifactType: "mcp"
784
+ };
785
+ }
786
+ const pkgPath = join6(p, "package.json");
787
+ let pkg2;
601
788
  try {
602
- cfg = JSON.parse(readFileSync2(cfgPath, "utf-8"));
789
+ pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
603
790
  } catch (err) {
604
- throw new Error(`could not parse MCP config ${cfgPath}: ${err.message}`);
791
+ throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
605
792
  }
606
- const servers = cfg.mcpServers ?? cfg.servers ?? {};
607
- const names = typeof servers === "object" && servers !== null ? Object.keys(servers).sort() : [];
793
+ const name = typeof pkg2.name === "string" && pkg2.name.length > 0 ? pkg2.name : basename3(p);
608
794
  return {
609
795
  kind: "mcp",
610
796
  inputPath: p,
611
- config: cfgPath,
612
- servers: names,
613
- serverCount: names.length
797
+ config: pkgPath,
798
+ servers: [name],
799
+ serverCount: 1,
800
+ artifactType: "mcp"
614
801
  };
615
802
  }
616
803
 
617
804
  // src/gate/safety.ts
618
- import { readFileSync as readFileSync3, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
805
+ import { readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
619
806
  import { execFile } from "child_process";
620
807
  import { delimiter, extname as extname2, join as join7 } from "path";
621
808
  import { promisify } from "util";
@@ -731,7 +918,7 @@ function readTextFile(file) {
731
918
  if (BINARY_EXT.has(extname2(file).toLowerCase())) return void 0;
732
919
  try {
733
920
  if (statSync4(file).size > MAX_FILE_BYTES) return void 0;
734
- const content = readFileSync3(file, "utf-8");
921
+ const content = readFileSync5(file, "utf-8");
735
922
  if (content.slice(0, 512).includes("\0")) return void 0;
736
923
  return content;
737
924
  } catch {
@@ -818,8 +1005,8 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
818
1005
  }
819
1006
 
820
1007
  // 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";
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";
823
1010
  var STOPWORDS = new Set(
824
1011
  `
825
1012
  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 +1027,8 @@ function splitLimited(text, sep, maxSplit) {
840
1027
  return parts;
841
1028
  }
842
1029
  function readFrontmatter(skillMdPath) {
843
- const text = readFileSync4(skillMdPath, "utf-8");
844
- const meta = { name: basename4(dirname2(skillMdPath)), description: "" };
1030
+ const text = readFileSync6(skillMdPath, "utf-8");
1031
+ const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
845
1032
  if (text.startsWith("---\n")) {
846
1033
  const block = splitLimited(text, "---", 2)[1] ?? "";
847
1034
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -935,7 +1122,7 @@ function suggestVerbCode(topScore) {
935
1122
  return verbTier(topScore).verb;
936
1123
  }
937
1124
  function listSubdirs(dir) {
938
- if (!existsSync7(dir)) return [];
1125
+ if (!existsSync8(dir)) return [];
939
1126
  let entries;
940
1127
  try {
941
1128
  entries = readdirSync4(dir, { withFileTypes: true });
@@ -948,16 +1135,16 @@ function findSkillMdFiles(root) {
948
1135
  const found = /* @__PURE__ */ new Set();
949
1136
  for (const dir of listSubdirs(root)) {
950
1137
  const candidate = join8(dir, "SKILL.md");
951
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1138
+ if (existsSync8(candidate)) found.add(resolve3(candidate));
952
1139
  }
953
1140
  for (const dir of listSubdirs(join8(root, "skills"))) {
954
1141
  const candidate = join8(dir, "SKILL.md");
955
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1142
+ if (existsSync8(candidate)) found.add(resolve3(candidate));
956
1143
  }
957
1144
  for (const topDir of listSubdirs(root)) {
958
1145
  for (const dir of listSubdirs(join8(topDir, "skills"))) {
959
1146
  const candidate = join8(dir, "SKILL.md");
960
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1147
+ if (existsSync8(candidate)) found.add(resolve3(candidate));
961
1148
  }
962
1149
  }
963
1150
  return Array.from(found).filter((p) => !p.includes("/.git/")).sort();
@@ -971,7 +1158,7 @@ function scanOverlap(candidatePath, skillsRoot) {
971
1158
  if (isDir(cpath)) {
972
1159
  cpath = join8(cpath, "SKILL.md");
973
1160
  }
974
- if (!existsSync7(cpath)) {
1161
+ if (!existsSync8(cpath)) {
975
1162
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
976
1163
  }
977
1164
  const cand = readFrontmatter(cpath);
@@ -994,6 +1181,338 @@ function scanOverlap(candidatePath, skillsRoot) {
994
1181
  };
995
1182
  }
996
1183
 
1184
+ // 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
+ }
1238
+ function isConfigOrExampleFile(file) {
1239
+ const base = basename5(file).toLowerCase();
1240
+ if (base.startsWith(".env")) return true;
1241
+ if (/\.(json|ya?ml)$/.test(base)) return true;
1242
+ return /(example|sample|config)/.test(base);
1243
+ }
1244
+ var CREDENTIAL_KEY_RE = /(API[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i;
1245
+ function looksCredentialKey(name) {
1246
+ return CREDENTIAL_KEY_RE.test(name);
1247
+ }
1248
+ var PLACEHOLDER_VALUE_RE = /^(\$\{.*\}|\$[A-Z0-9_]+|your[_-]?.*|<.*>|xxx+|changeme|replace[_-]?me|example|placeholder|todo|fill[_-]?me|none|null|redacted|\.\.\.)$/i;
1249
+ function isPlaceholderValue(value) {
1250
+ const v = value.trim();
1251
+ if (v === "") return true;
1252
+ if (v.startsWith("process.env")) return true;
1253
+ return PLACEHOLDER_VALUE_RE.test(v);
1254
+ }
1255
+ function looksHighEntropy(value) {
1256
+ if (/\s/.test(value)) return false;
1257
+ if (value.length < 20) return false;
1258
+ const hasLower = /[a-z]/.test(value);
1259
+ const hasUpper = /[A-Z]/.test(value);
1260
+ const hasDigit = /[0-9]/.test(value);
1261
+ const varietyCount = [hasLower, hasUpper, hasDigit].filter(Boolean).length;
1262
+ const uniqueChars = new Set(value).size;
1263
+ return varietyCount >= 2 && uniqueChars / value.length > 0.4;
1264
+ }
1265
+ function isPinnedPackageSpec(spec) {
1266
+ const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
1267
+ return withoutScope.includes("@");
1268
+ }
1269
+ function collectServerNodes(node, out) {
1270
+ if (Array.isArray(node)) {
1271
+ for (const item of node) collectServerNodes(item, out);
1272
+ return;
1273
+ }
1274
+ if (node && typeof node === "object") {
1275
+ const obj = node;
1276
+ if (typeof obj.command === "string" || Array.isArray(obj.args) || obj.env && typeof obj.env === "object") {
1277
+ out.push(obj);
1278
+ }
1279
+ for (const value of Object.values(obj)) collectServerNodes(value, out);
1280
+ }
1281
+ }
1282
+ function tryParseJson(text) {
1283
+ try {
1284
+ return JSON.parse(text);
1285
+ } catch {
1286
+ return void 0;
1287
+ }
1288
+ }
1289
+ function scanJsonConfig(file, parsed) {
1290
+ const findings = [];
1291
+ const nodes = [];
1292
+ collectServerNodes(parsed, nodes);
1293
+ for (const node of nodes) {
1294
+ const command = typeof node.command === "string" ? node.command : void 0;
1295
+ const args = Array.isArray(node.args) ? node.args.filter((a) => typeof a === "string") : [];
1296
+ if (command === "npx") {
1297
+ const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1298
+ if (flagIdx !== -1) {
1299
+ const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
1300
+ if (pkgSpec && !isPinnedPackageSpec(pkgSpec)) {
1301
+ findings.push({
1302
+ severity: "MEDIUM",
1303
+ rule: "mcp-unpinned-npx",
1304
+ file,
1305
+ detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>) so the resolved server code can't change silently`
1306
+ });
1307
+ }
1308
+ }
1309
+ }
1310
+ for (const a of args) {
1311
+ if (/^--dangerously-/i.test(a) || a === "--no-sandbox") {
1312
+ findings.push({
1313
+ severity: "HIGH",
1314
+ rule: "mcp-dangerous-flag",
1315
+ file,
1316
+ detail: `launch arg ${a} disables a safety guard`
1317
+ });
1318
+ }
1319
+ if (a === "/" || a === "~") {
1320
+ findings.push({
1321
+ severity: "MEDIUM",
1322
+ rule: "mcp-filesystem-root-arg",
1323
+ file,
1324
+ detail: `launch arg "${a}" grants filesystem access at the root/home directory \u2014 scope this to a specific directory`
1325
+ });
1326
+ }
1327
+ }
1328
+ if (node.env && typeof node.env === "object" && !Array.isArray(node.env)) {
1329
+ for (const [key, value] of Object.entries(node.env)) {
1330
+ if (typeof value !== "string" || isPlaceholderValue(value)) continue;
1331
+ if (looksCredentialKey(key) || looksHighEntropy(value)) {
1332
+ findings.push({
1333
+ severity: "HIGH",
1334
+ rule: "mcp-inline-credential-value",
1335
+ file,
1336
+ detail: `env.${key} has an inline credential value \u2014 emit an empty string and document that the user must fill it in`
1337
+ });
1338
+ }
1339
+ }
1340
+ }
1341
+ }
1342
+ return findings;
1343
+ }
1344
+ var NPX_SHELL_RE = /\bnpx\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/g;
1345
+ var DANGEROUS_FLAG_RE = /--dangerously-[\w-]+|--no-sandbox\b/g;
1346
+ var CREDENTIAL_ASSIGNMENT_RE = /["']?([A-Za-z][A-Za-z0-9_]*)["']?\s*[:=]\s*["']([^"']{6,})["']/g;
1347
+ function scanTextFile(file, content, allowCredentialCheck) {
1348
+ const findings = [];
1349
+ const lines = content.split("\n");
1350
+ for (let i = 0; i < lines.length; i++) {
1351
+ const line = lines[i];
1352
+ const lineNo = i + 1;
1353
+ NPX_SHELL_RE.lastIndex = 0;
1354
+ let m;
1355
+ while ((m = NPX_SHELL_RE.exec(line)) !== null) {
1356
+ const pkgSpec = m[1];
1357
+ const pinned = m[2] !== void 0;
1358
+ if (!pinned) {
1359
+ findings.push({
1360
+ severity: "MEDIUM",
1361
+ rule: "mcp-unpinned-npx",
1362
+ file,
1363
+ detail: `line ${lineNo}: npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>)`
1364
+ });
1365
+ }
1366
+ }
1367
+ DANGEROUS_FLAG_RE.lastIndex = 0;
1368
+ while ((m = DANGEROUS_FLAG_RE.exec(line)) !== null) {
1369
+ findings.push({
1370
+ severity: "HIGH",
1371
+ rule: "mcp-dangerous-flag",
1372
+ file,
1373
+ detail: `line ${lineNo}: launch arg ${m[0]} disables a safety guard`
1374
+ });
1375
+ }
1376
+ if (allowCredentialCheck) {
1377
+ CREDENTIAL_ASSIGNMENT_RE.lastIndex = 0;
1378
+ 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
+ }
1390
+ }
1391
+ }
1392
+ }
1393
+ return findings;
1394
+ }
1395
+ function scanFileForMcpFindings(file) {
1396
+ const content = readTextFile2(file);
1397
+ if (content === void 0) return [];
1398
+ if (extname3(file).toLowerCase() === ".json") {
1399
+ const parsed = tryParseJson(content);
1400
+ if (parsed !== void 0) return scanJsonConfig(file, parsed);
1401
+ }
1402
+ return scanTextFile(file, content, isConfigOrExampleFile(file));
1403
+ }
1404
+ function scanMcpFindings(targetPath) {
1405
+ let root;
1406
+ try {
1407
+ root = statSync5(targetPath);
1408
+ } catch {
1409
+ return [];
1410
+ }
1411
+ const files = root.isDirectory() ? Array.from(walk2(targetPath)) : [targetPath];
1412
+ const findings = [];
1413
+ for (const file of files) {
1414
+ findings.push(...scanFileForMcpFindings(file));
1415
+ }
1416
+ return findings;
1417
+ }
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
+ async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
1430
+ const base = await scanSafety(targetPath, strictness);
1431
+ const mcpFindings = scanMcpFindings(targetPath);
1432
+ const findings = [...base.findings, ...mcpFindings];
1433
+ return { findings, verdict: verdictFor2(findings, strictness) };
1434
+ }
1435
+
1436
+ // src/gate/mcpOverlap.ts
1437
+ import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
1438
+ import { resolve as resolve4 } from "path";
1439
+ function readTargetServers(targetPath) {
1440
+ const path = resolve4(expandHome(targetPath));
1441
+ if (!existsSync9(path)) return [];
1442
+ let cfg;
1443
+ try {
1444
+ cfg = JSON.parse(readFileSync8(path, "utf-8"));
1445
+ } catch {
1446
+ return [];
1447
+ }
1448
+ const servers = cfg.mcpServers ?? cfg.servers ?? {};
1449
+ if (typeof servers !== "object" || servers === null) return [];
1450
+ return Object.entries(servers).map(([name, raw]) => {
1451
+ const v = raw ?? {};
1452
+ return {
1453
+ name,
1454
+ command: typeof v.command === "string" ? v.command : void 0,
1455
+ args: Array.isArray(v.args) ? v.args.filter((a) => typeof a === "string") : void 0
1456
+ };
1457
+ });
1458
+ }
1459
+ function tokenizeName(name) {
1460
+ return new Set(name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
1461
+ }
1462
+ function nameSimilarity(a, b) {
1463
+ if (a.toLowerCase() === b.toLowerCase()) return 1;
1464
+ const ta = tokenizeName(a);
1465
+ const tb = tokenizeName(b);
1466
+ if (ta.size === 0 || tb.size === 0) return 0;
1467
+ let inter = 0;
1468
+ for (const t of ta) if (tb.has(t)) inter += 1;
1469
+ const union = (/* @__PURE__ */ new Set([...ta, ...tb])).size;
1470
+ return union ? inter / union : 0;
1471
+ }
1472
+ function packageBaseName(spec) {
1473
+ const searchFrom = spec.startsWith("@") ? 1 : 0;
1474
+ const atIdx = spec.indexOf("@", searchFrom);
1475
+ return atIdx === -1 ? spec : spec.slice(0, atIdx);
1476
+ }
1477
+ function extractPackageSpec(command, args = []) {
1478
+ if (!command) return void 0;
1479
+ const base = command.split("/").pop() ?? command;
1480
+ if (["npx", "npm", "pnpm", "yarn", "uvx"].includes(base)) {
1481
+ return args.find((a) => !a.startsWith("-"));
1482
+ }
1483
+ return base;
1484
+ }
1485
+ function commandSimilarity(candidate, target) {
1486
+ const candSpec = candidate.packageName ?? extractPackageSpec(candidate.command, candidate.args ?? []);
1487
+ const targetSpec = extractPackageSpec(target.command, target.args ?? []);
1488
+ if (!candSpec || !targetSpec) return 0;
1489
+ return packageBaseName(candSpec).toLowerCase() === packageBaseName(targetSpec).toLowerCase() ? 1 : 0;
1490
+ }
1491
+ function round32(n) {
1492
+ return Math.round(n * 1e3) / 1e3;
1493
+ }
1494
+ function score2(candidate, target) {
1495
+ return round32(0.6 * nameSimilarity(candidate.name, target.name) + 0.4 * commandSimilarity(candidate, target));
1496
+ }
1497
+ function scanMcpOverlap(candidate, mcpTargetPaths) {
1498
+ const ranking = [];
1499
+ for (const targetPath of mcpTargetPaths) {
1500
+ const resolvedPath = resolve4(expandHome(targetPath));
1501
+ for (const target of readTargetServers(targetPath)) {
1502
+ ranking.push({ skill: target.name, path: resolvedPath, score: score2(candidate, target) });
1503
+ }
1504
+ }
1505
+ ranking.sort((a, b) => b.score - a.score);
1506
+ const top = ranking[0];
1507
+ return {
1508
+ candidate: candidate.name,
1509
+ nearest: top?.skill ?? "",
1510
+ nearestScore: top?.score ?? 0,
1511
+ suggestedVerb: suggestVerb(top?.score ?? 0),
1512
+ ranking
1513
+ };
1514
+ }
1515
+
997
1516
  // src/license.ts
998
1517
  import { verify as cryptoVerify } from "crypto";
999
1518
  var LICENSE_PREFIX = "skf1";
@@ -1109,11 +1628,20 @@ function renderFindingsLines(findings) {
1109
1628
  if (!findings?.length) return [];
1110
1629
  return [`${padLabel("Findings")}:`, ...findings.map(renderFindingLine)];
1111
1630
  }
1631
+ function profileHeaderFields(profile) {
1632
+ if (profile.kind === "mcp") {
1633
+ const name = profile.serverCount === 1 ? profile.servers[0] : `${profile.serverCount} servers`;
1634
+ return { name, version: "n/a", license: "n/a" };
1635
+ }
1636
+ return {
1637
+ name: profile.name ?? "unknown",
1638
+ version: profile.version ?? "unknown",
1639
+ license: profile.kind === "skill" ? profile.license : "UNKNOWN"
1640
+ };
1641
+ }
1112
1642
  function renderReport(result) {
1113
1643
  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";
1644
+ const { name, version, license } = profileHeaderFields(profile);
1117
1645
  const overlapLines = overlapLocked ? [`${padLabel("Overlap analysis")}: ${upgradeNotice("overlap analysis")}`] : overlap ? [
1118
1646
  `${padLabel("Nearest existing skill")}: ${overlap.nearest} (overlap ${overlap.nearestScore})`,
1119
1647
  `${padLabel("Recommended verb")}: ${overlap.suggestedVerb}`
@@ -1122,6 +1650,7 @@ function renderReport(result) {
1122
1650
  BORDER,
1123
1651
  `\u{1F528} Forge: ${name} (v${version}, ${license})`,
1124
1652
  BORDER,
1653
+ `${padLabel("Artifact type")}: ${profile.artifactType}`,
1125
1654
  ...overlapLines,
1126
1655
  renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
1127
1656
  renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
@@ -1147,10 +1676,13 @@ function printGateOutput(gateResult, json) {
1147
1676
  }
1148
1677
  console.log(json ? renderJson(gateResult) : renderReport(gateResult));
1149
1678
  }
1150
- function asSkillProfile(candidatePath) {
1679
+ function asProfile(candidatePath, artifact) {
1151
1680
  const profile = profileSkill(candidatePath);
1152
- if (profile.kind !== "skill") {
1153
- throw new Error(`expected a skill candidate at ${candidatePath}, found a ${profile.kind}`);
1681
+ const expectedKind = artifact === "mcp" ? "mcp" : "skill";
1682
+ if (profile.kind !== expectedKind) {
1683
+ throw new Error(
1684
+ artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
1685
+ );
1154
1686
  }
1155
1687
  return profile;
1156
1688
  }
@@ -1162,14 +1694,42 @@ function tryOverlap(candidatePath, skillsRoot) {
1162
1694
  return void 0;
1163
1695
  }
1164
1696
  }
1697
+ function tryMcpOverlap(profile, mcpTargets) {
1698
+ try {
1699
+ const detail = resolveMcpCandidateDetail(profile);
1700
+ return scanMcpOverlap(
1701
+ {
1702
+ name: detail.name,
1703
+ command: detail.command,
1704
+ args: detail.args,
1705
+ packageName: detail.command ? void 0 : detail.name
1706
+ },
1707
+ mcpTargets
1708
+ );
1709
+ } catch (err) {
1710
+ console.error(`[skill-forge] mcp overlap scan skipped: ${err.message}`);
1711
+ return void 0;
1712
+ }
1713
+ }
1714
+ function resolveMcpCandidatePath(entry) {
1715
+ if (entry.packageDir) return entry.packageDir;
1716
+ let children = [];
1717
+ try {
1718
+ children = readdirSync6(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1719
+ } catch {
1720
+ }
1721
+ return children.length === 1 ? join10(entry.quarantinePath, children[0]) : entry.quarantinePath;
1722
+ }
1165
1723
  async function runGate(candidatePath, skillsRoot, {
1166
1724
  proFeatureLabel = "overlap analysis",
1167
- strictness = DEFAULT_STRICTNESS
1725
+ strictness = DEFAULT_STRICTNESS,
1726
+ artifact = "skill",
1727
+ mcpTargets = []
1168
1728
  } = {}) {
1169
- const profile = asSkillProfile(candidatePath);
1170
- const safety = await scanSafety(candidatePath, strictness);
1729
+ const profile = asProfile(candidatePath, artifact);
1171
1730
  const proStatus = proFeatureStatus(proFeatureLabel);
1172
- const overlap = proStatus.enabled ? tryOverlap(candidatePath, skillsRoot) : void 0;
1731
+ const safety = artifact === "mcp" ? await mcpScan(candidatePath, strictness) : await scanSafety(candidatePath, strictness);
1732
+ const overlap = !proStatus.enabled ? void 0 : artifact === "mcp" ? tryMcpOverlap(profile, mcpTargets) : tryOverlap(candidatePath, skillsRoot);
1173
1733
  const gateResult = {
1174
1734
  profile,
1175
1735
  overlap,
@@ -1189,23 +1749,23 @@ async function runGate(candidatePath, skillsRoot, {
1189
1749
 
1190
1750
  // src/queue.ts
1191
1751
  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";
1752
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1753
+ import { join as join11 } from "path";
1194
1754
  function getQueuePath() {
1195
- return join9(getHomeDir(), "queue.json");
1755
+ return join11(getHomeDir(), "queue.json");
1196
1756
  }
1197
1757
  function readQueue() {
1198
1758
  const path = getQueuePath();
1199
- if (!existsSync8(path)) {
1759
+ if (!existsSync10(path)) {
1200
1760
  return { version: 1, entries: [] };
1201
1761
  }
1202
- const raw = readFileSync5(path, "utf-8");
1762
+ const raw = readFileSync9(path, "utf-8");
1203
1763
  const parsed = JSON.parse(raw);
1204
1764
  return { version: 1, entries: parsed.entries ?? [] };
1205
1765
  }
1206
1766
  function writeQueue(queue) {
1207
- mkdirSync4(getHomeDir(), { recursive: true });
1208
- writeFileSync3(getQueuePath(), JSON.stringify(queue, null, 2) + "\n", "utf-8");
1767
+ mkdirSync5(getHomeDir(), { recursive: true });
1768
+ writeFileSync4(getQueuePath(), JSON.stringify(queue, null, 2) + "\n", "utf-8");
1209
1769
  }
1210
1770
  function appendQueueEntry(entry) {
1211
1771
  const queue = readQueue();
@@ -1229,16 +1789,16 @@ function generateId(existingIds) {
1229
1789
 
1230
1790
  // src/handoff.ts
1231
1791
  import { spawnSync } from "child_process";
1232
- import { existsSync as existsSync9 } from "fs";
1233
- import { dirname as dirname3, join as join10 } from "path";
1792
+ import { existsSync as existsSync11 } from "fs";
1793
+ import { dirname as dirname4, join as join12 } from "path";
1234
1794
  import { fileURLToPath } from "url";
1235
1795
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
1236
1796
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
1237
1797
  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");
1798
+ 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");
1242
1802
  }
1243
1803
  function defaultIsOnPath(bin) {
1244
1804
  const finder = process.platform === "win32" ? "where" : "which";
@@ -1293,20 +1853,28 @@ function runHandoff(config, installedPath, options = {}) {
1293
1853
  }
1294
1854
 
1295
1855
  // src/commands/init.ts
1296
- import { existsSync as existsSync10 } from "fs";
1856
+ import { existsSync as existsSync12 } from "fs";
1297
1857
  import { homedir as homedir4 } from "os";
1298
1858
  import { createInterface } from "readline/promises";
1299
1859
 
1300
1860
  // src/agents.ts
1861
+ import { statSync as statSync6 } from "fs";
1301
1862
  import { homedir as homedir3 } from "os";
1302
- import { join as join11, resolve as resolve4 } from "path";
1863
+ import { join as join13, resolve as resolve5 } from "path";
1864
+ function isFile(p) {
1865
+ try {
1866
+ return statSync6(p).isFile();
1867
+ } catch {
1868
+ return false;
1869
+ }
1870
+ }
1303
1871
  function projectPath(root, cwd) {
1304
- return root ? resolve4(cwd, root) : void 0;
1872
+ return root ? resolve5(cwd, root) : void 0;
1305
1873
  }
1306
1874
  function globalPath(root, home) {
1307
1875
  if (!root) return void 0;
1308
1876
  if (root === "~") return home;
1309
- if (root.startsWith("~/")) return join11(home, root.slice(2));
1877
+ if (root.startsWith("~/")) return join13(home, root.slice(2));
1310
1878
  return root;
1311
1879
  }
1312
1880
  function makeAgent(spec) {
@@ -1317,6 +1885,7 @@ function makeAgent(spec) {
1317
1885
  globalRoot: spec.globalRoot,
1318
1886
  verified: spec.verified ?? true,
1319
1887
  defaultHandoffTemplate: spec.defaultHandoffTemplate,
1888
+ mcpConfig: spec.mcpConfig,
1320
1889
  detect: (cwd, home) => detectAgentRoot(agent, cwd, home) !== void 0
1321
1890
  };
1322
1891
  return agent;
@@ -1331,6 +1900,19 @@ function detectAgentRoot(agent, cwd = process.cwd(), home = homedir3()) {
1331
1900
  }
1332
1901
  return void 0;
1333
1902
  }
1903
+ function resolveMcpConfigPath(agent, scope, cwd = process.cwd(), home = homedir3()) {
1904
+ if (!agent.mcpConfig) return void 0;
1905
+ return scope === "project" ? projectPath(agent.mcpConfig.project, cwd) : globalPath(agent.mcpConfig.global, home);
1906
+ }
1907
+ function detectMcpConfigFiles(agent, cwd = process.cwd(), home = homedir3()) {
1908
+ if (!agent.mcpConfig) return [];
1909
+ const found = [];
1910
+ for (const scope of ["project", "global"]) {
1911
+ const path = resolveMcpConfigPath(agent, scope, cwd, home);
1912
+ if (path && isFile(path)) found.push({ scope, path });
1913
+ }
1914
+ return found;
1915
+ }
1334
1916
  var AGENTS = [
1335
1917
  { id: "aider-desk", displayName: "AiderDesk", projectRoot: ".aider-desk/skills", globalRoot: "~/.aider-desk/skills" },
1336
1918
  { id: "amp", displayName: "Amp", projectRoot: ".agents/skills", globalRoot: "~/.config/agents/skills" },
@@ -1354,7 +1936,12 @@ var AGENTS = [
1354
1936
  globalRoot: "~/.claude/skills",
1355
1937
  // `claude -p "<prompt>"` runs one non-interactive turn and exits. Verified 2026-07-11:
1356
1938
  // https://code.claude.com/docs/en/headless
1357
- defaultHandoffTemplate: ["claude", "-p", HANDOFF_INSTRUCTION]
1939
+ defaultHandoffTemplate: ["claude", "-p", HANDOFF_INSTRUCTION],
1940
+ // Project scope writes to `.mcp.json` at the project root (shared via version control).
1941
+ // User scope writes to a top-level `mcpServers` key in `~/.claude.json` (not exclusively
1942
+ // an MCP file — it's Claude Code's broader state file — but it's the correct read/write
1943
+ // target for user-scoped servers). Verified 2026-07-12: https://code.claude.com/docs/en/mcp
1944
+ mcpConfig: { project: ".mcp.json", global: "~/.claude.json", verified: true }
1358
1945
  },
1359
1946
  { id: "openclaw", displayName: "OpenClaw", projectRoot: "skills", globalRoot: "~/.openclaw/skills" },
1360
1947
  { id: "cline", displayName: "Cline", projectRoot: ".agents/skills", globalRoot: "~/.agents/skills" },
@@ -1379,7 +1966,12 @@ var AGENTS = [
1379
1966
  globalRoot: "~/.codex/skills",
1380
1967
  // `codex exec "<prompt>"` runs one non-interactive task and prints the final message.
1381
1968
  // Verified 2026-07-11: https://developers.openai.com/codex/noninteractive
1382
- defaultHandoffTemplate: ["codex", "exec", HANDOFF_INSTRUCTION]
1969
+ defaultHandoffTemplate: ["codex", "exec", HANDOFF_INSTRUCTION],
1970
+ // TOML, not JSON: `[mcp_servers.<name>]` tables. User config at `~/.codex/config.toml`;
1971
+ // project config at `.codex/config.toml` (only honored for trusted projects). Verified
1972
+ // 2026-07-12: https://learn.chatgpt.com/docs/extend/mcp?surface=cli — detect/report-only
1973
+ // per the v0.5 contract (promote only writes JSON targets).
1974
+ mcpConfig: { project: ".codex/config.toml", global: "~/.codex/config.toml", format: "toml", verified: true }
1383
1975
  },
1384
1976
  {
1385
1977
  id: "command-code",
@@ -1390,7 +1982,16 @@ var AGENTS = [
1390
1982
  { id: "continue", displayName: "Continue", projectRoot: ".continue/skills", globalRoot: "~/.continue/skills" },
1391
1983
  { id: "cortex", displayName: "Cortex Code", projectRoot: ".cortex/skills", globalRoot: "~/.snowflake/cortex/skills" },
1392
1984
  { id: "crush", displayName: "Crush", projectRoot: ".crush/skills", globalRoot: "~/.config/crush/skills" },
1393
- { id: "cursor", displayName: "Cursor", projectRoot: ".agents/skills", globalRoot: "~/.cursor/skills" },
1985
+ {
1986
+ id: "cursor",
1987
+ displayName: "Cursor",
1988
+ projectRoot: ".agents/skills",
1989
+ globalRoot: "~/.cursor/skills",
1990
+ // Both scopes use a top-level `mcpServers` key. Verified 2026-07-12:
1991
+ // https://cursor.com/docs/context/mcp ("Create `.cursor/mcp.json` in your project ...";
1992
+ // "Create `~/.cursor/mcp.json` in your home directory ...").
1993
+ mcpConfig: { project: ".cursor/mcp.json", global: "~/.cursor/mcp.json", verified: true }
1994
+ },
1394
1995
  {
1395
1996
  id: "deepagents",
1396
1997
  displayName: "Deep Agents",
@@ -1409,13 +2010,24 @@ var AGENTS = [
1409
2010
  globalRoot: "~/.gemini/skills",
1410
2011
  // `gemini -p "<prompt>"` executes a single turn headlessly and exits. Verified 2026-07-11:
1411
2012
  // https://google-gemini.github.io/gemini-cli/docs/cli/headless.html
1412
- defaultHandoffTemplate: ["gemini", "-p", HANDOFF_INSTRUCTION]
2013
+ defaultHandoffTemplate: ["gemini", "-p", HANDOFF_INSTRUCTION],
2014
+ // `mcpServers` key inside `settings.json` (shared with other CLI settings, not an
2015
+ // MCP-exclusive file, but the correct read/write target). User config at
2016
+ // `~/.gemini/settings.json`, project config at `.gemini/settings.json`. Verified
2017
+ // 2026-07-12: https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html
2018
+ mcpConfig: { project: ".gemini/settings.json", global: "~/.gemini/settings.json", verified: true }
1413
2019
  },
1414
2020
  {
1415
2021
  id: "github-copilot",
1416
2022
  displayName: "GitHub Copilot",
1417
2023
  projectRoot: ".agents/skills",
1418
- globalRoot: "~/.copilot/skills"
2024
+ globalRoot: "~/.copilot/skills",
2025
+ // Copilot CLI (matches the `~/.copilot` skills convention above, not the VS Code
2026
+ // extension — which uses a different `servers`-keyed `.vscode/mcp.json` and isn't covered
2027
+ // by this entry). User config `~/.copilot/mcp-config.json` (`mcpServers` key), repo-level
2028
+ // config `.github/mcp.json`. Verified 2026-07-12:
2029
+ // https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers
2030
+ mcpConfig: { project: ".github/mcp.json", global: "~/.copilot/mcp-config.json", verified: true }
1419
2031
  },
1420
2032
  { id: "goose", displayName: "Goose", projectRoot: ".goose/skills", globalRoot: "~/.config/goose/skills" },
1421
2033
  { id: "hermes-agent", displayName: "Hermes Agent", projectRoot: ".hermes/skills", globalRoot: "~/.hermes/skills" },
@@ -1464,7 +2076,15 @@ var AGENTS = [
1464
2076
  { id: "tinycloud", displayName: "Tinycloud", projectRoot: ".tinycloud/skills", globalRoot: "~/.tinycloud/skills" },
1465
2077
  { id: "trae", displayName: "Trae", projectRoot: ".trae/skills", globalRoot: "~/.trae/skills" },
1466
2078
  { 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" },
2079
+ {
2080
+ id: "windsurf",
2081
+ displayName: "Windsurf",
2082
+ projectRoot: ".windsurf/skills",
2083
+ globalRoot: "~/.codeium/windsurf/skills",
2084
+ // `mcpServers` key; global only — no project-scoped MCP config is documented. Verified
2085
+ // 2026-07-12: https://docs.windsurf.com/windsurf/cascade/mcp
2086
+ mcpConfig: { global: "~/.codeium/windsurf/mcp_config.json", verified: true }
2087
+ },
1468
2088
  { id: "zcode", displayName: "ZCode", projectRoot: ".zcode/skills", globalRoot: "~/.zcode/skills" },
1469
2089
  { id: "zencoder", displayName: "Zencoder", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
1470
2090
  { id: "zenflow", displayName: "Zenflow", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
@@ -1483,6 +2103,15 @@ function detectAgents(cwd, home) {
1483
2103
  }
1484
2104
  return found;
1485
2105
  }
2106
+ function detectMcpTargets(cwd, home) {
2107
+ const found = [];
2108
+ for (const agent of AGENTS) {
2109
+ for (const hit of detectMcpConfigFiles(agent, cwd, home)) {
2110
+ found.push({ agent, ...hit });
2111
+ }
2112
+ }
2113
+ return found;
2114
+ }
1486
2115
  function printDetectionResults(detected) {
1487
2116
  console.log(`[skill-forge] init \u2014 agent detection (${AGENTS.length} known agents)`);
1488
2117
  if (detected.length === 0) {
@@ -1493,17 +2122,27 @@ function printDetectionResults(detected) {
1493
2122
  console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.root}`);
1494
2123
  }
1495
2124
  }
2125
+ function printMcpDetectionResults(detectedMcp) {
2126
+ if (detectedMcp.length === 0) return;
2127
+ console.log("\n[skill-forge] init \u2014 MCP config detection");
2128
+ for (const d of detectedMcp) {
2129
+ console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.path}`);
2130
+ }
2131
+ }
1496
2132
  function handoffTemplateFor(agent) {
1497
2133
  return agent.defaultHandoffTemplate ?? [agent.id, "-p", HANDOFF_INSTRUCTION];
1498
2134
  }
1499
- function buildConfig(selected, defaultTargetEntry, handoffAgent) {
2135
+ function buildConfig(selected, defaultTargetEntry, handoffAgent, selectedMcp = []) {
1500
2136
  const existing = loadConfig();
1501
2137
  return {
1502
2138
  ...existing,
1503
- skillsRoots: selected.map((s) => s.root),
1504
- agents: selected.map((s) => ({ id: s.agent.id, skillsRoot: s.root })),
2139
+ // Skill-root selections are only overwritten when this run actually offered a choice —
2140
+ // an mcp-only run (no skill roots detected) must not blank out an existing skillsRoots/agents.
2141
+ skillsRoots: selected.length > 0 ? selected.map((s) => s.root) : existing.skillsRoots,
2142
+ agents: selected.length > 0 ? selected.map((s) => ({ id: s.agent.id, skillsRoot: s.root })) : existing.agents,
1505
2143
  defaultTarget: defaultTargetEntry?.root ?? existing.defaultTarget,
1506
- handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand
2144
+ handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand,
2145
+ mcpTargets: selectedMcp.length > 0 ? selectedMcp.map((m) => m.path) : existing.mcpTargets
1507
2146
  };
1508
2147
  }
1509
2148
  function printSummary(config) {
@@ -1512,6 +2151,7 @@ function printSummary(config) {
1512
2151
  console.log(` gate targets: ${config.skillsRoots.join(", ")}`);
1513
2152
  if (config.defaultTarget) console.log(` default target: ${config.defaultTarget}`);
1514
2153
  if (config.handoffCommand?.length) console.log(` handoff command: ${config.handoffCommand.join(" ")}`);
2154
+ if (config.mcpTargets?.length) console.log(` mcp targets: ${config.mcpTargets.join(", ")}`);
1515
2155
  }
1516
2156
  function parseChoice(answer, max) {
1517
2157
  const n = Number(answer);
@@ -1537,6 +2177,26 @@ async function promptMultiSelect(rl, detected) {
1537
2177
  }
1538
2178
  return detected.filter((_, i) => chosen.has(i));
1539
2179
  }
2180
+ async function promptMcpMultiSelect(rl, detectedMcp) {
2181
+ const chosen = new Set(detectedMcp.map((_, i) => i));
2182
+ for (; ; ) {
2183
+ console.log("\nMCP targets (all detected config files pre-selected \u2014 type a number to toggle, Enter to confirm):");
2184
+ detectedMcp.forEach((d, i) => {
2185
+ const mark = chosen.has(i) ? "[x]" : "[ ]";
2186
+ console.log(` ${mark} ${i + 1}. ${d.agent.displayName} (${d.scope}) \u2014 ${d.path}`);
2187
+ });
2188
+ const answer = (await rl.question("> ")).trim();
2189
+ if (answer === "") break;
2190
+ const idx = parseChoice(answer, detectedMcp.length);
2191
+ if (idx !== void 0) {
2192
+ if (chosen.has(idx)) chosen.delete(idx);
2193
+ else chosen.add(idx);
2194
+ } else {
2195
+ console.log(" (invalid selection \u2014 enter a listed number, or press Enter to confirm)");
2196
+ }
2197
+ }
2198
+ return detectedMcp.filter((_, i) => chosen.has(i));
2199
+ }
1540
2200
  async function promptDefaultTarget(rl, selected) {
1541
2201
  console.log("\nDefault promotion target:");
1542
2202
  selected.forEach((d, i) => console.log(` ${i + 1}. ${d.agent.displayName} \u2014 ${d.root}`));
@@ -1559,17 +2219,19 @@ async function init(options = {}) {
1559
2219
  const cwd = options.cwd ?? process.cwd();
1560
2220
  const home = options.home ?? homedir4();
1561
2221
  const detected = detectAgents(cwd, home);
2222
+ const detectedMcp = detectMcpTargets(cwd, home);
1562
2223
  if (options.list) {
1563
2224
  printDetectionResults(detected);
2225
+ printMcpDetectionResults(detectedMcp);
1564
2226
  return;
1565
2227
  }
1566
- if (detected.length === 0) {
1567
- console.log("[skill-forge] No known agent skill directories were found.");
2228
+ if (detected.length === 0 && detectedMcp.length === 0) {
2229
+ console.log("[skill-forge] No known agent skill directories or MCP config files were found.");
1568
2230
  console.log(" Install a coding agent (Claude Code, Codex, Cursor, ...) or create a skills root, then re-run `skill-forge init`.");
1569
2231
  return;
1570
2232
  }
1571
2233
  if (options.defaults) {
1572
- const config = buildConfig(detected, detected[0], void 0);
2234
+ const config = buildConfig(detected, detected[0], void 0, detectedMcp);
1573
2235
  saveConfig(config);
1574
2236
  printSummary(config);
1575
2237
  return;
@@ -1579,16 +2241,18 @@ async function init(options = {}) {
1579
2241
  return;
1580
2242
  }
1581
2243
  printDetectionResults(detected);
2244
+ printMcpDetectionResults(detectedMcp);
1582
2245
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1583
2246
  try {
1584
- const selected = await promptMultiSelect(rl, detected);
1585
- if (selected.length === 0) {
2247
+ const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
2248
+ const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
2249
+ if (selected.length === 0 && selectedMcp.length === 0) {
1586
2250
  console.log("[skill-forge] No targets selected \u2014 aborting, nothing written.");
1587
2251
  return;
1588
2252
  }
1589
- const target = await promptDefaultTarget(rl, selected);
1590
- const handoffAgent = await promptHandoffAgent(rl, detected);
1591
- const config = buildConfig(selected, target, handoffAgent);
2253
+ const target = selected.length > 0 ? await promptDefaultTarget(rl, selected) : void 0;
2254
+ const handoffAgent = detected.length > 0 ? await promptHandoffAgent(rl, detected) : void 0;
2255
+ const config = buildConfig(selected, target, handoffAgent, selectedMcp);
1592
2256
  saveConfig(config);
1593
2257
  printSummary(config);
1594
2258
  } finally {
@@ -1597,7 +2261,7 @@ async function init(options = {}) {
1597
2261
  }
1598
2262
  async function maybeOfferInit(options = {}) {
1599
2263
  if (options.json || options.yes || !process.stdin.isTTY) return;
1600
- if (existsSync10(getConfigPath())) return;
2264
+ if (existsSync12(getConfigPath())) return;
1601
2265
  console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
1602
2266
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1603
2267
  let answer = "";
@@ -1613,6 +2277,7 @@ async function maybeOfferInit(options = {}) {
1613
2277
 
1614
2278
  // src/commands/add.ts
1615
2279
  var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
2280
+ var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
1616
2281
  function logOutcome(message, json) {
1617
2282
  if (json) console.error(message);
1618
2283
  else console.log(message);
@@ -1620,11 +2285,16 @@ function logOutcome(message, json) {
1620
2285
  async function add(source, options = {}) {
1621
2286
  await maybeOfferInit({ json: options.json, yes: options.yes });
1622
2287
  const config = loadConfig();
2288
+ const artifact = options.artifact ?? "skill";
2289
+ if (artifact === "mcp") {
2290
+ await addMcp(source, config, options);
2291
+ return;
2292
+ }
1623
2293
  const targetRoot = options.target ?? config.defaultTarget ?? config.skillsRoots[0];
1624
2294
  if (!targetRoot) {
1625
2295
  throw new Error("no target skills root configured; pass --target <dir> or set skillsRoots in the config");
1626
2296
  }
1627
- const resolved = resolve(source);
2297
+ const resolved = resolve(source, artifact);
1628
2298
  const entry = await quarantine(resolved, config.quarantineDir);
1629
2299
  if (entry.skillDirs.length === 0) {
1630
2300
  console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
@@ -1640,9 +2310,13 @@ async function add(source, options = {}) {
1640
2310
  }
1641
2311
  const { safety, gateResult, proEnabled } = await runGate(entry.skillDirs[0], targetRoot, {
1642
2312
  proFeatureLabel: PRO_FEATURE_LABEL,
1643
- strictness: config.strictness
2313
+ strictness: config.strictness,
2314
+ artifact
1644
2315
  });
1645
2316
  const { profile, overlap } = gateResult;
2317
+ if (profile.kind !== "skill") {
2318
+ throw new Error(`expected a skill candidate, found a ${profile.kind}`);
2319
+ }
1646
2320
  printGateOutput(gateResult, options.json);
1647
2321
  const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
1648
2322
  let installedPath = null;
@@ -1695,6 +2369,78 @@ async function add(source, options = {}) {
1695
2369
  }
1696
2370
  }
1697
2371
  }
2372
+ async function addMcp(source, config, options) {
2373
+ const resolved = resolve(source, "mcp");
2374
+ const entry = await quarantine(resolved, config.quarantineDir);
2375
+ const candidatePath = resolveMcpCandidatePath(entry);
2376
+ const { safety, gateResult, proEnabled } = await runGate(candidatePath, "", {
2377
+ proFeatureLabel: MCP_PRO_FEATURE_LABEL,
2378
+ strictness: config.strictness,
2379
+ artifact: "mcp",
2380
+ mcpTargets: config.mcpTargets ?? []
2381
+ });
2382
+ const { profile, overlap } = gateResult;
2383
+ if (profile.kind !== "mcp") {
2384
+ throw new Error(`expected an MCP candidate, found a ${profile.kind}`);
2385
+ }
2386
+ if (profile.serverCount > 1) {
2387
+ console.error(
2388
+ `[skill-forge] ${profile.serverCount} MCP servers found in ${source}; gating/promoting the first \u2014 the whole quarantine entry is promoted/rejected together:
2389
+ ${profile.servers.join(", ")}`
2390
+ );
2391
+ }
2392
+ printGateOutput(gateResult, options.json);
2393
+ const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
2394
+ let installedPath = null;
2395
+ if (decision === "promote") {
2396
+ const detail = resolveMcpCandidateDetail(profile);
2397
+ const serverProfile = {
2398
+ name: detail.name,
2399
+ version: detail.version ?? "unknown",
2400
+ envVars: detail.env ? Object.keys(detail.env) : void 0,
2401
+ candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2402
+ };
2403
+ const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join14(process.cwd(), ".mcp.json");
2404
+ const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
2405
+ reject(entry);
2406
+ logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
2407
+ if (result.backupPath) {
2408
+ logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
2409
+ }
2410
+ if (result.envVarsToFill.length > 0) {
2411
+ logOutcome(`[skill-forge] fill in these env vars before use: ${result.envVarsToFill.join(", ")}`, options.json);
2412
+ }
2413
+ installedPath = result.target;
2414
+ } else if (decision === "reject") {
2415
+ reject(entry);
2416
+ logOutcome("[skill-forge] rejected \u2014 quarantine sandbox removed", options.json);
2417
+ if (safety.verdict === "block") process.exitCode = 1;
2418
+ return;
2419
+ } else {
2420
+ hold(entry);
2421
+ logOutcome(`[skill-forge] held in quarantine -> ${entry.quarantinePath}`, options.json);
2422
+ }
2423
+ if (proEnabled) {
2424
+ appendQueueEntry({
2425
+ source,
2426
+ sourceType: resolved.type,
2427
+ installedPath,
2428
+ quarantinePath: entry.quarantinePath,
2429
+ gate: {
2430
+ license: null,
2431
+ safetyVerdict: safety.verdict,
2432
+ safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
2433
+ overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
2434
+ suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
2435
+ },
2436
+ status: "pending",
2437
+ artifactType: "mcp"
2438
+ });
2439
+ if (options.ingest && installedPath) {
2440
+ runHandoff(config, installedPath);
2441
+ }
2442
+ }
2443
+ }
1698
2444
  function yesDecision(verdict) {
1699
2445
  return verdict === "block" ? "reject" : "promote";
1700
2446
  }
@@ -1722,25 +2468,41 @@ ${prompt}`);
1722
2468
  async function scan(source, options = {}) {
1723
2469
  await maybeOfferInit({ json: options.json });
1724
2470
  const config = loadConfig();
2471
+ const artifact = options.artifact ?? "skill";
1725
2472
  const skillsRoot = config.defaultTarget ?? config.skillsRoots[0];
1726
- if (!skillsRoot) {
2473
+ if (artifact === "skill" && !skillsRoot) {
1727
2474
  throw new Error("no skills root configured; set skillsRoots in the config");
1728
2475
  }
1729
- const resolved = resolve(source);
2476
+ const resolved = resolve(source, artifact);
1730
2477
  const entry = await quarantine(resolved, config.quarantineDir);
1731
2478
  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;
2479
+ let candidatePath;
2480
+ if (artifact === "mcp") {
2481
+ candidatePath = resolveMcpCandidatePath(entry);
2482
+ } else {
2483
+ if (entry.skillDirs.length === 0) {
2484
+ console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
2485
+ process.exitCode = 1;
2486
+ return;
2487
+ }
2488
+ if (entry.skillDirs.length > 1) {
2489
+ console.error(
2490
+ `[skill-forge] ${entry.skillDirs.length} skills found in ${source}; scanning the first:
2491
+ ` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
2492
+ );
2493
+ }
2494
+ candidatePath = entry.skillDirs[0];
1736
2495
  }
1737
- if (entry.skillDirs.length > 1) {
2496
+ const { safety, gateResult } = await runGate(candidatePath, skillsRoot ?? "", {
2497
+ strictness: config.strictness,
2498
+ artifact,
2499
+ mcpTargets: config.mcpTargets ?? []
2500
+ });
2501
+ if (artifact === "mcp" && gateResult.profile.kind === "mcp" && gateResult.profile.serverCount > 1) {
1738
2502
  console.error(
1739
- `[skill-forge] ${entry.skillDirs.length} skills found in ${source}; scanning the first:
1740
- ` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
2503
+ `[skill-forge] ${gateResult.profile.serverCount} MCP servers found in ${source}; scanning the first \u2014 ` + gateResult.profile.servers.join(", ")
1741
2504
  );
1742
2505
  }
1743
- const { safety, gateResult } = await runGate(entry.skillDirs[0], skillsRoot, { strictness: config.strictness });
1744
2506
  printGateOutput(gateResult, options.json);
1745
2507
  if (safety.verdict === "block") {
1746
2508
  process.exitCode = 1;
@@ -1751,7 +2513,7 @@ async function scan(source, options = {}) {
1751
2513
  }
1752
2514
 
1753
2515
  // src/commands/list.ts
1754
- import { join as join12 } from "path";
2516
+ import { join as join15 } from "path";
1755
2517
  async function list() {
1756
2518
  await maybeOfferInit();
1757
2519
  const config = loadConfig();
@@ -1764,14 +2526,14 @@ async function list() {
1764
2526
  }
1765
2527
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
1766
2528
  for (const id of ids) {
1767
- const skillDirs = findSkillDirs(join12(quarantineDir, id));
2529
+ const skillDirs = findSkillDirs(join15(quarantineDir, id));
1768
2530
  if (skillDirs.length === 0) {
1769
2531
  console.log(` ${id} (no SKILL.md found)`);
1770
2532
  continue;
1771
2533
  }
1772
2534
  const names = skillDirs.map((dir) => {
1773
2535
  try {
1774
- return readFrontmatter(join12(dir, "SKILL.md")).name;
2536
+ return readFrontmatter(join15(dir, "SKILL.md")).name;
1775
2537
  } catch {
1776
2538
  return dir;
1777
2539
  }
@@ -1795,10 +2557,19 @@ async function status() {
1795
2557
  // src/cli.ts
1796
2558
  var require2 = createRequire(import.meta.url);
1797
2559
  var pkg = require2("../package.json");
2560
+ function parseArtifact(value) {
2561
+ if (value !== "skill" && value !== "mcp") {
2562
+ throw new UserError(`--artifact must be "skill" or "mcp" (got "${value}")`);
2563
+ }
2564
+ return value;
2565
+ }
1798
2566
  var program = new Command();
1799
2567
  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);
2568
+ 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(
2569
+ "--mcp-target <file>",
2570
+ "MCP config file to promote into (mcp artifacts only; defaults to config.mcpTargets[0], then <cwd>/.mcp.json)"
2571
+ ).option("--force", "overwrite an existing same-name MCP server entry when promoting (mcp artifacts only)").action(add);
2572
+ 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
2573
  program.command("list").description("List skills currently in quarantine").action(list);
1803
2574
  program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
1804
2575
  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);