@rhize/skill-forge 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -0
- package/README.md +75 -4
- package/dist/cli.js +956 -163
- package/dist/cli.js.map +1 -1
- package/package.json +8 -5
package/dist/cli.js
CHANGED
|
@@ -6,11 +6,13 @@ import { createRequire } from "module";
|
|
|
6
6
|
|
|
7
7
|
// src/commands/add.ts
|
|
8
8
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
9
|
+
import { join as join14 } from "path";
|
|
9
10
|
|
|
10
11
|
// src/config.ts
|
|
11
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
12
13
|
import { homedir } from "os";
|
|
13
14
|
import { join } from "path";
|
|
15
|
+
var DEFAULT_STRICTNESS = "block-high";
|
|
14
16
|
function getHomeDir() {
|
|
15
17
|
return process.env.SKILL_FORGE_HOME ?? join(homedir(), ".skill-forge");
|
|
16
18
|
}
|
|
@@ -22,7 +24,7 @@ function defaultConfig() {
|
|
|
22
24
|
return {
|
|
23
25
|
skillsRoots: [join(process.cwd(), ".claude", "skills")],
|
|
24
26
|
quarantineDir: join(home, "quarantine"),
|
|
25
|
-
strictness:
|
|
27
|
+
strictness: DEFAULT_STRICTNESS
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
function loadConfig() {
|
|
@@ -40,7 +42,8 @@ function loadConfig() {
|
|
|
40
42
|
licenseKey: parsed.licenseKey ?? defaults.licenseKey,
|
|
41
43
|
defaultTarget: parsed.defaultTarget ?? defaults.defaultTarget,
|
|
42
44
|
agents: parsed.agents ?? defaults.agents,
|
|
43
|
-
handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand
|
|
45
|
+
handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand,
|
|
46
|
+
mcpTargets: parsed.mcpTargets ?? defaults.mcpTargets
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
function saveConfig(config) {
|
|
@@ -53,15 +56,53 @@ function saveConfig(config) {
|
|
|
53
56
|
|
|
54
57
|
// src/install/resolve.ts
|
|
55
58
|
import { existsSync as existsSync2 } from "fs";
|
|
59
|
+
|
|
60
|
+
// src/errors.ts
|
|
61
|
+
var UserError = class extends Error {
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "UserError";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/util.ts
|
|
69
|
+
import { statSync } from "fs";
|
|
70
|
+
import { homedir as homedir2 } from "os";
|
|
71
|
+
import { join as join2 } from "path";
|
|
72
|
+
function expandHome(p) {
|
|
73
|
+
if (p === "~") return homedir2();
|
|
74
|
+
if (p.startsWith("~/")) return join2(homedir2(), p.slice(2));
|
|
75
|
+
return p;
|
|
76
|
+
}
|
|
77
|
+
function stripQuotes(s) {
|
|
78
|
+
return s.replace(/^['"]+/, "").replace(/['"]+$/, "");
|
|
79
|
+
}
|
|
80
|
+
function isDir(p) {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(p).isDirectory();
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/install/resolve.ts
|
|
56
89
|
var GIT_URL_PATTERN = /^(git@|https?:\/\/|ssh:\/\/)/;
|
|
57
90
|
var LOCAL_PATH_PATTERN = /^(\.{1,2}\/|\/|~\/|~$)/;
|
|
58
|
-
|
|
91
|
+
var NPM_SCOPED_PATTERN = /^@[^/]+\/[^/]+$/;
|
|
92
|
+
function resolve(source, artifact = "skill") {
|
|
59
93
|
const value = source.trim();
|
|
94
|
+
if (value.startsWith("-")) {
|
|
95
|
+
throw new UserError(`skill source cannot start with "-" (looks like a flag, not a source): ${value}`);
|
|
96
|
+
}
|
|
60
97
|
if (GIT_URL_PATTERN.test(value) || value.endsWith(".git")) {
|
|
61
98
|
return { type: "git", value };
|
|
62
99
|
}
|
|
63
|
-
|
|
64
|
-
|
|
100
|
+
const localCandidate = expandHome(value);
|
|
101
|
+
if (existsSync2(localCandidate) || LOCAL_PATH_PATTERN.test(value)) {
|
|
102
|
+
return { type: "local", value: localCandidate };
|
|
103
|
+
}
|
|
104
|
+
if (artifact === "mcp" && (NPM_SCOPED_PATTERN.test(value) || !value.includes("/"))) {
|
|
105
|
+
return { type: "npm", value };
|
|
65
106
|
}
|
|
66
107
|
return { type: "skills.sh", value };
|
|
67
108
|
}
|
|
@@ -70,24 +111,15 @@ function resolve(source) {
|
|
|
70
111
|
import { randomUUID } from "crypto";
|
|
71
112
|
import { spawn } from "child_process";
|
|
72
113
|
import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync } from "fs";
|
|
73
|
-
import { basename, join as
|
|
74
|
-
|
|
75
|
-
// src/errors.ts
|
|
76
|
-
var UserError = class extends Error {
|
|
77
|
-
constructor(message) {
|
|
78
|
-
super(message);
|
|
79
|
-
this.name = "UserError";
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
// src/install/quarantine.ts
|
|
114
|
+
import { basename, join as join3, resolve as resolvePath } from "path";
|
|
84
115
|
var SKILL_MANIFEST = "SKILL.md";
|
|
85
116
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
86
117
|
async function quarantine(source, quarantineDir, deps = {}) {
|
|
87
118
|
const exec = deps.exec ?? defaultExec;
|
|
88
119
|
const id = randomUUID();
|
|
89
|
-
const quarantinePath =
|
|
120
|
+
const quarantinePath = join3(quarantineDir, id);
|
|
90
121
|
mkdirSync2(quarantinePath, { recursive: true });
|
|
122
|
+
let packageDir;
|
|
91
123
|
switch (source.type) {
|
|
92
124
|
case "skills.sh": {
|
|
93
125
|
const result = await exec("npx", ["-y", "skills@latest", "add", source.value], {
|
|
@@ -101,8 +133,8 @@ async function quarantine(source, quarantineDir, deps = {}) {
|
|
|
101
133
|
break;
|
|
102
134
|
}
|
|
103
135
|
case "git": {
|
|
104
|
-
const clonePath =
|
|
105
|
-
const result = await exec("git", ["clone", "--depth", "1", source.value, clonePath], {
|
|
136
|
+
const clonePath = join3(quarantinePath, deriveGitDirName(source.value));
|
|
137
|
+
const result = await exec("git", ["clone", "--depth", "1", "--", source.value, clonePath], {
|
|
106
138
|
cwd: quarantinePath
|
|
107
139
|
});
|
|
108
140
|
if (result.code !== 0) {
|
|
@@ -117,14 +149,40 @@ async function quarantine(source, quarantineDir, deps = {}) {
|
|
|
117
149
|
throw new UserError(`local path not found: ${source.value}`);
|
|
118
150
|
}
|
|
119
151
|
const dirName = basename(resolvePath(source.value));
|
|
120
|
-
cpSync(source.value,
|
|
152
|
+
cpSync(source.value, join3(quarantinePath, dirName), { recursive: true });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "npm": {
|
|
156
|
+
const packResult = await exec("npm", ["pack", source.value, "--pack-destination", quarantinePath], {
|
|
157
|
+
cwd: quarantinePath
|
|
158
|
+
});
|
|
159
|
+
if (packResult.code !== 0) {
|
|
160
|
+
throw new UserError(
|
|
161
|
+
`npm pack failed for "${source.value}" (exit ${packResult.code}): ${packResult.stderr || packResult.stdout}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const tarballPath = join3(quarantinePath, findTarball(quarantinePath, source.value));
|
|
165
|
+
const extractResult = await exec("tar", ["-xzf", tarballPath, "-C", quarantinePath], {
|
|
166
|
+
cwd: quarantinePath
|
|
167
|
+
});
|
|
168
|
+
if (extractResult.code !== 0) {
|
|
169
|
+
throw new UserError(
|
|
170
|
+
`tarball extraction failed for "${source.value}" (exit ${extractResult.code}): ${extractResult.stderr || extractResult.stdout}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
packageDir = join3(quarantinePath, "package");
|
|
174
|
+
if (!existsSync3(packageDir)) {
|
|
175
|
+
throw new UserError(
|
|
176
|
+
`npm pack for "${source.value}" did not produce the expected package/ dir after extraction`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
121
179
|
break;
|
|
122
180
|
}
|
|
123
181
|
default: {
|
|
124
182
|
throw new UserError(`unknown source type: ${source.type}`);
|
|
125
183
|
}
|
|
126
184
|
}
|
|
127
|
-
return { id, quarantinePath, skillDirs: findSkillDirs(quarantinePath), source };
|
|
185
|
+
return { id, quarantinePath, skillDirs: findSkillDirs(quarantinePath), packageDir, source };
|
|
128
186
|
}
|
|
129
187
|
function listQuarantineIds(quarantineDir) {
|
|
130
188
|
if (!existsSync3(quarantineDir)) return [];
|
|
@@ -132,7 +190,7 @@ function listQuarantineIds(quarantineDir) {
|
|
|
132
190
|
}
|
|
133
191
|
function findSkillDirs(root) {
|
|
134
192
|
const found = [];
|
|
135
|
-
function
|
|
193
|
+
function walk3(dir) {
|
|
136
194
|
let entries;
|
|
137
195
|
try {
|
|
138
196
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -144,13 +202,20 @@ function findSkillDirs(root) {
|
|
|
144
202
|
}
|
|
145
203
|
for (const entry of entries) {
|
|
146
204
|
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
147
|
-
|
|
205
|
+
walk3(join3(dir, entry.name));
|
|
148
206
|
}
|
|
149
207
|
}
|
|
150
208
|
}
|
|
151
|
-
|
|
209
|
+
walk3(root);
|
|
152
210
|
return found;
|
|
153
211
|
}
|
|
212
|
+
function findTarball(dir, sourceValue) {
|
|
213
|
+
const tarball = readdirSync(dir).find((name) => name.endsWith(".tgz"));
|
|
214
|
+
if (!tarball) {
|
|
215
|
+
throw new UserError(`npm pack for "${sourceValue}" did not produce a .tgz file in ${dir}`);
|
|
216
|
+
}
|
|
217
|
+
return tarball;
|
|
218
|
+
}
|
|
154
219
|
function deriveGitDirName(url) {
|
|
155
220
|
const stripped = url.replace(/\.git$/, "");
|
|
156
221
|
const segments = stripped.split(/[/:]/).filter(Boolean);
|
|
@@ -170,11 +235,11 @@ function defaultExec(command, args, options) {
|
|
|
170
235
|
|
|
171
236
|
// src/install/promote.ts
|
|
172
237
|
import { cpSync as cpSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, renameSync, rmSync } from "fs";
|
|
173
|
-
import { basename as basename2, join as
|
|
238
|
+
import { basename as basename2, join as join4 } from "path";
|
|
174
239
|
function promote(entry, targetSkillsRoot, options = {}) {
|
|
175
240
|
const force = options.force ?? false;
|
|
176
241
|
for (const skillDir of entry.skillDirs) {
|
|
177
|
-
const dest =
|
|
242
|
+
const dest = join4(targetSkillsRoot, basename2(skillDir));
|
|
178
243
|
if (existsSync4(dest) && !force) {
|
|
179
244
|
throw new Error(`Refusing to overwrite existing skill dir: ${dest} (pass force to override)`);
|
|
180
245
|
}
|
|
@@ -182,7 +247,7 @@ function promote(entry, targetSkillsRoot, options = {}) {
|
|
|
182
247
|
mkdirSync3(targetSkillsRoot, { recursive: true });
|
|
183
248
|
const promoted = [];
|
|
184
249
|
for (const skillDir of entry.skillDirs) {
|
|
185
|
-
const dest =
|
|
250
|
+
const dest = join4(targetSkillsRoot, basename2(skillDir));
|
|
186
251
|
if (existsSync4(dest)) {
|
|
187
252
|
rmSync(dest, { recursive: true, force: true });
|
|
188
253
|
}
|
|
@@ -210,9 +275,98 @@ function moveDir(src, dest) {
|
|
|
210
275
|
}
|
|
211
276
|
}
|
|
212
277
|
|
|
278
|
+
// src/install/promoteMcp.ts
|
|
279
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
280
|
+
import { dirname } from "path";
|
|
281
|
+
function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
|
|
282
|
+
const force = options.force ?? false;
|
|
283
|
+
const serverName = profile.name;
|
|
284
|
+
let config = {};
|
|
285
|
+
let rawExisting;
|
|
286
|
+
if (existsSync5(targetConfigPath)) {
|
|
287
|
+
rawExisting = readFileSync2(targetConfigPath, "utf-8");
|
|
288
|
+
try {
|
|
289
|
+
config = JSON.parse(rawExisting);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
throw new UserError(`could not parse existing MCP config ${targetConfigPath}: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const servers = { ...config.mcpServers ?? {} };
|
|
295
|
+
if (Object.prototype.hasOwnProperty.call(servers, serverName) && !force) {
|
|
296
|
+
throw new UserError(
|
|
297
|
+
`Refusing to overwrite existing MCP server entry "${serverName}" in ${targetConfigPath} (pass force to override)`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
let backupPath;
|
|
301
|
+
if (rawExisting !== void 0) {
|
|
302
|
+
backupPath = `${targetConfigPath}.bak-${Date.now()}`;
|
|
303
|
+
writeFileSync2(backupPath, rawExisting);
|
|
304
|
+
}
|
|
305
|
+
const { command, args, envVarsToFill } = deriveServerEntry(profile);
|
|
306
|
+
const env = {};
|
|
307
|
+
for (const name of envVarsToFill) env[name] = "";
|
|
308
|
+
servers[serverName] = { command, args, env };
|
|
309
|
+
config.mcpServers = servers;
|
|
310
|
+
mkdirSync4(dirname(targetConfigPath), { recursive: true });
|
|
311
|
+
writeFileSync2(targetConfigPath, `${JSON.stringify(config, null, 2)}
|
|
312
|
+
`);
|
|
313
|
+
return { target: targetConfigPath, serverName, envVarsToFill, backupPath };
|
|
314
|
+
}
|
|
315
|
+
function deriveServerEntry(profile) {
|
|
316
|
+
const envNames = new Set(profile.envVars ?? []);
|
|
317
|
+
if (profile.candidateEntry?.env) {
|
|
318
|
+
for (const key of Object.keys(profile.candidateEntry.env)) envNames.add(key);
|
|
319
|
+
}
|
|
320
|
+
if (profile.candidateEntry) {
|
|
321
|
+
return {
|
|
322
|
+
command: profile.candidateEntry.command,
|
|
323
|
+
args: profile.candidateEntry.args ?? [],
|
|
324
|
+
envVarsToFill: Array.from(envNames)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
command: "npx",
|
|
329
|
+
args: ["-y", `${profile.name}@${profile.version}`],
|
|
330
|
+
envVarsToFill: Array.from(envNames)
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/install/mcpCandidate.ts
|
|
335
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
336
|
+
function readPackageVersion(pkgPath) {
|
|
337
|
+
try {
|
|
338
|
+
const pkg2 = JSON.parse(readFileSync3(pkgPath, "utf-8"));
|
|
339
|
+
return typeof pkg2.version === "string" ? pkg2.version : void 0;
|
|
340
|
+
} catch {
|
|
341
|
+
return void 0;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function readConfigEntry(configPath, name) {
|
|
345
|
+
try {
|
|
346
|
+
const cfg = JSON.parse(readFileSync3(configPath, "utf-8"));
|
|
347
|
+
const servers = cfg.mcpServers ?? cfg.servers ?? {};
|
|
348
|
+
const raw = servers[name] ?? {};
|
|
349
|
+
return {
|
|
350
|
+
command: typeof raw.command === "string" ? raw.command : void 0,
|
|
351
|
+
args: Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === "string") : void 0,
|
|
352
|
+
env: raw.env && typeof raw.env === "object" && !Array.isArray(raw.env) ? raw.env : void 0
|
|
353
|
+
};
|
|
354
|
+
} catch {
|
|
355
|
+
return void 0;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function resolveMcpCandidateDetail(profile) {
|
|
359
|
+
const name = profile.servers[0];
|
|
360
|
+
if (profile.config.endsWith("package.json")) {
|
|
361
|
+
return { name, version: readPackageVersion(profile.config) };
|
|
362
|
+
}
|
|
363
|
+
const entry = readConfigEntry(profile.config, name);
|
|
364
|
+
return { name, command: entry?.command, args: entry?.args, env: entry?.env };
|
|
365
|
+
}
|
|
366
|
+
|
|
213
367
|
// src/provenance.ts
|
|
214
|
-
import { appendFileSync, existsSync as
|
|
215
|
-
import { join as
|
|
368
|
+
import { appendFileSync, existsSync as existsSync6, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
369
|
+
import { join as join5 } from "path";
|
|
216
370
|
var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
|
|
217
371
|
var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
|
|
218
372
|
function todayLocalIsoDate() {
|
|
@@ -226,7 +380,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
226
380
|
if (!FORGE_VERBS.includes(entry.verb)) {
|
|
227
381
|
throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
|
|
228
382
|
}
|
|
229
|
-
if (!
|
|
383
|
+
if (!existsSync6(skillsRoot) || !statSync2(skillsRoot).isDirectory()) {
|
|
230
384
|
throw new Error(`skills-root not a directory: ${skillsRoot}`);
|
|
231
385
|
}
|
|
232
386
|
const today = todayLocalIsoDate();
|
|
@@ -243,51 +397,33 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
243
397
|
- **Drift check:** \`${driftCheck}\`
|
|
244
398
|
- **Notes:** ${entry.notes ?? ""}
|
|
245
399
|
`;
|
|
246
|
-
const ledgerPath =
|
|
247
|
-
if (!
|
|
248
|
-
|
|
400
|
+
const ledgerPath = join5(skillsRoot, "SOURCES.md");
|
|
401
|
+
if (!existsSync6(ledgerPath)) {
|
|
402
|
+
writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
|
|
249
403
|
}
|
|
250
404
|
appendFileSync(ledgerPath, block, "utf-8");
|
|
251
405
|
return ledgerPath;
|
|
252
406
|
}
|
|
253
407
|
|
|
408
|
+
// src/commands/gatePipeline.ts
|
|
409
|
+
import { readdirSync as readdirSync6 } from "fs";
|
|
410
|
+
import { join as join10 } from "path";
|
|
411
|
+
|
|
254
412
|
// src/gate/profile.ts
|
|
255
413
|
import { execFileSync } from "child_process";
|
|
256
414
|
import {
|
|
257
415
|
closeSync,
|
|
258
|
-
existsSync as
|
|
416
|
+
existsSync as existsSync7,
|
|
259
417
|
mkdtempSync,
|
|
260
418
|
openSync,
|
|
261
|
-
readFileSync as
|
|
419
|
+
readFileSync as readFileSync4,
|
|
262
420
|
readdirSync as readdirSync2,
|
|
263
421
|
readSync,
|
|
264
422
|
rmSync as rmSync2,
|
|
265
423
|
statSync as statSync3
|
|
266
424
|
} from "fs";
|
|
267
425
|
import { tmpdir } from "os";
|
|
268
|
-
import { basename as basename3, dirname, extname, join as join6, resolve as resolve2 } from "path";
|
|
269
|
-
|
|
270
|
-
// src/util.ts
|
|
271
|
-
import { statSync as statSync2 } from "fs";
|
|
272
|
-
import { homedir as homedir2 } from "os";
|
|
273
|
-
import { join as join5 } from "path";
|
|
274
|
-
function expandHome(p) {
|
|
275
|
-
if (p === "~") return homedir2();
|
|
276
|
-
if (p.startsWith("~/")) return join5(homedir2(), p.slice(2));
|
|
277
|
-
return p;
|
|
278
|
-
}
|
|
279
|
-
function stripQuotes(s) {
|
|
280
|
-
return s.replace(/^['"]+/, "").replace(/['"]+$/, "");
|
|
281
|
-
}
|
|
282
|
-
function isDir(p) {
|
|
283
|
-
try {
|
|
284
|
-
return statSync2(p).isDirectory();
|
|
285
|
-
} catch {
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// src/gate/profile.ts
|
|
426
|
+
import { basename as basename3, dirname as dirname2, extname, join as join6, resolve as resolve2 } from "path";
|
|
291
427
|
var SPDX_HINTS = {
|
|
292
428
|
mit: "MIT (permissive)",
|
|
293
429
|
apache: "Apache-2.0 (permissive)",
|
|
@@ -329,7 +465,7 @@ var PYTHON_STDLIB = /* @__PURE__ */ new Set([
|
|
|
329
465
|
]);
|
|
330
466
|
function profileSkill(inputPath) {
|
|
331
467
|
const p = resolve2(expandHome(inputPath));
|
|
332
|
-
if (!
|
|
468
|
+
if (!existsSync7(p)) {
|
|
333
469
|
throw new Error(`path does not exist: ${p}`);
|
|
334
470
|
}
|
|
335
471
|
const stat = statSync3(p);
|
|
@@ -343,8 +479,41 @@ function profileSkill(inputPath) {
|
|
|
343
479
|
return profileSkillDir(p);
|
|
344
480
|
}
|
|
345
481
|
function classifyInput(p) {
|
|
346
|
-
if (
|
|
347
|
-
if (
|
|
482
|
+
if (existsSync7(join6(p, ".claude-plugin", "plugin.json"))) return "plugin";
|
|
483
|
+
if (detectArtifactType(p) === "mcp") return "mcp";
|
|
484
|
+
return "skill";
|
|
485
|
+
}
|
|
486
|
+
var MCP_SDK_DEP = "@modelcontextprotocol/sdk";
|
|
487
|
+
var MCP_KEYWORD = "mcp-server";
|
|
488
|
+
var MCP_BIN_PREFIX = /^mcp-/i;
|
|
489
|
+
function readPackageJsonSafe(root) {
|
|
490
|
+
const pkgPath = join6(root, "package.json");
|
|
491
|
+
if (!existsSync7(pkgPath)) return void 0;
|
|
492
|
+
try {
|
|
493
|
+
return JSON.parse(readFileSync4(pkgPath, "utf-8"));
|
|
494
|
+
} catch {
|
|
495
|
+
return void 0;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function hasMcpPackageMarkers(pkg2) {
|
|
499
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
500
|
+
const deps = pkg2[section];
|
|
501
|
+
if (deps && typeof deps === "object" && MCP_SDK_DEP in deps) return true;
|
|
502
|
+
}
|
|
503
|
+
const keywords = pkg2.keywords;
|
|
504
|
+
if (Array.isArray(keywords) && keywords.some((k) => k === MCP_KEYWORD)) return true;
|
|
505
|
+
const bin = pkg2.bin;
|
|
506
|
+
if (typeof bin === "string") {
|
|
507
|
+
if (MCP_BIN_PREFIX.test(basename3(bin))) return true;
|
|
508
|
+
} else if (bin && typeof bin === "object") {
|
|
509
|
+
if (Object.keys(bin).some((k) => MCP_BIN_PREFIX.test(k))) return true;
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
function detectArtifactType(root) {
|
|
514
|
+
if (existsSync7(join6(root, ".mcp.json")) || existsSync7(join6(root, "mcp.json"))) return "mcp";
|
|
515
|
+
const pkg2 = readPackageJsonSafe(root);
|
|
516
|
+
if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
|
|
348
517
|
return "skill";
|
|
349
518
|
}
|
|
350
519
|
function resolveSkillMd(inputPath) {
|
|
@@ -358,16 +527,16 @@ function resolveSkillMd(inputPath) {
|
|
|
358
527
|
}
|
|
359
528
|
const stat = statSync3(currentPath);
|
|
360
529
|
if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
|
|
361
|
-
return { skillMd: currentPath, root:
|
|
530
|
+
return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
|
|
362
531
|
}
|
|
363
532
|
if (stat.isDirectory()) {
|
|
364
533
|
const direct = join6(currentPath, "SKILL.md");
|
|
365
|
-
if (
|
|
534
|
+
if (existsSync7(direct)) {
|
|
366
535
|
return { skillMd: direct, root: currentPath, tmpDir };
|
|
367
536
|
}
|
|
368
537
|
const matches = findSkillMdOneLevelDown(currentPath);
|
|
369
538
|
if (matches.length === 1) {
|
|
370
|
-
return { skillMd: matches[0], root:
|
|
539
|
+
return { skillMd: matches[0], root: dirname2(matches[0]), tmpDir };
|
|
371
540
|
}
|
|
372
541
|
if (matches.length > 1) {
|
|
373
542
|
cleanupTmp(tmpDir);
|
|
@@ -388,7 +557,7 @@ function findSkillMdOneLevelDown(dir) {
|
|
|
388
557
|
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
389
558
|
if (entry.isDirectory()) {
|
|
390
559
|
const candidate = join6(dir, entry.name, "SKILL.md");
|
|
391
|
-
if (
|
|
560
|
+
if (existsSync7(candidate)) matches.add(candidate);
|
|
392
561
|
}
|
|
393
562
|
}
|
|
394
563
|
const skillsDir = join6(dir, "skills");
|
|
@@ -396,7 +565,7 @@ function findSkillMdOneLevelDown(dir) {
|
|
|
396
565
|
for (const entry of readdirSync2(skillsDir, { withFileTypes: true })) {
|
|
397
566
|
if (entry.isDirectory()) {
|
|
398
567
|
const candidate = join6(skillsDir, entry.name, "SKILL.md");
|
|
399
|
-
if (
|
|
568
|
+
if (existsSync7(candidate)) matches.add(candidate);
|
|
400
569
|
}
|
|
401
570
|
}
|
|
402
571
|
}
|
|
@@ -480,8 +649,8 @@ function detectLicense(root, fm) {
|
|
|
480
649
|
const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
|
|
481
650
|
for (const name of names) {
|
|
482
651
|
const p = join6(root, name);
|
|
483
|
-
if (
|
|
484
|
-
const head =
|
|
652
|
+
if (existsSync7(p) && statSync3(p).isFile()) {
|
|
653
|
+
const head = readFileSync4(p, "utf-8").slice(0, 2e3).toLowerCase();
|
|
485
654
|
for (const [hint, label] of Object.entries(SPDX_HINTS)) {
|
|
486
655
|
if (head.includes(hint)) return label;
|
|
487
656
|
}
|
|
@@ -520,7 +689,7 @@ function scanMcpAndImports(text, root) {
|
|
|
520
689
|
if (isDir(scriptsDir)) {
|
|
521
690
|
for (const f of walkFiles(scriptsDir)) {
|
|
522
691
|
if (!f.endsWith(".py")) continue;
|
|
523
|
-
const ft =
|
|
692
|
+
const ft = readFileSync4(f, "utf-8");
|
|
524
693
|
for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
|
|
525
694
|
const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
|
|
526
695
|
let m;
|
|
@@ -537,7 +706,7 @@ function scanMcpAndImports(text, root) {
|
|
|
537
706
|
function profileSkillDir(inputPath) {
|
|
538
707
|
const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
|
|
539
708
|
try {
|
|
540
|
-
const text =
|
|
709
|
+
const text = readFileSync4(skillMd, "utf-8");
|
|
541
710
|
const fm = parseFrontmatter(text);
|
|
542
711
|
const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
|
|
543
712
|
const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
|
|
@@ -557,7 +726,8 @@ function profileSkillDir(inputPath) {
|
|
|
557
726
|
topHeaders: headers.slice(0, 12),
|
|
558
727
|
resources,
|
|
559
728
|
mcpDependencies: mcpDeps,
|
|
560
|
-
externalPythonDeps: externalDeps
|
|
729
|
+
externalPythonDeps: externalDeps,
|
|
730
|
+
artifactType: detectArtifactType(root)
|
|
561
731
|
};
|
|
562
732
|
} finally {
|
|
563
733
|
cleanupTmp(tmpDir);
|
|
@@ -567,14 +737,14 @@ function profilePlugin(p) {
|
|
|
567
737
|
const manifestPath = join6(p, ".claude-plugin", "plugin.json");
|
|
568
738
|
let manifest;
|
|
569
739
|
try {
|
|
570
|
-
manifest = JSON.parse(
|
|
740
|
+
manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
|
|
571
741
|
} catch (err) {
|
|
572
742
|
throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
|
|
573
743
|
}
|
|
574
744
|
const skillsDir = join6(p, "skills");
|
|
575
745
|
let skills = [];
|
|
576
746
|
if (isDir(skillsDir)) {
|
|
577
|
-
skills = readdirSync2(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() &&
|
|
747
|
+
skills = readdirSync2(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync7(join6(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
|
|
578
748
|
}
|
|
579
749
|
const commandsDir = join6(p, "commands");
|
|
580
750
|
const commands = isDir(commandsDir) ? readdirSync2(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
@@ -589,35 +759,59 @@ function profilePlugin(p) {
|
|
|
589
759
|
skills,
|
|
590
760
|
skillCount: skills.length,
|
|
591
761
|
commands,
|
|
592
|
-
mcpServers
|
|
762
|
+
mcpServers,
|
|
763
|
+
artifactType: detectArtifactType(p)
|
|
593
764
|
};
|
|
594
765
|
}
|
|
595
766
|
function profileMcp(p) {
|
|
596
|
-
const
|
|
597
|
-
|
|
767
|
+
const explicitConfig = existsSync7(join6(p, ".mcp.json")) ? join6(p, ".mcp.json") : existsSync7(join6(p, "mcp.json")) ? join6(p, "mcp.json") : void 0;
|
|
768
|
+
if (explicitConfig) {
|
|
769
|
+
let cfg;
|
|
770
|
+
try {
|
|
771
|
+
cfg = JSON.parse(readFileSync4(explicitConfig, "utf-8"));
|
|
772
|
+
} catch (err) {
|
|
773
|
+
throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
|
|
774
|
+
}
|
|
775
|
+
const servers = cfg.mcpServers ?? cfg.servers ?? {};
|
|
776
|
+
const names = typeof servers === "object" && servers !== null ? Object.keys(servers).sort() : [];
|
|
777
|
+
return {
|
|
778
|
+
kind: "mcp",
|
|
779
|
+
inputPath: p,
|
|
780
|
+
config: explicitConfig,
|
|
781
|
+
servers: names,
|
|
782
|
+
serverCount: names.length,
|
|
783
|
+
artifactType: "mcp"
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
const pkgPath = join6(p, "package.json");
|
|
787
|
+
let pkg2;
|
|
598
788
|
try {
|
|
599
|
-
|
|
789
|
+
pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
|
|
600
790
|
} catch (err) {
|
|
601
|
-
throw new Error(`could not parse
|
|
791
|
+
throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
|
|
602
792
|
}
|
|
603
|
-
const
|
|
604
|
-
const names = typeof servers === "object" && servers !== null ? Object.keys(servers).sort() : [];
|
|
793
|
+
const name = typeof pkg2.name === "string" && pkg2.name.length > 0 ? pkg2.name : basename3(p);
|
|
605
794
|
return {
|
|
606
795
|
kind: "mcp",
|
|
607
796
|
inputPath: p,
|
|
608
|
-
config:
|
|
609
|
-
servers:
|
|
610
|
-
serverCount:
|
|
797
|
+
config: pkgPath,
|
|
798
|
+
servers: [name],
|
|
799
|
+
serverCount: 1,
|
|
800
|
+
artifactType: "mcp"
|
|
611
801
|
};
|
|
612
802
|
}
|
|
613
803
|
|
|
614
804
|
// src/gate/safety.ts
|
|
615
|
-
import { readFileSync as
|
|
805
|
+
import { readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
616
806
|
import { execFile } from "child_process";
|
|
617
807
|
import { delimiter, extname as extname2, join as join7 } from "path";
|
|
618
808
|
import { promisify } from "util";
|
|
619
809
|
var execFileAsync = promisify(execFile);
|
|
620
|
-
var
|
|
810
|
+
var BLOCK_SEVERITIES_BY_STRICTNESS = {
|
|
811
|
+
"block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
|
|
812
|
+
"block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
|
|
813
|
+
"warn-only": /* @__PURE__ */ new Set()
|
|
814
|
+
};
|
|
621
815
|
var DENY_RULES = [
|
|
622
816
|
{
|
|
623
817
|
rule: "curl-pipe-shell",
|
|
@@ -724,7 +918,7 @@ function readTextFile(file) {
|
|
|
724
918
|
if (BINARY_EXT.has(extname2(file).toLowerCase())) return void 0;
|
|
725
919
|
try {
|
|
726
920
|
if (statSync4(file).size > MAX_FILE_BYTES) return void 0;
|
|
727
|
-
const content =
|
|
921
|
+
const content = readFileSync5(file, "utf-8");
|
|
728
922
|
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
729
923
|
return content;
|
|
730
924
|
} catch {
|
|
@@ -799,19 +993,20 @@ async function scanWithSkillSpector(targetPath) {
|
|
|
799
993
|
return [];
|
|
800
994
|
}
|
|
801
995
|
}
|
|
802
|
-
function verdictFor(findings) {
|
|
803
|
-
|
|
996
|
+
function verdictFor(findings, strictness) {
|
|
997
|
+
const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
|
|
998
|
+
if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
|
|
804
999
|
if (findings.length > 0) return "warn";
|
|
805
1000
|
return "pass";
|
|
806
1001
|
}
|
|
807
|
-
async function scanSafety(targetPath) {
|
|
1002
|
+
async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
808
1003
|
const findings = [...scanStaticRules(targetPath), ...await scanWithSkillSpector(targetPath)];
|
|
809
|
-
return { findings, verdict: verdictFor(findings) };
|
|
1004
|
+
return { findings, verdict: verdictFor(findings, strictness) };
|
|
810
1005
|
}
|
|
811
1006
|
|
|
812
1007
|
// src/gate/overlap.ts
|
|
813
|
-
import { existsSync as
|
|
814
|
-
import { basename as basename4, dirname as
|
|
1008
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
|
|
1009
|
+
import { basename as basename4, dirname as dirname3, join as join8, resolve as resolve3 } from "path";
|
|
815
1010
|
var STOPWORDS = new Set(
|
|
816
1011
|
`
|
|
817
1012
|
a an the and or of to for in on with use used using when whether this that these those is are be
|
|
@@ -832,8 +1027,8 @@ function splitLimited(text, sep, maxSplit) {
|
|
|
832
1027
|
return parts;
|
|
833
1028
|
}
|
|
834
1029
|
function readFrontmatter(skillMdPath) {
|
|
835
|
-
const text =
|
|
836
|
-
const meta = { name: basename4(
|
|
1030
|
+
const text = readFileSync6(skillMdPath, "utf-8");
|
|
1031
|
+
const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
|
|
837
1032
|
if (text.startsWith("---\n")) {
|
|
838
1033
|
const block = splitLimited(text, "---", 2)[1] ?? "";
|
|
839
1034
|
const nameMatch = block.match(/^name:\s*(.+)$/m);
|
|
@@ -927,7 +1122,7 @@ function suggestVerbCode(topScore) {
|
|
|
927
1122
|
return verbTier(topScore).verb;
|
|
928
1123
|
}
|
|
929
1124
|
function listSubdirs(dir) {
|
|
930
|
-
if (!
|
|
1125
|
+
if (!existsSync8(dir)) return [];
|
|
931
1126
|
let entries;
|
|
932
1127
|
try {
|
|
933
1128
|
entries = readdirSync4(dir, { withFileTypes: true });
|
|
@@ -940,16 +1135,16 @@ function findSkillMdFiles(root) {
|
|
|
940
1135
|
const found = /* @__PURE__ */ new Set();
|
|
941
1136
|
for (const dir of listSubdirs(root)) {
|
|
942
1137
|
const candidate = join8(dir, "SKILL.md");
|
|
943
|
-
if (
|
|
1138
|
+
if (existsSync8(candidate)) found.add(resolve3(candidate));
|
|
944
1139
|
}
|
|
945
1140
|
for (const dir of listSubdirs(join8(root, "skills"))) {
|
|
946
1141
|
const candidate = join8(dir, "SKILL.md");
|
|
947
|
-
if (
|
|
1142
|
+
if (existsSync8(candidate)) found.add(resolve3(candidate));
|
|
948
1143
|
}
|
|
949
1144
|
for (const topDir of listSubdirs(root)) {
|
|
950
1145
|
for (const dir of listSubdirs(join8(topDir, "skills"))) {
|
|
951
1146
|
const candidate = join8(dir, "SKILL.md");
|
|
952
|
-
if (
|
|
1147
|
+
if (existsSync8(candidate)) found.add(resolve3(candidate));
|
|
953
1148
|
}
|
|
954
1149
|
}
|
|
955
1150
|
return Array.from(found).filter((p) => !p.includes("/.git/")).sort();
|
|
@@ -963,7 +1158,7 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
963
1158
|
if (isDir(cpath)) {
|
|
964
1159
|
cpath = join8(cpath, "SKILL.md");
|
|
965
1160
|
}
|
|
966
|
-
if (!
|
|
1161
|
+
if (!existsSync8(cpath)) {
|
|
967
1162
|
throw new Error(`candidate SKILL.md not found: ${cpath}`);
|
|
968
1163
|
}
|
|
969
1164
|
const cand = readFrontmatter(cpath);
|
|
@@ -986,6 +1181,338 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
986
1181
|
};
|
|
987
1182
|
}
|
|
988
1183
|
|
|
1184
|
+
// src/gate/mcpSafety.ts
|
|
1185
|
+
import { readFileSync as readFileSync7, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
|
|
1186
|
+
import { basename as basename5, extname as extname3, join as join9 } from "path";
|
|
1187
|
+
var SKIP_DIRS3 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
1188
|
+
var BINARY_EXT2 = /* @__PURE__ */ new Set([
|
|
1189
|
+
".png",
|
|
1190
|
+
".jpg",
|
|
1191
|
+
".jpeg",
|
|
1192
|
+
".gif",
|
|
1193
|
+
".webp",
|
|
1194
|
+
".ico",
|
|
1195
|
+
".pdf",
|
|
1196
|
+
".zip",
|
|
1197
|
+
".tar",
|
|
1198
|
+
".gz",
|
|
1199
|
+
".woff",
|
|
1200
|
+
".woff2",
|
|
1201
|
+
".ttf",
|
|
1202
|
+
".eot",
|
|
1203
|
+
".mp4",
|
|
1204
|
+
".mp3",
|
|
1205
|
+
".wasm",
|
|
1206
|
+
".bin",
|
|
1207
|
+
".exe"
|
|
1208
|
+
]);
|
|
1209
|
+
var MAX_FILE_BYTES2 = 2 * 1024 * 1024;
|
|
1210
|
+
function* walk2(dir) {
|
|
1211
|
+
let entries;
|
|
1212
|
+
try {
|
|
1213
|
+
entries = readdirSync5(dir, { withFileTypes: true });
|
|
1214
|
+
} catch {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
for (const entry of entries) {
|
|
1218
|
+
const full = join9(dir, entry.name);
|
|
1219
|
+
if (entry.isDirectory()) {
|
|
1220
|
+
if (SKIP_DIRS3.has(entry.name)) continue;
|
|
1221
|
+
yield* walk2(full);
|
|
1222
|
+
} else if (entry.isFile()) {
|
|
1223
|
+
yield full;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
function readTextFile2(file) {
|
|
1228
|
+
if (BINARY_EXT2.has(extname3(file).toLowerCase())) return void 0;
|
|
1229
|
+
try {
|
|
1230
|
+
if (statSync5(file).size > MAX_FILE_BYTES2) return void 0;
|
|
1231
|
+
const content = readFileSync7(file, "utf-8");
|
|
1232
|
+
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
1233
|
+
return content;
|
|
1234
|
+
} catch {
|
|
1235
|
+
return void 0;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
function isConfigOrExampleFile(file) {
|
|
1239
|
+
const base = basename5(file).toLowerCase();
|
|
1240
|
+
if (base.startsWith(".env")) return true;
|
|
1241
|
+
if (/\.(json|ya?ml)$/.test(base)) return true;
|
|
1242
|
+
return /(example|sample|config)/.test(base);
|
|
1243
|
+
}
|
|
1244
|
+
var CREDENTIAL_KEY_RE = /(API[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i;
|
|
1245
|
+
function looksCredentialKey(name) {
|
|
1246
|
+
return CREDENTIAL_KEY_RE.test(name);
|
|
1247
|
+
}
|
|
1248
|
+
var PLACEHOLDER_VALUE_RE = /^(\$\{.*\}|\$[A-Z0-9_]+|your[_-]?.*|<.*>|xxx+|changeme|replace[_-]?me|example|placeholder|todo|fill[_-]?me|none|null|redacted|\.\.\.)$/i;
|
|
1249
|
+
function isPlaceholderValue(value) {
|
|
1250
|
+
const v = value.trim();
|
|
1251
|
+
if (v === "") return true;
|
|
1252
|
+
if (v.startsWith("process.env")) return true;
|
|
1253
|
+
return PLACEHOLDER_VALUE_RE.test(v);
|
|
1254
|
+
}
|
|
1255
|
+
function looksHighEntropy(value) {
|
|
1256
|
+
if (/\s/.test(value)) return false;
|
|
1257
|
+
if (value.length < 20) return false;
|
|
1258
|
+
const hasLower = /[a-z]/.test(value);
|
|
1259
|
+
const hasUpper = /[A-Z]/.test(value);
|
|
1260
|
+
const hasDigit = /[0-9]/.test(value);
|
|
1261
|
+
const varietyCount = [hasLower, hasUpper, hasDigit].filter(Boolean).length;
|
|
1262
|
+
const uniqueChars = new Set(value).size;
|
|
1263
|
+
return varietyCount >= 2 && uniqueChars / value.length > 0.4;
|
|
1264
|
+
}
|
|
1265
|
+
function isPinnedPackageSpec(spec) {
|
|
1266
|
+
const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
|
|
1267
|
+
return withoutScope.includes("@");
|
|
1268
|
+
}
|
|
1269
|
+
function collectServerNodes(node, out) {
|
|
1270
|
+
if (Array.isArray(node)) {
|
|
1271
|
+
for (const item of node) collectServerNodes(item, out);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (node && typeof node === "object") {
|
|
1275
|
+
const obj = node;
|
|
1276
|
+
if (typeof obj.command === "string" || Array.isArray(obj.args) || obj.env && typeof obj.env === "object") {
|
|
1277
|
+
out.push(obj);
|
|
1278
|
+
}
|
|
1279
|
+
for (const value of Object.values(obj)) collectServerNodes(value, out);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
function tryParseJson(text) {
|
|
1283
|
+
try {
|
|
1284
|
+
return JSON.parse(text);
|
|
1285
|
+
} catch {
|
|
1286
|
+
return void 0;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
function scanJsonConfig(file, parsed) {
|
|
1290
|
+
const findings = [];
|
|
1291
|
+
const nodes = [];
|
|
1292
|
+
collectServerNodes(parsed, nodes);
|
|
1293
|
+
for (const node of nodes) {
|
|
1294
|
+
const command = typeof node.command === "string" ? node.command : void 0;
|
|
1295
|
+
const args = Array.isArray(node.args) ? node.args.filter((a) => typeof a === "string") : [];
|
|
1296
|
+
if (command === "npx") {
|
|
1297
|
+
const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
|
|
1298
|
+
if (flagIdx !== -1) {
|
|
1299
|
+
const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
|
|
1300
|
+
if (pkgSpec && !isPinnedPackageSpec(pkgSpec)) {
|
|
1301
|
+
findings.push({
|
|
1302
|
+
severity: "MEDIUM",
|
|
1303
|
+
rule: "mcp-unpinned-npx",
|
|
1304
|
+
file,
|
|
1305
|
+
detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>) so the resolved server code can't change silently`
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
for (const a of args) {
|
|
1311
|
+
if (/^--dangerously-/i.test(a) || a === "--no-sandbox") {
|
|
1312
|
+
findings.push({
|
|
1313
|
+
severity: "HIGH",
|
|
1314
|
+
rule: "mcp-dangerous-flag",
|
|
1315
|
+
file,
|
|
1316
|
+
detail: `launch arg ${a} disables a safety guard`
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
if (a === "/" || a === "~") {
|
|
1320
|
+
findings.push({
|
|
1321
|
+
severity: "MEDIUM",
|
|
1322
|
+
rule: "mcp-filesystem-root-arg",
|
|
1323
|
+
file,
|
|
1324
|
+
detail: `launch arg "${a}" grants filesystem access at the root/home directory \u2014 scope this to a specific directory`
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
if (node.env && typeof node.env === "object" && !Array.isArray(node.env)) {
|
|
1329
|
+
for (const [key, value] of Object.entries(node.env)) {
|
|
1330
|
+
if (typeof value !== "string" || isPlaceholderValue(value)) continue;
|
|
1331
|
+
if (looksCredentialKey(key) || looksHighEntropy(value)) {
|
|
1332
|
+
findings.push({
|
|
1333
|
+
severity: "HIGH",
|
|
1334
|
+
rule: "mcp-inline-credential-value",
|
|
1335
|
+
file,
|
|
1336
|
+
detail: `env.${key} has an inline credential value \u2014 emit an empty string and document that the user must fill it in`
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return findings;
|
|
1343
|
+
}
|
|
1344
|
+
var NPX_SHELL_RE = /\bnpx\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/g;
|
|
1345
|
+
var DANGEROUS_FLAG_RE = /--dangerously-[\w-]+|--no-sandbox\b/g;
|
|
1346
|
+
var CREDENTIAL_ASSIGNMENT_RE = /["']?([A-Za-z][A-Za-z0-9_]*)["']?\s*[:=]\s*["']([^"']{6,})["']/g;
|
|
1347
|
+
function scanTextFile(file, content, allowCredentialCheck) {
|
|
1348
|
+
const findings = [];
|
|
1349
|
+
const lines = content.split("\n");
|
|
1350
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1351
|
+
const line = lines[i];
|
|
1352
|
+
const lineNo = i + 1;
|
|
1353
|
+
NPX_SHELL_RE.lastIndex = 0;
|
|
1354
|
+
let m;
|
|
1355
|
+
while ((m = NPX_SHELL_RE.exec(line)) !== null) {
|
|
1356
|
+
const pkgSpec = m[1];
|
|
1357
|
+
const pinned = m[2] !== void 0;
|
|
1358
|
+
if (!pinned) {
|
|
1359
|
+
findings.push({
|
|
1360
|
+
severity: "MEDIUM",
|
|
1361
|
+
rule: "mcp-unpinned-npx",
|
|
1362
|
+
file,
|
|
1363
|
+
detail: `line ${lineNo}: npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>)`
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
DANGEROUS_FLAG_RE.lastIndex = 0;
|
|
1368
|
+
while ((m = DANGEROUS_FLAG_RE.exec(line)) !== null) {
|
|
1369
|
+
findings.push({
|
|
1370
|
+
severity: "HIGH",
|
|
1371
|
+
rule: "mcp-dangerous-flag",
|
|
1372
|
+
file,
|
|
1373
|
+
detail: `line ${lineNo}: launch arg ${m[0]} disables a safety guard`
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
if (allowCredentialCheck) {
|
|
1377
|
+
CREDENTIAL_ASSIGNMENT_RE.lastIndex = 0;
|
|
1378
|
+
while ((m = CREDENTIAL_ASSIGNMENT_RE.exec(line)) !== null) {
|
|
1379
|
+
const varName = m[1];
|
|
1380
|
+
const value = m[2];
|
|
1381
|
+
if (isPlaceholderValue(value)) continue;
|
|
1382
|
+
if (looksCredentialKey(varName) || looksHighEntropy(value)) {
|
|
1383
|
+
findings.push({
|
|
1384
|
+
severity: "HIGH",
|
|
1385
|
+
rule: "mcp-inline-credential-value",
|
|
1386
|
+
file,
|
|
1387
|
+
detail: `line ${lineNo}: inline credential value assigned to ${varName}`
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return findings;
|
|
1394
|
+
}
|
|
1395
|
+
function scanFileForMcpFindings(file) {
|
|
1396
|
+
const content = readTextFile2(file);
|
|
1397
|
+
if (content === void 0) return [];
|
|
1398
|
+
if (extname3(file).toLowerCase() === ".json") {
|
|
1399
|
+
const parsed = tryParseJson(content);
|
|
1400
|
+
if (parsed !== void 0) return scanJsonConfig(file, parsed);
|
|
1401
|
+
}
|
|
1402
|
+
return scanTextFile(file, content, isConfigOrExampleFile(file));
|
|
1403
|
+
}
|
|
1404
|
+
function scanMcpFindings(targetPath) {
|
|
1405
|
+
let root;
|
|
1406
|
+
try {
|
|
1407
|
+
root = statSync5(targetPath);
|
|
1408
|
+
} catch {
|
|
1409
|
+
return [];
|
|
1410
|
+
}
|
|
1411
|
+
const files = root.isDirectory() ? Array.from(walk2(targetPath)) : [targetPath];
|
|
1412
|
+
const findings = [];
|
|
1413
|
+
for (const file of files) {
|
|
1414
|
+
findings.push(...scanFileForMcpFindings(file));
|
|
1415
|
+
}
|
|
1416
|
+
return findings;
|
|
1417
|
+
}
|
|
1418
|
+
var BLOCK_SEVERITIES_BY_STRICTNESS2 = {
|
|
1419
|
+
"block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
|
|
1420
|
+
"block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
|
|
1421
|
+
"warn-only": /* @__PURE__ */ new Set()
|
|
1422
|
+
};
|
|
1423
|
+
function verdictFor2(findings, strictness) {
|
|
1424
|
+
const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS2[strictness];
|
|
1425
|
+
if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
|
|
1426
|
+
if (findings.length > 0) return "warn";
|
|
1427
|
+
return "pass";
|
|
1428
|
+
}
|
|
1429
|
+
async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
1430
|
+
const base = await scanSafety(targetPath, strictness);
|
|
1431
|
+
const mcpFindings = scanMcpFindings(targetPath);
|
|
1432
|
+
const findings = [...base.findings, ...mcpFindings];
|
|
1433
|
+
return { findings, verdict: verdictFor2(findings, strictness) };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/gate/mcpOverlap.ts
|
|
1437
|
+
import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
|
|
1438
|
+
import { resolve as resolve4 } from "path";
|
|
1439
|
+
function readTargetServers(targetPath) {
|
|
1440
|
+
const path = resolve4(expandHome(targetPath));
|
|
1441
|
+
if (!existsSync9(path)) return [];
|
|
1442
|
+
let cfg;
|
|
1443
|
+
try {
|
|
1444
|
+
cfg = JSON.parse(readFileSync8(path, "utf-8"));
|
|
1445
|
+
} catch {
|
|
1446
|
+
return [];
|
|
1447
|
+
}
|
|
1448
|
+
const servers = cfg.mcpServers ?? cfg.servers ?? {};
|
|
1449
|
+
if (typeof servers !== "object" || servers === null) return [];
|
|
1450
|
+
return Object.entries(servers).map(([name, raw]) => {
|
|
1451
|
+
const v = raw ?? {};
|
|
1452
|
+
return {
|
|
1453
|
+
name,
|
|
1454
|
+
command: typeof v.command === "string" ? v.command : void 0,
|
|
1455
|
+
args: Array.isArray(v.args) ? v.args.filter((a) => typeof a === "string") : void 0
|
|
1456
|
+
};
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
function tokenizeName(name) {
|
|
1460
|
+
return new Set(name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
1461
|
+
}
|
|
1462
|
+
function nameSimilarity(a, b) {
|
|
1463
|
+
if (a.toLowerCase() === b.toLowerCase()) return 1;
|
|
1464
|
+
const ta = tokenizeName(a);
|
|
1465
|
+
const tb = tokenizeName(b);
|
|
1466
|
+
if (ta.size === 0 || tb.size === 0) return 0;
|
|
1467
|
+
let inter = 0;
|
|
1468
|
+
for (const t of ta) if (tb.has(t)) inter += 1;
|
|
1469
|
+
const union = (/* @__PURE__ */ new Set([...ta, ...tb])).size;
|
|
1470
|
+
return union ? inter / union : 0;
|
|
1471
|
+
}
|
|
1472
|
+
function packageBaseName(spec) {
|
|
1473
|
+
const searchFrom = spec.startsWith("@") ? 1 : 0;
|
|
1474
|
+
const atIdx = spec.indexOf("@", searchFrom);
|
|
1475
|
+
return atIdx === -1 ? spec : spec.slice(0, atIdx);
|
|
1476
|
+
}
|
|
1477
|
+
function extractPackageSpec(command, args = []) {
|
|
1478
|
+
if (!command) return void 0;
|
|
1479
|
+
const base = command.split("/").pop() ?? command;
|
|
1480
|
+
if (["npx", "npm", "pnpm", "yarn", "uvx"].includes(base)) {
|
|
1481
|
+
return args.find((a) => !a.startsWith("-"));
|
|
1482
|
+
}
|
|
1483
|
+
return base;
|
|
1484
|
+
}
|
|
1485
|
+
function commandSimilarity(candidate, target) {
|
|
1486
|
+
const candSpec = candidate.packageName ?? extractPackageSpec(candidate.command, candidate.args ?? []);
|
|
1487
|
+
const targetSpec = extractPackageSpec(target.command, target.args ?? []);
|
|
1488
|
+
if (!candSpec || !targetSpec) return 0;
|
|
1489
|
+
return packageBaseName(candSpec).toLowerCase() === packageBaseName(targetSpec).toLowerCase() ? 1 : 0;
|
|
1490
|
+
}
|
|
1491
|
+
function round32(n) {
|
|
1492
|
+
return Math.round(n * 1e3) / 1e3;
|
|
1493
|
+
}
|
|
1494
|
+
function score2(candidate, target) {
|
|
1495
|
+
return round32(0.6 * nameSimilarity(candidate.name, target.name) + 0.4 * commandSimilarity(candidate, target));
|
|
1496
|
+
}
|
|
1497
|
+
function scanMcpOverlap(candidate, mcpTargetPaths) {
|
|
1498
|
+
const ranking = [];
|
|
1499
|
+
for (const targetPath of mcpTargetPaths) {
|
|
1500
|
+
const resolvedPath = resolve4(expandHome(targetPath));
|
|
1501
|
+
for (const target of readTargetServers(targetPath)) {
|
|
1502
|
+
ranking.push({ skill: target.name, path: resolvedPath, score: score2(candidate, target) });
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
ranking.sort((a, b) => b.score - a.score);
|
|
1506
|
+
const top = ranking[0];
|
|
1507
|
+
return {
|
|
1508
|
+
candidate: candidate.name,
|
|
1509
|
+
nearest: top?.skill ?? "",
|
|
1510
|
+
nearestScore: top?.score ?? 0,
|
|
1511
|
+
suggestedVerb: suggestVerb(top?.score ?? 0),
|
|
1512
|
+
ranking
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
|
|
989
1516
|
// src/license.ts
|
|
990
1517
|
import { verify as cryptoVerify } from "crypto";
|
|
991
1518
|
var LICENSE_PREFIX = "skf1";
|
|
@@ -1101,11 +1628,20 @@ function renderFindingsLines(findings) {
|
|
|
1101
1628
|
if (!findings?.length) return [];
|
|
1102
1629
|
return [`${padLabel("Findings")}:`, ...findings.map(renderFindingLine)];
|
|
1103
1630
|
}
|
|
1631
|
+
function profileHeaderFields(profile) {
|
|
1632
|
+
if (profile.kind === "mcp") {
|
|
1633
|
+
const name = profile.serverCount === 1 ? profile.servers[0] : `${profile.serverCount} servers`;
|
|
1634
|
+
return { name, version: "n/a", license: "n/a" };
|
|
1635
|
+
}
|
|
1636
|
+
return {
|
|
1637
|
+
name: profile.name ?? "unknown",
|
|
1638
|
+
version: profile.version ?? "unknown",
|
|
1639
|
+
license: profile.kind === "skill" ? profile.license : "UNKNOWN"
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1104
1642
|
function renderReport(result) {
|
|
1105
1643
|
const { profile, overlap, overlapLocked, safety, worthTaking, leaveBehind } = result;
|
|
1106
|
-
const name = profile
|
|
1107
|
-
const version = profile.version ?? "unknown";
|
|
1108
|
-
const license = profile.license ?? "UNKNOWN";
|
|
1644
|
+
const { name, version, license } = profileHeaderFields(profile);
|
|
1109
1645
|
const overlapLines = overlapLocked ? [`${padLabel("Overlap analysis")}: ${upgradeNotice("overlap analysis")}`] : overlap ? [
|
|
1110
1646
|
`${padLabel("Nearest existing skill")}: ${overlap.nearest} (overlap ${overlap.nearestScore})`,
|
|
1111
1647
|
`${padLabel("Recommended verb")}: ${overlap.suggestedVerb}`
|
|
@@ -1114,6 +1650,7 @@ function renderReport(result) {
|
|
|
1114
1650
|
BORDER,
|
|
1115
1651
|
`\u{1F528} Forge: ${name} (v${version}, ${license})`,
|
|
1116
1652
|
BORDER,
|
|
1653
|
+
`${padLabel("Artifact type")}: ${profile.artifactType}`,
|
|
1117
1654
|
...overlapLines,
|
|
1118
1655
|
renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
|
|
1119
1656
|
renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
|
|
@@ -1139,10 +1676,13 @@ function printGateOutput(gateResult, json) {
|
|
|
1139
1676
|
}
|
|
1140
1677
|
console.log(json ? renderJson(gateResult) : renderReport(gateResult));
|
|
1141
1678
|
}
|
|
1142
|
-
function
|
|
1679
|
+
function asProfile(candidatePath, artifact) {
|
|
1143
1680
|
const profile = profileSkill(candidatePath);
|
|
1144
|
-
|
|
1145
|
-
|
|
1681
|
+
const expectedKind = artifact === "mcp" ? "mcp" : "skill";
|
|
1682
|
+
if (profile.kind !== expectedKind) {
|
|
1683
|
+
throw new Error(
|
|
1684
|
+
artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
|
|
1685
|
+
);
|
|
1146
1686
|
}
|
|
1147
1687
|
return profile;
|
|
1148
1688
|
}
|
|
@@ -1154,11 +1694,42 @@ function tryOverlap(candidatePath, skillsRoot) {
|
|
|
1154
1694
|
return void 0;
|
|
1155
1695
|
}
|
|
1156
1696
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1697
|
+
function tryMcpOverlap(profile, mcpTargets) {
|
|
1698
|
+
try {
|
|
1699
|
+
const detail = resolveMcpCandidateDetail(profile);
|
|
1700
|
+
return scanMcpOverlap(
|
|
1701
|
+
{
|
|
1702
|
+
name: detail.name,
|
|
1703
|
+
command: detail.command,
|
|
1704
|
+
args: detail.args,
|
|
1705
|
+
packageName: detail.command ? void 0 : detail.name
|
|
1706
|
+
},
|
|
1707
|
+
mcpTargets
|
|
1708
|
+
);
|
|
1709
|
+
} catch (err) {
|
|
1710
|
+
console.error(`[skill-forge] mcp overlap scan skipped: ${err.message}`);
|
|
1711
|
+
return void 0;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
function resolveMcpCandidatePath(entry) {
|
|
1715
|
+
if (entry.packageDir) return entry.packageDir;
|
|
1716
|
+
let children = [];
|
|
1717
|
+
try {
|
|
1718
|
+
children = readdirSync6(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
1719
|
+
} catch {
|
|
1720
|
+
}
|
|
1721
|
+
return children.length === 1 ? join10(entry.quarantinePath, children[0]) : entry.quarantinePath;
|
|
1722
|
+
}
|
|
1723
|
+
async function runGate(candidatePath, skillsRoot, {
|
|
1724
|
+
proFeatureLabel = "overlap analysis",
|
|
1725
|
+
strictness = DEFAULT_STRICTNESS,
|
|
1726
|
+
artifact = "skill",
|
|
1727
|
+
mcpTargets = []
|
|
1728
|
+
} = {}) {
|
|
1729
|
+
const profile = asProfile(candidatePath, artifact);
|
|
1160
1730
|
const proStatus = proFeatureStatus(proFeatureLabel);
|
|
1161
|
-
const
|
|
1731
|
+
const safety = artifact === "mcp" ? await mcpScan(candidatePath, strictness) : await scanSafety(candidatePath, strictness);
|
|
1732
|
+
const overlap = !proStatus.enabled ? void 0 : artifact === "mcp" ? tryMcpOverlap(profile, mcpTargets) : tryOverlap(candidatePath, skillsRoot);
|
|
1162
1733
|
const gateResult = {
|
|
1163
1734
|
profile,
|
|
1164
1735
|
overlap,
|
|
@@ -1178,23 +1749,23 @@ async function runGate(candidatePath, skillsRoot, proFeatureLabel = "overlap ana
|
|
|
1178
1749
|
|
|
1179
1750
|
// src/queue.ts
|
|
1180
1751
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1181
|
-
import { existsSync as
|
|
1182
|
-
import { join as
|
|
1752
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
|
|
1753
|
+
import { join as join11 } from "path";
|
|
1183
1754
|
function getQueuePath() {
|
|
1184
|
-
return
|
|
1755
|
+
return join11(getHomeDir(), "queue.json");
|
|
1185
1756
|
}
|
|
1186
1757
|
function readQueue() {
|
|
1187
1758
|
const path = getQueuePath();
|
|
1188
|
-
if (!
|
|
1759
|
+
if (!existsSync10(path)) {
|
|
1189
1760
|
return { version: 1, entries: [] };
|
|
1190
1761
|
}
|
|
1191
|
-
const raw =
|
|
1762
|
+
const raw = readFileSync9(path, "utf-8");
|
|
1192
1763
|
const parsed = JSON.parse(raw);
|
|
1193
1764
|
return { version: 1, entries: parsed.entries ?? [] };
|
|
1194
1765
|
}
|
|
1195
1766
|
function writeQueue(queue) {
|
|
1196
|
-
|
|
1197
|
-
|
|
1767
|
+
mkdirSync5(getHomeDir(), { recursive: true });
|
|
1768
|
+
writeFileSync4(getQueuePath(), JSON.stringify(queue, null, 2) + "\n", "utf-8");
|
|
1198
1769
|
}
|
|
1199
1770
|
function appendQueueEntry(entry) {
|
|
1200
1771
|
const queue = readQueue();
|
|
@@ -1218,16 +1789,16 @@ function generateId(existingIds) {
|
|
|
1218
1789
|
|
|
1219
1790
|
// src/handoff.ts
|
|
1220
1791
|
import { spawnSync } from "child_process";
|
|
1221
|
-
import { existsSync as
|
|
1222
|
-
import { dirname as
|
|
1792
|
+
import { existsSync as existsSync11 } from "fs";
|
|
1793
|
+
import { dirname as dirname4, join as join12 } from "path";
|
|
1223
1794
|
import { fileURLToPath } from "url";
|
|
1224
1795
|
var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
|
|
1225
1796
|
var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
|
|
1226
1797
|
function resolvePromptPath() {
|
|
1227
|
-
const here =
|
|
1228
|
-
const builtCandidate =
|
|
1229
|
-
if (
|
|
1230
|
-
return
|
|
1798
|
+
const here = dirname4(fileURLToPath(import.meta.url));
|
|
1799
|
+
const builtCandidate = join12(here, "ingest-prompt.md");
|
|
1800
|
+
if (existsSync11(builtCandidate)) return builtCandidate;
|
|
1801
|
+
return join12(here, "..", "assets", "ingest-prompt.md");
|
|
1231
1802
|
}
|
|
1232
1803
|
function defaultIsOnPath(bin) {
|
|
1233
1804
|
const finder = process.platform === "win32" ? "where" : "which";
|
|
@@ -1282,20 +1853,28 @@ function runHandoff(config, installedPath, options = {}) {
|
|
|
1282
1853
|
}
|
|
1283
1854
|
|
|
1284
1855
|
// src/commands/init.ts
|
|
1285
|
-
import { existsSync as
|
|
1856
|
+
import { existsSync as existsSync12 } from "fs";
|
|
1286
1857
|
import { homedir as homedir4 } from "os";
|
|
1287
1858
|
import { createInterface } from "readline/promises";
|
|
1288
1859
|
|
|
1289
1860
|
// src/agents.ts
|
|
1861
|
+
import { statSync as statSync6 } from "fs";
|
|
1290
1862
|
import { homedir as homedir3 } from "os";
|
|
1291
|
-
import { join as
|
|
1863
|
+
import { join as join13, resolve as resolve5 } from "path";
|
|
1864
|
+
function isFile(p) {
|
|
1865
|
+
try {
|
|
1866
|
+
return statSync6(p).isFile();
|
|
1867
|
+
} catch {
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1292
1871
|
function projectPath(root, cwd) {
|
|
1293
|
-
return root ?
|
|
1872
|
+
return root ? resolve5(cwd, root) : void 0;
|
|
1294
1873
|
}
|
|
1295
1874
|
function globalPath(root, home) {
|
|
1296
1875
|
if (!root) return void 0;
|
|
1297
1876
|
if (root === "~") return home;
|
|
1298
|
-
if (root.startsWith("~/")) return
|
|
1877
|
+
if (root.startsWith("~/")) return join13(home, root.slice(2));
|
|
1299
1878
|
return root;
|
|
1300
1879
|
}
|
|
1301
1880
|
function makeAgent(spec) {
|
|
@@ -1306,6 +1885,7 @@ function makeAgent(spec) {
|
|
|
1306
1885
|
globalRoot: spec.globalRoot,
|
|
1307
1886
|
verified: spec.verified ?? true,
|
|
1308
1887
|
defaultHandoffTemplate: spec.defaultHandoffTemplate,
|
|
1888
|
+
mcpConfig: spec.mcpConfig,
|
|
1309
1889
|
detect: (cwd, home) => detectAgentRoot(agent, cwd, home) !== void 0
|
|
1310
1890
|
};
|
|
1311
1891
|
return agent;
|
|
@@ -1320,6 +1900,19 @@ function detectAgentRoot(agent, cwd = process.cwd(), home = homedir3()) {
|
|
|
1320
1900
|
}
|
|
1321
1901
|
return void 0;
|
|
1322
1902
|
}
|
|
1903
|
+
function resolveMcpConfigPath(agent, scope, cwd = process.cwd(), home = homedir3()) {
|
|
1904
|
+
if (!agent.mcpConfig) return void 0;
|
|
1905
|
+
return scope === "project" ? projectPath(agent.mcpConfig.project, cwd) : globalPath(agent.mcpConfig.global, home);
|
|
1906
|
+
}
|
|
1907
|
+
function detectMcpConfigFiles(agent, cwd = process.cwd(), home = homedir3()) {
|
|
1908
|
+
if (!agent.mcpConfig) return [];
|
|
1909
|
+
const found = [];
|
|
1910
|
+
for (const scope of ["project", "global"]) {
|
|
1911
|
+
const path = resolveMcpConfigPath(agent, scope, cwd, home);
|
|
1912
|
+
if (path && isFile(path)) found.push({ scope, path });
|
|
1913
|
+
}
|
|
1914
|
+
return found;
|
|
1915
|
+
}
|
|
1323
1916
|
var AGENTS = [
|
|
1324
1917
|
{ id: "aider-desk", displayName: "AiderDesk", projectRoot: ".aider-desk/skills", globalRoot: "~/.aider-desk/skills" },
|
|
1325
1918
|
{ id: "amp", displayName: "Amp", projectRoot: ".agents/skills", globalRoot: "~/.config/agents/skills" },
|
|
@@ -1341,7 +1934,14 @@ var AGENTS = [
|
|
|
1341
1934
|
displayName: "Claude Code",
|
|
1342
1935
|
projectRoot: ".claude/skills",
|
|
1343
1936
|
globalRoot: "~/.claude/skills",
|
|
1344
|
-
|
|
1937
|
+
// `claude -p "<prompt>"` runs one non-interactive turn and exits. Verified 2026-07-11:
|
|
1938
|
+
// https://code.claude.com/docs/en/headless
|
|
1939
|
+
defaultHandoffTemplate: ["claude", "-p", HANDOFF_INSTRUCTION],
|
|
1940
|
+
// Project scope writes to `.mcp.json` at the project root (shared via version control).
|
|
1941
|
+
// User scope writes to a top-level `mcpServers` key in `~/.claude.json` (not exclusively
|
|
1942
|
+
// an MCP file — it's Claude Code's broader state file — but it's the correct read/write
|
|
1943
|
+
// target for user-scoped servers). Verified 2026-07-12: https://code.claude.com/docs/en/mcp
|
|
1944
|
+
mcpConfig: { project: ".mcp.json", global: "~/.claude.json", verified: true }
|
|
1345
1945
|
},
|
|
1346
1946
|
{ id: "openclaw", displayName: "OpenClaw", projectRoot: "skills", globalRoot: "~/.openclaw/skills" },
|
|
1347
1947
|
{ id: "cline", displayName: "Cline", projectRoot: ".agents/skills", globalRoot: "~/.agents/skills" },
|
|
@@ -1364,7 +1964,14 @@ var AGENTS = [
|
|
|
1364
1964
|
displayName: "Codex",
|
|
1365
1965
|
projectRoot: ".agents/skills",
|
|
1366
1966
|
globalRoot: "~/.codex/skills",
|
|
1367
|
-
|
|
1967
|
+
// `codex exec "<prompt>"` runs one non-interactive task and prints the final message.
|
|
1968
|
+
// Verified 2026-07-11: https://developers.openai.com/codex/noninteractive
|
|
1969
|
+
defaultHandoffTemplate: ["codex", "exec", HANDOFF_INSTRUCTION],
|
|
1970
|
+
// TOML, not JSON: `[mcp_servers.<name>]` tables. User config at `~/.codex/config.toml`;
|
|
1971
|
+
// project config at `.codex/config.toml` (only honored for trusted projects). Verified
|
|
1972
|
+
// 2026-07-12: https://learn.chatgpt.com/docs/extend/mcp?surface=cli — detect/report-only
|
|
1973
|
+
// per the v0.5 contract (promote only writes JSON targets).
|
|
1974
|
+
mcpConfig: { project: ".codex/config.toml", global: "~/.codex/config.toml", format: "toml", verified: true }
|
|
1368
1975
|
},
|
|
1369
1976
|
{
|
|
1370
1977
|
id: "command-code",
|
|
@@ -1375,7 +1982,16 @@ var AGENTS = [
|
|
|
1375
1982
|
{ id: "continue", displayName: "Continue", projectRoot: ".continue/skills", globalRoot: "~/.continue/skills" },
|
|
1376
1983
|
{ id: "cortex", displayName: "Cortex Code", projectRoot: ".cortex/skills", globalRoot: "~/.snowflake/cortex/skills" },
|
|
1377
1984
|
{ id: "crush", displayName: "Crush", projectRoot: ".crush/skills", globalRoot: "~/.config/crush/skills" },
|
|
1378
|
-
{
|
|
1985
|
+
{
|
|
1986
|
+
id: "cursor",
|
|
1987
|
+
displayName: "Cursor",
|
|
1988
|
+
projectRoot: ".agents/skills",
|
|
1989
|
+
globalRoot: "~/.cursor/skills",
|
|
1990
|
+
// Both scopes use a top-level `mcpServers` key. Verified 2026-07-12:
|
|
1991
|
+
// https://cursor.com/docs/context/mcp ("Create `.cursor/mcp.json` in your project ...";
|
|
1992
|
+
// "Create `~/.cursor/mcp.json` in your home directory ...").
|
|
1993
|
+
mcpConfig: { project: ".cursor/mcp.json", global: "~/.cursor/mcp.json", verified: true }
|
|
1994
|
+
},
|
|
1379
1995
|
{
|
|
1380
1996
|
id: "deepagents",
|
|
1381
1997
|
displayName: "Deep Agents",
|
|
@@ -1392,13 +2008,26 @@ var AGENTS = [
|
|
|
1392
2008
|
displayName: "Gemini CLI",
|
|
1393
2009
|
projectRoot: ".agents/skills",
|
|
1394
2010
|
globalRoot: "~/.gemini/skills",
|
|
1395
|
-
|
|
2011
|
+
// `gemini -p "<prompt>"` executes a single turn headlessly and exits. Verified 2026-07-11:
|
|
2012
|
+
// https://google-gemini.github.io/gemini-cli/docs/cli/headless.html
|
|
2013
|
+
defaultHandoffTemplate: ["gemini", "-p", HANDOFF_INSTRUCTION],
|
|
2014
|
+
// `mcpServers` key inside `settings.json` (shared with other CLI settings, not an
|
|
2015
|
+
// MCP-exclusive file, but the correct read/write target). User config at
|
|
2016
|
+
// `~/.gemini/settings.json`, project config at `.gemini/settings.json`. Verified
|
|
2017
|
+
// 2026-07-12: https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html
|
|
2018
|
+
mcpConfig: { project: ".gemini/settings.json", global: "~/.gemini/settings.json", verified: true }
|
|
1396
2019
|
},
|
|
1397
2020
|
{
|
|
1398
2021
|
id: "github-copilot",
|
|
1399
2022
|
displayName: "GitHub Copilot",
|
|
1400
2023
|
projectRoot: ".agents/skills",
|
|
1401
|
-
globalRoot: "~/.copilot/skills"
|
|
2024
|
+
globalRoot: "~/.copilot/skills",
|
|
2025
|
+
// Copilot CLI (matches the `~/.copilot` skills convention above, not the VS Code
|
|
2026
|
+
// extension — which uses a different `servers`-keyed `.vscode/mcp.json` and isn't covered
|
|
2027
|
+
// by this entry). User config `~/.copilot/mcp-config.json` (`mcpServers` key), repo-level
|
|
2028
|
+
// config `.github/mcp.json`. Verified 2026-07-12:
|
|
2029
|
+
// https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers
|
|
2030
|
+
mcpConfig: { project: ".github/mcp.json", global: "~/.copilot/mcp-config.json", verified: true }
|
|
1402
2031
|
},
|
|
1403
2032
|
{ id: "goose", displayName: "Goose", projectRoot: ".goose/skills", globalRoot: "~/.config/goose/skills" },
|
|
1404
2033
|
{ id: "hermes-agent", displayName: "Hermes Agent", projectRoot: ".hermes/skills", globalRoot: "~/.hermes/skills" },
|
|
@@ -1424,6 +2053,8 @@ var AGENTS = [
|
|
|
1424
2053
|
displayName: "OpenCode",
|
|
1425
2054
|
projectRoot: ".agents/skills",
|
|
1426
2055
|
globalRoot: "~/.config/opencode/skills",
|
|
2056
|
+
// `opencode run "<prompt>"` runs one non-interactive prompt outside the TUI.
|
|
2057
|
+
// Verified 2026-07-11: https://opencode.ai/docs/cli/
|
|
1427
2058
|
defaultHandoffTemplate: ["opencode", "run", HANDOFF_INSTRUCTION]
|
|
1428
2059
|
},
|
|
1429
2060
|
{ id: "openhands", displayName: "OpenHands", projectRoot: ".openhands/skills", globalRoot: "~/.openhands/skills" },
|
|
@@ -1445,7 +2076,15 @@ var AGENTS = [
|
|
|
1445
2076
|
{ id: "tinycloud", displayName: "Tinycloud", projectRoot: ".tinycloud/skills", globalRoot: "~/.tinycloud/skills" },
|
|
1446
2077
|
{ id: "trae", displayName: "Trae", projectRoot: ".trae/skills", globalRoot: "~/.trae/skills" },
|
|
1447
2078
|
{ id: "trae-cn", displayName: "Trae CN", projectRoot: ".trae/skills", globalRoot: "~/.trae-cn/skills" },
|
|
1448
|
-
{
|
|
2079
|
+
{
|
|
2080
|
+
id: "windsurf",
|
|
2081
|
+
displayName: "Windsurf",
|
|
2082
|
+
projectRoot: ".windsurf/skills",
|
|
2083
|
+
globalRoot: "~/.codeium/windsurf/skills",
|
|
2084
|
+
// `mcpServers` key; global only — no project-scoped MCP config is documented. Verified
|
|
2085
|
+
// 2026-07-12: https://docs.windsurf.com/windsurf/cascade/mcp
|
|
2086
|
+
mcpConfig: { global: "~/.codeium/windsurf/mcp_config.json", verified: true }
|
|
2087
|
+
},
|
|
1449
2088
|
{ id: "zcode", displayName: "ZCode", projectRoot: ".zcode/skills", globalRoot: "~/.zcode/skills" },
|
|
1450
2089
|
{ id: "zencoder", displayName: "Zencoder", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
|
|
1451
2090
|
{ id: "zenflow", displayName: "Zenflow", projectRoot: ".zencoder/skills", globalRoot: "~/.zencoder/skills" },
|
|
@@ -1464,6 +2103,15 @@ function detectAgents(cwd, home) {
|
|
|
1464
2103
|
}
|
|
1465
2104
|
return found;
|
|
1466
2105
|
}
|
|
2106
|
+
function detectMcpTargets(cwd, home) {
|
|
2107
|
+
const found = [];
|
|
2108
|
+
for (const agent of AGENTS) {
|
|
2109
|
+
for (const hit of detectMcpConfigFiles(agent, cwd, home)) {
|
|
2110
|
+
found.push({ agent, ...hit });
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
return found;
|
|
2114
|
+
}
|
|
1467
2115
|
function printDetectionResults(detected) {
|
|
1468
2116
|
console.log(`[skill-forge] init \u2014 agent detection (${AGENTS.length} known agents)`);
|
|
1469
2117
|
if (detected.length === 0) {
|
|
@@ -1474,17 +2122,27 @@ function printDetectionResults(detected) {
|
|
|
1474
2122
|
console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.root}`);
|
|
1475
2123
|
}
|
|
1476
2124
|
}
|
|
2125
|
+
function printMcpDetectionResults(detectedMcp) {
|
|
2126
|
+
if (detectedMcp.length === 0) return;
|
|
2127
|
+
console.log("\n[skill-forge] init \u2014 MCP config detection");
|
|
2128
|
+
for (const d of detectedMcp) {
|
|
2129
|
+
console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.path}`);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
1477
2132
|
function handoffTemplateFor(agent) {
|
|
1478
2133
|
return agent.defaultHandoffTemplate ?? [agent.id, "-p", HANDOFF_INSTRUCTION];
|
|
1479
2134
|
}
|
|
1480
|
-
function buildConfig(selected, defaultTargetEntry, handoffAgent) {
|
|
2135
|
+
function buildConfig(selected, defaultTargetEntry, handoffAgent, selectedMcp = []) {
|
|
1481
2136
|
const existing = loadConfig();
|
|
1482
2137
|
return {
|
|
1483
2138
|
...existing,
|
|
1484
|
-
|
|
1485
|
-
|
|
2139
|
+
// Skill-root selections are only overwritten when this run actually offered a choice —
|
|
2140
|
+
// an mcp-only run (no skill roots detected) must not blank out an existing skillsRoots/agents.
|
|
2141
|
+
skillsRoots: selected.length > 0 ? selected.map((s) => s.root) : existing.skillsRoots,
|
|
2142
|
+
agents: selected.length > 0 ? selected.map((s) => ({ id: s.agent.id, skillsRoot: s.root })) : existing.agents,
|
|
1486
2143
|
defaultTarget: defaultTargetEntry?.root ?? existing.defaultTarget,
|
|
1487
|
-
handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand
|
|
2144
|
+
handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand,
|
|
2145
|
+
mcpTargets: selectedMcp.length > 0 ? selectedMcp.map((m) => m.path) : existing.mcpTargets
|
|
1488
2146
|
};
|
|
1489
2147
|
}
|
|
1490
2148
|
function printSummary(config) {
|
|
@@ -1493,6 +2151,7 @@ function printSummary(config) {
|
|
|
1493
2151
|
console.log(` gate targets: ${config.skillsRoots.join(", ")}`);
|
|
1494
2152
|
if (config.defaultTarget) console.log(` default target: ${config.defaultTarget}`);
|
|
1495
2153
|
if (config.handoffCommand?.length) console.log(` handoff command: ${config.handoffCommand.join(" ")}`);
|
|
2154
|
+
if (config.mcpTargets?.length) console.log(` mcp targets: ${config.mcpTargets.join(", ")}`);
|
|
1496
2155
|
}
|
|
1497
2156
|
function parseChoice(answer, max) {
|
|
1498
2157
|
const n = Number(answer);
|
|
@@ -1518,6 +2177,26 @@ async function promptMultiSelect(rl, detected) {
|
|
|
1518
2177
|
}
|
|
1519
2178
|
return detected.filter((_, i) => chosen.has(i));
|
|
1520
2179
|
}
|
|
2180
|
+
async function promptMcpMultiSelect(rl, detectedMcp) {
|
|
2181
|
+
const chosen = new Set(detectedMcp.map((_, i) => i));
|
|
2182
|
+
for (; ; ) {
|
|
2183
|
+
console.log("\nMCP targets (all detected config files pre-selected \u2014 type a number to toggle, Enter to confirm):");
|
|
2184
|
+
detectedMcp.forEach((d, i) => {
|
|
2185
|
+
const mark = chosen.has(i) ? "[x]" : "[ ]";
|
|
2186
|
+
console.log(` ${mark} ${i + 1}. ${d.agent.displayName} (${d.scope}) \u2014 ${d.path}`);
|
|
2187
|
+
});
|
|
2188
|
+
const answer = (await rl.question("> ")).trim();
|
|
2189
|
+
if (answer === "") break;
|
|
2190
|
+
const idx = parseChoice(answer, detectedMcp.length);
|
|
2191
|
+
if (idx !== void 0) {
|
|
2192
|
+
if (chosen.has(idx)) chosen.delete(idx);
|
|
2193
|
+
else chosen.add(idx);
|
|
2194
|
+
} else {
|
|
2195
|
+
console.log(" (invalid selection \u2014 enter a listed number, or press Enter to confirm)");
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
return detectedMcp.filter((_, i) => chosen.has(i));
|
|
2199
|
+
}
|
|
1521
2200
|
async function promptDefaultTarget(rl, selected) {
|
|
1522
2201
|
console.log("\nDefault promotion target:");
|
|
1523
2202
|
selected.forEach((d, i) => console.log(` ${i + 1}. ${d.agent.displayName} \u2014 ${d.root}`));
|
|
@@ -1540,17 +2219,19 @@ async function init(options = {}) {
|
|
|
1540
2219
|
const cwd = options.cwd ?? process.cwd();
|
|
1541
2220
|
const home = options.home ?? homedir4();
|
|
1542
2221
|
const detected = detectAgents(cwd, home);
|
|
2222
|
+
const detectedMcp = detectMcpTargets(cwd, home);
|
|
1543
2223
|
if (options.list) {
|
|
1544
2224
|
printDetectionResults(detected);
|
|
2225
|
+
printMcpDetectionResults(detectedMcp);
|
|
1545
2226
|
return;
|
|
1546
2227
|
}
|
|
1547
|
-
if (detected.length === 0) {
|
|
1548
|
-
console.log("[skill-forge] No known agent skill directories were found.");
|
|
2228
|
+
if (detected.length === 0 && detectedMcp.length === 0) {
|
|
2229
|
+
console.log("[skill-forge] No known agent skill directories or MCP config files were found.");
|
|
1549
2230
|
console.log(" Install a coding agent (Claude Code, Codex, Cursor, ...) or create a skills root, then re-run `skill-forge init`.");
|
|
1550
2231
|
return;
|
|
1551
2232
|
}
|
|
1552
2233
|
if (options.defaults) {
|
|
1553
|
-
const config = buildConfig(detected, detected[0], void 0);
|
|
2234
|
+
const config = buildConfig(detected, detected[0], void 0, detectedMcp);
|
|
1554
2235
|
saveConfig(config);
|
|
1555
2236
|
printSummary(config);
|
|
1556
2237
|
return;
|
|
@@ -1560,16 +2241,18 @@ async function init(options = {}) {
|
|
|
1560
2241
|
return;
|
|
1561
2242
|
}
|
|
1562
2243
|
printDetectionResults(detected);
|
|
2244
|
+
printMcpDetectionResults(detectedMcp);
|
|
1563
2245
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1564
2246
|
try {
|
|
1565
|
-
const selected = await promptMultiSelect(rl, detected);
|
|
1566
|
-
|
|
2247
|
+
const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
|
|
2248
|
+
const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
|
|
2249
|
+
if (selected.length === 0 && selectedMcp.length === 0) {
|
|
1567
2250
|
console.log("[skill-forge] No targets selected \u2014 aborting, nothing written.");
|
|
1568
2251
|
return;
|
|
1569
2252
|
}
|
|
1570
|
-
const target = await promptDefaultTarget(rl, selected);
|
|
1571
|
-
const handoffAgent = await promptHandoffAgent(rl, detected);
|
|
1572
|
-
const config = buildConfig(selected, target, handoffAgent);
|
|
2253
|
+
const target = selected.length > 0 ? await promptDefaultTarget(rl, selected) : void 0;
|
|
2254
|
+
const handoffAgent = detected.length > 0 ? await promptHandoffAgent(rl, detected) : void 0;
|
|
2255
|
+
const config = buildConfig(selected, target, handoffAgent, selectedMcp);
|
|
1573
2256
|
saveConfig(config);
|
|
1574
2257
|
printSummary(config);
|
|
1575
2258
|
} finally {
|
|
@@ -1578,7 +2261,7 @@ async function init(options = {}) {
|
|
|
1578
2261
|
}
|
|
1579
2262
|
async function maybeOfferInit(options = {}) {
|
|
1580
2263
|
if (options.json || options.yes || !process.stdin.isTTY) return;
|
|
1581
|
-
if (
|
|
2264
|
+
if (existsSync12(getConfigPath())) return;
|
|
1582
2265
|
console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
|
|
1583
2266
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1584
2267
|
let answer = "";
|
|
@@ -1594,6 +2277,7 @@ async function maybeOfferInit(options = {}) {
|
|
|
1594
2277
|
|
|
1595
2278
|
// src/commands/add.ts
|
|
1596
2279
|
var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
|
|
2280
|
+
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
1597
2281
|
function logOutcome(message, json) {
|
|
1598
2282
|
if (json) console.error(message);
|
|
1599
2283
|
else console.log(message);
|
|
@@ -1601,11 +2285,16 @@ function logOutcome(message, json) {
|
|
|
1601
2285
|
async function add(source, options = {}) {
|
|
1602
2286
|
await maybeOfferInit({ json: options.json, yes: options.yes });
|
|
1603
2287
|
const config = loadConfig();
|
|
2288
|
+
const artifact = options.artifact ?? "skill";
|
|
2289
|
+
if (artifact === "mcp") {
|
|
2290
|
+
await addMcp(source, config, options);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
1604
2293
|
const targetRoot = options.target ?? config.defaultTarget ?? config.skillsRoots[0];
|
|
1605
2294
|
if (!targetRoot) {
|
|
1606
2295
|
throw new Error("no target skills root configured; pass --target <dir> or set skillsRoots in the config");
|
|
1607
2296
|
}
|
|
1608
|
-
const resolved = resolve(source);
|
|
2297
|
+
const resolved = resolve(source, artifact);
|
|
1609
2298
|
const entry = await quarantine(resolved, config.quarantineDir);
|
|
1610
2299
|
if (entry.skillDirs.length === 0) {
|
|
1611
2300
|
console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
|
|
@@ -1619,8 +2308,15 @@ async function add(source, options = {}) {
|
|
|
1619
2308
|
` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
|
|
1620
2309
|
);
|
|
1621
2310
|
}
|
|
1622
|
-
const { safety, gateResult, proEnabled } = await runGate(entry.skillDirs[0], targetRoot,
|
|
2311
|
+
const { safety, gateResult, proEnabled } = await runGate(entry.skillDirs[0], targetRoot, {
|
|
2312
|
+
proFeatureLabel: PRO_FEATURE_LABEL,
|
|
2313
|
+
strictness: config.strictness,
|
|
2314
|
+
artifact
|
|
2315
|
+
});
|
|
1623
2316
|
const { profile, overlap } = gateResult;
|
|
2317
|
+
if (profile.kind !== "skill") {
|
|
2318
|
+
throw new Error(`expected a skill candidate, found a ${profile.kind}`);
|
|
2319
|
+
}
|
|
1624
2320
|
printGateOutput(gateResult, options.json);
|
|
1625
2321
|
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
1626
2322
|
let installedPath = null;
|
|
@@ -1673,6 +2369,78 @@ async function add(source, options = {}) {
|
|
|
1673
2369
|
}
|
|
1674
2370
|
}
|
|
1675
2371
|
}
|
|
2372
|
+
async function addMcp(source, config, options) {
|
|
2373
|
+
const resolved = resolve(source, "mcp");
|
|
2374
|
+
const entry = await quarantine(resolved, config.quarantineDir);
|
|
2375
|
+
const candidatePath = resolveMcpCandidatePath(entry);
|
|
2376
|
+
const { safety, gateResult, proEnabled } = await runGate(candidatePath, "", {
|
|
2377
|
+
proFeatureLabel: MCP_PRO_FEATURE_LABEL,
|
|
2378
|
+
strictness: config.strictness,
|
|
2379
|
+
artifact: "mcp",
|
|
2380
|
+
mcpTargets: config.mcpTargets ?? []
|
|
2381
|
+
});
|
|
2382
|
+
const { profile, overlap } = gateResult;
|
|
2383
|
+
if (profile.kind !== "mcp") {
|
|
2384
|
+
throw new Error(`expected an MCP candidate, found a ${profile.kind}`);
|
|
2385
|
+
}
|
|
2386
|
+
if (profile.serverCount > 1) {
|
|
2387
|
+
console.error(
|
|
2388
|
+
`[skill-forge] ${profile.serverCount} MCP servers found in ${source}; gating/promoting the first \u2014 the whole quarantine entry is promoted/rejected together:
|
|
2389
|
+
${profile.servers.join(", ")}`
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
printGateOutput(gateResult, options.json);
|
|
2393
|
+
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
2394
|
+
let installedPath = null;
|
|
2395
|
+
if (decision === "promote") {
|
|
2396
|
+
const detail = resolveMcpCandidateDetail(profile);
|
|
2397
|
+
const serverProfile = {
|
|
2398
|
+
name: detail.name,
|
|
2399
|
+
version: detail.version ?? "unknown",
|
|
2400
|
+
envVars: detail.env ? Object.keys(detail.env) : void 0,
|
|
2401
|
+
candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
|
|
2402
|
+
};
|
|
2403
|
+
const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join14(process.cwd(), ".mcp.json");
|
|
2404
|
+
const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
|
|
2405
|
+
reject(entry);
|
|
2406
|
+
logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
|
|
2407
|
+
if (result.backupPath) {
|
|
2408
|
+
logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
|
|
2409
|
+
}
|
|
2410
|
+
if (result.envVarsToFill.length > 0) {
|
|
2411
|
+
logOutcome(`[skill-forge] fill in these env vars before use: ${result.envVarsToFill.join(", ")}`, options.json);
|
|
2412
|
+
}
|
|
2413
|
+
installedPath = result.target;
|
|
2414
|
+
} else if (decision === "reject") {
|
|
2415
|
+
reject(entry);
|
|
2416
|
+
logOutcome("[skill-forge] rejected \u2014 quarantine sandbox removed", options.json);
|
|
2417
|
+
if (safety.verdict === "block") process.exitCode = 1;
|
|
2418
|
+
return;
|
|
2419
|
+
} else {
|
|
2420
|
+
hold(entry);
|
|
2421
|
+
logOutcome(`[skill-forge] held in quarantine -> ${entry.quarantinePath}`, options.json);
|
|
2422
|
+
}
|
|
2423
|
+
if (proEnabled) {
|
|
2424
|
+
appendQueueEntry({
|
|
2425
|
+
source,
|
|
2426
|
+
sourceType: resolved.type,
|
|
2427
|
+
installedPath,
|
|
2428
|
+
quarantinePath: entry.quarantinePath,
|
|
2429
|
+
gate: {
|
|
2430
|
+
license: null,
|
|
2431
|
+
safetyVerdict: safety.verdict,
|
|
2432
|
+
safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
|
|
2433
|
+
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
2434
|
+
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
2435
|
+
},
|
|
2436
|
+
status: "pending",
|
|
2437
|
+
artifactType: "mcp"
|
|
2438
|
+
});
|
|
2439
|
+
if (options.ingest && installedPath) {
|
|
2440
|
+
runHandoff(config, installedPath);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
1676
2444
|
function yesDecision(verdict) {
|
|
1677
2445
|
return verdict === "block" ? "reject" : "promote";
|
|
1678
2446
|
}
|
|
@@ -1700,25 +2468,41 @@ ${prompt}`);
|
|
|
1700
2468
|
async function scan(source, options = {}) {
|
|
1701
2469
|
await maybeOfferInit({ json: options.json });
|
|
1702
2470
|
const config = loadConfig();
|
|
1703
|
-
const
|
|
1704
|
-
|
|
2471
|
+
const artifact = options.artifact ?? "skill";
|
|
2472
|
+
const skillsRoot = config.defaultTarget ?? config.skillsRoots[0];
|
|
2473
|
+
if (artifact === "skill" && !skillsRoot) {
|
|
1705
2474
|
throw new Error("no skills root configured; set skillsRoots in the config");
|
|
1706
2475
|
}
|
|
1707
|
-
const resolved = resolve(source);
|
|
2476
|
+
const resolved = resolve(source, artifact);
|
|
1708
2477
|
const entry = await quarantine(resolved, config.quarantineDir);
|
|
1709
2478
|
try {
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
2479
|
+
let candidatePath;
|
|
2480
|
+
if (artifact === "mcp") {
|
|
2481
|
+
candidatePath = resolveMcpCandidatePath(entry);
|
|
2482
|
+
} else {
|
|
2483
|
+
if (entry.skillDirs.length === 0) {
|
|
2484
|
+
console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
|
|
2485
|
+
process.exitCode = 1;
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
if (entry.skillDirs.length > 1) {
|
|
2489
|
+
console.error(
|
|
2490
|
+
`[skill-forge] ${entry.skillDirs.length} skills found in ${source}; scanning the first:
|
|
2491
|
+
` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
candidatePath = entry.skillDirs[0];
|
|
1714
2495
|
}
|
|
1715
|
-
|
|
2496
|
+
const { safety, gateResult } = await runGate(candidatePath, skillsRoot ?? "", {
|
|
2497
|
+
strictness: config.strictness,
|
|
2498
|
+
artifact,
|
|
2499
|
+
mcpTargets: config.mcpTargets ?? []
|
|
2500
|
+
});
|
|
2501
|
+
if (artifact === "mcp" && gateResult.profile.kind === "mcp" && gateResult.profile.serverCount > 1) {
|
|
1716
2502
|
console.error(
|
|
1717
|
-
`[skill-forge] ${
|
|
1718
|
-
` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
|
|
2503
|
+
`[skill-forge] ${gateResult.profile.serverCount} MCP servers found in ${source}; scanning the first \u2014 ` + gateResult.profile.servers.join(", ")
|
|
1719
2504
|
);
|
|
1720
2505
|
}
|
|
1721
|
-
const { safety, gateResult } = await runGate(entry.skillDirs[0], skillsRoot);
|
|
1722
2506
|
printGateOutput(gateResult, options.json);
|
|
1723
2507
|
if (safety.verdict === "block") {
|
|
1724
2508
|
process.exitCode = 1;
|
|
@@ -1729,7 +2513,7 @@ async function scan(source, options = {}) {
|
|
|
1729
2513
|
}
|
|
1730
2514
|
|
|
1731
2515
|
// src/commands/list.ts
|
|
1732
|
-
import { join as
|
|
2516
|
+
import { join as join15 } from "path";
|
|
1733
2517
|
async function list() {
|
|
1734
2518
|
await maybeOfferInit();
|
|
1735
2519
|
const config = loadConfig();
|
|
@@ -1742,14 +2526,14 @@ async function list() {
|
|
|
1742
2526
|
}
|
|
1743
2527
|
console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
|
|
1744
2528
|
for (const id of ids) {
|
|
1745
|
-
const skillDirs = findSkillDirs(
|
|
2529
|
+
const skillDirs = findSkillDirs(join15(quarantineDir, id));
|
|
1746
2530
|
if (skillDirs.length === 0) {
|
|
1747
2531
|
console.log(` ${id} (no SKILL.md found)`);
|
|
1748
2532
|
continue;
|
|
1749
2533
|
}
|
|
1750
2534
|
const names = skillDirs.map((dir) => {
|
|
1751
2535
|
try {
|
|
1752
|
-
return readFrontmatter(
|
|
2536
|
+
return readFrontmatter(join15(dir, "SKILL.md")).name;
|
|
1753
2537
|
} catch {
|
|
1754
2538
|
return dir;
|
|
1755
2539
|
}
|
|
@@ -1773,10 +2557,19 @@ async function status() {
|
|
|
1773
2557
|
// src/cli.ts
|
|
1774
2558
|
var require2 = createRequire(import.meta.url);
|
|
1775
2559
|
var pkg = require2("../package.json");
|
|
2560
|
+
function parseArtifact(value) {
|
|
2561
|
+
if (value !== "skill" && value !== "mcp") {
|
|
2562
|
+
throw new UserError(`--artifact must be "skill" or "mcp" (got "${value}")`);
|
|
2563
|
+
}
|
|
2564
|
+
return value;
|
|
2565
|
+
}
|
|
1776
2566
|
var program = new Command();
|
|
1777
2567
|
program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
|
|
1778
|
-
program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").
|
|
1779
|
-
|
|
2568
|
+
program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).option(
|
|
2569
|
+
"--mcp-target <file>",
|
|
2570
|
+
"MCP config file to promote into (mcp artifacts only; defaults to config.mcpTargets[0], then <cwd>/.mcp.json)"
|
|
2571
|
+
).option("--force", "overwrite an existing same-name MCP server entry when promoting (mcp artifacts only)").action(add);
|
|
2572
|
+
program.command("scan <source>").description("Run the safety gate against a skill without installing it").option("--json", "print the gate result as JSON instead of the terminal report").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).action(scan);
|
|
1780
2573
|
program.command("list").description("List skills currently in quarantine").action(list);
|
|
1781
2574
|
program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
|
|
1782
2575
|
program.command("init").description("Detect installed agents and set gate targets, default promotion target, and handoff agent").option("--defaults", "non-interactive: use all detected agents as gate targets, the first as the default target").option("--list", "print detected agent skill roots and exit \u2014 no writes").action(init);
|