Status

Accepted

Date

2026-04-28

Context

zpm depended on zig-cli 0.10.0 but bypassed almost all of its functionality. Argv was reparsed by hand in src/cli/arg_mapper.zig, extractFormatFlag, and parseUpgradeOptions; help was rendered by a custom printHelp that imitated zig-cli’s ANSI palette; and the routing in main.zig short-circuited cli.AppRunner.run for all 22 tool subcommands and for upgrade. The result was ~672 lines of hand-rolled parsing code that duplicated what zig-cli already provides, plus a dependency that was barely doing any work.

The duplication had concrete costs:

  • Type validation gap: --max-depth abc and similar malformed integer flags were silently coerced or dropped instead of being rejected at parse time.
  • Help drift: The custom printHelp was a frozen snapshot. Adding a new tool required hand-editing the help string to match the registry.
  • Two parsers, one source of truth: Every flag had to be declared in the registry and threaded through arg_mapper. Inconsistencies were possible and hard to detect.
  • Panic-prone glue: A custom dispatcher.zig panic catcher tried to recover from tool handler crashes, layered on top of zig-cli’s own error handling.

Candidates

OptionProsCons
Keep the bypass (status quo)No refactor cost; behaviour frozenContinued duplication; no type validation; help drift remains a hazard
Pragmatic reclaim (small arg_mapper bridge)Less code to touchStill carries a parallel parser; doesn’t justify the zig-cli dependency
Full reclaim (this ADR)Single source of truth; type-validated flags; auto-generated helpVisual changes to help/error output; requires module-level slot statics
Drop zig-cli entirelyRemoves one dependencyHave to rebuild help/error machinery — exactly what zig-cli provides

Decision

Refactor every CLI surface to use zig-cli idiomatically:

  • cli.AppRunner.run(&app) is the only entry point — no kebab-name bypass, no --version short-circuit, no unknown-command guard.
  • Every flag is a cli.Option or cli.PositionalArg with a typed slot (?[]const u8, ?i64, the Channel enum, bool).
  • A per-tool comptime generic ToolCommand(comptime def) (in src/cli/tool_command.zig) synthesizes the slot statics, options, positional args, and exec thunk for each registry entry.
  • A new src/cli/app.zig assembles the top-level cli.App from the registry plus the four built-in subcommands (init, serve, upgrade, version).
  • zig-cli generates --help, all argv-error messages, and the value parsers.
  • --version and -v are auto-generated by zig-cli but currently fall through to the help banner with exit 1 (a known limitation in how zig-cli flushes the version short-circuit). A version subcommand was added as the working path, mirroring what the broken flag was meant to print.

Files affected

  • Deleted: src/cli/arg_mapper.zig (~242 lines), src/cli/dispatcher.zig (~205 lines), printHelp and ANSI helpers in src/main.zig (~50 lines)
  • Added: src/cli/tool_command.zig, src/cli/app.zig
  • Rewritten: src/main.zig (the file is 52 lines, of which the main() function is 5; the rest is two test functions and the test {} import block)
  • Modified: src/cli/registry.zig (new params: []const ParamSpec field), src/cli/upgrade.zig (drops parseUpgradeOptions in favour of cli.Option slots)

Consequences

What becomes easier

  • Single source of truth: Each tool’s CLI shape lives in one place — the registry’s params array. Adding a tool generates the matching CLI entry automatically (NFR-004 holds structurally).
  • Type-validated flags: --max-depth abc is rejected at parse time. Unknown options raise a real error from the parser instead of being silently dropped.
  • No help drift: Help text is rendered by zig-cli straight from the registry. There is no second representation to keep in sync.
  • ~150 net lines deleted: The two deleted modules and the inlined printHelp/parseUpgradeOptions add up to more code than the new tool_command.zig + app.zig introduce.

What becomes harder

  • Visual diff in help and error output: Help banners and parser error messages now follow zig-cli’s formatting rather than the previous custom palette. Functional tests assert keyword presence (e.g. version, init, serve), not exact rendering.
  • Module-level slot statics: ToolCommand(comptime def) declares its option/positional slots at module level for zig-cli to bind into. Running zpm twice in the same process would corrupt those slots — acceptable for a one-shot CLI binary, but it bakes the executable-only constraint into the type system; turning zpm into a library in the future would require redesigning ToolCommand to thread per-invocation slots through the call site.
  • --version/-v fall through to help: A known zig-cli limitation prevents the auto-generated version short-circuit from flushing cleanly to stdout. The version subcommand replaces it as the working path. We accepted this rather than forking zig-cli because (a) the subcommand provides full parity for scripting and installation checks, (b) a fix would require an upstream PR or maintaining a vendor patch, and (c) the same limitation affects parser error messages on stderr — a fix narrow enough to land version printing would not address the broader silent-error class. Functional tests assert on exit codes only for those paths.

Constitution Compliance

PrincipleStatusJustification
Flat module structureCompliantsrc/cli/ retains a flat layout; no new abstraction layers introduced
Executable-only projectCompliantNo library module changes
Tool handler signatures unchangedCompliantToolCommand thunks call the same mcp.tools.Tool handlers; no handler API drift
Inline tests preservedCompliantEach src/cli/*.zig module owns its inline tests; tests/functional_cli_test.sh covers the end-to-end argv contract