this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve CLI ergonomics: command structure, help text, prompts, and completions

Restructure commands into logical groups (opake account, opake share),
extract shared prompt module, add styled help with examples, version,
and shell completion generation via clap_complete.

[CL-305] [CL-306] [CL-307] [CL-334] [CL-92]

+573 -158
+5
CHANGELOG.md
··· 7 7 ## [Unreleased] 8 8 9 9 ### Added 10 + - CLI ergonomics: command structure and completion (#305) 10 11 - Add BIP-39 seed phrase recovery for account identity (24 words) [#211](https://issues.opake.app/issues/211.html) 11 12 12 13 ### Fixed 13 14 14 15 ### Changed 16 + - Audit and improve command hierarchy for clarity (#306) 17 + - Improve help text and examples for all commands [#307](https://issues.opake.app/issues/307.html) 18 + - Refactor stdin prompt handling: consolidate 4+ duplicated implementations [#334](https://issues.opake.app/issues/334.html) 19 + - Generate man page for opake CLI [#339](https://issues.opake.app/issues/339.html) 15 20 - Update docs: seed phrase derivation, CLI flows, and recovery procedures [#338](https://issues.opake.app/issues/338.html) 16 21 - Add web UI for seed phrase recovery and generation [#216](https://issues.opake.app/issues/216.html) 17 22 - Add WASM exports for mnemonic and derivation functions [#215](https://issues.opake.app/issues/215.html)
+10
Cargo.lock
··· 285 285 ] 286 286 287 287 [[package]] 288 + name = "clap_complete" 289 + version = "4.6.0" 290 + source = "registry+https://github.com/rust-lang/crates.io-index" 291 + checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" 292 + dependencies = [ 293 + "clap", 294 + ] 295 + 296 + [[package]] 288 297 name = "clap_derive" 289 298 version = "4.5.55" 290 299 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1279 1288 "base64", 1280 1289 "chrono", 1281 1290 "clap", 1291 + "clap_complete", 1282 1292 "env_logger", 1283 1293 "log", 1284 1294 "mime_guess",
+1 -1
Cargo.toml
··· 11 11 anyhow = "1" 12 12 base64 = "0.22" 13 13 chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } 14 - clap = { version = "4", features = ["derive"] } 14 + clap = { version = "4", features = ["derive", "color"] } 15 15 ed25519-dalek = { version = "2", features = ["rand_core"] } 16 16 env_logger = "0.11" 17 17 log = "0.4"
+1
crates/opake-cli/Cargo.toml
··· 15 15 base64.workspace = true 16 16 chrono.workspace = true 17 17 clap.workspace = true 18 + clap_complete = "4" 18 19 env_logger.workspace = true 19 20 log.workspace = true 20 21 mime_guess = "2"
+45
crates/opake-cli/src/commands/account.rs
··· 1 + use clap::{Args, Subcommand}; 2 + use opake_core::client::Session; 3 + 4 + use crate::config::FileStorage; 5 + 6 + use super::{accounts, login, logout, set_default}; 7 + 8 + /// Manage accounts and authentication 9 + #[derive(Args)] 10 + pub struct AccountCommand { 11 + #[command(subcommand)] 12 + action: AccountAction, 13 + } 14 + 15 + #[derive(Subcommand)] 16 + enum AccountAction { 17 + /// Authenticate with your PDS 18 + Login(login::LoginCommand), 19 + /// Remove a local account (PDS data is not affected) 20 + Logout(logout::LogoutCommand), 21 + /// List all logged-in accounts (* marks the default) 22 + List(accounts::AccountsCommand), 23 + /// Set the default account (used when --as is omitted) 24 + SetDefault(set_default::SetDefaultCommand), 25 + } 26 + 27 + impl AccountCommand { 28 + pub async fn execute(self, storage: &FileStorage) -> anyhow::Result<Option<Session>> { 29 + match self.action { 30 + AccountAction::Login(cmd) => cmd.execute(storage).await, 31 + AccountAction::Logout(cmd) => { 32 + cmd.run(storage)?; 33 + Ok(None) 34 + } 35 + AccountAction::List(cmd) => { 36 + cmd.run(storage)?; 37 + Ok(None) 38 + } 39 + AccountAction::SetDefault(cmd) => { 40 + cmd.run(storage)?; 41 + Ok(None) 42 + } 43 + } 44 + } 45 + }
+23
crates/opake-cli/src/commands/completions.rs
··· 1 + use clap::Args; 2 + use clap_complete::Shell; 3 + 4 + /// Generate shell completion scripts 5 + /// 6 + /// Outputs a completion script for the specified shell to stdout. 7 + /// Redirect the output to the appropriate file for your shell. 8 + #[derive(Args)] 9 + #[command(after_help = "\ 10 + Install completions: 11 + bash: opake completions bash > ~/.local/share/bash-completion/completions/opake 12 + zsh: opake completions zsh > ~/.zfunc/_opake 13 + fish: opake completions fish > ~/.config/fish/completions/opake.fish")] 14 + pub struct CompletionsCommand { 15 + /// Shell to generate completions for 16 + shell: Shell, 17 + } 18 + 19 + impl CompletionsCommand { 20 + pub fn run(self, cmd: &mut clap::Command) { 21 + clap_complete::generate(self.shell, cmd, "opake", &mut std::io::stdout()); 22 + } 23 + }
+12 -3
crates/opake-cli/src/commands/download.rs
··· 16 16 use crate::session::{self, CommandContext}; 17 17 use opake_core::client::ReqwestTransport; 18 18 19 + /// Download and decrypt a file 20 + /// 21 + /// Three modes: own files (by name or AT-URI), shared grants (--grant), 22 + /// and keyring member files (--keyring-member) for cross-PDS access. 19 23 #[derive(Args)] 20 - /// Download and decrypt a file 24 + #[command(after_help = "\ 25 + Examples: 26 + opake download secret.pdf 27 + opake download secret.pdf -o ~/Downloads/ 28 + opake download --grant at://did:plc:abc/app.opake.grant/xyz 29 + opake download doc.pdf --keyring-member at://did:plc:abc/app.opake.document/xyz")] 21 30 pub struct DownloadCommand { 22 31 /// AT URI or filename of the document (not needed with --grant) 23 32 reference: Option<String>, ··· 27 36 output: Option<PathBuf>, 28 37 29 38 /// Grant URI for downloading a shared file from another user's PDS 30 - #[arg(long, conflicts_with = "keyring_member")] 39 + #[arg(long, conflicts_with = "keyring_member", value_name = "AT-URI")] 31 40 grant: Option<String>, 32 41 33 42 /// Download a keyring-encrypted document as a member (cross-PDS) 34 - #[arg(long, conflicts_with = "grant")] 43 + #[arg(long, conflicts_with = "grant", value_name = "AT-URI")] 35 44 keyring_member: Option<String>, 36 45 } 37 46
+1 -1
crates/opake-cli/src/commands/inbox.rs
··· 15 15 long: bool, 16 16 17 17 /// AppView URL (overrides OPAKE_APPVIEW_URL and config) 18 - #[arg(long)] 18 + #[arg(long, value_name = "URL")] 19 19 appview: Option<String>, 20 20 } 21 21
+11 -5
crates/opake-cli/src/commands/keyring.rs
··· 13 13 use crate::session::{self, CommandContext}; 14 14 use opake_core::client::ReqwestTransport; 15 15 16 + /// Manage keyrings for group-based access control 17 + /// 18 + /// Keyrings enable group-based access. Members share a group key; 19 + /// documents encrypted to the keyring are accessible to all members. 16 20 #[derive(Args)] 17 - /// Manage keyrings for group-based access control 21 + #[command(after_help = "\ 22 + Examples: 23 + opake keyring create family-photos 24 + opake keyring add-member family-photos bob.bsky.social 25 + opake keyring ls -l 26 + opake keyring remove-member family-photos bob.bsky.social")] 18 27 pub struct KeyringCommand { 19 28 #[command(subcommand)] 20 29 action: KeyringAction, ··· 197 206 display, args.keyring 198 207 ); 199 208 eprintln!("existing documents stay encrypted under the old key."); 200 - eprint!("continue? [y/N] "); 201 - let mut input = String::new(); 202 - std::io::stdin().read_line(&mut input)?; 203 - if !input.trim().eq_ignore_ascii_case("y") { 209 + if !crate::prompt::confirm("continue?")? { 204 210 eprintln!("cancelled"); 205 211 return Ok(session::refreshed_session(&client)); 206 212 }
+21 -33
crates/opake-cli/src/commands/login.rs
··· 26 26 } 27 27 28 28 fn prompt_password(identifier: &str, pds: &str) -> Result<String> { 29 - print!("Password for user {} on {}: ", identifier, pds); 30 - std::io::Write::flush(&mut std::io::stdout())?; 31 - let mut input = String::new(); 32 - std::io::stdin().read_line(&mut input)?; 33 - Ok(input.trim().to_string()) 29 + crate::prompt::input(&format!("Password for {identifier} on {pds}: ")) 34 30 } 35 31 36 - #[derive(Args)] 37 32 /// Authenticate with your PDS 33 + /// 34 + /// Uses OAuth by default. Falls back to legacy password authentication 35 + /// if the PDS does not support OAuth. 36 + #[derive(Args)] 37 + #[command(after_help = "\ 38 + Examples: 39 + opake account login alice.bsky.social 40 + opake account login alice.bsky.social --legacy 41 + opake account login did:plc:abc123 --pds https://pds.example.com")] 38 42 pub struct LoginCommand { 39 43 /// Handle or DID (e.g. alice.bsky.social, did:plc:...) 40 44 identifier: String, 41 45 42 46 /// PDS URL override (e.g. https://pds.example.com). Resolved automatically if omitted. 43 - #[arg(long)] 47 + #[arg(long, value_name = "URL")] 44 48 pds: Option<String>, 45 49 46 50 /// Force legacy password-based authentication ··· 169 173 // Published key exists + --force → scary confirmation before overwriting. 170 174 if existing.is_none() && has_published_key && force { 171 175 println!(); 172 - println!("WARNING: --force will generate a new encryption identity."); 173 - println!("All data encrypted to the old identity will become permanently unreadable."); 174 - println!(); 175 - println!("Type exactly: This will brick my data and I am okay with that"); 176 - print!("> "); 177 - std::io::Write::flush(&mut std::io::stdout())?; 178 - let mut confirmation = String::new(); 179 - std::io::stdin().read_line(&mut confirmation)?; 180 - if confirmation.trim() != "This will brick my data and I am okay with that" { 181 - anyhow::bail!("Identity reset cancelled."); 182 - } 176 + crate::prompt::confirm_exact( 177 + "WARNING: --force will generate a new encryption identity.\n\ 178 + All data encrypted to the old identity will become permanently unreadable.\n", 179 + "This will brick my data and I am okay with that", 180 + )?; 183 181 println!("Proceeding with new identity generation."); 184 182 } 185 183 ··· 225 223 let words = mnemonic.words(); 226 224 for &idx in &confirm_indices { 227 225 let expected = &words[idx]; 228 - println!("Enter word #{}: ", idx + 1); 229 - print!("> "); 230 - std::io::Write::flush(&mut std::io::stdout())?; 231 - let mut input = String::new(); 232 - std::io::stdin().read_line(&mut input)?; 233 - if input.trim() != expected.as_str() { 226 + let entered = crate::prompt::input(&format!("Enter word #{}: ", idx + 1))?; 227 + if entered != expected.as_str() { 234 228 anyhow::bail!( 235 - "word #{} incorrect (expected {expected:?}, got {:?}). \ 229 + "word #{} incorrect (expected {expected:?}, got {entered:?}). \ 236 230 Please try again with `opake login`.", 237 231 idx + 1, 238 - input.trim() 239 232 ); 240 233 } 241 234 } ··· 260 253 .join("opake-seed-phrase.txt"); 261 254 262 255 loop { 263 - print!("[{}] > ", default_path.display()); 264 - std::io::Write::flush(&mut std::io::stdout())?; 256 + let trimmed = crate::prompt::input_with_default(">", &default_path.display().to_string())?; 265 257 266 - let mut input = String::new(); 267 - std::io::stdin().read_line(&mut input)?; 268 - let trimmed = input.trim(); 269 - 270 - if trimmed.is_empty() { 258 + if trimmed == default_path.display().to_string() { 271 259 // Use default path. 272 260 let path = &default_path; 273 261 match write_seed_file(path, mnemonic) {
+11 -1
crates/opake-cli/src/commands/metadata.rs
··· 12 12 use crate::keyring_store; 13 13 use crate::session::{self, CommandContext}; 14 14 15 - #[derive(Args)] 16 15 /// View or modify document metadata (name, tags, description) 16 + /// 17 + /// All metadata is encrypted client-side. Record-level fields on the PDS 18 + /// contain only dummy values. 19 + #[derive(Args)] 20 + #[command(after_help = "\ 21 + Examples: 22 + opake metadata show report.pdf 23 + opake metadata rename report.pdf quarterly-report.pdf 24 + opake metadata describe report.pdf \"Q4 financial summary\" 25 + opake metadata tag add report.pdf finance 26 + opake metadata tag remove report.pdf draft")] 17 27 pub struct MetadataCommand { 18 28 #[command(subcommand)] 19 29 action: MetadataAction,
+3
crates/opake-cli/src/commands/mod.rs
··· 1 + pub mod account; 1 2 pub mod accounts; 2 3 pub mod cat; 4 + pub mod completions; 3 5 pub mod download; 4 6 pub mod inbox; 5 7 pub mod keyring; ··· 17 19 pub mod rm; 18 20 pub mod set_default; 19 21 pub mod share; 22 + pub mod share_group; 20 23 pub mod shared; 21 24 pub mod tree; 22 25 pub mod upload;
+7 -1
crates/opake-cli/src/commands/move_cmd.rs
··· 9 9 use crate::identity; 10 10 use crate::session::{self, CommandContext}; 11 11 12 - #[derive(Args)] 13 12 /// Move a document or directory into another directory 13 + /// 14 + /// To rename a document, use `opake metadata rename` instead. 15 + #[derive(Args)] 16 + #[command(after_help = "\ 17 + Examples: 18 + opake move report.pdf projects/ 19 + opake move old-notes/ archive/")] 14 20 pub struct MoveCommand { 15 21 /// Source path, filename, or AT-URI 16 22 source: String,
+11 -5
crates/opake-cli/src/commands/pair.rs
··· 14 14 use crate::identity; 15 15 use crate::session::{self, CommandContext}; 16 16 17 + /// Transfer encryption identity between devices 18 + /// 19 + /// Uses an ephemeral key exchange via PDS relay records. Compare 20 + /// fingerprints on both devices to verify the pairing. 17 21 #[derive(Args)] 18 - /// Transfer encryption identity between devices 22 + #[command(after_help = "\ 23 + Workflow: 24 + New device: opake pair request 25 + Existing device: opake pair approve")] 19 26 pub struct PairCommand { 20 27 #[command(subcommand)] 21 28 action: PairAction, ··· 178 185 } 179 186 180 187 println!(); 181 - eprint!("Approve which request? [1-{}] ", requests.len()); 182 - let mut input = String::new(); 183 - std::io::stdin().read_line(&mut input)?; 184 - let choice: usize = input.trim().parse().context("invalid selection")?; 188 + let selection = 189 + crate::prompt::input(&format!("Approve which request? [1-{}] ", requests.len()))?; 190 + let choice: usize = selection.parse().context("invalid selection")?; 185 191 anyhow::ensure!( 186 192 choice >= 1 && choice <= requests.len(), 187 193 "selection out of range"
+18 -24
crates/opake-cli/src/commands/purge.rs
··· 1 - use anyhow::{Context, Result}; 1 + use anyhow::Result; 2 2 use clap::Args; 3 3 use opake_core::atproto; 4 4 use opake_core::client::{Session, Transport, XrpcClient}; ··· 27 27 ]; 28 28 29 29 /// Delete all Opake data from the PDS 30 + /// 31 + /// Permanently deletes all Opake records and blobs from your PDS. 32 + /// This action is irreversible. 30 33 #[derive(Args)] 34 + #[command(after_help = "\ 35 + Requires typing the exact phrase: \"I want to delete all my Opake data\" 36 + 37 + Recommended workflow: 38 + opake purge --dry-run # preview what would be deleted 39 + opake purge # delete with confirmation prompt 40 + opake purge --force # skip all prompts (use with caution)")] 31 41 pub struct PurgeCommand { 32 42 /// Show what would be deleted without deleting anything 33 43 #[arg(long)] ··· 71 81 /// Prompt the user to type the exact confirmation phrase. 72 82 fn require_confirmation() -> Result<()> { 73 83 println!(); 74 - println!("WARNING: This will permanently delete ALL Opake data from your PDS."); 75 - println!("All encrypted files, keys, grants, and directories will be gone."); 76 - println!("This action is irreversible."); 77 - println!(); 78 - println!("Type exactly: {CONFIRMATION_PHRASE}"); 79 - print!("> "); 80 - std::io::Write::flush(&mut std::io::stdout())?; 81 - 82 - let mut input = String::new(); 83 - std::io::stdin().read_line(&mut input)?; 84 - 85 - if input.trim() != CONFIRMATION_PHRASE { 86 - anyhow::bail!("Purge cancelled."); 87 - } 88 - 89 - Ok(()) 84 + crate::prompt::confirm_exact( 85 + "WARNING: This will permanently delete ALL Opake data from your PDS.\n\ 86 + All encrypted files, keys, grants, and directories will be gone.\n\ 87 + This action is irreversible.\n", 88 + CONFIRMATION_PHRASE, 89 + ) 90 90 } 91 91 92 92 /// Ask whether to delete local identity and session files. ··· 95 95 return Ok(true); 96 96 } 97 97 98 - eprint!("Delete local identity and session? [y/N] "); 99 - let mut answer = String::new(); 100 - std::io::stdin() 101 - .read_line(&mut answer) 102 - .context("failed to read confirmation")?; 103 - 104 - Ok(answer.trim().eq_ignore_ascii_case("y")) 98 + crate::prompt::confirm("Delete local identity and session?") 105 99 } 106 100 107 101 impl Execute for PurgeCommand {
+18 -23
crates/opake-cli/src/commands/recover.rs
··· 10 10 use crate::identity; 11 11 use crate::session::{self, CommandContext}; 12 12 13 + /// Recover encryption identity from a 24-word seed phrase 14 + /// 15 + /// Derives the encryption keypair from a BIP-39 mnemonic. Warns if the 16 + /// derived key does not match the key published on your PDS. 13 17 #[derive(Args)] 14 - /// Recover encryption identity from a 24-word seed phrase 18 + #[command(after_help = "\ 19 + Examples: 20 + opake recover # enter phrase interactively 21 + opake recover -f seed-backup.txt # read from backup file")] 15 22 pub struct RecoverCommand { 16 23 /// Read seed phrase from a .txt backup file instead of stdin 17 24 #[arg(long, short)] ··· 40 47 } 41 48 None => { 42 49 println!("Enter your 24-word seed phrase (space-separated):"); 43 - print!("> "); 44 - std::io::Write::flush(&mut std::io::stdout())?; 45 - 46 - let mut input = String::new(); 47 - std::io::stdin().read_line(&mut input)?; 48 - 49 - parse_mnemonic(input.trim()) 50 - .map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))? 50 + let entered = crate::prompt::input("> ")?; 51 + parse_mnemonic(&entered).map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))? 51 52 } 52 53 }; 53 54 ··· 59 60 60 61 if mismatch { 61 62 println!(); 62 - println!( 63 - "WARNING: The derived public key does NOT match the key published on your PDS." 63 + let result = crate::prompt::confirm_exact( 64 + "WARNING: The derived public key does NOT match the key published on your PDS.\n\ 65 + This means either:\n\ 66 + \x20 - The seed phrase is for a different account\n\ 67 + \x20 - The account's identity was generated randomly (not from a seed phrase)\n\n\ 68 + Saving this identity will NOT let you decrypt existing data.", 69 + "save anyway", 64 70 ); 65 - println!("This means either:"); 66 - println!(" - The seed phrase is for a different account"); 67 - println!(" - The account's identity was generated randomly (not from a seed phrase)"); 68 - println!(); 69 - println!("Saving this identity will NOT let you decrypt existing data."); 70 - println!("Type 'save anyway' to proceed, or anything else to cancel:"); 71 - print!("> "); 72 - std::io::Write::flush(&mut std::io::stdout())?; 73 - 74 - let mut confirm = String::new(); 75 - std::io::stdin().read_line(&mut confirm)?; 76 - if confirm.trim() != "save anyway" { 71 + if result.is_err() { 77 72 println!("Recovery cancelled."); 78 73 return Ok(None); 79 74 }
+4 -11
crates/opake-cli/src/commands/revoke.rs
··· 1 - use anyhow::{Context, Result}; 1 + use anyhow::Result; 2 2 use clap::Args; 3 3 use opake_core::client::Session; 4 4 use opake_core::sharing; ··· 21 21 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 22 22 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 23 23 24 - if !self.yes { 25 - eprint!("revoke {}? [y/N] ", self.grant); 26 - let mut answer = String::new(); 27 - std::io::stdin() 28 - .read_line(&mut answer) 29 - .context("failed to read confirmation")?; 30 - if !answer.trim().eq_ignore_ascii_case("y") { 31 - println!("aborted"); 32 - return Ok(session::refreshed_session(&client)); 33 - } 24 + if !self.yes && !crate::prompt::confirm(&format!("revoke {}?", self.grant))? { 25 + println!("aborted"); 26 + return Ok(session::refreshed_session(&client)); 34 27 } 35 28 36 29 sharing::revoke_grant(&mut client, &self.grant).await?;
+5 -17
crates/opake-cli/src/commands/rm.rs
··· 1 - use anyhow::{Context, Result}; 1 + use anyhow::Result; 2 2 use chrono::Utc; 3 3 use clap::Args; 4 4 use opake_core::client::Session; ··· 58 58 59 59 // Fast path: document AT-URI — no tree needed, no parent cleanup. 60 60 if let Some(resolved) = try_fast_resolve(&self.reference) { 61 - if !self.yes { 62 - eprint!("delete {}? [y/N] ", resolved.name); 63 - let mut answer = String::new(); 64 - std::io::stdin() 65 - .read_line(&mut answer) 66 - .context("failed to read confirmation")?; 67 - if !answer.trim().eq_ignore_ascii_case("y") { 68 - println!("aborted"); 69 - return Ok(session::refreshed_session(&client)); 70 - } 61 + if !self.yes && !crate::prompt::confirm(&format!("delete {}?", resolved.name))? { 62 + println!("aborted"); 63 + return Ok(session::refreshed_session(&client)); 71 64 } 72 65 73 66 documents::delete_document(&mut client, &resolved.uri).await?; ··· 104 97 } 105 98 }; 106 99 107 - eprint!("{prompt} [y/N] "); 108 - let mut answer = String::new(); 109 - std::io::stdin() 110 - .read_line(&mut answer) 111 - .context("failed to read confirmation")?; 112 - if !answer.trim().eq_ignore_ascii_case("y") { 100 + if !crate::prompt::confirm(&prompt)? { 113 101 println!("aborted"); 114 102 return Ok(session::refreshed_session(&client)); 115 103 }
+2 -2
crates/opake-cli/src/commands/share.rs
··· 16 16 17 17 #[derive(Args)] 18 18 /// Share a document with another user 19 - pub struct ShareCommand { 19 + pub struct NewShareCommand { 20 20 /// AT URI or filename of the document 21 21 document: String, 22 22 ··· 28 28 note: Option<String>, 29 29 } 30 30 31 - impl Execute for ShareCommand { 31 + impl Execute for NewShareCommand { 32 32 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 33 33 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 34 34 let id =
+47
crates/opake-cli/src/commands/share_group.rs
··· 1 + use clap::{Args, Subcommand}; 2 + use opake_core::client::Session; 3 + 4 + use super::Execute; 5 + use crate::session::CommandContext; 6 + 7 + use super::{inbox, revoke, share, shared}; 8 + 9 + /// Share documents and manage grants 10 + /// 11 + /// Wraps a document's content key to a recipient's public key, 12 + /// enabling cross-PDS encrypted sharing without federation. 13 + #[derive(Args)] 14 + pub struct ShareGroupCommand { 15 + #[command(subcommand)] 16 + action: ShareAction, 17 + } 18 + 19 + #[derive(Subcommand)] 20 + enum ShareAction { 21 + /// Share a document with another user 22 + /// 23 + /// Resolves the recipient's encryption key from their PDS and wraps 24 + /// the document's content key for them. Works across PDS instances. 25 + New(share::NewShareCommand), 26 + /// List grants you've shared with others 27 + List(shared::SharedCommand), 28 + /// List grants shared with you (via appview) 29 + Inbox(inbox::InboxCommand), 30 + /// Revoke a share grant 31 + /// 32 + /// Deletes the grant record. The recipient can no longer decrypt 33 + /// the content key. Note: if the recipient already downloaded and 34 + /// cached the document, revocation cannot undo that access. 35 + Revoke(revoke::RevokeCommand), 36 + } 37 + 38 + impl Execute for ShareGroupCommand { 39 + async fn execute(self, ctx: &CommandContext) -> anyhow::Result<Option<Session>> { 40 + match self.action { 41 + ShareAction::New(cmd) => cmd.execute(ctx).await, 42 + ShareAction::List(cmd) => cmd.execute(ctx).await, 43 + ShareAction::Inbox(cmd) => cmd.execute(ctx).await, 44 + ShareAction::Revoke(cmd) => cmd.execute(ctx).await, 45 + } 46 + } 47 + }
+4 -1
crates/opake-cli/src/commands/tree.rs
··· 11 11 use crate::identity; 12 12 use crate::session::{self, CommandContext}; 13 13 14 + /// Display directory hierarchy as a tree 15 + /// 16 + /// Shows all directories and documents in a nested tree structure. 17 + /// Document names are decrypted client-side from encrypted metadata. 14 18 #[derive(Args)] 15 - /// Display directory hierarchy as a tree 16 19 pub struct TreeCommand; 17 20 18 21 impl Execute for TreeCommand {
+10 -1
crates/opake-cli/src/commands/upload.rs
··· 16 16 use crate::session::{self, CommandContext}; 17 17 use crate::{document_resolve, identity, keyring_store}; 18 18 19 + /// Upload and encrypt a file 20 + /// 21 + /// Files are encrypted client-side with AES-256-GCM before upload. 22 + /// MIME type is auto-detected from the file extension. 19 23 #[derive(Args)] 20 - /// Upload and encrypt a file 24 + #[command(after_help = "\ 25 + Examples: 26 + opake upload photo.jpg 27 + opake upload doc.pdf --dir projects/ 28 + opake upload data.csv --keyring team 29 + opake upload notes.md --description \"meeting notes\"")] 21 30 pub struct UploadCommand { 22 31 /// Path to the file to encrypt and upload 23 32 path: PathBuf,
+55 -29
crates/opake-cli/src/main.rs
··· 4 4 mod identity; 5 5 mod keyring_store; 6 6 mod oauth; 7 + mod prompt; 7 8 mod session; 8 9 pub mod utils; 9 10 10 - use clap::{Parser, Subcommand}; 11 + use clap::builder::styling::{AnsiColor, Styles}; 12 + use clap::{CommandFactory, Parser, Subcommand}; 11 13 use commands::Execute; 12 14 use config::FileStorage; 13 15 use log::info; 14 16 17 + const fn opake_styles() -> Styles { 18 + Styles::styled() 19 + .header(AnsiColor::BrightCyan.on_default().bold()) 20 + .usage(AnsiColor::BrightCyan.on_default()) 21 + .literal(AnsiColor::BrightWhite.on_default().bold()) 22 + .placeholder(AnsiColor::BrightMagenta.on_default()) 23 + .valid(AnsiColor::BrightGreen.on_default()) 24 + .invalid(AnsiColor::BrightRed.on_default()) 25 + .error(AnsiColor::BrightRed.on_default().bold()) 26 + } 27 + 28 + /// Encrypted personal cloud on AT Protocol. 29 + /// 30 + /// Opake encrypts files client-side and stores them on your AT Protocol PDS. 31 + /// All crypto happens locally — the server only ever sees ciphertext. 15 32 #[derive(Parser)] 16 - #[command(name = "opake", about = "Encrypted personal cloud on AT Protocol")] 33 + #[command( 34 + name = "opake", 35 + version, 36 + author = "Not Herself <me@sans-self.org>", 37 + styles = opake_styles(), 38 + after_help = "\ 39 + Quick start: 40 + opake account login alice.bsky.social 41 + opake upload secret.pdf 42 + opake ls 43 + opake share new secret.pdf bob.bsky.social 44 + 45 + https://opake.app", 46 + )] 17 47 struct Cli { 18 48 /// Act as a specific account (handle or DID) 19 49 #[arg(long, global = true)] ··· 33 63 34 64 #[derive(Subcommand)] 35 65 enum Command { 36 - Login(commands::login::LoginCommand), 37 - Logout(commands::logout::LogoutCommand), 38 - Accounts(commands::accounts::AccountsCommand), 39 - SetDefault(commands::set_default::SetDefaultCommand), 66 + // --- Grouped commands --- 67 + Account(commands::account::AccountCommand), 68 + Share(commands::share_group::ShareGroupCommand), 69 + Keyring(commands::keyring::KeyringCommand), 70 + Metadata(commands::metadata::MetadataCommand), 71 + Pair(commands::pair::PairCommand), 72 + 73 + // --- Files --- 40 74 Upload(commands::upload::UploadCommand), 41 75 Download(commands::download::DownloadCommand), 42 76 Cat(commands::cat::CatCommand), 43 - Inbox(commands::inbox::InboxCommand), 44 77 Ls(commands::ls::LsCommand), 45 - Metadata(commands::metadata::MetadataCommand), 78 + Tree(commands::tree::TreeCommand), 79 + Rm(commands::rm::RmCommand), 80 + Move(commands::move_cmd::MoveCommand), 46 81 Mkdir(commands::mkdir::MkdirCommand), 47 - /// Moves a file to another directory. Use the metadata command for that. 48 - Move(commands::move_cmd::MoveCommand), 49 - Rm(commands::rm::RmCommand), 50 - Resolve(commands::resolve::ResolveCommand), 51 - Share(commands::share::ShareCommand), 52 - Shared(commands::shared::SharedCommand), 53 - Revoke(commands::revoke::RevokeCommand), 54 - Keyring(commands::keyring::KeyringCommand), 55 - Pair(commands::pair::PairCommand), 56 - /// Delete all Opake data from the PDS 82 + 83 + // --- Danger Zone --- 57 84 Purge(commands::purge::PurgeCommand), 58 - /// Recover encryption identity from a 24-word seed phrase 85 + 86 + // --- Utilities --- 59 87 Recover(commands::recover::RecoverCommand), 60 - Tree(commands::tree::TreeCommand), 88 + Resolve(commands::resolve::ResolveCommand), 89 + Completions(commands::completions::CompletionsCommand), 61 90 } 62 91 63 92 async fn run_with_context( ··· 100 129 let storage = FileStorage::new(base_dir); 101 130 102 131 match command { 103 - Command::Login(cmd) => { 132 + Command::Account(cmd) => { 104 133 let session = cmd.execute(&storage).await?; 105 134 if let Some(ref s) = session { 106 135 session::persist_session(&storage, s.did(), s)?; 107 136 } 108 137 } 109 - Command::Logout(cmd) => cmd.run(&storage)?, 110 - Command::Accounts(cmd) => cmd.run(&storage)?, 111 - Command::SetDefault(cmd) => cmd.run(&storage)?, 112 138 113 139 Command::Upload(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 114 140 Command::Download(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 115 141 Command::Cat(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 116 - Command::Inbox(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 117 142 Command::Ls(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 118 143 Command::Metadata(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 119 144 Command::Mkdir(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 120 145 Command::Move(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 121 146 Command::Rm(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 122 - Command::Resolve(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 147 + Command::Tree(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 148 + 123 149 Command::Share(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 124 - Command::Shared(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 125 - Command::Revoke(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 126 150 Command::Keyring(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 151 + 127 152 Command::Pair(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 128 153 Command::Purge(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 129 154 Command::Recover(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 130 - Command::Tree(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 155 + Command::Resolve(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 156 + Command::Completions(cmd) => cmd.run(&mut Cli::command()), 131 157 } 132 158 133 159 Ok(())
+110
crates/opake-cli/src/prompt.rs
··· 1 + use std::io::{BufRead, Write}; 2 + 3 + use anyhow::{Context, Result}; 4 + 5 + /// Yes/No confirmation. Returns `true` only on case-insensitive "y". 6 + /// Prints prompt to stderr (UX chrome, not pipeable output). 7 + pub fn confirm(prompt: &str) -> Result<bool> { 8 + confirm_from(prompt, &mut std::io::stdin().lock(), &mut std::io::stderr()) 9 + } 10 + 11 + fn confirm_from(prompt: &str, reader: &mut impl BufRead, writer: &mut impl Write) -> Result<bool> { 12 + write!(writer, "{prompt} [y/N] ")?; 13 + writer.flush()?; 14 + 15 + let mut input = String::new(); 16 + reader 17 + .read_line(&mut input) 18 + .context("failed to read stdin")?; 19 + 20 + Ok(input.trim().eq_ignore_ascii_case("y")) 21 + } 22 + 23 + /// Scary confirmation requiring an exact phrase match. Bails on mismatch. 24 + /// Prints warning lines to stdout, prompt to stderr. 25 + pub fn confirm_exact(warning: &str, phrase: &str) -> Result<()> { 26 + confirm_exact_from( 27 + warning, 28 + phrase, 29 + &mut std::io::stdin().lock(), 30 + &mut std::io::stdout(), 31 + &mut std::io::stderr(), 32 + ) 33 + } 34 + 35 + fn confirm_exact_from( 36 + warning: &str, 37 + phrase: &str, 38 + reader: &mut impl BufRead, 39 + info_writer: &mut impl Write, 40 + prompt_writer: &mut impl Write, 41 + ) -> Result<()> { 42 + writeln!(info_writer, "{warning}")?; 43 + writeln!(info_writer, "Type exactly: {phrase}")?; 44 + write!(prompt_writer, "> ")?; 45 + prompt_writer.flush()?; 46 + 47 + let mut input = String::new(); 48 + reader 49 + .read_line(&mut input) 50 + .context("failed to read stdin")?; 51 + 52 + if input.trim() != phrase { 53 + anyhow::bail!("Cancelled."); 54 + } 55 + 56 + Ok(()) 57 + } 58 + 59 + /// Generic trimmed line input. Prints prompt to stderr, returns trimmed string. 60 + pub fn input(prompt: &str) -> Result<String> { 61 + input_from(prompt, &mut std::io::stdin().lock(), &mut std::io::stderr()) 62 + } 63 + 64 + fn input_from(prompt: &str, reader: &mut impl BufRead, writer: &mut impl Write) -> Result<String> { 65 + write!(writer, "{prompt}")?; 66 + writer.flush()?; 67 + 68 + let mut input = String::new(); 69 + reader 70 + .read_line(&mut input) 71 + .context("failed to read stdin")?; 72 + 73 + Ok(input.trim().to_string()) 74 + } 75 + 76 + /// Input with a default value shown in brackets. Empty input returns the default. 77 + pub fn input_with_default(prompt: &str, default: &str) -> Result<String> { 78 + input_with_default_from( 79 + prompt, 80 + default, 81 + &mut std::io::stdin().lock(), 82 + &mut std::io::stderr(), 83 + ) 84 + } 85 + 86 + fn input_with_default_from( 87 + prompt: &str, 88 + default: &str, 89 + reader: &mut impl BufRead, 90 + writer: &mut impl Write, 91 + ) -> Result<String> { 92 + write!(writer, "{prompt} [{default}] ")?; 93 + writer.flush()?; 94 + 95 + let mut input = String::new(); 96 + reader 97 + .read_line(&mut input) 98 + .context("failed to read stdin")?; 99 + 100 + let trimmed = input.trim(); 101 + if trimmed.is_empty() { 102 + Ok(default.to_string()) 103 + } else { 104 + Ok(trimmed.to_string()) 105 + } 106 + } 107 + 108 + #[cfg(test)] 109 + #[path = "prompt_tests.rs"] 110 + mod tests;
+138
crates/opake-cli/src/prompt_tests.rs
··· 1 + use std::io::Cursor; 2 + 3 + use super::*; 4 + 5 + fn reader(input: &str) -> Cursor<Vec<u8>> { 6 + Cursor::new(input.as_bytes().to_vec()) 7 + } 8 + 9 + fn writer() -> Vec<u8> { 10 + Vec::new() 11 + } 12 + 13 + #[test] 14 + fn confirm_yes() { 15 + let result = confirm_from("delete?", &mut reader("y\n"), &mut writer()); 16 + assert!(result.unwrap()); 17 + } 18 + 19 + #[test] 20 + fn confirm_yes_uppercase() { 21 + let result = confirm_from("delete?", &mut reader("Y\n"), &mut writer()); 22 + assert!(result.unwrap()); 23 + } 24 + 25 + #[test] 26 + fn confirm_no() { 27 + let result = confirm_from("delete?", &mut reader("n\n"), &mut writer()); 28 + assert!(!result.unwrap()); 29 + } 30 + 31 + #[test] 32 + fn confirm_empty_is_no() { 33 + let result = confirm_from("delete?", &mut reader("\n"), &mut writer()); 34 + assert!(!result.unwrap()); 35 + } 36 + 37 + #[test] 38 + fn confirm_garbage_is_no() { 39 + let result = confirm_from("delete?", &mut reader("sure\n"), &mut writer()); 40 + assert!(!result.unwrap()); 41 + } 42 + 43 + #[test] 44 + fn confirm_prompt_format() { 45 + let mut output = writer(); 46 + let _ = confirm_from("delete foo?", &mut reader("n\n"), &mut output); 47 + let prompt = String::from_utf8(output).unwrap(); 48 + assert_eq!(prompt, "delete foo? [y/N] "); 49 + } 50 + 51 + #[test] 52 + fn confirm_exact_match() { 53 + let result = confirm_exact_from( 54 + "WARNING: danger", 55 + "do it", 56 + &mut reader("do it\n"), 57 + &mut writer(), 58 + &mut writer(), 59 + ); 60 + assert!(result.is_ok()); 61 + } 62 + 63 + #[test] 64 + fn confirm_exact_mismatch_bails() { 65 + let result = confirm_exact_from( 66 + "WARNING: danger", 67 + "do it", 68 + &mut reader("nope\n"), 69 + &mut writer(), 70 + &mut writer(), 71 + ); 72 + assert!(result.is_err()); 73 + assert!(result.unwrap_err().to_string().contains("Cancelled")); 74 + } 75 + 76 + #[test] 77 + fn confirm_exact_empty_bails() { 78 + let result = confirm_exact_from( 79 + "WARNING", 80 + "confirm", 81 + &mut reader("\n"), 82 + &mut writer(), 83 + &mut writer(), 84 + ); 85 + assert!(result.is_err()); 86 + } 87 + 88 + #[test] 89 + fn confirm_exact_shows_phrase_in_output() { 90 + let mut info = writer(); 91 + let _ = confirm_exact_from( 92 + "WARNING: bad things", 93 + "I accept", 94 + &mut reader("I accept\n"), 95 + &mut info, 96 + &mut writer(), 97 + ); 98 + let output = String::from_utf8(info).unwrap(); 99 + assert!(output.contains("WARNING: bad things")); 100 + assert!(output.contains("Type exactly: I accept")); 101 + } 102 + 103 + #[test] 104 + fn input_trims_whitespace() { 105 + let result = input_from("> ", &mut reader(" hello \n"), &mut writer()); 106 + assert_eq!(result.unwrap(), "hello"); 107 + } 108 + 109 + #[test] 110 + fn input_empty() { 111 + let result = input_from("> ", &mut reader("\n"), &mut writer()); 112 + assert_eq!(result.unwrap(), ""); 113 + } 114 + 115 + #[test] 116 + fn input_with_default_empty_returns_default() { 117 + let result = input_with_default_from("path:", "/tmp/foo", &mut reader("\n"), &mut writer()); 118 + assert_eq!(result.unwrap(), "/tmp/foo"); 119 + } 120 + 121 + #[test] 122 + fn input_with_default_nonempty_overrides() { 123 + let result = input_with_default_from( 124 + "path:", 125 + "/tmp/foo", 126 + &mut reader("/home/bar\n"), 127 + &mut writer(), 128 + ); 129 + assert_eq!(result.unwrap(), "/home/bar"); 130 + } 131 + 132 + #[test] 133 + fn input_with_default_prompt_format() { 134 + let mut output = writer(); 135 + let _ = input_with_default_from("save to:", "/tmp/x", &mut reader("\n"), &mut output); 136 + let prompt = String::from_utf8(output).unwrap(); 137 + assert_eq!(prompt, "save to: [/tmp/x] "); 138 + }