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}