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