# CLI Restructure — Design

**Date:** 2026-05-04
**Status:** Proposed
**Source:** `docs/superpowers/reviews/2026-05-04-architecture-review.md` (P1.2, P1.3, P1.10) + folded P2s from DEV-B's CLI section + DEV-A's P2 base32 dedup
**Effort estimate:** M-L

## Summary

Plan B is the single biggest readability lift in the whole-codebase architecture review. `crates/relicario-cli/src/main.rs` is a 2641-line flat file: every `cmd_*`, every `build_*_item`, every `edit_*`, six prompt helpers, three pure parsers, the `ParamsFile` writer, and 24+ git shell-outs all live as peers. This plan splits that file into a `commands/` folder plus `prompt.rs` and `parse.rs`, then builds on top of that split to centralize the duplicated git-error UX, unify the manifest-after-mutation cache discipline, deduplicate the on-disk `params.json` shape, batch the `cmd_purge` git invocations, and finally migrate the pure parsers (and a third copy of base32) into `relicario-core` so the extension can consume them through new WASM exports. After the file is navigable rather than scrollable, a tinkerer who opens `cmd_add` can follow it end-to-end in one screenful — which is the user's stated learning-by-tinkering goal.

## Findings addressed

- **P1.2** — `crates/relicario-cli/src/main.rs:1-2641` is a 2641-line monolith with no submodule boundaries (DEV-B). The clap surface (lines 1-455) is excellent; lines 456-2641 are flat code.
- **P1.3** — Git invocation boilerplate duplicated ~16× with one-line `bail!("git X failed")` errors at `crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` (and others) (DEV-B).
- **P1.10** — Pure parsers (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) live only in the CLI at `crates/relicario-cli/src/main.rs:942-980` despite the extension needing them (DEV-B). Pairs with DEV-A's P2 finding that three base32 implementations live in core (`crates/relicario-core/src/item.rs:255-275`, `crates/relicario-core/src/import_lastpass.rs:202-220`, and intentionally separate Steam at `crates/relicario-core/src/item_types/totp.rs:13`).
- **CLI P2 — `build_*_item` complexity** at `crates/relicario-cli/src/main.rs:664-921` (DEV-B). Each builder mixes prompting, parsing, and core construction.
- **CLI P2 — `refresh_groups_cache` discipline** at `crates/relicario-cli/src/main.rs:641, 998, 1123, 1197, 1414, 1432, 1474` (DEV-B). The "every mutating handler must call this" rule is unenforced.
- **CLI P2 — `ParamsFile` defined twice with mismatching shapes** at `crates/relicario-cli/src/main.rs:2287` (writer with `aead`, `salt_path`, `format_version`) versus `crates/relicario-cli/src/session.rs:114` (reader with only `kdf`) (DEV-B).
- **CLI P2 — Batched purge needed** at `crates/relicario-cli/src/main.rs:1476-1480` and `:1896-1900` (DEV-B). A 50-item `trash empty` currently fires 150 git invocations.

## Approach

The architectural shape is "one file per top-level subcommand, plus two cross-cutting helpers, plus a thin dispatcher." The clap definitions stay at the top of `main.rs` because they read like a tour of the product and that's exactly what a learner wants to land on first; everything past the dispatcher moves out. Each new module becomes self-contained enough that a tinkerer following a single command does not need to scroll through neighbours.

Post-split layout under `crates/relicario-cli/src/`:

