Skip to main content

aristo_cli/
lib.rs

1//! Library form of the Aristo CLI. The `aristo` binary (`src/main.rs`) is
2//! a thin wrapper that calls [`run`] and exits with its return code.
3//!
4//! Splitting the CLI into a lib + tiny bin lets integration tests exercise
5//! `dispatch` directly without spawning a child process for every case
6//! (the `binary_smoke` test still spawns one, on purpose, as the canary
7//! for the binary's own glue).
8
9mod commands;
10mod data_plane;
11mod error;
12mod filter;
13/// The nudge/progress engine (Phase 18 #9). Public so the as-yet-unwired
14/// scorer is part of the lib API surface; the `aristo nudge` emitter (S0d)
15/// is its in-crate consumer.
16pub mod nudge;
17mod pipeline;
18mod session;
19mod skills;
20mod update_notify;
21mod workspace;
22
23pub use error::{CliError, CliResult};
24pub use filter::{Filter, FilterParseError};
25pub use workspace::{Workspace, WorkspaceError};
26
27use clap::{Parser, Subcommand};
28use std::path::PathBuf;
29use std::process::ExitCode;
30
31/// Aristo annotation SDK CLI.
32///
33/// Each subcommand handles one stage of the annotation lifecycle:
34/// authoring (`init`, `lang`, `install-skills`), indexing and stamping
35/// (`index`, `stamp`, `rename`), inspection (`show`, `list`, `status`,
36/// `graph`, `doc`, `badge`), quality gates (`lint`, `verify`,
37/// `critique`), review-session management (`session`), and canon
38/// binding against the Aretta server (`auth`, `canon`).
39#[derive(Parser, Debug)]
40#[command(
41    name = "aristo",
42    version,
43    about = "Aristo annotation SDK — write, verify, and document intent.",
44    long_about = None,
45)]
46struct Cli {
47    #[command(subcommand)]
48    command: Commands,
49}
50
51#[derive(Subcommand, Debug)]
52enum Commands {
53    /// Set up Aristo in this repo (creates `aristo.toml`, `.aristo/`,
54    /// pre-commit hook). Pass `--ci` / `--ci-verify` to also add CI workflows.
55    Init {
56        /// Modify Cargo.toml to add `aristo` as a dependency. Default
57        /// behavior just prints the dep line for the user to paste in.
58        #[arg(short, long)]
59        force: bool,
60
61        /// Also write a lite PR-gate workflow (`.github/workflows/aristo.yml`)
62        /// that runs audit/lint/doc via the shared aristo-action. No token needed.
63        #[arg(long)]
64        ci: bool,
65
66        /// Also write the nightly/manual verify workflow
67        /// (`.github/workflows/aristo-verify.yml`). Needs an `ARETTA_TOKEN` repo
68        /// secret (paid tier). Implies `--ci`.
69        #[arg(long)]
70        ci_verify: bool,
71
72        /// Install a local pre-commit hook (DEPRECATED). Off by default and no
73        /// longer auto-installed: the index is a gitignored cache, so CI
74        /// (`aristo verify --audit`) is the enforcement point. The hook only
75        /// runs `aristo doc` + `lint` locally as a convenience.
76        #[arg(long)]
77        hook: bool,
78    },
79
80    /// Print a syntax cheat sheet for the detected language.
81    Lang {
82        /// Detect language for a specific file. Currently only Rust
83        /// (`.rs`) is supported; other extensions error out.
84        #[arg(long)]
85        file: Option<PathBuf>,
86    },
87
88    /// Install Aristo skills for a coding agent (claude-code, cursor, codex, opencode, antigravity).
89    InstallSkills {
90        /// Target agent. Required unless `--list-agents` is used.
91        #[arg(long, value_name = "name")]
92        agent: Option<String>,
93        /// List supported agents and exit.
94        #[arg(long)]
95        list_agents: bool,
96        /// Install at user-level (e.g. ~/.claude/skills/) instead of project-level.
97        #[arg(long)]
98        user: bool,
99        /// Re-pin installed skills to this binary's version. With `--agent`,
100        /// re-installs that agent; without it, heals every already-installed
101        /// agent in place (both scopes, or just `--user`).
102        #[arg(long)]
103        update: bool,
104        /// Report whether installed skills are stale relative to this binary
105        /// (read-only). Exits non-zero if any are out of date.
106        #[arg(long)]
107        check: bool,
108        /// Internal: emit a SessionStart hook `additionalContext` block when
109        /// installed skills are stale (used by the hook this command installs).
110        #[arg(long, hide = true)]
111        hook_format: bool,
112    },
113
114    /// Reverse `install-skills`: remove SDK-bundled skills.
115    UninstallSkills {
116        /// Target agent. Required.
117        #[arg(long, value_name = "name")]
118        agent: String,
119        /// Uninstall from user-level instead of project-level.
120        #[arg(long)]
121        user: bool,
122        /// Override the "skip locally-modified" safety check.
123        #[arg(long)]
124        force: bool,
125    },
126
127    /// Scan source for annotations and write the index (`.aristo/index.toml`).
128    Index {
129        /// Force a full re-walk, ignoring the per-file mtime cache.
130        #[arg(long)]
131        all: bool,
132    },
133
134    /// Refresh the annotation index — pick up new annotations, detect
135    /// drift, and (when signed in) match against the Aretta canon.
136    Stamp {
137        /// CI mode: report whether `stamp` would change the index or
138        /// `.aristo/canon-matches.toml`, without writing either.
139        /// Exits non-zero if it would. Skips the canon-match step
140        /// too (no outbound network calls in this mode).
141        #[arg(long)]
142        check: bool,
143        /// Skip the canon-match step for this run. Doesn't disable
144        /// canon globally — set `[canon] enabled = false` in
145        /// `aristo.toml` for that. Useful when you're offline or want
146        /// a fast local stamp.
147        #[arg(long = "skip-canon")]
148        skip_canon: bool,
149        /// Invalidate the local canon-match cache and re-query every
150        /// annotation on this run. Equivalent to
151        /// `aristo canon refresh && aristo stamp`.
152        #[arg(long = "refresh-canon", conflicts_with = "skip_canon")]
153        refresh_canon: bool,
154        /// Garbage-collect archived orphan proofs after stamping. Removed
155        /// annotations' `.proof` files are normally *archived* (moved to
156        /// `.aristo/archive/proofs/`) so a stray stamp never loses a verdict;
157        /// `--gc` is the only path that hard-deletes them. No-op under
158        /// `--check` (CI must not mutate the workspace).
159        #[arg(long = "gc")]
160        gc: bool,
161    },
162
163    /// Look up an annotation by id, fn / mod / struct name, or file:line.
164    Show {
165        /// Selector: bare id, `fn <name>`, `mod <name>`, `struct <name>`,
166        /// `enum <name>`, `trait <name>`, or `<file>:<line>`.
167        selector: String,
168        /// Emit the entry as JSON instead of human-readable text.
169        #[arg(long, conflicts_with = "toml_out")]
170        json: bool,
171        /// Emit the entry as TOML (mirrors the on-disk index schema).
172        #[arg(long = "toml", conflicts_with = "json")]
173        toml_out: bool,
174    },
175
176    /// List every annotation in the index.
177    List {
178        /// Unified filter clause (`id=<id>`, `file=<path>`,
179        /// `parent=<id>`, `status=<state>`). Repeatable; multiple
180        /// `--filter` flags AND together.
181        #[arg(long = "filter", value_name = "key=value")]
182        filters: Vec<String>,
183        /// Emit a JSON array of records instead of human-readable text.
184        #[arg(long)]
185        json: bool,
186    },
187
188    /// Project-level summary (tier, counts, freshness).
189    Status,
190
191    /// Machine-readable project metrics (counts, unverified backlog, tier).
192    /// The same `Metrics` value the nudge engine computes internally.
193    Metrics {
194        /// Emit the metrics as a JSON object instead of a human summary.
195        #[arg(long)]
196        json: bool,
197    },
198
199    /// The nudge/progress engine. With no `--event`, prints what the engine
200    /// would surface right now (human introspection). With `--event`, runs as
201    /// a Claude Code hook emitter (`post-tool-use` / `user-prompt-submit` /
202    /// `session-start`) — always exits 0 so a nudge can never break the
203    /// agent's workflow. Agent-facing output uses `additionalContext` (the
204    /// only channel that reaches the model); `Stop` is intentionally not
205    /// served (its output never reaches the agent).
206    Nudge {
207        /// Hook event to serve (omit for the human readout).
208        #[arg(long)]
209        event: Option<String>,
210    },
211
212    /// Ambient one-line status segment for the Claude Code `statusLine`
213    /// (e.g. `aristo  3 review · 2 unverified · Apprentice`). Read-only;
214    /// prints nothing when there's no workspace or nudges are off.
215    Statusline,
216
217    /// Review newly-authored intents (#7). With no flags, lists what awaits
218    /// review (split into new-this-session vs backlog when a baseline exists).
219    /// `--mark <id>` records intents as reviewed once you've looked at them.
220    Review {
221        /// Mark these authored intents reviewed (repeatable; comma-lists OK).
222        /// The whole batch is validated before anything is written.
223        #[arg(long = "mark", value_name = "ID")]
224        mark: Vec<String>,
225        /// Emit the review snapshot (or mark result) as JSON instead of a
226        /// human summary.
227        #[arg(long)]
228        json: bool,
229    },
230
231    /// Check annotation prose for quality issues (rule-based; no LLM).
232    Lint {
233        /// Read-only mode: exit non-zero on `error` findings (or
234        /// `warn` with `--strict`); never modifies source.
235        #[arg(long)]
236        check: bool,
237        /// Apply auto-fixable lint rules (whitespace only) to source
238        /// files in place.
239        #[arg(long, conflicts_with = "check")]
240        fix: bool,
241        /// Treat `warn` severity as failure too (only meaningful with `--check`).
242        #[arg(long)]
243        strict: bool,
244    },
245
246    /// Run verification for every annotation that opted in.
247    Verify {
248        /// Unified filter clause (`id=<id>`, `file=<path>`,
249        /// `parent=<id>`, `status=<state>`). Repeatable; multiple
250        /// `--filter` flags AND together.
251        #[arg(long = "filter", value_name = "key=value")]
252        filters: Vec<String>,
253        /// Re-verify entries that are already in a clean verified
254        /// state. By default they're skipped; pass `--rerun` to force
255        /// a re-check.
256        #[arg(long)]
257        rerun: bool,
258        /// CI mode: report whether any status would change, without
259        /// writing the index. Exits non-zero if any change is needed.
260        #[arg(long)]
261        check: bool,
262        /// Treat warn-severity verification outcomes as failure too.
263        #[arg(long)]
264        strict: bool,
265        /// Audit mode: regenerate the index in memory and validate every
266        /// `.aristo/proofs/<id>.proof` against current source. Reports
267        /// fresh / stale / refuted / orphan counts; with `--strict`, exits
268        /// non-zero on any stale, counterexample, or orphan proof. The CI
269        /// freshness gate (replaces `aristo stamp --check`) — no dispatch,
270        /// no network, no token.
271        #[arg(long = "audit")]
272        audit: bool,
273        /// Apply pending verdict files in `.aristo/proofs/` to the
274        /// index. Reads every `<id>.proof`, runs the mechanical
275        /// validator, and (if it passes) flips the entry's status.
276        /// Skips dispatch of new verifications when set.
277        #[arg(long = "apply-verdicts", conflicts_with = "submit_verdict")]
278        apply_verdicts: bool,
279        /// Migration only: ignore any agent-stamped ground hashes in
280        /// the `.proof` files and recompute them from the cited file
281        /// ranges and index entries. Use this once when migrating
282        /// from older proof files that recorded hashes the SDK now
283        /// fills in itself. Without this flag, a stamped hash that
284        /// mismatches the current source is reported as staleness
285        /// and the proof is rejected. Only meaningful with
286        /// `--apply-verdicts`.
287        #[arg(long = "rewrite-hashes", requires = "apply_verdicts")]
288        rewrite_hashes: bool,
289        /// **Internal — invoked by the verification skill.** Submit
290        /// a single verdict: parse the JSON payload, validate it,
291        /// and (on pass) atomically write `.aristo/proofs/<id>.proof`.
292        /// Prints `accepted: sha256:<hex>` on success; structured
293        /// errors on reject. Agents never write `.proof` files
294        /// directly — the SDK is the sole writer.
295        #[arg(long = "submit-verdict", requires = "id", requires = "json")]
296        submit_verdict: bool,
297        /// Annotation id this verdict is about. Required with
298        /// `--submit-verdict`. The `.proof` file lands at
299        /// `.aristo/proofs/<id>.proof` (with `:` rewritten to `__`).
300        #[arg(long = "id", requires = "submit_verdict")]
301        id: Option<String>,
302        /// JSON-serialized ProofFile body. Required with
303        /// `--submit-verdict`. Pass as a single-quoted shell string;
304        /// the SDK parses it into a ProofFile and rejects anything
305        /// the validator would reject. Same schema as the TOML body
306        /// written on accept.
307        #[arg(long = "json", requires = "submit_verdict")]
308        json: Option<String>,
309        /// **Internal — invoked by the verification skill.**
310        /// Atomically claim one task from the pending queue and
311        /// print its TOML body to stdout. Empty stdout means the
312        /// queue is drained (exit 0 either way). Verify workers are
313        /// single-shot — call once, process the task, exit — so
314        /// context doesn't carry between verifications. The
315        /// orchestrator runs N workers in parallel and uses
316        /// `--queue-status` to decide when to spawn the next wave.
317        #[arg(long = "pop-next", conflicts_with_all = ["apply_verdicts", "submit_verdict", "queue_status"])]
318        pop_next: bool,
319        /// Peek at queue state without claiming. Prints `pending: N`,
320        /// `claimed: M` to stdout, exit 0. Used by the orchestrator
321        /// to decide whether to dispatch another wave of workers.
322        /// Safe to call concurrently.
323        #[arg(long = "queue-status", conflicts_with_all = ["apply_verdicts", "submit_verdict"])]
324        queue_status: bool,
325        /// Block until the canon-verify session reaches a terminal
326        /// state, rendering a snapshot at each long-poll return and
327        /// emitting a `still running…` heartbeat every 60s. Exit code
328        /// is derived from the final summary: `0` iff every
329        /// annotation is `verified` or `no_coverage`. Without
330        /// `--wait` the SDK detaches after dispatch (prints session
331        /// id and exits 0). Combine with `--view <id>` to attach to
332        /// a session another invocation started.
333        #[arg(long = "wait")]
334        wait: bool,
335        /// Re-attach to a previously-dispatched canon-verify session
336        /// by id. Skips the source eligibility scan, push-first
337        /// precheck, and POST — just GETs the session state and
338        /// renders. Combine with `--wait` to block until terminal.
339        #[arg(long = "view", value_name = "SESSION_ID")]
340        view: Option<String>,
341        /// Subset the canon-verify dispatch to the listed annotation
342        /// ids. Comma-separated; each id must be a canon-bound entry
343        /// (`aristos:foo` or `kanon:bar`) in the workspace's index.
344        /// Bare canon-id suffixes (e.g. `foo`) are accepted as a
345        /// shorthand. `arta_*` (server-side opaque) ids are rejected
346        /// — those are not user-facing.
347        #[arg(long = "tags", value_name = "id1,id2,...", value_delimiter = ',')]
348        tags: Vec<String>,
349        /// Phase 16 (c): record a user-side known-failure waiver for a
350        /// canon-bound property your code currently violates. Write-only
351        /// — does NOT dispatch a verification. Requires `--because`. The
352        /// gap lands in `.aristo/expectations.toml` (commit it); at
353        /// verify time it renders as a "known gap (accepted)" instead of
354        /// a failure, and the strict ratchet flips it back to red if the
355        /// property ever starts passing — so stale waivers can't rot.
356        /// Accepts a bare canon-id suffix (e.g. `foo`) as shorthand.
357        #[arg(
358            long = "accept",
359            value_name = "CANON_ID",
360            requires = "because",
361            conflicts_with_all = [
362                "view", "wait", "tags", "rerun", "check", "strict", "filters",
363                "apply_verdicts", "rewrite_hashes", "submit_verdict", "id",
364                "json", "pop_next", "queue_status"
365            ]
366        )]
367        accept: Option<String>,
368        /// Reason the gap is accepted (mandatory with `--accept`).
369        /// Recorded verbatim and shown on the verify card. A reasonless
370        /// waiver is how baselines rot, so it is required.
371        #[arg(long = "because", value_name = "REASON", requires = "accept")]
372        because: Option<String>,
373        /// Optional tracking reference (issue URL, ticket id) for the
374        /// accepted gap. Only meaningful with `--accept`.
375        #[arg(long = "tracking", value_name = "REF", requires = "accept")]
376        tracking: Option<String>,
377    },
378
379    /// Run the critique skill against annotation prose — opinionated
380    /// suggestions, severity-tagged findings.
381    Critique {
382        /// Unified filter clause (`id=<id>[,<id>,...]`,
383        /// `file=<path>`, `parent=<id>`, `status=<state>`). Repeatable;
384        /// multiple `--filter` flags AND together; values may be
385        /// comma-separated. **REQUIRED** — `aristo critique` with no
386        /// filter errors with usage. To sweep every annotation in the
387        /// index, opt in explicitly with `--all --yes`.
388        #[arg(long = "filter", value_name = "key=value")]
389        filters: Vec<String>,
390        /// Apply pending critique files in `.aristo/critiques/` —
391        /// re-validate every `<id>.critique` and print a summary
392        /// grouped by id. Defaults to listing only findings whose
393        /// `disposition` is `None` (open / not yet reviewed); pass
394        /// `--include-closed` for the full view including findings
395        /// already triaged via `aristo session decide`.
396        #[arg(long = "apply-findings", conflicts_with_all = ["submit_findings", "pop_next", "queue_status"])]
397        apply_findings: bool,
398        /// Include findings whose `disposition` has been set (Accepted /
399        /// Rejected / Deferred) in the `--apply-findings` summary.
400        /// By default only open findings are listed — closed ones
401        /// stop re-surfacing on every apply, which is how a review
402        /// closes the loop. Only meaningful with `--apply-findings`.
403        #[arg(long = "include-closed", requires = "apply_findings")]
404        include_closed: bool,
405        /// Force re-enqueue of every matched annotation, bypassing the
406        /// `last_critiqued_at_text_hash` cache. Default behavior skips
407        /// annotations whose text hasn't drifted since the cached
408        /// critique was produced (so re-runs of `aristo critique
409        /// --filter id=X` are free when X is unchanged).
410        #[arg(long = "rerun")]
411        rerun: bool,
412        /// Restrict scope to annotations in files git-staged for the
413        /// next commit (`git diff --cached --name-only`). Useful for
414        /// pre-commit hook integration. Satisfies the filter-required
415        /// guard on its own; composes with explicit `--filter`
416        /// clauses via intersection (annotations must match BOTH
417        /// `--filter` and appear in the staged set).
418        #[arg(long = "staged")]
419        staged: bool,
420        /// Sweep every intent annotation that has a real `verify`
421        /// method (skips documentation-only `verify = false`). Loud
422        /// on purpose: prints `(this will enqueue N annotations;
423        /// ~$X cost — proceed with --all --yes?)` and exits 2 unless
424        /// you also pass `--yes`. Without the confirmation, an agent
425        /// could accidentally fire hundreds of LLM calls in one go.
426        #[arg(long = "all", conflicts_with_all = ["filters", "staged"])]
427        all: bool,
428        /// Skip the confirmation prompt for `--all`. Required
429        /// alongside `--all` to actually enqueue the sweep; without
430        /// it `--all` just prints the cost estimate and exits 2.
431        #[arg(long = "yes", requires = "all")]
432        yes: bool,
433        /// **Internal — invoked by the critique skill.** Atomically
434        /// claim one task from the critique queue and print its TOML
435        /// body to stdout. Empty stdout means the queue is drained
436        /// (exit 0 either way). Unlike verify, critique workers loop
437        /// on this call — the tasks are shallow and vocabulary stays
438        /// consistent when one worker handles several.
439        #[arg(long = "pop-next", conflicts_with_all = ["apply_findings", "submit_findings", "queue_status"])]
440        pop_next: bool,
441        /// Peek at queue state without claiming. Prints `pending: N`
442        /// + `claimed: M` to stdout, exit 0.
443        #[arg(long = "queue-status", conflicts_with_all = ["apply_findings", "submit_findings"])]
444        queue_status: bool,
445        /// **Internal — invoked by the critique skill.** Submit a
446        /// single critique: parse the JSON payload, validate it, and
447        /// (on accept) atomically write
448        /// `.aristo/critiques/<id>.critique`. Prints
449        /// `accepted: sha256:<hex>` on success.
450        #[arg(long = "submit-findings", requires = "id", requires = "json")]
451        submit_findings: bool,
452        /// Annotation id this submission is about. Required with
453        /// `--submit-findings`.
454        #[arg(long = "id", requires = "submit_findings")]
455        id: Option<String>,
456        /// JSON-serialized CritiqueFile body. Required with
457        /// `--submit-findings`.
458        #[arg(long = "json", requires = "submit_findings")]
459        json: Option<String>,
460    },
461
462    /// Generate per-annotation markdown to .aristo/doc/.
463    Doc {
464        /// Write only the crate-root summary (`_summary.md`); skip the
465        /// per-annotation pass.
466        #[arg(long)]
467        summary: bool,
468        /// Include each annotation's current verification status in
469        /// the rendered markdown. Status is a build-time snapshot
470        /// that drifts as code evolves; the default omits it so doc
471        /// artifacts stay reproducible on a clean checkout.
472        #[arg(long = "include-status")]
473        include_status: bool,
474        /// CI mode: recompute expected per-annotation MD from the index,
475        /// compare against `.aristo/doc/`, exit non-zero on drift. Never
476        /// writes.
477        #[arg(long)]
478        check: bool,
479        /// Composite: also generate the annotation graph (Mermaid)
480        /// and embed it inline in `_summary.md`. Implies `--summary`.
481        /// Conflicts with `--check` (read-only mode can't write the
482        /// graph block).
483        #[arg(long = "include-graph", conflicts_with = "check")]
484        include_graph: bool,
485    },
486
487    /// Generate the annotation graph (Mermaid / DOT / SVG).
488    Graph {
489        /// Output format. `mermaid` (default) emits a fenced
490        /// flowchart TD block; `dot` emits Graphviz DOT; `svg`
491        /// requires `dot` on PATH and shells out to render.
492        #[arg(long, default_value = "mermaid")]
493        format: String,
494        /// Write to this path instead of stdout. Atomic via
495        /// temp-file + rename. Relative paths resolve against the
496        /// invoking directory.
497        #[arg(long)]
498        out: Option<PathBuf>,
499        /// Unified filter clause (`id=<id>`, `file=<path>[:<LO>-<HI>]`,
500        /// `parent=<id>`, `status=<state>`). Repeatable; multiple
501        /// `--filter` flags AND together. With no filter, the scope
502        /// is the whole index.
503        #[arg(long = "filter", value_name = "key=value")]
504        filters: Vec<String>,
505        /// Drop `assume` nodes from the rendered graph. They're
506        /// included by default because assumes describe the
507        /// background facts your intents rely on — dropping them by
508        /// default would hide those.
509        #[arg(long = "exclude-assumes")]
510        exclude_assumes: bool,
511        /// Walk N hops from each filter-matched node in both
512        /// directions (ancestors + descendants) and include them in
513        /// the rendered graph. Useful for "show me this annotation
514        /// plus some context". Only meaningful with `--filter`;
515        /// without a filter, the scope is already the whole index.
516        #[arg(long, value_name = "N")]
517        depth: Option<u32>,
518        /// Include intent nodes that have no parent and no children.
519        /// They're omitted by default — usually they're standalone
520        /// claims that don't add structure to the rendered graph.
521        /// Assumes are always included (see `--exclude-assumes` for
522        /// that opt-out).
523        #[arg(long = "include-orphans")]
524        include_orphans: bool,
525        /// Color nodes by their current verification status instead
526        /// of by `verify` level (verified=green / tested=blue /
527        /// neural=yellow / stale=orange / orphan=purple /
528        /// forged=red+border / unknown=gray /
529        /// counterexample=red+border / inconclusive=red+border /
530        /// pending-deepen=gray). The `verify` level moves to the
531        /// in-node label. Use when you want to see what's still
532        /// unverified.
533        #[arg(long = "include-status")]
534        include_status: bool,
535    },
536
537    /// Generate a shareable SVG verification badge for README / docs.
538    Badge {
539        /// Write SVG to this path (relative to workspace root, or absolute).
540        /// Default: stdout.
541        #[arg(long)]
542        out: Option<PathBuf>,
543        /// Badge style: `flat-square` (default), `flat`, or `plastic`.
544        #[arg(long, default_value = "flat-square")]
545        style: String,
546        /// Which metric the SVG value half displays. `tier` (default,
547        /// the locked D7 score → D8 tier) is the headline signal;
548        /// `count` and `rate` preserve the slice-31 surfaces for
549        /// projects that prefer the simpler counters.
550        #[arg(long, default_value = "tier")]
551        metric: String,
552    },
553
554    /// Rename an annotation id everywhere it appears — source files,
555    /// index, and doc artifacts. Either every change lands or none do.
556    ///
557    /// Supported renames: bare → bare, and stamp-assigned opaque
558    /// (`aret_*`) → bare. Canon-bound prefixes (`aristos:` / `kanon:`)
559    /// are rejected in either direction — those prefixes are applied
560    /// by `aristo canon accept` and removed by `aristo canon unbind`.
561    /// The new id cannot itself be an opaque `aret_*` id (those are
562    /// stamp-assigned only).
563    Rename {
564        /// Annotation id to rename FROM. Must exist in the current
565        /// `.aristo/index.toml`.
566        old_id: String,
567        /// Annotation id to rename TO. Must not already exist and must
568        /// not use the reserved `aret_*` / `aristos:` / `kanon:`
569        /// prefixes.
570        new_id: String,
571        /// Compute and print the rename plan (source edits + per-id
572        /// artifact moves + index updates) without writing anything.
573        #[arg(long = "dry-run")]
574        dry_run: bool,
575    },
576
577    /// Run a review session over a pipeline's open artifacts —
578    /// critique findings, proof verdicts, and so on. Start it,
579    /// inspect bucket counts, record decisions, and close out.
580    Session {
581        #[command(subcommand)]
582        action: SessionAction,
583    },
584
585    /// Sign in to the Aretta canon API. Required for `aristo stamp`
586    /// and `aristo critique` to see canon matches on the Pro /
587    /// Enterprise tiers.
588    Auth {
589        #[command(subcommand)]
590        action: AuthAction,
591    },
592
593    /// Manage canon bindings: accept or reject pending matches,
594    /// inspect or refresh the local cache, unbind bound ids, and
595    /// request a verifier for a canon entry.
596    Canon {
597        #[command(subcommand)]
598        action: CanonAction,
599    },
600
601    /// Manage C instrumentation artifacts: vendor the C runtime, run codegen.
602    Instrument {
603        #[command(subcommand)]
604        action: InstrumentAction,
605    },
606}
607
608/// Subcommands under `aristo auth`. Each operates on the persistent
609/// credentials store under `$XDG_CONFIG_HOME/aristo/credentials`
610/// (or the platform default per `aristo_core::auth`).
611#[derive(clap::Subcommand, Debug)]
612pub(crate) enum AuthAction {
613    /// Authenticate against the Aretta proxy.
614    ///
615    /// **Default mode (GitHub OAuth):** the CLI fetches the GitHub
616    /// authorization URL from the proxy, tries to open it in your
617    /// browser, and prompts you to paste the code shown on the
618    /// proxy's callback page. The proxy then mints an `arta_*`
619    /// token scoped to your `(user, repo)` pair.
620    ///
621    /// **Bypass modes (for CI / scripting):**
622    ///
623    /// - **`--stdin`** — read the raw token from stdin
624    ///   (`echo "$TOKEN" | aristo auth login --stdin`).
625    /// - **`--token=<T>`** — use the literal token value.
626    ///
627    /// The token is persisted to `$XDG_CONFIG_HOME/aristo/credentials`
628    /// with `0600` Unix permissions.
629    Login {
630        /// Read the token from stdin (consumes entire stdin). Skips
631        /// the OAuth flow.
632        #[arg(long, conflicts_with = "token")]
633        stdin: bool,
634        /// Use this token directly. Skips the OAuth flow.
635        #[arg(long, value_name = "TOKEN")]
636        token: Option<String>,
637        /// Aretta server to authenticate against. Accepts:
638        /// `prod` / `production` (= https://code.aretta.ai),
639        /// or a full URL for a self-hosted deployment or per-org
640        /// conductor (`https://aretta.example.com`).
641        ///
642        /// Precedence when unset: this flag > the `ARETTA_API_URL` env
643        /// var (parsed the same way, full URLs included) > zero-config
644        /// org discovery for the repo > the `prod` default. Discovery
645        /// runs only when neither this flag nor `ARETTA_API_URL` is set,
646        /// so an explicit choice always wins and skips the lookup.
647        /// Honoring `ARETTA_API_URL` keeps the login (auth) plane pointed
648        /// at the same deployment as the data plane.
649        #[arg(long)]
650        server: Option<String>,
651        /// Repo to scope the minted token to (`owner/repo`). Defaults to
652        /// auto-deriving from `<cwd>/.git/config`'s `remote.origin.url`.
653        /// Required for non-git directories or when the remote isn't a
654        /// GitHub URL. In `--stdin` / `--token` bypass modes it (with
655        /// `--server`) keys the stored credential for later multi-repo
656        /// lookup — best-effort there, so it may be omitted.
657        #[arg(long, value_name = "OWNER/REPO")]
658        repo: Option<String>,
659    },
660    /// Show the current authentication state. Lists every stored
661    /// credential (server, repo, user) — never the token itself — plus
662    /// any `ARETTA_TOKEN` env override. Handy for sanity-checking before
663    /// running `aristo stamp`.
664    Status,
665    /// Print the resolved `arta_*` token to stdout — the `ARETTA_TOKEN`
666    /// env var if set, else the stored credential for the current repo.
667    /// Nothing else is printed, so it pipes cleanly to your clipboard,
668    /// e.g. `aristo auth token | pbcopy` (macOS) or
669    /// `aristo auth token | xclip -selection clipboard` (Linux). Handy for
670    /// setting the `ARETTA_TOKEN` CI secret. Errors if not authenticated.
671    Token {
672        /// Repo (`owner/repo`) whose token to print. Defaults to the
673        /// cwd's git remote; with several credentials stored and no
674        /// match, errors telling you to pass this.
675        #[arg(long, value_name = "OWNER/REPO")]
676        repo: Option<String>,
677    },
678    /// Remove a stored credential. By default removes the current repo's
679    /// entry (from `--repo` or the cwd's git remote); `--all` clears
680    /// every credential. Idempotent — logging out when not logged in is
681    /// not an error.
682    Logout {
683        /// Remove every stored credential, not just the current repo's.
684        #[arg(long)]
685        all: bool,
686        /// Repo (`owner/repo`) whose credential to remove. Defaults to
687        /// the cwd's git remote. Ignored with `--all`.
688        #[arg(long, value_name = "OWNER/REPO", conflicts_with = "all")]
689        repo: Option<String>,
690    },
691}
692
693/// Subcommands under `aristo instrument`.
694#[derive(clap::Subcommand, Debug)]
695pub(crate) enum InstrumentAction {
696    /// Emit the vendored C runtime (`aristo.h` + `aristo.c`) into a SUT so it
697    /// can link Aristo's fault-injection / observation points. C11, gated by
698    /// `-DARISTO_INSTRUMENT`.
699    VendorC {
700        /// Directory to write `aristo.h` / `aristo.c` into.
701        #[arg(long, default_value = "aristo")]
702        out: PathBuf,
703    },
704
705    /// Render read-only field accessors from `// @aristo inspect(...)`
706    /// directives into a gated `aristo_generated.{h,c}` pair.
707    GenC {
708        /// One or more C source files to read directives from.
709        #[arg(required = true)]
710        paths: Vec<PathBuf>,
711        /// The opaque-handle header the generated `.h` includes.
712        #[arg(long, default_value = "db.h")]
713        handle_header: String,
714        /// Directory to write `aristo_generated.{h,c}` into.
715        #[arg(long, default_value = "aristo")]
716        out: PathBuf,
717        /// Verify committed generated files match the directives; write
718        /// nothing, exit non-zero on drift (CI gate).
719        #[arg(long)]
720        check: bool,
721    },
722}
723
724/// Subcommands under `aristo canon`.
725#[derive(clap::Subcommand, Debug)]
726pub(crate) enum CanonAction {
727    /// Accept a pending canon match: rewrite source to use the
728    /// canonical text + apply the `aristos:` / `kanon:` prefix to
729    /// the annotation id, update the index entry's binding state
730    /// to `Bound`, and move the cache entry from `pending_matches`
731    /// to `accepted_matches`.
732    ///
733    /// Both arguments are required: the bare annotation id as it
734    /// appears in `.aristo/index.toml` (NOT prefixed; the prefix
735    /// is applied by accept) and the bare canon id from the
736    /// pending match (e.g. `cell_written_exactly_once_per_page_edit`).
737    Accept {
738        /// Annotation id whose pending match you're accepting. Use
739        /// the bare form (no `aristos:` / `kanon:` prefix); the
740        /// prefix is applied by the accept itself based on the
741        /// pending match's `prefix_tier`.
742        annotation_id: String,
743        /// Canon id from the pending match (also bare — no
744        /// prefix). The pair `(annotation_id, canon_id)` locates
745        /// the exact pending match in `.aristo/canon-matches.toml`.
746        canon_id: String,
747    },
748
749    /// Reject a pending canon match: move the entry from
750    /// `pending_matches` to `rejected_matches`, pinned to the
751    /// current annotation `text_hash`. The rejection keeps the same
752    /// `(canon_id, text_hash)` pair from re-surfacing on future
753    /// `aristo stamp` runs; once the annotation text changes, the
754    /// rejection no longer applies and the match is re-evaluated.
755    /// Source and index are not touched — rejection is a cache-only
756    /// operation.
757    Reject {
758        /// Annotation id whose pending match you're rejecting.
759        annotation_id: String,
760        /// Canon id from the pending match.
761        canon_id: String,
762        /// Optional note recorded with the rejection. Useful for
763        /// capturing the *why* (e.g. "this canon entry is too broad",
764        /// "wrong category") for whoever revisits it later.
765        #[arg(long = "reason")]
766        reason: Option<String>,
767    },
768
769    /// List the current canon match state: one line per annotation
770    /// with pending / accepted / rejected counts, plus per-bucket
771    /// detail lines for each match. Reads `.aristo/canon-matches.toml`;
772    /// does not call the canon API.
773    List,
774
775    /// Fetch the canon entry detail for `<canon_id>` via the canon
776    /// API and render the longer description + example + references.
777    /// For the full trust card (server description + local binding
778    /// state combined), use `aristo show <bound_id>` instead.
779    Show {
780        /// Bare canon id (no `aristos:` / `kanon:` prefix). The
781        /// server's `GET /canon/entry/<canon_id>` endpoint returns
782        /// the same entry regardless of which tier you'd bind into;
783        /// the prefix is a per-user, per-scope attribute.
784        canon_id: String,
785        /// Optional explicit version (`v<minor>.<patch>`). Omit to
786        /// get the catalog's currently active version.
787        #[arg(long = "version")]
788        version: Option<String>,
789    },
790
791    /// Re-query the canon API for every annotation in the index,
792    /// bypassing the local match cache. Equivalent to
793    /// `aristo stamp --refresh-canon` without the rest of the stamp
794    /// pipeline — no source walk, no drift check, no index rewrite.
795    /// Useful when you know a new catalog version has shipped and
796    /// want fresh matches without a full stamp.
797    Refresh,
798
799    /// Reverse of `aristo canon accept`: strip the `aristos:` /
800    /// `kanon:` prefix from a canon-bound annotation, revert its
801    /// binding to `Local`, and drop the accepted_matches cache
802    /// entry. Source is rewritten in place (only the `id =` value
803    /// changes; canonical text + verify + parent are preserved).
804    /// The next `aristo stamp` may re-pull a fresh pending match
805    /// against the same annotation text.
806    ///
807    /// Unbind is for LIVE annotations. If the annotation was deleted
808    /// from source, no unbind is needed: the next `aristo stamp`
809    /// prunes its `.aristo/canon-matches.toml` entry automatically.
810    Unbind {
811        /// Canon-bound annotation id including the prefix (e.g.
812        /// `aristos:cell_written_exactly_once_per_page_edit`).
813        prefixed_id: String,
814    },
815
816    /// Record a verification-demand signal against a canon entry.
817    /// Idempotent on `(canon_id, repo, user)` — repeated calls don't
818    /// pile up. Use when an annotation is bound at the `kanon:` tier
819    /// and you'd like Aretta to invest in a verifier for that canon
820    /// entry.
821    RequestVerify {
822        /// Canon id (no prefix). The same id the trust card shows,
823        /// or that `aristo canon list` reports.
824        canon_id: String,
825        /// Optional note to attach to the demand signal (e.g.
826        /// "critical for our financial-tx audit"). A repeat call
827        /// with a new note replaces the previous one server-side.
828        #[arg(long = "notes")]
829        notes: Option<String>,
830    },
831
832    /// Report per-binding version drift between the local cache and
833    /// the canon API. Reports three classes: `current` (no change),
834    /// `patch-bump` (same canon_id, newer version — recommended
835    /// action: `aristo canon refresh`), and `minor-bump` (canon_id
836    /// retired — recommended action: `aristo canon unbind <id>` then
837    /// re-stamp). Currently diagnostic-only; automatic patch-bump
838    /// application is planned.
839    Migrate,
840
841    /// Download the canon catalogue (the full list of available canon
842    /// entries) to `.aristo/catalogue.json` — a gitignored local
843    /// snapshot — and print a summary. Requires authentication; served
844    /// by the per-repo conductor addressed via `[instance] url`.
845    Catalogue,
846
847    /// Run the S2 presence probe against a local SUT checkout: union
848    /// the instrumentation bundles carried by this repo's accepted
849    /// canon matches, generate an ephemeral probe crate (one
850    /// type-checked accessor call per record), `[patch]` the SUT
851    /// package to `--sut-path`, and `cargo check` it twice — with the
852    /// bundle's instrumentation features on (presence) and off (the
853    /// accessors must vanish; gating check). Failures are classified
854    /// per accessor — missing (rustc E0599/E0609) vs
855    /// SUT_FEATURE_UNDECLARED (cargo feature resolution) — and
856    /// rendered as escalation cards naming the conformance catch that
857    /// needs the accessor. Reuses the SUT's Cargo.lock + target/ so a
858    /// warm probe compile takes seconds. Fully offline: reads
859    /// `.aristo/canon-matches.toml`; never calls the canon API.
860    Probe {
861        /// Path to the local SUT checkout root (e.g. your
862        /// aretta-ai/turso fork clone). The probe `[patch]`es the SUT
863        /// package to this path and docks into its build graph
864        /// (Cargo.lock, target/, toolchain file).
865        #[arg(long = "sut-path", value_name = "DIR")]
866        sut_path: PathBuf,
867        /// The escalation card's `[ flag-broken ]` decision: run the
868        /// probe, verify this accessor still fails, and append the
869        /// rustc evidence to `.aristo/instrumentation-debt.jsonl`
870        /// (committed, append-only). Flagged accessors are re-surfaced
871        /// as `SKIPPED(instr-debt: <id>)` on later runs — visible
872        /// debt, never silently green.
873        #[arg(
874            long = "flag-broken",
875            value_name = "ACCESSOR_ID",
876            conflicts_with = "gen_only"
877        )]
878        flag_broken: Option<String>,
879        /// Keep the generated probe crate directory (under the SUT
880        /// target dir) instead of removing it after the run.
881        #[arg(long = "keep-probe")]
882        keep_probe: bool,
883        /// Generate and write the probe crate, then stop before
884        /// compiling (implies --keep-probe). Useful for inspecting
885        /// exactly what the probe will check.
886        #[arg(long = "gen-only")]
887        gen_only: bool,
888    },
889
890    /// List or inspect queued §17 proof-tree suggestions (the related
891    /// canon entries dragged in alongside a primary match). Read-only:
892    /// it does not open a review session or mutate the queue. To review
893    /// and adopt suggestions, run `aristo session start intent-review`.
894    ///
895    /// With no argument, lists every queued cluster. With an
896    /// `<objective>` (the cluster's objective canon_id, or the seeding
897    /// primary id for siblings-only clusters), shows that cluster's
898    /// detail. With `--counts`, emits a machine-readable JSON summary
899    /// `{matches:{new,pending}, suggestions:{new,pending}}` for the
900    /// menu / skill entry Q&A.
901    Suggestions {
902        /// Objective canon_id (or seeding primary id) of the cluster to
903        /// show. Omit to list all queued clusters.
904        objective: Option<String>,
905        /// Emit counts only (`{matches:{...}, suggestions:{...}}`) as
906        /// JSON; no per-cluster detail. Mutually exclusive with
907        /// passing an `<objective>`.
908        #[arg(long = "counts", conflicts_with = "objective")]
909        counts: bool,
910        /// Scope the listing with the shared `--filter` grammar
911        /// (`id=`, `file=[:LO-HI]`, `parent=`, `status=`). The
912        /// `cluster <objective>` mode (§6B) is `--filter
913        /// parent=<kanon:objective>` — it scopes the list to one proof
914        /// cluster (leaves carry `parent=`). Multiple `--filter` flags
915        /// AND together. No new grammar — reuses `filter.rs` verbatim.
916        #[arg(
917            long = "filter",
918            value_name = "KEY=VALUE",
919            conflicts_with = "objective"
920        )]
921        filter: Vec<Filter>,
922    },
923}
924
925/// Subcommands under `aristo session`. Each maps to one substrate
926/// operation; per-kind side effects (e.g. mutating a `.critique`
927/// file on accept) plug in via the `SessionKind` trait wired in
928/// step 5.
929#[derive(clap::Subcommand, Debug)]
930pub(crate) enum SessionAction {
931    /// Begin a new review session of the given kind. Fails if a
932    /// session is already active — pass `--allow-nesting` to override
933    /// (currently no kind allows nesting).
934    Start {
935        /// Session kind (`critique-review`, `proof-review`).
936        kind: String,
937        /// Display label for the artifact under review (e.g.
938        /// `src/critique/pending.rs` or
939        /// `proof:balance_no_duplicate_cells`).
940        #[arg(long = "subject")]
941        subject: String,
942        /// Override the kind's default nesting policy. Currently only
943        /// `Disallow` is implemented; the flag is reserved for future
944        /// per-kind opt-ins.
945        #[arg(long = "allow-nesting", default_value_t = false)]
946        allow_nesting: bool,
947    },
948    /// Print the active session id (or empty stdout if none).
949    /// Exit 0 either way.
950    Active {
951        /// Emit the full `<system-reminder>` block instead of just
952        /// the id — for the `UserPromptSubmit` hook installed by
953        /// `aristo install-skills`. Empty stdout when no session
954        /// is active (the hook then injects nothing).
955        #[arg(long = "hook-format", default_value_t = false)]
956        hook_format: bool,
957    },
958    /// Print bucket counts + open items for the active session.
959    /// Exit 0; errors out if no session is active.
960    Status,
961    /// Record a decision on one item in the active session.
962    Decide {
963        /// Item reference (`<id>#<index>` for indexed items, or any
964        /// opaque per-kind string).
965        #[arg(long = "item")]
966        item: String,
967        /// Which bucket the item lands in.
968        #[arg(long = "bucket", value_enum)]
969        bucket: BucketArg,
970        /// Optional note recorded with the decision.
971        #[arg(long = "note")]
972        note: Option<String>,
973    },
974    /// Close the active session. Strict by default — errors out if
975    /// any items are still in the open bucket.
976    Exit {
977        /// Move open items to the per-kind backlog instead of
978        /// erroring. Items are never silently dropped; the next
979        /// session of this kind surfaces them via the backlog menu.
980        #[arg(long = "defer-undecided", default_value_t = false)]
981        defer_undecided: bool,
982    },
983    /// Cancel the session and discard every decision recorded so far.
984    /// Requires `--yes` to skip the confirmation prompt.
985    Abort {
986        /// Skip the confirmation prompt.
987        #[arg(long = "yes", default_value_t = false)]
988        yes: bool,
989    },
990    /// List the active session and the most recent N closed sessions.
991    List {
992        /// Maximum number of closed-session rows to include.
993        #[arg(long = "limit", default_value_t = 10)]
994        limit: usize,
995    },
996}
997
998/// User-facing bucket choices for `aristo session decide`. Maps to
999/// the substrate's [`session::types::ItemStatus`] (minus `Open`,
1000/// which is the implicit pre-decision state).
1001#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1002pub(crate) enum BucketArg {
1003    Accepted,
1004    Rejected,
1005    Pending,
1006}
1007
1008/// Process entry point. Parses `argv`, dispatches to the chosen subcommand,
1009/// and returns the exit code. Prints `error: <msg>` to stderr on any
1010/// `CliError`.
1011pub fn run() -> ExitCode {
1012    let cli = Cli::parse();
1013    let code = match dispatch(cli.command) {
1014        Ok(()) => ExitCode::SUCCESS,
1015        Err(e) => {
1016            if !e.is_silent() {
1017                eprintln!("error: {e}");
1018            }
1019            ExitCode::from(e.exit_code())
1020        }
1021    };
1022    // Best-effort "newer aristo available" notice, printed after the
1023    // command's own output and never affecting its exit code. Silent in
1024    // CI / pipes / on opt-out (see `update_notify`).
1025    update_notify::maybe_notify();
1026    code
1027}
1028
1029/// Maps a parsed `Commands` variant to its handler.
1030fn dispatch(cmd: Commands) -> CliResult<()> {
1031    match cmd {
1032        Commands::Init {
1033            force,
1034            ci,
1035            ci_verify,
1036            hook,
1037        } => commands::init::run(force, ci, ci_verify, hook),
1038        Commands::Lang { file } => commands::lang::run(file),
1039        Commands::InstallSkills {
1040            agent,
1041            list_agents,
1042            user,
1043            update,
1044            check,
1045            hook_format,
1046        } => {
1047            commands::install_skills::install(agent, list_agents, user, update, check, hook_format)
1048        }
1049        Commands::UninstallSkills { agent, user, force } => {
1050            commands::install_skills::uninstall(agent, user, force)
1051        }
1052        Commands::Index { all } => commands::index::run(all),
1053        Commands::Stamp {
1054            check,
1055            skip_canon,
1056            refresh_canon,
1057            gc,
1058        } => commands::stamp::run(check, skip_canon, refresh_canon, gc),
1059        Commands::Show {
1060            selector,
1061            json,
1062            toml_out,
1063        } => commands::show::run(&selector, output_mode(json, toml_out)),
1064        Commands::List { filters, json } => commands::list::run(&filters, json),
1065        Commands::Status => commands::status::run(),
1066        Commands::Metrics { json } => commands::metrics::run(json),
1067        Commands::Nudge { event } => commands::nudge::run(event),
1068        Commands::Statusline => commands::statusline::run(),
1069        Commands::Review { mark, json } => commands::review::run(json, mark),
1070        Commands::Lint { check, fix, strict } => commands::lint::run(check, fix, strict),
1071        Commands::Verify {
1072            filters,
1073            rerun,
1074            check,
1075            strict,
1076            audit,
1077            apply_verdicts,
1078            rewrite_hashes,
1079            submit_verdict,
1080            id,
1081            json,
1082            pop_next,
1083            queue_status,
1084            wait,
1085            view,
1086            tags,
1087            accept,
1088            because,
1089            tracking,
1090        } => commands::verify::run(
1091            &filters,
1092            rerun,
1093            check,
1094            strict,
1095            audit,
1096            apply_verdicts,
1097            rewrite_hashes,
1098            submit_verdict,
1099            pop_next,
1100            queue_status,
1101            id,
1102            json,
1103            wait,
1104            view,
1105            &tags,
1106            accept,
1107            because,
1108            tracking,
1109        ),
1110        Commands::Critique {
1111            filters,
1112            apply_findings,
1113            include_closed,
1114            rerun,
1115            staged,
1116            all,
1117            yes,
1118            pop_next,
1119            queue_status,
1120            submit_findings,
1121            id,
1122            json,
1123        } => commands::critique::run(
1124            &filters,
1125            submit_findings,
1126            pop_next,
1127            queue_status,
1128            apply_findings,
1129            include_closed,
1130            rerun,
1131            staged,
1132            all,
1133            yes,
1134            id,
1135            json,
1136        ),
1137        Commands::Doc {
1138            summary,
1139            include_status,
1140            check,
1141            include_graph,
1142        } => commands::doc::run(summary, include_status, check, include_graph),
1143        Commands::Graph {
1144            format,
1145            out,
1146            filters,
1147            exclude_assumes,
1148            depth,
1149            include_orphans,
1150            include_status,
1151        } => commands::graph::run(
1152            &format,
1153            out,
1154            &filters,
1155            exclude_assumes,
1156            depth,
1157            include_orphans,
1158            include_status,
1159        ),
1160        Commands::Badge { out, style, metric } => {
1161            let style =
1162                commands::badge::Style::parse(&style).map_err(|message| CliError::Other {
1163                    message,
1164                    exit_code: 2,
1165                })?;
1166            let metric =
1167                commands::badge::Metric::parse(&metric).map_err(|message| CliError::Other {
1168                    message,
1169                    exit_code: 2,
1170                })?;
1171            commands::badge::run(out, style, metric)
1172        }
1173        Commands::Rename {
1174            old_id,
1175            new_id,
1176            dry_run,
1177        } => commands::rename::run(&old_id, &new_id, dry_run),
1178        Commands::Session { action } => commands::session::run(action),
1179        Commands::Auth { action } => commands::auth::run(action),
1180        Commands::Canon { action } => match action {
1181            CanonAction::Accept {
1182                annotation_id,
1183                canon_id,
1184            } => commands::canon::accept::run(&annotation_id, &canon_id),
1185            CanonAction::Reject {
1186                annotation_id,
1187                canon_id,
1188                reason,
1189            } => commands::canon::reject::run(&annotation_id, &canon_id, reason),
1190            CanonAction::List => commands::canon::list::run(),
1191            CanonAction::Show { canon_id, version } => {
1192                commands::canon::show::run(&canon_id, version)
1193            }
1194            CanonAction::Refresh => commands::canon::refresh::run(),
1195            CanonAction::Unbind { prefixed_id } => commands::canon::unbind::run(&prefixed_id),
1196            CanonAction::RequestVerify { canon_id, notes } => {
1197                commands::canon::request_verify::run(&canon_id, notes)
1198            }
1199            CanonAction::Migrate => commands::canon::migrate::run(),
1200            CanonAction::Catalogue => commands::canon::catalogue::run(),
1201            CanonAction::Probe {
1202                sut_path,
1203                flag_broken,
1204                keep_probe,
1205                gen_only,
1206            } => {
1207                commands::canon::probe::run(&sut_path, flag_broken.as_deref(), keep_probe, gen_only)
1208            }
1209            CanonAction::Suggestions {
1210                objective,
1211                counts,
1212                filter,
1213            } => commands::canon::suggestions::run(objective, counts, filter),
1214        },
1215        Commands::Instrument { action } => match action {
1216            InstrumentAction::VendorC { out } => commands::instrument::vendor_c::run(out),
1217            InstrumentAction::GenC {
1218                paths,
1219                handle_header,
1220                out,
1221                check,
1222            } => commands::instrument::gen_c::run(paths, handle_header, out, check),
1223        },
1224    }
1225}
1226
1227fn output_mode(json: bool, toml_out: bool) -> commands::show::OutputMode {
1228    if json {
1229        commands::show::OutputMode::Json
1230    } else if toml_out {
1231        commands::show::OutputMode::Toml
1232    } else {
1233        commands::show::OutputMode::Text
1234    }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240    use clap::CommandFactory;
1241
1242    #[test]
1243    fn cli_parser_construction_is_valid() {
1244        // clap performs a structural sanity check (e.g. no duplicate
1245        // subcommand names) when CommandFactory::command() runs. We assert
1246        // it succeeds rather than panicking at runtime when a user types
1247        // `--help`. Cheap canary against future enum-shape mistakes.
1248        Cli::command().debug_assert();
1249    }
1250
1251    // Note: slice 32 removed the last `not_yet(...)` stub (Rename now
1252    // has a real implementation). The `CliError::NotImplemented` variant
1253    // is kept for future stubs but is no longer reachable from dispatch.
1254}