@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/LICENSE +1 -0
- package/README.md +66 -0
- package/dist/cli.js +883 -112
- package/dist/cli.js.map +1 -1
- package/package.json +8 -5
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
|
-
|
|
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
|
|
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
|
-
|
|
205
|
+
walk3(join3(dir, entry.name));
|
|
173
206
|
}
|
|
174
207
|
}
|
|
175
208
|
}
|
|
176
|
-
|
|
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
|
|
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 (!
|
|
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 (!
|
|
273
|
-
|
|
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
|
|
416
|
+
existsSync as existsSync7,
|
|
284
417
|
mkdtempSync,
|
|
285
418
|
openSync,
|
|
286
|
-
readFileSync as
|
|
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 (!
|
|
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 (
|
|
350
|
-
if (
|
|
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:
|
|
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 (
|
|
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:
|
|
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 (
|
|
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 (
|
|
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 (
|
|
487
|
-
const head =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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() &&
|
|
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
|
|
600
|
-
|
|
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
|
-
|
|
789
|
+
pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
|
|
603
790
|
} catch (err) {
|
|
604
|
-
throw new Error(`could not parse
|
|
791
|
+
throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
|
|
605
792
|
}
|
|
606
|
-
const
|
|
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:
|
|
612
|
-
servers:
|
|
613
|
-
serverCount:
|
|
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
|
|
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 =
|
|
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
|
|
822
|
-
import { basename as basename4, dirname as
|
|
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 =
|
|
844
|
-
const meta = { name: basename4(
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
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
|
|
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
|
|
1679
|
+
function asProfile(candidatePath, artifact) {
|
|
1151
1680
|
const profile = profileSkill(candidatePath);
|
|
1152
|
-
|
|
1153
|
-
|
|
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 =
|
|
1170
|
-
const safety = await scanSafety(candidatePath, strictness);
|
|
1729
|
+
const profile = asProfile(candidatePath, artifact);
|
|
1171
1730
|
const proStatus = proFeatureStatus(proFeatureLabel);
|
|
1172
|
-
const
|
|
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
|
|
1193
|
-
import { join as
|
|
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
|
|
1755
|
+
return join11(getHomeDir(), "queue.json");
|
|
1196
1756
|
}
|
|
1197
1757
|
function readQueue() {
|
|
1198
1758
|
const path = getQueuePath();
|
|
1199
|
-
if (!
|
|
1759
|
+
if (!existsSync10(path)) {
|
|
1200
1760
|
return { version: 1, entries: [] };
|
|
1201
1761
|
}
|
|
1202
|
-
const raw =
|
|
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
|
-
|
|
1208
|
-
|
|
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
|
|
1233
|
-
import { dirname as
|
|
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 =
|
|
1239
|
-
const builtCandidate =
|
|
1240
|
-
if (
|
|
1241
|
-
return
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
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] ${
|
|
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
|
|
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(
|
|
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(
|
|
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").
|
|
1801
|
-
|
|
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);
|