```
src/
├── main.rs              # clap surface (Cli, Commands, AddKind, …) + dispatch match (~470 lines)
├── helpers.rs           # vault_dir, git_command, git_run (NEW), iso8601, humanize_age,
│                        # groups_cache_path, sanitize_for_commit, decode_totp_qr
├── session.rs           # UnlockedVault, ParamsFile (single canonical), after_manifest_change (NEW)
├── device.rs            # ed25519 keypair storage, configure_git_signing
├── gitea.rs             # GiteaClient (deploy-key API)
├── prompt.rs            # NEW — prompt, prompt_optional, prompt_secret, prompt_or_flag<T>,
│                        #       and the four other small prompt_* helpers
├── parse.rs             # NEW — thin wrappers around the migrated core parsers
│                        #       (until phase 7, the parsers themselves live here)
└── commands/
    ├── mod.rs           # NEW — pub use re-exports for the dispatcher
    ├── add.rs           # cmd_add + the seven build_*_item helpers
    ├── get.rs           # cmd_get
    ├── list.rs          # cmd_list, cmd_history, resolve_query
    ├── edit.rs          # cmd_edit + per-type edit_* helpers
    ├── trash.rs         # cmd_rm, cmd_restore, cmd_purge, cmd_trash, cmd_trash_empty,
    │                    #   purge_item
    ├── backup.rs        # cmd_backup, cmd_backup_export, cmd_backup_restore
    ├── import.rs        # cmd_import (LastPass)
    ├── attach.rs        # cmd_attach, cmd_attachments, cmd_extract, cmd_detach
    ├── settings.rs      # cmd_settings (and its subaction handlers)
    ├── sync.rs          # cmd_sync (push/pull/fetch)
    ├── status.rs        # cmd_status (vault state summary)
    ├── device.rs        # cmd_device + register/revoke/list, load_gitea_client
    ├── recovery_qr.rs   # cmd_recovery_qr, cmd_recovery_qr_generate, cmd_recovery_qr_unwrap
    ├── init.rs          # cmd_init  (also writes ParamsFile via session module)
    ├── generate.rs      # cmd_generate
    └── rate.rs          # cmd_rate
```

Rationale for the boundaries:

- **One file per top-level subcommand** is the navigation invariant: `relicario add` ⇒ `commands/add.rs`. `cmd_add` and the seven `build_*_item` helpers it calls are the ~260-line vertical slice a tinkerer wants in front of them at once. Same shape for every other subcommand.
- **`prompt.rs` is the input layer**: every command that reads from the user funnels through one module, so swapping in a different prompt strategy (richer TTY widgets, scripted-test mode) is a one-file change. The `prompt_or_flag<T>` helper introduced in phase 3 lives here.
- **`parse.rs` is the validation layer** during phases 1–6 — it owns `parse_month_year`, `base32_decode_lenient`, `guess_mime`. In phase 7 the bodies move to `relicario-core` and `parse.rs` becomes a thin re-export so callers don't have to change imports twice.
- **`helpers.rs` keeps `git_command` (the hardened `Command` builder) and gains `git_run`** (the bail-on-failure wrapper). Both are flat utilities; they belong with `vault_dir` and `iso8601`.
- **`session.rs` keeps `UnlockedVault` and absorbs the canonical `ParamsFile`** so the on-disk shape has one definition, not two. The `after_manifest_change` wrapper introduced in phase 4 also lives here, because it's logically a session method.
- **`commands/mod.rs`** re-exports each `cmd_*` so the dispatch match in `main.rs` reads as `Commands::Add { kind } => commands::add::cmd_add(kind)` — the dispatch surface still fits on one screen.

Boundary discipline: every `cmd_*` becomes `pub fn` in its module file; every `build_*_item` and `edit_*` stays `pub(super)` (only its sibling dispatcher needs it). Helpers shared across `commands/` (e.g. `commit_paths`, `resolve_query`) live in `commands/mod.rs` as `pub(crate)`. The compile error if a sibling moves and a caller forgets the visibility bump is exactly the safety net we want.

