# Architecture: relicario-cli

> **Audience:** contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. **Does NOT own:** crypto, wire formats, or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/FORMATS.md](../../docs/FORMATS.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)).

## What this crate is for

The `relicario` binary is the platform layer for `relicario-core`: it adds
filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts,
clipboard handoff, and a clap-based command surface. The crate has two design
roles. First, it is the developer / power-user surface that exposes everything
the core can do (every `ItemCore` variant, every `VaultSettings` knob, history
inspection, device key management). Second, it is the only working interface
during disaster recovery — the extension may be uninstalled, the device may be
new — so it intentionally maintains feature parity with the extension's vault
tab. It deliberately shells out to `git` rather than depending on libgit2 /
gitoxide; this keeps the dep tree slim, lets the user override `git` config
locally, and lets recovery debugging happen with familiar tooling.

## Module map

`src/main.rs` is now a thin clap-surface + dispatcher; per-command logic lives
under `src/commands/`. Each source file has one job.

- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
  Owns the top-level `Cli` / `Commands` enum and every subcommand enum
  (`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
  `DeviceAction`, `RecoveryQrCmd`), plus the org clap surface `OrgCommands`
  (`main.rs:448`) and `OrgAddKind` (`main.rs:556`) — the latter's Card / Key /
  Document / Totp variants carry `--collection` and the `--*-stdin` secret flags.
  `main()` is a single `match` that
  delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
  three test-only env-var hooks (`test_passphrase_override`,
  `test_item_secret_override`, `test_backup_passphrase_override`) — each is
  stripped from release builds via `#[cfg(debug_assertions)]`.

- **`src/commands/`** — one module per top-level command. `mod.rs` re-exports
  the public surface and hosts the shared `commit_paths` helper (the single
  chokepoint for git commits during vault mutations) plus other cross-command
  glue. Per-command modules: `init`, `add`, `get`, `list` (also hosts
  `cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
  `backup` (export / restore), `import` (lastpass), `attach` (attach /
  attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
  `rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret
  fields then delegate to the shared `item_build` module's per-`ItemCore`
  `build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
  reads top-to-bottom and can be tested through the same integration paths.

- **`src/commands/item_build.rs`** — shared per-type item construction and
  interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
  (`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
  resolution (`resolve_secret_line` — reads one line from stdin or falls back
  to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
  EOF, printing an optional hint in the interactive case); type parsers
  (`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
  (`build_login`, `build_secure_note`, `build_identity`, `build_card`,
  `build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
  (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
  `edit_identity`, `edit_document_message`); and `push_history`.

- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
  `prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
  flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
  `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to
  `rpassword`.

- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
  expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.

- **`src/device.rs`** — device-management plumbing called by
  `commands::device`: ed25519 keypair generation via `relicario-core::device`,
  on-disk layout under `<config_dir>/relicario/devices/<name>/`, and the
  read/write of `.relicario/devices.json` / `revoked.json`.

- **`src/gitea.rs`** — minimal Gitea REST client used by `commands::device add`
  / `revoke` to register and remove deploy keys. Reads
  `RELICARIO_GITEA_{URL,TOKEN,OWNER,REPO}` env vars (overridable via CLI flags).

- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
  the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
  key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the
  `unlock_interactive` flow (vault root walk → salt read → params read →
  reference image extract → passphrase prompt → KDF) at `session.rs:33-59`,
  the typed `load_*` / `save_*` accessors for `Item` / `Manifest` /
  `VaultSettings`, the `read_salt` / `read_params` helpers, the
  `RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which
  every disk write to a vault file goes through. Owns the env-var escape
  hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
  (`session.rs:125`) that integration tests use to bypass the TTY.

- **`src/org_session.rs`** — `UnlockedOrgVault`, the org-vault analogue of
  `session.rs`. Holds the org master key in `Zeroizing<[u8; 32]>` for one CLI
  invocation, recovered by unwrapping `keys/<member-id>.enc` with the device
  ed25519 seed. `open_org_vault` calls `crate::device::current_device_seed()`
  directly (`device.rs`) — a duplicate private fn that previously existed in
  `org_session.rs` was removed during the A5 sweep (implementations were
  identical). Owns the **collection-scoped** `item_path`
  (`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive
  hook authorizes against, never decrypting), fingerprint-based member matching
  (`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment
  differences), `atomic_write`, and `org_git_run`. As of v0.8.1 it also owns
  **collection-scoped attachment storage** — `attachment_path` /
  `save_attachment` / `load_attachment` / `remove_item_attachments`
  (`org_session.rs:125-157`) at layout
  `attachments/<collection-slug>/<item-id>/<att-id>.enc` (the same leading slug
  the pre-receive hook authorizes against as for `item_path`), capped
  per-attachment by `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` (10 MiB,
  `org_session.rs:20`). Note `org_git_run` runs
  **bare git** — unlike `helpers::git_run` it does NOT inject
  `commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
  every commit's signature); signing config is established by
  `configure_git_signing` during `org init`.

- **`src/commands/org.rs`** — the `relicario org` subcommand surface. Full
  19-subcommand surface is merged and wired via `Commands::Org` in `main.rs`.

  *Admin / lifecycle (12):* `init` (structure + wrap + `configure_git_signing` +
  signed bootstrap commit), `add-member` / `remove-member` / `set-role`
  (owner-only escalation guard), `create-collection` / `grant` / `revoke`,
  `rotate-key` (`run_rotate_key`, `commands/org.rs:332` — fresh key, re-wrap for
  all members, re-encrypt every item blob + manifest under the new key,
  concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` /
  `audit` (verified-signer attribution + `TAMPERED` flag).

  *Item CRUD (7):* full item-type parity with the personal vault (v0.8.1).
  `org add` creates **all seven types** (Login / SecureNote / Identity / Card /
  Key / Document / Totp) via `OrgAddKind` (`commands/org.rs:751`); each arm
  delegates to the shared `item_build::build_*` builders through `build_org_item`
  (`commands/org.rs:799`), and `run_add` (`commands/org.rs:823`) sets tags
  post-build. Document is special-cased in `run_add` (`commands/org.rs:839`): its
  builder also yields an `EncryptedAttachment` that is written via
  `save_attachment` and git-staged before the signed commit. Single-line secrets
  (card number/CVV/PIN, TOTP secret, login password) accept a `--*-stdin` flag;
  multiline secrets (Key material, SecureNote body) read stdin to EOF — the same
  `resolve_secret_line` / `resolve_secret_multiline` convention as personal `add`
  (`commands/item_build.rs`).

  `org edit <query>` (`run_edit`, `commands/org.rs:1004`) is **interactive
  per-type** as of v0.8.1 (it was flag-driven before): it prompts Title, then
  dispatches on `&mut item.core` to the shared `item_build::edit_*` helpers
  ("blank keeps current", field-history capture via `push_history`), mirroring
  personal `cmd_edit`. `--totp-qr` sets a Login TOTP from a QR image; `--file`
  replaces a Document's primary attachment (`commands/org.rs:1039`, rejected for
  non-Document items at `commands/org.rs:1018`). The edit commit carries
  `Relicario-Action: item-update`.

  `org get <query> [--show]` masks every secret unless `--show`; `org list
  [--trashed]` filters by the caller's collection grants; `org rm` soft-deletes,
  `org restore` undoes, `org purge` (`run_purge`, `commands/org.rs:1164`)
  permanently removes the encrypted blob **and** the item's attachment directory
  (`remove_item_attachments`, `commands/org.rs:1173`). All item ops are
  collection-scoped and grant-enforced (`filter_for_member` over the manifest +
  `ensure_grant` before any load/mutate). The audit trail emits `item-create` /
  `item-update` / `item-delete` / `item-restore` / `item-purge`.

  Deferred: extension org reads and writes (Plan B-2 / phase 2).

- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
  `find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
  looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
  for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the
  hardened-`git` factory that every git invocation in the crate (production
  code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix
  seconds for human-readable output (audit M11). The hardening is
  load-bearing — see Invariants & Gotchas below.

## Invariants & contracts

These are the load-bearing rules the crate relies on. Each has been verified
in code; cite the line if you change it.

- **Every vault-mutating command unlocks via `UnlockedVault`.** The struct
  holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on
  scope exit (`session.rs:22-25`). No command bypasses this except
  `cmd_generate` outside a vault dir and `cmd_init` (which derives the key
  inline before there is a vault to unlock).

- **Every `git` invocation in production code goes through
  `helpers::git_command`.** A grep for `Command::new("git")` outside
  `helpers.rs` finds zero hits in `src/`; the only other match is in
  `tests/edit_and_history.rs:18`, which is test-side verification of the git
  log and is exempt by design. `git_command` injects
  `core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true`
  via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would
  bypass the hardening — don't.

- **Every file write to a vault file uses `atomic_write`.** `atomic_write`
  (`session.rs:144-151`) writes `<path>.tmp` then renames over `<path>`; a
  partial write never appears as the live file. All `UnlockedVault::save_*`
  helpers route through it. (`cmd_init` writes pre-creation files via
  `fs::write` at `main.rs:373-393`; that path doesn't need atomicity because
  the vault doesn't exist yet — failure leaves a half-built vault that the
  next run rejects via `relicario_dir.exists()` at `main.rs:326`.)

- **Every commit during a mutating command uses `commit_paths`.**
  `commit_paths` (`main.rs:767-775`) does `git add <paths> && git commit -m
  <msg>` through the hardened wrapper. Commit message convention is
  `<verb>: <title> (<id>)` — `add:`, `edit:`, `trash:`, `restore:`, `purge:`,
  `attach:`, `detach:`, `settings: update`, `device: add <name>`, `device:
  revoke <name>`, `init: new relicario vault (format v2)`, `trash empty:
  purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use
  `git_command` directly (not `commit_paths`) because they need a slightly
  different add/commit pattern; they still go through the hardened wrapper.

- **`cmd_generate` is the only command that runs without unlock — and only
  when invoked outside a vault directory.** Inside a vault,
  `cmd_generate` unlocks to read `settings.generator_defaults`
  (`main.rs:1440-1445`); explicit flags override the stored defaults. This is
  why the smoke-test `cargo run -p relicario-cli -- generate --length 32`
  works without any setup.

- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
  directly; `Item::new` (called inside every `item_build::build_*`) does it via
  `relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.

- **Manifest is always saved last.** Within a single command, the order is:
  write item file → mutate manifest → save manifest → commit. If the process
  dies between step 1 and step 3, the next run sees an item file with no
  manifest entry; `cmd_status` / `cmd_list` ignore it because they read the
  manifest, not the directory. (Recovery would manually re-`add` to surface
  it.)

- **Vault root is always discovered, never assumed to be `cwd`.**
  `helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any
  command run from a subdirectory of the vault works (verified by
  `vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally
  rejected because they don't contain `.relicario/` — no compat shim needed
  (`vault_detection.rs:42-59`).

- **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to
  `rpassword`.** This is the only way integration tests can drive the
  per-item secret prompts (Login password, Card number, TOTP secret rotation,
  Key material) without a real TTY. The check is at `main.rs:308-313`.

## Key flows

### Vault init (`cmd_init`, `main.rs:315-418`)

1. Refuse if `.relicario/` already exists (`main.rs:326-328`).
2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm
   they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail
   with audit-H3 message on weak input (`main.rs:331-348`).
3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the
   carrier JPEG via `imgsecret::embed`, write the stego output to `--output`
   (`main.rs:351-360`).
4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3,
   argon2_p: 4 }` (production-grade) at `main.rs:363-365`.
5. `derive_master_key(passphrase, image_secret, salt, params)` →
   `Zeroizing<[u8;32]>` (`main.rs:368`).
6. Create `.relicario/`, `items/`, `attachments/` dirs; write
   `.relicario/{salt, params.json, devices.json}`; encrypt and write
   `manifest.enc` (empty `Manifest::new()`) and `settings.enc`
   (`VaultSettings::default()`) (`main.rs:370-393`).
7. Write `.gitignore` listing the reference image filename (so the second
   factor never accidentally ends up in git) (`main.rs:396-400`).
8. `git init` then initial commit `init: new relicario vault (format v2)`
   via `git_command` (`main.rs:403-412`). Note the initial commit does NOT
   go through `commit_paths` — it precedes the existence of an
   `UnlockedVault`, so the path list is hand-spelled.

### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`)

1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the
   "run `relicario init` first" message on miss (`helpers.rs:21-26`).
2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length).
3. `read_params` deserializes `.relicario/params.json` and extracts the
   nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested
   shape exists because `params.json` also stores `format_version`, `aead`,
   and `salt_path` for forward-compat probing.
4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg`
   convention, then prompts (`session.rs:124-140`).
5. Read the reference image bytes; `imgsecret::extract` runs the DCT
   majority-vote decode to recover the 32-byte image secret
   (`session.rs:38-40`).
6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword`
   (`session.rs:42-49`).
7. `derive_master_key` produces the master key; `UnlockedVault { root,
   master_key }` is returned and lives until the command function returns.

### Item add (`cmd_add`, `main.rs:419-456`)

1. Unlock the vault and load the manifest.
2. Match on the `AddKind` variant: resolve `title` and non-secret fields
   (username, URL, holder, expiry, etc.) via `prompt_or_flag` /
   `prompt_or_flag_optional`, then delegate to the matching `build_*` builder
   in `commands/item_build.rs`. Seven variants → seven builders; only
   `build_document` takes `&UnlockedVault` because it needs `attachment_caps`
   and writes the encrypted blob alongside the item.
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
   accept a `--*-stdin` flag that reads one line from stdin instead of
   prompting; multiline secrets (SecureNote body, Key material) always read
   stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
   Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
   `resolve_secret_line` / `resolve_secret_multiline`.
4. The builder returns a fully-populated `Item` (with title, group, tags,
   favorite-flag, primary attachment if any).
5. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
   `vault.save_manifest(&manifest)`.
6. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
   `attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
   with message `add: <title> (<id>)` (`main.rs:444-452`).

### Item edit (`cmd_edit`, `main.rs:938-977`)

1. Unlock, load manifest, resolve query → item id, load the item.
2. Universally-editable fields (title, group, tags) are prompted via
   `prompt_keep` / `prompt_keep_opt` first; blank input keeps the current
   value (`main.rs:952-956`).
3. Borrow `&mut item.field_history` once into a local `history` binding
   (`main.rs:958`), then `match` on `&mut item.core` and dispatch to the
   per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking
   editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`,
   `edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`,
   `edit_document_message`) don't.
4. Each editor that mutates a tracked secret calls `push_history(history,
   "<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below
   for the synthetic-key convention.
5. `item.modified = now_unix()`, save, upsert manifest, commit
   `edit: <title> (<id>)`.

`edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` /
`extract` instead" — Document items can't be field-edited; they're
attachment-shaped.

The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it
exists so the editor signatures don't have to spell out the full
`HashMap<FieldId, Vec<FieldHistoryEntry>>`.

### History capture and view (`push_history` + `cmd_history`)

`push_history` (`main.rs:1095-1109`) records an old value under a synthetic
`FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so
they can never collide with real custom-field UUIDs from the typed-item
custom-fields work. The keys used in the codebase are:

- `core:login_password` (`main.rs:998`)
- `core:secure_note_body` (`main.rs:1012`)
- `core:card_number` (`main.rs:1031`)
- `core:key_material` (`main.rs:1045`)
- `core:totp_secret` (`main.rs:1063`)

`cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the
keys, strips the `core:` prefix for display, and prints each entry list
masked or revealed depending on `--show`. The `--field <name>` filter
matches against either the stripped name (`login_password`) or the raw key
(`core:login_password`) so both forms work (`main.rs:1126-1129`). The
`relicario history bank --field totp_secret` form is what
`edit_and_history.rs` exercises.

### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`)

