@rhize/skill-forge 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +3 -0
- package/README.md +215 -4
- package/dist/cli.js +2226 -306
- package/dist/cli.js.map +1 -1
- package/dist/curation-prompt.md +121 -0
- package/dist/ingest-prompt.md +107 -15
- 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) {
|
|
@@ -549,7 +561,7 @@ function resolveMcpCandidateDetail(profile) {
|
|
|
549
561
|
}
|
|
550
562
|
|
|
551
563
|
// src/provenance.ts
|
|
552
|
-
import { appendFileSync, existsSync as existsSync6, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
564
|
+
import { appendFileSync, existsSync as existsSync6, readFileSync as readFileSync5, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
553
565
|
import { join as join7 } from "path";
|
|
554
566
|
var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
|
|
555
567
|
var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
|
|
@@ -588,6 +600,55 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
588
600
|
appendFileSync(ledgerPath, block, "utf-8");
|
|
589
601
|
return ledgerPath;
|
|
590
602
|
}
|
|
603
|
+
function extractField(block, label) {
|
|
604
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
605
|
+
const match = block.match(new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.*)`));
|
|
606
|
+
return match ? match[1].trim() : void 0;
|
|
607
|
+
}
|
|
608
|
+
function parseProvenanceLedger(text) {
|
|
609
|
+
const entries = [];
|
|
610
|
+
const warnings = [];
|
|
611
|
+
const blocks = text.split(/\n## /);
|
|
612
|
+
for (const block of blocks.slice(1)) {
|
|
613
|
+
const headerLine = block.split("\n", 1)[0] ?? "";
|
|
614
|
+
const [namePart, ...dateParts] = headerLine.split(" \u2014 ");
|
|
615
|
+
const name = namePart?.trim();
|
|
616
|
+
if (!name) {
|
|
617
|
+
warnings.push(`skipped malformed ledger block: missing "## <name> \u2014 <date>" header (saw "${headerLine.slice(0, 80)}")`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const date = dateParts.join(" \u2014 ").trim();
|
|
621
|
+
const verbRaw = extractField(block, "Verb");
|
|
622
|
+
if (!verbRaw || !FORGE_VERBS.includes(verbRaw)) {
|
|
623
|
+
warnings.push(`skipped malformed ledger block for "${name}": missing or invalid Verb (saw "${verbRaw ?? ""}")`);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const driftCheckMatch = block.match(/\*\*Drift check:\*\*\s*`([^`]*)`/);
|
|
627
|
+
entries.push({
|
|
628
|
+
name,
|
|
629
|
+
date,
|
|
630
|
+
source: extractField(block, "Source") ?? "",
|
|
631
|
+
upstreamRef: extractField(block, "Upstream ref") ?? "",
|
|
632
|
+
license: extractField(block, "License") ?? "",
|
|
633
|
+
verb: verbRaw,
|
|
634
|
+
target: extractField(block, "Target") ?? "",
|
|
635
|
+
took: extractField(block, "Took") ?? "",
|
|
636
|
+
verified: extractField(block, "Verified") ?? "",
|
|
637
|
+
driftCheck: driftCheckMatch ? driftCheckMatch[1] : "",
|
|
638
|
+
notes: extractField(block, "Notes") ?? ""
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return { entries, warnings };
|
|
642
|
+
}
|
|
643
|
+
function readProvenanceLedger(skillsRoot) {
|
|
644
|
+
const ledgerPath = join7(skillsRoot, "SOURCES.md");
|
|
645
|
+
if (!existsSync6(ledgerPath)) {
|
|
646
|
+
return { ledgerPath, exists: false, entries: [], warnings: [] };
|
|
647
|
+
}
|
|
648
|
+
const text = readFileSync5(ledgerPath, "utf-8");
|
|
649
|
+
const parsed = parseProvenanceLedger(text);
|
|
650
|
+
return { ledgerPath, exists: true, ...parsed };
|
|
651
|
+
}
|
|
591
652
|
|
|
592
653
|
// src/commands/gatePipeline.ts
|
|
593
654
|
import { readdirSync as readdirSync5 } from "fs";
|
|
@@ -600,7 +661,7 @@ import {
|
|
|
600
661
|
existsSync as existsSync8,
|
|
601
662
|
mkdtempSync,
|
|
602
663
|
openSync,
|
|
603
|
-
readFileSync as
|
|
664
|
+
readFileSync as readFileSync7,
|
|
604
665
|
readdirSync as readdirSync3,
|
|
605
666
|
readSync,
|
|
606
667
|
rmSync as rmSync2,
|
|
@@ -610,13 +671,13 @@ import { tmpdir } from "os";
|
|
|
610
671
|
import { basename as basename3, dirname as dirname3, extname as extname3, join as join9, resolve as resolve2 } from "path";
|
|
611
672
|
|
|
612
673
|
// src/gate/mcpCapabilities.ts
|
|
613
|
-
import { existsSync as existsSync7, readFileSync as
|
|
674
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
614
675
|
import { extname as extname2, join as join8 } from "path";
|
|
615
676
|
var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
616
677
|
var MAX_ITEMS_PER_CATEGORY = 200;
|
|
617
678
|
function readJsonSafe(file) {
|
|
618
679
|
try {
|
|
619
|
-
return JSON.parse(
|
|
680
|
+
return JSON.parse(readFileSync6(file, "utf-8"));
|
|
620
681
|
} catch {
|
|
621
682
|
return void 0;
|
|
622
683
|
}
|
|
@@ -844,7 +905,7 @@ function readPackageJsonSafe(root) {
|
|
|
844
905
|
const pkgPath = join9(root, "package.json");
|
|
845
906
|
if (!existsSync8(pkgPath)) return void 0;
|
|
846
907
|
try {
|
|
847
|
-
return JSON.parse(
|
|
908
|
+
return JSON.parse(readFileSync7(pkgPath, "utf-8"));
|
|
848
909
|
} catch {
|
|
849
910
|
return void 0;
|
|
850
911
|
}
|
|
@@ -1004,7 +1065,7 @@ function detectLicense(root, fm) {
|
|
|
1004
1065
|
for (const name of names) {
|
|
1005
1066
|
const p = join9(root, name);
|
|
1006
1067
|
if (existsSync8(p) && statSync4(p).isFile()) {
|
|
1007
|
-
const head =
|
|
1068
|
+
const head = readFileSync7(p, "utf-8").slice(0, 2e3).toLowerCase();
|
|
1008
1069
|
for (const [hint, label] of Object.entries(SPDX_HINTS)) {
|
|
1009
1070
|
if (head.includes(hint)) return label;
|
|
1010
1071
|
}
|
|
@@ -1043,7 +1104,7 @@ function scanMcpAndImports(text, root) {
|
|
|
1043
1104
|
if (isDir(scriptsDir)) {
|
|
1044
1105
|
for (const f of walkFiles(scriptsDir)) {
|
|
1045
1106
|
if (!f.endsWith(".py")) continue;
|
|
1046
|
-
const ft =
|
|
1107
|
+
const ft = readFileSync7(f, "utf-8");
|
|
1047
1108
|
for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
|
|
1048
1109
|
const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
|
|
1049
1110
|
let m;
|
|
@@ -1060,7 +1121,7 @@ function scanMcpAndImports(text, root) {
|
|
|
1060
1121
|
function profileSkillDir(inputPath) {
|
|
1061
1122
|
const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
|
|
1062
1123
|
try {
|
|
1063
|
-
const text =
|
|
1124
|
+
const text = readFileSync7(skillMd, "utf-8");
|
|
1064
1125
|
const fm = parseFrontmatter(text);
|
|
1065
1126
|
const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
|
|
1066
1127
|
const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
|
|
@@ -1091,7 +1152,7 @@ function profilePlugin(p) {
|
|
|
1091
1152
|
const manifestPath = join9(p, ".claude-plugin", "plugin.json");
|
|
1092
1153
|
let manifest;
|
|
1093
1154
|
try {
|
|
1094
|
-
manifest = JSON.parse(
|
|
1155
|
+
manifest = JSON.parse(readFileSync7(manifestPath, "utf-8"));
|
|
1095
1156
|
} catch (err) {
|
|
1096
1157
|
throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
|
|
1097
1158
|
}
|
|
@@ -1122,7 +1183,7 @@ function profileMcp(p) {
|
|
|
1122
1183
|
if (explicitConfig) {
|
|
1123
1184
|
let cfg;
|
|
1124
1185
|
try {
|
|
1125
|
-
cfg = JSON.parse(
|
|
1186
|
+
cfg = JSON.parse(readFileSync7(explicitConfig, "utf-8"));
|
|
1126
1187
|
} catch (err) {
|
|
1127
1188
|
throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
|
|
1128
1189
|
}
|
|
@@ -1141,7 +1202,7 @@ function profileMcp(p) {
|
|
|
1141
1202
|
const pkgPath = join9(p, "package.json");
|
|
1142
1203
|
let pkg2;
|
|
1143
1204
|
try {
|
|
1144
|
-
pkg2 = JSON.parse(
|
|
1205
|
+
pkg2 = JSON.parse(readFileSync7(pkgPath, "utf-8"));
|
|
1145
1206
|
} catch (err) {
|
|
1146
1207
|
throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
|
|
1147
1208
|
}
|
|
@@ -1299,7 +1360,7 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
|
1299
1360
|
}
|
|
1300
1361
|
|
|
1301
1362
|
// src/gate/overlap.ts
|
|
1302
|
-
import { existsSync as existsSync9, readFileSync as
|
|
1363
|
+
import { existsSync as existsSync9, readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
|
|
1303
1364
|
import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve3 } from "path";
|
|
1304
1365
|
var STOPWORDS = new Set(
|
|
1305
1366
|
`
|
|
@@ -1308,6 +1369,7 @@ your you it its as at by from into via per also any all not no than then so if u
|
|
|
1308
1369
|
skill skills user claude code based comprehensive enhanced practical including include includes
|
|
1309
1370
|
`.split(/\s+/).filter(Boolean)
|
|
1310
1371
|
);
|
|
1372
|
+
var DEFAULT_SET_THRESHOLD = 0.45;
|
|
1311
1373
|
function splitLimited(text, sep2, maxSplit) {
|
|
1312
1374
|
const parts = [];
|
|
1313
1375
|
let rest = text;
|
|
@@ -1321,7 +1383,7 @@ function splitLimited(text, sep2, maxSplit) {
|
|
|
1321
1383
|
return parts;
|
|
1322
1384
|
}
|
|
1323
1385
|
function readFrontmatter(skillMdPath) {
|
|
1324
|
-
const text =
|
|
1386
|
+
const text = readFileSync8(skillMdPath, "utf-8");
|
|
1325
1387
|
const meta = { name: basename4(dirname4(skillMdPath)), description: "" };
|
|
1326
1388
|
if (text.startsWith("---\n")) {
|
|
1327
1389
|
const block = splitLimited(text, "---", 2)[1] ?? "";
|
|
@@ -1474,6 +1536,26 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
1474
1536
|
ranking
|
|
1475
1537
|
};
|
|
1476
1538
|
}
|
|
1539
|
+
function scanOverlapPaths(skillMdPaths, threshold = DEFAULT_SET_THRESHOLD) {
|
|
1540
|
+
const metas = skillMdPaths.map(readFrontmatter);
|
|
1541
|
+
const pairs = [];
|
|
1542
|
+
for (let i = 0; i < metas.length; i++) {
|
|
1543
|
+
for (let j = i + 1; j < metas.length; j++) {
|
|
1544
|
+
const sc = Math.max(score(metas[i], metas[j]), score(metas[j], metas[i]));
|
|
1545
|
+
if (sc >= threshold) {
|
|
1546
|
+
pairs.push({
|
|
1547
|
+
a: metas[i].name,
|
|
1548
|
+
b: metas[j].name,
|
|
1549
|
+
aPath: skillMdPaths[i],
|
|
1550
|
+
bPath: skillMdPaths[j],
|
|
1551
|
+
score: round3(sc)
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
pairs.sort((a, b) => b.score - a.score);
|
|
1557
|
+
return pairs;
|
|
1558
|
+
}
|
|
1477
1559
|
|
|
1478
1560
|
// src/gate/mcpSafety.ts
|
|
1479
1561
|
import { statSync as statSync6 } from "fs";
|
|
@@ -1672,14 +1754,14 @@ async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
|
1672
1754
|
}
|
|
1673
1755
|
|
|
1674
1756
|
// src/gate/mcpOverlap.ts
|
|
1675
|
-
import { existsSync as existsSync10, readFileSync as
|
|
1757
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
|
|
1676
1758
|
import { resolve as resolve4 } from "path";
|
|
1677
1759
|
function readTargetServers(targetPath) {
|
|
1678
1760
|
const path = resolve4(expandHome(targetPath));
|
|
1679
1761
|
if (!existsSync10(path)) return [];
|
|
1680
1762
|
let cfg;
|
|
1681
1763
|
try {
|
|
1682
|
-
cfg = JSON.parse(
|
|
1764
|
+
cfg = JSON.parse(readFileSync9(path, "utf-8"));
|
|
1683
1765
|
} catch {
|
|
1684
1766
|
return [];
|
|
1685
1767
|
}
|
|
@@ -1987,7 +2069,7 @@ async function runGate(candidatePath, skillsRoot, {
|
|
|
1987
2069
|
|
|
1988
2070
|
// src/queue.ts
|
|
1989
2071
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1990
|
-
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as
|
|
2072
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync10, renameSync as renameSync2, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
1991
2073
|
import { join as join13 } from "path";
|
|
1992
2074
|
function getQueuePath() {
|
|
1993
2075
|
return join13(getHomeDir(), "queue.json");
|
|
@@ -1997,26 +2079,46 @@ function readQueue() {
|
|
|
1997
2079
|
if (!existsSync11(path)) {
|
|
1998
2080
|
return { version: 1, entries: [] };
|
|
1999
2081
|
}
|
|
2000
|
-
const raw =
|
|
2082
|
+
const raw = readFileSync10(path, "utf-8");
|
|
2001
2083
|
const parsed = JSON.parse(raw);
|
|
2002
2084
|
return { version: 1, entries: parsed.entries ?? [] };
|
|
2003
2085
|
}
|
|
2004
|
-
function writeQueue(
|
|
2005
|
-
|
|
2006
|
-
|
|
2086
|
+
function writeQueue(queue2) {
|
|
2087
|
+
const home = getHomeDir();
|
|
2088
|
+
mkdirSync5(home, { recursive: true });
|
|
2089
|
+
const finalPath = getQueuePath();
|
|
2090
|
+
const tempPath = join13(home, `.queue.json.tmp-${randomUUID2()}`);
|
|
2091
|
+
try {
|
|
2092
|
+
writeFileSync4(tempPath, JSON.stringify(queue2, null, 2) + "\n", "utf-8");
|
|
2093
|
+
renameSync2(tempPath, finalPath);
|
|
2094
|
+
} catch (err) {
|
|
2095
|
+
rmSync3(tempPath, { force: true });
|
|
2096
|
+
throw err;
|
|
2097
|
+
}
|
|
2007
2098
|
}
|
|
2008
2099
|
function appendQueueEntry(entry) {
|
|
2009
|
-
const
|
|
2010
|
-
const existingIds = new Set(
|
|
2100
|
+
const queue2 = readQueue();
|
|
2101
|
+
const existingIds = new Set(queue2.entries.map((e) => e.id));
|
|
2011
2102
|
const full = {
|
|
2012
2103
|
...entry,
|
|
2013
2104
|
id: generateId(existingIds),
|
|
2014
2105
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2015
2106
|
};
|
|
2016
|
-
|
|
2017
|
-
writeQueue(
|
|
2107
|
+
queue2.entries.push(full);
|
|
2108
|
+
writeQueue(queue2);
|
|
2018
2109
|
return full;
|
|
2019
2110
|
}
|
|
2111
|
+
function updateQueueEntry(id, patch) {
|
|
2112
|
+
const queue2 = readQueue();
|
|
2113
|
+
const index = queue2.entries.findIndex((e) => e.id === id);
|
|
2114
|
+
if (index === -1) {
|
|
2115
|
+
throw new Error(`no queue entry with id: ${id}`);
|
|
2116
|
+
}
|
|
2117
|
+
const updated = { ...queue2.entries[index], ...patch };
|
|
2118
|
+
queue2.entries[index] = updated;
|
|
2119
|
+
writeQueue(queue2);
|
|
2120
|
+
return updated;
|
|
2121
|
+
}
|
|
2020
2122
|
function generateId(existingIds) {
|
|
2021
2123
|
let id;
|
|
2022
2124
|
do {
|
|
@@ -2032,11 +2134,11 @@ import { dirname as dirname5, join as join14 } from "path";
|
|
|
2032
2134
|
import { fileURLToPath } from "url";
|
|
2033
2135
|
var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
|
|
2034
2136
|
var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
|
|
2035
|
-
function resolvePromptPath() {
|
|
2137
|
+
function resolvePromptPath(assetName = "ingest-prompt.md") {
|
|
2036
2138
|
const here = dirname5(fileURLToPath(import.meta.url));
|
|
2037
|
-
const builtCandidate = join14(here,
|
|
2139
|
+
const builtCandidate = join14(here, assetName);
|
|
2038
2140
|
if (existsSync12(builtCandidate)) return builtCandidate;
|
|
2039
|
-
return join14(here, "..", "assets",
|
|
2141
|
+
return join14(here, "..", "assets", assetName);
|
|
2040
2142
|
}
|
|
2041
2143
|
function defaultIsOnPath(bin) {
|
|
2042
2144
|
const finder = process.platform === "win32" ? "where" : "which";
|
|
@@ -2048,7 +2150,7 @@ function substitute(template, vars) {
|
|
|
2048
2150
|
}
|
|
2049
2151
|
function buildHandoffCommand(config, installedPath, deps = {}) {
|
|
2050
2152
|
const isOnPath = deps.isOnPath ?? defaultIsOnPath;
|
|
2051
|
-
const promptPath = resolvePromptPath();
|
|
2153
|
+
const promptPath = resolvePromptPath(deps.promptAsset);
|
|
2052
2154
|
const vars = { path: installedPath, prompt: promptPath };
|
|
2053
2155
|
if (config.handoffCommand && config.handoffCommand.length > 0) {
|
|
2054
2156
|
return { argv: substitute(config.handoffCommand, vars), source: "template" };
|
|
@@ -2060,8 +2162,8 @@ function buildHandoffCommand(config, installedPath, deps = {}) {
|
|
|
2060
2162
|
}
|
|
2061
2163
|
return { argv: [], source: "none" };
|
|
2062
2164
|
}
|
|
2063
|
-
function printOnlyMessage(installedPath) {
|
|
2064
|
-
const promptPath = resolvePromptPath();
|
|
2165
|
+
function printOnlyMessage(installedPath, promptAsset) {
|
|
2166
|
+
const promptPath = resolvePromptPath(promptAsset);
|
|
2065
2167
|
return `[skill-forge] no agent handoff available \u2014 run one yourself:
|
|
2066
2168
|
Prompt: ${promptPath}
|
|
2067
2169
|
Skill path: ${installedPath}`;
|
|
@@ -2069,18 +2171,19 @@ function printOnlyMessage(installedPath) {
|
|
|
2069
2171
|
function runHandoff(config, installedPath, options = {}) {
|
|
2070
2172
|
const command = buildHandoffCommand(config, installedPath, options);
|
|
2071
2173
|
const spawn2 = options.spawn ?? spawnSync;
|
|
2174
|
+
const log = options.log ?? console.log;
|
|
2072
2175
|
if (command.source === "none" || command.argv.length === 0) {
|
|
2073
|
-
const printed = printOnlyMessage(installedPath);
|
|
2074
|
-
|
|
2176
|
+
const printed = printOnlyMessage(installedPath, options.promptAsset);
|
|
2177
|
+
log(printed);
|
|
2075
2178
|
return { ran: false, command, printed };
|
|
2076
2179
|
}
|
|
2077
2180
|
const [bin, ...args] = command.argv;
|
|
2078
2181
|
const result = spawn2(bin, args, { stdio: options.stdio ?? "inherit" });
|
|
2079
2182
|
const error = result.error;
|
|
2080
2183
|
if (error?.code === "ENOENT") {
|
|
2081
|
-
const printed = printOnlyMessage(installedPath);
|
|
2082
|
-
|
|
2083
|
-
|
|
2184
|
+
const printed = printOnlyMessage(installedPath, options.promptAsset);
|
|
2185
|
+
log(`[skill-forge] ${bin} not found on PATH \u2014 run this yourself:`);
|
|
2186
|
+
log(printed);
|
|
2084
2187
|
return { ran: false, command, printed, error: error.message };
|
|
2085
2188
|
}
|
|
2086
2189
|
if (error) {
|
|
@@ -2091,9 +2194,9 @@ function runHandoff(config, installedPath, options = {}) {
|
|
|
2091
2194
|
}
|
|
2092
2195
|
|
|
2093
2196
|
// src/commands/init.ts
|
|
2094
|
-
import { existsSync as
|
|
2197
|
+
import { existsSync as existsSync15 } from "fs";
|
|
2095
2198
|
import { homedir as homedir4 } from "os";
|
|
2096
|
-
import { createInterface } from "readline/promises";
|
|
2199
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
2097
2200
|
|
|
2098
2201
|
// src/agents.ts
|
|
2099
2202
|
import { statSync as statSync7 } from "fs";
|
|
@@ -2332,189 +2435,756 @@ var AGENTS = [
|
|
|
2332
2435
|
{ id: "adal", displayName: "AdaL", projectRoot: ".adal/skills", globalRoot: "~/.adal/skills" }
|
|
2333
2436
|
].map(makeAgent);
|
|
2334
2437
|
|
|
2335
|
-
// src/commands/
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2438
|
+
// src/commands/audit.ts
|
|
2439
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
|
|
2440
|
+
import { dirname as dirname6, join as join17, resolve as resolvePath3 } from "path";
|
|
2441
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
2442
|
+
|
|
2443
|
+
// src/audit.ts
|
|
2444
|
+
import {
|
|
2445
|
+
existsSync as existsSync13,
|
|
2446
|
+
lstatSync,
|
|
2447
|
+
mkdtempSync as mkdtempSync2,
|
|
2448
|
+
readFileSync as readFileSync11,
|
|
2449
|
+
readdirSync as readdirSync6,
|
|
2450
|
+
realpathSync,
|
|
2451
|
+
renameSync as renameSync3,
|
|
2452
|
+
rmSync as rmSync4,
|
|
2453
|
+
statSync as statSync8,
|
|
2454
|
+
writeFileSync as writeFileSync5
|
|
2455
|
+
} from "fs";
|
|
2456
|
+
import { basename as basename6, delimiter as delimiter2, extname as extname5, join as join16, resolve as resolvePath2 } from "path";
|
|
2457
|
+
var OVERSIZED_SKILL_EST_TOKENS = 5e3;
|
|
2458
|
+
function toAuditFindings(source) {
|
|
2459
|
+
return source.map((f) => ({ severity: f.severity, rule: f.rule, target: f.file, detail: f.detail }));
|
|
2343
2460
|
}
|
|
2344
|
-
function
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2461
|
+
function resolveConfiguredRoot(root) {
|
|
2462
|
+
return resolvePath2(expandHome(root));
|
|
2463
|
+
}
|
|
2464
|
+
function safeStat(p) {
|
|
2465
|
+
try {
|
|
2466
|
+
return statSync8(p);
|
|
2467
|
+
} catch {
|
|
2468
|
+
return void 0;
|
|
2350
2469
|
}
|
|
2351
|
-
return found;
|
|
2352
2470
|
}
|
|
2353
|
-
function
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2471
|
+
function isDirFollowingSymlink(p) {
|
|
2472
|
+
return safeStat(p)?.isDirectory() ?? false;
|
|
2473
|
+
}
|
|
2474
|
+
function listSubdirsFollowingSymlinks(dir) {
|
|
2475
|
+
let entries;
|
|
2476
|
+
try {
|
|
2477
|
+
entries = readdirSync6(dir, { withFileTypes: true });
|
|
2478
|
+
} catch {
|
|
2479
|
+
return [];
|
|
2358
2480
|
}
|
|
2359
|
-
|
|
2360
|
-
|
|
2481
|
+
const out = [];
|
|
2482
|
+
for (const entry of entries) {
|
|
2483
|
+
const full = join16(dir, entry.name);
|
|
2484
|
+
if (entry.isDirectory() || entry.isSymbolicLink() && isDirFollowingSymlink(full)) {
|
|
2485
|
+
out.push(full);
|
|
2486
|
+
}
|
|
2361
2487
|
}
|
|
2488
|
+
return out;
|
|
2362
2489
|
}
|
|
2363
|
-
function
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2490
|
+
function listSubdirsNoSymlinks(dir) {
|
|
2491
|
+
let entries;
|
|
2492
|
+
try {
|
|
2493
|
+
entries = readdirSync6(dir, { withFileTypes: true });
|
|
2494
|
+
} catch {
|
|
2495
|
+
return [];
|
|
2368
2496
|
}
|
|
2497
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join16(dir, e.name));
|
|
2369
2498
|
}
|
|
2370
|
-
function
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2499
|
+
function findSkillDirsUnderRoot(root) {
|
|
2500
|
+
const found = /* @__PURE__ */ new Set();
|
|
2501
|
+
const rootSubdirs = listSubdirsFollowingSymlinks(root);
|
|
2502
|
+
for (const dir of rootSubdirs) {
|
|
2503
|
+
if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
|
|
2504
|
+
}
|
|
2505
|
+
for (const dir of listSubdirsFollowingSymlinks(join16(root, "skills"))) {
|
|
2506
|
+
if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
|
|
2507
|
+
}
|
|
2508
|
+
for (const topDir of rootSubdirs) {
|
|
2509
|
+
for (const dir of listSubdirsNoSymlinks(join16(topDir, "skills"))) {
|
|
2510
|
+
if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
return Array.from(found).filter((p) => !p.includes("/.git/"));
|
|
2385
2514
|
}
|
|
2386
|
-
function
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2515
|
+
function collectSkillCandidates(roots) {
|
|
2516
|
+
const candidates = [];
|
|
2517
|
+
const rootMeta = /* @__PURE__ */ new Map();
|
|
2518
|
+
for (const root of roots) {
|
|
2519
|
+
const resolvedRoot = resolveConfiguredRoot(root);
|
|
2520
|
+
const exists = isDir(resolvedRoot);
|
|
2521
|
+
let realPath;
|
|
2522
|
+
if (exists) {
|
|
2523
|
+
try {
|
|
2524
|
+
realPath = realpathSync(resolvedRoot);
|
|
2525
|
+
} catch {
|
|
2526
|
+
realPath = void 0;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
rootMeta.set(root, { exists, realPath });
|
|
2530
|
+
if (!exists) continue;
|
|
2531
|
+
for (const dir of findSkillDirsUnderRoot(resolvedRoot)) {
|
|
2532
|
+
candidates.push({ configuredPath: dir, root });
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
return { candidates, rootMeta };
|
|
2393
2536
|
}
|
|
2394
|
-
function
|
|
2395
|
-
const
|
|
2396
|
-
|
|
2537
|
+
function dedupeSkillCandidates(candidates, findings) {
|
|
2538
|
+
const byRealPath = /* @__PURE__ */ new Map();
|
|
2539
|
+
const order = [];
|
|
2540
|
+
for (const candidate of candidates) {
|
|
2541
|
+
let realPath;
|
|
2542
|
+
try {
|
|
2543
|
+
realPath = realpathSync(candidate.configuredPath);
|
|
2544
|
+
} catch (err) {
|
|
2545
|
+
findings.push({
|
|
2546
|
+
severity: "MEDIUM",
|
|
2547
|
+
rule: "unreadable-skill",
|
|
2548
|
+
target: candidate.configuredPath,
|
|
2549
|
+
detail: `could not resolve skill directory (broken symlink or cycle): ${err.message}`
|
|
2550
|
+
});
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
const existing = byRealPath.get(realPath);
|
|
2554
|
+
if (existing) {
|
|
2555
|
+
if (existing.canonicalPath !== candidate.configuredPath && !existing.aliasPaths.includes(candidate.configuredPath)) {
|
|
2556
|
+
existing.aliasPaths.push(candidate.configuredPath);
|
|
2557
|
+
}
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2560
|
+
byRealPath.set(realPath, { realPath, canonicalPath: candidate.configuredPath, root: candidate.root, aliasPaths: [] });
|
|
2561
|
+
order.push(realPath);
|
|
2562
|
+
}
|
|
2563
|
+
return order.map((realPath) => byRealPath.get(realPath));
|
|
2397
2564
|
}
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
}
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2565
|
+
function parseStrictFrontmatter(text) {
|
|
2566
|
+
if (!text.startsWith("---\n")) {
|
|
2567
|
+
return { valid: false, errors: ['missing frontmatter fence (SKILL.md must start with "---")'] };
|
|
2568
|
+
}
|
|
2569
|
+
const afterOpen = text.slice(4);
|
|
2570
|
+
const closeIdx = afterOpen.indexOf("\n---");
|
|
2571
|
+
if (closeIdx === -1) {
|
|
2572
|
+
return { valid: false, errors: ['unclosed frontmatter fence (no closing "---" found)'] };
|
|
2573
|
+
}
|
|
2574
|
+
const block = afterOpen.slice(0, closeIdx);
|
|
2575
|
+
const errors = [];
|
|
2576
|
+
const nameMatch = block.match(/^name:\s*(.+)$/m);
|
|
2577
|
+
const name = nameMatch ? stripQuotes(nameMatch[1].trim()) : void 0;
|
|
2578
|
+
if (!name) errors.push('missing required "name" field in frontmatter');
|
|
2579
|
+
const descMatch = block.match(/^description:\s*(.*)$/m);
|
|
2580
|
+
let description;
|
|
2581
|
+
if (descMatch) {
|
|
2582
|
+
const first = descMatch[1].trim();
|
|
2583
|
+
if ([">", ">-", "|", "|-", ""].includes(first)) {
|
|
2584
|
+
const lines = block.split(/\r\n|\r|\n/);
|
|
2585
|
+
const idx = lines.findIndex((l) => /^description:/.test(l));
|
|
2586
|
+
const buf = [];
|
|
2587
|
+
if (idx !== -1) {
|
|
2588
|
+
for (const l of lines.slice(idx + 1)) {
|
|
2589
|
+
if (/^\s+\S/.test(l)) buf.push(l.trim());
|
|
2590
|
+
else if (l.trim() === "") continue;
|
|
2591
|
+
else break;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
description = buf.join(" ").trim() || void 0;
|
|
2412
2595
|
} else {
|
|
2413
|
-
|
|
2596
|
+
description = stripQuotes(first);
|
|
2414
2597
|
}
|
|
2415
2598
|
}
|
|
2416
|
-
|
|
2599
|
+
if (!description) errors.push('missing required "description" field in frontmatter');
|
|
2600
|
+
return { valid: errors.length === 0, name, description, errors };
|
|
2417
2601
|
}
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2602
|
+
function estimateTokens(text) {
|
|
2603
|
+
return Math.ceil(Buffer.byteLength(text, "utf-8") / 4);
|
|
2604
|
+
}
|
|
2605
|
+
function profileAuditedSkill(group, findings, unreadableSkillMdPaths) {
|
|
2606
|
+
const skillMdPath = join16(group.canonicalPath, "SKILL.md");
|
|
2607
|
+
const base = {
|
|
2608
|
+
path: group.canonicalPath,
|
|
2609
|
+
skillMdPath,
|
|
2610
|
+
root: group.root,
|
|
2611
|
+
aliasPaths: group.aliasPaths
|
|
2612
|
+
};
|
|
2613
|
+
let text;
|
|
2614
|
+
let sizeBytes;
|
|
2615
|
+
try {
|
|
2616
|
+
sizeBytes = statSync8(skillMdPath).size;
|
|
2617
|
+
text = readFileSync11(skillMdPath, "utf-8");
|
|
2618
|
+
} catch (err) {
|
|
2619
|
+
findings.push({
|
|
2620
|
+
severity: "MEDIUM",
|
|
2621
|
+
rule: "unreadable-skill",
|
|
2622
|
+
target: skillMdPath,
|
|
2623
|
+
detail: `could not read SKILL.md: ${err.message}`
|
|
2425
2624
|
});
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
console.log(" (invalid selection \u2014 enter a listed number, or press Enter to confirm)");
|
|
2625
|
+
unreadableSkillMdPaths.add(skillMdPath);
|
|
2626
|
+
return { ...base, name: basename6(group.canonicalPath), validFrontmatter: false, sizeBytes: 0, estTokens: 0 };
|
|
2627
|
+
}
|
|
2628
|
+
const fm = parseStrictFrontmatter(text);
|
|
2629
|
+
if (!fm.valid) {
|
|
2630
|
+
for (const error of fm.errors) {
|
|
2631
|
+
findings.push({ severity: "MEDIUM", rule: "malformed-frontmatter", target: skillMdPath, detail: error });
|
|
2434
2632
|
}
|
|
2435
2633
|
}
|
|
2436
|
-
return
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2634
|
+
return {
|
|
2635
|
+
...base,
|
|
2636
|
+
name: fm.name ?? basename6(group.canonicalPath),
|
|
2637
|
+
validFrontmatter: fm.valid,
|
|
2638
|
+
description: fm.description,
|
|
2639
|
+
sizeBytes,
|
|
2640
|
+
estTokens: estimateTokens(text)
|
|
2641
|
+
};
|
|
2444
2642
|
}
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
});
|
|
2451
|
-
const answer = (await rl.question(`Choose [1-${detected.length}] or Enter to skip: `)).trim();
|
|
2452
|
-
if (answer === "") return void 0;
|
|
2453
|
-
const idx = parseChoice(answer, detected.length);
|
|
2454
|
-
return idx === void 0 ? void 0 : detected[idx].agent;
|
|
2643
|
+
function hasRecognizedMcpShape(parsed) {
|
|
2644
|
+
if (typeof parsed !== "object" || parsed === null) return false;
|
|
2645
|
+
const obj = parsed;
|
|
2646
|
+
const serversRaw = obj.mcpServers ?? obj.servers;
|
|
2647
|
+
return typeof serversRaw === "object" && serversRaw !== null;
|
|
2455
2648
|
}
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
const
|
|
2459
|
-
const
|
|
2460
|
-
const
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2649
|
+
function enumerateMcpServers(parsed) {
|
|
2650
|
+
if (!hasRecognizedMcpShape(parsed)) return [];
|
|
2651
|
+
const obj = parsed;
|
|
2652
|
+
const serversRaw = obj.mcpServers ?? obj.servers;
|
|
2653
|
+
const out = [];
|
|
2654
|
+
for (const [name, raw] of Object.entries(serversRaw)) {
|
|
2655
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
2656
|
+
const entry = raw;
|
|
2657
|
+
const command = typeof entry.command === "string" ? entry.command : void 0;
|
|
2658
|
+
const args = Array.isArray(entry.args) ? entry.args.filter((a) => typeof a === "string") : [];
|
|
2659
|
+
const env = entry.env && typeof entry.env === "object" && !Array.isArray(entry.env) ? entry.env : {};
|
|
2660
|
+
out.push({
|
|
2661
|
+
name,
|
|
2662
|
+
commandBasename: command ? basename6(command) : void 0,
|
|
2663
|
+
packageSpec: command && isNpxCommand(command) ? collectNpxPackageSpecs(args).positionalSpec : void 0,
|
|
2664
|
+
argCount: args.length,
|
|
2665
|
+
envCount: Object.keys(env).length
|
|
2666
|
+
});
|
|
2465
2667
|
}
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2668
|
+
return out;
|
|
2669
|
+
}
|
|
2670
|
+
function auditMcpTarget(targetPath, notices) {
|
|
2671
|
+
const findings = [];
|
|
2672
|
+
const isToml = extname5(targetPath).toLowerCase() === ".toml";
|
|
2673
|
+
const format = isToml ? "toml" : "json";
|
|
2674
|
+
if (!existsSync13(targetPath)) {
|
|
2675
|
+
findings.push({
|
|
2676
|
+
severity: "LOW",
|
|
2677
|
+
rule: "missing-mcp-target",
|
|
2678
|
+
target: targetPath,
|
|
2679
|
+
detail: "configured MCP target file does not exist"
|
|
2680
|
+
});
|
|
2681
|
+
return { target: { path: targetPath, format, status: "missing", servers: [] }, findings };
|
|
2470
2682
|
}
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2683
|
+
try {
|
|
2684
|
+
findings.push(...toAuditFindings(scanMcpFindings(targetPath)));
|
|
2685
|
+
} catch (err) {
|
|
2686
|
+
findings.push({
|
|
2687
|
+
severity: "MEDIUM",
|
|
2688
|
+
rule: "unreadable-mcp-target",
|
|
2689
|
+
target: targetPath,
|
|
2690
|
+
detail: `MCP safety scan failed: ${err.message}`
|
|
2691
|
+
});
|
|
2476
2692
|
}
|
|
2477
|
-
if (
|
|
2478
|
-
|
|
2479
|
-
|
|
2693
|
+
if (isToml) {
|
|
2694
|
+
try {
|
|
2695
|
+
readFileSync11(targetPath, "utf-8");
|
|
2696
|
+
} catch (err) {
|
|
2697
|
+
findings.push({
|
|
2698
|
+
severity: "MEDIUM",
|
|
2699
|
+
rule: "unreadable-mcp-target",
|
|
2700
|
+
target: targetPath,
|
|
2701
|
+
detail: `could not read: ${err.message}`
|
|
2702
|
+
});
|
|
2703
|
+
return { target: { path: targetPath, format, status: "unreadable", servers: [] }, findings };
|
|
2704
|
+
}
|
|
2705
|
+
notices.push(`MCP target ${targetPath}: TOML format \u2014 structured server enumeration unsupported (textual safety scan only)`);
|
|
2706
|
+
return { target: { path: targetPath, format, status: "clean", servers: [] }, findings };
|
|
2480
2707
|
}
|
|
2481
|
-
|
|
2482
|
-
printMcpDetectionResults(detectedMcp);
|
|
2483
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2708
|
+
let raw;
|
|
2484
2709
|
try {
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
saveConfig(config);
|
|
2495
|
-
printSummary(config);
|
|
2496
|
-
} finally {
|
|
2497
|
-
rl.close();
|
|
2710
|
+
raw = readFileSync11(targetPath, "utf-8");
|
|
2711
|
+
} catch (err) {
|
|
2712
|
+
findings.push({
|
|
2713
|
+
severity: "MEDIUM",
|
|
2714
|
+
rule: "unreadable-mcp-target",
|
|
2715
|
+
target: targetPath,
|
|
2716
|
+
detail: `could not read: ${err.message}`
|
|
2717
|
+
});
|
|
2718
|
+
return { target: { path: targetPath, format, status: "unreadable", servers: [] }, findings };
|
|
2498
2719
|
}
|
|
2499
|
-
|
|
2500
|
-
async function maybeOfferInit(options = {}) {
|
|
2501
|
-
if (options.json || options.yes || !process.stdin.isTTY) return;
|
|
2502
|
-
if (existsSync13(getConfigPath())) return;
|
|
2503
|
-
console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
|
|
2504
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2505
|
-
let answer = "";
|
|
2720
|
+
let parsed;
|
|
2506
2721
|
try {
|
|
2507
|
-
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2722
|
+
parsed = JSON.parse(raw);
|
|
2723
|
+
} catch (err) {
|
|
2724
|
+
findings.push({
|
|
2725
|
+
severity: "MEDIUM",
|
|
2726
|
+
rule: "malformed-mcp-target",
|
|
2727
|
+
target: targetPath,
|
|
2728
|
+
detail: `invalid JSON: ${err.message}`
|
|
2729
|
+
});
|
|
2730
|
+
return { target: { path: targetPath, format, status: "malformed", servers: [] }, findings };
|
|
2510
2731
|
}
|
|
2511
|
-
if (
|
|
2512
|
-
|
|
2732
|
+
if (!hasRecognizedMcpShape(parsed)) {
|
|
2733
|
+
findings.push({
|
|
2734
|
+
severity: "LOW",
|
|
2735
|
+
rule: "unrecognized-mcp-target-shape",
|
|
2736
|
+
target: targetPath,
|
|
2737
|
+
detail: 'valid JSON but no "mcpServers" or "servers" object was found \u2014 reporting 0 servers'
|
|
2738
|
+
});
|
|
2513
2739
|
}
|
|
2740
|
+
return { target: { path: targetPath, format, status: "clean", servers: enumerateMcpServers(parsed) }, findings };
|
|
2741
|
+
}
|
|
2742
|
+
function clusterOverlapPairs(pairs) {
|
|
2743
|
+
const parent = /* @__PURE__ */ new Map();
|
|
2744
|
+
function find2(x) {
|
|
2745
|
+
if (!parent.has(x)) parent.set(x, x);
|
|
2746
|
+
let root = x;
|
|
2747
|
+
while (parent.get(root) !== root) root = parent.get(root);
|
|
2748
|
+
let cur = x;
|
|
2749
|
+
while (parent.get(cur) !== root) {
|
|
2750
|
+
const next = parent.get(cur);
|
|
2751
|
+
parent.set(cur, root);
|
|
2752
|
+
cur = next;
|
|
2753
|
+
}
|
|
2754
|
+
return root;
|
|
2755
|
+
}
|
|
2756
|
+
function union(a, b) {
|
|
2757
|
+
const ra = find2(a);
|
|
2758
|
+
const rb = find2(b);
|
|
2759
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
2760
|
+
}
|
|
2761
|
+
for (const pair of pairs) union(pair.aPath, pair.bPath);
|
|
2762
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2763
|
+
for (const pair of pairs) {
|
|
2764
|
+
const root = find2(pair.aPath);
|
|
2765
|
+
const group = groups.get(root) ?? { members: /* @__PURE__ */ new Set(), topScore: 0 };
|
|
2766
|
+
group.members.add(pair.aPath);
|
|
2767
|
+
group.members.add(pair.bPath);
|
|
2768
|
+
group.topScore = Math.max(group.topScore, pair.score);
|
|
2769
|
+
groups.set(root, group);
|
|
2770
|
+
}
|
|
2771
|
+
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]));
|
|
2772
|
+
}
|
|
2773
|
+
function toClusterMembers(members) {
|
|
2774
|
+
const sorted = Array.from(members).sort();
|
|
2775
|
+
if (sorted.length < 2) {
|
|
2776
|
+
throw new Error(`internal error: overlap cluster has fewer than 2 members (${sorted.length})`);
|
|
2777
|
+
}
|
|
2778
|
+
return sorted;
|
|
2779
|
+
}
|
|
2780
|
+
function defaultWhich(cmd) {
|
|
2781
|
+
const pathEnv = process.env.PATH ?? "";
|
|
2782
|
+
const dirs = pathEnv.split(delimiter2).filter(Boolean);
|
|
2783
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
2784
|
+
for (const dir of dirs) {
|
|
2785
|
+
for (const ext of exts) {
|
|
2786
|
+
const candidate = join16(dir, cmd + ext);
|
|
2787
|
+
try {
|
|
2788
|
+
if (statSync8(candidate).isFile()) return candidate;
|
|
2789
|
+
} catch {
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
return void 0;
|
|
2794
|
+
}
|
|
2795
|
+
var SKILLSPECTOR_PROVIDER_KEYMAP = {
|
|
2796
|
+
openai: "OPENAI_API_KEY",
|
|
2797
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
2798
|
+
nv_build: "NVIDIA_INFERENCE_KEY"
|
|
2799
|
+
};
|
|
2800
|
+
function checkTooling(which2, env) {
|
|
2801
|
+
const skillspectorPath = which2("skillspector");
|
|
2802
|
+
const skillspector = skillspectorPath ? { installed: true } : {
|
|
2803
|
+
installed: false,
|
|
2804
|
+
hint: "NOT installed \u2014 github.com/NVIDIA/skillspector (git clone + make install; Python 3.12+)"
|
|
2805
|
+
};
|
|
2806
|
+
const tokenPresent = Boolean(env.VERCEL_OIDC_TOKEN);
|
|
2807
|
+
const skillsShToken = tokenPresent ? { present: true } : {
|
|
2808
|
+
present: false,
|
|
2809
|
+
hint: "missing \u2014 enable OIDC Federation on your Vercel project, then `vercel link && vercel env pull`"
|
|
2810
|
+
};
|
|
2811
|
+
const provider = env.SKILLSPECTOR_PROVIDER;
|
|
2812
|
+
const providerKeyEnv = provider ? SKILLSPECTOR_PROVIDER_KEYMAP[provider] : void 0;
|
|
2813
|
+
const skillspectorLlm = {
|
|
2814
|
+
configured: Boolean(provider && providerKeyEnv && env[providerKeyEnv]),
|
|
2815
|
+
provider: provider || void 0
|
|
2816
|
+
};
|
|
2817
|
+
return { skillspector, skillsShToken, skillspectorLlm };
|
|
2818
|
+
}
|
|
2819
|
+
async function auditConfiguredSet(config, options = {}) {
|
|
2820
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
2821
|
+
const oversizedThreshold = options.oversizedSkillTokens ?? OVERSIZED_SKILL_EST_TOKENS;
|
|
2822
|
+
const findings = [];
|
|
2823
|
+
const notices = [];
|
|
2824
|
+
const { candidates, rootMeta } = collectSkillCandidates(config.skillsRoots);
|
|
2825
|
+
const groups = dedupeSkillCandidates(candidates, findings);
|
|
2826
|
+
const unreadableSkillMdPaths = /* @__PURE__ */ new Set();
|
|
2827
|
+
const skills = groups.map((g) => profileAuditedSkill(g, findings, unreadableSkillMdPaths));
|
|
2828
|
+
const skillRoots = config.skillsRoots.map((root) => {
|
|
2829
|
+
const meta = rootMeta.get(root) ?? { exists: false };
|
|
2830
|
+
if (!meta.exists) {
|
|
2831
|
+
findings.push({ severity: "MEDIUM", rule: "missing-skills-root", target: root, detail: "configured skills root does not exist on disk" });
|
|
2832
|
+
} else if (!meta.realPath) {
|
|
2833
|
+
findings.push({
|
|
2834
|
+
severity: "MEDIUM",
|
|
2835
|
+
rule: "unreadable-skills-root",
|
|
2836
|
+
target: root,
|
|
2837
|
+
detail: "could not resolve skills root to a real path (broken symlink or cycle)"
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
return {
|
|
2841
|
+
root,
|
|
2842
|
+
realPath: meta.realPath ?? resolveConfiguredRoot(root),
|
|
2843
|
+
exists: meta.exists,
|
|
2844
|
+
skillCount: skills.filter((s) => s.root === root).length
|
|
2845
|
+
};
|
|
2846
|
+
});
|
|
2847
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2848
|
+
for (const skill of skills) {
|
|
2849
|
+
const arr = byName.get(skill.name) ?? [];
|
|
2850
|
+
arr.push(skill);
|
|
2851
|
+
byName.set(skill.name, arr);
|
|
2852
|
+
}
|
|
2853
|
+
for (const [name, group] of byName) {
|
|
2854
|
+
if (group.length > 1) {
|
|
2855
|
+
for (const skill of group) {
|
|
2856
|
+
const otherPaths = group.filter((g) => g.path !== skill.path).map((g) => g.path);
|
|
2857
|
+
findings.push({
|
|
2858
|
+
severity: "MEDIUM",
|
|
2859
|
+
rule: "duplicate-skill-name",
|
|
2860
|
+
target: skill.path,
|
|
2861
|
+
detail: `name "${name}" is duplicated \u2014 also found at ${otherPaths.join(", ")}`
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
for (const skill of skills) {
|
|
2867
|
+
if (skill.estTokens > oversizedThreshold) {
|
|
2868
|
+
findings.push({
|
|
2869
|
+
severity: "LOW",
|
|
2870
|
+
rule: "oversized-skill",
|
|
2871
|
+
target: skill.skillMdPath,
|
|
2872
|
+
detail: `est. ${skill.estTokens} tokens exceeds the ${oversizedThreshold}-token threshold \u2014 consider trimming or splitting`
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
const safetyVerdictBySkillPath = /* @__PURE__ */ new Map();
|
|
2877
|
+
for (const skill of skills) {
|
|
2878
|
+
try {
|
|
2879
|
+
const result = await scanSafety(skill.path, config.strictness);
|
|
2880
|
+
findings.push(...toAuditFindings(result.findings));
|
|
2881
|
+
safetyVerdictBySkillPath.set(skill.path, result.verdict);
|
|
2882
|
+
} catch (err) {
|
|
2883
|
+
findings.push({
|
|
2884
|
+
severity: "MEDIUM",
|
|
2885
|
+
rule: "unreadable-skill",
|
|
2886
|
+
target: skill.path,
|
|
2887
|
+
detail: `safety scan failed: ${err.message}`
|
|
2888
|
+
});
|
|
2889
|
+
safetyVerdictBySkillPath.set(skill.path, "block");
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
const mcpTargetPaths = config.mcpTargets ?? [];
|
|
2893
|
+
const mcpTargets = [];
|
|
2894
|
+
for (const targetPath of mcpTargetPaths) {
|
|
2895
|
+
const { target, findings: targetFindings } = auditMcpTarget(targetPath, notices);
|
|
2896
|
+
mcpTargets.push(target);
|
|
2897
|
+
findings.push(...targetFindings);
|
|
2898
|
+
}
|
|
2899
|
+
const overlapStatus = proFeatureStatus("installed-set overlap analysis");
|
|
2900
|
+
let overlapClusters = [];
|
|
2901
|
+
if (overlapStatus.enabled) {
|
|
2902
|
+
if (overlapStatus.notice) notices.push(overlapStatus.notice);
|
|
2903
|
+
try {
|
|
2904
|
+
const readableSkillMdPaths = skills.map((s) => s.skillMdPath).filter((p) => !unreadableSkillMdPaths.has(p));
|
|
2905
|
+
const pairs = scanOverlapPaths(readableSkillMdPaths, options.overlapThreshold);
|
|
2906
|
+
overlapClusters = clusterOverlapPairs(pairs);
|
|
2907
|
+
} catch (err) {
|
|
2908
|
+
findings.push({
|
|
2909
|
+
severity: "MEDIUM",
|
|
2910
|
+
rule: "overlap-scan-failed",
|
|
2911
|
+
target: "installed-set overlap analysis",
|
|
2912
|
+
detail: `cross-root overlap scan failed: ${err.message}`
|
|
2913
|
+
});
|
|
2914
|
+
notices.push(`cross-root overlap scan failed \u2014 overlap clusters unavailable this run: ${err.message}`);
|
|
2915
|
+
overlapClusters = [];
|
|
2916
|
+
}
|
|
2917
|
+
} else {
|
|
2918
|
+
notices.push(overlapStatus.notice ?? "Pro feature (installed-set overlap analysis) locked.");
|
|
2919
|
+
}
|
|
2920
|
+
const evolveEligible = skills.filter((s) => s.validFrontmatter && safetyVerdictBySkillPath.get(s.path) === "pass").map((s) => ({ name: s.name, path: s.path }));
|
|
2921
|
+
const foundation = config.foundationSkillPath ? {
|
|
2922
|
+
available: false,
|
|
2923
|
+
existingPath: config.foundationSkillPath,
|
|
2924
|
+
message: `a business-foundation skill already exists at ${config.foundationSkillPath}`
|
|
2925
|
+
} : {
|
|
2926
|
+
available: true,
|
|
2927
|
+
message: "no business-foundation skill yet \u2014 `skill-forge audit --foundation` can scaffold one to ground future skills in your business profile"
|
|
2928
|
+
};
|
|
2929
|
+
const findingCounts = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0 };
|
|
2930
|
+
for (const f of findings) findingCounts[f.severity]++;
|
|
2931
|
+
const summary = {
|
|
2932
|
+
rootCount: skillRoots.length,
|
|
2933
|
+
skillCount: skills.length,
|
|
2934
|
+
mcpTargetCount: mcpTargets.length,
|
|
2935
|
+
findingCounts,
|
|
2936
|
+
overlapClusterCount: overlapClusters.length,
|
|
2937
|
+
evolveEligibleCount: evolveEligible.length
|
|
2938
|
+
};
|
|
2939
|
+
const tooling = checkTooling(options.which ?? defaultWhich, options.env ?? process.env);
|
|
2940
|
+
return {
|
|
2941
|
+
schemaVersion: 1,
|
|
2942
|
+
generatedAt: now().toISOString(),
|
|
2943
|
+
inventory: { skillRoots, skills, mcpTargets },
|
|
2944
|
+
hygiene: findings,
|
|
2945
|
+
opportunities: { overlapClusters, evolveEligible, foundation, overlapLocked: !overlapStatus.enabled },
|
|
2946
|
+
tooling,
|
|
2947
|
+
notices,
|
|
2948
|
+
summary
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
function bulletList(items, emptyPlaceholder) {
|
|
2952
|
+
return items.length > 0 ? items.map((i) => `- ${i}`) : [`- ${emptyPlaceholder}`];
|
|
2953
|
+
}
|
|
2954
|
+
function sanitizeForMarkdown(s) {
|
|
2955
|
+
return s.replace(/[\x00-\x1F\x7F-\x9F]/g, "");
|
|
2956
|
+
}
|
|
2957
|
+
function joinDefined(parts) {
|
|
2958
|
+
return parts.filter((v) => Boolean(v)).join(", ");
|
|
2959
|
+
}
|
|
2960
|
+
function renderRootsSection(roots) {
|
|
2961
|
+
return [
|
|
2962
|
+
"### Skill roots",
|
|
2963
|
+
"",
|
|
2964
|
+
...bulletList(
|
|
2965
|
+
roots.map((r) => `${r.root}${r.exists ? "" : " (MISSING)"} \u2014 ${r.skillCount} skill${r.skillCount === 1 ? "" : "s"}`),
|
|
2966
|
+
"(none configured)"
|
|
2967
|
+
)
|
|
2968
|
+
];
|
|
2969
|
+
}
|
|
2970
|
+
function renderSkillsSection(skills) {
|
|
2971
|
+
return [
|
|
2972
|
+
"### Skills",
|
|
2973
|
+
"",
|
|
2974
|
+
...bulletList(
|
|
2975
|
+
skills.map((s) => {
|
|
2976
|
+
const flags = joinDefined([
|
|
2977
|
+
s.validFrontmatter ? void 0 : "INVALID FRONTMATTER",
|
|
2978
|
+
s.aliasPaths.length > 0 ? `${s.aliasPaths.length} alias path(s)` : void 0
|
|
2979
|
+
]);
|
|
2980
|
+
return `${sanitizeForMarkdown(s.name)} \u2014 ${s.path} (est. ${s.estTokens} tokens)${flags ? ` [${flags}]` : ""}`;
|
|
2981
|
+
}),
|
|
2982
|
+
"(none found)"
|
|
2983
|
+
)
|
|
2984
|
+
];
|
|
2985
|
+
}
|
|
2986
|
+
function renderMcpTargetsSection(targets) {
|
|
2987
|
+
const lines = ["### MCP targets", ""];
|
|
2988
|
+
if (targets.length === 0) {
|
|
2989
|
+
lines.push("- (none configured)");
|
|
2990
|
+
return lines;
|
|
2991
|
+
}
|
|
2992
|
+
for (const t of targets) {
|
|
2993
|
+
lines.push(`- ${t.path} \u2014 ${t.format}, ${t.status}`);
|
|
2994
|
+
for (const server of t.servers) {
|
|
2995
|
+
const detail = joinDefined([
|
|
2996
|
+
server.commandBasename ? sanitizeForMarkdown(server.commandBasename) : void 0,
|
|
2997
|
+
server.packageSpec ? sanitizeForMarkdown(server.packageSpec) : void 0,
|
|
2998
|
+
`${server.argCount} arg${server.argCount === 1 ? "" : "s"}`,
|
|
2999
|
+
`${server.envCount} env var${server.envCount === 1 ? "" : "s"}`
|
|
3000
|
+
]);
|
|
3001
|
+
lines.push(` - ${sanitizeForMarkdown(server.name)} (${detail})`);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
return lines;
|
|
3005
|
+
}
|
|
3006
|
+
function renderFindingsSection(findings) {
|
|
3007
|
+
return [
|
|
3008
|
+
"## Hygiene findings",
|
|
3009
|
+
"",
|
|
3010
|
+
...bulletList(
|
|
3011
|
+
findings.map((f) => `[${f.severity}] ${f.rule} \u2014 ${sanitizeForMarkdown(f.target)}: ${sanitizeForMarkdown(f.detail)}`),
|
|
3012
|
+
"(none \u2014 clean)"
|
|
3013
|
+
)
|
|
3014
|
+
];
|
|
3015
|
+
}
|
|
3016
|
+
function renderOpportunitiesSection(report) {
|
|
3017
|
+
const { overlapClusters, evolveEligible, foundation } = report.opportunities;
|
|
3018
|
+
return [
|
|
3019
|
+
"## Opportunities",
|
|
3020
|
+
"",
|
|
3021
|
+
"### Overlap clusters",
|
|
3022
|
+
"",
|
|
3023
|
+
...bulletList(
|
|
3024
|
+
overlapClusters.map((c) => `top score ${c.topScore} \u2014 ${c.suggestedVerb} \u2014 ${c.members.join(", ")}`),
|
|
3025
|
+
"(none \u2014 either no cross-root overlap found, or the Pro feature is locked, see Notices)"
|
|
3026
|
+
),
|
|
3027
|
+
"",
|
|
3028
|
+
"### Evolve-eligible",
|
|
3029
|
+
"",
|
|
3030
|
+
...bulletList(
|
|
3031
|
+
evolveEligible.map((c) => `${sanitizeForMarkdown(c.name)} \u2014 ${c.path}`),
|
|
3032
|
+
"(none)"
|
|
3033
|
+
),
|
|
3034
|
+
"",
|
|
3035
|
+
"### Foundation skill",
|
|
3036
|
+
"",
|
|
3037
|
+
`- ${foundation.message}`
|
|
3038
|
+
];
|
|
3039
|
+
}
|
|
3040
|
+
function renderToolingSection(tooling) {
|
|
3041
|
+
const lines = ["## Tooling", ""];
|
|
3042
|
+
if (!tooling) {
|
|
3043
|
+
lines.push("- (not available in this report)");
|
|
3044
|
+
return lines;
|
|
3045
|
+
}
|
|
3046
|
+
lines.push(`- SkillSpector CLI: ${tooling.skillspector.installed ? "installed" : "NOT installed"}`);
|
|
3047
|
+
if (tooling.skillspector.hint) lines.push(` - ${tooling.skillspector.hint}`);
|
|
3048
|
+
lines.push(`- skills.sh auth (VERCEL_OIDC_TOKEN): ${tooling.skillsShToken.present ? "present" : "missing"}`);
|
|
3049
|
+
if (tooling.skillsShToken.hint) lines.push(` - ${tooling.skillsShToken.hint}`);
|
|
3050
|
+
const llm = tooling.skillspectorLlm;
|
|
3051
|
+
if (llm.configured) {
|
|
3052
|
+
lines.push(`- SkillSpector LLM stage (optional): configured (provider: ${sanitizeForMarkdown(llm.provider ?? "")})`);
|
|
3053
|
+
} else if (llm.provider) {
|
|
3054
|
+
lines.push(`- SkillSpector LLM stage (optional): not configured \u2014 SKILLSPECTOR_PROVIDER is "${sanitizeForMarkdown(llm.provider)}" but its API key env var is not set`);
|
|
3055
|
+
} else {
|
|
3056
|
+
lines.push(
|
|
3057
|
+
"- SkillSpector LLM stage (optional): not set \u2014 static scan works without it (use --no-llm); set SKILLSPECTOR_PROVIDER + its API key for semantic analysis"
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
3060
|
+
return lines;
|
|
3061
|
+
}
|
|
3062
|
+
function renderAuditMarkdown(report) {
|
|
3063
|
+
const { summary } = report;
|
|
3064
|
+
const lines = [
|
|
3065
|
+
`# skill-forge audit \u2014 ${report.generatedAt}`,
|
|
3066
|
+
"",
|
|
3067
|
+
"## Summary",
|
|
3068
|
+
"",
|
|
3069
|
+
`- Skill roots: ${summary.rootCount}`,
|
|
3070
|
+
`- Skills: ${summary.skillCount}`,
|
|
3071
|
+
`- MCP targets: ${summary.mcpTargetCount}`,
|
|
3072
|
+
`- Findings: ${summary.findingCounts.CRITICAL} CRITICAL, ${summary.findingCounts.HIGH} HIGH, ${summary.findingCounts.MEDIUM} MEDIUM, ${summary.findingCounts.LOW} LOW`,
|
|
3073
|
+
`- Overlap clusters: ${summary.overlapClusterCount}`,
|
|
3074
|
+
`- Evolve-eligible skills: ${summary.evolveEligibleCount}`,
|
|
3075
|
+
"",
|
|
3076
|
+
"## Inventory",
|
|
3077
|
+
"",
|
|
3078
|
+
...renderRootsSection(report.inventory.skillRoots),
|
|
3079
|
+
"",
|
|
3080
|
+
...renderSkillsSection(report.inventory.skills),
|
|
3081
|
+
"",
|
|
3082
|
+
...renderMcpTargetsSection(report.inventory.mcpTargets),
|
|
3083
|
+
"",
|
|
3084
|
+
...renderFindingsSection(report.hygiene),
|
|
3085
|
+
"",
|
|
3086
|
+
...renderOpportunitiesSection(report),
|
|
3087
|
+
"",
|
|
3088
|
+
...renderToolingSection(report.tooling),
|
|
3089
|
+
"",
|
|
3090
|
+
"## Notices",
|
|
3091
|
+
"",
|
|
3092
|
+
...bulletList(report.notices, "(none)")
|
|
3093
|
+
];
|
|
3094
|
+
return lines.join("\n") + "\n";
|
|
3095
|
+
}
|
|
3096
|
+
function renderFoundationSkillMd(profile) {
|
|
3097
|
+
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.";
|
|
3098
|
+
const lines = [
|
|
3099
|
+
"---",
|
|
3100
|
+
"name: business-foundation",
|
|
3101
|
+
`description: ${description}`,
|
|
3102
|
+
"---",
|
|
3103
|
+
"",
|
|
3104
|
+
`# Business Foundation${profile.businessName ? `: ${profile.businessName}` : ""}`,
|
|
3105
|
+
"",
|
|
3106
|
+
profile.industry ? `**Industry:** ${profile.industry}` : void 0,
|
|
3107
|
+
"",
|
|
3108
|
+
"## Audiences",
|
|
3109
|
+
"",
|
|
3110
|
+
...bulletList(profile.audiences, "(none captured yet)"),
|
|
3111
|
+
"",
|
|
3112
|
+
"## Workflows",
|
|
3113
|
+
"",
|
|
3114
|
+
...bulletList(profile.workflows, "(none captured yet)"),
|
|
3115
|
+
"",
|
|
3116
|
+
"## Constraints",
|
|
3117
|
+
"",
|
|
3118
|
+
...bulletList(profile.constraints, "(none captured yet)"),
|
|
3119
|
+
"",
|
|
3120
|
+
"## Integration guidance",
|
|
3121
|
+
"",
|
|
3122
|
+
"New and existing skills in this set should align with the business context above:",
|
|
3123
|
+
"- Match the audiences, terminology, and tone described here.",
|
|
3124
|
+
"- Respect the workflows and constraints listed above when proposing changes.",
|
|
3125
|
+
"- 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."
|
|
3126
|
+
];
|
|
3127
|
+
return lines.filter((l) => l !== void 0).join("\n") + "\n";
|
|
3128
|
+
}
|
|
3129
|
+
async function scaffoldFoundationSkill(config, profile, targetRoot) {
|
|
3130
|
+
const resolvedRoot = resolveConfiguredRoot(targetRoot);
|
|
3131
|
+
const configuredRoots = config.skillsRoots.map(resolveConfiguredRoot);
|
|
3132
|
+
if (!configuredRoots.includes(resolvedRoot)) {
|
|
3133
|
+
throw new UserError(`refusing to scaffold a foundation skill under ${resolvedRoot}: not one of the configured skillsRoots`);
|
|
3134
|
+
}
|
|
3135
|
+
if (!isDir(resolvedRoot)) {
|
|
3136
|
+
throw new UserError(`skills root does not exist: ${resolvedRoot}`);
|
|
3137
|
+
}
|
|
3138
|
+
const destDir = join16(resolvedRoot, "business-foundation");
|
|
3139
|
+
let destLstat;
|
|
3140
|
+
try {
|
|
3141
|
+
destLstat = lstatSync(destDir);
|
|
3142
|
+
} catch {
|
|
3143
|
+
destLstat = void 0;
|
|
3144
|
+
}
|
|
3145
|
+
if (destLstat?.isSymbolicLink()) {
|
|
3146
|
+
throw new UserError(`refusing to scaffold a foundation skill at ${destDir}: destination is a symlink`);
|
|
3147
|
+
}
|
|
3148
|
+
if (destLstat) {
|
|
3149
|
+
throw new UserError(`refusing to scaffold a foundation skill at ${destDir}: already exists`);
|
|
3150
|
+
}
|
|
3151
|
+
const content = renderFoundationSkillMd(profile);
|
|
3152
|
+
const tempDir = mkdtempSync2(join16(resolvedRoot, ".business-foundation-"));
|
|
3153
|
+
try {
|
|
3154
|
+
writeFileSync5(join16(tempDir, "SKILL.md"), content, { flag: "wx", mode: 384 });
|
|
3155
|
+
const safety = await scanSafety(tempDir, config.strictness);
|
|
3156
|
+
if (safety.verdict === "block") {
|
|
3157
|
+
throw new UserError(
|
|
3158
|
+
`refusing to scaffold a foundation skill: the generated SKILL.md was flagged by the safety scan (${safety.findings.map(formatSafetyFinding).join("; ")})`
|
|
3159
|
+
);
|
|
3160
|
+
}
|
|
3161
|
+
renameSync3(tempDir, destDir);
|
|
3162
|
+
} catch (err) {
|
|
3163
|
+
rmSync4(tempDir, { recursive: true, force: true });
|
|
3164
|
+
throw err;
|
|
3165
|
+
}
|
|
3166
|
+
try {
|
|
3167
|
+
appendProvenance(
|
|
3168
|
+
{
|
|
3169
|
+
name: "business-foundation",
|
|
3170
|
+
source: "skill-forge audit (business profile)",
|
|
3171
|
+
verb: "DEFER",
|
|
3172
|
+
target: destDir,
|
|
3173
|
+
took: "scaffolded a new business-foundation skill from the captured business profile",
|
|
3174
|
+
notes: "scaffolded by skill-forge audit"
|
|
3175
|
+
},
|
|
3176
|
+
resolvedRoot
|
|
3177
|
+
);
|
|
3178
|
+
} catch (err) {
|
|
3179
|
+
throw new UserError(
|
|
3180
|
+
`the business-foundation skill was created at ${destDir}, but recording the provenance entry failed: ${err.message}`
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
return destDir;
|
|
2514
3184
|
}
|
|
2515
3185
|
|
|
2516
3186
|
// src/decision.ts
|
|
2517
|
-
import { createInterface
|
|
3187
|
+
import { createInterface } from "readline/promises";
|
|
2518
3188
|
function logOutcome(message, json) {
|
|
2519
3189
|
if (json) console.error(message);
|
|
2520
3190
|
else console.log(message);
|
|
@@ -2523,7 +3193,7 @@ function yesDecision(verdict) {
|
|
|
2523
3193
|
return verdict === "block" ? "reject" : "promote";
|
|
2524
3194
|
}
|
|
2525
3195
|
async function promptDecision(verdict, subject) {
|
|
2526
|
-
const rl =
|
|
3196
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2527
3197
|
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2528
3198
|
const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2529
3199
|
try {
|
|
@@ -2542,6 +3212,326 @@ ${prompt}`);
|
|
|
2542
3212
|
}
|
|
2543
3213
|
}
|
|
2544
3214
|
|
|
3215
|
+
// src/commands/audit.ts
|
|
3216
|
+
var CURATION_PROMPT_ASSET = "curation-prompt.md";
|
|
3217
|
+
function splitCsv(s) {
|
|
3218
|
+
return s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
3219
|
+
}
|
|
3220
|
+
async function askAnswer(rl, prompt) {
|
|
3221
|
+
return (await rl.question(prompt)).trim().toLowerCase();
|
|
3222
|
+
}
|
|
3223
|
+
async function captureBusinessProfile(rl, existing) {
|
|
3224
|
+
console.log("\n[skill-forge] Business profile \u2014 grounds the business-foundation skill scaffold and the curation handoff prompt.");
|
|
3225
|
+
console.log(
|
|
3226
|
+
"[skill-forge] Do not enter credentials, API keys, tokens, or confidential customer data \u2014 this is stored in plain text in config.json."
|
|
3227
|
+
);
|
|
3228
|
+
if (existing) {
|
|
3229
|
+
const keep = await askAnswer(rl, `Reuse the existing business profile (captured ${existing.updatedAt})? [Y/n] `);
|
|
3230
|
+
if (keep === "" || keep === "y" || keep === "yes") return existing;
|
|
3231
|
+
}
|
|
3232
|
+
const businessName = (await rl.question("Business name (optional, Enter to skip): ")).trim() || void 0;
|
|
3233
|
+
const industry = (await rl.question("Industry (optional, Enter to skip): ")).trim() || void 0;
|
|
3234
|
+
const audiences = splitCsv(await rl.question("Audiences (comma-separated, optional): "));
|
|
3235
|
+
const workflows = splitCsv(await rl.question("Workflows (comma-separated, optional): "));
|
|
3236
|
+
const constraints = splitCsv(await rl.question("Constraints (comma-separated, optional): "));
|
|
3237
|
+
const profile = { businessName, industry, audiences, workflows, constraints, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3238
|
+
console.log("\n[skill-forge] Business profile summary:");
|
|
3239
|
+
console.log(` Business name: ${profile.businessName ?? "(none)"}`);
|
|
3240
|
+
console.log(` Industry: ${profile.industry ?? "(none)"}`);
|
|
3241
|
+
console.log(` Audiences: ${profile.audiences.join(", ") || "(none)"}`);
|
|
3242
|
+
console.log(` Workflows: ${profile.workflows.join(", ") || "(none)"}`);
|
|
3243
|
+
console.log(` Constraints: ${profile.constraints.join(", ") || "(none)"}`);
|
|
3244
|
+
const confirm = await askAnswer(rl, "Save this business profile? [Y/n] ");
|
|
3245
|
+
if (confirm === "n" || confirm === "no") return existing;
|
|
3246
|
+
return profile;
|
|
3247
|
+
}
|
|
3248
|
+
function resolveReportPath(reportOption) {
|
|
3249
|
+
if (reportOption) return resolvePath3(reportOption);
|
|
3250
|
+
const iso = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3251
|
+
return join17(getHomeDir(), "reports", `audit-${iso}.md`);
|
|
3252
|
+
}
|
|
3253
|
+
function writeReport(reportPath, contents) {
|
|
3254
|
+
mkdirSync6(dirname6(reportPath), { recursive: true });
|
|
3255
|
+
try {
|
|
3256
|
+
writeFileSync6(reportPath, contents, { flag: "wx", mode: 384 });
|
|
3257
|
+
} catch (err) {
|
|
3258
|
+
if (err.code === "EEXIST") {
|
|
3259
|
+
throw new UserError(`report file already exists: ${reportPath} \u2014 pass a different --report path`);
|
|
3260
|
+
}
|
|
3261
|
+
throw err;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
function printTerminalSummary(report, reportPath) {
|
|
3265
|
+
const { summary } = report;
|
|
3266
|
+
console.log("[skill-forge] audit complete");
|
|
3267
|
+
console.log(` skill roots: ${summary.rootCount} skills: ${summary.skillCount} MCP targets: ${summary.mcpTargetCount}`);
|
|
3268
|
+
console.log(
|
|
3269
|
+
` findings: ${summary.findingCounts.CRITICAL} CRITICAL, ${summary.findingCounts.HIGH} HIGH, ${summary.findingCounts.MEDIUM} MEDIUM, ${summary.findingCounts.LOW} LOW`
|
|
3270
|
+
);
|
|
3271
|
+
console.log(` overlap clusters: ${summary.overlapClusterCount} evolve-eligible: ${summary.evolveEligibleCount}`);
|
|
3272
|
+
console.log(` report -> ${reportPath}`);
|
|
3273
|
+
for (const notice of report.notices) console.log(` [notice] ${notice}`);
|
|
3274
|
+
console.log(
|
|
3275
|
+
" next steps: `skill-forge add <source>` to install something new; `skill-forge evolve <skill-dir>` to refine an evolve-eligible skill."
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
3278
|
+
async function maybeScaffoldFoundation(config, report, options, rl) {
|
|
3279
|
+
let wantFoundation = options.foundation ?? false;
|
|
3280
|
+
if (!wantFoundation && rl && config.businessProfile && report.opportunities.foundation.available) {
|
|
3281
|
+
const answer = await askAnswer(rl, "\nCreate a business-foundation skill now? [y/N] ");
|
|
3282
|
+
wantFoundation = answer === "y" || answer === "yes";
|
|
3283
|
+
}
|
|
3284
|
+
if (!wantFoundation) return config;
|
|
3285
|
+
if (!config.businessProfile) {
|
|
3286
|
+
throw new UserError(
|
|
3287
|
+
"--foundation requires a business profile \u2014 run `skill-forge audit` interactively first to capture one."
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3290
|
+
const targetRoot = config.defaultTarget ?? config.skillsRoots[0];
|
|
3291
|
+
if (!targetRoot) {
|
|
3292
|
+
throw new UserError("cannot scaffold a foundation skill: no skills root configured.");
|
|
3293
|
+
}
|
|
3294
|
+
const foundationPath = await scaffoldFoundationSkill(config, config.businessProfile, targetRoot);
|
|
3295
|
+
const updated = { ...config, foundationSkillPath: foundationPath };
|
|
3296
|
+
saveConfig(updated);
|
|
3297
|
+
logOutcome(`[skill-forge] scaffolded business-foundation skill -> ${foundationPath}`, options.json);
|
|
3298
|
+
return updated;
|
|
3299
|
+
}
|
|
3300
|
+
async function maybeHandoff(config, reportPath, options, rl) {
|
|
3301
|
+
let wantHandoff = options.handoff ?? false;
|
|
3302
|
+
if (!wantHandoff && rl) {
|
|
3303
|
+
const answer = await askAnswer(rl, "\nHand the curation report to a coding agent now? [y/N] ");
|
|
3304
|
+
wantHandoff = answer === "y" || answer === "yes";
|
|
3305
|
+
}
|
|
3306
|
+
if (!wantHandoff) return;
|
|
3307
|
+
runHandoff(config, reportPath, { promptAsset: CURATION_PROMPT_ASSET, log: options.json ? console.error : console.log });
|
|
3308
|
+
}
|
|
3309
|
+
async function auditCommand(options = {}) {
|
|
3310
|
+
await maybeOfferInit({ json: options.json, yes: options.yes, suppressAuditOffer: true });
|
|
3311
|
+
if (!existsSync14(getConfigPath())) {
|
|
3312
|
+
throw new UserError(
|
|
3313
|
+
"no skill-forge config found \u2014 run `skill-forge init` first (audit requires a configured skill/MCP set; it never audits an invented default)."
|
|
3314
|
+
);
|
|
3315
|
+
}
|
|
3316
|
+
let config = loadConfig();
|
|
3317
|
+
const interactive = !options.json && !options.yes && Boolean(process.stdin.isTTY);
|
|
3318
|
+
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : void 0;
|
|
3319
|
+
try {
|
|
3320
|
+
if (rl) {
|
|
3321
|
+
const profile = await captureBusinessProfile(rl, config.businessProfile);
|
|
3322
|
+
if (profile !== config.businessProfile) {
|
|
3323
|
+
config = { ...config, businessProfile: profile };
|
|
3324
|
+
saveConfig(config);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
const report = await auditConfiguredSet(config);
|
|
3328
|
+
const reportPath = resolveReportPath(options.report);
|
|
3329
|
+
writeReport(reportPath, renderAuditMarkdown(report));
|
|
3330
|
+
if (options.json) {
|
|
3331
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3332
|
+
} else {
|
|
3333
|
+
printTerminalSummary(report, reportPath);
|
|
3334
|
+
}
|
|
3335
|
+
config = await maybeScaffoldFoundation(config, report, options, rl);
|
|
3336
|
+
await maybeHandoff(config, reportPath, options, rl);
|
|
3337
|
+
} finally {
|
|
3338
|
+
rl?.close();
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
// src/commands/init.ts
|
|
3343
|
+
function detectAgents(cwd, home) {
|
|
3344
|
+
const found = [];
|
|
3345
|
+
for (const agent of AGENTS) {
|
|
3346
|
+
const hit = detectAgentRoot(agent, cwd, home);
|
|
3347
|
+
if (hit) found.push({ agent, ...hit });
|
|
3348
|
+
}
|
|
3349
|
+
return found;
|
|
3350
|
+
}
|
|
3351
|
+
function detectMcpTargets(cwd, home) {
|
|
3352
|
+
const found = [];
|
|
3353
|
+
for (const agent of AGENTS) {
|
|
3354
|
+
for (const hit of detectMcpConfigFiles(agent, cwd, home)) {
|
|
3355
|
+
found.push({ agent, ...hit });
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
return found;
|
|
3359
|
+
}
|
|
3360
|
+
function printDetectionResults(detected) {
|
|
3361
|
+
console.log(`[skill-forge] init \u2014 agent detection (${AGENTS.length} known agents)`);
|
|
3362
|
+
if (detected.length === 0) {
|
|
3363
|
+
console.log(" none detected");
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
for (const d of detected) {
|
|
3367
|
+
console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.root}`);
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
function printMcpDetectionResults(detectedMcp) {
|
|
3371
|
+
if (detectedMcp.length === 0) return;
|
|
3372
|
+
console.log("\n[skill-forge] init \u2014 MCP config detection");
|
|
3373
|
+
for (const d of detectedMcp) {
|
|
3374
|
+
console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.path}`);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
function handoffTemplateFor(agent) {
|
|
3378
|
+
return agent.defaultHandoffTemplate ?? [agent.id, "-p", HANDOFF_INSTRUCTION];
|
|
3379
|
+
}
|
|
3380
|
+
function buildConfig(selected, defaultTargetEntry, handoffAgent, selectedMcp = []) {
|
|
3381
|
+
const existing = loadConfig();
|
|
3382
|
+
return {
|
|
3383
|
+
...existing,
|
|
3384
|
+
// Skill-root selections are only overwritten when this run actually offered a choice —
|
|
3385
|
+
// an mcp-only run (no skill roots detected) must not blank out an existing skillsRoots/agents.
|
|
3386
|
+
skillsRoots: selected.length > 0 ? selected.map((s) => s.root) : existing.skillsRoots,
|
|
3387
|
+
agents: selected.length > 0 ? selected.map((s) => ({ id: s.agent.id, skillsRoot: s.root })) : existing.agents,
|
|
3388
|
+
defaultTarget: defaultTargetEntry?.root ?? existing.defaultTarget,
|
|
3389
|
+
handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand,
|
|
3390
|
+
mcpTargets: selectedMcp.length > 0 ? selectedMcp.map((m) => m.path) : existing.mcpTargets
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
function printSummary(config) {
|
|
3394
|
+
console.log(`
|
|
3395
|
+
[skill-forge] wrote config to ${getConfigPath()}`);
|
|
3396
|
+
console.log(` gate targets: ${config.skillsRoots.join(", ")}`);
|
|
3397
|
+
if (config.defaultTarget) console.log(` default target: ${config.defaultTarget}`);
|
|
3398
|
+
if (config.handoffCommand?.length) console.log(` handoff command: ${config.handoffCommand.join(" ")}`);
|
|
3399
|
+
if (config.mcpTargets?.length) console.log(` mcp targets: ${config.mcpTargets.join(", ")}`);
|
|
3400
|
+
}
|
|
3401
|
+
function parseChoice(answer, max) {
|
|
3402
|
+
const n = Number(answer);
|
|
3403
|
+
return Number.isInteger(n) && n >= 1 && n <= max ? n - 1 : void 0;
|
|
3404
|
+
}
|
|
3405
|
+
async function promptMultiSelect(rl, detected) {
|
|
3406
|
+
const chosen = new Set(detected.map((_, i) => i));
|
|
3407
|
+
for (; ; ) {
|
|
3408
|
+
console.log("\nGate targets (all detected roots pre-selected \u2014 type a number to toggle, Enter to confirm):");
|
|
3409
|
+
detected.forEach((d, i) => {
|
|
3410
|
+
const mark = chosen.has(i) ? "[x]" : "[ ]";
|
|
3411
|
+
console.log(` ${mark} ${i + 1}. ${d.agent.displayName} (${d.scope}) \u2014 ${d.root}`);
|
|
3412
|
+
});
|
|
3413
|
+
const answer = (await rl.question("> ")).trim();
|
|
3414
|
+
if (answer === "") break;
|
|
3415
|
+
const idx = parseChoice(answer, detected.length);
|
|
3416
|
+
if (idx !== void 0) {
|
|
3417
|
+
if (chosen.has(idx)) chosen.delete(idx);
|
|
3418
|
+
else chosen.add(idx);
|
|
3419
|
+
} else {
|
|
3420
|
+
console.log(" (invalid selection \u2014 enter a listed number, or press Enter to confirm)");
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
return detected.filter((_, i) => chosen.has(i));
|
|
3424
|
+
}
|
|
3425
|
+
async function promptMcpMultiSelect(rl, detectedMcp) {
|
|
3426
|
+
const chosen = new Set(detectedMcp.map((_, i) => i));
|
|
3427
|
+
for (; ; ) {
|
|
3428
|
+
console.log("\nMCP targets (all detected config files pre-selected \u2014 type a number to toggle, Enter to confirm):");
|
|
3429
|
+
detectedMcp.forEach((d, i) => {
|
|
3430
|
+
const mark = chosen.has(i) ? "[x]" : "[ ]";
|
|
3431
|
+
console.log(` ${mark} ${i + 1}. ${d.agent.displayName} (${d.scope}) \u2014 ${d.path}`);
|
|
3432
|
+
});
|
|
3433
|
+
const answer = (await rl.question("> ")).trim();
|
|
3434
|
+
if (answer === "") break;
|
|
3435
|
+
const idx = parseChoice(answer, detectedMcp.length);
|
|
3436
|
+
if (idx !== void 0) {
|
|
3437
|
+
if (chosen.has(idx)) chosen.delete(idx);
|
|
3438
|
+
else chosen.add(idx);
|
|
3439
|
+
} else {
|
|
3440
|
+
console.log(" (invalid selection \u2014 enter a listed number, or press Enter to confirm)");
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
return detectedMcp.filter((_, i) => chosen.has(i));
|
|
3444
|
+
}
|
|
3445
|
+
async function promptDefaultTarget(rl, selected) {
|
|
3446
|
+
console.log("\nDefault promotion target:");
|
|
3447
|
+
selected.forEach((d, i) => console.log(` ${i + 1}. ${d.agent.displayName} \u2014 ${d.root}`));
|
|
3448
|
+
const answer = (await rl.question(`Choose [1-${selected.length}] (default 1): `)).trim();
|
|
3449
|
+
const idx = answer === "" ? 0 : parseChoice(answer, selected.length) ?? 0;
|
|
3450
|
+
return selected[idx];
|
|
3451
|
+
}
|
|
3452
|
+
async function promptHandoffAgent(rl, detected) {
|
|
3453
|
+
console.log("\nHandoff agent (used to hand off follow-up prompts after promotion; optional):");
|
|
3454
|
+
detected.forEach((d, i) => {
|
|
3455
|
+
const note = d.agent.defaultHandoffTemplate ? "" : " (no confirmed CLI \u2014 best-effort)";
|
|
3456
|
+
console.log(` ${i + 1}. ${d.agent.displayName}${note}`);
|
|
3457
|
+
});
|
|
3458
|
+
const answer = (await rl.question(`Choose [1-${detected.length}] or Enter to skip: `)).trim();
|
|
3459
|
+
if (answer === "") return void 0;
|
|
3460
|
+
const idx = parseChoice(answer, detected.length);
|
|
3461
|
+
return idx === void 0 ? void 0 : detected[idx].agent;
|
|
3462
|
+
}
|
|
3463
|
+
async function init(options = {}, deps = {}) {
|
|
3464
|
+
const runAudit = deps.runAudit ?? auditCommand;
|
|
3465
|
+
const cwd = options.cwd ?? process.cwd();
|
|
3466
|
+
const home = options.home ?? homedir4();
|
|
3467
|
+
const detected = detectAgents(cwd, home);
|
|
3468
|
+
const detectedMcp = detectMcpTargets(cwd, home);
|
|
3469
|
+
if (options.list) {
|
|
3470
|
+
printDetectionResults(detected);
|
|
3471
|
+
printMcpDetectionResults(detectedMcp);
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
if (detected.length === 0 && detectedMcp.length === 0) {
|
|
3475
|
+
console.log("[skill-forge] No known agent skill directories or MCP config files were found.");
|
|
3476
|
+
console.log(" Install a coding agent (Claude Code, Codex, Cursor, ...) or create a skills root, then re-run `skill-forge init`.");
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
if (options.defaults) {
|
|
3480
|
+
const config = buildConfig(detected, detected[0], void 0, detectedMcp);
|
|
3481
|
+
saveConfig(config);
|
|
3482
|
+
printSummary(config);
|
|
3483
|
+
await runAudit({ yes: true });
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
if (!process.stdin.isTTY) {
|
|
3487
|
+
console.log("[skill-forge] Not an interactive terminal \u2014 run with --defaults for non-interactive setup.");
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
printDetectionResults(detected);
|
|
3491
|
+
printMcpDetectionResults(detectedMcp);
|
|
3492
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
3493
|
+
let runAuditNow = false;
|
|
3494
|
+
try {
|
|
3495
|
+
const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
|
|
3496
|
+
const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
|
|
3497
|
+
if (selected.length === 0 && selectedMcp.length === 0) {
|
|
3498
|
+
console.log("[skill-forge] No targets selected \u2014 aborting, nothing written.");
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
const target = selected.length > 0 ? await promptDefaultTarget(rl, selected) : void 0;
|
|
3502
|
+
const handoffAgent = detected.length > 0 ? await promptHandoffAgent(rl, detected) : void 0;
|
|
3503
|
+
const config = buildConfig(selected, target, handoffAgent, selectedMcp);
|
|
3504
|
+
saveConfig(config);
|
|
3505
|
+
printSummary(config);
|
|
3506
|
+
if (options.offerAudit !== false) {
|
|
3507
|
+
const answer = (await rl.question("\nRun the skills & MCP audit now? [Y/n] ")).trim().toLowerCase();
|
|
3508
|
+
runAuditNow = answer === "" || answer === "y" || answer === "yes";
|
|
3509
|
+
}
|
|
3510
|
+
} finally {
|
|
3511
|
+
rl.close();
|
|
3512
|
+
}
|
|
3513
|
+
if (runAuditNow) {
|
|
3514
|
+
await runAudit();
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
async function maybeOfferInit(options = {}) {
|
|
3518
|
+
if (options.json || options.yes || !process.stdin.isTTY) return;
|
|
3519
|
+
if (existsSync15(getConfigPath())) return;
|
|
3520
|
+
console.log(
|
|
3521
|
+
"[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)."
|
|
3522
|
+
);
|
|
3523
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
3524
|
+
let answer = "";
|
|
3525
|
+
try {
|
|
3526
|
+
answer = (await rl.question("Run `skill-forge init` now? [y/N] ")).trim().toLowerCase();
|
|
3527
|
+
} finally {
|
|
3528
|
+
rl.close();
|
|
3529
|
+
}
|
|
3530
|
+
if (answer === "y" || answer === "yes") {
|
|
3531
|
+
await init(options.suppressAuditOffer ? { offerAudit: false } : {});
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
|
|
2545
3535
|
// src/commands/add.ts
|
|
2546
3536
|
var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
|
|
2547
3537
|
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
@@ -2669,7 +3659,7 @@ async function addMcp(source, config, options) {
|
|
|
2669
3659
|
envVars: detail.env ? Object.keys(detail.env) : void 0,
|
|
2670
3660
|
candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
|
|
2671
3661
|
};
|
|
2672
|
-
const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ??
|
|
3662
|
+
const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join18(process.cwd(), ".mcp.json");
|
|
2673
3663
|
const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
|
|
2674
3664
|
reject(entry);
|
|
2675
3665
|
logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
|
|
@@ -2764,7 +3754,7 @@ async function scan(source, options = {}) {
|
|
|
2764
3754
|
}
|
|
2765
3755
|
|
|
2766
3756
|
// src/commands/list.ts
|
|
2767
|
-
import { join as
|
|
3757
|
+
import { join as join19 } from "path";
|
|
2768
3758
|
async function list() {
|
|
2769
3759
|
await maybeOfferInit();
|
|
2770
3760
|
const config = loadConfig();
|
|
@@ -2777,14 +3767,14 @@ async function list() {
|
|
|
2777
3767
|
}
|
|
2778
3768
|
console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
|
|
2779
3769
|
for (const id of ids) {
|
|
2780
|
-
const skillDirs = findSkillDirs(
|
|
3770
|
+
const skillDirs = findSkillDirs(join19(quarantineDir, id));
|
|
2781
3771
|
if (skillDirs.length === 0) {
|
|
2782
3772
|
console.log(` ${id} (no SKILL.md found)`);
|
|
2783
3773
|
continue;
|
|
2784
3774
|
}
|
|
2785
3775
|
const names = skillDirs.map((dir) => {
|
|
2786
3776
|
try {
|
|
2787
|
-
return readFrontmatter(
|
|
3777
|
+
return readFrontmatter(join19(dir, "SKILL.md")).name;
|
|
2788
3778
|
} catch {
|
|
2789
3779
|
return dir;
|
|
2790
3780
|
}
|
|
@@ -2806,19 +3796,19 @@ async function status() {
|
|
|
2806
3796
|
}
|
|
2807
3797
|
|
|
2808
3798
|
// src/evolve.ts
|
|
2809
|
-
import { createInterface as
|
|
3799
|
+
import { createInterface as createInterface4 } from "readline/promises";
|
|
2810
3800
|
import { createHash } from "crypto";
|
|
2811
|
-
import { cpSync as cpSync3, existsSync as
|
|
3801
|
+
import { cpSync as cpSync3, existsSync as existsSync16, lstatSync as lstatSync2, mkdtempSync as mkdtempSync3, readFileSync as readFileSync12, rmSync as rmSync5 } from "fs";
|
|
2812
3802
|
import { tmpdir as tmpdir2 } from "os";
|
|
2813
|
-
import { basename as
|
|
3803
|
+
import { basename as basename7, dirname as dirname7, join as join20, resolve as resolvePath4, sep } from "path";
|
|
2814
3804
|
var SKILLOPT_BIN = "skillopt-sleep";
|
|
2815
3805
|
var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
|
|
2816
3806
|
async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
2817
3807
|
const exec = deps.exec ?? defaultExec;
|
|
2818
3808
|
const json = options.json;
|
|
2819
|
-
const resolvedTargetDir =
|
|
2820
|
-
const skillMdPath =
|
|
2821
|
-
if (!
|
|
3809
|
+
const resolvedTargetDir = resolvePath4(targetSkillDir);
|
|
3810
|
+
const skillMdPath = join20(resolvedTargetDir, "SKILL.md");
|
|
3811
|
+
if (!existsSync16(skillMdPath)) {
|
|
2822
3812
|
throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
|
|
2823
3813
|
}
|
|
2824
3814
|
const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
|
|
@@ -2845,7 +3835,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2845
3835
|
} else {
|
|
2846
3836
|
notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
|
|
2847
3837
|
}
|
|
2848
|
-
const project =
|
|
3838
|
+
const project = resolvePath4(options.project ?? process.cwd());
|
|
2849
3839
|
const present = await detectSkillopt(exec, project);
|
|
2850
3840
|
if (!present) {
|
|
2851
3841
|
throw new UserError(
|
|
@@ -2876,40 +3866,40 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2876
3866
|
}
|
|
2877
3867
|
return;
|
|
2878
3868
|
}
|
|
2879
|
-
const stagingRoot =
|
|
2880
|
-
const stagingDir =
|
|
3869
|
+
const stagingRoot = join20(project, ".skillopt-sleep", "staging");
|
|
3870
|
+
const stagingDir = resolvePath4(parsed.staging_dir);
|
|
2881
3871
|
if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
|
|
2882
3872
|
throw new UserError(
|
|
2883
3873
|
`skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
|
|
2884
3874
|
);
|
|
2885
3875
|
}
|
|
2886
|
-
const proposedSkillMd =
|
|
2887
|
-
const proposedClaudeMd =
|
|
2888
|
-
const reportMdPath =
|
|
2889
|
-
if (!
|
|
3876
|
+
const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
|
|
3877
|
+
const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
|
|
3878
|
+
const reportMdPath = join20(stagingDir, "report.md");
|
|
3879
|
+
if (!existsSync16(proposedSkillMd)) {
|
|
2890
3880
|
throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
|
|
2891
3881
|
}
|
|
2892
|
-
if (
|
|
3882
|
+
if (existsSync16(reportMdPath)) {
|
|
2893
3883
|
notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
|
|
2894
3884
|
}
|
|
2895
3885
|
assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
|
|
2896
3886
|
assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
|
|
2897
3887
|
const scannedSkillHash = sha256File(proposedSkillMd);
|
|
2898
3888
|
const scannedClaudeHash = sha256File(proposedClaudeMd);
|
|
2899
|
-
const tempDir =
|
|
3889
|
+
const tempDir = mkdtempSync3(join20(tmpdir2(), "skill-forge-evolve-"));
|
|
2900
3890
|
let profile;
|
|
2901
3891
|
let safety;
|
|
2902
3892
|
try {
|
|
2903
|
-
cpSync3(proposedSkillMd,
|
|
2904
|
-
if (
|
|
2905
|
-
cpSync3(proposedClaudeMd,
|
|
3893
|
+
cpSync3(proposedSkillMd, join20(tempDir, "SKILL.md"));
|
|
3894
|
+
if (existsSync16(proposedClaudeMd)) {
|
|
3895
|
+
cpSync3(proposedClaudeMd, join20(tempDir, "CLAUDE.md"));
|
|
2906
3896
|
}
|
|
2907
3897
|
profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
|
|
2908
3898
|
const strictness = loadConfig().strictness;
|
|
2909
3899
|
safety = await scanSafety(tempDir, strictness);
|
|
2910
3900
|
} finally {
|
|
2911
3901
|
try {
|
|
2912
|
-
|
|
3902
|
+
rmSync5(tempDir, { recursive: true, force: true });
|
|
2913
3903
|
} catch (err) {
|
|
2914
3904
|
console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
|
|
2915
3905
|
}
|
|
@@ -2944,12 +3934,12 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2944
3934
|
throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
|
|
2945
3935
|
}
|
|
2946
3936
|
logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
|
|
2947
|
-
const skillsRootForProvenance =
|
|
3937
|
+
const skillsRootForProvenance = dirname7(resolvedTargetDir);
|
|
2948
3938
|
let ledgerPath;
|
|
2949
3939
|
try {
|
|
2950
3940
|
ledgerPath = appendProvenance(
|
|
2951
3941
|
{
|
|
2952
|
-
name: profile.name ??
|
|
3942
|
+
name: profile.name ?? basename7(resolvedTargetDir),
|
|
2953
3943
|
source: resolvedTargetDir,
|
|
2954
3944
|
version: profile.version,
|
|
2955
3945
|
license: profile.license,
|
|
@@ -2984,138 +3974,1052 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
|
2984
3974
|
origin: "evolve"
|
|
2985
3975
|
});
|
|
2986
3976
|
} catch (err) {
|
|
2987
|
-
throw new UserError(
|
|
2988
|
-
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed (provenance was recorded), but appending the pending-ingestion queue entry failed: ${err.message}`
|
|
2989
|
-
);
|
|
3977
|
+
throw new UserError(
|
|
3978
|
+
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed (provenance was recorded), but appending the pending-ingestion queue entry failed: ${err.message}`
|
|
3979
|
+
);
|
|
3980
|
+
}
|
|
3981
|
+
logOutcome("[skill-forge] queued for ingestion review", json);
|
|
3982
|
+
} else if (decision === "reject") {
|
|
3983
|
+
try {
|
|
3984
|
+
rmSync5(stagingDir, { recursive: true, force: true });
|
|
3985
|
+
} catch (err) {
|
|
3986
|
+
throw new UserError(
|
|
3987
|
+
`rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
|
|
3988
|
+
);
|
|
3989
|
+
}
|
|
3990
|
+
logOutcome("[skill-forge] rejected \u2014 staging dir removed", json);
|
|
3991
|
+
if (safetyVerdict === "block") process.exitCode = 1;
|
|
3992
|
+
} else {
|
|
3993
|
+
logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
|
|
3994
|
+
if (existsSync16(reportMdPath)) {
|
|
3995
|
+
logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
async function defaultConfirm(question) {
|
|
4000
|
+
const rl = createInterface4({ input: process.stdin, output: process.stdout });
|
|
4001
|
+
try {
|
|
4002
|
+
process.stdout.write(question);
|
|
4003
|
+
for await (const raw of rl) {
|
|
4004
|
+
return /^y(es)?$/i.test(raw.trim());
|
|
4005
|
+
}
|
|
4006
|
+
return false;
|
|
4007
|
+
} finally {
|
|
4008
|
+
rl.close();
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
async function detectSkillopt(exec, cwd) {
|
|
4012
|
+
try {
|
|
4013
|
+
const result = await exec(SKILLOPT_BIN, ["--help"], { cwd });
|
|
4014
|
+
return result.code === 0;
|
|
4015
|
+
} catch {
|
|
4016
|
+
return false;
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
function tail(text, maxLines = 20) {
|
|
4020
|
+
return text.trim().split("\n").slice(-maxLines).join("\n");
|
|
4021
|
+
}
|
|
4022
|
+
function validateRunOutput(raw) {
|
|
4023
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
4024
|
+
throw new UserError(`skillopt-sleep produced unexpected JSON output (expected an object): ${JSON.stringify(raw)}`);
|
|
4025
|
+
}
|
|
4026
|
+
const obj = raw;
|
|
4027
|
+
const stagingDirRaw = obj.staging_dir;
|
|
4028
|
+
if (stagingDirRaw !== null && stagingDirRaw !== void 0 && typeof stagingDirRaw !== "string") {
|
|
4029
|
+
throw new UserError(
|
|
4030
|
+
`skillopt-sleep produced an unexpected "staging_dir" (expected a string or null): ${JSON.stringify(stagingDirRaw)}`
|
|
4031
|
+
);
|
|
4032
|
+
}
|
|
4033
|
+
return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
|
|
4034
|
+
}
|
|
4035
|
+
function readManifest(stagingDir) {
|
|
4036
|
+
const manifestPath = join20(stagingDir, "manifest.json");
|
|
4037
|
+
let raw;
|
|
4038
|
+
try {
|
|
4039
|
+
raw = readFileSync12(manifestPath, "utf-8");
|
|
4040
|
+
} catch (err) {
|
|
4041
|
+
throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
|
|
4042
|
+
}
|
|
4043
|
+
let parsed;
|
|
4044
|
+
try {
|
|
4045
|
+
parsed = JSON.parse(raw);
|
|
4046
|
+
} catch (err) {
|
|
4047
|
+
throw new UserError(`could not parse staging manifest ${manifestPath}: ${err.message}`);
|
|
4048
|
+
}
|
|
4049
|
+
return validateManifest(parsed, manifestPath);
|
|
4050
|
+
}
|
|
4051
|
+
function validateManifest(raw, manifestPath) {
|
|
4052
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
4053
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected shape (expected an object)`);
|
|
4054
|
+
}
|
|
4055
|
+
const obj = raw;
|
|
4056
|
+
if (typeof obj.live_skill_path !== "string") {
|
|
4057
|
+
throw new UserError(`staging manifest ${manifestPath} is missing a valid "live_skill_path" string`);
|
|
4058
|
+
}
|
|
4059
|
+
if (obj.live_memory_path !== void 0 && obj.live_memory_path !== null && typeof obj.live_memory_path !== "string") {
|
|
4060
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected "live_memory_path" (expected a string or null)`);
|
|
4061
|
+
}
|
|
4062
|
+
if (typeof obj.has_skill !== "boolean" || typeof obj.has_memory !== "boolean") {
|
|
4063
|
+
throw new UserError(`staging manifest ${manifestPath} is missing valid "has_skill"/"has_memory" booleans`);
|
|
4064
|
+
}
|
|
4065
|
+
return {
|
|
4066
|
+
live_skill_path: obj.live_skill_path,
|
|
4067
|
+
live_memory_path: obj.live_memory_path ?? null,
|
|
4068
|
+
has_skill: obj.has_skill,
|
|
4069
|
+
has_memory: obj.has_memory
|
|
4070
|
+
};
|
|
4071
|
+
}
|
|
4072
|
+
function filesIdentical(a, b) {
|
|
4073
|
+
if (!existsSync16(a) || !existsSync16(b)) return false;
|
|
4074
|
+
return readFileSync12(a).equals(readFileSync12(b));
|
|
4075
|
+
}
|
|
4076
|
+
function isAlreadyAdopted(stagingDir, manifest) {
|
|
4077
|
+
const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
|
|
4078
|
+
const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
|
|
4079
|
+
let sawComparison = false;
|
|
4080
|
+
if (existsSync16(proposedSkillMd)) {
|
|
4081
|
+
sawComparison = true;
|
|
4082
|
+
if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
|
|
4083
|
+
}
|
|
4084
|
+
if (existsSync16(proposedClaudeMd) && manifest.live_memory_path) {
|
|
4085
|
+
sawComparison = true;
|
|
4086
|
+
if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
|
|
4087
|
+
}
|
|
4088
|
+
return sawComparison;
|
|
4089
|
+
}
|
|
4090
|
+
function assertNotSymlink(path, label) {
|
|
4091
|
+
let stat;
|
|
4092
|
+
try {
|
|
4093
|
+
stat = lstatSync2(path);
|
|
4094
|
+
} catch {
|
|
4095
|
+
return;
|
|
4096
|
+
}
|
|
4097
|
+
if (stat.isSymbolicLink()) {
|
|
4098
|
+
throw new UserError(`refusing to re-gate ${label}: ${path} is a symlink (a staged proposal must be a plain file)`);
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
function sha256File(path) {
|
|
4102
|
+
if (!existsSync16(path)) return null;
|
|
4103
|
+
return createHash("sha256").update(readFileSync12(path)).digest("hex");
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
// src/commands/evolve.ts
|
|
4107
|
+
async function evolveCommand(targetSkillDir, options = {}) {
|
|
4108
|
+
await evolve(targetSkillDir, options);
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
// src/commands/organize.ts
|
|
4112
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7 } from "fs";
|
|
4113
|
+
import { dirname as dirname8, resolve as resolvePath6 } from "path";
|
|
4114
|
+
|
|
4115
|
+
// src/organize.ts
|
|
4116
|
+
import { existsSync as existsSync17, readFileSync as readFileSync13, readdirSync as readdirSync7, realpathSync as realpathSync2, statSync as statSync9 } from "fs";
|
|
4117
|
+
import { basename as basename8, join as join21, resolve as resolvePath5 } from "path";
|
|
4118
|
+
var PRO_FEATURE_LABEL3 = "set-level organizer reports";
|
|
4119
|
+
var TIERS = /* @__PURE__ */ new Set(["resource", "custom"]);
|
|
4120
|
+
var MATURITIES = /* @__PURE__ */ new Set(["seedling", "stable", "deprecated"]);
|
|
4121
|
+
function listSubdirs2(dir) {
|
|
4122
|
+
let entries;
|
|
4123
|
+
try {
|
|
4124
|
+
entries = readdirSync7(dir, { withFileTypes: true });
|
|
4125
|
+
} catch {
|
|
4126
|
+
return [];
|
|
4127
|
+
}
|
|
4128
|
+
const out = [];
|
|
4129
|
+
for (const entry of entries) {
|
|
4130
|
+
const full = join21(dir, entry.name);
|
|
4131
|
+
if (entry.isDirectory()) {
|
|
4132
|
+
out.push(full);
|
|
4133
|
+
continue;
|
|
4134
|
+
}
|
|
4135
|
+
if (entry.isSymbolicLink()) {
|
|
4136
|
+
try {
|
|
4137
|
+
if (statSync9(full).isDirectory()) out.push(full);
|
|
4138
|
+
} catch {
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
return out;
|
|
4143
|
+
}
|
|
4144
|
+
function findSkillDirsUnderRoot2(root) {
|
|
4145
|
+
const found = /* @__PURE__ */ new Set();
|
|
4146
|
+
const topDirs = listSubdirs2(root);
|
|
4147
|
+
for (const dir of topDirs) {
|
|
4148
|
+
if (existsSync17(join21(dir, "SKILL.md"))) found.add(dir);
|
|
4149
|
+
}
|
|
4150
|
+
for (const dir of listSubdirs2(join21(root, "skills"))) {
|
|
4151
|
+
if (existsSync17(join21(dir, "SKILL.md"))) found.add(dir);
|
|
4152
|
+
}
|
|
4153
|
+
for (const topDir of topDirs) {
|
|
4154
|
+
for (const dir of listSubdirs2(join21(topDir, "skills"))) {
|
|
4155
|
+
if (existsSync17(join21(dir, "SKILL.md"))) found.add(dir);
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
return Array.from(found).filter((p) => !p.includes("/.git/"));
|
|
4159
|
+
}
|
|
4160
|
+
function collectCandidates(roots) {
|
|
4161
|
+
const out = [];
|
|
4162
|
+
for (const root of roots) {
|
|
4163
|
+
const resolvedRoot = resolvePath5(expandHome(root));
|
|
4164
|
+
if (!isDir(resolvedRoot)) continue;
|
|
4165
|
+
for (const dirPath of findSkillDirsUnderRoot2(resolvedRoot)) {
|
|
4166
|
+
out.push({ dirPath, root });
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
return out;
|
|
4170
|
+
}
|
|
4171
|
+
function parseYamlList(block, key) {
|
|
4172
|
+
const inlineMatch = block.match(new RegExp(`^${key}:\\s*\\[(.*?)\\]\\s*$`, "m"));
|
|
4173
|
+
if (inlineMatch) {
|
|
4174
|
+
return inlineMatch[1].split(",").map((x) => stripQuotes(x.trim())).filter(Boolean);
|
|
4175
|
+
}
|
|
4176
|
+
if (!new RegExp(`^${key}:\\s*$`, "m").test(block)) return [];
|
|
4177
|
+
const lines = block.split(/\r\n|\r|\n/);
|
|
4178
|
+
const startIdx = lines.findIndex((l) => new RegExp(`^${key}:\\s*$`).test(l));
|
|
4179
|
+
if (startIdx === -1) return [];
|
|
4180
|
+
const out = [];
|
|
4181
|
+
for (const line of lines.slice(startIdx + 1)) {
|
|
4182
|
+
const itemMatch = line.match(/^\s*-\s*(.+)$/);
|
|
4183
|
+
if (itemMatch) {
|
|
4184
|
+
out.push(stripQuotes(itemMatch[1].trim()));
|
|
4185
|
+
} else if (line.trim() === "") {
|
|
4186
|
+
continue;
|
|
4187
|
+
} else {
|
|
4188
|
+
break;
|
|
4189
|
+
}
|
|
4190
|
+
}
|
|
4191
|
+
return out;
|
|
4192
|
+
}
|
|
4193
|
+
function extractFrontmatterBlock(text) {
|
|
4194
|
+
if (!text.startsWith("---\n")) return null;
|
|
4195
|
+
const afterOpen = text.slice(4);
|
|
4196
|
+
const closeIdx = afterOpen.indexOf("\n---");
|
|
4197
|
+
if (closeIdx === -1) return null;
|
|
4198
|
+
return afterOpen.slice(0, closeIdx);
|
|
4199
|
+
}
|
|
4200
|
+
function parseCapabilityFrontmatter(text, fallbackName) {
|
|
4201
|
+
const block = extractFrontmatterBlock(text);
|
|
4202
|
+
if (block === null) {
|
|
4203
|
+
return { name: fallbackName, consumes: [], validFrontmatter: false };
|
|
4204
|
+
}
|
|
4205
|
+
const fields = {};
|
|
4206
|
+
for (const key of ["name", "tier", "domain", "provenance", "maturity"]) {
|
|
4207
|
+
const m = block.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
4208
|
+
if (m) fields[key] = stripQuotes(m[1].trim());
|
|
4209
|
+
}
|
|
4210
|
+
return {
|
|
4211
|
+
name: fields.name ?? fallbackName,
|
|
4212
|
+
tier: fields.tier,
|
|
4213
|
+
domain: fields.domain,
|
|
4214
|
+
provenance: fields.provenance,
|
|
4215
|
+
maturity: fields.maturity,
|
|
4216
|
+
consumes: parseYamlList(block, "consumes"),
|
|
4217
|
+
validFrontmatter: true
|
|
4218
|
+
};
|
|
4219
|
+
}
|
|
4220
|
+
function loadUsageSnapshot(snapshotPath) {
|
|
4221
|
+
const resolvedPath = resolvePath5(expandHome(snapshotPath));
|
|
4222
|
+
let raw;
|
|
4223
|
+
try {
|
|
4224
|
+
raw = readFileSync13(resolvedPath, "utf-8");
|
|
4225
|
+
} catch (err) {
|
|
4226
|
+
return { usage: /* @__PURE__ */ new Map(), warning: `could not read usage snapshot (${err.message}); proceeding without usage` };
|
|
4227
|
+
}
|
|
4228
|
+
let parsed;
|
|
4229
|
+
try {
|
|
4230
|
+
parsed = JSON.parse(raw);
|
|
4231
|
+
} catch (err) {
|
|
4232
|
+
return { usage: /* @__PURE__ */ new Map(), warning: `could not parse usage snapshot (${err.message}); proceeding without usage` };
|
|
4233
|
+
}
|
|
4234
|
+
const usage = /* @__PURE__ */ new Map();
|
|
4235
|
+
const outer = parsed;
|
|
4236
|
+
const report = outer && typeof outer === "object" ? outer.report : void 0;
|
|
4237
|
+
const topSkills = report && typeof report === "object" ? report.top_skills : void 0;
|
|
4238
|
+
if (Array.isArray(topSkills)) {
|
|
4239
|
+
for (const row of topSkills) {
|
|
4240
|
+
if (Array.isArray(row) && row.length === 2 && typeof row[0] === "string" && typeof row[1] === "number") {
|
|
4241
|
+
const [name, count] = row;
|
|
4242
|
+
usage.set(name, count);
|
|
4243
|
+
if (name.includes(":")) {
|
|
4244
|
+
const bare = name.split(":").pop();
|
|
4245
|
+
if (!usage.has(bare)) usage.set(bare, count);
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
return { usage };
|
|
4251
|
+
}
|
|
4252
|
+
var WHITE = 0;
|
|
4253
|
+
var GRAY = 1;
|
|
4254
|
+
var BLACK = 2;
|
|
4255
|
+
function findCycles(adjacency) {
|
|
4256
|
+
const color = /* @__PURE__ */ new Map();
|
|
4257
|
+
for (const id of adjacency.keys()) color.set(id, WHITE);
|
|
4258
|
+
const stack = [];
|
|
4259
|
+
const cycles = [];
|
|
4260
|
+
function dfs(u) {
|
|
4261
|
+
color.set(u, GRAY);
|
|
4262
|
+
stack.push(u);
|
|
4263
|
+
for (const v of adjacency.get(u) ?? []) {
|
|
4264
|
+
if (!color.has(v)) continue;
|
|
4265
|
+
if (color.get(v) === GRAY && stack.includes(v)) {
|
|
4266
|
+
const idx = stack.indexOf(v);
|
|
4267
|
+
cycles.push([...stack.slice(idx), v]);
|
|
4268
|
+
} else if (color.get(v) === WHITE) {
|
|
4269
|
+
dfs(v);
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
color.set(u, BLACK);
|
|
4273
|
+
stack.pop();
|
|
4274
|
+
}
|
|
4275
|
+
for (const id of adjacency.keys()) {
|
|
4276
|
+
if (color.get(id) === WHITE) dfs(id);
|
|
4277
|
+
}
|
|
4278
|
+
return cycles;
|
|
4279
|
+
}
|
|
4280
|
+
function buildGraph(entries, nameToIds) {
|
|
4281
|
+
const nodes = entries.map((e) => ({ id: e.id, name: e.name, tier: e.tier, domain: e.domain }));
|
|
4282
|
+
const edges = [];
|
|
4283
|
+
const dangling = [];
|
|
4284
|
+
const ambiguous = [];
|
|
4285
|
+
const incoming = new Map(entries.map((e) => [e.id, 0]));
|
|
4286
|
+
const adjacency = new Map(entries.map((e) => [e.id, []]));
|
|
4287
|
+
for (const e of entries) {
|
|
4288
|
+
for (const target of e.consumes) {
|
|
4289
|
+
const matches = nameToIds.get(target) ?? [];
|
|
4290
|
+
edges.push({ from: e.id, to: target, resolvedIds: matches });
|
|
4291
|
+
if (matches.length === 0) {
|
|
4292
|
+
dangling.push({ from: e.id, to: target });
|
|
4293
|
+
} else if (matches.length === 1) {
|
|
4294
|
+
incoming.set(matches[0], (incoming.get(matches[0]) ?? 0) + 1);
|
|
4295
|
+
adjacency.get(e.id).push(matches[0]);
|
|
4296
|
+
} else {
|
|
4297
|
+
ambiguous.push({ from: e.id, to: target, matches });
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
const orphans = entries.filter((e) => e.tier === "custom" && e.consumes.length === 0).map((e) => e.id);
|
|
4302
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
4303
|
+
const hotResources = Array.from(incoming.entries()).filter(([, count]) => count > 0).map(([id, count]) => ({ id, name: byId.get(id).name, consumedBy: count })).sort((a, b) => b.consumedBy - a.consumedBy);
|
|
4304
|
+
const cycles = findCycles(adjacency);
|
|
4305
|
+
return {
|
|
4306
|
+
nodeCount: nodes.length,
|
|
4307
|
+
edgeCount: edges.length,
|
|
4308
|
+
nodes,
|
|
4309
|
+
edges,
|
|
4310
|
+
orphans,
|
|
4311
|
+
hotResources,
|
|
4312
|
+
danglingEdges: dangling,
|
|
4313
|
+
ambiguousConsumesTargets: ambiguous,
|
|
4314
|
+
cycles
|
|
4315
|
+
};
|
|
4316
|
+
}
|
|
4317
|
+
function buildRegistryEntries(candidates, warnings) {
|
|
4318
|
+
const entries = [];
|
|
4319
|
+
const seenRealPaths = /* @__PURE__ */ new Set();
|
|
4320
|
+
for (const candidate of candidates) {
|
|
4321
|
+
let realPath;
|
|
4322
|
+
try {
|
|
4323
|
+
realPath = realpathSync2(candidate.dirPath);
|
|
4324
|
+
} catch (err) {
|
|
4325
|
+
warnings.push(`could not resolve skill directory ${candidate.dirPath}: ${err.message}`);
|
|
4326
|
+
continue;
|
|
2990
4327
|
}
|
|
2991
|
-
|
|
2992
|
-
|
|
4328
|
+
if (seenRealPaths.has(realPath)) continue;
|
|
4329
|
+
seenRealPaths.add(realPath);
|
|
4330
|
+
const skillMdPath = join21(realPath, "SKILL.md");
|
|
4331
|
+
let text;
|
|
2993
4332
|
try {
|
|
2994
|
-
|
|
4333
|
+
text = readFileSync13(skillMdPath, "utf-8");
|
|
2995
4334
|
} catch (err) {
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
);
|
|
4335
|
+
warnings.push(`could not read ${skillMdPath}: ${err.message}`);
|
|
4336
|
+
continue;
|
|
2999
4337
|
}
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
4338
|
+
const fm = parseCapabilityFrontmatter(text, basename8(realPath));
|
|
4339
|
+
const name = fm.name ?? basename8(realPath);
|
|
4340
|
+
if (fm.tier && !TIERS.has(fm.tier)) {
|
|
4341
|
+
warnings.push(`${name}: tier '${fm.tier}' not in ${JSON.stringify(Array.from(TIERS).sort())}`);
|
|
4342
|
+
}
|
|
4343
|
+
if (fm.maturity && !MATURITIES.has(fm.maturity)) {
|
|
4344
|
+
warnings.push(`${name}: maturity '${fm.maturity}' not in ${JSON.stringify(Array.from(MATURITIES).sort())}`);
|
|
3006
4345
|
}
|
|
4346
|
+
entries.push({
|
|
4347
|
+
id: realPath,
|
|
4348
|
+
name,
|
|
4349
|
+
path: realPath,
|
|
4350
|
+
root: candidate.root,
|
|
4351
|
+
tier: fm.tier,
|
|
4352
|
+
domain: fm.domain,
|
|
4353
|
+
consumes: fm.consumes,
|
|
4354
|
+
provenance: fm.provenance,
|
|
4355
|
+
maturity: fm.maturity,
|
|
4356
|
+
validFrontmatter: fm.validFrontmatter
|
|
4357
|
+
});
|
|
3007
4358
|
}
|
|
4359
|
+
return entries;
|
|
3008
4360
|
}
|
|
3009
|
-
|
|
3010
|
-
const
|
|
4361
|
+
function joinUsageSnapshot(entries, snapshotPath, warnings) {
|
|
4362
|
+
const { usage, warning } = loadUsageSnapshot(snapshotPath);
|
|
4363
|
+
if (warning) warnings.push(warning);
|
|
4364
|
+
if (usage.size === 0) return false;
|
|
4365
|
+
for (const entry of entries) {
|
|
4366
|
+
const count = usage.get(entry.name);
|
|
4367
|
+
if (count !== void 0) entry.usage = count;
|
|
4368
|
+
}
|
|
4369
|
+
if (!entries.some((e) => e.usage !== void 0)) {
|
|
4370
|
+
warnings.push(
|
|
4371
|
+
"usage snapshot loaded but 0 skills matched by name \u2014 registry `name:` vs telemetry key mismatch; the usage column will be empty"
|
|
4372
|
+
);
|
|
4373
|
+
}
|
|
4374
|
+
return true;
|
|
4375
|
+
}
|
|
4376
|
+
function buildNameToIds(entries, warnings) {
|
|
4377
|
+
const nameToIds = /* @__PURE__ */ new Map();
|
|
4378
|
+
for (const e of entries) {
|
|
4379
|
+
const ids = nameToIds.get(e.name) ?? [];
|
|
4380
|
+
ids.push(e.id);
|
|
4381
|
+
nameToIds.set(e.name, ids);
|
|
4382
|
+
}
|
|
4383
|
+
for (const [name, ids] of nameToIds) {
|
|
4384
|
+
if (ids.length > 1) {
|
|
4385
|
+
warnings.push(`duplicate skill name "${name}" across ${ids.length} locations: ${ids.join(", ")}`);
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
return nameToIds;
|
|
4389
|
+
}
|
|
4390
|
+
function findUntagged(entries) {
|
|
4391
|
+
return entries.filter((e) => !e.tier || !e.domain).map((e) => ({
|
|
4392
|
+
id: e.id,
|
|
4393
|
+
name: e.name,
|
|
4394
|
+
missing: [!e.tier ? "tier" : void 0, !e.domain ? "domain" : void 0].filter((v) => Boolean(v))
|
|
4395
|
+
}));
|
|
4396
|
+
}
|
|
4397
|
+
function countByTierAndDomain(entries) {
|
|
4398
|
+
const byTier = {};
|
|
4399
|
+
const byDomain = {};
|
|
4400
|
+
for (const e of entries) {
|
|
4401
|
+
byTier[e.tier ?? "untagged"] = (byTier[e.tier ?? "untagged"] ?? 0) + 1;
|
|
4402
|
+
byDomain[e.domain ?? "untagged"] = (byDomain[e.domain ?? "untagged"] ?? 0) + 1;
|
|
4403
|
+
}
|
|
4404
|
+
return { byTier, byDomain };
|
|
4405
|
+
}
|
|
4406
|
+
function buildOrganizeReport(config, options = {}) {
|
|
4407
|
+
const proStatus = proFeatureStatus(PRO_FEATURE_LABEL3);
|
|
4408
|
+
if (!proStatus.enabled) {
|
|
4409
|
+
throw new UserError(proStatus.notice ?? upgradeNotice(PRO_FEATURE_LABEL3));
|
|
4410
|
+
}
|
|
4411
|
+
const notices = proStatus.notice ? [proStatus.notice] : [];
|
|
4412
|
+
const warnings = [];
|
|
4413
|
+
const candidates = collectCandidates(config.skillsRoots);
|
|
4414
|
+
if (candidates.length === 0) {
|
|
4415
|
+
throw new UserError(
|
|
4416
|
+
`no SKILL.md found under any configured skills root (${config.skillsRoots.join(", ") || "(none configured)"})`
|
|
4417
|
+
);
|
|
4418
|
+
}
|
|
4419
|
+
const entries = buildRegistryEntries(candidates, warnings);
|
|
4420
|
+
const usageJoined = options.usageSnapshot ? joinUsageSnapshot(entries, options.usageSnapshot, warnings) : false;
|
|
4421
|
+
const nameToIds = buildNameToIds(entries, warnings);
|
|
4422
|
+
const untagged = findUntagged(entries);
|
|
4423
|
+
const { byTier, byDomain } = countByTierAndDomain(entries);
|
|
4424
|
+
const registry = {
|
|
4425
|
+
skillsRoots: [...config.skillsRoots],
|
|
4426
|
+
count: entries.length,
|
|
4427
|
+
byTier,
|
|
4428
|
+
byDomain,
|
|
4429
|
+
untagged,
|
|
4430
|
+
warnings,
|
|
4431
|
+
usageJoined,
|
|
4432
|
+
skills: entries
|
|
4433
|
+
};
|
|
4434
|
+
const graph = buildGraph(entries, nameToIds);
|
|
4435
|
+
return {
|
|
4436
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4437
|
+
registry,
|
|
4438
|
+
graph,
|
|
4439
|
+
notices
|
|
4440
|
+
};
|
|
4441
|
+
}
|
|
4442
|
+
|
|
4443
|
+
// src/commands/organize.ts
|
|
4444
|
+
function writeReport2(outPath, contents) {
|
|
4445
|
+
const resolved = resolvePath6(outPath);
|
|
4446
|
+
mkdirSync7(dirname8(resolved), { recursive: true });
|
|
3011
4447
|
try {
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
4448
|
+
writeFileSync7(resolved, contents, { flag: "wx", mode: 384 });
|
|
4449
|
+
} catch (err) {
|
|
4450
|
+
if (err.code === "EEXIST") {
|
|
4451
|
+
throw new UserError(`output file already exists: ${resolved} \u2014 pass a different --out path`);
|
|
3015
4452
|
}
|
|
3016
|
-
|
|
3017
|
-
}
|
|
3018
|
-
|
|
4453
|
+
throw err;
|
|
4454
|
+
}
|
|
4455
|
+
return resolved;
|
|
4456
|
+
}
|
|
4457
|
+
function renderSummary(report) {
|
|
4458
|
+
const { registry, graph } = report;
|
|
4459
|
+
const lines = [];
|
|
4460
|
+
lines.push(`[skill-forge] organize: ${registry.count} skill(s) across ${registry.skillsRoots.length} root(s)`);
|
|
4461
|
+
lines.push(` by tier: ${JSON.stringify(registry.byTier)}`);
|
|
4462
|
+
lines.push(` by domain: ${JSON.stringify(registry.byDomain)}`);
|
|
4463
|
+
lines.push(` usage joined: ${registry.usageJoined}`);
|
|
4464
|
+
lines.push(` untagged (missing tier/domain): ${registry.untagged.length}`);
|
|
4465
|
+
for (const u of registry.untagged.slice(0, 30)) {
|
|
4466
|
+
lines.push(` - ${sanitizeForMarkdown(u.name)}: missing ${u.missing.join(", ")}`);
|
|
4467
|
+
}
|
|
4468
|
+
if (registry.warnings.length > 0) {
|
|
4469
|
+
lines.push(" warnings:");
|
|
4470
|
+
for (const w of registry.warnings) lines.push(` ! ${sanitizeForMarkdown(w)}`);
|
|
4471
|
+
}
|
|
4472
|
+
lines.push("");
|
|
4473
|
+
lines.push(` graph: ${graph.nodeCount} nodes, ${graph.edgeCount} edges`);
|
|
4474
|
+
if (graph.hotResources.length > 0) {
|
|
4475
|
+
lines.push(" hot resources (consumed by N customs):");
|
|
4476
|
+
for (const h of graph.hotResources) lines.push(` ${h.consumedBy}x ${sanitizeForMarkdown(h.name)}`);
|
|
4477
|
+
}
|
|
4478
|
+
if (graph.danglingEdges.length > 0) {
|
|
4479
|
+
lines.push(" dangling edges (consumes a target not in the set):");
|
|
4480
|
+
for (const d of graph.danglingEdges) lines.push(` ${sanitizeForMarkdown(d.from)} -> ${sanitizeForMarkdown(d.to)} (missing)`);
|
|
4481
|
+
}
|
|
4482
|
+
if (graph.ambiguousConsumesTargets.length > 0) {
|
|
4483
|
+
lines.push(" ambiguous consumes targets (name matched multiple skills):");
|
|
4484
|
+
for (const a of graph.ambiguousConsumesTargets) {
|
|
4485
|
+
lines.push(
|
|
4486
|
+
` ${sanitizeForMarkdown(a.from)} -> ${sanitizeForMarkdown(a.to)} (matches: ${a.matches.map(sanitizeForMarkdown).join(", ")})`
|
|
4487
|
+
);
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
if (graph.cycles.length > 0) {
|
|
4491
|
+
lines.push(" cycles (consumes should be acyclic):");
|
|
4492
|
+
for (const c of graph.cycles) lines.push(` ${c.map(sanitizeForMarkdown).join(" -> ")}`);
|
|
4493
|
+
}
|
|
4494
|
+
if (graph.orphans.length > 0) {
|
|
4495
|
+
lines.push(` custom skills with no declared consumes (${graph.orphans.length}):`);
|
|
4496
|
+
for (const o of graph.orphans.slice(0, 30)) lines.push(` - ${sanitizeForMarkdown(o)}`);
|
|
3019
4497
|
}
|
|
4498
|
+
for (const n of report.notices) lines.push(` [notice] ${sanitizeForMarkdown(n)}`);
|
|
4499
|
+
return lines;
|
|
3020
4500
|
}
|
|
3021
|
-
async function
|
|
4501
|
+
async function organizeCommand(options = {}) {
|
|
4502
|
+
const config = loadConfig();
|
|
4503
|
+
const report = buildOrganizeReport(config, { usageSnapshot: options.usageSnapshot });
|
|
4504
|
+
let outPath;
|
|
4505
|
+
if (options.out) {
|
|
4506
|
+
outPath = writeReport2(options.out, JSON.stringify(report, null, 2) + "\n");
|
|
4507
|
+
}
|
|
4508
|
+
if (options.json) {
|
|
4509
|
+
for (const n of report.notices) console.error(`[skill-forge] ${n}`);
|
|
4510
|
+
console.log(JSON.stringify(report, null, 2));
|
|
4511
|
+
return;
|
|
4512
|
+
}
|
|
4513
|
+
for (const line of renderSummary(report)) console.log(line);
|
|
4514
|
+
if (outPath) console.log(` report -> ${outPath}`);
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
// src/gate/skillsShApi.ts
|
|
4518
|
+
var SKILLS_SH_ORIGIN = "https://skills.sh";
|
|
4519
|
+
var SKILLS_SH_API_BASE = "/api/v1";
|
|
4520
|
+
var MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
4521
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
4522
|
+
var ID_PATTERN = /^[A-Za-z0-9._/-]+$/;
|
|
4523
|
+
var TOKEN_HINT = "No VERCEL_OIDC_TOKEN found. skills.sh authenticates with a short-lived Vercel OIDC token:\n 1. Vercel dashboard -> your project -> Settings -> OIDC Federation -> enable.\n 2. npm i -g vercel && vercel link && vercel env pull (writes VERCEL_OIDC_TOKEN to .env.local)\n 3. export VERCEL_OIDC_TOKEN from .env.local into your shell before running this command\n (or set it directly: export VERCEL_OIDC_TOKEN=...) - docs: https://skills.sh/docs/api";
|
|
4524
|
+
var SkillsShError = class extends Error {
|
|
4525
|
+
exitCode;
|
|
4526
|
+
constructor(message, exitCode) {
|
|
4527
|
+
super(message);
|
|
4528
|
+
this.name = "SkillsShError";
|
|
4529
|
+
this.exitCode = exitCode;
|
|
4530
|
+
}
|
|
4531
|
+
};
|
|
4532
|
+
var RISK_ORDER = { NONE: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
|
|
4533
|
+
function getToken(env = process.env) {
|
|
4534
|
+
const tok = env.VERCEL_OIDC_TOKEN;
|
|
4535
|
+
return tok ? tok : void 0;
|
|
4536
|
+
}
|
|
4537
|
+
function assertValidId(id) {
|
|
4538
|
+
if (id.length === 0 || !ID_PATTERN.test(id)) {
|
|
4539
|
+
throw new SkillsShError(`invalid skills.sh id: ${JSON.stringify(id)}`, 1);
|
|
4540
|
+
}
|
|
4541
|
+
}
|
|
4542
|
+
function validatedIdSegments(id) {
|
|
4543
|
+
assertValidId(id);
|
|
4544
|
+
const segments = id.split("/");
|
|
4545
|
+
if (segments.some((s) => s === "" || s === "." || s === "..")) {
|
|
4546
|
+
throw new SkillsShError(`invalid skills.sh id: ${JSON.stringify(id)}`, 1);
|
|
4547
|
+
}
|
|
4548
|
+
return segments;
|
|
4549
|
+
}
|
|
4550
|
+
function buildUrl(pathSegments, params) {
|
|
4551
|
+
const url = new URL(SKILLS_SH_API_BASE + "/" + pathSegments.map((s) => encodeURIComponent(s)).join("/"), SKILLS_SH_ORIGIN);
|
|
4552
|
+
if (params) {
|
|
4553
|
+
const search2 = new URLSearchParams();
|
|
4554
|
+
for (const [k, v] of Object.entries(params)) search2.set(k, String(v));
|
|
4555
|
+
url.search = search2.toString();
|
|
4556
|
+
}
|
|
4557
|
+
return url;
|
|
4558
|
+
}
|
|
4559
|
+
async function readCappedText(res) {
|
|
4560
|
+
const text = await res.text();
|
|
4561
|
+
if (new TextEncoder().encode(text).length > MAX_RESPONSE_BYTES) {
|
|
4562
|
+
throw new SkillsShError("skills.sh response exceeded the 2 MB size cap", 1);
|
|
4563
|
+
}
|
|
4564
|
+
return text;
|
|
4565
|
+
}
|
|
4566
|
+
async function call(pathSegments, params, options = {}) {
|
|
4567
|
+
const env = options.env ?? process.env;
|
|
4568
|
+
const token = getToken(env);
|
|
4569
|
+
if (!token) {
|
|
4570
|
+
throw new SkillsShError(TOKEN_HINT, 3);
|
|
4571
|
+
}
|
|
4572
|
+
const url = buildUrl(pathSegments, params);
|
|
4573
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
4574
|
+
let res;
|
|
3022
4575
|
try {
|
|
3023
|
-
|
|
3024
|
-
|
|
4576
|
+
res = await fetchImpl(url, {
|
|
4577
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
4578
|
+
redirect: "error",
|
|
4579
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
4580
|
+
});
|
|
4581
|
+
} catch (err) {
|
|
4582
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4583
|
+
throw new SkillsShError(`skills.sh request failed: ${message}`, 1);
|
|
4584
|
+
}
|
|
4585
|
+
if (res.status === 404) {
|
|
4586
|
+
return void 0;
|
|
4587
|
+
}
|
|
4588
|
+
if (!res.ok) {
|
|
4589
|
+
if (res.status === 401) {
|
|
4590
|
+
throw new SkillsShError("skills.sh HTTP 401: unauthorized \u2014 the VERCEL_OIDC_TOKEN was rejected (expired or invalid)", 3);
|
|
4591
|
+
}
|
|
4592
|
+
let snippet = "";
|
|
4593
|
+
try {
|
|
4594
|
+
snippet = (await readCappedText(res)).slice(0, 300);
|
|
4595
|
+
} catch {
|
|
4596
|
+
}
|
|
4597
|
+
throw new SkillsShError(`skills.sh HTTP ${res.status}${snippet ? `: ${snippet}` : ""}`, 1);
|
|
4598
|
+
}
|
|
4599
|
+
const text = await readCappedText(res);
|
|
4600
|
+
let parsed;
|
|
4601
|
+
try {
|
|
4602
|
+
parsed = JSON.parse(text);
|
|
3025
4603
|
} catch {
|
|
3026
|
-
|
|
4604
|
+
throw new SkillsShError("skills.sh returned malformed JSON", 1);
|
|
3027
4605
|
}
|
|
4606
|
+
return parsed;
|
|
3028
4607
|
}
|
|
3029
|
-
function
|
|
3030
|
-
return
|
|
4608
|
+
function isRecord(v) {
|
|
4609
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3031
4610
|
}
|
|
3032
|
-
function
|
|
3033
|
-
|
|
3034
|
-
|
|
4611
|
+
async function search(query, limit, options = {}) {
|
|
4612
|
+
const data = await call(["skills", "search"], { q: query, limit }, options);
|
|
4613
|
+
if (!isRecord(data) || !Array.isArray(data.data)) {
|
|
4614
|
+
throw new SkillsShError("skills.sh search response had an unexpected shape", 1);
|
|
3035
4615
|
}
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
4616
|
+
return data;
|
|
4617
|
+
}
|
|
4618
|
+
async function audit(id, options = {}) {
|
|
4619
|
+
const data = await call(["skills", "audit", ...validatedIdSegments(id)], void 0, options);
|
|
4620
|
+
if (data === void 0) return void 0;
|
|
4621
|
+
if (!isRecord(data) || !Array.isArray(data.audits)) {
|
|
4622
|
+
throw new SkillsShError("skills.sh audit response had an unexpected shape", 1);
|
|
3042
4623
|
}
|
|
3043
|
-
return
|
|
4624
|
+
return data;
|
|
3044
4625
|
}
|
|
3045
|
-
function
|
|
3046
|
-
|
|
3047
|
-
|
|
4626
|
+
async function get(id, options = {}) {
|
|
4627
|
+
return call(["skills", ...validatedIdSegments(id)], void 0, options);
|
|
4628
|
+
}
|
|
4629
|
+
async function curated(options = {}) {
|
|
4630
|
+
return call(["skills", "curated"], void 0, options);
|
|
4631
|
+
}
|
|
4632
|
+
function auditVerdict(data) {
|
|
4633
|
+
const audits = data.audits ?? [];
|
|
4634
|
+
const statuses = new Set(audits.map((a) => a.status));
|
|
4635
|
+
let worst = "NONE";
|
|
4636
|
+
let worstRank = 0;
|
|
4637
|
+
for (const a of audits) {
|
|
4638
|
+
const rl = a.riskLevel ?? "NONE";
|
|
4639
|
+
const rank = RISK_ORDER[rl] ?? 0;
|
|
4640
|
+
if (rank > worstRank) {
|
|
4641
|
+
worstRank = rank;
|
|
4642
|
+
worst = rl;
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
const verdict = statuses.has("fail") ? "fail" : statuses.has("warn") ? "warn" : audits.length > 0 ? "pass" : "none";
|
|
4646
|
+
return { verdict, risk: worst, audits };
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4649
|
+
// src/commands/find.ts
|
|
4650
|
+
var INSTALL_HINT_NOTE = "Results are unvetted \u2014 `skill-forge add <id>` runs the full quarantine + safety gate; `skill-forge find --audit <id>` shows partner audit verdicts.";
|
|
4651
|
+
function isRecordWithDataArray(v) {
|
|
4652
|
+
return typeof v === "object" && v !== null && Array.isArray(v.data);
|
|
4653
|
+
}
|
|
4654
|
+
function printJson(data) {
|
|
4655
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4656
|
+
}
|
|
4657
|
+
function printSearchResults(data, query) {
|
|
4658
|
+
const items = data.data;
|
|
4659
|
+
const count = data.count ?? items.length;
|
|
4660
|
+
const searchType = data.searchType ? ` (${sanitizeForMarkdown(data.searchType)})` : "";
|
|
4661
|
+
console.log(`skills.sh \u2014 ${count} matches for ${JSON.stringify(query)}${searchType}:`);
|
|
4662
|
+
for (const item of items) {
|
|
4663
|
+
const name = sanitizeForMarkdown(String(item.name ?? item.id));
|
|
4664
|
+
const id = sanitizeForMarkdown(String(item.id));
|
|
4665
|
+
const installs = item.installs ?? "?";
|
|
4666
|
+
console.log(` - ${name} [${id}] ${installs} installs [unvetted]`);
|
|
4667
|
+
console.log(` install: skill-forge add ${id}`);
|
|
4668
|
+
}
|
|
4669
|
+
console.log("");
|
|
4670
|
+
console.log(INSTALL_HINT_NOTE);
|
|
4671
|
+
}
|
|
4672
|
+
async function find(query, options = {}, deps = {}) {
|
|
4673
|
+
const modes = [query !== void 0, options.audit !== void 0, options.get !== void 0, Boolean(options.curated)];
|
|
4674
|
+
const activeCount = modes.filter(Boolean).length;
|
|
4675
|
+
if (activeCount === 0) {
|
|
4676
|
+
throw new UserError("skill-forge find: provide a search query, or exactly one of --audit <id>, --get <id>, --curated");
|
|
4677
|
+
}
|
|
4678
|
+
if (activeCount > 1) {
|
|
4679
|
+
throw new UserError("skill-forge find: exactly one mode per invocation \u2014 a search query, or exactly one of --audit <id>, --get <id>, --curated");
|
|
4680
|
+
}
|
|
4681
|
+
const callOptions = { fetchImpl: deps.fetchImpl, env: deps.env };
|
|
3048
4682
|
try {
|
|
3049
|
-
|
|
4683
|
+
if (query !== void 0) {
|
|
4684
|
+
if (query.trim().length < 2) {
|
|
4685
|
+
throw new UserError("skill-forge find: query must be at least 2 characters");
|
|
4686
|
+
}
|
|
4687
|
+
const limit = options.limit ?? 10;
|
|
4688
|
+
const data2 = await search(query, limit, callOptions);
|
|
4689
|
+
if (options.json) {
|
|
4690
|
+
printJson(data2);
|
|
4691
|
+
return;
|
|
4692
|
+
}
|
|
4693
|
+
printSearchResults(data2, query);
|
|
4694
|
+
return;
|
|
4695
|
+
}
|
|
4696
|
+
if (options.audit !== void 0) {
|
|
4697
|
+
const id = options.audit;
|
|
4698
|
+
const data2 = await audit(id, callOptions);
|
|
4699
|
+
if (!data2) {
|
|
4700
|
+
console.log(`no partner audits yet for ${sanitizeForMarkdown(id)} (generated after first install).`);
|
|
4701
|
+
return;
|
|
4702
|
+
}
|
|
4703
|
+
if (options.json) {
|
|
4704
|
+
printJson(data2);
|
|
4705
|
+
return;
|
|
4706
|
+
}
|
|
4707
|
+
const { verdict, risk, audits } = auditVerdict(data2);
|
|
4708
|
+
console.log(
|
|
4709
|
+
`skills.sh audit for ${sanitizeForMarkdown(id)}: ${verdict.toUpperCase()} (max risk ${sanitizeForMarkdown(risk)}) \u2014 ${audits.length} partner(s)`
|
|
4710
|
+
);
|
|
4711
|
+
for (const entry of audits) {
|
|
4712
|
+
const provider = sanitizeForMarkdown(String(entry.provider ?? "unknown"));
|
|
4713
|
+
const status2 = sanitizeForMarkdown(String(entry.status ?? "unknown"));
|
|
4714
|
+
const riskLevel = entry.riskLevel ? ` \xB7 ${sanitizeForMarkdown(String(entry.riskLevel))}` : "";
|
|
4715
|
+
const summary = entry.summary ? ` \u2014 ${sanitizeForMarkdown(String(entry.summary))}` : "";
|
|
4716
|
+
console.log(` - ${provider}: ${status2}${riskLevel}${summary}`);
|
|
4717
|
+
}
|
|
4718
|
+
return;
|
|
4719
|
+
}
|
|
4720
|
+
if (options.get !== void 0) {
|
|
4721
|
+
const data2 = await get(options.get, callOptions);
|
|
4722
|
+
printJson(data2);
|
|
4723
|
+
return;
|
|
4724
|
+
}
|
|
4725
|
+
const data = await curated(callOptions);
|
|
4726
|
+
if (options.json) {
|
|
4727
|
+
printJson(data);
|
|
4728
|
+
return;
|
|
4729
|
+
}
|
|
4730
|
+
if (isRecordWithDataArray(data)) {
|
|
4731
|
+
printSearchResults(data, "(curated)");
|
|
4732
|
+
return;
|
|
4733
|
+
}
|
|
4734
|
+
printJson(data);
|
|
3050
4735
|
} catch (err) {
|
|
3051
|
-
|
|
4736
|
+
if (err instanceof SkillsShError) {
|
|
4737
|
+
console.error(err.message);
|
|
4738
|
+
process.exitCode = err.exitCode;
|
|
4739
|
+
return;
|
|
4740
|
+
}
|
|
4741
|
+
throw err;
|
|
3052
4742
|
}
|
|
3053
|
-
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
// src/commands/watch.ts
|
|
4746
|
+
import { realpathSync as realpathSync3 } from "fs";
|
|
4747
|
+
import { join as join22, resolve as resolvePath7 } from "path";
|
|
4748
|
+
var PRO_FEATURE_LABEL4 = "provenance drift watch (watch)";
|
|
4749
|
+
function isGitUrl(source) {
|
|
4750
|
+
const s = source.trim();
|
|
4751
|
+
return /^https?:\/\/\S+$/i.test(s) || /^git@[\w.-]+:\S+$/i.test(s);
|
|
4752
|
+
}
|
|
4753
|
+
function looksLikeGitRef(ref) {
|
|
4754
|
+
const r = ref.trim();
|
|
4755
|
+
return /^[0-9a-fA-F]{7,40}$/.test(r);
|
|
4756
|
+
}
|
|
4757
|
+
function firstNonEmptyLine(text) {
|
|
4758
|
+
return text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "(no output)";
|
|
4759
|
+
}
|
|
4760
|
+
async function checkGitDrift(exec, url, ref) {
|
|
4761
|
+
let result;
|
|
3054
4762
|
try {
|
|
3055
|
-
|
|
4763
|
+
result = await exec("git", ["ls-remote", "--", url, ref], { cwd: process.cwd() });
|
|
3056
4764
|
} catch (err) {
|
|
3057
|
-
|
|
4765
|
+
return { status: "unreachable", detail: `git ls-remote failed to run: ${err.message}` };
|
|
3058
4766
|
}
|
|
3059
|
-
|
|
4767
|
+
if (result.code !== 0) {
|
|
4768
|
+
return { status: "unreachable", detail: `git ls-remote exited ${result.code}: ${firstNonEmptyLine(result.stderr || result.stdout)}` };
|
|
4769
|
+
}
|
|
4770
|
+
const shas = result.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).map((l) => l.split(/\s+/)[0] ?? "").filter((sha) => sha.length > 0);
|
|
4771
|
+
if (shas.length === 0) {
|
|
4772
|
+
return { status: "unreachable", detail: `no ref matching "${ref}" found on remote` };
|
|
4773
|
+
}
|
|
4774
|
+
const recorded = ref.trim().toLowerCase();
|
|
4775
|
+
if (shas.some((sha) => sha.toLowerCase() === recorded)) {
|
|
4776
|
+
return { status: "unchanged", detail: `current: ${recorded}` };
|
|
4777
|
+
}
|
|
4778
|
+
return { status: "moved", detail: `recorded ${ref}, remote now resolves to ${shas[0]}` };
|
|
3060
4779
|
}
|
|
3061
|
-
function
|
|
3062
|
-
|
|
3063
|
-
|
|
4780
|
+
async function runWatch(config, options = {}, deps = {}) {
|
|
4781
|
+
const proStatus = proFeatureStatus(PRO_FEATURE_LABEL4);
|
|
4782
|
+
if (!proStatus.enabled) {
|
|
4783
|
+
return { roots: [], rows: [], warnings: [], notices: [proStatus.notice ?? "Pro feature (watch) locked."] };
|
|
3064
4784
|
}
|
|
3065
|
-
const
|
|
3066
|
-
|
|
3067
|
-
|
|
4785
|
+
const notices = proStatus.notice ? [proStatus.notice] : [];
|
|
4786
|
+
const exec = deps.exec ?? defaultExec;
|
|
4787
|
+
const roots = [];
|
|
4788
|
+
const rows = [];
|
|
4789
|
+
const warnings = [];
|
|
4790
|
+
const seenRealPaths = /* @__PURE__ */ new Set();
|
|
4791
|
+
for (const root of config.skillsRoots) {
|
|
4792
|
+
const resolvedRoot = resolvePath7(expandHome(root));
|
|
4793
|
+
const exists = isDir(resolvedRoot);
|
|
4794
|
+
let realPath = resolvedRoot;
|
|
4795
|
+
if (exists) {
|
|
4796
|
+
try {
|
|
4797
|
+
realPath = realpathSync3(resolvedRoot);
|
|
4798
|
+
} catch {
|
|
4799
|
+
realPath = resolvedRoot;
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
if (seenRealPaths.has(realPath)) continue;
|
|
4803
|
+
seenRealPaths.add(realPath);
|
|
4804
|
+
let ledger;
|
|
4805
|
+
try {
|
|
4806
|
+
ledger = readProvenanceLedger(resolvedRoot);
|
|
4807
|
+
} catch (err) {
|
|
4808
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4809
|
+
const ledgerPath = join22(resolvedRoot, "SOURCES.md");
|
|
4810
|
+
roots.push({ root, realPath, exists, ledgerPath, ledgerExists: false });
|
|
4811
|
+
warnings.push(`${ledgerPath}: could not read ledger: ${message}`);
|
|
4812
|
+
continue;
|
|
4813
|
+
}
|
|
4814
|
+
roots.push({ root, realPath, exists, ledgerPath: ledger.ledgerPath, ledgerExists: ledger.exists });
|
|
4815
|
+
warnings.push(...ledger.warnings.map((w) => `${ledger.ledgerPath}: ${w}`));
|
|
4816
|
+
for (const entry of ledger.entries) {
|
|
4817
|
+
if (entry.verb === "REJECT") continue;
|
|
4818
|
+
const rowBase = {
|
|
4819
|
+
root,
|
|
4820
|
+
ledgerPath: ledger.ledgerPath,
|
|
4821
|
+
name: entry.name,
|
|
4822
|
+
verb: entry.verb,
|
|
4823
|
+
source: entry.source,
|
|
4824
|
+
upstreamRef: entry.upstreamRef,
|
|
4825
|
+
driftCheckCommand: entry.driftCheck
|
|
4826
|
+
};
|
|
4827
|
+
if (!(isGitUrl(entry.source) && looksLikeGitRef(entry.upstreamRef))) {
|
|
4828
|
+
rows.push({
|
|
4829
|
+
...rowBase,
|
|
4830
|
+
checkMethod: "manual",
|
|
4831
|
+
driftStatus: "not-checked",
|
|
4832
|
+
detail: "source is not a recognized git URL + ref pin \u2014 check manually"
|
|
4833
|
+
});
|
|
4834
|
+
continue;
|
|
4835
|
+
}
|
|
4836
|
+
if (options.offline) {
|
|
4837
|
+
rows.push({
|
|
4838
|
+
...rowBase,
|
|
4839
|
+
checkMethod: "git-ls-remote",
|
|
4840
|
+
driftStatus: "not-checked",
|
|
4841
|
+
detail: "--offline: network check skipped"
|
|
4842
|
+
});
|
|
4843
|
+
continue;
|
|
4844
|
+
}
|
|
4845
|
+
const { status: status2, detail } = await checkGitDrift(exec, entry.source, entry.upstreamRef);
|
|
4846
|
+
rows.push({ ...rowBase, checkMethod: "git-ls-remote", driftStatus: status2, detail });
|
|
4847
|
+
}
|
|
3068
4848
|
}
|
|
3069
|
-
|
|
3070
|
-
|
|
4849
|
+
return { roots, rows, warnings, notices };
|
|
4850
|
+
}
|
|
4851
|
+
function printWatchTerminal(report) {
|
|
4852
|
+
console.log("[skill-forge] watch \u2014 provenance drift check");
|
|
4853
|
+
for (const notice of report.notices) console.log(`[notice] ${notice}`);
|
|
4854
|
+
if (report.roots.length === 0) {
|
|
4855
|
+
console.log("\nNo configured skills roots.");
|
|
3071
4856
|
}
|
|
3072
|
-
|
|
3073
|
-
|
|
4857
|
+
for (const root of report.roots) {
|
|
4858
|
+
console.log(`
|
|
4859
|
+
${sanitizeForMarkdown(root.root)} (${sanitizeForMarkdown(root.realPath)})`);
|
|
4860
|
+
if (!root.exists) {
|
|
4861
|
+
console.log(" root does not exist on disk");
|
|
4862
|
+
continue;
|
|
4863
|
+
}
|
|
4864
|
+
if (!root.ledgerExists) {
|
|
4865
|
+
console.log(` no ledger at ${sanitizeForMarkdown(root.ledgerPath)}`);
|
|
4866
|
+
continue;
|
|
4867
|
+
}
|
|
4868
|
+
console.log(` ledger: ${sanitizeForMarkdown(root.ledgerPath)}`);
|
|
4869
|
+
const rowsForRoot = report.rows.filter((r) => r.root === root.root);
|
|
4870
|
+
if (rowsForRoot.length === 0) {
|
|
4871
|
+
console.log(" (no tracked, non-REJECT sources)");
|
|
4872
|
+
continue;
|
|
4873
|
+
}
|
|
4874
|
+
for (const row of rowsForRoot) {
|
|
4875
|
+
console.log(` \u2022 ${sanitizeForMarkdown(row.name)} [${sanitizeForMarkdown(row.verb)}] ref=${sanitizeForMarkdown(row.upstreamRef)}`);
|
|
4876
|
+
if (row.checkMethod === "git-ls-remote") {
|
|
4877
|
+
console.log(` drift: ${row.driftStatus} \u2014 ${sanitizeForMarkdown(row.detail)}`);
|
|
4878
|
+
} else {
|
|
4879
|
+
console.log(` manual check \u2014 stored drift-check command (data, NOT executed): ${sanitizeForMarkdown(row.driftCheckCommand)}`);
|
|
4880
|
+
}
|
|
4881
|
+
}
|
|
4882
|
+
}
|
|
4883
|
+
if (report.warnings.length > 0) {
|
|
4884
|
+
console.log("\nWarnings:");
|
|
4885
|
+
for (const w of report.warnings) console.log(` - ${sanitizeForMarkdown(w)}`);
|
|
4886
|
+
}
|
|
4887
|
+
console.log("\nCompare each stored ref to current upstream; re-run ingestion on any that moved.");
|
|
4888
|
+
}
|
|
4889
|
+
async function watchCommand(options = {}) {
|
|
4890
|
+
const report = await runWatch(loadConfig(), options);
|
|
4891
|
+
if (options.json) {
|
|
4892
|
+
console.log(JSON.stringify(report, null, 2));
|
|
4893
|
+
for (const notice of report.notices) console.error(`[skill-forge] ${notice}`);
|
|
4894
|
+
return;
|
|
4895
|
+
}
|
|
4896
|
+
printWatchTerminal(report);
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
// src/commands/ingest.ts
|
|
4900
|
+
import { realpathSync as realpathSync4 } from "fs";
|
|
4901
|
+
import { dirname as dirname9, isAbsolute, relative, resolve as resolvePath8 } from "path";
|
|
4902
|
+
var PRO_FEATURE_LABEL5 = "the pending-ingestion queue";
|
|
4903
|
+
var VALID_STATUSES = ["ingested", "dismissed"];
|
|
4904
|
+
function requireProFeature() {
|
|
4905
|
+
const status2 = proFeatureStatus(PRO_FEATURE_LABEL5);
|
|
4906
|
+
if (!status2.enabled) {
|
|
4907
|
+
throw new UserError(
|
|
4908
|
+
status2.notice ?? "ingest is a Pro feature \u2014 set SKILL_FORGE_LICENSE or config.licenseKey to a valid license to unlock."
|
|
4909
|
+
);
|
|
3074
4910
|
}
|
|
4911
|
+
if (status2.notice) console.error(`[skill-forge] ${status2.notice}`);
|
|
4912
|
+
}
|
|
4913
|
+
function pluralizeEntries(count) {
|
|
4914
|
+
return `entr${count === 1 ? "y" : "ies"}`;
|
|
4915
|
+
}
|
|
4916
|
+
function toListRow(entry) {
|
|
3075
4917
|
return {
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
4918
|
+
id: entry.id,
|
|
4919
|
+
source: entry.source,
|
|
4920
|
+
artifactType: entry.artifactType ?? "skill",
|
|
4921
|
+
safetyVerdict: entry.gate.safetyVerdict,
|
|
4922
|
+
createdAt: entry.createdAt
|
|
3080
4923
|
};
|
|
3081
4924
|
}
|
|
3082
|
-
function
|
|
3083
|
-
if (
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
function isAlreadyAdopted(stagingDir, manifest) {
|
|
3087
|
-
const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
|
|
3088
|
-
const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
|
|
3089
|
-
let sawComparison = false;
|
|
3090
|
-
if (existsSync14(proposedSkillMd)) {
|
|
3091
|
-
sawComparison = true;
|
|
3092
|
-
if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
|
|
4925
|
+
function printPendingList(pending, json) {
|
|
4926
|
+
if (json) {
|
|
4927
|
+
console.log(JSON.stringify({ entries: pending.map(toListRow) }, null, 2));
|
|
4928
|
+
return;
|
|
3093
4929
|
}
|
|
3094
|
-
if (
|
|
3095
|
-
|
|
3096
|
-
|
|
4930
|
+
if (pending.length === 0) {
|
|
4931
|
+
console.log("[skill-forge] no pending queue entries.");
|
|
4932
|
+
return;
|
|
4933
|
+
}
|
|
4934
|
+
console.log(`[skill-forge] ${pending.length} pending ${pluralizeEntries(pending.length)}:`);
|
|
4935
|
+
for (const entry of pending) {
|
|
4936
|
+
const row = toListRow(entry);
|
|
4937
|
+
console.log(` ${row.id} ${row.source} (${row.artifactType}) verdict=${row.safetyVerdict} created=${row.createdAt}`);
|
|
3097
4938
|
}
|
|
3098
|
-
return sawComparison;
|
|
3099
4939
|
}
|
|
3100
|
-
function
|
|
3101
|
-
let stat;
|
|
4940
|
+
function realpathOrNull(p) {
|
|
3102
4941
|
try {
|
|
3103
|
-
|
|
4942
|
+
return realpathSync4(p);
|
|
3104
4943
|
} catch {
|
|
3105
|
-
return;
|
|
4944
|
+
return null;
|
|
3106
4945
|
}
|
|
3107
|
-
|
|
3108
|
-
|
|
4946
|
+
}
|
|
4947
|
+
function isUnderRoot(candidate, root) {
|
|
4948
|
+
const rel = relative(root, candidate);
|
|
4949
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
4950
|
+
}
|
|
4951
|
+
function validatePendingEntries(entries, config) {
|
|
4952
|
+
const quarantineRoot = realpathOrNull(resolvePath8(config.quarantineDir));
|
|
4953
|
+
const skillsRoots = config.skillsRoots.map((r) => realpathOrNull(resolvePath8(r))).filter((r) => r !== null);
|
|
4954
|
+
const mcpTargetDirs = (config.mcpTargets ?? []).map((f) => realpathOrNull(dirname9(resolvePath8(f)))).filter((r) => r !== null);
|
|
4955
|
+
const allowedRoots = [quarantineRoot, ...skillsRoots, ...mcpTargetDirs].filter((r) => r !== null);
|
|
4956
|
+
const valid = [];
|
|
4957
|
+
const excluded = [];
|
|
4958
|
+
for (const entry of entries) {
|
|
4959
|
+
const effectivePath = entry.installedPath ?? entry.quarantinePath;
|
|
4960
|
+
if (!effectivePath) {
|
|
4961
|
+
excluded.push({ entry, reason: "no installedPath or quarantinePath recorded on the entry" });
|
|
4962
|
+
continue;
|
|
4963
|
+
}
|
|
4964
|
+
const real = realpathOrNull(resolvePath8(effectivePath));
|
|
4965
|
+
if (!real) {
|
|
4966
|
+
excluded.push({ entry, reason: `path does not exist or could not be resolved: ${effectivePath}` });
|
|
4967
|
+
continue;
|
|
4968
|
+
}
|
|
4969
|
+
if (allowedRoots.length === 0 || !allowedRoots.some((root) => isUnderRoot(real, root))) {
|
|
4970
|
+
excluded.push({ entry, reason: `path escapes every configured root (quarantineDir/skillsRoots/mcpTargets): ${effectivePath}` });
|
|
4971
|
+
continue;
|
|
4972
|
+
}
|
|
4973
|
+
valid.push(entry);
|
|
3109
4974
|
}
|
|
4975
|
+
return { valid, excluded };
|
|
3110
4976
|
}
|
|
3111
|
-
function
|
|
3112
|
-
|
|
3113
|
-
|
|
4977
|
+
async function ingest(options = {}) {
|
|
4978
|
+
requireProFeature();
|
|
4979
|
+
const queue2 = readQueue();
|
|
4980
|
+
const pending = queue2.entries.filter((e) => e.status === "pending");
|
|
4981
|
+
if (options.list) {
|
|
4982
|
+
printPendingList(pending, options.json);
|
|
4983
|
+
return;
|
|
4984
|
+
}
|
|
4985
|
+
if (pending.length === 0) {
|
|
4986
|
+
console.log("[skill-forge] no pending queue entries to ingest.");
|
|
4987
|
+
return;
|
|
4988
|
+
}
|
|
4989
|
+
const config = loadConfig();
|
|
4990
|
+
const { valid, excluded } = validatePendingEntries(pending, config);
|
|
4991
|
+
if (excluded.length > 0) {
|
|
4992
|
+
console.error(`[skill-forge] excluded ${excluded.length} pending ${pluralizeEntries(excluded.length)} that failed path validation:`);
|
|
4993
|
+
for (const { entry, reason } of excluded) {
|
|
4994
|
+
console.error(` - ${entry.id} (${entry.source}): ${reason}`);
|
|
4995
|
+
}
|
|
4996
|
+
console.error(
|
|
4997
|
+
"[skill-forge] excluded entries stay in the queue \u2014 review each, then dismiss with: skill-forge queue close <id> --status dismissed"
|
|
4998
|
+
);
|
|
4999
|
+
}
|
|
5000
|
+
if (valid.length === 0) {
|
|
5001
|
+
console.log("[skill-forge] no pending entries passed path validation \u2014 nothing to hand off.");
|
|
5002
|
+
return;
|
|
5003
|
+
}
|
|
5004
|
+
console.log(`[skill-forge] ${valid.length} pending ${pluralizeEntries(valid.length)} ready for ingest:`);
|
|
5005
|
+
for (const entry of valid) {
|
|
5006
|
+
console.log(` - ${entry.id} ${entry.source} (${entry.artifactType ?? "skill"})`);
|
|
5007
|
+
}
|
|
5008
|
+
runHandoff(config, getQueuePath(), { promptAsset: "ingest-prompt.md" });
|
|
3114
5009
|
}
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
5010
|
+
async function queueClose(id, options = {}) {
|
|
5011
|
+
requireProFeature();
|
|
5012
|
+
const status2 = options.status;
|
|
5013
|
+
if (!status2 || !VALID_STATUSES.includes(status2)) {
|
|
5014
|
+
throw new UserError(`--status must be one of: ${VALID_STATUSES.join(", ")} (got ${status2 ? JSON.stringify(status2) : "nothing"})`);
|
|
5015
|
+
}
|
|
5016
|
+
let updated;
|
|
5017
|
+
try {
|
|
5018
|
+
updated = updateQueueEntry(id, { status: status2 });
|
|
5019
|
+
} catch (err) {
|
|
5020
|
+
throw new UserError(err instanceof Error ? err.message : String(err));
|
|
5021
|
+
}
|
|
5022
|
+
console.log(`[skill-forge] queue entry ${updated.id} closed -> status: ${updated.status}`);
|
|
3119
5023
|
}
|
|
3120
5024
|
|
|
3121
5025
|
// src/cli.ts
|
|
@@ -3134,6 +5038,13 @@ function parseLookbackHours(value) {
|
|
|
3134
5038
|
}
|
|
3135
5039
|
return n;
|
|
3136
5040
|
}
|
|
5041
|
+
function parseLimit(value) {
|
|
5042
|
+
const n = Number(value);
|
|
5043
|
+
if (value.trim() === "" || !Number.isInteger(n) || n < 1 || n > 50) {
|
|
5044
|
+
throw new UserError(`--limit must be an integer between 1 and 50 (got "${value}")`);
|
|
5045
|
+
}
|
|
5046
|
+
return n;
|
|
5047
|
+
}
|
|
3137
5048
|
var program = new Command();
|
|
3138
5049
|
program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
|
|
3139
5050
|
program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).option(
|
|
@@ -3144,6 +5055,15 @@ program.command("scan <source>").description("Run the safety gate against a skil
|
|
|
3144
5055
|
program.command("list").description("List skills currently in quarantine").action(list);
|
|
3145
5056
|
program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
|
|
3146
5057
|
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);
|
|
5058
|
+
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);
|
|
5059
|
+
program.command("organize").description("Build the set-level capability registry + dependency graph across configured skills roots (Pro)").option("--json", "print the registry+graph report as JSON instead of the terminal summary").option("--out <file>", "write the full report JSON to this path (refuses an existing path)").option("--usage-snapshot <file>", "join per-skill usage counts from a skill-monitor snapshot JSON").action(organizeCommand);
|
|
5060
|
+
program.command("find [query]").description("Discover skills via skills.sh and check partner security audits (requires VERCEL_OIDC_TOKEN)").option("--limit <n>", "max search results (default 10)", parseLimit).option("--audit <id>", "show partner audit verdicts (Socket/Snyk/\u2026) for a skills.sh id").option("--get <id>", "show detail + file tree for a skills.sh id").option("--curated", "list skills.sh curated skills").option("--json", "print the raw API payload as JSON").action(find);
|
|
5061
|
+
program.command("watch").description("Drift check across every SOURCES.md provenance ledger: which ingested upstreams have moved (Pro)").option("--offline", "skip all network checks; list entries for manual comparison only").option("--json", "print the drift report as JSON instead of the terminal summary").action(watchCommand);
|
|
5062
|
+
program.command("ingest").description(
|
|
5063
|
+
"Hand the pending-ingestion queue to the configured coding agent for the decide/absorb pass (Pro). For a single new source, use `skill-forge add <source> --ingest` instead."
|
|
5064
|
+
).option("--list", "print pending queue entries and exit \u2014 no handoff").option("--json", "with --list: print pending entries as JSON").action(ingest);
|
|
5065
|
+
var queue = program.command("queue").description("Manage the pending-ingestion queue (Pro)");
|
|
5066
|
+
queue.command("close <id>").description("Close a queue entry after the decide pass \u2014 agents use this instead of hand-editing queue.json").requiredOption("--status <status>", 'final status: "ingested" or "dismissed"').action(queueClose);
|
|
3147
5067
|
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);
|
|
3148
5068
|
try {
|
|
3149
5069
|
await program.parseAsync();
|