use anyhow::{bail, Result};
use patina::{paths, scanner, secrets};
use std::env;
use std::io::{self, BufRead, Write};
#[derive(Debug, Clone, clap::Subcommand)]
pub enum SecretsCommands {
Add {
name: String,
#[arg(long)]
env: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
global: bool,
},
Run {
#[arg(long)]
ssh: Option<String>,
#[arg(last = true, required = true)]
command: Vec<String>,
},
AddRecipient {
key: String,
},
RemoveRecipient {
key: String,
},
ListRecipients,
Check,
Audit,
}
#[derive(Debug, Clone, clap::Args)]
pub struct SecretsFlags {
#[arg(long)]
pub remove: Option<String>,
#[arg(long)]
pub export_key: bool,
#[arg(long)]
pub stdout: bool,
#[arg(long)]
pub import_key: bool,
#[arg(long)]
pub reset: bool,
#[arg(long)]
pub lock: bool,
#[arg(long)]
pub confirm: bool,
#[arg(long)]
pub global: bool,
}
pub fn execute_cli(command: Option<SecretsCommands>, flags: SecretsFlags) -> Result<()> {
if flags.lock {
return secrets::lock_session();
}
if flags.export_key {
return export_key(flags.confirm, flags.stdout);
}
if flags.import_key {
return import_key();
}
if flags.reset {
return reset_identity(flags.confirm);
}
if let Some(name) = flags.remove {
let project_root = env::current_dir().ok();
return secrets::remove_secret(&name, flags.global, project_root.as_deref());
}
match command {
Some(cmd) => execute(cmd),
None => status(), }
}
pub fn execute(command: SecretsCommands) -> Result<()> {
match command {
SecretsCommands::Add {
name,
env,
stdin,
global,
} => add(&name, env.as_deref(), stdin, global),
SecretsCommands::Run { ssh, command } => run(ssh.as_deref(), &command),
SecretsCommands::AddRecipient { key } => add_recipient(&key),
SecretsCommands::RemoveRecipient { key } => remove_recipient(&key),
SecretsCommands::ListRecipients => list_recipients(),
SecretsCommands::Check => check_staged(),
SecretsCommands::Audit => audit_tracked(),
}
}
fn status() -> Result<()> {
let project_root = env::current_dir().ok();
let status = secrets::check_status(project_root.as_deref())?;
println!("Identity:");
match status.identity_source {
Some(source) => {
println!(" ✓ Available via {}", source);
if let Some(ref key) = status.recipient_key {
println!(" Public key: {}", key);
}
}
None => {
println!(" ✗ Not configured");
println!(" Run: patina secrets add <name> to create vault and identity");
}
}
println!();
println!("Global vault (~/.patina/):");
if status.global.exists {
println!(
" ✓ {} secrets, {} recipients",
status.global.secret_count, status.global.recipient_count
);
if !status.global.secret_names.is_empty() {
println!(" Secrets: {}", status.global.secret_names.join(", "));
}
} else {
println!(" ✗ Not initialized");
}
if let Some(project) = &status.project {
println!();
println!("Project vault (.patina/):");
if project.exists {
println!(
" ✓ {} secrets, {} recipients",
project.secret_count, project.recipient_count
);
if !project.secret_names.is_empty() {
println!(" Secrets: {}", project.secret_names.join(", "));
}
} else {
println!(" ✗ Not initialized");
}
}
println!();
println!("Commands:");
println!(" patina secrets add NAME [--stdin] Add a secret");
println!(" patina secrets run -- CMD Run with secrets");
println!(" patina secrets --lock Clear session cache");
Ok(())
}
fn add(name: &str, env: Option<&str>, from_stdin: bool, global: bool) -> Result<()> {
let project_root = env::current_dir().ok();
let secret_value = if from_stdin {
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
line.trim().to_string()
} else if atty::is(atty::Stream::Stdin) {
eprint!("Value for {}: ", name);
secrets::prompt_for_value(name)?
} else {
bail!("Use --stdin to read secret values from a pipe");
};
if secret_value.is_empty() {
bail!("Secret value cannot be empty");
}
secrets::add_secret(name, &secret_value, env, global, project_root.as_deref())
}
fn run(ssh: Option<&str>, command: &[String]) -> Result<()> {
let project_root = env::current_dir().ok();
let exit_code = if let Some(host) = ssh {
secrets::run_with_secrets_ssh(project_root.as_deref(), host, command)?
} else {
secrets::run_with_secrets(project_root.as_deref(), command)?
};
if exit_code != 0 {
std::process::exit(exit_code);
}
Ok(())
}
fn export_key(confirm: bool, to_stdout: bool) -> Result<()> {
if !confirm {
println!("⚠️ This will export your private key.");
println!(" Add --confirm to proceed.");
println!(" Add --stdout to print to terminal (for piping).");
return Ok(());
}
let identity = secrets::export_identity()?;
if to_stdout {
println!("{}", &*identity);
} else {
let key_path = paths::patina_home().join("identity.age");
std::fs::write(&key_path, identity.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
}
println!("✓ Key exported to {} (0o600)", key_path.display());
}
Ok(())
}
fn import_key() -> Result<()> {
print!("Paste identity: ");
io::stdout().flush()?;
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
let recipient = secrets::import_identity(line.trim())?;
println!("✓ Stored in macOS Keychain (Touch ID protected)");
println!(" Public key: {}", recipient);
Ok(())
}
fn reset_identity(confirm: bool) -> Result<()> {
if !confirm {
println!("⚠️ This will DELETE your private key from Keychain.");
println!(" You will lose access to all encrypted vaults unless you have a backup.");
println!(" Add --confirm to proceed.");
return Ok(());
}
secrets::reset_identity()?;
println!("✓ Identity removed from Keychain");
Ok(())
}
fn add_recipient(key: &str) -> Result<()> {
let project_root = env::current_dir()?;
secrets::add_recipient(&project_root, key)
}
fn remove_recipient(key: &str) -> Result<()> {
let project_root = env::current_dir()?;
secrets::remove_recipient(&project_root, key)
}
fn list_recipients() -> Result<()> {
let project_root = env::current_dir()?;
let recipients = secrets::list_recipients(&project_root)?;
if recipients.is_empty() {
println!("No recipients configured.");
println!(" Run: patina secrets add <name> to initialize vault");
} else {
println!("Recipients ({}):", recipients.len());
for r in recipients {
println!(" {}", r);
}
}
Ok(())
}
fn check_staged() -> Result<()> {
let repo_root = env::current_dir()?;
let findings = scanner::scan_staged(&repo_root)?;
if findings.is_empty() {
println!("No secrets found in staged files.");
return Ok(());
}
println!("Found {} secret(s):\n", findings.len());
print_findings(&findings);
println!("\nCommit blocked. Remove secret or use `patina secrets add`.");
std::process::exit(1);
}
fn audit_tracked() -> Result<()> {
let repo_root = env::current_dir()?;
let findings = scanner::scan_tracked(&repo_root)?;
if findings.is_empty() {
println!("All clear - no secrets found.");
return Ok(());
}
println!("Found {} secret(s):\n", findings.len());
print_findings(&findings);
std::process::exit(1);
}
fn print_findings(findings: &[scanner::Finding]) {
for f in findings {
println!(" {}:{}:{}", f.path.display(), f.line, f.column);
println!(" Pattern: {}", f.pattern);
println!(" Severity: {}", f.severity);
println!(" Match: {}", f.matched);
println!();
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_secrets_command_parse() {
assert!(true);
}
}