- `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets
  `trashed_at`), saves, upserts manifest, commits `trash:`.
- `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`,
  same wrap-up, commit `restore:`.
- `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`)
  which removes the item file, the attachment dir, the manifest entry, and
  `git rm -rf --ignore-unmatch`s the paths. Then a single `git add
  manifest.enc` + commit `purge: <title> (<id>)`.
- `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating
  command. It loads settings once, iterates all items past their
  `trash_retention` window, calls `purge_item` for each, then does a single
  `git add manifest.enc` + commit `trash empty: purged N item(s)`. The
  single-unlock-per-batch shape was the fix in commit `b5015b3` — the
  earlier version re-prompted for the passphrase per item.

### Attach / detach / extract

- `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings
  and rejects if the item has hit `per_item_max_count`. `encrypt_attachment`
  enforces `per_attachment_max_bytes`. The encrypted blob lands at
  `attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core.
  Commit message: `attach: <file> → <title> (<id>)`.
- `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one
  attachment from the item, deletes the encrypted blob, rewrites the item.
  Refuses if the target `aid` is a `Document` item's `primary_attachment`
  (`main.rs:1392-1400`) — that would orphan the item; use `purge` instead.
  Commit message: `detach: <filename> from <title> (<id>)`.
- `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the
  plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no
  state mutation.
