tl_cli/cli/commands/
providers.rs1use 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
10const RESERVED_NAMES: &[&str] = &["add", "edit", "remove", "list"];
12
13pub 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
57pub 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 let name = input_provider_name(&config.providers.keys().cloned().collect::<Vec<_>>())?;
67
68 let endpoint = input_endpoint(None)?;
70
71 let (api_key, api_key_env) = input_api_key_method(None, None)?;
73
74 let models = input_models(None)?;
76
77 let provider_config = ProviderConfig {
79 endpoint,
80 api_key,
81 api_key_env,
82 models,
83 };
84
85 config.providers.insert(name.clone(), provider_config);
87
88 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
102pub 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 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 let endpoint = input_endpoint(Some(&provider.endpoint))?;
123
124 let (api_key, api_key_env) =
126 input_api_key_method(provider.api_key.as_deref(), provider.api_key_env.as_deref())?;
127
128 let models = input_models(Some(&provider.models))?;
130
131 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 manager.save(&config)?;
143
144 println!();
145 println!(
146 "{} Provider '{}' updated",
147 Style::success("✓"),
148 Style::value(name)
149 );
150
151 Ok(())
152}
153
154pub 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 if !config.providers.contains_key(name) {
164 bail!("Provider '{name}' not found");
165 }
166
167 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 if config.providers.len() == 1 {
177 println!(
178 "{} This is the last configured provider.",
179 Style::warning("Warning:")
180 );
181 }
182
183 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 config.providers.remove(name);
197
198 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 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 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}