Skip to content

refactor: decompose CLI into per-command modules (M5.T1)#27

Merged
flyingrobots merged 21 commits intomainfrom
commands-split
Feb 13, 2026
Merged

refactor: decompose CLI into per-command modules (M5.T1)#27
flyingrobots merged 21 commits intomainfrom
commands-split

Conversation

@flyingrobots
Copy link
Member

@flyingrobots flyingrobots commented Feb 13, 2026

Summary

  • Decomposes the 2491-line bin/warp-graph.js monolith into 15 focused modules under bin/cli/
  • Entrypoint reduced to 112 LOC (target was <300): imports, COMMANDS map, main(), error handler
  • Pure refactor — zero behavior changes, all public CLI contracts preserved

New module structure

Module LOC Purpose
bin/cli/types.js 84 JSDoc typedefs (Persistence, WarpGraphInstance, etc.)
bin/cli/infrastructure.js 267 EXIT_CODES, HELP_TEXT, CliError, parseArgs
bin/cli/shared.js 244 12 helpers used by 2+ commands
bin/cli/commands/*.js 10 files Per-command handlers

Largest command module is seek.js (711 LOC, 20+ tightly interrelated helpers). Added to ESLint relaxed-complexity block.

Test plan

  • npm run lint — clean
  • npx tsc --noEmit — clean
  • node scripts/ts-policy-check.js — passed
  • npm run test:local — 3476 tests pass (170 files)
  • wc -l bin/warp-graph.js — 112 LOC
  • Pre-push hooks (lint + typecheck + policy + full unit suite) — all green
  • BATS CLI integration tests (CI)

Summary by CodeRabbit

  • New Features

    • Many new CLI commands (verify-audit, check, info, history, query, path, materialize, seek, install-hooks, view) plus human/JSON renderers and optional TUI.
  • Refactor

    • CLI reworked into a modular per-command dispatcher with unified argument parsing, validation, and view handling.
  • Documentation

    • Changelog, guide, and spec updated with audit verification details; roadmap items marked done.
  • Tests

    • Extensive unit, integration, and benchmark tests covering CLI parsing and audit verification.
  • Chores

    • Package version bumped and CLI files included in distribution.

CI Bot added 2 commits February 12, 2026 14:25
…-AUDIT, v10.10.0)

Adds AuditVerifierService domain service and `git warp verify-audit` CLI
command that walks audit chains backward from tip to genesis, validating
schema, chain linking, Git parent consistency, tick monotonicity,
trailer-CBOR consistency, OID format, and tree structure. Supports
`--writer` filtering, `--since` partial verification, and ref-race
detection. Exit code 3 on integrity failures.
…ANDS-SPLIT, v10.11.0)

Split bin/warp-graph.js (2491 LOC) into 15 focused modules:
- bin/warp-graph.js: 112 LOC entrypoint (imports + COMMANDS map + dispatch)
- bin/cli/infrastructure.js: EXIT_CODES, HELP_TEXT, CliError, parseArgs
- bin/cli/shared.js: 12 helpers used by 2+ commands
- bin/cli/types.js: JSDoc typedefs
- bin/cli/commands/: 10 per-command handlers

Pure refactor — no behavior changes. All 3476 unit tests pass.
@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces a monolithic CLI with a modular per-command dispatch, adds a verify-audit feature and AuditVerifierService, introduces robust CLI parsing/schemas and helper infra, extends persistence ports/adapters with commit→tree support, wires presenters, adds tests/benchmarks, docs, changelog and packaging updates.

Changes

Cohort / File(s) Summary
CLI Core & Types
bin/warp-graph.js, bin/cli/infrastructure.js, bin/cli/shared.js, bin/cli/types.js, bin/cli/schemas.js
New modular dispatch, centralized parseArgs, CliError types, per-command arg plumbing, cursor/hook/persistence helpers, and Zod schemas/JSDoc typedefs.
Command Handlers
bin/cli/commands/*
bin/cli/commands/check.js, .../history.js, .../info.js, .../install-hooks.js, .../materialize.js, .../path.js, .../query.js, .../seek.js, .../verify-audit.js, .../view.js, bin/cli/commands/registry.js
Adds discrete command modules with parsing, graph interactions, payload construction, optional view rendering, and a COMMANDS registry for dispatch (seek and verify-audit are large).
Presenters & Renderers
bin/presenters/index.js, bin/presenters/text.js
Registers and implements a verify-audit text renderer and wires it into TEXT_RENDERERS.
Audit Verification Domain
src/domain/services/AuditVerifierService.js
New AuditVerifierService: walks audit receipt chains, decodes CBOR/trailers, validates schema/OIDs/trailers/links, and produces per-chain statuses, errors, warnings and summaries.
Persistence / Ports
src/infrastructure/adapters/GitGraphAdapter.js, src/infrastructure/adapters/InMemoryGraphAdapter.js, src/ports/CommitPort.js
Adds getCommitTree(sha) to adapters and the CommitPort interface to support commit→tree lookup used by audit verification.
Ref utilities
src/domain/utils/RefLayout.js
Adds buildAuditPrefix(graphName) to enumerate audit refs for a graph.
Tests, Benchmarks & Helpers
test/...
test/bats/cli-verify-audit.bats, test/bats/helpers/seed-audit-graph.js, test/unit/domain/services/AuditVerifierService.test.js, ...bench.js, test/unit/ports/GraphPersistencePort.test.js, test/unit/cli/*.test.js
Extensive unit tests, BATS CLI tests, a benchmark, and helpers for verify-audit; updates port tests to expect getCommitTree.
Docs, Changelog & Roadmap
CHANGELOG.md, ROADMAP.md, docs/GUIDE.md, docs/specs/AUDIT_RECEIPT.md
Adds changelog entries (10.11.0, 10.12.0), marks roadmap tasks done, and documents the verify-audit command and verification output schema.
Config & Packaging
eslint.config.js, package.json
Expands ESLint targets to include audit/CLI files, bumps package version to 10.12.0, and includes bin/cli in package files.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI (verify-audit)
    participant Service as AuditVerifierService
    participant Persistence as GitGraphAdapter / InMemoryGraphAdapter
    participant Codec as AuditMessageCodec

    CLI->>Service: verifyChain(graph, writerId, { since? })
    Service->>Persistence: readRef(auditRef) / readCommit(tipSha)
    Persistence-->>Service: tipCommit, commitInfo
    loop for each receipt from tip → genesis
        Service->>Persistence: getCommitTree(commitSha)
        Persistence-->>Service: treeOid
        Service->>Persistence: readTree(treeOid) / readBlob(receipt.cbor)
        Persistence-->>Service: receiptBlob
        Service->>Codec: decode(receiptBlob)
        Codec-->>Service: receipt, decodedTrailers
        Service->>Service: validateReceiptSchema(...) / validateTrailerConsistency(...)
        alt has prevAuditCommit
            Service->>Persistence: readCommit(prevSha)
            Persistence-->>Service: prevCommitInfo
            Service->>Service: _validateChainLink(prevReceipt)
        end
    end
    Service->>Persistence: readRef(auditRef) (re-check tip for races)
    Persistence-->>Service: currentTip
    Service->>CLI: { status, receiptsVerified, errors, warnings, chains }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I split the pile into tidy rows,
Commands now hop where each one goes,
I sniff the receipts, one by one,
I spot the gnaws and call them done,
A carrot for code well-run! 🥕

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor: decompose CLI into per-command modules (M5.T1)' accurately and concisely summarizes the main change in the changeset: splitting the monolithic bin/warp-graph.js file into a modular per-command CLI structure under bin/cli/.
Docstring Coverage ✅ Passed Docstring coverage is 94.51% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch commands-split

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

Release Preflight

  • package version: 10.11.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.11.0, release workflow will publish.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
package.json (1)

43-56: ⚠️ Potential issue | 🔴 Critical

bin/cli/ directory is missing from the files array — published package will break at runtime.

bin/warp-graph.js imports from bin/cli/infrastructure.js, bin/cli/shared.js, and bin/cli/commands/*.js, but bin/cli is not listed in the files array. Running npm pack will exclude the entire bin/cli/ directory, causing MODULE_NOT_FOUND errors for any consumer.

Proposed fix
   "files": [
     "bin/warp-graph.js",
+    "bin/cli",
     "bin/presenters",
     "bin/git-warp",
     "src",
🤖 Fix all issues with AI agents
In `@bin/cli/commands/verify-audit.js`:
- Around line 24-27: The argument parsing loop that checks for flag values
treats empty strings as absent because it uses a truthy check (args[i +
1])—update the check in the for-loop that handles '--since' and '--writer' so it
uses strict undefined comparison (args[i + 1] !== undefined) and still
increments i and assigns since/writer when the next element is an empty string;
ensure the branch that currently does `since = args[i + 1]; i++;` and the
analogous `writer = args[i + 1]; i++;` are reached when the next element is not
undefined.
- Around line 24-32: The args-parsing loop in verify-audit.js currently ignores
unrecognized flags (loop handling for --since and --writer), so update it to
reject unknown args by throwing usageError with a helpful message when an arg is
not --since or --writer or when a flag is missing its value; also add an import
for usageError at the top (currently only EXIT_CODES is imported) and use the
existing variables since and writerFilter to validate/assign values before
continuing. Ensure the thrown usageError mirrors other modules (history.js,
path.js, install-hooks.js) so typos like --writter trigger an error instead of
being silently skipped.

In `@CHANGELOG.md`:
- Line 19: The changelog claim that `bin/warp-graph.js` was "replaced" is
inaccurate because `bin/warp-graph.js` still remains in the ESLint
relaxed-complexity block; update the CHANGELOG.md entry to reflect the actual
change (e.g., "Added `bin/cli/commands/seek.js`, `bin/cli/commands/query.js`,
and other `bin/cli/` modules to the relaxed-complexity block") or adjust the
ESLint config (`eslint.config.js`) to remove `bin/warp-graph.js` if it was truly
replaced—refer to the relaxed-complexity block and the filenames
`bin/warp-graph.js`, `bin/cli/commands/seek.js`, and `bin/cli/commands/query.js`
when making the correction.

In `@docs/specs/AUDIT_RECEIPT.md`:
- Line 730: The section numbering skips 13: update the header "## 14.
Verification Output (M4.T1)" to "## 13. Verification Output (M4.T1)" or insert a
new "## 13" section before it (e.g., "## 13. [Title]" with appropriate content),
ensuring sequential numbering; specifically adjust the markdown header text "##
14. Verification Output (M4.T1)" in AUDIT_RECEIPT.md (or add a new "## 13"
block) so sections proceed 12 → 13 → 14 consistently.

In `@test/bats/cli-verify-audit.bats`:
- Around line 72-95: WRONG_PARENT is assigned but never used; either remove the
UNUSED assignment or use it to create a commit with an explicit wrong parent.
Fix by either deleting the WRONG_PARENT="$(printf '0%.0s' {1..40})" line, or
change the commit creation to include it as a parent (replace the commit-tree
invocation that sets FAKE with something like using git commit-tree -p
"${WRONG_PARENT}" "${TREE}" while keeping MSG as the commit message) so the
variable is actually referenced (symbols: WRONG_PARENT, FAKE, TREE, MSG, git
commit-tree).
🧹 Nitpick comments (12)
bin/cli/commands/view.js (1)

27-34: Broad Cannot find module check could mask sub-dependency failures.

If startTui throws an error whose message contains "Cannot find module" (e.g., a broken sub-dependency within @git-stunts/git-warp-tui), it would be caught and reported as "Install with: npm install -g @git-stunts/git-warp-tui" instead of surfacing the real error.

Consider tightening the string check to match the specific package name:

Suggested improvement
-    if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
+    if (err.code === 'ERR_MODULE_NOT_FOUND' && err.message?.includes('@git-stunts/git-warp-tui')
+      || (err.message && err.message.includes("Cannot find module '@git-stunts/git-warp-tui'"))) {
bin/cli/commands/info.js (1)

75-89: Sequential patch fetching could be parallelized for view mode.

The for loop on lines 84-87 fetches patches for each writer sequentially. For graphs with many writers in view mode, this could be slow. Consider using Promise.all for concurrent fetching.

Proposed parallel fetching
-    /** `@type` {Record<string, number>} */
-    const writerPatches = {};
-    for (const writerId of writerIds) {
-      const patches = await graph.getWriterPatches(writerId);
-      writerPatches[/** `@type` {string} */ (writerId)] = patches.length;
-    }
+    /** `@type` {Record<string, number>} */
+    const writerPatches = {};
+    await Promise.all(writerIds.map(async (writerId) => {
+      const patches = await graph.getWriterPatches(writerId);
+      writerPatches[/** `@type` {string} */ (writerId)] = patches.length;
+    }));
bin/cli/commands/install-hooks.js (1)

