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