- `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename
  — read-only.

### Generate (`cmd_generate`, `main.rs:1426-1489`)

Has two distinct modes:

- **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays
  `None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`,
  `words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt.
- **Inside a vault** — `vault_dir()` succeeds; full unlock; load
  `settings.generator_defaults`. Explicit flags override the stored defaults
  field-by-field. `--bip39` flips mode; absent that flag, the mode is
  whatever the stored default is. Tests:
  `settings.rs::generate_uses_vault_default_length` (length-tracking) and
  `basic_flows.rs::generate_random_and_bip39` (no-vault smoke).

The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is
the only command outside `cmd_init` that touches `helpers::vault_dir()`
directly instead of going through `UnlockedVault::unlock_interactive()`.

### Sync (`cmd_sync`, `main.rs:1582-1590`)

`git pull --rebase` then `git push`, both via the hardened wrapper. No
unlock — sync moves opaque ciphertext, the master key is never needed. This
is the only command that fails on conflict; it doesn't try to resolve.
Resolution happens manually in the user's git tooling.

### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`)

Unlocks; loads manifest; counts items (active vs trashed), attachments
(count + total bytes), devices (parsed from `devices.json`); shells out to
`git log -1 --pretty=format:%h %s` for the last-commit summary line. All
read-only — no commit, no state change.

