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            );
65        },
66        ProviderCommands::Set(args) => {
67            let result = set_default_provider(&args.provider)?;
68            render_result(&CommandOutput::card_value("Provider Updated", &result));
69        },
70        ProviderCommands::Enable(args) => {
71            let result = set_provider_enabled(&args.provider, true)?;
72            render_result(&CommandOutput::card_value("Provider Enabled", &result));
73        },
74        ProviderCommands::Disable(args) => {
75            let result = set_provider_enabled(&args.provider, false)?;
76            render_result(&CommandOutput::card_value("Provider Disabled", &result));
77        },
78    }
79    Ok(())
80}
81
82fn get_ai_config_path() -> Result<std::path::PathBuf> {
83    ConfigSection::Ai.file_path()
84}
85
86fn list_providers() -> Result<ProviderListOutput> {
87    let registry = &ProfileBootstrap::get()?.providers;
88    let file_path = get_ai_config_path()?;
89    let content = read_yaml_file(&file_path)?;
90
91    let ai = content
92        .get("ai")
93        .ok_or_else(|| anyhow::anyhow!("Missing 'ai' section in config"))?;
94
95    let default_provider = ai
96        .get("default_provider")
97        .and_then(|v| v.as_str())
98        .unwrap_or("unknown")
99        .to_owned();
100
101    let providers_section = ai.get("providers");
102
103    let mut providers = Vec::new();
104
105    if let Some(serde_yaml::Value::Mapping(providers_map)) = providers_section {
106        for (name, config) in providers_map {
107            let name_str = name.as_str().unwrap_or("unknown").to_owned();
108
109            let enabled = config
110                .get("enabled")
111                .and_then(serde_yaml::Value::as_bool)
112                .unwrap_or(true);
113
114            let model = config
115                .get("default_model")
116                .and_then(|v| v.as_str())
117                .unwrap_or("unknown")
118                .to_owned();
119
120            let endpoint = registry
121                .find_provider(&name_str)
122                .map(|entry| entry.endpoint.clone());
123
124            providers.push(ProviderInfo {
125                name: name_str.clone(),
126                enabled,
127                is_default: name_str == default_provider,
128                model,
129                endpoint,
130            });
131        }
132    }
133
134    Ok(ProviderListOutput {
135        providers,
136        default_provider,
137    })
138}
139
140fn set_default_provider(provider: &str) -> Result<ProviderSetOutput> {
141    let registry = &ProfileBootstrap::get()?.providers;
142    if registry.find_provider(provider).is_none() {
143        let available: Vec<&str> = registry.providers.iter().map(|p| p.name.as_str()).collect();
144        anyhow::bail!(
145            "Unknown provider: '{}' is not in profile.providers. Available: {:?}",
146            provider,
147            available
148        );
149    }
150
151    let file_path = get_ai_config_path()?;
152    let mut content = read_yaml_file(&file_path)?;
153
154    let policy = content.get("ai").and_then(|ai| ai.get("providers"));
155    let enabled = policy
156        .and_then(|p| p.get(provider))
157        .and_then(|p| p.get("enabled"))
158        .and_then(serde_yaml::Value::as_bool)
159        .unwrap_or(true);
160    if !enabled {
161        anyhow::bail!(
162            "Provider '{}' is disabled in AI policy; enable it first \
163             (admin config provider enable {})",
164            provider,
165            provider
166        );
167    }
168
169    if let Some(serde_yaml::Value::Mapping(ai_map)) = content.get_mut("ai") {
170        ai_map.insert(
171            serde_yaml::Value::String("default_provider".to_owned()),
172            serde_yaml::Value::String(provider.to_owned()),
173        );
174    }
175
176    write_yaml_file(&file_path, &content)?;
177
178    Ok(ProviderSetOutput {
179        provider: provider.to_owned(),
180        action: "set_default".to_owned(),
181        message: format!("Default provider set to '{}'", provider),
182    })
183}
184
185fn set_provider_enabled(provider: &str, enabled: bool) -> Result<ProviderSetOutput> {
186    let file_path = get_ai_config_path()?;
187    let mut content = read_yaml_file(&file_path)?;
188
189    let ai = content
190        .get_mut("ai")
191        .ok_or_else(|| anyhow::anyhow!("Missing 'ai' section"))?;
192
193    let providers = ai
194        .get_mut("providers")
195        .ok_or_else(|| anyhow::anyhow!("Missing 'providers' section"))?;
196
197    let provider_config = providers
198        .get_mut(provider)
199        .ok_or_else(|| anyhow::anyhow!("Unknown provider: '{}'", provider))?;
200
201    if let serde_yaml::Value::Mapping(config_map) = provider_config {
202        config_map.insert(
203            serde_yaml::Value::String("enabled".to_owned()),
204            serde_yaml::Value::Bool(enabled),
205        );
206    }
207
208    write_yaml_file(&file_path, &content)?;
209
210    let action = if enabled { "enabled" } else { "disabled" };
211
212    Ok(ProviderSetOutput {
213        provider: provider.to_owned(),
214        action: action.to_owned(),
215        message: format!("Provider '{}' {}", provider, action),
216    })
217}