@drbaher/draft-cli 0.5.0 → 0.6.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/CHANGELOG.md +46 -0
- package/PARAM_SCHEMA.md +63 -0
- package/draft-cli.mjs +138 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ 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.6.0 — 2026-05-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Cross-template `parties.json` registry.** A repo-local
|
|
12
|
+
`parties.json` declares known parties once; templates' schemas
|
|
13
|
+
reference fields with `ref:parties.<key>.<field>`:
|
|
14
|
+
```json
|
|
15
|
+
// parties.json
|
|
16
|
+
{ "acme_corp": { "name": "Acme Corporation", "state": "Delaware" } }
|
|
17
|
+
```
|
|
18
|
+
```json
|
|
19
|
+
// <template>.params.json
|
|
20
|
+
{
|
|
21
|
+
"_meta": { "v": 1 },
|
|
22
|
+
"party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" },
|
|
23
|
+
"party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" }
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
Resolution happens between `resolveValues` and typed-parameter
|
|
27
|
+
normalization, so `ref:`-resolved values feed cleanly into
|
|
28
|
+
`type: date | money | party` flows.
|
|
29
|
+
- **`--parties PATH` flag** overrides the default CWD/`parties.json`
|
|
30
|
+
lookup. Missing explicit path → exit 1 with a clear error.
|
|
31
|
+
- **New public API:** `loadParties(path)`, `resolveRef(value, parties)`,
|
|
32
|
+
`resolveRefs(resolved, sources, parties)`.
|
|
33
|
+
|
|
34
|
+
### Decisions locked (V2_BRIEFS_REMAINING Q2.1–Q2.3)
|
|
35
|
+
|
|
36
|
+
- **Q2.1 File location:** default is `./parties.json` in CWD;
|
|
37
|
+
override with `--parties PATH`.
|
|
38
|
+
- **Q2.2 Ref scope:** refs resolve in `--params` JSON and schema
|
|
39
|
+
`default` values only. CLI flag values with `ref:` prefix pass
|
|
40
|
+
through **unchanged** — they're treated as literal strings.
|
|
41
|
+
- **Q2.3 Versioning:** out of scope for v0.6.0. When a party's
|
|
42
|
+
metadata changes in `parties.json`, all drafts that ref it produce
|
|
43
|
+
different output if re-run. Documented as a known property.
|
|
44
|
+
|
|
45
|
+
### Schema-contract change
|
|
46
|
+
|
|
47
|
+
`PARAM_SCHEMA.md` §5 gains a "Cross-template `parties.json` registry"
|
|
48
|
+
subsection. v0.6.0 schemas are forward-compatible with v0.5.x
|
|
49
|
+
readers — `ref:` strings just look like literal values to older
|
|
50
|
+
readers (and won't substitute correctly, but won't error out either
|
|
51
|
+
since "ref:..." is a valid string).
|
|
52
|
+
|
|
7
53
|
## 0.5.0 — 2026-05-16
|
|
8
54
|
|
|
9
55
|
### Added
|
package/PARAM_SCHEMA.md
CHANGED
|
@@ -388,6 +388,69 @@ and `position_index` (0-based) fields. `substitute` switches to
|
|
|
388
388
|
byte-index substitution for these, which `substituteDocxXml` does not
|
|
389
389
|
currently support.
|
|
390
390
|
|
|
391
|
+
### Cross-template `parties.json` registry (v0.6.0, opt-in)
|
|
392
|
+
|
|
393
|
+
A repo-local `parties.json` declares known parties once; templates'
|
|
394
|
+
schemas reference fields with `ref:parties.<party_key>.<field>`.
|
|
395
|
+
Eliminates duplicating party metadata (name, state, CIK, signing
|
|
396
|
+
contact) across every template.
|
|
397
|
+
|
|
398
|
+
```json
|
|
399
|
+
// parties.json
|
|
400
|
+
{
|
|
401
|
+
"acme_corp": {
|
|
402
|
+
"name": "Acme Corporation",
|
|
403
|
+
"state": "Delaware",
|
|
404
|
+
"cik": "0001234567"
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```json
|
|
410
|
+
// <template>.params.json
|
|
411
|
+
{
|
|
412
|
+
"_meta": { "schema_version": 1 },
|
|
413
|
+
"party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" },
|
|
414
|
+
"party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" }
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Q2.1 locked:** default file location is `./parties.json` in the
|
|
419
|
+
process CWD. Override with `--parties PATH`. Missing explicit path
|
|
420
|
+
is exit 1 (`EXIT.IO`); missing default file silently means "no
|
|
421
|
+
registry loaded" (refs then fail at resolution time with a clear hint).
|
|
422
|
+
|
|
423
|
+
**Q2.2 locked:** refs resolve in `--params` JSON values and schema
|
|
424
|
+
`default` values only. **CLI flag values pass through unchanged** —
|
|
425
|
+
`--party-a "ref:parties.acme.name"` is treated as a literal string
|
|
426
|
+
that happens to start with `ref:`. This keeps CLI parsing
|
|
427
|
+
unambiguous and avoids users accidentally leaking parties data on
|
|
428
|
+
the command line.
|
|
429
|
+
|
|
430
|
+
**Q2.3 locked:** versioning is out of scope for v0.6.0. When a
|
|
431
|
+
party's metadata changes in `parties.json`, all drafts that ref it
|
|
432
|
+
produce different output if re-run. This is by design (single source
|
|
433
|
+
of truth for party info), but worth knowing — historical drafts may
|
|
434
|
+
diverge from their original `parties.json` values.
|
|
435
|
+
|
|
436
|
+
**Ref syntax:** `ref:parties.<party_key>.<field>` where both
|
|
437
|
+
`<party_key>` and `<field>` match `[A-Za-z_][A-Za-z0-9_]*`. Malformed
|
|
438
|
+
refs, unknown party keys, and unknown fields all surface as hard
|
|
439
|
+
errors before substitution (exit 4).
|
|
440
|
+
|
|
441
|
+
**Resolution order:** value resolution → ref expansion → typed-param
|
|
442
|
+
normalization → computed values → substitute. Refs run before typed
|
|
443
|
+
normalization so a ref returning `"2027-01-15"` can still flow
|
|
444
|
+
through `type: date` formatting.
|
|
445
|
+
|
|
446
|
+
**Field-value coercion:** ref'd fields are coerced to strings via
|
|
447
|
+
`String(value)` (e.g. `cik: 1234567` becomes `"1234567"`). The
|
|
448
|
+
parties registry can store non-string values for ergonomics, but
|
|
449
|
+
substitution always uses string output.
|
|
450
|
+
|
|
451
|
+
Programmatic API: `loadParties(path)`, `resolveRef(value, parties)`,
|
|
452
|
+
`resolveRefs(resolved, sources, parties)`.
|
|
453
|
+
|
|
391
454
|
### Orphan handling (Q4 locked)
|
|
392
455
|
|
|
393
456
|
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.
|
|
73
|
+
export const VERSION = "0.6.0";
|
|
74
74
|
|
|
75
75
|
// ─── EXIT CODES ─────────────────────────────────────────────────────────────
|
|
76
76
|
/**
|
|
@@ -277,6 +277,7 @@ export function parseArgs(argv) {
|
|
|
277
277
|
if (a === "--silent" || a === "-q") { opts.silent = true; continue; }
|
|
278
278
|
if (a === "--diff") { opts.diff = true; continue; }
|
|
279
279
|
if (a === "--params") { opts.params = argv[++i]; continue; }
|
|
280
|
+
if (a === "--parties") { opts.parties = argv[++i]; continue; }
|
|
280
281
|
if (a === "--output" || a === "-o") { opts.output = argv[++i]; continue; }
|
|
281
282
|
if (a === "--syntax") {
|
|
282
283
|
const v = argv[++i];
|
|
@@ -1272,6 +1273,108 @@ export function loadParamsFile(path) {
|
|
|
1272
1273
|
}
|
|
1273
1274
|
}
|
|
1274
1275
|
|
|
1276
|
+
/**
|
|
1277
|
+
* Load a `parties.json` registry (v2 #5). Returns the parsed object or
|
|
1278
|
+
* `null` if no file is present. Explicit `--parties PATH` errors if
|
|
1279
|
+
* the path doesn't exist; the default `./parties.json` is treated as
|
|
1280
|
+
* absent if the file isn't there (no error).
|
|
1281
|
+
*
|
|
1282
|
+
* @param {string | null} explicitPath — value of `--parties PATH`, or null
|
|
1283
|
+
* to auto-detect `./parties.json` in CWD.
|
|
1284
|
+
* @returns {Object<string, Object<string, *>> | null}
|
|
1285
|
+
* @throws {Error} with `.exitCode = EXIT.IO` on missing explicit file or invalid JSON.
|
|
1286
|
+
*/
|
|
1287
|
+
export function loadParties(explicitPath) {
|
|
1288
|
+
const fallback = "parties.json";
|
|
1289
|
+
const path = explicitPath || (existsSync(fallback) ? fallback : null);
|
|
1290
|
+
if (!path) return null;
|
|
1291
|
+
if (!existsSync(path)) {
|
|
1292
|
+
const e = new Error(`parties file not found: ${path}`);
|
|
1293
|
+
e.exitCode = EXIT.IO;
|
|
1294
|
+
throw e;
|
|
1295
|
+
}
|
|
1296
|
+
let parsed;
|
|
1297
|
+
try {
|
|
1298
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
const e = new Error(`could not parse ${path}: ${err.message}`);
|
|
1301
|
+
e.exitCode = EXIT.IO;
|
|
1302
|
+
throw e;
|
|
1303
|
+
}
|
|
1304
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1305
|
+
const e = new Error(`parties file ${path} must be a JSON object`);
|
|
1306
|
+
e.exitCode = EXIT.IO;
|
|
1307
|
+
throw e;
|
|
1308
|
+
}
|
|
1309
|
+
// Reject non-object party entries early so downstream `ref:` resolution
|
|
1310
|
+
// can safely lookup fields without a per-call shape check.
|
|
1311
|
+
for (const [partyKey, party] of Object.entries(parsed)) {
|
|
1312
|
+
if (!party || typeof party !== "object" || Array.isArray(party)) {
|
|
1313
|
+
const e = new Error(`parties file ${path}: entry "${partyKey}" must be a JSON object`);
|
|
1314
|
+
e.exitCode = EXIT.IO;
|
|
1315
|
+
throw e;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return parsed;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Resolve a `ref:parties.<party>.<field>` reference against a loaded
|
|
1323
|
+
* parties object. Throws on malformed ref, missing parties registry,
|
|
1324
|
+
* unknown party, or unknown field. Non-`ref:` strings pass through
|
|
1325
|
+
* unchanged.
|
|
1326
|
+
*
|
|
1327
|
+
* @param {string} value
|
|
1328
|
+
* @param {Object<string, Object<string, *>> | null} parties
|
|
1329
|
+
* @returns {string}
|
|
1330
|
+
* @throws {Error} on malformed or unresolvable reference.
|
|
1331
|
+
*/
|
|
1332
|
+
export function resolveRef(value, parties) {
|
|
1333
|
+
if (typeof value !== "string" || !value.startsWith("ref:")) return value;
|
|
1334
|
+
if (!parties) {
|
|
1335
|
+
throw new Error(`reference "${value}" but no parties.json loaded (pass --parties PATH or put parties.json in cwd)`);
|
|
1336
|
+
}
|
|
1337
|
+
const m = /^ref:parties\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
|
|
1338
|
+
if (!m) {
|
|
1339
|
+
throw new Error(`malformed reference "${value}" (expected "ref:parties.<party_key>.<field>")`);
|
|
1340
|
+
}
|
|
1341
|
+
const [, partyKey, fieldKey] = m;
|
|
1342
|
+
const party = parties[partyKey];
|
|
1343
|
+
if (!party) {
|
|
1344
|
+
throw new Error(`unknown party "${partyKey}" in reference "${value}"`);
|
|
1345
|
+
}
|
|
1346
|
+
if (!(fieldKey in party)) {
|
|
1347
|
+
throw new Error(`unknown field "${fieldKey}" on party "${partyKey}" in reference "${value}"`);
|
|
1348
|
+
}
|
|
1349
|
+
const out = party[fieldKey];
|
|
1350
|
+
return out == null ? "" : String(out);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Walk a resolved-values map and replace any `ref:` strings with their
|
|
1355
|
+
* resolved values from the parties registry. CLI-sourced values are
|
|
1356
|
+
* left alone (Q2.2: refs are params/default only, never CLI). Collects
|
|
1357
|
+
* all errors before returning so the user sees every failure at once.
|
|
1358
|
+
*
|
|
1359
|
+
* @param {Object<string,string>} resolved — mutated in place.
|
|
1360
|
+
* @param {Object<string,string>} sources — from `resolveValues`.
|
|
1361
|
+
* @param {Object<string, Object<string, *>> | null} parties
|
|
1362
|
+
* @returns {{ ok: boolean, errors: Array<{ key: string, message: string }> }}
|
|
1363
|
+
*/
|
|
1364
|
+
export function resolveRefs(resolved, sources, parties) {
|
|
1365
|
+
const errors = [];
|
|
1366
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
1367
|
+
if (sources[key] === "cli") continue; // Q2.2: CLI values pass through
|
|
1368
|
+
if (typeof value !== "string" || !value.startsWith("ref:")) continue;
|
|
1369
|
+
try {
|
|
1370
|
+
resolved[key] = resolveRef(value, parties);
|
|
1371
|
+
} catch (e) {
|
|
1372
|
+
errors.push({ key, message: e.message });
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return { ok: errors.length === 0, errors };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1275
1378
|
/**
|
|
1276
1379
|
* Resolve a value for every placeholder using the locked precedence chain:
|
|
1277
1380
|
* CLI flag > `--params` JSON > `--interactive` prompt > schema default >
|
|
@@ -1912,7 +2015,7 @@ export async function cmdListPlaceholders(opts, input, schema, envObj, { fetcher
|
|
|
1912
2015
|
return EXIT.OK;
|
|
1913
2016
|
}
|
|
1914
2017
|
|
|
1915
|
-
export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
|
|
2018
|
+
export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
|
|
1916
2019
|
const result = await runCascade(input, opts, schema, envObj, { fetcher });
|
|
1917
2020
|
if (result.tier === "none") {
|
|
1918
2021
|
err.write(paint("error: no placeholders detected by any tier\n", "red", err));
|
|
@@ -1940,6 +2043,22 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
|
|
|
1940
2043
|
}
|
|
1941
2044
|
return EXIT.VALIDATION;
|
|
1942
2045
|
}
|
|
2046
|
+
// v2 #5: parties.json ref resolution. Refs like
|
|
2047
|
+
// `ref:parties.acme_corp.name` in --params or schema defaults expand
|
|
2048
|
+
// before typed normalization. CLI values pass through unchanged.
|
|
2049
|
+
const refCheck = resolveRefs(resolved, sources, parties);
|
|
2050
|
+
if (!refCheck.ok) {
|
|
2051
|
+
for (const re of refCheck.errors) {
|
|
2052
|
+
err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
|
|
2053
|
+
}
|
|
2054
|
+
if (opts.json) {
|
|
2055
|
+
out.write(JSON.stringify({
|
|
2056
|
+
ok: false,
|
|
2057
|
+
ref_errors: refCheck.errors.map(({ key, message }) => ({ key, message })),
|
|
2058
|
+
}, null, 2) + "\n");
|
|
2059
|
+
}
|
|
2060
|
+
return EXIT.VALIDATION;
|
|
2061
|
+
}
|
|
1943
2062
|
// v2 #3: typed-parameter validation. Mirror what cmdDraft does so
|
|
1944
2063
|
// `--validate` catches type errors before the user runs draft.
|
|
1945
2064
|
const typeCheck = normalizeTypedValues(result.placeholders, resolved);
|
|
@@ -1977,7 +2096,7 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
|
|
|
1977
2096
|
return EXIT.OK;
|
|
1978
2097
|
}
|
|
1979
2098
|
|
|
1980
|
-
export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
|
|
2099
|
+
export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
|
|
1981
2100
|
const result = await runCascade(input, opts, schema, envObj, { fetcher });
|
|
1982
2101
|
if (result.tier === "none") {
|
|
1983
2102
|
const hasProvider = Boolean(llmProviderFromEnv(envObj));
|
|
@@ -2045,6 +2164,18 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
2045
2164
|
return EXIT.VALIDATION;
|
|
2046
2165
|
}
|
|
2047
2166
|
|
|
2167
|
+
// v2 #5: parties.json ref resolution. Refs like
|
|
2168
|
+
// `ref:parties.acme_corp.name` in --params or schema defaults expand
|
|
2169
|
+
// before typed normalization. CLI values pass through unchanged
|
|
2170
|
+
// (Q2.2 lock).
|
|
2171
|
+
const refCheck = resolveRefs(resolved, sources, parties);
|
|
2172
|
+
if (!refCheck.ok) {
|
|
2173
|
+
for (const re of refCheck.errors) {
|
|
2174
|
+
err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
|
|
2175
|
+
}
|
|
2176
|
+
return EXIT.VALIDATION;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2048
2179
|
// v2 #3: typed-parameter normalization. Schema entries can declare
|
|
2049
2180
|
// `type: date | money | party`. Inputs are validated and normalized
|
|
2050
2181
|
// before substitution. Hard error on bad input (Q3.3 decision).
|
|
@@ -2462,12 +2593,13 @@ export async function main(argv, io = {}) {
|
|
|
2462
2593
|
return EXIT.IO;
|
|
2463
2594
|
}
|
|
2464
2595
|
|
|
2465
|
-
let input, schema, paramsObj, envObj;
|
|
2596
|
+
let input, schema, paramsObj, envObj, parties;
|
|
2466
2597
|
try {
|
|
2467
2598
|
input = await resolveInput(opts.positional[0], { spawner, stdinReader });
|
|
2468
2599
|
schema = loadSchema(input.path);
|
|
2469
2600
|
paramsObj = loadParamsFile(opts.params);
|
|
2470
2601
|
envObj = effectiveEnv(cwd, processEnv);
|
|
2602
|
+
parties = loadParties(opts.parties || null);
|
|
2471
2603
|
} catch (e) {
|
|
2472
2604
|
err.write(paint(`error: ${e.message}\n`, "red", err));
|
|
2473
2605
|
return e.exitCode || EXIT.IO;
|
|
@@ -2478,9 +2610,9 @@ export async function main(argv, io = {}) {
|
|
|
2478
2610
|
return await cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err });
|
|
2479
2611
|
}
|
|
2480
2612
|
if (opts.validate) {
|
|
2481
|
-
return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
|
|
2613
|
+
return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
|
|
2482
2614
|
}
|
|
2483
|
-
return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
|
|
2615
|
+
return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
|
|
2484
2616
|
} catch (e) {
|
|
2485
2617
|
err.write(paint(`error: ${e.message}\n`, "red", err));
|
|
2486
2618
|
return e.exitCode || EXIT.IO;
|