### Device management (`cmd_device`, `main.rs:1632-1702`)

Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to
`.relicario/devices.json`, write the secret signing key to
`<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit
`device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name,
rewrite `devices.json`, commit `device: revoke <name>`. Note that device
keys are kept entirely separate from the KDF (passphrase × image stays
unchanged across device add/revoke), as per the design spec.

### Backup (`commands::backup`, `commands/backup.rs`)

Two subcommands, both keyed by a *backup* passphrase that is independent of
the vault master passphrase.

- **`backup export <out> [--include-image] [--image PATH] [--no-history]`** —
  reads the entire on-disk vault layout (`.relicario/{salt,params.json,
  devices.json}`, `manifest.enc`, `settings.enc`, every `items/*.enc`, every
  `attachments/<iid>/<aid>.enc`), optionally bundles the reference JPEG and
  the `.git/` directory (as an in-memory tar), and hands the lot to
  `relicario_core::backup::pack_backup` with a zxcvbn-gated backup
  passphrase prompted twice. The resulting `.relbak` is written via
  `tmp` + rename. A `.relicario/last_backup` marker file (ISO-8601 line) is
  also written so `cmd_status` can show "last backup at …".
- **`backup restore <input> [<target>]`** — refuses to overwrite an existing
  vault (`target/.relicario/` must not exist). Unpacks the `.relbak` via
  `unpack_backup`, then materialises every byte into the target layout. The
  bundled `.git/` tar is extracted via the hardened
  `relicario_core::safe_unpack_git_archive` (path-traversal / symlink /
  size-cap guards) with a cap of `min(100 × tar_size, 1 GiB)`; if no
  history was bundled, the target gets a fresh `git init` + initial commit.

