tl_cli/cli/commands/
providers.rs

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