@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.
- package/SKILL.md +7 -0
- package/guide/cli.md +3 -2
- package/guide/consumers/index.md +106 -0
- package/guide/consumers/quickstart.md +96 -0
- package/guide/consumers/recipes/ci-gate.md +125 -0
- package/guide/consumers/recipes/dated-wiki.md +131 -0
- package/guide/consumers/recipes/format-gate.md +126 -0
- package/guide/consumers/recipes/post-write-heal.md +125 -0
- package/guide/consumers/recipes/skill-absent.md +111 -0
- package/guide/consumers/recipes/subject-wiki.md +110 -0
- package/guide/consumers/recipes/testing.md +149 -0
- package/guide/index.md +9 -0
- package/guide/ux/user-intent.md +5 -4
- package/package.json +6 -2
- package/scripts/cli.mjs +473 -13
- package/scripts/lib/contract.mjs +229 -0
- package/scripts/lib/heal.mjs +162 -0
- package/scripts/lib/init.mjs +210 -0
- package/scripts/lib/json-envelope.mjs +190 -0
- package/scripts/lib/templates.mjs +78 -0
- package/scripts/lib/where.mjs +71 -0
- package/scripts/testkit/assert-frontmatter.mjs +171 -0
- package/scripts/testkit/cli-run.mjs +95 -0
- package/scripts/testkit/make-wiki-fixture.mjs +301 -0
- package/scripts/testkit/stub-skill.mjs +107 -0
- package/templates/adrs.llmwiki.layout.yaml +33 -0
- package/templates/plans.llmwiki.layout.yaml +34 -0
- package/templates/regressions.llmwiki.layout.yaml +34 -0
- package/templates/reports.llmwiki.layout.yaml +33 -0
- package/templates/runbooks.llmwiki.layout.yaml +33 -0
- package/templates/sessions.llmwiki.layout.yaml +34 -0
|
@@ -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
|
+
}
|