### Import (`commands::import`, `commands/import.rs`)

- **`import lastpass <csv>`** — reads the CSV, calls
  `relicario_core::import_lastpass::parse_lastpass_csv`, then unlocks the
  vault and writes every produced `Item` through `vault.save_item` + manifest
  upsert. Failed rows surface as `ImportWarning`s on stderr and never abort
  the import; only a missing or malformed header is fatal. Commit message:
  `import: <N> items from LastPass (<csv-filename>)`. The dispatch shape
  (`ImportAction` subcommand enum) is in place for future importers
  (Bitwarden, 1Password, etc.) — each would add one `ImportAction` variant
  and one helper.

### Rate (`commands::rate`, `commands/rate.rs`)

`rate <passphrase|->` runs `relicario_core::generators::rate_passphrase`
(zxcvbn-backed) and prints the 0–4 score, a human-readable label, and the
estimated guess count as `~10^N`. Reads one line from stdin when the
argument is `-`, which keeps the passphrase out of shell history. Purely
informational — does not unlock or mutate anything; the `init` command
calls `validate_passphrase_strength` directly and does not consult `rate`.

### RecoveryQr (`commands::recovery_qr`, `commands/recovery_qr.rs`)

Two subcommands wrapping `relicario_core::recovery_qr::{generate_recovery_qr,
unwrap_recovery_qr}`.

