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

32 KiB
Raw Permalink Blame History

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.2crates/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 addcommands/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.rscmd_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.rsmain.rs:1767 site post-split. Context: "edit: git commit".
    • Modified: crates/relicario-cli/src/commands/sync.rsmain.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.rspurge_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.rspub(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.rspub 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-tests).
  • 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).