nordisk/nornir
src/bin/nornir.rs
//! `nornir` — human-facing CLI driver over the nornir library.
//!
//! Reads the consumer's `nornir.toml` (autodiscovered or `--config`)
//! and dispatches to lib APIs. Mirror image of `nornir-mcp`, which
//! exposes the same surface to LLM agents over MCP.
// Multicore-friendly global allocator (fat-mode deep-scan parity with the
// server): per-thread heaps so 32-worker columnization doesn't serialize on the
// glibc arena lock. The path is still zero-alloc/zero-copy where we can get it —
// this just stops the allocs we DO make from contending.
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, CommandFactory, Parser, Subcommand};
use nornir::bench;
use nornir::config::{self, Loaded};
use nornir::docs;
use nornir::guard;
use nornir::index;
use nornir::introspect;
use nornir::release;
use nornir::warehouse;
#[derive(Parser)]
#[command(name = "nornir", version = nornir::version_full(env!("CARGO_BIN_NAME")), about = "Companion to cargo: bench, docs, guard (release orchestration → `dwarves release`).")]
struct Cli {
/// Path to nornir.toml (default: autodiscover by walking up from cwd).
#[arg(long, global = true)]
config: Option<PathBuf>,
/// Run heavy tests (#[ignore]'d container/corpus/network/model/scale-matrix
/// arms). Heavy tests are OFF by default everywhere (release, `test`, viz) —
/// this opts IN. Sets NORNIR_RUN_HEAVY for the spawned `cargo test`/matrix.
#[arg(long, global = true)]
heavy: bool,
/// Skip heavy tests. DEPRECATED / no-op: heavy is already OFF by default —
/// kept so existing scripts/CI passing `--skip-heavy` don't break. Use
/// `--heavy` to opt IN. Sets NORNIR_SKIP_HEAVY for back-compat.
#[arg(long, global = true)]
skip_heavy: bool,
/// Thin-client target: connect to this nornir-server, e.g. `http://oden:7878`
/// (a bare `host:port` gets `http://` prepended). Works on EVERY subcommand —
/// sets `NORNIR_SERVER` for the whole invocation so the command routes to the
/// server instead of the local warehouse. Falls back to `$NORNIR_SERVER`.
#[arg(long, global = true)]
server: Option<String>,
/// Bearer token for `--server`. Falls back to `$NORNIR_SERVER_TOKEN`, then the
/// user-readable `<home>/.nornir/token`.
#[arg(long, global = true)]
token: Option<String>,
/// Target workspace by name (the `workspace_<name>` suffix, e.g. `nordisk`),
/// for when you are NOT standing in a `workspace_<name>/` dir. Sets
/// `NORNIR_WORKSPACE` for the whole invocation. In thin-client mode the name
/// must be one the `--server` serves (the client lists the served names if
/// not). Falls back to `$NORNIR_WORKSPACE`, then the cwd's `workspace_<name>`.
#[arg(long, global = true)]
workspace: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// AI-agent guard rails (chmod -w forbidden paths).
Guard {
#[command(subcommand)]
op: GuardOp,
},
/// List every CLI command — the recursive clap leaf paths (`bench history`,
/// `workspace ls`, …). This is the CLI's self-description, the autopilot's
/// surface source (the CLI twin of MCP `tools/list`). `--json` emits a uniform
/// `CommandOutcome` for tooling (AUT9). A migrated command — emits its data
/// through `CommandOutcome`, so the autopilot's sane-data check can assert it.
Commands {
/// Emit a machine-readable `CommandOutcome{command,ok,data,human}` instead
/// of the plain list.
#[arg(long)]
json: bool,
},
/// Benchmark HISTORY — and, as an alias, the runner that now lives on the forge.
///
/// `bench history-show` is warehouse work and stays here: it is a read of
/// `bench_runs`, which nornir owns. `bench run` is FORGE work — it builds and
/// runs a measurement over a product — and its home is now `dwarves bench run`.
/// It is kept here as a working alias that prints where it moved, because a
/// verb that silently stops existing is worse than one that points.
Bench {
#[command(subcommand)]
op: BenchOp,
},
/// Release gates and publish.
Release {
#[command(subcommand)]
op: ReleaseOp,
},
/// Standardized test-matrix (C6): run a repo's native `cargo test` /
/// `cargo nextest run`, parse per-test pass/fail + duration, and persist
/// each run to the warehouse `test_results` table. The viz Test pane reads
/// it back as a per-repo green/red matrix.
Test {
#[command(subcommand)]
op: TestOp,
},
/// Source-based **line/region coverage**, maven-report style (feature N).
/// Runs `cargo-llvm-cov` (LLVM source-based instrumentation) over a repo and
/// emits per-crate + per-file + per-function coverage, persisting the run to
/// the warehouse `coverage`/`coverage_fn` tables (time-travellable, feeds the
/// viz 🧪 Test pane + the docs `nornir:gen:coverage` table). A dedicated
/// `--boundary` view lists the ui / gRPC / emitter layer-boundary functions
/// and whether each is exercised. CLI twin of the viz coverage panel.
Coverage(CoverageArgs),
/// Run the embedded holger proxy (`/cache` crates.io mirror + `/sparring`) as a
/// tracked nornir job — a local registry cargo can build through, writing into
/// the znippy archive.
Cache {
#[command(subcommand)]
op: CacheOp,
},
/// Documentation assembly.
Docs {
#[command(subcommand)]
op: DocsOp,
},
/// Code introspection (call graph, dep graph, public API).
Introspect {
#[command(subcommand)]
op: IntrospectOp,
},
/// Binary graph extraction (EPIC item 8): reconstruct the dependency +
/// symbol graph `G = (V, E)` from compiled **ELF** artefacts — the airgap /
/// vendored-`.so` case where no source is checked out. Pure Rust (the
/// `object` crate), never shells out. Emitted node/edge rows carry the
/// `binary_graph` provenance marker so they stay distinguishable from
/// source-derived facts.
Graph {
#[command(subcommand)]
op: GraphOp,
},
/// Binary analysis of a COMPILED artifact (task #65): size (per-section /
/// per-symbol / per-crate) + hardening (checksec: RELRO/PIE/NX/canary/
/// stripped/debug). Pure-Rust (`object`+`gimli`), persisted into Mimir
/// (`binary_facts`/`section_sizes`/`symbol_sizes`) keyed by `content_sha256`.
/// The artifact-side twin of `nornir knowledge`.
Bin {
#[command(subcommand)]
op: BinOp,
},
#[cfg(feature = "vector")]
/// 👯 TWIN SCAN — sub-item duplicate code, in one of two scopes.
///
/// ⚠ This is the SECOND door. The scan normally happens on its own: own-code
/// as a lens fold on every generation change, deps as a jera job when the
/// resolved dependency closure moves. This verb exists so a human can force
/// a run, and it calls exactly the same `nornir::twinscan` functions — there
/// is one writer, not one per door.
///
/// `own` — the constellation's own repos. CPU (gatling), ~0.2 s.
/// `deps` — all of `~/.cargo/registry/src`. **Requires a real discrete GPU
/// and REFUSES without one**, before reading a single file: 5 702 262
/// rows on CPU is hours. The refusal names every adapter it turned
/// down, so a `llvmpipe`-only box reads as *refused a software
/// adapter*, never as *no adapter*.
Twinscan {
/// `own` or `deps`. Anything else is refused, never defaulted.
scope: String,
/// Repo roots to scan (`own` only; defaults to the current directory).
#[arg(long = "root")]
roots: Vec<std::path::PathBuf>,
/// Emit the findings as JSON instead of the operator table.
#[arg(long)]
json: bool,
},
/// Warehouse (Parquet + SQLite catalog) for bench history.
Warehouse {
#[command(subcommand)]
op: WarehouseOp,
},
/// Full-text index (Tantivy/BM25) over docs/code/bench/changelog/config.
Index {
#[command(subcommand)]
op: IndexOp,
},
/// Semantic / vector search: embed code+docs into the warehouse and query
/// by meaning. `search --mode` does lexical (BM25), semantic (vector), or
/// hybrid (both, RRF-merged). Indexing needs an embedder feature
/// (`embed-tract` CPU or `embed-ort` GPU).
/// UI-robot tester: drive a real browser (WebDriver) through declarative
/// TOML scenarios — a plan-tests-DAG with `needs = []` edges. Feature `robot`.
#[cfg(feature = "robot")]
Robot {
#[command(subcommand)]
op: RobotOp,
},
#[cfg(feature = "vector")]
Vector {
#[command(subcommand)]
op: VectorOp,
},
/// Knowledge map: the git activity scan (gix) and the queries over the code
/// room (symbols, calls and feature gates are written by its consumer).
Knowledge {
#[command(subcommand)]
op: KnowledgeOp,
},
/// Map an appointed workspace member end-to-end: persisted knowledge scan +
/// build the workspace code index (scoped to members) + snapshot it keyed to
/// this member's HEAD. One verb for the whole index pipeline.
Map(RepoArg),
/// Architecture-wiring generator (EPIC ARCH): coarsen the knowledge facts
/// (the code room's nodes + CALLS + TOUCHES_TABLE edges) into ONE
/// component/gRPC/core/table wiring graph, render it skade circuit-board
/// style, and historize it in the warehouse keyed by git sha.
Arch {
#[command(subcommand)]
op: ArchOp,
},
/// Skíðblaðnir airgap: pack a lake into a signed bundle, verify-on-receipt,
/// unfold a target (Redfish/Terraform/KVM), install + start in dep order —
/// zero network on the target. (EPIC AIRGAP; crate `skidbladnir`.)
Airgap {
#[command(subcommand)]
op: AirgapOp,
},
/// Idea→plan funnel (the planning DAG) — human CLI mirror of the MCP tools.
/// QUERY verbs (show/next/history/cards/board) stay here; the funnel ACTION
/// (decompose/plan-drive/prompt/run) moved to `dwarves funnel-action`
/// (doctrine 2026-07-15) and now warns on use.
Funnel {
#[command(subcommand)]
op: FunnelOp,
},
/// List configured repos.
Repos,
/// **WHICH REF IS EACH CHECKOUT AT, and is anything unpushed?** — one row per
/// checkout under `ROOT`, then a verdict line naming the ratio.
///
/// The question asked at the top of most sessions ("är allt commitat och
/// pushat till main?") had no verb: it was answered by
/// `workspace_nordisk/.nornir/fleet-refs.sh`, a shell script no Rust code
/// dispatches to, in a constellation whose own law is no shelling. This is
/// that answer as code, over `nornir_git::fleet` — pure gix, no `git`
/// subprocess.
///
/// Four axes, and the fourth is NOT the third: `dirty` (uncommitted work),
/// `behind` (the default ref moved on), `ahead` (this checkout has commits
/// the default ref lacks) and **AT-RISK** (the ahead commits that no remote
/// ref anywhere holds). Work pushed to a side branch is ahead and perfectly
/// safe; only AT-RISK is the shape that loses work when a disk dies.
///
/// A checkout is FIT when it sits on its remote default ref, is level with
/// it, is clean, and holds nothing at risk. **Exits non-zero when any row is
/// not FIT**, so it is a guard and not only a report.
///
/// It does NOT fetch — every verdict is against the remote-tracking refs as
/// they stand on disk, and each row names the ref it was measured against.
Fleet {
/// Directory holding the checkouts. Every entry with a `.git` — FILE or
/// directory, so linked worktrees count — is surveyed.
#[arg(default_value = ".")]
root: String,
/// Emit the rows as JSON instead of the table.
#[arg(long)]
json: bool,
},
/// The unified **job ledger** — what long-running nornir op is running / did
/// it finish / how long did it take (release, bench, test matrix, docs,
/// coverage, deepscan, container runs, …). Reads the SAME data the viz 🧬
/// nornir-tab job list and the `Viz.Jobs` RPC return: the server's `jobs.redb`
/// when `NORNIR_SERVER` is set, else the local warehouse's `jobs.redb`.
Jobs(JobsArgs),
/// Run a **container** (`podman`/`docker run …`) AS a tracked nornir job:
/// spawn → `running`, capture stdout/stderr + exit, then `done` (exit 0) or
/// `failed`. Appears in `nornir jobs` / the viz like any other job. The
/// process's exit code is propagated (a non-zero container exit ⇒ non-zero
/// `nornir`). Example: `nornir container --image docker.io/library/alpine:3 -- echo hi`.
Container(ContainerArgs),
/// Print the resolved nornir data root (`<home>/.nornir`) for the running
/// user — "where does nornir keep its data?". ONE rule for everyone, derived
/// from `$HOME`; no path env-vars. The server (user `nornir`) prints
/// `/home/nornir/.nornir`.
Root,
/// Launch the gRPC server (thin wrapper over the `nornir-server` binary).
Serve(ServeArgs),
/// Launch the time-travel visualizer (thin wrapper over `nornir-ui`).
/// Reads `NORNIR_SERVER`/`NORNIR_SERVER_TOKEN`/`NORNIR_WORKSPACE` like the CLI;
/// with `NORNIR_SERVER` set it views a running server live, else opens a local
/// warehouse.
Viz(VizArgs),
/// Manage nornir-server as a systemd service on THIS host: add | update |
/// remove. (For other lake components from airgap bundles, see
/// `nornir airgap component …`.)
Install {
#[command(subcommand)]
op: InstallOp,
},
/// SSH identity used for git poll/push in server-monitored mode. The keypair
/// lives in the `nornir` user's `.ssh` dir (created by `install add`).
Key {
#[command(subcommand)]
op: KeyOp,
},
/// The server's workspace registry — the index of every workspace it tracks
/// (server-monitored / pushed / external). Stored in `<root>/registry.redb`.
Workspace {
#[command(subcommand)]
op: WorkspaceOp,
},
/// SBOM + vulnerability + license scan (CycloneDX 1.5; `cargo metadata` + OSV).
Security {
#[command(subcommand)]
cmd: SecurityCmd,
},
/// Cross-repo dependency-graph queries (the `Mimir` surface). Mirrors the
/// server's `Mimir` RPCs + the MCP tools: deps-of / dependents-of / affected
/// / build-order / dep-path / external-users / svg / overview / changed.
/// Routes to the server over gRPC when `NORNIR_SERVER` is set (warehouse-
/// cached), else builds the graph locally (drives `cargo metadata`).
Mimir {
#[command(subcommand)]
op: MimirOp,
},
/// Render the viz `Timeline` model as headless diagrams (static SVG /
/// Markdown tables) — the data-only `src/viz/diagram.rs` renderers the egui app draws,
/// reachable from the CLI. Reads a local warehouse, or the server's
/// `Viz.Timeline` when `NORNIR_SERVER` is set.
Diagram {
#[command(subcommand)]
op: DiagramOp,
},
/// Agent × model **bake-off MATRIX** (H5): run one task across a
/// cross-product of **agents** (claude / kilo / a local-LLM driver) ×
/// **models**, recording one `agent_model_runs` row per `(task, agent,
/// model)` cell (score, wall_ms, tokens/s, cost, mcp_tool_calls) to the
/// warehouse, then read the leaderboard / matrix back. The viz 🏆 Leaderboard
/// pane shows the same agent×model grid with the winner highlighted.
Bakeoff {
#[command(subcommand)]
op: BakeoffOp,
},
/// Repo-level git plumbing: pure-Rust (gix) history tools. `scan-blobs`
/// reports the largest blobs across ALL history (what a clean would reclaim);
/// `clean-history` deep-cleans a path out of EVERY commit across EVERY ref —
/// the pure-Rust analogue of `git filter-repo --invert-paths --path <p>`.
Repo {
#[command(subcommand)]
op: RepoOp,
},
/// Declarative **custom builds** (task #19): drive `cargo build` from a
/// repo's declared build variants. A repo like holger ships several
/// distinct binaries (`holger-server`, `holger-agent`, `holger-mannequin`)
/// plus a fat GUI (`holger-ui`) — each a `package × bin × feature-set ×
/// profile × target` point declared as data (a `[[build.variant]]` table in
/// `nornir.toml` or a standalone `.nornir/build-variants.toml`). This verb
/// materializes the exact cargo invocation, refusing combinations the repo
/// never offered. `--list-variants` shows the space; `--dry-run` prints the
/// argv without building.
Build(BuildArgs),
/// **UI-movement saga** viewer + replay (crate `nornir-saga`). View live UI
/// movements from a saga (an Elm `Msg` stream) and REPLAY them
/// deterministically — in the terminal (state_json frames), in a browser
/// (`--web`, self-contained axum+SSE page that actually moves), or driven at
/// a real page (`--drive`, via the `WebReplayDriver` seam). Demoable with a
/// built-in synthetic saga (`--demo`) — no live Njord required. See
/// `.nornir/nornir-saga.md`.
Saga {
#[command(subcommand)]
op: SagaOp,
},
/// **WORLD-CODEROOM** (design `.nornir/world-coderoom-design.md`) — generalize
/// Mímir to the crates.io world at symbol granularity. `ingest` politely fetches
/// (disk-cached, rate-limited, UA'd) `.crate` tarballs and syn-scans them into the
/// warehouse partitioned by `(crate, version)`; `compat` persists the `compatible?`
/// edge (the real producer of the `semver_diffs` slot); `what-breaks` answers
/// "who across the world calls a symbol version Z removed" (agent-callable JSON).
/// Mímir v2: `ingest`, `compat` and `what-breaks` wait for the world ring and
/// answer `not_wired` (the tarball scan is gone); `snapshot-diff` answers from
/// the workspace's code room.
Coderoom {
#[command(subcommand)]
op: CoderoomOp,
},
/// Interactive OIDC single-sign-on (RFC 8628 device-authorization grant) — the
/// CLIENT side of holger-style auth. Prints a verification URL + user code, waits
/// for approval, and caches the token in `~/.nornir/auth-token.json`; nornir's
/// client calls then PREFER it over the static `~/.nornir/token`. Needs
/// `--features login` (the lean build prints a rebuild hint).
Login,
/// Print the current signed-in identity (silently refreshing an expired token when a
/// refresh token is held). Exit 1 when signed out. Needs `--features login`.
Whoami,
/// Remove the cached OIDC login token (`~/.nornir/auth-token.json`). Idempotent —
/// leaves the static `~/.nornir/token` untouched. Needs `--features login`.
Logout,
}
#[derive(Subcommand)]
enum CoderoomOp {
/// Fetch + ingest crates.io crates into Mímir. Each SPEC is either `name`
/// (→ its latest `--versions-per` versions from the sparse index) or
/// `name@version` (exact). Between the two newest ingested versions of a crate,
/// the `compatible?` edge is persisted automatically. Polite by default
/// (disk cache + rate-limit), so re-runs never re-fetch.
Ingest {
/// Crate specs: `name` or `name@version` (repeatable; commas also split).
/// Optional when `--top N` supplies the ranked list instead.
specs: Vec<String>,
/// Ingest the TOP N crates by reverse-dependency count (crates.io db-dump)
/// instead of / in addition to `specs` — the scale-phase ranked input.
#[arg(long = "top")]
top: Option<usize>,
/// Permit the LARGE one-time crates.io db-dump download that `--top` needs
/// (cached after). Without it, `--top` errors if the dump isn't already cached.
#[arg(long = "download", default_value_t = false)]
download: bool,
/// Latest versions to ingest for a bare `name` / `--top` crate (default 2).
#[arg(long = "versions-per", default_value_t = 2)]
versions_per: usize,
/// Override the on-disk cache dir (default `$NORNIR_CODEROOM_CACHE` /
/// `~/.cache/nornir/coderoom`).
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
/// Override the DEDICATED coderoom warehouse (default
/// `$NORNIR_CODEROOM_WAREHOUSE` / `<cache>/warehouse` — NEVER the workspace Mímir).
#[arg(long = "warehouse")]
warehouse: Option<PathBuf>,
/// Milliseconds to pause after each NETWORK fetch (politeness; default 1000).
#[arg(long = "rate-ms", default_value_t = 1000)]
rate_ms: u64,
/// Force re-scan of versions ALREADY ingested (default: skip them — a published
/// version is immutable, so re-ingest is a no-op, making a `--top N` re-run
/// incremental). Pass to re-append (e.g. after a scanner change).
#[arg(long = "reingest", default_value_t = false)]
reingest: bool,
},
/// Print the TOP N crate names by reverse-dependency count (crates.io db-dump,
/// cached). The ranked ingest input for `ingest --top N`.
TopN {
n: usize,
/// Permit the large one-time db-dump download (cached after).
#[arg(long = "download", default_value_t = false)]
download: bool,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
},
/// Persist the `compatible?` edge for KRATE between FROM→TO (both must already be
/// ingested): the T1 public-API drop, written to `semver_diffs`.
Compat {
krate: String,
from: String,
to: String,
#[arg(long = "warehouse")]
warehouse: Option<PathBuf>,
},
/// "What breaks if KRATE bumps to VERSION" — the world callers referencing a symbol
/// VERSION removed. `--from PREV` restricts to symbols actually dropped in PREV→VERSION.
/// `--transitive` walks the full impact closure (callers-of-callers …), each impacted
/// crate tagged with its hop distance (direct callers = 1).
WhatBreaks {
krate: String,
version: String,
#[arg(long)]
from: Option<String>,
/// Walk the TRANSITIVE impact closure outward over the corpus call-graph
/// (default: direct callers only).
#[arg(long)]
transitive: bool,
/// Max call-graph hops for `--transitive` (0 = full closure; 1 = direct-only).
#[arg(long, default_value_t = 0)]
radius: usize,
/// ALSO anti-join the `call_edges` of this warehouse — in practice the real
/// Mímir, whose `repo = "deps"` partition is the deepscan corpus and whose
/// other partitions are our own repos. Without it a breaking change that
/// breaks OUR code reports zero callers, because the coderoom warehouse is
/// deliberately a separate root. Read-only; repeatable.
#[arg(long = "mimir")]
mimir: Vec<PathBuf>,
/// A `crate@version` the caller asked about but could NOT ingest (a failed
/// fetch). Named in the answer's evidence note as a MISSING FEED, so an
/// incomplete corpus can never read as a clean zero. Repeatable.
#[arg(long = "unavailable")]
unavailable: Vec<String>,
#[arg(long = "warehouse")]
warehouse: Option<PathBuf>,
},
/// CORPUS snapshot-diff — what changed across the WHOLE ingested world of the
/// coderoom warehouse between two of its snapshots: crates added / removed, and
/// per surviving crate, symbols added / removed / changed (JSON, agent-callable).
/// SNAP_A/SNAP_B are `snapshot_id`s; both default to the two most-recent snapshots.
SnapshotDiff {
/// The "before" snapshot id (default: second-most-recent snapshot).
snap_a: Option<String>,
/// The "after" snapshot id (default: most-recent snapshot).
snap_b: Option<String>,
#[arg(long = "warehouse")]
warehouse: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum SagaOp {
/// Fold a saga's movement stream through the ELM viewer and print the
/// resulting `state_json` (the whole observable model). `--frames` prints one
/// `state_json` per movement (deterministic sequence). Use `--demo` for the
/// built-in synthetic saga instead of a file.
View(SagaViewArgs),
/// Replay a saga. Default: print each frame's `state_json` as JSONL
/// (deterministic). `--web` serves a self-contained browser page that moves
/// (needs `--features saga-web`). `--drive <url>` dispatches the recorded
/// movements at a real page through the `WebReplayDriver` seam (mock by
/// default — prints the gestures; real WebDriver with `--features saga-drive`
/// + `--webdriver <endpoint>`).
Replay(SagaReplayArgs),
/// **Nav-path / funnel** for a recorded session: read its `saga_events` back
/// from the warehouse and print the ordered route funnel (route →
/// sessions-reached, per-step drop-off, next-route transitions). `--json` emits
/// the funnel as JSON. An unknown session prints a clear "no events" message.
Path(SagaPathArgs),
/// **EGUI replay** (the egui sibling of the web `replay`) — read a recorded
/// session's `saga_events` back into a `ReplayPlan`, convert it to the SHARED
/// `nornir_robotui::Scenario`, and replay it THROUGH the live nornir viz:
/// HEADLESS (egui_kittest matrix drive, default) or `--visible` (a real,
/// paced eframe window you WATCH). `--demo` uses a built-in synthetic plan
/// (no warehouse needed). Needs nornir built with `--features saga-egui`. The
/// answer to "saga replays on web — now it replays on egui too".
EguiReplay(SagaEguiArgs),
}
#[derive(Args)]
struct SagaEguiArgs {
/// The recorded session id to replay (its `saga_events` are read back into a
/// `ReplayPlan`). Omit with `--demo`.
session: Option<String>,
/// Use the built-in synthetic replay plan (a short tab tour) instead of a
/// recorded session — demoable with no `saga_events` in the warehouse.
#[arg(long)]
demo: bool,
/// Open a REAL, visible eframe window and replay the steps PACED so a human
/// watches the viz move (`RunMode::Watch`). Without it the replay runs
/// headless (egui_kittest → test-matrix rows). CLI parity for the
/// `NORNIR_ROBOT_MODE=visible` env toggle; the flag wins when both are set.
#[arg(long)]
visible: bool,
/// Force headless even if `NORNIR_ROBOT_MODE`/`ROBOTUI_WATCH` ask for visible
/// (mutually exclusive with `--visible`).
#[arg(long, conflicts_with = "visible")]
headless: bool,
/// Warehouse root to read the session's `saga_events` from (defaults to the
/// configured `[storage].local_path`, else `<home>/.nornir/warehouse`).
#[arg(long)]
warehouse: Option<PathBuf>,
}
#[derive(Args)]
struct SagaPathArgs {
/// The recorded session id to aggregate (as stored in `saga_events`).
session: String,
/// Emit the funnel as JSON instead of a human-readable table.
#[arg(long)]
json: bool,
/// Warehouse root to read from (defaults to the configured
/// `[storage].local_path`, else `<home>/.nornir/warehouse`).
#[arg(long)]
warehouse: Option<PathBuf>,
}
#[derive(Args)]
struct SagaViewArgs {
/// Path to a saga JSON file (omit with `--demo`).
saga: Option<String>,
/// Use the built-in synthetic demo saga instead of a file.
#[arg(long)]
demo: bool,
/// Print one `state_json` per movement (the full deterministic sequence)
/// instead of just the final model.
#[arg(long)]
frames: bool,
}
#[derive(Args)]
struct SagaReplayArgs {
/// Path to a saga JSON file (omit with `--demo`).
saga: Option<String>,
/// Use the built-in synthetic demo saga instead of a file.
#[arg(long)]
demo: bool,
/// Serve the replay as a self-contained browser page (axum + SSE) that
/// actually moves. Requires nornir built with `--features saga-web`.
#[arg(long)]
web: bool,
/// Address to bind the `--web` server to.
#[arg(long, default_value = "127.0.0.1:8971")]
addr: String,
/// Dispatch the recorded movements at a REAL page at this base URL through
/// the `WebReplayDriver`. Without `--features saga-drive` this uses the mock
/// driver and prints the gestures that WOULD be dispatched.
#[arg(long)]
drive: Option<String>,
/// WebDriver endpoint (e.g. `http://localhost:4444`) for `--drive` with a
/// real browser (needs `--features saga-drive`).
#[arg(long)]
webdriver: Option<String>,
/// Playback speed multiplier (1.0 = real time). Scales inter-frame timing.
#[arg(long, default_value_t = 1.0)]
speed: f64,
}
#[derive(Subcommand)]
enum RepoOp {
/// Read-only: walk all refs + ancestry, enumerate blobs and sizes, and report
/// the N largest (size, example path, #commits) + the total — "what would a
/// clean reclaim". `--path <glob>` narrows to matching paths.
ScanBlobs(RepoScanArgs),
/// Deep-clean: remove `--path <p>` from EVERY commit across EVERY ref. DRY-RUN
/// by default (reports commits/blobs/bytes); pass `--apply` to rewrite history
/// (writes a `refs/original/*` + file backup first, repoints refs, and leaves
/// the worktree consistent with the new HEAD). Idempotent. On-disk shrink of
/// `.git` needs a follow-up `git gc --prune=now` (history is rewritten
/// regardless).
CleanHistory(RepoCleanArgs),
}
#[derive(Args)]
struct RepoScanArgs {
/// Repo to scan: a configured member name OR a path to a git working dir.
repo: String,
/// Only report paths matching this glob (`*`/`?`, `*` spans `/`), e.g. `*.pdf`.
#[arg(long)]
path: Option<String>,
/// How many of the largest blobs to list.
#[arg(long, default_value_t = 20)]
top: usize,
}
#[derive(Args)]
struct RepoCleanArgs {
/// Repo to clean: a configured member name OR a path to a git working dir.
repo: String,
/// Exact repo path(s) to strip from ALL history, e.g. `docs/book.pdf`.
/// Repeatable. Nested paths supported; empty subtrees are dropped.
#[arg(long, required = true)]
path: Vec<String>,
/// Perform the rewrite. Without this it is a DRY-RUN (report only).
#[arg(long)]
apply: bool,
}
#[derive(Subcommand)]
enum BakeoffOp {
/// Run `--task` across the `--agents × --models` cross-product and persist one
/// row per `(task, agent, model)` cell. With no `--agents`, a single
/// local-LLM agent is used (a model-only bake-off, as before); with no
/// `--models`, the embedding-model registry's ids. By default the models are
/// called via ollama (`$OLLAMA_HOST` or http://localhost:11434); pass `--mock`
/// to record canned offline rows (no LLM needed).
Run(BakeoffRunArgs),
/// Show a bake-off's leaderboard — `(agent, model)` cells ranked by score then
/// tokens/s. REF is matched first as a `run_id`; if no rows carry it, as a
/// model name (per-model history). With no REF, the most recent run.
/// `--json` prints the raw rows; `--matrix` prints the agent × model grid.
Leaderboard(BakeoffLeaderboardArgs),
/// **Demo:** write a realistic mock agent × model matrix to the warehouse
/// (3 agents × 3 models over one task, with one failed cell) so a fresh viz
/// shows a populated 🏆 Leaderboard. Mirrors `funnel demo`; no LLM needed.
/// Idempotent-ish: each invocation writes a fresh `run_id`.
Demo,
}
#[derive(Args)]
struct BakeoffRunArgs {
/// The task/prompt to pit the agent×model cells against.
#[arg(long, visible_alias = "prompt")]
task: String,
/// Stable handle for the task (so two runs of the same task compare).
#[arg(long, default_value = "default", visible_alias = "prompt-id")]
task_id: String,
/// Comma-separated agent ids (claude,kilo,local-llm,…). Empty ⇒ a single
/// local-LLM agent (a model-only bake-off).
#[arg(long, value_delimiter = ',')]
agents: Vec<String>,
/// Comma-separated model ids/tags. Empty ⇒ the embedding-model registry's ids.
#[arg(long, value_delimiter = ',')]
models: Vec<String>,
/// ollama host base URL (else `$OLLAMA_HOST`, else http://localhost:11434).
#[arg(long)]
host: Option<String>,
/// Record canned offline rows instead of calling ollama (no live LLM needed).
#[arg(long)]
mock: bool,
/// Generative-LLM backend spec for the model call (EPIC #39): one of
/// `candle:<m>` | `mistralrs:<m>` | `onnx:<m>` | `ollama:<m>` | `mock`. Each
/// real backend is feature-gated (`gen-candle`/`gen-mistralrs`/`gen-onnx`,
/// `gen-ollama`); an absent backend gives a clear "rebuild with --features"
/// error. Falls back to `$NORNIR_GEN_BACKEND`, then to `--mock`/ollama. The
/// `generator(spec)` factory drives the same model across every cell.
#[arg(long, value_name = "SPEC")]
r#gen: Option<String>,
/// Emit the persisted rows as JSON after the run.
#[arg(long)]
json: bool,
/// Print the agent × model grid (not just the flat leaderboard) after the run.
#[arg(long)]
matrix: bool,
/// **Codegen judge:** path to the target crate each cell's answer is scored
/// against (compile-and-test). Set it to score answers by build+test outcome
/// instead of leaving the quality score at 0.0. The answer is dropped into a
/// scratch copy of this crate, `cargo build` + the accept command run, and
/// the row's `score` becomes the verdict (0.0 = won't compile, fraction of
/// acceptance tests passing, 1.0 = build+all-tests green).
#[arg(long, value_name = "PATH")]
judge_crate: Option<std::path::PathBuf>,
/// Where the judged answer goes inside `--judge-crate` (relative path), e.g.
/// `src/lib.rs` — the answer is a full file body that overwrites it. Mutually
/// exclusive with `--judge-patch`. Required with `--judge-crate` unless
/// `--judge-patch` is set.
#[arg(long, value_name = "REL_PATH")]
judge_file: Option<String>,
/// Treat each answer as a unified diff and `git apply` it to `--judge-crate`
/// instead of overwriting a file. Mutually exclusive with `--judge-file`.
#[arg(long)]
judge_patch: bool,
/// The acceptance command the judge gates on (default `cargo test`). Quote it,
/// e.g. `--judge-cmd "cargo test --test integration"`.
#[arg(long, value_name = "CMD")]
judge_cmd: Option<String>,
}
#[derive(Args)]
struct BakeoffLeaderboardArgs {
/// run_id (or model) to read. Empty ⇒ the most recent run.
#[arg(default_value = "")]
reference: String,
/// Emit the raw `agent_model_runs` rows as JSON instead of the leaderboard.
#[arg(long)]
json: bool,
/// Print the agent × model grid instead of the flat ranked leaderboard.
#[arg(long)]
matrix: bool,
}
#[derive(Subcommand)]
enum MimirOp {
/// Repos that REPO depends on (`--transitive` for the full forward closure).
DepsOf {
repo: String,
#[arg(long)]
transitive: bool,
},
/// Repos that depend ON REPO — the blast radius (`--transitive` for closure).
DependentsOf {
repo: String,
#[arg(long)]
transitive: bool,
},
/// Changed repos + everything that transitively depends on them, in build order.
Affected {
/// One or more changed repos.
repos: Vec<String>,
},
/// Full workspace build order (dependencies before dependents).
BuildOrder,
/// Shortest dependency path FROM→TO, annotated with the justifying crates.
DepPath { from: String, to: String },
/// Workspace repos that consume the external crate KRATE.
ExternalUsers { krate: String },
/// Render the cross-repo dependency graph as a static SVG flowchart.
Svg,
/// One-shot orientation for REPO: internal deps + dependents, build-order
/// index, and a knowledge digest (symbol/call counts) from the warehouse.
Overview { repo: String },
/// Repos changed since their last recorded release (server-only; needs the
/// warehouse's release lineage). Requires `NORNIR_SERVER`.
Changed,
/// Warehouse-native code analyses over the persisted knowledge map.
Analyze {
#[command(subcommand)]
op: AnalyzeOp,
},
}
#[derive(Subcommand)]
enum AnalyzeOp {
/// Copied-not-moved detection: symbols whose embedding cosine AND structural
/// signals (signature + callee neighbourhood) BOTH clear the thresholds are
/// clustered across crates/repos. Reads one vector per function node (written
/// by the `embeddings` feed / `workspace embed`) and the code room's nodes and
/// CALLS edges; says `vectors_missing` / `no_embedder` when there is none.
Duplicates {
/// Max clusters in the ranked tail (default 10).
#[arg(long)]
limit: Option<usize>,
/// Report only clusters touching this repo (detection stays workspace-wide).
#[arg(long)]
repo: Option<String>,
/// Minimum vector cosine of a pair (default: the lens's own).
#[arg(long)]
cosine: Option<f64>,
/// Minimum structural agreement of a pair, signature + callee set (default: the lens's own).
#[arg(long)]
structural: Option<f64>,
/// Also report pairs inside ONE crate (default: only pairs in different crates).
#[arg(long)]
same_crate: bool,
/// List the trait-impl / constructor / accessor families instead of withholding them.
#[arg(long)]
keep_trait_impls: bool,
/// Add the threshold instrument to the answer: the distribution of every
/// pairwise cosine, what each threshold keeps, and the pairs around the bar
/// (`--limit` of each) for a person to judge.
#[arg(long)]
calibration: bool,
/// With `--calibration`: the bar the "just above" / "just below" lists hug
/// (default: the cosine in force).
#[arg(long)]
around: Option<f64>,
/// With `--calibration`: `a.rs,b.rs` — every function of the first file
/// with its nearest function in the second.
#[arg(long)]
files: Option<String>,
},
/// Semantic clones: the model over the NORMALISED body (names → roles),
/// cosine ≥ 0.90 AND agreement of the normalised-body signal (exact /
/// near) or the structural one. Names never decide a pair.
SemanticClones {
/// Report only clusters touching this repo (default: report all).
#[arg(long)]
repo: Option<String>,
/// Max clusters in the ranked tail (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Exact clones: the same NORMALISED body (names → roles, literals → kinds, layout and comments gone).
ExactClones {
/// Report only findings touching this repo (default: report all).
#[arg(long)]
repo: Option<String>,
/// Max findings (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Near clones: MinHash/LSH over the normalised bodies' role shingles (Jaccard estimate ≥ 0.80).
NearClones {
/// Report only findings touching this repo (default: report all).
#[arg(long)]
repo: Option<String>,
/// Max findings (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Structural clones: IDF-weighted Jaccard of param/ret/used types, fields read, callees — names play no part.
StructuralClones {
/// Report only findings touching this repo (default: report all).
#[arg(long)]
repo: Option<String>,
/// Max findings (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Nearest by role: FastRP role vectors over the code room (types, fields, callees, callers).
RoleNeighbours {
/// Report only findings touching this repo (default: report all).
#[arg(long)]
repo: Option<String>,
/// Max findings (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Semantic twins: independent re-implementations (role × normalised meaning × body
/// different) and copies (normalised body + structure); the false-hit families left out.
SemanticTwins {
/// Report only findings touching this repo (default: report all).
#[arg(long)]
repo: Option<String>,
/// Max findings (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Re-implemented libraries: a crate that depends on crate B and holds a copy or
/// twin of a B function — call the library's instead (the twins × DEPENDS_ON).
ReimplementsShared {
/// Report only functions of this repo to replace (default: all).
#[arg(long)]
repo: Option<String>,
/// Max findings (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Same intent — NAME-DRIVEN: the raw-text model, names included on purpose.
SameIntent {
/// Report only findings touching this repo (default: report all).
#[arg(long)]
repo: Option<String>,
/// Max findings (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Undocumented public API in churny files (idea #16): `pub fn` with no doc
/// comment (`doc_lines=0`) in a file many commits/authors keep changing — an
/// undocumented public contract under active change. The code room's nodes ×
/// `git_heat_facts` on (repo, file), ranked by recent churn.
UndocApi {
/// Restrict to one repo (default: all repos).
#[arg(long)]
repo: Option<String>,
/// Churn floor — only files with `commits_90d >= N` (default 2).
#[arg(long)]
min_commits_90d: Option<i64>,
/// Max findings in the ranked tail (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// CPG graph-soundness self-audit (idea #34): leaf fn names defined in more
/// than one place, which the string-name call-graph matcher conflates into one
/// node. Reports the fraction of CALLS edges pointing at an ambiguous callee;
/// cross-repo colliders rank worst.
NameCollision {
/// Restrict the definitions considered to one repo (default: all repos).
#[arg(long)]
repo: Option<String>,
/// Max collisions in the ranked tail (default 50).
#[arg(long)]
limit: Option<usize>,
},
/// Provenance-trust semiring (Gemini 2A): walk the code room's CALLS FORWARD from the
/// entry surface under the bottleneck `(⊕=max over paths, ⊗=min weakest-hop)`
/// semiring, dock local trust by shell-out impurity × sensitive-data touch ×
/// test coverage, and map each function's inherited trust to act/hold/refuse —
/// naming the weakest hop that caps the verdict.
Trust {
/// Restrict to one repo (default: all repos in the warehouse).
#[arg(long)]
repo: Option<String>,
/// ACT floor — inherited trust ≥ this ⇒ act (default 0.66).
#[arg(long)]
act_at: Option<f64>,
/// HOLD floor — inherited trust ≥ this ⇒ hold, else refuse (default 0.33).
#[arg(long)]
hold_at: Option<f64>,
/// Max verdicts in the ranked (refuse/hold) tail (default 50).
#[arg(long)]
limit: Option<usize>,
/// Emit the skade-style red-marked CPG SVG (refuse/hold set) instead of JSON.
#[arg(long)]
svg: bool,
},
/// b1 reachable-vuln (baddie-detection flagship): `vuln_findings` ×
/// `sbom_components` × the code room's CALLS. A CVE present in a repo's resolved
/// components is escalated to refuse 🔴 ONLY if the vulnerable crate is actually
/// CALLED (a call edge targets it); present-but-unreachable is hold 🟠. The
/// reachability gate is the 80–90% false-positive killer. Empty until modgunn's
/// `warehouse scan --persist` feeds `vuln_findings`.
ReachableVuln {
/// Restrict to one repo (default: all repos in the warehouse).
#[arg(long)]
repo: Option<String>,
/// Max findings in the ranked (refuse-before-hold) tail (default 50).
#[arg(long)]
limit: Option<usize>,
/// Emit the skade-style red-marked CPG SVG (refuse/hold set) instead of JSON.
#[arg(long)]
svg: bool,
},
/// SUBSTRATE invariant gate: is the code room's node id (the SCIP moniker) actually an identity
/// (one definition per symbol per snapshot)? Every duplicate claim the
/// similarity tier makes rests on it. Separates OUR defect — a bare
/// `local <id>`, SCIP's document-scoped form written by a binary predating
/// `scope_local_symbol`, cured by re-ingesting the named repos — from UPSTREAM
/// ambiguity (rust-analyzer defining one global moniker twice), which no
/// re-ingest fixes. An unfed table reads `unknown`, never `sound`.
SymbolIdentity {
/// Restrict the audit to one repo (default: all repos in the warehouse).
#[arg(long)]
repo: Option<String>,
/// Max ambiguous symbols in the ranked (ours-before-upstream) tail (default 50).
#[arg(long)]
limit: Option<usize>,
},
}
#[derive(Subcommand)]
enum DiagramOp {
/// Static SVG timeline of every release node per lane.
Timeline(DiagramArgs),
/// Markdown table: one row per lane (repo) with its release count + latest sha.
LaneSummary(DiagramArgs),
/// Static SVG flowchart of the latest snapshot's cross-repo dep edges.
Depgraph(DiagramArgs),
/// Plain edge list (`from -> to`) of the latest snapshot.
SnapshotEdges(DiagramArgs),
/// Markdown gate-status matrix (release × repo).
GateMatrix(DiagramArgs),
/// Markdown table of published crate versions per release.
ReleaseVersions(DiagramArgs),
/// Markdown bench-history table (`--limit N` caps rows; newest first).
BenchHistory {
#[command(flatten)]
args: DiagramArgs,
#[arg(long)]
limit: Option<usize>,
},
/// Markdown bench first-vs-last comparison table per metric.
BenchCompare(DiagramArgs),
}
#[derive(Args)]
struct DiagramArgs {
// Workspace comes from the global `--workspace` flag / `$NORNIR_WORKSPACE`
// (see `env_workspace_or`); no per-op flag needed.
}
#[derive(Subcommand)]
enum SecurityCmd {
/// Deep dependency scan: vulnerabilities (OSV.dev) + licenses; optionally
/// write a CycloneDX 1.5 SBOM. Exit code = vulnerable-crate count (CI gate).
Scan {
/// Repo directory (default: current dir).
#[arg(default_value = ".")]
repo: std::path::PathBuf,
/// Write the CycloneDX SBOM to this path.
#[arg(long)]
out: Option<std::path::PathBuf>,
/// Emit the uniform `CommandOutcome` as JSON (the CLI twin of the Security
/// tab's `state_json`) instead of the human table.
#[arg(long)]
json: bool,
},
/// Clone/update the local rustsec advisory-db mirror for **offline (airgap)**
/// vuln matching. `NORNIR_ADVISORY_DB` = path (default /opt/nornir/advisory-db),
/// `NORNIR_ADVISORY_DB_URL` = source (e.g. holger; default github rustsec).
UpdateDb,
}
#[derive(Subcommand)]
enum WorkspaceOp {
/// List registered workspaces (name · mode · members · last-synced) — the CLI
/// twin of the Nornir-tab roster. `--json` emits a uniform `CommandOutcome`.
#[command(visible_alias = "list")]
Ls {
#[arg(long)]
json: bool,
/// PO1: roll up each workspace's `clone_events` into a POPULATE verdict
/// column (green = all members' latest ok · red = ≥1 latest error · stale
/// = never populated), naming the first failing member + its error. The
/// CLI twin of the Nornir-tab roster's red/green badge.
#[arg(long)]
status: bool,
},
/// Show one workspace's full record.
Show { name: String },
/// Register a workspace — **EAGER by default**: record it AND immediately
/// fetch (clone members) + build the warehouse inline, so members + the
/// snapshot are populated NOW (the same pipeline the poll loop runs on a
/// change). The poll loop then handles UPDATES. Pass `--lazy` for the old
/// record-only behaviour (defer population to the poll loop — scripted/daemon
/// flows). (`add` is the explicit eager alias, kept for habit.)
Register {
name: String,
/// Local path or git URL to the workspace's `nornir-workspace.toml`.
/// OPTIONAL — when omitted, nornir discovers `nornir-workspace.toml` by
/// walking up from the current directory (stand in the workspace dir and
/// just run `nornir workspace register <name>`). In thin/server mode a
/// discovered LOCAL descriptor's content is shipped to the server.
#[arg(long)]
descriptor: Option<String>,
/// Server polls members and republishes (server-user `nornir`).
#[arg(long)]
monitored: bool,
/// Sources live outside; only `builds/` is server-owned (`git/` empty).
#[arg(long)]
external: bool,
/// Poll interval for monitored workspaces, e.g. `60s`.
#[arg(long)]
poll: Option<String>,
/// Record-only: skip the eager fetch+build, leaving population to the
/// poll loop. The opt-out from the default eager behaviour.
#[arg(long)]
lazy: bool,
},
/// **Eager add** — register + fetch (clone members) + build the warehouse, all
/// inline, so the workspace is **populated now** (no waiting for a poll tick).
/// The SAME thing the server's poll loop does on a change — so a fat user and
/// the server do the same thing; `add` is the manual button, the poll loop is
/// the automated one. Identical flags to `register`.
Add {
name: String,
/// Local path or git URL to the workspace's `nornir-workspace.toml`.
/// OPTIONAL — when omitted, nornir discovers `nornir-workspace.toml` by
/// walking up from the current directory (stand in the workspace dir and
/// just run `nornir workspace add <name>`). In thin/server mode a
/// discovered LOCAL descriptor's content is shipped to the server.
#[arg(long)]
descriptor: Option<String>,
/// Server polls members + republishes (server-user `nornir`).
#[arg(long)]
monitored: bool,
/// Sources live outside; only `builds/` is server-owned (`git/` empty).
#[arg(long)]
external: bool,
/// Poll interval for monitored workspaces, e.g. `60s`.
#[arg(long)]
poll: Option<String>,
},
/// Remove a workspace from the registry.
Rm {
name: String,
/// Also delete the workspace's on-disk build data — its `builds/` dir
/// (warehouse + catalog.redb + jobs.redb) — not just the registry row.
/// Default (without this flag) is registry-only (back-compat). Alias `--data`.
#[arg(long, visible_alias = "data")]
purge: bool,
},
/// Clone/fetch every git member into `<root>/<name>/git/` and update each
/// member's sync state, then consume what is behind into the code room.
Fetch {
name: String,
/// The explicit refresh: also run again the feeds (git heat, sbom, code
/// index, embeddings) that did not run green for a member's commit. Maps
/// to the `force` field of the Fetch RPC.
#[arg(long)]
force: bool,
},
/// **Build the whole workspace's code room NOW and wait for it** (builder 1,
/// Runa `BuildWorkspace`): every member behind is read, the members whose
/// next commit is their head in ONE super build (all repos compiled at once
/// over one target dir, each shared unit once). Inline it runs here; over
/// `--server` it asks the server to read now and waits until nothing is
/// queued or in flight. Fails when a member could not be read.
Build {
name: String,
/// Only this member (inline only).
#[arg(long)]
member: Option<String>,
/// Only what a change reaches (Runa `only: Affected`): the members
/// behind are read, the members that depend on them (Mímir's
/// affected_by_change) compiled to see that they still build, the
/// rest not touched. Fails when a dependent does not build (inline).
#[arg(long)]
affected: bool,
},
/// **Populate now**: clone the members, then consume them into the workspace's
/// code room (Mímir v2) — every commit behind a member's offset becomes one
/// atomic release. A member that was never consumed starts at its TIP.
///
/// Over `--server` this returns right after the clone and the server's
/// consumer loop follows in the background; without a server it runs inline
/// (this process then holds the lake: stop the server first).
Populate {
name: String,
/// Backfill: where a member's history starts — a commit (≥ 7 hex digits)
/// or a date (`YYYY-MM-DD`: the first commit at or after it). For a member
/// that was already consumed the commits from there are REPLAYED (its
/// ordinals keep counting; nothing is deleted). Over `--server` this
/// QUEUES the walk on the server and returns: one commit per turn, in the
/// background (`workspace repopulate` says how far it is). Without a
/// server it runs inline.
#[arg(long)]
from: Option<String>,
/// Only this member.
#[arg(long)]
member: Option<String>,
/// With `--from` over a server: first FORGET the members' dumps that
/// carry no MIR facts, so the walk dumps them again with them —
/// `--from head --mir` re-dumps each member's current commit only.
#[arg(long)]
mir: bool,
},
/// **Make the code room current, now** — report which state is served, which
/// members are behind their offset (and start consuming them), what is in
/// flight, and why nothing runs when nothing can (the driver is not installed).
///
/// Goes through the server's OPEN handles (`Workspaces.Repopulate`); without a
/// server it runs the consume inline, like `populate`.
Repopulate {
name: String,
/// Also drop the orphaned v1 tables (the syn / rust-analyzer era's
/// `symbol_facts`, `call_edges`, `lens_*` …) from the warehouse. Once: a
/// second purge drops nothing. The code room's lake is never purged.
#[arg(long)]
purge: bool,
},
/// **Embed now** — run ONLY the semantic-vector embed step over the workspace's
/// CURRENT member checkouts, and nothing else: no fetch, no knowledge scan, no
/// resolved (SCIP) ingest, no deep-scan, no republish.
///
/// This is the escape hatch for a registered workspace whose `embeddings` table
/// is EMPTY (measured 2026-08-14 on `nordisk`: 0 parquet files for `embeddings`
/// against 74 for `symbol_facts`). The embed only ever ran inside a full
/// populate/republish pass, and on an UNCHANGED tree the monitor short-circuits
/// before reaching it — so the vectors could not be filled at all short of a
/// purge + re-register, which consumes the whole workspace again
/// first, for nothing.
///
/// IDEMPOTENT and RESUMABLE: content already stored for the active model
/// profile is never re-embedded, and each member's manifest + snapshot commits
/// on its own boundary, so a run killed mid-flight keeps every member it
/// finished and the next run continues from there.
///
/// ⚠ EMBEDDED/FAT PATH ONLY — there is no `Workspaces.Embed` RPC, so this does
/// NOT work against `--server`/`NORNIR_SERVER`. Run it on the server host as
/// the server user. The warehouse catalog is single-writer, so `nornir-server`
/// must be stopped for the duration.
///
/// Two kinds of vectors are written. FUNCTIONS first: one vector per
/// function node of every consumed member (what `mimir analyze duplicates`
/// / `semantic-clones` read), cut by the code room's spans — a member whose
/// checkout is not at the commit the room holds is named and left alone.
/// Then the LINE WINDOWS free-text search reads, as before.
Embed {
name: String,
/// Also embed the DEPENDENCY RING's functions (every crate@version the
/// driver dumped that has no vectors yet). On a server this runs by
/// itself as a background job when the lockfile moves; this flag runs
/// it here and now. A ring is 10^5–10^6 function bodies: a GPU job. On
/// a CPU box the embed budget refuses it with the measured ETA
/// (`NORNIR_EMBED_BUDGET=off` accepts the cost).
#[arg(long)]
ring: bool,
},
/// **Populate status** (PLAN #6): print the recent clone/fetch/republish
/// outcomes for a workspace — one line per member attempt (ok / failed + the
/// error chain + when), so you can see *why* a member didn't populate (auth,
/// vanished repo, network) instead of only missing data. Reads the `clone_events`
/// warehouse table; `--server` reads it over `Viz.CloneEvents`. `--json` for
/// machine output.
Events {
name: String,
/// Emit the raw event rows as JSON instead of the human view.
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum KeyOp {
/// Print the public key — paste it as a deploy key on each monitored repo.
Show,
/// Print the path to the public key.
Path,
/// Create the ed25519 keypair if it doesn't exist yet (idempotent).
Generate,
}
#[derive(Args)]
struct ServeArgs {
/// `nornir.toml` to serve (else the server uses NORNIR_CONFIG / cwd discovery).
#[arg(long)]
config: Option<PathBuf>,
/// Bind address (default `127.0.0.1:7878`).
#[arg(long)]
addr: Option<String>,
/// `nornir-server` binary (default: next to this executable).
#[arg(long)]
server_bin: Option<PathBuf>,
}
#[derive(Args)]
struct VizArgs {
// Workspace + thin-client target come from the global `--server`/`--token`/
// `--workspace` flags (placed in the env by `main`); the execed `nornir-ui`
// inherits that env.
/// Local warehouse dir (embedded mode; ignored when `--server`/`NORNIR_SERVER` is set).
#[arg(long)]
warehouse: Option<PathBuf>,
/// `nornir-ui` binary (default: next to this executable / on PATH).
#[arg(long)]
viz_bin: Option<PathBuf>,
/// **LAW4b** — take a self-screenshot once the viz starts and write it to
/// this path as a PNG. The viz also appends the screenshot to the warehouse
/// `robot_test_results` blob store so it is durable and queryable.
/// CLI parity with the Ctrl+Shift+S keyboard shortcut and `VizCommand::Screenshot`.
#[arg(long)]
screenshot: Option<String>,
/// 🎬 **Demo mode** — launch a hands-free guided tour that ROBOT-DRIVES the
/// viz through every tab (Arch/Metro, Knowledge, Funnel, Test, Warehouse Deck,
/// Release, Security) via the existing robot-UI control channel, with pauses
/// so the motion (graph pan/zoom, 3D spin, palette glow) plays. Loops until
/// closed. `$NORNIR_VIZ_DEMO_BEAT_MS` tunes the pause between beats.
#[arg(long)]
demo: bool,
}
/// `nornir airgap …` — the Skíðblaðnir pipeline verbs (EPIC AIRGAP / AIRGAP9).
/// CLI parity with the `skidbladnir` crate API + the MCP tools.
#[derive(Subcommand)]
enum AirgapOp {
/// AIRGAP1/2 — pack a staging tree into a signed `tar.zst` bundle + a sidecar
/// manifest (per-entry SHA-256), and a `VERSION` file beside it.
Pack(AirgapPackArgs),
/// AIRGAP3 — verify a received bundle against its manifest (zero network).
Fetch(AirgapBundleArgs),
/// AIRGAP7 — show the unfold backend's provisioning plan for a target
/// (kvm qemu argv | terraform HCL | redfish reset body).
Unfold(AirgapUnfoldArgs),
/// AIRGAP6 — install one component from a bundle (root-only on Linux).
Install(AirgapInstallArgs),
/// Install/manage a lake component (holger | tunnr | nornir-server) BY NAME
/// from the built-in registry — the adoption layer over the airgap installer
/// (add | upgrade | remove | status). Renders `<name>.service`.
Component {
#[command(subcommand)]
op: ComponentOp,
},
/// AIRGAP8 — dependency-ordered start waves for a lake spec; emits
/// `airgap_events` to the warehouse.
Start(AirgapStartArgs),
/// AIRGAP-TEARDOWN — tear a lake down in **reverse** dependency order
/// (dependents removed before their deps). Removes each component's systemd
/// unit (keeps data). Idempotent.
Destroy(AirgapStartArgs),
/// Read back the `airgap_events` DAG (from the warehouse JSONL sink).
Events(AirgapEventsArgs),
}
#[derive(Args)]
struct AirgapPackArgs {
/// Staging tree to pack (artifacts laid out as they appear in the bundle).
staging: PathBuf,
/// Output bundle path (`*.tar.zst`).
#[arg(long)]
out: PathBuf,
/// Airgap generation stamp (default: today, YYYYMMDD).
#[arg(long)]
version: Option<String>,
}
#[derive(Args)]
struct AirgapBundleArgs {
/// The received bundle (its sidecar `<bundle>.manifest.json` must be present).
bundle: PathBuf,
/// Optional out-of-band whole-file SHA-256 to also confirm.
#[arg(long)]
sha256: Option<String>,
/// Emit the uniform `CommandOutcome` as JSON (machine) instead of the verdict.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct AirgapUnfoldArgs {
/// Backend: `kvm` | `terraform` | `redfish`.
backend: String,
/// Target name / workspace / BMC url (backend-specific).
target: String,
/// KVM / libvirt: disk image to boot.
#[arg(long)]
disk: Option<String>,
/// libvirt only: libvirtd `host:port` (TCP, default port 16509).
#[arg(long)]
host: Option<String>,
/// Actually drive the backend (libvirt: define+start over the pure-Rust RPC),
/// not just print the plan.
#[arg(long)]
run: bool,
/// Emit the uniform `CommandOutcome` as JSON (machine) instead of the plan.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct AirgapInstallArgs {
/// The verified bundle to install from.
bundle: PathBuf,
/// Component manifest (`manifests/<name>.json`) path INSIDE the bundle, or a
/// local `ComponentSpec` JSON file.
#[arg(long)]
spec: PathBuf,
/// Software root (version-dated dirs created under here).
#[arg(long, default_value = "/opt/lake")]
software_dir: PathBuf,
/// Fast-moving data root.
#[arg(long, default_value = "/var/lib")]
data_dir: PathBuf,
/// Log root (per-product log dirs created under here).
#[arg(long, default_value = "/var/log")]
log_dir: PathBuf,
}
#[derive(Args)]
struct AirgapStartArgs {
/// Lake spec JSON (`LakeSpec`: version + products + pki).
spec: PathBuf,
/// Override the warehouse root the `airgap_events.jsonl` sink writes under
/// (default: the resolved workspace warehouse root).
#[arg(long)]
warehouse: Option<PathBuf>,
}
#[derive(Args)]
struct AirgapEventsArgs {
/// Warehouse root holding `airgap_events.jsonl` (default: resolved root).
#[arg(long)]
warehouse: Option<PathBuf>,
/// Emit the uniform `CommandOutcome` as JSON (machine) instead of the table.
#[arg(long)]
json: bool,
}
/// `nornir install …` — manage nornir's own server as a systemd service on THIS
/// host. The everyday lifecycle is three verbs: `add` (install), `update` (swap
/// in a freshly-built binary + restart), `remove` (tear down).
///
/// Two **scopes** (auto-picked by privilege, overridable with `--scope`):
/// - **user** (default when NOT root): a `systemctl --user` service that runs as
/// YOU, execs your own `~/.cargo/bin/nornir-server` in place, and keeps its
/// state in your own `~/.nornir`. No root, no system user, no `/etc`.
/// - **system** (default when root): a system service running as the dedicated
/// `nornir` user, binaries copied to `/usr/local/bin`, state under
/// `/home/nornir/.nornir`. This is the "always-on box" install.
///
/// Either way the bearer token lives in a 0600 `<home>/.nornir/token` (read by
/// the server AND the CLI client — no `/etc/nornir`, no env file); the bind addr
/// and monitor settings are inline `Environment=` lines in the unit.
///
/// To install OTHER lake components (holger, tunnr) from airgap bundles, see
/// `nornir airgap component …` — that is the generic, registry-driven installer;
/// this is the bespoke fast path for nornir-server, and both render the SAME
/// `nornir-server.service` unit.
#[derive(Subcommand)]
enum InstallOp {
/// Install nornir-server as a systemd service. Default scope follows privilege
/// (`--scope user` when non-root → runs as you from `~/.cargo/bin` with state
/// in `~/.nornir`; `--scope system` when root → creates the `nornir` user,
/// copies binaries to `/usr/local/bin`, state under `/home/nornir`). Writes
/// the SSH identity + the `<home>/.nornir/token`. Idempotent (safe to re-run);
/// to only refresh the binary after a new release, prefer `update`.
Add(SystemdArgs),
/// Swap in freshly-built binaries and restart the service — PRESERVING the
/// unit, token, and SSH key. System scope copies the `nornir`/`nornir-server`
/// binaries you ran into `/usr/local/bin`; user scope just restarts (your
/// `cargo install` already replaced `~/.cargo/bin`). The "new nornir crate
/// shipped → update the box" path. NEVER fetches from a registry.
Update(UpdateArgs),
/// UNINSTALL: stop + remove the systemd service AND erase the data home —
/// warehouses, registry, caches, funnel, jobs — while ALWAYS keeping the `ssh/`
/// deploy key + the `token`. No flag needed and no flag can turn the data erase
/// off: THE SUITE UNINSTALL LAW is that an uninstall always kills the data and
/// always keeps the keys. Destroying the identity is the one explicit opt-in,
/// `--wipe-identity`.
Remove(UninstallArgs),
}
/// Service scope for `nornir install`. Defaults to `User` when not root and
/// `System` when root.
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum Scope {
/// `systemctl --user` unit that runs as the invoking user, from that user's
/// own binaries, with state in that user's `~/.nornir`. No root.
User,
/// System unit (root) running as the dedicated `nornir` system user, binaries
/// in `/usr/local/bin`, state under `/home/nornir`.
System,
}
/// `nornir airgap component …` — the generic, registry-driven component
/// installer (adopting `skidbladnir`). Drives
/// `skidbladnir::product::{Installer, SystemdInstaller, ComponentSpec}` against
/// the built-in registry (holger, tunnr, nornir-server). ONE install system with
/// the bespoke `nornir install` above — both render `<name>.service`.
#[derive(Subcommand)]
enum ComponentOp {
/// Install a lake component from an airgap bundle by name (holger | tunnr |
/// nornir-server). Resolves the canonical `ComponentSpec` from the registry,
/// renders the systemd unit, unpacks the binary, opens ports. Root-only on
/// Linux. With `--dry-run`, prints the unit it WOULD install (no privilege).
Add(InstallComponentArgs),
/// Re-install (upgrade) a component from a (newer) bundle — same atomic code
/// path as `add` (layout version swap). `--delta` requests a binary-delta
/// apply via the znippy seam; until that engine is wired it falls back to a
/// full re-install with a clear note.
Upgrade(InstallUpgradeArgs),
/// Stop + remove a component's systemd unit by name (idempotent). Root-only.
Remove(InstallComponentRefArgs),
/// Show whether a component's unit is active (or list the whole registry).
Status(InstallStatusArgs),
}
/// Shared: identify a registered component + the airgap layout roots.
#[derive(Args)]
struct InstallComponentArgs {
/// Component name (holger | tunnr | nornir-server). See `airgap component status`.
component: String,
/// The verified airgap bundle to install the component binary from.
#[arg(long)]
bundle: PathBuf,
/// Software root (version-dated dirs created under here).
#[arg(long, default_value = "/opt/lake")]
software_dir: PathBuf,
/// Fast-moving data root.
#[arg(long, default_value = "/var/lib")]
data_dir: PathBuf,
/// Log root (per-product log dirs created under here).
#[arg(long, default_value = "/var/log")]
log_dir: PathBuf,
/// Render the systemd unit the component WOULD install, then stop — no root,
/// no side effects (the "render unit → would-install" demo).
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct InstallUpgradeArgs {
#[command(flatten)]
inner: InstallComponentArgs,
/// Request a binary-delta upgrade via the znippy zero-copy patch seam
/// (INSTALL-5). Falls back to a full re-install when the delta engine is not
/// built in (honest fallback).
#[arg(long)]
delta: bool,
/// The previously-installed bundle to diff against (the delta base). With
/// `--delta`, the new bundle in `--bundle` is staged from this base + only the
/// changed blobs — the airgap-kubernetes speed story.
#[arg(long)]
from: Option<PathBuf>,
}
#[derive(Args)]
struct InstallComponentRefArgs {
/// Component name (holger | tunnr | nornir-server).
component: String,
}
#[derive(Args)]
struct InstallStatusArgs {
/// Component name; omit to list the whole registry with active state.
component: Option<String>,
}
#[derive(Args)]
struct UninstallArgs {
/// DEPRECATED — no effect, erasing the data IS the uninstall. `install remove`
/// always wipes the data home (warehouses, registry, caches, funnel, jobs) and
/// always keeps the `ssh/` deploy key + the `token`. Still accepted so existing
/// scripts and pipelines keep working.
#[arg(long)]
purge: bool,
/// THE ONE OPT-IN: also destroy the install identity — delete the `ssh/` deploy
/// key and the server `token`. THE DEPLOY KEY REGISTERED ON THE GIT REMOTES IS
/// DESTROYED: a reinstall mints a brand-new key that you must register again on
/// every repo (Codeberg/GitHub deploy keys) before the server can clone or push
/// anything. Never implied, never a side effect.
#[arg(long)]
wipe_identity: bool,
/// DEPRECATED — no effect; keeping the `ssh/` key + `token` is unconditional
/// (see `--wipe-identity` to opt out of it). Still accepted so existing scripts
/// and pipelines keep working.
#[arg(long)]
keep_identity: bool,
/// Service user (system scope only; default `nornir`).
#[arg(long, default_value = "nornir")]
user: String,
/// Scope to tear down (`user` | `system`). Defaults to `system` when root,
/// `user` otherwise — i.e. the scope this same invocation would have installed.
#[arg(long, value_enum)]
scope: Option<Scope>,
}
/// The service account's home directory, read from a passwd-format file — the
/// SYSTEM-scope half of the scope-aware state-root rule.
///
/// INCIDENT 2026-07-27: dwarves and korp erased a hardcoded `/home/<app>`
/// regardless of scope. On a per-user install that path does not exist, so the
/// uninstall wiped NOTHING, left the real multi-GB state root intact — and printed
/// a success line. A resolver and an eraser that disagree about where state lives
/// means one of them is lying. So the eraser resolves, it never assumes.
///
/// `None` when the account has no passwd row (never installed / already removed).
#[cfg(unix)]
fn service_home_in(user: &str, passwd_path: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(passwd_path).ok()?;
text.lines().find_map(|line| {
let mut f = line.split(':');
(f.next()? == user).then_some(())?;
// name:passwd:uid:gid:gecos:home:shell — `next` took `name`, so the home
// field is 4 further along.
let home = f.nth(4)?;
(!home.is_empty()).then(|| PathBuf::from(home))
})
}
/// [`service_home_in`] against the real `/etc/passwd`, falling back to the
/// conventional [`NORNIR_HOME`] only when the account has no row at all.
#[cfg(unix)]
fn service_home(user: &str) -> PathBuf {
service_home_in(user, Path::new("/etc/passwd")).unwrap_or_else(|| PathBuf::from(NORNIR_HOME))
}
/// The state root an uninstall erases, RESOLVED BY SCOPE — never a hardcoded
/// absolute constant (see [`service_home_in`] for the incident that rule comes
/// from). User scope is the invoking user's own `$HOME/.nornir`; system scope is
/// the service account's `<passwd home>/.nornir`. Same rule
/// (`config::nornir_home_from`) the SERVER itself uses to decide where to write,
/// so the eraser and the resolver can never disagree.
#[cfg(unix)]
fn uninstall_state_root(scope: Scope, user: &str, invoking_home: &Path) -> PathBuf {
match scope {
Scope::User => nornir::config::nornir_home_from(invoking_home),
Scope::System => nornir::config::nornir_home_from(&service_home(user)),
}
}
/// The install-identity entries an uninstall NEVER destroys — the `ssh/`
/// deploy key registered on the git remotes and the server bearer `token`. `.ssh`
/// is in the list too because the system-scope key lives at `<service home>/.ssh`
/// (see [`nornir_ssh_dir`]), which is the very name skidbladnir's
/// `purge_service_data_keep_keys` spares.
///
/// THE SUITE UNINSTALL LAW (`workspace_nordisk/.nornir/suite-uninstall-law.md`):
/// uninstalling ALWAYS kills the data and ALWAYS keeps the ssh keys — not behind a
/// flag, not opt-in. Deleting the identity is the one explicit opt-in
/// (`--wipe-identity`), never a side effect.
#[cfg(unix)]
const IDENTITY_KEEP: &[&str] = &["ssh", ".ssh", "token"];
/// Wipe every entry directly under `root` EXCEPT the names in `keep`, returning
/// `(wiped, kept)`.
///
/// Each individual delete goes through skidbladnir's post-incident guarded
/// primitive `skidbladnir::systemd::guarded_remove_dir_all` — the SAME authority
/// behind `skidbladnir::systemd::purge_service_data_keep_keys`, which refuses any
/// path with a `.ssh` component, a real user's home, a system directory, or a
/// symlink pointing out of the tree, and pins the delete to `root`. The delete
/// decision lives in skidbladnir once (L5); nornir only supplies the keep-list,
/// because nornir's identity sits at `<data root>/ssh` + `<data root>/token`
/// rather than the `<service home>/.ssh` that skidbladnir's own keep-list covers.
///
/// A missing `root` is a no-op that reports `(0, 0)` — the caller must NOT print a
/// success line for a purge that removed nothing.
#[cfg(unix)]
fn purge_except(root: &Path, keep: &[&str], owner_uid: u32) -> (usize, usize) {
let (mut wiped, mut kept) = (0usize, 0usize);
let Ok(entries) = std::fs::read_dir(root) else {
return (0, 0);
};
for entry in entries.flatten() {
let name = entry.file_name();
if keep.iter().any(|k| name == std::ffi::OsStr::new(k)) {
kept += 1; // KEEP THE KEY — never touch the registered identity
continue;
}
let path = entry.path();
match skidbladnir::systemd::guarded_remove_dir_all(&path, owner_uid, Some(root)) {
Ok(()) => wiped += 1,
Err(e) => eprintln!("nornir: uninstall left {} in place: {e:#}", path.display()),
}
}
(wiped, kept)
}
/// The identity DECISION for `install remove`, in one place: which names survive
/// the erase. `--wipe-identity` (and only that flag) returns an empty keep-list;
/// `--purge`/`--keep-identity` are inert because erasing the data while keeping
/// the key is the unconditional behaviour.
///
/// Reverting the law here is what turns
/// `purge_law_tests::uninstall_with_no_flags_kills_data_and_keeps_the_key` RED.
#[cfg(unix)]
fn purge_keep_list(a: &UninstallArgs) -> &'static [&'static str] {
if a.wipe_identity { &[] } else { IDENTITY_KEEP }
}
/// The skidbladnir guard REFUSES any path with a `.ssh` component outright — key
/// material is never data. `--wipe-identity` is the operator explicitly overriding
/// exactly that, so the `.ssh` child of the tree being purged is removed HERE,
/// outside the guard, and nowhere else in nornir. Returns how many entries went.
#[cfg(unix)]
fn force_remove_key_material(root: &Path) -> usize {
let dot_ssh = root.join(".ssh");
usize::from(dot_ssh.exists() && std::fs::remove_dir_all(&dot_ssh).is_ok())
}
/// Purge a data home per the law: everything under `data` goes, the identity stays
/// unless `--wipe-identity` was passed. Returns `(wiped, kept)` so the caller can
/// print HONESTLY (and stay quiet when nothing was there). With `--wipe-identity`
/// the key material and the now-empty root are removed too.
#[cfg(unix)]
fn purge_data_home(data: &Path, a: &UninstallArgs, owner_uid: u32) -> (usize, usize) {
let (mut wiped, kept) = purge_except(data, purge_keep_list(a), owner_uid);
if a.wipe_identity {
wiped += force_remove_key_material(data);
// Nothing was spared, so the root itself is now an empty shell — drop it.
let _ = std::fs::remove_dir(data);
}
(wiped, kept)
}
#[derive(Args)]
struct UpdateArgs {
/// `nornir-server` binary to install (default: next to this executable —
/// e.g. a fresh `cargo build`/`cargo install` tree).
#[arg(long)]
server_bin: Option<PathBuf>,
/// Scope to update (`user` | `system`). Defaults to `system` when root,
/// `user` otherwise.
#[arg(long, value_enum)]
scope: Option<Scope>,
}
#[derive(Args)]
struct SystemdArgs {
/// OPTIONAL release-recipe `nornir.toml` for a *served* workspace
/// (gates/bench/publish_order). Omit for a plain empty server — workspaces
/// are added at runtime via `nornir workspace add` / the viz UI.
#[arg(long)]
config: Option<PathBuf>,
/// Service scope. `user` = a `systemctl --user` unit running as you (no root);
/// `system` = a root unit running as the `nornir` user. Default: `user` when
/// not root, `system` when root.
#[arg(long, value_enum)]
scope: Option<Scope>,
/// Bind address baked into the unit as `Environment=NORNIR_SERVER_ADDR`
/// (default `127.0.0.1:7878`). If the port is already taken the install fails
/// — pick another with `--addr` (no silent port-bumping).
#[arg(long, default_value = "127.0.0.1:7878")]
addr: String,
/// System user to create + run as (system scope only; default `nornir`).
#[arg(long, default_value = "nornir")]
user: String,
/// `nornir-server` binary path (default: next to this executable).
#[arg(long)]
server_bin: Option<PathBuf>,
/// DEPRECATED no-op: the registry root is home-derived
/// (`<home>/.nornir/workspaces`); set the service user's `$HOME` to relocate.
#[arg(long)]
root: Option<PathBuf>,
}
#[derive(Subcommand)]
enum FunnelOp {
/// Submit a krav / prompt / idea into the intake funnel. Prints the new
/// idea id. The text comes from the positional arg, `--file <path>`, or
/// stdin (pipe) — so long requirements ingest naturally:
/// `nornir funnel submit "<krav>"` · `--file krav.md` · `cat p | nornir funnel submit`
Submit {
/// Inline idea text. Omit when using `--file` or piping via stdin.
text: Option<String>,
/// Read the idea text from a file (e.g. a krav/prompt markdown).
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
source: Option<String>,
},
/// FI1 — paste an **idea** (feature/krav/prompt) into the funnel. Alias of
/// `submit` with `item_kind=idea`. Text from the arg, `--file`, or stdin.
/// `nornir funnel idea "<idea>"` · `--file krav.md` · `cat p | nornir funnel idea`
Idea {
/// Inline idea text. Omit when using `--file` or piping via stdin.
text: Option<String>,
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
source: Option<String>,
},
/// FI1 — paste an **error report** into the funnel (a first-class funnel
/// item, kind `error`, triageable to a plan like any idea). Text from the
/// arg, `--file`, or stdin (the natural way to ingest a captured report):
/// `nornir funnel error - < report.txt` · `nornir funnel error "<msg>"`
Error {
/// Inline error text, or `-` to read the report from stdin. Omit when
/// using `--file` or piping (stdin is the default with no arg).
text: Option<String>,
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
source: Option<String>,
},
/// FI-extension — paste a **test-idea** (a desired/missing test) into the
/// funnel (a first-class funnel item, kind `test`, planned into `test:write`
/// node(s) on the affected component(s) like any idea). Text from the arg,
/// `--file`, or stdin:
/// `nornir funnel test "cover the retry path in util"` · `--file t.md`
Test {
/// Inline test-idea text, or `-` to read from stdin. Omit when using
/// `--file` or piping.
text: Option<String>,
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
source: Option<String>,
},
/// **Plan without big AI** — auto-derive a plan for IDEA_ID from the
/// component dependency graph. Classifies the item text → component(s) (local
/// LLM → embedder → pick-list), computes the topological affected set (blast
/// radius in build order), and creates a plan with one node per affected
/// component. Prints the proposal + affected set + new plan id.
/// `nornir funnel plan-from i-003` · `--component util` (skip classify)
#[command(name = "plan-from")]
PlanFrom {
/// The funnel item id (idea / error / test) to plan.
idea_id: String,
/// Force the touched component(s), skipping classification (repeatable).
/// Use this to act on the pick-list when no LLM/embedder is confident.
#[arg(long = "component")]
components: Vec<String>,
/// Only classify + print the proposal & affected set; do NOT create the
/// plan (a dry-run preview).
#[arg(long)]
dry_run: bool,
},
/// FI2 — browse submitted errors/ideas newest-first (the intake history),
/// optionally filtered by kind/status. JSON unless `--table`.
/// `nornir funnel history` · `--kind error` · `--status untriaged --table`
History {
/// Filter by kind: `idea` | `error` | `test` (default: all).
#[arg(long)]
kind: Option<String>,
/// Filter by derived status: untriaged | accepted | dropped | planned.
#[arg(long)]
status: Option<String>,
/// Max items (newest-first). 0 = all.
#[arg(long, default_value_t = 0)]
limit: usize,
/// Render a compact human table instead of JSON.
#[arg(long)]
table: bool,
},
/// Create a plan refining IDEA_ID (auto-activated). Prints the plan id.
Plan { idea_id: String, summary: String },
/// Add a node to PLAN_ID. KIND is a free verb (e.g. `code:write`).
Node {
plan_id: String,
kind: String,
#[arg(long)]
title: Option<String>,
/// Target artifact (repeatable).
#[arg(long = "target")]
targets: Vec<String>,
/// Node id in the same plan this node depends on (repeatable).
#[arg(long = "needs")]
needs: Vec<String>,
#[arg(long)]
prompt: Option<String>,
},
/// Add a dependency edge: TO becomes ready only after FROM is done.
Link {
plan_id: String,
from: String,
to: String,
},
/// Print the ready PlanNodes across active plans (JSON), topo-ordered.
Next {
/// Emit the machine-readable `CommandOutcome` JSON (the autopilot shape)
/// instead of the human listing. `next` is JSON-native, so this is on by
/// default; pass `--no-json`-style by omitting it for the human render.
#[arg(long)]
json: bool,
},
/// Flip a node's status: ready | active | blocked | done | failed.
Status {
plan_id: String,
node_id: String,
status: String,
#[arg(long)]
why: Option<String>,
},
/// Dump the whole funnel (ideas, plans, nodes, edges).
Show {
/// Emit the machine-readable `CommandOutcome` JSON (the autopilot shape)
/// instead of the human tree.
#[arg(long)]
json: bool,
},
/// **Decompose (EPIC #45):** break a task into ≤N atomic, testable subtasks
/// (RAG-anchored to real files/fns via the code index), then run each through
/// a local-LLM generate → CodegenJudge oracle → retry loop and print the
/// subtask DAG + per-subtask verdicts. 100% local — `--gen mock` (default)
/// needs no model and no network; the compiler is the accept/reject oracle.
///
/// `nornir funnel decompose "make add() correct" --fixture`
/// `nornir funnel decompose i-003 --gen candle:qwen2-0.5b --store`
Decompose(DecomposeArgs),
/// **funnel-prompt-planner keystone:** prompt-as-mother. Persists TEXT as
/// the root `Idea` (the "mother" node), grounds + decomposes it into an
/// anchored, dep-linked child task DAG (100% offline/model-free — the
/// codegen generate→judge loop is orthogonal and never runs here), and
/// routes the children per mode: `--auto` commits straight into `Planned`;
/// `--manual` (default) stages them in `Proposed` for `funnel accept`.
/// `--assign auto` allocates by role/capacity from `.nornir/team.toml`.
///
/// `nornir funnel prompt "add retry to the http client" --auto`
/// `nornir funnel prompt "fix the flaky test" --manual --assign auto`
Prompt(PromptArgs),
/// **Executor dry-run** — print the topological waves the executor would run
/// for a plan-DAG (the ready-frontier order), without executing. Live drain
/// (drive each node through the generate→judge oracle) lands with a
/// Claude-backed generator — a role separate from the planner.
/// `nornir funnel run p-001`
Run(RunArgs),
/// (Re)assign a card — a mother idea (`i-NNN`) or a decomposed task node
/// (`n-NNN`) — to a real team member (spec §3).
/// `nornir funnel assign n-004 ada --role coder`
Assign {
/// The card id (`i-NNN` or `n-NNN`).
node: String,
member: String,
#[arg(long)]
role: Option<String>,
#[arg(long)]
why: Option<String>,
},
/// Promote a `Proposed` (Manual-mode draft) card to `Planned`, or with
/// `--reject`, drop it to `Dropped` — the Manual-mode review gate (spec
/// §2). Errors if the card isn't currently `Proposed`.
/// `nornir funnel accept n-004` · `nornir funnel accept n-005 --reject`
Accept {
/// The card id (`i-NNN` or `n-NNN`), normally a task node.
node: String,
#[arg(long)]
reject: bool,
#[arg(long)]
why: Option<String>,
},
/// YouTrack-style smart query over every card (ideas/errors/tests + every
/// decomposed task node) — `type:`/`lane:`/`is:open`/`reporter:`/
/// `assignee:`/`for:me`/`label:`/`has:plan`/`sort:`, free text otherwise.
/// `nornir funnel cards "assignee:ada lane:planned"` · `--json`
Cards {
/// The smart-query string (empty = everything).
query: Option<String>,
#[arg(long)]
json: bool,
},
/// The kanban board: every card grouped into its 5 fixed lanes
/// (Inbox/Accepted/Proposed/Planned/Dropped).
Board {
#[arg(long)]
json: bool,
},
/// **Demo:** materialise `--size N` fake git repos near the git root (each a
/// `Cargo.toml` + `src/lib.rs` wired into a layered path-dep graph) AND
/// inject the matching funnel DAG into the warehouse. Idempotent — a rerun
/// detects the existing `funnel:demo` idea and skips the warehouse half.
/// A manifest `git/.nornir-funnel-demo.json` records exactly what was made
/// so `nuke` deletes only demo paths.
Demo {
/// Number of fake repos to create.
#[arg(long, default_value_t = 1)]
size: usize,
},
/// **Nuke (disk-only):** delete the fake repos the demo created on disk,
/// reading the manifest so only demo paths are removed (never a heuristic
/// `rm`). The warehouse funnel data is PRESERVED — the plan still shows in
/// `funnel show` / the viz Funnel tab. Refuses without `--yes`.
Nuke {
/// Confirm the disk deletion (required). Without it, prints what it
/// would delete and exits.
#[arg(long)]
yes: bool,
},
}
#[derive(Args)]
struct DecomposeArgs {
/// The task: either a funnel item id (`i-003`/`p-…` — its text is used) or a
/// raw task string. With `--store`, an id is required (the subtasks hang
/// under it); a raw string is fine for a stateless `--fixture` dry-run.
task: String,
/// Run the OFFLINE simulation against a self-contained tiny fixture crate
/// (created in a temp dir, no repo touched): the code index IS the fixture,
/// the judge builds+tests the fixture. The fully model-free, network-free
/// demo. Mutually exclusive with `--repo`.
#[arg(long)]
fixture: bool,
/// Judge against this on-disk crate dir (RAG over its scanned symbols, the
/// CodegenJudge builds+tests a scratch copy). Defaults to the fixture when
/// neither this nor `--fixture` is given on a dry run.
#[arg(long)]
repo: Option<String>,
/// Generator spec (`mock` | `candle:<m>` | `mistralrs:<m>` | `onnx:<m>` |
/// `ollama:<m>`). Default `mock` — offline, no model. Falls back to
/// `$NORNIR_GEN_BACKEND`.
#[arg(long)]
r#gen: Option<String>,
/// Max atomic subtasks (hard cap 20).
#[arg(long, default_value_t = 8)]
max_subtasks: usize,
/// Bounded generate→judge retry rounds per subtask.
#[arg(long, default_value_t = 3)]
rounds: usize,
/// Top-k RAG anchors retrieved for the task.
#[arg(long, default_value_t = 5)]
top_k: usize,
/// Persist the subtasks as child funnel DAG nodes under the task item +
/// write the per-subtask verdicts back (requires `task` to be an item id).
#[arg(long)]
store: bool,
/// Emit the whole report as JSON (`state_json`) instead of the DAG view.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct PromptArgs {
/// The free-text idea/prompt. Persisted VERBATIM as the mother `Idea`'s
/// body (its first line becomes the card title).
text: String,
/// Auto (Populate): child tasks land straight in `Planned`. Mutually
/// exclusive with `--manual`.
#[arg(long, conflicts_with = "manual")]
auto: bool,
/// Manual (Suggest): child tasks land in `Proposed` for `funnel accept`
/// to promote/reject. DEFAULT when neither `--auto` nor `--manual` is
/// given (front-loaded human review — see spec §2 "Safety").
#[arg(long, conflicts_with = "auto")]
manual: bool,
/// `auto` (allocate by role/capacity from `.nornir/team.toml`) |
/// `<member>` (blanket-assign) | `none` (default — unassigned/pull model).
#[arg(long, default_value = "none")]
assign: String,
/// Repo name to ground the decomposition in — matches a prior `nornir
/// knowledge scan <repo>`. Best-effort: with no prior scan (or no
/// warehouse yet) the decomposition still runs, via `decompose`'s
/// whole-task fallback subtask. Defaults to the workspace name.
#[arg(long)]
repo: Option<String>,
#[arg(long)]
source: Option<String>,
/// Max atomic child subtasks (hard cap 20).
#[arg(long, default_value_t = 8)]
max_subtasks: usize,
/// Top-k RAG anchors retrieved for the prompt.
#[arg(long, default_value_t = 5)]
top_k: usize,
/// Emit the sub-DAG as `state_json` (LAW 6) instead of the human view.
#[arg(long)]
json: bool,
/// Decompose the prompt with an LLM (Claude) instead of the model-free
/// RAG/code-index leg. Needs `ANTHROPIC_API_KEY` (+ the `claude` build
/// feature). On any failure it prints a notice and falls back to the
/// model-free decompose — never a silent no-op.
#[arg(long)]
llm: bool,
}
#[derive(Args)]
struct RunArgs {
/// The plan id to execute, e.g. `p-001`.
plan_id: String,
/// Generator spec (`claude`, `mock`, `candle:<m>`, …). LIVE-generates a
/// proposal per ready node with that backend (real model calls, proposals
/// only — no state change). Omit for a dry-run frontier walk.
#[arg(long = "gen")]
generator: Option<String>,
}
#[derive(Subcommand)]
enum KnowledgeOp {
/// Scan <repo>: walk HEAD's commits with gix (per-file heat) and persist
/// `git_heat_facts`; pass `--no-save` to only print the summary. Symbols,
/// calls and feature gates are NOT scanned here: the code room's consumer
/// writes them per commit (`nornir workspace populate`).
Scan {
repo: String,
/// Don't write to the warehouse (default: persist the repo's git heat —
/// nothing computed is discarded).
#[arg(long = "no-save")]
no_save: bool,
},
/// Query the code room's graph of <repo> — asked of the SERVER's
/// `CodeRoom.Answer` (`--server <url>` / `$NORNIR_SERVER`: the Dwarves forge
/// or a nornir-server); the CLI never opens the lake itself, and without a
/// server the query is refused. `kind` is one of:
/// `callers` (who calls ARG), `callees` (what ARG calls),
/// `defined-in` (symbols in files ending with ARG), `lookup`
/// (symbols whose name contains ARG), `path` (shortest call chain from
/// ARG to `--to`, BFS over call edges), `feature-gates` (the `#[cfg(...)]`
/// items of the latest scan, ARG filters by cfg-expression substring).
Query {
repo: String,
/// callers | callees | defined-in | lookup | path | feature-gates
kind: String,
/// function name / file suffix / name substring / path source (per `kind`)
arg: String,
/// Target function for `kind = path` (matched by last path segment).
#[arg(long)]
to: Option<String>,
#[arg(long, default_value_t = 50)]
limit: usize,
},
/// Graph-RAG **context slices** for <repo>: embed QUERY, vector-match the
/// repo's `embeddings`, JOIN the `embedding_manifest` span + the code room's nodes
/// (kind/name/visibility), and return ranked, LLM-injectable code slices —
/// no disk re-search. Optionally trim to a CALLS change-radius
/// (`--seed` symbols expanded `--hops` hops) and hydrate the exact span text
/// from `--hydrate <repo-root>`. Prints JSON. Needs an embedder feature.
Context {
repo: String,
/// Natural-language / code query — embedded with the repo's model.
query: String,
/// Point-in-time git sha (default: latest indexed snapshot).
#[arg(long)]
sha: Option<String>,
/// How many ranked slices to return.
#[arg(long, default_value_t = 8)]
limit: usize,
/// Change-radius seed symbol(s): keep only slices within `--hops` call-
/// graph hops of a seed. Repeatable. Empty = no radius trim.
#[arg(long = "seed")]
seed: Vec<String>,
/// Call-graph hop radius from the seeds (only meaningful with `--seed`).
#[arg(long, default_value_t = 1)]
hops: usize,
/// Also follow reverse edges (callers) when expanding the radius.
#[arg(long = "include-callers")]
include_callers: bool,
/// Read the exact span text back from this repo root into each slice.
#[arg(long)]
hydrate: Option<String>,
},
}
#[derive(Subcommand)]
enum GuardOp {
/// Report writable state of each forbidden path. `--json` prints the
/// CommandOutcome (machine); else a human table.
Status {
#[arg(long)]
json: bool,
},
/// chmod -w every forbidden path, then record the tamper-evidence
/// manifest (sha256 + mode) and export guard-policy.json.
Apply,
/// Verify forbidden paths against the recorded manifest (drift report).
Verify,
/// chmod +w every forbidden path (intentional human unlock). Requires
/// the NORNIR_GUARD_UNLOCK_TOKEN env var to be set — never agent-facing.
Release,
}
#[derive(Subcommand)]
enum BenchOp {
/// Show <repo>'s bench history — scalar metrics + test pass/fail per run
/// (newest first). `--json` prints the raw JSONL instead; `--limit N` caps rows.
HistoryShow(HistoryShowArgs),
/// **MOVED — use `dwarves bench run <repo>`.** Kept as a working alias.
///
/// Run <repo>'s examples/nornir-bench.rs (or xtask/examples/nornir-bench.rs),
/// parse the BenchRun it prints, and append it to the iceberg
/// `bench_runs` table.
///
/// Running a build-and-measure over a product is forge work, so the verb's
/// home is the forge; the warehouse keeps the READ (`bench history-show`).
/// Both spawn the identical measurement — one `nornir_bench::runner`, no
/// second copy — so a row written here and a row written there are the same
/// row, and this alias resolves <repo> by NAME against the workspace root
/// exactly as it always has. `dwarves bench run` additionally accepts a PATH,
/// which is what makes it usable from a worktree.
Run(BenchRunArgs),
}
/// Args for `nornir bench run <repo> [--features …]`. A dedicated struct (rather
/// than the shared `RepoArg`) so the `--features` passthrough lands ONLY on the
/// bench-run command and doesn't leak onto the ~30 other `RepoArg`-backed verbs.
#[derive(Args)]
struct BenchRunArgs {
repo: String,
/// Extra cargo features to build the bench example with (comma/space-separated,
/// e.g. `s3` to enable the RustFS/Nessie/Polaris S3 container arms). Overrides
/// the `NORNIR_BENCH_FEATURES` env var. Local-run path only for now.
#[arg(long, value_name = "FEATURES")]
features: Option<String>,
}
#[derive(Subcommand)]
enum TestOp {
/// Run <repo>'s **multi-aspect** matrix (EPIC L) and append every row to
/// `test_results` under one run id. Default aspects: build,unit,doctest,
/// clippy,fmt,audit,funnel,funnel-demo,functional,gated-tests (slow/optional
/// ones — bench-smoke,coverage,feature-powerset,msrv,examples,target-compile — only with
/// `--aspects`). Guarded against drift by
/// `tests/aspect_help_matches_the_engine.rs`. The unit aspect wraps the
/// repo's native `cargo nextest run` / `cargo test`; a missing tool
/// (clippy/audit/…) is a neutral `skip`, never a hard fail. The stall
/// watchdog (`NORNIR_TEST_STALL_SECS`, default 120) records a red `stalled`
/// row if the test subprocess goes silent.
Run(TestRunArgs),
/// Run the FULL aspect matrix across EVERY repo in nornir.toml, persist every
/// row, print a per-repo×aspect grid, and exit red if any red.
/// `--aspects build,unit,clippy,…` overrides the default set.
All(TestAllArgs),
/// List the BUILD-FREE test matrix for <repo>: every discovered `#[test]`
/// (from the code room's test nodes — no test is run for it) with its tri-state verdict
/// `ok | fail | X` (X = discovered but not run) and a `heavy` tag for
/// `#[ignore]`'d tests. This is the CLI twin of the viz 🧪 Test pane — the
/// matrix is the `test_inventory` ⟕ `test_results` join. `--json` prints the
/// raw rows; `--heavy-only` shows just the heavy tests; `--not-run` shows just
/// the X (not-run) tests.
List(TestListArgs),
/// **Which tests a commit can break** (incremental tests): the tests of
/// <repo> whose reach in the code room holds something that changed between
/// `--from` (default: the newest commit before `--to` whose Runa test run was
/// green) and `--to` (default: the newest consumed commit) — "these N of M
/// tests, because …", the skip list a runner takes (`--skip <name> --exact`),
/// the changed items, and the changed fns no test reaches. When the graph
/// cannot be sure (a manifest, the lock, a build script, a cfg, a macro, a
/// file a test may read, a commit the room has not read) it says `all` and
/// why. The CLI twin of the `Test.Affected` surface a Runa
/// `Nextest(only: Affected)` step asks.
Affected(TestAffectedArgs),
/// Show <repo>'s recorded test history — per-run green/red counts (newest
/// first) with red cases listed. `--json` prints the raw rows; `--limit N`
/// caps the number of runs shown (0 = all).
History(TestHistoryArgs),
/// autonom COMPLETENESS GATE (n-005): discover the testable surface
/// (viz tabs × {thin,fat}, CLI subcommands, MCP tools, and the unreached-fn
/// set = the code room's functions − those a test reaches over CALLS), difference it
/// against the covered set + the checked-in `.nornir/autonom-allow.toml`,
/// persist the verdict to `surface_coverage`, and FAIL (exit 1) if any
/// surface is uncovered+un-allowlisted OR an allowlist entry is stale.
/// Green ⟺ every surface is wired. `--seed-allowlist` rewrites the allowlist
/// with every currently-uncovered surface (green now, burn down by deleting).
Coverage(TestCoverageArgs),
}
#[derive(Args)]
struct TestCoverageArgs {
/// Repo whose code room drives the function
/// surface. Defaults to the workspace's primary repo (the workspace name).
#[arg(long)]
repo: Option<String>,
/// Seed `.nornir/autonom-allow.toml` with every currently-uncovered surface
/// node (green now; burn down by deleting entries as tests land). Does NOT
/// fail the gate — it WRITES the allowlist.
#[arg(long)]
seed_allowlist: bool,
/// Emit the persisted per-node coverage rows as JSON instead of the summary.
#[arg(long)]
json: bool,
/// Emit the autonom **metrics SVG** (the gate's visual twin: surface ×
/// {ran, utfallsrum, reachable} per workspace) to stdout instead of the
/// summary. Redirect to a `.svg` to embed in docs. Self-contained, no JS.
#[arg(long)]
svg: bool,
/// Emit the FIELD-level **value-gap worklist** instead of the surface gate:
/// every on-screen `state_json` value pointer some UI emits but no robot
/// `Assert::State` pins — RAGNARÖK / failure-path first. Drives the embedded
/// facett components headless (behind `viz`), enumerates their emitted value
/// pointers, scores each against the robot scenarios' asserted pointers, lands
/// `ui_value` rows in `surface_coverage`, then prints the ranked worklist
/// (`WHERE utfallsrum_covered = 0 AND kind IN (ui_value, viz_tab,
/// facett_component)`). The machine-readable twin of the hand gap doc. `--json`
/// dumps the rows.
#[arg(long = "ui-values")]
ui_values: bool,
}
#[derive(Args)]
struct ContainerArgs {
/// The image to run, e.g. `docker.io/library/alpine:3`.
#[arg(long)]
image: String,
/// Container engine binary (default `podman`; pass `docker` to use docker).
#[arg(long, default_value = "podman")]
engine: String,
/// Extra flags inserted between `run` and the image (e.g. `-e FOO=bar`).
/// Repeatable; `--rm` is always added so nothing leaks.
#[arg(long = "flag")]
flags: Vec<String>,
/// Human label for the job row (defaults to the image).
#[arg(long)]
target: Option<String>,
/// Command + args to run inside the container, after `--`. Empty = the
/// image's default entrypoint.
#[arg(last = true)]
cmd: Vec<String>,
}
#[derive(Args)]
struct JobsArgs {
/// Filter to one job kind (e.g. `bench_run`, `release_run`, `test_matrix`,
/// `container`, `coverage`). Empty = every kind.
#[arg(long)]
kind: Option<String>,
/// Only show running (non-terminal) jobs.
#[arg(long)]
running: bool,
/// Max rows to show (newest-first). 0 = no cap.
#[arg(long, default_value_t = 50)]
limit: usize,
/// Emit the rows as a JSON array of `jobs::JobRecord` instead of the table
/// (CLI ⟺ viz parity: the SAME records the 🧬 nornir tab renders).
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct CoverageArgs {
/// Repo to measure (must be in nornir.toml). Defaults to the workspace's
/// primary repo (the workspace name).
repo: Option<String>,
/// Skip running `cargo-llvm-cov` and just READ + print the latest persisted
/// coverage run from the warehouse (the viz-pane / docs source of truth).
#[arg(long)]
show: bool,
/// Show ONLY the ui / gRPC / emitter boundary-coverage view (task #4): every
/// layer-boundary function + whether it was exercised, with a per-boundary
/// green/red tally. The CLI twin of the viz pane's boundary view.
#[arg(long)]
boundary: bool,
/// Emit the report as JSON (the maven-style report) instead of the table.
#[arg(long)]
json: bool,
/// Extra args passed verbatim to `cargo llvm-cov` (e.g. `--all-features`).
#[arg(last = true)]
extra: Vec<String>,
}
#[derive(Args)]
struct TestRunArgs {
/// Repo to run the matrix for (must be in nornir.toml).
repo: String,
/// Comma-separated aspect list (e.g. `build,unit,clippy,coverage`). Empty =
/// the default set (build,unit,doctest,clippy,fmt,audit,funnel,funnel-demo,
/// functional,gated-tests). Aspects: build · unit · doctest · clippy · fmt ·
/// audit · bench-smoke · coverage · feature-powerset · msrv · examples ·
/// target-compile · funnel · funnel-demo · functional · gated-tests.
#[arg(long)]
aspects: Option<String>,
}
#[derive(Args)]
struct TestAllArgs {
/// Comma-separated aspect list applied to every repo. Empty = the default set.
#[arg(long)]
aspects: Option<String>,
}
#[derive(Args)]
struct TestAffectedArgs {
/// The workspace member whose tests are asked about.
repo: String,
/// The commit under test (a sha or a prefix of ≥ 7); default: the newest
/// commit the room has read.
#[arg(long)]
to: Option<String>,
/// The commit to compare with; default: the newest commit before `--to`
/// whose Runa test run was green.
#[arg(long)]
from: Option<String>,
}
#[derive(Args)]
struct TestListArgs {
/// Repo whose discovered tests to list (must be in nornir.toml). Omit to list
/// every repo configured in nornir.toml.
repo: Option<String>,
/// Emit the raw matrix rows as JSON instead of the human table.
#[arg(long)]
json: bool,
/// Show only the heavy (`#[ignore]`'d) tests.
#[arg(long)]
heavy_only: bool,
/// Show only the X (not-run) tests.
#[arg(long)]
not_run: bool,
}
#[derive(Args)]
struct TestHistoryArgs {
/// Repo (or run id) to read. Matched first as a repo name; if no rows carry
/// it, treated as an exact run_id.
repo: String,
/// Emit the raw `test_results` rows as JSON instead of the matrix view.
#[arg(long)]
json: bool,
/// Show only the N most recent runs (0 = all).
#[arg(long, default_value_t = 0)]
limit: usize,
}
#[derive(Subcommand)]
enum CacheOp {
/// Bring up the holger `/cache` + `/sparring` proxy (foreground; Ctrl+C stops).
/// Registers a `holger-proxy` job so it shows in the nornir job/task list + viz.
Up {
/// holger-server binary.
#[arg(long, default_value = "holger-server")]
holger_bin: PathBuf,
/// gRPC control-plane bind addr.
#[arg(long, default_value = "127.0.0.1:18443")]
grpc: String,
/// HTTP sparse bind addr cargo talks to.
#[arg(long, default_value = "127.0.0.1:18464")]
http: String,
/// Persistent data root for the /cache + /sparring znippy archives.
#[arg(long)]
data: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum ReleaseOp {
/// Regression time-bisect: for <repo>, find the last green release and the
/// first red one (the gate's last-good → first-bad boundary) from recorded
/// release history, and bound the suspect commit range. No rebuild — pure
/// scan over the warehouse (see `release::regression`).
Trace {
repo: String,
/// Emit JSON instead of the human-readable trace.
/// (Restrict to a workspace with the global `--workspace`; default = all.)
#[arg(long)]
json: bool,
},
/// Print the recorded release-op DAG (the `release_events` table) for a
/// repo or a specific run id. <selector> is matched first as an exact
/// run_id; if no events carry it, it's treated as a repo name. `--json`
/// emits the raw rows; otherwise a human topological view (per-run
/// timeline + `depends_on` edges) is printed. Pure warehouse scan, no
/// rebuild — lock-tolerant so it coexists with a live server.
Events {
/// A run_id (groups one release run) or a repo name (all its runs).
selector: String,
/// Emit the raw event rows as JSON instead of the topo view.
#[arg(long)]
json: bool,
},
/// Compute the impacted-crate set for <repo> by comparing HEAD
/// against <base> and walking the latest dep-graph snapshot's
/// reverse edges. Prints each crate on its own line, ready to
/// feed to `cargo test -p ...` (feature 11).
ImpactedCrates {
repo: String,
#[arg(long, default_value = "main")]
base: String,
/// Emit the changed + impacted sets as the uniform CommandOutcome JSON.
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum ArchOp {
/// Print the wiring board of a repo. (There is no `generate`: the board is
/// a view of the code graph, not a table somebody has to fill first.)
Svg(ArchGenerateArgs),
/// Trace one entrypoint (UI pane / gRPC verb / module ident) to the focused
/// sub-board of everything it reaches downstream (the visual twin of
/// `knowledge_call_path`). Renders the SVG to stdout or `--out`.
Trace(ArchTraceArgs),
/// List historized wiring snapshots for a repo (newest first). `--json`
/// emits the uniform `CommandOutcome`.
History(ArchHistoryArgs),
/// Resolve a symbol / arch-node label to its source `file:line` (the
/// click-to-code bridge the 🏛 Architecture viz tab uses on a node click),
/// printing the location. With `--open` it also launches `code --goto`.
Open(ArchOpenArgs),
}
#[derive(Args)]
struct ArchOpenArgs {
#[command(flatten)]
repo: RepoArg,
/// The symbol / arch-node label to resolve (e.g. `ArchTabState`,
/// `nornir::arch::trace_from`, or a `Service.verb` gRPC label). Matched
/// against the code room's nodes by name (exact → leaf segment → substring).
symbol: String,
/// Also open the resolved `file:line` in the editor (`code --goto`).
#[arg(long)]
open: bool,
/// Print every candidate, not just the best match.
#[arg(long)]
all: bool,
/// Emit the uniform `CommandOutcome` JSON (the resolved `file:line` as data).
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ArchHistoryArgs {
#[command(flatten)]
repo: RepoArg,
/// Emit the uniform `CommandOutcome` JSON (the snapshot listing as data).
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ArchTraceArgs {
#[command(flatten)]
repo: RepoArg,
/// The entrypoint to trace from — matched against node labels/ids by
/// case-insensitive substring (`TestTab`, `Viz.Timeline`, a module bucket).
entrypoint: String,
/// Write the rendered SVG to this path instead of stdout.
#[arg(long)]
out: Option<PathBuf>,
/// Emit the uniform `CommandOutcome` JSON (the reachable node/edge set as
/// data) instead of the rendered SVG.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ArchGenerateArgs {
#[command(flatten)]
repo: RepoArg,
/// Also write the rendered SVG to this path.
#[arg(long)]
out: Option<PathBuf>,
/// Build the graph but skip historizing it into the warehouse.
#[arg(long)]
skip_snapshot: bool,
}
#[derive(Subcommand)]
enum DocsOp {
/// Scaffold `.nornir/` and migrate existing README.md / CHANGELOG.md
/// into `.nornir/` as source-of-truth.
Init(RepoArg),
/// Render every source in `.nornir/` to the repo-root artifact
/// (atomic write, preserves read-only bit).
Render(DocsRenderArgs),
/// CI gate: re-render in memory and fail on any drift vs disk.
Check(DocsCheckArgs),
/// Install a tracked pre-commit hook (`.githooks/pre-commit` +
/// `core.hooksPath`) that re-renders managed markdown on every commit. The
/// hook is markdown-only and never blocks a commit on failure. Idempotent.
InstallHooks(RepoArg),
/// Render the assembled README to PDF / HTML / MD bytes (runs render first).
#[cfg(feature = "docs-export")]
Export(DocsExportArgs),
/// Render the *whole* doc set (every `.nornir/*.md` + non-generated
/// `<repo>/*.md`) into one PDF / HTML / MD book (runs render first).
#[cfg(feature = "docs-export")]
Book(DocsBookArgs),
/// Assemble a single self-contained markdown "AI context pack": the repo's
/// architecture + EVERY exported item's signature & doc-comment + the prose
/// docs, in one file to feed an AI agent as prompt context. Accepts a repo
/// NAME (from nornir.toml) or a PATH to any repo root.
#[cfg(feature = "docs-export")]
AiContext(DocsAiContextArgs),
/// Publish the rendered `docs/book.pdf` to the repo's Codeberg Pages
/// `pages` branch (orphan: `.nojekyll` + `index.html` viewer + `book.pdf`),
/// served at `https://nordisk.codeberg.page/<repo>/`. Renders the book first
/// if it's missing. The branch is staged locally (pure-gix, orphan commit,
/// HEAD untouched); the force-push line is printed for the operator/CI to run
/// (nornir has no in-process push — same as `release`).
#[cfg(feature = "docs-export")]
PublishPages(DocsPublishPagesArgs),
/// List document exports historized in the Iceberg `doc_exports` table.
History(DocsHistoryArgs),
/// Build the dedicated docs Tantivy index (separate from the code index)
/// and historize it into the `docs_index_*` Iceberg tables.
Index(DocsIndexArgs),
/// BM25 search over the docs index (restores from iceberg on a cold cache).
Search(DocsSearchArgs),
}
#[derive(Args)]
struct DocsIndexArgs {
#[command(flatten)]
repo: RepoArg,
/// Build the index but skip historizing it into iceberg.
#[arg(long)]
skip_snapshot: bool,
}
#[derive(Args)]
struct DocsSearchArgs {
#[command(flatten)]
repo: RepoArg,
/// Query string (Tantivy syntax).
query: String,
/// Pin a historical git SHA (time-travel). Defaults to the latest snapshot.
#[arg(long)]
sha: Option<String>,
/// Max hits to return.
#[arg(long, default_value_t = 10)]
limit: usize,
}
#[derive(Args)]
struct DocsHistoryArgs {
#[command(flatten)]
repo: RepoArg,
/// Restrict to one document (e.g. README).
#[arg(long)]
doc: Option<String>,
/// Restrict to one version.
#[arg(long)]
version: Option<String>,
/// Restrict to one format (pdf | html | md).
#[arg(long)]
format: Option<String>,
/// Max rows to show (newest first).
#[arg(long, default_value_t = 20)]
limit: usize,
/// Emit the uniform CommandOutcome as JSON instead of the human table.
#[arg(long)]
json: bool,
}
#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsExportArgs {
#[command(flatten)]
repo: RepoArg,
/// Output format: pdf | html | md.
#[arg(long, default_value = "pdf")]
format: String,
/// Extra output file path. Always writes `<repo>/docs/README.<ext>`; this
/// copies the bytes to an additional path too.
#[arg(long)]
out: Option<PathBuf>,
}
#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsBookArgs {
#[command(flatten)]
repo: RepoArg,
/// Output format: pdf | html | md. (md + pdf are the primary paths;
/// html is experimental via typst's HTML target.)
#[arg(long, default_value = "pdf")]
format: String,
/// Additional output file path. Always writes `<repo>/docs/book.<ext>`;
/// `--out` copies the same bytes to an extra path too.
#[arg(long)]
out: Option<PathBuf>,
/// Assemble the FULL doc set (include the non-manual back-matter: reasoning,
/// history, unsorted, idiot/dummy guides, Tankegångar). Default is the thin
/// **manual** (README + design + guide + benchmarks only) so the shipped
/// `docs/book.pdf` stays small — the doctrine + Codeberg-space rule.
#[arg(long)]
full: bool,
}
#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsAiContextArgs {
/// Repo NAME (from nornir.toml) or a PATH to a repo root.
repo: String,
/// Additional output path. Always writes `<repo>/docs/ai-context.md` too.
#[arg(long)]
out: Option<PathBuf>,
/// Include private items, not just the exported (`pub`/`pub(crate)`) surface.
#[arg(long)]
private: bool,
/// Skip the appended hand-written prose in the full pack.
#[arg(long)]
no_prose: bool,
/// Generation mode: `development` (default), `ci`, or `release`. CI/release
/// refuse a dirty working tree unless `--allow-dirty` is passed.
#[arg(long, default_value = "development")]
mode: String,
/// Permit a dirty-tree generation in ci/release mode (records the override).
#[arg(long)]
allow_dirty: bool,
}
#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsPublishPagesArgs {
#[command(flatten)]
repo: RepoArg,
/// Git remote to force-push the `pages` branch to.
#[arg(long, default_value = "origin")]
remote: String,
/// Re-render the book even if `docs/book.pdf` already exists.
#[arg(long)]
render: bool,
}
#[derive(Args)]
struct DocsFileArgs {
#[command(flatten)]
repo: RepoArg,
}
#[allow(dead_code)] // kept for the docs_fresh gate to reference if needed
type _DocsFileArgsRef = DocsFileArgs;
#[derive(Subcommand)]
enum GraphOp {
/// Extract the binary graph from one or more ELF artefacts (executables /
/// `.so` shared objects), then print the reconstructed symbol + call-edge
/// facts. Calls `graph::extract::binary::extract_graph`, projects to
/// `SymbolRow`/`CallEdgeRow`, and reports the counts. `--json` emits the
/// uniform `CommandOutcome` (the rows as data, tagged `binary_graph`).
Binary(GraphBinaryArgs),
}
#[derive(Args)]
struct GraphBinaryArgs {
/// One or more ELF artefacts to parse (executable / `.so`). Passing several
/// lets the extractor resolve cross-object `DT_NEEDED` + symbol edges.
#[arg(required = true, num_args = 1..)]
paths: Vec<PathBuf>,
/// Emit the uniform `CommandOutcome` JSON (symbol/call-edge rows as data)
/// instead of the human summary.
#[arg(long)]
json: bool,
}
#[derive(Subcommand)]
enum BinOp {
/// Analyse a compiled artifact end-to-end (size + hardening) and PERSIST the
/// `binary_facts`/`section_sizes`/`symbol_sizes` rows into Mimir, keyed by the
/// artifact's `content_sha256`. `--source-root` enables DWARF crate + file/line
/// attribution. `--json` emits the uniform `CommandOutcome`.
Analyze(BinAnalyzeArgs),
/// Per-crate / per-symbol / per-section size report (cargo-bloat / twiggy /
/// Bloaty view). Reads the artifact directly (no persist).
Bloat(BinBloatArgs),
/// Hardening badges (checksec: RELRO/PIE/NX/stack-canary/stripped/debug-info/
/// FORTIFY). Exit code = count of missing protections (CI gate).
Checksec(BinChecksecArgs),
/// Diff two compiled artifacts (base → new): size waterfall (total +
/// per-section + per-crate deltas), symbol churn (added/removed/grown/shrunk),
/// and the coarse ABI-surface delta (exported symbols added/removed). Persists
/// the `artifact_diff` rows into Mimir keyed on the `(base, new)` content-id
/// pair. The binary twin of the source-side `semver_diffs`.
Diff(BinDiffArgs),
/// The **release binary-GATE** (task #65 slice 3): a pass/fail verdict over the
/// base→new diff. FAILS on a total-size regression beyond a threshold, an ABI
/// removal (a dropped export), or a known CVE in the shipped bytes. `base` is the
/// prior *released* artifact (`release_lineage`), `new` the candidate. Emits
/// structured findings (JSON, agent-callable) and exits nonzero on fail.
Gate(BinGateArgs),
/// The **binary-derived SBOM** (task #65 slice 4a): the components actually
/// present in the shipped bytes (per-crate attribution), CVE-matched via the
/// OSV/RUSTSEC engine. Persists `sbom_components` rows with `derived_from=binary`
/// — the twin of the source-side cargo-metadata SBOM.
Sbom(BinSbomArgs),
}
#[derive(Args)]
struct BinAnalyzeArgs {
/// The compiled artifact to analyse (ELF executable / `.so` / `.a`).
artifact: PathBuf,
/// Workspace root enabling DWARF crate + source file/line attribution
/// (symbols whose source lives inside it are attributed to their crate).
#[arg(long)]
source_root: Option<PathBuf>,
/// Repo name recorded on the header row (build context; default empty).
#[arg(long, default_value = "")]
repo: String,
/// Git SHA recorded on the header row (build context; default empty).
#[arg(long, default_value = "")]
git_sha: String,
/// Analyse only — do NOT persist into the warehouse.
#[arg(long)]
no_save: bool,
/// Emit the uniform `CommandOutcome` JSON.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct BinBloatArgs {
/// The compiled artifact to analyse.
artifact: PathBuf,
/// Workspace root for DWARF crate + source attribution.
#[arg(long)]
source_root: Option<PathBuf>,
/// Roll-up axis: `crate` (default), `symbol`, or `section`.
#[arg(long, default_value = "crate")]
by: String,
/// Show the top N rows (default 20).
#[arg(long, default_value_t = 20)]
top: usize,
/// Emit the uniform `CommandOutcome` JSON.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct BinChecksecArgs {
/// The compiled artifact to analyse.
artifact: PathBuf,
/// Emit the uniform `CommandOutcome` JSON.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct BinDiffArgs {
/// The BASE (prior / reference) artifact.
base: PathBuf,
/// The NEW (candidate) artifact.
new: PathBuf,
/// Workspace root enabling DWARF crate + source attribution on both sides.
#[arg(long)]
source_root: Option<PathBuf>,
/// Repo name recorded on the persisted diff rows (build context; default empty).
#[arg(long, default_value = "")]
repo: String,
/// Release-lineage anchor recorded on the diff rows (default empty).
#[arg(long, default_value = "")]
release_id: String,
/// Show the top N symbol/section/crate rows in each bucket (default 20).
#[arg(long, default_value_t = 20)]
top: usize,
/// Diff only — do NOT persist the `artifact_diff` rows into the warehouse.
#[arg(long)]
no_save: bool,
/// Emit the uniform `CommandOutcome` JSON.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct BinGateArgs {
/// The BASE (prior *released*) artifact — the `release_lineage` anchor.
base: PathBuf,
/// The NEW (candidate) artifact under gate.
new: PathBuf,
/// Workspace root enabling DWARF crate + source attribution on both sides.
#[arg(long)]
source_root: Option<PathBuf>,
/// Release-lineage id this verdict anchors to (recorded on the output).
#[arg(long, default_value = "")]
release_id: String,
/// Fail when the total size grows more than this many bytes (absolute budget).
#[arg(long)]
max_growth_bytes: Option<i64>,
/// Fail when the total size grows more than this percent of the base (e.g. 5.0
/// = 5%). Defaults to 5% when neither size flag is given.
#[arg(long)]
max_growth_pct: Option<f64>,
/// Do NOT fail on an ABI-surface removal (a dropped exported symbol).
#[arg(long)]
allow_abi_removed: bool,
/// Skip the CVE match against the binary-derived SBOM.
#[arg(long)]
no_cve: bool,
/// Do NOT fail on a hardening regression (PIE/RELRO/NX/canary/FORTIFY lost
/// vs the base release).
#[arg(long)]
allow_hardening_regression: bool,
/// Emit the uniform `CommandOutcome` JSON.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct BinSbomArgs {
/// The compiled artifact to derive the SBOM from.
artifact: PathBuf,
/// Workspace root for DWARF crate attribution.
#[arg(long)]
source_root: Option<PathBuf>,
/// Repo name recorded on the persisted rows (default = artifact file stem).
#[arg(long, default_value = "")]
repo: String,
/// CVE-match the derived components against the OSV/RUSTSEC advisory DB.
#[arg(long)]
cve: bool,
/// Derive only — do NOT persist the `sbom_components` rows into the warehouse.
#[arg(long)]
no_save: bool,
/// Emit the uniform `CommandOutcome` JSON.
#[arg(long)]
json: bool,
}
#[derive(Subcommand)]
enum IntrospectOp {
/// Print the workspace dependency graph (static SVG).
Depgraph(RepoArg),
/// Extract every function symbol from a built binary (DWARF) as JSON, and
/// snapshot the symbols + callgraph into the warehouse (`dwarf_*` tables,
/// git-SHA keyed) so they're queryable without the binary. `--no-save` to skip.
Symbols(DwarfSymbolsArgs),
/// Search extracted symbols by name substring.
SymbolLookup(SymbolLookupArgs),
/// List symbols defined in a source file (suffix match).
DefinedIn(DefinedInArgs),
/// Extract every inline-call edge from a built binary (DWARF) as JSON.
Callgraph(SymbolsArgs),
/// List functions that call <name> (inline edges only).
Callers(CallQueryArgs),
/// List functions called by <name> (inline edges only).
Callees(CallQueryArgs),
/// Shortest call chain from <from> to <to>, if any.
PathBetween(PathArgs),
}
#[derive(Args)]
struct CallQueryArgs {
binary: PathBuf,
name: String,
}
#[derive(Args)]
struct PathArgs {
binary: PathBuf,
from: String,
to: String,
}
#[derive(Args)]
struct HistoryShowArgs {
repo: String,
/// Print the raw JSONL (one BenchRun per line) instead of the formatted view.
#[arg(long)]
json: bool,
/// Show only the N most recent runs (0 = all).
#[arg(long, default_value_t = 0)]
limit: usize,
}
#[derive(Args)]
struct SymbolsArgs {
/// Path to a debug-info-bearing binary (e.g. `target/debug/nornir`).
binary: PathBuf,
}
#[derive(Args)]
struct DwarfSymbolsArgs {
/// Path to a debug-info-bearing binary (e.g. `target/debug/nornir`).
binary: PathBuf,
/// Don't snapshot the extracted symbols + callgraph into the warehouse
/// `dwarf_*` tables (default: persist, keyed by git SHA, so nothing computed
/// is discarded and they're queryable later without the binary).
#[arg(long = "no-save")]
no_save: bool,
/// Member repo to key the snapshot to (its HEAD SHA + label). Defaults to
/// the whole workspace (`_workspace`). Only meaningful when saving (default).
#[arg(long)]
repo: Option<String>,
}
#[derive(Args)]
struct SymbolLookupArgs {
binary: PathBuf,
pattern: String,
#[arg(long, default_value_t = 20)]
limit: usize,
}
#[derive(Args)]
struct DefinedInArgs {
binary: PathBuf,
/// Path suffix to match, e.g. `bench/mod.rs`.
file: String,
#[arg(long, default_value_t = 50)]
limit: usize,
}
#[derive(Subcommand)]
enum WarehouseOp {
/// Import existing bench_history.jsonl for <repo> into the warehouse.
ImportJsonl(RepoArg),
/// Run **SQL** over the warehouse tables (DataFusion over the Iceberg
/// warehouse, via native `iceberg-datafusion` `TableProvider`s → projection
/// + filter pushdown to Parquet). Table names = warehouse table names; the
/// full `SELECT … WHERE / JOIN / GROUP BY / ORDER BY / LIMIT` surface works.
/// Read-only: tolerates a live `nornir-server` holding the catalog lock by
/// reading a copied-aside snapshot. Example:
/// `nornir warehouse query "SELECT repo, COUNT(*) FROM git_heat_facts GROUP BY repo"`.
Query(WarehouseSqlArgs),
/// **Discover** what `warehouse query` can SELECT from: with no argument,
/// list every queryable warehouse table; with a `<table>` argument, describe
/// that table's columns (name · Arrow type · nullable). Cheap — reads only
/// the catalog metadata, never a Parquet file, and (like `query`) tolerates
/// a live `nornir-server` holding the catalog lock. `--json` emits a machine
/// shape (array of table names, or array of `{name,data_type,nullable}`).
/// Example: `nornir warehouse tables` then
/// `nornir warehouse tables git_heat_facts`.
Tables(WarehouseTablesArgs),
/// Query bench runs for <repo> (Parquet → JSON, last N if --last). The
/// convenience typed reader; for arbitrary SQL use `warehouse query`.
#[command(name = "bench-query")]
BenchQuery(WarehouseQueryArgs),
/// **COMPACT** small data files into a few large SORTED ones (per table +
/// partition), committing a replace snapshot. Far fewer/larger/sorted files
/// → tighter manifest bounds + fewer Parquet footers per scan. Operate on a
/// FIXTURE warehouse; never the live server's locked warehouse.
Compact(WarehouseCompactArgs),
/// **EXPIRE** old snapshots (keep the last N / last D days) and delete their
/// now-unreferenced data + manifest files from disk. Reclaims disk. A file is
/// removed only when no retained snapshot references it.
Expire(WarehouseExpireArgs),
/// **RESET** — empty the warehouse: remove BOTH the catalog (`catalog.redb`)
/// and the data dir (`warehouse/`), so the next open starts from a clean,
/// zero-row warehouse. This is the "empty → measure → repopulate" step of the
/// ingest loop, which until now meant hand-removing two paths and getting it
/// wrong: deleting only `warehouse/` leaves a catalog whose metadata points at
/// files that no longer exist, and `open()` then fails. Refuses while another
/// process holds the catalog lock (a live `nornir-server`), and refuses
/// without `--yes`. Unlike `compact`/`expire` this is NOT reclaim — it is
/// total data loss, on purpose.
Reset(WarehouseResetArgs),
}
#[derive(Args)]
struct WarehouseResetArgs {
/// Reset THIS warehouse root instead of the configured one — e.g. a
/// workspace's `~/.nornir/workspaces/<name>/builds`.
#[arg(long)]
root: Option<PathBuf>,
/// Actually delete. Without it the command only REPORTS what it would remove
/// (a dry run) and changes nothing.
#[arg(long)]
yes: bool,
}
#[derive(Args)]
struct WarehouseQueryArgs {
repo: String,
#[arg(long)]
machine: Option<String>,
#[arg(long)]
last: Option<usize>,
}
#[derive(Args)]
struct WarehouseSqlArgs {
/// The SQL to run, e.g. `SELECT * FROM bench_runs WHERE repo = 'nornir' LIMIT 5`.
sql: String,
/// Output format: `table` (bordered text, default) or `json` (array of row
/// objects).
#[arg(long, value_enum, default_value_t = SqlFormat::Table)]
format: SqlFormat,
}
#[derive(Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
enum SqlFormat {
Table,
Json,
}
#[derive(Args)]
struct WarehouseTablesArgs {
/// A table to describe (its columns + types). Omit to list every table.
table: Option<String>,
/// Emit JSON: an array of table names (list mode), or an array of
/// `{name,data_type,nullable}` column objects (describe mode).
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct WarehouseCompactArgs {
/// Restrict to one table (default: every sorted/point-lookup table).
#[arg(long)]
table: Option<String>,
/// Restrict to one `repo` partition (default: every partition).
#[arg(long)]
repo: Option<String>,
/// Emit the per-table CompactReport array as JSON instead of a human summary.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct WarehouseExpireArgs {
/// Restrict to one table (default: every table).
#[arg(long)]
table: Option<String>,
/// Retain the most-recent N snapshots (default 10). The current head is
/// always retained regardless.
#[arg(long = "keep-last")]
keep_last: Option<usize>,
/// Also retain every snapshot newer than D days.
#[arg(long = "keep-days")]
keep_days: Option<u64>,
/// Emit the per-table ExpireReport array as JSON instead of a human summary.
#[arg(long)]
json: bool,
}
#[derive(Subcommand)]
enum IndexOp {
/// Walk the workspace and bring the index up to date.
Build(IndexBuildArgs),
/// BM25 search over the index (optionally filter by --corpus/--repo).
Search(IndexSearchArgs),
/// Print per-corpus document counts. `--json` prints the CommandOutcome
/// (machine); else a human summary.
Stats {
#[arg(long)]
json: bool,
},
/// Snapshot the current tantivy index byte-for-byte into iceberg,
/// keyed by HEAD git SHA. Idempotent per `(repo, sha)`.
Snapshot(IndexSnapshotArgs),
/// Restore a tantivy index from iceberg blobs back to disk.
/// Pass `--sha` to pin a specific release, or omit to pull the
/// most recent snapshot for `--repo`.
Restore(IndexRestoreArgs),
/// Push the locally-built tantivy index *up* to a running nornir-server,
/// which writes it into its (locked) warehouse — the live-refresh path
/// for a server's index (it can't open the warehouse a client built).
/// Requires `NORNIR_SERVER`; embedded mode has no server to push to (use
/// `index snapshot`). Idempotent server-side per `(repo, sha)`.
Upload(IndexSnapshotArgs),
}
#[derive(Args)]
struct IndexBuildArgs {
/// Wipe the index dir first and rebuild from scratch. Use after changing
/// `[repo.*]` scope or `[storage].local_path` so out-of-scope documents
/// indexed under the previous config are evicted (incremental builds only
/// add/update in-scope docs; they never remove stale cross-repo hits).
#[arg(long)]
clean: bool,
/// Don't historize the built index into the warehouse afterwards (default:
/// snapshot it, so the index reaches the warehouse, not just the local cache).
#[arg(long = "no-snapshot")]
no_snapshot: bool,
}
#[derive(Args)]
struct IndexSnapshotArgs {
/// Repo whose .nornir/cache/index/ to capture. Defaults to the
/// workspace-wide index at <workspace>/.nornir/cache/index/.
#[arg(long)]
repo: Option<String>,
// Workspace name recorded in the iceberg row comes from the global
// `--workspace` / `$NORNIR_WORKSPACE` (default `workspace_holger`).
}
#[derive(Args)]
struct IndexRestoreArgs {
/// Repo whose snapshot to restore.
#[arg(long)]
repo: Option<String>,
/// Specific HEAD sha to restore. Defaults to the latest snapshot
/// for `--repo`.
#[arg(long)]
sha: Option<String>,
/// Destination directory. Defaults to the same `.nornir/cache/index/`
/// path the index normally lives at.
#[arg(long)]
into: Option<PathBuf>,
}
#[derive(Args)]
struct IndexSearchArgs {
query: String,
#[arg(long)]
corpus: Option<String>,
#[arg(long)]
repo: Option<String>,
#[arg(long, default_value_t = 10)]
limit: usize,
}
#[cfg(feature = "robot")]
#[derive(Subcommand)]
enum RobotOp {
/// Run a scenario file against a live app via a WebDriver endpoint.
Run(RobotRunArgs),
}
#[cfg(feature = "robot")]
#[derive(Args)]
struct RobotRunArgs {
/// Repo under test (for the report's identity; resolved for its git sha).
repo: String,
/// Scenario file (plan-tests-DAG TOML).
#[arg(long, default_value = "robot/scenarios.toml")]
scenarios: PathBuf,
/// App base URL (env BASE_URL also honored).
#[arg(long, default_value = "http://127.0.0.1:3000")]
base_url: String,
/// WebDriver endpoint (geckodriver :4444 / chromedriver :9515).
#[arg(long, default_value = "http://127.0.0.1:9515")]
webdriver: String,
/// Parse + print the DAG execution plan without driving a browser.
#[arg(long)]
dry_run: bool,
}
#[cfg(feature = "vector")]
#[derive(Subcommand)]
enum VectorOp {
/// Embed a repo's Rust sources at HEAD into the warehouse (idempotent;
/// only changed/new chunks are re-embedded). Needs an embedder feature.
Index(VectorIndexArgs),
/// Search by meaning. `--mode lexical|semantic|hybrid`.
Search(VectorSearchArgs),
/// Count materialized embeddings + index snapshots in the warehouse.
Stats,
/// Preflight the embedder + GPU: report NVIDIA driver / CUDA / cuDNN status,
/// run a test embed, and advise what to fix if the GPU isn't engaged.
Doctor,
/// Benchmark the CPU cosine kernels (G2): scalar vs runtime SIMD vs int8
/// VNNI on a synthetic corpus. Prints per-kernel timing, throughput, the
/// SIMD/int8 speedup over scalar, and the int8 quantization error.
Bench(VectorBenchArgs),
/// Pin a complete CUDA runtime set into /opt/nornir/cuda by copying the
/// libs from wherever they exist on this box (pip nvidia wheels, ollama,
/// a toolkit) — after this the GPU "just works" with no env. Usually sudo.
SetupCuda(SetupCudaArgs),
/// Pin a complete AMD ROCm runtime set (+ ROCm-enabled libonnxruntime.so)
/// into /opt/nornir/rocm by copying from wherever they exist on this box
/// (/opt/rocm*, $ROCM_PATH, onnxruntime-rocm) — after this the AMD GPU
/// "just works" with no env. Usually sudo. AMD/Strix-Halo sibling of
/// setup-cuda.
SetupRocm(SetupRocmArgs),
/// Lend this box's GPU embedder to servers without one: serve
/// `Embedder.Embed` (texts → vectors in this build's model space) on
/// `--listen`. A server names it with `$NORNIR_EMBED_REMOTE=<host:port>`
/// and its `embeddings` feed embeds here. Refuses to serve the CPU
/// unless `--allow-cpu`.
#[cfg(all(feature = "server", feature = "embed-tract", feature = "embed-ort"))]
ServeEmbed(ServeEmbedArgs),
/// Mímir SemanticClones STAGE 2: embed every function's k-hop call-chain
/// SLICE (forward callee closure, order-independent structural
/// serialisation) under the synthetic namespace `<repo>::slices` — so
/// re-implementations SPLIT across N helper functions become findable.
/// Opt-in; NOT wired into populate (recorded follow-up, stinky mimir-9).
SlicesIndex(SlicesIndexArgs),
/// Search the `<repo>::slices` namespace BY EXAMPLE: build <QUERY_FN>'s
/// own slice, embed its serialisation, k-NN it against every indexed
/// slice. Hits point at each matching slice's ROOT function definition.
SlicesSearch(SlicesSearchArgs),
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct SlicesIndexArgs {
/// Repo whose knowledge view (the code room's nodes + CALLS) gets sliced.
repo: String,
/// Forward hop count of each slice (part of the namespace identity — a
/// different k forces a re-index).
#[arg(long, default_value_t = nornir::mimir_slices::DEFAULT_K)]
k: usize,
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct SlicesSearchArgs {
/// Repo whose slice namespace to search.
repo: String,
/// The example: a function ident in <REPO> (fqn, suffix, or bare name —
/// resolved by longest matching suffix).
query_fn: String,
/// Hop count for the QUERY slice (should match the indexed k).
#[arg(long, default_value_t = nornir::mimir_slices::DEFAULT_K)]
k: usize,
#[arg(long, default_value_t = 10)]
limit: usize,
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct SetupCudaArgs {
/// Target dir (a built-in CUDA search dir).
#[arg(long, default_value = "/opt/nornir/cuda")]
target: PathBuf,
}
#[cfg(all(feature = "server", feature = "embed-tract", feature = "embed-ort"))]
#[derive(Args)]
struct ServeEmbedArgs {
/// Address to listen on.
#[arg(long, default_value = "0.0.0.0:7932")]
listen: String,
/// The bearer callers must send (first line of the file). Without it the
/// worker is open to anyone who can reach the port.
#[arg(long)]
token_file: Option<PathBuf>,
/// Serve the CPU when no GPU backend is here (a worker is for a GPU).
#[arg(long)]
allow_cpu: bool,
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct SetupRocmArgs {
/// Target dir (a built-in ROCm search dir).
#[arg(long, default_value = "/opt/nornir/rocm")]
target: PathBuf,
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorBenchArgs {
/// Number of vectors in the synthetic corpus.
#[arg(long, default_value_t = 100_000)]
n: usize,
/// Vector dimensionality (default = jina-v2 768).
#[arg(long, default_value_t = 768)]
dim: usize,
/// Timed iterations to average over.
#[arg(long, default_value_t = 20)]
iters: usize,
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorIndexArgs {
/// Repo to vectorize (must be declared in nornir.toml). Omit with `--all`.
repo: Option<String>,
/// Embed EVERY workspace member in ONE flattened, GPU-batched pass — the
/// monitor's populate embed path (`index_repos_batched`): a single model load +
/// CPU chunking flattened across all members in one gatling pool + chunks
/// de-duplicated across repos into full GPU batches. The fast way to embed a
/// whole workspace, vs N serial `vector index <repo>` invocations (N model
/// loads, one thin GPU batch per repo, 31/32 cores idle between members).
#[arg(long, conflicts_with = "repo")]
all: bool,
/// Memory ceiling for the embed, e.g. `2G`, `1536M`, `768MiB`; a bare
/// number is MiB. Env twin: `NORNIR_EMBED_MAX_MEMORY_MB`.
///
/// `vector index` was OOM-killed at 7.5 GB RSS on a loaded box
/// (INVENTORY-semantic-twins-2026-09-10 §2): the CPU embedder runs one
/// batch per core CONCURRENTLY and every in-flight forward owns its own
/// `[B, L]` activations, so peak RSS is `baseline + workers × batch tokens ×
/// ~40 KiB`, and `taskset` was the only lever. This fits the fan-out under
/// the ceiling (workers first, then batch width) and prints the plan with
/// its estimated peak on stderr. The estimate is MEASURED from two points on
/// t14s, not derived — compare it with `ps` and correct `vector::memory` if
/// they disagree. Without it the plan is unbounded (the old behaviour).
#[arg(long = "max-memory", value_parser = parse_max_memory)]
max_memory: Option<u64>,
}
/// `--max-memory` value parser — clap wants `Result<T, String>`.
#[cfg(feature = "vector")]
fn parse_max_memory(s: &str) -> Result<u64, String> {
nornir::vector::memory::parse_memory_size(s).and_then(|b| {
if b == 0 {
Err("a zero ceiling would refuse every batch".to_string())
} else {
Ok(b)
}
})
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorSearchArgs {
query: String,
/// Repo to search. Required for semantic/hybrid (embeddings are per-repo);
/// optional for lexical (defaults to the workspace index).
#[arg(long)]
repo: Option<String>,
/// Pin a historical git SHA (time-travel). Defaults to the latest snapshot.
#[arg(long)]
sha: Option<String>,
/// lexical (BM25) | semantic (vector) | hybrid (both, RRF-merged).
#[arg(long, default_value = "semantic")]
mode: String,
#[arg(long, default_value_t = 10)]
limit: usize,
}
#[derive(Args)]
struct RepoArg {
repo: String,
}
/// `nornir build` — drive a declared build variant (task #19).
#[derive(Args)]
struct BuildArgs {
/// The variant to build (`server`, `agent`, `gui`, …). Required unless
/// `--list-variants` / `--json` / `--image` / `--vm` is given.
#[arg(long)]
variant: Option<String>,
/// Build a declared **container image** target (`[[build.container]]`) by name
/// and seal provenance over the produced image digest. Needs `--features
/// build-image` for the real bollard build; otherwise `--dry-run` previews it.
#[arg(long, conflicts_with = "variant")]
image: Option<String>,
/// Build/seal a declared **VM appliance** target (`[[build.vm]]`) by name —
/// seals provenance over the produced kernel+rootfs/disk hashes (the artifacts
/// must already be on disk; the heavy VM build is the app's own).
#[arg(long, conflicts_with = "variant")]
vm: Option<String>,
/// Master a declared **disk-installer ISO** target (`[[build.installer]]`) by
/// name — drives the installer cell's `installer_iso()` through the pure-Rust
/// ISO-9660/El-Torito masterer (zero-shell) and seals provenance over the
/// produced `.iso`. `--dry-run` resolves + previews the ISO plan without
/// mastering. NB a live-bootable installer needs a namable PID-1 payload and a
/// kernel provided out-of-band (e.g. korp's `$KORP_KERNEL`); without them this
/// stages the ISO but cannot boot it.
#[arg(long, conflicts_with = "variant")]
installer: Option<String>,
/// List the repo's declared variants (name → materialized cargo argv) and exit.
#[arg(long)]
list_variants: bool,
/// Emit the data-driven build-thing SURFACE (option axes, per-variant
/// validity, materialized command, Build action) as `state_json` and exit —
/// the model a viz tab / the MCP tool renders.
#[arg(long)]
json: bool,
/// Print the cargo invocation without running it (still validates the variant).
#[arg(long)]
dry_run: bool,
/// After a successful build, capture a build-provenance attestation (variant,
/// inputs, toolchain, artifact hashes) and write it as JSON.
#[arg(long)]
attest: bool,
/// znippy-SEAL the attestation with a dev signer (implies `--attest`). Needs
/// the `build-provenance` feature; otherwise the attestation stays unsealed.
#[arg(long)]
seal: bool,
/// Where to write the attestation JSON (default:
/// `<repo>/.nornir/build-provenance/<variant>-<timestamp>.json`).
#[arg(long)]
out: Option<std::path::PathBuf>,
/// Appointed repo whose variants to use; defaults to the sole configured
/// repo (or the workspace root's own `.nornir/build-variants.toml`).
#[arg(long)]
repo: Option<String>,
/// **Build the WHOLE suite** — walk every `[repo.<name>]` in the loaded
/// workspace roster (`workspace_nordisk/release/nornir.toml`), discover each
/// repo's `.nornir/build-variants.toml`, and build every declared CARGO
/// variant (Exe / Lib / Wasm / Service) with its exact feature set (thin vs
/// fat, prod-strips-`demo`, …), landing each at its canonical `dist/<name>`
/// path. Container / VM / ISO targets are SKIPPED (they need an engine — build
/// those per-repo with `--image`/`--vm`/`--installer`). Combine with
/// `--dry-run` to enumerate the whole-suite cargo plan without compiling, or
/// `--role <server|ui|cli|…>` to build just one role across the suite.
#[arg(long, conflicts_with_all = ["variant", "image", "vm", "installer", "repo"])]
all: bool,
/// With `--all` (or a single `--repo`): build ONLY the variants whose declared
/// `role` matches (`server`, `client`, `library`, `tool`). Omit to build every
/// role.
#[arg(long)]
role: Option<String>,
/// Skip landing a named copy of each built binary into `<repo>/dist/<name>`
/// (the canonical-artifact scheme). By default a successful Exe/Service build
/// is hardlinked (else copied) into `dist/` under its variant `name`.
#[arg(long)]
no_dist: bool,
/// The **delivery (utgåva)** the variant belongs to — `--suite ORIGINAL` in
/// every nordisk member, `--suite MVP` in holger. Required only once a repo
/// declares more than one; an unknown name is a hard error that lists the real
/// ones (`nornir_build_thing::suite_cli::resolve`, the SAME resolver
/// `cargo xtask build` uses — one reader of the declaration, two front doors).
#[arg(long)]
suite: Option<String>,
}
#[derive(Args)]
struct DocsCheckArgs {
#[command(flatten)]
repo: RepoArg,
/// Emit the uniform CommandOutcome as JSON instead of the human summary.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct DocsRenderArgs {
repo: String,
/// Render the managed **markdown only** (README.md / CHANGELOG.md): never
/// (re)generate the non-deterministic depgraph SVG or the book PDF. The
/// committed assets are reused for image links. This is what the pre-commit
/// hook uses so an ordinary commit stays cheap and deterministic.
/// Alias: `--no-assets`.
#[arg(long, alias = "no-assets")]
markdown_only: bool,
/// Render the bench sections from a `BenchRun` **JSON file on disk** instead
/// of the warehouse.
///
/// This is the seam for a run that never reached the warehouse. `nornir bench
/// run --server` executes the bench server-side, where `NORNIR_MACHINE` /
/// `NORNIR_BENCH_ONLY` / `…SKIP` / `…PREVIEW` do not exist, so its rows stamp
/// as the literal machine `"server"` — useless for a host-stamped table. The
/// working alternative is to run each repo's `nornir-bench` example directly
/// and keep the emitted `BenchRun` envelope as a file; this flag renders the
/// documentation from that file with the SAME renderers, so the tables stay
/// generated rather than transcribed by hand.
///
/// The file is one `BenchRun` JSON object (what the example prints). Warehouse
/// history is still used for `bench_history` sections; only the *latest run*
/// is replaced.
#[arg(long, value_name = "PATH")]
from_json: Option<PathBuf>,
}
/// Every LEAF subcommand path in nornir's clap tree, space-joined — e.g.
/// `"bench history"`, `"workspace ls"`, `"mimir deps-of"`. RECURSIVE, unlike the
/// old `get_subcommands().map(get_name)` which saw only the ~25 top-level roots
/// and left every nested subcommand invisible to the autonom completeness gate.
/// A command with no subcommands of its own IS a leaf (the actually-runnable
/// verb). clap's auto-injected `help` subcommand is skipped. This is the CLI twin
/// of `viz::surfaces::viz_surfaces()` — mechanical "find them all", never a hand
/// list — so the gate's `Gap = Surface − Covered` covers EVERY command.
fn clap_command_paths() -> Vec<String> {
fn walk(cmd: &clap::Command, prefix: &str, out: &mut Vec<String>) {
let children: Vec<&clap::Command> = cmd
.get_subcommands()
.filter(|c| c.get_name() != "help")
.collect();
if children.is_empty() {
if !prefix.is_empty() {
out.push(prefix.to_string());
}
return;
}
for sub in children {
let path = if prefix.is_empty() {
sub.get_name().to_string()
} else {
format!("{prefix} {}", sub.get_name())
};
walk(sub, &path, out);
}
}
let mut out = Vec::new();
walk(&Cli::command(), "", &mut out);
out.sort();
out.dedup();
out
}
/// `nornir commands` as a [`CommandOutcome`] — the reference AUT9 migration. `data`
/// is the recursive leaf-command list (the autopilot's surface source / CLI twin of
/// MCP `tools/list`); `human` is the newline list. Sane by construction (a non-empty
/// command list), so the autopilot's sane-data check passes on it.
/// `nornir fleet [ROOT]` — the fleet sweep.
///
/// ONE reader: the rows, the table and the exit code all come from
/// [`nornir_git::fleet`], so a human's table and a script's exit code can never
/// disagree about what "clean" means. Non-zero exit when any checkout is not
/// FIT — the verb is a guard, not a report.
fn run_fleet(root: &str, json: bool) -> anyhow::Result<()> {
let rows = nornir_git::fleet::survey_root(std::path::Path::new(root))?;
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
} else {
print!("{}", nornir_git::fleet::report(&rows));
}
if rows.iter().any(|r| !r.fit()) {
std::process::exit(1);
}
Ok(())
}
fn commands_outcome() -> nornir::cli_outcome::CommandOutcome {
let paths = clap_command_paths();
let human = paths.join("\n");
nornir::cli_outcome::CommandOutcome::ok("commands", serde_json::json!(paths), human)
}
fn main() -> Result<()> {
let cli = Cli::parse();
// Heavy is OFF by default everywhere; the global `--heavy` opts IN by setting
// the env `test_matrix::heavy_enabled()` reads. `--skip-heavy` is a no-op kept
// for back-compat (off is already the default). SAFETY: single-threaded here,
// before any worker/child spawns.
if cli.heavy {
unsafe { std::env::set_var("NORNIR_RUN_HEAVY", "1") };
}
if cli.skip_heavy {
unsafe { std::env::set_var("NORNIR_SKIP_HEAVY", "1") };
}
// Global thin-client flags → env, so every `server_target()` / `client_workspace()`
// path lights up uniformly for ALL subcommands (not just viz/serve). A bare
// `host:port` gets `http://`; the token falls back to a cached OIDC token (from
// `nornir login`), then the user-readable `<home>/.nornir/token`. SAFETY:
// single-threaded, before any worker spawns.
if let Some(s) = &cli.server {
let url = if s.starts_with("http://") || s.starts_with("https://") {
s.clone()
} else {
format!("http://{s}")
};
unsafe { std::env::set_var("NORNIR_SERVER", &url) };
}
// ★ THE LADDER IS WALKED FOR THE **RESOLVED** TARGET, NOT FOR THE FLAG.
//
// This used to read `else if cli.server.is_some() && …`, so the OIDC and token-file
// rungs were walked for `nornir jobs --server <url>` and NOT for
// `NORNIR_SERVER=<url> nornir jobs` — even though `server_target()` honours both and
// the block right above has already normalised the flag INTO `$NORNIR_SERVER`.
// Measured live 2026-08-27: the env form answered `Unauthenticated`, the flag form
// returned rows. Two ways to name the same server, one of them unauthenticated, and
// the client reported the server's refusal instead of its own missing walk.
//
// Reading `$NORNIR_SERVER` here is what makes the two forms ONE fact: whichever way
// the target arrived, it is in that variable by now.
//
// The walk itself is `nornir::client_bearer` — the SHARED
// `nornir_service::resolve_token_whence` ladder plus nornir's OIDC rung inserted
// between the env var and the static file (LAW 5). What was here was a hand-rolled
// `read_to_string` + `trim` + `is_empty` copy of that helper's bottom rung, which is
// how the gate above could drift away from the resolver in the first place.
// SAFETY: single-threaded, before any worker spawns.
let resolved_target = std::env::var("NORNIR_SERVER").ok();
if let Some((tok, _whence)) = nornir::client_bearer(
cli.token.as_deref(),
resolved_target.as_deref(),
Some(&nornir::config::nornir_home().join("token")),
nornir::cached_oidc_bearer().as_deref(),
) {
unsafe { std::env::set_var("NORNIR_SERVER_TOKEN", tok) };
}
if let Some(w) = &cli.workspace {
unsafe { std::env::set_var("NORNIR_WORKSPACE", w) };
}
// Launch/install verbs don't need a discovered workspace config.
match &cli.cmd {
Cmd::Serve(a) => return run_serve(a),
Cmd::Viz(a) => return run_viz(a, cli.config.as_deref()),
Cmd::Install { op } => return run_install(op),
Cmd::Key { op } => return run_key(op),
Cmd::Workspace { op } => return run_workspace(op),
Cmd::Security { cmd } => return run_security(cmd),
// A filesystem sweep of checkouts — no workspace config, no server, no
// network. Sits with the other pre-config verbs precisely because the
// question it answers is often asked BEFORE anything is configured.
Cmd::Fleet { root, json } => return run_fleet(root, *json),
// Pure environment query — no config discovery needed.
Cmd::Root => {
println!("{}", nornir::config::nornir_home().display());
return Ok(());
}
// CLI self-description — no config needed. The reference `CommandOutcome`
// migration (AUT9): the autopilot's surface source, dogfooding the contract.
Cmd::Commands { json } => {
commands_outcome().print(*json);
return Ok(());
}
// Binary graph extraction reads ELF files off the local filesystem —
// no workspace config, no server round-trip (like `introspect depgraph`
// / `callgraph-llvm`, this parses artefacts directly).
Cmd::Graph { op } => return run_graph(op),
// Binary analysis parses a compiled artifact off the local filesystem, so
// unlike its workspace-aware neighbours it must still run when NO config
// is discoverable (`nornir bin analyze /bin/true` from anywhere). Hence a
// BEST-EFFORT load here rather than the mandatory `load_config` below —
// it only ever feeds [`bin_warehouse_root`], which picks the warehouse
// from the global `--workspace` first, a discovered `workspace_<name>`
// config second, and the machine default otherwise.
Cmd::Bin { op } => return run_bin(op, load_config(cli.config.as_deref()).ok().as_ref()),
// The twin scan reads source off the local filesystem and, for `deps`,
// refuses on the device before it reads anything at all. Neither half
// needs a workspace config — and the deps REFUSAL in particular must be
// reachable on a box where no config is discoverable, because "no GPU"
// is the answer whatever the workspace says.
#[cfg(feature = "vector")]
Cmd::Twinscan { scope, roots, json } => return run_twinscan(scope, roots, *json),
// The embed worker holds no warehouse: no workspace config either.
#[cfg(all(feature = "server", feature = "embed-tract", feature = "embed-ort"))]
Cmd::Vector { op: VectorOp::ServeEmbed(a) } => return run_vector_serve_embed(a),
// UI-movement saga viewer/replay reads a saga JSON off the local
// filesystem (or the built-in synthetic saga) — no workspace config.
Cmd::Saga { op } => return run_saga(op),
// OIDC device-code SSO — self-contained (talks to the IdP, not a nornir
// server / workspace). No config discovery needed.
Cmd::Login => std::process::exit(run_login_cli()),
Cmd::Whoami => std::process::exit(run_whoami_cli()),
Cmd::Logout => std::process::exit(run_logout_cli()),
_ => {}
}
// Thin-client commands target a workspace. If none is resolvable (no
// --workspace, no $NORNIR_WORKSPACE, not in a workspace_<name>/ dir), pick one
// interactively (TTY) or error with the served list — never send a bogus name.
#[cfg(feature = "wire-client")]
{
if let Some(server) = server_target() {
let have_ws = std::env::var("NORNIR_WORKSPACE")
.ok()
.filter(|s| !s.is_empty())
.is_some()
|| std::env::current_dir()
.ok()
.and_then(|d| cwd_workspace_name(&d))
.is_some();
if !have_ws {
let chosen = pick_workspace_or_explain(&server)?;
// SAFETY: single-threaded, before any worker spawns / first client call.
unsafe { std::env::set_var("NORNIR_WORKSPACE", &chosen) };
}
}
}
let loaded = load_config(cli.config.as_deref())?;
match cli.cmd {
Cmd::Serve(_)
| Cmd::Viz(_)
| Cmd::Install { .. }
| Cmd::Key { .. }
| Cmd::Workspace { .. }
| Cmd::Security { .. }
| Cmd::Graph { .. }
| Cmd::Bin { .. }
| Cmd::Saga { .. }
| Cmd::Fleet { .. }
| Cmd::Root => {
unreachable!("handled before load_config")
}
// A separate arm rather than another `|` in the chain above: the variant
// itself is `#[cfg]`-gated, and a cfg cannot be attached to one leg of an
// or-pattern.
#[cfg(feature = "vector")]
Cmd::Twinscan { .. } => unreachable!("handled before load_config"),
Cmd::Repos => {
// Client mode lists the *server's* repos; embedded lists local config.
if let Some(server) = server_target() {
for name in list_repos_remote(&server)? {
println!("{name}");
}
} else {
for name in loaded.nornir.repo.keys() {
println!("{name}");
}
}
}
Cmd::Guard { op } => run_guard(op, &loaded)?,
Cmd::Bench { op } => run_bench(op, &loaded)?,
Cmd::Release { op } => run_release(op, &loaded)?,
Cmd::Cache { op } => run_cache(op, &loaded)?,
Cmd::Test { op } => run_test(op, &loaded)?,
Cmd::Coverage(a) => run_coverage(a, &loaded)?,
Cmd::Docs { op } => run_docs(op, &loaded)?,
Cmd::Introspect { op } => run_introspect(op, &loaded)?,
Cmd::Warehouse { op } => run_warehouse(op, &loaded)?,
Cmd::Index { op } => run_index(op, &loaded)?,
#[cfg(feature = "vector")]
Cmd::Vector { op } => run_vector(op, &loaded)?,
#[cfg(feature = "robot")]
Cmd::Robot { op } => run_robot(op, &loaded)?,
Cmd::Knowledge { op } => run_knowledge(op, &loaded)?,
Cmd::Map(a) => run_map(&a.repo, &loaded)?,
Cmd::Arch { op } => run_arch(op, &loaded)?,
Cmd::Airgap { op } => run_airgap(op, &loaded)?,
Cmd::Funnel { op } => run_funnel(op, &loaded)?,
Cmd::Mimir { op } => run_mimir(op, &loaded)?,
Cmd::Diagram { op } => run_diagram(op, &loaded)?,
Cmd::Bakeoff { op } => run_bakeoff_cmd(op, &loaded)?,
Cmd::Repo { op } => run_repo(op, &loaded)?,
Cmd::Coderoom { op } => run_coderoom(op, &loaded)?,
Cmd::Build(a) => run_build(a, &loaded)?,
Cmd::Jobs(a) => run_jobs(a, &loaded)?,
Cmd::Container(a) => run_container_cmd(a, &loaded)?,
// Handled in the early, config-free dispatch above (returns before here).
Cmd::Commands { .. } => {
unreachable!("`commands` is dispatched before workspace resolution")
}
Cmd::Login | Cmd::Whoami | Cmd::Logout => {
unreachable!(
"login/whoami/logout are dispatched (and exit) before workspace resolution"
)
}
}
Ok(())
}
/// `nornir bakeoff …` (H5) — run a prompt across a set of local models and
/// persist one `agent_model_runs` row per model, then read the leaderboard
/// back. The model-call layer is mockable (`--mock`) so this never needs a
/// running LLM; the viz Leaderboard pane ranks the same rows.
fn run_bakeoff_cmd(op: BakeoffOp, loaded: &Loaded) -> Result<()> {
use nornir::warehouse::agent_model_runs::{
BakeoffSelector, MockCaller, ModelAnswer, ModelCaller, OllamaCaller, default_agents,
default_models, demo_matrix_rows, new_run_id, query_agent_model_runs, render_leaderboard,
render_matrix, rows_to_json, run_bakeoff_matrix, score_rows_with_judge, short_run,
};
use nornir::warehouse::codegen_judge::{CodegenJudge, CodegenSpec};
use nornir::warehouse::iceberg::IcebergWarehouse;
match op {
BakeoffOp::Run(a) => {
let agents = if a.agents.is_empty() {
default_agents()
} else {
a.agents.clone()
};
let models = if a.models.is_empty() {
default_models()
} else {
a.models.clone()
};
if models.is_empty() {
bail!("no models to run — pass --models a,b,c or configure the registry");
}
let job = start_job(
loaded,
nornir::jobs::kind::BAKEOFF,
workspace_name(loaded).as_str(),
serde_json::json!({ "agents": agents.len(), "models": models.len() }),
);
let run_id = new_run_id();
let ts_micros = chrono::Utc::now().timestamp_micros();
// The bake-off backend, chosen in priority order:
// 1. `--gen <spec>` / `$NORNIR_GEN_BACKEND` → the EPIC #39
// generative-LLM factory (candle / mistralrs / onnx / ollama /
// mock), driving the SAME selected model across every cell.
// 2. `--mock` → a canned offline matrix (deterministic economics).
// 3. otherwise → the prior-art ollama HTTP caller.
let gen_spec = a
.r#gen
.clone()
.or_else(nornir::warehouse::generator::spec_from_env);
let caller: Box<dyn ModelCaller> = if let Some(spec) = gen_spec {
use nornir::warehouse::generator::{BoxGeneratorCaller, generator};
let g = generator(&spec)?;
println!(
"▶ generator backend `{}` (available: {})",
g.id(),
g.available()
);
Box::new(BoxGeneratorCaller::new(g))
} else if a.mock {
let mut mock = MockCaller::new();
for (ai, agent) in agents.iter().enumerate() {
let hosted = agent != "local-llm" && agent != "-" && agent != "local";
for (mi, m) in models.iter().enumerate() {
let tout = 16 + (mi as i64) + (ai as i64) * 2;
let lat = 100.0 + (mi as f64) * 50.0 + (ai as f64) * 30.0;
mock = mock.with_cell(
agent.clone(),
m.clone(),
ModelAnswer {
output: format!("[mock answer from {agent}/{m}]"),
latency_ms: lat,
tokens_in: 12,
tokens_out: tout,
tokens_per_s: tout as f64 / (lat / 1000.0),
score: (1.0 - (mi as f64) * 0.1 + if hosted { 0.05 } else { 0.0 })
.min(1.0),
cost_usd: if hosted {
0.01 * (mi as f64 + 1.0)
} else {
0.0
},
mcp_tool_calls: if hosted { 3 + mi as i64 } else { 0 },
},
);
}
}
Box::new(mock)
} else {
Box::new(OllamaCaller::new(a.host.clone()))
};
println!(
"▶ bake-off `{}` matrix: {} agent(s) [{}] × {} model(s) [{}] = {} cells",
a.task_id,
agents.len(),
agents.join(", "),
models.len(),
models.join(", "),
agents.len() * models.len(),
);
let mut rows = run_bakeoff_matrix(
caller.as_ref(),
&run_id,
ts_micros,
&a.task_id,
&a.task,
&agents,
&models,
);
// Optional compile-and-test scoring: if a codegen spec was given,
// score each cell's answer by building+testing it, replacing the
// (otherwise 0.0) quality score with the verdict.
if let Some(crate_dir) = a.judge_crate.clone() {
if a.judge_patch && a.judge_file.is_some() {
bail!("--judge-patch and --judge-file are mutually exclusive");
}
let spec = if a.judge_patch {
CodegenSpec::apply_patch(crate_dir)
} else {
let rel = a.judge_file.clone().ok_or_else(|| {
anyhow::anyhow!(
"--judge-crate needs --judge-file <rel-path> (or --judge-patch)"
)
})?;
CodegenSpec::write_file(crate_dir, rel)
};
let spec = match a.judge_cmd.clone() {
Some(cmd) => spec
.with_accept_cmd(cmd.split_whitespace().map(|s| s.to_string()).collect()),
None => spec,
};
println!(
"⚖ judging {} cell(s) by compile-and-test against `{}` ({})",
rows.iter().filter(|r| r.ok).count(),
spec.crate_dir.display(),
spec.accept_cmd.join(" "),
);
let judge = CodegenJudge::new(spec);
let reports = score_rows_with_judge(&mut rows, &judge);
for rep in &reports {
let detail = match (&rep.verdict, &rep.error) {
(Some(v), _) => v.message.clone(),
(None, Some(e)) => format!("judge error: {e}"),
(None, None) => String::new(),
};
println!(
" {}/{}: score {:.3} — {}",
rep.agent, rep.model, rep.score, detail
);
}
}
persist_bakeoff(&loaded, &rows)?;
job.finish(
serde_json::json!({ "rows": rows.len() }),
&format!("agent_model_runs:{run_id}"),
);
println!(
"✓ recorded run {} ({} rows)\n",
short_run(&run_id),
rows.len()
);
if a.json {
println!("{}", rows_to_json(&rows));
} else if a.matrix {
print!("{}", render_matrix(&rows));
} else {
print!("{}", render_leaderboard(&rows));
}
}
BakeoffOp::Demo => {
let run_id = new_run_id();
let ts_micros = chrono::Utc::now().timestamp_micros();
let rows = demo_matrix_rows(&run_id, ts_micros);
persist_bakeoff(&loaded, &rows)?;
println!(
"✓ seeded demo bake-off matrix: run {} ({} cells, 3 agents × 3 models)\n",
short_run(&run_id),
rows.len()
);
print!("{}", render_matrix(&rows));
println!(
"\nopen the 🏆 Leaderboard tab in `nornir viz`, or: nornir bakeoff leaderboard --matrix"
);
}
BakeoffOp::Leaderboard(a) => {
// Source of truth is the warehouse `agent_model_runs` table. Server
// mode: pull every row over Viz.BakeoffResults (the server holds the
// redb lock — a thin client can't open it) and apply the SAME pure
// selection in-memory, so reading matches where `bakeoff demo`/`run`
// submitted. Embedded mode: open the local warehouse read-only.
// (Fixes: writes went to oden but `leaderboard` read a stale LOCAL
// warehouse → the old m1/m2 run.)
#[cfg(feature = "wire-client")]
let all_rows: Option<
Vec<nornir::warehouse::agent_model_runs::AgentModelRunRow>,
> = match server_target() {
Some(server) => Some(bakeoff_results_remote(&server)?),
None => None,
};
#[cfg(not(feature = "wire-client"))]
let all_rows: Option<
Vec<nornir::warehouse::agent_model_runs::AgentModelRunRow>,
> = None;
let rows = if let Some(all) = all_rows {
// Server mode: select over the fetched rows (pure, identical to the
// embedded `BakeoffSelector` scoping below).
select_bakeoff_rows(all, &a.reference)
} else {
// Embedded: read-only + lock-tolerant (coexist with a live server),
// like `release events` / `test history`.
let warehouse_root = loaded.warehouse_root();
let wh = IcebergWarehouse::open_read_only(&warehouse_root).with_context(|| {
format!("open iceberg warehouse at {}", warehouse_root.display())
})?;
if a.reference.is_empty() {
let all = wh.block_on(query_agent_model_runs(&wh, &BakeoffSelector::All))?;
match all
.iter()
.max_by_key(|r| r.ts_micros)
.map(|r| r.run_id.clone())
{
Some(run_id) => all.into_iter().filter(|r| r.run_id == run_id).collect(),
None => Vec::new(),
}
} else {
let by_run = wh.block_on(query_agent_model_runs(
&wh,
&BakeoffSelector::Run(a.reference.clone()),
))?;
if by_run.is_empty() {
wh.block_on(query_agent_model_runs(
&wh,
&BakeoffSelector::Model(a.reference.clone()),
))?
} else {
by_run
}
}
};
// AUT9: shape the already-fetched rows through the shared
// `leaderboard_outcome` builder so the fat path and the thin
// (Viz.BakeoffResults) path emit the SAME {command, ok, data, human}.
nornir::warehouse::agent_model_runs::leaderboard_outcome(&a.reference, &rows, a.matrix)
.print(a.json);
}
}
Ok(())
}
/// Pure leaderboard selection over a fetched row set (server mode), mirroring the
/// embedded `BakeoffSelector` precedence: empty `reference` → only the newest run
/// (by `ts_micros`); else try `reference` as a `run_id`, and if no row matches,
The file goes on; the console shows all of it.