Skip to main content

systemprompt_cli/commands/admin/config/
provider.rs

1//! `admin config provider` command: manage AI providers in the
2//! `ai/config.yaml`.
3//!
4//! [`ProviderCommands`] lists providers, sets the default, and toggles a
5//! provider's enabled flag, editing the AI config YAML in place.
6
7use anyhow::Result;
8use clap::{Args, Subcommand};
9use systemprompt_config::ProfileBootstrap;
10
11use super::types::{
12    ConfigSection, ProviderInfo, ProviderListOutput, ProviderSetOutput, read_yaml_file,
13    write_yaml_file,
14};
15use crate::CliConfig;
16use crate::shared::{CommandOutput, render_result};
17
18#[derive(Debug, Subcommand)]
19pub enum ProviderCommands {
20    #[command(about = "List AI providers")]
21    List(ListArgs),
22
23    #[command(about = "Set default provider")]
24    Set(SetArgs),
25
26    #[command(about = "Enable a provider")]
27    Enable(EnableArgs),
28
29    #[command(about = "Disable a provider")]
30    Disable(DisableArgs),
31}
32
33#[derive(Debug, Clone, Copy, Args)]
34pub struct ListArgs;
35
36#[derive(Debug, Clone, Args)]
37pub struct SetArgs {
38    #[arg(value_name = "PROVIDER")]
39    pub provider: String,
40}
41
42#[derive(Debug, Clone, Args)]
43pub struct EnableArgs {
44    #[arg(value_name = "PROVIDER")]
45    pub provider: String,
46}
47
48#[derive(Debug, Clone, Args)]
49pub struct DisableArgs {
50    #[arg(value_name = "PROVIDER")]
51    pub provider: String,
52}
53
54pub fn execute(cmd: ProviderCommands, config: &CliConfig) -> Result<()> {
55    match cmd {
56        ProviderCommands::List(_args) => {
57            let result = list_providers()?;
58            render_result(
59                &CommandOutput::table_of(
60                    vec!["name", "enabled", "is_default", "model", "endpoint"],
61                    &result.providers,
62                )
63                .with_title("AI Providers"),
64                config,
65            );
66        },
67        ProviderCommands::Set(args) => {
68            let result = set_default_provider(&args.provider)?;
69            render_result(
70                &CommandOutput::card_value("Provider Updated", &result),
71                config,
72            );
73        },
74        ProviderCommands::Enable(args) => {
75            let result = set_provider_enabled(&args.provider, true)?;
76            render_result(
77                &CommandOutput::card_value("Provider Enabled", &result),
78                config,
79            );
80        },
81        ProviderCommands::Disable(args) => {
82            let result = set_provider_enabled(&args.provider, false)?;
83            render_result(
84                &CommandOutput::card_value("Provider Disabled", &result),
85                config,
86            );
87        },
88    }
89    Ok(())
90}
91
92fn get_ai_config_path() -> Result<std::path::PathBuf> {
93    ConfigSection::Ai.file_path()
94}
95
96fn list_providers() -> Result<ProviderListOutput> {
97    let registry = &ProfileBootstrap::get()?.providers;
98    let file_path = get_ai_config_path()?;
99    let content = read_yaml_file(&file_path)?;
100
101    let ai = content
102        .get("ai")
103        .ok_or_else(|| anyhow::anyhow!("Missing 'ai' section in config"))?;
104
105    let default_provider = ai
106        .get("default_provider")
107        .and_then(|v| v.as_str())
108        .unwrap_or("unknown")
109        .to_owned();
110
111    let providers_section = ai.get("providers");
112
113    let mut providers = Vec::new();
114
115    if let Some(serde_yaml::Value::Mapping(providers_map)) = providers_section {
116        for (name, config) in providers_map {
117            let name_str = name.as_str().unwrap_or("unknown").to_owned();
118
119            let enabled = config
120                .get("enabled")
121                .and_then(serde_yaml::Value::as_bool)
122                .unwrap_or(true);
123
124            let model = config
125                .get("default_model")
126                .and_then(|v| v.as_str())
127                .unwrap_or("unknown")
128                .to_owned();
129
130            let endpoint = registry
131                .find_provider(&name_str)
132                .map(|entry| entry.endpoint.clone());
133
134            providers.push(ProviderInfo {
135                name: name_str.clone(),
136                enabled,
137                is_default: name_str == default_provider,
138                model,
139                endpoint,
140            });
141        }
142    }
143
144    Ok(ProviderListOutput {
145        providers,
146        default_provider,
147    })
148}
149
150fn set_default_provider(provider: &str) -> Result<ProviderSetOutput> {
151    let registry = &ProfileBootstrap::get()?.providers;
152    if registry.find_provider(provider).is_none() {
153        let available: Vec<&str> = registry.providers.iter().map(|p| p.name.as_str()).collect();
154        anyhow::bail!(
155            "Unknown provider: '{}' is not in profile.providers. Available: {:?}",
156            provider,
157            available
158        );
159    }
160
161    let file_path = get_ai_config_path()?;
162    let mut content = read_yaml_file(&file_path)?;
163
164    let policy = content.get("ai").and_then(|ai| ai.get("providers"));
165    let enabled = policy
166        .and_then(|p| p.get(provider))
167        .and_then(|p| p.get("enabled"))
168        .and_then(serde_yaml::Value::as_bool)
169        .unwrap_or(true);
170    if !enabled {
171        anyhow::bail!(
172            "Provider '{}' is disabled in AI policy; enable it first \
173             (admin config provider enable {})",
174            provider,
175            provider
176        );
177    }
178
179    // JSON: mutates one key in the untyped AI config document, leaving unknown
180    // operator-authored keys in place.
181    if let Some(serde_yaml::Value::Mapping(ai_map)) = content.get_mut("ai") {
182        ai_map.insert(
183            serde_yaml::Value::String("default_provider".to_owned()),
184            serde_yaml::Value::String(provider.to_owned()),
185        );
186    }
187
188    write_yaml_file(&file_path, &content)?;
189
190    Ok(ProviderSetOutput {
191        provider: provider.to_owned(),
192        action: "set_default".to_owned(),
193        message: format!("Default provider set to '{}'", provider),
194    })
195}
196
197fn set_provider_enabled(provider: &str, enabled: bool) -> Result<ProviderSetOutput> {
198    let file_path = get_ai_config_path()?;
199    let mut content = read_yaml_file(&file_path)?;
200
201    let ai = content
202        .get_mut("ai")
203        .ok_or_else(|| anyhow::anyhow!("Missing 'ai' section"))?;
204
205    let providers = ai
206        .get_mut("providers")
207        .ok_or_else(|| anyhow::anyhow!("Missing 'providers' section"))?;
208
209    let provider_config = providers
210        .get_mut(provider)
211        .ok_or_else(|| anyhow::anyhow!("Unknown provider: '{}'", provider))?;
212
213    // JSON: mutates one key in the untyped AI config document, leaving unknown
214    // operator-authored keys in place.
215    if let serde_yaml::Value::Mapping(config_map) = provider_config {
216        config_map.insert(
217            serde_yaml::Value::String("enabled".to_owned()),
218            serde_yaml::Value::Bool(enabled),
219        );
220    }
221
222    write_yaml_file(&file_path, &content)?;
223
224    let action = if enabled { "enabled" } else { "disabled" };
225
226    Ok(ProviderSetOutput {
227        provider: provider.to_owned(),
228        action: action.to_owned(),
229        message: format!("Provider '{}' {}", provider, action),
230    })
231}