@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/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 join16 } from "path";
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 readFileSync6,
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 readFileSync5 } from "fs";
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(readFileSync5(file, "utf-8"));
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(readFileSync6(pkgPath, "utf-8"));
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 = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
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 = readFileSync6(f, "utf-8");
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 = readFileSync6(skillMd, "utf-8");
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(readFileSync6(manifestPath, "utf-8"));
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(readFileSync6(explicitConfig, "utf-8"));
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(readFileSync6(pkgPath, "utf-8"));
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 readFileSync7, readdirSync as readdirSync4 } from "fs";
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 = readFileSync7(skillMdPath, "utf-8");
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 readFileSync8 } from "fs";
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(readFileSync8(path, "utf-8"));
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 readFileSync9, writeFileSync as writeFileSync4 } from "fs";
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 = readFileSync9(path, "utf-8");
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(queue) {
2005
- mkdirSync5(getHomeDir(), { recursive: true });
2006
- writeFileSync4(getQueuePath(), JSON.stringify(queue, null, 2) + "\n", "utf-8");
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 queue = readQueue();
2010
- const existingIds = new Set(queue.entries.map((e) => e.id));
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
- queue.entries.push(full);
2017
- writeQueue(queue);
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, "ingest-prompt.md");
2139
+ const builtCandidate = join14(here, assetName);
2038
2140
  if (existsSync12(builtCandidate)) return builtCandidate;
