Skip to main content

systemprompt_cli/commands/admin/config/
secret.rs

1//! `admin config secret set` — write a provider or custom credential into the
2//! profile's secrets file without hand-editing JSON.
3//!
4//! Infrastructure secrets (database URLs, at-rest pepper, signing seed) are
5//! rejected: they are provisioned out-of-band, so a partial edit here cannot
6//! corrupt the values the runtime depends on.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::path::Path;
12
13use anyhow::{Context, Result, bail};
14use clap::{Args, Subcommand};
15use systemprompt_config::ProfileBootstrap;
16
17use super::profile_io::{load_profile, profile_dir};
18use super::types::ConfigMutationOutput;
19use crate::CliConfig;
20use crate::shared::{CommandOutput, render_result};
21
22const RESERVED: &[&str] = &[
23    "oauth_at_rest_pepper",
24    "manifest_signing_secret_seed",
25    "database_url",
26    "database_write_url",
27    "external_database_url",
28    "internal_database_url",
29];
30
31#[derive(Debug, Subcommand)]
32pub enum SecretCommands {
33    #[command(about = "Set a provider or custom secret")]
34    Set(SetArgs),
35
36    #[command(about = "Report the active secrets source, key names, and gaps")]
37    Check,
38}
39
40#[derive(Debug, Clone, Args)]
41pub struct SetArgs {
42    #[arg(help = "Secret name (e.g. anthropic, minimax)")]
43    pub name: String,
44
45    #[arg(help = "Secret value")]
46    pub value: String,
47}
48
49pub async fn execute(command: &SecretCommands, config: &CliConfig) -> Result<()> {
50    let args = match command {
51        SecretCommands::Set(args) => args,
52        SecretCommands::Check => return super::secret_check::execute(config).await,
53    };
54
55    let profile_path = ProfileBootstrap::get_path()?;
56    let profile = load_profile(profile_path)?;
57    let secrets_rel = profile
58        .secrets
59        .as_ref()
60        .ok_or_else(|| anyhow::anyhow!("profile has no secrets section"))?
61        .secrets_path()?
62        .to_owned();
63    let secrets_file = profile_dir(profile_path).join(&secrets_rel);
64
65    set_secret(&secrets_file, &args.name, &args.value)?;
66
67    render_result(
68        &CommandOutput::card_value(
69            "Secret Updated",
70            &ConfigMutationOutput {
71                field: "secrets".to_owned(),
72                message: format!("Secret '{}' set", args.name),
73            },
74        ),
75        config,
76    );
77    Ok(())
78}
79
80pub fn set_secret(secrets_file: &Path, name: &str, value: &str) -> Result<()> {
81    if RESERVED.contains(&name) {
82        bail!("'{name}' is a reserved infrastructure secret and cannot be set here");
83    }
84
85    let content = std::fs::read_to_string(secrets_file)
86        .with_context(|| format!("Failed to read secrets: {}", secrets_file.display()))?;
87    // JSON: operator tooling edits the on-disk secrets document by key, so a
88    // file still missing a required field can be completed one secret at a time.
89    let mut doc: serde_json::Value = serde_json::from_str(&content)
90        .with_context(|| format!("Failed to parse secrets: {}", secrets_file.display()))?;
91    let object = doc.as_object_mut().ok_or_else(|| {
92        anyhow::anyhow!(
93            "secrets file is not a JSON object: {}",
94            secrets_file.display()
95        )
96    })?;
97    object.insert(name.to_owned(), serde_json::Value::String(value.to_owned()));
98
99    let serialized = serde_json::to_string_pretty(&doc).context("Failed to serialize secrets")?;
100    std::fs::write(secrets_file, serialized)
101        .with_context(|| format!("Failed to write {}", secrets_file.display()))?;
102    Ok(())
103}