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
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
# 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/|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).**
|
||||
```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.
|
||||
Reference in New Issue
Block a user