@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/AGENTS.md +224 -0
- package/ARCHITECTURE.md +206 -0
- package/CHANGELOG.md +108 -0
- package/FAQ.md +190 -0
- package/GETTING_STARTED.md +263 -0
- package/LICENSE +21 -0
- package/PARAM_SCHEMA.md +341 -0
- package/README.md +305 -0
- package/SECURITY.md +76 -0
- package/draft-cli.mjs +1757 -0
- package/package.json +58 -0
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
524
|
+
.replace(/"/g, '"').replace(/'/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
|
+
}
|