@rhize/skill-forge 0.3.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,11 +6,13 @@ 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";
12
13
  import { homedir } from "os";
13
14
  import { join } from "path";
15
+ var DEFAULT_STRICTNESS = "block-high";
14
16
  function getHomeDir() {
15
17
  return process.env.SKILL_FORGE_HOME ?? join(homedir(), ".skill-forge");
16
18
  }
@@ -22,7 +24,7 @@ function defaultConfig() {
22
24
  return {
23
25
  skillsRoots: [join(process.cwd(), ".claude", "skills")],
24
26
  quarantineDir: join(home, "quarantine"),
25
- strictness: "block-high"
27
+ strictness: DEFAULT_STRICTNESS
26
28
  };
27
29
  }
28
30
  function loadConfig() {
@@ -40,7 +42,8 @@ function loadConfig() {
40
42
  licenseKey: parsed.licenseKey ?? defaults.licenseKey,
41
43
  defaultTarget: parsed.defaultTarget ?? defaults.defaultTarget,
42
44
  agents: parsed.agents ?? defaults.agents,
43
- handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand
45
+ handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand,
46
+ mcpTargets: parsed.mcpTargets ?? defaults.mcpTargets
44
47
  };
45
48
  }
46
49
  function saveConfig(config) {
@@ -53,15 +56,53 @@ function saveConfig(config) {
53
56
 
54
57
  // src/install/resolve.ts
55
58
  import { existsSync as existsSync2 } from "fs";
59
+
60
+ // src/errors.ts
61
+ var UserError = class extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = "UserError";
65
+ }
66
+ };
67
+
68
+ // src/util.ts
69
+ import { statSync } from "fs";
70
+ import { homedir as homedir2 } from "os";
71
+ import { join as join2 } from "path";
72
+ function expandHome(p) {
73
+ if (p === "~") return homedir2();
74
+ if (p.startsWith("~/")) return join2(homedir2(), p.slice(2));
75
+ return p;
76
+ }
77
+ function stripQuotes(s) {
78
+ return s.replace(/^['"]+/, "").replace(/['"]+$/, "");
79
+ }
80
+ function isDir(p) {
81
+ try {
82
+ return statSync(p).isDirectory();
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ // src/install/resolve.ts
56
89
  var GIT_URL_PATTERN = /^(git@|https?:\/\/|ssh:\/\/)/;
57
90
  var LOCAL_PATH_PATTERN = /^(\.{1,2}\/|\/|~\/|~$)/;
58
- function resolve(source) {
91
+ var NPM_SCOPED_PATTERN = /^@[^/]+\/[^/]+$/;
92
+ function resolve(source, artifact = "skill") {
59
93
  const value = source.trim();
94
+ if (value.startsWith("-")) {
95
+ throw new UserError(`skill source cannot start with "-" (looks like a flag, not a source): ${value}`);
96
+ }
60
97
  if (GIT_URL_PATTERN.test(value) || value.endsWith(".git")) {
61
98
  return { type: "git", value };
62
99
  }
63
- if (existsSync2(value) || LOCAL_PATH_PATTERN.test(value)) {
64
- return { type: "local", value };
100
+ const localCandidate = expandHome(value);
101
+ if (existsSync2(localCandidate) || LOCAL_PATH_PATTERN.test(value)) {
102
+ return { type: "local", value: localCandidate };
103
+ }
104
+ if (artifact === "mcp" && (NPM_SCOPED_PATTERN.test(value) || !value.includes("/"))) {
105
+ return { type: "npm", value };
65
106
  }
66
107
  return { type: "skills.sh", value };
67
108
  }
@@ -70,24 +111,15 @@ function resolve(source) {
70
111
  import { randomUUID } from "crypto";
71
112
  import { spawn } from "child_process";
72
113
  import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync } from "fs";
73
- import { basename, join as join2, resolve as resolvePath } from "path";
74
-
75
- // src/errors.ts
76
- var UserError = class extends Error {
77
- constructor(message) {
78
- super(message);
79
- this.name = "UserError";
80
- }
81
- };
82
-
83
- // src/install/quarantine.ts
114
+ import { basename, join as join3, resolve as resolvePath } from "path";
84
115
  var SKILL_MANIFEST = "SKILL.md";
85
116
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules"]);
86
117
  async function quarantine(source, quarantineDir, deps = {}) {
87
118
  const exec = deps.exec ?? defaultExec;
88
119
  const id = randomUUID();
89
- const quarantinePath = join2(quarantineDir, id);
120
+ const quarantinePath = join3(quarantineDir, id);
90
121
  mkdirSync2(quarantinePath, { recursive: true });
122
+ let packageDir;
91
123
  switch (source.type) {
92
124
  case "skills.sh": {
93
125
  const result = await exec("npx", ["-y", "skills@latest", "add", source.value], {
@@ -101,8 +133,8 @@ async function quarantine(source, quarantineDir, deps = {}) {
101
133
  break;
102
134
  }
103
135
  case "git": {
104
- const clonePath = join2(quarantinePath, deriveGitDirName(source.value));
105
- const result = await exec("git", ["clone", "--depth", "1", source.value, clonePath], {
136
+ const clonePath = join3(quarantinePath, deriveGitDirName(source.value));
137
+ const result = await exec("git", ["clone", "--depth", "1", "--", source.value, clonePath], {
106
138
  cwd: quarantinePath
107
139
  });
108
140
  if (result.code !== 0) {
@@ -117,14 +149,40 @@ async function quarantine(source, quarantineDir, deps = {}) {
117
149
  throw new UserError(`local path not found: ${source.value}`);
118
150
  }
119
151
  const dirName = basename(resolvePath(source.value));
120
- cpSync(source.value, join2(quarantinePath, dirName), { recursive: true });
152
+ cpSync(source.value, join3(quarantinePath, dirName), { recursive: true });
153
+ break;
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
+ }
121
179
  break;
122
180
  }
123
181
  default: {
124
182
  throw new UserError(`unknown source type: ${source.type}`);
125
183
  }
126
184
  }
127
- return { id, quarantinePath, skillDirs: findSkillDirs(quarantinePath), source };
185
+ return { id, quarantinePath, skillDirs: findSkillDirs(quarantinePath), packageDir, source };
128
186
  }
129
187
  function listQuarantineIds(quarantineDir) {
130
188
  if (!existsSync3(quarantineDir)) return [];
@@ -132,7 +190,7 @@ function listQuarantineIds(quarantineDir) {
132
190
  }
133
191
  function findSkillDirs(root) {
134
192
  const found = [];
135
- function walk2(dir) {
193
+ function walk3(dir) {
136
194
  let entries;
137
195
  try {
138
196
  entries = readdirSync(dir, { withFileTypes: true });
@@ -144,13 +202,20 @@ function findSkillDirs(root) {
144
202
  }
145
203
  for (const entry of entries) {
146
204
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
147
- walk2(join2(dir, entry.name));
205
+ walk3(join3(dir, entry.name));
148
206
  }
149
207
  }
150
208
  }
151
- walk2(root);
209
+ walk3(root);
152
210
  return found;
153
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
+ }
154
219
  function deriveGitDirName(url) {
155
220
  const stripped = url.replace(/\.git$/, "");
156
221
  const segments = stripped.split(/[/:]/).filter(Boolean);
@@ -170,11 +235,11 @@ function defaultExec(command, args, options) {
170
235
 
171
236
  // src/install/promote.ts
172
237
  import { cpSync as cpSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, renameSync, rmSync } from "fs";
173
- import { basename as basename2, join as join3 } from "path";
238
+ import { basename as basename2, join as join4 } from "path";
174
239
  function promote(entry, targetSkillsRoot, options = {}) {
175
240
  const force = options.force ?? false;
176
241
  for (const skillDir of entry.skillDirs) {
177
- const dest = join3(targetSkillsRoot, basename2(skillDir));
242
+ const dest = join4(targetSkillsRoot, basename2(skillDir));
178
243
  if (existsSync4(dest) && !force) {
179
244
  throw new Error(`Refusing to overwrite existing skill dir: ${dest} (pass force to override)`);
180
245
  }
@@ -182,7 +247,7 @@ function promote(entry, targetSkillsRoot, options = {}) {
182
247
  mkdirSync3(targetSkillsRoot, { recursive: true });
183
248
  const promoted = [];
184
249
  for (const skillDir of entry.skillDirs) {
185
- const dest = join3(targetSkillsRoot, basename2(skillDir));
250
+ const dest = join4(targetSkillsRoot, basename2(skillDir));
186
251
  if (existsSync4(dest)) {
187
252
  rmSync(dest, { recursive: true, force: true });
188
253
  }
@@ -210,9 +275,98 @@ function moveDir(src, dest) {
210
275
  }
211
276
  }
212
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
+
213
367
  // src/provenance.ts
214
- import { appendFileSync, existsSync as existsSync5, statSync, writeFileSync as writeFileSync2 } from "fs";
215
- import { join as join4 } from "path";
368
+ import { appendFileSync, existsSync as existsSync6, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
369
+ import { join as join5 } from "path";
216
370
  var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
217
371
  var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
218
372
  function todayLocalIsoDate() {
@@ -226,7 +380,7 @@ function appendProvenance(entry, skillsRoot) {
226
380
  if (!FORGE_VERBS.includes(entry.verb)) {
227
381
  throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
228
382
  }
229
- if (!existsSync5(skillsRoot) || !statSync(skillsRoot).isDirectory()) {
383
+ if (!existsSync6(skillsRoot) || !statSync2(skillsRoot).isDirectory()) {
230
384
  throw new Error(`skills-root not a directory: ${skillsRoot}`);
231
385
  }
232
386
  const today = todayLocalIsoDate();
@@ -243,51 +397,33 @@ function appendProvenance(entry, skillsRoot) {
243
397
  - **Drift check:** \`${driftCheck}\`
244
398
  - **Notes:** ${entry.notes ?? ""}
245
399
  `;
246
- const ledgerPath = join4(skillsRoot, "SOURCES.md");
247
- if (!existsSync5(ledgerPath)) {
248
- writeFileSync2(ledgerPath, LEDGER_HEADER, "utf-8");
400
+ const ledgerPath = join5(skillsRoot, "SOURCES.md");
401
+ if (!existsSync6(ledgerPath)) {
402
+ writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
249
403
  }
250
404
  appendFileSync(ledgerPath, block, "utf-8");
251
405
  return ledgerPath;
252
406
  }
253
407
 
408
+ // src/commands/gatePipeline.ts
409
+ import { readdirSync as readdirSync6 } from "fs";
410
+ import { join as join10 } from "path";
411
+
254
412
  // src/gate/profile.ts
255
413
  import { execFileSync } from "child_process";
256
414
  import {
257
415
  closeSync,
258
- existsSync as existsSync6,
416
+ existsSync as existsSync7,
259
417
  mkdtempSync,
260
418
  openSync,
261
- readFileSync as readFileSync2,
419
+ readFileSync as readFileSync4,
262
420
  readdirSync as readdirSync2,
263
421
  readSync,
264
422
  rmSync as rmSync2,
265
423
  statSync as statSync3
266
424
  } from "fs";
267
425
  import { tmpdir } from "os";
268
- import { basename as basename3, dirname, extname, join as join6, resolve as resolve2 } from "path";
269
-
270
- // src/util.ts
271
- import { statSync as statSync2 } from "fs";
272
- import { homedir as homedir2 } from "os";
273
- import { join as join5 } from "path";
274
- function expandHome(p) {
275
- if (p === "~") return homedir2();
276
- if (p.startsWith("~/")) return join5(homedir2(), p.slice(2));
277
- return p;
278
- }
279
- function stripQuotes(s) {
280
- return s.replace(/^['"]+/, "").replace(/['"]+$/, "");
281
- }
282
- function isDir(p) {
283
- try {
284
- return statSync2(p).isDirectory();
285
- } catch {
286
- return false;
287
- }
288
- }
289
-
290
- // src/gate/profile.ts
426
+ import { basename as basename3, dirname as dirname2, extname, join as join6, resolve as resolve2 } from "path";
291
427
  var SPDX_HINTS = {
292
428
  mit: "MIT (permissive)",
293
429
  apache: "Apache-2.0 (permissive)",
@@ -329,7 +465,7 @@ var PYTHON_STDLIB = /* @__PURE__ */ new Set([
329
465
  ]);
330
466
  function profileSkill(inputPath) {
331
467
  const p = resolve2(expandHome(inputPath));
332
- if (!existsSync6(p)) {
468
+ if (!existsSync7(p)) {
333
469
  throw new Error(`path does not exist: ${p}`);
334
470
  }
335
471
  const stat = statSync3(p);
@@ -343,8 +479,41 @@ function profileSkill(inputPath) {
343
479
  return profileSkillDir(p);
344
480
  }
345
481
  function classifyInput(p) {
346
- if (existsSync6(join6(p, ".claude-plugin", "plugin.json"))) return "plugin";
347
- 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";
348
517
  return "skill";
349
518
  }
350
519
  function resolveSkillMd(inputPath) {
@@ -358,16 +527,16 @@ function resolveSkillMd(inputPath) {
358
527
  }
359
528
  const stat = statSync3(currentPath);
360
529
  if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
361
- return { skillMd: currentPath, root: dirname(currentPath), tmpDir };
530
+ return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
362
531
  }
363
532
  if (stat.isDirectory()) {
364
533
  const direct = join6(currentPath, "SKILL.md");
365
- if (existsSync6(direct)) {
534
+ if (existsSync7(direct)) {
366
535
  return { skillMd: direct, root: currentPath, tmpDir };
367
536
  }
368
537
  const matches = findSkillMdOneLevelDown(currentPath);
369
538
  if (matches.length === 1) {
370
- return { skillMd: matches[0], root: dirname(matches[0]), tmpDir };
539
+ return { skillMd: matches[0], root: dirname2(matches[0]), tmpDir };
371
540
  }
372
541
  if (matches.length > 1) {
373
542
  cleanupTmp(tmpDir);
@@ -388,7 +557,7 @@ function findSkillMdOneLevelDown(dir) {
388
557
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
389
558
  if (entry.isDirectory()) {
390
559
  const candidate = join6(dir, entry.name, "SKILL.md");
391
- if (existsSync6(candidate)) matches.add(candidate);
560
+ if (existsSync7(candidate)) matches.add(candidate);
392
561
  }
393
562
  }
394
563
  const skillsDir = join6(dir, "skills");
@@ -396,7 +565,7 @@ function findSkillMdOneLevelDown(dir) {
396
565
  for (const entry of readdirSync2(skillsDir, { withFileTypes: true })) {
397
566
  if (entry.isDirectory()) {
398
567
  const candidate = join6(skillsDir, entry.name, "SKILL.md");
399
- if (existsSync6(candidate)) matches.add(candidate);
568
+ if (existsSync7(candidate)) matches.add(candidate);
400
569
  }
401
570
  }
402
571
  }
@@ -480,8 +649,8 @@ function detectLicense(root, fm) {
480
649
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
481
650
  for (const name of names) {
482
651
  const p = join6(root, name);
483
- if (existsSync6(p) && statSync3(p).isFile()) {
484
- 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();
485
654
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
486
655
  if (head.includes(hint)) return label;
487
656
  }
@@ -520,7 +689,7 @@ function scanMcpAndImports(text, root) {
520
689
  if (isDir(scriptsDir)) {
521
690
  for (const f of walkFiles(scriptsDir)) {
522
691
  if (!f.endsWith(".py")) continue;
523
- const ft = readFileSync2(f, "utf-8");
692
+ const ft = readFileSync4(f, "utf-8");
524
693
  for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
525
694
  const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
526
695
  let m;
@@ -537,7 +706,7 @@ function scanMcpAndImports(text, root) {
537
706
  function profileSkillDir(inputPath) {
538
707
  const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
539
708
  try {
540
- const text = readFileSync2(skillMd, "utf-8");
709
+ const text = readFileSync4(skillMd, "utf-8");
541
710
  const fm = parseFrontmatter(text);
542
711
  const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
543
712
  const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
@@ -557,7 +726,8 @@ function profileSkillDir(inputPath) {
557
726
  topHeaders: headers.slice(0, 12),
558
727
  resources,
559
728
  mcpDependencies: mcpDeps,
560
- externalPythonDeps: externalDeps
729
+ externalPythonDeps: externalDeps,
730
+ artifactType: detectArtifactType(root)
561
731
  };
562
732
  } finally {
563
733
  cleanupTmp(tmpDir);
@@ -567,14 +737,14 @@ function profilePlugin(p) {
567
737
  const manifestPath = join6(p, ".claude-plugin", "plugin.json");
568
738
  let manifest;
569
739
  try {
570
- manifest = JSON.parse(readFileSync2(manifestPath, "utf-8"));
740
+ manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
571
741
  } catch (err) {
572
742
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
573
743
  }
574
744
  const skillsDir = join6(p, "skills");
575
745
  let skills = [];
576
746
  if (isDir(skillsDir)) {
577
- 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();
578
748
  }
579
749
  const commandsDir = join6(p, "commands");
580
750
  const commands = isDir(commandsDir) ? readdirSync2(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
@@ -589,35 +759,59 @@ function profilePlugin(p) {
589
759
  skills,
590
760
  skillCount: skills.length,
591
761
  commands,
592
- mcpServers
762
+ mcpServers,
763
+ artifactType: detectArtifactType(p)
593
764
  };
594
765
  }
595
766
  function profileMcp(p) {
596
- const cfgPath = existsSync6(join6(p, ".mcp.json")) ? join6(p, ".mcp.json") : join6(p, "mcp.json");
597
- 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;
598
788
  try {
599
- cfg = JSON.parse(readFileSync2(cfgPath, "utf-8"));
789
+ pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
600
790
  } catch (err) {
601
- throw new Error(`could not parse MCP config ${cfgPath}: ${err.message}`);
791
+ throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
602
792
  }
603
- const servers = cfg.mcpServers ?? cfg.servers ?? {};
604
- 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);
605
794
  return {
606
795
  kind: "mcp",
607
796
  inputPath: p,
608
- config: cfgPath,
609
- servers: names,
610
- serverCount: names.length
797
+ config: pkgPath,
798
+ servers: [name],
799
+ serverCount: 1,
800
+ artifactType: "mcp"
611
801
  };
612
802
  }
613
803
 
614
804
  // src/gate/safety.ts
615
- 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";
616
806
  import { execFile } from "child_process";
617
807
  import { delimiter, extname as extname2, join as join7 } from "path";
618
808
  import { promisify } from "util";
619
809
  var execFileAsync = promisify(execFile);
620
- var BLOCK_SEVERITIES = /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]);
810
+ var BLOCK_SEVERITIES_BY_STRICTNESS = {
811
+ "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
812
+ "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
813
+ "warn-only": /* @__PURE__ */ new Set()
814
+ };
621
815
  var DENY_RULES = [
622
816
  {
623
817
  rule: "curl-pipe-shell",
@@ -724,7 +918,7 @@ function readTextFile(file) {
724
918
  if (BINARY_EXT.has(extname2(file).toLowerCase())) return void 0;
725
919
  try {
726
920
  if (statSync4(file).size > MAX_FILE_BYTES) return void 0;
727
- const content = readFileSync3(file, "utf-8");
921
+ const content = readFileSync5(file, "utf-8");
728
922
  if (content.slice(0, 512).includes("\0")) return void 0;
729
923
  return content;
730
924
  } catch {
@@ -799,19 +993,20 @@ async function scanWithSkillSpector(targetPath) {
799
993
  return [];
800
994
  }
801
995
  }
802
- function verdictFor(findings) {
803
- if (findings.some((f) => BLOCK_SEVERITIES.has(f.severity))) return "block";
996
+ function verdictFor(findings, strictness) {
997
+ const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
998
+ if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
804
999
  if (findings.length > 0) return "warn";
805
1000
  return "pass";
806
1001
  }
807
- async function scanSafety(targetPath) {
1002
+ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
808
1003
  const findings = [...scanStaticRules(targetPath), ...await scanWithSkillSpector(targetPath)];
809
- return { findings, verdict: verdictFor(findings) };
1004
+ return { findings, verdict: verdictFor(findings, strictness) };
810
1005
  }
811
1006
 
812
1007
  // src/gate/overlap.ts
813
- import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync4 } from "fs";
814
- 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";
815
1010
  var STOPWORDS = new Set(
816
1011
  `
817
1012
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -832,8 +1027,8 @@ function splitLimited(text, sep, maxSplit) {
832
1027
  return parts;
833
1028
  }
834
1029
  function readFrontmatter(skillMdPath) {
835
- const text = readFileSync4(skillMdPath, "utf-8");
836
- const meta = { name: basename4(dirname2(skillMdPath)), description: "" };
1030
+ const text = readFileSync6(skillMdPath, "utf-8");
1031
+ const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
837
1032
  if (text.startsWith("---\n")) {
838
1033
  const block = splitLimited(text, "---", 2)[1] ?? "";
839
1034
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -927,7 +1122,7 @@ function suggestVerbCode(topScore) {
927
1122
  return verbTier(topScore).verb;
928
1123
  }
929
1124
  function listSubdirs(dir) {
930
- if (!existsSync7(dir)) return [];
1125
+ if (!existsSync8(dir)) return [];
931
1126
  let entries;
932
1127
  try {
933
1128
  entries = readdirSync4(dir, { withFileTypes: true });
@@ -940,16 +1135,16 @@ function findSkillMdFiles(root) {
940
1135
  const found = /* @__PURE__ */ new Set();
941
1136
  for (const dir of listSubdirs(root)) {
942
1137
  const candidate = join8(dir, "SKILL.md");
943
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1138
+ if (existsSync8(candidate)) found.add(resolve3(candidate));
944
1139
  }
945
1140
  for (const dir of listSubdirs(join8(root, "skills"))) {
946
1141
  const candidate = join8(dir, "SKILL.md");
947
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1142
+ if (existsSync8(candidate)) found.add(resolve3(candidate));
948
1143
  }
949
1144
  for (const topDir of listSubdirs(root)) {
950
1145
  for (const dir of listSubdirs(join8(topDir, "skills"))) {
951
1146
  const candidate = join8(dir, "SKILL.md");
952
- if (existsSync7(candidate)) found.add(resolve3(candidate));
1147
+ if (existsSync8(candidate)) found.add(resolve3(candidate));
953
1148
  }
954
1149
  }
955
1150
  return Array.from(found).filter((p) => !p.includes("/.git/")).sort();
@@ -963,7 +1158,7 @@ function scanOverlap(candidatePath, skillsRoot) {
963
1158
  if (isDir(cpath)) {
964
1159
  cpath = join8(cpath, "SKILL.md");
965
1160
  }
966
- if (!existsSync7(cpath)) {
1161
+ if (!existsSync8(cpath)) {
967
1162
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
968
1163
  }
969
1164
  const cand = readFrontmatter(cpath);
@@ -986,6 +1181,338 @@ function scanOverlap(candidatePath, skillsRoot) {
986
1181
  };
987
1182
  }
988
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
+
989
1516
  // src/license.ts
990
1517
  import { verify as cryptoVerify } from "crypto";
991
1518
  var LICENSE_PREFIX = "skf1";
@@ -1101,11 +1628,20 @@ function renderFindingsLines(findings) {
1101
1628
  if (!findings?.length) return [];
1102
1629
  return [`${padLabel("Findings")}:`, ...findings.map(renderFindingLine)];
1103
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
+ }
1104
1642
  function renderReport(result) {
1105
1643
  const { profile, overlap, overlapLocked, safety, worthTaking, leaveBehind } = result;
1106
- const name = profile.name ?? "unknown";
1107
- const version = profile.version ?? "unknown";
1108
- const license = profile.license ?? "UNKNOWN";
1644
+ const { name, version, license } = profileHeaderFields(profile);
1109
1645
  const overlapLines = overlapLocked ? [`${padLabel("Overlap analysis")}: ${upgradeNotice("overlap analysis")}`] : overlap ? [
1110
1646
  `${padLabel("Nearest existing skill")}: ${overlap.nearest} (overlap ${overlap.nearestScore})`,
1111
1647
  `${padLabel("Recommended verb")}: ${overlap.suggestedVerb}`
@@ -1114,6 +1650,7 @@ function renderReport(result) {
1114
1650
  BORDER,
1115
1651
  `\u{1F528} Forge: ${name} (v${version}, ${license})`,
1116
1652
  BORDER,
1653
+ `${padLabel("Artifact type")}: ${profile.artifactType}`,
1117
1654
  ...overlapLines,
1118
1655
  renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
1119
1656
  renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
@@ -1139,10 +1676,13 @@ function printGateOutput(gateResult, json) {
1139
1676
  }
1140
1677
  console.log(json ? renderJson(gateResult) : renderReport(gateResult));
1141
1678
  }
1142
- function asSkillProfile(candidatePath) {
1679
+ function asProfile(candidatePath, artifact) {
1143
1680
  const profile = profileSkill(candidatePath);
1144
- if (profile.kind !== "skill") {
1145
- 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
+ );
1146
1686
  }
1147
1687
  return profile;
1148
1688
  }
@@ -1154,11 +1694,42 @@ function tryOverlap(candidatePath, skillsRoot) {
1154
1694
  return void 0;
1155
1695
  }
1156
1696
  }
1157
- async function runGate(candidatePath, skillsRoot, proFeatureLabel = "overlap analysis") {
1158
- const profile = asSkillProfile(candidatePath);
1159
- const safety = await scanSafety(candidatePath);
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
+ }
1723
+ async function runGate(candidatePath, skillsRoot, {
1724
+ proFeatureLabel = "overlap analysis",
1725
+ strictness = DEFAULT_STRICTNESS,
1726
+ artifact = "skill",
1727
+ mcpTargets = []
1728
+ } = {}) {
1729
+ const profile = asProfile(candidatePath, artifact);
1160
1730
  const proStatus = proFeatureStatus(proFeatureLabel);
1161
- 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);
1162
1733
  const gateResult = {
1163
1734
  profile,
1164
1735
  overlap,
@@ -1178,23 +1749,23 @@ async function runGate(candidatePath, skillsRoot, proFeatureLabel = "overlap ana
1178
1749
 
1179
1750
  // src/queue.ts
1180
1751
  import { randomUUID as randomUUID2 } from "crypto";
1181
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1182
- 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";
1183
1754
  function getQueuePath() {
1184
- return join9(getHomeDir(), "queue.json");
1755
+ return join11(getHomeDir(), "queue.json");
1185
1756
  }
1186
1757
  function readQueue() {
1187
1758
  const path = getQueuePath();
1188
- if (!existsSync8(path)) {
1759
+ if (!existsSync10(path)) {
1189
1760
  return { version: 1, entries: [] };
1190
1761
  }
1191
- const raw = readFileSync5(path, "utf-8");
1762
+ const raw = readFileSync9(path, "utf-8");
1192
1763
  const parsed = JSON.parse(raw);
1193
1764
  return { version: 1, entries: parsed.entries ?? [] };
1194
1765
  }
1195
1766
  function writeQueue(queue) {
1196
- mkdirSync4(getHomeDir(), { recursive: true });
1197
- 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");
1198
1769
  }
1199
1770
  function appendQueueEntry(entry) {
1200
1771
  const queue = readQueue();
@@ -1218,16 +1789,16 @@ function generateId(existingIds) {
1218
1789
 
1219
1790
  // src/handoff.ts
1220
1791
  import { spawnSync } from "child_process";
1221
- import { existsSync as existsSync9 } from "fs";
1222
- 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";
1223
1794
  import { fileURLToPath } from "url";
1224
1795
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
1225
1796
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
1226
1797
  function resolvePromptPath() {
1227
- const here = dirname3(fileURLToPath(import.meta.url));
1228
- const builtCandidate = join10(here, "ingest-prompt.md");
1229
- if (existsSync9(builtCandidate)) return builtCandidate;
1230
- 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");
1231
1802
  }
1232
1803
  function defaultIsOnPath(bin) {
1233
1804
  const finder = process.platform === "win32" ? "where" : "which";
@@ -1282,20 +1853,28 @@ function runHandoff(config, installedPath, options = {}) {
1282
1853
  }
1283
1854
 
1284
1855
  // src/commands/init.ts
1285
- import { existsSync as existsSync10 } from "fs";
1856
+ import { existsSync as existsSync12 } from "fs";
1286
1857
  import { homedir as homedir4 } from "os";
1287
1858
  import { createInterface } from "readline/promises";
1288
1859
 
1289
1860
  // src/agents.ts
1861
+ import { statSync as statSync6 } from "fs";
1290
1862
  import { homedir as homedir3 } from "os";
1291
- 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
+ }
1292
1871
  function projectPath(root, cwd) {
1293
- return root ? resolve4(cwd, root) : void 0;
1872
+ return root ? resolve5(cwd, root) : void 0;
1294
1873
  }
1295
1874
  function globalPath(root, home) {
1296
1875
  if (!root) return void 0;
1297
1876
  if (root === "~") return home;
1298
- if (root.startsWith("~/")) return join11(home, root.slice(2));
1877
+ if (root.startsWith("~/")) return join13(home, root.slice(2));
1299
1878
  return root;
1300
1879
  }
1301
1880
  function makeAgent(spec) {
@@ -1306,6 +1885,7 @@ function makeAgent(spec) {
1306
1885
  globalRoot: spec.globalRoot,
1307
1886
  verified: spec.verified ?? true,
1308
1887
  defaultHandoffTemplate: spec.defaultHandoffTemplate,
1888
+ mcpConfig: spec.mcpConfig,
1309
1889
  detect: (cwd, home) => detectAgentRoot(agent, cwd, home) !== void 0
1310
1890
  };
1311
1891
  return agent;
@@ -1320,6 +1900,19 @@ function detectAgentRoot(agent, cwd = process.cwd(), home = homedir3()) {
1320
1900
  }
1321
1901
  return void 0;
1322
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
+ }
1323
1916
  var AGENTS = [
1324
1917
  { id: "aider-desk", displayName: "AiderDesk", projectRoot: ".aider-desk/skills", globalRoot: "~/.aider-desk/skills" },
1325
1918
  { id: "amp", displayName: "Amp", projectRoot: ".agents/skills", globalRoot: "~/.config/agents/skills" },
@@ -1341,7 +1934,14 @@ var AGENTS = [
1341
1934
  displayName: "Claude Code",
1342
1935
  projectRoot: ".claude/skills",
1343
1936
  globalRoot: "~/.claude/skills",
1344
- defaultHandoffTemplate: ["claude", "-p", HANDOFF_INSTRUCTION]
1937
+ // `claude -p "<prompt>"` runs one non-interactive turn and exits. Verified 2026-07-11:
1938
+ // https://code.claude.com/docs/en/headless
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 }
1345
1945
  },
1346
1946
  { id: "openclaw", displayName: "OpenClaw", projectRoot: "skills", globalRoot: "~/.openclaw/skills" },
1347
1947
  { id: "cline", displayName: "Cline", projectRoot: ".agents/skills", globalRoot: "~/.agents/skills" },
@@ -1364,7 +1964,14 @@ var AGENTS = [
1364
1964
  displayName: "Codex",
1365
1965
  projectRoot: ".agents/skills",
1366
1966
  globalRoot: "~/.codex/skills",
1367
- defaultHandoffTemplate: ["codex", "exec", HANDOFF_INSTRUCTION]
1967
+ // `codex exec "<prompt>"` runs one non-interactive task and prints the final message.
1968
+ // Verified 2026-07-11: https://developers.openai.com/codex/noninteractive
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 }
1368
1975
  },
1369
1976
  {
1370
1977
  id: "command-code",
@@ -1375,7 +1982,16 @@ var AGENTS = [
1375
1982
  { id: "continue", displayName: "Continue", projectRoot: ".continue/skills", globalRoot: "~/.continue/skills" },
1376
1983
  { id: "cortex", displayName: "Cortex Code", projectRoot: ".cortex/skills", globalRoot: "~/.snowflake/cortex/skills" },
1377
1984
  { id: "crush", displayName: "Crush", projectRoot: ".crush/skills", globalRoot: "~/.config/crush/skills" },
1378
- { 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
+ },
1379
1995
  {
1380
1996
  id: "deepagents",
1381
1997
  displayName: "Deep Agents",
@@ -1392,13 +2008,26 @@ var AGENTS = [
1392
2008
  displayName: "Gemini CLI",
1393
2009
  projectRoot: ".agents/skills",
1394
2010
  globalRoot: "~/.gemini/skills",
1395
- defaultHandoffTemplate: ["gemini", "-p", HANDOFF_INSTRUCTION]
2011
+ // `gemini -p "<prompt>"` executes a single turn headlessly and exits. Verified 2026-07-11:
2012
+ // https://google-gemini.github.io/gemini-cli/docs/cli/headless.html
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 }
1396
2019
  },
1397
2020
  {
1398
2021
  id: "github-copilot",
1399
2022
  displayName: "GitHub Copilot",
1400
2023
  projectRoot: ".agents/skills",
1401
- 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 }
1402
2031
  },
1403
2032
  { id: "goose", displayName: "Goose", projectRoot: ".goose/skills", globalRoot: "~/.config/goose/skills" },
1404
2033
  { id: "hermes-agent", displayName: "Hermes Agent", projectRoot: ".hermes/skills", globalRoot: "~/.hermes/skills" },
@@ -1424,6 +2053,8 @@ var AGENTS = [
1424
2053
  displayName: "OpenCode",
1425
2054
  projectRoot: ".agents/skills",
1426
2055
  globalRoot: "~/.config/opencode/skills",
2056
+ // `opencode run "<prompt>"` runs one non-interactive prompt outside the TUI.
2057
+ // Verified 2026-07-11: https://opencode.ai/docs/cli/
1427
2058
  defaultHandoffTemplate: ["opencode", "run", HANDOFF_INSTRUCTION]
1428
2059
  },
1429
2060
  { id: "openhands", displayName: "OpenHands", projectRoot: ".openhands/skills", globalRoot: "~/.openhands/skills" },
@@ -1445,7 +2076,15 @@ var AGENTS = [
1445
2076
  { id: "tinycloud", displayName: "Tinycloud", projectRoot: ".tinycloud/skills", globalRoot: "~/.tinycloud/skills" },
1446
2077
  { id: "trae", displayName: "Trae", projectRoot: ".trae/skills", globalRoot: "~/.trae/skills" },
1447
2078
  { id: "trae-cn", displayName: "Trae CN", projectRoot: ".trae/skills", globalRoot: "~/.trae-cn/skills" },
1448
- { 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
+ },
1449
2088
  { id: "zcode", displayName: "ZCode", projectRoot: ".zcode/skills", globalRoot: "~/.zcode/skills" },
1450
2089
  { id: "zencoder", displayName: "Zencoder", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
1451
2090
  { id: "zenflow", displayName: "Zenflow", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
@@ -1464,6 +2103,15 @@ function detectAgents(cwd, home) {
1464
2103
  }
1465
2104
  return found;
1466
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
+ }
1467
2115
  function printDetectionResults(detected) {
1468
2116
  console.log(`[skill-forge] init \u2014 agent detection (${AGENTS.length} known agents)`);
1469
2117
  if (detected.length === 0) {
@@ -1474,17 +2122,27 @@ function printDetectionResults(detected) {
1474
2122
  console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.root}`);
1475
2123
  }
1476
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
+ }
1477
2132
  function handoffTemplateFor(agent) {
1478
2133
  return agent.defaultHandoffTemplate ?? [agent.id, "-p", HANDOFF_INSTRUCTION];
1479
2134
  }
1480
- function buildConfig(selected, defaultTargetEntry, handoffAgent) {
2135
+ function buildConfig(selected, defaultTargetEntry, handoffAgent, selectedMcp = []) {
1481
2136
  const existing = loadConfig();
1482
2137
  return {
1483
2138
  ...existing,
1484
- skillsRoots: selected.map((s) => s.root),
1485
- 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,
1486
2143
  defaultTarget: defaultTargetEntry?.root ?? existing.defaultTarget,
1487
- 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
1488
2146
  };
1489
2147
  }
1490
2148
  function printSummary(config) {
@@ -1493,6 +2151,7 @@ function printSummary(config) {
1493
2151
  console.log(` gate targets: ${config.skillsRoots.join(", ")}`);
1494
2152
  if (config.defaultTarget) console.log(` default target: ${config.defaultTarget}`);
1495
2153
  if (config.handoffCommand?.length) console.log(` handoff command: ${config.handoffCommand.join(" ")}`);
2154
+ if (config.mcpTargets?.length) console.log(` mcp targets: ${config.mcpTargets.join(", ")}`);
1496
2155
  }
1497
2156
  function parseChoice(answer, max) {
1498
2157
  const n = Number(answer);
@@ -1518,6 +2177,26 @@ async function promptMultiSelect(rl, detected) {
1518
2177
  }
1519
2178
  return detected.filter((_, i) => chosen.has(i));
1520
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
+ }
1521
2200
  async function promptDefaultTarget(rl, selected) {
1522
2201
  console.log("\nDefault promotion target:");
1523
2202
  selected.forEach((d, i) => console.log(` ${i + 1}. ${d.agent.displayName} \u2014 ${d.root}`));
@@ -1540,17 +2219,19 @@ async function init(options = {}) {
1540
2219
  const cwd = options.cwd ?? process.cwd();
1541
2220
  const home = options.home ?? homedir4();
1542
2221
  const detected = detectAgents(cwd, home);
2222
+ const detectedMcp = detectMcpTargets(cwd, home);
1543
2223
  if (options.list) {
1544
2224
  printDetectionResults(detected);
2225
+ printMcpDetectionResults(detectedMcp);
1545
2226
  return;
1546
2227
  }
1547
- if (detected.length === 0) {
1548
- 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.");
1549
2230
  console.log(" Install a coding agent (Claude Code, Codex, Cursor, ...) or create a skills root, then re-run `skill-forge init`.");
1550
2231
  return;
1551
2232
  }
1552
2233
  if (options.defaults) {
1553
- const config = buildConfig(detected, detected[0], void 0);
2234
+ const config = buildConfig(detected, detected[0], void 0, detectedMcp);
1554
2235
  saveConfig(config);
1555
2236
  printSummary(config);
1556
2237
  return;
@@ -1560,16 +2241,18 @@ async function init(options = {}) {
1560
2241
  return;
1561
2242
  }
1562
2243
  printDetectionResults(detected);
2244
+ printMcpDetectionResults(detectedMcp);
1563
2245
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1564
2246
  try {
1565
- const selected = await promptMultiSelect(rl, detected);
1566
- 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) {
1567
2250
  console.log("[skill-forge] No targets selected \u2014 aborting, nothing written.");
1568
2251
  return;
1569
2252
  }
1570
- const target = await promptDefaultTarget(rl, selected);
1571
- const handoffAgent = await promptHandoffAgent(rl, detected);
1572
- 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);
1573
2256
  saveConfig(config);
1574
2257
  printSummary(config);
1575
2258
  } finally {
@@ -1578,7 +2261,7 @@ async function init(options = {}) {
1578
2261
  }
1579
2262
  async function maybeOfferInit(options = {}) {
1580
2263
  if (options.json || options.yes || !process.stdin.isTTY) return;
1581
- if (existsSync10(getConfigPath())) return;
2264
+ if (existsSync12(getConfigPath())) return;
1582
2265
  console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
1583
2266
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1584
2267
  let answer = "";
@@ -1594,6 +2277,7 @@ async function maybeOfferInit(options = {}) {
1594
2277
 
1595
2278
  // src/commands/add.ts
1596
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";
1597
2281
  function logOutcome(message, json) {
1598
2282
  if (json) console.error(message);
1599
2283
  else console.log(message);
@@ -1601,11 +2285,16 @@ function logOutcome(message, json) {
1601
2285
  async function add(source, options = {}) {
1602
2286
  await maybeOfferInit({ json: options.json, yes: options.yes });
1603
2287
  const config = loadConfig();
2288
+ const artifact = options.artifact ?? "skill";
2289
+ if (artifact === "mcp") {
2290
+ await addMcp(source, config, options);
2291
+ return;
2292
+ }
1604
2293
  const targetRoot = options.target ?? config.defaultTarget ?? config.skillsRoots[0];
1605
2294
  if (!targetRoot) {
1606
2295
  throw new Error("no target skills root configured; pass --target <dir> or set skillsRoots in the config");
1607
2296
  }
1608
- const resolved = resolve(source);
2297
+ const resolved = resolve(source, artifact);
1609
2298
  const entry = await quarantine(resolved, config.quarantineDir);
1610
2299
  if (entry.skillDirs.length === 0) {
1611
2300
  console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
@@ -1619,8 +2308,15 @@ async function add(source, options = {}) {
1619
2308
  ` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
1620
2309
  );
1621
2310
  }
1622
- const { safety, gateResult, proEnabled } = await runGate(entry.skillDirs[0], targetRoot, PRO_FEATURE_LABEL);
2311
+ const { safety, gateResult, proEnabled } = await runGate(entry.skillDirs[0], targetRoot, {
2312
+ proFeatureLabel: PRO_FEATURE_LABEL,
2313
+ strictness: config.strictness,
2314
+ artifact
2315
+ });
1623
2316
  const { profile, overlap } = gateResult;
2317
+ if (profile.kind !== "skill") {
2318
+ throw new Error(`expected a skill candidate, found a ${profile.kind}`);
2319
+ }
1624
2320
  printGateOutput(gateResult, options.json);
1625
2321
  const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
1626
2322
  let installedPath = null;
@@ -1673,6 +2369,78 @@ async function add(source, options = {}) {
1673
2369
  }
1674
2370
  }
1675
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
+ }
1676
2444
  function yesDecision(verdict) {
1677
2445
  return verdict === "block" ? "reject" : "promote";
1678
2446
  }
@@ -1700,25 +2468,41 @@ ${prompt}`);
1700
2468
  async function scan(source, options = {}) {
1701
2469
  await maybeOfferInit({ json: options.json });
1702
2470
  const config = loadConfig();
1703
- const skillsRoot = config.skillsRoots[0];
1704
- if (!skillsRoot) {
2471
+ const artifact = options.artifact ?? "skill";
2472
+ const skillsRoot = config.defaultTarget ?? config.skillsRoots[0];
2473
+ if (artifact === "skill" && !skillsRoot) {
1705
2474
  throw new Error("no skills root configured; set skillsRoots in the config");
1706
2475
  }
1707
- const resolved = resolve(source);
2476
+ const resolved = resolve(source, artifact);
1708
2477
  const entry = await quarantine(resolved, config.quarantineDir);
1709
2478
  try {
1710
- if (entry.skillDirs.length === 0) {
1711
- console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
1712
- process.exitCode = 1;
1713
- 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];
1714
2495
  }
1715
- 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) {
1716
2502
  console.error(
1717
- `[skill-forge] ${entry.skillDirs.length} skills found in ${source}; scanning the first:
1718
- ` + 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(", ")
1719
2504
  );
1720
2505
  }
1721
- const { safety, gateResult } = await runGate(entry.skillDirs[0], skillsRoot);
1722
2506
  printGateOutput(gateResult, options.json);
1723
2507
  if (safety.verdict === "block") {
1724
2508
  process.exitCode = 1;
@@ -1729,7 +2513,7 @@ async function scan(source, options = {}) {
1729
2513
  }
1730
2514
 
1731
2515
  // src/commands/list.ts
1732
- import { join as join12 } from "path";
2516
+ import { join as join15 } from "path";
1733
2517
  async function list() {
1734
2518
  await maybeOfferInit();
1735
2519
  const config = loadConfig();
@@ -1742,14 +2526,14 @@ async function list() {
1742
2526
  }
1743
2527
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
1744
2528
  for (const id of ids) {
1745
- const skillDirs = findSkillDirs(join12(quarantineDir, id));
2529
+ const skillDirs = findSkillDirs(join15(quarantineDir, id));
1746
2530
  if (skillDirs.length === 0) {
1747
2531
  console.log(` ${id} (no SKILL.md found)`);
1748
2532
  continue;
1749
2533
  }
1750
2534
  const names = skillDirs.map((dir) => {
1751
2535
  try {
1752
- return readFrontmatter(join12(dir, "SKILL.md")).name;
2536
+ return readFrontmatter(join15(dir, "SKILL.md")).name;
1753
2537
  } catch {
1754
2538
  return dir;
1755
2539
  }
@@ -1773,10 +2557,19 @@ async function status() {
1773
2557
  // src/cli.ts
1774
2558
  var require2 = createRequire(import.meta.url);
1775
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
+ }
1776
2566
  var program = new Command();
1777
2567
  program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
1778
- 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 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);
1779
- 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);
1780
2573
  program.command("list").description("List skills currently in quarantine").action(list);
1781
2574
  program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
1782
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);