Skip to main content

defect_cli/
init.rs

1//! `defect init` — scan the environment for provider API keys, fetch each detected
2//! provider's real model list over its API, and write a global
3//! `~/.config/defect/config.toml`.
4//!
5//! Interaction model follows `rustup-init`: detect the environment, show what will be
6//! written, confirm. When exactly one provider key is present and the user passes
7//! `--yes`, it is fully non-interactive. With multiple keys, `--yes` requires an explicit
8//! `--default-provider` — defect never guesses which provider should be the default from
9//! ambient state (see the project's explicit-provider rule).
10//!
11//! Model IDs are NEVER hardcoded: with an API key in hand, init calls the provider's
12//! `list_models()` (the same path the agent uses) to obtain the live, authoritative set.
13//! A provider whose model list cannot be fetched is a hard error, not a fall back to a
14//! guessed model id.
15//!
16//! The interactive prompts require the `init` build feature (on by default); a binary
17//! built with `--no-default-features` can still run `defect init --yes`.
18
19use std::fs;
20use std::sync::Arc;
21
22use defect_agent::llm::{LlmProvider, ModelInfo};
23
24use crate::args::InitArgs;
25
26/// A provider defect can write into the generated config.
27struct ProviderSpec {
28    /// The `[default] provider` / `[providers.<id>]` key.
29    id: &'static str,
30    /// Human label for prompts.
31    display: &'static str,
32    /// Environment variable that holds the API key.
33    api_key_env: &'static str,
34}
35
36/// Providers eligible for auto-detection. Bedrock is intentionally excluded: it
37/// authenticates through the AWS credential chain (profiles, IMDS, SSO), not a single
38/// env var, so there is no reliable "key present" signal to scan for.
39const 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
61/// Providers whose API-key env var is set (non-empty), in [`PROVIDERS`] order.
62fn 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
69/// Construct the LLM provider for `id` from the environment API key and return its live
70/// model list. Errors (auth, transport, server, or a provider not compiled into this
71/// build) are surfaced verbatim — init never substitutes a guessed model id.
72async 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/// Build a provider instance for `id` using only environment credentials and default
85/// endpoints. Mirrors the per-provider construction in `crate::providers`, but standalone
86/// so it does not require a `LoadedConfig` (init runs before any config exists).
87#[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
140/// Entry point for `defect init`.
141pub 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    // Decide which providers to configure and which is the default — without touching the
172    // network yet.
173    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    // Fetch the live model list for each selected provider. This is where the user's API
183    // key is actually used; failures are hard errors.
184    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    // Resolve the default model from the default provider's live list.
196    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/// Which providers to configure and which is the default — decided before any network I/O.
227#[derive(Debug)]
228struct Selection {
229    providers: Vec<&'static str>,
230    default_provider: &'static str,
231}
232
233/// A provider plus its fetched (live) model ids.
234#[derive(Debug)]
235struct ConfiguredProvider {
236    id: &'static str,
237    models: Vec<String>,
238}
239
240/// The fully resolved decision to write.
241struct Plan {
242    providers: Vec<ConfiguredProvider>,
243    default_provider: &'static str,
244    default_model: String,
245}
246
247/// Non-interactive (`--yes`): configure every detected provider. The default is the sole
248/// detected provider, or `--default-provider` when more than one is present.
249fn 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            // Explicit-provider rule: with multiple keys, defect will not pick for the
271            // user under --yes.
272            _ => 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
367/// Pick the default model for the default provider from its live model list.
368/// `--default-model` is validated against the list; under `--yes` with no flag the first
369/// listed model is used; interactively the user selects one.
370fn 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        // The first model the provider lists. Hard error already guards against empty.
388        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    // Unreachable in practice: non-interactive callers pass `non_interactive = true`,
412    // and the `init` feature is required to reach the interactive selection path at all.
413    anyhow::bail!("interactive model selection requires the `init` feature")
414}
415
416/// Render a commented `config.toml`. Hand-written (not serde) to preserve comments and
417/// field ordering, matching the repo's TOML style.
418fn 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        // No hardcoded guesses leaked in.
479        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}