---
phase: 01-extraction
plan: 06
subsystem: extract-gmd / CLI dispatcher + decomp/TOOLS.md (EXT-08)
tags: [extract, cli, dispatcher, exit-codes, decomp-tools, ext-07, ext-08, lateralgm-port]
requires:
  - tools/extract-gmd/src/reader/readProjectFile.ts (plan 04)
  - tools/extract-gmd/src/emit/index.ts (plan 05 — emitProject async + verifyManifest sync + getSharpVersions)
  - tools/extract-gmd/tests/fixtures/build-fixtures.ts (plan 04 — buildTinyFull)
  - decomp/wiki/15-extraction-pipeline.md (substantive 4-tier methodology)
  - decomp/wiki/13-modern-tool-incompat.md (UTMT/Altar.NET hard-ban list)
provides:
  - extract(inputPath, outDir): Promise<void> — readFileSync + readProjectFile + emitProject glue
  - verify(sourceDir): VerifyResult — CLI-friendly wrapper around verifyManifest; missing-dir surfaces as ok=false (no throw, VR-03)
  - main(argv): Promise<number> — CLI argv dispatcher with exit-code matrix 0/1/2 (success/functional/usage)
  - decomp/TOOLS.md — EXT-08 deliverable; thin wrapper around wiki/15-extraction-pipeline.md per D-19
  - "pnpm extract:all script (created in plan 01) now runs end-to-end against any input .gmd"
affects:
  - tools/extract-gmd/cli.ts — first CLI entrypoint; shebang + direct-invocation guard via argv[1] suffix check
  - tools/extract-gmd/src/extract.ts — first library glue layer
  - tools/extract-gmd/src/verify.ts — first CLI-friendly result wrapper
tech-stack:
  added: []
  patterns:
    - "Exit-code matrix: 0=success, 1=functional failure (parse/drift/IO), 2=usage error (no/unknown command, missing args)"
    - "verify() catches all errors and surfaces as ok=false rather than throwing — keeps CLI dispatcher's exit-code logic linear"
    - "Direct-invocation guard via argv[1].endsWith('cli.ts') | endsWith('cli.js') — works under tsx (cli.ts) AND node (cli.js); test imports skip the auto-run branch"
    - "stdout=success messages, stderr=usage+failures (POSIX convention; CLI-04 asserts FAIL line on stderr, not stdout)"
    - "TOOLS.md documents canonical-platform=linux-x64 rule for committed PNG hashes; cross-platform drift is soft warning, not CI failure"
key-files:
  created:
    - tools/extract-gmd/cli.ts
    - tools/extract-gmd/src/extract.ts
    - tools/extract-gmd/src/verify.ts
    - tools/extract-gmd/tests/integration/cli-extract.test.ts
    - tools/extract-gmd/tests/integration/cli-verify.test.ts
    - decomp/TOOLS.md
  modified: []
decisions:
  - "Exit-code matrix locked at 0/1/2 (success/functional/usage). CLI-04 tests assert exit-code 1 on drift specifically (not 2); plan 07 + Phase 5 CI rely on this distinction."
  - "verify() wraps verifyManifest in try/catch and converts missing-dir / missing-MANIFEST to {ok:false, driftLines:['ERROR ...']} rather than throwing. Rationale: keeps cli.ts dispatcher straight-line — no extra rejection-handling branch — and keeps `pnpm extract:verify` exit code in the 0/1 lane (never 1 from an unhandled rejection)."
  - "Direct-invocation guard uses `.endsWith('cli.ts') || .endsWith('cli.js')` rather than `import.meta.url === ...` alone. Reason: under tsx the entrypoint is `cli.ts` but the import.meta URL still has the .ts extension; under a future tsc-build path it would be `cli.js`. Both must auto-run; vitest imports skip both because the test file path doesn't end in cli.{ts,js}."
  - "decomp/TOOLS.md is a thin wrapper around wiki/15-extraction-pipeline.md per D-19 — content NOT duplicated. Adds installation/provenance, canonical-platform rule, and explicit `tools/extract-gmd` daily-extractor naming. 12 ATX headings, 145 lines, See also footer per wiki style."
  - "Lint string `linux-x64` chosen verbatim (lowercase + hyphen) so it matches Node's `process.platform`/arch idiom and the Fly.io runner architecture identifier — readable with both human + grep eyes."
metrics:
  duration: ~10min
  tasks: 2
  files_created: 6
  files_modified: 0
  tests_added: 11 (EX-01..03, VR-01..03, CLI-01..05)
  cumulative_tests: 137/137
  completed: 2026-05-02
---

# Phase 01 Plan 06: CLI Dispatcher + decomp/TOOLS.md Summary