**WASM/extension parity seam (P1.10).** The three CLI parsers move into `relicario-core` in phase 7, get re-exported as `#[wasm_bindgen]` functions in phase 8, and the `extension/src/wasm.d.ts` file is updated in the same commit (per DEV-C's boundary note, that file is hand-maintained — every export change must be mirrored manually). Plan C (extension restructure) then wires SW message handlers that call those WASM exports; **the SW handlers themselves are not designed in this plan** — phase 8 only delivers the seam Plan C consumes.

## Implementation phases

### Phase 1 — Mechanical split of `main.rs`

- **Goal:** turn 2641 LOC of flat code into a navigable directory tree without changing any behaviour.
- **Changes:**
  - New: `crates/relicario-cli/src/commands/{mod,add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr,init,generate,rate}.rs`.
  - New: `crates/relicario-cli/src/prompt.rs` (lifts `prompt`, `prompt_optional`, `prompt_secret` from `main.rs:508-940`).
  - New: `crates/relicario-cli/src/parse.rs` (lifts `parse_month_year`, `base32_decode_lenient`, `guess_mime` from `main.rs:942-980`).
  - Modified: `crates/relicario-cli/src/main.rs` — keeps `mod` declarations, the clap `Cli`/`Commands`/`AddKind` enums (lines 1-455), the dispatch `match` (lines 405-454), the three `test_*_override()` shims (lines 475-503), and the `refresh_groups_cache` shim (lines 457-473) until phase 4 collapses it.
  - Modified: every other file that referenced `crate::session::UnlockedVault::unlock_interactive`, `crate::helpers::*`, `crate::test_passphrase_override` keeps working unchanged (visibility bumps only).
- **Tests:** the existing CLI integration tests at `crates/relicario-cli/tests/{basic_flows,attachments,backup,edit_and_history,settings,smart_inputs,vault_detection,import_lastpass}.rs` are the regression budget. They test from the binary surface (via `assert_cmd`), so a pure relocation must not change a single assertion. Run between every sub-step. No new tests in this phase; logic is unchanged.
- **Effort:** M (largest single phase by LOC moved; mechanical).
- **Depends on:** none.

### Phase 2 — `helpers::git_run` and the 16-site sweep

- **Goal:** replace 16 `bail!("git X failed")` one-liners with `git_run(repo, args, context)?`, so a failing git subprocess prints actionable stderr instead of just the verb.
- **Changes:**
  - Modified: `crates/relicario-cli/src/helpers.rs` — adds `pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()>` that uses `.output()` (capturing stderr), prints the captured stderr unmodified to the user's stderr on failure, and bails with `"<context>: git failed (status N)"`. Keeps `git_command` for the rare interactive callsite that needs a `Command` (phase decides per call).
  - Modified: `crates/relicario-cli/src/commands/init.rs` — three sites collapse (`main.rs:601, 602, 610` post-split). Contexts: `"init: git init"`, `"init: git add"`, `"init: git commit"`.
  - Modified: `crates/relicario-cli/src/commands/add.rs` — two sites in `commit_paths` (`main.rs:986, 988` post-split moves to `commands/mod.rs`). Context: `"add: git add <id>"`, `"add: git commit"`.
  - Modified: `crates/relicario-cli/src/commands/trash.rs` — `cmd_purge` and `cmd_trash_empty` sites (`main.rs:1477, 1480, 1897, 1900` post-split). Contexts include the item title for trace clarity. (Phase 6 batches them further.)
  - Modified: `crates/relicario-cli/src/commands/edit.rs` — `main.rs:1767` site post-split. Context: `"edit: git commit"`.
  - Modified: `crates/relicario-cli/src/commands/sync.rs` — `main.rs:2432, 2438, 2533, 2540` sites post-split. Contexts: `"sync: git fetch"`, `"sync: git pull"`, `"sync: git push"`.
  - Modified: any other `git_command(...).status()? + bail!` site identified by grep during phase 1 (DEV-B's "and others" list).
- **Tests:** add a unit test in `crates/relicario-cli/src/helpers.rs`'s `mod tests` that invokes `git_run` against a deliberately-failing git subcommand in a `tempfile::TempDir` and asserts both that the bail message contains the context string and that the captured stderr is reproduced. Existing integration tests must still pass.
- **Effort:** S.
- **Depends on:** Phase 1.

### Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression

- **Goal:** collapse the seven `build_*_item` builders so each one takes already-validated values, with a single `prompt_or_flag<T>` helper handling the "use this flag if Some, otherwise prompt" pattern.
- **Changes:**
  - Modified: `crates/relicario-cli/src/prompt.rs` — adds `pub fn prompt_or_flag<T>(flag: Option<T>, label: &str, parser: impl FnOnce(&str) -> Result<T>) -> Result<T>` and a `prompt_or_flag_optional<T>` sibling for fields where the empty-string case maps to `None`.
  - Modified: `crates/relicario-cli/src/commands/add.rs` — every `build_*_item` (post-split locations of `main.rs:664-921`) drops its `Option<T>::map(Ok).unwrap_or_else(|| prompt(...))?` chains in favour of `prompt_or_flag(title, "Title", |s| Ok(s.into()))?` etc. Per-type bodies shrink by ~30%.
- **Tests:** existing `tests/basic_flows.rs::add_login_then_list_shows_it`, `tests/smart_inputs.rs` cover the prompt path; `tests/attachments.rs` covers the document builder. Add one focused test: `prompt_or_flag_uses_flag_value_when_some` and `prompt_or_flag_prompts_when_none` (synthetic stdin via `Cursor`).
- **Effort:** S-M.
- **Depends on:** Phase 1.

### Phase 4 — `Vault::after_manifest_change` and the seven-site sweep

- **Goal:** make "every mutating handler must call `refresh_groups_cache`" a compile-time invariant rather than a discipline rule.
- **Changes:**
  - Modified: `crates/relicario-cli/src/session.rs` — adds `pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()>` that calls `self.save_manifest(manifest)?` and then writes the groups cache. Marks the existing `save_manifest` as `pub(crate)` (or renames to `save_manifest_raw` and makes it `pub(crate)`) so callers funnel through the wrapper. Cache writing logic moves out of `main.rs:457-473` into this method.
  - Modified: `crates/relicario-cli/src/commands/{add,edit,trash,attach,settings,import}.rs` — the seven sites (`main.rs:641, 998, 1123, 1197, 1414, 1432, 1474` post-split) collapse from `vault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest);` to `vault.after_manifest_change(&manifest)?;`.
  - Removed: the standalone `refresh_groups_cache` function in `main.rs:463`. The cache-write closure inside `after_manifest_change` keeps the existing "failures are silently swallowed" semantics (preserve the `let _ =` and the doc comment from `main.rs:457-462`).
- **Tests:** `tests/smart_inputs.rs::groups_cache_*` already exercises the write/suppress paths from the binary surface; rerun. Add a unit test in `session.rs` confirming `after_manifest_change` writes both the manifest and the cache file.
- **Effort:** S.
- **Depends on:** Phase 1.

### Phase 5 — Single canonical `ParamsFile`

- **Goal:** one definition of the on-disk `params.json` shape, used by both the `init` writer and the `unlock_interactive` reader.
- **Changes:**
  - Modified: `crates/relicario-cli/src/session.rs` — promotes the inner `ParamsFile` struct (`session.rs:114`) to a module-level `pub(crate) struct ParamsFile { pub format_version: u32, pub kdf: KdfParams, pub aead: String, pub salt_path: String }`. Adds `Serialize` + `Deserialize` derives. Provides constructors: `ParamsFile::for_new_vault(params: &KdfParams) -> Self` and inversion `pub fn into_kdf_params(self) -> KdfParams`. Verifies field-rename compatibility against the existing on-disk JSON (read a temp `params.json` written by current `main.rs` to confirm round-trip).
  - Modified: `crates/relicario-cli/src/commands/init.rs` — replaces the inline `ParamsFile`/`ParamsKdf` structs at the post-split equivalent of `main.rs:2287-2301` with `session::ParamsFile::for_new_vault(&params)`. Removes the duplicate writer-side definition.
  - Decision note in the module doc-comment: keep `ParamsFile` in `session.rs` rather than `relicario-core`. Rationale: the struct describes a *file on disk*, and `relicario-core` is bytes-in/bytes-out (no filesystem). The `KdfParams` value inside is core's already; the envelope is the CLI's I/O concern.
- **Tests:** add `tests/common/mod.rs` (or extend an existing test) round-trip: write via `ParamsFile::for_new_vault`, parse via `read_params`, confirm the resulting `KdfParams` matches the input. The on-disk schema must not change (`tests/basic_flows.rs::init_creates_expected_layout` indirectly covers this).
- **Effort:** S.
- **Depends on:** Phase 1.

### Phase 6 — Batched purge in `cmd_purge` and `cmd_trash_empty`

- **Goal:** a 50-item `trash empty` should fire 3 git invocations total, not 150.
- **Changes:**
  - Modified: `crates/relicario-cli/src/commands/trash.rs` — `purge_item` (post-split of `main.rs:1450-1462`) is renamed `purge_item_filesystem` and **only** mutates the working tree + manifest (filesystem `remove_file`/`remove_dir_all`, manifest `remove`). It does **not** invoke `git rm`. Returns `Vec<String>` of the paths it removed (so the caller can stage them).
  - `cmd_purge` (post-split `main.rs:1464-1482`): collects the paths from `purge_item_filesystem`, calls `vault.after_manifest_change(&manifest)?`, then a single `git_run(vault.root(), &["rm", "-rf", "--ignore-unmatch", … paths …, "manifest.enc"], "purge: git rm")?` followed by `git_run(... ["add", "manifest.enc"], "purge: git add")?` (manifest is staged separately because it was rewritten not removed) and `git_run(... ["commit", "-m", &msg], "purge: git commit")?`. Three invocations, fixed.
  - `cmd_trash_empty` (post-split `main.rs:1885-1903`): same pattern over the loop of purgeables — accumulate all paths, then one `git rm`, one `git add manifest.enc`, one `git commit`. The commit message keeps `"trash empty: purged N item(s)"`.
- **Tests:** existing `tests/basic_flows.rs::rm_restore_purge_cycle` covers the single-item purge contract. Add `tests/basic_flows.rs::trash_empty_batches_git_invocations` (or extend `tests/settings.rs`) — purge ≥3 items via `trash empty`, then assert via `git log --oneline --since=…` that exactly **one** new commit appeared (a strict invariant that catches accidental re-introduction of per-item commits). Synthetic fixtures only; no binary blobs.
- **Effort:** S-M.
- **Depends on:** Phases 1, 2, 4 (uses `git_run` and `after_manifest_change`).

### Phase 7 — Migrate parsers to `relicario-core` + `pub(crate) mod base32`

- **Goal:** close DEV-A's three-base32-implementations finding and DEV-B's parsers-in-CLI-only finding in one stroke. The CLI keeps thin wrappers (no caller-side import changes); core becomes the single source of truth.
- **Changes:**
  - Modified: `crates/relicario-core/src/time.rs` — adds `impl MonthYear { pub fn parse(s: &str) -> Result<Self, RelicarioError> { … } }` accepting `MM/YYYY`, `MM-YYYY`, and `MM/YY` (lifts the body of `parse_month_year` from `crates/relicario-cli/src/parse.rs`). Note: `MonthYear::new` currently returns `Result<_, &'static str>` (DEV-A P3); this phase has the option to either bring `new` to `RelicarioError` for consistency, or leave it and have `parse` call `new` and re-wrap. Recommendation: re-wrap only — the `new` migration is DEV-A's P3 to keep this plan focused.
  - New: `crates/relicario-core/src/base32.rs` — `pub(crate) mod base32 { pub fn encode_rfc4648(bytes: &[u8]) -> String; pub fn decode_rfc4648_lenient(s: &str) -> Result<Vec<u8>, RelicarioError>; }`. Body lifted/unified from `crates/relicario-core/src/item.rs:255-275` (the encoder) and `crates/relicario-core/src/import_lastpass.rs:202-220` (the decoder), plus the lenient input handling (case-insensitive, padding-optional, whitespace-stripped) from `crates/relicario-cli/src/parse.rs`. Steam's `STEAM_ALPHABET` at `crates/relicario-core/src/item_types/totp.rs:13` stays put with a neighbour comment: `// not RFC 4648 — Steam Guard's de-ambiguated alphabet; see crate::base32 for the standard impl.`
  - Modified: `crates/relicario-core/src/item.rs` — removes the inline `base32_encode` (lines 255-275); call sites switch to `crate::base32::encode_rfc4648`.
  - Modified: `crates/relicario-core/src/import_lastpass.rs` — removes the inline `decode_base32_totp` (lines 202-220); call sites switch to `crate::base32::decode_rfc4648_lenient`.
  - New: `crates/relicario-core/src/item_types/totp.rs` — adds `impl TotpConfig { pub fn parse_secret(s: &str) -> Result<Zeroizing<Vec<u8>>, RelicarioError> { Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?)) } }`. The `Zeroizing` wrap is the value-add over a raw base32 call.
  - New: `crates/relicario-core/src/mime.rs` — `pub fn guess_for_extension(filename: &str) -> &'static str` (lifts `guess_mime` from CLI). Default `application/octet-stream`. The match is small enough that exhaustive enum tagging (PDF/PNG/JPEG/TXT/JSON) is overkill; keep the string-match shape.
  - Modified: `crates/relicario-core/src/lib.rs` — re-exports `MonthYear` (already exported), `mime`, `base32` (`pub(crate)` only, not in the public API surface). `Totp::parse_secret` is reachable via `ItemCore::Totp(_).parse_secret(...)` already.
  - Modified: `crates/relicario-cli/src/parse.rs` — becomes a thin shim re-exporting the new core API. `pub fn parse_month_year(s: &str) -> Result<MonthYear, RelicarioError> { MonthYear::parse(s) }` (so the existing CLI callsites need zero edits in this phase). Same for `base32_decode_lenient` and `guess_mime`. Once Plan C lands and consumers have all migrated, a follow-up plan can delete `parse.rs` entirely.
  - Verification step: before phase 7, grep `grep -RIn "parse_month_year\|base32_decode_lenient\|guess_mime\|base32_encode\|decode_base32_totp" crates/ extension/` to confirm the only consumers are the CLI files about to be touched. The Cargo workspace currently has no other consumer.
