@ctxr/skill-llm-wiki 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +7 -0
  4. package/guide/cli.md +6 -4
  5. package/guide/consumers/index.md +106 -0
  6. package/guide/consumers/quickstart.md +96 -0
  7. package/guide/consumers/recipes/ci-gate.md +125 -0
  8. package/guide/consumers/recipes/dated-wiki.md +131 -0
  9. package/guide/consumers/recipes/format-gate.md +126 -0
  10. package/guide/consumers/recipes/post-write-heal.md +125 -0
  11. package/guide/consumers/recipes/skill-absent.md +111 -0
  12. package/guide/consumers/recipes/subject-wiki.md +110 -0
  13. package/guide/consumers/recipes/testing.md +149 -0
  14. package/guide/index.md +9 -0
  15. package/guide/substrate/operators.md +1 -1
  16. package/guide/substrate/tiered-ai.md +6 -5
  17. package/guide/ux/user-intent.md +6 -5
  18. package/package.json +9 -3
  19. package/scripts/cli.mjs +565 -15
  20. package/scripts/lib/balance.mjs +579 -0
  21. package/scripts/lib/cluster-detect.mjs +482 -4
  22. package/scripts/lib/contract.mjs +257 -0
  23. package/scripts/lib/decision-log.mjs +121 -15
  24. package/scripts/lib/heal.mjs +167 -0
  25. package/scripts/lib/init.mjs +210 -0
  26. package/scripts/lib/intent.mjs +370 -4
  27. package/scripts/lib/join-constants.mjs +22 -0
  28. package/scripts/lib/join.mjs +917 -0
  29. package/scripts/lib/json-envelope.mjs +190 -0
  30. package/scripts/lib/nest-applier.mjs +395 -32
  31. package/scripts/lib/operators.mjs +472 -38
  32. package/scripts/lib/orchestrator.mjs +419 -12
  33. package/scripts/lib/root-containment.mjs +351 -0
  34. package/scripts/lib/similarity-cache.mjs +115 -20
  35. package/scripts/lib/similarity.mjs +11 -0
  36. package/scripts/lib/soft-dag.mjs +726 -0
  37. package/scripts/lib/templates.mjs +78 -0
  38. package/scripts/lib/tiered.mjs +42 -18
  39. package/scripts/lib/validate.mjs +22 -0
  40. package/scripts/lib/where.mjs +71 -0
  41. package/scripts/testkit/assert-frontmatter.mjs +171 -0
  42. package/scripts/testkit/cli-run.mjs +95 -0
  43. package/scripts/testkit/make-wiki-fixture.mjs +301 -0
  44. package/scripts/testkit/stub-skill.mjs +107 -0
  45. package/templates/adrs.llmwiki.layout.yaml +33 -0
  46. package/templates/plans.llmwiki.layout.yaml +34 -0
  47. package/templates/regressions.llmwiki.layout.yaml +34 -0
  48. package/templates/reports.llmwiki.layout.yaml +33 -0
  49. package/templates/runbooks.llmwiki.layout.yaml +33 -0
  50. package/templates/sessions.llmwiki.layout.yaml +34 -0
