@drbaher/draft-cli 0.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.
package/draft-cli.mjs ADDED
@@ -0,0 +1,1757 @@
1
+ #!/usr/bin/env node
2
+ // draft-cli — fill placeholders in legal-document templates.
3
+ // Part of the contract-operations suite. MIT. See LICENSE.
4
+ // Single-file Node.js CLI. Stdlib-only except `jszip` for .docx unzip.
5
+
6
+ import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
7
+ import { resolve, dirname, basename, extname, join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createInterface } from "node:readline";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ /**
13
+ * @typedef {"bracket"|"mustache"|"docx-highlight"|"heuristic"|"llm"|"none"} Tier
14
+ *
15
+ * @typedef {Object} DetectionHit
16
+ * A single raw detection from one of the cascade tiers.
17
+ * @property {string} match — the full matched span (e.g. "[Party A]").
18
+ * @property {string} inner — the text inside the delimiters (e.g. "Party A").
19
+ * @property {number} [index] — byte offset into the body, if known.
20
+ * @property {string} [suggested_key] — only on T5/LLM hits.
21
+ * @property {string} [color] — only on T3/docx-highlight hits.
22
+ *
23
+ * @typedef {Object} Placeholder
24
+ * An assembled placeholder — one canonical key, all its hits, schema metadata.
25
+ * @property {string} key — canonical snake_case identifier.
26
+ * @property {string} first_seen_as — the inner text of the first hit.
27
+ * @property {number} occurrences — number of hits for this key.
28
+ * @property {Tier} tier — which cascade tier produced this.
29
+ * @property {boolean} required — whether the user MUST supply a value.
30
+ * @property {string|null} default — schema-supplied fallback, or null.
31
+ * @property {string[]} aliases — phrase forms that map to this key.
32
+ * @property {DetectionHit[]} hits — every detection for this key.
33
+ *
34
+ * @typedef {Object} SchemaEntry
35
+ * @property {string[]} aliases — phrase forms accepted for this key.
36
+ * @property {boolean} required
37
+ * @property {string|null} default
38
+ *
39
+ * @typedef {Object} Schema
40
+ * @property {"short"|"long"} form
41
+ * @property {Object<string, SchemaEntry>} entries
42
+ * @property {string} [sourcePath] — file the schema was loaded from, if any.
43
+ *
44
+ * @typedef {Object} ParsedArgs
45
+ * Result of parseArgs(argv). See parseArgs() for shape.
46
+ *
47
+ * @typedef {Object} CascadeResult
48
+ * @property {Tier} tier
49
+ * @property {Placeholder[]} placeholders
50
+ * @property {string[]} warnings
51
+ * @property {Array<{phrase: string, tier: Tier}>} unmapped
52
+ * @property {boolean} [heuristicGate] — true iff tier='heuristic' and
53
+ * substitution requires explicit confirmation.
54
+ *
55
+ * @typedef {Object} ResolvedValues
56
+ * @property {Object<string, string>} resolved — key -> value.
57
+ * @property {Placeholder[]} missing — unresolved required placeholders.
58
+ * @property {Object<string, "cli"|"params"|"interactive"|"default">} sources
59
+ *
60
+ * @typedef {Object} Input
61
+ * @property {"text"|"docx"} kind
62
+ * @property {string} body — the template text (extracted for docx).
63
+ * @property {string|null} path — filesystem path, or null for stdin/vault.
64
+ * @property {string} [docxXml] — raw word/document.xml for docx inputs.
65
+ *
66
+ * @typedef {Object} LlmProvider
67
+ * @property {string} provider — "anthropic" | "openai" | custom.
68
+ * @property {string} apiKey
69
+ * @property {string|null} model
70
+ */
71
+
72
+ /** @type {string} */
73
+ export const VERSION = "0.1.0";
74
+
75
+ // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
+ /**
77
+ * Stable exit codes. Documented in AGENTS.md and never re-numbered without
78
+ * a major-version bump.
79
+ * @type {Readonly<{OK: 0, IO: 1, VALIDATION: 2, VAULT: 3, LLM: 4}>}
80
+ */
81
+ export const EXIT = Object.freeze({ OK: 0, IO: 1, VALIDATION: 2, VAULT: 3, LLM: 4 });
82
+
83
+ // ─── COLOR (honors NO_COLOR / FORCE_COLOR) ──────────────────────────────────
84
+ const ANSI = {
85
+ reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m",
86
+ yellow: "\x1b[33m", cyan: "\x1b[36m", dim: "\x1b[2m", bold: "\x1b[1m",
87
+ };
88
+
89
+ /**
90
+ * Whether ANSI color should be emitted to `stream`. Honors the no-color.org
91
+ * convention: `NO_COLOR` (any value) → off, `FORCE_COLOR` (any value) → on,
92
+ * otherwise on iff `stream.isTTY`.
93
+ *
94
+ * @param {{isTTY?: boolean} | null | undefined} stream
95
+ * @returns {boolean}
96
+ */
97
+ export function colorEnabled(stream) {
98
+ if (process.env.NO_COLOR) return false;
99
+ if (process.env.FORCE_COLOR) return true;
100
+ return Boolean(stream && stream.isTTY);
101
+ }
102
+
103
+ /**
104
+ * Wrap `s` with an ANSI color code if {@link colorEnabled} for `stream`.
105
+ *
106
+ * @param {string} s
107
+ * @param {"red"|"green"|"yellow"|"cyan"|"dim"|"bold"} color
108
+ * @param {{isTTY?: boolean} | null | undefined} stream
109
+ * @returns {string}
110
+ */
111
+ export function paint(s, color, stream) {
112
+ return colorEnabled(stream) ? `${ANSI[color] || ""}${s}${ANSI.reset}` : String(s);
113
+ }
114
+
115
+ // ─── BUNDLED HEURISTIC DICTIONARY ───────────────────────────────────────────
116
+ export const DEFAULT_HEURISTIC_DICT = [
117
+ "John Doe", "Jane Doe", "Jane Roe", "John Smith", "John Q. Public",
118
+ "Acme Corporation", "Acme Corp.", "Acme Corp", "Acme Inc.", "Acme Inc",
119
+ "Acme Co.", "Acme Co", "Acme LLC", "Acme, Inc.",
120
+ "Foo Corp", "Foo Corp.", "Foo Inc.", "Foo Inc", "Foo LLC",
121
+ "FooBar LLC", "FooBar, Inc.",
122
+ "Example Inc.", "Example Inc", "Example Corporation", "Example Corp",
123
+ "Sample Company", "Sample Corp", "Sample Inc.",
124
+ "Newco", "Newco Inc.", "Newco Inc",
125
+ "123 Main Street", "123 Main St.", "123 Main St",
126
+ "1 First Avenue", "1 First Ave", "1 First Ave.",
127
+ "Anytown, USA", "Anytown, US",
128
+ "example@example.com", "user@example.com", "john@example.com",
129
+ "jane@example.com", "test@test.com",
130
+ "555-555-5555", "(555) 555-5555", "+1-555-555-5555",
131
+ "555-5555", "555-1234",
132
+ "January 1, 20XX", "MM/DD/YYYY", "DD/MM/YYYY",
133
+ "YYYY-MM-DD", "20XX-XX-XX",
134
+ "TBD", "TBC", "TBA",
135
+ ];
136
+
137
+ // ─── .env READER (tiny inline parser) ───────────────────────────────────────
138
+ /**
139
+ * Read a `.env` file. Tiny inline parser — handles `KEY=VALUE`, comments,
140
+ * blanks, and matched single/double quotes around values. Returns `{}` if
141
+ * the file doesn't exist.
142
+ *
143
+ * @param {string} path — usually `join(cwd, ".env")`.
144
+ * @returns {Object<string, string>}
145
+ */
146
+ export function readDotenv(path) {
147
+ if (!existsSync(path)) return {};
148
+ const out = {};
149
+ for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) {
150
+ const line = raw.trim();
151
+ if (!line || line.startsWith("#")) continue;
152
+ const eq = line.indexOf("=");
153
+ if (eq <= 0) continue;
154
+ const key = line.slice(0, eq).trim();
155
+ let val = line.slice(eq + 1).trim();
156
+ if ((val.startsWith('"') && val.endsWith('"')) ||
157
+ (val.startsWith("'") && val.endsWith("'"))) {
158
+ val = val.slice(1, -1);
159
+ }
160
+ out[key] = val;
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * Merge `.env` file contents with the process environment. Process env
167
+ * always wins where both define a key.
168
+ *
169
+ * @param {string} [cwd]
170
+ * @param {Object<string, string>} [processEnv]
171
+ * @returns {Object<string, string>}
172
+ */
173
+ export function effectiveEnv(cwd = process.cwd(), processEnv = process.env) {
174
+ const fileEnv = readDotenv(join(cwd, ".env"));
175
+ return { ...fileEnv, ...processEnv };
176
+ }
177
+
178
+ /**
179
+ * Pick an LLM provider configuration from a merged env object. Order:
180
+ * explicit `DRAFT_LLM_*` triple > `ANTHROPIC_API_KEY` > `OPENAI_API_KEY`.
181
+ *
182
+ * @param {Object<string, string>} envObj
183
+ * @returns {LlmProvider | null} null if no provider is configured.
184
+ */
185
+ export function llmProviderFromEnv(envObj) {
186
+ if (envObj.DRAFT_LLM_PROVIDER && envObj.DRAFT_LLM_API_KEY) {
187
+ return {
188
+ provider: envObj.DRAFT_LLM_PROVIDER,
189
+ apiKey: envObj.DRAFT_LLM_API_KEY,
190
+ model: envObj.DRAFT_LLM_MODEL || null,
191
+ };
192
+ }
193
+ if (envObj.ANTHROPIC_API_KEY) {
194
+ return {
195
+ provider: "anthropic",
196
+ apiKey: envObj.ANTHROPIC_API_KEY,
197
+ model: envObj.DRAFT_LLM_MODEL || "claude-sonnet-4-6",
198
+ };
199
+ }
200
+ if (envObj.OPENAI_API_KEY) {
201
+ return {
202
+ provider: "openai",
203
+ apiKey: envObj.OPENAI_API_KEY,
204
+ model: envObj.DRAFT_LLM_MODEL || "gpt-4o-mini",
205
+ };
206
+ }
207
+ return null;
208
+ }
209
+
210
+ // ─── ARG PARSING ────────────────────────────────────────────────────────────
211
+ // Two-phase: known flags first, unknown --x VALUE pairs collected for later
212
+ // param resolution. Boolean flags listed in KNOWN_BOOLEAN; value flags in
213
+ // KNOWN_VALUE. Everything else --x is treated as a param flag.
214
+ const KNOWN_BOOLEAN = new Set([
215
+ "--help", "-h", "--version", "-V", "--demo",
216
+ "--validate", "--list-placeholders",
217
+ "--why", "--json", "--interactive", "-i",
218
+ "--no-heuristic", "--yes-heuristic",
219
+ "--no-llm", "--llm", "--check-llm",
220
+ "--silent", "-q",
221
+ "--diff",
222
+ ]);
223
+
224
+ const KNOWN_VALUE = new Set([
225
+ "--params", "--output", "-o", "--syntax", "--dictionary", "--completion",
226
+ ]);
227
+
228
+ /**
229
+ * Parse argv into a structured options object. Two-phase: known flags are
230
+ * recognized explicitly; everything else of the form `--xxx VALUE` is
231
+ * collected into `paramFlags` (canonical_key → value).
232
+ *
233
+ * @param {string[]} argv — typically `process.argv.slice(2)`.
234
+ * @returns {ParsedArgs}
235
+ * @throws {UsageError} on invalid `--syntax`, `--completion`, missing values.
236
+ */
237
+ export function parseArgs(argv) {
238
+ const opts = {
239
+ positional: [],
240
+ params: null,
241
+ output: null,
242
+ syntax: "bracket",
243
+ dictionary: null,
244
+ interactive: false,
245
+ validate: false,
246
+ listPlaceholders: false,
247
+ why: false,
248
+ json: false,
249
+ demo: false,
250
+ completion: null,
251
+ silent: false,
252
+ checkLlm: false,
253
+ diff: false,
254
+ noHeuristic: false,
255
+ yesHeuristic: false,
256
+ noLlm: false,
257
+ forceLlm: false,
258
+ help: false,
259
+ version: false,
260
+ paramFlags: {}, // canonical_key -> value (set from --kebab-name VALUE)
261
+ };
262
+ for (let i = 0; i < argv.length; i++) {
263
+ const a = argv[i];
264
+ if (a === "--help" || a === "-h") { opts.help = true; continue; }
265
+ if (a === "--version" || a === "-V") { opts.version = true; continue; }
266
+ if (a === "--demo") { opts.demo = true; continue; }
267
+ if (a === "--validate") { opts.validate = true; continue; }
268
+ if (a === "--list-placeholders") { opts.listPlaceholders = true; continue; }
269
+ if (a === "--why") { opts.why = true; continue; }
270
+ if (a === "--json") { opts.json = true; continue; }
271
+ if (a === "--interactive" || a === "-i") { opts.interactive = true; continue; }
272
+ if (a === "--no-heuristic") { opts.noHeuristic = true; continue; }
273
+ if (a === "--yes-heuristic") { opts.yesHeuristic = true; continue; }
274
+ if (a === "--no-llm") { opts.noLlm = true; continue; }
275
+ if (a === "--llm") { opts.forceLlm = true; continue; }
276
+ if (a === "--check-llm") { opts.checkLlm = true; continue; }
277
+ if (a === "--silent" || a === "-q") { opts.silent = true; continue; }
278
+ if (a === "--diff") { opts.diff = true; continue; }
279
+ if (a === "--params") { opts.params = argv[++i]; continue; }
280
+ if (a === "--output" || a === "-o") { opts.output = argv[++i]; continue; }
281
+ if (a === "--syntax") {
282
+ const v = argv[++i];
283
+ if (v !== "bracket" && v !== "mustache") {
284
+ throw new UsageError(`--syntax must be 'bracket' or 'mustache' (got '${v}')`);
285
+ }
286
+ opts.syntax = v;
287
+ continue;
288
+ }
289
+ if (a === "--dictionary") { opts.dictionary = argv[++i]; continue; }
290
+ if (a === "--completion") {
291
+ const v = argv[++i];
292
+ if (v !== "bash" && v !== "zsh") {
293
+ throw new UsageError(`--completion must be 'bash' or 'zsh' (got '${v}')`);
294
+ }
295
+ opts.completion = v;
296
+ continue;
297
+ }
298
+ if (a.startsWith("--")) {
299
+ // Unknown --x — treat as param flag with the next token as value.
300
+ const key = kebabToSnake(a.slice(2));
301
+ if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
302
+ throw new UsageError(`flag ${a} requires a value`);
303
+ }
304
+ opts.paramFlags[key] = argv[++i];
305
+ continue;
306
+ }
307
+ opts.positional.push(a);
308
+ }
309
+ return opts;
310
+ }
311
+
312
+ export class UsageError extends Error {
313
+ constructor(msg) { super(msg); this.name = "UsageError"; }
314
+ }
315
+
316
+ // ─── KEY DERIVATION ─────────────────────────────────────────────────────────
317
+ /** @param {string} s @returns {string} kebab-case → snake_case. */
318
+ export function kebabToSnake(s) { return s.replace(/-/g, "_"); }
319
+
320
+ /**
321
+ * Derive a canonical snake_case key from arbitrary placeholder text.
322
+ * Permissive: non-alphanumerics collapse to `_`, leading-digit inputs get
323
+ * an `_` prefix, length capped at 60. Always produces a valid snake_case
324
+ * key for any non-empty input that contains at least one alphanumeric.
325
+ *
326
+ * @param {string} matchText — e.g. "Party A Name", "Today's date", "1 year(s)".
327
+ * @returns {string} e.g. "party_a_name", "today_s_date", "_1_year_s".
328
+ */
329
+ export function canonicalKey(matchText) {
330
+ // Permissive slug: lowercase, non-alphanum runs become single "_",
331
+ // strip leading/trailing "_", prefix "_" if leading char is a digit.
332
+ let k = matchText.trim().toLowerCase()
333
+ .replace(/[^a-z0-9]+/g, "_")
334
+ .replace(/^_+|_+$/g, "");
335
+ if (/^[0-9]/.test(k)) k = "_" + k;
336
+ // Cap at 60 chars to keep CLI flags usable.
337
+ if (k.length > 60) k = k.slice(0, 60).replace(/_+$/, "");
338
+ return k;
339
+ }
340
+
341
+ const VALID_KEY_RE = /^[a-z_][a-z0-9_]*$/;
342
+ /** @param {string} key @returns {boolean} */
343
+ export function validKey(key) { return VALID_KEY_RE.test(key); }
344
+
345
+ // ─── HELP ───────────────────────────────────────────────────────────────────
346
+ export const HELP_TEXT = `\
347
+ draft — fill placeholders in a legal-document template.
348
+
349
+ USAGE
350
+ draft <template> [--params FILE] [--<PARAM> VALUE]... [options]
351
+ draft <category>/<name> (pulls via \`template-vault get\`)
352
+ draft - (template body on stdin)
353
+ draft --list-placeholders <template> [--json]
354
+ draft --validate <template> --params FILE
355
+ draft --demo (bundled demo, no file needed)
356
+ draft --completion bash (emit shell completion script)
357
+
358
+ DETECTION CASCADE (sequential-with-stop; first non-empty tier wins)
359
+ 1. bracket [Title Case] deterministic, default on
360
+ 2. mustache {{Title Case}} opt-in via --syntax mustache
361
+ 3. docx-highlight yellow / green / cyan auto when input is .docx
362
+ 4. heuristic generic-name dictionary --no-heuristic to skip;
363
+ warn-only without --yes-heuristic
364
+ 5. llm last resort runs only if .env or process env
365
+ configures a provider; --no-llm
366
+ disables; --llm forces
367
+
368
+ OPTIONS
369
+ --params FILE JSON file of param values (snake_case keys).
370
+ -o, --output PATH Write result to PATH (default: stdout).
371
+ --syntax KIND 'bracket' (default) or 'mustache'.
372
+ -i, --interactive Prompt for any missing required parameters.
373
+ --validate Validate completeness; never writes output.
374
+ --list-placeholders Enumerate placeholders and exit.
375
+ --why Print a structured explanation to stderr.
376
+ --json Emit JSON to stdout (suppresses human messages).
377
+ -q, --silent Suppress all stderr output (warnings, --why, notes).
378
+ --no-heuristic Disable tier 4.
379
+ --yes-heuristic Substitute tier-4 matches without confirmation.
380
+ --no-llm Disable tier 5 even when env is configured.
381
+ --llm Assert env-configured LLM; fail-fast if not.
382
+ --check-llm One-token roundtrip to the configured provider.
383
+ --diff Show substitution table without writing output.
384
+ --dictionary PATH Override the bundled heuristic dictionary.
385
+ --completion bash|zsh Emit a shell completion script to stdout.
386
+ --<param-name> VALUE Set a parameter directly. Kebab -> snake_case.
387
+ -h, --help Show this help.
388
+ -V, --version Show version.
389
+
390
+ EXIT CODES
391
+ 0 ok 1 i/o error 2 validation 3 template-vault failure 4 llm failure
392
+
393
+ Part of the contract-operations suite. See cli.drbaher.com.
394
+ `;
395
+
396
+ // ─── INPUT RESOLUTION ───────────────────────────────────────────────────────
397
+ // Returns { kind: "text"|"docx", body: string, docxXml?: string, path: string|null }
398
+ const VAULT_REF_RE = /^[a-z][a-z0-9-]*\/[a-z0-9-]+(?:@[A-Za-z0-9._-]+)?$/;
399
+
400
+ /**
401
+ * Resolve a positional template argument into a usable {@link Input}.
402
+ * Handles three forms: stdin (`-`), a `template-vault get` ref
403
+ * (`<category>/<name>[@version]`), or a filesystem path (text or `.docx`).
404
+ *
405
+ * @param {string} arg
406
+ * @param {{ spawner?: typeof spawnSync, stdinReader?: () => Promise<string> }} [opts]
407
+ * Injectable spawn / stdin reader for tests.
408
+ * @returns {Promise<Input>}
409
+ * @throws {Error} with `.exitCode` set to one of {@link EXIT}'s values on
410
+ * I/O failure (1), vault subprocess failure (3), or `.docx` parse failure (1).
411
+ */
412
+ export async function resolveInput(arg, { spawner = spawnSync, stdinReader = readStdin } = {}) {
413
+ if (arg === "-") {
414
+ return { kind: "text", body: await stdinReader(), path: null };
415
+ }
416
+ if (VAULT_REF_RE.test(arg)) {
417
+ const r = spawner("template-vault", ["get", arg], { encoding: "utf8" });
418
+ if (r.error || r.status !== 0) {
419
+ const msg = (r.stderr || "").toString().trim() || (r.error && r.error.message) ||
420
+ `template-vault get ${arg} failed`;
421
+ const e = new Error(msg);
422
+ e.exitCode = EXIT.VAULT;
423
+ throw e;
424
+ }
425
+ return { kind: "text", body: (r.stdout || "").toString(), path: null };
426
+ }
427
+ if (!existsSync(arg)) {
428
+ const e = new Error(`template not found: ${arg}`);
429
+ e.exitCode = EXIT.IO;
430
+ throw e;
431
+ }
432
+ const ext = extname(arg).toLowerCase();
433
+ if (ext === ".docx") {
434
+ const { body, xml } = await extractDocxText(arg);
435
+ return { kind: "docx", body, docxXml: xml, path: arg };
436
+ }
437
+ return { kind: "text", body: readFileSync(arg, "utf8"), path: arg };
438
+ }
439
+
440
+ /**
441
+ * Read stdin to completion as a UTF-8 string.
442
+ * @returns {Promise<string>}
443
+ */
444
+ export async function readStdin() {
445
+ return await new Promise((res, rej) => {
446
+ let s = "";
447
+ process.stdin.setEncoding("utf8");
448
+ process.stdin.on("data", (d) => { s += d; });
449
+ process.stdin.on("end", () => res(s));
450
+ process.stdin.on("error", rej);
451
+ });
452
+ }
453
+
454
+ // ─── DOCX EXTRACTION (jszip + regex on word/document.xml) ───────────────────
455
+ async function loadJSZip() {
456
+ try { return (await import("jszip")).default; }
457
+ catch {
458
+ const e = new Error("the 'jszip' package is required for .docx input.\nrun: npm install -g jszip (or reinstall @drbaher/draft-cli)");
459
+ e.exitCode = EXIT.IO;
460
+ throw e;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Open a `.docx`, return its extracted plain-text body and the raw XML
466
+ * (the XML is needed for tier-3 highlight detection).
467
+ *
468
+ * @param {string} path
469
+ * @returns {Promise<{ body: string, xml: string }>}
470
+ * @throws {Error} with `.exitCode = EXIT.IO` on missing jszip, invalid
471
+ * `.docx`, or missing `word/document.xml`.
472
+ */
473
+ export async function extractDocxText(path) {
474
+ const JSZip = await loadJSZip();
475
+ let zip;
476
+ try { zip = await JSZip.loadAsync(readFileSync(path)); }
477
+ catch (err) {
478
+ const e = new Error(`could not open .docx (${err.message})`);
479
+ e.exitCode = EXIT.IO;
480
+ throw e;
481
+ }
482
+ const docFile = zip.file("word/document.xml");
483
+ if (!docFile) {
484
+ const e = new Error("invalid .docx: missing word/document.xml");
485
+ e.exitCode = EXIT.IO;
486
+ throw e;
487
+ }
488
+ const xml = await docFile.async("string");
489
+ return { body: docxXmlToText(xml), xml };
490
+ }
491
+
492
+ // Walk the XML in document order. For each <w:p> emit a line; concatenate
493
+ // <w:t> contents within. Decode XML entities. Used for both output body and
494
+ // T1/T2 detection on docx input.
495
+ /**
496
+ * Walk Word's document XML in paragraph order and produce plain text.
497
+ * One line per `<w:p>`; text-run contents concatenated within. XML entities
498
+ * are decoded via {@link decodeXml}.
499
+ *
500
+ * @param {string} xml
501
+ * @returns {string}
502
+ */
503
+ export function docxXmlToText(xml) {
504
+ const paragraphs = xml.split(/<w:p[\s>]/i).slice(1);
505
+ const lines = [];
506
+ for (const p of paragraphs) {
507
+ const para = p.split(/<\/w:p>/i)[0];
508
+ const texts = [];
509
+ const re = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g;
510
+ let m;
511
+ while ((m = re.exec(para)) !== null) texts.push(decodeXml(m[1]));
512
+ lines.push(texts.join(""));
513
+ }
514
+ return lines.join("\n");
515
+ }
516
+
517
+ /**
518
+ * Decode the five XML entities that appear in Word's `<w:t>` runs.
519
+ * @param {string} s
520
+ * @returns {string}
521
+ */
522
+ export function decodeXml(s) {
523
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
524
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'");
525
+ }
526
+
527
+ const RECOGNIZED_HIGHLIGHTS = new Set(["yellow", "green", "cyan", "magenta"]);
528
+
529
+ // Scan the XML for highlighted runs. Returns an array of { text, color }.
530
+ /**
531
+ * Find every highlighted text run in a Word document's XML. Unlike
532
+ * {@link detectDocxHighlight}, does NOT dedupe — multiple occurrences of
533
+ * the same highlighted text appear multiple times.
534
+ *
535
+ * @param {string} xml
536
+ * @returns {Array<{ text: string, color: string }>}
537
+ */
538
+ export function extractDocxHighlights(xml) {
539
+ const out = [];
540
+ const runRe = /<w:r\b[\s\S]*?<\/w:r>/g;
541
+ let m;
542
+ while ((m = runRe.exec(xml)) !== null) {
543
+ const run = m[0];
544
+ const hm = /<w:highlight\s+w:val="([^"]+)"/.exec(run);
545
+ if (!hm) continue;
546
+ const color = hm[1].toLowerCase();
547
+ if (!RECOGNIZED_HIGHLIGHTS.has(color)) continue;
548
+ const texts = [];
549
+ const tRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g;
550
+ let tm;
551
+ while ((tm = tRe.exec(run)) !== null) texts.push(decodeXml(tm[1]));
552
+ const text = texts.join("").trim();
553
+ if (text) out.push({ text, color });
554
+ }
555
+ return out;
556
+ }
557
+
558
+ // ─── TIER 1: BRACKET ────────────────────────────────────────────────────────
559
+ // Match [...] runs that are NOT immediately followed by '(' (markdown link).
560
+ const BRACKET_RE = /\[([^\[\]\n]{1,200})\](?!\()/g;
561
+ const SECTION_REF_RE = /^\d+(?:\.\d+)*$/;
562
+ const CHECKBOX_RE = /^[ xX]{1,3}$/;
563
+
564
+ /**
565
+ * The locked T1 admission rule. Rejects markdown links, checkbox markers,
566
+ * pure section refs, punctuation-only runs, and all-uppercase headings.
567
+ * Permissive otherwise — accepts sentence-shaped placeholders with full
568
+ * punctuation, as real legal templates use.
569
+ *
570
+ * @param {string} inner — bracket contents (no `[` `]`).
571
+ * @returns {boolean}
572
+ */
573
+ export function isBracketPlaceholder(inner) {
574
+ if (!inner) return false;
575
+ if (CHECKBOX_RE.test(inner)) return false;
576
+ if (SECTION_REF_RE.test(inner)) return false;
577
+ // Must contain at least one letter so we don't catch [___] or [---].
578
+ if (!/[A-Za-z]/.test(inner)) return false;
579
+ // Reject all-caps headings ([CONFIDENTIALITY], [ARTICLE I]).
580
+ if (inner === inner.toUpperCase() && /[A-Z]/.test(inner)) return false;
581
+ return true;
582
+ }
583
+
584
+ /**
585
+ * Tier 1 detection: bracketed `[...]` placeholders.
586
+ *
587
+ * `schemaAliases` (optional) is a Set of phrase strings declared in the
588
+ * schema file; bracketed runs whose inner matches a schema alias are
589
+ * admitted even if the heuristic rule {@link isBracketPlaceholder} would
590
+ * reject them (lets a schema rescue `[COMPANY]`, `[_____________]`, etc.).
591
+ *
592
+ * @param {string} body
593
+ * @param {Set<string>} [schemaAliases]
594
+ * @returns {DetectionHit[]}
595
+ */
596
+ export function detectBracket(body, schemaAliases = new Set()) {
597
+ const out = [];
598
+ let m;
599
+ BRACKET_RE.lastIndex = 0;
600
+ while ((m = BRACKET_RE.exec(body)) !== null) {
601
+ if (isBracketPlaceholder(m[1]) || schemaAliases.has(m[1])) {
602
+ out.push({ match: m[0], inner: m[1], index: m.index });
603
+ }
604
+ }
605
+ return out;
606
+ }
607
+
608
+ // ─── TIER 2: MUSTACHE ───────────────────────────────────────────────────────
609
+ const MUSTACHE_RE = /\{\{\s*([^{}\n]{1,80}?)\s*\}\}/g;
610
+ const SNAKE_RE = /^[a-z][a-z0-9_]{0,78}$/;
611
+
612
+ /**
613
+ * T2 admission rule. Accepts snake_case or Title-Case inner text.
614
+ * @param {string} inner
615
+ * @returns {boolean}
616
+ */
617
+ export function isMustachePlaceholder(inner) {
618
+ if (SNAKE_RE.test(inner)) return true;
619
+ return isBracketPlaceholder(inner);
620
+ }
621
+
622
+ /**
623
+ * Tier 2 detection: `{{Title Case}}` or `{{snake_case}}` mustache placeholders.
624
+ * Only invoked when `--syntax mustache` is selected. Schema-rescue same as T1.
625
+ *
626
+ * @param {string} body
627
+ * @param {Set<string>} [schemaAliases]
628
+ * @returns {DetectionHit[]}
629
+ */
630
+ export function detectMustache(body, schemaAliases = new Set()) {
631
+ const out = [];
632
+ let m;
633
+ MUSTACHE_RE.lastIndex = 0;
634
+ while ((m = MUSTACHE_RE.exec(body)) !== null) {
635
+ const inner = m[1].trim();
636
+ if (isMustachePlaceholder(inner) || schemaAliases.has(inner)) {
637
+ out.push({ match: m[0], inner, index: m.index });
638
+ }
639
+ }
640
+ return out;
641
+ }
642
+
643
+ /**
644
+ * Whether `body` contains both bracket and mustache placeholders.
645
+ * Triggers the mixed-convention `--why`/stderr warning.
646
+ *
647
+ * @param {string} body
648
+ * @returns {boolean}
649
+ */
650
+ export function hasBothConventions(body) {
651
+ return detectBracket(body).length > 0 && detectMustache(body).length > 0;
652
+ }
653
+
654
+ // ─── TIER 3: DOCX HIGHLIGHT ─────────────────────────────────────────────────
655
+ /**
656
+ * Tier 3 detection: scan a Word document's XML for highlighted text runs.
657
+ * Recognizes yellow / green / cyan / magenta highlights as placeholders;
658
+ * other colors are ignored. Dedupes by exact text match.
659
+ *
660
+ * @param {string} xml — content of `word/document.xml`.
661
+ * @returns {DetectionHit[]}
662
+ */
663
+ export function detectDocxHighlight(xml) {
664
+ if (!xml) return [];
665
+ const hits = extractDocxHighlights(xml);
666
+ const seen = new Map();
667
+ for (const { text, color } of hits) {
668
+ if (!seen.has(text)) seen.set(text, { match: text, inner: text, color });
669
+ }
670
+ return [...seen.values()];
671
+ }
672
+
673
+ // ─── TIER 4: HEURISTIC ──────────────────────────────────────────────────────
674
+ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
675
+
676
+ /**
677
+ * Tier 4 detection: scan body for known generic-placeholder phrases from a
678
+ * curated dictionary. Whole-word matching only. Returns one entry per
679
+ * phrase that appears (dedupe by phrase).
680
+ *
681
+ * Note: substitute() does a global regex replace on T4 hits, so a single
682
+ * detection may correspond to multiple substitutions in the output.
683
+ *
684
+ * @param {string} body
685
+ * @param {string[]} [dict] — phrases to look for. Defaults to {@link DEFAULT_HEURISTIC_DICT}.
686
+ * @returns {DetectionHit[]}
687
+ */
688
+ export function detectHeuristic(body, dict = DEFAULT_HEURISTIC_DICT) {
689
+ const out = [];
690
+ const seen = new Set();
691
+ for (const phrase of dict) {
692
+ const re = new RegExp(`(?<![A-Za-z0-9])${escapeRegex(phrase)}(?![A-Za-z0-9])`, "g");
693
+ let m;
694
+ while ((m = re.exec(body)) !== null) {
695
+ if (!seen.has(phrase)) {
696
+ seen.add(phrase);
697
+ out.push({ match: phrase, inner: phrase, index: m.index });
698
+ }
699
+ break; // count once per phrase for detection; substitution replaces all
700
+ }
701
+ }
702
+ return out;
703
+ }
704
+
705
+ // ─── TIER 5: LLM ────────────────────────────────────────────────────────────
706
+ /**
707
+ * Tier 5 detection: ask an LLM to suggest placeholders in the body.
708
+ *
709
+ * Sends template text ONLY. Does not include params, schema, or env. The
710
+ * provider's response is parsed as `{ placeholders: [{text, suggested_key}] }`;
711
+ * malformed entries are dropped silently. Tests inject a `fetcher` so they
712
+ * never make real network calls.
713
+ *
714
+ * @param {string} body
715
+ * @param {LlmProvider} providerCfg
716
+ * @param {{ fetcher?: typeof fetch | null }} [opts]
717
+ * @returns {Promise<DetectionHit[]>}
718
+ * @throws {Error} with `.exitCode = EXIT.LLM` on auth, transport, or
719
+ * parse failure.
720
+ */
721
+ export async function detectLlm(body, providerCfg, { fetcher = (typeof fetch !== "undefined" ? fetch : null) } = {}) {
722
+ if (!fetcher) {
723
+ const e = new Error("fetch is not available; Node 18+ is required for the LLM tier");
724
+ e.exitCode = EXIT.LLM;
725
+ throw e;
726
+ }
727
+ const prompt = `You are a placeholder detector for a legal-document drafting tool.
728
+ Given the document text below, identify spans that look like placeholders — names, dates, or
729
+ party-identifier text that a drafter would replace before sending. Do NOT detect cross-references
730
+ or section labels. Output JSON ONLY in this exact shape:
731
+ {"placeholders":[{"text":"<verbatim span>","suggested_key":"<snake_case_key>"}]}
732
+
733
+ If you find nothing, output {"placeholders":[]}.
734
+
735
+ DOCUMENT:
736
+ ${body.slice(0, 12000)}`;
737
+ const raw = await callLlm(providerCfg, prompt, fetcher);
738
+ let parsed;
739
+ try {
740
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
741
+ parsed = JSON.parse(jsonMatch ? jsonMatch[0] : raw);
742
+ } catch {
743
+ const e = new Error(`LLM returned non-JSON response`);
744
+ e.exitCode = EXIT.LLM;
745
+ throw e;
746
+ }
747
+ const items = Array.isArray(parsed.placeholders) ? parsed.placeholders : [];
748
+ const out = [];
749
+ const seen = new Set();
750
+ for (const it of items) {
751
+ if (!it || typeof it.text !== "string" || typeof it.suggested_key !== "string") continue;
752
+ if (!validKey(it.suggested_key)) continue;
753
+ if (seen.has(it.suggested_key)) continue;
754
+ seen.add(it.suggested_key);
755
+ out.push({ match: it.text, inner: it.text, suggested_key: it.suggested_key });
756
+ }
757
+ return out;
758
+ }
759
+
760
+ async function callLlm(cfg, prompt, fetcher) {
761
+ if (cfg.provider === "anthropic") {
762
+ const r = await fetcher("https://api.anthropic.com/v1/messages", {
763
+ method: "POST",
764
+ headers: {
765
+ "x-api-key": cfg.apiKey,
766
+ "anthropic-version": "2023-06-01",
767
+ "content-type": "application/json",
768
+ },
769
+ body: JSON.stringify({
770
+ model: cfg.model || "claude-sonnet-4-6",
771
+ max_tokens: 4096,
772
+ messages: [{ role: "user", content: prompt }],
773
+ }),
774
+ });
775
+ if (!r.ok) {
776
+ const e = new Error(`LLM call failed: ${r.status} ${await safeText(r)}`);
777
+ e.exitCode = EXIT.LLM;
778
+ throw e;
779
+ }
780
+ const j = await r.json();
781
+ return (j.content && j.content[0] && j.content[0].text) || "";
782
+ }
783
+ if (cfg.provider === "openai") {
784
+ const r = await fetcher("https://api.openai.com/v1/chat/completions", {
785
+ method: "POST",
786
+ headers: {
787
+ "authorization": `Bearer ${cfg.apiKey}`,
788
+ "content-type": "application/json",
789
+ },
790
+ body: JSON.stringify({
791
+ model: cfg.model || "gpt-4o-mini",
792
+ messages: [{ role: "user", content: prompt }],
793
+ }),
794
+ });
795
+ if (!r.ok) {
796
+ const e = new Error(`LLM call failed: ${r.status} ${await safeText(r)}`);
797
+ e.exitCode = EXIT.LLM;
798
+ throw e;
799
+ }
800
+ const j = await r.json();
801
+ return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
802
+ }
803
+ const e = new Error(`unsupported LLM provider: ${cfg.provider}`);
804
+ e.exitCode = EXIT.LLM;
805
+ throw e;
806
+ }
807
+
808
+ async function safeText(r) { try { return await r.text(); } catch { return ""; } }
809
+
810
+ // ─── SCHEMA LOADING ─────────────────────────────────────────────────────────
811
+ // Returns { form: "short"|"long", entries: { [key]: { aliases, required, default } } }
812
+ // or null if no schema file exists.
813
+ /**
814
+ * Look for a sibling `<template>.params.json` and parse it.
815
+ *
816
+ * @param {string|null} templatePath — pass null for stdin / vault input.
817
+ * @returns {Schema|null} Parsed schema (with `.sourcePath` set) or null if no file.
818
+ * @throws {Error} with `.exitCode = EXIT.IO` on malformed JSON or invalid structure.
819
+ */
820
+ export function loadSchema(templatePath) {
821
+ if (!templatePath) return null;
822
+ const candidate = templatePath.replace(/\.[^./]+$/, "") + ".params.json";
823
+ const alt = templatePath + ".params.json";
824
+ const file = existsSync(candidate) ? candidate : existsSync(alt) ? alt : null;
825
+ if (!file) return null;
826
+ let parsed;
827
+ try { parsed = JSON.parse(readFileSync(file, "utf8")); }
828
+ catch (err) {
829
+ const e = new Error(`schema file ${file} is not valid JSON: ${err.message}`);
830
+ e.exitCode = EXIT.IO;
831
+ throw e;
832
+ }
833
+ const out = parseSchema(parsed, file);
834
+ out.sourcePath = file;
835
+ return out;
836
+ }
837
+
838
+ /**
839
+ * Validate and normalize a parsed JSON schema object. Auto-selects short vs
840
+ * long form based on the presence of a top-level `_meta` key.
841
+ *
842
+ * @param {Object} parsed — JSON.parse output of the schema file.
843
+ * @param {string} [sourceLabel] — used in error messages.
844
+ * @returns {Schema}
845
+ * @throws {Error} with `.exitCode = EXIT.IO` on invalid structure.
846
+ */
847
+ export function parseSchema(parsed, sourceLabel = "<schema>") {
848
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
849
+ const e = new Error(`${sourceLabel}: top-level must be an object`);
850
+ e.exitCode = EXIT.IO;
851
+ throw e;
852
+ }
853
+ const long = Object.prototype.hasOwnProperty.call(parsed, "_meta");
854
+ const entries = {};
855
+ for (const [k, v] of Object.entries(parsed)) {
856
+ if (k.startsWith("_")) continue;
857
+ if (!validKey(k)) {
858
+ const e = new Error(`${sourceLabel}: invalid key '${k}' (must be snake_case)`);
859
+ e.exitCode = EXIT.IO;
860
+ throw e;
861
+ }
862
+ if (long) {
863
+ if (!v || typeof v !== "object" || !Array.isArray(v.aliases)) {
864
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' must have an aliases array`);
865
+ e.exitCode = EXIT.IO;
866
+ throw e;
867
+ }
868
+ entries[k] = {
869
+ aliases: v.aliases.slice(),
870
+ required: v.required !== false,
871
+ default: Object.prototype.hasOwnProperty.call(v, "default") ? v.default : null,
872
+ };
873
+ } else {
874
+ if (!Array.isArray(v)) {
875
+ const e = new Error(`${sourceLabel}: short-form entry '${k}' must be an array of phrase strings`);
876
+ e.exitCode = EXIT.IO;
877
+ throw e;
878
+ }
879
+ entries[k] = { aliases: v.slice(), required: true, default: null };
880
+ }
881
+ }
882
+ return { form: long ? "long" : "short", entries };
883
+ }
884
+
885
+ // ─── DETECTION ORCHESTRATOR ─────────────────────────────────────────────────
886
+ // Returns:
887
+ // {
888
+ // tier: "bracket"|"mustache"|"docx-highlight"|"heuristic"|"llm",
889
+ // placeholders: [ { key, first_seen_as, occurrences, tier, hits:[{match,inner,index?}] } ],
890
+ // warnings: string[],
891
+ // unmapped: [{ phrase, tier }],
892
+ // }
893
+ /**
894
+ * Run the five-tier sequential-with-stop detection cascade on an input.
895
+ * The first tier to return ≥1 hit wins; subsequent tiers are skipped.
896
+ * Always emits a mixed-convention warning when both `[...]` and `{{...}}`
897
+ * appear in the body, regardless of which tier wins.
898
+ *
899
+ * @param {Input} input
900
+ * @param {ParsedArgs} opts
901
+ * @param {Schema|null} schema
902
+ * @param {Object<string, string>} envObj — merged file + process env.
903
+ * @param {{ fetcher?: typeof fetch }} [io] — for LLM tier mocking.
904
+ * @returns {Promise<CascadeResult>}
905
+ * @throws {Error} with `.exitCode = EXIT.LLM` if `--llm` was set but no
906
+ * provider is configured.
907
+ */
908
+ export async function runCascade(input, opts, schema, envObj, { fetcher } = {}) {
909
+ const warnings = [];
910
+ const body = input.body;
911
+ const provider = llmProviderFromEnv(envObj);
912
+
913
+ // Validate --llm up-front: if user asserts --llm, env must configure a provider.
914
+ if (opts.forceLlm && !provider) {
915
+ const e = new Error("--llm requires an LLM provider configured in .env or process env (ANTHROPIC_API_KEY, OPENAI_API_KEY, or DRAFT_LLM_*)");
916
+ e.exitCode = EXIT.LLM;
917
+ throw e;
918
+ }
919
+
920
+ // Mixed-convention warning regardless of cascade outcome.
921
+ if (hasBothConventions(body)) {
922
+ const b = detectBracket(body).length;
923
+ const m = detectMustache(body).length;
924
+ warnings.push(`mixed placeholder conventions: ${b} bracket, ${m} mustache (using --syntax ${opts.syntax})`);
925
+ }
926
+
927
+ // Pre-compute the schema's union of declared phrase forms so detection can
928
+ // rescue placeholders the heuristic rule would otherwise reject (e.g. all-
929
+ // caps signature-block markers like [COMPANY], or fill-in markers like
930
+ // [_____________]). Without this, a schema-declared alias is silently
931
+ // dropped during detection and never reaches the alias-resolution step.
932
+ const schemaAliasSet = new Set();
933
+ if (schema) {
934
+ for (const entry of Object.values(schema.entries)) {
935
+ for (const a of entry.aliases) schemaAliasSet.add(a);
936
+ }
937
+ }
938
+
939
+ // Tier 1 / 2 (sequenced by --syntax; only the selected family runs).
940
+ if (opts.syntax === "bracket") {
941
+ const hits = detectBracket(body, schemaAliasSet);
942
+ if (hits.length > 0) {
943
+ return assemble("bracket", hits, schema, warnings);
944
+ }
945
+ } else {
946
+ const hits = detectMustache(body, schemaAliasSet);
947
+ if (hits.length > 0) {
948
+ return assemble("mustache", hits, schema, warnings);
949
+ }
950
+ }
951
+
952
+ // Tier 3 (docx-highlight) — only if input is .docx.
953
+ if (input.kind === "docx") {
954
+ const hits = detectDocxHighlight(input.docxXml);
955
+ if (hits.length > 0) {
956
+ return assemble("docx-highlight", hits, schema, warnings);
957
+ }
958
+ }
959
+
960
+ // Tier 4 (heuristic).
961
+ if (!opts.noHeuristic) {
962
+ const dict = opts.dictionary ? readDictionary(opts.dictionary) : DEFAULT_HEURISTIC_DICT;
963
+ const hits = detectHeuristic(body, dict);
964
+ if (hits.length > 0) {
965
+ const r = assemble("heuristic", hits, schema, warnings);
966
+ r.heuristicGate = true; // signal: requires confirmation
967
+ return r;
968
+ }
969
+ }
970
+
971
+ // Tier 5 (LLM) — auto-runs when a provider is configured and --no-llm is absent.
972
+ if (provider && !opts.noLlm) {
973
+ const hits = await detectLlm(body, provider, { fetcher });
974
+ if (hits.length > 0) {
975
+ return assemble("llm", hits, schema, warnings, /*fromLlm=*/true);
976
+ }
977
+ }
978
+
979
+ return { tier: "none", placeholders: [], warnings, unmapped: [] };
980
+ }
981
+
982
+ /**
983
+ * Read a custom heuristic dictionary file. Must be a JSON array of strings.
984
+ *
985
+ * @param {string} path
986
+ * @returns {string[]}
987
+ * @throws {Error} with `.exitCode = EXIT.IO` on read or parse failure.
988
+ */
989
+ export function readDictionary(path) {
990
+ try {
991
+ const j = JSON.parse(readFileSync(path, "utf8"));
992
+ if (!Array.isArray(j)) throw new Error("dictionary file must be a JSON array of strings");
993
+ return j;
994
+ } catch (err) {
995
+ const e = new Error(`could not read dictionary ${path}: ${err.message}`);
996
+ e.exitCode = EXIT.IO;
997
+ throw e;
998
+ }
999
+ }
1000
+
1001
+ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1002
+ // Group hits by canonical key (schema-aware).
1003
+ const byKey = new Map();
1004
+ const unmapped = [];
1005
+ for (const h of hits) {
1006
+ const resolved = resolveKey(h, schema, fromLlm);
1007
+ if (!resolved) {
1008
+ unmapped.push({ phrase: h.inner, tier });
1009
+ continue;
1010
+ }
1011
+ if (!byKey.has(resolved.key)) {
1012
+ byKey.set(resolved.key, {
1013
+ key: resolved.key,
1014
+ first_seen_as: h.inner,
1015
+ occurrences: 0,
1016
+ tier,
1017
+ required: resolved.required,
1018
+ default: resolved.default,
1019
+ aliases: resolved.aliases,
1020
+ hits: [],
1021
+ });
1022
+ }
1023
+ const entry = byKey.get(resolved.key);
1024
+ entry.occurrences += 1;
1025
+ entry.hits.push(h);
1026
+ }
1027
+ return { tier, placeholders: [...byKey.values()], warnings, unmapped };
1028
+ }
1029
+
1030
+ function resolveKey(hit, schema, fromLlm) {
1031
+ if (schema) {
1032
+ for (const [key, entry] of Object.entries(schema.entries)) {
1033
+ if (entry.aliases.includes(hit.inner)) {
1034
+ return { key, required: entry.required, default: entry.default, aliases: entry.aliases };
1035
+ }
1036
+ }
1037
+ return null;
1038
+ }
1039
+ const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1040
+ if (!validKey(key)) return null;
1041
+ return { key, required: true, default: null, aliases: [hit.inner] };
1042
+ }
1043
+
1044
+ // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
1045
+ /**
1046
+ * Read the JSON `--params` file. Returns `{}` if path is null.
1047
+ *
1048
+ * @param {string | null} path
1049
+ * @returns {Object<string, *>}
1050
+ * @throws {Error} with `.exitCode = EXIT.IO` on missing or invalid file.
1051
+ */
1052
+ export function loadParamsFile(path) {
1053
+ if (!path) return {};
1054
+ if (!existsSync(path)) {
1055
+ const e = new Error(`params file not found: ${path}`);
1056
+ e.exitCode = EXIT.IO;
1057
+ throw e;
1058
+ }
1059
+ try {
1060
+ const j = JSON.parse(readFileSync(path, "utf8"));
1061
+ if (!j || typeof j !== "object" || Array.isArray(j)) {
1062
+ const e = new Error(`params file ${path} must be a JSON object`);
1063
+ e.exitCode = EXIT.IO;
1064
+ throw e;
1065
+ }
1066
+ return j;
1067
+ } catch (err) {
1068
+ if (err.exitCode) throw err;
1069
+ const e = new Error(`could not parse ${path}: ${err.message}`);
1070
+ e.exitCode = EXIT.IO;
1071
+ throw e;
1072
+ }
1073
+ }
1074
+
1075
+ /**
1076
+ * Resolve a value for every placeholder using the locked precedence chain:
1077
+ * CLI flag > `--params` JSON > `--interactive` prompt > schema default >
1078
+ * (missing). Empty-string CLI values are considered supplied (not missing).
1079
+ *
1080
+ * @param {Placeholder[]} placeholders
1081
+ * @param {ParsedArgs} opts
1082
+ * @param {Object<string, *>} paramsObj — parsed JSON params file.
1083
+ * @param {{ prompter?: (p: Placeholder) => Promise<string|null> }} [io]
1084
+ * @returns {Promise<ResolvedValues>}
1085
+ */
1086
+ export async function resolveValues(placeholders, opts, paramsObj, { prompter = nodePrompter } = {}) {
1087
+ const resolved = {};
1088
+ const missing = [];
1089
+ const sources = {};
1090
+ for (const p of placeholders) {
1091
+ if (Object.prototype.hasOwnProperty.call(opts.paramFlags, p.key)) {
1092
+ resolved[p.key] = opts.paramFlags[p.key];
1093
+ sources[p.key] = "cli";
1094
+ continue;
1095
+ }
1096
+ if (Object.prototype.hasOwnProperty.call(paramsObj, p.key)) {
1097
+ resolved[p.key] = String(paramsObj[p.key]);
1098
+ sources[p.key] = "params";
1099
+ continue;
1100
+ }
1101
+ if (opts.interactive) {
1102
+ const v = await prompter(p);
1103
+ if (v !== null && v !== undefined && v !== "") {
1104
+ resolved[p.key] = String(v);
1105
+ sources[p.key] = "interactive";
1106
+ continue;
1107
+ }
1108
+ }
1109
+ if (p.default !== null && p.default !== undefined) {
1110
+ resolved[p.key] = String(p.default);
1111
+ sources[p.key] = "default";
1112
+ continue;
1113
+ }
1114
+ if (p.required) missing.push(p);
1115
+ }
1116
+ return { resolved, missing, sources };
1117
+ }
1118
+
1119
+ async function nodePrompter(placeholder) {
1120
+ if (!process.stdin.isTTY) return null;
1121
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1122
+ return await new Promise((res) => {
1123
+ rl.question(`${placeholder.key} (${placeholder.first_seen_as}): `, (a) => {
1124
+ rl.close(); res(a.trim());
1125
+ });
1126
+ });
1127
+ }
1128
+
1129
+ // Schema declares params not present in the template → orphan error.
1130
+ /**
1131
+ * Find schema-declared keys whose alias list matches no detected placeholder.
1132
+ * Orphans are exit-2 errors by design (catch schema drift early).
1133
+ *
1134
+ * @param {Schema|null} schema
1135
+ * @param {Placeholder[]} placeholders
1136
+ * @returns {Array<{key: string, aliases: string[]}>}
1137
+ */
1138
+ export function findOrphans(schema, placeholders) {
1139
+ if (!schema) return [];
1140
+ const present = new Set(placeholders.map((p) => p.key));
1141
+ const orphans = [];
1142
+ for (const [key, entry] of Object.entries(schema.entries)) {
1143
+ if (!present.has(key)) orphans.push({ key, aliases: entry.aliases.slice() });
1144
+ }
1145
+ return orphans;
1146
+ }
1147
+
1148
+ // ─── SUBSTITUTION ───────────────────────────────────────────────────────────
1149
+ /**
1150
+ * Substitute resolved values into the template body. For T1/T2 (bracket /
1151
+ * mustache) we replace the literal match span; for T3/T4/T5 we do a
1152
+ * whole-word regex replace on the matched phrase. The original body is
1153
+ * preserved byte-for-byte except at substitution sites.
1154
+ *
1155
+ * @param {string} body
1156
+ * @param {Placeholder[]} placeholders
1157
+ * @param {Object<string, string>} values — key -> value, from {@link resolveValues}.
1158
+ * @param {Tier} tier
1159
+ * @returns {string} the substituted body.
1160
+ */
1161
+ export function substitute(body, placeholders, values, tier) {
1162
+ let out = body;
1163
+ for (const p of placeholders) {
1164
+ const v = values[p.key];
1165
+ if (v === undefined) continue;
1166
+ for (const h of p.hits) {
1167
+ if (tier === "bracket" || tier === "mustache") {
1168
+ out = replaceAll(out, h.match, v);
1169
+ } else {
1170
+ // Tier 3/4/5: replace literal phrase (whole-word) globally.
1171
+ const re = new RegExp(`(?<![A-Za-z0-9])${escapeRegex(h.inner)}(?![A-Za-z0-9])`, "g");
1172
+ out = out.replace(re, v);
1173
+ }
1174
+ }
1175
+ }
1176
+ return out;
1177
+ }
1178
+
1179
+ function replaceAll(s, find, repl) {
1180
+ return s.split(find).join(repl);
1181
+ }
1182
+
1183
+ // ─── --why BUILDER ──────────────────────────────────────────────────────────
1184
+ /**
1185
+ * Format the `--why` stderr block. Stable shape across minor versions; see
1186
+ * the `tier`, `placeholders`, `resolved`, `defaulted`, `unresolved`,
1187
+ * `unmapped`, and `warnings` keys.
1188
+ *
1189
+ * @param {{
1190
+ * inputDescriptor: string,
1191
+ * schemaDescriptor: string,
1192
+ * tier: Tier,
1193
+ * placeholders: Placeholder[],
1194
+ * sources: Object<string, string>,
1195
+ * missing: Placeholder[],
1196
+ * unmapped: Array<{phrase: string, tier: Tier}>,
1197
+ * warnings: string[],
1198
+ * outputPath: string | null,
1199
+ * }} args
1200
+ * @returns {string}
1201
+ */
1202
+ export function buildWhyBlock({ inputDescriptor, schemaDescriptor, tier, placeholders, sources, missing, unmapped, warnings, outputPath }) {
1203
+ const counts = { cli: 0, params: 0, interactive: 0, default: 0 };
1204
+ for (const s of Object.values(sources)) counts[s] = (counts[s] || 0) + 1;
1205
+ const distinct = placeholders.length;
1206
+ const occurrences = placeholders.reduce((acc, p) => acc + p.occurrences, 0);
1207
+ const lines = [
1208
+ `draft: substituted ${distinct - missing.length} of ${distinct} placeholders${outputPath ? ` → ${outputPath}` : ""}`,
1209
+ `why:`,
1210
+ ` input = ${inputDescriptor}`,
1211
+ ` tier = ${tier}`,
1212
+ ` schema = ${schemaDescriptor}`,
1213
+ ` placeholders = ${distinct} distinct, ${occurrences} occurrences`,
1214
+ ` resolved = ${distinct - missing.length} (${counts.cli || 0} from CLI, ${counts.params || 0} from --params, ${counts.interactive || 0} interactive, ${counts.default || 0} default)`,
1215
+ ` defaulted = ${counts.default || 0}`,
1216
+ ` unresolved = ${missing.length}`,
1217
+ ` unmapped = ${unmapped.length}${unmapped.length ? ` (${unmapped.map(u => u.phrase).join(", ")})` : ""}`,
1218
+ ` warnings = ${warnings.length}`,
1219
+ ];
1220
+ return lines.join("\n");
1221
+ }
1222
+
1223
+ // ─── COMMANDS ───────────────────────────────────────────────────────────────
1224
+ function publicPlaceholders(placeholders) {
1225
+ return placeholders.map((p) => ({
1226
+ key: p.key,
1227
+ first_seen_as: p.first_seen_as,
1228
+ aliases: p.aliases,
1229
+ required: p.required,
1230
+ occurrences: p.occurrences,
1231
+ tier: p.tier,
1232
+ }));
1233
+ }
1234
+
1235
+ export async function cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err } = {}) {
1236
+ const result = await runCascade(input, opts, schema, envObj, { fetcher });
1237
+ const placeholders = publicPlaceholders(result.placeholders);
1238
+ if (opts.json) {
1239
+ out.write(JSON.stringify({
1240
+ template: input.path || (input.kind === "text" ? "-" : "<docx>"),
1241
+ tier: result.tier,
1242
+ placeholders,
1243
+ warnings: result.warnings,
1244
+ unmapped: result.unmapped,
1245
+ }, null, 2) + "\n");
1246
+ } else {
1247
+ if (placeholders.length === 0) {
1248
+ err.write(paint("no placeholders detected.\n", "yellow", err));
1249
+ } else {
1250
+ for (const p of placeholders) {
1251
+ out.write(`${p.key} (${p.first_seen_as})${p.aliases.length > 1 ? ` aliases: ${p.aliases.join(", ")}` : ""} ×${p.occurrences} [tier=${p.tier}]\n`);
1252
+ }
1253
+ }
1254
+ for (const w of result.warnings) err.write(paint(`warning: ${w}\n`, "yellow", err));
1255
+ }
1256
+ return EXIT.OK;
1257
+ }
1258
+
1259
+ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
1260
+ const result = await runCascade(input, opts, schema, envObj, { fetcher });
1261
+ if (result.tier === "none") {
1262
+ err.write(paint("error: no placeholders detected by any tier\n", "red", err));
1263
+ return EXIT.VALIDATION;
1264
+ }
1265
+ const orphans = findOrphans(schema, result.placeholders);
1266
+ if (orphans.length > 0) {
1267
+ for (const o of orphans) {
1268
+ err.write(paint(`error: schema declares "${o.key}" with aliases [${o.aliases.map(a => `"${a}"`).join(",")}], but no matching phrase was detected by tier '${result.tier}'.\n`, "red", err));
1269
+ }
1270
+ return EXIT.VALIDATION;
1271
+ }
1272
+ const { resolved, missing, sources } = await resolveValues(result.placeholders, opts, paramsObj);
1273
+ if (missing.length > 0) {
1274
+ printMissing(missing, err);
1275
+ if (opts.json) {
1276
+ out.write(JSON.stringify({ ok: false, missing: missing.map(m => m.key) }, null, 2) + "\n");
1277
+ }
1278
+ return EXIT.VALIDATION;
1279
+ }
1280
+ if (opts.json) {
1281
+ out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
1282
+ } else {
1283
+ err.write(paint(`ok: ${Object.keys(resolved).length} parameter(s) resolved\n`, "green", err));
1284
+ }
1285
+ return EXIT.OK;
1286
+ }
1287
+
1288
+ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
1289
+ const result = await runCascade(input, opts, schema, envObj, { fetcher });
1290
+ if (result.tier === "none") {
1291
+ const hasProvider = Boolean(llmProviderFromEnv(envObj));
1292
+ err.write(paint(
1293
+ `error: no placeholders detected by deterministic tiers (bracket, mustache, docx-highlight, heuristic).\n` +
1294
+ (hasProvider
1295
+ ? `hint: pass --llm to invoke LLM detection explicitly.\n`
1296
+ : `hint: set ANTHROPIC_API_KEY in .env to enable LLM detection,\n or pass --syntax mustache if your template uses {{...}}.\n`),
1297
+ "red", err
1298
+ ));
1299
+ return EXIT.VALIDATION;
1300
+ }
1301
+
1302
+ // Heuristic safety gate.
1303
+ if (result.heuristicGate && !opts.yesHeuristic) {
1304
+ if (process.stdin.isTTY && process.stderr.isTTY && !opts.json) {
1305
+ err.write(paint(`note: tier 'heuristic' found these generic phrases:\n`, "yellow", err));
1306
+ for (const p of result.placeholders) err.write(` - ${p.first_seen_as}\n`);
1307
+ const ok = await confirmTty("substitute these? [y/N] ");
1308
+ if (!ok) {
1309
+ err.write(paint("aborted (heuristic not confirmed). Pass --yes-heuristic to skip this prompt.\n", "yellow", err));
1310
+ return EXIT.VALIDATION;
1311
+ }
1312
+ } else {
1313
+ err.write(paint(
1314
+ `warning: heuristic tier found ${result.placeholders.length} match(es) but --yes-heuristic was not given.\n` +
1315
+ `nothing was substituted. matches: ${result.placeholders.map(p => p.first_seen_as).join(", ")}\n`,
1316
+ "yellow", err
1317
+ ));
1318
+ return EXIT.VALIDATION;
1319
+ }
1320
+ }
1321
+
1322
+ // Orphan check.
1323
+ const orphans = findOrphans(schema, result.placeholders);
1324
+ if (orphans.length > 0) {
1325
+ for (const o of orphans) {
1326
+ err.write(paint(`error: schema declares "${o.key}" with aliases [${o.aliases.map(a => `"${a}"`).join(",")}], but no matching phrase was detected by tier '${result.tier}'.\n`, "red", err));
1327
+ err.write(`hint: remove the entry from the schema, or add the phrase to the template.\n`);
1328
+ }
1329
+ return EXIT.VALIDATION;
1330
+ }
1331
+
1332
+ const { resolved, missing, sources } = await resolveValues(result.placeholders, opts, paramsObj);
1333
+ // Footgun guard: flag --typo'd-key VALUE that didn't match any detected
1334
+ // placeholder. Without this warning, a typo'd flag is silently dropped and
1335
+ // the user sees only a "missing required" error without the connection.
1336
+ const declaredKeys = new Set(result.placeholders.map((p) => p.key));
1337
+ const unusedFlags = Object.keys(opts.paramFlags).filter((k) => !declaredKeys.has(k));
1338
+ for (const u of unusedFlags) {
1339
+ result.warnings.push(`flag --${u.replace(/_/g, "-")} did not match any detected placeholder (possible typo?)`);
1340
+ }
1341
+ if (missing.length > 0) {
1342
+ printMissing(missing, err);
1343
+ if (unusedFlags.length > 0) {
1344
+ err.write(paint(`note: you also passed ${unusedFlags.map(u => `--${u.replace(/_/g, "-")}`).join(", ")} which did not match any placeholder.\n`, "yellow", err));
1345
+ }
1346
+ return EXIT.VALIDATION;
1347
+ }
1348
+
1349
+ // Diff mode: print a substitution table and exit without writing output.
1350
+ if (opts.diff) {
1351
+ if (opts.json) {
1352
+ out.write(JSON.stringify({
1353
+ ok: true,
1354
+ tier: result.tier,
1355
+ diff: result.placeholders.map((p) => ({
1356
+ key: p.key,
1357
+ from: `[${p.first_seen_as}]`,
1358
+ to: resolved[p.key] !== undefined ? resolved[p.key] : null,
1359
+ occurrences: p.occurrences,
1360
+ })),
1361
+ }, null, 2) + "\n");
1362
+ } else {
1363
+ out.write(buildDiffBlock(result.placeholders, resolved, { stream: out }));
1364
+ }
1365
+ return EXIT.OK;
1366
+ }
1367
+
1368
+ const output = substitute(input.body, result.placeholders, resolved, result.tier);
1369
+
1370
+ // Write output.
1371
+ if (opts.output) {
1372
+ try { writeFileSync(opts.output, output, "utf8"); }
1373
+ catch (e) {
1374
+ err.write(paint(`error: could not write ${opts.output}: ${e.message}\n`, "red", err));
1375
+ return EXIT.IO;
1376
+ }
1377
+ } else if (!opts.json) {
1378
+ out.write(output);
1379
+ }
1380
+
1381
+ if (opts.json) {
1382
+ out.write(JSON.stringify({
1383
+ ok: true,
1384
+ tier: result.tier,
1385
+ output_path: opts.output || null,
1386
+ output: opts.output ? null : output,
1387
+ placeholders: publicPlaceholders(result.placeholders),
1388
+ sources,
1389
+ warnings: result.warnings,
1390
+ unmapped: result.unmapped,
1391
+ }, null, 2) + "\n");
1392
+ }
1393
+
1394
+ if (opts.why && !opts.json) {
1395
+ err.write(buildWhyBlock({
1396
+ inputDescriptor: describeInput(input),
1397
+ schemaDescriptor: schema ? `${schema.sourcePath || "(parsed)"} (${schema.form} form)` : "(none, inferred)",
1398
+ tier: result.tier,
1399
+ placeholders: result.placeholders,
1400
+ sources,
1401
+ missing,
1402
+ unmapped: result.unmapped,
1403
+ warnings: result.warnings,
1404
+ outputPath: opts.output,
1405
+ }) + "\n");
1406
+ }
1407
+ for (const w of result.warnings) err.write(paint(`warning: ${w}\n`, "yellow", err));
1408
+ return EXIT.OK;
1409
+ }
1410
+
1411
+ function describeInput(input) {
1412
+ if (input.path) return input.path;
1413
+ if (input.kind === "text") return "stdin";
1414
+ return "<docx>";
1415
+ }
1416
+
1417
+ function printMissing(missing, err) {
1418
+ err.write(paint("error: missing required parameter(s):\n", "red", err));
1419
+ for (const m of missing) {
1420
+ const flag = `--${m.key.replace(/_/g, "-")}`;
1421
+ err.write(` - ${m.key} (matched: ${m.aliases.map(a => `[${a}]`).join(", ")})\n supply ${flag} or set "${m.key}" in --params\n`);
1422
+ }
1423
+ }
1424
+
1425
+ async function confirmTty(prompt) {
1426
+ return await new Promise((res) => {
1427
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1428
+ rl.question(prompt, (a) => {
1429
+ rl.close();
1430
+ const v = a.trim().toLowerCase();
1431
+ res(v === "y" || v === "yes");
1432
+ });
1433
+ });
1434
+ }
1435
+
1436
+ // ─── COMPLETION ─────────────────────────────────────────────────────────────
1437
+ // Hand-rolled bash/zsh completion. No third-party generator. Install with:
1438
+ // draft --completion bash >> ~/.bashrc # bash
1439
+ // draft --completion zsh > ~/.zsh/_draft # zsh (then add to fpath)
1440
+
1441
+ const BOOLEAN_FLAGS_FOR_COMPLETION = [
1442
+ "--help", "--version", "--demo", "--check-llm",
1443
+ "--validate", "--list-placeholders", "--diff",
1444
+ "--why", "--json", "--silent", "--interactive",
1445
+ "--no-heuristic", "--yes-heuristic",
1446
+ "--no-llm", "--llm",
1447
+ ];
1448
+
1449
+ const VALUE_FLAGS_FOR_COMPLETION = [
1450
+ "--params", "--output", "--syntax", "--dictionary", "--completion",
1451
+ ];
1452
+
1453
+ /**
1454
+ * Emit a shell completion script for the given shell.
1455
+ *
1456
+ * @param {"bash"|"zsh"} shell
1457
+ * @returns {string} the completion script body.
1458
+ * @throws {Error} with `.exitCode = EXIT.IO` for unsupported shells.
1459
+ */
1460
+ export function completionScript(shell) {
1461
+ if (shell === "bash") return bashCompletion();
1462
+ if (shell === "zsh") return zshCompletion();
1463
+ const e = new Error(`unsupported shell for completion: ${shell}`);
1464
+ e.exitCode = EXIT.IO;
1465
+ throw e;
1466
+ }
1467
+
1468
+ function bashCompletion() {
1469
+ const allFlags = [...BOOLEAN_FLAGS_FOR_COMPLETION, ...VALUE_FLAGS_FOR_COMPLETION].join(" ");
1470
+ return `# bash completion for draft-cli — install with:
1471
+ # draft --completion bash >> ~/.bashrc
1472
+ # or, for a single session:
1473
+ # eval "$(draft --completion bash)"
1474
+
1475
+ _draft_completion() {
1476
+ local cur prev
1477
+ cur="\${COMP_WORDS[COMP_CWORD]}"
1478
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
1479
+ case "$prev" in
1480
+ --syntax)
1481
+ COMPREPLY=( $(compgen -W "bracket mustache" -- "$cur") )
1482
+ return 0
1483
+ ;;
1484
+ --completion)
1485
+ COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") )
1486
+ return 0
1487
+ ;;
1488
+ --params|--dictionary)
1489
+ COMPREPLY=( $(compgen -f -X '!*.json' -- "$cur") $(compgen -d -- "$cur") )
1490
+ return 0
1491
+ ;;
1492
+ --output|-o)
1493
+ COMPREPLY=( $(compgen -f -- "$cur") )
1494
+ return 0
1495
+ ;;
1496
+ esac
1497
+ if [[ "$cur" == --* ]]; then
1498
+ COMPREPLY=( $(compgen -W "${allFlags}" -- "$cur") )
1499
+ elif [[ "$cur" == -* ]]; then
1500
+ COMPREPLY=( $(compgen -W "-h -V -i -o -q ${allFlags}" -- "$cur") )
1501
+ else
1502
+ COMPREPLY=( $(compgen -f -- "$cur") )
1503
+ fi
1504
+ return 0
1505
+ }
1506
+ complete -F _draft_completion draft
1507
+ `;
1508
+ }
1509
+
1510
+ function zshCompletion() {
1511
+ return `#compdef draft
1512
+ # zsh completion for draft-cli — install with:
1513
+ # draft --completion zsh > ~/.zsh/completions/_draft
1514
+ # and ensure ~/.zsh/completions is in fpath:
1515
+ # fpath=(~/.zsh/completions $fpath)
1516
+ # autoload -U compinit && compinit
1517
+
1518
+ _draft() {
1519
+ local -a flags
1520
+ flags=(
1521
+ '--help[show help]'
1522
+ '-h[show help]'
1523
+ '--version[show version]'
1524
+ '-V[show version]'
1525
+ '--demo[bundled demo, no file needed]'
1526
+ '--validate[completeness check, never writes output]'
1527
+ '--list-placeholders[enumerate placeholders and exit]'
1528
+ '--why[structured explanation to stderr]'
1529
+ '--json[machine-readable output on stdout]'
1530
+ '--silent[suppress all stderr output]'
1531
+ '-q[suppress all stderr output]'
1532
+ '--interactive[prompt for missing required params]'
1533
+ '-i[prompt for missing required params]'
1534
+ '--no-heuristic[disable tier 4]'
1535
+ '--yes-heuristic[substitute tier-4 matches without confirmation]'
1536
+ '--no-llm[disable tier 5 even when env is configured]'
1537
+ '--llm[assert env-configured LLM, fail-fast if missing]'
1538
+ '--check-llm[one-token roundtrip to verify provider config]'
1539
+ '--diff[show substitution table without writing output]'
1540
+ '--params[JSON params file]:params file:_files -g "*.json"'
1541
+ '--output[output path]:output:_files'
1542
+ '-o[output path]:output:_files'
1543
+ '--syntax[placeholder convention]:syntax:(bracket mustache)'
1544
+ '--dictionary[heuristic dictionary override]:dict:_files -g "*.json"'
1545
+ '--completion[emit shell completion script]:shell:(bash zsh)'
1546
+ '*:template:_files'
1547
+ )
1548
+ _arguments -s -S $flags
1549
+ }
1550
+
1551
+ _draft "$@"
1552
+ `;
1553
+ }
1554
+
1555
+ // ─── DOCTOR: --check-llm ────────────────────────────────────────────────────
1556
+ /**
1557
+ * One-token roundtrip to the configured LLM provider. Confirms env, auth,
1558
+ * and provider reachability without sending any template content. Useful in
1559
+ * CI / startup health checks for agent-driven pipelines.
1560
+ *
1561
+ * Returns EXIT.OK on success, EXIT.LLM on provider error, EXIT.IO if no
1562
+ * provider is configured.
1563
+ *
1564
+ * @param {Object<string, string>} envObj
1565
+ * @param {NodeJS.WritableStream} out
1566
+ * @param {NodeJS.WritableStream} err
1567
+ * @param {{ fetcher?: typeof fetch }} [io]
1568
+ * @returns {Promise<number>}
1569
+ */
1570
+ export async function runCheckLlm(envObj, out, err, { fetcher } = {}) {
1571
+ const provider = llmProviderFromEnv(envObj);
1572
+ if (!provider) {
1573
+ err.write(paint("error: no LLM provider configured in .env or process env.\n", "red", err));
1574
+ err.write(`hint: set ANTHROPIC_API_KEY, OPENAI_API_KEY, or the DRAFT_LLM_* triple.\n`);
1575
+ return EXIT.IO;
1576
+ }
1577
+ err.write(paint(`checking ${provider.provider} (${provider.model || "default model"})…\n`, "cyan", err));
1578
+ try {
1579
+ const hits = await detectLlm("ping", provider, { fetcher });
1580
+ // Regardless of what detectLlm returns, getting through the parse step
1581
+ // without throwing means: auth ok, transport ok, response parseable.
1582
+ out.write(`ok: ${provider.provider} reachable, ${provider.model || "default model"}\n`);
1583
+ return EXIT.OK;
1584
+ } catch (e) {
1585
+ err.write(paint(`error: ${e.message}\n`, "red", err));
1586
+ return e.exitCode || EXIT.LLM;
1587
+ }
1588
+ }
1589
+
1590
+ // ─── DIFF MODE ──────────────────────────────────────────────────────────────
1591
+ /**
1592
+ * Build a per-placeholder substitution table for `--diff` mode. One line per
1593
+ * placeholder showing what would change, plus a summary footer.
1594
+ *
1595
+ * @param {Placeholder[]} placeholders
1596
+ * @param {Object<string, string>} resolved
1597
+ * @param {{stream?: {isTTY?: boolean}}} [io]
1598
+ * @returns {string}
1599
+ */
1600
+ export function buildDiffBlock(placeholders, resolved, { stream } = {}) {
1601
+ if (placeholders.length === 0) return "no changes (no placeholders detected).\n";
1602
+ const maxFrom = Math.max(...placeholders.map((p) => `[${p.first_seen_as}]`.length));
1603
+ const lines = [];
1604
+ let totalSubstitutions = 0;
1605
+ let unresolvedCount = 0;
1606
+ for (const p of placeholders) {
1607
+ const from = `[${p.first_seen_as}]`.padEnd(maxFrom);
1608
+ const to = resolved[p.key];
1609
+ if (to === undefined) {
1610
+ lines.push(` ${paint(from, "red", stream)} → ${paint("(unresolved)", "red", stream)}` +
1611
+ (p.occurrences > 1 ? ` ×${p.occurrences}` : ""));
1612
+ unresolvedCount += 1;
1613
+ } else {
1614
+ lines.push(` ${paint(from, "yellow", stream)} → ${paint(to, "green", stream)}` +
1615
+ (p.occurrences > 1 ? ` ×${p.occurrences}` : ""));
1616
+ totalSubstitutions += p.occurrences;
1617
+ }
1618
+ }
1619
+ lines.unshift("changes that would be made:");
1620
+ lines.push("");
1621
+ lines.push(`${placeholders.length} placeholder(s), ${totalSubstitutions} substitution(s), ${unresolvedCount} unresolved.`);
1622
+ return lines.join("\n") + "\n";
1623
+ }
1624
+
1625
+ // ─── DEMO (bundled fixture for the 30-second first run) ────────────────────
1626
+ export const DEMO_TEMPLATE = `# Mutual Non-Disclosure Agreement (demo)
1627
+
1628
+ This Agreement is entered into on [Effective Date] between [Party A]
1629
+ and [Party B] (collectively, the "Parties").
1630
+
1631
+ 1. Confidentiality. [Party A] and [Party B] agree to keep confidential
1632
+ any information disclosed under this Agreement.
1633
+
1634
+ 2. Term. This Agreement remains in effect for two years from the
1635
+ [Effective Date].
1636
+ `;
1637
+
1638
+ export const DEMO_VALUES = {
1639
+ party_a: "Acme Corporation",
1640
+ party_b: "Vendor Inc.",
1641
+ effective_date: "2026-06-01",
1642
+ };
1643
+
1644
+ export function runDemo(out, err) {
1645
+ const hits = detectBracket(DEMO_TEMPLATE);
1646
+ const byKey = new Map();
1647
+ for (const h of hits) {
1648
+ const key = canonicalKey(h.inner);
1649
+ if (!byKey.has(key)) byKey.set(key, { key, hits: [] });
1650
+ byKey.get(key).hits.push(h);
1651
+ }
1652
+ const placeholders = [...byKey.values()];
1653
+ const output = substitute(DEMO_TEMPLATE, placeholders, DEMO_VALUES, "bracket");
1654
+ err.write(paint("demo: substituting [Party A], [Party B], [Effective Date]\n", "cyan", err));
1655
+ out.write(output);
1656
+ err.write(paint("\nthis is what a real run looks like. try:\n", "dim", err));
1657
+ err.write(` draft your-template.md --party-a "Acme" --party-b "Vendor" --effective-date 2026-06-01\n`);
1658
+ return EXIT.OK;
1659
+ }
1660
+
1661
+ // ─── MAIN ───────────────────────────────────────────────────────────────────
1662
+ // A stderr-like sink that drops everything. Used when --silent is set so
1663
+ // downstream pipelines see zero stderr noise. Note: parse-time errors still
1664
+ // go to the real stderr (--silent isn't honored until args are parsed).
1665
+ const SILENT_STREAM = { write() {}, isTTY: false };
1666
+
1667
+ /**
1668
+ * The CLI entry point. Parses argv, resolves the input, runs the selected
1669
+ * mode (draft / list-placeholders / validate / demo / completion), and
1670
+ * returns the exit code.
1671
+ *
1672
+ * @param {string[]} argv — typically `process.argv.slice(2)`.
1673
+ * @param {{
1674
+ * out?: NodeJS.WritableStream,
1675
+ * err?: NodeJS.WritableStream,
1676
+ * cwd?: string,
1677
+ * env?: Object<string, string>,
1678
+ * fetcher?: typeof fetch,
1679
+ * spawner?: typeof spawnSync,
1680
+ * stdinReader?: () => Promise<string>,
1681
+ * }} [io] — injection seam for tests.
1682
+ * @returns {Promise<number>} one of {@link EXIT}'s values.
1683
+ */
1684
+ export async function main(argv, io = {}) {
1685
+ const out = io.out || process.stdout;
1686
+ const realErr = io.err || process.stderr;
1687
+ const cwd = io.cwd || process.cwd();
1688
+ const fetcher = io.fetcher;
1689
+ const spawner = io.spawner || spawnSync;
1690
+ const stdinReader = io.stdinReader || readStdin;
1691
+ const processEnv = io.env || process.env;
1692
+
1693
+ let opts;
1694
+ try { opts = parseArgs(argv); }
1695
+ catch (e) {
1696
+ realErr.write(paint(`error: ${e.message}\n`, "red", realErr));
1697
+ realErr.write(`run \`draft --help\` for usage.\n`);
1698
+ return EXIT.IO;
1699
+ }
1700
+
1701
+ const err = opts.silent ? SILENT_STREAM : realErr;
1702
+
1703
+ if (opts.help) { out.write(HELP_TEXT); return EXIT.OK; }
1704
+ if (opts.version) { out.write(`draft-cli ${VERSION}\n`); return EXIT.OK; }
1705
+ if (opts.completion) { out.write(completionScript(opts.completion)); return EXIT.OK; }
1706
+ if (opts.demo) { return runDemo(out, err); }
1707
+ if (opts.checkLlm) {
1708
+ const envObj = effectiveEnv(cwd, processEnv);
1709
+ return await runCheckLlm(envObj, out, err, { fetcher });
1710
+ }
1711
+
1712
+ if (opts.positional.length === 0) {
1713
+ err.write(paint(`error: no template given\n`, "red", err));
1714
+ err.write(`run \`draft --help\` for usage.\n`);
1715
+ return EXIT.IO;
1716
+ }
1717
+ if (opts.positional.length > 1) {
1718
+ err.write(paint(`error: expected one template (got ${opts.positional.length})\n`, "red", err));
1719
+ return EXIT.IO;
1720
+ }
1721
+
1722
+ let input, schema, paramsObj, envObj;
1723
+ try {
1724
+ input = await resolveInput(opts.positional[0], { spawner, stdinReader });
1725
+ schema = loadSchema(input.path);
1726
+ paramsObj = loadParamsFile(opts.params);
1727
+ envObj = effectiveEnv(cwd, processEnv);
1728
+ } catch (e) {
1729
+ err.write(paint(`error: ${e.message}\n`, "red", err));
1730
+ return e.exitCode || EXIT.IO;
1731
+ }
1732
+
1733
+ try {
1734
+ if (opts.listPlaceholders) {
1735
+ return await cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err });
1736
+ }
1737
+ if (opts.validate) {
1738
+ return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
1739
+ }
1740
+ return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
1741
+ } catch (e) {
1742
+ err.write(paint(`error: ${e.message}\n`, "red", err));
1743
+ return e.exitCode || EXIT.IO;
1744
+ }
1745
+ }
1746
+
1747
+ // Entry point: only run when invoked directly (not when imported by tests).
1748
+ const isMain = (() => {
1749
+ try { return process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); }
1750
+ catch { return false; }
1751
+ })();
1752
+ if (isMain) {
1753
+ main(process.argv.slice(2)).then((c) => process.exit(c)).catch((e) => {
1754
+ process.stderr.write(`fatal: ${e && e.stack || e}\n`);
1755
+ process.exit(1);
1756
+ });
1757
+ }