- **Tests:** move CLI's existing parser unit tests (if any) into `crates/relicario-core/src/`. Add: `MonthYear::parse` round-trip for `01/2026`, `12/30`, `12-2026`, plus error cases (`13/2026`, `01/1999`, `garbage`); `base32::encode_rfc4648`/`decode_rfc4648_lenient` round-trip on a known TOTP vector (the RFC 6238 test vector already lives in `item_types/totp.rs`); `mime::guess_for_extension` table — `pdf.PDF`, `image.jpg`, `image.jpeg`, `unknown.xyz`. Keep `tests/import_lastpass.rs` green (it'll go through the new shared decoder). Existing CLI integration tests at `crates/relicario-cli/tests/*` must still pass; the public CLI surface is unchanged.
- **Effort:** M.
- **Depends on:** Phase 1.

### Phase 8 — WASM exports for the migrated parsers + wasm.d.ts mirror

- **Goal:** make the new core parsers reachable from the extension via the SW. Deliver only the seam; Plan C wires the SW handlers.
- **Changes:**
  - Modified: `crates/relicario-wasm/src/lib.rs` — adds three `#[wasm_bindgen]` exports:
    - `pub fn parse_month_year(s: &str) -> Result<JsValue, JsError>` returning the serialized `MonthYear` (`{ month, year }` plain object via the existing `Serializer::new().serialize_maps_as_objects(true)` pattern; reuse `js_value_for`).
    - `pub fn base32_decode_lenient(s: &str) -> Result<Vec<u8>, JsError>` returning the decoded bytes as a `Uint8Array` on the JS side (wasm-bindgen converts `Vec<u8>` into a `Uint8Array` copy — same convention as `attachment_decrypt`).
    - `pub fn guess_mime(filename: &str) -> String`.
  - Modified: `extension/src/wasm.d.ts` — adds the three matching declarations under the existing `declare module 'relicario-wasm'` block. Per DEV-C's boundary note, this file is hand-maintained; this commit must update both files together. Suggested placement: directly after `extract_image_secret`/`embed_image_secret` (line ~60), grouped under a `// Pure parsers (no session needed)` comment so the extension-side reader sees they require no `SessionHandle`.
  - Naming convention call-out: phase 8 keeps **snake_case** JS names (consistent with every existing export, e.g. `manifest_encrypt`, `extract_image_secret`). The snake_case → camelCase decision (DEV-B/DEV-C P3) is **explicitly deferred to a separate plan**; introducing camelCase only for the three new exports would create a worst-of-both-worlds inconsistency.