@@ -0,0 +1,78 @@
1
+ // templates.mjs — discovery + metadata for the shipped layout
2
+ // templates under <SKILL_ROOT>/templates/*.llmwiki.layout.yaml.
3
+ //
4
+ // Feature 1 (this module) exposes the list and paths. Feature 2
5
+ // (init) uses them to seed a consumer's topic wiki with one command.
6
+ // Consumers can also copy templates by hand via `skill-llm-wiki
7
+ // where --json` + templates_dir.
8
+
9
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { SKILL_ROOT } from "./where.mjs";
12
+
13
+ const TEMPLATES_DIR = join(SKILL_ROOT, "templates");
14
+ const TEMPLATE_SUFFIX = ".llmwiki.layout.yaml";
15
+
16
+ // Metadata layer ON TOP of the template files. Keeps the per-template
17
+ // YAML focused on the layout contract itself while the "what kind of
18
+ // topic am I?" mapping lives in code. A template whose name isn't
19
+ // listed here is still usable, just not recommended via the CLI's
20
+ // `--kind` short-hand.
21
+ const TEMPLATE_META = {
22
+ reports: { kind: "dated", description: "Generated reports filed by day." },
23
+ sessions: { kind: "dated", description: "Daily session logs filed by day." },
24
+ regressions: { kind: "dated", description: "Regression notes filed by month." },
25
+ plans: { kind: "dated", description: "Plans filed by day, with subject subfolders for families." },
26
+ runbooks: { kind: "subject", description: "Runbooks grouped by subject." },
27
+ adrs: { kind: "subject", description: "Architecture decision records, numbered by subject." },
28
+ };
29
+
30
+ export function templatesDir() {
31
+ return TEMPLATES_DIR;
32
+ }
33
+
34
+ // Return a map of template name (e.g. "reports") -> { path, kind,
35
+ // description }. Only templates that actually exist on disk are
36
+ // returned — package.json `files` ships the templates/ dir, but a
37
+ // broken install or an older skill version may lack a given file.
38
+ export function listTemplates() {
39
+ if (!existsSync(TEMPLATES_DIR)) return {};
40
+ const out = {};
41
+ for (const entry of readdirSync(TEMPLATES_DIR)) {
42
+ if (!entry.endsWith(TEMPLATE_SUFFIX)) continue;
43
+ const name = entry.slice(0, -TEMPLATE_SUFFIX.length);
44
+ const abs = join(TEMPLATES_DIR, entry);
45
+ try {
46
+ if (!statSync(abs).isFile()) continue;
47
+ } catch {
48
+ continue;
49
+ }
50
+ const meta = TEMPLATE_META[name] ?? {
51
+ kind: "unknown",
52
+ description: "",
53
+ };
54
+ out[name] = { path: abs, kind: meta.kind, description: meta.description };
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export function getTemplate(name) {
60
+ const all = listTemplates();
61
+ return all[name] ?? null;
62
+ }
63
+
64
+ export function readTemplate(name) {
65
+ const t = getTemplate(name);
66
+ if (!t) return null;
67
+ return readFileSync(t.path, "utf8");
68
+ }
69
+
70
+ // Returns the canonical default template name for a given --kind.
71
+ // Consumers who don't want to name a specific template can pass
72
+ // --kind and get a sensible default (dated -> reports, subject ->
73
+ // runbooks).
74
+ export function defaultTemplateForKind(kind) {
75
+ if (kind === "dated") return "reports";
76
+ if (kind === "subject") return "runbooks";
77
+ return null;
78
+ }
@@ -10,7 +10,7 @@
10
10
  // circuits the whole ladder.
11
11
  //
12
12
  // Three quality modes, selected via --quality-mode or the
13
- // LLM_WIKI_QUALITY_MODE env var:
13
+ // LLM_WIKI_QUALITY_MODE env var (see resolveQualityMode):
14
14
  //
15
15
  // tiered-fast (default):
16
16
  // Tier 0 → Tier 1 → Tier 2, the full ladder. Mid-band Tier 0
@@ -21,9 +21,16 @@
21
21
  // obvious decisions) but anything in the Tier 0 mid-band goes
22
22
  // straight to Tier 2, skipping Tier 1.
23
23
  //
24
- // tier0-only:
25
- // Tier 0 decisions only. Mid-band becomes an explicit
26
- // "undecidable" marker that the caller must resolve manually.
24
+ // deterministic:
25
+ // Tier 0 + Tier 1 ladder, but the ladder terminates at Tier 1:
26
+ // mid-band Tier 0 escalates to Tier 1 (as in tiered-fast), but
27
+ // mid-band Tier 1 is resolved by a deterministic threshold
28
+ // (`TIER1_DETERMINISTIC_THRESHOLD`) instead of escalating to
29
+ // Tier 2. No LLM/sub-agent is ever consulted — every decision
30
+ // is produced from TF-IDF + MiniLM cosine alone, so repeated
31
+ // runs on the same inputs are byte-reproducible. This is the
32
+ // mode the clustering pipeline pairs with algorithmic HAC +
33
+ // auto-slug to produce deterministic wiki builds end-to-end.
27
34
  //
28
35
  // Tier 2 escalation contract: the skill's CLI runs under Node with
29
36
  // no access to Claude Code's `Agent` tool, so it cannot spawn
@@ -64,11 +71,27 @@ import {
64
71
  export const QUALITY_MODES = Object.freeze([
65
72
  "tiered-fast",
66
73
  "claude-first",
67
- "tier0-only",
74
+ "deterministic",
68
75
  ]);
69
76
 
70
77
  export const DEFAULT_QUALITY_MODE = "tiered-fast";
71
78
 
79
+ // Deterministic-mode split point for resolving mid-band Tier 1
80
+ // similarities. Derived as the midpoint of the Tier 1 mid-band so
81
+ // future tuning of the decisive-same / decisive-different thresholds
82
+ // propagates here without a separate code-change — no drift between
83
+ // "where the ladder says 'escalate'" and "where deterministic mode
84
+ // says 'same vs different'". Any pair whose Tier 1 cosine sits
85
+ // strictly above this is routed to "same"; anything at-or-below is
86
+ // routed to "different". In this mode there is no mid-band
87
+ // "undecidable" / pending-Tier-2 outcome — Tier 1 always produces a
88
+ // concrete branch without an LLM in the loop. (Note: Tier 0 can still
89
+ // produce an "undecidable" result on insufficient-text inputs — two
90
+ // empty frontmatters — independent of quality mode; that predates
91
+ // deterministic mode and is by design.)
92
+ export const TIER1_DETERMINISTIC_THRESHOLD =
93
+ (TIER1_DECISIVE_SAME + TIER1_DECISIVE_DIFFERENT) / 2;
94
+
72
95
  export function resolveQualityMode(flags = {}) {
73
96
  const fromFlag = flags.quality_mode;
74
97
  const fromEnv = process.env.LLM_WIKI_QUALITY_MODE;
@@ -244,18 +267,6 @@ export async function decide(
244
267
  }
245
268
 
246
269
  // Mid-band Tier 0 → escalate. Behaviour depends on quality mode.
247
- if (qualityMode === "tier0-only") {
248
- const result = {
249
- tier: 0,
250
- similarity: t0.similarity,
251
- decision: "undecidable",
252
- confidence_band: t0.confidence_band,
253
- reason: "tier0-only quality mode — mid-band left unresolved",
254
- };
255
- finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
256
- return result;
257
- }
258
-
259
270
  if (qualityMode === "claude-first") {
260
271
  // Skip Tier 1 entirely, go straight to Tier 2.
261
272
  return await escalateToTier2(
@@ -308,7 +319,20 @@ export async function decide(
308
319
  finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
309
320
  return result;
310
321
  }
311
- // Mid-band Tier 1 Tier 2.
322
+ // Mid-band Tier 1. Branch on quality mode: deterministic resolves
323
+ // algorithmically, tiered-fast escalates to Tier 2.
324
+ if (qualityMode === "deterministic") {
325
+ const decision = sim > TIER1_DETERMINISTIC_THRESHOLD ? "same" : "different";
326
+ const result = {
327
+ tier: 1,
328
+ similarity: sim,
329
+ decision,
330
+ confidence_band: "deterministic-mid-band",
331
+ reason: `deterministic mode: sim ${sim.toFixed(3)} ${decision === "same" ? ">" : "≤"} ${TIER1_DETERMINISTIC_THRESHOLD}`,
332
+ };
333
+ finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
334
+ return result;
335
+ }
312
336
  return await escalateToTier2(
313
337
  a, b, hashA, hashB, wikiRoot, opId, operator,
314
338
  sim, "tier1 mid-band", writeLog, writeCache,
@@ -133,6 +133,28 @@ export function validateWiki(wikiRoot) {
133
133
  }
134
134
  }
135
135
  }
136
+
137
+ // LEAF-AT-WIKI-ROOT — the wiki root must hold only `index.md`
138
+ // plus subdirectories. Any `.md` file at the wiki root other
139
+ // than `index.md` itself violates the invariant, regardless of
140
+ // what its frontmatter `type:` claims. Keying off path rather
141
+ // than `data.type` catches the edge case of a hand-authored
142
+ // `foo.md` at root declared as `type: index` — it's still a
143
+ // loose root file the navigational model forbids. The rule is
144
+ // navigational: Claude reading `<root>/index.md` and following
145
+ // its `entries[]` should reach every leaf via a
146
+ // semantically-named category; loose root files bypass that
147
+ // mental model and bloat the top-level index.
148
+ const absDir = dirname(e.absolute);
149
+ const absName = basename(e.absolute);
150
+ if (absDir === wikiRoot && absName !== "index.md") {
151
+ push(
152
+ "error",
153
+ "LEAF-AT-WIKI-ROOT",
154
+ e.absolute,
155
+ `non-index markdown file at wiki root — must live in a subcategory (run 'fix' to contain)`,
156
+ );
157
+ }
136
158
  }
137
159
 
138
160
  return findings;
@@ -0,0 +1,71 @@
1
+ // where.mjs — canonical "where am I installed?" report.
2
+ //
3
+ // Consumers need a reliable way to resolve the skill's install path
4
+ // without hard-coding `~/.claude/skills/...` or duplicating the
5
+ // @ctxr/kit path list. `skill-llm-wiki where` answers:
6
+ // - where is the skill root?
7
+ // - where is SKILL.md?
8
+ // - where is the templates/ directory?
9
+ // - where is the scripts/testkit/ directory?
10
+ // - what are the current package and format versions?
11
+ //
12
+ // Safe to invoke before the runtime-dep preflight resolves; uses
13
+ // only node:fs + node:path + node:url. No gray-matter, no transformers.
14
+
15
+ import { readFileSync, existsSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+ import { FORMAT_VERSION } from "./contract.mjs";
19
+
20
+ // `where.mjs` lives at <SKILL_ROOT>/scripts/lib/where.mjs. The skill
21
+ // root is two directories up. Exported so other lib / testkit
22
+ // modules that need the skill root import this single source of
23
+ // truth (contract.mjs, templates.mjs, cli-run.mjs).
24
+ export const SKILL_ROOT = dirname(
25
+ dirname(dirname(fileURLToPath(import.meta.url))),
26
+ );
27
+
28
+ function readPackageVersion() {
29
+ try {
30
+ const pkg = JSON.parse(
31
+ readFileSync(join(SKILL_ROOT, "package.json"), "utf8"),
32
+ );
33
+ return pkg.version ?? "unknown";
34
+ } catch {
35
+ return "unknown";
36
+ }
37
+ }
38
+
39
+ function pathIfExists(p) {
40
+ return existsSync(p) ? p : null;
41
+ }
42
+
43
+ export function getWhere() {
44
+ return {
45
+ schema: "skill-llm-wiki/where/v1",
46
+ skill_root: SKILL_ROOT,
47
+ skill_md: join(SKILL_ROOT, "SKILL.md"),
48
+ cli: join(SKILL_ROOT, "scripts", "cli.mjs"),
49
+ guide_dir: join(SKILL_ROOT, "guide"),
50
+ templates_dir: pathIfExists(join(SKILL_ROOT, "templates")),
51
+ testkit_dir: pathIfExists(join(SKILL_ROOT, "scripts", "testkit")),
52
+ package_version: readPackageVersion(),
53
+ format_version: FORMAT_VERSION,
54
+ };
55
+ }
56
+
57
+ // Human-readable summary. Absolute paths, one per line, aligned so
58
+ // operators can eyeball things without parsing JSON.
59
+ export function renderWhereText(info) {
60
+ const lines = [
61
+ `skill_root: ${info.skill_root}`,
62
+ `skill_md: ${info.skill_md}`,
63
+ `cli: ${info.cli}`,
64
+ `guide_dir: ${info.guide_dir}`,
65
+ `templates_dir: ${info.templates_dir ?? "<not shipped>"}`,
66
+ `testkit_dir: ${info.testkit_dir ?? "<not shipped>"}`,
67
+ `package_version: ${info.package_version}`,
68
+ `format_version: ${info.format_version}`,
69
+ ];
70
+ return lines.join("\n") + "\n";
71
+ }
@@ -0,0 +1,171 @@
1
+ // assert-frontmatter.mjs — testkit helper: read a leaf, parse its
2
+ // frontmatter block, assert expected fields match.
3
+ //
4
+ // Deliberately lightweight: does not import gray-matter. The
5
+ // opening `---` fence, body, and closing `---` fence pattern the
6
+ // skill emits is stable enough that a ~10-line parser is the right
7
+ // shape for a testkit. Tolerates both LF and CRLF line endings so
8
+ // consumer tests running on Windows runners don't see spurious
9
+ // parse failures.
10
+ //
11
+ // Zero runtime deps; pure Node built-ins.
12
+
13
+ import { readFileSync } from "node:fs";
14
+
15
+ // Both fences match CRLF and LF: git on Windows checks repos out
16
+ // with native line endings by default. Capture group on FM_START is
17
+ // the length of the consumed fence so the caller can slice past it.
18
+ const FM_START = /^---(\r?\n)/;
19
+ const FM_END = /\r?\n---(\r?\n|$)/;
20
+
21
+ // Parse a leaf's frontmatter block into a flat key: string-ish
22
+ // object. Only the shallow YAML shape the skill emits is
23
+ // supported. For full YAML-as-data consumers should use gray-matter
24
+ // in their own test code; this helper is for sanity checks.
25
+ export function readLeafFrontmatter(absLeafPath) {
26
+ const raw = readFileSync(absLeafPath, "utf8");
27
+ const startMatch = FM_START.exec(raw);
28
+ if (!startMatch) {
29
+ throw new Error(
30
+ `readLeafFrontmatter: ${absLeafPath} has no frontmatter block`,
31
+ );
32
+ }
33
+ const afterFirst = raw.slice(startMatch[0].length);
34
+ const endMatch = FM_END.exec(afterFirst);
35
+ if (!endMatch) {
36
+ throw new Error(
37
+ `readLeafFrontmatter: ${absLeafPath} has an unterminated frontmatter block`,
38
+ );
39
+ }
40
+ const block = afterFirst.slice(0, endMatch.index);
41
+ const data = {};
42
+ // A "pending key" is a top-level key with an empty RHS whose
43
+ // type isn't yet decided. The first indented continuation line
44
+ // picks: `- x` → list, `subkey: v` → map. Once decided, further
45
+ // continuations at the same indent extend the same container.
46
+ let pendingKey = null;
47
+ let pendingIndent = -1;
48
+ let pendingKind = null; // null | "list" | "map"
49
+ for (const line of block.split(/\r?\n/)) {
50
+ if (!line.trim() || line.trim().startsWith("#")) continue;
51
+ const indent = line.length - line.trimStart().length;
52
+ const trimmed = line.trimStart();
53
+
54
+ // Continuation: line is indented past the open key.
55
+ if (pendingKey !== null && indent > pendingIndent) {
56
+ const listMatch = /^-\s*(.*)$/.exec(trimmed);
57
+ if (listMatch && (pendingKind === null || pendingKind === "list")) {
58
+ if (pendingKind === null) {
59
+ data[pendingKey] = [];
60
+ pendingKind = "list";
61
+ }
62
+ data[pendingKey].push(unquote(listMatch[1].trim()));
63
+ continue;
64
+ }
65
+ const nestedKv = /^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i.exec(trimmed);
66
+ if (nestedKv && (pendingKind === null || pendingKind === "map")) {
67
+ if (pendingKind === null) {
68
+ data[pendingKey] = {};
69
+ pendingKind = "map";
70
+ }
71
+ data[pendingKey][nestedKv[1]] = unquote(nestedKv[2].trim());
72
+ continue;
73
+ }
74
+ // Fall through: unknown continuation shape, ignore.
75
+ continue;
76
+ }
77
+
78
+ // New top-level key ends any open container.
79
+ const kv = /^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i.exec(line);
80
+ if (!kv) continue;
81
+ pendingKey = null;
82
+ pendingIndent = -1;
83
+ pendingKind = null;
84
+ const key = kv[1];
85
+ const val = kv[2].trim();
86
+ if (val === "") {
87
+ // Empty RHS: open a pending key; the first continuation
88
+ // picks list vs map. Default to an empty object until
89
+ // decided — consumers that assert a key exists without
90
+ // inspecting its type still pass.
91
+ data[key] = {};
92
+ pendingKey = key;
93
+ pendingIndent = indent;
94
+ pendingKind = null;
95
+ } else if (val === "[]") {
96
+ data[key] = [];
97
+ } else if (val.startsWith("[") && val.endsWith("]")) {
98
+ data[key] = val
99
+ .slice(1, -1)
100
+ .split(",")
101
+ .map((s) => unquote(s.trim()))
102
+ .filter(Boolean);
103
+ } else {
104
+ data[key] = unquote(val);
105
+ }
106
+ }
107
+ return data;
108
+ }
109
+
110
+ function unquote(s) {
111
+ if (
112
+ (s.startsWith('"') && s.endsWith('"')) ||
113
+ (s.startsWith("'") && s.endsWith("'"))
114
+ ) {
115
+ return s.slice(1, -1);
116
+ }
117
+ return s;
118
+ }
119
+
120
+ // Compare actual frontmatter to an expected subset. Only fields
121
+ // named in `expected` are checked; extra fields in the leaf are
122
+ // allowed. Arrays compare element-wise as strings; objects compare
123
+ // each expected key shallowly (string-equality) so consumers can
124
+ // write `{ source: { origin: "file", path: "foo.md" } }` against
125
+ // the skill's canonical frontmatter shape.
126
+ //
127
+ // Throws an Error when any mismatch is found. Returns the parsed
128
+ // frontmatter object on success.
129
+ export function assertFrontmatterShape(absLeafPath, expected) {
130
+ const data = readLeafFrontmatter(absLeafPath);
131
+ const mismatches = [];
132
+ for (const [key, want] of Object.entries(expected ?? {})) {
133
+ const got = data[key];
134
+ if (Array.isArray(want)) {
135
+ if (!Array.isArray(got)) {
136
+ mismatches.push(`${key}: expected array, got ${JSON.stringify(got)}`);
137
+ continue;
138
+ }
139
+ if (got.length !== want.length || got.some((v, i) => String(v) !== String(want[i]))) {
140
+ mismatches.push(
141
+ `${key}: expected [${want.join(", ")}], got [${got.join(", ")}]`,
142
+ );
143
+ }
144
+ continue;
145
+ }
146
+ if (want !== null && typeof want === "object") {
147
+ if (got === null || typeof got !== "object" || Array.isArray(got)) {
148
+ mismatches.push(`${key}: expected object, got ${JSON.stringify(got)}`);
149
+ continue;
150
+ }
151
+ for (const [subKey, subWant] of Object.entries(want)) {
152
+ if (String(got[subKey]) !== String(subWant)) {
153
+ mismatches.push(
154
+ `${key}.${subKey}: expected ${JSON.stringify(subWant)}, got ${JSON.stringify(got[subKey])}`,
155
+ );
156
+ }
157
+ }
158
+ continue;
159
+ }
160
+ if (String(got) !== String(want)) {
161
+ mismatches.push(`${key}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
162
+ }
163
+ }
164
+ if (mismatches.length > 0) {
165
+ throw new Error(
166
+ `assertFrontmatterShape failed for ${absLeafPath}:\n - ` +
167
+ mismatches.join("\n - "),
168
+ );
169
+ }
170
+ return data;
171
+ }
@@ -0,0 +1,95 @@
1
+ // cli-run.mjs — testkit helper: spawn the skill CLI as a child
2
+ // process, capture stdout/stderr/exitCode, optionally parse the
3
+ // --json envelope. Consumers use this in their test suites to drive
4
+ // the skill without re-implementing the spawn ceremony.
5
+ //
6
+ // Zero runtime deps; pure Node built-ins.
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import { join } from "node:path";
10
+ import { SKILL_ROOT } from "../lib/where.mjs";
11
+ import { hasJsonFlag } from "../lib/json-envelope.mjs";
12
+
13
+ export const CLI_PATH = join(SKILL_ROOT, "scripts", "cli.mjs");
14
+
15
+ // Run the skill CLI with `args`. Returns an object of:
16
+ // { status, stdout, stderr, envelope, error }
17
+ //
18
+ // `envelope` is only populated when `args` includes `--json` or
19
+ // `--json-errors` AND the stdout parses as JSON. On parse failure it
20
+ // is `null` and the caller can inspect `stdout` directly.
21
+ //
22
+ // `error` is populated when the child process could not be spawned
23
+ // at all (ENOENT, EACCES, EPERM). When it is set, `status` will be
24
+ // `null` (spawnSync's convention) and the `error` field carries the
25
+ // Node errno. Consumers writing cross-platform tests need to see
26
+ // this to distinguish "CLI ran and exited with status X" from "CLI
27
+ // never ran".
28
+ //
29
+ // Note on environment: when `env` is supplied, the testkit forwards
30
+ // the parent process environment too (object-spread with the
31
+ // override). This is intentional for local test convenience; CI
32
+ // harnesses writing untrusted fixtures should scrub `process.env`
33
+ // before calling.
34
+ export function runCli(args, { cwd, env } = {}) {
35
+ const resolvedArgs = Array.isArray(args) ? args : [];
36
+ const r = spawnSync(process.execPath, [CLI_PATH, ...resolvedArgs], {
37
+ encoding: "utf8",
38
+ cwd: cwd ?? process.cwd(),
39
+ env: env ? { ...process.env, ...env } : process.env,
40
+ });
41
+ const wantJson = hasJsonFlag(resolvedArgs);
42
+ let envelope = null;
43
+ if (wantJson && r.stdout) {
44
+ // Two output shapes exist. Envelope subcommands (validate, init,
45
+ // heal) emit a single-line JSON object. contract/where emit
46
+ // pretty-printed JSON spanning multiple lines. Try parsing the
47
+ // full stdout first; if that fails, fall back to the last
48
+ // JSON-like line (handles envelope output that may be preceded
49
+ // by progress lines).
50
+ const full = r.stdout.trim();
51
+ try {
52
+ envelope = JSON.parse(full);
53
+ } catch {
54
+ const lines = full.split("\n");
55
+ for (let i = lines.length - 1; i >= 0; i--) {
56
+ const line = lines[i].trim();
57
+ if (!line.startsWith("{")) continue;
58
+ try {
59
+ envelope = JSON.parse(line);
60
+ break;
61
+ } catch {
62
+ continue;
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return {
68
+ status: r.status,
69
+ stdout: r.stdout,
70
+ stderr: r.stderr,
71
+ envelope,
72
+ error: r.error ?? null,
73
+ };
74
+ }
75
+
76
+ // Convenience: assert a clean run, throw on non-zero exit with the
77
+ // stderr attached so the consumer's test output is useful. When the
78
+ // child failed to spawn at all (ENOENT/EACCES), surface that
79
+ // explicitly rather than saying "exited null".
80
+ export function runCliOk(args, opts) {
81
+ const r = runCli(args, opts);
82
+ const argString = Array.isArray(args) ? args.join(" ") : "";
83
+ if (r.error) {
84
+ throw new Error(
85
+ `runCliOk: failed to spawn skill-llm-wiki ${argString}: ` +
86
+ `${r.error.code ?? "unknown"} — ${r.error.message}`,
87
+ );
88
+ }
89
+ if (r.status !== 0) {
90
+ throw new Error(
91
+ `runCliOk: skill-llm-wiki ${argString} exited ${r.status}:\n${r.stderr}`,
92
+ );
93
+ }
94
+ return r;
95
+ }