43-60: Duplicate HookInstaller instantiation.

promptForOursStrategy creates a new HookInstaller (line 44) solely to read _version, but handleInstallHooks already creates one at line 98. Consider passing the installer (or its version) as a parameter to avoid the redundant construction and filesystem reads.

Proposed fix: pass installer version through
-/** `@param` {*} classification */
-async function promptForOursStrategy(classification) {
-  const installer = createHookInstaller();
-  if (classification.version === installer._version) {
+/**
+ * `@param` {*} classification
+ * `@param` {string} currentVersion
+ */
+async function promptForOursStrategy(classification, currentVersion) {
+  if (classification.version === currentVersion) {
     return 'up-to-date';
   }
 
@@ ..
   const answer = await promptUser(
-    `Upgrade hook from v${classification.version} to v${installer._version}? [Y/n] `,
+    `Upgrade hook from v${classification.version} to v${currentVersion}? [Y/n] `,
   );

Then update resolveStrategy to accept and forward the version:

-async function resolveStrategy(classification, hookOptions) {
+async function resolveStrategy(classification, hookOptions, currentVersion) {
   ...
   if (classification.kind === 'ours') {
-    return await promptForOursStrategy(classification);
+    return await promptForOursStrategy(classification, currentVersion);
   }

And in handleInstallHooks:

-  const strategy = await resolveStrategy(classification, hookOptions);
+  const strategy = await resolveStrategy(classification, hookOptions, installer._version);
bin/cli/commands/path.js (1)

97-103: parseMaxDepth accepts negative values.

Number.parseInt("-1", 10) passes the NaN check but negative depth values are likely nonsensical for a shortest-path traversal.

Proposed validation
 function parseMaxDepth(value) {
   const parsed = Number.parseInt(value, 10);
-  if (Number.isNaN(parsed)) {
+  if (Number.isNaN(parsed) || parsed < 0) {
     throw usageError('Invalid value for --max-depth');
   }
   return parsed;
 }
bin/cli/commands/seek.js (2)

252-259: Minor UX nit: --diff error message for default status action could be confusing.

When the user passes --diff without any action flag, spec.action is still 'status' (the default). The resulting error message --diff cannot be used with --status references a --status flag that doesn't exist. Consider a more user-friendly message:

Suggested improvement
  const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
  if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
-   throw usageError(`--diff cannot be used with --${spec.action}`);
+   throw usageError(`--diff requires --tick, --latest, or --load`);
  }

438-463: applyDiffLimit caps in a fixed order — certain change categories may be entirely suppressed.

The cap() closure is applied sequentially: nodes added/removed → edges added/removed → props set/removed. If there are many node additions, edges and props will be entirely cut. This is acceptable for a truncation strategy, but documenting the priority (or applying a proportional budget) might improve UX for very diverse diffs. This is a design choice rather than a bug.

test/bats/cli-verify-audit.bats (1)

97-120: Test manually re-creates repo setup — consider extracting a helper.

This test duplicates the repo initialization logic from setup_test_repo in helpers/setup.bash. If the setup evolves (e.g., new git config keys), this test could drift.

That said, since it needs a different seed script, the duplication is understandable and the test is self-contained.

bin/cli/infrastructure.js (2)

162-184: Hardcoded KNOWN_COMMANDS list for --view parsing requires manual sync.

The KNOWN_COMMANDS array on line 165 must be kept in sync with the actual command set. If a new command is added later but not added here, --view <newcommand> would attempt to interpret the command name as a view mode and throw an invalid mode error.

Consider deriving this list from the COMMANDS map in bin/warp-graph.js, or at minimum add a comment noting the sync requirement.


75-89: Pass cause to the Error super constructor for idiomatic error handling.

CliError manually assigns this.cause = cause instead of passing it through the Error constructor. Since the project requires Node.js ≥22.0.0 and support for Error(message, { cause }) has been available since Node.js 16.9.0, passing the cause parameter to super() aligns with standard error handling practices.

Suggested improvement
  constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
-   super(message);
+   super(message, { cause });
    this.code = code;
    this.exitCode = exitCode;
-   this.cause = cause;
  }
bin/cli/shared.js (2)

86-102: Redundant listGraphNames call when options.graph is specified.

resolveGraphName (line 88) returns options.graph directly when it's truthy. Then lines 89–94 call listGraphNames again to validate the graph exists. This results in two listRefs I/O calls. Consider either having resolveGraphName validate existence when an explicit graph is provided, or moving the validation before resolveGraphName.

Suggested consolidation
 export async function openGraph(options) {
   const { persistence } = await createPersistence(options.repo);
-  const graphName = await resolveGraphName(persistence, options.graph);
-  if (options.graph) {
-    const graphNames = await listGraphNames(persistence);
-    if (!graphNames.includes(options.graph)) {
-      throw notFoundError(`Graph not found: ${options.graph}`);
-    }
+  const graphNames = await listGraphNames(persistence);
+  let graphName;
+  if (options.graph) {
+    if (!graphNames.includes(options.graph)) {
+      throw notFoundError(`Graph not found: ${options.graph}`);
+    }
+    graphName = options.graph;
+  } else if (graphNames.length === 1) {
+    graphName = graphNames[0];
+  } else if (graphNames.length === 0) {
+    throw notFoundError('No graphs found in repo; specify --graph');
+  } else {
+    throw usageError('Multiple graphs found; specify --graph');
   }

232-244: persistence.plumbing leaks the adapter implementation through the port abstraction.

wireSeekCache accesses persistence.plumbing (line 241), which is a property specific to GitGraphAdapter, not part of the Persistence port type. This couples the seek cache wiring to the concrete adapter.

For CLI-only code where persistence is always GitGraphAdapter, this is pragmatically fine, but worth noting for any future adapter swap.

src/domain/services/AuditVerifierService.js (1)

352-358: _addError sets status to ERROR, then callers immediately override — consider accepting status as a parameter.

At line 355, _addError internally sets result.status = STATUS_ERROR, then line 356 immediately overwrites it with STATUS_DATA_MISMATCH. The same pattern occurs with STATUS_BROKEN_CHAIN at lines 371, 399, 419, 425, and 575. This works correctly but is fragile — a future maintainer might forget the override after _addError.

Suggested improvement
- _addError(result, code, message, commit) {
+ _addError(result, code, message, commit, status = STATUS_ERROR) {
    result.errors.push({ code, message, ...(commit ? { commit } : {}) });
    if (result.status === STATUS_VALID || result.status === STATUS_PARTIAL) {
-     result.status = STATUS_ERROR;
+     result.status = status;
    }
  }

Then callers become:

this._addError(result, 'TRAILER_MISMATCH', trailerErr, current, STATUS_DATA_MISMATCH);
return;

CI Bot added 4 commits February 13, 2026 02:08
…lags

Extract parseVerifyAuditArgs() with strict undefined check (was truthy),
reject unknown flags/positionals with usageError, and throw on missing
values. Aligns verify-audit with history.js/install-hooks.js patterns.

Add 10 unit tests covering happy path, empty strings, unknown flags,
missing values, and unexpected positionals.
…ot replaced

bin/warp-graph.js is still in the relaxed-complexity block; the new
bin/cli/ modules were added alongside it, not as replacements.
Section numbering skipped from 12 to 14. Renumber Verification Output
section to 13 for sequential consistency.
Variable was assigned but never referenced — the test already correctly
creates an orphan commit to break the chain.
@github-actions
Copy link

Release Preflight

  • package version: 10.11.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.11.0, release workflow will publish.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@bin/cli/commands/verify-audit.js`:
- Around line 53-70: The current truthy check on writerFilter causes an
empty-string writer (which parseVerifyAuditArgs now accepts) to fall through to
verifyAll; change the gate to detect absence explicitly (e.g., if (writerFilter
!== undefined) or if (typeof writerFilter === 'string') ) so that an empty
string is treated as a valid writer and calls verifier.verifyChain(graphName,
writerFilter, { since }) instead of verifier.verifyAll. Ensure you reference
writerFilter, verifier.verifyChain and verifier.verifyAll when making the
change.
🧹 Nitpick comments (1)
test/unit/cli/verify-audit-args.test.js (1)

1-52: Good coverage of the core parsing paths.

The tests cover happy paths, empty inputs, empty-string edge cases, and all three error branches. Two minor gaps worth considering:

  1. Reverse flag order (['--writer', 'bob', '--since', 'abc']) — currently only the --since-first order is tested.
  2. Duplicate flags (['--since', 'a', '--since', 'b']) — the parser silently takes the last value; a test would document whether that's intentional.

Neither is blocking, just nice-to-have for completeness.

CI Bot added 6 commits February 13, 2026 02:51
The commands-split refactor moved CLI code into bin/cli/ but didn't
update the files array, breaking the published package for CLI use.
- NodeCryptoAdapter → WebCryptoAdapter in shared.js, info.js, materialize.js
- ClockAdapter.node() → ClockAdapter.global() in check.js
- Remove `import crypto from 'node:crypto'` in seek.js; convert
  computeFrontierHash to async using globalThis.crypto.subtle

The CLI now uses only cross-runtime APIs (Web Crypto, globalThis.performance)
and can run under Node 22+, Bun, and Deno without modification.
Replace hand-rolled parseArgs in infrastructure.js with a two-pass
approach using node:util.parseArgs:

1. extractBaseArgs: scans argv for known base flags (--repo, --json,
   --view, etc.) regardless of position, extracts them and identifies
   the command name and per-command args.
2. preprocessView: injects 'ascii' default for --view's optional-value
   semantics (not natively supported by parseArgs).
3. nodeParseArgs: strict parsing of base args only.

Return shape changes from {options, positionals} to
{options, command, commandArgs} — warp-graph.js updated accordingly.

25 new tests in test/unit/cli/parseArgs.test.js covering base option
parsing, --view optional value, command splitting, and passthrough.
Replace hand-rolled arg parsing in all 10 command handlers with
node:util.parseArgs configs validated by Zod schemas.

New file: bin/cli/schemas.js — Zod schemas for all commands with
type coercion, enum validation, and mutual-exclusion checks (seek).

Per-command changes:
- history: HISTORY_OPTIONS + historySchema
- install-hooks: INSTALL_HOOKS_OPTIONS + installHooksSchema
- verify-audit: VERIFY_AUDIT_OPTIONS + verifyAuditSchema
- path: PATH_OPTIONS + pathSchema (positionals for from/to, comma labels)
- query: hybrid — extractTraversalSteps for --outgoing/--incoming optional
  values, then QUERY_OPTIONS + querySchema for the rest
- view: VIEW_OPTIONS + viewSchema (positionals for log subcommand)
- seek: SEEK_OPTIONS + seekSchema (10 flags, Zod superRefine for
  mutual-exclusion validation)

Infrastructure:
- New parseCommandArgs() helper wrapping nodeParseArgs + Zod safeParse
- Removed readOptionValue and its helpers (no longer used)

Tests:
- 32 new tests in test/unit/cli/schemas.test.js
- All 67 CLI tests pass, all 3543 unit tests pass
- CHANGELOG: new entry documenting cross-runtime support, parseArgs
  migration, packaging fix, and 67 new CLI tests
- package.json: bump version to 10.12.0
@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@bin/cli/commands/install-hooks.js`:
- Around line 80-87: The readHookContent function currently swallows all errors
from fs.readFileSync which masks permission/encoding errors; update
readHookContent to only return null for ENOENT (file not found) and re-throw any
other errors so permission/encoding issues surface. Locate readHookContent and
its fs.readFileSync call and change the catch block to inspect the thrown
error's code (e.g., err.code === 'ENOENT')—return null in that case and re-throw
the error otherwise.

In `@bin/cli/commands/view.js`:
- Around line 32-39: The catch block in the try/catch around requiring the TUI
(the error variable `err` handled in the catch) is too broad and may treat
transitive dependency failures as the TUI itself missing; update the conditional
that currently checks `err.code === 'ERR_MODULE_NOT_FOUND' || (err.message &&
err.message.includes('Cannot find module'))` to ensure the missing-module error
actually references the TUI package (check `err.specifier`, `err.url`, or ensure
`err.message` contains `@git-stunts/git-warp-tui`) before calling `usageError`
for installation, otherwise rethrow the original `err`; keep the `usageError`
call and `throw err` behavior but only when the missing specifier/url
specifically points to `@git-stunts/git-warp-tui`.

In `@bin/cli/infrastructure.js`:
- Line 102: The KNOWN_COMMANDS array in infrastructure.js is a hardcoded list
that must mirror the COMMANDS map in warp-graph.js to ensure extractBaseArgs and
isViewValue correctly distinguish commands from arguments; fix by either (A)
deriving KNOWN_COMMANDS programmatically from the keys of the COMMANDS export in
warp-graph.js (import COMMANDS and set KNOWN_COMMANDS = Object.keys(COMMANDS))
or (B) add a unit test that imports both KNOWN_COMMANDS and COMMANDS and asserts
their key sets are identical so future changes stay in sync; update
extractBaseArgs/isViewValue imports if needed to use the derived list.

In `@bin/cli/schemas.js`:
- Line 37: The schema for the 'max-depth' field currently uses
z.coerce.number().int().optional(), which allows negative integers; update the
validator for 'max-depth' in bin/cli/schemas.js (the schema entry keyed
'max-depth') to constrain values to non-negative (or strictly positive) integers
by chaining an appropriate Zod check such as .nonnegative() or .min(0) (or
.positive() if negatives and zero should be disallowed) after .int() so negative
depths are rejected.
- Around line 103-110: The current check (val.diff && !DIFF_ACTIONS &&
actions.length > 0) lets --diff pass when used alone; update the validation so
--diff is only allowed when one of the action flags is present by changing the
condition to simply val.diff && !DIFF_ACTIONS (or, if the intent is to permit
--diff with bare status, add a clarifying comment above DIFF_ACTIONS). Modify
the block that calls ctx.addIssue (referencing DIFF_ACTIONS, val.diff, and
actions.length) to use the new condition so a missing action flag triggers the
error message.
🧹 Nitpick comments (11)
test/unit/cli/schemas.test.js (1)

194-196: Consider adding a test for --diff with no action flags (bare status).

The superRefine condition val.diff && !DIFF_ACTIONS && actions.length > 0 means { diff: true } (no action flag, i.e. status) silently passes validation, since actions.length is 0. If --diff is only intended to work with --tick, --latest, or --load (as the comment in schemas.js states), this is a gap. Add a test to document whether seekSchema.parse({ diff: true }) should succeed or throw.

bin/cli/commands/history.js (1)

22-33: patchTouchesNode doesn't match edge property ops by the edge's endpoint IDs.

Edge property ops (PropSet with encoded edge key) have op.node set to the encoded edge key (e.g., \x01from\x01to\x01label\x01key), not the raw node ID. If a user filters history by a node ID that is an edge endpoint, edge property ops on that edge won't be included. This may be intentional (node filter = node-level ops only), but worth documenting.

bin/cli/commands/info.js (2)

75-89: Sequential getWriterPatches loop — consider parallelizing for repos with many writers.

Each writer's patches are fetched sequentially. For graphs with many writers, this could be slow since each call involves Git I/O. Promise.all would speed this up.

Suggested refactor
   if (includeWriterPatches && writerIds.length > 0) {
     const graph = await WarpGraph.open({
       persistence,
       graphName,
       writerId: 'cli',
       crypto: new WebCryptoAdapter(),
     });
     /** `@type` {Record<string, number>} */
     const writerPatches = {};
-    for (const writerId of writerIds) {
-      const patches = await graph.getWriterPatches(writerId);
-      writerPatches[/** `@type` {string} */ (writerId)] = patches.length;
-    }
+    const results = await Promise.all(
+      writerIds.map(async (writerId) => {
+        const patches = await graph.getWriterPatches(/** `@type` {string} */ (writerId));
+        return [/** `@type` {string} */ (writerId), patches.length];
+      })
+    );
+    for (const [id, count] of results) {
+      writerPatches[/** `@type` {string} */ (id)] = /** `@type` {number} */ (count);
+    }
     info.writerPatches = writerPatches;
   }

94-138: Return shape is handled correctly but inconsistent with other handlers.

The dispatcher in bin/warp-graph.js (lines 70-75) has auto-wrapping logic that normalizes handler results: if a result lacks a payload property, it's automatically wrapped as { payload: result, exitCode: EXIT_CODES.OK }. This means handleInfo's { repo, graphs } return will work correctly.

However, other command handlers (handlePath, handleHistory, handleView) explicitly return { payload, exitCode }. For consistency, consider updating the return statement to match:

return {
  payload: { repo: options.repo, graphs },
  exitCode: EXIT_CODES.OK,
};

This makes the pattern explicit rather than relying on implicit auto-wrapping.

bin/cli/commands/install-hooks.js (1)

40-58: Accessing installer._version and creating a redundant HookInstaller instance.

promptForOursStrategy creates its own HookInstaller (line 42) solely to read _version, while handleInstallHooks (line 96) creates another. The underscore-prefixed _version signals a private member. Consider either:

  1. Exposing a public version getter on HookInstaller, or
  2. Passing the version (or the installer instance) into resolveStrategy / promptForOursStrategy to avoid the extra instantiation and the encapsulation breach.
♻️ Suggested approach — pass installer down
-async function resolveStrategy(classification, hookOptions) {
+async function resolveStrategy(classification, hookOptions, installer) {
   if (hookOptions.force) {
     return 'replace';
   }
   if (classification.kind === 'none') {
     return 'install';
   }
   if (classification.kind === 'ours') {
-    return await promptForOursStrategy(classification);
+    return await promptForOursStrategy(classification, installer);
   }
   return await promptForForeignStrategy();
 }

-async function promptForOursStrategy(classification) {
-  const installer = createHookInstaller();
-  if (classification.version === installer._version) {
+async function promptForOursStrategy(classification, installer) {
+  if (classification.version === installer._version) {
     return 'up-to-date';
   }
   ...

Then in handleInstallHooks:

-  const strategy = await resolveStrategy(classification, hookOptions);
+  const strategy = await resolveStrategy(classification, hookOptions, installer);
bin/cli/commands/seek.js (2)

195-205: countPatchesAtTick counts patches up to the tick, not at the tick.

The function name implies counting patches exactly at a given tick, but the condition t <= tick counts all patches from the beginning through the given tick. This appears intentional given usage context (showing cumulative patch count), but the name is misleading.

Consider renaming to countPatchesThrough or countPatchesUpToTick for clarity.


329-343: Diff-limit budget allocation order is implicit and may surprise users.

The cap closure sequentially consumes a shared remaining budget across the six diff categories in a fixed order (nodes added → nodes removed → edges added → edges removed → props set → props removed). This means if the limit is small and there are many added nodes, removed nodes and later categories get zero entries.

This is acceptable for a truncation mechanism, but worth documenting the priority order (e.g., in the --diff-limit help text or a code comment) so users understand why certain diff categories may appear empty in truncated output.

bin/cli/infrastructure.js (1)

76-90: CliError doesn't pass cause to super() via the standard ES2022 mechanism.

The constructor sets this.cause = cause manually (line 88) rather than passing { cause } to super(message, { cause }). While functionally equivalent for this codebase's usage, the standard mechanism enables tooling (e.g., structured logging, error serialization) to discover the cause chain automatically.

♻️ Optional improvement
-  constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
-    super(message);
+  constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
+    super(message, cause ? { cause } : undefined);
     this.code = code;
     this.exitCode = exitCode;
-    this.cause = cause;
   }
bin/cli/shared.js (3)

86-102: openGraph calls listGraphNames redundantly when --graph is specified.

When options.graph is set, resolveGraphName (line 88) returns immediately without calling listGraphNames. Then line 90 calls listGraphNames for validation. This is correct but could be tightened by having resolveGraphName perform the existence check itself, avoiding the extra round-trip.

That said, this is a minor inefficiency in a CLI startup path, not a hot loop.


161-166: writeActiveCursor uses Node-specific Buffer.from.

Given the PR's stated goal of cross-runtime compatibility (Node 22+, Bun, Deno), Buffer.from(json, 'utf8') at line 164 is Node-specific. The seek.js module (line 70) has the same pattern (Buffer.from(json, 'utf8')) in writeSavedCursor. While Bun and Deno provide Buffer compatibility, using new TextEncoder().encode(json) would be more portable (as done in computeFrontierHash at seek.js line 217).


235-244: wireSeekCache couples to adapter internals by accessing persistence.plumbing.

Line 241 reaches into an implementation detail—plumbing is not part of the GraphPersistencePort interface. InMemoryGraphAdapter, for example, has no plumbing property. If the persistence adapter is swapped, this code fails silently at runtime.

Pass plumbing as a separate parameter to wireSeekCache, or expose it through the persistence adapter's public interface.

- verify-audit: reject empty-string --since/--writer via .min(1) schema
- install-hooks: readHookContent only swallows ENOENT, rethrows others
- view: narrow module-not-found catch to git-warp-tui specifier only
- infrastructure: export KNOWN_COMMANDS + add sync drift-detection test
- schemas: --max-depth rejects negatives (.nonnegative()); --diff alone
  (bare status, no --tick/--latest/--load) now correctly rejected
@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@bin/cli/schemas.js`:
- Around line 73-83: The seekSchema currently allows empty strings for the
cursor name fields ('save', 'load', 'drop') which is likely unintended; update
the seekSchema's definitions for save, load, and drop (the z.string() calls) to
require at least one character (e.g., use z.string().min(1) or
z.string().nonempty()) so empty values are rejected like verifyAuditSchema does
for its string fields.
🧹 Nitpick comments (2)
test/unit/cli/schemas.test.js (1)

137-213: Good coverage of seekSchema, but consider adding edge-case tests for empty-string --save/--load/--drop.

The verifyAuditSchema properly rejects empty strings for --since/--writer via z.string().min(1), but seekSchema uses bare z.string().optional() for --save, --load, and --drop. An empty-string name (e.g., --save "") would pass validation and produce a cursor with an empty name. Consider adding a test to document whether this is intentional, or adding .min(1) to those fields in the schema.

bin/cli/infrastructure.js (1)

76-90: Consider using the standard cause option in Error constructor.

CliError sets this.cause manually, but ES2022+ Error supports { cause } natively via the super constructor. Using the standard mechanism ensures the cause integrates with runtimes and debugging tools that inspect error.cause through the built-in chain.

♻️ Proposed change
-  constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
-    super(message);
+  constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
+    super(message, cause ? { cause } : undefined);
     this.code = code;
     this.exitCode = exitCode;
-    this.cause = cause;
   }

Adds .min(1) validation to seek schema's save, load, and drop fields,
matching the pattern already used for verify-audit's --since/--writer.
@flyingrobots
Copy link
Member Author

@coderabbitai review please

@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

@flyingrobots, I'll review the changes in this PR. This is a substantial refactor that decomposes the monolithic CLI into a modular architecture—I'll analyze the code organization, validate that behavior is preserved, and check for any potential issues.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

- verify-audit: change `if (writerFilter)` to `if (writerFilter !== undefined)`
  so the gate is correct independent of upstream schema validation
- view: tighten module-not-found check to match specifier or package name
  in the error message, not just any mention of 'git-warp-tui' (which
  would match transitive dep failures via the import path)
@flyingrobots
Copy link
Member Author

@coderabbitai review please

@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

@flyingrobots, I'll review the changes in this pull request now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

Previous test compared KNOWN_COMMANDS against a hardcoded list, which
couldn't detect drift if a command was added to warp-graph.js but not
to infrastructure.js. Now reads warp-graph.js source and extracts
command names from the COMMANDS Map literal via regex.
@flyingrobots
Copy link
Member Author

@coderabbitai review please

@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

@flyingrobots, I'll review the changes in this pull request now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

Moves the COMMANDS Map from warp-graph.js to bin/cli/commands/registry.js
(side-effect-free). The sync test now imports COMMANDS directly instead
of parsing source with regex. warp-graph.js re-imports from the registry.
@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

@flyingrobots
Copy link
Member Author

@coderabbitai review please

@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

@flyingrobots, I'll review the changes in this pull request now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…reated

The seed-audit-graph.js helper opened the graph with audit: true but
never called materialize(), leaving _cachedState null. The audit
receipt path in _onPatchCommitted requires non-null cached state;
without it all 5 verify-audit BATS tests failed in CI.
@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

- Custom Zod messages for .int() and .positive(): "must be a positive
  integer" (was generic "Number must be greater than 0")
- --diff error changed to "cannot be used without" to match BATS grep
- Added --diff-limit requires --diff validation in superRefine
- BATS: --diff-limit no-value test greps "argument missing" (parseArgs)
- 6 new unit tests for all seek validation edge cases
@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

Adds InMemoryGraphAdapter to the public API so downstream packages
can import it directly for their test suites instead of reaching
into internal infrastructure paths.
@github-actions
Copy link

Release Preflight

  • package version: 10.12.0
  • prerelease: false
  • npm dist-tag on release: latest
  • npm pack dry-run: passed
  • jsr publish dry-run: passed

If you tag this commit as v10.12.0, release workflow will publish.

@flyingrobots flyingrobots merged commit 9bb3b05 into main Feb 13, 2026
7 checks passed
@flyingrobots flyingrobots deleted the commands-split branch February 13, 2026 12:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant