tl_cli/cli/commands/
providers.rs

1//! Provider management command handler.
2
3use anyhow::{Result, bail};
4use inquire::{Confirm, Select, Text};
5
6use super::load_config;
7use crate::config::ProviderConfig;
8use crate::ui::{Style, handle_prompt_cancellation};
9
10/// Reserved names that cannot be used as provider names.
11const RESERVED_NAMES: &[&str] = &["add", "edit", "remove", "list"];
12
13/// Prints all configured providers.
14pub fn list_providers() -> Result<()> {
15    let (_manager, config) = load_config()?;
16
17    if config.providers.is_empty() {
18        println!("{}", Style::warning("No providers configured."));
19        println!(
20            "{}",
21            Style::hint("Run 'tl providers add' to add a provider.")
22        );
23        return Ok(());
24    }
25
26    let default_provider = config.tl.provider.as_deref();
27
28    println!("{}", Style::header("Configured providers"));
29    for (name, provider) in &config.providers {
30        let is_default = default_provider == Some(name.as_str());
31        println!(
32            "  {}{}",
33            Style::value(name),
34            if is_default {
35                format!(" {}", Style::default_marker())
36            } else {
37                String::new()
38            }
39        );
40        println!(
41            "    {}  {}",
42            Style::label("endpoint"),
43            Style::secondary(&provider.endpoint)
44        );
45        if !provider.models.is_empty() {
46            println!(
47                "    {}    {}",
48                Style::label("models"),
49                Style::secondary(provider.models.join(", "))
50            );
51        }
52    }
53
54    Ok(())
55}
56
57/// Interactively adds a new provider.
58pub fn add_provider() -> Result<()> {
59    handle_prompt_cancellation(add_provider_inner)
60}
61
62fn add_provider_inner() -> Result<()> {
63    let (manager, mut config) = load_config()?;
64
65    // Input provider name
66    let name = input_provider_name(&config.providers.keys().cloned().collect::<Vec<_>>())?;
67
68    // Input endpoint
69    let endpoint = input_endpoint(None)?;
70
71    // Input API key method
72    let (api_key, api_key_env) = input_api_key_method(None, None)?;
73
74    // Input models
75    let models = input_models(None)?;
76
77    // Create provider config
78    let provider_config = ProviderConfig {
79        endpoint,
80        api_key,
81        api_key_env,
82        models,
83    };
84
85    // Add to config
86    config.providers.insert(name.clone(), provider_config);
87
88    // Save config
89    manager.save(&config)?;
90
91    println!();
92    println!(
93        "{} Provider '{}' added to {}",
94        Style::success("✓"),
95        Style::value(&name),
96        Style::secondary(manager.config_path().display().to_string())
97    );
98
99    Ok(())
100}
101
102/// Interactively edits an existing provider.
103pub fn edit_provider(name: &str) -> Result<()> {
104    handle_prompt_cancellation(|| edit_provider_inner(name))
105}
106
107fn edit_provider_inner(name: &str) -> Result<()> {
108    let (manager, mut config) = load_config()?;
109
110    // Check if provider exists
111    let Some(provider) = config.providers.get(name) else {
112        bail!("Provider '{name}' not found");
113    };
114
115    println!(
116        "{} '{}':\n",
117        Style::header("Editing provider"),
118        Style::value(name)
119    );
120
121    // Input endpoint
122    let endpoint = input_endpoint(Some(&provider.endpoint))?;
123
124    // Input API key method
125    let (api_key, api_key_env) =
126        input_api_key_method(provider.api_key.as_deref(), provider.api_key_env.as_deref())?;
127
128    // Input models
129    let models = input_models(Some(&provider.models))?;
130
131    // Update provider config
132    let provider_config = ProviderConfig {
133        endpoint,
134        api_key,
135        api_key_env,
136        models,
137    };
138
139    config.providers.insert(name.to_string(), provider_config);
140
141    // Save config
142    manager.save(&config)?;
143
144    println!();
145    println!(
146        "{} Provider '{}' updated",
147        Style::success("✓"),
148        Style::value(name)
149    );
150
151    Ok(())
152}
153
154/// Removes a provider with confirmation.
155pub fn remove_provider(name: &str) -> Result<()> {
156    handle_prompt_cancellation(|| remove_provider_inner(name))
157}
158
159fn remove_provider_inner(name: &str) -> Result<()> {
160    let (manager, mut config) = load_config()?;
161
162    // Check if provider exists
163    if !config.providers.contains_key(name) {
164        bail!("Provider '{name}' not found");
165    }
166
167    // Check if this is the default provider
168    if config.tl.provider.as_deref() == Some(name) {
169        bail!(
170            "Cannot remove '{name}' because it is the default provider.\n\n\
171             Run 'tl configure' to change the default provider first."
172        );
173    }
174
175    // Check if this is the last provider
176    if config.providers.len() == 1 {
177        println!(
178            "{} This is the last configured provider.",
179            Style::warning("Warning:")
180        );
181    }
182
183    // Confirm removal
184    let confirmed = Confirm::new(&format!(
185        "Are you sure you want to remove provider '{name}'?"
186    ))
187    .with_default(false)
188    .prompt()?;
189
190    if !confirmed {
191        println!("Cancelled.");
192        return Ok(());
193    }
194
195    // Remove provider
196    config.providers.remove(name);
197
198    // Save config
199    manager.save(&config)?;
200
201    println!();
202    println!(
203        "{} Provider '{}' removed",
204        Style::success("✓"),
205        Style::value(name)
206    );
207
208    Ok(())
209}
210
211fn input_provider_name(existing_names: &[String]) -> Result<String> {
212    let name = Text::new("Provider name:")
213        .with_help_message("A unique name for this provider (e.g., ollama, openrouter)")
214        .prompt()?;
215
216    let name = name.trim().to_string();
217
218    if name.is_empty() {
219        bail!("Provider name cannot be empty");
220    }
221
222    if RESERVED_NAMES.contains(&name.as_str()) {
223        bail!("Provider name '{name}' is reserved. Choose a different name.");
224    }
225
226    if existing_names.contains(&name) {
227        bail!("Provider '{name}' already exists");
228    }
229
230    Ok(name)
231}
232
233fn input_endpoint(default: Option<&str>) -> Result<String> {
234    let mut prompt = Text::new("Endpoint URL:").with_help_message("OpenAI-compatible API endpoint");
235
236    if let Some(d) = default {
237        prompt = prompt.with_default(d);
238    }
239
240    let endpoint = prompt.prompt()?;
241    let endpoint = endpoint.trim().to_string();
242
243    if endpoint.is_empty() {
244        bail!("Endpoint URL cannot be empty");
245    }
246
247    // Basic URL validation
248    if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
249        bail!("Endpoint must start with http:// or https://");
250    }
251
252    Ok(endpoint)
253}
254
255fn input_api_key_method(
256    current_api_key: Option<&str>,
257    current_api_key_env: Option<&str>,
258) -> Result<(Option<String>, Option<String>)> {
259    let options = vec![
260        "Environment variable (recommended)",
261        "Store in config file",
262        "None (no auth required)",
263    ];
264
265    // Determine default selection based on current config
266    let default_index = if current_api_key_env.is_some() {
267        0
268    } else if current_api_key.is_some() {
269        1
270    } else {
271        2
272    };
273
274    let selection = Select::new("API key method:", options)
275        .with_starting_cursor(default_index)
276        .prompt()?;
277
278    match selection {
279        "Environment variable (recommended)" => {
280            let mut prompt = Text::new("Environment variable name:")
281                .with_help_message("e.g., OPENROUTER_API_KEY");
282
283            if let Some(d) = current_api_key_env {
284                prompt = prompt.with_default(d);
285            }
286
287            let env_var = prompt.prompt()?;
288            let env_var = env_var.trim().to_string();
289
290            if env_var.is_empty() {
291                bail!("Environment variable name cannot be empty");
292            }
293
294            Ok((None, Some(env_var)))
295        }
296        "Store in config file" => {
297            let mut prompt =
298                Text::new("API key:").with_help_message("Will be stored in plain text");
299
300            if let Some(d) = current_api_key {
301                prompt = prompt.with_default(d);
302            }
303
304            let api_key = prompt.prompt()?;
305            let api_key = api_key.trim().to_string();
306
307            if api_key.is_empty() {
308                bail!("API key cannot be empty");
309            }
310
311            Ok((Some(api_key), None))
312        }
313        "None (no auth required)" => Ok((None, None)),
314        _ => unreachable!(),
315    }
316}
317
318fn input_models(current: Option<&Vec<String>>) -> Result<Vec<String>> {
319    let default = current.map(|m| m.join(", ")).unwrap_or_default();
320
321    let mut prompt = Text::new("Models (comma-separated, optional):")
322        .with_help_message("e.g., gpt-4o, claude-3.5-sonnet");
323
324    if !default.is_empty() {
325        prompt = prompt.with_default(&default);
326    }
327
328    let input = prompt.prompt()?;
329
330    let models: Vec<String> = input
331        .split(',')
332        .map(|s| s.trim().to_string())
333        .filter(|s| !s.is_empty())
334        .collect();
335
336    Ok(models)
337}