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

374 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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> --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:
```rust
#[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.**
```rust
#[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):
```rust
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.**
```rust
// 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.**
```rust
// 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.
```rust
// 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).**
```rust
// 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.rs`** — `graph 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`/`-V` → `aura 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 --help` ≠ `aura 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.