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 data_plane;
11mod error;
12mod filter;
13/// The nudge/progress engine (Phase 18 #9). Public so the as-yet-unwired
14/// scorer is part of the lib API surface; the `aristo nudge` emitter (S0d)
15/// is its in-crate consumer.
16pub mod nudge;
17mod pipeline;
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 audit/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. Implies `--ci`.
69 #[arg(long)]
70 ci_verify: bool,
71
72 /// Install a local pre-commit hook (DEPRECATED). Off by default and no
73 /// longer auto-installed: the index is a gitignored cache, so CI
74 /// (`aristo verify --audit`) is the enforcement point. The hook only
75 /// runs `aristo doc` + `lint` locally as a convenience.
76 #[arg(long)]
77 hook: bool,
78 },
79
80 /// Print a syntax cheat sheet for the detected language.
81 Lang {
82 /// Detect language for a specific file. Currently only Rust
83 /// (`.rs`) is supported; other extensions error out.
84 #[arg(long)]
85 file: Option<PathBuf>,
86 },
87
88 /// Install Aristo skills for a coding agent (claude-code, cursor, codex, opencode, antigravity).
89 InstallSkills {
90 /// Target agent. Required unless `--list-agents` is used.
91 #[arg(long, value_name = "name")]
92 agent: Option<String>,
93 /// List supported agents and exit.
94 #[arg(long)]
95 list_agents: bool,
96 /// Install at user-level (e.g. ~/.claude/skills/) instead of project-level.
97 #[arg(long)]
98 user: bool,
99 /// Re-pin installed skills to this binary's version. With `--agent`,
100 /// re-installs that agent; without it, heals every already-installed
101 /// agent in place (both scopes, or just `--user`).
102 #[arg(long)]
103 update: bool,
104 /// Report whether installed skills are stale relative to this binary
105 /// (read-only). Exits non-zero if any are out of date.
106 #[arg(long)]
107 check: bool,
108 /// Internal: emit a SessionStart hook `additionalContext` block when
109 /// installed skills are stale (used by the hook this command installs).
110 #[arg(long, hide = true)]
111 hook_format: bool,
112 },
113
114 /// Reverse `install-skills`: remove SDK-bundled skills.
115 UninstallSkills {
116 /// Target agent. Required.
117 #[arg(long, value_name = "name")]
118 agent: String,
119 /// Uninstall from user-level instead of project-level.
120 #[arg(long)]
121 user: bool,
122 /// Override the "skip locally-modified" safety check.
123 #[arg(long)]
124 force: 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 or
131 /// `.aristo/canon-matches.toml`, without writing either.
132 /// Exits non-zero if it would. Skips the canon-match step
133 /// too (no outbound network calls in this 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 stamp --refresh-canon`.
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 /// Treat warn-severity verification outcomes as failure too.
252 #[arg(long)]
253 strict: bool,
254 /// Audit mode: regenerate the index in memory and validate every
255 /// `.aristo/proofs/<id>.proof` against current source. Reports
256 /// fresh / stale / refuted / orphan counts; with `--strict`, exits
257 /// non-zero on any stale, counterexample, or orphan proof. The CI
258 /// freshness gate (replaces `aristo stamp --check`) — no dispatch,
259 /// no network, no token.
260 #[arg(long = "audit")]
261 audit: bool,
262 /// Apply pending verdict files in `.aristo/proofs/` to the
263 /// index. Reads every `<id>.proof`, runs the mechanical
264 /// validator, and (if it passes) flips the entry's status.
265 /// Skips dispatch of new verifications when set.
266 #[arg(
267 long = "apply-verdicts",
268 hide = true,
269 conflicts_with = "submit_verdict"
270 )]
271 apply_verdicts: bool,
272 /// Migration only: ignore any agent-stamped ground hashes in
273 /// the `.proof` files and recompute them from the cited file
274 /// ranges and index entries. Use this once when migrating
275 /// from older proof files that recorded hashes the SDK now
276 /// fills in itself. Without this flag, a stamped hash that
277 /// mismatches the current source is reported as staleness
278 /// and the proof is rejected. Only meaningful with
279 /// `--apply-verdicts`.
280 #[arg(long = "rewrite-hashes", hide = true, requires = "apply_verdicts")]
281 rewrite_hashes: bool,
282 /// **Internal — invoked by the verification skill.** Submit
283 /// a single verdict: parse the JSON payload, validate it,
284 /// and (on pass) atomically write `.aristo/proofs/<id>.proof`.
285 /// Prints `accepted: sha256:<hex>` on success; structured
286 /// errors on reject. Agents never write `.proof` files
287 /// directly — the SDK is the sole writer.
288 #[arg(
289 long = "submit-verdict",
290 hide = true,
291 requires = "id",
292 requires = "json"
293 )]
294 submit_verdict: bool,
295 /// Annotation id this verdict is about. Required with
296 /// `--submit-verdict`. The `.proof` file lands at
297 /// `.aristo/proofs/<id>.proof` (with `:` rewritten to `__`).
298 #[arg(long = "id", hide = true, requires = "submit_verdict")]
299 id: Option<String>,
300 /// JSON-serialized ProofFile body. Required with
301 /// `--submit-verdict`. Pass as a single-quoted shell string;
302 /// the SDK parses it into a ProofFile and rejects anything
303 /// the validator would reject. Same schema as the TOML body
304 /// written on accept.
305 #[arg(long = "json", hide = true, requires = "submit_verdict")]
306 json: Option<String>,
307 /// **Internal — invoked by the verification skill.**
308 /// Atomically claim one task from the pending queue and
309 /// print its TOML body to stdout. Empty stdout means the
310 /// queue is drained (exit 0 either way). Verify workers are
311 /// single-shot — call once, process the task, exit — so
312 /// context doesn't carry between verifications. The
313 /// orchestrator runs N workers in parallel and uses
314 /// `--queue-status` to decide when to spawn the next wave.
315 #[arg(long = "pop-next", hide = true, conflicts_with_all = ["apply_verdicts", "submit_verdict", "queue_status"])]
316 pop_next: bool,
317 /// Peek at queue state without claiming. Prints `pending: N`,
318 /// `claimed: M` to stdout, exit 0. Used by the orchestrator
319 /// to decide whether to dispatch another wave of workers.
320 /// Safe to call concurrently.
321 #[arg(long = "queue-status", hide = true, conflicts_with_all = ["apply_verdicts", "submit_verdict"])]
322 queue_status: bool,
323 /// Block until the canon-verify session reaches a terminal
324 /// state, rendering a snapshot at each long-poll return and
325 /// emitting a `still running…` heartbeat every 60s. Exit code
326 /// is derived from the final summary: `0` iff every
327 /// annotation is `verified` or `no_coverage`. Transient poll
328 /// failures (server 5xx, network, timeout) are retried with
329 /// backoff; the whole wait is bounded by a 2-hour deadline
330 /// (override via `ARISTO_VERIFY_WAIT_TIMEOUT_SECS`; `0`
331 /// disables the deadline). Without
332 /// `--wait` the SDK detaches after dispatch (prints session
333 /// id and exits 0). Combine with `--view <id>` to attach to
334 /// a session another invocation started.
335 #[arg(long = "wait")]
336 wait: bool,
337 /// CI guard against vacuous green: exit non-zero when the
338 /// canon-verify dispatch set is empty (no annotations were
339 /// sent to the server — e.g. a missing/stale canon-matches
340 /// cache, zero canon-bound `verify="full"` entries, or
341 /// everything skipped as already clean). Without this flag a
342 /// zero-dispatch run exits 0, which CI reads as "verified"
343 /// even though nothing ran. Usable only on the dispatch path:
344 /// combining it with a verb that cannot dispatch is a usage
345 /// error, never a silent no-op.
346 #[arg(
347 long = "require-dispatch",
348 conflicts_with_all = [
349 "view", "audit", "pop_next", "queue_status",
350 "submit_verdict", "apply_verdicts", "accept"
351 ]
352 )]
353 require_dispatch: bool,
354 /// Re-attach to a previously-dispatched canon-verify session
355 /// by id. Skips the source eligibility scan, push-first
356 /// precheck, and POST — just GETs the session state and
357 /// renders. Combine with `--wait` to block until terminal.
358 #[arg(long = "view", value_name = "SESSION_ID")]
359 view: Option<String>,
360 /// Subset the canon-verify dispatch to the listed annotation
361 /// ids. Comma-separated; each id must be a canon-bound entry
362 /// (`aristos:foo` or `kanon:bar`) in the workspace's index.
363 /// Bare canon-id suffixes (e.g. `foo`) are accepted as a
364 /// shorthand. `arta_*` (server-side opaque) ids are rejected
365 /// — those are not user-facing.
366 #[arg(long = "tags", value_name = "id1,id2,...", value_delimiter = ',')]
367 tags: Vec<String>,
368 /// Phase 16 (c): record a user-side known-failure waiver for a
369 /// canon-bound property your code currently violates. Write-only
370 /// — does NOT dispatch a verification. Requires `--because`. The
371 /// gap lands in `.aristo/expectations.toml` (commit it); at
372 /// verify time it renders as a "known gap (accepted)" instead of
373 /// a failure, and the strict ratchet flips it back to red if the
374 /// property ever starts passing — so stale waivers can't rot.
375 /// Accepts a bare canon-id suffix (e.g. `foo`) as shorthand.
376 #[arg(
377 long = "accept",
378 value_name = "CANON_ID",
379 requires = "because",
380 conflicts_with_all = [
381 "view", "wait", "tags", "rerun", "strict", "filters",
382 "apply_verdicts", "rewrite_hashes", "submit_verdict", "id",
383 "json", "pop_next", "queue_status"
384 ]
385 )]
386 accept: Option<String>,
387 /// Reason the gap is accepted (mandatory with `--accept`).
388 /// Recorded verbatim and shown on the verify card. A reasonless
389 /// waiver is how baselines rot, so it is required.
390 #[arg(long = "because", value_name = "REASON", requires = "accept")]
391 because: Option<String>,
392 /// Optional tracking reference (issue URL, ticket id) for the
393 /// accepted gap. Only meaningful with `--accept`.
394 #[arg(long = "tracking", value_name = "REF", requires = "accept")]
395 tracking: Option<String>,
396 },
397
398 /// Run the critique skill against annotation prose — opinionated
399 /// suggestions, severity-tagged findings.
400 Critique {
401 /// Unified filter clause (`id=<id>[,<id>,...]`,
402 /// `file=<path>`, `parent=<id>`, `status=<state>`). Repeatable;
403 /// multiple `--filter` flags AND together; values may be
404 /// comma-separated. **REQUIRED** — `aristo critique` with no
405 /// filter errors with usage. To sweep every annotation in the
406 /// index, opt in explicitly with `--all --yes`.
407 #[arg(long = "filter", value_name = "key=value")]
408 filters: Vec<String>,
409 /// Apply pending critique files in `.aristo/critiques/` —
410 /// re-validate every `<id>.critique` and print a summary
411 /// grouped by id. Defaults to listing only findings whose
412 /// `disposition` is `None` (open / not yet reviewed); pass
413 /// `--include-closed` for the full view including findings
414 /// already triaged via `aristo session decide`.
415 #[arg(long = "apply-findings", hide = true, conflicts_with_all = ["submit_findings", "pop_next", "queue_status"])]
416 apply_findings: bool,
417 /// Include findings whose `disposition` has been set (Accepted /
418 /// Rejected / Deferred) in the `--apply-findings` summary.
419 /// By default only open findings are listed — closed ones
420 /// stop re-surfacing on every apply, which is how a review
421 /// closes the loop. Only meaningful with `--apply-findings`.
422 #[arg(long = "include-closed", hide = true, requires = "apply_findings")]
423 include_closed: bool,
424 /// Force re-enqueue of every matched annotation, bypassing the
425 /// `last_critiqued_at_text_hash` cache. Default behavior skips
426 /// annotations whose text hasn't drifted since the cached
427 /// critique was produced (so re-runs of `aristo critique
428 /// --filter id=X` are free when X is unchanged).
429 #[arg(long = "rerun", hide = true)]
430 rerun: bool,
431 /// Restrict scope to annotations in files git-staged for the
432 /// next commit (`git diff --cached --name-only`). Useful for
433 /// pre-commit hook integration. Satisfies the filter-required
434 /// guard on its own; composes with explicit `--filter`
435 /// clauses via intersection (annotations must match BOTH
436 /// `--filter` and appear in the staged set).
437 #[arg(long = "staged")]
438 staged: bool,
439 /// Sweep every intent annotation that has a real `verify`
440 /// method (skips documentation-only `verify = false`). Loud
441 /// on purpose: prints `(this will enqueue N annotations;
442 /// ~$X cost — proceed with --all --yes?)` and exits 2 unless
443 /// you also pass `--yes`. Without the confirmation, an agent
444 /// could accidentally fire hundreds of LLM calls in one go.
445 #[arg(long = "all", hide = true, conflicts_with_all = ["filters", "staged"])]
446 all: bool,
447 /// Skip the confirmation prompt for `--all`. Required
448 /// alongside `--all` to actually enqueue the sweep; without
449 /// it `--all` just prints the cost estimate and exits 2.
450 #[arg(long = "yes", hide = true, requires = "all")]
451 yes: bool,
452 /// **Internal — invoked by the critique skill.** Atomically
453 /// claim one task from the critique queue and print its TOML
454 /// body to stdout. Empty stdout means the queue is drained
455 /// (exit 0 either way). Unlike verify, critique workers loop
456 /// on this call — the tasks are shallow and vocabulary stays
457 /// consistent when one worker handles several.
458 #[arg(long = "pop-next", hide = true, conflicts_with_all = ["apply_findings", "submit_findings", "queue_status"])]
459 pop_next: bool,
460 /// Peek at queue state without claiming. Prints `pending: N`
461 /// + `claimed: M` to stdout, exit 0.
462 #[arg(long = "queue-status", hide = true, conflicts_with_all = ["apply_findings", "submit_findings"])]
463 queue_status: bool,
464 /// **Internal — invoked by the critique skill.** Submit a
465 /// single critique: parse the JSON payload, validate it, and
466 /// (on accept) atomically write
467 /// `.aristo/critiques/<id>.critique`. Prints
468 /// `accepted: sha256:<hex>` on success.
469 #[arg(
470 long = "submit-findings",
471 hide = true,
472 requires = "id",
473 requires = "json"
474 )]
475 submit_findings: bool,
476 /// Annotation id this submission is about. Required with
477 /// `--submit-findings`.
478 #[arg(long = "id", hide = true, requires = "submit_findings")]
479 id: Option<String>,
480 /// JSON-serialized CritiqueFile body. Required with
481 /// `--submit-findings`.
482 #[arg(long = "json", hide = true, requires = "submit_findings")]
483 json: Option<String>,
484 },
485
486 /// Generate per-annotation markdown to .aristo/doc/.
487 Doc {
488 /// Write only the crate-root summary (`_summary.md`); skip the
489 /// per-annotation pass.
490 #[arg(long)]
491 summary: bool,
492 /// Include each annotation's current verification status in
493 /// the rendered markdown. Status is a build-time snapshot
494 /// that drifts as code evolves; the default omits it so doc
495 /// artifacts stay reproducible on a clean checkout.
496 #[arg(long = "include-status")]
497 include_status: bool,
498 /// CI mode: recompute expected per-annotation MD from the index,
499 /// compare against `.aristo/doc/`, exit non-zero on drift. Never
500 /// writes.
501 #[arg(long)]
502 check: bool,
503 /// Composite: also generate the annotation graph (Mermaid)
504 /// and embed it inline in `_summary.md`. Implies `--summary`.
505 /// Conflicts with `--check` (read-only mode can't write the
506 /// graph block).
507 #[arg(long = "include-graph", conflicts_with = "check")]
508 include_graph: bool,
509 },
510
511 /// Generate the annotation graph (Mermaid / DOT / SVG).
512 Graph {
513 /// Output format. `mermaid` (default) emits a fenced
514 /// flowchart TD block; `dot` emits Graphviz DOT; `svg`
515 /// requires `dot` on PATH and shells out to render.
516 #[arg(long, default_value = "mermaid")]
517 format: String,
518 /// Write to this path instead of stdout. Atomic via
519 /// temp-file + rename. Relative paths resolve against the
520 /// invoking directory.
521 #[arg(long)]
522 out: Option<PathBuf>,
523 /// Unified filter clause (`id=<id>`, `file=<path>[:<LO>-<HI>]`,
524 /// `parent=<id>`, `status=<state>`). Repeatable; multiple
525 /// `--filter` flags AND together. With no filter, the scope
526 /// is the whole index.
527 #[arg(long = "filter", value_name = "key=value")]
528 filters: Vec<String>,
529 /// Drop `assume` nodes from the rendered graph. They're
530 /// included by default because assumes describe the
531 /// background facts your intents rely on — dropping them by
532 /// default would hide those.
533 #[arg(long = "exclude-assumes")]
534 exclude_assumes: bool,
535 /// Walk N hops from each filter-matched node in both
536 /// directions (ancestors + descendants) and include them in
537 /// the rendered graph. Useful for "show me this annotation
538 /// plus some context". Only meaningful with `--filter`;
539 /// without a filter, the scope is already the whole index.
540 #[arg(long, value_name = "N")]
541 depth: Option<u32>,
542 /// Include intent nodes that have no parent and no children.
543 /// They're omitted by default — usually they're standalone
544 /// claims that don't add structure to the rendered graph.
545 /// Assumes are always included (see `--exclude-assumes` for
546 /// that opt-out).
547 #[arg(long = "include-orphans")]
548 include_orphans: bool,
549 /// Color nodes by their current verification status instead
550 /// of by `verify` level (verified=green / tested=blue /
551 /// neural=yellow / stale=orange / orphan=purple /
552 /// forged=red+border / unknown=gray /
553 /// counterexample=red+border / inconclusive=red+border /
554 /// pending-deepen=gray). The `verify` level moves to the
555 /// in-node label. Use when you want to see what's still
556 /// unverified.
557 #[arg(long = "include-status")]
558 include_status: bool,
559 },
560
561 /// Generate a shareable SVG verification badge for README / docs.
562 Badge {
563 /// Write SVG to this path (relative to workspace root, or absolute).
564 /// Default: stdout.
565 #[arg(long)]
566 out: Option<PathBuf>,
567 /// Badge style: `flat-square` (default), `flat`, or `plastic`.
568 #[arg(long, default_value = "flat-square")]
569 style: String,
570 /// Which metric the SVG value half displays. `tier` (default,
571 /// the locked D7 score → D8 tier) is the headline signal;
572 /// `count` and `rate` preserve the slice-31 surfaces for
573 /// projects that prefer the simpler counters.
574 #[arg(long, default_value = "tier")]
575 metric: String,
576 },
577
578 /// Rename an annotation id everywhere it appears — source files,
579 /// index, and doc artifacts. Either every change lands or none do.
580 ///
581 /// Supported renames: bare → bare, and stamp-assigned opaque
582 /// (`aret_*`) → bare. Canon-bound prefixes (`aristos:` / `kanon:`)
583 /// are rejected in either direction — those prefixes are applied
584 /// by `aristo canon accept` and removed by `aristo canon unbind`.
585 /// The new id cannot itself be an opaque `aret_*` id (those are
586 /// stamp-assigned only).
587 Rename {
588 /// Annotation id to rename FROM. Must exist in the current
589 /// `.aristo/index.toml`.
590 old_id: String,
591 /// Annotation id to rename TO. Must not already exist and must
592 /// not use the reserved `aret_*` / `aristos:` / `kanon:`
593 /// prefixes.
594 new_id: String,
595 /// Compute and print the rename plan (source edits + per-id
596 /// artifact moves + index updates) without writing anything.
597 #[arg(long = "dry-run")]
598 dry_run: bool,
599 },
600
601 /// Run a review session over a pipeline's open artifacts —
602 /// critique findings, proof verdicts, and so on. Start it,
603 /// inspect bucket counts, record decisions, and close out.
604 Session {
605 #[command(subcommand)]
606 action: SessionAction,
607 },
608
609 /// Sign in to your org's Aretta server. Required for `aristo stamp`
610 /// and `aristo critique` to see canon matches, and for `aristo verify`
611 /// to dispatch server sessions.
612 Auth {
613 #[command(subcommand)]
614 action: AuthAction,
615 },
616
617 /// Manage canon bindings: accept or reject pending matches,
618 /// inspect or refresh the local cache, unbind bound ids, and
619 /// request a verifier for a canon entry.
620 Canon {
621 #[command(subcommand)]
622 action: CanonAction,
623 },
624
625 /// Manage C instrumentation artifacts: vendor the C runtime, run codegen.
626 Instrument {
627 #[command(subcommand)]
628 action: InstrumentAction,
629 },
630}
631
632/// Subcommands under `aristo auth`. Each operates on the persistent
633/// credentials store under `$XDG_CONFIG_HOME/aristo/credentials`
634/// (or the platform default per `aristo_core::auth`).
635#[derive(clap::Subcommand, Debug)]
636pub(crate) enum AuthAction {
637 /// Sign in with GitHub and store the minted token.
638 ///
639 /// The CLI fetches the GitHub authorization URL from your org's
640 /// Aretta server, tries to open it in your browser, and prompts you
641 /// to paste the code shown on the callback page. The server mints
642 /// an `arta_*` token — an org grant, valid for every repo the org
643 /// admits you to — stored under `$XDG_CONFIG_HOME/aristo/credentials`
644 /// with `0600` Unix permissions, one entry per server.
645 ///
646 /// CI and scripts do not log in: set `ARETTA_TOKEN` and
647 /// `ARETTA_API_URL` in the environment instead.
648 Login {
649 /// Your org's Aretta host — your dashboard's hostname, e.g.
650 /// `https://<org>.aretta.ai` (a bare host gets `https://`).
651 /// Required: this flag, else the `ARETTA_API_URL` env var.
652 #[arg(long, value_name = "URL")]
653 server: Option<String>,
654 },
655 /// Show the current authentication state: every stored credential
656 /// (server, user) — never the token itself — which one commands will
657 /// use, and any `ARETTA_TOKEN` env override. Exits 1 when a run from
658 /// here would not authenticate.
659 Status,
660 /// Print the resolved `arta_*` token to stdout — the `ARETTA_TOKEN`
661 /// env var if set, else the stored credential for the server. Nothing
662 /// else is printed, so it pipes cleanly to your clipboard, e.g.
663 /// `aristo auth token | pbcopy` (macOS) or
664 /// `aristo auth token | xclip -selection clipboard` (Linux). Handy for
665 /// setting the `ARETTA_TOKEN` CI secret. Errors if not authenticated.
666 Token {
667 /// Server whose token to print. Defaults to `ARETTA_API_URL`,
668 /// else the single stored entry; with several stored and none
669 /// named, errors telling you to pass this.
670 #[arg(long, value_name = "URL")]
671 server: Option<String>,
672 },
673 /// Remove a stored credential: the one for `--server` (or
674 /// `ARETTA_API_URL`), the single entry when only one is stored, or
675 /// `--all`. Idempotent — logging out when not logged in is not an
676 /// error.
677 Logout {
678 /// Remove every stored credential.
679 #[arg(long)]
680 all: bool,
681 /// Server whose credential to remove. Ignored with `--all`.
682 #[arg(long, value_name = "URL", conflicts_with = "all")]
683 server: Option<String>,
684 },
685}
686
687/// Subcommands under `aristo instrument`.
688#[derive(clap::Subcommand, Debug)]
689pub(crate) enum InstrumentAction {
690 /// Emit the vendored C runtime (`aristo.h` + `aristo.c`) into a SUT so it
691 /// can link Aristo's fault-injection / observation points. C11, gated by
692 /// `-DARISTO_INSTRUMENT`.
693 VendorC {
694 /// Directory to write `aristo.h` / `aristo.c` into.
695 #[arg(long, default_value = "aristo")]
696 out: PathBuf,
697 },
698
699 /// Render read-only field accessors from `// @aristo inspect(...)`
700 /// directives into a gated `aristo_generated.{h,c}` pair.
701 GenC {
702 /// One or more C source files to read directives from.
703 #[arg(required = true)]
704 paths: Vec<PathBuf>,
705 /// The opaque-handle header the generated `.h` includes.
706 #[arg(long, default_value = "db.h")]
707 handle_header: String,
708 /// Directory to write `aristo_generated.{h,c}` into.
709 #[arg(long, default_value = "aristo")]
710 out: PathBuf,
711 /// Verify committed generated files match the directives; write
712 /// nothing, exit non-zero on drift (CI gate).
713 #[arg(long)]
714 check: bool,
715 },
716}
717
718/// Subcommands under `aristo canon`.
719#[derive(clap::Subcommand, Debug)]
720pub(crate) enum CanonAction {
721 /// Accept a pending canon match: rewrite source to use the
722 /// canonical text + apply the `aristos:` / `kanon:` prefix to
723 /// the annotation id, update the index entry's binding state
724 /// to `Bound`, and move the cache entry from `pending_matches`
725 /// to `accepted_matches`.
726 ///
727 /// Both arguments are required: the bare annotation id as it
728 /// appears in `.aristo/index.toml` (NOT prefixed; the prefix
729 /// is applied by accept) and the bare canon id from the
730 /// pending match (e.g. `cell_written_exactly_once_per_page_edit`).
731 Accept {
732 /// Annotation id whose pending match you're accepting. Use
733 /// the bare form (no `aristos:` / `kanon:` prefix); the
734 /// prefix is applied by the accept itself based on the
735 /// pending match's `prefix_tier`.
736 annotation_id: String,
737 /// Canon id from the pending match (also bare — no
738 /// prefix). The pair `(annotation_id, canon_id)` locates
739 /// the exact pending match in `.aristo/canon-matches.toml`.
740 canon_id: String,
741 },
742
743 /// List the current canon match state: one line per annotation
744 /// with pending / accepted / rejected counts, plus per-bucket
745 /// detail lines for each match. Reads `.aristo/canon-matches.toml`;
746 /// does not call the canon API.
747 List,
748
749 /// Reverse of `aristo canon accept`: strip the `aristos:` /
750 /// `kanon:` prefix from a canon-bound annotation, revert its
751 /// binding to `Local`, and drop the accepted_matches cache
752 /// entry. Source is rewritten in place (only the `id =` value
753 /// changes; canonical text + verify + parent are preserved).
754 /// The next `aristo stamp` may re-pull a fresh pending match
755 /// against the same annotation text.
756 ///
757 /// Unbind is for LIVE annotations. If the annotation was deleted
758 /// from source, no unbind is needed: the next `aristo stamp`
759 /// prunes its `.aristo/canon-matches.toml` entry automatically.
760 #[command(hide = true)]
761 Unbind {
762 /// Canon-bound annotation id including the prefix (e.g.
763 /// `aristos:cell_written_exactly_once_per_page_edit`).
764 prefixed_id: String,
765 },
766
767 #[command(hide = true)]
768 /// Report per-binding version drift between the local cache and
769 /// the canon API. Reports three classes: `current` (no change),
770 /// `patch-bump` (same canon_id, newer version — recommended
771 /// action: `aristo stamp --refresh-canon`), and `minor-bump` (canon_id
772 /// retired — recommended action: `aristo canon unbind <id>` then
773 /// re-stamp). Currently diagnostic-only; automatic patch-bump
774 /// application is planned.
775 Migrate,
776
777 /// Download the canon catalogue (the full list of available canon
778 /// entries) to `.aristo/catalogue.json` — a gitignored local
779 /// snapshot — and print a summary. Requires authentication; served
780 /// by the org's server.
781 Catalogue,
782
783 /// Run the S2 presence probe against a local SUT checkout: union
784 /// the instrumentation bundles carried by this repo's accepted
785 /// canon matches, generate an ephemeral probe crate (one
786 /// type-checked accessor call per record), `[patch]` the SUT
787 /// package to `--sut-path`, and `cargo check` it twice — with the
788 /// bundle's instrumentation features on (presence) and off (the
789 /// accessors must vanish; gating check). Failures are classified
790 /// per accessor — missing (rustc E0599/E0609) vs
791 /// SUT_FEATURE_UNDECLARED (cargo feature resolution) — and
792 /// rendered as escalation cards naming the conformance catch that
793 /// needs the accessor. Reuses the SUT's Cargo.lock + target/ so a
794 /// warm probe compile takes seconds. Fully offline: reads
795 /// `.aristo/canon-matches.toml`; never calls the canon API.
796 Probe {
797 /// Path to the local SUT checkout root (e.g. your
798 /// aretta-ai/turso fork clone). The probe `[patch]`es the SUT
799 /// package to this path and docks into its build graph
800 /// (Cargo.lock, target/, toolchain file).
801 #[arg(long = "sut-path", value_name = "DIR")]
802 sut_path: PathBuf,
803 /// The escalation card's `[ flag-broken ]` decision: run the
804 /// probe, verify this accessor still fails, and append the
805 /// rustc evidence to `.aristo/instrumentation-debt.jsonl`
806 /// (committed, append-only). Flagged accessors are re-surfaced
807 /// as `SKIPPED(instr-debt: <id>)` on later runs — visible
808 /// debt, never silently green.
809 #[arg(
810 long = "flag-broken",
811 value_name = "ACCESSOR_ID",
812 conflicts_with = "gen_only"
813 )]
814 flag_broken: Option<String>,
815 /// Keep the generated probe crate directory (under the SUT
816 /// target dir) instead of removing it after the run.
817 #[arg(long = "keep-probe")]
818 keep_probe: bool,
819 /// Generate and write the probe crate, then stop before
820 /// compiling (implies --keep-probe). Useful for inspecting
821 /// exactly what the probe will check.
822 #[arg(long = "gen-only")]
823 gen_only: bool,
824 },
825
826 /// List or inspect queued §17 proof-tree suggestions (the related
827 /// canon entries dragged in alongside a primary match). Read-only:
828 /// it does not open a review session or mutate the queue. To review
829 /// and adopt suggestions, run `aristo session start intent-review`.
830 ///
831 /// With no argument, lists every queued cluster. With an
832 /// `<objective>` (the cluster's objective canon_id, or the seeding
833 /// primary id for siblings-only clusters), shows that cluster's
834 /// detail. With `--counts`, emits a machine-readable JSON summary
835 /// `{matches:{new,pending}, suggestions:{new,pending}}` for the
836 /// menu / skill entry Q&A.
837 Suggestions {
838 /// Objective canon_id (or seeding primary id) of the cluster to
839 /// show. Omit to list all queued clusters.
840 objective: Option<String>,
841 /// Emit counts only (`{matches:{...}, suggestions:{...}}`) as
842 /// JSON; no per-cluster detail. Mutually exclusive with
843 /// passing an `<objective>`.
844 #[arg(long = "counts", conflicts_with = "objective")]
845 counts: bool,
846 /// Scope the listing with the shared `--filter` grammar
847 /// (`id=`, `file=[:LO-HI]`, `parent=`, `status=`). The
848 /// `cluster <objective>` mode (§6B) is `--filter
849 /// parent=<kanon:objective>` — it scopes the list to one proof
850 /// cluster (leaves carry `parent=`). Multiple `--filter` flags
851 /// AND together. No new grammar — reuses `filter.rs` verbatim.
852 #[arg(
853 long = "filter",
854 value_name = "KEY=VALUE",
855 conflicts_with = "objective"
856 )]
857 filter: Vec<Filter>,
858 },
859}
860
861/// Subcommands under `aristo session`. Each maps to one substrate
862/// operation; per-kind side effects (e.g. mutating a `.critique`
863/// file on accept) plug in via the `SessionKind` trait wired in
864/// step 5.
865#[derive(clap::Subcommand, Debug)]
866pub(crate) enum SessionAction {
867 /// Begin a new review session of the given kind. Fails if a
868 /// session is already active — pass `--allow-nesting` to override
869 /// (currently no kind allows nesting).
870 Start {
871 /// Session kind (`critique-review`, `proof-review`).
872 kind: String,
873 /// Display label for the artifact under review (e.g.
874 /// `src/critique/pending.rs` or
875 /// `proof:balance_no_duplicate_cells`).
876 #[arg(long = "subject")]
877 subject: String,
878 /// Override the kind's default nesting policy. Currently only
879 /// `Disallow` is implemented; the flag is reserved for future
880 /// per-kind opt-ins.
881 #[arg(long = "allow-nesting", default_value_t = false)]
882 allow_nesting: bool,
883 },
884 /// Print the active session id (or empty stdout if none).
885 /// Exit 0 either way.
886 Active {
887 /// Emit the full `<system-reminder>` block instead of just
888 /// the id — for the `UserPromptSubmit` hook installed by
889 /// `aristo install-skills`. Empty stdout when no session
890 /// is active (the hook then injects nothing).
891 #[arg(long = "hook-format", default_value_t = false)]
892 hook_format: bool,
893 },
894 /// Print bucket counts + open items for the active session.
895 /// Exit 0; errors out if no session is active.
896 #[command(hide = true)]
897 Status,
898 /// Record a decision on one item in the active session.
899 #[command(hide = true)]
900 Decide {
901 /// Item reference (`<id>#<index>` for indexed items, or any
902 /// opaque per-kind string).
903 #[arg(long = "item")]
904 item: String,
905 /// Which bucket the item lands in.
906 #[arg(long = "bucket", value_enum)]
907 bucket: BucketArg,
908 /// Optional note recorded with the decision.
909 #[arg(long = "note")]
910 note: Option<String>,
911 },
912 /// Close the active session. Strict by default — errors out if
913 /// any items are still in the open bucket.
914 Exit {
915 /// Move open items to the per-kind backlog instead of
916 /// erroring. Items are never silently dropped; the next
917 /// session of this kind surfaces them via the backlog menu.
918 #[arg(long = "defer-undecided", default_value_t = false)]
919 defer_undecided: bool,
920 },
921 /// Cancel the session and discard every decision recorded so far.
922 /// Requires `--yes` to skip the confirmation prompt.
923 Abort {
924 /// Skip the confirmation prompt.
925 #[arg(long = "yes", default_value_t = false)]
926 yes: bool,
927 },
928 /// List the active session and the most recent N closed sessions.
929 #[command(hide = true)]
930 List {
931 /// Maximum number of closed-session rows to include.
932 #[arg(long = "limit", default_value_t = 10)]
933 limit: usize,
934 },
935}
936
937/// User-facing bucket choices for `aristo session decide`. Maps to
938/// the substrate's [`session::types::ItemStatus`] (minus `Open`,
939/// which is the implicit pre-decision state).
940#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
941pub(crate) enum BucketArg {
942 Accepted,
943 Rejected,
944 Pending,
945}
946
947/// Process entry point. Parses `argv`, dispatches to the chosen subcommand,
948/// and returns the exit code. Prints `error: <msg>` to stderr on any
949/// `CliError`.
950pub fn run() -> ExitCode {
951 let cli = Cli::parse();
952 let code = match dispatch(cli.command) {
953 Ok(()) => ExitCode::SUCCESS,
954 Err(e) => {
955 if !e.is_silent() {
956 eprintln!("error: {e}");
957 }
958 ExitCode::from(e.exit_code())
959 }
960 };
961 // Best-effort "newer aristo available" notice, printed after the
962 // command's own output and never affecting its exit code. Silent in
963 // CI / pipes / on opt-out (see `update_notify`).
964 update_notify::maybe_notify();
965 code
966}
967
968/// Maps a parsed `Commands` variant to its handler.
969fn dispatch(cmd: Commands) -> CliResult<()> {
970 match cmd {
971 Commands::Init {
972 force,
973 ci,
974 ci_verify,
975 hook,
976 } => commands::init::run(force, ci, ci_verify, hook),
977 Commands::Lang { file } => commands::lang::run(file),
978 Commands::InstallSkills {
979 agent,
980 list_agents,
981 user,
982 update,
983 check,
984 hook_format,
985 } => {
986 commands::install_skills::install(agent, list_agents, user, update, check, hook_format)
987 }
988 Commands::UninstallSkills { agent, user, force } => {
989 commands::install_skills::uninstall(agent, user, force)
990 }
991 Commands::Stamp {
992 check,
993 skip_canon,
994 refresh_canon,
995 gc,
996 } => commands::stamp::run(check, skip_canon, refresh_canon, gc),
997 Commands::Show {
998 selector,
999 json,
1000 toml_out,
1001 } => commands::show::run(&selector, output_mode(json, toml_out)),
1002 Commands::List { filters, json } => commands::list::run(&filters, json),
1003 Commands::Status => commands::status::run(),
1004 Commands::Metrics { json } => commands::metrics::run(json),
1005 Commands::Nudge { event } => commands::nudge::run(event),
1006 Commands::Statusline => commands::statusline::run(),
1007 Commands::Review { mark, json } => commands::review::run(json, mark),
1008 Commands::Lint { check, fix, strict } => commands::lint::run(check, fix, strict),
1009 Commands::Verify {
1010 filters,
1011 rerun,
1012 strict,
1013 audit,
1014 apply_verdicts,
1015 rewrite_hashes,
1016 submit_verdict,
1017 id,
1018 json,
1019 pop_next,
1020 queue_status,
1021 wait,
1022 require_dispatch,
1023 view,
1024 tags,
1025 accept,
1026 because,
1027 tracking,
1028 } => commands::verify::run(
1029 &filters,
1030 rerun,
1031 strict,
1032 audit,
1033 apply_verdicts,
1034 rewrite_hashes,
1035 submit_verdict,
1036 pop_next,
1037 queue_status,
1038 id,
1039 json,
1040 wait,
1041 require_dispatch,
1042 view,
1043 &tags,
1044 accept,
1045 because,
1046 tracking,
1047 ),
1048 Commands::Critique {
1049 filters,
1050 apply_findings,
1051 include_closed,
1052 rerun,
1053 staged,
1054 all,
1055 yes,
1056 pop_next,
1057 queue_status,
1058 submit_findings,
1059 id,
1060 json,
1061 } => commands::critique::run(
1062 &filters,
1063 submit_findings,
1064 pop_next,
1065 queue_status,
1066 apply_findings,
1067 include_closed,
1068 rerun,
1069 staged,
1070 all,
1071 yes,
1072 id,
1073 json,
1074 ),
1075 Commands::Doc {
1076 summary,
1077 include_status,
1078 check,
1079 include_graph,
1080 } => commands::doc::run(summary, include_status, check, include_graph),
1081 Commands::Graph {
1082 format,
1083 out,
1084 filters,
1085 exclude_assumes,
1086 depth,
1087 include_orphans,
1088 include_status,
1089 } => commands::graph::run(
1090 &format,
1091 out,
1092 &filters,
1093 exclude_assumes,
1094 depth,
1095 include_orphans,
1096 include_status,
1097 ),
1098 Commands::Badge { out, style, metric } => {
1099 let style =
1100 commands::badge::Style::parse(&style).map_err(|message| CliError::Other {
1101 message,
1102 exit_code: 2,
1103 })?;
1104 let metric =
1105 commands::badge::Metric::parse(&metric).map_err(|message| CliError::Other {
1106 message,
1107 exit_code: 2,
1108 })?;
1109 commands::badge::run(out, style, metric)
1110 }
1111 Commands::Rename {
1112 old_id,
1113 new_id,
1114 dry_run,
1115 } => commands::rename::run(&old_id, &new_id, dry_run),
1116 Commands::Session { action } => commands::session::run(action),
1117 Commands::Auth { action } => commands::auth::run(action),
1118 Commands::Canon { action } => match action {
1119 CanonAction::Accept {
1120 annotation_id,
1121 canon_id,
1122 } => commands::canon::accept::run(&annotation_id, &canon_id),
1123 CanonAction::List => commands::canon::list::run(),
1124 CanonAction::Unbind { prefixed_id } => commands::canon::unbind::run(&prefixed_id),
1125 CanonAction::Migrate => commands::canon::migrate::run(),
1126 CanonAction::Catalogue => commands::canon::catalogue::run(),
1127 CanonAction::Probe {
1128 sut_path,
1129 flag_broken,
1130 keep_probe,
1131 gen_only,
1132 } => {
1133 commands::canon::probe::run(&sut_path, flag_broken.as_deref(), keep_probe, gen_only)
1134 }
1135 CanonAction::Suggestions {
1136 objective,
1137 counts,
1138 filter,
1139 } => commands::canon::suggestions::run(objective, counts, filter),
1140 },
1141 Commands::Instrument { action } => match action {
1142 InstrumentAction::VendorC { out } => commands::instrument::vendor_c::run(out),
1143 InstrumentAction::GenC {
1144 paths,
1145 handle_header,
1146 out,
1147 check,
1148 } => commands::instrument::gen_c::run(paths, handle_header, out, check),
1149 },
1150 }
1151}
1152
1153fn output_mode(json: bool, toml_out: bool) -> commands::show::OutputMode {
1154 if json {
1155 commands::show::OutputMode::Json
1156 } else if toml_out {
1157 commands::show::OutputMode::Toml
1158 } else {
1159 commands::show::OutputMode::Text
1160 }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::*;
1166 use clap::CommandFactory;
1167
1168 #[test]
1169 fn cli_parser_construction_is_valid() {
1170 // clap performs a structural sanity check (e.g. no duplicate
1171 // subcommand names) when CommandFactory::command() runs. We assert
1172 // it succeeds rather than panicking at runtime when a user types
1173 // `--help`. Cheap canary against future enum-shape mistakes.
1174 Cli::command().debug_assert();
1175 }
1176
1177 // Note: slice 32 removed the last `not_yet(...)` stub (Rename now
1178 // has a real implementation). The `CliError::NotImplemented` variant
1179 // is kept for future stubs but is no longer reachable from dispatch.
1180}