Gunnar

nordisk/nornir

.nornir/cli-command-map.md

<!-- nornir:class: design -->
# CLI command map — every `nornir` verb, its gRPC RPC, and its handler

The complete, code-derived map of the **`nornir` CLI surface**: every command
and subcommand, whether it can run **thin** (gRPC to a `nornir-server`) or only
**fat** (direct warehouse on disk), the exact `Service.Rpc` it calls in thin
mode, and the server handler behind that RPC. This is the **CLI↔gRPC↔handler**
half of the wiring [`architecture-wiring.md`](architecture-wiring.md) draws for
the viz↔table half — together they are the textual form of the EPIC ARCH board.

Source code:
[`src/bin/nornir.rs`](../src/bin/nornir.rs) (the clap `Cli`/`Cmd` + every
`*Op` subcommand enum + the `*_remote` thin paths),
[`proto/nornir.proto`](../proto/nornir.proto) (the gRPC services + RPCs),
[`src/bin/nornir-server.rs`](../src/bin/nornir-server.rs) (the tonic handlers).
Companion: [`cli-client-modes.md`](cli-client-modes.md) (the mode rule),
[`mode-matrix.md`](mode-matrix.md), [`mcp-tools.md`](mcp-tools.md) (the MCP twin).

## 1. The thin / fat duality

`nornir` is **one core library** with several faces (CLI, `nornir-server`,
`nornir-mcp`, `nornir viz`). The CLI itself runs in one of two modes per
invocation:

- **Fat (embedded):** no server target — the verb opens the local Iceberg
  **warehouse files directly** under `<home>/.nornir/...` and owns the redb lock
  in-process. This is the default. Every "Fat behavior" cell below is a direct
  core call.
- **Thin (client):** with a **server target** the verb talks **gRPC** to a
  running `nornir-server`, which owns the warehouse lock and runs the same core
  fn under it. This mirrors exactly how the viz tabs and the MCP tools reach the
  server — same API, same `workspace_<name>` convention.

The switch is purely the **global flags** (they work in any position and apply
to *every* subcommand), each falling back to an env var:

| Global flag | Env fallback | Effect |
|---|---|---|
| `--server <url>` | `$NORNIR_SERVER` | sets thin mode; a bare `host:port` gets `http://` prepended |
| `--token <tok>` | `$NORNIR_SERVER_TOKEN`, then `<home>/.nornir/token` | bearer token for the server (tonic interceptor) |
| `--workspace <name>` | `$NORNIR_WORKSPACE`, then cwd `workspace_<name>/` | picks the workspace; in thin mode it must be one the server serves |

`server_target()` is just `NORNIR_SERVER` (the `--server` flag exports it in
`main` before dispatch); each thin-capable verb checks it and, if set, returns
through its `*_remote` gRPC path instead of the embedded path. Standing in a
`workspace_<name>/` directory auto-resolves the workspace with no flag.

## 1a. Version identity — `nornir --version`

Every nornir build carries one canonical **identity line**:

```
nornir v<CARGO_PKG_VERSION> (<git-short-sha>)
```

e.g. `nornir v0.5.4 (9b1a9a8)`. `--version` / `-V` prints exactly this and
**exits immediately** — clap short-circuits before any subcommand dispatch, so
it never touches the warehouse, a server, or the MCP boot.

The string is defined **once** in the library and reused everywhere so the
faces can never disagree:

| surface | value |
|---|---|
| `nornir --version` / `-V` | `nornir <VERSION_LINE>` (clap `version = nornir::VERSION_LINE`) |
| `Health.Ping.version` (gRPC) | `nornir::VERSION_LINE` |
| viz ℹ️ About panel | `client_version` (`CARGO_PKG_VERSION`) + `build_sha` (the git hash) |

- `nornir::VERSION_LINE` = `concat!("v", CARGO_PKG_VERSION, " (", NORNIR_GIT_HASH, ")")`
  and `nornir::GIT_HASH` = `NORNIR_GIT_HASH` — both in `src/lib.rs`.