CLI is invocable. `pnpm tsx tools/extract-gmd/cli.ts extract <input.gmd> <out-dir>` reads any `.gmd` and produces the full D-07 tree + MANIFEST.sha256. `verify <src>` returns exit 0 on byte-identity, 1 on drift (with drift report on stderr). `decomp/TOOLS.md` ships as the EXT-08 deliverable: thin wrapper around `wiki/15-extraction-pipeline.md` per D-19, with the canonical-platform=`linux-x64` rule documented and UTMT/Altar.NET hard-banned per CLAUDE.md hard rule #7. The repo-root `pnpm extract:all` script (defined in plan 01) is now end-to-end functional; plan 07 invokes it against the real `BN Online Client 5-8.gmd` and `BN Online Master 5-4.gmd` files.

## Tasks

### Task 1 — CLI dispatcher + extract.ts + verify.ts (TDD)

**Commits:** `f20fc02` test RED, `458590b` feat GREEN

- `src/extract.ts` (new) — library glue: `readFileSync(input)` → `readProjectFile(buf)` → `await emitProject(outDir, project, getSharpVersions())`. Async (sharp.toFile internally awaits).
- `src/verify.ts` (new) — CLI-friendly wrapper. Returns `VerifyResult { ok, driftCount, driftLines }`. Missing dir / missing MANIFEST.sha256 / unreadable surfaces as `ok=false` rather than throwing (VR-03 explicit). Drift lines formatted as `DRIFT  <relpath>  expected=<12hex>…  actual=<12hex>…`.
- `cli.ts` (new) — first CLI entrypoint in this project. `#!/usr/bin/env node` shebang. Exports `main(argv): Promise<number>`. Switch on `cmd ∈ {extract, verify, help, --help, -h}`. Auto-runs when invoked directly (detected via `argv[1].endsWith('cli.{ts,js}')`); test imports skip the auto-run branch.
- `tests/integration/cli-extract.test.ts` (new) — 8 tests: EX-01..03 (extract.ts: success / ENOENT / bad-magic) + CLI-01..05 (dispatcher: no cmd / extract / verify-after-extract / drift / unknown).
- `tests/integration/cli-verify.test.ts` (new) — 3 tests: VR-01 (clean tree → ok=true) / VR-02 (mutated file → ok=false, driftLines names mutated path) / VR-03 (nonexistent dir → ok=false, NO throw).

11 new tests pass first try after a single tsc cleanup (test-file `vi.spyOn` lambda needed explicit `(c: unknown[])` param type for strict-mode — not a behavior change).

### Task 2 — decomp/TOOLS.md (EXT-08 deliverable)

**Commit:** `302e6b6` docs

