@ctxr/skill-llm-wiki 1.0.1 → 1.0.2

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.
@@ -0,0 +1,190 @@
1
+ // json-envelope.mjs — shared JSON stdout shape for consumer-facing
2
+ // operational subcommands that use the envelope (validate, init,
3
+ // heal, rollback). Those commands emit exactly one envelope object
4
+ // on stdout; no surrounding prose, no multiple envelopes per
5
+ // invocation. stderr stays free-form for logs.
6
+ //
7
+ // Probe-style commands — `contract --json` and `where --json` —
8
+ // intentionally return their own non-envelope JSON shapes because
9
+ // they pre-date the envelope contract and have stable consumer
10
+ // schemas of their own (skill-llm-wiki/contract/v1 and
11
+ // skill-llm-wiki/where/v1).
12
+ //
13
+ // Consumers validate the `schema` discriminator as their first
14
+ // check. See guide/consumers/recipes/post-write-heal.md and
15
+ // recipes/ci-gate.md for canonical parsing patterns.
16
+ //
17
+ // Envelope shape (format_version 1):
18
+ // {
19
+ // "schema": "skill-llm-wiki/v1",
20
+ // "command": "validate",
21
+ // "target": "/abs/path" | null,
22
+ // "verdict": "ok" | "fixable" | "needs-rebuild" | "broken"
23
+ // | "built" | "extended" | "healed" | "initialised"
24
+ // | "aborted" | "ambiguous",
25
+ // "exit": <integer>,
26
+ // "diagnostics": [
27
+ // { "code": "IDX-01", "severity": "warning", "path": "...", "message": "..." }
28
+ // ],
29
+ // "artifacts": { "created": [...], "modified": [...], "deleted": [...] },
30
+ // "timing_ms": <integer>,
31
+ // "next": null | { "command": "skill-llm-wiki", "args": ["fix", "/wiki", "--json"] }
32
+ // }
33
+ //
34
+ // `next` is present only when the subcommand wants to hand the
35
+ // consumer a machine-readable follow-up command (init emits the
36
+ // build invocation; heal emits the fix or rebuild invocation).
37
+ // Consumers that receive `verdict: "fixable"` / `"needs-rebuild"`
38
+ // should invoke `next.command` with `next.args` rather than parse
39
+ // the `NEXT-01` info diagnostic's free-text message.
40
+
41
+ export const ENVELOPE_SCHEMA = "skill-llm-wiki/v1";
42
+
43
+ // Every known verdict string. Consumers can switch on these without
44
+ // fearing a surprise value; adding a new verdict is a format_version
45
+ // bump.
46
+ export const VERDICTS = Object.freeze([
47
+ "ok",
48
+ "fixable",
49
+ "needs-rebuild",
50
+ "broken",
51
+ "built",
52
+ "extended",
53
+ "healed",
54
+ "initialised",
55
+ "aborted",
56
+ "ambiguous",
57
+ ]);
58
+
59
+ // Diagnostic severity levels. Consumers gate their CI on `error`.
60
+ export const SEVERITIES = Object.freeze(["error", "warning", "info"]);
61
+
62
+ // Build an envelope from a minimal set of inputs. Missing artifact
63
+ // buckets default to empty arrays so consumers never have to
64
+ // defensively check for undefined.
65
+ //
66
+ // `next` is an optional structured hint for consumers that also
67
+ // carries a human-readable form in an info diagnostic. It is the
68
+ // machine-readable sibling of the NEXT-01 diagnostic consumers may
69
+ // still parse today.
70
+ export function makeEnvelope({
71
+ command,
72
+ target = null,
73
+ verdict,
74
+ exit,
75
+ diagnostics = [],
76
+ artifacts = {},
77
+ timing_ms = 0,
78
+ next = null,
79
+ } = {}) {
80
+ if (!command || typeof command !== "string") {
81
+ throw new Error("makeEnvelope: command is required");
82
+ }
83
+ if (!verdict || typeof verdict !== "string") {
84
+ throw new Error("makeEnvelope: verdict is required");
85
+ }
86
+ if (!VERDICTS.includes(verdict)) {
87
+ throw new Error(
88
+ `makeEnvelope: unknown verdict "${verdict}". Known: ${VERDICTS.join(", ")}`,
89
+ );
90
+ }
91
+ if (!Number.isInteger(exit)) {
92
+ throw new Error("makeEnvelope: exit must be an integer");
93
+ }
94
+ const envelope = {
95
+ schema: ENVELOPE_SCHEMA,
96
+ command,
97
+ target,
98
+ verdict,
99
+ exit,
100
+ diagnostics: Array.isArray(diagnostics) ? diagnostics : [],
101
+ artifacts: {
102
+ created: artifacts.created ?? [],
103
+ modified: artifacts.modified ?? [],
104
+ deleted: artifacts.deleted ?? [],
105
+ },
106
+ timing_ms: Number.isInteger(timing_ms) ? timing_ms : 0,
107
+ };
108
+ if (next !== null) {
109
+ if (
110
+ typeof next !== "object" ||
111
+ typeof next.command !== "string" ||
112
+ !Array.isArray(next.args)
113
+ ) {
114
+ throw new Error(
115
+ "makeEnvelope: next must be { command: string, args: string[] } or null",
116
+ );
117
+ }
118
+ envelope.next = { command: next.command, args: next.args.slice() };
119
+ }
120
+ return envelope;
121
+ }
122
+
123
+ // Shared error-envelope builder used by consumer subcommands that
124
+ // need to surface a structured failure without reinventing the
125
+ // envelope shape. Verdict defaults to "ambiguous" (the canonical
126
+ // error verdict) and exit defaults to 2 (validation / ambiguity per
127
+ // the skill-wide scheme). Usage-error callers pass exit=1 explicitly.
128
+ export function makeErrorEnvelope({
129
+ command,
130
+ code,
131
+ message,
132
+ target = null,
133
+ verdict = "ambiguous",
134
+ exit = 2,
135
+ } = {}) {
136
+ if (!command || typeof command !== "string") {
137
+ throw new Error("makeErrorEnvelope: command is required");
138
+ }
139
+ if (!code || typeof code !== "string") {
140
+ throw new Error("makeErrorEnvelope: code is required");
141
+ }
142
+ return makeEnvelope({
143
+ command,
144
+ target,
145
+ verdict,
146
+ exit,
147
+ diagnostics: [
148
+ { code, severity: "error", path: target, message: message ?? "" },
149
+ ],
150
+ });
151
+ }
152
+
153
+ // Write an envelope to stdout as one line of JSON followed by a
154
+ // newline. Single-line output is easier to pipe through `jq` and
155
+ // also to `grep`-assert in test harnesses.
156
+ export function writeEnvelope(envelope, stream = process.stdout) {
157
+ stream.write(JSON.stringify(envelope) + "\n");
158
+ }
159
+
160
+ // Detect whether --json or --json-errors (legacy alias) was passed.
161
+ // Returns true on the first positive match. `--json-errors`
162
+ // predates the envelope; we accept it as an alias rather than
163
+ // deprecating it loudly, because every existing consumer passes it
164
+ // to get structured intent errors.
165
+ //
166
+ // Only the bare flag forms are supported. `--json=1` / `--json=true`
167
+ // are NOT accepted: the skill's shared arg parser (parseSubArgv)
168
+ // rejects inline values on boolean flags, and hasJsonFlag would
169
+ // otherwise diverge from that contract and silently accept an
170
+ // argument shape that build/extend/rebuild/... reject.
171
+ export function hasJsonFlag(args) {
172
+ if (!Array.isArray(args)) return false;
173
+ for (const tok of args) {
174
+ if (typeof tok !== "string") continue;
175
+ if (tok === "--json" || tok === "--json-errors") return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ // Convert a validate-style finding (code, severity, target, message)
181
+ // into a diagnostic object in the envelope's canonical shape. Shared
182
+ // so consumers see the same field names everywhere.
183
+ export function findingToDiagnostic(finding) {
184
+ return {
185
+ code: finding.code ?? "UNKNOWN",
186
+ severity: finding.severity ?? "info",
187
+ path: finding.target ?? null,
188
+ message: finding.message ?? "",
189
+ };
190
+ }
@@ -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
+ }
@@ -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
+ }