@henols/vice-mcp 0.1.12 → 0.2.1

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/r2000-cli.ts ADDED
@@ -0,0 +1,1103 @@
1
+ #!/usr/bin/env node
2
+ // r2000-cli.ts -- the thin CLI ergonomics layer over the guarded regenerator2000
3
+ // seam (D-06). Reached as `vice-mcp r2000 <verb>` because that bin is the only
4
+ // surface that resolves identically across the Claude Code plugin route and
5
+ // both npm-installer routes: `installer/bin/cli.mjs`'s `viceServerEntry()`
6
+ // always launches this server via `npx` in BOTH npm-installer modes, and
7
+ // neither route places `.claude/mcp/vice/*.ts` as plain files inside a
8
+ // consuming project for some other filesystem-path-resolving design to find.
9
+ //
10
+ // WHAT NOT TO DO, named concretely:
11
+ // - Never accept a caller-supplied passthrough of extra flags to
12
+ // regenerator2000 (D-07). Every child-process argv is built only by
13
+ // `r2000-launch.ts`'s fixed builders (`buildExportAsmArgs()`,
14
+ // `buildVerifyArgs()`) -- this file adds exactly two options of its own
15
+ // (`--entry`, `--out`), neither of which reaches the child process argv.
16
+ // - Never auto-pick a `.d64` entry when the caller does not name one (D-02).
17
+ // A silent auto-pick would happily analyse a cracktro or loader stub's
18
+ // bytes instead of the actual game -- precisely the failure
19
+ // `c64-provenance-diff` exists to prevent elsewhere in this project. Zero
20
+ // `--entry` means: print the directory listing, tell the user to re-run
21
+ // with `--entry NAME`, and exit 2. Never guess.
22
+ // - Never dispatch a flat `.raw`/`.bin` capture by its BYTE LENGTH alone
23
+ // (WR-07). The incident: a 4096-byte `capture.raw` fell through to the
24
+ // `bytes.length === 65536` branch's `else`, which is `parsePrg()` --
25
+ // whose first two bytes become the load address -- so a truncated
26
+ // capture silently "bootstrapped" with origin `$62c5` (its own first two
27
+ // payload bytes read backwards) and exit 0, with every downstream
28
+ // address wrong and no diagnostic. That is exactly the "silently guess"
29
+ // behaviour D-02 exists to forbid, just for a different input shape.
30
+ // `.raw`/`.bin` inputs are now dispatched by EXTENSION, before the
31
+ // length check, so `flatImageOrigin()`'s own named refusal (any length
32
+ // other than exactly 65536) is always reachable for those two
33
+ // extensions.
34
+
35
+ //
36
+ // `runR2000Cli()` returns an exit code and never terminates the process
37
+ // itself, so it is testable in-process as well as from the bin (the bin,
38
+ // `vice-proxy.ts`, is the only place that ends the process with this
39
+ // function's return value). All output goes to stdout/stderr via
40
+ // `console.log`/`console.error` -- never a thrown stack trace for an
41
+ // expected, user-facing failure (missing file, unknown `.d64` entry, `.vsf`
42
+ // input): each of those produces a single actionable line instead.
43
+ //
44
+ // Import nothing from `hostpath.ts` or `containerpath.ts`. Every path this
45
+ // CLI handles is already container-side: regenerator2000 runs on the MCP
46
+ // proxy's side of the boundary (D-R4), and translating any of these
47
+ // arguments would be the mirror image of the DERIV-07 screenshot-path trap,
48
+ // where a client-side-derived path was wrongly translated a second time.
49
+ // This absence is asserted structurally by `hostpath-consumers.test.ts`
50
+ // (D-08), not merely stated here.
51
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
52
+ import { tmpdir } from "node:os";
53
+ import { dirname, extname, join } from "node:path";
54
+
55
+ import { buildExportAsmArgs, runR2000, R2000ViceFlagError } from "./r2000-launch.ts";
56
+ import { synthesizeProject, parsePrg, flatImageOrigin } from "./r2000-project.ts";
57
+ import { listEntries, extractEntry, assertPlainImage } from "./r2000-d64.ts";
58
+ import { verifyProject } from "./r2000-verify.ts";
59
+ import { generateEnums } from "./r2000-enum-gen.ts";
60
+ import { exportLabels, importLabels } from "./r2000-symbols.ts";
61
+ import { renderMemoryMap, checkRenderedMemoryMap } from "./r2000-memmap-render.ts";
62
+
63
+ const NPX_INVOCATION = "npx -y @henols/vice-mcp r2000 <verb>";
64
+ const PLUGIN_INVOCATION = "node <plugin-root>/.claude/mcp/vice/vice-proxy.ts r2000 <verb>";
65
+
66
+ // `verify` is the criterion-4 (R2000-06) proof route: it runs
67
+ // regenerator2000's own `--verify` and reports the verdict `r2000-verify.ts`'s
68
+ // `acmeVerdict()` derives from the PARSED ACME result line -- never from the
69
+ // child process's exit code (D-10). A skipped ACME reads as a failure here,
70
+ // not as an absence, even on a transcript whose own exit code and summary
71
+ // line both say "passed" -- see r2000-verify.ts's header comment and
72
+ // r2000-verify.test.ts's pinned trap transcript for the live incident this
73
+ // guards against. A future maintainer reading only this file must not
74
+ // reintroduce a `result.status === 0` shortcut anywhere in `cmdVerify()`.
75
+ //
76
+ // FLOW-02 (D-11.1-01): the `.vsf` paragraph below used to end by naming a
77
+ // specific numbered phase as the eventual owner of closing that gap. That
78
+ // phase shipped and never touched `.vsf` bootstrap, so the sentence told a
79
+ // user to wait on a remediation that would never arrive. A phase number is
80
+ // a planning artifact -- it has no place in a shipped diagnostic. Corrected
81
+ // to name the backlog file instead; see the matching fix in
82
+ // `bootstrapProject()` below and `docs-dangling-refs.test.ts`'s guard
83
+ // against this defect class recurring anywhere in this file's string
84
+ // literals.
85
+ const USAGE = `usage (npm install): ${NPX_INVOCATION}
86
+ usage (plugin/in-repo): ${PLUGIN_INVOCATION}
87
+
88
+ verbs:
89
+ bootstrap <input> [--entry NAME] [--out PROJECT] [--force]
90
+ Accepts a .prg, a .d64 (pick an entry with --entry), or a flat 64K
91
+ .raw/.bin capture. Writes a .regen2000proj to --out (default: the
92
+ input path with its extension replaced by .regen2000proj). Refuses to
93
+ overwrite an existing file at that path unless --force is given. A
94
+ .regen2000proj input is refused outright -- bootstrap never re-reads
95
+ one; use export-asm or verify on it directly.
96
+
97
+ export-asm <input-or-project> [--entry NAME] [--out FILE] [--force]
98
+ If given a .regen2000proj it is used directly; otherwise this bootstraps
99
+ the input to a temporary project first, so a bare .prg becomes ACME
100
+ source in one command with no human interaction (--entry is forwarded
101
+ to that bootstrap step, and is required the same way it is for
102
+ bootstrap itself when the input is a .d64 -- IN-06/D-11.1-04: this line
103
+ used to omit --entry even though the verb already accepted and used
104
+ it, which is corrected here alongside closing the sibling defect of
105
+ accepting-but-silently-dropping an option). Writes ACME source to
106
+ --out (default: the input stem plus .a). Refuses to overwrite an
107
+ existing file at that path unless --force is given.
108
+
109
+ verify <input-or-project> [--entry NAME]
110
+ The criterion-4 (R2000-06) reassembly proof: exports and reassembles
111
+ through regenerator2000's own --verify, and prints each assembler's
112
+ parsed result line. Exits 0 only when ACME's own line reports success --
113
+ a skipped ACME is a failure here, never a pass, regardless of the child
114
+ process's own exit code or its "All roundtrip verifications passed."
115
+ summary line (D-10). Accepts a .regen2000proj directly, or bootstraps a
116
+ bare .prg/.d64/flat-64K input to a temporary project first, exactly
117
+ like export-asm.
118
+
119
+ gen-enums <project> [--max-results N]
120
+ Generates program-specific enums from the register writes an existing
121
+ .regen2000proj's disassembly already contains (D-20/D-22/D-23,
122
+ R2000-13) -- one variant per DISTINCT value actually written, named
123
+ from the curated bit-name table (r2000-regbits.json), applied at every
124
+ matching immediate-load address, and saved. Prints total/paired/
125
+ unpaired register-store counts and, per created/updated enum, its name
126
+ and variant count. Requires an EXISTING .regen2000proj (this verb does
127
+ not bootstrap from a raw input -- run bootstrap first). --max-results
128
+ overrides the 10000-row ceiling on each of the two search passes this
129
+ verb runs internally; exits non-zero, printing the reason, when either
130
+ pass returns exactly its own ceiling (coverage may be incomplete) or
131
+ when generateEnums() itself refuses (an illegal generated identifier,
132
+ or an enum install that failed outright).
133
+
134
+ export-lbl <project> [--out FILE]
135
+ Exports the project's user-defined labels to a VICE label file
136
+ (R2000-14): "al C:xxxx .Name" lines that stock-symbols.ts's existing
137
+ parser accepts. The written file is read back and validated through
138
+ that same parser before this verb reports success -- a regenerator2000
139
+ exit code has lied before (r2000-verify.ts's D-10 founding incident),
140
+ so the exit code alone is never trusted. Defaults --out to the project
141
+ path's stem plus .lbl. Prints the written path and the symbol count
142
+ parsed back from the file. Requires an EXISTING .regen2000proj (this
143
+ verb does not bootstrap from a raw input).
144
+
145
+ import-lbl <project> <lbl>
146
+ Imports a VICE label file into the project (R2000-15, the D-28 path):
147
+ --import_lbl paired with --mcp-server-stdio plus an explicit
148
+ r2000_save_project -- the only combination that actually persists the
149
+ import (--import_lbl alone under --headless silently discards it,
150
+ main.rs:800-806). Prints the names imported, then an explicit line
151
+ naming the fact that the import was persisted by an explicit save
152
+ (proven by re-reading the project from disk in a fresh process, never
153
+ trusted from the child's own success text alone). Exits non-zero,
154
+ printing the reason, when the result is not disk-verified, or when the
155
+ given .lbl fails stock-symbols.ts's own size/line/symbol ceilings.
156
+ Requires an EXISTING .regen2000proj (this verb does not bootstrap from
157
+ a raw input).
158
+
159
+ render-memmap <project> --provenance FILE [--out FILE] [--check]
160
+ Generates the Markdown memory map from the project's store plus a
161
+ validated provenance sidecar (D-24: the store is canonical, this
162
+ output is a GENERATED VIEW -- never hand-edit it). Without --check,
163
+ writes --out (default: memory-map.md beside the project) and prints
164
+ the row count, the number of [unknown]-graded rows, and the render
165
+ digest. With --check, re-renders in memory and compares against the
166
+ file at --out: prints "in sync" and exits 0 when they match, prints
167
+ the first differing line and exits non-zero on drift (from either a
168
+ hand edit OR a store-side change since the file was last rendered),
169
+ or prints "missing" and exits non-zero when --out does not exist yet.
170
+ --check is how a hand edit to the generated file is caught. Requires
171
+ an EXISTING .regen2000proj and an EXISTING --provenance sidecar (this
172
+ verb does not bootstrap from a raw input).
173
+
174
+ .d64 input with no --entry named prints the directory listing and exits 2 --
175
+ this CLI never guesses which entry to use (D-02).
176
+
177
+ .vsf input is not supported by any verb -- regenerator2000's auto-detected
178
+ machine-type field is correct only by coincidence for C64 snapshots, and no
179
+ R2000-* requirement covers .vsf as a bootstrap input (D-34). The idea is
180
+ recorded as backlog at
181
+ .planning/todos/pending/2026-08-20-vsf-as-a-bootstrap-input.md. Convert to
182
+ .prg, .d64 or a flat 64K capture.
183
+ `;
184
+
185
+ function errMsg(err: unknown): string {
186
+ return err instanceof Error ? err.message : String(err);
187
+ }
188
+
189
+ interface ParsedArgs {
190
+ positional: string[];
191
+ entry?: string;
192
+ out?: string;
193
+ force?: boolean;
194
+ entryMissingValue?: boolean;
195
+ outMissingValue?: boolean;
196
+ }
197
+
198
+ /** Fixed, closed option set -- exactly `--entry`, `--out` and `--force`. No
199
+ * rest field, no passthrough: an unrecognised flag is treated as a
200
+ * positional token (and will surface as "input file not found" rather than
201
+ * silently reaching regenerator2000, since nothing here ever forwards raw
202
+ * argv to the child process).
203
+ *
204
+ * WR-08: a value that is absent or itself `--`-shaped is a refusal
205
+ * (`entryMissingValue`/`outMissingValue`), never silently taken as the
206
+ * option's value -- the same posture `parseExportLblArgs()` below already
207
+ * has for `--out`. Before this fix, `entry = rest[++i]` and `out =
208
+ * rest[++i]` took the NEXT token unconditionally, so `bootstrap x.prg --out
209
+ * --entry FOO` parsed as `{ out: "--entry", entry: undefined }` and
210
+ * `bootstrapProject()` went on to `writeFileSync()` a project literally
211
+ * named `--entry` -- a dash-prefixed filename that is a shell-glob hazard
212
+ * for whatever later reads that directory. Callers check the two
213
+ * `*MissingValue` flags and refuse with a one-line message naming the
214
+ * option that was actually short a value (WR-09's lesson: `--out --entry
215
+ * FOO` must say `--out` is missing a value, never that `--entry` is a bad
216
+ * path) -- never throw, so `bootstrapProject()`'s never-throw contract
217
+ * holds for every caller of this parser too. */
218
+ function parseArgs(rest: string[]): ParsedArgs {
219
+ const positional: string[] = [];
220
+ let entry: string | undefined;
221
+ let out: string | undefined;
222
+ let force = false;
223
+ let entryMissingValue = false;
224
+ let outMissingValue = false;
225
+ for (let i = 0; i < rest.length; i++) {
226
+ const a = rest[i]!;
227
+ if (a === "--entry") {
228
+ const value = rest[i + 1];
229
+ if (value === undefined || value.startsWith("--")) {
230
+ entryMissingValue = true;
231
+ } else {
232
+ entry = value;
233
+ i++;
234
+ }
235
+ } else if (a === "--out") {
236
+ const value = rest[i + 1];
237
+ if (value === undefined || value.startsWith("--")) {
238
+ outMissingValue = true;
239
+ } else {
240
+ out = value;
241
+ i++;
242
+ }
243
+ } else if (a === "--force") {
244
+ force = true;
245
+ } else {
246
+ positional.push(a);
247
+ }
248
+ }
249
+ return { positional, entry, out, force, entryMissingValue, outMissingValue };
250
+ }
251
+
252
+ /**
253
+ * IN-06 (D-11.1-04): the ONE declared verb-to-accepted-options fact in this
254
+ * file. Every option every verb's own code actually reads is listed here --
255
+ * ground truth, not merely what USAGE happens to say (see the export-asm
256
+ * comment above: USAGE previously under-documented a real, working
257
+ * `--entry` forward, which this map's own test caught and which USAGE was
258
+ * corrected to match). `verify`'s entry is the fix for IN-06 itself:
259
+ * `parseArgs()` parses `--out` for every verb because it is a single shared
260
+ * parser, but `cmdVerify()` never reads the resulting `out` field -- so a
261
+ * caller who passed `--out` to `verify` got no error and no effect. Listing
262
+ * only `--entry` here means `checkAcceptedOptions()` below refuses `--out`
263
+ * (and `--force`) before `cmdVerify()` ever runs.
264
+ */
265
+ export const VERB_OPTIONS: Readonly<Record<string, readonly string[]>> = Object.freeze({
266
+ bootstrap: ["--entry", "--out", "--force"],
267
+ "export-asm": ["--entry", "--out", "--force"],
268
+ verify: ["--entry"],
269
+ "gen-enums": ["--max-results"],
270
+ "export-lbl": ["--out"],
271
+ "import-lbl": [],
272
+ "render-memmap": ["--provenance", "--out", "--check"],
273
+ });
274
+
275
+ /**
276
+ * The one shared refusal check IN-06 generalises to every verb (WR-08's
277
+ * closed-option-set posture, applied uniformly rather than verb by verb).
278
+ * Scans `rest` for any `--flag`-shaped token not in `verb`'s accepted set
279
+ * from `VERB_OPTIONS` and returns a one-line refusal naming the flag and the
280
+ * accepted set; returns `undefined` when every flag-shaped token is
281
+ * accepted (or when `verb` is not a key in the map at all, so an unknown
282
+ * verb still falls through to `runR2000Cli()`'s own "unknown verb"
283
+ * message). Never throws -- this file's never-throw posture applies here
284
+ * too.
285
+ */
286
+ export function checkAcceptedOptions(verb: string, rest: string[]): string | undefined {
287
+ const accepted = VERB_OPTIONS[verb];
288
+ if (!accepted) return undefined;
289
+ for (const token of rest) {
290
+ if (token.startsWith("--") && !accepted.includes(token)) {
291
+ const acceptedList = accepted.length > 0 ? accepted.join(", ") : "none";
292
+ return `${verb}: unknown option "${token}" -- not accepted by this verb (accepted: ${acceptedList})`;
293
+ }
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ /**
299
+ * Refuses to overwrite an existing file at `outPath` unless the caller
300
+ * passed `--force`. Shared by every verb that writes an output file
301
+ * (`bootstrap`, `export-asm`) so overwrite safety stays uniform rather than
302
+ * one verb accreting a check the others lack (CR-01/CR-02).
303
+ */
304
+ function refuseOverwrite(outPath: string, force: boolean | undefined, verbLabel: string, extraHint = ""): boolean {
305
+ if (force || !existsSync(outPath)) return true;
306
+ console.error(
307
+ `${verbLabel}: refusing to overwrite the existing file ${outPath}${extraHint} -- ` +
308
+ `pass --force to overwrite it deliberately.`,
309
+ );
310
+ return false;
311
+ }
312
+
313
+ interface BootstrapOutcome {
314
+ code: number;
315
+ path?: string;
316
+ }
317
+
318
+ /**
319
+ * Shared bootstrap logic used both by the `bootstrap` verb directly and by
320
+ * `export-asm` when handed a bare input rather than an existing
321
+ * `.regen2000proj`. Never throws for an expected, user-facing failure --
322
+ * every branch below returns a `{ code }` result with a one-line message
323
+ * already printed to stderr/stdout, so callers never see a stack trace for a
324
+ * missing file, an unknown `.d64` entry, or a `.vsf` input.
325
+ */
326
+ function bootstrapProject(
327
+ input: string,
328
+ opts: { entry?: string; outPath?: string; force?: boolean },
329
+ ): BootstrapOutcome {
330
+ if (!existsSync(input)) {
331
+ console.error(`bootstrap: input file not found: ${input}`);
332
+ return { code: 1 };
333
+ }
334
+
335
+ const ext = extname(input).toLowerCase();
336
+ if (ext === ".regen2000proj") {
337
+ // CR-02: without this branch, a .regen2000proj input fell through to
338
+ // parsePrg(), which happily "parsed" the JSON text as a .prg (its first
339
+ // two bytes read as a bogus little-endian load address), and the
340
+ // derived out-path was then byte-identical to the input path --
341
+ // clobbering the project with garbage synthesised from its own text.
342
+ // Decision (see commit message): refuse rather than silently pass it
343
+ // through unchanged -- bootstrap's whole job is synthesising a project
344
+ // from RAW input, and a caller who names an existing .regen2000proj as
345
+ // bootstrap's input almost certainly meant export-asm or verify, which
346
+ // already accept a .regen2000proj directly and do the right thing.
347
+ console.error(
348
+ `bootstrap: ${input} is already a .regen2000proj -- bootstrap synthesises project files from raw ` +
349
+ "input (a .prg, a .d64 entry, or a flat 64K capture), it never re-reads one. Use export-asm or " +
350
+ "verify on it directly.",
351
+ );
352
+ return { code: 1 };
353
+ }
354
+ if (ext === ".vsf") {
355
+ // FLOW-02 (D-11.1-01): this refusal used to end by naming a specific
356
+ // numbered phase as the eventual owner of closing that gap. That phase
357
+ // shipped and never touched `.vsf` bootstrap -- the sentence pointed a
358
+ // user at a remediation path that would never exist. Name the backlog
359
+ // file instead; the WHY (the machine-type coincidence) stays, since
360
+ // that is the reason a user actually needs.
361
+ console.error(
362
+ "bootstrap: .vsf input is not supported -- regenerator2000's auto-detected machine-type field only " +
363
+ 'reads correctly by coincidence ("C64SC" falls through to its own default, matching none of its ' +
364
+ "literal System arms). Filed as backlog, not covered by any R2000-* requirement (D-34): see " +
365
+ ".planning/todos/pending/2026-08-20-vsf-as-a-bootstrap-input.md. Convert to .prg, .d64 or a flat " +
366
+ "64K capture instead.",
367
+ );
368
+ return { code: 1 };
369
+ }
370
+
371
+ let bytes: Uint8Array;
372
+ try {
373
+ bytes = readFileSync(input);
374
+ } catch (err) {
375
+ console.error(`bootstrap: could not read ${input}: ${errMsg(err)}`);
376
+ return { code: 1 };
377
+ }
378
+
379
+ let origin: number;
380
+ let body: Uint8Array;
381
+
382
+ if (ext === ".d64") {
383
+ try {
384
+ assertPlainImage(bytes);
385
+ } catch (err) {
386
+ console.error(`bootstrap: ${errMsg(err)}`);
387
+ return { code: 1 };
388
+ }
389
+
390
+ if (!opts.entry) {
391
+ // D-02's fail-loud contract, implemented here and nowhere else: never
392
+ // pick an entry. A silent auto-pick would happily analyse a cracktro
393
+ // or loader stub instead of the game.
394
+ let entries;
395
+ try {
396
+ entries = listEntries(bytes);
397
+ } catch (err) {
398
+ console.error(`bootstrap: ${errMsg(err)}`);
399
+ return { code: 1 };
400
+ }
401
+ console.log(`bootstrap: ${input} is a .d64 image with no --entry given -- directory listing:`);
402
+ if (entries.length === 0) {
403
+ console.log(" (no entries)");
404
+ } else {
405
+ for (const e of entries) {
406
+ console.log(` ${e.name}\t${e.type}\t${e.sizeBlocks} block(s)`);
407
+ }
408
+ }
409
+ console.log("Re-run with --entry NAME to choose one. This CLI never guesses (D-02).");
410
+ return { code: 2 };
411
+ }
412
+
413
+ let extracted: Uint8Array;
414
+ try {
415
+ extracted = extractEntry(bytes, opts.entry);
416
+ } catch (err) {
417
+ console.error(`bootstrap: ${errMsg(err)}`);
418
+ return { code: 1 };
419
+ }
420
+ try {
421
+ ({ origin, body } = parsePrg(extracted));
422
+ } catch {
423
+ // WR-09 (D-11.1-04): parsePrg() throws only when its input is under 3
424
+ // bytes (a .prg needs a 2-byte load address plus at least 1 payload
425
+ // byte). Every sibling branch below already wraps its own parsePrg()
426
+ // call -- this was the one gap, and its unwrapped throw escaped to
427
+ // runR2000Cli()'s last-resort net, surfacing as `r2000: parsePrg:
428
+ // input is N byte(s)` -- a message naming an internal function to a
429
+ // user who only ever supplied a disk image. Reworded entirely in the
430
+ // caller's own vocabulary (the entry name, never `parsePrg`) so the
431
+ // never-throw contract holds on this branch too.
432
+ console.error(
433
+ `bootstrap: entry "${opts.entry}" holds ${extracted.length} byte(s) -- not a loadable program ` +
434
+ "(a .prg needs at least 3 bytes: a 2-byte load address plus at least 1 payload byte). Choose a " +
435
+ "different --entry, or use a different input.",
436
+ );
437
+ return { code: 1 };
438
+ }
439
+ } else if (ext === ".raw" || ext === ".bin") {
440
+ // WR-07: dispatch by extension, not by byte length, so a truncated or
441
+ // oversized flat capture hits flatImageOrigin()'s own named refusal
442
+ // instead of falling through to parsePrg() and being silently
443
+ // reinterpreted as a .prg (see the header comment above).
444
+ try {
445
+ origin = flatImageOrigin(bytes);
446
+ } catch (err) {
447
+ console.error(`bootstrap: ${errMsg(err)}`);
448
+ return { code: 1 };
449
+ }
450
+ body = bytes;
451
+ } else if (bytes.length === 65536) {
452
+ // Extension-less flat capture (no `.raw`/`.bin` suffix): the only
453
+ // remaining route by which a 65536-byte flat image reaches
454
+ // flatImageOrigin() is this length check, kept for that case alone.
455
+ origin = flatImageOrigin(bytes);
456
+ body = bytes;
457
+ } else {
458
+ try {
459
+ ({ origin, body } = parsePrg(bytes));
460
+ } catch (err) {
461
+ console.error(`bootstrap: ${errMsg(err)}`);
462
+ return { code: 1 };
463
+ }
464
+ }
465
+
466
+ const outPath = opts.outPath ?? input.replace(/\.[^./\\]+$/, "") + ".regen2000proj";
467
+ if (!refuseOverwrite(outPath, opts.force, "bootstrap")) {
468
+ return { code: 1 };
469
+ }
470
+ let projectJson: string;
471
+ try {
472
+ projectJson = synthesizeProject(body, { origin });
473
+ } catch (err) {
474
+ console.error(`bootstrap: ${errMsg(err)}`);
475
+ return { code: 1 };
476
+ }
477
+ try {
478
+ writeFileSync(outPath, projectJson);
479
+ } catch (err) {
480
+ // WR-09 (D-11.1-04): an ENOENT (missing parent directory), EACCES or
481
+ // ENOSPC here is an ordinary, expected failure -- refuseOverwrite()
482
+ // above only handles the exists-case; this is the everything-else case,
483
+ // and it must not throw past this function's own never-throw contract.
484
+ console.error(`bootstrap: could not write ${outPath}: ${errMsg(err)}`);
485
+ return { code: 1 };
486
+ }
487
+ console.log(`bootstrap: wrote ${outPath} (origin $${origin.toString(16).padStart(4, "0")})`);
488
+ return { code: 0, path: outPath };
489
+ }
490
+
491
+ function cmdBootstrap(rest: string[]): number {
492
+ const { positional, entry, out, force, entryMissingValue, outMissingValue } = parseArgs(rest);
493
+ // WR-08: refused before any file is touched -- a flag-shaped or missing
494
+ // value for either option must never reach bootstrapProject() as if it
495
+ // were a real value.
496
+ if (entryMissingValue) {
497
+ console.error("bootstrap: --entry requires a value\n");
498
+ console.log(USAGE);
499
+ return 1;
500
+ }
501
+ if (outMissingValue) {
502
+ console.error("bootstrap: --out requires a value\n");
503
+ console.log(USAGE);
504
+ return 1;
505
+ }
506
+ const input = positional[0];
507
+ if (!input) {
508
+ console.error("bootstrap: usage: bootstrap <input> [--entry NAME] [--out PROJECT] [--force]");
509
+ return 1;
510
+ }
511
+ return bootstrapProject(input, { entry, outPath: out, force }).code;
512
+ }
513
+
514
+ function cmdExportAsm(rest: string[]): number {
515
+ const { positional, entry, out, force, entryMissingValue, outMissingValue } = parseArgs(rest);
516
+ // WR-08, same posture as cmdBootstrap() above: refuse before any temp
517
+ // directory or file is created.
518
+ if (entryMissingValue) {
519
+ console.error("export-asm: --entry requires a value\n");
520
+ console.log(USAGE);
521
+ return 1;
522
+ }
523
+ if (outMissingValue) {
524
+ console.error("export-asm: --out requires a value\n");
525
+ console.log(USAGE);
526
+ return 1;
527
+ }
528
+ const input = positional[0];
529
+ if (!input) {
530
+ console.error("export-asm: usage: export-asm <input-or-project> [--out FILE] [--force]");
531
+ return 1;
532
+ }
533
+ if (!existsSync(input)) {
534
+ console.error(`export-asm: input file not found: ${input}`);
535
+ return 1;
536
+ }
537
+
538
+ const ext = extname(input).toLowerCase();
539
+ let projectPath: string;
540
+ let tmpDir: string | undefined;
541
+
542
+ try {
543
+ if (ext === ".regen2000proj") {
544
+ projectPath = input;
545
+ } else {
546
+ // Bootstrap to a temp project first, so a bare .prg (or .d64/.raw)
547
+ // becomes ACME source in one command with no human interaction.
548
+ tmpDir = mkdtempSync(join(tmpdir(), "r2000-cli-"));
549
+ const tmpProjectPath = join(tmpDir, "bootstrap.regen2000proj");
550
+ const outcome = bootstrapProject(input, { entry, outPath: tmpProjectPath });
551
+ if (outcome.code !== 0) return outcome.code;
552
+ projectPath = outcome.path!;
553
+ }
554
+
555
+ const outPath = out ?? input.replace(/\.[^./\\]+$/, "") + ".a";
556
+ if (
557
+ !refuseOverwrite(
558
+ outPath,
559
+ force,
560
+ "export-asm",
561
+ " with generated source -- acme-build's own convention pairs <stem>.a with <stem>.prg, so " +
562
+ "the default target is very often hand-written source",
563
+ )
564
+ ) {
565
+ return 1;
566
+ }
567
+ const argv = buildExportAsmArgs({ projectPath, outPath });
568
+ const result = runR2000(argv);
569
+ if (result.status !== 0) {
570
+ console.error(`export-asm: regenerator2000 exited ${result.status}`);
571
+ if (result.stderr) console.error(result.stderr);
572
+ return result.status ?? 1;
573
+ }
574
+ console.log(`export-asm: wrote ${outPath}`);
575
+ return 0;
576
+ } finally {
577
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
578
+ }
579
+ }
580
+
581
+ /**
582
+ * `verify <input-or-project>` -- the criterion-4 (R2000-06) reassembly
583
+ * proof. Accepts a `.regen2000proj` directly, or bootstraps a bare input to
584
+ * a temporary project first, exactly like `cmdExportAsm()` above. Prints
585
+ * every parsed assembler result line, then the verdict's own reason. The
586
+ * exit code comes from `verifyProject()`'s `ok` field -- which
587
+ * `r2000-verify.ts`'s `acmeVerdict()` derives ONLY from the parsed ACME
588
+ * result line, never from regenerator2000's own process exit code (D-10).
589
+ * A skipped ACME therefore prints and exits as a failure here, reading as
590
+ * exactly that -- never as a silent absence -- even though the underlying
591
+ * `--verify` process may itself have exited 0 with a summary line claiming
592
+ * success (r2000-verify.test.ts's pinned trap transcript is the live
593
+ * incident this guards against).
594
+ */
595
+ function cmdVerify(rest: string[]): number {
596
+ const { positional, entry, entryMissingValue } = parseArgs(rest);
597
+ // WR-08: `--out` is not in verify's own accepted set (VERB_OPTIONS above),
598
+ // so `checkAcceptedOptions()` already refuses it before this function ever
599
+ // runs -- only `--entry`'s missing-value case is reachable here.
600
+ if (entryMissingValue) {
601
+ console.error("verify: --entry requires a value\n");
602
+ console.log(USAGE);
603
+ return 1;
604
+ }
605
+ const input = positional[0];
606
+ if (!input) {
607
+ console.error("verify: usage: verify <input-or-project> [--entry NAME]");
608
+ return 1;
609
+ }
610
+ if (!existsSync(input)) {
611
+ console.error(`verify: input file not found: ${input}`);
612
+ return 1;
613
+ }
614
+
615
+ const ext = extname(input).toLowerCase();
616
+ let projectPath: string;
617
+ let tmpDir: string | undefined;
618
+
619
+ try {
620
+ if (ext === ".regen2000proj") {
621
+ projectPath = input;
622
+ } else {
623
+ // Bootstrap to a temp project first, so a bare .prg (or .d64/.raw)
624
+ // can be verified in one command with no human interaction.
625
+ tmpDir = mkdtempSync(join(tmpdir(), "r2000-cli-"));
626
+ const tmpProjectPath = join(tmpDir, "bootstrap.regen2000proj");
627
+ const outcome = bootstrapProject(input, { entry, outPath: tmpProjectPath });
628
+ if (outcome.code !== 0) return outcome.code;
629
+ projectPath = outcome.path!;
630
+ }
631
+
632
+ const result = verifyProject(projectPath);
633
+ for (const line of result.lines) {
634
+ const glyph = line.outcome === "ok" ? "✓" : "✗";
635
+ console.log(` ${glyph} ${line.assembler} — ${line.detail}`);
636
+ }
637
+
638
+ if (!result.ok) {
639
+ // Print the reason verbatim -- especially a "skipped" reason, which
640
+ // must read as a failure and not as an absence (D-10).
641
+ console.error(`verify: ${result.reason}`);
642
+ return 1;
643
+ }
644
+ console.log(`verify: ${result.reason}`);
645
+ return 0;
646
+ } finally {
647
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
648
+ }
649
+ }
650
+
651
+ interface GenEnumsParsedArgs {
652
+ positional: string[];
653
+ maxResults?: number;
654
+ maxResultsRaw?: string;
655
+ unknownOption?: string;
656
+ }
657
+
658
+ /** Fixed, closed option set for gen-enums -- exactly `--max-results`. Per
659
+ * WR-08's posture (do not silently accept a flag a verb does not
660
+ * implement), any OTHER `--flag`-shaped token is recorded as `unknownOption`
661
+ * and refused by the caller, rather than silently treated as a positional
662
+ * argument the way the other verbs' `parseArgs()` does. */
663
+ function parseGenEnumsArgs(rest: string[]): GenEnumsParsedArgs {
664
+ const positional: string[] = [];
665
+ let maxResults: number | undefined;
666
+ let maxResultsRaw: string | undefined;
667
+ let unknownOption: string | undefined;
668
+ for (let i = 0; i < rest.length; i++) {
669
+ const a = rest[i]!;
670
+ if (a === "--max-results") {
671
+ maxResultsRaw = rest[++i];
672
+ maxResults = maxResultsRaw !== undefined ? Number.parseInt(maxResultsRaw, 10) : Number.NaN;
673
+ } else if (a.startsWith("--")) {
674
+ unknownOption ??= a;
675
+ } else {
676
+ positional.push(a);
677
+ }
678
+ }
679
+ return { positional, maxResults, maxResultsRaw, unknownOption };
680
+ }
681
+
682
+ /**
683
+ * `gen-enums <project> [--max-results N]` -- D-20/D-22/D-23's whole pass,
684
+ * driven through `generateEnums()` (`r2000-enum-gen.ts`, Task 2). Prints the
685
+ * coverage report's own summary lines (total/paired/unpaired counts, one
686
+ * line per created/updated enum) and returns non-zero when: an unknown
687
+ * option was given (WR-08); no project path was given; the project file
688
+ * does not exist; `--max-results` did not parse as a positive integer;
689
+ * either search pass returned exactly its own ceiling (a possible-
690
+ * truncation signal, D-23's "no silent caps" applied at the CLI's own exit
691
+ * code); or `generateEnums()` itself threw (an illegal generated
692
+ * identifier, or an enum install/apply call that failed outright).
693
+ */
694
+ async function cmdGenEnums(rest: string[]): Promise<number> {
695
+ const { positional, maxResults, maxResultsRaw, unknownOption } = parseGenEnumsArgs(rest);
696
+ if (unknownOption) {
697
+ console.error(`gen-enums: unknown option "${unknownOption}"\n`);
698
+ console.log(USAGE);
699
+ return 1;
700
+ }
701
+
702
+ const project = positional[0];
703
+ if (!project) {
704
+ console.error("gen-enums: usage: gen-enums <project> [--max-results N]");
705
+ return 1;
706
+ }
707
+ if (!existsSync(project)) {
708
+ console.error(`gen-enums: input file not found: ${project}`);
709
+ return 1;
710
+ }
711
+ if (maxResults !== undefined && (!Number.isInteger(maxResults) || maxResults <= 0)) {
712
+ console.error(`gen-enums: --max-results must be a positive integer, got "${maxResultsRaw}"`);
713
+ return 1;
714
+ }
715
+
716
+ let report: Awaited<ReturnType<typeof generateEnums>>;
717
+ try {
718
+ report = maxResults !== undefined ? await generateEnums({ projectPath: project, maxResults }) : await generateEnums({ projectPath: project });
719
+ } catch (err) {
720
+ console.error(`gen-enums: ${errMsg(err)}`);
721
+ return 1;
722
+ }
723
+
724
+ for (const line of report.summaryLines) {
725
+ console.log(` ${line}`);
726
+ }
727
+
728
+ if (report.pass1Truncated || report.pass2Truncated) {
729
+ console.error(
730
+ "gen-enums: a search pass returned exactly its own max_results ceiling -- coverage may be incomplete; " +
731
+ "re-run with a higher --max-results",
732
+ );
733
+ return 1;
734
+ }
735
+
736
+ console.log(`gen-enums: ${report.enums.length} enum(s) created/updated`);
737
+ return 0;
738
+ }
739
+
740
+ interface ExportLblParsedArgs {
741
+ positional: string[];
742
+ out?: string;
743
+ outMissingValue?: boolean;
744
+ unknownOption?: string;
745
+ }
746
+
747
+ /** Fixed, closed option set for export-lbl -- exactly `--out`. Per WR-08's
748
+ * posture (do not silently accept a flag a verb does not implement, or a
749
+ * flag missing its value), any OTHER `--flag`-shaped token is refused as
750
+ * `unknownOption`, and `--out` with no value (or a flag-shaped "value") is
751
+ * refused as `outMissingValue`, rather than silently treated the way
752
+ * bootstrap/export-asm's own looser `parseArgs()` does. */
753
+ function parseExportLblArgs(rest: string[]): ExportLblParsedArgs {
754
+ const positional: string[] = [];
755
+ let out: string | undefined;
756
+ let outMissingValue = false;
757
+ let unknownOption: string | undefined;
758
+ for (let i = 0; i < rest.length; i++) {
759
+ const a = rest[i]!;
760
+ if (a === "--out") {
761
+ const value = rest[i + 1];
762
+ if (value === undefined || value.startsWith("--")) {
763
+ outMissingValue = true;
764
+ } else {
765
+ out = value;
766
+ i++;
767
+ }
768
+ } else if (a.startsWith("--")) {
769
+ unknownOption ??= a;
770
+ } else {
771
+ positional.push(a);
772
+ }
773
+ }
774
+ return { positional, out, outMissingValue, unknownOption };
775
+ }
776
+
777
+ interface ImportLblParsedArgs {
778
+ positional: string[];
779
+ unknownOption?: string;
780
+ }
781
+
782
+ /** import-lbl takes no options at all besides its two positional arguments
783
+ * -- any `--flag`-shaped token is refused as `unknownOption` (WR-08
784
+ * posture), never silently treated as a positional. */
785
+ function parseImportLblArgs(rest: string[]): ImportLblParsedArgs {
786
+ const positional: string[] = [];
787
+ let unknownOption: string | undefined;
788
+ for (const a of rest) {
789
+ if (a.startsWith("--")) unknownOption ??= a;
790
+ else positional.push(a);
791
+ }
792
+ return { positional, unknownOption };
793
+ }
794
+
795
+ /**
796
+ * `export-lbl <project> [--out FILE]` -- the R2000-14 export leg, via
797
+ * `r2000-symbols.ts`'s `exportLabels()`. Prints the written path and the
798
+ * symbol count parsed back from the file (never the raw regenerator2000
799
+ * exit code alone -- `exportLabels()` itself already re-reads and validates
800
+ * the file through stock-symbols.ts's parser before returning).
801
+ */
802
+ async function cmdExportLbl(rest: string[]): Promise<number> {
803
+ const { positional, out, outMissingValue, unknownOption } = parseExportLblArgs(rest);
804
+ if (unknownOption) {
805
+ console.error(`export-lbl: unknown option "${unknownOption}"\n`);
806
+ console.log(USAGE);
807
+ return 1;
808
+ }
809
+ if (outMissingValue) {
810
+ console.error("export-lbl: --out requires a value\n");
811
+ console.log(USAGE);
812
+ return 1;
813
+ }
814
+
815
+ const project = positional[0];
816
+ if (!project) {
817
+ console.error("export-lbl: usage: export-lbl <project> [--out FILE]");
818
+ return 1;
819
+ }
820
+ if (!existsSync(project)) {
821
+ console.error(`export-lbl: project file not found: ${project}`);
822
+ return 1;
823
+ }
824
+
825
+ const outPath = out ?? project.replace(/\.[^./\\]+$/, "") + ".lbl";
826
+ let result: Awaited<ReturnType<typeof exportLabels>>;
827
+ try {
828
+ result = await exportLabels({ projectPath: project, outPath });
829
+ } catch (err) {
830
+ console.error(`export-lbl: ${errMsg(err)}`);
831
+ return 1;
832
+ }
833
+
834
+ console.log(`export-lbl: wrote ${outPath} (${result.symbolCount} symbol(s))`);
835
+ return 0;
836
+ }
837
+
838
+ /**
839
+ * `import-lbl <project> <lbl>` -- the R2000-15/D-28 import leg, via
840
+ * `r2000-symbols.ts`'s `importLabels()`. Prints the names imported, then an
841
+ * explicit line naming that persistence was proven by an independent disk
842
+ * re-read (never left implicit, so the transcript itself shows the D-28
843
+ * trap was avoided). Exits non-zero -- naming the reason, which includes
844
+ * stock-symbols.ts's own ceiling messages verbatim when the given `.lbl`
845
+ * fails them -- whenever the result is not disk-verified.
846
+ */
847
+ async function cmdImportLbl(rest: string[]): Promise<number> {
848
+ const { positional, unknownOption } = parseImportLblArgs(rest);
849
+ if (unknownOption) {
850
+ console.error(`import-lbl: unknown option "${unknownOption}"\n`);
851
+ console.log(USAGE);
852
+ return 1;
853
+ }
854
+
855
+ const [project, lbl] = positional;
856
+ if (!project || !lbl) {
857
+ console.error("import-lbl: usage: import-lbl <project> <lbl>");
858
+ return 1;
859
+ }
860
+ if (!existsSync(project)) {
861
+ console.error(`import-lbl: project file not found: ${project}`);
862
+ return 1;
863
+ }
864
+ if (!existsSync(lbl)) {
865
+ console.error(`import-lbl: label file not found: ${lbl}`);
866
+ return 1;
867
+ }
868
+
869
+ let result: Awaited<ReturnType<typeof importLabels>>;
870
+ try {
871
+ result = await importLabels({ projectPath: project, lblPath: lbl });
872
+ } catch (err) {
873
+ console.error(`import-lbl: ${errMsg(err)}`);
874
+ return 1;
875
+ }
876
+
877
+ console.log(`import-lbl: imported ${result.importedNames.length} name(s): ${result.importedNames.join(", ")}`);
878
+ if (!result.diskVerified) {
879
+ console.error(`import-lbl: ${result.reason}`);
880
+ return 1;
881
+ }
882
+ console.log(
883
+ "import-lbl: persisted by an explicit r2000_save_project call over the same --mcp-server-stdio session " +
884
+ "(D-28) -- verified by re-reading the project from disk in a fresh process, not merely trusted from the " +
885
+ "child's own success text.",
886
+ );
887
+ return 0;
888
+ }
889
+
890
+ interface RenderMemmapParsedArgs {
891
+ positional: string[];
892
+ provenance?: string;
893
+ provenanceMissingValue?: boolean;
894
+ out?: string;
895
+ outMissingValue?: boolean;
896
+ check?: boolean;
897
+ unknownOption?: string;
898
+ }
899
+
900
+ /** Fixed, closed option set for render-memmap -- exactly `--provenance`,
901
+ * `--out` and `--check`. Per WR-08's posture (do not silently accept a flag
902
+ * a verb does not implement, or a flag missing its value), any OTHER
903
+ * `--flag`-shaped token is refused as `unknownOption`, and `--provenance`/
904
+ * `--out` with no value (or a flag-shaped "value") is refused via their own
905
+ * `*MissingValue` fields. */
906
+ function parseRenderMemmapArgs(rest: string[]): RenderMemmapParsedArgs {
907
+ const positional: string[] = [];
908
+ let provenance: string | undefined;
909
+ let provenanceMissingValue = false;
910
+ let out: string | undefined;
911
+ let outMissingValue = false;
912
+ let check = false;
913
+ let unknownOption: string | undefined;
914
+ for (let i = 0; i < rest.length; i++) {
915
+ const a = rest[i]!;
916
+ if (a === "--provenance") {
917
+ const value = rest[i + 1];
918
+ if (value === undefined || value.startsWith("--")) {
919
+ provenanceMissingValue = true;
920
+ } else {
921
+ provenance = value;
922
+ i++;
923
+ }
924
+ } else if (a === "--out") {
925
+ const value = rest[i + 1];
926
+ if (value === undefined || value.startsWith("--")) {
927
+ outMissingValue = true;
928
+ } else {
929
+ out = value;
930
+ i++;
931
+ }
932
+ } else if (a === "--check") {
933
+ check = true;
934
+ } else if (a.startsWith("--")) {
935
+ unknownOption ??= a;
936
+ } else {
937
+ positional.push(a);
938
+ }
939
+ }
940
+ return { positional, provenance, provenanceMissingValue, out, outMissingValue, check, unknownOption };
941
+ }
942
+
943
+ /**
944
+ * `render-memmap <project> --provenance FILE [--out FILE] [--check]` --
945
+ * D-24's generated-view verb, via `r2000-memmap-render.ts`'s
946
+ * `renderMemoryMap()`/`checkRenderedMemoryMap()`. Never writes a file when
947
+ * `--check` is given -- that mode only reads and reports.
948
+ */
949
+ async function cmdRenderMemmap(rest: string[]): Promise<number> {
950
+ const {
951
+ positional,
952
+ provenance,
953
+ provenanceMissingValue,
954
+ out,
955
+ outMissingValue,
956
+ check,
957
+ unknownOption,
958
+ } = parseRenderMemmapArgs(rest);
959
+
960
+ if (unknownOption) {
961
+ console.error(`render-memmap: unknown option "${unknownOption}"\n`);
962
+ console.log(USAGE);
963
+ return 1;
964
+ }
965
+ if (provenanceMissingValue) {
966
+ console.error("render-memmap: --provenance requires a value\n");
967
+ console.log(USAGE);
968
+ return 1;
969
+ }
970
+ if (outMissingValue) {
971
+ console.error("render-memmap: --out requires a value\n");
972
+ console.log(USAGE);
973
+ return 1;
974
+ }
975
+
976
+ const project = positional[0];
977
+ if (!project) {
978
+ console.error("render-memmap: usage: render-memmap <project> --provenance FILE [--out FILE] [--check]");
979
+ return 1;
980
+ }
981
+ if (!existsSync(project)) {
982
+ console.error(`render-memmap: project file not found: ${project}`);
983
+ return 1;
984
+ }
985
+ if (!provenance) {
986
+ console.error("render-memmap: --provenance FILE is required\n");
987
+ console.log(USAGE);
988
+ return 1;
989
+ }
990
+ if (!existsSync(provenance)) {
991
+ console.error(`render-memmap: provenance sidecar not found: ${provenance}`);
992
+ return 1;
993
+ }
994
+
995
+ const outPath = out ?? join(dirname(project), "memory-map.md");
996
+
997
+ if (check) {
998
+ let result: Awaited<ReturnType<typeof checkRenderedMemoryMap>>;
999
+ try {
1000
+ result = await checkRenderedMemoryMap({ projectPath: project, provenancePath: provenance, renderedPath: outPath });
1001
+ } catch (err) {
1002
+ console.error(`render-memmap: ${errMsg(err)}`);
1003
+ return 1;
1004
+ }
1005
+ if (result.status === "in-sync") {
1006
+ console.log(`render-memmap: in sync (${outPath})`);
1007
+ return 0;
1008
+ }
1009
+ if (result.status === "missing") {
1010
+ console.error(`render-memmap: missing -- ${outPath} does not exist yet. Run render-memmap without --check first.`);
1011
+ return 1;
1012
+ }
1013
+ console.error(`render-memmap: drifted at line ${result.line}`);
1014
+ console.error(` expected: ${result.expected}`);
1015
+ console.error(` actual: ${result.actual}`);
1016
+ return 1;
1017
+ }
1018
+
1019
+ let rendered: Awaited<ReturnType<typeof renderMemoryMap>>;
1020
+ try {
1021
+ rendered = await renderMemoryMap({ projectPath: project, provenancePath: provenance });
1022
+ } catch (err) {
1023
+ console.error(`render-memmap: ${errMsg(err)}`);
1024
+ return 1;
1025
+ }
1026
+ try {
1027
+ writeFileSync(outPath, rendered.markdown);
1028
+ } catch (err) {
1029
+ // WR-09 (D-11.1-04): the same shape as bootstrapProject()'s write above,
1030
+ // one verb over -- an ordinary write failure (missing parent directory,
1031
+ // permissions, full disk) must not throw past this verb's own
1032
+ // never-throw contract.
1033
+ console.error(`render-memmap: could not write ${outPath}: ${errMsg(err)}`);
1034
+ return 1;
1035
+ }
1036
+ console.log(
1037
+ `render-memmap: wrote ${outPath} (${rendered.rowCount} row(s), ${rendered.unknownCount} [unknown], digest ${rendered.renderDigest})`,
1038
+ );
1039
+ return 0;
1040
+ }
1041
+
1042
+ /**
1043
+ * Entry point for the `r2000` subcommand. Returns an exit code; never calls
1044
+ * exit the process directly (the bin does that). Handles `--help`/no verb/unknown
1045
+ * verb per `acme.mjs`'s own dispatch convention (`.claude/skills/acme-build/
1046
+ * scripts/acme.mjs`), with one deliberate difference: an explicit `--help`
1047
+ * returns 0 (a no-op invocation with no verb also returns 0), while an
1048
+ * unrecognised verb returns 1.
1049
+ */
1050
+ export async function runR2000Cli(argv: string[]): Promise<number> {
1051
+ const [verb, ...rest] = argv;
1052
+
1053
+ if (!verb || verb === "--help" || verb === "-h") {
1054
+ console.log(USAGE);
1055
+ return 0;
1056
+ }
1057
+
1058
+ // IN-06 (D-11.1-04): the single call site for the shared verb-options
1059
+ // check, run BEFORE dispatch so a refused option never reaches any cmd*
1060
+ // function -- one place enforces the closed option set for every verb,
1061
+ // rather than seven places each doing (or, as `verify` proved, NOT doing)
1062
+ // it themselves.
1063
+ const optionError = checkAcceptedOptions(verb, rest);
1064
+ if (optionError) {
1065
+ console.error(optionError);
1066
+ console.log(USAGE);
1067
+ return 1;
1068
+ }
1069
+
1070
+ try {
1071
+ switch (verb) {
1072
+ case "bootstrap":
1073
+ return cmdBootstrap(rest);
1074
+ case "export-asm":
1075
+ return cmdExportAsm(rest);
1076
+ case "verify":
1077
+ return cmdVerify(rest);
1078
+ case "gen-enums":
1079
+ return await cmdGenEnums(rest);
1080
+ case "export-lbl":
1081
+ return await cmdExportLbl(rest);
1082
+ case "import-lbl":
1083
+ return await cmdImportLbl(rest);
1084
+ case "render-memmap":
1085
+ return await cmdRenderMemmap(rest);
1086
+ default:
1087
+ console.error(`r2000: unknown verb "${verb}"\n`);
1088
+ console.log(USAGE);
1089
+ return 1;
1090
+ }
1091
+ } catch (err) {
1092
+ // An R2000ViceFlagError from the seam (or any other unexpected throw)
1093
+ // must be re-thrown or reported verbatim, never swallowed -- the loud
1094
+ // failure is the point (D-07). This is a last-resort net: every expected
1095
+ // failure path above already returns its own code with its own message.
1096
+ if (err instanceof R2000ViceFlagError) {
1097
+ console.error(err.message);
1098
+ return 1;
1099
+ }
1100
+ console.error(`r2000: ${errMsg(err)}`);
1101
+ return 1;
1102
+ }
1103
+ }