- **`recovery-qr generate`** — re-extracts the 32-byte image_secret from the
  reference JPEG (via `get_image_path` + `imgsecret::extract`), prompts for
  the recovery passphrase (which may be the same as the vault passphrase or
  different — domain-separated by core), produces the 109-byte sealed
  payload, and renders it as a Unicode-block QR (EcLevel::M) directly to
  stdout. The payload is **never written to disk** — the user is expected to
  print or photograph it.
- **`recovery-qr unwrap`** — reads a base64-encoded payload from stdin,
  prompts for the recovery passphrase, runs `unwrap_recovery_qr`, and prints
  the recovered `image_secret` as hex. Useful for recovery dry-runs and for
  reconstructing a lost reference image.

## Cross-cutting concerns

- **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors
  bubble up through `?` from `RelicarioError`. Per-step context is added
  via `with_context(|| ...)` chains, e.g. `format!("failed to read {}",
  path.display())`. AEAD authentication failures intentionally surface as
  the ambiguous "wrong passphrase or corrupt vault" message from core — the
  CLI does not differentiate. clap argument errors are produced by clap
  (e.g., `--days` and `--forever` together fail at the
  `SettingsAction::TrashRetention` arm in `main.rs:1504-1510`).

- **Atomicity.** Every disk write to a vault file goes through
  `session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then
  rename over `<path>`. Manifest is the single source of truth and is
  always written *last* in any multi-file operation, so a process kill
  between item-write and manifest-write leaves an orphan item file (which
  doesn't appear in `list`/`status`) but never a manifest pointing to a
  missing file.

- **Git history as audit log.** Per-action commits, never amended, never
  squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`,
  `restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`,
  `init:`) makes `git log --oneline` a literal audit trail. Tests verify
  this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`).

- **Where secrets live.**
  - Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>`
    (`session.rs:24`). Wipes on drop.
  - Image secret — `Zeroizing<[u8; 32]>`, lives only inside
    `unlock_interactive` until the KDF call (`session.rs:40`).
  - Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or
    the env var (`session.rs:42-49`, `main.rs:333-342`).
  - Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`,
    `Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and
    `Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from
    base32). All flow through core types.
  - Clipboard copy — `Zeroizing<String>` cloned into the detached 30s
    auto-clear thread (`main.rs:873-889`).

- **Test escape hatches.** Three env vars exist for integration tests; all
  are read at exactly one site each:
  - `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and
    `main.rs:333,338` (init).
  - `RELICARIO_IMAGE` — `session.rs:125` (image path resolution).
  - `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only).
  None of them have a production fall-through; absent the var, the code
  always prompts. They are safe in production binaries because the user
  would have to set them explicitly.

- **Generate-without-unlock is intentional.** It is NOT an oversight.
  `relicario generate --length 32` is the documented smoke test (see the
  repo's CLAUDE.md) and works as a standalone CSPRNG password generator
  outside any vault. Inside a vault it does require unlock — see Gotchas.

## Test architecture

All tests are integration tests; there are no `#[cfg(test)]` modules in
`src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests
(`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601`
formatting in isolation. Everything else is `tests/`.

- **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()`
  spins up a fresh `TempDir`, generates a 400×300 JPEG via
  `make_test_jpeg()` (deterministic noise; no binary fixtures), runs
  `relicario init --image carrier.jpg --output reference.jpg` with
  `RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference
  image path on the struct. `run` and `run_with_input` are the two ways to
  invoke the binary against the test vault: both inherit
  `RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra
  newlines into stdin (used for interactive prompts that aren't
  `rpassword`-driven). The note at the top warns Task 23 implementers
  about the new-item-password rpassword path; the fix landed as
  `RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`.

