@rhize/skill-forge 0.1.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 +21 -0
- package/README.md +218 -0
- package/dist/cli.js +1341 -0
- package/dist/cli.js.map +1 -0
- package/package.json +48 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
|
|
7
|
+
// src/commands/add.ts
|
|
8
|
+
import { createInterface } from "readline/promises";
|
|
9
|
+
import { spawnSync } from "child_process";
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
function getHomeDir() {
|
|
16
|
+
return process.env.SKILL_FORGE_HOME ?? join(homedir(), ".skill-forge");
|
|
17
|
+
}
|
|
18
|
+
function getConfigPath() {
|
|
19
|
+
return join(getHomeDir(), "config.json");
|
|
20
|
+
}
|
|
21
|
+
function defaultConfig() {
|
|
22
|
+
const home = getHomeDir();
|
|
23
|
+
return {
|
|
24
|
+
skillsRoots: [join(process.cwd(), ".claude", "skills")],
|
|
25
|
+
quarantineDir: join(home, "quarantine"),
|
|
26
|
+
strictness: "block-high"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function loadConfig() {
|
|
30
|
+
const configPath = getConfigPath();
|
|
31
|
+
const defaults = defaultConfig();
|
|
32
|
+
if (!existsSync(configPath)) {
|
|
33
|
+
return defaults;
|
|
34
|
+
}
|
|
35
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return {
|
|
38
|
+
skillsRoots: parsed.skillsRoots ?? defaults.skillsRoots,
|
|
39
|
+
quarantineDir: parsed.quarantineDir ?? defaults.quarantineDir,
|
|
40
|
+
strictness: parsed.strictness ?? defaults.strictness,
|
|
41
|
+
licenseKey: parsed.licenseKey ?? defaults.licenseKey
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/install/resolve.ts
|
|
46
|
+
import { existsSync as existsSync2 } from "fs";
|
|
47
|
+
var GIT_URL_PATTERN = /^(git@|https?:\/\/|ssh:\/\/)/;
|
|
48
|
+
function resolve(source) {
|
|
49
|
+
const value = source.trim();
|
|
50
|
+
if (GIT_URL_PATTERN.test(value) || value.endsWith(".git")) {
|
|
51
|
+
return { type: "git", value };
|
|
52
|
+
}
|
|
53
|
+
if (existsSync2(value)) {
|
|
54
|
+
return { type: "local", value };
|
|
55
|
+
}
|
|
56
|
+
return { type: "skills.sh", value };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/install/quarantine.ts
|
|
60
|
+
import { randomUUID } from "crypto";
|
|
61
|
+
import { spawn } from "child_process";
|
|
62
|
+
import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync } from "fs";
|
|
63
|
+
import { basename, join as join2, resolve as resolvePath } from "path";
|
|
64
|
+
var SKILL_MANIFEST = "SKILL.md";
|
|
65
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
66
|
+
async function quarantine(source, quarantineDir, deps = {}) {
|
|
67
|
+
const exec = deps.exec ?? defaultExec;
|
|
68
|
+
const id = randomUUID();
|
|
69
|
+
const quarantinePath = join2(quarantineDir, id);
|
|
70
|
+
mkdirSync2(quarantinePath, { recursive: true });
|
|
71
|
+
switch (source.type) {
|
|
72
|
+
case "skills.sh": {
|
|
73
|
+
const result = await exec("npx", ["-y", "skills@latest", "add", source.value], {
|
|
74
|
+
cwd: quarantinePath
|
|
75
|
+
});
|
|
76
|
+
if (result.code !== 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`skills.sh install failed for "${source.value}" (exit ${result.code}): ${result.stderr || result.stdout}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "git": {
|
|
84
|
+
const clonePath = join2(quarantinePath, deriveGitDirName(source.value));
|
|
85
|
+
const result = await exec("git", ["clone", "--depth", "1", source.value, clonePath], {
|
|
86
|
+
cwd: quarantinePath
|
|
87
|
+
});
|
|
88
|
+
if (result.code !== 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`git clone failed for "${source.value}" (exit ${result.code}): ${result.stderr || result.stdout}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "local": {
|
|
96
|
+
const dirName = basename(resolvePath(source.value));
|
|
97
|
+
cpSync(source.value, join2(quarantinePath, dirName), { recursive: true });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { id, quarantinePath, skillDirs: findSkillDirs(quarantinePath), source };
|
|
102
|
+
}
|
|
103
|
+
function listQuarantineIds(quarantineDir) {
|
|
104
|
+
if (!existsSync3(quarantineDir)) return [];
|
|
105
|
+
return readdirSync(quarantineDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
106
|
+
}
|
|
107
|
+
function findSkillDirs(root) {
|
|
108
|
+
const found = [];
|
|
109
|
+
function walk2(dir) {
|
|
110
|
+
let entries;
|
|
111
|
+
try {
|
|
112
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (entries.some((entry) => entry.isFile() && entry.name === SKILL_MANIFEST)) {
|
|
117
|
+
found.push(dir);
|
|
118
|
+
}
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
121
|
+
walk2(join2(dir, entry.name));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
walk2(root);
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
function deriveGitDirName(url) {
|
|
129
|
+
const stripped = url.replace(/\.git$/, "");
|
|
130
|
+
const segments = stripped.split(/[/:]/).filter(Boolean);
|
|
131
|
+
return segments[segments.length - 1] || "repo";
|
|
132
|
+
}
|
|
133
|
+
function defaultExec(command, args, options) {
|
|
134
|
+
return new Promise((resolvePromise, reject2) => {
|
|
135
|
+
const child = spawn(command, args, { cwd: options.cwd });
|
|
136
|
+
let stdout = "";
|
|
137
|
+
let stderr = "";
|
|
138
|
+
child.stdout?.on("data", (chunk) => stdout += chunk.toString());
|
|
139
|
+
child.stderr?.on("data", (chunk) => stderr += chunk.toString());
|
|
140
|
+
child.on("error", reject2);
|
|
141
|
+
child.on("close", (code) => resolvePromise({ stdout, stderr, code: code ?? 1 }));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/install/promote.ts
|
|
146
|
+
import { cpSync as cpSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, renameSync, rmSync } from "fs";
|
|
147
|
+
import { basename as basename2, join as join3 } from "path";
|
|
148
|
+
function promote(entry, targetSkillsRoot, options = {}) {
|
|
149
|
+
const force = options.force ?? false;
|
|
150
|
+
for (const skillDir of entry.skillDirs) {
|
|
151
|
+
const dest = join3(targetSkillsRoot, basename2(skillDir));
|
|
152
|
+
if (existsSync4(dest) && !force) {
|
|
153
|
+
throw new Error(`Refusing to overwrite existing skill dir: ${dest} (pass force to override)`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
mkdirSync3(targetSkillsRoot, { recursive: true });
|
|
157
|
+
const promoted = [];
|
|
158
|
+
for (const skillDir of entry.skillDirs) {
|
|
159
|
+
const dest = join3(targetSkillsRoot, basename2(skillDir));
|
|
160
|
+
if (existsSync4(dest)) {
|
|
161
|
+
rmSync(dest, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
moveDir(skillDir, dest);
|
|
164
|
+
promoted.push(dest);
|
|
165
|
+
}
|
|
166
|
+
rmSync(entry.quarantinePath, { recursive: true, force: true });
|
|
167
|
+
return { promoted };
|
|
168
|
+
}
|
|
169
|
+
function reject(entry) {
|
|
170
|
+
rmSync(entry.quarantinePath, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
function hold(_entry) {
|
|
173
|
+
}
|
|
174
|
+
function moveDir(src, dest) {
|
|
175
|
+
try {
|
|
176
|
+
renameSync(src, dest);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err.code === "EXDEV") {
|
|
179
|
+
cpSync2(src, dest, { recursive: true });
|
|
180
|
+
rmSync(src, { recursive: true, force: true });
|
|
181
|
+
} else {
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/license.ts
|
|
188
|
+
import { verify as cryptoVerify } from "crypto";
|
|
189
|
+
var LICENSE_PREFIX = "skf1";
|
|
190
|
+
var PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
191
|
+
MCowBQYDK2VwAyEAWSVbcX9u70IsN1O5zS5z+AYtAUVz7ExT7h+1gT9x+wQ=
|
|
192
|
+
-----END PUBLIC KEY-----
|
|
193
|
+
`;
|
|
194
|
+
function readLicenseKey() {
|
|
195
|
+
const fromEnv = process.env.SKILL_FORGE_LICENSE?.trim();
|
|
196
|
+
if (fromEnv) return fromEnv;
|
|
197
|
+
return loadConfig().licenseKey?.trim() || void 0;
|
|
198
|
+
}
|
|
199
|
+
function isLicensePayload(value) {
|
|
200
|
+
if (typeof value !== "object" || value === null) return false;
|
|
201
|
+
const p = value;
|
|
202
|
+
return typeof p.name === "string" && typeof p.email === "string" && typeof p.seats === "number" && typeof p.exp === "string";
|
|
203
|
+
}
|
|
204
|
+
function checkLicense(rawKey = readLicenseKey()) {
|
|
205
|
+
if (!rawKey) {
|
|
206
|
+
return { valid: false, reason: "missing" };
|
|
207
|
+
}
|
|
208
|
+
const parts = rawKey.trim().split(".");
|
|
209
|
+
if (parts.length !== 3 || parts[0] !== LICENSE_PREFIX) {
|
|
210
|
+
return { valid: false, reason: "malformed" };
|
|
211
|
+
}
|
|
212
|
+
const [, payloadPart, signaturePart] = parts;
|
|
213
|
+
const payloadBuffer = Buffer.from(payloadPart, "base64url");
|
|
214
|
+
const signature = Buffer.from(signaturePart, "base64url");
|
|
215
|
+
let payload;
|
|
216
|
+
try {
|
|
217
|
+
payload = JSON.parse(payloadBuffer.toString("utf-8"));
|
|
218
|
+
} catch {
|
|
219
|
+
return { valid: false, reason: "malformed" };
|
|
220
|
+
}
|
|
221
|
+
if (!isLicensePayload(payload)) {
|
|
222
|
+
return { valid: false, reason: "malformed" };
|
|
223
|
+
}
|
|
224
|
+
let signatureValid;
|
|
225
|
+
try {
|
|
226
|
+
signatureValid = cryptoVerify(null, payloadBuffer, PUBLIC_KEY_PEM, signature);
|
|
227
|
+
} catch {
|
|
228
|
+
return { valid: false, reason: "malformed" };
|
|
229
|
+
}
|
|
230
|
+
if (!signatureValid) {
|
|
231
|
+
return { valid: false, reason: "signature" };
|
|
232
|
+
}
|
|
233
|
+
const expMs = new Date(payload.exp).getTime();
|
|
234
|
+
if (Number.isNaN(expMs) || expMs < Date.now()) {
|
|
235
|
+
return { valid: false, reason: "expired" };
|
|
236
|
+
}
|
|
237
|
+
return { valid: true, payload };
|
|
238
|
+
}
|
|
239
|
+
function hasProLicense() {
|
|
240
|
+
return checkLicense().valid;
|
|
241
|
+
}
|
|
242
|
+
function upgradeNotice(feature) {
|
|
243
|
+
return `[skill-forge] Pro feature (${feature}) \u2014 set SKILL_FORGE_LICENSE or config.licenseKey to a valid license to unlock.`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/report/render.ts
|
|
247
|
+
var BORDER = "\u2501".repeat(55);
|
|
248
|
+
var LABEL_WIDTH = 20;
|
|
249
|
+
function padLabel(text) {
|
|
250
|
+
return text.padEnd(LABEL_WIDTH);
|
|
251
|
+
}
|
|
252
|
+
function renderListField(labelText, items, placeholder) {
|
|
253
|
+
const head = `${padLabel(labelText)}: `;
|
|
254
|
+
if (!items || items.length === 0) {
|
|
255
|
+
return `${head}${placeholder}`;
|
|
256
|
+
}
|
|
257
|
+
const bullets = items.map((item) => ` - ${item}`).join("\n");
|
|
258
|
+
return `${head.trimEnd()}
|
|
259
|
+
${bullets}`;
|
|
260
|
+
}
|
|
261
|
+
function renderSafetyLine(safety) {
|
|
262
|
+
const detail = [safety.severity, safety.score !== void 0 ? `score ${safety.score}` : void 0].filter((v) => Boolean(v)).join(", ");
|
|
263
|
+
const withDetail = detail ? `${safety.verdict} (${detail})` : safety.verdict;
|
|
264
|
+
return safety.recommendation ? `${withDetail} \u2014 ${safety.recommendation}` : withDetail;
|
|
265
|
+
}
|
|
266
|
+
function renderReport(result) {
|
|
267
|
+
const { profile, overlap, overlapLocked, safety, worthTaking, leaveBehind } = result;
|
|
268
|
+
const name = profile.name ?? "unknown";
|
|
269
|
+
const version = profile.version ?? "unknown";
|
|
270
|
+
const license = profile.license ?? "UNKNOWN";
|
|
271
|
+
const overlapLines = overlapLocked ? [`${padLabel("Overlap analysis")}: ${upgradeNotice("overlap analysis")}`] : overlap ? [
|
|
272
|
+
`${padLabel("Nearest Rhize skill")}: ${overlap.nearest} (overlap ${overlap.nearestScore})`,
|
|
273
|
+
`${padLabel("Recommended verb")}: ${overlap.suggestedVerb}`
|
|
274
|
+
] : [`${padLabel("Nearest Rhize skill")}: n/a \u2014 no skills root configured`, `${padLabel("Recommended verb")}: n/a`];
|
|
275
|
+
const lines = [
|
|
276
|
+
BORDER,
|
|
277
|
+
`\u{1F528} Forge: ${name} (v${version}, ${license})`,
|
|
278
|
+
BORDER,
|
|
279
|
+
...overlapLines,
|
|
280
|
+
renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
|
|
281
|
+
renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
|
|
282
|
+
`${padLabel("Safety verdict")}: ${renderSafetyLine(safety)}`,
|
|
283
|
+
BORDER
|
|
284
|
+
];
|
|
285
|
+
return lines.join("\n");
|
|
286
|
+
}
|
|
287
|
+
function renderJson(result) {
|
|
288
|
+
return JSON.stringify(result, null, 2);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/provenance.ts
|
|
292
|
+
import { appendFileSync, existsSync as existsSync5, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
293
|
+
import { join as join4 } from "path";
|
|
294
|
+
var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
|
|
295
|
+
var LEDGER_HEADER = "# Rhize Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
|
|
296
|
+
function todayLocalIsoDate() {
|
|
297
|
+
const d = /* @__PURE__ */ new Date();
|
|
298
|
+
const year = d.getFullYear();
|
|
299
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
300
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
301
|
+
return `${year}-${month}-${day}`;
|
|
302
|
+
}
|
|
303
|
+
function appendProvenance(entry, skillsRoot) {
|
|
304
|
+
if (!FORGE_VERBS.includes(entry.verb)) {
|
|
305
|
+
throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
|
|
306
|
+
}
|
|
307
|
+
if (!existsSync5(skillsRoot) || !statSync(skillsRoot).isDirectory()) {
|
|
308
|
+
throw new Error(`skills-root not a directory: ${skillsRoot}`);
|
|
309
|
+
}
|
|
310
|
+
const today = todayLocalIsoDate();
|
|
311
|
+
const driftCheck = entry.driftCheck ?? `# define how to detect upstream change for ${entry.name}`;
|
|
312
|
+
const block = `
|
|
313
|
+
## ${entry.name} \u2014 ${today}
|
|
314
|
+
- **Source:** ${entry.source}
|
|
315
|
+
- **Upstream ref:** ${entry.version ?? "n/a"}
|
|
316
|
+
- **License:** ${entry.license ?? "UNKNOWN"}
|
|
317
|
+
- **Verb:** ${entry.verb}
|
|
318
|
+
- **Target:** ${entry.target ?? "n/a"}
|
|
319
|
+
- **Took:** ${entry.took ?? "nothing"}
|
|
320
|
+
- **Verified:** ${entry.verified ?? "n/a"}
|
|
321
|
+
- **Drift check:** \`${driftCheck}\`
|
|
322
|
+
- **Notes:** ${entry.notes ?? ""}
|
|
323
|
+
`;
|
|
324
|
+
const ledgerPath = join4(skillsRoot, "SOURCES.md");
|
|
325
|
+
if (!existsSync5(ledgerPath)) {
|
|
326
|
+
writeFileSync2(ledgerPath, LEDGER_HEADER, "utf-8");
|
|
327
|
+
}
|
|
328
|
+
appendFileSync(ledgerPath, block, "utf-8");
|
|
329
|
+
return ledgerPath;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/gate/profile.ts
|
|
333
|
+
import { execFileSync } from "child_process";
|
|
334
|
+
import {
|
|
335
|
+
closeSync,
|
|
336
|
+
existsSync as existsSync6,
|
|
337
|
+
mkdtempSync,
|
|
338
|
+
openSync,
|
|
339
|
+
readFileSync as readFileSync2,
|
|
340
|
+
readdirSync as readdirSync2,
|
|
341
|
+
readSync,
|
|
342
|
+
rmSync as rmSync2,
|
|
343
|
+
statSync as statSync3
|
|
344
|
+
} from "fs";
|
|
345
|
+
import { tmpdir } from "os";
|
|
346
|
+
import { basename as basename3, dirname, extname, join as join6, resolve as resolve2 } from "path";
|
|
347
|
+
|
|
348
|
+
// src/util.ts
|
|
349
|
+
import { statSync as statSync2 } from "fs";
|
|
350
|
+
import { homedir as homedir2 } from "os";
|
|
351
|
+
import { join as join5 } from "path";
|
|
352
|
+
function expandHome(p) {
|
|
353
|
+
if (p === "~") return homedir2();
|
|
354
|
+
if (p.startsWith("~/")) return join5(homedir2(), p.slice(2));
|
|
355
|
+
return p;
|
|
356
|
+
}
|
|
357
|
+
function stripQuotes(s) {
|
|
358
|
+
return s.replace(/^['"]+/, "").replace(/['"]+$/, "");
|
|
359
|
+
}
|
|
360
|
+
function isDir(p) {
|
|
361
|
+
try {
|
|
362
|
+
return statSync2(p).isDirectory();
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/gate/profile.ts
|
|
369
|
+
var SPDX_HINTS = {
|
|
370
|
+
mit: "MIT (permissive)",
|
|
371
|
+
apache: "Apache-2.0 (permissive)",
|
|
372
|
+
bsd: "BSD (permissive)",
|
|
373
|
+
isc: "ISC (permissive)",
|
|
374
|
+
unlicense: "Unlicense (public domain)",
|
|
375
|
+
cc0: "CC0 (public domain)",
|
|
376
|
+
"mozilla public license": "MPL (weak copyleft)",
|
|
377
|
+
"gnu general public": "GPL (copyleft)",
|
|
378
|
+
"gnu affero": "AGPL (copyleft)",
|
|
379
|
+
"creative commons attribution-sharealike": "CC-BY-SA (copyleft)",
|
|
380
|
+
"creative commons attribution": "CC-BY (attribution)",
|
|
381
|
+
"all rights reserved": "All rights reserved (restrictive)"
|
|
382
|
+
};
|
|
383
|
+
var RESOURCE_DIRS = ["scripts", "references", "reference", "commands", "hooks", "assets", "templates", "sub-skills"];
|
|
384
|
+
var PYTHON_STDLIB = /* @__PURE__ */ new Set([
|
|
385
|
+
"os",
|
|
386
|
+
"sys",
|
|
387
|
+
"re",
|
|
388
|
+
"json",
|
|
389
|
+
"argparse",
|
|
390
|
+
"pathlib",
|
|
391
|
+
"tempfile",
|
|
392
|
+
"zipfile",
|
|
393
|
+
"subprocess",
|
|
394
|
+
"collections",
|
|
395
|
+
"datetime",
|
|
396
|
+
"math",
|
|
397
|
+
"itertools",
|
|
398
|
+
"typing",
|
|
399
|
+
"shutil",
|
|
400
|
+
"glob",
|
|
401
|
+
"hashlib",
|
|
402
|
+
"urllib",
|
|
403
|
+
"csv",
|
|
404
|
+
"io",
|
|
405
|
+
"functools",
|
|
406
|
+
"__future__"
|
|
407
|
+
]);
|
|
408
|
+
function profileSkill(inputPath) {
|
|
409
|
+
const p = resolve2(expandHome(inputPath));
|
|
410
|
+
if (!existsSync6(p)) {
|
|
411
|
+
throw new Error(`path does not exist: ${p}`);
|
|
412
|
+
}
|
|
413
|
+
const stat = statSync3(p);
|
|
414
|
+
const kind = stat.isDirectory() ? classifyInput(p) : "skill";
|
|
415
|
+
if (kind === "plugin") {
|
|
416
|
+
return profilePlugin(p);
|
|
417
|
+
}
|
|
418
|
+
if (kind === "mcp") {
|
|
419
|
+
return profileMcp(p);
|
|
420
|
+
}
|
|
421
|
+
return profileSkillDir(p);
|
|
422
|
+
}
|
|
423
|
+
function classifyInput(p) {
|
|
424
|
+
if (existsSync6(join6(p, ".claude-plugin", "plugin.json"))) return "plugin";
|
|
425
|
+
if (existsSync6(join6(p, ".mcp.json")) || existsSync6(join6(p, "mcp.json"))) return "mcp";
|
|
426
|
+
return "skill";
|
|
427
|
+
}
|
|
428
|
+
function resolveSkillMd(inputPath) {
|
|
429
|
+
let currentPath = inputPath;
|
|
430
|
+
let tmpDir;
|
|
431
|
+
const initialStat = statSync3(currentPath);
|
|
432
|
+
if (extname(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
|
|
433
|
+
tmpDir = mkdtempSync(join6(tmpdir(), "forge_"));
|
|
434
|
+
extractZip(currentPath, tmpDir);
|
|
435
|
+
currentPath = tmpDir;
|
|
436
|
+
}
|
|
437
|
+
const stat = statSync3(currentPath);
|
|
438
|
+
if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
|
|
439
|
+
return { skillMd: currentPath, root: dirname(currentPath), tmpDir };
|
|
440
|
+
}
|
|
441
|
+
if (stat.isDirectory()) {
|
|
442
|
+
const direct = join6(currentPath, "SKILL.md");
|
|
443
|
+
if (existsSync6(direct)) {
|
|
444
|
+
return { skillMd: direct, root: currentPath, tmpDir };
|
|
445
|
+
}
|
|
446
|
+
const matches = findSkillMdOneLevelDown(currentPath);
|
|
447
|
+
if (matches.length === 1) {
|
|
448
|
+
return { skillMd: matches[0], root: dirname(matches[0]), tmpDir };
|
|
449
|
+
}
|
|
450
|
+
if (matches.length > 1) {
|
|
451
|
+
cleanupTmp(tmpDir);
|
|
452
|
+
throw new Error(
|
|
453
|
+
`multiple SKILL.md found under ${currentPath}; point at a single skill dir:
|
|
454
|
+
${matches.join("\n ")}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
cleanupTmp(tmpDir);
|
|
459
|
+
throw new Error(`no SKILL.md found at ${currentPath}`);
|
|
460
|
+
}
|
|
461
|
+
function cleanupTmp(tmpDir) {
|
|
462
|
+
if (tmpDir) rmSync2(tmpDir, { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
function findSkillMdOneLevelDown(dir) {
|
|
465
|
+
const matches = /* @__PURE__ */ new Set();
|
|
466
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
467
|
+
if (entry.isDirectory()) {
|
|
468
|
+
const candidate = join6(dir, entry.name, "SKILL.md");
|
|
469
|
+
if (existsSync6(candidate)) matches.add(candidate);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const skillsDir = join6(dir, "skills");
|
|
473
|
+
if (isDir(skillsDir)) {
|
|
474
|
+
for (const entry of readdirSync2(skillsDir, { withFileTypes: true })) {
|
|
475
|
+
if (entry.isDirectory()) {
|
|
476
|
+
const candidate = join6(skillsDir, entry.name, "SKILL.md");
|
|
477
|
+
if (existsSync6(candidate)) matches.add(candidate);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return Array.from(matches);
|
|
482
|
+
}
|
|
483
|
+
function isZipFile(p) {
|
|
484
|
+
try {
|
|
485
|
+
const fd = openSync(p, "r");
|
|
486
|
+
const buf = Buffer.alloc(4);
|
|
487
|
+
const bytesRead = readSync(fd, buf, 0, 4, 0);
|
|
488
|
+
closeSync(fd);
|
|
489
|
+
if (bytesRead < 4) return false;
|
|
490
|
+
return buf[0] === 80 && buf[1] === 75 && (buf[2] === 3 || buf[2] === 5 || buf[2] === 7);
|
|
491
|
+
} catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function extractZip(zipPath, destDir) {
|
|
496
|
+
try {
|
|
497
|
+
execFileSync("unzip", ["-q", "-o", zipPath, "-d", destDir], { stdio: ["ignore", "ignore", "pipe"] });
|
|
498
|
+
} catch (err) {
|
|
499
|
+
const e = err;
|
|
500
|
+
if (e.code === "ENOENT") {
|
|
501
|
+
throw new Error(
|
|
502
|
+
"unsupported without unzip: .skill archives require the system `unzip` binary, which was not found on PATH. Install unzip, or extract the archive yourself and point profileSkill at the resulting directory."
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
throw new Error(`failed to extract ${zipPath}: ${e.message ?? String(err)}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function frontmatterBlock(text) {
|
|
509
|
+
const first = text.indexOf("---");
|
|
510
|
+
const rest = text.slice(first + 3);
|
|
511
|
+
const second = rest.indexOf("---");
|
|
512
|
+
return second === -1 ? rest : rest.slice(0, second);
|
|
513
|
+
}
|
|
514
|
+
function extractSimpleField(block, key) {
|
|
515
|
+
const re = new RegExp(`^${key}:\\s*(.+)$`, "m");
|
|
516
|
+
const m = block.match(re);
|
|
517
|
+
if (!m) return void 0;
|
|
518
|
+
return stripQuotes(m[1].trim());
|
|
519
|
+
}
|
|
520
|
+
function extractDescription(block) {
|
|
521
|
+
const m = block.match(/^description:\s*(.*)$/m);
|
|
522
|
+
if (!m) return void 0;
|
|
523
|
+
const first = m[1].trim();
|
|
524
|
+
if ([">", ">-", "|", "|-", ""].includes(first)) {
|
|
525
|
+
const lines = block.split("\n");
|
|
526
|
+
const idx = lines.findIndex((l) => /^description:/.test(l));
|
|
527
|
+
if (idx === -1) return void 0;
|
|
528
|
+
const buf = [];
|
|
529
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
530
|
+
const l = lines[i];
|
|
531
|
+
if (/^\s+\S/.test(l)) {
|
|
532
|
+
buf.push(l.trim());
|
|
533
|
+
} else if (l.trim() === "") {
|
|
534
|
+
continue;
|
|
535
|
+
} else {
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return buf.join(" ").trim();
|
|
540
|
+
}
|
|
541
|
+
return stripQuotes(first);
|
|
542
|
+
}
|
|
543
|
+
function parseFrontmatter(text) {
|
|
544
|
+
if (!text.startsWith("---\n")) {
|
|
545
|
+
return { validFrontmatter: false };
|
|
546
|
+
}
|
|
547
|
+
const block = frontmatterBlock(text);
|
|
548
|
+
return {
|
|
549
|
+
validFrontmatter: true,
|
|
550
|
+
name: extractSimpleField(block, "name"),
|
|
551
|
+
version: extractSimpleField(block, "version"),
|
|
552
|
+
license: extractSimpleField(block, "license"),
|
|
553
|
+
description: extractDescription(block)
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function detectLicense(root, fm) {
|
|
557
|
+
if (fm.license) return fm.license;
|
|
558
|
+
const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
|
|
559
|
+
for (const name of names) {
|
|
560
|
+
const p = join6(root, name);
|
|
561
|
+
if (existsSync6(p) && statSync3(p).isFile()) {
|
|
562
|
+
const head = readFileSync2(p, "utf-8").slice(0, 2e3).toLowerCase();
|
|
563
|
+
for (const [hint, label] of Object.entries(SPDX_HINTS)) {
|
|
564
|
+
if (head.includes(hint)) return label;
|
|
565
|
+
}
|
|
566
|
+
return "present but unrecognized";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return "NONE STATED";
|
|
570
|
+
}
|
|
571
|
+
function walkFiles(dir) {
|
|
572
|
+
const out = [];
|
|
573
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
574
|
+
const full = join6(dir, entry.name);
|
|
575
|
+
if (entry.isDirectory()) {
|
|
576
|
+
out.push(...walkFiles(full));
|
|
577
|
+
} else if (entry.isFile()) {
|
|
578
|
+
out.push(full);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
function countResources(root) {
|
|
584
|
+
const resources = {};
|
|
585
|
+
for (const d of RESOURCE_DIRS) {
|
|
586
|
+
const dp = join6(root, d);
|
|
587
|
+
if (isDir(dp)) {
|
|
588
|
+
resources[d] = walkFiles(dp).length;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return resources;
|
|
592
|
+
}
|
|
593
|
+
function scanMcpAndImports(text, root) {
|
|
594
|
+
const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
|
|
595
|
+
const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
|
|
596
|
+
const imports = /* @__PURE__ */ new Set();
|
|
597
|
+
const scriptsDir = join6(root, "scripts");
|
|
598
|
+
if (isDir(scriptsDir)) {
|
|
599
|
+
for (const f of walkFiles(scriptsDir)) {
|
|
600
|
+
if (!f.endsWith(".py")) continue;
|
|
601
|
+
const ft = readFileSync2(f, "utf-8");
|
|
602
|
+
for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
|
|
603
|
+
const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
|
|
604
|
+
let m;
|
|
605
|
+
while ((m = importRe.exec(ft)) !== null) {
|
|
606
|
+
imports.add(m[1]);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
mcpDeps: Array.from(mcpRefs).sort(),
|
|
612
|
+
externalDeps: Array.from(imports).filter((i) => !PYTHON_STDLIB.has(i)).sort()
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function profileSkillDir(inputPath) {
|
|
616
|
+
const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
|
|
617
|
+
try {
|
|
618
|
+
const text = readFileSync2(skillMd, "utf-8");
|
|
619
|
+
const fm = parseFrontmatter(text);
|
|
620
|
+
const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
|
|
621
|
+
const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
|
|
622
|
+
const resources = countResources(root);
|
|
623
|
+
const { mcpDeps, externalDeps } = scanMcpAndImports(text, root);
|
|
624
|
+
return {
|
|
625
|
+
kind: "skill",
|
|
626
|
+
inputPath,
|
|
627
|
+
skillMd,
|
|
628
|
+
name: fm.name,
|
|
629
|
+
version: fm.version,
|
|
630
|
+
validFrontmatter: fm.validFrontmatter,
|
|
631
|
+
description: fm.description,
|
|
632
|
+
license: detectLicense(root, fm),
|
|
633
|
+
bodyLines,
|
|
634
|
+
headerCount: headers.length,
|
|
635
|
+
topHeaders: headers.slice(0, 12),
|
|
636
|
+
resources,
|
|
637
|
+
mcpDependencies: mcpDeps,
|
|
638
|
+
externalPythonDeps: externalDeps
|
|
639
|
+
};
|
|
640
|
+
} finally {
|
|
641
|
+
cleanupTmp(tmpDir);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function profilePlugin(p) {
|
|
645
|
+
const manifestPath = join6(p, ".claude-plugin", "plugin.json");
|
|
646
|
+
let manifest;
|
|
647
|
+
try {
|
|
648
|
+
manifest = JSON.parse(readFileSync2(manifestPath, "utf-8"));
|
|
649
|
+
} catch (err) {
|
|
650
|
+
throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
|
|
651
|
+
}
|
|
652
|
+
const skillsDir = join6(p, "skills");
|
|
653
|
+
let skills = [];
|
|
654
|
+
if (isDir(skillsDir)) {
|
|
655
|
+
skills = readdirSync2(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync6(join6(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
|
|
656
|
+
}
|
|
657
|
+
const commandsDir = join6(p, "commands");
|
|
658
|
+
const commands = isDir(commandsDir) ? readdirSync2(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
659
|
+
const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
|
|
660
|
+
const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
|
|
661
|
+
return {
|
|
662
|
+
kind: "plugin",
|
|
663
|
+
inputPath: p,
|
|
664
|
+
name: manifest.name,
|
|
665
|
+
version: manifest.version,
|
|
666
|
+
description: manifest.description,
|
|
667
|
+
skills,
|
|
668
|
+
skillCount: skills.length,
|
|
669
|
+
commands,
|
|
670
|
+
mcpServers
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function profileMcp(p) {
|
|
674
|
+
const cfgPath = existsSync6(join6(p, ".mcp.json")) ? join6(p, ".mcp.json") : join6(p, "mcp.json");
|
|
675
|
+
let cfg;
|
|
676
|
+
try {
|
|
677
|
+
cfg = JSON.parse(readFileSync2(cfgPath, "utf-8"));
|
|
678
|
+
} catch (err) {
|
|
679
|
+
throw new Error(`could not parse MCP config ${cfgPath}: ${err.message}`);
|
|
680
|
+
}
|
|
681
|
+
const servers = cfg.mcpServers ?? cfg.servers ?? {};
|
|
682
|
+
const names = typeof servers === "object" && servers !== null ? Object.keys(servers).sort() : [];
|
|
683
|
+
return {
|
|
684
|
+
kind: "mcp",
|
|
685
|
+
inputPath: p,
|
|
686
|
+
config: cfgPath,
|
|
687
|
+
servers: names,
|
|
688
|
+
serverCount: names.length
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/gate/safety.ts
|
|
693
|
+
import { readFileSync as readFileSync3, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
694
|
+
import { execFile } from "child_process";
|
|
695
|
+
import { delimiter, extname as extname2, join as join7 } from "path";
|
|
696
|
+
import { promisify } from "util";
|
|
697
|
+
var execFileAsync = promisify(execFile);
|
|
698
|
+
var BLOCK_SEVERITIES = /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]);
|
|
699
|
+
var DENY_RULES = [
|
|
700
|
+
{
|
|
701
|
+
rule: "curl-pipe-shell",
|
|
702
|
+
severity: "CRITICAL",
|
|
703
|
+
pattern: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(bash|sh|zsh)\b/i,
|
|
704
|
+
message: "downloads a remote script and pipes it directly into a shell"
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
rule: "base64-obfuscated-exec",
|
|
708
|
+
severity: "CRITICAL",
|
|
709
|
+
pattern: /(base64\s+(-d|--decode)\b[^\n|]*\|\s*(bash|sh|zsh|python3?)\b)|(\bexec\s*\(\s*(base64\.)?b64decode\()|(\beval\s*\(\s*Buffer\.from\([^)]*['"]base64['"])/i,
|
|
710
|
+
message: "decodes a base64 payload and executes it (obfuscated execution)"
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
rule: "reverse-shell",
|
|
714
|
+
severity: "CRITICAL",
|
|
715
|
+
pattern: /(\/dev\/tcp\/\S+\/\d+)|(\bnc\s+-e\s+\/bin\/(ba)?sh\b)|(bash\s+-i\s*>&\s*\/dev\/tcp)/i,
|
|
716
|
+
message: "opens a reverse shell back to a remote host"
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
rule: "destructive-rm-rf",
|
|
720
|
+
severity: "HIGH",
|
|
721
|
+
pattern: /\brm\s+-[a-z]*r[a-z]*f[a-z]*\b|\brm\s+-[a-z]*f[a-z]*r[a-z]*\b/i,
|
|
722
|
+
message: "recursive force-delete (rm -rf) \u2014 verify the target path is scoped"
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
rule: "credential-file-access",
|
|
726
|
+
severity: "HIGH",
|
|
727
|
+
pattern: /(~|\$HOME)?\/\.(ssh\/id_(rsa|ed25519|ecdsa)|aws\/credentials|npmrc|netrc|gnupg|docker\/config\.json)\b|\bid_rsa\b|\.env\b.*\bcat\b|\bcat\b.*\.env\b/i,
|
|
728
|
+
message: "reads a credential/secrets file (SSH keys, cloud credentials, .env)"
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
rule: "credential-exfil",
|
|
732
|
+
severity: "HIGH",
|
|
733
|
+
pattern: /\b(curl|wget|fetch)\b[^\n]*(--data|--data-binary|-F|-d)\b[^\n]*(\.ssh|\.aws|\.env|credentials|id_rsa)/i,
|
|
734
|
+
message: "uploads credential/secret file contents to a remote endpoint"
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
rule: "dynamic-eval",
|
|
738
|
+
severity: "MEDIUM",
|
|
739
|
+
pattern: /\beval\s*\(|\bexec\s*\(\s*compile\s*\(|\bnew\s+Function\s*\(/,
|
|
740
|
+
message: "evaluates dynamically constructed code"
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
rule: "shell-true-subprocess",
|
|
744
|
+
severity: "MEDIUM",
|
|
745
|
+
pattern: /subprocess\.(Popen|run|call)\([^)]*shell\s*=\s*True/,
|
|
746
|
+
message: "runs a subprocess with shell=True (shell-injection risk)"
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
rule: "persistence-cron-rc",
|
|
750
|
+
severity: "MEDIUM",
|
|
751
|
+
pattern: />>\s*~?\/?\.(bashrc|zshrc|bash_profile|profile)\b|\bcrontab\s+-/i,
|
|
752
|
+
message: "modifies shell startup files or crontab (persistence)"
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
rule: "sudo-usage",
|
|
756
|
+
severity: "LOW",
|
|
757
|
+
pattern: /\bsudo\s+/,
|
|
758
|
+
message: "invokes sudo \u2014 review for unnecessary privilege escalation"
|
|
759
|
+
}
|
|
760
|
+
];
|
|
761
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
762
|
+
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
763
|
+
".png",
|
|
764
|
+
".jpg",
|
|
765
|
+
".jpeg",
|
|
766
|
+
".gif",
|
|
767
|
+
".webp",
|
|
768
|
+
".ico",
|
|
769
|
+
".pdf",
|
|
770
|
+
".zip",
|
|
771
|
+
".tar",
|
|
772
|
+
".gz",
|
|
773
|
+
".woff",
|
|
774
|
+
".woff2",
|
|
775
|
+
".ttf",
|
|
776
|
+
".eot",
|
|
777
|
+
".mp4",
|
|
778
|
+
".mp3",
|
|
779
|
+
".wasm",
|
|
780
|
+
".bin",
|
|
781
|
+
".exe"
|
|
782
|
+
]);
|
|
783
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
784
|
+
function* walk(dir) {
|
|
785
|
+
let entries;
|
|
786
|
+
try {
|
|
787
|
+
entries = readdirSync3(dir, { withFileTypes: true });
|
|
788
|
+
} catch {
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
for (const entry of entries) {
|
|
792
|
+
const full = join7(dir, entry.name);
|
|
793
|
+
if (entry.isDirectory()) {
|
|
794
|
+
if (SKIP_DIRS2.has(entry.name)) continue;
|
|
795
|
+
yield* walk(full);
|
|
796
|
+
} else if (entry.isFile()) {
|
|
797
|
+
yield full;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function readTextFile(file) {
|
|
802
|
+
if (BINARY_EXT.has(extname2(file).toLowerCase())) return void 0;
|
|
803
|
+
try {
|
|
804
|
+
if (statSync4(file).size > MAX_FILE_BYTES) return void 0;
|
|
805
|
+
const content = readFileSync3(file, "utf-8");
|
|
806
|
+
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
807
|
+
return content;
|
|
808
|
+
} catch {
|
|
809
|
+
return void 0;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
function scanStaticRules(targetPath) {
|
|
813
|
+
const findings = [];
|
|
814
|
+
let root;
|
|
815
|
+
try {
|
|
816
|
+
root = statSync4(targetPath);
|
|
817
|
+
} catch {
|
|
818
|
+
return findings;
|
|
819
|
+
}
|
|
820
|
+
const files = root.isDirectory() ? Array.from(walk(targetPath)) : [targetPath];
|
|
821
|
+
for (const file of files) {
|
|
822
|
+
const content = readTextFile(file);
|
|
823
|
+
if (content === void 0) continue;
|
|
824
|
+
const lines = content.split("\n");
|
|
825
|
+
for (const rule of DENY_RULES) {
|
|
826
|
+
for (let i = 0; i < lines.length; i++) {
|
|
827
|
+
if (rule.pattern.test(lines[i])) {
|
|
828
|
+
findings.push({
|
|
829
|
+
severity: rule.severity,
|
|
830
|
+
rule: rule.rule,
|
|
831
|
+
file,
|
|
832
|
+
detail: `line ${i + 1}: ${rule.message}`
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return findings;
|
|
839
|
+
}
|
|
840
|
+
function which(cmd) {
|
|
841
|
+
const pathEnv = process.env.PATH ?? "";
|
|
842
|
+
const dirs = pathEnv.split(delimiter).filter(Boolean);
|
|
843
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
844
|
+
for (const dir of dirs) {
|
|
845
|
+
for (const ext of exts) {
|
|
846
|
+
const candidate = join7(dir, cmd + ext);
|
|
847
|
+
try {
|
|
848
|
+
if (statSync4(candidate).isFile()) return candidate;
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return void 0;
|
|
854
|
+
}
|
|
855
|
+
function normalizeSeverity(sev) {
|
|
856
|
+
const s = (sev ?? "").toUpperCase();
|
|
857
|
+
if (s === "LOW" || s === "MEDIUM" || s === "HIGH" || s === "CRITICAL") return s;
|
|
858
|
+
return "MEDIUM";
|
|
859
|
+
}
|
|
860
|
+
async function scanWithSkillSpector(targetPath) {
|
|
861
|
+
const bin = which("skillspector");
|
|
862
|
+
if (!bin) return [];
|
|
863
|
+
try {
|
|
864
|
+
const { stdout } = await execFileAsync(bin, ["scan", targetPath, "--format", "json", "--no-llm"], {
|
|
865
|
+
timeout: 6e4,
|
|
866
|
+
maxBuffer: 10 * 1024 * 1024
|
|
867
|
+
});
|
|
868
|
+
const parsed = JSON.parse(stdout);
|
|
869
|
+
const raw = parsed.filtered_findings ?? parsed.findings ?? [];
|
|
870
|
+
return raw.map((f) => ({
|
|
871
|
+
severity: normalizeSeverity(f.severity),
|
|
872
|
+
rule: f.rule_id ?? "skillspector",
|
|
873
|
+
file: targetPath,
|
|
874
|
+
detail: f.message ?? ""
|
|
875
|
+
}));
|
|
876
|
+
} catch {
|
|
877
|
+
return [];
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function verdictFor(findings) {
|
|
881
|
+
if (findings.some((f) => BLOCK_SEVERITIES.has(f.severity))) return "block";
|
|
882
|
+
if (findings.length > 0) return "warn";
|
|
883
|
+
return "pass";
|
|
884
|
+
}
|
|
885
|
+
async function scanSafety(targetPath) {
|
|
886
|
+
const findings = [...scanStaticRules(targetPath), ...await scanWithSkillSpector(targetPath)];
|
|
887
|
+
return { findings, verdict: verdictFor(findings) };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/gate/overlap.ts
|
|
891
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync4 } from "fs";
|
|
892
|
+
import { basename as basename4, dirname as dirname2, join as join8, resolve as resolve3 } from "path";
|
|
893
|
+
var STOPWORDS = new Set(
|
|
894
|
+
`
|
|
895
|
+
a an the and or of to for in on with use used using when whether this that these those is are be
|
|
896
|
+
your you it its as at by from into via per also any all not no than then so if up out via about
|
|
897
|
+
skill skills user claude code based comprehensive enhanced practical including include includes
|
|
898
|
+
`.split(/\s+/).filter(Boolean)
|
|
899
|
+
);
|
|
900
|
+
function splitLimited(text, sep, maxSplit) {
|
|
901
|
+
const parts = [];
|
|
902
|
+
let rest = text;
|
|
903
|
+
for (let i = 0; i < maxSplit; i++) {
|
|
904
|
+
const idx = rest.indexOf(sep);
|
|
905
|
+
if (idx === -1) break;
|
|
906
|
+
parts.push(rest.slice(0, idx));
|
|
907
|
+
rest = rest.slice(idx + sep.length);
|
|
908
|
+
}
|
|
909
|
+
parts.push(rest);
|
|
910
|
+
return parts;
|
|
911
|
+
}
|
|
912
|
+
function readFrontmatter(skillMdPath) {
|
|
913
|
+
const text = readFileSync4(skillMdPath, "utf-8");
|
|
914
|
+
const meta = { name: basename4(dirname2(skillMdPath)), description: "" };
|
|
915
|
+
if (text.startsWith("---\n")) {
|
|
916
|
+
const block = splitLimited(text, "---", 2)[1] ?? "";
|
|
917
|
+
const nameMatch = block.match(/^name:\s*(.+)$/m);
|
|
918
|
+
if (nameMatch) {
|
|
919
|
+
meta.name = stripQuotes(nameMatch[1].trim());
|
|
920
|
+
}
|
|
921
|
+
const descMatch = block.match(/^description:\s*(.*)$/m);
|
|
922
|
+
if (descMatch) {
|
|
923
|
+
const first = descMatch[1].trim();
|
|
924
|
+
if ([">", ">-", "|", "|-", ""].includes(first)) {
|
|
925
|
+
const lines = block.split(/\r\n|\r|\n/);
|
|
926
|
+
const idx = lines.findIndex((l) => /^description:/.test(l));
|
|
927
|
+
const buf = [];
|
|
928
|
+
if (idx !== -1) {
|
|
929
|
+
for (const l of lines.slice(idx + 1)) {
|
|
930
|
+
if (/^\s+\S/.test(l)) {
|
|
931
|
+
buf.push(l.trim());
|
|
932
|
+
} else if (l.trim() === "") {
|
|
933
|
+
continue;
|
|
934
|
+
} else {
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
meta.description = buf.join(" ");
|
|
940
|
+
} else {
|
|
941
|
+
meta.description = stripQuotes(first);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
const heads = Array.from(text.matchAll(/^#\s+(.+)$/gm)).map((m) => m[1]);
|
|
946
|
+
meta.description = `${heads[0] ?? ""} ${text.slice(0, 400)}`;
|
|
947
|
+
}
|
|
948
|
+
return meta;
|
|
949
|
+
}
|
|
950
|
+
function tokenize(text) {
|
|
951
|
+
const matches = text.toLowerCase().match(/[a-z][a-z0-9+.-]{2,}/g) ?? [];
|
|
952
|
+
return new Set(matches.filter((t) => !STOPWORDS.has(t)));
|
|
953
|
+
}
|
|
954
|
+
function intersectSize(a, b) {
|
|
955
|
+
let n = 0;
|
|
956
|
+
for (const x of a) if (b.has(x)) n += 1;
|
|
957
|
+
return n;
|
|
958
|
+
}
|
|
959
|
+
function unionSize(a, b) {
|
|
960
|
+
const u = new Set(a);
|
|
961
|
+
for (const x of b) u.add(x);
|
|
962
|
+
return u.size;
|
|
963
|
+
}
|
|
964
|
+
function isSubset(sub, sup) {
|
|
965
|
+
for (const x of sub) if (!sup.has(x)) return false;
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
function round3(n) {
|
|
969
|
+
return Math.round(n * 1e3) / 1e3;
|
|
970
|
+
}
|
|
971
|
+
function score(cand, other) {
|
|
972
|
+
const a = tokenize(`${cand.name} ${cand.description}`);
|
|
973
|
+
const b = tokenize(`${other.name} ${other.description}`);
|
|
974
|
+
if (a.size === 0) return 0;
|
|
975
|
+
const inter = intersectSize(a, b);
|
|
976
|
+
const union = unionSize(a, b);
|
|
977
|
+
const jaccard = union ? inter / union : 0;
|
|
978
|
+
const containment = inter / a.size;
|
|
979
|
+
const phrases = /* @__PURE__ */ new Set();
|
|
980
|
+
for (const m of cand.description.matchAll(/"([^"]{2,40})"/g)) phrases.add(m[1]);
|
|
981
|
+
for (const m of cand.description.matchAll(/'([^']{2,40})'/g)) phrases.add(m[1]);
|
|
982
|
+
let pscore = 0;
|
|
983
|
+
if (phrases.size > 0) {
|
|
984
|
+
let hits = 0;
|
|
985
|
+
for (const p of phrases) {
|
|
986
|
+
const pw = tokenize(p);
|
|
987
|
+
if (pw.size > 0 && isSubset(pw, b)) hits += 1;
|
|
988
|
+
}
|
|
989
|
+
pscore = hits / phrases.size;
|
|
990
|
+
}
|
|
991
|
+
return round3(0.3 * jaccard + 0.5 * containment + 0.2 * pscore);
|
|
992
|
+
}
|
|
993
|
+
var VERB_TIERS = [
|
|
994
|
+
{ min: 0.45, verb: "ABSORB", prose: "ABSORB (or REJECT if ours is already better)" },
|
|
995
|
+
{ min: 0.2, verb: "FORK", prose: "FORK (re-skin), or ABSORB one piece" },
|
|
996
|
+
{ min: -Infinity, verb: "DEFER", prose: "DEFER / new FORK (little overlap)" }
|
|
997
|
+
];
|
|
998
|
+
function verbTier(topScore) {
|
|
999
|
+
return VERB_TIERS.find((t) => topScore >= t.min) ?? VERB_TIERS[VERB_TIERS.length - 1];
|
|
1000
|
+
}
|
|
1001
|
+
function suggestVerb(topScore) {
|
|
1002
|
+
return verbTier(topScore).prose;
|
|
1003
|
+
}
|
|
1004
|
+
function suggestVerbCode(topScore) {
|
|
1005
|
+
return verbTier(topScore).verb;
|
|
1006
|
+
}
|
|
1007
|
+
function listSubdirs(dir) {
|
|
1008
|
+
if (!existsSync7(dir)) return [];
|
|
1009
|
+
let entries;
|
|
1010
|
+
try {
|
|
1011
|
+
entries = readdirSync4(dir, { withFileTypes: true });
|
|
1012
|
+
} catch {
|
|
1013
|
+
return [];
|
|
1014
|
+
}
|
|
1015
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join8(dir, e.name));
|
|
1016
|
+
}
|
|
1017
|
+
function findSkillMdFiles(root) {
|
|
1018
|
+
const found = /* @__PURE__ */ new Set();
|
|
1019
|
+
for (const dir of listSubdirs(root)) {
|
|
1020
|
+
const candidate = join8(dir, "SKILL.md");
|
|
1021
|
+
if (existsSync7(candidate)) found.add(resolve3(candidate));
|
|
1022
|
+
}
|
|
1023
|
+
for (const dir of listSubdirs(join8(root, "skills"))) {
|
|
1024
|
+
const candidate = join8(dir, "SKILL.md");
|
|
1025
|
+
if (existsSync7(candidate)) found.add(resolve3(candidate));
|
|
1026
|
+
}
|
|
1027
|
+
for (const topDir of listSubdirs(root)) {
|
|
1028
|
+
for (const dir of listSubdirs(join8(topDir, "skills"))) {
|
|
1029
|
+
const candidate = join8(dir, "SKILL.md");
|
|
1030
|
+
if (existsSync7(candidate)) found.add(resolve3(candidate));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return Array.from(found).filter((p) => !p.includes("/.git/")).sort();
|
|
1034
|
+
}
|
|
1035
|
+
function scanOverlap(candidatePath, skillsRoot) {
|
|
1036
|
+
const root = resolve3(expandHome(skillsRoot));
|
|
1037
|
+
if (!isDir(root)) {
|
|
1038
|
+
throw new Error(`skills-root not a directory: ${root}`);
|
|
1039
|
+
}
|
|
1040
|
+
let cpath = resolve3(expandHome(candidatePath));
|
|
1041
|
+
if (isDir(cpath)) {
|
|
1042
|
+
cpath = join8(cpath, "SKILL.md");
|
|
1043
|
+
}
|
|
1044
|
+
if (!existsSync7(cpath)) {
|
|
1045
|
+
throw new Error(`candidate SKILL.md not found: ${cpath}`);
|
|
1046
|
+
}
|
|
1047
|
+
const cand = readFrontmatter(cpath);
|
|
1048
|
+
const existing = findSkillMdFiles(root).filter((p) => p !== resolve3(cpath));
|
|
1049
|
+
if (existing.length === 0) {
|
|
1050
|
+
throw new Error(`no existing skills found under ${root}`);
|
|
1051
|
+
}
|
|
1052
|
+
const ranking = existing.map((p) => {
|
|
1053
|
+
const other = readFrontmatter(p);
|
|
1054
|
+
return { skill: other.name, path: p, score: score(cand, other) };
|
|
1055
|
+
});
|
|
1056
|
+
ranking.sort((a, b) => b.score - a.score);
|
|
1057
|
+
const top = ranking[0];
|
|
1058
|
+
return {
|
|
1059
|
+
candidate: cand.name,
|
|
1060
|
+
nearest: top.skill,
|
|
1061
|
+
nearestScore: top.score,
|
|
1062
|
+
suggestedVerb: suggestVerb(top.score),
|
|
1063
|
+
ranking
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// src/commands/gatePipeline.ts
|
|
1068
|
+
var SAFETY_VERDICT_MAP = {
|
|
1069
|
+
pass: "ALLOW",
|
|
1070
|
+
warn: "CAUTION",
|
|
1071
|
+
block: "BLOCK"
|
|
1072
|
+
};
|
|
1073
|
+
function asSkillProfile(candidatePath) {
|
|
1074
|
+
const profile = profileSkill(candidatePath);
|
|
1075
|
+
if (profile.kind !== "skill") {
|
|
1076
|
+
throw new Error(`expected a skill candidate at ${candidatePath}, found a ${profile.kind}`);
|
|
1077
|
+
}
|
|
1078
|
+
return profile;
|
|
1079
|
+
}
|
|
1080
|
+
function tryOverlap(candidatePath, skillsRoot) {
|
|
1081
|
+
try {
|
|
1082
|
+
return scanOverlap(candidatePath, skillsRoot);
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
console.error(`[skill-forge] overlap scan skipped: ${err.message}`);
|
|
1085
|
+
return void 0;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
async function runGate(candidatePath, skillsRoot) {
|
|
1089
|
+
const profile = asSkillProfile(candidatePath);
|
|
1090
|
+
const safety = await scanSafety(candidatePath);
|
|
1091
|
+
const pro = hasProLicense();
|
|
1092
|
+
const overlap = pro ? tryOverlap(candidatePath, skillsRoot) : void 0;
|
|
1093
|
+
const gateResult = {
|
|
1094
|
+
profile,
|
|
1095
|
+
overlap,
|
|
1096
|
+
overlapLocked: !pro,
|
|
1097
|
+
safety: {
|
|
1098
|
+
verdict: SAFETY_VERDICT_MAP[safety.verdict],
|
|
1099
|
+
findings: safety.findings.map((f) => ({
|
|
1100
|
+
severity: f.severity,
|
|
1101
|
+
rule: f.rule,
|
|
1102
|
+
message: `${f.file}: ${f.detail}`
|
|
1103
|
+
}))
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
return { safety, gateResult, pro };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/queue.ts
|
|
1110
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1111
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1112
|
+
import { join as join9 } from "path";
|
|
1113
|
+
function getQueuePath() {
|
|
1114
|
+
return join9(getHomeDir(), "queue.json");
|
|
1115
|
+
}
|
|
1116
|
+
function readQueue() {
|
|
1117
|
+
const path = getQueuePath();
|
|
1118
|
+
if (!existsSync8(path)) {
|
|
1119
|
+
return { version: 1, entries: [] };
|
|
1120
|
+
}
|
|
1121
|
+
const raw = readFileSync5(path, "utf-8");
|
|
1122
|
+
const parsed = JSON.parse(raw);
|
|
1123
|
+
return { version: 1, entries: parsed.entries ?? [] };
|
|
1124
|
+
}
|
|
1125
|
+
function writeQueue(queue) {
|
|
1126
|
+
mkdirSync4(getHomeDir(), { recursive: true });
|
|
1127
|
+
writeFileSync3(getQueuePath(), JSON.stringify(queue, null, 2) + "\n", "utf-8");
|
|
1128
|
+
}
|
|
1129
|
+
function appendQueueEntry(entry) {
|
|
1130
|
+
const queue = readQueue();
|
|
1131
|
+
const existingIds = new Set(queue.entries.map((e) => e.id));
|
|
1132
|
+
const full = {
|
|
1133
|
+
...entry,
|
|
1134
|
+
id: generateId(existingIds),
|
|
1135
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1136
|
+
};
|
|
1137
|
+
queue.entries.push(full);
|
|
1138
|
+
writeQueue(queue);
|
|
1139
|
+
return full;
|
|
1140
|
+
}
|
|
1141
|
+
function generateId(existingIds) {
|
|
1142
|
+
let id;
|
|
1143
|
+
do {
|
|
1144
|
+
id = randomUUID2().split("-")[0];
|
|
1145
|
+
} while (existingIds.has(id));
|
|
1146
|
+
return id;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// src/commands/add.ts
|
|
1150
|
+
async function add(source, options = {}) {
|
|
1151
|
+
const config = loadConfig();
|
|
1152
|
+
const targetRoot = options.target ?? config.skillsRoots[0];
|
|
1153
|
+
if (!targetRoot) {
|
|
1154
|
+
throw new Error("no target skills root configured; pass --target <dir> or set skillsRoots in the config");
|
|
1155
|
+
}
|
|
1156
|
+
const resolved = resolve(source);
|
|
1157
|
+
const entry = await quarantine(resolved, config.quarantineDir);
|
|
1158
|
+
if (entry.skillDirs.length === 0) {
|
|
1159
|
+
console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
|
|
1160
|
+
reject(entry);
|
|
1161
|
+
process.exitCode = 1;
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
if (entry.skillDirs.length > 1) {
|
|
1165
|
+
console.error(
|
|
1166
|
+
`[skill-forge] ${entry.skillDirs.length} skills found in ${source}; gating the first \u2014 the whole quarantine entry is promoted/rejected together:
|
|
1167
|
+
` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
const { safety, gateResult, pro } = await runGate(entry.skillDirs[0], targetRoot);
|
|
1171
|
+
const { profile, overlap } = gateResult;
|
|
1172
|
+
console.log(options.json ? renderJson(gateResult) : renderReport(gateResult));
|
|
1173
|
+
const decision = options.yes ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
1174
|
+
let installedPath = null;
|
|
1175
|
+
if (decision === "promote") {
|
|
1176
|
+
const result = promote(entry, targetRoot);
|
|
1177
|
+
console.log(`[skill-forge] promoted -> ${result.promoted.join(", ")}`);
|
|
1178
|
+
if (pro) {
|
|
1179
|
+
const ledgerPath = appendProvenance(
|
|
1180
|
+
{
|
|
1181
|
+
name: profile.name ?? entry.id,
|
|
1182
|
+
source,
|
|
1183
|
+
version: profile.version,
|
|
1184
|
+
license: profile.license,
|
|
1185
|
+
verb: "DEFER",
|
|
1186
|
+
target: targetRoot,
|
|
1187
|
+
took: `installed as-is (${result.promoted.length} skill dir${result.promoted.length === 1 ? "" : "s"})`,
|
|
1188
|
+
notes: `gate: safety=${safety.verdict}` + (overlap ? `, overlap nearest=${overlap.nearest} (${overlap.nearestScore})` : "")
|
|
1189
|
+
},
|
|
1190
|
+
targetRoot
|
|
1191
|
+
);
|
|
1192
|
+
console.log(`[skill-forge] provenance recorded -> ${ledgerPath}`);
|
|
1193
|
+
}
|
|
1194
|
+
installedPath = result.promoted[0] ?? null;
|
|
1195
|
+
} else if (decision === "reject") {
|
|
1196
|
+
reject(entry);
|
|
1197
|
+
console.log("[skill-forge] rejected \u2014 quarantine sandbox removed");
|
|
1198
|
+
if (safety.verdict === "block") process.exitCode = 1;
|
|
1199
|
+
return;
|
|
1200
|
+
} else {
|
|
1201
|
+
hold(entry);
|
|
1202
|
+
console.log(`[skill-forge] held in quarantine -> ${entry.quarantinePath}`);
|
|
1203
|
+
}
|
|
1204
|
+
if (pro) {
|
|
1205
|
+
appendQueueEntry({
|
|
1206
|
+
source,
|
|
1207
|
+
sourceType: resolved.type,
|
|
1208
|
+
installedPath,
|
|
1209
|
+
quarantinePath: entry.quarantinePath,
|
|
1210
|
+
gate: {
|
|
1211
|
+
license: profile.license ?? null,
|
|
1212
|
+
safetyVerdict: safety.verdict,
|
|
1213
|
+
safetyFindings: safety.findings.map((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
|
|
1214
|
+
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
1215
|
+
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
1216
|
+
},
|
|
1217
|
+
status: "pending"
|
|
1218
|
+
});
|
|
1219
|
+
if (options.ingest && installedPath) {
|
|
1220
|
+
handoffToClaude(installedPath);
|
|
1221
|
+
}
|
|
1222
|
+
} else {
|
|
1223
|
+
console.log(upgradeNotice("provenance ledger, pending-ingestion queue, and --ingest handoff"));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
function handoffToClaude(installedPath) {
|
|
1227
|
+
const command = `/rhize-meta:forge-ingest ${installedPath}`;
|
|
1228
|
+
const result = spawnSync("claude", ["-p", command], { stdio: "inherit" });
|
|
1229
|
+
const error = result.error;
|
|
1230
|
+
if (error?.code === "ENOENT") {
|
|
1231
|
+
console.log("[skill-forge] claude not found on PATH \u2014 run this yourself:");
|
|
1232
|
+
console.log(` claude -p "${command}"`);
|
|
1233
|
+
} else if (error) {
|
|
1234
|
+
console.error(`[skill-forge] failed to launch claude: ${error.message}`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
function yesDecision(verdict) {
|
|
1238
|
+
return verdict === "block" ? "reject" : "promote";
|
|
1239
|
+
}
|
|
1240
|
+
async function promptDecision(verdict) {
|
|
1241
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1242
|
+
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
1243
|
+
const prompt = `Promote, hold, or reject this skill? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
1244
|
+
try {
|
|
1245
|
+
process.stdout.write(prompt);
|
|
1246
|
+
for await (const raw of rl) {
|
|
1247
|
+
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
1248
|
+
if (answer === "p" || answer === "promote") return "promote";
|
|
1249
|
+
if (answer === "h" || answer === "hold") return "hold";
|
|
1250
|
+
if (answer === "r" || answer === "reject") return "reject";
|
|
1251
|
+
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
1252
|
+
${prompt}`);
|
|
1253
|
+
}
|
|
1254
|
+
return defaultChoice;
|
|
1255
|
+
} finally {
|
|
1256
|
+
rl.close();
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/commands/scan.ts
|
|
1261
|
+
async function scan(source) {
|
|
1262
|
+
const config = loadConfig();
|
|
1263
|
+
const skillsRoot = config.skillsRoots[0];
|
|
1264
|
+
if (!skillsRoot) {
|
|
1265
|
+
throw new Error("no skills root configured; set skillsRoots in the config");
|
|
1266
|
+
}
|
|
1267
|
+
const resolved = resolve(source);
|
|
1268
|
+
const entry = await quarantine(resolved, config.quarantineDir);
|
|
1269
|
+
try {
|
|
1270
|
+
if (entry.skillDirs.length === 0) {
|
|
1271
|
+
console.error(`[skill-forge] no SKILL.md found in installed source: ${source}`);
|
|
1272
|
+
process.exitCode = 1;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (entry.skillDirs.length > 1) {
|
|
1276
|
+
console.error(
|
|
1277
|
+
`[skill-forge] ${entry.skillDirs.length} skills found in ${source}; scanning the first:
|
|
1278
|
+
` + entry.skillDirs.map((d) => ` - ${d}`).join("\n")
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
const { safety, gateResult } = await runGate(entry.skillDirs[0], skillsRoot);
|
|
1282
|
+
console.log(renderReport(gateResult));
|
|
1283
|
+
if (safety.verdict === "block") {
|
|
1284
|
+
process.exitCode = 1;
|
|
1285
|
+
}
|
|
1286
|
+
} finally {
|
|
1287
|
+
reject(entry);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// src/commands/list.ts
|
|
1292
|
+
import { join as join10 } from "path";
|
|
1293
|
+
async function list() {
|
|
1294
|
+
const config = loadConfig();
|
|
1295
|
+
const { quarantineDir } = config;
|
|
1296
|
+
const ids = listQuarantineIds(quarantineDir);
|
|
1297
|
+
if (ids.length === 0) {
|
|
1298
|
+
console.log(`[skill-forge] quarantine dir: ${quarantineDir}`);
|
|
1299
|
+
console.log(" (empty \u2014 nothing held)");
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
|
|
1303
|
+
for (const id of ids) {
|
|
1304
|
+
const skillDirs = findSkillDirs(join10(quarantineDir, id));
|
|
1305
|
+
if (skillDirs.length === 0) {
|
|
1306
|
+
console.log(` ${id} (no SKILL.md found)`);
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
const names = skillDirs.map((dir) => {
|
|
1310
|
+
try {
|
|
1311
|
+
return readFrontmatter(join10(dir, "SKILL.md")).name;
|
|
1312
|
+
} catch {
|
|
1313
|
+
return dir;
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
console.log(` ${id} -> ${names.join(", ")}`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// src/commands/status.ts
|
|
1321
|
+
async function status() {
|
|
1322
|
+
const config = loadConfig();
|
|
1323
|
+
const heldCount = listQuarantineIds(config.quarantineDir).length;
|
|
1324
|
+
console.log("[skill-forge] status");
|
|
1325
|
+
console.log(` skillsRoots: ${config.skillsRoots.join(", ")}`);
|
|
1326
|
+
console.log(` quarantineDir: ${config.quarantineDir}`);
|
|
1327
|
+
console.log(` strictness: ${config.strictness}`);
|
|
1328
|
+
console.log(` held in quarantine: ${heldCount}`);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// src/cli.ts
|
|
1332
|
+
var require2 = createRequire(import.meta.url);
|
|
1333
|
+
var pkg = require2("../package.json");
|
|
1334
|
+
var program = new Command();
|
|
1335
|
+
program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
|
|
1336
|
+
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 to Claude via /rhize-meta:forge-ingest").action(add);
|
|
1337
|
+
program.command("scan <source>").description("Run the safety gate against a skill without installing it").action(scan);
|
|
1338
|
+
program.command("list").description("List skills currently in quarantine").action(list);
|
|
1339
|
+
program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
|
|
1340
|
+
await program.parseAsync();
|
|
1341
|
+
//# sourceMappingURL=cli.js.map
|