- `NORNIR_GIT_HASH` (short SHA) is stamped by **`build.rs`** at build time by
  reading the git metadata with **pure `std::fs`** — it never runs
  `Command::new("git")`, per the zero-shell law. It resolves a symbolic HEAD
  through the loose ref then `packed-refs`, a detached HEAD directly, and a
  linked worktree's `.git` pointer file + `commondir`. Off a git checkout the
  hash is `unknown`. The same value is also emitted as `NORNIR_BUILD_SHA` (the
  historical env the viz "fresh client?" check reads).

## 2. Command ↔ gRPC ↔ handler table

Every CLI command and subcommand. **Mode** is `both` (has a `*_remote` thin
path *and* a fat embedded path), `fat-only` (no server routing — runs locally /
spawns cargo / writes local config), or `thin-only` (no embedded path). For
thin-capable verbs the **gRPC RPC** column names the `Service.Rpc`; the handler
is that service's tonic impl in `src/bin/nornir-server.rs` (e.g. `Release.GateAll`
→ the `gate_all` method on the `Release` service impl).

| Command | Mode — gRPC `Service.Rpc` (thin) | Fat behavior (direct warehouse / local) | What it does |
|---|---|---|---|
| `commands` | fat-only | recursive clap leaf paths (`--json` → `CommandOutcome`) | list every CLI command (the CLI twin of MCP `tools/list`) |
| `repos` | both — `Repos.List` | lists `nornir.toml` repo keys | list monitored repos (server's vs local config) |
| `guard status` | both — `Guard.Status` | `guard::status` over local paths | report AI-agent guard-rail (chmod -w) state |
| `guard apply` | both — `Guard.Apply` | `guard::apply` | chmod -w the forbidden paths |
| `guard release` | both — `Guard.Release` | `guard::release` | chmod +w (release the guard) |
| `guard verify` | fat-only (`Guard.Verify` exists for server self-check) | `guard::verify` | verify guard invariants |
| `bench run` | fat-only (results submit via `Bench.Submit`) | runs benches, `query_bench_*` + append | run benches for a repo, persist `bench_telemetry`/`bench_runs` |
| `bench history-show` | both — `Bench.History` | `bench_history.jsonl` + `query_bench_runs` | show bench history for a repo |
| `release trace` | both — `Release.Trace` | regression trace over `bench_history` | trace a regression to a commit |
| `release events` | both — `Viz.ReleaseEvents` | `query_release_events` → `release_events` | dump release-pipeline events |
| `release impacted-crates` | fat-only | dep graph + `changed_since_last_release` | crates impacted by a change |
<!-- release ORCHESTRATION (gate-*/run/publish/stage/doctor/undo/bump-version/changelog/wait-for-index/tarball-stats/strip-patch-blocks/yank-cascade/mirror-to-holger) MOVED to the `dwarves release` tool (move=delete, 2026-07-18). nornir keeps only the read-only warehouse VIEW verbs above (trace/events/impacted-crates). The gate ENGINE (`release::gate::*`) stays as nornir-server RPCs. -->
| `test run` | both (results via `Telemetry.SubmitTestResults`) | `nornir-testmatrix` detect+run, append `test_results` | run a repo's tests, persist the matrix |
| `test all` | both (submit per row) | run tests for all workspace repos | full matrix run |
| `test history` | both — `Viz.TestResults` | `query_test_results` → `test_results` | show the test-result history/matrix |
| `test coverage` | fat-only (AUT5; MCP twin `test_coverage`) | autonom surface − covered → `surface_coverage` | completeness-gate coverage report |
| `coverage` (top-level) | fat-only | `cargo-llvm-cov` over a repo → `coverage`/`coverage_fn` tables (`--boundary` view) | source line/region coverage (maven-report style) |
| `docs init` | both — `Docs.Init` | `docs::init` scaffolds `.nornir/` | scaffold a repo's docs |
| `docs render` | both — `Docs.Render` | `docs::render` (typst) | regenerate README/CHANGELOG |
| `docs check` | both — `Docs.Check` | `docs::check` freshness | verify docs are current |
| `docs export` | both — `Docs.Export` | `docs::export` (PDF/HTML/MD) | export docs in a format |
| `docs book` | both — `Docs.Book` | `docs::book` whole-doc book | render the full documentation book |
| `docs history` | both — `Docs.History` | `docs_exports` table scan | show doc-export history |
| `docs index` | fat-only | builds docs BM25 index | (re)build the docs search index |
| `docs search` | fat-only (server twin is `Search.Query`) | BM25 over docs index | search the docs |
| `introspect depgraph` | both — `Mimir.Svg` | `dep_graph` → SVG | per-repo dep-graph SVG |
| `introspect symbols` | both — `Introspect.Symbols` | DWARF symbol extraction | extract symbols from a binary |
| `introspect symbol-lookup` | both — `Introspect.SymbolLookup` | symbol search | find symbols by name |
| `introspect defined-in` | both — `Introspect.DefinedIn` | symbol → file/line | where a symbol is defined |
| `introspect callgraph` | fat-only | DWARF/syn call graph | build the call graph |
| `introspect callers` | both — `Introspect.Callers` | `callers_of` | who calls a symbol |
| `introspect callees` | both — `Introspect.Callees` | `callees_of` | what a symbol calls |
| `introspect path-between` | both — `Introspect.PathBetween` | `path_between` | call path between two symbols |
| `knowledge scan` | fat-only (server scans live clones) | `knowledge::scan_all` → `symbol_facts`+`call_edges`+`feature_gate_facts` | scan source into knowledge facts |
| `knowledge query` (lookup/defined-in/callers/callees/call-path) | both — `Knowledge.SymbolLookup`/`DefinedIn`/`Callers`/`Callees`/`CallPath` | query the `symbol_facts`/`call_edges` tables | query the knowledge graph |
| `watch <repo>` | fat-only (feature `inotify`, Linux) | `knowledge::watch::watch` → debounced `knowledge::delta::SymbolDelta::rescan` → `append_symbol_scan` | **live-tree** knowledge map: kernel file events, not a timer poll. For a tree that changes WITHOUT git; a git-driven server keeps the monitor poll. Without the feature it reports so and exits. |
| `scip` (ingest/…) | fat-only (feature `scip`) | ingest a `rust-analyzer scip` index → resolved defs+refs, SHA-keyed | RESOLVED knowledge map (IDE-grade, opt-in) |
| `map <repo>` | fat-only | renders the repo's knowledge map | architecture/knowledge map for a repo |
| `arch generate <repo>` | fat-only | `arch::generate` → historize `architecture_wiring` (MCP twin `arch_graph`) | **generate the wiring graph** (component·gRPC·core·table) and persist it |
| `arch svg <repo>` | fat-only (MCP twin `arch_svg`) | `arch::generate` + `to_svg`, prints/persists | **emit the skade-style wiring SVG** |
| `arch history <repo>` | fat-only | `arch::warehouse::list_arch_wiring` | list historized wiring rows (by git sha) |
| `funnel submit/idea` | both — `Funnel.SubmitIdea` | `funnel::Store` append `funnel_events` | submit an idea into the funnel DAG |
| `funnel error` | both — `Funnel.SubmitIdea` (error kind) | `funnel::Store` | record an error/blocker node |
| `funnel test` | both — `Funnel.SubmitIdea` | `funnel::Store` | submit a test idea |
| `funnel plan / plan-from` | both — `Funnel.CreatePlan` | `funnel::Store` | create a plan from idea(s) |
| `funnel node` | both — `Funnel.AddNode` | `funnel::Store` | add a node to a plan |
| `funnel link` | both — `Funnel.Link` | `funnel::Store` | link two nodes |
| `funnel status` | both — `Funnel.SetStatus` | `funnel::Store` | set a node's status |
| `funnel next` | both — `Funnel.Next` | `funnel::next` | what to work on next |
| `funnel show` | both — `Funnel.Show` | `funnel::Store` dump | dump the funnel DAG |
| `funnel history` | both — `Funnel.History` | `funnel_events` scan | funnel event history |
| `funnel decompose` | fat-only | `funnel::decompose` | auto-decompose an idea into a plan |
| `funnel demo` | fat-only | seeds demo data | funnel demo seeding |
| `funnel nuke` | fat-only | clears funnel state | reset the funnel |
| `mimir deps-of` | both — `Mimir.DepsOf` | `deps_of` over the dep graph | a repo's dependencies |
| `mimir dependents-of` | both — `Mimir.DependentsOf` | `dependents_of` | a repo's dependents |
| `mimir affected` | both — `Mimir.AffectedByChange` | `affected_by_change` | blast radius of a change |
| `mimir build-order` | both — `Mimir.BuildOrder` | topo sort | dependency build order |
| `mimir dep-path` | both — `Mimir.DepPath` | `dep_path` | path between two repos |
| `mimir external-users` | both — `Mimir.ExternalDepUsers` | `external_dep_users` | who uses an external crate |
| `mimir overview` | both — `Mimir.RepoOverview` | `repo_overview` | repo summary (deps/release/docs) |
| (mimir svg / changed) | both — `Mimir.Svg` / `Mimir.ChangedSinceLastRelease` | `dep_graph` SVG / `changed_since_last_release` | dep-graph SVG / changed-set |
| `diagram timeline` | both — `Viz.Timeline` | `build_timeline` → `release_lineage`+`dep_graph_edges` | release/dep timeline diagram |
| `diagram lane-summary` | fat-only | timeline model | per-lane summary diagram |
| `diagram depgraph` | fat-only | `dep_graph_edges` | dep-graph diagram |
| `diagram snapshot-edges` | fat-only | dep snapshot edges | snapshot-edge diagram |
| `diagram gate-matrix` | fat-only | gate results | gate-matrix diagram |
| `diagram release-versions` | fat-only | `release_lineage` | release-versions diagram |
| `diagram bench-history` | fat-only | `bench_history` | bench-history diagram |
| `diagram bench-compare` | fat-only | `bench_runs` | bench-compare diagram |
| `bakeoff run` | fat-only (submit via `Telemetry.SubmitBakeoff`) | runs models, append `agent_model_runs` | run a prompt across local models (H5) |
| `bakeoff leaderboard` | both — `Viz.BakeoffResults` | `query_agent_model_runs` → `agent_model_runs` | model leaderboard |
| `warehouse import-jsonl` | fat-only | append rows from JSONL | import legacy JSONL into the warehouse |
| `warehouse query` | fat-only (read-only open; tolerates a locked live catalog via snapshot) | `warehouse::sql::query` → DataFusion over native `iceberg-datafusion` `TableProvider`s | run SQL (`SELECT … WHERE / JOIN / GROUP BY`) over the warehouse tables; `--format json` |
| `warehouse bench-query` | both — `Warehouse.Tables` + `Warehouse.Scan` | `query_bench_runs` → `bench_runs` | typed bench-run reader for a repo (Parquet → JSON) |
| `warehouse compact` | fat-only (operator op; emits `WAREHOUSE_COMPACT` job) | `maintenance::compact_all` → replace snapshot | merge small data files → few large SORTED files per partition |
| `warehouse expire` | fat-only (operator op; emits `WAREHOUSE_EXPIRE` job) | `maintenance::expire_all` → `RemoveSnapshots` + orphan GC | drop old snapshots + reclaim their unreferenced files |
| `index build` | fat-only | builds tantivy code index | (re)build the code search index |
| `index search` | both — `Search.Query` | BM25 over the index | full-text code search |
| `index stats` | both — `Index.Stats` | index stats | code-index stats |
| `index snapshot` | both — `Index.Snapshot` | snapshot the index | snapshot the index to the warehouse |
| `index restore` | fat-only (`IndexOp::Restore` opens the Iceberg warehouse directly — the `Index` service has no `Restore`/`Get` RPC, only `Stats`/`Snapshot`/`UploadSnapshot`) | `restore_from_iceberg` → blobs back to disk | restore the index |
| `index upload` | both — `Index.UploadSnapshot` (stream) | upload index blob | push an index snapshot to the server |
| `vector index` | fat-only | embeds into `embeddings` | build semantic embeddings (feature `vector`) |
| `vector search` | both — `Vector.Search` | semantic/hybrid query | search by meaning |
| `vector stats` | fat-only | embedding stats | embedding-store stats |
| `vector doctor` | fat-only | env/HW check | diagnose the vector backend |
| `vector bench` | fat-only | benches embedding | embedding throughput bench |
| `vector setup-cuda` | fat-only | sets up CUDA dir | provision a CUDA build dir |
| `vector setup-rocm` | fat-only | pins a ROCm runtime set (+ ROCm `libonnxruntime.so`) into `/opt/nornir/rocm` | provision an AMD ROCm build dir |
| `robot run` | fat-only | drives the viz headlessly (`nornir-robotui`) | robot-UI test driver (feature `robot`) |
| `workspace ls` | both — `Workspaces.List` | local workspace registry | list workspaces (all served, multi-ws) |
| `workspace show <name>` | both — `Workspaces.Get` | registry record | show a workspace's members |
| `workspace register` | both — `Workspaces.Register` | record + eager fetch/build | register a workspace |
| `workspace add` | both — `Workspaces.Register` (member add) | edit descriptor | add a member repo to a workspace |
| `workspace rm <name>` | both — `Workspaces.Remove` | remove from registry | remove a workspace |
| `workspace fetch <name>` | both — `Workspaces.Fetch` | clone members + rebuild warehouse | fetch a workspace's remotes now |
| `workspace populate <name>` | both — `Workspaces.Fetch` (background) | instant clone, async build | populate (fast clone, deferred build) |
| `jobs` | both — `Viz.Jobs` (server `jobs.redb`) | local warehouse `jobs.redb` (`--kind/--running/--limit/--json`) | the unified job ledger (release/bench/test/docs/coverage/container runs) |
| `container` | fat-only | spawns `podman`/`docker run …` AS a tracked job (stdout/stderr+exit captured) | run a container as a tracked nornir job |
| `cache` (run/…) | fat-only | runs the embedded holger proxy (`/cache` crates.io mirror + `/sparring`) as a tracked job | local registry cargo can build through |
| `airgap` (pack/verify/unfold/…) | fat-only (MCP twins `airgap_pack`/`airgap_verify`/…) | `skidbladnir` airgap: pack→sign→verify→unfold→install in dep order | offline lake bundle pipeline (EPIC AIRGAP) |
| `root` | fat-only | prints `<home>/.nornir` (derived from `$HOME`) | print the resolved nornir data root |
| `serve` | fat-only (this *is* the server) | runs `nornir-server` (tonic) | start the gRPC server |
| `viz` | thin-or-fat at launch | execs `nornir-ui` (egui) | launch the visualizer (21 panes) |
| `install add`/`update`/`remove` | fat-only | writes systemd unit + user; `remove` ALWAYS erases the DATA and ALWAYS **keeps** the ssh deploy key + token (`--wipe-identity` is the one opt-in that destroys them; `--purge`/`--keep-identity` are deprecated no-ops) | install / refresh-binary / uninstall the server as a service |
| `key` (gen/show/…) | fat-only | manages `<home>/.nornir/token` | manage the bearer token |
| `security` (scan) | fat-only (server twin `Mimir.SecurityScan`) | `security::scan` → `vuln_findings`+`sbom_components` | SBOM + vuln + license scan |

> The dedicated `Viz.*` data RPCs (`Timeline`, `ReleaseEvents`, `TestResults`,
> `BakeoffResults`, `BenchTelemetry`, `Knowledge`, `TestMatrix`, `CloneEvents`,
> `Architecture`, `Jobs`, `Coverage`, `HolgerRegistry` — see the `service Viz` in
> `proto/nornir.proto` from line 809, the authoritative list) are thin mirrors of
> the fat query fns — the same data the viz panes use (see
> [`architecture-wiring.md`](architecture-wiring.md) §2a). The CLI verbs above
> (`release events`, `test history`, `bakeoff leaderboard`, `diagram timeline`)
> ride those same RPCs, so CLI ≡ viz ≡ MCP over one server API.

## 3. Wiring map — status of the EPIC ARCH generator

The plan item for this is **EPIC ARCH** in
[`nordisk_plan.md`](nordisk_plan.md) §"living architecture diagrams: UI buttons
↔ gRPC backends, wired (skade-style SVG)" (also tracked as **AUT7** under EPIC
AUTONOM). It specifies, across ARCH1–ARCH5:

- **ARCH1** — enumerate viz surfaces (`Facet ∩ registry()` + tab enum) and the
  gRPC verb each thin surface calls; emit the wiring as data.
- **ARCH2** — a coarsened wiring graph (owner-fn quotient: UI-component · gRPC ·
  core fn · table nodes) from `call_edges` + the new `fn → table` access fact.
- **ARCH3** — skade-style **Typst→SVG** render (NOT mermaid), regenerated by
  `nornir docs`, embedded in `.nornir` design docs.
- **ARCH4** — storage decision: `workspace_*/` asset vs warehouse
  `architecture_wiring` table (historized by git sha).
- **ARCH5** — CLI + MCP parity: `nornir arch [svg | trace <entrypoint>]` + an
  MCP tool reusing `knowledge_call_path`.

**Status in code: the generator EXISTS and ships today** — the plan checkboxes
(`[ ]`) are **stale drift**, not an accurate status. Concretely:

- The generator is [`src/arch/mod.rs`](../src/arch/mod.rs): `arch::generate`
  builds the coarsened `ArchGraph` (the owner-fn quotient — `impl Facet for T` →
  Component, `*SvcTrait for *Svc` handler → `Service.Verb` gRPC, `query_/append_`
  accessor + the `fn→table` access fact → Table, else CoreFn), and `to_svg()`
  renders it as a **skade circuit-board SVG** (direct-emit, no mermaid, no typst
  engine — available in every feature set). The `<dynamic>` table and intra-node
  self-edges are dropped.
