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 error;
11mod filter;
12mod pipeline;
13mod preflight;
14mod session;
15mod skills;
16mod workspace;
17
18pub use error::{CliError, CliResult};
19pub use filter::{Filter, FilterParseError};
20pub use workspace::{Workspace, WorkspaceError};
21
22use clap::{Parser, Subcommand};
23use std::path::PathBuf;
24use std::process::ExitCode;
25
26/// Aristo annotation SDK CLI.
27///
28/// Each subcommand handles one stage of the annotation lifecycle:
29/// authoring (`init`, `lang`, `install-skills`), indexing and stamping
30/// (`index`, `stamp`, `rename`), inspection (`show`, `list`, `status`,
31/// `graph`, `doc`, `badge`), quality gates (`lint`, `verify`,
32/// `critique`), review-session management (`session`), and canon
33/// binding against the Aretta server (`auth`, `canon`).
34#[derive(Parser, Debug)]
35#[command(
36    name = "aristo",
37    version,
38    about = "Aristo annotation SDK — write, verify, and document intent.",
39    long_about = None,
40)]
41struct Cli {
42    #[command(subcommand)]
43    command: Commands,
44}
45
46#[derive(Subcommand, Debug)]
47enum Commands {
48    /// Set up Aristo in this repo (creates `aristo.toml`, `.aristo/`,
49    /// pre-commit hook, CI workflow).
50    Init {
51        /// Modify Cargo.toml to add `aristo` as a dependency. Default
52        /// behavior just prints the dep line for the user to paste in.
53        #[arg(short, long)]
54        force: bool,
55    },
56
57    /// Print a syntax cheat sheet for the detected language.
58    Lang {
59        /// Detect language for a specific file. Currently only Rust
60        /// (`.rs`) is supported; other extensions error out.
61        #[arg(long)]
62        file: Option<PathBuf>,
63    },
64
65    /// Install Aristo skills for a coding agent (claude-code, cursor, codex, opencode, antigravity).
66    InstallSkills {
67        /// Target agent. Required unless `--list-agents` is used.
68        #[arg(long, value_name = "name")]
69        agent: Option<String>,
70        /// List supported agents and exit.
71        #[arg(long)]
72        list_agents: bool,
73        /// Install at user-level (e.g. ~/.claude/skills/) instead of project-level.
74        #[arg(long)]
75        user: bool,
76        /// Force reinstall (re-pinning to current SDK version).
77        #[arg(long)]
78        update: bool,
79    },
80
81    /// Reverse `install-skills`: remove SDK-bundled skills.
82    UninstallSkills {
83        /// Target agent. Required.
84        #[arg(long, value_name = "name")]
85        agent: String,
86        /// Uninstall from user-level instead of project-level.
87        #[arg(long)]
88        user: bool,
89        /// Override the "skip locally-modified" safety check.
90        #[arg(long)]
91        force: bool,
92    },
93
94    /// Scan source for annotations and write the index (`.aristo/index.toml`).
95    Index {
96        /// Force a full re-walk, ignoring the per-file mtime cache.
97        #[arg(long)]
98        all: bool,
99    },
100
101    /// Refresh the annotation index — pick up new annotations, detect
102    /// drift, and (when signed in) match against the Aretta canon.
103    Stamp {
104        /// CI mode: report whether `stamp` would change the index,
105        /// without writing. Exits non-zero if it would. Skips the
106        /// canon-match step too (no outbound network calls in this
107        /// mode).
108        #[arg(long)]
109        check: bool,
110        /// Skip the canon-match step for this run. Doesn't disable
111        /// canon globally — set `[canon] enabled = false` in
112        /// `aristo.toml` for that. Useful when you're offline or want
113        /// a fast local stamp.
114        #[arg(long = "skip-canon")]
115        skip_canon: bool,
116        /// Invalidate the local canon-match cache and re-query every
117        /// annotation on this run. Equivalent to
118        /// `aristo canon refresh && aristo stamp`.
119        #[arg(long = "refresh-canon", conflicts_with = "skip_canon")]
120        refresh_canon: bool,
121    },
122
123    /// Look up an annotation by id, fn / mod / struct name, or file:line.
124    Show {
125        /// Selector: bare id, `fn <name>`, `mod <name>`, `struct <name>`,
126        /// `enum <name>`, `trait <name>`, or `<file>:<line>`.
127        selector: String,
128        /// Emit the entry as JSON instead of human-readable text.
129        #[arg(long, conflicts_with = "toml_out")]
130        json: bool,
131        /// Emit the entry as TOML (mirrors the on-disk index schema).
132        #[arg(long = "toml", conflicts_with = "json")]
133        toml_out: bool,
134    },
135
136    /// List every annotation in the index.
137    List {
138        /// Unified filter clause (`id=<id>`, `file=<path>`,
139        /// `parent=<id>`, `status=<state>`). Repeatable; multiple
140        /// `--filter` flags AND together.
141        #[arg(long = "filter", value_name = "key=value")]
142        filters: Vec<String>,
143        /// Emit a JSON array of records instead of human-readable text.
144        #[arg(long)]
145        json: bool,
146    },
147
148    /// Project-level summary (tier, counts, freshness).
149    Status,
150
151    /// Check annotation prose for quality issues (rule-based; no LLM).
152    Lint {
153        /// Read-only mode: exit non-zero on `error` findings (or
154        /// `warn` with `--strict`); never modifies source.
155        #[arg(long)]
156        check: bool,
157        /// Apply auto-fixable lint rules (whitespace only) to source
158        /// files in place.
159        #[arg(long, conflicts_with = "check")]
160        fix: bool,
161        /// Treat `warn` severity as failure too (only meaningful with `--check`).
162        #[arg(long)]
163        strict: bool,
164    },
165
166    /// Run verification for every annotation that opted in.
167    Verify {
168        /// Unified filter clause (`id=<id>`, `file=<path>`,
169        /// `parent=<id>`, `status=<state>`). Repeatable; multiple
170        /// `--filter` flags AND together.
171        #[arg(long = "filter", value_name = "key=value")]
172        filters: Vec<String>,
173        /// Re-verify entries that are already in a clean verified
174        /// state. By default they're skipped; pass `--rerun` to force
175        /// a re-check.
176        #[arg(long)]
177        rerun: bool,
178        /// CI mode: report whether any status would change, without
179        /// writing the index. Exits non-zero if any change is needed.
180        #[arg(long)]
181        check: bool,
182        /// Treat warn-severity verification outcomes as failure too.
183        #[arg(long)]
184        strict: bool,
185        /// Apply pending verdict files in `.aristo/proofs/` to the
186        /// index. Reads every `<id>.proof`, runs the mechanical
187        /// validator, and (if it passes) flips the entry's status.
188        /// Skips dispatch of new verifications when set.
189        #[arg(long = "apply-verdicts", conflicts_with = "submit_verdict")]
190        apply_verdicts: bool,
191        /// Migration only: ignore any agent-stamped ground hashes in
192        /// the `.proof` files and recompute them from the cited file
193        /// ranges and index entries. Use this once when migrating
194        /// from older proof files that recorded hashes the SDK now
195        /// fills in itself. Without this flag, a stamped hash that
196        /// mismatches the current source is reported as staleness
197        /// and the proof is rejected. Only meaningful with
198        /// `--apply-verdicts`.
199        #[arg(long = "rewrite-hashes", requires = "apply_verdicts")]
200        rewrite_hashes: bool,
201        /// **Internal — invoked by the verification skill.** Submit
202        /// a single verdict: parse the JSON payload, validate it,
203        /// and (on pass) atomically write `.aristo/proofs/<id>.proof`.
204        /// Prints `accepted: sha256:<hex>` on success; structured
205        /// errors on reject. Agents never write `.proof` files
206        /// directly — the SDK is the sole writer.
207        #[arg(long = "submit-verdict", requires = "id", requires = "json")]
208        submit_verdict: bool,
209        /// Annotation id this verdict is about. Required with
210        /// `--submit-verdict`. The `.proof` file lands at
211        /// `.aristo/proofs/<id>.proof` (with `:` rewritten to `__`).
212        #[arg(long = "id", requires = "submit_verdict")]
213        id: Option<String>,
214        /// JSON-serialized ProofFile body. Required with
215        /// `--submit-verdict`. Pass as a single-quoted shell string;
216        /// the SDK parses it into a ProofFile and rejects anything
217        /// the validator would reject. Same schema as the TOML body
218        /// written on accept.
219        #[arg(long = "json", requires = "submit_verdict")]
220        json: Option<String>,
221        /// **Internal — invoked by the verification skill.**
222        /// Atomically claim one task from the pending queue and
223        /// print its TOML body to stdout. Empty stdout means the
224        /// queue is drained (exit 0 either way). Verify workers are
225        /// single-shot — call once, process the task, exit — so
226        /// context doesn't carry between verifications. The
227        /// orchestrator runs N workers in parallel and uses
228        /// `--queue-status` to decide when to spawn the next wave.
229        #[arg(long = "pop-next", conflicts_with_all = ["apply_verdicts", "submit_verdict", "queue_status"])]
230        pop_next: bool,
231        /// Peek at queue state without claiming. Prints `pending: N`,
232        /// `claimed: M` to stdout, exit 0. Used by the orchestrator
233        /// to decide whether to dispatch another wave of workers.
234        /// Safe to call concurrently.
235        #[arg(long = "queue-status", conflicts_with_all = ["apply_verdicts", "submit_verdict"])]
236        queue_status: bool,
237        /// Block until the canon-verify session reaches a terminal
238        /// state, rendering a snapshot at each long-poll return and
239        /// emitting a `still running…` heartbeat every 60s. Exit code
240        /// is derived from the final summary: `0` iff every
241        /// annotation is `verified` or `no_coverage`. Without
242        /// `--wait` the SDK detaches after dispatch (prints session
243        /// id and exits 0). Combine with `--view <id>` to attach to
244        /// a session another invocation started.
245        #[arg(long = "wait")]
246        wait: bool,
247        /// Re-attach to a previously-dispatched canon-verify session
248        /// by id. Skips the source eligibility scan, push-first
249        /// precheck, and POST — just GETs the session state and
250        /// renders. Combine with `--wait` to block until terminal.
251        #[arg(long = "view", value_name = "SESSION_ID")]
252        view: Option<String>,
253        /// Subset the canon-verify dispatch to the listed annotation
254        /// ids. Comma-separated; each id must be a canon-bound entry
255        /// (`aristos:foo` or `kanon:bar`) in the workspace's index.
256        /// Bare canon-id suffixes (e.g. `foo`) are accepted as a
257        /// shorthand. `arta_*` (server-side opaque) ids are rejected
258        /// — those are not user-facing.
259        #[arg(long = "tags", value_name = "id1,id2,...", value_delimiter = ',')]
260        tags: Vec<String>,
261    },
262
263    /// Run the critique skill against annotation prose — opinionated
264    /// suggestions, severity-tagged findings.
265    Critique {
266        /// Unified filter clause (`id=<id>[,<id>,...]`,
267        /// `file=<path>`, `parent=<id>`, `status=<state>`). Repeatable;
268        /// multiple `--filter` flags AND together; values may be
269        /// comma-separated. **REQUIRED** — `aristo critique` with no
270        /// filter errors with usage. To sweep every annotation in the
271        /// index, opt in explicitly with `--all --yes`.
272        #[arg(long = "filter", value_name = "key=value")]
273        filters: Vec<String>,
274        /// Apply pending critique files in `.aristo/critiques/` —
275        /// re-validate every `<id>.critique` and print a summary
276        /// grouped by id. Defaults to listing only findings whose
277        /// `disposition` is `None` (open / not yet reviewed); pass
278        /// `--include-closed` for the full view including findings
279        /// already triaged via `aristo session decide`.
280        #[arg(long = "apply-findings", conflicts_with_all = ["submit_findings", "pop_next", "queue_status"])]
281        apply_findings: bool,
282        /// Include findings whose `disposition` has been set (Accepted /
283        /// Rejected / Deferred) in the `--apply-findings` summary.
284        /// By default only open findings are listed — closed ones
285        /// stop re-surfacing on every apply, which is how a review
286        /// closes the loop. Only meaningful with `--apply-findings`.
287        #[arg(long = "include-closed", requires = "apply_findings")]
288        include_closed: bool,
289        /// Force re-enqueue of every matched annotation, bypassing the
290        /// `last_critiqued_at_text_hash` cache. Default behavior skips
291        /// annotations whose text hasn't drifted since the cached
292        /// critique was produced (so re-runs of `aristo critique
293        /// --filter id=X` are free when X is unchanged).
294        #[arg(long = "rerun")]
295        rerun: bool,
296        /// Restrict scope to annotations in files git-staged for the
297        /// next commit (`git diff --cached --name-only`). Useful for
298        /// pre-commit hook integration. Satisfies the filter-required
299        /// guard on its own; composes with explicit `--filter`
300        /// clauses via intersection (annotations must match BOTH
301        /// `--filter` and appear in the staged set).
302        #[arg(long = "staged")]
303        staged: bool,
304        /// Sweep every intent annotation that has a real `verify`
305        /// method (skips documentation-only `verify = false`). Loud
306        /// on purpose: prints `(this will enqueue N annotations;
307        /// ~$X cost — proceed with --all --yes?)` and exits 2 unless
308        /// you also pass `--yes`. Without the confirmation, an agent
309        /// could accidentally fire hundreds of LLM calls in one go.
310        #[arg(long = "all", conflicts_with_all = ["filters", "staged"])]
311        all: bool,
312        /// Skip the confirmation prompt for `--all`. Required
313        /// alongside `--all` to actually enqueue the sweep; without
314        /// it `--all` just prints the cost estimate and exits 2.
315        #[arg(long = "yes", requires = "all")]
316        yes: bool,
317        /// **Internal — invoked by the critique skill.** Atomically
318        /// claim one task from the critique queue and print its TOML
319        /// body to stdout. Empty stdout means the queue is drained
320        /// (exit 0 either way). Unlike verify, critique workers loop
321        /// on this call — the tasks are shallow and vocabulary stays
322        /// consistent when one worker handles several.
323        #[arg(long = "pop-next", conflicts_with_all = ["apply_findings", "submit_findings", "queue_status"])]
324        pop_next: bool,
325        /// Peek at queue state without claiming. Prints `pending: N`
326        /// + `claimed: M` to stdout, exit 0.
327        #[arg(long = "queue-status", conflicts_with_all = ["apply_findings", "submit_findings"])]
328        queue_status: bool,
329        /// **Internal — invoked by the critique skill.** Submit a
330        /// single critique: parse the JSON payload, validate it, and
331        /// (on accept) atomically write
332        /// `.aristo/critiques/<id>.critique`. Prints
333        /// `accepted: sha256:<hex>` on success.
334        #[arg(long = "submit-findings", requires = "id", requires = "json")]
335        submit_findings: bool,
336        /// Annotation id this submission is about. Required with
337        /// `--submit-findings`.
338        #[arg(long = "id", requires = "submit_findings")]
339        id: Option<String>,
340        /// JSON-serialized CritiqueFile body. Required with
341        /// `--submit-findings`.
342        #[arg(long = "json", requires = "submit_findings")]
343        json: Option<String>,
344    },
345
346    /// Generate per-annotation markdown to .aristo/doc/.
347    Doc {
348        /// Write only the crate-root summary (`_summary.md`); skip the
349        /// per-annotation pass.
350        #[arg(long)]
351        summary: bool,
352        /// Include each annotation's current verification status in
353        /// the rendered markdown. Status is a build-time snapshot
354        /// that drifts as code evolves; the default omits it so doc
355        /// artifacts stay reproducible on a clean checkout.
356        #[arg(long = "include-status")]
357        include_status: bool,
358        /// CI mode: recompute expected per-annotation MD from the index,
359        /// compare against `.aristo/doc/`, exit non-zero on drift. Never
360        /// writes.
361        #[arg(long)]
362        check: bool,
363        /// Composite: also generate the annotation graph (Mermaid)
364        /// and embed it inline in `_summary.md`. Implies `--summary`.
365        /// Conflicts with `--check` (read-only mode can't write the
366        /// graph block).
367        #[arg(long = "include-graph", conflicts_with = "check")]
368        include_graph: bool,
369    },
370
371    /// Generate the annotation graph (Mermaid / DOT / SVG).
372    Graph {
373        /// Output format. `mermaid` (default) emits a fenced
374        /// flowchart TD block; `dot` emits Graphviz DOT; `svg`
375        /// requires `dot` on PATH and shells out to render.
376        #[arg(long, default_value = "mermaid")]
377        format: String,
378        /// Write to this path instead of stdout. Atomic via
379        /// temp-file + rename. Relative paths resolve against the
380        /// invoking directory.
381        #[arg(long)]
382        out: Option<PathBuf>,
383        /// Unified filter clause (`id=<id>`, `file=<path>[:<LO>-<HI>]`,
384        /// `parent=<id>`, `status=<state>`). Repeatable; multiple
385        /// `--filter` flags AND together. With no filter, the scope
386        /// is the whole index.
387        #[arg(long = "filter", value_name = "key=value")]
388        filters: Vec<String>,
389        /// Drop `assume` nodes from the rendered graph. They're
390        /// included by default because assumes describe the
391        /// background facts your intents rely on — dropping them by
392        /// default would hide those.
393        #[arg(long = "exclude-assumes")]
394        exclude_assumes: bool,
395        /// Walk N hops from each filter-matched node in both
396        /// directions (ancestors + descendants) and include them in
397        /// the rendered graph. Useful for "show me this annotation
398        /// plus some context". Only meaningful with `--filter`;
399        /// without a filter, the scope is already the whole index.
400        #[arg(long, value_name = "N")]
401        depth: Option<u32>,
402        /// Include intent nodes that have no parent and no children.
403        /// They're omitted by default — usually they're standalone
404        /// claims that don't add structure to the rendered graph.
405        /// Assumes are always included (see `--exclude-assumes` for
406        /// that opt-out).
407        #[arg(long = "include-orphans")]
408        include_orphans: bool,
409        /// Color nodes by their current verification status instead
410        /// of by `verify` level (verified=green / tested=blue /
411        /// neural=yellow / stale=orange / orphan=purple /
412        /// forged=red+border / unknown=gray /
413        /// counterexample=red+border / inconclusive=red+border /
414        /// pending-deepen=gray). The `verify` level moves to the
415        /// in-node label. Use when you want to see what's still
416        /// unverified.
417        #[arg(long = "include-status")]
418        include_status: bool,
419    },
420
421    /// Generate a shareable SVG verification badge for README / docs.
422    Badge {
423        /// Write SVG to this path (relative to workspace root, or absolute).
424        /// Default: stdout.
425        #[arg(long)]
426        out: Option<PathBuf>,
427        /// Badge style. Mirrors shields.io's three default forms.
428        #[arg(long, default_value = "flat")]
429        style: String,
430        /// Which metric the SVG value half displays. `tier` (default,
431        /// the locked D7 score → D8 tier) is the headline signal;
432        /// `count` and `rate` preserve the slice-31 surfaces for
433        /// projects that prefer the simpler counters.
434        #[arg(long, default_value = "tier")]
435        metric: String,
436    },
437
438    /// Rename an annotation id everywhere it appears — source files,
439    /// index, and doc artifacts. Either every change lands or none do.
440    ///
441    /// Supported renames: bare → bare, and stamp-assigned opaque
442    /// (`aret_*`) → bare. Canon-bound prefixes (`aristos:` / `kanon:`)
443    /// are rejected in either direction — those prefixes are applied
444    /// by `aristo canon accept` and removed by `aristo canon unbind`.
445    /// The new id cannot itself be an opaque `aret_*` id (those are
446    /// stamp-assigned only).
447    Rename {
448        /// Annotation id to rename FROM. Must exist in the current
449        /// `.aristo/index.toml`.
450        old_id: String,
451        /// Annotation id to rename TO. Must not already exist and must
452        /// not use the reserved `aret_*` / `aristos:` / `kanon:`
453        /// prefixes.
454        new_id: String,
455        /// Compute and print the rename plan (source edits + per-id
456        /// artifact moves + index updates) without writing anything.
457        #[arg(long = "dry-run")]
458        dry_run: bool,
459    },
460
461    /// Run a review session over a pipeline's open artifacts —
462    /// critique findings, proof verdicts, and so on. Start it,
463    /// inspect bucket counts, record decisions, and close out.
464    Session {
465        #[command(subcommand)]
466        action: SessionAction,
467    },
468
469    /// Sign in to the Aretta canon API. Required for `aristo stamp`
470    /// and `aristo critique` to see canon matches on the Pro /
471    /// Enterprise tiers.
472    Auth {
473        #[command(subcommand)]
474        action: AuthAction,
475    },
476
477    /// Manage canon bindings: accept or reject pending matches,
478    /// inspect or refresh the local cache, unbind bound ids, and
479    /// request a verifier for a canon entry.
480    Canon {
481        #[command(subcommand)]
482        action: CanonAction,
483    },
484}
485
486/// Subcommands under `aristo auth`. Each operates on the persistent
487/// credentials store under `$XDG_CONFIG_HOME/aristo/credentials`
488/// (or the platform default per `aristo_core::auth`).
489#[derive(clap::Subcommand, Debug)]
490pub(crate) enum AuthAction {
491    /// Authenticate against the Aretta proxy.
492    ///
493    /// **Default mode (GitHub OAuth):** the CLI fetches the GitHub
494    /// authorization URL from the proxy, tries to open it in your
495    /// browser, and prompts you to paste the code shown on the
496    /// proxy's callback page. The proxy then mints an `arta_*`
497    /// token scoped to your `(user, repo)` pair.
498    ///
499    /// **Bypass modes (for CI / scripting):**
500    ///
501    /// - **`--stdin`** — read the raw token from stdin
502    ///   (`echo "$TOKEN" | aristo auth login --stdin`).
503    /// - **`--token=<T>`** — use the literal token value.
504    ///
505    /// The token is persisted to `$XDG_CONFIG_HOME/aristo/credentials`
506    /// with `0600` Unix permissions.
507    Login {
508        /// Read the token from stdin (consumes entire stdin). Skips
509        /// the OAuth flow.
510        #[arg(long, conflicts_with = "token")]
511        stdin: bool,
512        /// Use this token directly. Skips the OAuth flow.
513        #[arg(long, value_name = "TOKEN")]
514        token: Option<String>,
515        /// Aretta server to authenticate against. Accepts:
516        /// `prod` / `production` (= https://code.aretta.ai, default),
517        /// `dev` / `development` / `staging` (= https://dev.aretta.ai),
518        /// or a full URL for self-hosted deployments
519        /// (`https://aretta.example.com`).
520        #[arg(long, default_value = "prod")]
521        server: String,
522        /// Repo to scope the OAuth-minted token to (`owner/repo`).
523        /// Defaults to auto-deriving from `<cwd>/.git/config`'s
524        /// `remote.origin.url`. Required for non-git directories or
525        /// when the remote isn't a GitHub URL. Ignored in `--stdin` /
526        /// `--token` bypass modes (where the token is supplied
527        /// directly with its server-side scope already set).
528        #[arg(long, value_name = "OWNER/REPO")]
529        repo: Option<String>,
530    },
531    /// Show the current authentication state. Never prints the token
532    /// itself — only its source (env var, credentials file, or none).
533    /// Handy for sanity-checking before running `aristo stamp`.
534    Status,
535    /// Remove the stored credentials file. Idempotent — running
536    /// `logout` when not logged in is not an error.
537    Logout,
538}
539
540/// Subcommands under `aristo canon`.
541#[derive(clap::Subcommand, Debug)]
542pub(crate) enum CanonAction {
543    /// Accept a pending canon match: rewrite source to use the
544    /// canonical text + apply the `aristos:` / `kanon:` prefix to
545    /// the annotation id, update the index entry's binding state
546    /// to `Bound`, and move the cache entry from `pending_matches`
547    /// to `accepted_matches`.
548    ///
549    /// Both arguments are required: the bare annotation id as it
550    /// appears in `.aristo/index.toml` (NOT prefixed; the prefix
551    /// is applied by accept) and the bare canon id from the
552    /// pending match (e.g. `cell_written_exactly_once_per_page_edit`).
553    Accept {
554        /// Annotation id whose pending match you're accepting. Use
555        /// the bare form (no `aristos:` / `kanon:` prefix); the
556        /// prefix is applied by the accept itself based on the
557        /// pending match's `prefix_tier`.
558        annotation_id: String,
559        /// Canon id from the pending match (also bare — no
560        /// prefix). The pair `(annotation_id, canon_id)` locates
561        /// the exact pending match in `.aristo/canon-matches.toml`.
562        canon_id: String,
563    },
564
565    /// Reject a pending canon match: move the entry from
566    /// `pending_matches` to `rejected_matches`, pinned to the
567    /// current annotation `text_hash`. The rejection keeps the same
568    /// `(canon_id, text_hash)` pair from re-surfacing on future
569    /// `aristo stamp` runs; once the annotation text changes, the
570    /// rejection no longer applies and the match is re-evaluated.
571    /// Source and index are not touched — rejection is a cache-only
572    /// operation.
573    Reject {
574        /// Annotation id whose pending match you're rejecting.
575        annotation_id: String,
576        /// Canon id from the pending match.
577        canon_id: String,
578        /// Optional note recorded with the rejection. Useful for
579        /// capturing the *why* (e.g. "this canon entry is too broad",
580        /// "wrong category") for whoever revisits it later.
581        #[arg(long = "reason")]
582        reason: Option<String>,
583    },
584
585    /// List the current canon match state: one line per annotation
586    /// with pending / accepted / rejected counts, plus per-bucket
587    /// detail lines for each match. Reads `.aristo/canon-matches.toml`;
588    /// does not call the canon API.
589    List,
590
591    /// Fetch the canon entry detail for `<canon_id>` via the canon
592    /// API and render the longer description + example + references.
593    /// For the full trust card (server description + local binding
594    /// state combined), use `aristo show <bound_id>` instead.
595    Show {
596        /// Bare canon id (no `aristos:` / `kanon:` prefix). The
597        /// server's `GET /canon/entry/<canon_id>` endpoint returns
598        /// the same entry regardless of which tier you'd bind into;
599        /// the prefix is a per-user, per-scope attribute.
600        canon_id: String,
601        /// Optional explicit version (`v<minor>.<patch>`). Omit to
602        /// get the catalog's currently active version.
603        #[arg(long = "version")]
604        version: Option<String>,
605    },
606
607    /// Re-query the canon API for every annotation in the index,
608    /// bypassing the local match cache. Equivalent to
609    /// `aristo stamp --refresh-canon` without the rest of the stamp
610    /// pipeline — no source walk, no drift check, no index rewrite.
611    /// Useful when you know a new catalog version has shipped and
612    /// want fresh matches without a full stamp.
613    Refresh,
614
615    /// Reverse of `aristo canon accept`: strip the `aristos:` /
616    /// `kanon:` prefix from a canon-bound annotation, revert its
617    /// binding to `Local`, and drop the accepted_matches cache
618    /// entry. Source is rewritten in place (only the `id =` value
619    /// changes; canonical text + verify + parent are preserved).
620    /// The next `aristo stamp` may re-pull a fresh pending match
621    /// against the same annotation text.
622    Unbind {
623        /// Canon-bound annotation id including the prefix (e.g.
624        /// `aristos:cell_written_exactly_once_per_page_edit`).
625        prefixed_id: String,
626    },
627
628    /// Record a verification-demand signal against a canon entry.
629    /// Idempotent on `(canon_id, repo, user)` — repeated calls don't
630    /// pile up. Use when an annotation is bound at the `kanon:` tier
631    /// and you'd like Aretta to invest in a verifier for that canon
632    /// entry.
633    RequestVerify {
634        /// Canon id (no prefix). The same id the trust card shows,
635        /// or that `aristo canon list` reports.
636        canon_id: String,
637        /// Optional note to attach to the demand signal (e.g.
638        /// "critical for our financial-tx audit"). A repeat call
639        /// with a new note replaces the previous one server-side.
640        #[arg(long = "notes")]
641        notes: Option<String>,
642    },
643
644    /// Report per-binding version drift between the local cache and
645    /// the canon API. Reports three classes: `current` (no change),
646    /// `patch-bump` (same canon_id, newer version — recommended
647    /// action: `aristo canon refresh`), and `minor-bump` (canon_id
648    /// retired — recommended action: `aristo canon unbind <id>` then
649    /// re-stamp). Currently diagnostic-only; automatic patch-bump
650    /// application is planned.
651    Migrate,
652}
653
654/// Subcommands under `aristo session`. Each maps to one substrate
655/// operation; per-kind side effects (e.g. mutating a `.critique`
656/// file on accept) plug in via the `SessionKind` trait wired in
657/// step 5.
658#[derive(clap::Subcommand, Debug)]
659pub(crate) enum SessionAction {
660    /// Begin a new review session of the given kind. Fails if a
661    /// session is already active — pass `--allow-nesting` to override
662    /// (currently no kind allows nesting).
663    Start {
664        /// Session kind (`critique-review`, `proof-review`).
665        kind: String,
666        /// Display label for the artifact under review (e.g.
667        /// `src/critique/pending.rs` or
668        /// `proof:balance_no_duplicate_cells`).
669        #[arg(long = "subject")]
670        subject: String,
671        /// Override the kind's default nesting policy. Currently only
672        /// `Disallow` is implemented; the flag is reserved for future
673        /// per-kind opt-ins.
674        #[arg(long = "allow-nesting", default_value_t = false)]
675        allow_nesting: bool,
676    },
677    /// Print the active session id (or empty stdout if none).
678    /// Exit 0 either way.
679    Active {
680        /// Emit the full `<system-reminder>` block instead of just
681        /// the id — for the `UserPromptSubmit` hook installed by
682        /// `aristo install-skills`. Empty stdout when no session
683        /// is active (the hook then injects nothing).
684        #[arg(long = "hook-format", default_value_t = false)]
685        hook_format: bool,
686    },
687    /// Print bucket counts + open items for the active session.
688    /// Exit 0; errors out if no session is active.
689    Status,
690    /// Record a decision on one item in the active session.
691    Decide {
692        /// Item reference (`<id>#<index>` for indexed items, or any
693        /// opaque per-kind string).
694        #[arg(long = "item")]
695        item: String,
696        /// Which bucket the item lands in.
697        #[arg(long = "bucket", value_enum)]
698        bucket: BucketArg,
699        /// Optional note recorded with the decision.
700        #[arg(long = "note")]
701        note: Option<String>,
702    },
703    /// Close the active session. Strict by default — errors out if
704    /// any items are still in the open bucket.
705    Exit {
706        /// Move open items to the per-kind backlog instead of
707        /// erroring. Items are never silently dropped; the next
708        /// session of this kind surfaces them via the backlog menu.
709        #[arg(long = "defer-undecided", default_value_t = false)]
710        defer_undecided: bool,
711    },
712    /// Cancel the session and discard every decision recorded so far.
713    /// Requires `--yes` to skip the confirmation prompt.
714    Abort {
715        /// Skip the confirmation prompt.
716        #[arg(long = "yes", default_value_t = false)]
717        yes: bool,
718    },
719    /// List the active session and the most recent N closed sessions.
720    List {
721        /// Maximum number of closed-session rows to include.
722        #[arg(long = "limit", default_value_t = 10)]
723        limit: usize,
724    },
725}
726
727/// User-facing bucket choices for `aristo session decide`. Maps to
728/// the substrate's [`session::types::ItemStatus`] (minus `Open`,
729/// which is the implicit pre-decision state).
730#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
731pub(crate) enum BucketArg {
732    Accepted,
733    Rejected,
734    Pending,
735}
736
737/// Process entry point. Parses `argv`, dispatches to the chosen subcommand,
738/// and returns the exit code. Prints `error: <msg>` to stderr on any
739/// `CliError`.
740pub fn run() -> ExitCode {
741    let cli = Cli::parse();
742    match dispatch(cli.command) {
743        Ok(()) => ExitCode::SUCCESS,
744        Err(e) => {
745            if !e.is_silent() {
746                eprintln!("error: {e}");
747            }
748            ExitCode::from(e.exit_code())
749        }
750    }
751}
752
753/// Maps a parsed `Commands` variant to its handler.
754fn dispatch(cmd: Commands) -> CliResult<()> {
755    match cmd {
756        Commands::Init { force } => commands::init::run(force),
757        Commands::Lang { file } => commands::lang::run(file),
758        Commands::InstallSkills {
759            agent,
760            list_agents,
761            user,
762            update,
763        } => commands::install_skills::install(agent, list_agents, user, update),
764        Commands::UninstallSkills { agent, user, force } => {
765            commands::install_skills::uninstall(agent, user, force)
766        }
767        Commands::Index { all } => commands::index::run(all),
768        Commands::Stamp {
769            check,
770            skip_canon,
771            refresh_canon,
772        } => commands::stamp::run(check, skip_canon, refresh_canon),
773        Commands::Show {
774            selector,
775            json,
776            toml_out,
777        } => commands::show::run(&selector, output_mode(json, toml_out)),
778        Commands::List { filters, json } => commands::list::run(&filters, json),
779        Commands::Status => commands::status::run(),
780        Commands::Lint { check, fix, strict } => commands::lint::run(check, fix, strict),
781        Commands::Verify {
782            filters,
783            rerun,
784            check,
785            strict,
786            apply_verdicts,
787            rewrite_hashes,
788            submit_verdict,
789            id,
790            json,
791            pop_next,
792            queue_status,
793            wait,
794            view,
795            tags,
796        } => commands::verify::run(
797            &filters,
798            rerun,
799            check,
800            strict,
801            apply_verdicts,
802            rewrite_hashes,
803            submit_verdict,
804            pop_next,
805            queue_status,
806            id,
807            json,
808            wait,
809            view,
810            &tags,
811        ),
812        Commands::Critique {
813            filters,
814            apply_findings,
815            include_closed,
816            rerun,
817            staged,
818            all,
819            yes,
820            pop_next,
821            queue_status,
822            submit_findings,
823            id,
824            json,
825        } => commands::critique::run(
826            &filters,
827            submit_findings,
828            pop_next,
829            queue_status,
830            apply_findings,
831            include_closed,
832            rerun,
833            staged,
834            all,
835            yes,
836            id,
837            json,
838        ),
839        Commands::Doc {
840            summary,
841            include_status,
842            check,
843            include_graph,
844        } => commands::doc::run(summary, include_status, check, include_graph),
845        Commands::Graph {
846            format,
847            out,
848            filters,
849            exclude_assumes,
850            depth,
851            include_orphans,
852            include_status,
853        } => commands::graph::run(
854            &format,
855            out,
856            &filters,
857            exclude_assumes,
858            depth,
859            include_orphans,
860            include_status,
861        ),
862        Commands::Badge { out, style, metric } => {
863            let style =
864                commands::badge::Style::parse(&style).map_err(|message| CliError::Other {
865                    message,
866                    exit_code: 2,
867                })?;
868            let metric =
869                commands::badge::Metric::parse(&metric).map_err(|message| CliError::Other {
870                    message,
871                    exit_code: 2,
872                })?;
873            commands::badge::run(out, style, metric)
874        }
875        Commands::Rename {
876            old_id,
877            new_id,
878            dry_run,
879        } => commands::rename::run(&old_id, &new_id, dry_run),
880        Commands::Session { action } => commands::session::run(action),
881        Commands::Auth { action } => commands::auth::run(action),
882        Commands::Canon { action } => match action {
883            CanonAction::Accept {
884                annotation_id,
885                canon_id,
886            } => commands::canon::accept::run(&annotation_id, &canon_id),
887            CanonAction::Reject {
888                annotation_id,
889                canon_id,
890                reason,
891            } => commands::canon::reject::run(&annotation_id, &canon_id, reason),
892            CanonAction::List => commands::canon::list::run(),
893            CanonAction::Show { canon_id, version } => {
894                commands::canon::show::run(&canon_id, version)
895            }
896            CanonAction::Refresh => commands::canon::refresh::run(),
897            CanonAction::Unbind { prefixed_id } => commands::canon::unbind::run(&prefixed_id),
898            CanonAction::RequestVerify { canon_id, notes } => {
899                commands::canon::request_verify::run(&canon_id, notes)
900            }
901            CanonAction::Migrate => commands::canon::migrate::run(),
902        },
903    }
904}
905
906fn output_mode(json: bool, toml_out: bool) -> commands::show::OutputMode {
907    if json {
908        commands::show::OutputMode::Json
909    } else if toml_out {
910        commands::show::OutputMode::Toml
911    } else {
912        commands::show::OutputMode::Text
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    use clap::CommandFactory;
920
921    #[test]
922    fn cli_parser_construction_is_valid() {
923        // clap performs a structural sanity check (e.g. no duplicate
924        // subcommand names) when CommandFactory::command() runs. We assert
925        // it succeeds rather than panicking at runtime when a user types
926        // `--help`. Cheap canary against future enum-shape mistakes.
927        Cli::command().debug_assert();
928    }
929
930    // Note: slice 32 removed the last `not_yet(...)` stub (Rename now
931    // has a real implementation). The `CliError::NotImplemented` variant
932    // is kept for future stubs but is no longer reachable from dispatch.
933}