- **Tests:** add `wasm-bindgen-test` cases (or extend the existing `session_tests` mod at `crates/relicario-wasm/src/lib.rs:522-591`) covering the three new exports — at minimum, a round-trip for `base32_decode_lenient` against a known input, an error case for `parse_month_year("not-a-date")`, and a `guess_mime("doc.pdf") == "application/pdf"` smoke. Confirm `cargo build -p relicario-wasm --target wasm32-unknown-unknown` is clean.
- **Cross-boundary cite:** **after Phase 8 ships, Plan C can wire SW message handlers** for `parse_month_year`, `base32_decode_lenient`, and `guess_mime` (e.g. messages `parse_month_year`, `decode_totp_secret`, `guess_attachment_mime` in `extension/src/shared/messages.ts`, dispatched by `extension/src/service-worker/router/popup-only.ts`). This plan does **not** design those handlers; phase 8 only delivers the WASM seam Plan C consumes. Coordinate the wasm.d.ts update commit with Plan C so both crates' callers see the new surface in the same merge.
- **Effort:** S.
- **Depends on:** Phase 7.

## Risks and mitigations

- **Mechanical splits drift if a function calls a sibling that moved.** Mitigation: `cargo check -p relicario-cli` between every sub-step of phase 1 (i.e. between every file-extraction); explicit `pub(crate)` discipline so the compiler enforces visibility. The integration tests at `crates/relicario-cli/tests/*` are the regression budget — they exercise the binary surface, not internal modules, so a pure relocation cannot break them silently.
- **`helpers::git_run` semantic change: switching from `.status()` (inherited stderr to TTY) to `.output()` (captured) means interactive flows lose live stderr streaming.** A user who runs `relicario sync` in a terminal sees git's progress today; with `git_run` they see only the captured chunk on failure. Mitigation options: (a) `git_run` could keep `inherit` semantics by detecting `stderr.is_terminal()` and switching strategy; (b) accept the trade — captured-and-printed-on-failure is uniformly better for the CI/test/scripted-tooling case which is the failure-mode that matters. Recommendation: option (b) for phase 2, with a follow-up TODO if the live-streaming loss is reported. Keep `git_command` available for the rare site that needs a manual `Command` (pipe/stdin/etc.) so neither contract has to be perfect.
- **`Vault::after_manifest_change` adds a method to the session module; risk that callers forget to use the wrapper.** Mitigation: in phase 4, mark `save_manifest` as `pub(crate)` (or rename to `save_manifest_raw`) so all `commands/*` files are forced through `after_manifest_change`. The clap dispatcher calls only `cmd_*` functions, none of which need the raw method.
- **`ParamsFile` migration is on-disk-format-sensitive.** A change in the `Serialize` derive's field order or a missed `Deserialize` field tolerance would break existing vaults. Mitigation: phase 5's test round-trip reads a `params.json` written by the current code (committed as a fixture *string literal* in the test, not a binary blob) and confirms the new code parses it identically. Field names match exactly (`format_version`, `kdf`, `aead`, `salt_path`); the existing `read_params` only reads `kdf`, so adding tolerance for the other fields is the natural step.
- **Parser migration to core changes the public API surface of `relicario-core`.** Any in-flight work consuming the old CLI helpers must adapt. Mitigation: the grep step at the top of phase 7 confirms zero non-CLI consumers exist today; the CLI's `parse.rs` shim keeps callsites unchanged through phases 1–6, so this is a one-pass migration with no follow-up required from other plans. The Steam alphabet stays untouched (intentional non-RFC-4648 alphabet) with a neighbour comment.
- **WASM export rename / addition could break the extension at the `wasm.d.ts` boundary.** Per DEV-C's boundary notes, `extension/src/wasm.d.ts` is hand-maintained; every change to `crates/relicario-wasm/src/lib.rs`'s `#[wasm_bindgen]` surface must be mirrored manually. Mitigation: phase 8 updates both files in the same commit; a future CI guard (DEV-C's WASM P2 — comparing wasm-pack–generated `.d.ts` against the hand-maintained one) is out of scope here but would close this hole permanently. The boundary notes also flag the BigInt typing care that `attachment_encrypt` already requires (DEV-B item 3 in DEV-C's boundary notes); the three new exports take only `&str` and return primitives, so they avoid that class of footgun.
- **Cross-plan coupling.** Phase 8 lands a wasm.d.ts change; Plan C will land SW handlers consuming it. If the merge order interleaves — e.g. Plan C ships its handler stub before phase 8 lands the WASM export — the extension build breaks. Mitigation: phase 8 ships first; Plan C's SW-handler phase explicitly depends on phase 8's WASM exports (cite this seam in Plan C). The user's release-train coordination is the enforcement mechanism.