- The **`fn → table` access fact** (ARCH2's missing edge) is real:
  [`src/warehouse/access_scan.rs`](../src/warehouse/access_scan.rs) →
  `warehouse_access_edges`.
- **CLI verb (the SVG generator the plan asks for):**
  **`nornir arch generate <repo>`** (graph + persist) and
  **`nornir arch svg <repo>`** (emit the SVG); `nornir arch history <repo>`
  lists the historized rows. Dispatched at `Cmd::Arch` → `run_arch`.
- **Storage (ARCH4):** the warehouse table `architecture_wiring`
  ([`src/warehouse/iceberg_schema.rs`](../src/warehouse/iceberg_schema.rs)
  `architecture_wiring()`), one row per generated graph keyed by `git_sha`
  (time-travel), carrying both the graph JSON and the SVG. Written by
  `arch::warehouse::record_arch_wiring`.
- **MCP parity (ARCH5):** `arch_graph` (JSON `{nodes,edges}`) and `arch_svg`
  (the SVG) MCP tools in [`src/bin/nornir-mcp.rs`](../src/bin/nornir-mcp.rs),
  mirroring the CLI.

So: **yes, a CLI command produces the wiring SVG today** — `nornir arch svg
<repo>`. It covers the **CLI/viz↔gRPC↔core-fn↔warehouse-table** boundary as a
component/gRPC/core/table circuit board (the auto-generated twin of
[`architecture-wiring.md`](architecture-wiring.md) §2). The human-authored doc
is the **acceptance fixture** the generator must reproduce. (For reference,
crate-level dep graphs come from a separate path — `mimir svg` /
`introspect depgraph` / the `dep_graph_svg` MCP tool — and are not the same as
this fn-level wiring board.)

The **table in §2 above is the textual form of that map** for the *CLI* surface:
each row is one CLI↔gRPC↔handler edge, exactly the enumeration ARCH1 specifies
for the UI surface but for the command line.

## 4. The laws this map reflects

- **CLI parity with viz/GUI** — every viz pane and MCP tool has a CLI command on
  the same gRPC API: the `Viz.*` data RPCs, `Search`/`Vector`/`Knowledge`/
  `Mimir`/`Release`/`Funnel`/`Docs`/`Workspaces`, and `arch` all have a CLI
  verb. The `--server`/`--token`/`--workspace` flags make any verb thin, so a
  human at the CLI reaches exactly what the viz and an LLM agent reach.
- **Layer-boundary discoverability** — the wiring is *enumerable* precisely
  because the boundaries are type/name-discoverable: `impl Facet for T` marks UI
  components, `*SvcTrait for *Svc` handler methods (e.g. `VizSvc::timeline` →
  `Viz.Timeline`) mark gRPC verbs, and `query_<table>`/`append_<table>`
  accessors plus the `fn→table` access scan mark the one edge no call graph
  yields (which fn touches which table). The arch generator reads exactly these
  markers — the markers are why both this table and the SVG can be derived
  mechanically rather than hand-maintained.