use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::{Context, Result};
use astrid_capsule::manifest::{CapsuleManifest, EnvDef, EnvScope};
use astrid_core::PrincipalId;
use astrid_core::dirs::AstridHome;
use astrid_storage::{FileSecretStore, SecretStore, SecretStoreError};
use clap::{Args, Subcommand};
use colored::Colorize;
use serde::Serialize;
use serde_json::{Map, Value};
use crate::context;
use crate::theme::Theme;
use crate::value_formatter::{ValueFormat, emit_structured};
const HOST_SCOPE_SENTINEL: &str = "__host__";
fn secret_dir(home: &AstridHome, scope: &str, capsule: &str) -> PathBuf {
home.secrets_dir().join(scope).join(capsule)
}
fn open_secret_store(
home: &AstridHome,
principal: &PrincipalId,
capsule: &str,
scope: EnvScope,
) -> FileSecretStore {
let principal_seg = match scope {
EnvScope::Shared => HOST_SCOPE_SENTINEL.to_string(),
EnvScope::Agent => principal.as_str().to_string(),
};
FileSecretStore::new(secret_dir(home, &principal_seg, capsule))
}
fn secret_store_error(op: &str, e: &SecretStoreError) -> anyhow::Error {
anyhow::anyhow!("secret {op} failed: {e}")
}
fn load_capsule_manifest(capsule: &str) -> Result<Option<CapsuleManifest>> {
let home = AstridHome::resolve().context("Failed to resolve Astrid home directory")?;
let manifest_path = home
.principal_home(&PrincipalId::default())
.capsules_dir()
.join(capsule)
.join("Capsule.toml");
if !manifest_path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
let manifest: CapsuleManifest = toml::from_str(&contents)
.with_context(|| format!("Failed to parse {}", manifest_path.display()))?;
Ok(Some(manifest))
}
fn lookup_secret_decl<'a>(manifest: Option<&'a CapsuleManifest>, key: &str) -> Option<&'a EnvDef> {
manifest?
.env
.get(key)
.filter(|d| d.env_type.eq_ignore_ascii_case("secret"))
}
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum SecretCommand {
Set(SetArgs),
List(ListArgs),
Delete(DeleteArgs),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct SetArgs {
pub key: String,
pub value: String,
#[arg(short, long)]
pub agent: Option<String>,
#[arg(long, value_name = "NAME")]
pub capsule: Option<String>,
#[arg(long, value_name = "agent|shared")]
pub scope: Option<ScopeArg>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum ScopeArg {
Agent,
Shared,
}
impl From<ScopeArg> for EnvScope {
fn from(s: ScopeArg) -> Self {
match s {
ScopeArg::Agent => Self::Agent,
ScopeArg::Shared => Self::Shared,
}
}
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ListArgs {
#[arg(short, long)]
pub agent: Option<String>,
#[arg(long, default_value = "pretty")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct DeleteArgs {
pub key: String,
#[arg(short, long)]
pub agent: Option<String>,
#[arg(long, value_name = "NAME")]
pub capsule: Option<String>,
}
pub(crate) fn run(cmd: SecretCommand) -> Result<ExitCode> {
match cmd {
SecretCommand::Set(args) => run_set(&args),
SecretCommand::List(args) => run_list(&args),
SecretCommand::Delete(args) => run_delete(&args),
}
}
fn env_dir(principal: &PrincipalId) -> Result<PathBuf> {
let home = AstridHome::resolve().context("Failed to resolve Astrid home directory")?;
Ok(home.principal_home(principal).env_dir())
}
fn env_file(principal: &PrincipalId, capsule: Option<&str>) -> Result<PathBuf> {
let dir = env_dir(principal)?;
let name = capsule.unwrap_or("default");
Ok(dir.join(format!("{name}.env.json")))
}
fn read_env(path: &std::path::Path) -> Result<Map<String, Value>> {
if !path.exists() {
return Ok(Map::new());
}
let contents =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
if contents.trim().is_empty() {
return Ok(Map::new());
}
let value: Value = serde_json::from_str(&contents)
.with_context(|| format!("{} is not valid JSON", path.display()))?;
match value {
Value::Object(map) => Ok(map),
_ => anyhow::bail!("{} is not a JSON object", path.display()),
}
}
fn write_env(path: &std::path::Path, env: &Map<String, Value>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
let contents = serde_json::to_string_pretty(env).context("Failed to serialize env JSON")?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, &contents).with_context(|| format!("Failed to write {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&tmp, perms)
.with_context(|| format!("Failed to chmod {}", tmp.display()))?;
}
fs::rename(&tmp, path)
.with_context(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?;
Ok(())
}
fn classify_env_storage(is_declared_secret: bool, value: &Value) -> Option<SecretStorage> {
if !is_declared_secret {
return Some(SecretStorage::EnvJson);
}
if value.as_str() == Some("") {
return None;
}
Some(SecretStorage::EnvJsonLegacy)
}
fn run_set(args: &SetArgs) -> Result<ExitCode> {
if args.key.is_empty() {
anyhow::bail!("invalid key: must not be empty");
}
let principal = context::resolve_agent(args.agent.as_deref())?;
if args.scope.is_some() && args.capsule.is_none() {
anyhow::bail!(
"--scope requires --capsule (the manifest is read from the named capsule to confirm \
the key is declared as a secret)"
);
}
let manifest = match args.capsule.as_deref() {
Some(c) => load_capsule_manifest(c)?,
None => None,
};
let secret_decl = args
.capsule
.as_deref()
.and_then(|_| lookup_secret_decl(manifest.as_ref(), &args.key));
if args.scope.is_some() && secret_decl.is_none() {
anyhow::bail!(
"--scope requires the capsule manifest to declare '{}' as type=\"secret\" \
(manifest declares either a non-secret env field, or no field at all for this key)",
args.key
);
}
if secret_decl.is_some() {
let scope: EnvScope = args.scope.map_or(EnvScope::Agent, EnvScope::from);
let capsule = args
.capsule
.as_deref()
.expect("capsule name required to route a secret-typed key — the loader above already gates on Some(manifest), which requires args.capsule");
let home = AstridHome::resolve().context("Failed to resolve Astrid home directory")?;
let store = open_secret_store(&home, &principal, capsule, scope);
store
.set(&args.key, &args.value)
.map_err(|e| secret_store_error("set", &e))?;
let target = match scope {
EnvScope::Agent => format!("agent '{principal}' (capsule {capsule})"),
EnvScope::Shared => format!("host-wide (capsule {capsule})"),
};
println!(
"{}",
Theme::success(&format!("Stored '{}' for {target}", args.key))
);
} else {
let path = env_file(&principal, args.capsule.as_deref())?;
let mut env = read_env(&path)?;
env.insert(args.key.clone(), Value::String(args.value.clone()));
write_env(&path, &env)?;
println!(
"{}",
Theme::success(&format!(
"Stored '{}' for agent '{}'{}",
args.key,
principal,
args.capsule
.as_deref()
.map_or_else(String::new, |c| format!(" (capsule {c})"))
))
);
}
Ok(ExitCode::SUCCESS)
}
fn run_list(args: &ListArgs) -> Result<ExitCode> {
let principal = context::resolve_agent(args.agent.as_deref())?;
let format = ValueFormat::parse(&args.format);
let dir = env_dir(&principal)?;
let mut keys: Vec<SecretKey> = Vec::new();
if dir.exists() {
for entry in
fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))?
{
let entry = entry?;
let p = entry.path();
let Some(file_name) = p.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(stem) = file_name.strip_suffix(".env.json") else {
continue;
};
let env = read_env(&p)?;
let manifest = load_capsule_manifest(stem)?;
for (k, value) in &env {
let is_declared_secret = manifest
.as_ref()
.and_then(|m| m.env.get(k))
.is_some_and(|d| d.env_type.eq_ignore_ascii_case("secret"));
let Some(storage) = classify_env_storage(is_declared_secret, value) else {
continue;
};
keys.push(SecretKey {
capsule: stem.to_string(),
key: k.clone(),
storage,
scope: None,
});
}
}
}
if let Ok(home) = AstridHome::resolve() {
let capsules_dir = home.principal_home(&PrincipalId::default()).capsules_dir();
if capsules_dir.is_dir() {
for entry in fs::read_dir(&capsules_dir)
.with_context(|| format!("Failed to read {}", capsules_dir.display()))?
{
let entry = entry?;
let capsule = entry.file_name().to_string_lossy().into_owned();
let Some(manifest) = load_capsule_manifest(&capsule)? else {
continue;
};
for (key, decl) in &manifest.env {
if !decl.env_type.eq_ignore_ascii_case("secret") {
continue;
}
for scope in [EnvScope::Agent, EnvScope::Shared] {
let store = open_secret_store(&home, &principal, &capsule, scope);
if store.exists(key).unwrap_or(false) {
keys.push(SecretKey {
capsule: capsule.clone(),
key: key.clone(),
storage: SecretStorage::File,
scope: Some(scope),
});
}
}
}
}
}
}
keys.sort_by(|a, b| a.capsule.cmp(&b.capsule).then_with(|| a.key.cmp(&b.key)));
if !format.is_pretty() {
emit_structured(&keys, format)?;
return Ok(ExitCode::SUCCESS);
}
if keys.is_empty() {
println!("{}", Theme::info("(no secrets stored)"));
return Ok(ExitCode::SUCCESS);
}
println!(
"{:<24} {:<32} {:<12} {}",
"CAPSULE".bold(),
"KEY".bold(),
"STORAGE".bold(),
"SCOPE".bold(),
);
for k in &keys {
let storage = match k.storage {
SecretStorage::File => "file".green().to_string(),
SecretStorage::EnvJson => "env-json".dimmed().to_string(),
SecretStorage::EnvJsonLegacy => "env-json (LEGACY!)".red().to_string(),
};
let scope = match k.scope {
Some(EnvScope::Agent) => "agent",
Some(EnvScope::Shared) => "shared",
None => "—",
};
println!("{:<24} {:<32} {:<12} {scope}", k.capsule, k.key, storage);
}
Ok(ExitCode::SUCCESS)
}
fn run_delete(args: &DeleteArgs) -> Result<ExitCode> {
let principal = context::resolve_agent(args.agent.as_deref())?;
if let Some(capsule) = args.capsule.as_deref() {
let manifest = load_capsule_manifest(capsule)?;
if lookup_secret_decl(manifest.as_ref(), &args.key).is_some() {
let home = AstridHome::resolve().context("Failed to resolve Astrid home directory")?;
let mut removed = false;
for scope in [EnvScope::Agent, EnvScope::Shared] {
let store = open_secret_store(&home, &principal, capsule, scope);
match store.delete(&args.key) {
Ok(true) => removed = true,
Ok(false) => {},
Err(e) => return Err(secret_store_error("delete", &e)),
}
}
if removed {
println!(
"{}",
Theme::success(&format!(
"Removed '{}' for agent '{}' (capsule {})",
args.key, principal, capsule
))
);
return Ok(ExitCode::SUCCESS);
}
}
}
let path = env_file(&principal, args.capsule.as_deref())?;
let mut env = read_env(&path)?;
if env.remove(&args.key).is_none() {
eprintln!("{}", Theme::warning(&format!("'{}' not set", args.key)));
return Ok(ExitCode::from(1));
}
if env.is_empty() {
match fs::remove_file(&path) {
Ok(()) => {},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {},
Err(e) => {
return Err(e).with_context(|| format!("Failed to remove {}", path.display()));
},
}
} else {
write_env(&path, &env)?;
}
println!(
"{}",
Theme::success(&format!("Removed '{}' for agent '{}'", args.key, principal))
);
Ok(ExitCode::SUCCESS)
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SecretKey {
pub capsule: String,
pub key: String,
pub storage: SecretStorage,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<EnvScope>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SecretStorage {
File,
EnvJson,
EnvJsonLegacy,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_env_handles_missing_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("does-not-exist.env.json");
assert!(read_env(&p).unwrap().is_empty());
}
#[test]
fn read_env_handles_empty_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("empty.env.json");
fs::write(&p, "").unwrap();
assert!(read_env(&p).unwrap().is_empty());
}
#[test]
fn write_env_atomic_round_trip() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("a.env.json");
let mut env = Map::new();
env.insert("KEY".into(), Value::String("value".into()));
write_env(&p, &env).unwrap();
let read = read_env(&p).unwrap();
assert_eq!(read.get("KEY").and_then(|v| v.as_str()), Some("value"));
let tmp = p.with_extension("json.tmp");
assert!(!tmp.exists(), "tempfile should be renamed away");
}
#[test]
fn read_env_rejects_non_object() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("bad.env.json");
fs::write(&p, r#"["not", "an", "object"]"#).unwrap();
let err = read_env(&p).expect_err("malformed");
assert!(err.to_string().contains("not a JSON object"), "got: {err}");
}
#[test]
fn read_env_rejects_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("bad.env.json");
fs::write(&p, "{not json").unwrap();
let err = read_env(&p).expect_err("malformed");
assert!(err.to_string().contains("not valid JSON"), "got: {err}");
}
#[test]
fn empty_secret_marker_is_not_reported_as_legacy_plaintext() {
assert!(classify_env_storage(true, &Value::String(String::new())).is_none());
assert!(matches!(
classify_env_storage(true, &Value::String("secret".into())),
Some(SecretStorage::EnvJsonLegacy)
));
assert!(matches!(
classify_env_storage(false, &Value::String(String::new())),
Some(SecretStorage::EnvJson)
));
}
}