mod config;
mod crypto;
mod meta;
mod passphrase;
mod session;
mod vault;
use anyhow::Result;
use clap::{Parser, Subcommand};
use console::style;
use dialoguer::{Confirm, Input, Password, Select};
use std::path::PathBuf;
use meta::{AccessConfig, AllowAgent, VaultMeta};
use vault::{list_vault_dirs, Vault, SVAULT_DIR};
#[derive(Parser)]
#[command(name = "svault", about = "AI-aware secret access layer", version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(long)]
name: Option<String>,
},
Secret {
action: String,
name: Option<String>,
#[arg(long)]
vault: Option<String>,
},
Vaults,
Unlock {
#[arg(long)]
vault: Option<String>,
},
Lock {
#[arg(long)]
all: bool,
#[arg(long)]
vault: Option<String>,
},
Status,
Install {
#[arg(long, default_value = "auto")]
platform: String,
#[arg(long)]
project: bool,
},
Get {
name: String,
#[arg(long)]
scope: String,
#[arg(long)]
reason: String,
#[arg(long)]
vault: Option<String>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { name } => cmd_init(name),
Commands::Secret { action, name, vault } => cmd_secret(&action, name.as_deref(), vault.as_deref()),
Commands::Vaults => cmd_vaults(),
Commands::Unlock { vault } => cmd_unlock(vault.as_deref()),
Commands::Lock { all, vault } => cmd_lock(all, vault.as_deref()),
Commands::Status => cmd_status(),
Commands::Install { platform, .. } => {
println!("{} Install for '{}' coming in Step 4", style("⏳").yellow(), platform);
Ok(())
}
Commands::Get { name, scope, reason, .. } => {
println!("{} Requesting: {}", style("→").cyan(), style(&name).bold());
println!(" scope: {scope}");
println!(" reason: {reason}");
println!("{} Policy engine coming in Step 2", style("⏳").yellow());
Ok(())
}
}
}
fn cmd_init(name_arg: Option<String>) -> Result<()> {
println!("{}", style("┌─ New Vault ─────────────────────────────┐").dim());
let default_name = std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "my-vault".to_string());
let name: String = match name_arg {
Some(n) => n,
None => Input::new().with_prompt(" Vault name").default(default_name).interact_text()?,
};
let vault_dir = PathBuf::from(SVAULT_DIR).join(&name);
if vault_dir.exists() {
eprintln!("{} Vault '{}' already exists", style("✗").red(), name);
std::process::exit(1);
}
let description: String = Input::new()
.with_prompt(" Description")
.allow_empty(true)
.interact_text()?;
let agent_choices = &["yes — all agents", "no — block all agents", "list — specific agents only"];
let agent_idx = Select::new()
.with_prompt(" Allow agent access")
.items(agent_choices)
.default(0)
.interact()?;
let allow_agent = match agent_idx {
0 => AllowAgent::Bool(true),
1 => AllowAgent::Bool(false),
_ => {
let raw: String = Input::new()
.with_prompt(" Agent names (comma-separated)")
.interact_text()?;
AllowAgent::List(
raw.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
)
}
};
let rate_limit: String = Input::new()
.with_prompt(" Rate limit")
.default("10/hour".to_string())
.interact_text()?;
println!();
let passphrase = Password::new().with_prompt(" Passphrase").interact()?;
if let Some(w) = passphrase::check(&passphrase) {
println!("{} {}", style("⚠").yellow(), w.0);
if !Confirm::new().with_prompt(" Continue anyway?").default(false).interact()? {
return Ok(());
}
}
let confirm = Password::new().with_prompt(" Confirm passphrase").interact()?;
if passphrase != confirm {
eprintln!("{} Passphrases do not match", style("✗").red());
std::process::exit(1);
}
println!("\n Creating vault...");
let meta = VaultMeta::new(name.clone(), description, AccessConfig { allow_agent, rate_limit });
Vault::init(&vault_dir, &passphrase, meta)?;
println!();
println!(" {:<14} {}", style("Name").dim(), style(&name).bold().cyan());
println!(" {:<14} {}", style("Location").dim(), style(format!("{}/", vault_dir.display())).cyan());
println!();
println!("{} Vault '{}' created", style("✓").green().bold(), name);
println!("{}", style(" vault.enc + meta.yaml are safe to commit — encrypted at rest.").dim());
println!("{}", style(format!(" git add {}/", vault_dir.display())).dim());
Ok(())
}
fn cmd_unlock(vault_name: Option<&str>) -> Result<()> {
let vault_dir = resolve_vault_dir(vault_name)?;
let meta = VaultMeta::load_unverified(&vault_dir)?;
if session::is_unlocked(&vault_dir) {
println!("{} Vault '{}' is already unlocked", style("✓").green(), meta.name);
return Ok(());
}
let passphrase = Password::new()
.with_prompt(format!(" Passphrase for '{}'", meta.name))
.interact()?;
Vault::open(&vault_dir, &passphrase)
.map_err(|e| { eprintln!("{} {}", style("✗").red(), e); std::process::exit(1); #[allow(unreachable_code)] e })?;
session::unlock(&vault_dir, &passphrase)?;
println!("{} Vault '{}' unlocked", style("✓").green().bold(), meta.name);
println!("{}", style(" Session active — passphrase cached in .svault/<name>/.session (mode 0600)").dim());
println!("{}", style(" Run 'svault lock' to clear it.").dim());
Ok(())
}
fn cmd_lock(lock_all: bool, vault_name: Option<&str>) -> Result<()> {
if lock_all {
let count = session::lock_all(std::path::Path::new(SVAULT_DIR))?;
if count == 0 {
println!("{}", style("All vaults already locked.").dim());
} else {
println!("{} Locked {} vault(s)", style("✓").yellow().bold(), count);
}
return Ok(());
}
let vault_dir = resolve_vault_dir(vault_name)?;
let meta = VaultMeta::load_unverified(&vault_dir)?;
session::lock(&vault_dir)?;
println!("{} Vault '{}' locked", style("✓").yellow().bold(), meta.name);
Ok(())
}
fn cmd_status() -> Result<()> {
let dirs = list_vault_dirs();
if dirs.is_empty() {
println!("{}", style("No vaults found. Run 'svault init' to create one.").dim());
return Ok(());
}
println!("{:<20} {:<12} {}", style("VAULT").bold(), style("STATUS").bold(), style("DESCRIPTION").bold());
println!("{}", style("─".repeat(55)).dim());
for dir in &dirs {
if let Ok(meta) = VaultMeta::load_unverified(dir) {
let (lock_icon, status) = if session::is_unlocked(dir) {
(style("🔓").to_string(), style("unlocked").green().to_string())
} else {
(style("🔒").to_string(), style("locked").dim().to_string())
};
println!("{} {:<18} {:<12} {}",
lock_icon,
style(&meta.name).cyan(),
status,
if meta.description.is_empty() { "—".into() } else { meta.description.clone() },
);
}
}
Ok(())
}
fn cmd_secret(action: &str, name: Option<&str>, vault_name: Option<&str>) -> Result<()> {
let vault_dir = resolve_vault_dir(vault_name)?;
let meta_preview = VaultMeta::load_unverified(&vault_dir)?;
let passphrase = if session::is_unlocked(&vault_dir) {
session::get_passphrase(&vault_dir).unwrap_or_else(|| {
Password::new()
.with_prompt(format!(" Passphrase for '{}'", meta_preview.name))
.interact()
.unwrap()
})
} else {
let p = Password::new()
.with_prompt(format!(" Passphrase for '{}'", meta_preview.name))
.interact()?;
println!("{}", style(" Tip: run 'svault unlock' to cache passphrase for this session").dim());
p
};
let vault = Vault::open(&vault_dir, &passphrase)
.map_err(|e| { eprintln!("{} {}", style("✗").red(), e); std::process::exit(1); #[allow(unreachable_code)] e })?;
match action {
"add" => {
let secret_name: String = match name {
Some(n) => n.to_string(),
None => Input::new().with_prompt(" Secret name").interact_text()?,
};
let value = Password::new().with_prompt(format!(" Value for '{secret_name}'")).interact()?;
vault.add_secret(&secret_name, &value)?;
println!("{} Secret '{}' added", style("✓").green().bold(), secret_name);
}
"get" => {
let Some(secret_name) = name else {
eprintln!("{} Provide a secret name: svault secret get <NAME>", style("✗").red());
std::process::exit(1);
};
match vault.get_secret(secret_name)? {
Some(value) => println!("{value}"),
None => { eprintln!("{} Secret '{}' not found", style("✗").red(), secret_name); std::process::exit(1); }
}
}
"list" => {
let names = vault.list_secret_names()?;
if names.is_empty() {
println!("{}", style("No secrets stored yet.").dim());
} else {
println!("{}", style(format!("Secrets in '{}':", vault.meta.name)).bold());
for n in &names { println!(" {}", style(n).cyan()); }
}
}
"remove" => {
let secret_name: String = match name {
Some(n) => n.to_string(),
None => Input::new().with_prompt(" Secret name to remove").interact_text()?,
};
if Confirm::new().with_prompt(format!(" Remove '{secret_name}'?")).default(false).interact()? {
if vault.remove_secret(&secret_name)? {
println!("{} Secret '{}' removed", style("✓").yellow(), secret_name);
} else {
eprintln!("{} Secret '{}' not found", style("✗").red(), secret_name);
}
}
}
_ => {
eprintln!("{} Unknown action '{}'. Use: add | get | list | remove", style("✗").red(), action);
std::process::exit(1);
}
}
Ok(())
}
fn cmd_vaults() -> Result<()> {
let dirs = list_vault_dirs();
if dirs.is_empty() {
println!("{}", style("No vaults found. Run 'svault init' to create one.").dim());
return Ok(());
}
println!("{:<20} {:<30} {:<20} {:<12} {}",
style("NAME").bold(), style("DESCRIPTION").bold(),
style("ALLOW AGENT").bold(), style("RATE LIMIT").bold(), style("CREATED").bold(),
);
println!("{}", style("─".repeat(90)).dim());
for dir in &dirs {
if let Ok(meta) = VaultMeta::load_unverified(dir) {
let created = &meta.created_at[..10];
println!("{:<20} {:<30} {:<20} {:<12} {}",
style(&meta.name).cyan(),
if meta.description.is_empty() { "—".into() } else { meta.description.clone() },
meta.access.allow_agent.to_string(),
meta.access.rate_limit,
created,
);
}
}
Ok(())
}
fn resolve_vault_dir(vault_name: Option<&str>) -> Result<PathBuf> {
match vault_name {
Some(n) => Ok(PathBuf::from(SVAULT_DIR).join(n)),
None => {
let dirs = list_vault_dirs();
if dirs.is_empty() {
eprintln!("{} No vault found. Run {} first.", style("✗").red(), style("svault init").bold());
std::process::exit(1);
}
Ok(dirs[0].clone())
}
}
}