@drbaher/draft-cli 0.1.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,112 @@ All notable changes to this project will be documented in this file. The
4
4
  format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
5
5
  and the project adheres to semantic versioning once it leaves 0.x.
6
6
 
7
+ ## 0.3.2 — 2026-05-16
8
+
9
+ ### Fixed
10
+
11
+ - **Publish auth: restored `NODE_AUTH_TOKEN` env block.** v0.3.1's
12
+ hotfix bumped `publish.yml` Node from 20 to 22 on the hypothesis
13
+ that npm CLI 11.5.1+ would auto-detect OIDC and ignore the
14
+ setup-node placeholder. It didn't: Node 22's npm 11.x still sent
15
+ the literal `XXXXX-XXXXX-XXXXX-XXXXX` placeholder and got the
16
+ same 404 from npm. Root cause is *not* Node version; either
17
+ `setup-node@v6` always writes the placeholder env into the
18
+ publish step, or npm CLI prefers the `.npmrc` token over OIDC
19
+ even when both are available. Under investigation.
20
+ - **v0.3.1 tag exists on GitHub but did NOT publish to npm.** Skip
21
+ it. Registry latest is `0.2.0` until v0.3.2 ships.
22
+
23
+ Pragmatic call: v0.3.2 ships via the bootstrap `NPM_TOKEN` path.
24
+ Trusted Publisher stays configured on npm so the switch back to
25
+ pure OIDC is a one-line change in `publish.yml` once we understand
26
+ why npm CLI isn't using OIDC. `feedback_oidc_setup_node_v6_placeholder.md`
27
+ in memory tracks the symptom + workarounds tried.
28
+
29
+ ## 0.3.1 — 2026-05-16
30
+
31
+ ### Fixed
32
+
33
+ - **`publish.yml` now uses Node 22 instead of Node 20.** npm
34
+ Trusted Publishing requires npm CLI 11.5.1 or later, which ships
35
+ with Node 22.14+. Node 20 (npm 10.x) silently falls back to
36
+ `NODE_AUTH_TOKEN` when configured for a registry, and to
37
+ `setup-node@v6`'s placeholder value (`XXXXX-...`) when the env
38
+ isn't set — producing a 404 from npm masking the actual 401.
39
+ - **v0.3.0 tag exists on GitHub but did NOT publish to npm.** v0.3.0
40
+ was the first publish attempt without an `NPM_TOKEN` fallback
41
+ (PR #10 reverted the bootstrap); the npm-CLI-too-old issue
42
+ surfaced immediately. No package was uploaded. v0.3.1 is the
43
+ rebrand of v0.3.0's typed-parameter feature with the workflow
44
+ fix applied. Skip v0.3.0.
45
+
46
+ ## 0.3.0 — 2026-05-16
47
+
48
+ ### Added
49
+
50
+ - **Typed parameters (`type: date | money | party`).** Long-form
51
+ schema entries can declare a `type`, with optional `format` (date)
52
+ or `currency` (money). Inputs are validated and normalized between
53
+ value resolution and substitution; bad inputs hard-error with a
54
+ per-key message (exit 4). See `PARAM_SCHEMA.md` §5 for the accepted
55
+ shapes per type, the rejected ambiguous forms (Q3.1: US
56
+ `MM/DD/YYYY` and European `DD/MM/YYYY` are rejected as ambiguous),
57
+ and the v2 currency scope (Q3.2: USD only).
58
+ - **`--validate` now catches type errors** before draft runs. With
59
+ `--json`, errors are emitted as a `type_errors` array on the
60
+ result payload.
61
+ - **New public API:** `parseDateValue(raw)`, `formatDateValue(date, fmt)`,
62
+ `parseMoneyValue(raw)` → minor units, `formatMoneyValue(minor, currency)`,
63
+ `normalizeTypedValue(raw, placeholder)`, `normalizeTypedValues(placeholders, resolved)`.
64
+
65
+ ### Schema-contract change
66
+
67
+ `PARAM_SCHEMA.md` §5 gains a "Typed parameters" section. Long-form
68
+ entries can now include `type`, `format`, and `currency` fields; short
69
+ form is unchanged. v0.3.0 schemas are forward-compatible with v0.2.x
70
+ readers (which will silently ignore the new fields, since they're
71
+ opt-in metadata on the long-form entry).
72
+
73
+ ## 0.2.0 — 2026-05-16
74
+
75
+ ### Added
76
+
77
+ - **`.docx` output round-trip.** Templates read from `.docx` (tier 3
78
+ highlight detection) now write back as `.docx`, preserving runs,
79
+ styles, paragraph breaks, and every non-document part of the package
80
+ (`[Content_Types].xml`, relationships, images, headers, etc.).
81
+ Default output filename is `<basename>-filled.docx` next to the
82
+ input; override with `--output PATH.docx`. Schema-rescue, T1/T2
83
+ bracket/mustache detection, and T4/T5 substitution all benefit
84
+ too — any tier that detects a placeholder in a `.docx` template
85
+ now substitutes back into the same runs.
86
+ - **`--output -` writes plain text to stdout** (Unix `-` convention).
87
+ Use this on a `.docx` input to get the substituted body as text
88
+ instead of a `.docx` file: `draft contract.docx --output -`.
89
+ - **`writeDocxBuffer(originalPath, newDocumentXml)`**, **`makeDocxOutputPath(inputPath)`**,
90
+ **`substituteDocxXml(xml, placeholders, values, tier)`**, **`decideDocxOutput(opts, input)`**,
91
+ and **`encodeXml(s)`** added to the public API for programmatic
92
+ drivers. Same import surface as `substitute` and `extractDocxText`.
93
+
94
+ ### Changed
95
+
96
+ - **Default output for `.docx` input is now `<basename>-filled.docx`,
97
+ not stdout text.** Previously, `draft contract.docx` (no
98
+ `--output`) extracted text and wrote substituted plain text to
99
+ stdout. v0.2.0 writes `contract-filled.docx` next to the input.
100
+ Pipelines that depended on the stdout-text behavior should pass
101
+ `--output -` to opt back in.
102
+
103
+ ### Split-run handling
104
+
105
+ When a placeholder's text spans multiple `<w:t>` runs in the source
106
+ `.docx` (Word sometimes splits runs at punctuation, auto-correct
107
+ boundaries, or comment anchors), v0.2.0 emits a warning and skips
108
+ that substitution rather than merging the runs and losing run-level
109
+ styling. The warning explains how to fix the source: open the
110
+ document, retype the placeholder so it lives in one run, save, and
111
+ retry. This decision is logged in `PARAM_SCHEMA.md` §2.
112
+
7
113
  ## 0.1.1 — 2026-05-16
8
114
 
9
115
  ### Fixed
package/PARAM_SCHEMA.md CHANGED
@@ -22,7 +22,20 @@ draft <cat>/<name>[@ver] ... # pulls via `template-vault get`
22
22
  ```
23
23
 
24
24
  - **Input forms accepted:** `path/to/file.md`, `path/to/file.txt`, `path/to/file.docx`, stdin (`-`), or a `template-vault` ref shaped `<category>/<name>[@version]`. Vault refs shell out to `template-vault get` — no library import.
25
- - **Output:** stdout by default, `--output PATH` for files. Output is always plain text/markdown in v1 — `.docx` is **input-only** for v1. Writing `.docx` back is deferred to v2.
25
+ - **Output:** depends on input kind and `--output` target.
26
+
27
+ | Input | `--output` | Output |
28
+ | ------------ | ------------------- | ------------------------------------- |
29
+ | text (any) | absent | plain text on stdout |
30
+ | text (any) | `-` | plain text on stdout |
31
+ | text (any) | `PATH` (any ext) | plain text written to `PATH` |
32
+ | `.docx` | absent | `.docx` to `<basename>-filled.docx` |
33
+ | `.docx` | `PATH.docx` | `.docx` to `PATH.docx` |
34
+ | `.docx` | `-` | plain text (substituted body) on stdout |
35
+ | `.docx` | `PATH` (non-`.docx`)| plain text written to `PATH` |
36
+
37
+ `.docx` output is a round-trip: the original `.docx` package is reopened, the substituted text is written back into the same `<w:t>` runs that detection found, and all other parts of the package (relationships, images, headers, `[Content_Types].xml`, etc.) pass through unchanged. Run-level styling is preserved. If a placeholder's text spans multiple `<w:t>` runs in the source (Word sometimes splits runs at punctuation or auto-correct boundaries), that placeholder is **skipped**, not substituted, and a warning is emitted explaining how to fix the source — locked decision Q1.1.
38
+ - `--json`, `--diff`, `--validate`, and `--list-placeholders` all override the `.docx` round-trip and produce text/JSON to stdout (or to `--output PATH`, when provided).
26
39
  - **Encoding:** UTF-8 in, UTF-8 out. No BOM written; BOM tolerated on read.
27
40
 
28
41
  ## 3. Detection cascade (sequential-with-stop)
@@ -224,6 +237,39 @@ its own alias list (Q3 locked) — list it explicitly if needed.
224
237
  Parser selects long form iff a top-level `_meta` key is present. Short
225
238
  and long are not mixable within one file.
226
239
 
240
+ ### Typed parameters (v0.3.0, opt-in)
241
+
242
+ Long-form entries can declare `type`, with optional `format` (`date`)
243
+ or `currency` (`money`). Inputs are validated and normalized between
244
+ value resolution and substitution. Bad input → exit 4
245
+ (`EXIT.VALIDATION`) with a per-key error message; all type errors are
246
+ collected before exit so the user sees every issue at once.
247
+
248
+ ```json
249
+ {
250
+ "_meta": { "schema_version": 1 },
251
+ "effective_date": { "aliases": ["Effective Date"], "type": "date", "format": "MMMM d, yyyy" },
252
+ "purchase_amount": { "aliases": ["Purchase Amount"], "type": "money", "currency": "USD" },
253
+ "party_a": { "aliases": ["Party A"], "type": "party" }
254
+ }
255
+ ```
256
+
257
+ | `type` | Accepts | Normalizes to | Notes |
258
+ | -------- | -------------------------------------------------------------------- | -------------------------------------- | ----- |
259
+ | `date` | ISO (`2027-01-15`) or spelled (`January 15, 2027`, `Jan 15 2027`) | `format` field (default `MMMM d, yyyy`) | Q3.1 locked: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric forms are **rejected** as ambiguous. |
260
+ | `money` | `$5,000`, `5000.50`, `$5M`, `2.5K`, `1B`; rejects `$$5`, `5,00`, etc. | `currency`-formatted (e.g. `$5,000,000.00`) | Q3.2 locked: v0.3.0 supports `currency: "USD"` only. |
261
+ | `party` | Non-empty after trim; no markdown links `[text](url)`; no trailing punctuation `.,;:!?` | Trimmed string | Q3.3 locked: hard error on bad input — typed params are opt-in. |
262
+
263
+ `format` for `date` supports `yyyy`, `MMMM`, `MM`, `d` tokens (matched
264
+ in a single pass — `MM` doesn't accidentally consume `MMMM`, `d`
265
+ doesn't leak into month names). Other literal characters pass through
266
+ unchanged. Other tokens (e.g. `HH:mm`, `dd`, `EEEE`) are deferred to
267
+ future versions.
268
+
269
+ Programmatic API for drivers: `parseDateValue`, `formatDateValue`,
270
+ `parseMoneyValue`, `formatMoneyValue`, `normalizeTypedValue`,
271
+ `normalizeTypedValues`.
272
+
227
273
  ### Orphan handling (Q4 locked)
228
274
 
229
275
  Schema declares a key whose alias list matches no detected phrase →
package/draft-cli.mjs CHANGED
@@ -70,7 +70,7 @@ import { fileURLToPath } from "node:url";
70
70
  */
71
71
 
72
72
  /** @type {string} */
73
- export const VERSION = "0.1.1";
73
+ export const VERSION = "0.3.2";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -489,6 +489,43 @@ export async function extractDocxText(path) {
489
489
  return { body: docxXmlToText(xml), xml };
490
490
  }
491
491
 
492
+ /**
493
+ * Re-read the original `.docx`, swap in a new `word/document.xml`, and
494
+ * return the resulting `.docx` as a `Buffer`. All other parts of the
495
+ * package (`[Content_Types].xml`, relationships, images, headers, etc.)
496
+ * pass through unchanged.
497
+ *
498
+ * @param {string} originalPath — filesystem path to the source `.docx`.
499
+ * @param {string} newDocumentXml — replacement content for `word/document.xml`.
500
+ * @returns {Promise<Buffer>}
501
+ * @throws {Error} with `.exitCode = EXIT.IO` on missing jszip or invalid source.
502
+ */
503
+ export async function writeDocxBuffer(originalPath, newDocumentXml) {
504
+ const JSZip = await loadJSZip();
505
+ let zip;
506
+ try { zip = await JSZip.loadAsync(readFileSync(originalPath)); }
507
+ catch (err) {
508
+ const e = new Error(`could not re-open source .docx (${err.message})`);
509
+ e.exitCode = EXIT.IO;
510
+ throw e;
511
+ }
512
+ zip.file("word/document.xml", newDocumentXml);
513
+ return await zip.generateAsync({ type: "nodebuffer" });
514
+ }
515
+
516
+ /**
517
+ * Derive the default `.docx` output filename from an input path. Appends
518
+ * `-filled` before the extension: `contract.docx` → `contract-filled.docx`.
519
+ * If the input has no extension, appends `-filled.docx`.
520
+ * @param {string} inputPath
521
+ * @returns {string}
522
+ */
523
+ export function makeDocxOutputPath(inputPath) {
524
+ const ext = extname(inputPath);
525
+ if (!ext) return `${inputPath}-filled.docx`;
526
+ return `${inputPath.slice(0, -ext.length)}-filled${ext}`;
527
+ }
528
+
492
529
  // Walk the XML in document order. For each <w:p> emit a line; concatenate
493
530
  // <w:t> contents within. Decode XML entities. Used for both output body and
494
531
  // T1/T2 detection on docx input.
@@ -524,6 +561,18 @@ export function decodeXml(s) {
524
561
  .replace(/&quot;/g, '"').replace(/&apos;/g, "'");
525
562
  }
526
563
 
564
+ /**
565
+ * Inverse of {@link decodeXml}. Used when writing substituted text back into
566
+ * a Word document's `<w:t>` runs. Only encodes the three structural
567
+ * characters; double- and single-quotes don't need encoding inside element
568
+ * text content.
569
+ * @param {string} s
570
+ * @returns {string}
571
+ */
572
+ export function encodeXml(s) {
573
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
574
+ }
575
+
527
576
  const RECOGNIZED_HIGHLIGHTS = new Set(["yellow", "green", "cyan", "magenta"]);
528
577
 
529
578
  // Scan the XML for highlighted runs. Returns an array of { text, color }.
@@ -869,6 +918,12 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
869
918
  aliases: v.aliases.slice(),
870
919
  required: v.required !== false,
871
920
  default: Object.prototype.hasOwnProperty.call(v, "default") ? v.default : null,
921
+ // v2 #3: typed parameters. `type` is one of `date|money|party` (or
922
+ // absent → no validation/normalization). `format` (date) and
923
+ // `currency` (money) are optional.
924
+ type: typeof v.type === "string" ? v.type : null,
925
+ format: typeof v.format === "string" ? v.format : null,
926
+ currency: typeof v.currency === "string" ? v.currency : null,
872
927
  };
873
928
  } else {
874
929
  if (!Array.isArray(v)) {
@@ -876,7 +931,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
876
931
  e.exitCode = EXIT.IO;
877
932
  throw e;
878
933
  }
879
- entries[k] = { aliases: v.slice(), required: true, default: null };
934
+ entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null };
880
935
  }
881
936
  }
882
937
  return { form: long ? "long" : "short", entries };
@@ -1017,6 +1072,9 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1017
1072
  required: resolved.required,
1018
1073
  default: resolved.default,
1019
1074
  aliases: resolved.aliases,
1075
+ type: resolved.type,
1076
+ format: resolved.format,
1077
+ currency: resolved.currency,
1020
1078
  hits: [],
1021
1079
  });
1022
1080
  }
@@ -1031,14 +1089,22 @@ function resolveKey(hit, schema, fromLlm) {
1031
1089
  if (schema) {
1032
1090
  for (const [key, entry] of Object.entries(schema.entries)) {
1033
1091
  if (entry.aliases.includes(hit.inner)) {
1034
- return { key, required: entry.required, default: entry.default, aliases: entry.aliases };
1092
+ return {
1093
+ key,
1094
+ required: entry.required,
1095
+ default: entry.default,
1096
+ aliases: entry.aliases,
1097
+ type: entry.type || null,
1098
+ format: entry.format || null,
1099
+ currency: entry.currency || null,
1100
+ };
1035
1101
  }
1036
1102
  }
1037
1103
  return null;
1038
1104
  }
1039
1105
  const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1040
1106
  if (!validKey(key)) return null;
1041
- return { key, required: true, default: null, aliases: [hit.inner] };
1107
+ return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null };
1042
1108
  }
1043
1109
 
1044
1110
  // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
@@ -1116,6 +1182,218 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
1116
1182
  return { resolved, missing, sources };
1117
1183
  }
1118
1184
 
1185
+ // ─── TYPED-PARAMETER NORMALIZATION (v2 #3) ──────────────────────────────────
1186
+ // Schema entries can declare `type: date | money | party` with optional
1187
+ // `format` (date) or `currency` (money). Inputs are validated and normalized
1188
+ // after value resolution and before substitution. Hard error (exit 4) on
1189
+ // invalid input — typed params are opt-in; the user asked for validation.
1190
+
1191
+ const MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
1192
+ "July", "August", "September", "October", "November", "December"];
1193
+ const MONTH_INDEX = (() => {
1194
+ const m = {};
1195
+ MONTH_NAMES.forEach((name, i) => {
1196
+ m[name.toLowerCase()] = i;
1197
+ m[name.slice(0, 3).toLowerCase()] = i;
1198
+ });
1199
+ // "Sept" is a common 4-letter abbrev.
1200
+ m.sept = 8;
1201
+ return m;
1202
+ })();
1203
+
1204
+ /**
1205
+ * Parse a date input. Accepts ISO `YYYY-MM-DD` or spelled
1206
+ * `Month D, YYYY` / `Mon D YYYY`. Returns a UTC `Date` on success or `null`
1207
+ * on failure. Q3.1: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric
1208
+ * formats are NOT accepted — they're ambiguous and footgun-y. Use ISO for
1209
+ * machine input, spelled for human input.
1210
+ *
1211
+ * @param {string} raw
1212
+ * @returns {Date | null}
1213
+ */
1214
+ export function parseDateValue(raw) {
1215
+ const s = String(raw).trim();
1216
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
1217
+ if (iso) {
1218
+ const [, y, m, d] = iso;
1219
+ const date = new Date(Date.UTC(+y, +m - 1, +d));
1220
+ // Reject impossible dates (e.g. 2026-02-31 round-trips to 2026-03-03).
1221
+ if (date.getUTCFullYear() !== +y || date.getUTCMonth() !== +m - 1 || date.getUTCDate() !== +d) return null;
1222
+ return date;
1223
+ }
1224
+ const spelled = /^([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})$/.exec(s);
1225
+ if (spelled) {
1226
+ const month = MONTH_INDEX[spelled[1].toLowerCase()];
1227
+ if (month === undefined) return null;
1228
+ const date = new Date(Date.UTC(+spelled[3], month, +spelled[2]));
1229
+ if (date.getUTCMonth() !== month || date.getUTCDate() !== +spelled[2]) return null;
1230
+ return date;
1231
+ }
1232
+ return null;
1233
+ }
1234
+
1235
+ /**
1236
+ * Format a `Date` per a simple format string. Supported tokens:
1237
+ * `yyyy` (year), `MMMM` (full month name), `MM` (2-digit month), `d` (day).
1238
+ * Order doesn't matter; tokens are matched in a single pass so MMMM doesn't
1239
+ * accidentally consume MM, and `d` doesn't leak into month names.
1240
+ *
1241
+ * @param {Date} date
1242
+ * @param {string} format
1243
+ * @returns {string}
1244
+ */
1245
+ export function formatDateValue(date, format) {
1246
+ const y = date.getUTCFullYear();
1247
+ const m = date.getUTCMonth();
1248
+ const d = date.getUTCDate();
1249
+ return format.replace(/yyyy|MMMM|MM|d/g, (token) => {
1250
+ if (token === "yyyy") return String(y);
1251
+ if (token === "MMMM") return MONTH_NAMES[m];
1252
+ if (token === "MM") return String(m + 1).padStart(2, "0");
1253
+ if (token === "d") return String(d);
1254
+ return token;
1255
+ });
1256
+ }
1257
+
1258
+ /**
1259
+ * Parse a money input. Accepts `$5,000`, `5000.50`, `$5M`, `2.5K`, etc.
1260
+ * Handles `K`/`M`/`B` suffixes (case-insensitive). Returns the value in
1261
+ * minor units (cents for USD) as an integer, or `null` on failure.
1262
+ *
1263
+ * @param {string} raw
1264
+ * @returns {number | null}
1265
+ */
1266
+ export function parseMoneyValue(raw) {
1267
+ const s = String(raw).trim();
1268
+ if (!s) return null;
1269
+ // Strict shape: optional minus, optional single $, digits (with optional
1270
+ // thousand-comma groups), optional decimal, optional K/M/B. Rejects
1271
+ // doubled `$`, ad-hoc comma placement, multiple decimals, words.
1272
+ if (!/^-?\$?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?[KMB]?$/i.test(s)) return null;
1273
+ let core = s.replace(/[$,\s]/g, "");
1274
+ let mult = 1;
1275
+ if (/[KMB]$/i.test(core)) {
1276
+ mult = { K: 1e3, M: 1e6, B: 1e9 }[core.slice(-1).toUpperCase()];
1277
+ core = core.slice(0, -1);
1278
+ }
1279
+ const n = parseFloat(core);
1280
+ if (!isFinite(n)) return null;
1281
+ return Math.round(n * mult * 100);
1282
+ }
1283
+
1284
+ /**
1285
+ * Format a money value (in minor units, e.g. cents for USD) per a currency.
1286
+ * Q3.2: v2 supports USD only. Adds thousand separators and always renders
1287
+ * two decimal places.
1288
+ *
1289
+ * @param {number} minor — value in minor units (cents).
1290
+ * @param {string} currency — currency code (only "USD" supported in v2).
1291
+ * @returns {string}
1292
+ * @throws {Error} on unsupported currency.
1293
+ */
1294
+ export function formatMoneyValue(minor, currency) {
1295
+ if (currency !== "USD") {
1296
+ throw new Error(`only USD is supported in v0.3.0; got currency="${currency}"`);
1297
+ }
1298
+ const sign = minor < 0 ? "-" : "";
1299
+ const abs = Math.abs(minor);
1300
+ const dollars = Math.floor(abs / 100);
1301
+ const cents = abs % 100;
1302
+ const intPart = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1303
+ return `${sign}$${intPart}.${String(cents).padStart(2, "0")}`;
1304
+ }
1305
+
1306
+ /**
1307
+ * Normalize a raw value per a placeholder's schema-declared type. Returns
1308
+ * the normalized string. Throws on invalid input (Q3.3 → hard error).
1309
+ * If no `type` is declared on the placeholder, returns the raw value
1310
+ * unchanged.
1311
+ *
1312
+ * @param {string} rawValue
1313
+ * @param {{ type?: string|null, format?: string|null, currency?: string|null, key?: string }} placeholder
1314
+ * @returns {string}
1315
+ * @throws {Error} with `.exitCode = EXIT.VALIDATION` on bad input.
1316
+ */
1317
+ export function normalizeTypedValue(rawValue, placeholder) {
1318
+ const type = placeholder && placeholder.type;
1319
+ if (!type) return rawValue;
1320
+ if (type === "date") {
1321
+ const date = parseDateValue(rawValue);
1322
+ if (!date) {
1323
+ const e = new Error(
1324
+ `could not parse "${rawValue}" as a date. expected ISO ` +
1325
+ `(2027-01-15) or spelled ("January 15, 2027"). ` +
1326
+ `US ("01/15/2027") and European ("15/01/2027") forms are not ` +
1327
+ `accepted — they're ambiguous.`
1328
+ );
1329
+ e.exitCode = EXIT.VALIDATION;
1330
+ throw e;
1331
+ }
1332
+ return formatDateValue(date, placeholder.format || "MMMM d, yyyy");
1333
+ }
1334
+ if (type === "money") {
1335
+ const minor = parseMoneyValue(rawValue);
1336
+ if (minor === null) {
1337
+ const e = new Error(
1338
+ `could not parse "${rawValue}" as money. expected like ` +
1339
+ `"$5,000", "5000.50", "$5M", "2.5K".`
1340
+ );
1341
+ e.exitCode = EXIT.VALIDATION;
1342
+ throw e;
1343
+ }
1344
+ return formatMoneyValue(minor, placeholder.currency || "USD");
1345
+ }
1346
+ if (type === "party") {
1347
+ const s = String(rawValue).trim();
1348
+ if (!s) {
1349
+ const e = new Error(`party value must be non-empty`);
1350
+ e.exitCode = EXIT.VALIDATION;
1351
+ throw e;
1352
+ }
1353
+ if (/\]\(/.test(s)) {
1354
+ const e = new Error(`party value "${rawValue}" contains a markdown link; pass the bare party name instead.`);
1355
+ e.exitCode = EXIT.VALIDATION;
1356
+ throw e;
1357
+ }
1358
+ if (/[.!?,;:]$/.test(s)) {
1359
+ const e = new Error(`party value "${rawValue}" has trailing punctuation; remove it before passing.`);
1360
+ e.exitCode = EXIT.VALIDATION;
1361
+ throw e;
1362
+ }
1363
+ return s;
1364
+ }
1365
+ const e = new Error(`unknown type "${type}" on placeholder${placeholder.key ? ` "${placeholder.key}"` : ""}. expected one of: date, money, party.`);
1366
+ e.exitCode = EXIT.IO;
1367
+ throw e;
1368
+ }
1369
+
1370
+ /**
1371
+ * Run {@link normalizeTypedValue} across every resolved placeholder value.
1372
+ * Mutates `resolved` in place with normalized strings. Collects all errors
1373
+ * before returning so the user sees every type failure at once.
1374
+ *
1375
+ * @param {Placeholder[]} placeholders
1376
+ * @param {Object<string,string>} resolved
1377
+ * @returns {{ ok: boolean, errors: Array<{ key: string, message: string }>, normalized: Object<string,{from: string, to: string, type: string}> }}
1378
+ */
1379
+ export function normalizeTypedValues(placeholders, resolved) {
1380
+ const errors = [];
1381
+ const normalized = {};
1382
+ for (const p of placeholders) {
1383
+ if (!p.type) continue;
1384
+ if (resolved[p.key] === undefined) continue;
1385
+ const raw = resolved[p.key];
1386
+ try {
1387
+ const norm = normalizeTypedValue(raw, p);
1388
+ if (norm !== raw) normalized[p.key] = { from: raw, to: norm, type: p.type };
1389
+ resolved[p.key] = norm;
1390
+ } catch (e) {
1391
+ errors.push({ key: p.key, message: e.message });
1392
+ }
1393
+ }
1394
+ return { ok: errors.length === 0, errors, normalized };
1395
+ }
1396
+
1119
1397
  async function nodePrompter(placeholder) {
1120
1398
  if (!process.stdin.isTTY) return null;
1121
1399
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -1180,6 +1458,83 @@ function replaceAll(s, find, repl) {
1180
1458
  return s.split(find).join(repl);
1181
1459
  }
1182
1460
 
1461
+ /**
1462
+ * Substitute placeholder values *inside the Word XML*, preserving runs
1463
+ * and styling. Returns a new XML string plus warnings for any placeholder
1464
+ * whose text spans multiple `<w:t>` runs in the source — these are
1465
+ * skipped rather than substituted (merging the runs would lose styling
1466
+ * information; leaving them is reversible).
1467
+ *
1468
+ * For T1 (bracket) / T2 (mustache) the search text is the literal match
1469
+ * (e.g. `[Party A]` or `{{party_a}}`). For T3 (docx-highlight), T4
1470
+ * (heuristic), T5 (llm) the search text is the run's inner content with
1471
+ * whole-word boundaries — same semantics as {@link substitute}.
1472
+ *
1473
+ * @param {string} xml — original `word/document.xml`.
1474
+ * @param {Placeholder[]} placeholders
1475
+ * @param {Object<string,string>} values — `{ key: resolvedValue }`.
1476
+ * @param {Tier} tier
1477
+ * @returns {{ xml: string, warnings: string[] }}
1478
+ */
1479
+ export function substituteDocxXml(xml, placeholders, values, tier) {
1480
+ let out = xml;
1481
+ const warnings = [];
1482
+ const originalText = docxXmlToText(xml);
1483
+ for (const p of placeholders) {
1484
+ const v = values[p.key];
1485
+ if (v === undefined) continue;
1486
+ for (const h of p.hits) {
1487
+ const find = (tier === "bracket" || tier === "mustache") ? h.match : h.inner;
1488
+ const literal = (tier === "bracket" || tier === "mustache");
1489
+ const buildRe = (global) => literal
1490
+ ? new RegExp(escapeRegex(find), global ? "g" : "")
1491
+ : new RegExp(`(?<![A-Za-z0-9])${escapeRegex(find)}(?![A-Za-z0-9])`, global ? "g" : "");
1492
+ const replaceRe = buildRe(true);
1493
+ let madeSubstitution = false;
1494
+ out = out.replace(/<w:t(\s[^>]*)?>([\s\S]*?)<\/w:t>/g, (match, attrs, content) => {
1495
+ const decoded = decodeXml(content);
1496
+ replaceRe.lastIndex = 0;
1497
+ const replaced = decoded.replace(replaceRe, v);
1498
+ if (replaced === decoded) return match;
1499
+ madeSubstitution = true;
1500
+ return `<w:t${attrs || ""}>${encodeXml(replaced)}</w:t>`;
1501
+ });
1502
+ if (!madeSubstitution && buildRe(false).test(originalText)) {
1503
+ warnings.push(
1504
+ `docx substitution skipped for "${find}" (→ "${v}"): the placeholder spans ` +
1505
+ `multiple text runs in the source, which would lose run-level styling if merged. ` +
1506
+ `Open the document, retype the placeholder so it lives in a single run, and retry.`
1507
+ );
1508
+ }
1509
+ }
1510
+ }
1511
+ return { xml: out, warnings };
1512
+ }
1513
+
1514
+ /**
1515
+ * Decide whether to write `.docx` output (round-trip) versus plain text.
1516
+ * Returns `{ path }` for `.docx`, or `null` for text. Rules:
1517
+ * - input must be `.docx`;
1518
+ * - `--json`, `--diff`, `--validate`, `--list-placeholders` force text;
1519
+ * - `--output PATH.docx` writes `.docx` to PATH;
1520
+ * - `--output -` writes plain text to stdout (Unix `-` convention);
1521
+ * - `--output PATH` with any other extension writes plain text;
1522
+ * - no `--output` defaults to `<basename>-filled.docx`.
1523
+ *
1524
+ * @param {Object} opts — parsed CLI args.
1525
+ * @param {{kind: "text"|"docx", path: string|null}} input
1526
+ * @returns {{ path: string } | null}
1527
+ */
1528
+ export function decideDocxOutput(opts, input) {
1529
+ if (input.kind !== "docx") return null;
1530
+ if (opts.json || opts.diff || opts.listPlaceholders || opts.validate) return null;
1531
+ if (opts.output === "-") return null;
1532
+ if (opts.output) {
1533
+ return extname(opts.output) === ".docx" ? { path: opts.output } : null;
1534
+ }
1535
+ return { path: makeDocxOutputPath(input.path || "out.docx") };
1536
+ }
1537
+
1183
1538
  // ─── --why BUILDER ──────────────────────────────────────────────────────────
1184
1539
  /**
1185
1540
  * Format the `--why` stderr block. Stable shape across minor versions; see
@@ -1277,6 +1632,21 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1277
1632
  }
1278
1633
  return EXIT.VALIDATION;
1279
1634
  }
1635
+ // v2 #3: typed-parameter validation. Mirror what cmdDraft does so
1636
+ // `--validate` catches type errors before the user runs draft.
1637
+ const typeCheck = normalizeTypedValues(result.placeholders, resolved);
1638
+ if (!typeCheck.ok) {
1639
+ for (const te of typeCheck.errors) {
1640
+ err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
1641
+ }
1642
+ if (opts.json) {
1643
+ out.write(JSON.stringify({
1644
+ ok: false,
1645
+ type_errors: typeCheck.errors.map(({ key, message }) => ({ key, message })),
1646
+ }, null, 2) + "\n");
1647
+ }
1648
+ return EXIT.VALIDATION;
1649
+ }
1280
1650
  if (opts.json) {
1281
1651
  out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
1282
1652
  } else {
@@ -1346,6 +1716,17 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1346
1716
  return EXIT.VALIDATION;
1347
1717
  }
1348
1718
 
1719
+ // v2 #3: typed-parameter normalization. Schema entries can declare
1720
+ // `type: date | money | party`. Inputs are validated and normalized
1721
+ // before substitution. Hard error on bad input (Q3.3 decision).
1722
+ const typeCheck = normalizeTypedValues(result.placeholders, resolved);
1723
+ if (!typeCheck.ok) {
1724
+ for (const te of typeCheck.errors) {
1725
+ err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
1726
+ }
1727
+ return EXIT.VALIDATION;
1728
+ }
1729
+
1349
1730
  // Diff mode: print a substitution table and exit without writing output.
1350
1731
  if (opts.diff) {
1351
1732
  if (opts.json) {
@@ -1367,9 +1748,29 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1367
1748
 
1368
1749
  const output = substitute(input.body, result.placeholders, resolved, result.tier);
1369
1750
 
1370
- // Write output.
1371
- if (opts.output) {
1372
- try { writeFileSync(opts.output, output, "utf8"); }
1751
+ // Write output. Three paths:
1752
+ // (a) docx round-trip: input is .docx and target is .docx (default for .docx
1753
+ // inputs, unless --output is set to a non-.docx extension or `-`).
1754
+ // (b) write text to a file (--output PATH, where PATH ≠ "-").
1755
+ // (c) write text to stdout (no --output, or --output "-").
1756
+ // --json suppresses (c) so it doesn't collide with the JSON payload.
1757
+ const docxOut = decideDocxOutput(opts, input);
1758
+ let writtenPath = null;
1759
+ if (docxOut) {
1760
+ try {
1761
+ const { xml: newXml, warnings: docxWarnings } = substituteDocxXml(
1762
+ input.docxXml, result.placeholders, resolved, result.tier
1763
+ );
1764
+ if (docxWarnings.length) result.warnings.push(...docxWarnings);
1765
+ const buf = await writeDocxBuffer(input.path, newXml);
1766
+ writeFileSync(docxOut.path, buf);
1767
+ writtenPath = docxOut.path;
1768
+ } catch (e) {
1769
+ err.write(paint(`error: could not write ${docxOut.path}: ${e.message}\n`, "red", err));
1770
+ return EXIT.IO;
1771
+ }
1772
+ } else if (opts.output && opts.output !== "-") {
1773
+ try { writeFileSync(opts.output, output, "utf8"); writtenPath = opts.output; }
1373
1774
  catch (e) {
1374
1775
  err.write(paint(`error: could not write ${opts.output}: ${e.message}\n`, "red", err));
1375
1776
  return EXIT.IO;
@@ -1382,8 +1783,8 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1382
1783
  out.write(JSON.stringify({
1383
1784
  ok: true,
1384
1785
  tier: result.tier,
1385
- output_path: opts.output || null,
1386
- output: opts.output ? null : output,
1786
+ output_path: writtenPath,
1787
+ output: writtenPath ? null : output,
1387
1788
  placeholders: publicPlaceholders(result.placeholders),
1388
1789
  sources,
1389
1790
  warnings: result.warnings,
@@ -1401,7 +1802,7 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1401
1802
  missing,
1402
1803
  unmapped: result.unmapped,
1403
1804
  warnings: result.warnings,
1404
- outputPath: opts.output,
1805
+ outputPath: writtenPath,
1405
1806
  }) + "\n");
1406
1807
  }
1407
1808
  for (const w of result.warnings) err.write(paint(`warning: ${w}\n`, "yellow", err));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.2",
4
4
  "description": "Fill placeholders in a legal-document template with parameter values. Part of the contract-operations suite.",
5
5
  "type": "module",
6
6
  "bin": {