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