1use std::fs;
20use std::sync::Arc;
21
22use defect_agent::llm::{LlmProvider, ModelInfo};
23
24use crate::args::InitArgs;
25
26struct ProviderSpec {
28 id: &'static str,
30 display: &'static str,
32 api_key_env: &'static str,
34}
35
36const PROVIDERS: &[ProviderSpec] = &[
40 ProviderSpec {
41 id: "anthropic",
42 display: "Anthropic (Claude)",
43 api_key_env: "ANTHROPIC_API_KEY",
44 },
45 ProviderSpec {
46 id: "openai",
47 display: "OpenAI",
48 api_key_env: "OPENAI_API_KEY",
49 },
50 ProviderSpec {
51 id: "deepseek",
52 display: "DeepSeek",
53 api_key_env: "DEEPSEEK_API_KEY",
54 },
55];
56
57fn provider_by_id(id: &str) -> Option<&'static ProviderSpec> {
58 PROVIDERS.iter().find(|p| p.id == id)
59}
60
61fn detect_present() -> Vec<&'static ProviderSpec> {
63 PROVIDERS
64 .iter()
65 .filter(|p| std::env::var(p.api_key_env).is_ok_and(|v| !v.trim().is_empty()))
66 .collect()
67}
68
69async fn fetch_models(id: &str) -> anyhow::Result<Vec<ModelInfo>> {
73 let provider = build_provider(id)?;
74 let models = provider
75 .list_models()
76 .await
77 .map_err(|e| anyhow::anyhow!("failed to list models for `{id}`: {e}"))?;
78 if models.is_empty() {
79 anyhow::bail!("provider `{id}` returned an empty model list");
80 }
81 Ok(models)
82}
83
84#[cfg_attr(
88 not(any(
89 feature = "provider-anthropic",
90 feature = "provider-openai",
91 feature = "provider-deepseek"
92 )),
93 allow(unused_variables)
94)]
95fn build_provider(id: &str) -> anyhow::Result<Arc<dyn LlmProvider>> {
96 match id {
97 #[cfg(feature = "provider-anthropic")]
98 "anthropic" => {
99 use defect_llm::provider::anthropic::{AnthropicConfig, AnthropicProvider};
100 let provider = AnthropicProvider::new(AnthropicConfig::from_env())
101 .map_err(|e| anyhow::anyhow!("anthropic provider init failed: {e}"))?;
102 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
103 }
104 #[cfg(feature = "provider-openai")]
105 "openai" => {
106 use defect_llm::provider::openai::{OpenAiConfig, OpenAiProvider};
107 let provider = OpenAiProvider::new(OpenAiConfig::from_env())
108 .map_err(|e| anyhow::anyhow!("openai provider init failed: {e}"))?;
109 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
110 }
111 #[cfg(feature = "provider-deepseek")]
112 "deepseek" => {
113 use defect_llm::provider::deepseek::{DeepSeekConfig, DeepSeekProvider};
114 let provider = DeepSeekProvider::new(DeepSeekConfig::from_env())
115 .map_err(|e| anyhow::anyhow!("deepseek provider init failed: {e}"))?;
116 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
117 }
118 #[cfg(not(feature = "provider-anthropic"))]
119 "anthropic" => Err(provider_not_compiled("anthropic")),
120 #[cfg(not(feature = "provider-openai"))]
121 "openai" => Err(provider_not_compiled("openai")),
122 #[cfg(not(feature = "provider-deepseek"))]
123 "deepseek" => Err(provider_not_compiled("deepseek")),
124 other => Err(anyhow::anyhow!("unknown provider `{other}`")),
125 }
126}
127
128#[cfg(not(all(
129 feature = "provider-anthropic",
130 feature = "provider-openai",
131 feature = "provider-deepseek"
132)))]
133fn provider_not_compiled(feature_suffix: &str) -> anyhow::Error {
134 anyhow::anyhow!(
135 "provider `{feature_suffix}` was not compiled into this build; \
136 rebuild with `--features provider-{feature_suffix}` (or the default feature set)"
137 )
138}
139
140pub async fn run(args: InitArgs) -> anyhow::Result<()> {
142 let path = defect_config::user_config_path().ok_or_else(|| {
143 anyhow::anyhow!(
144 "cannot determine global config location: neither XDG_CONFIG_HOME nor HOME is set"
145 )
146 })?;
147
148 if path.exists() && !args.force {
149 anyhow::bail!(
150 "global config already exists at {}\n\
151 re-run with `--force` to overwrite it",
152 path.display()
153 );
154 }
155
156 let detected = detect_present();
157 if detected.is_empty() {
158 eprintln!(
159 "No provider API keys found in the environment.\n\
160 Set one of {} and re-run, or edit {} by hand.",
161 PROVIDERS
162 .iter()
163 .map(|p| p.api_key_env)
164 .collect::<Vec<_>>()
165 .join(", "),
166 path.display()
167 );
168 anyhow::bail!("nothing to configure: no provider API keys detected");
169 }
170
171 let selection = if args.yes {
174 select_non_interactive(&detected, args.default_provider.as_deref())?
175 } else {
176 select_interactive(&detected, args.default_provider.as_deref())?
177 };
178 if selection.providers.is_empty() {
179 anyhow::bail!("no providers selected; aborting");
180 }
181
182 let mut configured: Vec<ConfiguredProvider> = Vec::new();
185 for id in &selection.providers {
186 eprintln!("Fetching models for {id}…");
187 let models = fetch_models(id).await?;
188 let model_ids: Vec<String> = models.into_iter().map(|m| m.id).collect();
189 configured.push(ConfiguredProvider {
190 id,
191 models: model_ids,
192 });
193 }
194
195 let default_entry = configured
197 .iter()
198 .find(|c| c.id == selection.default_provider)
199 .ok_or_else(|| anyhow::anyhow!("internal: default provider not among configured"))?;
200 let default_model =
201 resolve_default_model(default_entry, args.default_model.as_deref(), args.yes)?;
202
203 let plan = Plan {
204 providers: configured,
205 default_provider: selection.default_provider,
206 default_model,
207 };
208
209 let body = render_config(&plan);
210
211 if let Some(parent) = path.parent() {
212 fs::create_dir_all(parent)
213 .map_err(|e| anyhow::anyhow!("failed to create {}: {e}", parent.display()))?;
214 }
215 fs::write(&path, body)
216 .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?;
217
218 println!("Wrote global config to {}", path.display());
219 println!(
220 "Default: provider `{}`, model `{}`",
221 plan.default_provider, plan.default_model
222 );
223 Ok(())
224}
225
226#[derive(Debug)]
228struct Selection {
229 providers: Vec<&'static str>,
230 default_provider: &'static str,
231}
232
233#[derive(Debug)]
235struct ConfiguredProvider {
236 id: &'static str,
237 models: Vec<String>,
238}
239
240struct Plan {
242 providers: Vec<ConfiguredProvider>,
243 default_provider: &'static str,
244 default_model: String,
245}
246
247fn select_non_interactive(
250 detected: &[&'static ProviderSpec],
251 default_provider: Option<&str>,
252) -> anyhow::Result<Selection> {
253 let providers: Vec<&'static str> = detected.iter().map(|p| p.id).collect();
254
255 let default_provider = match default_provider {
256 Some(id) => {
257 let spec = provider_by_id(id)
258 .ok_or_else(|| anyhow::anyhow!("unknown --default-provider `{id}`"))?;
259 if !providers.contains(&spec.id) {
260 anyhow::bail!(
261 "--default-provider `{}` has no API key in the environment ({} is unset)",
262 spec.id,
263 spec.api_key_env
264 );
265 }
266 spec.id
267 }
268 None => match providers.as_slice() {
269 [only] => only,
270 _ => anyhow::bail!(
273 "multiple provider keys detected ({}); pass --default-provider <{}> to \
274 choose the default explicitly",
275 providers.join(", "),
276 providers.join("|")
277 ),
278 },
279 };
280
281 Ok(Selection {
282 providers,
283 default_provider,
284 })
285}
286
287#[cfg(feature = "init")]
288fn select_interactive(
289 detected: &[&'static ProviderSpec],
290 default_provider: Option<&str>,
291) -> anyhow::Result<Selection> {
292 use inquire::{MultiSelect, Select};
293
294 let options: Vec<&'static str> = PROVIDERS.iter().map(|p| p.display).collect();
295 let default_idx: Vec<usize> = PROVIDERS
296 .iter()
297 .enumerate()
298 .filter(|(_, p)| detected.iter().any(|d| d.id == p.id))
299 .map(|(i, _)| i)
300 .collect();
301
302 let chosen_displays = MultiSelect::new("Which providers do you want to configure?", options)
303 .with_default(&default_idx)
304 .with_help_message("space to toggle, enter to confirm; detected keys are pre-selected")
305 .prompt()?;
306
307 let providers: Vec<&'static str> = PROVIDERS
308 .iter()
309 .filter(|p| chosen_displays.contains(&p.display))
310 .map(|p| p.id)
311 .collect();
312
313 if providers.is_empty() {
314 return Ok(Selection {
315 providers,
316 default_provider: "",
317 });
318 }
319
320 let default_provider = if let Some(id) = default_provider {
321 let spec = provider_by_id(id)
322 .ok_or_else(|| anyhow::anyhow!("unknown --default-provider `{id}`"))?;
323 if !providers.contains(&spec.id) {
324 anyhow::bail!(
325 "--default-provider `{}` is not among the chosen providers",
326 spec.id
327 );
328 }
329 spec.id
330 } else {
331 match providers.as_slice() {
332 [only] => only,
333 _ => {
334 let labels: Vec<&'static str> = providers
335 .iter()
336 .filter_map(|id| provider_by_id(id).map(|p| p.display))
337 .collect();
338 let picked =
339 Select::new("Which provider should be the default?", labels).prompt()?;
340 PROVIDERS
341 .iter()
342 .find(|p| p.display == picked)
343 .map(|p| p.id)
344 .or_else(|| providers.first().copied())
345 .unwrap_or("")
346 }
347 }
348 };
349
350 Ok(Selection {
351 providers,
352 default_provider,
353 })
354}
355
356#[cfg(not(feature = "init"))]
357fn select_interactive(
358 _detected: &[&'static ProviderSpec],
359 _default_provider: Option<&str>,
360) -> anyhow::Result<Selection> {
361 anyhow::bail!(
362 "this binary was built without the `init` feature; \
363 run `defect init --yes` for non-interactive setup, or rebuild with `--features init`"
364 )
365}
366
367fn resolve_default_model(
371 entry: &ConfiguredProvider,
372 requested: Option<&str>,
373 non_interactive: bool,
374) -> anyhow::Result<String> {
375 if let Some(model) = requested {
376 if !entry.models.iter().any(|m| m == model) {
377 anyhow::bail!(
378 "--default-model `{model}` is not offered by `{}`; available: {}",
379 entry.id,
380 entry.models.join(", ")
381 );
382 }
383 return Ok(model.to_string());
384 }
385
386 if non_interactive {
387 return entry
389 .models
390 .first()
391 .cloned()
392 .ok_or_else(|| anyhow::anyhow!("provider `{}` returned no models", entry.id));
393 }
394
395 pick_default_model_interactive(entry)
396}
397
398#[cfg(feature = "init")]
399fn pick_default_model_interactive(entry: &ConfiguredProvider) -> anyhow::Result<String> {
400 use inquire::Select;
401 let choice = Select::new(
402 &format!("Default model for `{}`?", entry.id),
403 entry.models.clone(),
404 )
405 .prompt()?;
406 Ok(choice)
407}
408
409#[cfg(not(feature = "init"))]
410fn pick_default_model_interactive(_entry: &ConfiguredProvider) -> anyhow::Result<String> {
411 anyhow::bail!("interactive model selection requires the `init` feature")
414}
415
416fn render_config(plan: &Plan) -> String {
419 let mut out = String::new();
420 out.push_str("# defect global configuration — generated by `defect init`.\n");
421 out.push_str("# Model lists were fetched live from each provider's API.\n");
422 out.push_str("# Edit freely; unknown keys hard-fail with this file's path.\n\n");
423
424 out.push_str("[default]\n");
425 out.push_str("# Provider/model used when --provider / DEFECT_PROVIDER is not given.\n");
426 out.push_str(&format!("provider = \"{}\"\n", plan.default_provider));
427 out.push_str(&format!("model = \"{}\"\n\n", plan.default_model));
428
429 for entry in &plan.providers {
430 let display = provider_by_id(entry.id)
431 .map(|p| p.display)
432 .unwrap_or(entry.id);
433 let api_key_env = provider_by_id(entry.id)
434 .map(|p| p.api_key_env)
435 .unwrap_or("");
436 out.push_str(&format!("# {display} — key read from ${api_key_env}\n"));
437 out.push_str(&format!("[providers.{}]\n", entry.id));
438 out.push_str("models = [\n");
439 for model in &entry.models {
440 out.push_str(&format!(" \"{}\",\n", model.replace('"', "")));
441 }
442 out.push_str("]\n\n");
443 }
444
445 out
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn spec(id: &str) -> &'static ProviderSpec {
453 provider_by_id(id).expect("known provider")
454 }
455
456 fn configured(id: &'static str, models: &[&str]) -> ConfiguredProvider {
457 ConfiguredProvider {
458 id,
459 models: models.iter().map(|m| m.to_string()).collect(),
460 }
461 }
462
463 #[test]
464 fn renders_with_fetched_models() {
465 let plan = Plan {
466 providers: vec![configured(
467 "deepseek",
468 &["deepseek-v4-flash", "deepseek-v4-pro"],
469 )],
470 default_provider: "deepseek",
471 default_model: "deepseek-v4-pro".to_string(),
472 };
473 let body = render_config(&plan);
474 assert!(body.contains("provider = \"deepseek\""));
475 assert!(body.contains("model = \"deepseek-v4-pro\""));
476 assert!(body.contains("\"deepseek-v4-flash\""));
477 assert!(body.contains("\"deepseek-v4-pro\""));
478 assert!(!body.contains("deepseek-chat"));
480 let _: toml::Value = body.parse().expect("valid toml");
481 }
482
483 #[test]
484 fn select_single_picks_default() {
485 let sel = select_non_interactive(&[spec("anthropic")], None).expect("select");
486 assert_eq!(sel.default_provider, "anthropic");
487 assert_eq!(sel.providers, vec!["anthropic"]);
488 }
489
490 #[test]
491 fn select_multiple_requires_explicit_default() {
492 let err = select_non_interactive(&[spec("anthropic"), spec("openai")], None)
493 .expect_err("should require --default-provider");
494 assert!(err.to_string().contains("--default-provider"), "{err}");
495 }
496
497 #[test]
498 fn select_multiple_honors_explicit_default() {
499 let sel = select_non_interactive(&[spec("anthropic"), spec("openai")], Some("openai"))
500 .expect("select");
501 assert_eq!(sel.default_provider, "openai");
502 assert_eq!(sel.providers, vec!["anthropic", "openai"]);
503 }
504
505 #[test]
506 fn select_rejects_undetected_default() {
507 let err = select_non_interactive(&[spec("anthropic")], Some("deepseek"))
508 .expect_err("deepseek not detected");
509 assert!(err.to_string().contains("no API key"), "{err}");
510 }
511
512 #[test]
513 fn select_rejects_unknown_provider() {
514 let err = select_non_interactive(&[spec("anthropic")], Some("bogus")).expect_err("unknown");
515 assert!(
516 err.to_string().contains("unknown --default-provider"),
517 "{err}"
518 );
519 }
520
521 #[test]
522 fn default_model_yes_takes_first_listed() {
523 let entry = configured("deepseek", &["deepseek-v4-flash", "deepseek-v4-pro"]);
524 let model = resolve_default_model(&entry, None, true).expect("model");
525 assert_eq!(model, "deepseek-v4-flash");
526 }
527
528 #[test]
529 fn default_model_validates_against_live_list() {
530 let entry = configured("deepseek", &["deepseek-v4-flash", "deepseek-v4-pro"]);
531 let err =
532 resolve_default_model(&entry, Some("deepseek-chat"), true).expect_err("not offered");
533 assert!(err.to_string().contains("not offered"), "{err}");
534 }
535
536 #[test]
537 fn default_model_accepts_listed_model() {
538 let entry = configured("deepseek", &["deepseek-v4-flash", "deepseek-v4-pro"]);
539 let model = resolve_default_model(&entry, Some("deepseek-v4-pro"), true).expect("model");
540 assert_eq!(model, "deepseek-v4-pro");
541 }
542}