Files
Aura/docs/specs/0098-cli-clap-gnu-compliance.md
T
Brummel e2e3a51c6c spec: 0098 CLI GNU/clig.dev compliance via clap adoption (boss-signed)
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
2026-07-01 17:34:08 +02:00

17 KiB
Raw Blame History

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 helpaura <sub> --help prints that subcommand's own usage with a per-flag Options section, on stdout, exit 0 (today every --help prints one global blob).
  • --version / -V — surfaces the workspace version 0.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 USAGE const duplicated across ten hand-written usage() 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 generalize inconsistency 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/-Vaura 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/--to without --real, cost flags without an R-harness, generalize requiring ≥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 generalize persist failure (today the lone exit 1 of this class) and the sweep/mc persist 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.rs tests pin the success bool 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/|entrystop|)
#[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 — add clap = { version = "4", features = ["derive"] }. (Version 0.1.0 is inherited via version.workspace = true; #[command(version)] reads CARGO_PKG_VERSION.)
  • crates/aura-cli/src/main.rs — the derive structs (Cli, Command, one *Args per subcommand), fn main reduced to Cli::parse() + typed dispatch, the per-subcommand handlers wrapping the existing run_*/emit_*/runs_*/ reproduce_* execution functions (which are unchanged — only their arg-plumbing changes). The is_file() dual-grammar helper, single-sourced.
  • crates/aura-cli/src/graph_construct.rsgraph build/introspect become clap args under the Graph subcommand; 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.rs must 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 asserting run --help contains "per ENGINE cycle" / "stage1-r only" (now via clap long_help). help_flag_prints_usage_to_stdout_and_exits_zero adjusts 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 + --help pointer). 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.
  • 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/-Vaura 0.1.0, stdout, exit 0; aura sweep --help names sweep-only flags and does NOT equal aura run --help (the scoped-help property #131's pin used to forbid); --flag=value form 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

  1. aura --version / -V prints aura 0.1.0 on stdout, exit 0.
  2. aura <sub> --help prints that subcommand's own usage with a per-flag Options section on stdout, exit 0; aura sweep --helpaura run --help.
  3. aura --help and bare aura behave per GNU: --help → top-level Commands list on stdout exit 0; bare aura → usage error exit 2.
  4. --flag=value, --, and long-option abbreviation are accepted (native clap).
  5. Every current valid invocation still succeeds with the same stdout and exit 0 — the dual-grammar .json vs built-in dispatch for run/sweep/ walkforward/mc is preserved byte-for-byte on the happy path, including sweep --list-axes and walkforward <bp.json> --axis.
  6. 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.
  7. No engine/registry/analysis change — the diff is confined to aura-cli (invariant 8 untouched); clap is the only new dependency, admitted under the C16 per-case review documented above.
  8. cargo build --workspace, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace are green after each iteration.