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