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>
32 KiB
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-2641is 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 atcrates/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 atcrates/relicario-cli/src/main.rs:942-980despite 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 atcrates/relicario-core/src/item_types/totp.rs:13). - CLI P2 —
build_*_itemcomplexity atcrates/relicario-cli/src/main.rs:664-921(DEV-B). Each builder mixes prompting, parsing, and core construction. - CLI P2 —
refresh_groups_cachediscipline atcrates/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 —
ParamsFiledefined twice with mismatching shapes atcrates/relicario-cli/src/main.rs:2287(writer withaead,salt_path,format_version) versuscrates/relicario-cli/src/session.rs:114(reader with onlykdf) (DEV-B). - CLI P2 — Batched purge needed at
crates/relicario-cli/src/main.rs:1476-1480and:1896-1900(DEV-B). A 50-itemtrash emptycurrently 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_addand the sevenbuild_*_itemhelpers it calls are the ~260-line vertical slice a tinkerer wants in front of them at once. Same shape for every other subcommand. prompt.rsis 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. Theprompt_or_flag<T>helper introduced in phase 3 lives here.parse.rsis the validation layer during phases 1–6 — it ownsparse_month_year,base32_decode_lenient,guess_mime. In phase 7 the bodies move torelicario-coreandparse.rsbecomes a thin re-export so callers don't have to change imports twice.helpers.rskeepsgit_command(the hardenedCommandbuilder) and gainsgit_run(the bail-on-failure wrapper). Both are flat utilities; they belong withvault_dirandiso8601.session.rskeepsUnlockedVaultand absorbs the canonicalParamsFileso the on-disk shape has one definition, not two. Theafter_manifest_changewrapper introduced in phase 4 also lives here, because it's logically a session method.commands/mod.rsre-exports eachcmd_*so the dispatch match inmain.rsreads asCommands::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(liftsprompt,prompt_optional,prompt_secretfrommain.rs:508-940). - New:
crates/relicario-cli/src/parse.rs(liftsparse_month_year,base32_decode_lenient,guess_mimefrommain.rs:942-980). - Modified:
crates/relicario-cli/src/main.rs— keepsmoddeclarations, the clapCli/Commands/AddKindenums (lines 1-455), the dispatchmatch(lines 405-454), the threetest_*_override()shims (lines 475-503), and therefresh_groups_cacheshim (lines 457-473) until phase 4 collapses it. - Modified: every other file that referenced
crate::session::UnlockedVault::unlock_interactive,crate::helpers::*,crate::test_passphrase_overridekeeps working unchanged (visibility bumps only).
- New:
- 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}.rsare the regression budget. They test from the binary surface (viaassert_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 withgit_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— addspub 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)". Keepsgit_commandfor the rare interactive callsite that needs aCommand(phase decides per call). - Modified:
crates/relicario-cli/src/commands/init.rs— three sites collapse (main.rs:601, 602, 610post-split). Contexts:"init: git init","init: git add","init: git commit". - Modified:
crates/relicario-cli/src/commands/add.rs— two sites incommit_paths(main.rs:986, 988post-split moves tocommands/mod.rs). Context:"add: git add <id>","add: git commit". - Modified:
crates/relicario-cli/src/commands/trash.rs—cmd_purgeandcmd_trash_emptysites (main.rs:1477, 1480, 1897, 1900post-split). Contexts include the item title for trace clarity. (Phase 6 batches them further.) - Modified:
crates/relicario-cli/src/commands/edit.rs—main.rs:1767site post-split. Context:"edit: git commit". - Modified:
crates/relicario-cli/src/commands/sync.rs—main.rs:2432, 2438, 2533, 2540sites 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).
- Modified:
- Tests: add a unit test in
crates/relicario-cli/src/helpers.rs'smod teststhat invokesgit_runagainst a deliberately-failing git subcommand in atempfile::TempDirand 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_*_itembuilders so each one takes already-validated values, with a singleprompt_or_flag<T>helper handling the "use this flag if Some, otherwise prompt" pattern. - Changes:
- Modified:
crates/relicario-cli/src/prompt.rs— addspub fn prompt_or_flag<T>(flag: Option<T>, label: &str, parser: impl FnOnce(&str) -> Result<T>) -> Result<T>and aprompt_or_flag_optional<T>sibling for fields where the empty-string case maps toNone. - Modified:
crates/relicario-cli/src/commands/add.rs— everybuild_*_item(post-split locations ofmain.rs:664-921) drops itsOption<T>::map(Ok).unwrap_or_else(|| prompt(...))?chains in favour ofprompt_or_flag(title, "Title", |s| Ok(s.into()))?etc. Per-type bodies shrink by ~30%.
- Modified:
- Tests: existing
tests/basic_flows.rs::add_login_then_list_shows_it,tests/smart_inputs.rscover the prompt path;tests/attachments.rscovers the document builder. Add one focused test:prompt_or_flag_uses_flag_value_when_someandprompt_or_flag_prompts_when_none(synthetic stdin viaCursor). - 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— addspub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()>that callsself.save_manifest(manifest)?and then writes the groups cache. Marks the existingsave_manifestaspub(crate)(or renames tosave_manifest_rawand makes itpub(crate)) so callers funnel through the wrapper. Cache writing logic moves out ofmain.rs:457-473into 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, 1474post-split) collapse fromvault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest);tovault.after_manifest_change(&manifest)?;. - Removed: the standalone
refresh_groups_cachefunction inmain.rs:463. The cache-write closure insideafter_manifest_changekeeps the existing "failures are silently swallowed" semantics (preserve thelet _ =and the doc comment frommain.rs:457-462).
- Modified:
- Tests:
tests/smart_inputs.rs::groups_cache_*already exercises the write/suppress paths from the binary surface; rerun. Add a unit test insession.rsconfirmingafter_manifest_changewrites 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.jsonshape, used by both theinitwriter and theunlock_interactivereader. - Changes:
- Modified:
crates/relicario-cli/src/session.rs— promotes the innerParamsFilestruct (session.rs:114) to a module-levelpub(crate) struct ParamsFile { pub format_version: u32, pub kdf: KdfParams, pub aead: String, pub salt_path: String }. AddsSerialize+Deserializederives. Provides constructors:ParamsFile::for_new_vault(params: &KdfParams) -> Selfand inversionpub fn into_kdf_params(self) -> KdfParams. Verifies field-rename compatibility against the existing on-disk JSON (read a tempparams.jsonwritten by currentmain.rsto confirm round-trip). - Modified:
crates/relicario-cli/src/commands/init.rs— replaces the inlineParamsFile/ParamsKdfstructs at the post-split equivalent ofmain.rs:2287-2301withsession::ParamsFile::for_new_vault(¶ms). Removes the duplicate writer-side definition. - Decision note in the module doc-comment: keep
ParamsFileinsession.rsrather thanrelicario-core. Rationale: the struct describes a file on disk, andrelicario-coreis bytes-in/bytes-out (no filesystem). TheKdfParamsvalue inside is core's already; the envelope is the CLI's I/O concern.
- Modified:
- Tests: add
tests/common/mod.rs(or extend an existing test) round-trip: write viaParamsFile::for_new_vault, parse viaread_params, confirm the resultingKdfParamsmatches the input. The on-disk schema must not change (tests/basic_flows.rs::init_creates_expected_layoutindirectly covers this). - Effort: S.
- Depends on: Phase 1.
Phase 6 — Batched purge in cmd_purge and cmd_trash_empty
- Goal: a 50-item
trash emptyshould fire 3 git invocations total, not 150. - Changes:
- Modified:
crates/relicario-cli/src/commands/trash.rs—purge_item(post-split ofmain.rs:1450-1462) is renamedpurge_item_filesystemand only mutates the working tree + manifest (filesystemremove_file/remove_dir_all, manifestremove). It does not invokegit rm. ReturnsVec<String>of the paths it removed (so the caller can stage them). cmd_purge(post-splitmain.rs:1464-1482): collects the paths frompurge_item_filesystem, callsvault.after_manifest_change(&manifest)?, then a singlegit_run(vault.root(), &["rm", "-rf", "--ignore-unmatch", … paths …, "manifest.enc"], "purge: git rm")?followed bygit_run(... ["add", "manifest.enc"], "purge: git add")?(manifest is staged separately because it was rewritten not removed) andgit_run(... ["commit", "-m", &msg], "purge: git commit")?. Three invocations, fixed.cmd_trash_empty(post-splitmain.rs:1885-1903): same pattern over the loop of purgeables — accumulate all paths, then onegit rm, onegit add manifest.enc, onegit commit. The commit message keeps"trash empty: purged N item(s)".
- Modified:
- Tests: existing
tests/basic_flows.rs::rm_restore_purge_cyclecovers the single-item purge contract. Addtests/basic_flows.rs::trash_empty_batches_git_invocations(or extendtests/settings.rs) — purge ≥3 items viatrash empty, then assert viagit 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_runandafter_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— addsimpl MonthYear { pub fn parse(s: &str) -> Result<Self, RelicarioError> { … } }acceptingMM/YYYY,MM-YYYY, andMM/YY(lifts the body ofparse_month_yearfromcrates/relicario-cli/src/parse.rs). Note:MonthYear::newcurrently returnsResult<_, &'static str>(DEV-A P3); this phase has the option to either bringnewtoRelicarioErrorfor consistency, or leave it and haveparsecallnewand re-wrap. Recommendation: re-wrap only — thenewmigration 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 fromcrates/relicario-core/src/item.rs:255-275(the encoder) andcrates/relicario-core/src/import_lastpass.rs:202-220(the decoder), plus the lenient input handling (case-insensitive, padding-optional, whitespace-stripped) fromcrates/relicario-cli/src/parse.rs. Steam'sSTEAM_ALPHABETatcrates/relicario-core/src/item_types/totp.rs:13stays 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 inlinebase32_encode(lines 255-275); call sites switch tocrate::base32::encode_rfc4648. - Modified:
crates/relicario-core/src/import_lastpass.rs— removes the inlinedecode_base32_totp(lines 202-220); call sites switch tocrate::base32::decode_rfc4648_lenient. - New:
crates/relicario-core/src/item_types/totp.rs— addsimpl TotpConfig { pub fn parse_secret(s: &str) -> Result<Zeroizing<Vec<u8>>, RelicarioError> { Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?)) } }. TheZeroizingwrap 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(liftsguess_mimefrom CLI). Defaultapplication/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-exportsMonthYear(already exported),mime,base32(pub(crate)only, not in the public API surface).Totp::parse_secretis reachable viaItemCore::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 forbase32_decode_lenientandguess_mime. Once Plan C lands and consumers have all migrated, a follow-up plan can deleteparse.rsentirely. - 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.
- Modified:
- Tests: move CLI's existing parser unit tests (if any) into
crates/relicario-core/src/. Add:MonthYear::parseround-trip for01/2026,12/30,12-2026, plus error cases (13/2026,01/1999,garbage);base32::encode_rfc4648/decode_rfc4648_lenientround-trip on a known TOTP vector (the RFC 6238 test vector already lives initem_types/totp.rs);mime::guess_for_extensiontable —pdf.PDF,image.jpg,image.jpeg,unknown.xyz. Keeptests/import_lastpass.rsgreen (it'll go through the new shared decoder). Existing CLI integration tests atcrates/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 serializedMonthYear({ month, year }plain object via the existingSerializer::new().serialize_maps_as_objects(true)pattern; reusejs_value_for).pub fn base32_decode_lenient(s: &str) -> Result<Vec<u8>, JsError>returning the decoded bytes as aUint8Arrayon the JS side (wasm-bindgen convertsVec<u8>into aUint8Arraycopy — same convention asattachment_decrypt).pub fn guess_mime(filename: &str) -> String.
- Modified:
extension/src/wasm.d.ts— adds the three matching declarations under the existingdeclare 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 afterextract_image_secret/embed_image_secret(line ~60), grouped under a// Pure parsers (no session needed)comment so the extension-side reader sees they require noSessionHandle. - 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.
- Modified:
- Tests: add
wasm-bindgen-testcases (or extend the existingsession_testsmod atcrates/relicario-wasm/src/lib.rs:522-591) covering the three new exports — at minimum, a round-trip forbase32_decode_lenientagainst a known input, an error case forparse_month_year("not-a-date"), and aguess_mime("doc.pdf") == "application/pdf"smoke. Confirmcargo build -p relicario-wasm --target wasm32-unknown-unknownis clean. - Cross-boundary cite: after Phase 8 ships, Plan C can wire SW message handlers for
parse_month_year,base32_decode_lenient, andguess_mime(e.g. messagesparse_month_year,decode_totp_secret,guess_attachment_mimeinextension/src/shared/messages.ts, dispatched byextension/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-clibetween every sub-step of phase 1 (i.e. between every file-extraction); explicitpub(crate)discipline so the compiler enforces visibility. The integration tests atcrates/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_runsemantic change: switching from.status()(inherited stderr to TTY) to.output()(captured) means interactive flows lose live stderr streaming. A user who runsrelicario syncin a terminal sees git's progress today; withgit_runthey see only the captured chunk on failure. Mitigation options: (a)git_runcould keepinheritsemantics by detectingstderr.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. Keepgit_commandavailable for the rare site that needs a manualCommand(pipe/stdin/etc.) so neither contract has to be perfect.Vault::after_manifest_changeadds a method to the session module; risk that callers forget to use the wrapper. Mitigation: in phase 4, marksave_manifestaspub(crate)(or rename tosave_manifest_raw) so allcommands/*files are forced throughafter_manifest_change. The clap dispatcher calls onlycmd_*functions, none of which need the raw method.ParamsFilemigration is on-disk-format-sensitive. A change in theSerializederive's field order or a missedDeserializefield tolerance would break existing vaults. Mitigation: phase 5's test round-trip reads aparams.jsonwritten 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 existingread_paramsonly readskdf, 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'sparse.rsshim 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.tsboundary. Per DEV-C's boundary notes,extension/src/wasm.d.tsis hand-maintained; every change tocrates/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.tsagainst the hand-maintained one) is out of scope here but would close this hole permanently. The boundary notes also flag the BigInt typing care thatattachment_encryptalready requires (DEV-B item 3 in DEV-C's boundary notes); the three new exports take only&strand 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);Locksubcommand visibility;DisplayforItemType(theformat!("{:?}", e.r#type)site atmain.rs:1158);helpers::relicario_diradoption sweep;gitea::GiteaClientper-call construction; scripted-prompt test layer fortests/edit_and_history.rs; deadblob_pathvariable intests/attachments.rs:69-76; the three-test-env-var macro consolidation;cmd_recovery_qr_unwrapempty-input check;cmd_recovery_qr_generatestdout/stderr split; the Task-12cmd_backup_exportcleanup. 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_qrrename,device.rs/session.rsconcurrency-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, relayqueue.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.rsis ≤ 500 LOC and contains only the clap surface, the dispatchmatch, and themoddeclarations +test_*_overrideshims.- 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.rsandcrates/relicario-cli/src/parse.rsexist, with the 6 prompt helpers and the 3 parser shims respectively.cargo test --workspacepasses (CLI integration tests, core unit tests, server tests, wasmwasm-bindgen-tests).cargo clippy --workspaceis silent.cargo build -p relicario-wasm --target wasm32-unknown-unknownis clean.- The 16
bail!("git X failed")sites listed in P1.3 (and any others surfaced by grep) are nowgit_run(repo, args, "<context>")?one-liners.grep -n 'bail!("git ' crates/relicario-cli/src/returns zero matches. - The 7
refresh_groups_cachecallsites all collapse tovault.after_manifest_change(&manifest)?.grep -n 'refresh_groups_cache' crates/relicario-cli/src/returns zero matches outsidesession.rs. - One canonical
ParamsFiledefinition incrates/relicario-cli/src/session.rs.grep -n 'struct ParamsFile' crates/relicario-cli/src/returns one match. - A 50-item
relicario trash emptyproduces exactly one new git commit (verified bygit log --onelinein the test); a single-itemrelicario purgeproduces 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_extensionexist inrelicario-core.crates/relicario-core/src/base32.rsispub(crate)and is the only RFC-4648 implementation in the crate (Steam alphabet atitem_types/totp.rs:13stays, with a neighbour comment).crates/relicario-wasm/src/lib.rsexportsparse_month_year,base32_decode_lenient,guess_mime.extension/src/wasm.d.tsmirrors the three declarations.- All existing CLI integration tests at
crates/relicario-cli/tests/*still pass without modification (regression budget held).