Files
relicario/docs/superpowers/specs/2026-05-04-cli-restructure-design.md
adlee-was-taken 4f7ab91f14 docs(specs): three architecture-review followup plans (security/CLI/extension)
Plan A (security & docs polish, S): SessionHandle impl Drop + JS .free()
audit + recovery_qr.rs documentation + relay launcher dev-c expansion.
Independent of B/C; ships first.

Plan B (CLI restructure, M-L): split cli/main.rs (2641 LOC) into commands/
folder + prompt.rs + parse.rs; helpers::git_run captures stderr; Vault::
after_manifest_change centralizes the groups-cache discipline; canonical
ParamsFile; batched purge; migrate parse_month_year/base32_decode_lenient/
guess_mime to relicario-core with WASM re-exports.

Plan C (extension restructure, L): typed StateHost (precondition); extract
service-worker/storage.ts; setup.ts SW migration via create_vault/
attach_vault messages + step-registry pattern; vault.ts split into
shell/sidebar/list/drawer/form-wrapper with vault_locked channel
unified through shared/state.ts; P2 cluster (timer reset, gitHost clear,
teardown helper, allSettled, MutationObserver debounce); get_vault_status
closes the relicario status parity gap.

Cross-boundary cites verified: Plan B Phase 8 WASM exports are the seam
Plan C consumes (deferred to a future plan); Plan A owns the .free() swallow
removal that Plan C respects without redoing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:02:40 -04:00

216 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 16 — 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 16, 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-packgenerated `.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).