systemprompt_cli/commands/admin/config/
secret.rs1use std::path::Path;
15
16use anyhow::{Context, Result, bail};
17use clap::{Args, Subcommand};
18use systemprompt_config::{ProfileBootstrap, write_private_atomic};
19use systemprompt_manifest::profile::SecretsSource;
20
21use super::profile_io::{load_profile, profile_dir};
22use super::types::ConfigMutationOutput;
23use crate::CliConfig;
24use crate::shared::{CommandOutput, render_result};
25
26const RESERVED: &[&str] = &[
27 "oauth_at_rest_pepper",
28 "manifest_signing_secret_seed",
29 "database_url",
30 "database_write_url",
31 "external_database_url",
32 "internal_database_url",
33];
34
35#[derive(Debug, Subcommand)]
36pub enum SecretCommands {
37 #[command(about = "Set a provider or custom secret")]
38 Set(SetArgs),
39
40 #[command(about = "Report the active secrets source, key names, and gaps")]
41 Check,
42}
43
44#[derive(Debug, Clone, Args)]
45pub struct SetArgs {
46 #[arg(help = "Secret name (e.g. anthropic, minimax)")]
47 pub name: String,
48
49 #[arg(help = "Secret value")]
50 pub value: String,
51}
52
53pub async fn execute(command: &SecretCommands, config: &CliConfig) -> Result<()> {
54 let args = match command {
55 SecretCommands::Set(args) => args,
56 SecretCommands::Check => return super::secret_check::execute(config).await,
57 };
58
59 let profile_path = ProfileBootstrap::get_path()?;
60 let profile = load_profile(profile_path)?;
61 let secrets_config = profile
62 .secrets
63 .as_ref()
64 .ok_or_else(|| anyhow::anyhow!("profile has no secrets section"))?;
65 if matches!(
66 secrets_config.source,
67 SecretsSource::Vault | SecretsSource::External
68 ) {
69 bail!(
70 "secrets are managed by {}; rotate them there and run `admin config secret check`",
71 secrets_config.source_name()
72 );
73 }
74 let secrets_rel = secrets_config.secrets_path()?.to_owned();
75 let secrets_file = profile_dir(profile_path).join(&secrets_rel);
76
77 set_secret(&secrets_file, &args.name, &args.value)?;
78
79 render_result(
80 &CommandOutput::card_value(
81 "Secret Updated",
82 &ConfigMutationOutput {
83 field: "secrets".to_owned(),
84 message: format!("Secret '{}' set", args.name),
85 },
86 ),
87 config,
88 );
89 Ok(())
90}
91
92pub fn set_secret(secrets_file: &Path, name: &str, value: &str) -> Result<()> {
93 if RESERVED.contains(&name) {
94 bail!("'{name}' is a reserved infrastructure secret and cannot be set here");
95 }
96
97 let content = std::fs::read_to_string(secrets_file)
98 .with_context(|| format!("Failed to read secrets: {}", secrets_file.display()))?;
99 let mut doc: serde_json::Value = serde_json::from_str(&content)
102 .with_context(|| format!("Failed to parse secrets: {}", secrets_file.display()))?;
103 let object = doc.as_object_mut().ok_or_else(|| {
104 anyhow::anyhow!(
105 "secrets file is not a JSON object: {}",
106 secrets_file.display()
107 )
108 })?;
109 object.insert(name.to_owned(), serde_json::Value::String(value.to_owned()));
110
111 let serialized = serde_json::to_string_pretty(&doc).context("Failed to serialize secrets")?;
112 write_private_atomic(secrets_file, serialized.as_bytes())
113 .with_context(|| format!("Failed to write {}", secrets_file.display()))
114}