tl_cli/cli/commands/
providers.rs1use anyhow::{Result, bail};
4use inquire::{Confirm, Select, Text};
5
6use crate::config::{ConfigManager, ProviderConfig};
7use crate::ui::{Style, handle_prompt_cancellation};
8
9const RESERVED_NAMES: &[&str] = &["add", "edit", "remove", "list"];
11
12pub 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
57pub 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 let name = input_provider_name(&config.providers.keys().cloned().collect::<Vec<_>>())?;
68
69 let endpoint = input_endpoint(None)?;
71
72 let (api_key, api_key_env) = input_api_key_method(None, None)?;
74
75 let models = input_models(None)?;
77
78 let provider_config = ProviderConfig {
80 endpoint,
81 api_key,
82 api_key_env,
83 models,
84 };
85
86 config.providers.insert(name.clone(), provider_config);
88
89 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
103pub 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 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 let endpoint = input_endpoint(Some(&provider.endpoint))?;
125
126 let (api_key, api_key_env) =
128 input_api_key_method(provider.api_key.as_deref(), provider.api_key_env.as_deref())?;
129
130 let models = input_models(Some(&provider.models))?;
132
133 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 manager.save(&config)?;
145
146 println!();
147 println!(
148 "{} Provider '{}' updated",
149 Style::success("✓"),
150 Style::value(name)
151 );
152
153 Ok(())
154}
155
156pub 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 if !config.providers.contains_key(name) {
167 bail!("Provider '{name}' not found");
168 }
169
170 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 if config.providers.len() == 1 {
180 println!(
181 "{} This is the last configured provider.",
182 Style::warning("Warning:")
183 );
184 }
185
186 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 config.providers.remove(name);
200
201 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 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 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}