Skip to main content

aristo_cli/
lib.rs

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