Title here
Summary here
Accepted
2026-04-28
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:
--max-depth abc and similar malformed integer flags were silently coerced or dropped instead of being rejected at parse time.printHelp was a frozen snapshot. Adding a new tool required hand-editing the help string to match the registry.arg_mapper. Inconsistencies were possible and hard to detect.dispatcher.zig panic catcher tried to recover from tool handler crashes, layered on top of zig-cli’s own error handling.| Option | Pros | Cons |
|---|---|---|
| Keep the bypass (status quo) | No refactor cost; behaviour frozen | Continued duplication; no type validation; help drift remains a hazard |
Pragmatic reclaim (small arg_mapper bridge) | Less code to touch | Still carries a parallel parser; doesn’t justify the zig-cli dependency |
| Full reclaim (this ADR) | Single source of truth; type-validated flags; auto-generated help | Visual changes to help/error output; requires module-level slot statics |
| Drop zig-cli entirely | Removes one dependency | Have to rebuild help/error machinery — exactly what zig-cli provides |
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.cli.Option or cli.PositionalArg with a typed slot (?[]const u8, ?i64, the Channel enum, bool).ToolCommand(comptime def) (in src/cli/tool_command.zig) synthesizes the slot statics, options, positional args, and exec thunk for each registry entry.src/cli/app.zig assembles the top-level cli.App from the registry plus the four built-in subcommands (init, serve, upgrade, version).--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.src/cli/arg_mapper.zig (~242 lines), src/cli/dispatcher.zig (~205 lines), printHelp and ANSI helpers in src/main.zig (~50 lines)src/cli/tool_command.zig, src/cli/app.zigsrc/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)src/cli/registry.zig (new params: []const ParamSpec field), src/cli/upgrade.zig (drops parseUpgradeOptions in favour of cli.Option slots)params array. Adding a tool generates the matching CLI entry automatically (NFR-004 holds structurally).--max-depth abc is rejected at parse time. Unknown options raise a real error from the parser instead of being silently dropped.printHelp/parseUpgradeOptions add up to more code than the new tool_command.zig + app.zig introduce.version, init, serve), not exact rendering.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.| Principle | Status | Justification |
|---|---|---|
| Flat module structure | Compliant | src/cli/ retains a flat layout; no new abstraction layers introduced |
| Executable-only project | Compliant | No library module changes |
| Tool handler signatures unchanged | Compliant | ToolCommand thunks call the same mcp.tools.Tool handlers; no handler API drift |
| Inline tests preserved | Compliant | Each src/cli/*.zig module owns its inline tests; tests/functional_cli_test.sh covers the end-to-end argv contract |