- **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout
  (`.relicario/{salt,params.json,devices.json}`, `manifest.enc`,
  `settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json`
  v2 shape; `add login` + `list`; `get` masking semantics (with and
  without `--show`); the rm/restore/purge cycle including `list --trashed`;
  and the two-mode `generate` smoke (random length + bip39 word count) run
  outside a vault.

- **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end
  by piping stdin lines (blank to keep, `y` to confirm) plus
  `RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*`
  verifies the item file is rewritten and the `edit: bank` commit lands.
  The four `history_command_*` tests cover masked listing, `--show`
  reveal, "no history captured" output, and per-field filtering. The
  `edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27
  in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the
  full TOTP edit including issuer / label / secret rotation.

- **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/
  `extract` round-trip (verifies the bytes survive the encrypt-decrypt
  hop); `detach` removes both the attachment ref and the encrypted blob
  on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads
  over `per_attachment_max_bytes`. The detach test (`detach_*`) and the
  cap test were added in `3f0f5b1` / `20350d5` respectively.

- **`tests/settings.rs`** (`135 lines`) — `settings show` and
  `settings trash-retention --days 60` round-trip; the conflicting-flags
  rejection (`--days` + `--forever`); the
  `generate_uses_vault_default_length` test that verifies (a) default
  vault length is 20, (b) updating `settings generator-defaults --length
  32` changes the default, (c) explicit `--length 8` overrides the stored
  default; the multi-shape `cmd_status` smoke; and the
  `generate_works_outside_vault` test that verifies the no-unlock path
  works in a bare `TempDir` with no `.relicario/`.

- **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit
  L8: `list` refuses without a marker; `list` from a nested subdirectory
  finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected
  with the `.relicario` hint in the error message.

The whole test suite uses `assert_cmd` to spawn the real binary against a
real temp directory, so they exercise actual fs / git / KDF code paths.
The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in
the test path (`main.rs:365`), which is why init takes a noticeable beat
in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note
applies to `relicario-core` unit tests, not these CLI integration tests.

## Gotchas & non-obvious decisions

- **`cmd_generate` runs without unlock outside a vault, but with unlock
  inside.** This is two ergonomic guarantees in one command. Outside, it's
  a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any
  user who installed `relicario` just for the generator. Inside, it
  consults `settings.generator_defaults` so the user gets the policy they
  configured. The branch is the `vault_dir().is_ok()` check at
  `main.rs:1440`. Tests pin both behaviours.

- **TOTP edit pushes history under the synthetic key `core:totp_secret`,
  not `core:totp` or anything else.** This is what `relicario history
  <query> --field totp_secret` matches against. The naming convention
  ("type underscore field") is shared across all five history-tracked
  fields (see Invariants). If you add a new history-tracked field, pick a
  matching `<type>_<field>` form so the user-facing `--field` filter
  stays predictable.

- **`detach` refuses a Document item's primary attachment.**
  (`main.rs:1392-1400`) Document items model "this item *is* a file"; the
  primary blob isn't optional. The error directs the user to `purge`
  instead. Non-primary attachments on a Document (e.g., a scanned
  contract with an addendum) detach normally.

- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
  `3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
  personal and org surfaces share one set). Before the extraction, `cmd_add`
  and `cmd_edit` carried 217-line `match` arms. The split-out functions are
  easier to read, easier to test individually (the existing integration tests
  still drive them through the same paths), and easier to grow when a new
  `ItemCore` variant lands. Keep this shape — don't fold them back.

- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
  reasons. (1) Dep tree: pulling in libgit2 doubles compile time and
  adds a C dependency. (2) Override surface: users can put any
  `~/.gitconfig` they want and it Just Works (subject to the hardening
  flags). (3) Recovery: when something is wrong with a vault, the user
  can poke around with `git log`, `git show`, `git fsck` directly; the
  CLI's git interactions are not opaque.

- **The hardened-`git` injection set is load-bearing.** `git_command`
  prepends three `-c` flags before the user-supplied args
  (`helpers.rs:48-52`):
  - `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned
    vault could otherwise run arbitrary code on every commit. Master key
    is in memory at the time of commit; this matters.
  - `commit.gpgsign=false` — if the user has global GPG signing on, the
    GPG agent prompt would block on `git commit` and hold the master
    key alive in memory until the user types the passphrase. Disable it
    for relicario commits.
  - `core.editor=true` — `true(1)` exits 0 with no output. If `git`
    decides to drop into `$EDITOR` (rebase conflict markers, missing
    `-m`), this neutralises it without crashing the rebase. We pass
    `-m <msg>` ourselves; this flag is the seatbelt.
  All three were added together in audit H4. A user can still run
  `git` themselves with their own config to inspect or repair the
  vault — the hardening only applies to relicario's invocations.

- **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4
  }`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE`
  bypasses the prompt but does not lower the KDF cost. This is a
  trade-off: integration tests pay the full Argon2id cost (~half a
  second per init on a modern machine), but the same code path runs in
  production. Don't lower the params here — the core's test-only fast
  params are for `relicario-core` unit tests.

- **`params.json` has a nested `kdf` object, not a flat one.**
  `read_params` (`session.rs:110-121`) deserializes via a private
  `ParamsFile { kdf: KdfParams }` struct. The nesting exists so
  `format_version`, `aead`, and `salt_path` can co-exist in the same
  file for forward-compat. An earlier version of `read_params` tried
  to deserialize the whole file as `KdfParams` and failed silently —
  that bug was fixed in commit `b263c27`.

- **`commit_paths` is the convention but not always the call site.**
  `cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command`
  directly because their add/commit pattern doesn't quite fit
  `commit_paths(vault, msg, &[paths...])`. They still use the
  hardened wrapper, just at one level lower. If you find yourself
  writing a new command with the same shape, prefer `commit_paths`;
  reach for `git_command` directly only when you need the slightly
  different control flow these three have.

- **Initial commit at `cmd_init` does not use `commit_paths`.**
  Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't
  construct one — it uses the master key inline before the vault
  exists. The init commit goes through `git_command` directly
  (`main.rs:403-412`). This is the only production code site outside
  `commit_paths` that does so.

- **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a
  session — every command re-derives the master key. The command
  exists only for UX parity with the extension, where `lock` actually
  evicts a cached session. Printed message: `no cached session to
  lock`.

- **`resolve_query` accepts an item id or a case-insensitive title
  substring** (`main.rs:855-871`). Exact id-match wins; otherwise it
  defers to `Manifest::search`. Multi-hit substring matches are
  rejected with an "ambiguous" error listing the matched titles. This
  is why every `cmd_*` that takes a `query: String` (get, edit,
  history, rm, restore, purge, attach, attachments, extract, detach)
  works the same way.

---

**Next:** [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.
