//! quicprochat CLI client. // ── v2 feature gate: when compiled with --features v2, use the SDK-based CLI. #[cfg(feature = "v2")] mod v2_commands; #[cfg(feature = "v2")] mod v2_main; #[cfg(feature = "v2")] fn main() { v2_main::main(); } // ── v1 CLI (default) ───────────────────────────────────────────────────────── #[cfg(not(feature = "v2"))] use std::path::{Path, PathBuf}; #[cfg(not(feature = "v2"))] use anyhow::Context; #[cfg(not(feature = "v2"))] use clap::{Parser, Subcommand}; #[cfg(not(feature = "v2"))] use quicprochat_client::{ cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify, cmd_fetch_key, cmd_health, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, run_repl, set_insecure_skip_verify, ClientAuth, }; #[cfg(all(feature = "tui", not(feature = "v2")))] use quicprochat_client::client::tui::run_tui; // ── Config file loading ────────────────────────────────────────────────────── // // Loads a TOML config file and sets QPQ_* environment variables for values // not already set. This runs BEFORE clap parses, so the natural precedence is: // CLI flags > environment variables > config file > compiled defaults. // // Config file search order: // 1. --config (parsed manually from argv) // 2. $QPC_CONFIG env var // 3. $XDG_CONFIG_HOME/qpc/config.toml (usually ~/.config/qpc/config.toml) // 4. ~/.qpc.toml #[cfg(not(feature = "v2"))] mod client_config { use serde::Deserialize; use std::path::PathBuf; #[derive(Debug, Default, Deserialize)] pub struct ClientFileConfig { pub server: Option, pub server_name: Option, pub ca_cert: Option, pub username: Option, pub password: Option, pub access_token: Option, pub device_id: Option, pub state_password: Option, pub state: Option, pub danger_accept_invalid_certs: Option, pub no_server: Option, } /// Find and load the config file. Returns the parsed config (or default if /// no file is found). pub fn load_client_config() -> ClientFileConfig { let path = find_config_path(); let path = match path { Some(p) if p.exists() => p, _ => return ClientFileConfig::default(), }; match std::fs::read_to_string(&path) { Ok(contents) => match toml::from_str(&contents) { Ok(cfg) => { eprintln!("Loaded config: {}", path.display()); cfg } Err(e) => { eprintln!("Warning: failed to parse {}: {e}", path.display()); ClientFileConfig::default() } }, Err(e) => { eprintln!("Warning: failed to read {}: {e}", path.display()); ClientFileConfig::default() } } } fn find_config_path() -> Option { // 1. --config from argv (before clap parses). let args: Vec = std::env::args().collect(); for i in 0..args.len().saturating_sub(1) { if args[i] == "--config" || args[i] == "-c" { return Some(PathBuf::from(&args[i + 1])); } } // 2. $QPC_CONFIG env var. if let Ok(p) = std::env::var("QPC_CONFIG") { return Some(PathBuf::from(p)); } // 3. $XDG_CONFIG_HOME/qpc/config.toml let xdg = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) .unwrap_or_else(|_| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); PathBuf::from(home).join(".config") }); let xdg_path = xdg.join("qpc").join("config.toml"); if xdg_path.exists() { return Some(xdg_path); } // 4. ~/.qpc.toml if let Ok(home) = std::env::var("HOME") { let home_path = PathBuf::from(home).join(".qpc.toml"); if home_path.exists() { return Some(home_path); } } None } /// Set QPQ_* env vars from config values, but only if they're not already set. pub fn apply_config_to_env(cfg: &ClientFileConfig) { fn set_if_empty(key: &str, val: &str) { if std::env::var(key).is_err() { std::env::set_var(key, val); } } if let Some(ref v) = cfg.server { set_if_empty("QPQ_SERVER", v); } if let Some(ref v) = cfg.server_name { set_if_empty("QPQ_SERVER_NAME", v); } if let Some(ref v) = cfg.ca_cert { set_if_empty("QPQ_CA_CERT", v); } if let Some(ref v) = cfg.username { set_if_empty("QPQ_USERNAME", v); } if let Some(ref v) = cfg.password { set_if_empty("QPQ_PASSWORD", v); } if let Some(ref v) = cfg.access_token { set_if_empty("QPQ_ACCESS_TOKEN", v); } if let Some(ref v) = cfg.device_id { set_if_empty("QPQ_DEVICE_ID", v); } if let Some(ref v) = cfg.state_password { set_if_empty("QPQ_STATE_PASSWORD", v); } if let Some(ref v) = cfg.state { set_if_empty("QPQ_STATE", v); } if let Some(v) = cfg.danger_accept_invalid_certs { if v { set_if_empty("QPQ_DANGER_ACCEPT_INVALID_CERTS", "true"); } } if let Some(v) = cfg.no_server { if v { set_if_empty("QPQ_NO_SERVER", "true"); } } } } // ── CLI ─────────────────────────────────────────────────────────────────────── #[cfg(not(feature = "v2"))] #[derive(Debug, Parser)] #[command(name = "qpc", about = "quicprochat CLI client", version)] struct Args { /// Path to a TOML config file (auto-detected from ~/.config/qpc/config.toml or ~/.qpc.toml). #[arg(long, short = 'c', global = true, env = "QPC_CONFIG")] config: Option, /// Path to the server's TLS certificate (self-signed by default). #[arg( long, global = true, default_value = "data/server-cert.der", env = "QPQ_CA_CERT" )] ca_cert: PathBuf, /// Expected TLS server name (must match the certificate SAN). #[arg( long, global = true, default_value = "localhost", env = "QPQ_SERVER_NAME" )] server_name: String, /// Bearer token or OPAQUE session token for authenticated requests. /// Not required for register-user and login commands. #[arg( long, global = true, env = "QPQ_ACCESS_TOKEN", default_value = "" )] access_token: String, /// Optional device identifier (UUID bytes encoded as hex or raw string). #[arg(long, global = true, env = "QPQ_DEVICE_ID")] device_id: Option, /// Password to encrypt/decrypt client state files (QPCE format). /// If set, state files are encrypted at rest with Argon2id + ChaCha20Poly1305. #[arg(long, global = true, env = "QPQ_STATE_PASSWORD")] state_password: Option, /// DANGER: Skip TLS certificate verification. Development only. /// Disables all certificate checks, making the connection vulnerable to MITM attacks. #[arg( long = "danger-accept-invalid-certs", global = true, env = "QPQ_DANGER_ACCEPT_INVALID_CERTS" )] danger_accept_invalid_certs: bool, // ── Default-repl args (used when no subcommand is given) ───────── /// State file path (identity + MLS state). Used when running the default REPL. #[arg(long, default_value = "qpc-state.bin", env = "QPQ_STATE")] state: PathBuf, /// Server address (host:port). Used when running the default REPL. #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// OPAQUE username for automatic registration/login. #[arg(long, env = "QPQ_USERNAME")] username: Option, /// OPAQUE password (prompted securely if --username is set but --password is not). #[arg(long, env = "QPQ_PASSWORD")] password: Option, /// Do not auto-start a local qpc-server (useful when connecting to a remote server). #[arg(long, env = "QPQ_NO_SERVER")] no_server: bool, #[command(subcommand)] command: Option, } #[cfg(not(feature = "v2"))] #[derive(Debug, Subcommand)] enum Command { /// Register a new user via OPAQUE (password never leaves the client). RegisterUser { #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Username for the new account. #[arg(long)] username: String, /// Password (will be used in OPAQUE PAKE; server never sees it). #[arg(long)] password: String, }, /// Log in via OPAQUE and receive a session token. Login { #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, #[arg(long)] username: String, #[arg(long)] password: String, /// Hex-encoded Ed25519 identity key (64 hex chars). Optional if --state is provided. #[arg(long)] identity_key: Option, /// State file to derive the identity key (requires same password if encrypted). #[arg(long)] state: Option, /// Password for the encrypted state file (if any). #[arg(long)] state_password: Option, }, /// Show local identity key, fingerprint, group status, and hybrid key status. Whoami { /// State file path (identity + MLS state). #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, }, /// Check server connectivity and print status. Health { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Check if a peer has registered a hybrid key (non-consuming lookup). CheckKey { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Peer's Ed25519 identity public key (64 hex chars = 32 bytes). identity_key: String, }, /// Send a Ping to the server and print the round-trip time. Ping { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Generate a fresh MLS KeyPackage and upload it to the Authentication Service. Register { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Fetch a peer's KeyPackage from the Authentication Service. FetchKey { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes). identity_key: String, }, /// Run a two-party MLS demo (creator + joiner) against live AS and DS. DemoGroup { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Upload the persistent identity's KeyPackage to the AS (uses state file). RegisterState { /// State file path (identity + MLS state). #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, /// Authentication Service address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Refresh the KeyPackage on the server (existing state only). /// Run periodically (e.g. before server TTL ~24h) or after your KeyPackage was consumed so others can invite you. RefreshKeypackage { /// State file path (identity + MLS state). #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Create a persistent group and save state to disk. CreateGroup { /// State file path (identity + MLS state). #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Group identifier (arbitrary bytes, typically a human-readable name). #[arg(long)] group_id: String, }, /// Invite a peer into the group and deliver a Welcome via DS. Invite { #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Peer identity public key (64 hex chars = 32 bytes). #[arg(long)] peer_key: String, }, /// Join a group by fetching the Welcome from the DS. Join { #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Send an application message via the DS. Send { #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Recipient identity key (hex, 32 bytes -> 64 chars). Omit when using --all. #[arg(long)] peer_key: Option, /// Send to all other group members (N-way groups). #[arg(long)] all: bool, /// Plaintext message to send. #[arg(long)] msg: String, }, /// Receive and decrypt all pending messages from the DS. Recv { #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Wait for up to this many milliseconds if no messages are queued. #[arg(long, default_value_t = 0)] wait_ms: u64, /// Continuously long-poll for messages. #[arg(long)] stream: bool, }, /// Interactive multi-conversation REPL. Supports /dm, /create-group, /invite, /join, /switch, and more. /// Automatically registers and logs in if --username/--password are provided (or prompts interactively). Repl { #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// OPAQUE username for automatic registration/login. #[arg(long, env = "QPQ_USERNAME")] username: Option, /// OPAQUE password (prompted securely if --username is set but --password is not). #[arg(long, env = "QPQ_PASSWORD")] password: Option, /// Do not auto-start a local qpc-server. #[arg(long, env = "QPQ_NO_SERVER")] no_server: bool, }, /// Full-screen Ratatui TUI (requires --features tui). /// Channels sidebar, scrollable message view, and inline input bar. #[cfg(feature = "tui")] Tui { #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// OPAQUE username for automatic registration/login. #[arg(long, env = "QPQ_USERNAME")] username: Option, /// OPAQUE password (prompted securely if --username is set but --password is not). #[arg(long, env = "QPQ_PASSWORD")] password: Option, }, /// Interactive 1:1 chat: type to send, incoming messages printed as \[peer\] msg. Ctrl+D to exit. /// In a two-person group, peer is chosen automatically; use --peer-key only with 3+ members. Chat { #[arg( long, default_value = "qpc-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Peer identity key (hex, 64 chars). Omit in a two-person group to use the only other member. #[arg(long)] peer_key: Option, /// How often to poll for incoming messages (milliseconds). #[arg(long, default_value_t = 500)] poll_interval_ms: u64, }, /// Export a conversation's message history to an encrypted, tamper-evident transcript file. /// /// The output file uses Argon2id + ChaCha20-Poly1305 encryption with a SHA-256 hash chain /// linking every record. Use `qpc export verify` to check chain integrity without decrypting. Export { /// Path to the conversation database (.convdb file). #[arg(long, default_value = "qpc-convdb.sqlite", env = "QPQ_CONV_DB")] conv_db: PathBuf, /// Conversation ID to export (32 hex chars = 16 bytes). #[arg(long)] conv_id: String, /// Output path for the .qpct transcript file. #[arg(long, default_value = "transcript.qpct")] output: PathBuf, /// Password used to encrypt the transcript (separate from the state/DB password). #[arg(long, env = "QPQ_TRANSCRIPT_PASSWORD")] transcript_password: Option, /// Password for the encrypted conversation database (if any). #[arg(long, env = "QPQ_STATE_PASSWORD")] db_password: Option, }, /// Verify the hash-chain integrity of a transcript file without decrypting content. ExportVerify { /// Path to the .qpct transcript file to verify. #[arg(long)] input: PathBuf, }, /// Execute a YAML playbook (scripted command sequence) and exit. /// Requires `--features playbook`. #[cfg(feature = "playbook")] Run { /// Path to the YAML playbook file. playbook: PathBuf, /// State file path (identity + MLS state). #[arg(long, default_value = "qpc-state.bin", env = "QPQ_STATE")] state: PathBuf, /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// OPAQUE username for automatic login. #[arg(long, env = "QPQ_USERNAME")] username: Option, /// OPAQUE password. #[arg(long, env = "QPQ_PASSWORD")] password: Option, /// Override playbook variables: KEY=VALUE (repeatable). #[arg(long = "var", short = 'V')] vars: Vec, }, } // ── Helpers ─────────────────────────────────────────────────────────────────── #[cfg(not(feature = "v2"))] /// Returns `qpc-{username}.bin` when `state` is still at the default /// (`qpc-state.bin`) and a username has been provided. Otherwise returns /// `state` unchanged. This lets `qpc --username alice` automatically isolate /// Alice's state without requiring a manual `--state` flag. fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf { if state == Path::new("qpc-state.bin") { if let Some(uname) = username { return PathBuf::from(format!("qpc-{uname}.bin")); } } state } // ── Playbook execution ─────────────────────────────────────────────────────── #[cfg(all(feature = "playbook", not(feature = "v2")))] async fn run_playbook( playbook_path: &Path, state: &Path, server: &str, ca_cert: &Path, server_name: &str, state_pw: Option<&str>, username: Option<&str>, password: Option<&str>, access_token: &str, device_id: Option<&str>, extra_vars: &[String], ) -> anyhow::Result<()> { use quicprochat_client::PlaybookRunner; let insecure = std::env::var("QPQ_DANGER_ACCEPT_INVALID_CERTS").is_ok(); // Connect to server. let client = quicprochat_client::connect_node_opt(server, ca_cert, server_name, insecure) .await .context("connect to server")?; // Build session state. let mut session = quicprochat_client::client::session::SessionState::load(state, state_pw) .context("load session state")?; // If username/password provided, do OPAQUE login. if let (Some(uname), Some(pw)) = (username, password) { if let Err(e) = quicprochat_client::opaque_login(&client, uname, pw, &session.identity.public_key_bytes()).await { eprintln!("OPAQUE login failed: {e:#}"); } } else if !access_token.is_empty() { let auth = ClientAuth::from_parts(access_token.to_string(), device_id.map(String::from)); init_auth(auth); } // Load playbook. let mut runner = PlaybookRunner::from_file(playbook_path) .with_context(|| format!("load playbook: {}", playbook_path.display()))?; // Inject extra variables from --var KEY=VALUE flags. for kv in extra_vars { if let Some((k, v)) = kv.split_once('=') { runner.set_var(k, v); } else { eprintln!("warning: ignoring malformed --var '{kv}' (expected KEY=VALUE)"); } } // Inject connection info as variables. runner.set_var("_server", server); if let Some(u) = username { runner.set_var("_username", u); } let report = runner.run(&mut session, &client).await; print!("{report}"); if report.all_passed() { Ok(()) } else { std::process::exit(1); } } // ── Entry point ─────────────────────────────────────────────────────────────── #[cfg(not(feature = "v2"))] #[tokio::main] async fn main() -> anyhow::Result<()> { // Install the rustls crypto provider before any TLS operations. let _ = rustls::crypto::ring::default_provider().install_default(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), ) .init(); // Load config file and apply to env BEFORE clap parses (so config values // act as defaults that env vars and CLI flags can override). { let cfg = client_config::load_client_config(); client_config::apply_config_to_env(&cfg); } let args = Args::parse(); if args.danger_accept_invalid_certs { eprintln!("WARNING: TLS verification disabled — insecure mode"); set_insecure_skip_verify(true); } // For the REPL and TUI, defer init_auth so they can resolve their own token via OPAQUE. // For all other subcommands, initialize auth immediately. #[cfg(not(feature = "tui"))] let is_repl = matches!(args.command, None | Some(Command::Repl { .. })); #[cfg(feature = "tui")] let is_repl = matches!(args.command, None | Some(Command::Repl { .. }) | Some(Command::Tui { .. })); if !is_repl { let auth_ctx = ClientAuth::from_parts(args.access_token.clone(), args.device_id.clone()); init_auth(auth_ctx); } let state_pw = args.state_password.as_deref(); // Default to REPL when no subcommand is given. let no_server = args.no_server; let command = args.command.unwrap_or_else(|| Command::Repl { state: derive_state_path(args.state, args.username.as_deref()), server: args.server, username: args.username, password: args.password, no_server, }); match command { Command::RegisterUser { server, username, password, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_register_user( &server, &args.ca_cert, &args.server_name, &username, &password, None, )) .await } Command::Login { server, username, password, identity_key, state, state_password, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_login( &server, &args.ca_cert, &args.server_name, &username, &password, identity_key.as_deref(), state.as_deref(), state_password.as_deref(), )) .await } Command::Whoami { state } => cmd_whoami(&state, state_pw), Command::Health { server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_health(&server, &args.ca_cert, &args.server_name)) .await } Command::CheckKey { server, identity_key, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_check_key( &server, &args.ca_cert, &args.server_name, &identity_key, )) .await } Command::Ping { server } => cmd_ping(&server, &args.ca_cert, &args.server_name).await, Command::Register { server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_register(&server, &args.ca_cert, &args.server_name)) .await } Command::FetchKey { server, identity_key, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_fetch_key( &server, &args.ca_cert, &args.server_name, &identity_key, )) .await } Command::DemoGroup { server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_demo_group(&server, &args.ca_cert, &args.server_name)) .await } Command::RegisterState { state, server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_register_state( &state, &server, &args.ca_cert, &args.server_name, state_pw, )) .await } Command::RefreshKeypackage { state, server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_refresh_keypackage( &state, &server, &args.ca_cert, &args.server_name, state_pw, )) .await } Command::CreateGroup { state, server, group_id, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_create_group(&state, &server, &group_id, state_pw)) .await } Command::Invite { state, server, peer_key, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_invite( &state, &server, &args.ca_cert, &args.server_name, &peer_key, state_pw, )) .await } Command::Join { state, server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_join( &state, &server, &args.ca_cert, &args.server_name, state_pw, )) .await } Command::Send { state, server, peer_key, all, msg, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_send( &state, &server, &args.ca_cert, &args.server_name, peer_key.as_deref(), all, &msg, state_pw, )) .await } Command::Recv { state, server, wait_ms, stream, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_recv( &state, &server, &args.ca_cert, &args.server_name, wait_ms, stream, state_pw, )) .await } Command::Repl { state, server, username, password, no_server, } => { let state = derive_state_path(state, username.as_deref()); let local = tokio::task::LocalSet::new(); local .run_until(run_repl( &state, &server, &args.ca_cert, &args.server_name, state_pw, username.as_deref(), password.as_deref(), &args.access_token, args.device_id.as_deref(), no_server, )) .await } Command::Chat { state, server, peer_key, poll_interval_ms, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_chat( &state, &server, &args.ca_cert, &args.server_name, peer_key.as_deref(), state_pw, poll_interval_ms, )) .await } #[cfg(feature = "tui")] Command::Tui { state, server, username, password, } => { let state = derive_state_path(state, username.as_deref()); let local = tokio::task::LocalSet::new(); local .run_until(run_tui( &state, &server, &args.ca_cert, &args.server_name, state_pw, username.as_deref(), password.as_deref(), &args.access_token, args.device_id.as_deref(), )) .await } Command::Export { conv_db, conv_id, output, transcript_password, db_password, } => { // Prompt for transcript password if not provided. let tp = match transcript_password { Some(p) => p, None => { eprint!("Transcript password: "); rpassword::read_password() .context("failed to read transcript password")? } }; cmd_export( &conv_db, &conv_id, &output, &tp, db_password.as_deref().or(state_pw), ) } Command::ExportVerify { input } => cmd_export_verify(&input), #[cfg(feature = "playbook")] Command::Run { playbook, state, server, username, password, vars, } => { let state = derive_state_path(state, username.as_deref()); let local = tokio::task::LocalSet::new(); local .run_until(run_playbook( &playbook, &state, &server, &args.ca_cert, &args.server_name, state_pw, username.as_deref(), password.as_deref(), &args.access_token, args.device_id.as_deref(), &vars, )) .await } } }