//! Relicario CLI — the platform layer for the Relicario password manager. //! //! See module docs for the unlock flow and vault layout. mod commands; mod device; mod gitea; mod helpers; mod parse; mod prompt; mod session; mod org_session; use std::path::PathBuf; use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; #[derive(Parser)] #[command( name = "relicario", version, about = "Relicario — git-backed password manager with reference-image two-factor unlock" )] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Initialize a new vault in the current directory. Init { /// Carrier JPEG to embed the secret into. #[arg(long)] image: PathBuf, /// Output path for the reference image (gitignored). #[arg(long, default_value = "reference.jpg")] output: PathBuf, }, /// Add a new item. Type-specific flags populate the core; missing fields /// are prompted for interactively. Add { #[command(subcommand)] kind: AddKind, }, /// Print an item. Secrets are masked by default; pass --show to reveal. Get { /// Item id or case-insensitive title substring. query: String, /// Print secret field values in plaintext. #[arg(long)] show: bool, /// Copy the primary secret (Login.password, Card.number, etc.) to clipboard. #[arg(long)] copy: bool, }, /// List items. List { #[arg(long)] r#type: Option, #[arg(long)] group: Option, #[arg(long)] tag: Option, #[arg(long)] trashed: bool, }, /// Edit an item interactively. Edit { query: String, /// Decode an `otpauth://` QR image to set the TOTP secret (login items only). #[arg(long, value_name = "PATH")] totp_qr: Option, }, /// View captured field history for an item. Values are masked by /// default; pass `--show` to reveal them. History { query: String, #[arg(long)] show: bool, /// Filter to a single field (matches against the synthetic key, /// e.g. `login_password`, `card_number`, `totp_secret`). #[arg(long)] field: Option, }, /// Soft-delete an item (moves to trash; reversible via `restore`). Rm { query: String }, /// Restore a soft-deleted item. Restore { query: String }, /// Permanently purge an item (and its attachments). Purge { query: String }, /// Trash operations. Trash { #[command(subcommand)] action: TrashAction, }, /// Backup operations: pack and unpack `.relbak` archives. Backup { #[command(subcommand)] action: BackupAction, }, /// Import items from another password manager into the unlocked vault. Import { #[command(subcommand)] action: ImportAction, }, /// Attach a file to an item. Attach { query: String, file: PathBuf }, /// List attachments on an item. Attachments { query: String }, /// Extract an attachment to disk. Extract { query: String, aid: String, #[arg(long)] out: Option, }, /// Remove an individual attachment from an item (deletes the encrypted /// blob and updates the item + manifest). Use `purge` to drop the entire /// item and all its attachments at once. Detach { query: String, aid: String }, /// Generate a password or passphrase. When run inside an initialized /// vault, falls back to `settings generator-defaults` for unspecified /// flags; outside a vault, uses built-in defaults (length 20, safe /// symbol set, 5 BIP39 words, space separator). #[command(alias = "gen")] Generate { #[arg(short = 'l', long)] length: Option, #[arg(long)] bip39: bool, #[arg(short = 'w', long)] words: Option, #[arg(long)] symbols: Option, /// Separator for BIP39 words. #[arg(long)] separator: Option, }, /// View or change vault settings. Settings { #[command(subcommand)] action: SettingsAction, }, /// Sync with the git remote (pull --rebase + push). Sync, /// Print a summary of the vault: items, attachments, last commit. Status, /// Lock the vault (no-op in CLI; present for UX parity with the extension). Lock, /// Emit a shell completion script for the given shell. /// /// For `--group ` autocomplete, the bash/zsh/fish scripts read /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file, /// which the CLI refreshes on every manifest read. In debug builds, set /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion /// will fall back to no value enumeration). /// /// Pipe stdout to your shell's completion location (e.g. /// `relicario completions bash > /etc/bash_completion.d/relicario`). Completions { #[arg(value_enum)] shell: Shell, }, /// Rate a passphrase with zxcvbn — prints score (0-4) and estimated /// guesses. Informational only; does not gate vault operations. /// /// Pass `-` as the argument to read one line from stdin instead, which /// keeps the passphrase out of shell history. Rate { /// Passphrase to score, or `-` to read from stdin. passphrase: String, }, /// Manage registered devices (signing keys + deploy keys). Device { #[command(subcommand)] action: DeviceAction, }, /// Recovery QR operations — generate or unwrap the 2FA recovery code. RecoveryQr { #[command(subcommand)] cmd: RecoveryQrCmd, }, /// Manage a multi-user org vault. Org { /// Path to the org vault directory (overrides RELICARIO_ORG_DIR). #[arg(long, global = true)] dir: Option, #[command(subcommand)] subcommand: OrgCommands, }, } #[derive(Subcommand)] pub(crate) enum AddKind { Login { #[arg(long)] title: Option, #[arg(long)] username: Option, #[arg(long)] url: Option, /// Prompt for password (vs reading from stdin or --password). #[arg(long)] password_prompt: bool, #[arg(long)] password: Option, /// Read the password from stdin (one line) instead of prompting. #[arg(long)] password_stdin: bool, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] favorite: bool, /// Decode an `otpauth://` QR image to fill the TOTP secret. #[arg(long, value_name = "PATH")] totp_qr: Option, }, SecureNote { #[arg(long)] title: Option, /// Read the note body from stdin (to EOF) instead of printing the Ctrl-D hint. #[arg(long)] body_stdin: bool, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Identity { #[arg(long)] title: Option, #[arg(long)] full_name: Option, #[arg(long)] email: Option, #[arg(long)] phone: Option, #[arg(long)] date_of_birth: Option, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Card { #[arg(long)] title: Option, #[arg(long)] holder: Option, #[arg(long)] expiry: Option, // MM/YYYY #[arg(long, default_value = "credit")] kind: String, /// Read the card number from stdin (one line) instead of prompting. #[arg(long)] number_stdin: bool, /// Read the CVV from stdin (one line) instead of prompting. #[arg(long)] cvv_stdin: bool, /// Read the PIN from stdin (one line) instead of prompting. #[arg(long)] pin_stdin: bool, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Key { #[arg(long)] title: Option, #[arg(long)] label: Option, #[arg(long)] algorithm: Option, /// Read the key material from stdin (to EOF) instead of printing the Ctrl-D hint. #[arg(long)] material_stdin: bool, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Document { #[arg(long)] title: Option, #[arg(long)] file: PathBuf, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Totp { #[arg(long)] title: Option, #[arg(long)] issuer: Option, #[arg(long)] label: Option, #[arg(long)] secret: Option, // base32 /// Read the TOTP secret from stdin (one line) instead of prompting. #[arg(long)] secret_stdin: bool, #[arg(long, default_value = "30")] period: u32, #[arg(long, default_value = "6")] digits: u8, #[arg(long, default_value = "sha1")] algorithm: String, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, } #[derive(Subcommand)] pub(crate) enum TrashAction { /// List trashed items. List, /// Purge every trashed item past its retention window. Empty, } #[derive(Subcommand)] pub(crate) enum SettingsAction { /// Show current settings as JSON. Show, /// Set trash retention (e.g., --days 30 or --forever). TrashRetention { #[arg(long)] days: Option, #[arg(long)] forever: bool, }, /// Set field history retention. HistoryRetention { #[arg(long)] last_n: Option, #[arg(long)] days: Option, #[arg(long)] forever: bool, }, /// Set per-attachment max size in bytes. AttachmentCap { #[arg(long)] per_attachment_max_bytes: Option, #[arg(long)] per_item_max_count: Option, #[arg(long)] per_vault_soft_cap_bytes: Option, #[arg(long)] per_vault_hard_cap_bytes: Option, }, /// Update the default password / passphrase generator settings used by /// `relicario generate` when run inside this vault. Pass `--bip39` or /// `--random` to switch mode; per-attribute flags update fields of the /// chosen mode. GeneratorDefaults { /// Switch the default mode to random-character password. #[arg(long, conflicts_with = "bip39")] random: bool, /// Switch the default mode to BIP39 passphrase. #[arg(long, conflicts_with = "random")] bip39: bool, /// Random mode: total password length. #[arg(long)] length: Option, /// BIP39 mode: number of words. #[arg(long)] words: Option, /// Random mode: symbol charset (`safe`, `extended`, or a custom literal). #[arg(long)] symbols: Option, /// BIP39 mode: word separator. #[arg(long)] separator: Option, }, } #[derive(Subcommand)] pub(crate) enum BackupAction { /// Pack the local vault into a single encrypted `.relbak` file. /// Backup passphrase is independent of the vault passphrase. Export { /// Output `.relbak` path. out: PathBuf, /// Bundle the reference JPEG into the encrypted envelope. #[arg(long)] include_image: bool, /// Override the reference image path (defaults to the vault's /// `reference.jpg` or `RELICARIO_IMAGE`). #[arg(long)] image: Option, /// Skip bundling `.git/` history. #[arg(long)] no_history: bool, }, /// Unpack a `.relbak` file into a fresh vault directory. Restore { /// Input `.relbak` path. input: PathBuf, /// Target directory (must NOT already contain `.relicario/`). /// Defaults to the current directory. #[arg(default_value = ".")] target: PathBuf, }, } #[derive(Subcommand)] pub(crate) enum ImportAction { /// Import a LastPass CSV export into the unlocked vault. /// Each row creates a new item with a freshly-minted ID; title /// collisions are kept (no dedup). Failed rows are skipped and /// reported on stderr. Lastpass { /// Path to the LastPass-format CSV export. csv: PathBuf, }, } #[derive(Subcommand)] pub(crate) enum DeviceAction { /// Register this machine as a new device. /// /// Generates two ed25519 keypairs: one for signing commits, one for push /// access (deploy key). The deploy public key is registered via the Gitea /// API. Both private keys are stored locally in /// `~/.config/relicario/devices//`. The vault's `.relicario/devices.json` /// is updated and committed. /// /// Required environment variables (or flags): /// RELICARIO_GITEA_URL — e.g. https://git.example.com /// RELICARIO_GITEA_TOKEN — personal access token with repo write access /// RELICARIO_GITEA_OWNER — repository owner /// RELICARIO_GITEA_REPO — repository name Add { /// Human-readable name for this device (e.g. "laptop-2026"). #[arg(long)] name: String, /// Gitea API base URL (overrides RELICARIO_GITEA_URL). #[arg(long)] gitea_url: Option, /// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN). #[arg(long)] gitea_token: Option, /// Gitea repository owner (overrides RELICARIO_GITEA_OWNER). #[arg(long)] owner: Option, /// Gitea repository name (overrides RELICARIO_GITEA_REPO). #[arg(long)] repo: Option, /// Skip Gitea API registration (useful when the remote is not Gitea). #[arg(long)] no_gitea: bool, }, /// Revoke a registered device. /// /// Removes the device from `devices.json`, adds it to `revoked.json`, /// deletes the deploy key from Gitea, and commits the change. Revoke { /// Name of the device to revoke. #[arg(long)] name: String, }, /// List registered devices. List, } #[derive(clap::Subcommand)] pub(crate) enum RecoveryQrCmd { /// Generate a recovery QR code and display it as ASCII art in the terminal. Generate, /// Unwrap a recovery QR payload (base64) to recover the image_secret as hex. Unwrap, } #[derive(clap::Subcommand)] pub(crate) enum OrgCommands { /// Create a new org vault. Init { #[arg(long)] name: String, }, /// Add a member to the org. AddMember { /// OpenSSH ed25519 public key of the new member. #[arg(long)] key: String, /// Display name. #[arg(long)] name: String, /// Role: owner, admin, or member. #[arg(long, default_value = "member")] role: String, }, /// Remove a member from the org. RemoveMember { /// Member ID prefix. member_id: String, }, /// Change a member's role. SetRole { member_id: String, role: String, }, /// Create a collection. CreateCollection { slug: String, #[arg(long)] name: String, }, /// Grant a member access to a collection. Grant { member_id: String, collection: String, }, /// Revoke a member's access to a collection. Revoke { member_id: String, collection: String, }, /// Rotate the org master key (run after removing a member). RotateKey, /// Transfer ownership to another member (owner only). By default the caller /// is demoted to admin; pass --keep-owner for explicit co-ownership. TransferOwnership { member_id: String, /// Keep the caller as an owner too (co-ownership) instead of demoting. #[arg(long)] keep_owner: bool, }, /// Delete the org (owner only; requires --confirm). DeleteOrg { #[arg(long)] confirm: bool, }, /// Show org members and collections. Status, /// Query the org audit log. Audit { #[arg(long)] since: Option, #[arg(long)] member: Option, #[arg(long)] collection: Option, #[arg(long)] action: Option, /// Output format: `table` (default) or `json`. #[arg(long, default_value = "table")] format: String, }, /// Add an item to a collection in the org vault. Add { #[command(subcommand)] kind: OrgAddKind, }, /// Print an org item (secrets masked unless --show). Get { /// Item id or case-insensitive title substring. query: String, #[arg(long)] show: bool, }, /// List org items visible to you (filtered by your collection grants). List { #[arg(long)] trashed: bool, }, /// Edit an org item interactively (per-type prompts; blank keeps current). Edit { /// Item id or case-insensitive title substring. query: String, /// Replace the login TOTP secret from a QR image. #[arg(long)] totp_qr: Option, /// Replace a Document item's attachment file. #[arg(long)] file: Option, }, /// Soft-delete an org item (reversible via `org restore`). Rm { query: String }, /// Restore a soft-deleted org item. Restore { query: String }, /// Permanently purge an org item (deletes the encrypted blob). Purge { query: String }, } #[derive(clap::Subcommand)] pub(crate) enum OrgAddKind { /// A login (username / url / password). Login { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] username: Option, #[arg(long)] url: Option, #[arg(long)] password: Option, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] password_stdin: bool, }, /// A secure note. SecureNote { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] body: Option, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] body_stdin: bool, }, /// An identity record. Identity { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] full_name: Option, #[arg(long)] email: Option, #[arg(long)] phone: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, /// A payment card (number / cvv / pin entered via --*-stdin or prompt). Card { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] holder: Option, #[arg(long)] expiry: Option, #[arg(long, default_value = "credit")] kind: String, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] number_stdin: bool, #[arg(long)] cvv_stdin: bool, #[arg(long)] pin_stdin: bool, }, /// A key / credential blob (material entered via --material-stdin or prompt). Key { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] label: Option, #[arg(long)] algorithm: Option, #[arg(long)] public_key: Option, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] material_stdin: bool, }, /// A TOTP authenticator (base32 secret via --secret or --secret-stdin). Totp { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] issuer: Option, #[arg(long)] label: Option, #[arg(long)] secret: Option, #[arg(long, default_value_t = 30)] period: u32, #[arg(long, default_value_t = 6)] digits: u8, #[arg(long, default_value = "sha1")] algorithm: String, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] secret_stdin: bool, }, /// A document (file payload encrypted into a collection-scoped attachment). Document { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] file: std::path::PathBuf, #[arg(long, value_delimiter = ',')] tags: Vec, }, } fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Init { image, output } => commands::init::cmd_init(image, output), Commands::Add { kind } => commands::add::cmd_add(kind), Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy), Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed), Commands::Edit { query, totp_qr } => commands::edit::cmd_edit(query, totp_qr), Commands::History { query, show, field } => commands::list::cmd_history(query, show, field), Commands::Rm { query } => commands::trash::cmd_rm(query), Commands::Restore { query } => commands::trash::cmd_restore(query), Commands::Purge { query } => commands::trash::cmd_purge(query), Commands::Trash { action } => commands::trash::cmd_trash(action), Commands::Backup { action } => commands::backup::cmd_backup(action), Commands::Import { action } => commands::import::cmd_import(action), Commands::Attach { query, file } => commands::attach::cmd_attach(query, file), Commands::Attachments { query } => commands::attach::cmd_attachments(query), Commands::Extract { query, aid, out } => commands::attach::cmd_extract(query, aid, out), Commands::Detach { query, aid } => commands::attach::cmd_detach(query, aid), Commands::Generate { length, bip39, words, symbols, separator } => { commands::generate::cmd_generate(length, bip39, words, symbols, separator) } Commands::Settings { action } => commands::settings::cmd_settings(action), Commands::Sync => commands::sync::cmd_sync(), Commands::Status => commands::status::cmd_status(), Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } Commands::Completions { shell } => { let mut cmd = Cli::command(); generate(shell, &mut cmd, "relicario", &mut std::io::stdout()); Ok(()) } Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase), Commands::Device { action } => commands::device::cmd_device(action), Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd), Commands::Org { dir, subcommand } => { let dir_path = dir.as_deref(); match subcommand { OrgCommands::Init { name } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_init(&d, &name)?; } OrgCommands::AddMember { key, name, role } => { let d = crate::org_session::org_dir(dir_path)?; let role = parse_org_role(&role)?; commands::org::run_add_member(&d, &key, &name, role)?; } OrgCommands::RemoveMember { member_id } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_remove_member(&d, &member_id)?; } OrgCommands::SetRole { member_id, role } => { let d = crate::org_session::org_dir(dir_path)?; let role = parse_org_role(&role)?; commands::org::run_set_role(&d, &member_id, role)?; } OrgCommands::CreateCollection { slug, name } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_create_collection(&d, &slug, &name)?; } OrgCommands::Grant { member_id, collection } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_grant(&d, &member_id, &collection)?; } OrgCommands::Revoke { member_id, collection } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_revoke(&d, &member_id, &collection)?; } OrgCommands::RotateKey => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_rotate_key(&d)?; } OrgCommands::TransferOwnership { member_id, keep_owner } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_transfer_ownership(&d, &member_id, keep_owner)?; } OrgCommands::DeleteOrg { confirm } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_delete_org(&d, confirm)?; } OrgCommands::Status => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_status(&d)?; } OrgCommands::Audit { since, member, collection, action, format } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_audit(&d, since.as_deref(), member.as_deref(), collection.as_deref(), action.as_deref(), &format)?; } OrgCommands::Add { kind } => { let d = crate::org_session::org_dir(dir_path)?; let (collection, add_kind, tags) = match kind { OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => ( collection, commands::org::OrgAddKind::Login { title, username, url, password, password_stdin }, tags, ), OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => ( collection, commands::org::OrgAddKind::SecureNote { title, body, body_stdin }, tags, ), OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => ( collection, commands::org::OrgAddKind::Identity { title, full_name, email, phone }, tags, ), OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => ( collection, commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin }, tags, ), OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => ( collection, commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin }, tags, ), OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => ( collection, commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm }, tags, ), OrgAddKind::Document { collection, title, file, tags } => ( collection, commands::org::OrgAddKind::Document { title, file }, tags, ), }; commands::org::run_add(&d, &collection, add_kind, tags)?; } OrgCommands::Get { query, show } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_get(&d, &query, show)?; } OrgCommands::List { trashed } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_list(&d, trashed)?; } OrgCommands::Edit { query, totp_qr, file } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_edit(&d, &query, totp_qr, file)?; } OrgCommands::Rm { query } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_rm(&d, &query)?; } OrgCommands::Restore { query } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_restore(&d, &query)?; } OrgCommands::Purge { query } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_purge(&d, &query)?; } } Ok(()) } } } fn parse_org_role(s: &str) -> anyhow::Result { match s { "owner" => Ok(relicario_core::OrgRole::Owner), "admin" => Ok(relicario_core::OrgRole::Admin), "member" => Ok(relicario_core::OrgRole::Member), other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"), } } /// Check for test passphrase override (debug builds only; stripped from release). #[cfg(debug_assertions)] pub(crate) fn test_passphrase_override() -> Option { std::env::var("RELICARIO_TEST_PASSPHRASE").ok() } #[cfg(not(debug_assertions))] pub(crate) fn test_passphrase_override() -> Option { None } /// Check for test item secret override (debug builds only; stripped from release). #[cfg(debug_assertions)] pub(crate) fn test_item_secret_override() -> Option { std::env::var("RELICARIO_TEST_ITEM_SECRET").ok() } #[cfg(not(debug_assertions))] pub(crate) fn test_item_secret_override() -> Option { None } /// Check for test backup passphrase override (debug builds only; stripped from release). #[cfg(debug_assertions)] pub(crate) fn test_backup_passphrase_override() -> Option { std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok() } #[cfg(not(debug_assertions))] pub(crate) fn test_backup_passphrase_override() -> Option { None }