2039
- return join14(here, "..", "assets", "ingest-prompt.md");
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
- console.log(printed);
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
- console.log(`[skill-forge] ${bin} not found on PATH \u2014 run this yourself:`);
2083
- console.log(printed);
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 existsSync13 } from "fs";
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/init.ts
2336
- function detectAgents(cwd, home) {
2337
- const found = [];
2338
- for (const agent of AGENTS) {
2339
- const hit = detectAgentRoot(agent, cwd, home);
2340
- if (hit) found.push({ agent, ...hit });
2341
- }
2342
- return found;
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 detectMcpTargets(cwd, home) {
2345
- const found = [];
2346
- for (const agent of AGENTS) {
2347
- for (const hit of detectMcpConfigFiles(agent, cwd, home)) {
2348
- found.push({ agent, ...hit });
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 printDetectionResults(detected) {
2354
- console.log(`[skill-forge] init \u2014 agent detection (${AGENTS.length} known agents)`);
2355
- if (detected.length === 0) {
2356
- console.log(" none detected");
2357
- return;
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
- for (const d of detected) {
2360
- console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.root}`);
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 printMcpDetectionResults(detectedMcp) {
2364
- if (detectedMcp.length === 0) return;
2365
- console.log("\n[skill-forge] init \u2014 MCP config detection");
2366
- for (const d of detectedMcp) {
2367
- console.log(` ${d.agent.displayName} (${d.agent.id}) \u2014 ${d.scope}: ${d.path}`);
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 handoffTemplateFor(agent) {
2371
- return agent.defaultHandoffTemplate ?? [agent.id, "-p", HANDOFF_INSTRUCTION];
2372
- }
2373
- function buildConfig(selected, defaultTargetEntry, handoffAgent, selectedMcp = []) {
2374
- const existing = loadConfig();
2375
- return {
2376
- ...existing,
2377
- // Skill-root selections are only overwritten when this run actually offered a choice —
2378
- // an mcp-only run (no skill roots detected) must not blank out an existing skillsRoots/agents.
2379
- skillsRoots: selected.length > 0 ? selected.map((s) => s.root) : existing.skillsRoots,
2380
- agents: selected.length > 0 ? selected.map((s) => ({ id: s.agent.id, skillsRoot: s.root })) : existing.agents,
2381
- defaultTarget: defaultTargetEntry?.root ?? existing.defaultTarget,
2382
- handoffCommand: handoffAgent ? handoffTemplateFor(handoffAgent) : existing.handoffCommand,
2383
- mcpTargets: selectedMcp.length > 0 ? selectedMcp.map((m) => m.path) : existing.mcpTargets
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 printSummary(config) {
2387
- console.log(`
2388
- [skill-forge] wrote config to ${getConfigPath()}`);
2389
- console.log(` gate targets: ${config.skillsRoots.join(", ")}`);
2390
- if (config.defaultTarget) console.log(` default target: ${config.defaultTarget}`);
2391
- if (config.handoffCommand?.length) console.log(` handoff command: ${config.handoffCommand.join(" ")}`);
2392
- if (config.mcpTargets?.length) console.log(` mcp targets: ${config.mcpTargets.join(", ")}`);
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 parseChoice(answer, max) {
2395
- const n = Number(answer);
2396
- return Number.isInteger(n) && n >= 1 && n <= max ? n - 1 : void 0;
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
- async function promptMultiSelect(rl, detected) {
2399
- const chosen = new Set(detected.map((_, i) => i));
2400
- for (; ; ) {
2401
- console.log("\nGate targets (all detected roots pre-selected \u2014 type a number to toggle, Enter to confirm):");
2402
- detected.forEach((d, i) => {
2403
- const mark = chosen.has(i) ? "[x]" : "[ ]";
2404
- console.log(` ${mark} ${i + 1}. ${d.agent.displayName} (${d.scope}) \u2014 ${d.root}`);
2405
- });
2406
- const answer = (await rl.question("> ")).trim();
2407
- if (answer === "") break;
2408
- const idx = parseChoice(answer, detected.length);
2409
- if (idx !== void 0) {
2410
- if (chosen.has(idx)) chosen.delete(idx);
2411
- else chosen.add(idx);
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
- console.log(" (invalid selection \u2014 enter a listed number, or press Enter to confirm)");
2596
+ description = stripQuotes(first);
2414
2597
  }
2415
2598
  }
2416
- return detected.filter((_, i) => chosen.has(i));
2599
+ if (!description) errors.push('missing required "description" field in frontmatter');
2600
+ return { valid: errors.length === 0, name, description, errors };
2417
2601
  }
2418
- async function promptMcpMultiSelect(rl, detectedMcp) {
2419
- const chosen = new Set(detectedMcp.map((_, i) => i));
2420
- for (; ; ) {
2421
- console.log("\nMCP targets (all detected config files pre-selected \u2014 type a number to toggle, Enter to confirm):");
2422
- detectedMcp.forEach((d, i) => {
2423
- const mark = chosen.has(i) ? "[x]" : "[ ]";
2424
- console.log(` ${mark} ${i + 1}. ${d.agent.displayName} (${d.scope}) \u2014 ${d.path}`);
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
- const answer = (await rl.question("> ")).trim();
2427
- if (answer === "") break;
2428
- const idx = parseChoice(answer, detectedMcp.length);
2429
- if (idx !== void 0) {
2430
- if (chosen.has(idx)) chosen.delete(idx);
2431
- else chosen.add(idx);
2432
- } else {
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 detectedMcp.filter((_, i) => chosen.has(i));
2437
- }
2438
- async function promptDefaultTarget(rl, selected) {
2439
- console.log("\nDefault promotion target:");
2440
- selected.forEach((d, i) => console.log(` ${i + 1}. ${d.agent.displayName} \u2014 ${d.root}`));
2441
- const answer = (await rl.question(`Choose [1-${selected.length}] (default 1): `)).trim();
2442
- const idx = answer === "" ? 0 : parseChoice(answer, selected.length) ?? 0;
2443
- return selected[idx];
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
- async function promptHandoffAgent(rl, detected) {
2446
- console.log("\nHandoff agent (used to hand off follow-up prompts after promotion; optional):");
2447
- detected.forEach((d, i) => {
2448
- const note = d.agent.defaultHandoffTemplate ? "" : " (no confirmed CLI \u2014 best-effort)";
2449
- console.log(` ${i + 1}. ${d.agent.displayName}${note}`);
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
- async function init(options = {}) {
2457
- const cwd = options.cwd ?? process.cwd();
2458
- const home = options.home ?? homedir4();
2459
- const detected = detectAgents(cwd, home);
2460
- const detectedMcp = detectMcpTargets(cwd, home);
2461
- if (options.list) {
2462
- printDetectionResults(detected);
2463
- printMcpDetectionResults(detectedMcp);
2464
- return;
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
- if (detected.length === 0 && detectedMcp.length === 0) {
2467
- console.log("[skill-forge] No known agent skill directories or MCP config files were found.");
2468
- console.log(" Install a coding agent (Claude Code, Codex, Cursor, ...) or create a skills root, then re-run `skill-forge init`.");
2469
- return;
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
- if (options.defaults) {
2472
- const config = buildConfig(detected, detected[0], void 0, detectedMcp);
2473
- saveConfig(config);
2474
- printSummary(config);
2475
- return;
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 (!process.stdin.isTTY) {
2478
- console.log("[skill-forge] Not an interactive terminal \u2014 run with --defaults for non-interactive setup.");
2479
- return;
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
- printDetectionResults(detected);
2482
- printMcpDetectionResults(detectedMcp);
2483
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2708
+ let raw;
2484
2709
  try {
2485
- const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
2486
- const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
2487
- if (selected.length === 0 && selectedMcp.length === 0) {
2488
- console.log("[skill-forge] No targets selected \u2014 aborting, nothing written.");
2489
- return;
2490
- }
2491
- const target = selected.length > 0 ? await promptDefaultTarget(rl, selected) : void 0;
2492
- const handoffAgent = detected.length > 0 ? await promptHandoffAgent(rl, detected) : void 0;
2493
- const config = buildConfig(selected, target, handoffAgent, selectedMcp);
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
- answer = (await rl.question("Run `skill-forge init` now? [y/N] ")).trim().toLowerCase();
2508
- } finally {
2509
- rl.close();
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 (answer === "y" || answer === "yes") {
2512
- await init();
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 as createInterface2 } from "readline/promises";
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 = createInterface2({ input: process.stdin, output: process.stdout });
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] ?? join16(process.cwd(), ".mcp.json");
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 join17 } from "path";
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(join17(quarantineDir, id));
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(join17(dir, "SKILL.md")).name;
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 createInterface3 } from "readline/promises";
3799
+ import { createInterface as createInterface4 } from "readline/promises";
2810
3800
  import { createHash } from "crypto";
2811
- import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
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 basename6, dirname as dirname6, join as join18, resolve as resolvePath2, sep } from "path";
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 = resolvePath2(targetSkillDir);
2820
- const skillMdPath = join18(resolvedTargetDir, "SKILL.md");
2821
- if (!existsSync14(skillMdPath)) {
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 = resolvePath2(options.project ?? process.cwd());
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 = join18(project, ".skillopt-sleep", "staging");
2880
- const stagingDir = resolvePath2(parsed.staging_dir);
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 = join18(stagingDir, "proposed_SKILL.md");
2887
- const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
2888
- const reportMdPath = join18(stagingDir, "report.md");
2889
- if (!existsSync14(proposedSkillMd)) {
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 (existsSync14(reportMdPath)) {
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 = mkdtempSync2(join18(tmpdir2(), "skill-forge-evolve-"));
3889
+ const tempDir = mkdtempSync3(join20(tmpdir2(), "skill-forge-evolve-"));
2900
3890
  let profile;
2901
3891
  let safety;
2902
3892
  try {
2903
- cpSync3(proposedSkillMd, join18(tempDir, "SKILL.md"));
2904
- if (existsSync14(proposedClaudeMd)) {
2905
- cpSync3(proposedClaudeMd, join18(tempDir, "CLAUDE.md"));
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
- rmSync3(tempDir, { recursive: true, force: true });
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 = dirname6(resolvedTargetDir);
3937
+ const skillsRootForProvenance = dirname7(resolvedTargetDir);
2948
3938
  let ledgerPath;
2949
3939
  try {
2950
3940
  ledgerPath = appendProvenance(
2951
3941
  {
2952
- name: profile.name ?? basename6(resolvedTargetDir),
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
- logOutcome("[skill-forge] queued for ingestion review", json);
2992
- } else if (decision === "reject") {
4328
+ if (seenRealPaths.has(realPath)) continue;
4329
+ seenRealPaths.add(realPath);
4330
+ const skillMdPath = join21(realPath, "SKILL.md");
4331
+ let text;
2993
4332
  try {
2994
- rmSync3(stagingDir, { recursive: true, force: true });
4333
+ text = readFileSync13(skillMdPath, "utf-8");
2995
4334
  } catch (err) {
2996
- throw new UserError(
2997
- `rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
2998
- );
4335
+ warnings.push(`could not read ${skillMdPath}: ${err.message}`);
4336
+ continue;
2999
4337
  }
3000
- logOutcome("[skill-forge] rejected \u2014 staging dir removed", json);
3001
- if (safetyVerdict === "block") process.exitCode = 1;
3002
- } else {
3003
- logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
3004
- if (existsSync14(reportMdPath)) {
3005
- logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
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
- async function defaultConfirm(question) {
3010
- const rl = createInterface3({ input: process.stdin, output: process.stdout });
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
- process.stdout.write(question);
3013
- for await (const raw of rl) {
3014
- return /^y(es)?$/i.test(raw.trim());
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
- return false;
3017
- } finally {
3018
- rl.close();
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 detectSkillopt(exec, cwd) {
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
- const result = await exec(SKILLOPT_BIN, ["--help"], { cwd });
3024
- return result.code === 0;
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
- return false;
4604
+ throw new SkillsShError("skills.sh returned malformed JSON", 1);
3027
4605
  }
4606
+ return parsed;
3028
4607
  }
3029
- function tail(text, maxLines = 20) {
3030
- return text.trim().split("\n").slice(-maxLines).join("\n");
4608
+ function isRecord(v) {
4609
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3031
4610
  }
3032
- function validateRunOutput(raw) {
3033
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3034
- throw new UserError(`skillopt-sleep produced unexpected JSON output (expected an object): ${JSON.stringify(raw)}`);
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
- const obj = raw;
3037
- const stagingDirRaw = obj.staging_dir;
3038
- if (stagingDirRaw !== null && stagingDirRaw !== void 0 && typeof stagingDirRaw !== "string") {
3039
- throw new UserError(
3040
- `skillopt-sleep produced an unexpected "staging_dir" (expected a string or null): ${JSON.stringify(stagingDirRaw)}`
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 { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
4624
+ return data;
3044
4625
  }
3045
- function readManifest(stagingDir) {
3046
- const manifestPath = join18(stagingDir, "manifest.json");
3047
- let raw;
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
- raw = readFileSync10(manifestPath, "utf-8");
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
- throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
4736
+ if (err instanceof SkillsShError) {
4737
+ console.error(err.message);
4738
+ process.exitCode = err.exitCode;
4739
+ return;
4740
+ }
4741
+ throw err;
3052
4742
  }
3053
- let parsed;
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
- parsed = JSON.parse(raw);
4763
+ result = await exec("git", ["ls-remote", "--", url, ref], { cwd: process.cwd() });
3056
4764
  } catch (err) {
3057
- throw new UserError(`could not parse staging manifest ${manifestPath}: ${err.message}`);
4765
+ return { status: "unreachable", detail: `git ls-remote failed to run: ${err.message}` };
3058
4766
  }
3059
- return validateManifest(parsed, manifestPath);
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 validateManifest(raw, manifestPath) {
3062
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3063
- throw new UserError(`staging manifest ${manifestPath} has an unexpected shape (expected an object)`);
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 obj = raw;
3066
- if (typeof obj.live_skill_path !== "string") {
3067
- throw new UserError(`staging manifest ${manifestPath} is missing a valid "live_skill_path" string`);
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
- if (obj.live_memory_path !== void 0 && obj.live_memory_path !== null && typeof obj.live_memory_path !== "string") {
3070
- throw new UserError(`staging manifest ${manifestPath} has an unexpected "live_memory_path" (expected a string or null)`);
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
- if (typeof obj.has_skill !== "boolean" || typeof obj.has_memory !== "boolean") {
3073
- throw new UserError(`staging manifest ${manifestPath} is missing valid "has_skill"/"has_memory" booleans`);
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
- live_skill_path: obj.live_skill_path,
3077
- live_memory_path: obj.live_memory_path ?? null,
3078
- has_skill: obj.has_skill,
3079
- has_memory: obj.has_memory
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 filesIdentical(a, b) {
3083
- if (!existsSync14(a) || !existsSync14(b)) return false;
3084
- return readFileSync10(a).equals(readFileSync10(b));
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 (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
3095
- sawComparison = true;
3096
- if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
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 assertNotSymlink(path, label) {
3101
- let stat;
4940
+ function realpathOrNull(p) {
3102
4941
  try {
3103
- stat = lstatSync(path);
4942
+ return realpathSync4(p);
3104
4943
  } catch {
3105
- return;
4944
+ return null;
3106
4945
  }
3107
- if (stat.isSymbolicLink()) {
3108
- throw new UserError(`refusing to re-gate ${label}: ${path} is a symlink (a staged proposal must be a plain file)`);
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 sha256File(path) {
3112
- if (!existsSync14(path)) return null;
3113
- return createHash("sha256").update(readFileSync10(path)).digest("hex");
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
- // src/commands/evolve.ts
3117
- async function evolveCommand(targetSkillDir, options = {}) {
3118
- await evolve(targetSkillDir, options);
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();