Replace the hand-rolled aura-cli argv parser with a clap derive parser: scoped `aura <sub> --help`, `--version`/`-V`, a per-flag Options section, and `--flag=value`/`--`/long-opt abbreviation for free. Two iterations — (1) clap migration, behaviour-preserving on exit codes; (2) exit-code split (usage=2, runtime=1). clap is admitted under the C16 per-case review (research-side CLI, invariant 8 untouched). Forks ratified by the user (clap over hand-rolled; GNU-compliance over the machine-first reframe; exit-code Option A) and the three spec-time derived decisions (dual-grammar clap mapping, parse-vs-runtime exit classification, two-iteration split) are recorded on #175. Boss-signed on grounding-check PASS (independent, fresh-context; 13 current-behaviour assumptions all ratified by named green tests). refs #175
17 KiB
CLI GNU/clig.dev compliance via clap adoption — Design Spec
Date: 2026-07-01 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Replace the hand-rolled argv parser in aura-cli with a clap derive-based
parser, closing the GNU / clig.dev convention gaps audited in #175 while
preserving every current behaviour that is not itself a deviation. The cycle
delivers, for the CLI's primary consumer (LLM / automation, C14) and the human
author alike:
- Scoped subcommand help —
aura <sub> --helpprints that subcommand's own usage with a per-flag Options section, on stdout, exit 0 (today every--helpprints one global blob). --version/-V— surfaces the workspace version0.1.0, stdout, exit 0 (today absent; the version exists but is never printed).- A per-flag Options section + top-level Commands list, generated from one
declarative source instead of a
USAGEconst duplicated across ten hand-writtenusage()closures. --flag=value(GNU equals), the--end-of-options terminator, and long-option abbreviation — native clap behaviour, absorbed for free.- A clean exit-code partition (#175 deviation #8): exit 2 = usage error, exit 1 = runtime failure (today exit 2 is overloaded for both, and even self-inconsistent).
Out of scope (deferred, recorded on #175): the machine-first surface
(--help --json, a command manifest, typed errors, aura run - stdin
op-scripts) — Fork B settled this cycle as human/GNU convention compliance, not
the machine-first reframe; that line rides the #157/C21 op-script track. --as-
stdin is app logic, not clap-automatic, and stays with that deferred track.
Dependency note (C16 per-case review). clap is a new top-level
dependency — absent from the tree today, not even transitive. It is admitted
under the C16 per-case policy (INDEX.md:1132-1139, "hand-rolling what a vetted
standard crate already does is the anti-pattern"): clap is the vetted standard
argument parser, and it replaces a hand-rolled parser plus ten duplicated help
surfaces with one declarative source. The CLI is research-side, not the
frozen deploy artifact (invariant 8), so clap's dependency/build surface is a
dev-loop/compile tax, never a frozen-artifact tax — the scrutiny bar C16
reserves for the bot does not apply. This rationale is lifted to the ledger at
cycle close.
Architecture
Two iterations
The cycle is one clap migration split into two reviewable iterations whose test surfaces are largely disjoint:
- Iteration 1 — clap migration (behaviour-preserving on exit codes). Every
subcommand becomes a clap derive parser. Scoped
--help,--version/-V, the Options section,--flag=value,--, and long-option abbreviation all fall out of clap. Exit codes are preserved: clap emits exit 2 for parse errors (matching today's usage-error = 2); domain refusals stay exit 2;reproduce-diverged stays exit 1. The renegotiated tests are the parser-path usage-substring pins and the three help pins. - Iteration 2 — exit-code split (#175 deviation #8). The runtime failures
that today exit 2 move to exit 1, and the lone
generalizeinconsistency is normalised. The renegotiated tests are the domain-refusal exit-2 pins. No parser structure changes.
The planner takes iteration 1 first.
clap shape
One root parser, one subcommand enum:
#[derive(clap::Parser)]
#[command(name = "aura", version, about, disable_help_subcommand = false)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(clap::Subcommand)]
enum Command {
Run(RunArgs),
Chart(ChartArgs),
Graph(GraphArgs), // nested: bare | build | introspect
Sweep(SweepArgs),
Walkforward(WalkforwardArgs),
Generalize(GeneralizeArgs),
Mc(McArgs),
Runs(RunsArgs), // nested: families | family <id> [rank <metric>]
Reproduce(ReproduceArgs),
}
#[command(version)] gives --version/-V → aura 0.1.0, exit 0.
disable_help_subcommand = false and clap's built-in --help/-h per
(sub)command give scoped help, exit 0. Bare aura (no subcommand) hits clap's
"subcommand required" error → usage message on stderr, exit 2 (preserves the
current no_args_prints_usage_and_exits_two semantics).
Dual-grammar subcommands (the load-bearing mapping)
run, sweep, walkforward, mc each carry two disjoint grammars under one
token, discriminated today by first.ends_with(".json") && is_file() (a
lockstep quartet at main.rs:4249/4297/4334/4376). clap's native subcommand
model cannot express "same token, two grammars keyed on whether a positional is
an existing .json file". The mapping (derived decision, recorded on #175):
One clap args struct per such subcommand, carrying an optional [blueprint]
positional plus the union of both branches' flags; a post-parse dispatch on the
same is_file() predicate selects the branch and validates flag applicability.
#[derive(clap::Args)]
struct RunArgs {
/// A serialized signal blueprint (price→bias). If given as an existing
/// .json file, runs the loaded-blueprint grammar; otherwise the built-in
/// harness grammar.
blueprint: Option<String>,
// built-in branch:
#[arg(long)] harness: Option<String>, // sma|macd|stage1-r
// .json branch:
#[arg(long)] params: Option<String>,
#[arg(long)] seed: Option<u64>,
// shared:
#[arg(long)] real: Option<String>,
#[arg(long)] from: Option<i64>,
#[arg(long)] to: Option<i64>,
#[arg(long)] trace: Option<String>,
#[arg(long)] cost_per_trade: Option<f64>,
#[arg(long)] slip_vol_mult: Option<f64>,
#[arg(long)] carry_per_cycle: Option<f64>,
}
Post-parse (in the Run handler):
match args.blueprint.as_deref()
.filter(|a| a.ends_with(".json") && Path::new(a).is_file())
{
Some(path) => run_blueprint(path, /* .json-branch flags */), // --params/--seed/--real/--from/--to
None => run_builtin(/* built-in-branch flags */), // --harness/--real/.../--cost-*
}
Flag applicability that clap cannot key on file-existence (e.g. --harness is
meaningless in the .json branch, --params in the built-in branch) is
validated post-parse and rejected as a usage error (exit 2) with a message
naming the offending flag — preserving today's strictness (unexpected tokens are
usage errors, never silently ignored). The is_file() predicate is
single-sourced in one helper so the quartet stays in lockstep.
Exit-code classification (iteration 2)
The partition rule (derived from Option A):
- exit 2 — usage error: any failure to parse or structurally validate the
command line. clap parse errors (unknown flag/subcommand, missing required
arg, bad enum value, malformed number) are exit 2 natively. aura's own
argument-structure validations that clap cannot express —
--from/--towithout--real, cost flags without an R-harness,generalizerequiring ≥2 distinct instruments and all four knobs, an empty/duplicate--axis, the dual-grammar flag-applicability checks — are post-parse validations that exit 2. - exit 1 — runtime failure: any failure after a valid parse, while the
operation runs — "no local data for symbol", "no recorded geometry", "no
recorded run or family", "run has no tap named", trace-name collision across
kinds, a family/trace persist write failure. These move from today's exit 2 to
exit 1. The
generalizepersist failure (today the lone exit 1 of this class) and thesweep/mcpersist failures (today exit 2) all normalise to exit 1 together. - Unchanged:
reproduce-diverged stays exit 1 — it is a semantic "reproduction diverged" verdict (the operation ran; its result is a reproduction failure), which is the runtime class, and is already exit 1. - graph build / introspect: argv-flag errors → exit 2 (usage); op-script
content errors read from stdin (unconnected node, kind mismatch, unknown
type) → exit 1 (runtime input). The
graph_construct.rstests pin thesuccessbool and message prose, not the exit integer, so this classification is compatible without renegotiating them.
Concrete code shapes
User-facing (the acceptance evidence — what a consumer runs)
$ aura --version
aura 0.1.0
$ aura sweep --help
Run a parameter sweep over a strategy or a loaded blueprint.
Usage: aura sweep [OPTIONS] [BLUEPRINT]
Arguments:
[BLUEPRINT] A loaded blueprint (.json); omit for the built-in --strategy grammar
Options:
--strategy <NAME> sma|momentum|stage1-r|stage1-breakout|stage1-meanrev
--real <SYMBOL> stream a real symbol instead of synthetic
--from <MS> window start (requires --real)
--to <MS> window end (requires --real)
--name <N> family name (no-persist)
--trace <N> family name (persisted)
--fast <CSV> ... grid axis
--axis <NAME=CSV> blueprint axis (repeatable; .json mode)
--list-axes list a loaded blueprint's sweepable axes and exit
-h, --help Print help
-V, --version Print version
$ aura run --bogus # unknown flag → usage error
error: unexpected argument '--bogus' found
...
$ echo $?
2
$ aura run --real EURUSD # valid call, no data → runtime failure (iter 2)
aura: no local data for symbol 'EURUSD'
$ echo $?
1
$ aura run examples/sig.json --seed 5 # dual grammar: .json branch still works
{"manifest":...}
$ aura run --harness=stage1-r # GNU --flag=value form now accepted
{"manifest":...}
Implementation shape (before → after, secondary)
Global --help short-circuit — removed.
// before (main.rs:4236-4239): fires before dispatch, prints the global blob for
// EVERY subcommand, so `aura sweep --help` == `aura --help`.
if args.iter().any(|a| a == "--help" || a == "-h") {
println!("{USAGE}{COST_FLAGS_NOTE}");
return;
}
// after: deleted. clap owns --help/-h per (sub)command → scoped help, exit 0.
Dispatch match → clap parse + typed handlers.
// before (main.rs:4240-4411): `match args.iter()...as_slice()` with ~13 string
// arms, each calling a hand-rolled parse_*_args returning Result<_, String>,
// and 67 `std::process::exit(2)` sites.
// after:
fn main() {
restore_sigpipe(); // unchanged (main.rs:4227-4230)
let cli = Cli::parse(); // clap: parse errors → stderr, exit 2
match cli.command {
Command::Run(a) => run::dispatch(a),
Command::Sweep(a) => sweep::dispatch(a),
// ... one handler per subcommand
}
}
USAGE const + COST_FLAGS_NOTE + the ten usage() closures — removed;
the cost-flags note becomes flag long_help so run --help still surfaces it.
// after: on the RunArgs cost fields
/// per-trade cost in price units (stage1-r only; charged in R as cost/|entry−stop|)
#[arg(long)] cost_per_trade: Option<f64>,
// ...the "per ENGINE cycle" / "stage1-r only" phrasing moves into long_help,
// keeping the #153 discoverability the old note gave, now scoped to `run --help`.
Exit codes (iteration 2).
// before: domain refusal, e.g. main.rs (no local data) → eprintln! + exit(2)
// after: a small typed boundary — usage-class errors exit 2, runtime-class exit 1.
// enum ExitClass { Usage = 2, Runtime = 1 }
// domain refusals map to Runtime; argument-structure validations to Usage.
// generalize persist failure: exit(1) unchanged; sweep/mc persist failures 2 → 1.
Components
crates/aura-cli/Cargo.toml— addclap = { version = "4", features = ["derive"] }. (Version 0.1.0 is inherited viaversion.workspace = true;#[command(version)]readsCARGO_PKG_VERSION.)crates/aura-cli/src/main.rs— the derive structs (Cli,Command, one*Argsper subcommand),fn mainreduced toCli::parse()+ typed dispatch, the per-subcommand handlers wrapping the existingrun_*/emit_*/runs_*/reproduce_*execution functions (which are unchanged — only their arg-plumbing changes). Theis_file()dual-grammar helper, single-sourced.crates/aura-cli/src/graph_construct.rs—graph build/introspectbecome clap args under theGraphsubcommand; the stdin op-list reading and the execution bodies are unchanged; exit-class applied in iteration 2.- The execution layer is untouched.
run_dispatch,run_sweep,run_walkforward,run_generalize,run_mc,run_blueprint_*,emit_chart,runs_*,reproduce_*, the manifest/report/render code — none of it changes; only how their arguments arrive (clap structs instead of hand-parsed tuples).
Data flow
argv → Cli::parse() (clap: usage errors → stderr + exit 2) → Command enum →
per-subcommand handler → [dual-grammar: post-parse is_file() dispatch +
flag-applicability validation, usage errors → exit 2] → existing execution
function → stdout (JSON report / rendered output) or runtime failure (stderr +
exit 1, iteration 2). Stream discipline is preserved: results to stdout, errors
to stderr, never mixed; the SIGPIPE reset stays.
Error handling
- Parse / usage errors → exit 2. clap's own errors, plus the post-parse argument-structure validations clap cannot express (dual-grammar flag applicability, cross-flag requirements). Messages go to stderr; stdout stays empty on the error path.
- Runtime failures → exit 1 (iteration 2). Domain refusals after a valid
parse. Messages keep the
aura: {msg}stderr shape and their existing substrings ("no local data for symbol '…'", etc.) — only the exit integer changes, so the message-substring pins survive while the exit-code pins are renegotiated. - SIGPIPE unchanged. The closed-pipe path stays a clean signal-13 exit
(
code() == None), never a panic (exit 101);cli_broken_pipe.rsmust stay green untouched. INFRA/panic — clap.parse()never panics on bad input (it exits 2); no new panic paths introduced.
Testing strategy
- Iteration 1 renegotiates the parser-path usage pins and the help pins:
- The three help pins flip/adjust:
per_subcommand_help_is_uniform_stdout_exit_zero(#131) inverts — help is now scoped, not uniform; it is rewritten to assert each subcommand's help names its own flags on stdout, exit 0, stderr empty.run_help_surfaces_cost_flag_units_note(#153) keeps assertingrun --helpcontains "per ENGINE cycle" / "stage1-r only" (now via claplong_help).help_flag_prints_usage_to_stdout_and_exits_zeroadjusts to clap's stdout help text. - The ~30 usage-substring pins (
"usage"on stderr, exit 2) are renegotiated to clap's error format. Exit-2 expectations are preserved in iteration 1; only the asserted message substrings change from"usage"to clap's wording (or a stable substring clap emits, e.g. the subcommand name +--helppointer). Where a test asserts an aura-specific message ("R-evaluator harness", "must be non-negative", "--real"), that message is preserved by the post-parse validation, so the pin survives. - Happy-path exit-0 /
.success()pins (21 sites) must stay green untouched — valid invocations keep exiting 0 with the same stdout.
- The three help pins flip/adjust:
- Iteration 2 renegotiates the ~10 domain-refusal pins from exit 2 to exit 1
(no local data, no geometry, no recorded run/family, tap-not-found, name
collision, sweep persist).
reproduce-diverged exit-1 pin stays. Message substrings unchanged. - New tests (iteration 1):
--version/-V→aura 0.1.0, stdout, exit 0;aura sweep --helpnames sweep-only flags and does NOT equalaura run --help(the scoped-help property #131's pin used to forbid);--flag=valueform accepted on a representative flag;--terminator honoured. - New tests (iteration 2): a representative runtime failure exits 1 while a representative usage error exits 2 — the partition pinned as an explicit property.
- The full workspace suite is green before iteration 1 starts (baseline
established at HEAD
780d823) and green after each iteration.
Acceptance criteria
aura --version/-Vprintsaura 0.1.0on stdout, exit 0.aura <sub> --helpprints that subcommand's own usage with a per-flag Options section on stdout, exit 0;aura sweep --help≠aura run --help.aura --helpand bareaurabehave per GNU:--help→ top-level Commands list on stdout exit 0; bareaura→ usage error exit 2.--flag=value,--, and long-option abbreviation are accepted (native clap).- Every current valid invocation still succeeds with the same stdout and
exit 0 — the dual-grammar
.jsonvs built-in dispatch for run/sweep/ walkforward/mc is preserved byte-for-byte on the happy path, includingsweep --list-axesandwalkforward <bp.json> --axis. - Exit codes partition cleanly (iteration 2): usage errors → exit 2,
runtime failures → exit 1, with no same-class inconsistency;
reproduce- diverged stays exit 1; SIGPIPE stays a clean signal exit. - No engine/registry/analysis change — the diff is confined to
aura-cli(invariant 8 untouched);clapis the only new dependency, admitted under the C16 per-case review documented above. cargo build --workspace,cargo clippy --workspace --all-targets -- -D warnings, andcargo test --workspaceare green after each iteration.