- Single new file `decomp/TOOLS.md` (145 lines, 12 ATX headings, See also footer).
- Per D-19: thin wrapper around `decomp/wiki/15-extraction-pipeline.md` — does NOT duplicate content; links instead.
- Required-section greppability (per VALIDATION.md per-task lint): `Rank 1`, `Rank 2`, `Rank 3`, `Rank 4`, `tools/extract-gmd`, `linux-x64`, `LateralGM`, `UndertaleModTool`, `wiki/15-extraction-pipeline.md` — all 9 strings present (lint script exits 0).
- Sections: primary daily extractor (install/run/output structure/reproducibility) → Rank 1 native source (already satisfied for both BNO `.gmd`s) → Rank 2 LateralGM oracle → Rank 3 WinXP VM (doc-only per D-04) → Rank 4 modern-tool HARD BAN (UTMT/Altar.NET per wiki/13 + CLAUDE.md hard rule #7) → file-extension cross-ref (`.gb1` ≡ `.gmd` per wiki/14) → See also footer.
- Canonical-platform rule documented explicitly: committed PNG hashes are authoritative on `linux-x64` only; macOS/Windows hash drift is soft warning, NOT CI failure.
- Cracked GM 5.3a IDE in `legacy/open-source-release-extras/` is explicitly called out as "do NOT use; source a legitimate copy" — TOOLS.md is the documentation control side of T-06-02.

## Acceptance Criteria

- [x] `tools/extract-gmd/cli.ts` exists; first line begins with `#!/usr/bin/env node` shebang
- [x] `tools/extract-gmd/cli.ts` exports `main(argv): Promise<number>`
- [x] `tools/extract-gmd/src/extract.ts` exports `extract(inputPath, outDir): Promise<void>`; greppable for `readProjectFile` AND `emitProject` AND `getSharpVersions`
- [x] `tools/extract-gmd/src/verify.ts` exports `verify(sourceDir): VerifyResult`
- [x] EX-01..03 pass (extract.ts: success / ENOENT / bad-magic)
- [x] VR-01..03 pass; VR-02 specifically asserts non-zero driftCount after file mutation
- [x] CLI-01..05 pass; CLI-04 specifically returns 1 (not 0, not 2) on drift
- [x] `npx vitest run` — 137/137 cumulative green; no regression in plans 01-05
- [x] `npx tsc --noEmit` exits 0 (strict TS clean)
- [x] `decomp/TOOLS.md` exists; greppable for `Rank 1..4`, `tools/extract-gmd`, `linux-x64`, `LateralGM`, `UndertaleModTool`, `wiki/15-extraction-pipeline.md`
- [x] `decomp/TOOLS.md` ≥ 60 lines (actual: 145)
- [x] `decomp/TOOLS.md` contains "See also" footer matching wiki convention
- [x] `pnpm extract:all` script defined at repo root + reachable (verified — package.json lines 6-9 from plan 01)
- [x] EXT-07 satisfied: deterministic re-runs (carried from plan 05; CLI is now the public entrypoint)
- [x] EXT-08 satisfied: decomp/TOOLS.md complete

## Exit-Code Matrix (CLI Contract)

| Exit | Trigger | Test |
|------|---------|------|
| 0 | `extract` succeeds + tree written | CLI-02 |
| 0 | `verify` succeeds + manifest matches | CLI-03 |
| 0 | `help` / `--help` / `-h` | (covered by switch — no separate test, manually verified) |
| 1 | `extract` throws (ENOENT, bad-magic, parse error) | EX-02, EX-03 (extract.ts level) — CLI dispatcher returns 1 from catch block |
| 1 | `verify` finds drift | CLI-04 (asserts code===1 + FAIL on stderr) |
| 1 | `verify` on missing dir (caught in verify.ts) | (VR-03 returns ok=false; CLI dispatcher converts to 1) |
| 2 | no command at all | CLI-01 |
| 2 | unknown command | CLI-05 |
| 2 | required arg missing (no input/outDir for extract; no srcDir for verify) | (covered by switch — no separate test) |

## Plan 07 Carry Notes

- **CLI is ready for real-`.gmd` end-to-end run.** Entrypoint: `pnpm extract:all` (defined in repo-root package.json, plan 01). It will run `extract:client` then `extract:server`, producing `extracted/client-5-8/` and `extracted/server-5-4/` with full D-07 trees + MANIFEST.sha256.
- **Verification entrypoint:** `pnpm extract:verify` runs `verify` against both extracted trees. Plan 07 commits the trees AND their manifests; CI (Phase 5) re-runs `extract:verify` on every PR.
- **Cross-platform PNG byte stability still untested.** TR-10 (plan 05) verified determinism on a single Windows host. Plan 07 should run extraction on Linux x64 and capture canonical hashes there if Phase 5 CI is targeted; if plan 07 commits hashes from a Windows host, those hashes are NOT authoritative for future Linux CI runs (per `decomp/TOOLS.md` Reproducibility section).
- **W5 fragility (rooms editor-info trailer)** carried from plan 04: if rooms-block cursor drift fires mid-extraction, capture `tests/fixtures/v530-room-trailer.bin` from the first room of `BN Online Client 5-8.gmd` as the triage baseline.
- **W4 framing (12 event types + sentinel)** carried from plan 04: if `MAX_EVENTS_PER_TYPE=1024` cap throws, framing assumption needs revision.

## Deviations from Plan

None. Plan executed exactly as written. One small RED→GREEN tsc cleanup (added `(c: unknown[])` param type to three `vi.spyOn` lambdas in `cli-extract.test.ts` to satisfy `noUncheckedIndexedAccess` + `strict`) is a test-author correction, not a behavior change — runtime tests already passed before the type annotation was added; tsc surfaced the missing annotation only after.

## Self-Check: PASSED

- `tools/extract-gmd/cli.ts` exists on disk
- `tools/extract-gmd/src/extract.ts` exists on disk
- `tools/extract-gmd/src/verify.ts` exists on disk
- `tools/extract-gmd/tests/integration/cli-extract.test.ts` exists on disk
- `tools/extract-gmd/tests/integration/cli-verify.test.ts` exists on disk
- `decomp/TOOLS.md` exists on disk
- 3 new commits in `git log`: `f20fc02` (test RED T1), `458590b` (feat GREEN T1), `302e6b6` (docs T2)
- `decomp/TOOLS.md` content lint script reports `decomp/TOOLS.md content lint OK` (all 9 required strings present)
- `decomp/TOOLS.md` has 12 `^#` headings (≥ 6 required)
- `decomp/TOOLS.md` has 145 lines (≥ 60 required)
- `npx vitest run` — 137/137 green; `npx tsc --noEmit` exits 0
