@rhize/skill-forge 0.7.0 → 0.8.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/README.md +94 -0
- package/dist/cli.js +1185 -244
- package/dist/cli.js.map +1 -1
- package/dist/curation-prompt.md +121 -0
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
|
|
7
7
|
// src/commands/add.ts
|
|
8
|
-
import { join as
|
|
8
|
+
import { join as join18 } from "path";
|
|
9
9
|
|
|
10
10
|
// src/config.ts
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -18,6 +18,16 @@ function getHomeDir() {
|
|
|
18
18
|
function getConfigPath() {
|
|
19
19
|
return join(getHomeDir(), "config.json");
|
|
20
20
|
}
|
|
21
|
+
function normalizeBusinessProfile(raw) {
|
|
22
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
23
|
+
const toStringArray = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
24
|
+
return {
|
|
25
|
+
...raw,
|
|
26
|
+
audiences: toStringArray(raw.audiences),
|
|
27
|
+
workflows: toStringArray(raw.workflows),
|
|
28
|
+
constraints: toStringArray(raw.constraints)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
21
31
|
function defaultConfig() {
|
|
22
32
|
const home = getHomeDir();
|
|
23
33
|
return {
|
|
@@ -42,7 +52,9 @@ function loadConfig() {
|
|
|
42
52
|
defaultTarget: parsed.defaultTarget ?? defaults.defaultTarget,
|
|
43
53
|
agents: parsed.agents ?? defaults.agents,
|
|
44
54
|
handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand,
|
|
45
|
-
mcpTargets: parsed.mcpTargets ?? defaults.mcpTargets
|
|
55
|
+
mcpTargets: parsed.mcpTargets ?? defaults.mcpTargets,
|
|
56
|
+
businessProfile: normalizeBusinessProfile(parsed.businessProfile) ?? defaults.businessProfile,
|
|
57
|
+
foundationSkillPath: parsed.foundationSkillPath ?? defaults.foundationSkillPath
|
|
46
58
|
};
|
|
47
59
|
}
|
|
48
60
|
function saveConfig(config) {
|
|
@@ -295,16 +307,132 @@ function moveDir(src, dest) {
|
|
|
295
307
|
}
|
|
296
308
|
|
|
297
309
|
// src/install/promoteMcp.ts
|
|
298
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as
|
|
310
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
299
311
|
import { dirname } from "path";
|
|
312
|
+
|
|
313
|
+
// src/gate/scanShared.ts
|
|
314
|
+
import { readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
315
|
+
import { extname, join as join5 } from "path";
|
|
316
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
317
|
+
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
318
|
+
".png",
|
|
319
|
+
".jpg",
|
|
320
|
+
".jpeg",
|
|
321
|
+
".gif",
|
|
322
|
+
".webp",
|
|
323
|
+
".ico",
|
|
324
|
+
".pdf",
|
|
325
|
+
".zip",
|
|
326
|
+
".tar",
|
|
327
|
+
".gz",
|
|
328
|
+
".woff",
|
|
329
|
+
".woff2",
|
|
330
|
+
".ttf",
|
|
331
|
+
".eot",
|
|
332
|
+
".mp4",
|
|
333
|
+
".mp3",
|
|
334
|
+
".wasm",
|
|
335
|
+
".bin",
|
|
336
|
+
".exe"
|
|
337
|
+
]);
|
|
338
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
339
|
+
function* walk(dir) {
|
|
340
|
+
let entries;
|
|
341
|
+
try {
|
|
342
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
343
|
+
} catch {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
const full = join5(dir, entry.name);
|
|
348
|
+
if (entry.isDirectory()) {
|
|
349
|
+
if (SKIP_DIRS2.has(entry.name)) continue;
|
|
350
|
+
yield* walk(full);
|
|
351
|
+
} else if (entry.isFile()) {
|
|
352
|
+
yield full;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function readTextFile(file) {
|
|
357
|
+
if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
|
|
358
|
+
try {
|
|
359
|
+
if (statSync2(file).size > MAX_FILE_BYTES) return void 0;
|
|
360
|
+
const content = readFileSync2(file, "utf-8");
|
|
361
|
+
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
362
|
+
return content;
|
|
363
|
+
} catch {
|
|
364
|
+
return void 0;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
var BLOCK_SEVERITIES_BY_STRICTNESS = {
|
|
368
|
+
"block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
|
|
369
|
+
"block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
|
|
370
|
+
"warn-only": /* @__PURE__ */ new Set()
|
|
371
|
+
};
|
|
372
|
+
function verdictFor(findings, strictness) {
|
|
373
|
+
const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
|
|
374
|
+
if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
|
|
375
|
+
if (findings.length > 0) return "warn";
|
|
376
|
+
return "pass";
|
|
377
|
+
}
|
|
378
|
+
function formatSafetyFinding(f) {
|
|
379
|
+
return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
|
|
380
|
+
}
|
|
381
|
+
function isPinnedPackageSpec(spec) {
|
|
382
|
+
const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
|
|
383
|
+
const atIdx = withoutScope.lastIndexOf("@");
|
|
384
|
+
if (atIdx <= 0) return false;
|
|
385
|
+
return /^\d/.test(withoutScope.slice(atIdx + 1));
|
|
386
|
+
}
|
|
387
|
+
function isNpxCommand(command) {
|
|
388
|
+
const base = command.split(/[\\/]/).pop() ?? command;
|
|
389
|
+
return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
|
|
390
|
+
}
|
|
391
|
+
function packageSpecBase(spec) {
|
|
392
|
+
const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
|
|
393
|
+
const atIdx = spec.lastIndexOf("@");
|
|
394
|
+
return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
|
|
395
|
+
}
|
|
396
|
+
var NPX_VALUE_FLAGS = /* @__PURE__ */ new Set(["p", "package", "c", "call", "cache", "userconfig", "shell", "n", "node-arg", "npm", "registry"]);
|
|
397
|
+
var NPX_PACKAGE_SPEC_FLAGS = /* @__PURE__ */ new Set(["p", "package"]);
|
|
398
|
+
function collectNpxPackageSpecs(args) {
|
|
399
|
+
const packageFlagSpecs = [];
|
|
400
|
+
let positionalSpec;
|
|
401
|
+
for (let i = 0; i < args.length; i++) {
|
|
402
|
+
const arg = args[i];
|
|
403
|
+
if (arg === "--") break;
|
|
404
|
+
if (arg.startsWith("-")) {
|
|
405
|
+
const eqIdx = arg.indexOf("=");
|
|
406
|
+
const key = (eqIdx === -1 ? arg : arg.slice(0, eqIdx)).replace(/^-+/, "");
|
|
407
|
+
const attached = eqIdx === -1 ? void 0 : arg.slice(eqIdx + 1);
|
|
408
|
+
if (NPX_VALUE_FLAGS.has(key)) {
|
|
409
|
+
let value = attached;
|
|
410
|
+
if (value === void 0) {
|
|
411
|
+
value = args[i + 1];
|
|
412
|
+
i++;
|
|
413
|
+
}
|
|
414
|
+
if (NPX_PACKAGE_SPEC_FLAGS.has(key) && value !== void 0) {
|
|
415
|
+
packageFlagSpecs.push(value);
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
positionalSpec = arg;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
return { packageFlagSpecs, positionalSpec };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/install/promoteMcp.ts
|
|
300
428
|
function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
|
|
301
429
|
const force = options.force ?? false;
|
|
302
430
|
const serverName = profile.name;
|
|
303
|
-
const { command, args, envVarsToFill } = deriveServerEntry(profile);
|
|
431
|
+
const { command, args, envVarsToFill, autoPinned } = deriveServerEntry(profile);
|
|
304
432
|
let config = {};
|
|
305
433
|
let rawExisting;
|
|
306
434
|
if (existsSync5(targetConfigPath)) {
|
|
307
|
-
rawExisting =
|
|
435
|
+
rawExisting = readFileSync3(targetConfigPath, "utf-8");
|
|
308
436
|
try {
|
|
309
437
|
config = JSON.parse(rawExisting);
|
|
310
438
|
} catch (err) {
|
|
@@ -329,7 +457,7 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
|
|
|
329
457
|
mkdirSync4(dirname(targetConfigPath), { recursive: true });
|
|
330
458
|
writeFileSync2(targetConfigPath, `${JSON.stringify(config, null, 2)}
|
|
331
459
|
`);
|
|
332
|
-
return { target: targetConfigPath, serverName, envVarsToFill, backupPath };
|
|
460
|
+
return { target: targetConfigPath, serverName, envVarsToFill, backupPath, autoPinned };
|
|
333
461
|
}
|
|
334
462
|
function deriveServerEntry(profile) {
|
|
335
463
|
const envNames = new Set(profile.envVars ?? []);
|
|
@@ -337,9 +465,41 @@ function deriveServerEntry(profile) {
|
|
|
337
465
|
for (const key of Object.keys(profile.candidateEntry.env)) envNames.add(key);
|
|
338
466
|
}
|
|
339
467
|
if (profile.candidateEntry) {
|
|
468
|
+
const { command, args = [] } = profile.candidateEntry;
|
|
469
|
+
if (isNpxCommand(command)) {
|
|
470
|
+
const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
|
|
471
|
+
const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
|
|
472
|
+
const unpinned = allSpecs.filter((spec) => !isPinnedPackageSpec(spec));
|
|
473
|
+
if (unpinned.length > 0) {
|
|
474
|
+
const isSoleUnpinnedPositional = packageFlagSpecs.length === 0 && allSpecs.length === 1 && positionalSpec === unpinned[0];
|
|
475
|
+
if (isSoleUnpinnedPositional) {
|
|
476
|
+
const spec = unpinned[0];
|
|
477
|
+
const specPackage = packageSpecBase(spec);
|
|
478
|
+
if (profile.packageName && specPackage === profile.packageName && profile.version) {
|
|
479
|
+
const to = `${profile.packageName}@${profile.version}`;
|
|
480
|
+
const specIdx = args.indexOf(spec);
|
|
481
|
+
const pinnedArgs = [...args];
|
|
482
|
+
pinnedArgs[specIdx] = to;
|
|
483
|
+
return {
|
|
484
|
+
command,
|
|
485
|
+
args: pinnedArgs,
|
|
486
|
+
envVarsToFill: Array.from(envNames),
|
|
487
|
+
autoPinned: { from: spec, to }
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const reason = profile.packageName && specPackage !== profile.packageName ? `the launch spec names package "${specPackage}", but the candidate's own package.json is "${profile.packageName}" \u2014 pinning it to a version scanned for a different package would be unsafe` : "no version was found in the candidate's own package.json to pin it to";
|
|
491
|
+
throw new UserError(
|
|
492
|
+
`cannot promote MCP server "${profile.name}": its documented launch command "npx ${spec}" is unpinned and can't be safely auto-pinned (${reason}). Pin the spec to an exact version in the candidate's config (e.g. \`${specPackage}@<version>\`) and try again \u2014 there is no override for this.`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
throw new UserError(
|
|
496
|
+
`cannot promote MCP server "${profile.name}": its documented launch command "npx" has unpinned package spec(s) \u2014 ${unpinned.map((s) => `"${s}"`).join(", ")} \u2014 and this shape (a -p/--package dependency, or more than one spec) can't be safely auto-pinned. Pin every spec to an exact version in the candidate's config and try again \u2014 there is no override for this.`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
340
500
|
return {
|
|
341
|
-
command
|
|
342
|
-
args
|
|
501
|
+
command,
|
|
502
|
+
args,
|
|
343
503
|
envVarsToFill: Array.from(envNames)
|
|
344
504
|
};
|
|
345
505
|
}
|
|
@@ -356,18 +516,22 @@ function deriveServerEntry(profile) {
|
|
|
356
516
|
}
|
|
357
517
|
|
|
358
518
|
// src/install/mcpCandidate.ts
|
|
359
|
-
import { readFileSync as
|
|
360
|
-
|
|
519
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
520
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
521
|
+
function readPackageInfo(pkgPath) {
|
|
361
522
|
try {
|
|
362
|
-
const pkg2 = JSON.parse(
|
|
363
|
-
return
|
|
523
|
+
const pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
|
|
524
|
+
return {
|
|
525
|
+
name: typeof pkg2.name === "string" ? pkg2.name : void 0,
|
|
526
|
+
version: typeof pkg2.version === "string" ? pkg2.version : void 0
|
|
527
|
+
};
|
|
364
528
|
} catch {
|
|
365
|
-
return
|
|
529
|
+
return {};
|
|
366
530
|
}
|
|
367
531
|
}
|
|
368
532
|
function readConfigEntry(configPath, name) {
|
|
369
533
|
try {
|
|
370
|
-
const cfg = JSON.parse(
|
|
534
|
+
const cfg = JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
371
535
|
const servers = cfg.mcpServers ?? cfg.servers ?? {};
|
|
372
536
|
const raw = servers[name] ?? {};
|
|
373
537
|
return {
|
|
@@ -382,15 +546,23 @@ function readConfigEntry(configPath, name) {
|
|
|
382
546
|
function resolveMcpCandidateDetail(profile) {
|
|
383
547
|
const name = profile.servers[0];
|
|
384
548
|
if (profile.config.endsWith("package.json")) {
|
|
385
|
-
return { name, version:
|
|
549
|
+
return { name, version: readPackageInfo(profile.config).version };
|
|
386
550
|
}
|
|
387
551
|
const entry = readConfigEntry(profile.config, name);
|
|
388
|
-
|
|
552
|
+
const sidecar = readPackageInfo(join6(dirname2(profile.config), "package.json"));
|
|
553
|
+
return {
|
|
554
|
+
name,
|
|
555
|
+
command: entry?.command,
|
|
556
|
+
args: entry?.args,
|
|
557
|
+
env: entry?.env,
|
|
558
|
+
version: sidecar.version,
|
|
559
|
+
packageName: sidecar.name
|
|
560
|
+
};
|
|
389
561
|
}
|
|
390
562
|
|
|
391
563
|
// src/provenance.ts
|
|
392
|
-
import { appendFileSync, existsSync as existsSync6, statSync as
|
|
393
|
-
import { join as
|
|
564
|
+
import { appendFileSync, existsSync as existsSync6, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
565
|
+
import { join as join7 } from "path";
|
|
394
566
|
var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
|
|
395
567
|
var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
|
|
396
568
|
function todayLocalIsoDate() {
|
|
@@ -404,7 +576,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
404
576
|
if (!FORGE_VERBS.includes(entry.verb)) {
|
|
405
577
|
throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
|
|
406
578
|
}
|
|
407
|
-
if (!existsSync6(skillsRoot) || !
|
|
579
|
+
if (!existsSync6(skillsRoot) || !statSync3(skillsRoot).isDirectory()) {
|
|
408
580
|
throw new Error(`skills-root not a directory: ${skillsRoot}`);
|
|
409
581
|
}
|
|
410
582
|
const today = todayLocalIsoDate();
|
|
@@ -421,7 +593,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
421
593
|
- **Drift check:** \`${driftCheck}\`
|
|
422
594
|
- **Notes:** ${entry.notes ?? ""}
|
|
423
595
|
`;
|
|
424
|
-
const ledgerPath =
|
|
596
|
+
const ledgerPath = join7(skillsRoot, "SOURCES.md");
|
|
425
597
|
if (!existsSync6(ledgerPath)) {
|
|
426
598
|
writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
|
|
427
599
|
}
|
|
@@ -431,7 +603,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
431
603
|
|
|
432
604
|
// src/commands/gatePipeline.ts
|
|
433
605
|
import { readdirSync as readdirSync5 } from "fs";
|
|
434
|
-
import { join as
|
|
606
|
+
import { join as join12 } from "path";
|
|
435
607
|
|
|
436
608
|
// src/gate/profile.ts
|
|
437
609
|
import { execFileSync } from "child_process";
|
|
@@ -447,82 +619,11 @@ import {
|
|
|
447
619
|
statSync as statSync4
|
|
448
620
|
} from "fs";
|
|
449
621
|
import { tmpdir } from "os";
|
|
450
|
-
import { basename as basename3, dirname as
|
|
622
|
+
import { basename as basename3, dirname as dirname3, extname as extname3, join as join9, resolve as resolve2 } from "path";
|
|
451
623
|
|
|
452
624
|
// src/gate/mcpCapabilities.ts
|
|
453
625
|
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
|
|
454
|
-
import { extname as extname2, join as
|
|
455
|
-
|
|
456
|
-
// src/gate/scanShared.ts
|
|
457
|
-
import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
458
|
-
import { extname, join as join6 } from "path";
|
|
459
|
-
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
460
|
-
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
461
|
-
".png",
|
|
462
|
-
".jpg",
|
|
463
|
-
".jpeg",
|
|
464
|
-
".gif",
|
|
465
|
-
".webp",
|
|
466
|
-
".ico",
|
|
467
|
-
".pdf",
|
|
468
|
-
".zip",
|
|
469
|
-
".tar",
|
|
470
|
-
".gz",
|
|
471
|
-
".woff",
|
|
472
|
-
".woff2",
|
|
473
|
-
".ttf",
|
|
474
|
-
".eot",
|
|
475
|
-
".mp4",
|
|
476
|
-
".mp3",
|
|
477
|
-
".wasm",
|
|
478
|
-
".bin",
|
|
479
|
-
".exe"
|
|
480
|
-
]);
|
|
481
|
-
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
482
|
-
function* walk(dir) {
|
|
483
|
-
let entries;
|
|
484
|
-
try {
|
|
485
|
-
entries = readdirSync2(dir, { withFileTypes: true });
|
|
486
|
-
} catch {
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
for (const entry of entries) {
|
|
490
|
-
const full = join6(dir, entry.name);
|
|
491
|
-
if (entry.isDirectory()) {
|
|
492
|
-
if (SKIP_DIRS2.has(entry.name)) continue;
|
|
493
|
-
yield* walk(full);
|
|
494
|
-
} else if (entry.isFile()) {
|
|
495
|
-
yield full;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
function readTextFile(file) {
|
|
500
|
-
if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
|
|
501
|
-
try {
|
|
502
|
-
if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
|
|
503
|
-
const content = readFileSync4(file, "utf-8");
|
|
504
|
-
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
505
|
-
return content;
|
|
506
|
-
} catch {
|
|
507
|
-
return void 0;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
var BLOCK_SEVERITIES_BY_STRICTNESS = {
|
|
511
|
-
"block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
|
|
512
|
-
"block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
|
|
513
|
-
"warn-only": /* @__PURE__ */ new Set()
|
|
514
|
-
};
|
|
515
|
-
function verdictFor(findings, strictness) {
|
|
516
|
-
const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
|
|
517
|
-
if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
|
|
518
|
-
if (findings.length > 0) return "warn";
|
|
519
|
-
return "pass";
|
|
520
|
-
}
|
|
521
|
-
function formatSafetyFinding(f) {
|
|
522
|
-
return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// src/gate/mcpCapabilities.ts
|
|
626
|
+
import { extname as extname2, join as join8 } from "path";
|
|
526
627
|
var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
527
628
|
var MAX_ITEMS_PER_CATEGORY = 200;
|
|
528
629
|
function readJsonSafe(file) {
|
|
@@ -628,7 +729,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
|
|
|
628
729
|
function extractMcpCapabilities(packageDir) {
|
|
629
730
|
const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
|
|
630
731
|
let hasPackageJsonMarker = false;
|
|
631
|
-
const pkgPath =
|
|
732
|
+
const pkgPath = join8(packageDir, "package.json");
|
|
632
733
|
if (existsSync7(pkgPath)) {
|
|
633
734
|
const pkg2 = readJsonSafe(pkgPath);
|
|
634
735
|
if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
|
|
@@ -636,7 +737,7 @@ function extractMcpCapabilities(packageDir) {
|
|
|
636
737
|
}
|
|
637
738
|
}
|
|
638
739
|
for (const manifestName of [".mcp.json", "mcp.json"]) {
|
|
639
|
-
const manifestPath =
|
|
740
|
+
const manifestPath = join8(packageDir, manifestName);
|
|
640
741
|
if (!existsSync7(manifestPath)) continue;
|
|
641
742
|
const parsed = readJsonSafe(manifestPath);
|
|
642
743
|
if (parsed !== void 0) {
|
|
@@ -744,7 +845,7 @@ function asProfile(candidatePath, artifact) {
|
|
|
744
845
|
return asSkillProfile(profile, `at ${candidatePath}`);
|
|
745
846
|
}
|
|
746
847
|
function classifyInput(p) {
|
|
747
|
-
if (existsSync8(
|
|
848
|
+
if (existsSync8(join9(p, ".claude-plugin", "plugin.json"))) return "plugin";
|
|
748
849
|
if (detectArtifactType(p) === "mcp") return "mcp";
|
|
749
850
|
return "skill";
|
|
750
851
|
}
|
|
@@ -752,7 +853,7 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
|
|
|
752
853
|
var MCP_KEYWORD2 = "mcp-server";
|
|
753
854
|
var MCP_BIN_PREFIX2 = /^mcp-/i;
|
|
754
855
|
function readPackageJsonSafe(root) {
|
|
755
|
-
const pkgPath =
|
|
856
|
+
const pkgPath = join9(root, "package.json");
|
|
756
857
|
if (!existsSync8(pkgPath)) return void 0;
|
|
757
858
|
try {
|
|
758
859
|
return JSON.parse(readFileSync6(pkgPath, "utf-8"));
|
|
@@ -776,7 +877,7 @@ function hasMcpPackageMarkers(pkg2) {
|
|
|
776
877
|
return false;
|
|
777
878
|
}
|
|
778
879
|
function detectArtifactType(root) {
|
|
779
|
-
if (existsSync8(
|
|
880
|
+
if (existsSync8(join9(root, ".mcp.json")) || existsSync8(join9(root, "mcp.json"))) return "mcp";
|
|
780
881
|
const pkg2 = readPackageJsonSafe(root);
|
|
781
882
|
if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
|
|
782
883
|
return "skill";
|
|
@@ -786,22 +887,22 @@ function resolveSkillMd(inputPath) {
|
|
|
786
887
|
let tmpDir;
|
|
787
888
|
const initialStat = statSync4(currentPath);
|
|
788
889
|
if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
|
|
789
|
-
tmpDir = mkdtempSync(
|
|
890
|
+
tmpDir = mkdtempSync(join9(tmpdir(), "forge_"));
|
|
790
891
|
extractZip(currentPath, tmpDir);
|
|
791
892
|
currentPath = tmpDir;
|
|
792
893
|
}
|
|
793
894
|
const stat = statSync4(currentPath);
|
|
794
895
|
if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
|
|
795
|
-
return { skillMd: currentPath, root:
|
|
896
|
+
return { skillMd: currentPath, root: dirname3(currentPath), tmpDir };
|
|
796
897
|
}
|
|
797
898
|
if (stat.isDirectory()) {
|
|
798
|
-
const direct =
|
|
899
|
+
const direct = join9(currentPath, "SKILL.md");
|
|
799
900
|
if (existsSync8(direct)) {
|
|
800
901
|
return { skillMd: direct, root: currentPath, tmpDir };
|
|
801
902
|
}
|
|
802
903
|
const matches = findSkillMdOneLevelDown(currentPath);
|
|
803
904
|
if (matches.length === 1) {
|
|
804
|
-
return { skillMd: matches[0], root:
|
|
905
|
+
return { skillMd: matches[0], root: dirname3(matches[0]), tmpDir };
|
|
805
906
|
}
|
|
806
907
|
if (matches.length > 1) {
|
|
807
908
|
cleanupTmp(tmpDir);
|
|
@@ -821,15 +922,15 @@ function findSkillMdOneLevelDown(dir) {
|
|
|
821
922
|
const matches = /* @__PURE__ */ new Set();
|
|
822
923
|
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
823
924
|
if (entry.isDirectory()) {
|
|
824
|
-
const candidate =
|
|
925
|
+
const candidate = join9(dir, entry.name, "SKILL.md");
|
|
825
926
|
if (existsSync8(candidate)) matches.add(candidate);
|
|
826
927
|
}
|
|
827
928
|
}
|
|
828
|
-
const skillsDir =
|
|
929
|
+
const skillsDir = join9(dir, "skills");
|
|
829
930
|
if (isDir(skillsDir)) {
|
|
830
931
|
for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
|
|
831
932
|
if (entry.isDirectory()) {
|
|
832
|
-
const candidate =
|
|
933
|
+
const candidate = join9(skillsDir, entry.name, "SKILL.md");
|
|
833
934
|
if (existsSync8(candidate)) matches.add(candidate);
|
|
834
935
|
}
|
|
835
936
|
}
|
|
@@ -913,7 +1014,7 @@ function detectLicense(root, fm) {
|
|
|
913
1014
|
if (fm.license) return fm.license;
|
|
914
1015
|
const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
|
|
915
1016
|
for (const name of names) {
|
|
916
|
-
const p =
|
|
1017
|
+
const p = join9(root, name);
|
|
917
1018
|
if (existsSync8(p) && statSync4(p).isFile()) {
|
|
918
1019
|
const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
|
|
919
1020
|
for (const [hint, label] of Object.entries(SPDX_HINTS)) {
|
|
@@ -927,7 +1028,7 @@ function detectLicense(root, fm) {
|
|
|
927
1028
|
function walkFiles(dir) {
|
|
928
1029
|
const out = [];
|
|
929
1030
|
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
930
|
-
const full =
|
|
1031
|
+
const full = join9(dir, entry.name);
|
|
931
1032
|
if (entry.isDirectory()) {
|
|
932
1033
|
out.push(...walkFiles(full));
|
|
933
1034
|
} else if (entry.isFile()) {
|
|
@@ -939,7 +1040,7 @@ function walkFiles(dir) {
|
|
|
939
1040
|
function countResources(root) {
|
|
940
1041
|
const resources = {};
|
|
941
1042
|
for (const d of RESOURCE_DIRS) {
|
|
942
|
-
const dp =
|
|
1043
|
+
const dp = join9(root, d);
|
|
943
1044
|
if (isDir(dp)) {
|
|
944
1045
|
resources[d] = walkFiles(dp).length;
|
|
945
1046
|
}
|
|
@@ -950,7 +1051,7 @@ function scanMcpAndImports(text, root) {
|
|
|
950
1051
|
const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
|
|
951
1052
|
const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
|
|
952
1053
|
const imports = /* @__PURE__ */ new Set();
|
|
953
|
-
const scriptsDir =
|
|
1054
|
+
const scriptsDir = join9(root, "scripts");
|
|
954
1055
|
if (isDir(scriptsDir)) {
|
|
955
1056
|
for (const f of walkFiles(scriptsDir)) {
|
|
956
1057
|
if (!f.endsWith(".py")) continue;
|
|
@@ -999,19 +1100,19 @@ function profileSkillDir(inputPath) {
|
|
|
999
1100
|
}
|
|
1000
1101
|
}
|
|
1001
1102
|
function profilePlugin(p) {
|
|
1002
|
-
const manifestPath =
|
|
1103
|
+
const manifestPath = join9(p, ".claude-plugin", "plugin.json");
|
|
1003
1104
|
let manifest;
|
|
1004
1105
|
try {
|
|
1005
1106
|
manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
|
|
1006
1107
|
} catch (err) {
|
|
1007
1108
|
throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
|
|
1008
1109
|
}
|
|
1009
|
-
const skillsDir =
|
|
1110
|
+
const skillsDir = join9(p, "skills");
|
|
1010
1111
|
let skills = [];
|
|
1011
1112
|
if (isDir(skillsDir)) {
|
|
1012
|
-
skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(
|
|
1113
|
+
skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join9(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
|
|
1013
1114
|
}
|
|
1014
|
-
const commandsDir =
|
|
1115
|
+
const commandsDir = join9(p, "commands");
|
|
1015
1116
|
const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
1016
1117
|
const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
|
|
1017
1118
|
const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
|
|
@@ -1029,7 +1130,7 @@ function profilePlugin(p) {
|
|
|
1029
1130
|
};
|
|
1030
1131
|
}
|
|
1031
1132
|
function profileMcp(p) {
|
|
1032
|
-
const explicitConfig = existsSync8(
|
|
1133
|
+
const explicitConfig = existsSync8(join9(p, ".mcp.json")) ? join9(p, ".mcp.json") : existsSync8(join9(p, "mcp.json")) ? join9(p, "mcp.json") : void 0;
|
|
1033
1134
|
if (explicitConfig) {
|
|
1034
1135
|
let cfg;
|
|
1035
1136
|
try {
|
|
@@ -1049,7 +1150,7 @@ function profileMcp(p) {
|
|
|
1049
1150
|
capabilities: extractMcpCapabilities(p)
|
|
1050
1151
|
};
|
|
1051
1152
|
}
|
|
1052
|
-
const pkgPath =
|
|
1153
|
+
const pkgPath = join9(p, "package.json");
|
|
1053
1154
|
let pkg2;
|
|
1054
1155
|
try {
|
|
1055
1156
|
pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
|
|
@@ -1071,7 +1172,7 @@ function profileMcp(p) {
|
|
|
1071
1172
|
// src/gate/safety.ts
|
|
1072
1173
|
import { statSync as statSync5 } from "fs";
|
|
1073
1174
|
import { execFile } from "child_process";
|
|
1074
|
-
import { delimiter, join as
|
|
1175
|
+
import { delimiter, join as join10 } from "path";
|
|
1075
1176
|
import { promisify } from "util";
|
|
1076
1177
|
var execFileAsync = promisify(execFile);
|
|
1077
1178
|
var DENY_RULES = [
|
|
@@ -1170,7 +1271,7 @@ function which(cmd) {
|
|
|
1170
1271
|
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
1171
1272
|
for (const dir of dirs) {
|
|
1172
1273
|
for (const ext of exts) {
|
|
1173
|
-
const candidate =
|
|
1274
|
+
const candidate = join10(dir, cmd + ext);
|
|
1174
1275
|
try {
|
|
1175
1276
|
if (statSync5(candidate).isFile()) return candidate;
|
|
1176
1277
|
} catch {
|
|
@@ -1211,7 +1312,7 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
|
1211
1312
|
|
|
1212
1313
|
// src/gate/overlap.ts
|
|
1213
1314
|
import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync4 } from "fs";
|
|
1214
|
-
import { basename as basename4, dirname as
|
|
1315
|
+
import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve3 } from "path";
|
|
1215
1316
|
var STOPWORDS = new Set(
|
|
1216
1317
|
`
|
|
1217
1318
|
a an the and or of to for in on with use used using when whether this that these those is are be
|
|
@@ -1219,6 +1320,7 @@ your you it its as at by from into via per also any all not no than then so if u
|
|
|
1219
1320
|
skill skills user claude code based comprehensive enhanced practical including include includes
|
|
1220
1321
|
`.split(/\s+/).filter(Boolean)
|
|
1221
1322
|
);
|
|
1323
|
+
var DEFAULT_SET_THRESHOLD = 0.45;
|
|
1222
1324
|
function splitLimited(text, sep2, maxSplit) {
|
|
1223
1325
|
const parts = [];
|
|
1224
1326
|
let rest = text;
|
|
@@ -1233,7 +1335,7 @@ function splitLimited(text, sep2, maxSplit) {
|
|
|
1233
1335
|
}
|
|
1234
1336
|
function readFrontmatter(skillMdPath) {
|
|
1235
1337
|
const text = readFileSync7(skillMdPath, "utf-8");
|
|
1236
|
-
const meta = { name: basename4(
|
|
1338
|
+
const meta = { name: basename4(dirname4(skillMdPath)), description: "" };
|
|
1237
1339
|
if (text.startsWith("---\n")) {
|
|
1238
1340
|
const block = splitLimited(text, "---", 2)[1] ?? "";
|
|
1239
1341
|
const nameMatch = block.match(/^name:\s*(.+)$/m);
|
|
@@ -1334,21 +1436,21 @@ function listSubdirs(dir) {
|
|
|
1334
1436
|
} catch {
|
|
1335
1437
|
return [];
|
|
1336
1438
|
}
|
|
1337
|
-
return entries.filter((e) => e.isDirectory()).map((e) =>
|
|
1439
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join11(dir, e.name));
|
|
1338
1440
|
}
|
|
1339
1441
|
function findSkillMdFiles(root) {
|
|
1340
1442
|
const found = /* @__PURE__ */ new Set();
|
|
1341
1443
|
for (const dir of listSubdirs(root)) {
|
|
1342
|
-
const candidate =
|
|
1444
|
+
const candidate = join11(dir, "SKILL.md");
|
|
1343
1445
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1344
1446
|
}
|
|
1345
|
-
for (const dir of listSubdirs(
|
|
1346
|
-
const candidate =
|
|
1447
|
+
for (const dir of listSubdirs(join11(root, "skills"))) {
|
|
1448
|
+
const candidate = join11(dir, "SKILL.md");
|
|
1347
1449
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1348
1450
|
}
|
|
1349
1451
|
for (const topDir of listSubdirs(root)) {
|
|
1350
|
-
for (const dir of listSubdirs(
|
|
1351
|
-
const candidate =
|
|
1452
|
+
for (const dir of listSubdirs(join11(topDir, "skills"))) {
|
|
1453
|
+
const candidate = join11(dir, "SKILL.md");
|
|
1352
1454
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1353
1455
|
}
|
|
1354
1456
|
}
|
|
@@ -1361,7 +1463,7 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
1361
1463
|
}
|
|
1362
1464
|
let cpath = resolve3(expandHome(candidatePath));
|
|
1363
1465
|
if (isDir(cpath)) {
|
|
1364
|
-
cpath =
|
|
1466
|
+
cpath = join11(cpath, "SKILL.md");
|
|
1365
1467
|
}
|
|
1366
1468
|
if (!existsSync9(cpath)) {
|
|
1367
1469
|
throw new Error(`candidate SKILL.md not found: ${cpath}`);
|
|
@@ -1385,6 +1487,26 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
1385
1487
|
ranking
|
|
1386
1488
|
};
|
|
1387
1489
|
}
|
|
1490
|
+
function scanOverlapPaths(skillMdPaths, threshold = DEFAULT_SET_THRESHOLD) {
|
|
1491
|
+
const metas = skillMdPaths.map(readFrontmatter);
|
|
1492
|
+
const pairs = [];
|
|
1493
|
+
for (let i = 0; i < metas.length; i++) {
|
|
1494
|
+
for (let j = i + 1; j < metas.length; j++) {
|
|
1495
|
+
const sc = Math.max(score(metas[i], metas[j]), score(metas[j], metas[i]));
|
|
1496
|
+
if (sc >= threshold) {
|
|
1497
|
+
pairs.push({
|
|
1498
|
+
a: metas[i].name,
|
|
1499
|
+
b: metas[j].name,
|
|
1500
|
+
aPath: skillMdPaths[i],
|
|
1501
|
+
bPath: skillMdPaths[j],
|
|
1502
|
+
score: round3(sc)
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
pairs.sort((a, b) => b.score - a.score);
|
|
1508
|
+
return pairs;
|
|
1509
|
+
}
|
|
1388
1510
|
|
|
1389
1511
|
// src/gate/mcpSafety.ts
|
|
1390
1512
|
import { statSync as statSync6 } from "fs";
|
|
@@ -1416,21 +1538,6 @@ function looksHighEntropy(value) {
|
|
|
1416
1538
|
const uniqueChars = new Set(value).size;
|
|
1417
1539
|
return varietyCount >= 2 && uniqueChars / value.length > 0.4;
|
|
1418
1540
|
}
|
|
1419
|
-
function isPinnedPackageSpec(spec) {
|
|
1420
|
-
const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
|
|
1421
|
-
const atIdx = withoutScope.lastIndexOf("@");
|
|
1422
|
-
if (atIdx <= 0) return false;
|
|
1423
|
-
return /^\d/.test(withoutScope.slice(atIdx + 1));
|
|
1424
|
-
}
|
|
1425
|
-
function isNpxCommand(command) {
|
|
1426
|
-
const base = command.split(/[\\/]/).pop() ?? command;
|
|
1427
|
-
return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
|
|
1428
|
-
}
|
|
1429
|
-
function packageSpecBase(spec) {
|
|
1430
|
-
const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
|
|
1431
|
-
const atIdx = spec.lastIndexOf("@");
|
|
1432
|
-
return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
|
|
1433
|
-
}
|
|
1434
1541
|
function collectServerNodes(node, out) {
|
|
1435
1542
|
if (Array.isArray(node)) {
|
|
1436
1543
|
for (const item of node) collectServerNodes(item, out);
|
|
@@ -1461,14 +1568,17 @@ function scanJsonConfig(file, parsed) {
|
|
|
1461
1568
|
if (command && isNpxCommand(command)) {
|
|
1462
1569
|
const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
|
|
1463
1570
|
if (flagIdx !== -1) {
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1571
|
+
const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
|
|
1572
|
+
const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
|
|
1573
|
+
for (const pkgSpec of allSpecs) {
|
|
1574
|
+
if (!isPinnedPackageSpec(pkgSpec)) {
|
|
1575
|
+
findings.push({
|
|
1576
|
+
severity: "MEDIUM",
|
|
1577
|
+
rule: "mcp-unpinned-npx",
|
|
1578
|
+
file,
|
|
1579
|
+
detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1472
1582
|
}
|
|
1473
1583
|
}
|
|
1474
1584
|
}
|
|
@@ -1883,7 +1993,7 @@ function resolveMcpCandidatePath(entry) {
|
|
|
1883
1993
|
children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
1884
1994
|
} catch {
|
|
1885
1995
|
}
|
|
1886
|
-
return children.length === 1 ?
|
|
1996
|
+
return children.length === 1 ? join12(entry.quarantinePath, children[0]) : entry.quarantinePath;
|
|
1887
1997
|
}
|
|
1888
1998
|
async function runGate(candidatePath, skillsRoot, {
|
|
1889
1999
|
proFeatureLabel = "overlap analysis",
|
|
@@ -1911,9 +2021,9 @@ async function runGate(candidatePath, skillsRoot, {
|
|
|
1911
2021
|
// src/queue.ts
|
|
1912
2022
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1913
2023
|
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
|
|
1914
|
-
import { join as
|
|
2024
|
+
import { join as join13 } from "path";
|
|
1915
2025
|
function getQueuePath() {
|
|
1916
|
-
return
|
|
2026
|
+
return join13(getHomeDir(), "queue.json");
|
|
1917
2027
|
}
|
|
1918
2028
|
function readQueue() {
|
|
1919
2029
|
const path = getQueuePath();
|
|
@@ -1951,15 +2061,15 @@ function generateId(existingIds) {
|
|
|
1951
2061
|
// src/handoff.ts
|
|
1952
2062
|
import { spawnSync } from "child_process";
|
|
1953
2063
|
import { existsSync as existsSync12 } from "fs";
|
|
1954
|
-
import { dirname as
|
|
2064
|
+
import { dirname as dirname5, join as join14 } from "path";
|
|
1955
2065
|
import { fileURLToPath } from "url";
|
|
1956
2066
|
var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
|
|
1957
2067
|
var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
|
|
1958
|
-
function resolvePromptPath() {
|
|
1959
|
-
const here =
|
|
1960
|
-
const builtCandidate =
|
|
2068
|
+
function resolvePromptPath(assetName = "ingest-prompt.md") {
|
|
2069
|
+
const here = dirname5(fileURLToPath(import.meta.url));
|
|
2070
|
+
const builtCandidate = join14(here, assetName);
|
|
1961
2071
|
if (existsSync12(builtCandidate)) return builtCandidate;
|
|
1962
|
-
return
|
|
2072
|
+
return join14(here, "..", "assets", assetName);
|
|
1963
2073
|
}
|
|
1964
2074
|
function defaultIsOnPath(bin) {
|
|
1965
2075
|
const finder = process.platform === "win32" ? "where" : "which";
|
|
@@ -1971,7 +2081,7 @@ function substitute(template, vars) {
|
|
|
1971
2081
|
}
|
|
1972
2082
|
function buildHandoffCommand(config, installedPath, deps = {}) {
|
|
1973
2083
|
const isOnPath = deps.isOnPath ?? defaultIsOnPath;
|
|
1974
|
-
const promptPath = resolvePromptPath();
|
|
2084
|
+
const promptPath = resolvePromptPath(deps.promptAsset);
|
|
1975
2085
|
const vars = { path: installedPath, prompt: promptPath };
|
|
1976
2086
|
if (config.handoffCommand && config.handoffCommand.length > 0) {
|
|
1977
2087
|
return { argv: substitute(config.handoffCommand, vars), source: "template" };
|
|
@@ -1983,8 +2093,8 @@ function buildHandoffCommand(config, installedPath, deps = {}) {
|
|
|
1983
2093
|
}
|
|
1984
2094
|
return { argv: [], source: "none" };
|
|
1985
2095
|
}
|
|
1986
|
-
function printOnlyMessage(installedPath) {
|
|
1987
|
-
const promptPath = resolvePromptPath();
|
|
2096
|
+
function printOnlyMessage(installedPath, promptAsset) {
|
|
2097
|
+
const promptPath = resolvePromptPath(promptAsset);
|
|
1988
2098
|
return `[skill-forge] no agent handoff available \u2014 run one yourself:
|
|
1989
2099
|
Prompt: ${promptPath}
|
|
1990
2100
|
Skill path: ${installedPath}`;
|
|
@@ -1992,18 +2102,19 @@ function printOnlyMessage(installedPath) {
|
|
|
1992
2102
|
function runHandoff(config, installedPath, options = {}) {
|
|
1993
2103
|
const command = buildHandoffCommand(config, installedPath, options);
|
|
1994
2104
|
const spawn2 = options.spawn ?? spawnSync;
|
|
2105
|
+
const log = options.log ?? console.log;
|
|
1995
2106
|
if (command.source === "none" || command.argv.length === 0) {
|
|
1996
|
-
const printed = printOnlyMessage(installedPath);
|
|
1997
|
-
|
|
2107
|
+
const printed = printOnlyMessage(installedPath, options.promptAsset);
|
|
2108
|
+
log(printed);
|
|
1998
2109
|
return { ran: false, command, printed };
|
|
1999
2110
|
}
|
|
2000
2111
|
const [bin, ...args] = command.argv;
|
|
2001
2112
|
const result = spawn2(bin, args, { stdio: options.stdio ?? "inherit" });
|
|
2002
2113
|
const error = result.error;
|
|
2003
2114
|
if (error?.code === "ENOENT") {
|
|
2004
|
-
const printed = printOnlyMessage(installedPath);
|
|
2005
|
-
|
|
2006
|
-
|
|
2115
|
+
const printed = printOnlyMessage(installedPath, options.promptAsset);
|
|
2116
|
+
log(`[skill-forge] ${bin} not found on PATH \u2014 run this yourself:`);
|
|
2117
|
+
log(printed);
|
|
2007
2118
|
return { ran: false, command, printed, error: error.message };
|
|
2008
2119
|
}
|
|
2009
2120
|
if (error) {
|
|
@@ -2014,14 +2125,14 @@ function runHandoff(config, installedPath, options = {}) {
|
|
|
2014
2125
|
}
|
|
2015
2126
|
|
|
2016
2127
|
// src/commands/init.ts
|
|
2017
|
-
import { existsSync as
|
|
2128
|
+
import { existsSync as existsSync15 } from "fs";
|
|
2018
2129
|
import { homedir as homedir4 } from "os";
|
|
2019
|
-
import { createInterface } from "readline/promises";
|
|
2130
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
2020
2131
|
|
|
2021
2132
|
// src/agents.ts
|
|
2022
2133
|
import { statSync as statSync7 } from "fs";
|
|
2023
2134
|
import { homedir as homedir3 } from "os";
|
|
2024
|
-
import { join as
|
|
2135
|
+
import { join as join15, resolve as resolve5 } from "path";
|
|
2025
2136
|
function isFile(p) {
|
|
2026
2137
|
try {
|
|
2027
2138
|
return statSync7(p).isFile();
|
|
@@ -2035,7 +2146,7 @@ function projectPath(root, cwd) {
|
|
|
2035
2146
|
function globalPath(root, home) {
|
|
2036
2147
|
if (!root) return void 0;
|
|
2037
2148
|
if (root === "~") return home;
|
|
2038
|
-
if (root.startsWith("~/")) return
|
|
2149
|
+
if (root.startsWith("~/")) return join15(home, root.slice(2));
|
|
2039
2150
|
return root;
|
|
2040
2151
|
}
|
|
2041
2152
|
function makeAgent(spec) {
|
|
@@ -2255,6 +2366,845 @@ var AGENTS = [
|
|
|
2255
2366
|
{ id: "adal", displayName: "AdaL", projectRoot: ".adal/skills", globalRoot: "~/.adal/skills" }
|
|
2256
2367
|
].map(makeAgent);
|
|
2257
2368
|
|
|
2369
|
+
// src/commands/audit.ts
|
|
2370
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
|
|
2371
|
+
import { dirname as dirname6, join as join17, resolve as resolvePath3 } from "path";
|
|
2372
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
2373
|
+
|
|
2374
|
+
// src/audit.ts
|
|
2375
|
+
import {
|
|
2376
|
+
existsSync as existsSync13,
|
|
2377
|
+
lstatSync,
|
|
2378
|
+
mkdtempSync as mkdtempSync2,
|
|
2379
|
+
readFileSync as readFileSync10,
|
|
2380
|
+
readdirSync as readdirSync6,
|
|
2381
|
+
realpathSync,
|
|
2382
|
+
renameSync as renameSync2,
|
|
2383
|
+
rmSync as rmSync3,
|
|
2384
|
+
statSync as statSync8,
|
|
2385
|
+
writeFileSync as writeFileSync5
|
|
2386
|
+
} from "fs";
|
|
2387
|
+
import { basename as basename6, extname as extname5, join as join16, resolve as resolvePath2 } from "path";
|
|
2388
|
+
var OVERSIZED_SKILL_EST_TOKENS = 5e3;
|
|
2389
|
+
function toAuditFindings(source) {
|
|
2390
|
+
return source.map((f) => ({ severity: f.severity, rule: f.rule, target: f.file, detail: f.detail }));
|
|
2391
|
+
}
|
|
2392
|
+
function resolveConfiguredRoot(root) {
|
|
2393
|
+
return resolvePath2(expandHome(root));
|
|
2394
|
+
}
|
|
2395
|
+
function safeStat(p) {
|
|
2396
|
+
try {
|
|
2397
|
+
return statSync8(p);
|
|
2398
|
+
} catch {
|
|
2399
|
+
return void 0;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
function isDirFollowingSymlink(p) {
|
|
2403
|
+
return safeStat(p)?.isDirectory() ?? false;
|
|
2404
|
+
}
|
|
2405
|
+
function listSubdirsFollowingSymlinks(dir) {
|
|
2406
|
+
let entries;
|
|
2407
|
+
try {
|
|
2408
|
+
entries = readdirSync6(dir, { withFileTypes: true });
|
|
2409
|
+
} catch {
|
|
2410
|
+
return [];
|
|
2411
|
+
}
|
|
2412
|
+
const out = [];
|
|
2413
|
+
for (const entry of entries) {
|
|
2414
|
+
const full = join16(dir, entry.name);
|
|
2415
|
+
if (entry.isDirectory() || entry.isSymbolicLink() && isDirFollowingSymlink(full)) {
|
|
2416
|
+
out.push(full);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
return out;
|
|
2420
|
+
}
|
|
2421
|
+
function listSubdirsNoSymlinks(dir) {
|
|
2422
|
+
let entries;
|
|
2423
|
+
try {
|
|
2424
|
+
entries = readdirSync6(dir, { withFileTypes: true });
|
|
2425
|
+
} catch {
|
|
2426
|
+
return [];
|
|
2427
|
+
}
|
|
2428
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join16(dir, e.name));
|
|
2429
|
+
}
|
|
2430
|
+
function findSkillDirsUnderRoot(root) {
|
|
2431
|
+
const found = /* @__PURE__ */ new Set();
|
|
2432
|
+
const rootSubdirs = listSubdirsFollowingSymlinks(root);
|
|
2433
|
+
for (const dir of rootSubdirs) {
|
|
2434
|
+
if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
|
|
2435
|
+
}
|
|
2436
|
+
for (const dir of listSubdirsFollowingSymlinks(join16(root, "skills"))) {
|
|
2437
|
+
if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
|
|
2438
|
+
}
|
|
2439
|
+
for (const topDir of rootSubdirs) {
|
|
2440
|
+
for (const dir of listSubdirsNoSymlinks(join16(topDir, "skills"))) {
|
|
2441
|
+
if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
return Array.from(found).filter((p) => !p.includes("/.git/"));
|
|
2445
|
+
}
|
|
2446
|
+
function collectSkillCandidates(roots) {
|
|
2447
|
+
const candidates = [];
|
|
2448
|
+
const rootMeta = /* @__PURE__ */ new Map();
|
|
2449
|
+
for (const root of roots) {
|
|
2450
|
+
const resolvedRoot = resolveConfiguredRoot(root);
|
|
2451
|
+
const exists = isDir(resolvedRoot);
|
|
2452
|
+
let realPath;
|
|
2453
|
+
if (exists) {
|
|
2454
|
+
try {
|
|
2455
|
+
realPath = realpathSync(resolvedRoot);
|
|
2456
|
+
} catch {
|
|
2457
|
+
realPath = void 0;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
rootMeta.set(root, { exists, realPath });
|
|
2461
|
+
if (!exists) continue;
|
|
2462
|
+
for (const dir of findSkillDirsUnderRoot(resolvedRoot)) {
|
|
2463
|
+
candidates.push({ configuredPath: dir, root });
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
return { candidates, rootMeta };
|
|
2467
|
+
}
|
|
2468
|
+
function dedupeSkillCandidates(candidates, findings) {
|
|
2469
|
+
const byRealPath = /* @__PURE__ */ new Map();
|
|
2470
|
+
const order = [];
|
|
2471
|
+
for (const candidate of candidates) {
|
|
2472
|
+
let realPath;
|
|
2473
|
+
try {
|
|
2474
|
+
realPath = realpathSync(candidate.configuredPath);
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
findings.push({
|
|
2477
|
+
severity: "MEDIUM",
|
|
2478
|
+
rule: "unreadable-skill",
|
|
2479
|
+
target: candidate.configuredPath,
|
|
2480
|
+
detail: `could not resolve skill directory (broken symlink or cycle): ${err.message}`
|
|
2481
|
+
});
|
|
2482
|
+
continue;
|
|
2483
|
+
}
|
|
2484
|
+
const existing = byRealPath.get(realPath);
|
|
2485
|
+
if (existing) {
|
|
2486
|
+
if (existing.canonicalPath !== candidate.configuredPath && !existing.aliasPaths.includes(candidate.configuredPath)) {
|
|
2487
|
+
existing.aliasPaths.push(candidate.configuredPath);
|
|
2488
|
+
}
|
|
2489
|
+
continue;
|
|
2490
|
+
}
|
|
2491
|
+
byRealPath.set(realPath, { realPath, canonicalPath: candidate.configuredPath, root: candidate.root, aliasPaths: [] });
|
|
2492
|
+
order.push(realPath);
|
|
2493
|
+
}
|
|
2494
|
+
return order.map((realPath) => byRealPath.get(realPath));
|
|
2495
|
+
}
|
|
2496
|
+
function parseStrictFrontmatter(text) {
|
|
2497
|
+
if (!text.startsWith("---\n")) {
|
|
2498
|
+
return { valid: false, errors: ['missing frontmatter fence (SKILL.md must start with "---")'] };
|
|
2499
|
+
}
|
|
2500
|
+
const afterOpen = text.slice(4);
|
|
2501
|
+
const closeIdx = afterOpen.indexOf("\n---");
|
|
2502
|
+
if (closeIdx === -1) {
|
|
2503
|
+
return { valid: false, errors: ['unclosed frontmatter fence (no closing "---" found)'] };
|
|
2504
|
+
}
|
|
2505
|
+
const block = afterOpen.slice(0, closeIdx);
|
|
2506
|
+
const errors = [];
|
|
2507
|
+
const nameMatch = block.match(/^name:\s*(.+)$/m);
|
|
2508
|
+
const name = nameMatch ? stripQuotes(nameMatch[1].trim()) : void 0;
|
|
2509
|
+
if (!name) errors.push('missing required "name" field in frontmatter');
|
|
2510
|
+
const descMatch = block.match(/^description:\s*(.*)$/m);
|
|
2511
|
+
let description;
|
|
2512
|
+
if (descMatch) {
|
|
2513
|
+
const first = descMatch[1].trim();
|
|
2514
|
+
if ([">", ">-", "|", "|-", ""].includes(first)) {
|
|
2515
|
+
const lines = block.split(/\r\n|\r|\n/);
|
|
2516
|
+
const idx = lines.findIndex((l) => /^description:/.test(l));
|
|
2517
|
+
const buf = [];
|
|
2518
|
+
if (idx !== -1) {
|
|
2519
|
+
for (const l of lines.slice(idx + 1)) {
|
|
2520
|
+
if (/^\s+\S/.test(l)) buf.push(l.trim());
|
|
2521
|
+
else if (l.trim() === "") continue;
|
|
2522
|
+
else break;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
description = buf.join(" ").trim() || void 0;
|
|
2526
|
+
} else {
|
|
2527
|
+
description = stripQuotes(first);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
if (!description) errors.push('missing required "description" field in frontmatter');
|
|
2531
|
+
return { valid: errors.length === 0, name, description, errors };
|
|
2532
|
+
}
|
|
2533
|
+
function estimateTokens(text) {
|
|
2534
|
+
return Math.ceil(Buffer.byteLength(text, "utf-8") / 4);
|
|
2535
|
+
}
|
|
2536
|
+
function profileAuditedSkill(group, findings, unreadableSkillMdPaths) {
|
|
2537
|
+
const skillMdPath = join16(group.canonicalPath, "SKILL.md");
|
|
2538
|
+
const base = {
|
|
2539
|
+
path: group.canonicalPath,
|
|
2540
|
+
skillMdPath,
|
|
2541
|
+
root: group.root,
|
|
2542
|
+
aliasPaths: group.aliasPaths
|
|
2543
|
+
};
|
|
2544
|
+
let text;
|
|
2545
|
+
let sizeBytes;
|
|
2546
|
+
try {
|
|
2547
|
+
sizeBytes = statSync8(skillMdPath).size;
|
|
2548
|
+
text = readFileSync10(skillMdPath, "utf-8");
|
|
2549
|
+
} catch (err) {
|
|
2550
|
+
findings.push({
|
|
2551
|
+
severity: "MEDIUM",
|
|
2552
|
+
rule: "unreadable-skill",
|
|
2553
|
+
target: skillMdPath,
|
|
2554
|
+
detail: `could not read SKILL.md: ${err.message}`
|
|
2555
|
+
});
|
|
2556
|
+
unreadableSkillMdPaths.add(skillMdPath);
|
|
2557
|
+
return { ...base, name: basename6(group.canonicalPath), validFrontmatter: false, sizeBytes: 0, estTokens: 0 };
|
|
2558
|
+
}
|
|
2559
|
+
const fm = parseStrictFrontmatter(text);
|
|
2560
|
+
if (!fm.valid) {
|
|
2561
|
+
for (const error of fm.errors) {
|
|
2562
|
+
findings.push({ severity: "MEDIUM", rule: "malformed-frontmatter", target: skillMdPath, detail: error });
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
return {
|
|
2566
|
+
...base,
|
|
2567
|
+
name: fm.name ?? basename6(group.canonicalPath),
|
|
2568
|
+
validFrontmatter: fm.valid,
|
|
2569
|
+
description: fm.description,
|
|
2570
|
+
sizeBytes,
|
|
2571
|
+
estTokens: estimateTokens(text)
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
function hasRecognizedMcpShape(parsed) {
|
|
2575
|
+
if (typeof parsed !== "object" || parsed === null) return false;
|
|
2576
|
+
const obj = parsed;
|
|
2577
|
+
const serversRaw = obj.mcpServers ?? obj.servers;
|
|
2578
|
+
return typeof serversRaw === "object" && serversRaw !== null;
|
|
2579
|
+
}
|
|
2580
|
+
function enumerateMcpServers(parsed) {
|
|
2581
|
+
if (!hasRecognizedMcpShape(parsed)) return [];
|
|
2582
|
+
const obj = parsed;
|
|
2583
|
+
const serversRaw = obj.mcpServers ?? obj.servers;
|
|
2584
|
+
const out = [];
|
|
2585
|
+
for (const [name, raw] of Object.entries(serversRaw)) {
|
|
2586
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
2587
|
+
const entry = raw;
|
|
2588
|
+
const command = typeof entry.command === "string" ? entry.command : void 0;
|
|
2589
|
+
const args = Array.isArray(entry.args) ? entry.args.filter((a) => typeof a === "string") : [];
|
|
2590
|
+
const env = entry.env && typeof entry.env === "object" && !Array.isArray(entry.env) ? entry.env : {};
|
|
2591
|
+
out.push({
|
|
2592
|
+
name,
|
|
2593
|
+
commandBasename: command ? basename6(command) : void 0,
|
|
2594
|
+
packageSpec: command && isNpxCommand(command) ? collectNpxPackageSpecs(args).positionalSpec : void 0,
|
|
2595
|
+
argCount: args.length,
|
|
2596
|
+
envCount: Object.keys(env).length
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
return out;
|
|
2600
|
+
}
|
|
2601
|
+
function auditMcpTarget(targetPath, notices) {
|
|
2602
|
+
const findings = [];
|
|
2603
|
+
const isToml = extname5(targetPath).toLowerCase() === ".toml";
|
|
2604
|
+
const format = isToml ? "toml" : "json";
|
|
2605
|
+
if (!existsSync13(targetPath)) {
|
|
2606
|
+
findings.push({
|
|
2607
|
+
severity: "LOW",
|
|
2608
|
+
rule: "missing-mcp-target",
|
|
2609
|
+
target: targetPath,
|
|
2610
|
+
detail: "configured MCP target file does not exist"
|
|
2611
|
+
});
|
|
2612
|
+
return { target: { path: targetPath, format, status: "missing", servers: [] }, findings };
|
|
2613
|
+
}
|
|
2614
|
+
try {
|
|
2615
|
+
findings.push(...toAuditFindings(scanMcpFindings(targetPath)));
|
|
2616
|
+
} catch (err) {
|
|
2617
|
+
findings.push({
|
|
2618
|
+
severity: "MEDIUM",
|
|
2619
|
+
rule: "unreadable-mcp-target",
|
|
2620
|
+
target: targetPath,
|
|
2621
|
+
detail: `MCP safety scan failed: ${err.message}`
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
if (isToml) {
|
|
2625
|
+
try {
|
|
2626
|
+
readFileSync10(targetPath, "utf-8");
|
|
2627
|
+
} catch (err) {
|
|
2628
|
+
findings.push({
|
|
2629
|
+
severity: "MEDIUM",
|
|
2630
|
+
rule: "unreadable-mcp-target",
|
|
2631
|
+
target: targetPath,
|
|
2632
|
+
detail: `could not read: ${err.message}`
|
|
2633
|
+
});
|
|
2634
|
+
return { target: { path: targetPath, format, status: "unreadable", servers: [] }, findings };
|
|
2635
|
+
}
|
|
2636
|
+
notices.push(`MCP target ${targetPath}: TOML format \u2014 structured server enumeration unsupported (textual safety scan only)`);
|
|
2637
|
+
return { target: { path: targetPath, format, status: "clean", servers: [] }, findings };
|
|
2638
|
+
}
|
|
2639
|
+
let raw;
|
|
2640
|
+
try {
|
|
2641
|
+
raw = readFileSync10(targetPath, "utf-8");
|
|
2642
|
+
} catch (err) {
|
|
2643
|
+
findings.push({
|
|
2644
|
+
severity: "MEDIUM",
|
|
2645
|
+
rule: "unreadable-mcp-target",
|
|
2646
|
+
target: targetPath,
|
|
2647
|
+
detail: `could not read: ${err.message}`
|
|
2648
|
+
});
|
|
2649
|
+
return { target: { path: targetPath, format, status: "unreadable", servers: [] }, findings };
|
|
2650
|
+
}
|
|
2651
|
+
let parsed;
|
|
2652
|
+
try {
|
|
2653
|
+
parsed = JSON.parse(raw);
|
|
2654
|
+
} catch (err) {
|
|
2655
|
+
findings.push({
|
|
2656
|
+
severity: "MEDIUM",
|
|
2657
|
+
rule: "malformed-mcp-target",
|
|
2658
|
+
target: targetPath,
|
|
2659
|
+
detail: `invalid JSON: ${err.message}`
|
|
2660
|
+
});
|
|
2661
|
+
return { target: { path: targetPath, format, status: "malformed", servers: [] }, findings };
|
|
2662
|
+
}
|
|
2663
|
+
if (!hasRecognizedMcpShape(parsed)) {
|
|
2664
|
+
findings.push({
|
|
2665
|
+
severity: "LOW",
|
|
2666
|
+
rule: "unrecognized-mcp-target-shape",
|
|
2667
|
+
target: targetPath,
|
|
2668
|
+
detail: 'valid JSON but no "mcpServers" or "servers" object was found \u2014 reporting 0 servers'
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
return { target: { path: targetPath, format, status: "clean", servers: enumerateMcpServers(parsed) }, findings };
|
|
2672
|
+
}
|
|
2673
|
+
function clusterOverlapPairs(pairs) {
|
|
2674
|
+
const parent = /* @__PURE__ */ new Map();
|
|
2675
|
+
function find(x) {
|
|
2676
|
+
if (!parent.has(x)) parent.set(x, x);
|
|
2677
|
+
let root = x;
|
|
2678
|
+
while (parent.get(root) !== root) root = parent.get(root);
|
|
2679
|
+
let cur = x;
|
|
2680
|
+
while (parent.get(cur) !== root) {
|
|
2681
|
+
const next = parent.get(cur);
|
|
2682
|
+
parent.set(cur, root);
|
|
2683
|
+
cur = next;
|
|
2684
|
+
}
|
|
2685
|
+
return root;
|
|
2686
|
+
}
|
|
2687
|
+
function union(a, b) {
|
|
2688
|
+
const ra = find(a);
|
|
2689
|
+
const rb = find(b);
|
|
2690
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
2691
|
+
}
|
|
2692
|
+
for (const pair of pairs) union(pair.aPath, pair.bPath);
|
|
2693
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2694
|
+
for (const pair of pairs) {
|
|
2695
|
+
const root = find(pair.aPath);
|
|
2696
|
+
const group = groups.get(root) ?? { members: /* @__PURE__ */ new Set(), topScore: 0 };
|
|
2697
|
+
group.members.add(pair.aPath);
|
|
2698
|
+
group.members.add(pair.bPath);
|
|
2699
|
+
group.topScore = Math.max(group.topScore, pair.score);
|
|
2700
|
+
groups.set(root, group);
|
|
2701
|
+
}
|
|
2702
|
+
return Array.from(groups.values()).map((g) => ({ members: toClusterMembers(g.members), topScore: g.topScore, suggestedVerb: suggestVerbCode(g.topScore) })).sort((a, b) => b.topScore - a.topScore || a.members[0].localeCompare(b.members[0]));
|
|
2703
|
+
}
|
|
2704
|
+
function toClusterMembers(members) {
|
|
2705
|
+
const sorted = Array.from(members).sort();
|
|
2706
|
+
if (sorted.length < 2) {
|
|
2707
|
+
throw new Error(`internal error: overlap cluster has fewer than 2 members (${sorted.length})`);
|
|
2708
|
+
}
|
|
2709
|
+
return sorted;
|
|
2710
|
+
}
|
|
2711
|
+
async function auditConfiguredSet(config, options = {}) {
|
|
2712
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
2713
|
+
const oversizedThreshold = options.oversizedSkillTokens ?? OVERSIZED_SKILL_EST_TOKENS;
|
|
2714
|
+
const findings = [];
|
|
2715
|
+
const notices = [];
|
|
2716
|
+
const { candidates, rootMeta } = collectSkillCandidates(config.skillsRoots);
|
|
2717
|
+
const groups = dedupeSkillCandidates(candidates, findings);
|
|
2718
|
+
const unreadableSkillMdPaths = /* @__PURE__ */ new Set();
|
|
2719
|
+
const skills = groups.map((g) => profileAuditedSkill(g, findings, unreadableSkillMdPaths));
|
|
2720
|
+
const skillRoots = config.skillsRoots.map((root) => {
|
|
2721
|
+
const meta = rootMeta.get(root) ?? { exists: false };
|
|
2722
|
+
if (!meta.exists) {
|
|
2723
|
+
findings.push({ severity: "MEDIUM", rule: "missing-skills-root", target: root, detail: "configured skills root does not exist on disk" });
|
|
2724
|
+
} else if (!meta.realPath) {
|
|
2725
|
+
findings.push({
|
|
2726
|
+
severity: "MEDIUM",
|
|
2727
|
+
rule: "unreadable-skills-root",
|
|
2728
|
+
target: root,
|
|
2729
|
+
detail: "could not resolve skills root to a real path (broken symlink or cycle)"
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
return {
|
|
2733
|
+
root,
|
|
2734
|
+
realPath: meta.realPath ?? resolveConfiguredRoot(root),
|
|
2735
|
+
exists: meta.exists,
|
|
2736
|
+
skillCount: skills.filter((s) => s.root === root).length
|
|
2737
|
+
};
|
|
2738
|
+
});
|
|
2739
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2740
|
+
for (const skill of skills) {
|
|
2741
|
+
const arr = byName.get(skill.name) ?? [];
|
|
2742
|
+
arr.push(skill);
|
|
2743
|
+
byName.set(skill.name, arr);
|
|
2744
|
+
}
|
|
2745
|
+
for (const [name, group] of byName) {
|
|
2746
|
+
if (group.length > 1) {
|
|
2747
|
+
for (const skill of group) {
|
|
2748
|
+
const otherPaths = group.filter((g) => g.path !== skill.path).map((g) => g.path);
|
|
2749
|
+
findings.push({
|
|
2750
|
+
severity: "MEDIUM",
|
|
2751
|
+
rule: "duplicate-skill-name",
|
|
2752
|
+
target: skill.path,
|
|
2753
|
+
detail: `name "${name}" is duplicated \u2014 also found at ${otherPaths.join(", ")}`
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
for (const skill of skills) {
|
|
2759
|
+
if (skill.estTokens > oversizedThreshold) {
|
|
2760
|
+
findings.push({
|
|
2761
|
+
severity: "LOW",
|
|
2762
|
+
rule: "oversized-skill",
|
|
2763
|
+
target: skill.skillMdPath,
|
|
2764
|
+
detail: `est. ${skill.estTokens} tokens exceeds the ${oversizedThreshold}-token threshold \u2014 consider trimming or splitting`
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
const safetyVerdictBySkillPath = /* @__PURE__ */ new Map();
|
|
2769
|
+
for (const skill of skills) {
|
|
2770
|
+
try {
|
|
2771
|
+
const result = await scanSafety(skill.path, config.strictness);
|
|
2772
|
+
findings.push(...toAuditFindings(result.findings));
|
|
2773
|
+
safetyVerdictBySkillPath.set(skill.path, result.verdict);
|
|
2774
|
+
} catch (err) {
|
|
2775
|
+
findings.push({
|
|
2776
|
+
severity: "MEDIUM",
|
|
2777
|
+
rule: "unreadable-skill",
|
|
2778
|
+
target: skill.path,
|
|
2779
|
+
detail: `safety scan failed: ${err.message}`
|
|
2780
|
+
});
|
|
2781
|
+
safetyVerdictBySkillPath.set(skill.path, "block");
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
const mcpTargetPaths = config.mcpTargets ?? [];
|
|
2785
|
+
const mcpTargets = [];
|
|
2786
|
+
for (const targetPath of mcpTargetPaths) {
|
|
2787
|
+
const { target, findings: targetFindings } = auditMcpTarget(targetPath, notices);
|
|
2788
|
+
mcpTargets.push(target);
|
|
2789
|
+
findings.push(...targetFindings);
|
|
2790
|
+
}
|
|
2791
|
+
const overlapStatus = proFeatureStatus("installed-set overlap analysis");
|
|
2792
|
+
let overlapClusters = [];
|
|
2793
|
+
if (overlapStatus.enabled) {
|
|
2794
|
+
if (overlapStatus.notice) notices.push(overlapStatus.notice);
|
|
2795
|
+
try {
|
|
2796
|
+
const readableSkillMdPaths = skills.map((s) => s.skillMdPath).filter((p) => !unreadableSkillMdPaths.has(p));
|
|
2797
|
+
const pairs = scanOverlapPaths(readableSkillMdPaths, options.overlapThreshold);
|
|
2798
|
+
overlapClusters = clusterOverlapPairs(pairs);
|
|
2799
|
+
} catch (err) {
|
|
2800
|
+
findings.push({
|
|
2801
|
+
severity: "MEDIUM",
|
|
2802
|
+
rule: "overlap-scan-failed",
|
|
2803
|
+
target: "installed-set overlap analysis",
|
|
2804
|
+
detail: `cross-root overlap scan failed: ${err.message}`
|
|
2805
|
+
});
|
|
2806
|
+
notices.push(`cross-root overlap scan failed \u2014 overlap clusters unavailable this run: ${err.message}`);
|
|
2807
|
+
overlapClusters = [];
|
|
2808
|
+
}
|
|
2809
|
+
} else {
|
|
2810
|
+
notices.push(overlapStatus.notice ?? "Pro feature (installed-set overlap analysis) locked.");
|
|
2811
|
+
}
|
|
2812
|
+
const evolveEligible = skills.filter((s) => s.validFrontmatter && safetyVerdictBySkillPath.get(s.path) === "pass").map((s) => ({ name: s.name, path: s.path }));
|
|
2813
|
+
const foundation = config.foundationSkillPath ? {
|
|
2814
|
+
available: false,
|
|
2815
|
+
existingPath: config.foundationSkillPath,
|
|
2816
|
+
message: `a business-foundation skill already exists at ${config.foundationSkillPath}`
|
|
2817
|
+
} : {
|
|
2818
|
+
available: true,
|
|
2819
|
+
message: "no business-foundation skill yet \u2014 `skill-forge audit --foundation` can scaffold one to ground future skills in your business profile"
|
|
2820
|
+
};
|
|
2821
|
+
const findingCounts = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0 };
|
|
2822
|
+
for (const f of findings) findingCounts[f.severity]++;
|
|
2823
|
+
const summary = {
|
|
2824
|
+
rootCount: skillRoots.length,
|
|
2825
|
+
skillCount: skills.length,
|
|
2826
|
+
mcpTargetCount: mcpTargets.length,
|
|
2827
|
+
findingCounts,
|
|
2828
|
+
overlapClusterCount: overlapClusters.length,
|
|
2829
|
+
evolveEligibleCount: evolveEligible.length
|
|
2830
|
+
};
|
|
2831
|
+
return {
|
|
2832
|
+
schemaVersion: 1,
|
|
2833
|
+
generatedAt: now().toISOString(),
|
|
2834
|
+
inventory: { skillRoots, skills, mcpTargets },
|
|
2835
|
+
hygiene: findings,
|
|
2836
|
+
opportunities: { overlapClusters, evolveEligible, foundation, overlapLocked: !overlapStatus.enabled },
|
|
2837
|
+
notices,
|
|
2838
|
+
summary
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
function bulletList(items, emptyPlaceholder) {
|
|
2842
|
+
return items.length > 0 ? items.map((i) => `- ${i}`) : [`- ${emptyPlaceholder}`];
|
|
2843
|
+
}
|
|
2844
|
+
function sanitizeForMarkdown(s) {
|
|
2845
|
+
return s.replace(/[\x00-\x1F\x7F-\x9F]/g, "");
|
|
2846
|
+
}
|
|
2847
|
+
function joinDefined(parts) {
|
|
2848
|
+
return parts.filter((v) => Boolean(v)).join(", ");
|
|
2849
|
+
}
|
|
2850
|
+
function renderRootsSection(roots) {
|
|
2851
|
+
return [
|
|
2852
|
+
"### Skill roots",
|
|
2853
|
+
"",
|
|
2854
|
+
...bulletList(
|
|
2855
|
+
roots.map((r) => `${r.root}${r.exists ? "" : " (MISSING)"} \u2014 ${r.skillCount} skill${r.skillCount === 1 ? "" : "s"}`),
|
|
2856
|
+
"(none configured)"
|
|
2857
|
+
)
|
|
2858
|
+
];
|
|
2859
|
+
}
|
|
2860
|
+
function renderSkillsSection(skills) {
|
|
2861
|
+
return [
|
|
2862
|
+
"### Skills",
|
|
2863
|
+
"",
|
|
2864
|
+
...bulletList(
|
|
2865
|
+
skills.map((s) => {
|
|
2866
|
+
const flags = joinDefined([
|
|
2867
|
+
s.validFrontmatter ? void 0 : "INVALID FRONTMATTER",
|
|
2868
|
+
s.aliasPaths.length > 0 ? `${s.aliasPaths.length} alias path(s)` : void 0
|
|
2869
|
+
]);
|
|
2870
|
+
return `${sanitizeForMarkdown(s.name)} \u2014 ${s.path} (est. ${s.estTokens} tokens)${flags ? ` [${flags}]` : ""}`;
|
|
2871
|
+
}),
|
|
2872
|
+
"(none found)"
|
|
2873
|
+
)
|
|
2874
|
+
];
|
|
2875
|
+
}
|
|
2876
|
+
function renderMcpTargetsSection(targets) {
|
|
2877
|
+
const lines = ["### MCP targets", ""];
|
|
2878
|
+
if (targets.length === 0) {
|
|
2879
|
+
lines.push("- (none configured)");
|
|
2880
|
+
return lines;
|
|
2881
|
+
}
|
|
2882
|
+
for (const t of targets) {
|
|
2883
|
+
lines.push(`- ${t.path} \u2014 ${t.format}, ${t.status}`);
|
|
2884
|
+
for (const server of t.servers) {
|
|
2885
|
+
const detail = joinDefined([
|
|
2886
|
+
server.commandBasename ? sanitizeForMarkdown(server.commandBasename) : void 0,
|
|
2887
|
+
server.packageSpec ? sanitizeForMarkdown(server.packageSpec) : void 0,
|
|
2888
|
+
`${server.argCount} arg${server.argCount === 1 ? "" : "s"}`,
|
|
2889
|
+
`${server.envCount} env var${server.envCount === 1 ? "" : "s"}`
|
|
2890
|
+
]);
|
|
2891
|
+
lines.push(` - ${sanitizeForMarkdown(server.name)} (${detail})`);
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
return lines;
|
|
2895
|
+
}
|
|
2896
|
+
function renderFindingsSection(findings) {
|
|
2897
|
+
return [
|
|
2898
|
+
"## Hygiene findings",
|
|
2899
|
+
"",
|
|
2900
|
+
...bulletList(
|
|
2901
|
+
findings.map((f) => `[${f.severity}] ${f.rule} \u2014 ${sanitizeForMarkdown(f.target)}: ${sanitizeForMarkdown(f.detail)}`),
|
|
2902
|
+
"(none \u2014 clean)"
|
|
2903
|
+
)
|
|
2904
|
+
];
|
|
2905
|
+
}
|
|
2906
|
+
function renderOpportunitiesSection(report) {
|
|
2907
|
+
const { overlapClusters, evolveEligible, foundation } = report.opportunities;
|
|
2908
|
+
return [
|
|
2909
|
+
"## Opportunities",
|
|
2910
|
+
"",
|
|
2911
|
+
"### Overlap clusters",
|
|
2912
|
+
"",
|
|
2913
|
+
...bulletList(
|
|
2914
|
+
overlapClusters.map((c) => `top score ${c.topScore} \u2014 ${c.suggestedVerb} \u2014 ${c.members.join(", ")}`),
|
|
2915
|
+
"(none \u2014 either no cross-root overlap found, or the Pro feature is locked, see Notices)"
|
|
2916
|
+
),
|
|
2917
|
+
"",
|
|
2918
|
+
"### Evolve-eligible",
|
|
2919
|
+
"",
|
|
2920
|
+
...bulletList(
|
|
2921
|
+
evolveEligible.map((c) => `${sanitizeForMarkdown(c.name)} \u2014 ${c.path}`),
|
|
2922
|
+
"(none)"
|
|
2923
|
+
),
|
|
2924
|
+
"",
|
|
2925
|
+
"### Foundation skill",
|
|
2926
|
+
"",
|
|
2927
|
+
`- ${foundation.message}`
|
|
2928
|
+
];
|
|
2929
|
+
}
|
|
2930
|
+
function renderAuditMarkdown(report) {
|
|
2931
|
+
const { summary } = report;
|
|
2932
|
+
const lines = [
|
|
2933
|
+
`# skill-forge audit \u2014 ${report.generatedAt}`,
|
|
2934
|
+
"",
|
|
2935
|
+
"## Summary",
|
|
2936
|
+
"",
|
|
2937
|
+
`- Skill roots: ${summary.rootCount}`,
|
|
2938
|
+
`- Skills: ${summary.skillCount}`,
|
|
2939
|
+
`- MCP targets: ${summary.mcpTargetCount}`,
|
|
2940
|
+
`- Findings: ${summary.findingCounts.CRITICAL} CRITICAL, ${summary.findingCounts.HIGH} HIGH, ${summary.findingCounts.MEDIUM} MEDIUM, ${summary.findingCounts.LOW} LOW`,
|
|
2941
|
+
`- Overlap clusters: ${summary.overlapClusterCount}`,
|
|
2942
|
+
`- Evolve-eligible skills: ${summary.evolveEligibleCount}`,
|
|
2943
|
+
"",
|
|
2944
|
+
"## Inventory",
|
|
2945
|
+
"",
|
|
2946
|
+
...renderRootsSection(report.inventory.skillRoots),
|
|
2947
|
+
"",
|
|
2948
|
+
...renderSkillsSection(report.inventory.skills),
|
|
2949
|
+
"",
|
|
2950
|
+
...renderMcpTargetsSection(report.inventory.mcpTargets),
|
|
2951
|
+
"",
|
|
2952
|
+
...renderFindingsSection(report.hygiene),
|
|
2953
|
+
"",
|
|
2954
|
+
...renderOpportunitiesSection(report),
|
|
2955
|
+
"",
|
|
2956
|
+
"## Notices",
|
|
2957
|
+
"",
|
|
2958
|
+
...bulletList(report.notices, "(none)")
|
|
2959
|
+
];
|
|
2960
|
+
return lines.join("\n") + "\n";
|
|
2961
|
+
}
|
|
2962
|
+
function renderFoundationSkillMd(profile) {
|
|
2963
|
+
const description = profile.businessName ? `Business-foundation context for ${profile.businessName} \u2014 grounds new and existing skills in its audiences, workflows, and constraints. Generated by skill-forge audit.` : "Business-foundation context for this business \u2014 grounds new and existing skills in its audiences, workflows, and constraints. Generated by skill-forge audit.";
|
|
2964
|
+
const lines = [
|
|
2965
|
+
"---",
|
|
2966
|
+
"name: business-foundation",
|
|
2967
|
+
`description: ${description}`,
|
|
2968
|
+
"---",
|
|
2969
|
+
"",
|
|
2970
|
+
`# Business Foundation${profile.businessName ? `: ${profile.businessName}` : ""}`,
|
|
2971
|
+
"",
|
|
2972
|
+
profile.industry ? `**Industry:** ${profile.industry}` : void 0,
|
|
2973
|
+
"",
|
|
2974
|
+
"## Audiences",
|
|
2975
|
+
"",
|
|
2976
|
+
...bulletList(profile.audiences, "(none captured yet)"),
|
|
2977
|
+
"",
|
|
2978
|
+
"## Workflows",
|
|
2979
|
+
"",
|
|
2980
|
+
...bulletList(profile.workflows, "(none captured yet)"),
|
|
2981
|
+
"",
|
|
2982
|
+
"## Constraints",
|
|
2983
|
+
"",
|
|
2984
|
+
...bulletList(profile.constraints, "(none captured yet)"),
|
|
2985
|
+
"",
|
|
2986
|
+
"## Integration guidance",
|
|
2987
|
+
"",
|
|
2988
|
+
"New and existing skills in this set should align with the business context above:",
|
|
2989
|
+
"- Match the audiences, terminology, and tone described here.",
|
|
2990
|
+
"- Respect the workflows and constraints listed above when proposing changes.",
|
|
2991
|
+
"- When curating or evolving skills (`skill-forge evolve`, `skill-forge add`), treat this file as ground truth for what the business actually needs \u2014 update it by re-running `skill-forge audit` rather than editing it out of band, so it stays a single source of truth."
|
|
2992
|
+
];
|
|
2993
|
+
return lines.filter((l) => l !== void 0).join("\n") + "\n";
|
|
2994
|
+
}
|
|
2995
|
+
async function scaffoldFoundationSkill(config, profile, targetRoot) {
|
|
2996
|
+
const resolvedRoot = resolveConfiguredRoot(targetRoot);
|
|
2997
|
+
const configuredRoots = config.skillsRoots.map(resolveConfiguredRoot);
|
|
2998
|
+
if (!configuredRoots.includes(resolvedRoot)) {
|
|
2999
|
+
throw new UserError(`refusing to scaffold a foundation skill under ${resolvedRoot}: not one of the configured skillsRoots`);
|
|
3000
|
+
}
|
|
3001
|
+
if (!isDir(resolvedRoot)) {
|
|
3002
|
+
throw new UserError(`skills root does not exist: ${resolvedRoot}`);
|
|
3003
|
+
}
|
|
3004
|
+
const destDir = join16(resolvedRoot, "business-foundation");
|
|
3005
|
+
let destLstat;
|
|
3006
|
+
try {
|
|
3007
|
+
destLstat = lstatSync(destDir);
|
|
3008
|
+
} catch {
|
|
3009
|
+
destLstat = void 0;
|
|
3010
|
+
}
|
|
3011
|
+
if (destLstat?.isSymbolicLink()) {
|
|
3012
|
+
throw new UserError(`refusing to scaffold a foundation skill at ${destDir}: destination is a symlink`);
|
|
3013
|
+
}
|
|
3014
|
+
if (destLstat) {
|
|
3015
|
+
throw new UserError(`refusing to scaffold a foundation skill at ${destDir}: already exists`);
|
|
3016
|
+
}
|
|
3017
|
+
const content = renderFoundationSkillMd(profile);
|
|
3018
|
+
const tempDir = mkdtempSync2(join16(resolvedRoot, ".business-foundation-"));
|
|
3019
|
+
try {
|
|
3020
|
+
writeFileSync5(join16(tempDir, "SKILL.md"), content, { flag: "wx", mode: 384 });
|
|
3021
|
+
const safety = await scanSafety(tempDir, config.strictness);
|
|
3022
|
+
if (safety.verdict === "block") {
|
|
3023
|
+
throw new UserError(
|
|
3024
|
+
`refusing to scaffold a foundation skill: the generated SKILL.md was flagged by the safety scan (${safety.findings.map(formatSafetyFinding).join("; ")})`
|
|
3025
|
+
);
|
|
3026
|
+
}
|
|
3027
|
+
renameSync2(tempDir, destDir);
|
|
3028
|
+
} catch (err) {
|
|
3029
|
+
rmSync3(tempDir, { recursive: true, force: true });
|
|
3030
|
+
throw err;
|
|
3031
|
+
}
|
|
3032
|
+
try {
|
|
3033
|
+
appendProvenance(
|
|
3034
|
+
{
|
|
3035
|
+
name: "business-foundation",
|
|
3036
|
+
source: "skill-forge audit (business profile)",
|
|
3037
|
+
verb: "DEFER",
|
|
3038
|
+
target: destDir,
|
|
3039
|
+
took: "scaffolded a new business-foundation skill from the captured business profile",
|
|
3040
|
+
notes: "scaffolded by skill-forge audit"
|
|
3041
|
+
},
|
|
3042
|
+
resolvedRoot
|
|
3043
|
+
);
|
|
3044
|
+
} catch (err) {
|
|
3045
|
+
throw new UserError(
|
|
3046
|
+
`the business-foundation skill was created at ${destDir}, but recording the provenance entry failed: ${err.message}`
|
|
3047
|
+
);
|
|
3048
|
+
}
|
|
3049
|
+
return destDir;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
// src/decision.ts
|
|
3053
|
+
import { createInterface } from "readline/promises";
|
|
3054
|
+
function logOutcome(message, json) {
|
|
3055
|
+
if (json) console.error(message);
|
|
3056
|
+
else console.log(message);
|
|
3057
|
+
}
|
|
3058
|
+
function yesDecision(verdict) {
|
|
3059
|
+
return verdict === "block" ? "reject" : "promote";
|
|
3060
|
+
}
|
|
3061
|
+
async function promptDecision(verdict, subject) {
|
|
3062
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3063
|
+
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
3064
|
+
const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
3065
|
+
try {
|
|
3066
|
+
process.stdout.write(prompt);
|
|
3067
|
+
for await (const raw of rl) {
|
|
3068
|
+
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
3069
|
+
if (answer === "p" || answer === "promote") return "promote";
|
|
3070
|
+
if (answer === "h" || answer === "hold") return "hold";
|
|
3071
|
+
if (answer === "r" || answer === "reject") return "reject";
|
|
3072
|
+
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
3073
|
+
${prompt}`);
|
|
3074
|
+
}
|
|
3075
|
+
return defaultChoice;
|
|
3076
|
+
} finally {
|
|
3077
|
+
rl.close();
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
// src/commands/audit.ts
|
|
3082
|
+
var CURATION_PROMPT_ASSET = "curation-prompt.md";
|
|
3083
|
+
function splitCsv(s) {
|
|
3084
|
+
return s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
3085
|
+
}
|
|
3086
|
+
async function askAnswer(rl, prompt) {
|
|
3087
|
+
return (await rl.question(prompt)).trim().toLowerCase();
|
|
3088
|
+
}
|
|
3089
|
+
async function captureBusinessProfile(rl, existing) {
|
|
3090
|
+
console.log("\n[skill-forge] Business profile \u2014 grounds the business-foundation skill scaffold and the curation handoff prompt.");
|
|
3091
|
+
console.log(
|
|
3092
|
+
"[skill-forge] Do not enter credentials, API keys, tokens, or confidential customer data \u2014 this is stored in plain text in config.json."
|
|
3093
|
+
);
|
|
3094
|
+
if (existing) {
|
|
3095
|
+
const keep = await askAnswer(rl, `Reuse the existing business profile (captured ${existing.updatedAt})? [Y/n] `);
|
|
3096
|
+
if (keep === "" || keep === "y" || keep === "yes") return existing;
|
|
3097
|
+
}
|
|
3098
|
+
const businessName = (await rl.question("Business name (optional, Enter to skip): ")).trim() || void 0;
|
|
3099
|
+
const industry = (await rl.question("Industry (optional, Enter to skip): ")).trim() || void 0;
|
|
3100
|
+
const audiences = splitCsv(await rl.question("Audiences (comma-separated, optional): "));
|
|
3101
|
+
const workflows = splitCsv(await rl.question("Workflows (comma-separated, optional): "));
|
|
3102
|
+
const constraints = splitCsv(await rl.question("Constraints (comma-separated, optional): "));
|
|
3103
|
+
const profile = { businessName, industry, audiences, workflows, constraints, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3104
|
+
console.log("\n[skill-forge] Business profile summary:");
|
|
3105
|
+
console.log(` Business name: ${profile.businessName ?? "(none)"}`);
|
|
3106
|
+
console.log(` Industry: ${profile.industry ?? "(none)"}`);
|
|
3107
|
+
console.log(` Audiences: ${profile.audiences.join(", ") || "(none)"}`);
|
|
3108
|
+
console.log(` Workflows: ${profile.workflows.join(", ") || "(none)"}`);
|
|
3109
|
+
console.log(` Constraints: ${profile.constraints.join(", ") || "(none)"}`);
|
|
3110
|
+
const confirm = await askAnswer(rl, "Save this business profile? [Y/n] ");
|
|
3111
|
+
if (confirm === "n" || confirm === "no") return existing;
|
|
3112
|
+
return profile;
|
|
3113
|
+
}
|
|
3114
|
+
function resolveReportPath(reportOption) {
|
|
3115
|
+
if (reportOption) return resolvePath3(reportOption);
|
|
3116
|
+
const iso = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3117
|
+
return join17(getHomeDir(), "reports", `audit-${iso}.md`);
|
|
3118
|
+
}
|
|
3119
|
+
function writeReport(reportPath, contents) {
|
|
3120
|
+
mkdirSync6(dirname6(reportPath), { recursive: true });
|
|
3121
|
+
try {
|
|
3122
|
+
writeFileSync6(reportPath, contents, { flag: "wx", mode: 384 });
|
|
3123
|
+
} catch (err) {
|
|
3124
|
+
if (err.code === "EEXIST") {
|
|
3125
|
+
throw new UserError(`report file already exists: ${reportPath} \u2014 pass a different --report path`);
|
|
3126
|
+
}
|
|
3127
|
+
throw err;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
function printTerminalSummary(report, reportPath) {
|
|
3131
|
+
const { summary } = report;
|
|
3132
|
+
console.log("[skill-forge] audit complete");
|
|
3133
|
+
console.log(` skill roots: ${summary.rootCount} skills: ${summary.skillCount} MCP targets: ${summary.mcpTargetCount}`);
|
|
3134
|
+
console.log(
|
|
3135
|
+
` findings: ${summary.findingCounts.CRITICAL} CRITICAL, ${summary.findingCounts.HIGH} HIGH, ${summary.findingCounts.MEDIUM} MEDIUM, ${summary.findingCounts.LOW} LOW`
|
|
3136
|
+
);
|
|
3137
|
+
console.log(` overlap clusters: ${summary.overlapClusterCount} evolve-eligible: ${summary.evolveEligibleCount}`);
|
|
3138
|
+
console.log(` report -> ${reportPath}`);
|
|
3139
|
+
for (const notice of report.notices) console.log(` [notice] ${notice}`);
|
|
3140
|
+
console.log(
|
|
3141
|
+
" next steps: `skill-forge add <source>` to install something new; `skill-forge evolve <skill-dir>` to refine an evolve-eligible skill."
|
|
3142
|
+
);
|
|
3143
|
+
}
|
|
3144
|
+
async function maybeScaffoldFoundation(config, report, options, rl) {
|
|
3145
|
+
let wantFoundation = options.foundation ?? false;
|
|
3146
|
+
if (!wantFoundation && rl && config.businessProfile && report.opportunities.foundation.available) {
|
|
3147
|
+
const answer = await askAnswer(rl, "\nCreate a business-foundation skill now? [y/N] ");
|
|
3148
|
+
wantFoundation = answer === "y" || answer === "yes";
|
|
3149
|
+
}
|
|
3150
|
+
if (!wantFoundation) return config;
|
|
3151
|
+
if (!config.businessProfile) {
|
|
3152
|
+
throw new UserError(
|
|
3153
|
+
"--foundation requires a business profile \u2014 run `skill-forge audit` interactively first to capture one."
|
|
3154
|
+
);
|
|
3155
|
+
}
|
|
3156
|
+
const targetRoot = config.defaultTarget ?? config.skillsRoots[0];
|
|
3157
|
+
if (!targetRoot) {
|
|
3158
|
+
throw new UserError("cannot scaffold a foundation skill: no skills root configured.");
|
|
3159
|
+
}
|
|
3160
|
+
const foundationPath = await scaffoldFoundationSkill(config, config.businessProfile, targetRoot);
|
|
3161
|
+
const updated = { ...config, foundationSkillPath: foundationPath };
|
|
3162
|
+
saveConfig(updated);
|
|
3163
|
+
logOutcome(`[skill-forge] scaffolded business-foundation skill -> ${foundationPath}`, options.json);
|
|
3164
|
+
return updated;
|
|
3165
|
+
}
|
|
3166
|
+
async function maybeHandoff(config, reportPath, options, rl) {
|
|
3167
|
+
let wantHandoff = options.handoff ?? false;
|
|
3168
|
+
if (!wantHandoff && rl) {
|
|
3169
|
+
const answer = await askAnswer(rl, "\nHand the curation report to a coding agent now? [y/N] ");
|
|
3170
|
+
wantHandoff = answer === "y" || answer === "yes";
|
|
3171
|
+
}
|
|
3172
|
+
if (!wantHandoff) return;
|
|
3173
|
+
runHandoff(config, reportPath, { promptAsset: CURATION_PROMPT_ASSET, log: options.json ? console.error : console.log });
|
|
3174
|
+
}
|
|
3175
|
+
async function auditCommand(options = {}) {
|
|
3176
|
+
await maybeOfferInit({ json: options.json, yes: options.yes, suppressAuditOffer: true });
|
|
3177
|
+
if (!existsSync14(getConfigPath())) {
|
|
3178
|
+
throw new UserError(
|
|
3179
|
+
"no skill-forge config found \u2014 run `skill-forge init` first (audit requires a configured skill/MCP set; it never audits an invented default)."
|
|
3180
|
+
);
|
|
3181
|
+
}
|
|
3182
|
+
let config = loadConfig();
|
|
3183
|
+
const interactive = !options.json && !options.yes && Boolean(process.stdin.isTTY);
|
|
3184
|
+
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : void 0;
|
|
3185
|
+
try {
|
|
3186
|
+
if (rl) {
|
|
3187
|
+
const profile = await captureBusinessProfile(rl, config.businessProfile);
|
|
3188
|
+
if (profile !== config.businessProfile) {
|
|
3189
|
+
config = { ...config, businessProfile: profile };
|
|
3190
|
+
saveConfig(config);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
const report = await auditConfiguredSet(config);
|
|
3194
|
+
const reportPath = resolveReportPath(options.report);
|
|
3195
|
+
writeReport(reportPath, renderAuditMarkdown(report));
|
|
3196
|
+
if (options.json) {
|
|
3197
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3198
|
+
} else {
|
|
3199
|
+
printTerminalSummary(report, reportPath);
|
|
3200
|
+
}
|
|
3201
|
+
config = await maybeScaffoldFoundation(config, report, options, rl);
|
|
3202
|
+
await maybeHandoff(config, reportPath, options, rl);
|
|
3203
|
+
} finally {
|
|
3204
|
+
rl?.close();
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
2258
3208
|
// src/commands/init.ts
|
|
2259
3209
|
function detectAgents(cwd, home) {
|
|
2260
3210
|
const found = [];
|
|
@@ -2376,7 +3326,8 @@ async function promptHandoffAgent(rl, detected) {
|
|
|
2376
3326
|
const idx = parseChoice(answer, detected.length);
|
|
2377
3327
|
return idx === void 0 ? void 0 : detected[idx].agent;
|
|
2378
3328
|
}
|
|
2379
|
-
async function init(options = {}) {
|
|
3329
|
+
async function init(options = {}, deps = {}) {
|
|
3330
|
+
const runAudit = deps.runAudit ?? auditCommand;
|
|
2380
3331
|
const cwd = options.cwd ?? process.cwd();
|
|
2381
3332
|
const home = options.home ?? homedir4();
|
|
2382
3333
|
const detected = detectAgents(cwd, home);
|
|
@@ -2395,6 +3346,7 @@ async function init(options = {}) {
|
|
|
2395
3346
|
const config = buildConfig(detected, detected[0], void 0, detectedMcp);
|
|
2396
3347
|
saveConfig(config);
|
|
2397
3348
|
printSummary(config);
|
|
3349
|
+
await runAudit({ yes: true });
|
|
2398
3350
|
return;
|
|
2399
3351
|
}
|
|
2400
3352
|
if (!process.stdin.isTTY) {
|
|
@@ -2403,7 +3355,8 @@ async function init(options = {}) {
|
|
|
2403
3355
|
}
|
|
2404
3356
|
printDetectionResults(detected);
|
|
2405
3357
|
printMcpDetectionResults(detectedMcp);
|
|
2406
|
-
const rl =
|
|
3358
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
3359
|
+
let runAuditNow = false;
|
|
2407
3360
|
try {
|
|
2408
3361
|
const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
|
|
2409
3362
|
const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
|
|
@@ -2416,15 +3369,24 @@ async function init(options = {}) {
|
|
|
2416
3369
|
const config = buildConfig(selected, target, handoffAgent, selectedMcp);
|
|
2417
3370
|
saveConfig(config);
|
|
2418
3371
|
printSummary(config);
|
|
3372
|
+
if (options.offerAudit !== false) {
|
|
3373
|
+
const answer = (await rl.question("\nRun the skills & MCP audit now? [Y/n] ")).trim().toLowerCase();
|
|
3374
|
+
runAuditNow = answer === "" || answer === "y" || answer === "yes";
|
|
3375
|
+
}
|
|
2419
3376
|
} finally {
|
|
2420
3377
|
rl.close();
|
|
2421
3378
|
}
|
|
3379
|
+
if (runAuditNow) {
|
|
3380
|
+
await runAudit();
|
|
3381
|
+
}
|
|
2422
3382
|
}
|
|
2423
3383
|
async function maybeOfferInit(options = {}) {
|
|
2424
3384
|
if (options.json || options.yes || !process.stdin.isTTY) return;
|
|
2425
|
-
if (
|
|
2426
|
-
console.log(
|
|
2427
|
-
|
|
3385
|
+
if (existsSync15(getConfigPath())) return;
|
|
3386
|
+
console.log(
|
|
3387
|
+
"[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets (init finishes with a `skill-forge audit` health check over what it just configured)."
|
|
3388
|
+
);
|
|
3389
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
2428
3390
|
let answer = "";
|
|
2429
3391
|
try {
|
|
2430
3392
|
answer = (await rl.question("Run `skill-forge init` now? [y/N] ")).trim().toLowerCase();
|
|
@@ -2432,36 +3394,7 @@ async function maybeOfferInit(options = {}) {
|
|
|
2432
3394
|
rl.close();
|
|
2433
3395
|
}
|
|
2434
3396
|
if (answer === "y" || answer === "yes") {
|
|
2435
|
-
await init();
|
|
2436
|
-
}
|
|
2437
|
-
}
|
|
2438
|
-
|
|
2439
|
-
// src/decision.ts
|
|
2440
|
-
import { createInterface as createInterface2 } from "readline/promises";
|
|
2441
|
-
function logOutcome(message, json) {
|
|
2442
|
-
if (json) console.error(message);
|
|
2443
|
-
else console.log(message);
|
|
2444
|
-
}
|
|
2445
|
-
function yesDecision(verdict) {
|
|
2446
|
-
return verdict === "block" ? "reject" : "promote";
|
|
2447
|
-
}
|
|
2448
|
-
async function promptDecision(verdict, subject) {
|
|
2449
|
-
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2450
|
-
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2451
|
-
const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2452
|
-
try {
|
|
2453
|
-
process.stdout.write(prompt);
|
|
2454
|
-
for await (const raw of rl) {
|
|
2455
|
-
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
2456
|
-
if (answer === "p" || answer === "promote") return "promote";
|
|
2457
|
-
if (answer === "h" || answer === "hold") return "hold";
|
|
2458
|
-
if (answer === "r" || answer === "reject") return "reject";
|
|
2459
|
-
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
2460
|
-
${prompt}`);
|
|
2461
|
-
}
|
|
2462
|
-
return defaultChoice;
|
|
2463
|
-
} finally {
|
|
2464
|
-
rl.close();
|
|
3397
|
+
await init(options.suppressAuditOffer ? { offerAudit: false } : {});
|
|
2465
3398
|
}
|
|
2466
3399
|
}
|
|
2467
3400
|
|
|
@@ -2585,13 +3518,20 @@ async function addMcp(source, config, options) {
|
|
|
2585
3518
|
// version may be absent — promoteMcp refuses the unpinnable npx fallback
|
|
2586
3519
|
// rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
|
|
2587
3520
|
version: detail.version,
|
|
3521
|
+
// packageName (v0.7) — the candidate's own scanned package.json name, so
|
|
3522
|
+
// deriveServerEntry can auto-pin an unpinned npx spec in candidateEntry
|
|
3523
|
+
// only when it provably matches the package that was actually scanned.
|
|
3524
|
+
packageName: detail.packageName,
|
|
2588
3525
|
envVars: detail.env ? Object.keys(detail.env) : void 0,
|
|
2589
3526
|
candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
|
|
2590
3527
|
};
|
|
2591
|
-
const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ??
|
|
3528
|
+
const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join18(process.cwd(), ".mcp.json");
|
|
2592
3529
|
const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
|
|
2593
3530
|
reject(entry);
|
|
2594
3531
|
logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
|
|
3532
|
+
if (result.autoPinned) {
|
|
3533
|
+
logOutcome(`[skill-forge] pinned launch spec ${result.autoPinned.from} -> ${result.autoPinned.to}`, options.json);
|
|
3534
|
+
}
|
|
2595
3535
|
if (result.backupPath) {
|
|
2596
3536
|
logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
|
|
2597
3537
|
}
|
|
@@ -2680,7 +3620,7 @@ async function scan(source, options = {}) {
|
|
|
2680
3620
|
}
|
|
2681
3621
|
|
|
2682
3622
|
// src/commands/list.ts
|
|
2683
|
-
import { join as
|
|
3623
|
+
import { join as join19 } from "path";
|
|
2684
3624
|
async function list() {
|
|
2685
3625
|
await maybeOfferInit();
|
|
2686
3626
|
const config = loadConfig();
|
|
@@ -2693,14 +3633,14 @@ async function list() {
|
|
|
2693
3633
|
}
|
|
2694
3634
|
console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
|
|
2695
3635
|
for (const id of ids) {
|
|
2696
|
-
const skillDirs = findSkillDirs(
|
|
3636
|
+
const skillDirs = findSkillDirs(join19(quarantineDir, id));
|
|
2697
3637
|
if (skillDirs.length === 0) {
|
|
2698
3638
|
console.log(` ${id} (no SKILL.md found)`);
|
|
2699
3639
|
continue;
|
|
2700
3640
|
}
|
|
2701
3641
|
const names = skillDirs.map((dir) => {
|
|
2702
3642
|
try {
|
|
2703
|
-
return readFrontmatter(
|
|
3643
|
+
return readFrontmatter(join19(dir, "SKILL.md")).name;
|
|
2704
3644
|
} catch {
|
|
2705
3645
|
return dir;
|
|
2706
3646
|
}
|
|
@@ -2722,19 +3662,19 @@ async function status() {
|
|
|
2722
3662
|
}
|
|
2723
3663
|
|
|
2724
3664
|
// src/evolve.ts
|
|
2725
|
-
import { createInterface as
|
|
3665
|
+
import { createInterface as createInterface4 } from "readline/promises";
|
|
2726
3666
|
import { createHash } from "crypto";
|
|
2727
|
-
import { cpSync as cpSync3, existsSync as
|
|
3667
|
+
import { cpSync as cpSync3, existsSync as existsSync16, lstatSync as lstatSync2, mkdtempSync as mkdtempSync3, readFileSync as readFileSync11, rmSync as rmSync4 } from "fs";
|
|
2728
3668
|
import { tmpdir as tmpdir2 } from "os";
|
|
2729
|
-
import { basename as
|
|
3669
|
+
import { basename as basename7, dirname as dirname7, join as join20, resolve as resolvePath4, sep } from "path";
|
|
2730
3670
|
var SKILLOPT_BIN = "skillopt-sleep";
|
|
2731
3671
|
var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
|
|
2732
3672
|
async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
2733
3673
|
const exec = deps.exec ?? defaultExec;
|
|
2734
3674
|
const json = options.json;
|
|
2735
|
-
const resolvedTargetDir =
|
|
2736
|
-
const skillMdPath =
|
|
2737
|
-
if (!
|
|
3675
|
+
const resolvedTargetDir = resolvePath4(targetSkillDir);
|
|
3676
|
+
const skillMdPath = join20(resolvedTargetDir, "SKILL.md");
|
|
3677
|
+
if (!existsSync16(skillMdPath)) {
|
|
2738
3678
|
throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
|
|
2739
3679
|
}
|
|
2740
3680
|
const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
|
|
@@ -2761,7 +3701,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2761
3701
|
} else {
|
|
2762
3702
|
notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
|
|
2763
3703
|
}
|
|
2764
|
-
const project =
|
|
3704
|
+
const project = resolvePath4(options.project ?? process.cwd());
|
|
2765
3705
|
const present = await detectSkillopt(exec, project);
|
|
2766
3706
|
if (!present) {
|
|
2767
3707
|
throw new UserError(
|
|
@@ -2792,40 +3732,40 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2792
3732
|
}
|
|
2793
3733
|
return;
|
|
2794
3734
|
}
|
|
2795
|
-
const stagingRoot =
|
|
2796
|
-
const stagingDir =
|
|
3735
|
+
const stagingRoot = join20(project, ".skillopt-sleep", "staging");
|
|
3736
|
+
const stagingDir = resolvePath4(parsed.staging_dir);
|
|
2797
3737
|
if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
|
|
2798
3738
|
throw new UserError(
|
|
2799
3739
|
`skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
|
|
2800
3740
|
);
|
|
2801
3741
|
}
|
|
2802
|
-
const proposedSkillMd =
|
|
2803
|
-
const proposedClaudeMd =
|
|
2804
|
-
const reportMdPath =
|
|
2805
|
-
if (!
|
|
3742
|
+
const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
|
|
3743
|
+
const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
|
|
3744
|
+
const reportMdPath = join20(stagingDir, "report.md");
|
|
3745
|
+
if (!existsSync16(proposedSkillMd)) {
|
|
2806
3746
|
throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
|
|
2807
3747
|
}
|
|
2808
|
-
if (
|
|
3748
|
+
if (existsSync16(reportMdPath)) {
|
|
2809
3749
|
notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
|
|
2810
3750
|
}
|
|
2811
3751
|
assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
|
|
2812
3752
|
assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
|
|
2813
3753
|
const scannedSkillHash = sha256File(proposedSkillMd);
|
|
2814
3754
|
const scannedClaudeHash = sha256File(proposedClaudeMd);
|
|
2815
|
-
const tempDir =
|
|
3755
|
+
const tempDir = mkdtempSync3(join20(tmpdir2(), "skill-forge-evolve-"));
|
|
2816
3756
|
let profile;
|
|
2817
3757
|
let safety;
|
|
2818
3758
|
try {
|
|
2819
|
-
cpSync3(proposedSkillMd,
|
|
2820
|
-
if (
|
|
2821
|
-
cpSync3(proposedClaudeMd,
|
|
3759
|
+
cpSync3(proposedSkillMd, join20(tempDir, "SKILL.md"));
|
|
3760
|
+
if (existsSync16(proposedClaudeMd)) {
|
|
3761
|
+
cpSync3(proposedClaudeMd, join20(tempDir, "CLAUDE.md"));
|
|
2822
3762
|
}
|
|
2823
3763
|
profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
|
|
2824
3764
|
const strictness = loadConfig().strictness;
|
|
2825
3765
|
safety = await scanSafety(tempDir, strictness);
|
|
2826
3766
|
} finally {
|
|
2827
3767
|
try {
|
|
2828
|
-
|
|
3768
|
+
rmSync4(tempDir, { recursive: true, force: true });
|
|
2829
3769
|
} catch (err) {
|
|
2830
3770
|
console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
|
|
2831
3771
|
}
|
|
@@ -2860,12 +3800,12 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2860
3800
|
throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
|
|
2861
3801
|
}
|
|
2862
3802
|
logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
|
|
2863
|
-
const skillsRootForProvenance =
|
|
3803
|
+
const skillsRootForProvenance = dirname7(resolvedTargetDir);
|
|
2864
3804
|
let ledgerPath;
|
|
2865
3805
|
try {
|
|
2866
3806
|
ledgerPath = appendProvenance(
|
|
2867
3807
|
{
|
|
2868
|
-
name: profile.name ??
|
|
3808
|
+
name: profile.name ?? basename7(resolvedTargetDir),
|
|
2869
3809
|
source: resolvedTargetDir,
|
|
2870
3810
|
version: profile.version,
|
|
2871
3811
|
license: profile.license,
|
|
@@ -2907,7 +3847,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2907
3847
|
logOutcome("[skill-forge] queued for ingestion review", json);
|
|
2908
3848
|
} else if (decision === "reject") {
|
|
2909
3849
|
try {
|
|
2910
|
-
|
|
3850
|
+
rmSync4(stagingDir, { recursive: true, force: true });
|
|
2911
3851
|
} catch (err) {
|
|
2912
3852
|
throw new UserError(
|
|
2913
3853
|
`rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
|
|
@@ -2917,13 +3857,13 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2917
3857
|
if (safetyVerdict === "block") process.exitCode = 1;
|
|
2918
3858
|
} else {
|
|
2919
3859
|
logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
|
|
2920
|
-
if (
|
|
3860
|
+
if (existsSync16(reportMdPath)) {
|
|
2921
3861
|
logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
|
|
2922
3862
|
}
|
|
2923
3863
|
}
|
|
2924
3864
|
}
|
|
2925
3865
|
async function defaultConfirm(question) {
|
|
2926
|
-
const rl =
|
|
3866
|
+
const rl = createInterface4({ input: process.stdin, output: process.stdout });
|
|
2927
3867
|
try {
|
|
2928
3868
|
process.stdout.write(question);
|
|
2929
3869
|
for await (const raw of rl) {
|
|
@@ -2959,10 +3899,10 @@ function validateRunOutput(raw) {
|
|
|
2959
3899
|
return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
|
|
2960
3900
|
}
|
|
2961
3901
|
function readManifest(stagingDir) {
|
|
2962
|
-
const manifestPath =
|
|
3902
|
+
const manifestPath = join20(stagingDir, "manifest.json");
|
|
2963
3903
|
let raw;
|
|
2964
3904
|
try {
|
|
2965
|
-
raw =
|
|
3905
|
+
raw = readFileSync11(manifestPath, "utf-8");
|
|
2966
3906
|
} catch (err) {
|
|
2967
3907
|
throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
|
|
2968
3908
|
}
|
|
@@ -2996,18 +3936,18 @@ function validateManifest(raw, manifestPath) {
|
|
|
2996
3936
|
};
|
|
2997
3937
|
}
|
|
2998
3938
|
function filesIdentical(a, b) {
|
|
2999
|
-
if (!
|
|
3000
|
-
return
|
|
3939
|
+
if (!existsSync16(a) || !existsSync16(b)) return false;
|
|
3940
|
+
return readFileSync11(a).equals(readFileSync11(b));
|
|
3001
3941
|
}
|
|
3002
3942
|
function isAlreadyAdopted(stagingDir, manifest) {
|
|
3003
|
-
const proposedSkillMd =
|
|
3004
|
-
const proposedClaudeMd =
|
|
3943
|
+
const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
|
|
3944
|
+
const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
|
|
3005
3945
|
let sawComparison = false;
|
|
3006
|
-
if (
|
|
3946
|
+
if (existsSync16(proposedSkillMd)) {
|
|
3007
3947
|
sawComparison = true;
|
|
3008
3948
|
if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
|
|
3009
3949
|
}
|
|
3010
|
-
if (
|
|
3950
|
+
if (existsSync16(proposedClaudeMd) && manifest.live_memory_path) {
|
|
3011
3951
|
sawComparison = true;
|
|
3012
3952
|
if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
|
|
3013
3953
|
}
|
|
@@ -3016,7 +3956,7 @@ function isAlreadyAdopted(stagingDir, manifest) {
|
|
|
3016
3956
|
function assertNotSymlink(path, label) {
|
|
3017
3957
|
let stat;
|
|
3018
3958
|
try {
|
|
3019
|
-
stat =
|
|
3959
|
+
stat = lstatSync2(path);
|
|
3020
3960
|
} catch {
|
|
3021
3961
|
return;
|
|
3022
3962
|
}
|
|
@@ -3025,8 +3965,8 @@ function assertNotSymlink(path, label) {
|
|
|
3025
3965
|
}
|
|
3026
3966
|
}
|
|
3027
3967
|
function sha256File(path) {
|
|
3028
|
-
if (!
|
|
3029
|
-
return createHash("sha256").update(
|
|
3968
|
+
if (!existsSync16(path)) return null;
|
|
3969
|
+
return createHash("sha256").update(readFileSync11(path)).digest("hex");
|
|
3030
3970
|
}
|
|
3031
3971
|
|
|
3032
3972
|
// src/commands/evolve.ts
|
|
@@ -3060,6 +4000,7 @@ program.command("scan <source>").description("Run the safety gate against a skil
|
|
|
3060
4000
|
program.command("list").description("List skills currently in quarantine").action(list);
|
|
3061
4001
|
program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
|
|
3062
4002
|
program.command("evolve <target-skill-dir>").description("Self-evolve a skill via SkillOpt-Sleep, re-gate the proposal, then promote/hold/reject (Pro)").option("--project <dir>", "project dir passed to SkillOpt-Sleep as --project (defaults to cwd)").option("--dry-run", "SkillOpt-Sleep dry-run: report only, nothing staged").option("--backend <name>", "SkillOpt-Sleep backend (default: mock, offline \u2014 a real backend requires --yes or interactive confirmation)").option("--lookback-hours <n>", "hours of session history for SkillOpt-Sleep to harvest", parseLookbackHours).option("-y, --yes", "skip interactive prompts; honor the re-gate verdict automatically").option("--json", "print the re-gate result as JSON instead of the terminal report").option("--force", "allow re-adopting a staging dir whose proposed content already matches the live skill").action(evolveCommand);
|
|
4003
|
+
program.command("audit").alias("doctor").description("Doctor-style health check over the configured skill/MCP set: inventory, hygiene findings, and consolidation/refinement opportunities").option("--json", "print the audit report as JSON instead of the terminal summary (non-interactive: skips the business-profile prompt)").option("-y, --yes", "skip interactive prompts (business profile, foundation, handoff); never implies --foundation or --handoff").option("--report <file>", "write the report to this path instead of the default ~/.skill-forge/reports/audit-<timestamp>.md (refuses an existing path)").option("--foundation", "authorizes exactly one skills-root write: scaffold a business-foundation skill from the captured business profile").option("--handoff", "authorizes launching the configured agent with the bundled curation prompt against the written report").action(auditCommand);
|
|
3063
4004
|
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);
|
|
3064
4005
|
try {
|
|
3065
4006
|
await program.parseAsync();
|