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