## Out of scope

- **Plan A** (security/docs polish) and **Plan C** (extension restructure) entirely.
- **All CLI P3 nits** that DEV-B enumerates: `let _ = entry;` pattern (`main.rs:1170, 1407, 1426, 1469, 1913, 2030`); `Lock` subcommand visibility; `Display` for `ItemType` (the `format!("{:?}", e.r#type)` site at `main.rs:1158`); `helpers::relicario_dir` adoption sweep; `gitea::GiteaClient` per-call construction; scripted-prompt test layer for `tests/edit_and_history.rs`; dead `blob_path` variable in `tests/attachments.rs:69-76`; the three-test-env-var macro consolidation; `cmd_recovery_qr_unwrap` empty-input check; `cmd_recovery_qr_generate` stdout/stderr split; the Task-12 `cmd_backup_export` cleanup. Each is a one-line edit appropriate for a follow-up sweep, not for this M-L plan.
- **Server findings** (P2/P3 in DEV-B's relicario-server section): `generate-hook` `$PATH`, bootstrap-branch tightening, `verify-commit --from-stdin`, server test coverage gaps. These belong in a server-focused plan.
- **WASM findings beyond the parser exports needed for P1.10**: DEV-B's WASM P2 list (`need_key`/`with(...).unwrap()` double-lookup, `Vec<u8>` getter clones, `wasm_*_recovery_qr` rename, `device.rs`/`session.rs` concurrency-primitive split). Not in this plan.
- **The 8 "Open architectural decisions"** in the synthesis appendix.
- **WASM JS-naming** (snake_case → camelCase) — defer to a separate plan, as called out in phase 8.
- **In-flight uncommitted v0.5.x work** (`extension/src/vault/vault.ts`, `vault.css`, `glyphs.ts`, manifest version bumps, relay `queue.ts`/`server.ts`/`start.sh`). Treat as in-flight; this plan touches none of them.

## Done criteria

A reviewer can confirm Plan B has shipped by checking, in order:

- [ ] `crates/relicario-cli/src/main.rs` is ≤ 500 LOC and contains only the clap surface, the dispatch `match`, and the `mod` declarations + `test_*_override` shims.
- [ ] The `crates/relicario-cli/src/commands/` directory contains the 17 files listed in the post-split tree, each owning exactly one top-level subcommand or a tightly-scoped helper.
- [ ] `crates/relicario-cli/src/prompt.rs` and `crates/relicario-cli/src/parse.rs` exist, with the 6 prompt helpers and the 3 parser shims respectively.
- [ ] `cargo test --workspace` passes (CLI integration tests, core unit tests, server tests, wasm `wasm-bindgen-test`s).
- [ ] `cargo clippy --workspace` is silent.
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` is clean.
- [ ] The 16 `bail!("git X failed")` sites listed in P1.3 (and any others surfaced by grep) are now `git_run(repo, args, "<context>")?` one-liners. `grep -n 'bail!("git ' crates/relicario-cli/src/` returns zero matches.
- [ ] The 7 `refresh_groups_cache` callsites all collapse to `vault.after_manifest_change(&manifest)?`. `grep -n 'refresh_groups_cache' crates/relicario-cli/src/` returns zero matches outside `session.rs`.
- [ ] One canonical `ParamsFile` definition in `crates/relicario-cli/src/session.rs`. `grep -n 'struct ParamsFile' crates/relicario-cli/src/` returns one match.
- [ ] A 50-item `relicario trash empty` produces exactly **one** new git commit (verified by `git log --oneline` in the test); a single-item `relicario purge` produces exactly one commit. Per-purge git invocations: 3 (rm + add manifest + commit), down from 3-per-item.
- [ ] `MonthYear::parse`, `Totp::parse_secret`, `mime::guess_for_extension` exist in `relicario-core`. `crates/relicario-core/src/base32.rs` is `pub(crate)` and is the only RFC-4648 implementation in the crate (Steam alphabet at `item_types/totp.rs:13` stays, with a neighbour comment).
- [ ] `crates/relicario-wasm/src/lib.rs` exports `parse_month_year`, `base32_decode_lenient`, `guess_mime`. `extension/src/wasm.d.ts` mirrors the three declarations.
- [ ] All existing CLI integration tests at `crates/relicario-cli/tests/*` still pass without modification (regression budget held).
