#!/usr/bin/env node /** * CLI for the lived-specifics bank (Fix #2 — kilde-så-draft). * * node --import tsx src/cli.ts query --tags [--bank ] [--json] * node --import tsx src/cli.ts add --type --content "" --tags * [--source ] [--verification ] [--bank ] * node --import tsx src/cli.ts list [--bank ] [--json] * * The command layer (Step 1.5) calls `query` to surface inventory the operator * already has for an edition's topics, and `add` to fold newly-elicited material * back in. Deterministic store; the elicitation interview itself lives upstream. * * Exit code: 0 on success, 2 on usage error. */ import { readFileSync, writeFileSync } from "node:fs"; import { addSpecific, defaultBankPath, loadBank, queryByTopic, saveBank, } from "./bank.js"; import { validateBinding } from "./binding.js"; import { renderKilder } from "./kilder.js"; import type { LivedSpecifics } from "./binding.js"; import type { SpecificType, Verification } from "./types.js"; const VALID_TYPES: SpecificType[] = [ "number", "named-case", "what-broke", "contrarian", "mind-change", "other", ]; const VALID_VERIFICATION: Verification[] = ["verified", "unverified", "n/a"]; function parseFlags(args: string[]): Record { const out: Record = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = args[i + 1]; if (next === undefined || next.startsWith("--")) { out[key] = "true"; } else { out[key] = next; i++; } } } return out; } function splitTags(raw: string | undefined): string[] { if (!raw) return []; return raw .split(",") .map((t) => t.trim()) .filter((t) => t.length > 0); } function usage(msg: string): never { console.error(`error: ${msg}`); console.error( "usage:\n" + " query --tags [--bank ] [--json]\n" + ' add --type --content "" --tags [--source ] [--verification ] [--bank ]\n' + " list [--bank ] [--json]\n" + " validate-binding --edition [--article ] [--bank ] [--json]\n" + " render-kilder --edition [--article ] [--bank ] [--out ]", ); process.exit(2); } /** Minimal shape of edition-state.json that the binding subcommands read. */ interface EditionArticle { title?: string; livedSpecifics?: LivedSpecifics; } interface EditionState { currentArticle?: string; articles?: Record; } const EMPTY_LS: LivedSpecifics = { slots: [], status: "pending" }; function loadEdition(path: string): EditionState { return JSON.parse(readFileSync(path, "utf8")) as EditionState; } /** Resolve the target article: explicit --article, else currentArticle, else "01". */ function resolveArticle(edition: EditionState, flag?: string): { id: string; article: EditionArticle } { const id = flag ?? edition.currentArticle ?? "01"; return { id, article: edition.articles?.[id] ?? {} }; } function today(): string { return new Date().toISOString().slice(0, 10); } function main(): void { const [command, ...rest] = process.argv.slice(2); const flags = parseFlags(rest); const bankPath = flags.bank ?? defaultBankPath(); const asJson = flags.json === "true"; if (command === "query") { const tags = splitTags(flags.tags); if (tags.length === 0) usage("query needs --tags "); const hits = queryByTopic(loadBank(bankPath), tags); if (asJson) { console.log(JSON.stringify(hits, null, 2)); return; } if (hits.length === 0) { console.log(`No lived specifics found for tags: ${tags.join(", ")}`); console.log("→ elicit fresh material from the operator (Step 1.5), then `add` it."); return; } console.log(`${hits.length} lived specific(s) for: ${tags.join(", ")}`); for (const { specific, tagOverlap } of hits) { const v = specific.verification === "verified" ? "" : ` [${specific.verification}]`; console.log(`\n · (${specific.type}, overlap ${tagOverlap})${v} ${specific.content}`); console.log(` tags: ${specific.topicTags.join(", ")} — from ${specific.provenance.source}`); } return; } if (command === "add") { const type = flags.type as SpecificType; if (!VALID_TYPES.includes(type)) usage(`--type must be one of: ${VALID_TYPES.join(", ")}`); const content = flags.content; if (!content || content === "true") usage("add needs --content \"\""); const tags = splitTags(flags.tags); if (tags.length === 0) usage("add needs --tags "); const verification = (flags.verification as Verification) ?? "unverified"; if (!VALID_VERIFICATION.includes(verification)) { usage(`--verification must be one of: ${VALID_VERIFICATION.join(", ")}`); } const bank = loadBank(bankPath); const res = addSpecific(bank, { type, content, topicTags: tags, provenance: { capturedAt: today(), source: flags.source ?? "manual" }, verification, }); saveBank(bankPath, res.bank); if (res.added) { console.log(`Added (${type}): ${content}`); } else { console.log(`Duplicate — already in bank${res.merged ? " (tags unioned)" : ""}: ${content}`); } console.log(`Bank: ${bankPath} (${res.bank.specifics.length} specifics)`); return; } if (command === "list") { const bank = loadBank(bankPath); if (asJson) { console.log(JSON.stringify(bank, null, 2)); return; } console.log(`Bank: ${bankPath} — ${bank.specifics.length} specific(s)`); for (const s of bank.specifics) { const flag = s.status === "active" ? "" : ` (${s.status})`; console.log(` · [${s.type}]${flag} ${s.content} {${s.topicTags.join(", ")}}`); } return; } if (command === "validate-binding") { if (!flags.edition || flags.edition === "true") usage("validate-binding needs --edition "); const edition = loadEdition(flags.edition); const { id, article } = resolveArticle(edition, flags.article); const ls = article.livedSpecifics ?? EMPTY_LS; const res = validateBinding(ls, loadBank(bankPath)); if (asJson) { console.log(JSON.stringify({ article: id, ...res }, null, 2)); } else { const c = res.coverage; console.log(`Binding for article ${id}: ${res.verdict}`); console.log( ` slots ${c.total} — levd ${c.specific} · abstrakt ${c.abstrakt} · ekstern ${c.ekstern} · uavklart ${c.unresolved}`, ); for (const p of res.problems) console.log(` ⛔ ${p.slotId}: ${p.reason} — ${p.detail}`); for (const w of res.warnings) console.log(` ⚠ ${w.slotId}: ${w.detail}`); } process.exit(res.verdict === "BLOCK" ? 1 : 0); } if (command === "render-kilder") { if (!flags.edition || flags.edition === "true") usage("render-kilder needs --edition "); const edition = loadEdition(flags.edition); const { id, article } = resolveArticle(edition, flags.article); const md = renderKilder({ articleId: id, title: article.title ?? id, livedSpecifics: article.livedSpecifics ?? EMPTY_LS, bank: loadBank(bankPath), }); if (flags.out && flags.out !== "true") { writeFileSync(flags.out, md, "utf8"); console.log(`Wrote ${flags.out}`); } else { process.stdout.write(md); } return; } usage(command ? `unknown command: ${command}` : "no command given"); } main();