Skip to main content

aristo_cli/
lib.rs

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