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