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