Skip to main content

sparrow/onboarding/
wizard.rs

1//! First-run interactive terminal wizard.
2//!
3//! Detects existing API keys from environment variables, checks if the `gh`
4//! CLI is installed for GitHub Copilot, offers free providers (NVIDIA NIM,
5//! Groq) with setup instructions, and saves the resulting configuration to
6//! the user's Sparrow config file.
7//!
8//! Has a "Quick start with what you have" option that skips all prompts.
9
10use std::io::{self, Write};
11
12use crate::auth::{AuthStore, Credential};
13use crate::config::{Config, ConfigStore, ProviderConfig};
14use crate::provider::detect::{self, DetectedProvider, ProviderTier};
15
16// ─── Wizard entry point ──────────────────────────────────────────────────────
17
18/// Run the first-run wizard.
19///
20/// Guides the user through provider detection and configuration. If the user
21/// chooses "Quick start", all detected providers are configured immediately
22/// without further prompts.
23pub async fn run_wizard(
24    config: &Config,
25    store: &dyn ConfigStore,
26) -> anyhow::Result<()> {
27    let _auth = crate::auth::store::ChainedAuthStore::new(config.config_dir.clone());
28
29    print_banner();
30
31    // Phase 1: Detect everything
32    println!("🔍 Détection des providers...\n");
33    let mut providers = detect::detect_all_providers();
34    detect::validate_detected_providers(&mut providers).await;
35
36    let ready = detect::ready_providers(&providers);
37    let free = detect::free_providers(&providers);
38    let gh_available = detect::gh_cli_installed();
39
40    // Phase 2: Show what we found
41    show_detection_summary(&providers, &ready, &free, gh_available);
42
43    // Phase 3: Offer quick start
44    println!();
45    println!("1. ⚡ Démarrage rapide — utilise tout ce qui est détecté");
46    println!("2. 🧭 Assistant pas à pas — je choisis ce que je veux configurer");
47    println!("3. 🚪 Quitter sans configurer");
48    print!("\nChoix [1]: ");
49    io::stdout().flush().ok();
50
51    let mut choice = String::new();
52    io::stdin().read_line(&mut choice)?;
53    let choice = choice.trim();
54
55    match choice {
56        "3" => {
57            println!("\n👋 Pas de souci ! Tu pourras configurer plus tard avec : sparrow setup");
58            return Ok(());
59        }
60        "2" => {
61            step_by_step(config, store, &providers, gh_available).await?;
62        }
63        _ => {
64            // Quick start (default)
65            quick_start(config, store, &ready, gh_available).await?;
66        }
67    }
68
69    Ok(())
70}
71
72// ─── Banner ──────────────────────────────────────────────────────────────────
73
74fn print_banner() {
75    println!("══════════════════════════════════════════════════");
76    println!("  🐦 Sparrow — Assistant de configuration");
77    println!("  version {}", env!("CARGO_PKG_VERSION"));
78    println!("══════════════════════════════════════════════════");
79    println!();
80    println!("On va détecter tes clés API et configurer Sparrow");
81    println!("en quelques secondes. C'est gratuit et open-source !");
82    println!();
83}
84
85// ─── Detection summary ───────────────────────────────────────────────────────
86
87fn show_detection_summary(
88    all: &[DetectedProvider],
89    ready: &[&DetectedProvider],
90    free: &[&DetectedProvider],
91    gh_available: bool,
92) {
93    if !ready.is_empty() {
94        println!("✅ Providers prêts à l'emploi :");
95        for p in ready {
96            let env_hint = p
97                .env_var
98                .as_ref()
99                .map(|e| format!(" ({e})"))
100                .unwrap_or_default();
101            println!("   • {} — clé détectée et validée{}", p.label, env_hint);
102        }
103    } else {
104        println!("⚠️  Aucune clé API trouvée dans ton environnement.");
105    }
106
107    let need_key: Vec<_> = all
108        .iter()
109        .filter(|p| p.key_found && p.validated == Some(false))
110        .collect();
111    if !need_key.is_empty() {
112        println!("\n⚠️  Clés trouvées mais invalides :");
113        for p in &need_key {
114            println!("   • {} — {}", p.label, p.validation_error.as_deref().unwrap_or("?"));
115        }
116    }
117
118    if !free.is_empty() && ready.is_empty() {
119        println!("\n🎁 Providers GRATUITS disponibles :");
120        for p in free {
121            println!("   • {} — {}", p.label, p.description);
122            if let Some(url) = &p.signup_url {
123                println!("     → Crée une clé : {url}");
124            }
125            if let Some(env) = &p.env_var {
126                println!("     → Puis exporte-la : export {}=\"ta-clé\"", env);
127            }
128        }
129    }
130
131    if gh_available {
132        println!("\n🐙 GitHub CLI détecté (gh) — GitHub Copilot dispo !");
133        println!("   → Connecte-toi avec : gh auth login");
134        println!("   → Puis : gh copilot suggest \"hello\"");
135    }
136
137    // Show un-configured providers count
138    let not_configured = all
139        .iter()
140        .filter(|p| !p.key_found)
141        .count();
142    if not_configured > 0 {
143        println!(
144            "\n📋 {} autres providers dispos (payants ou nécessitent une clé).",
145            not_configured
146        );
147    }
148}
149
150// ─── Quick start ─────────────────────────────────────────────────────────────
151
152async fn quick_start(
153    config: &Config,
154    store: &dyn ConfigStore,
155    ready: &[&DetectedProvider],
156    gh_available: bool,
157) -> anyhow::Result<()> {
158    println!("\n⚡ Démarrage rapide — configuration automatique...\n");
159
160    let auth = crate::auth::store::ChainedAuthStore::new(config.config_dir.clone());
161    let mut updated = config.clone();
162
163    for p in ready {
164        add_provider_to_config(&mut updated, p);
165        // Store credential from env var
166        if let Some(env_var) = &p.env_var {
167            if let Ok(key) = std::env::var(env_var) {
168                if !key.trim().is_empty() {
169                    auth.set(&p.id, Credential::api_key(key))?;
170                    println!("   ✓ {:<25} configuré (modèle par défaut)", p.label);
171                }
172            }
173        }
174    }
175
176    // If nothing was configured, suggest free providers
177    if ready.is_empty() {
178        println!("   ⚠️  Aucun provider prêt. Voici des options gratuites :\n");
179        println!("   • NVIDIA NIM — gratuit, crée une clé : https://build.nvidia.com/explore/discover");
180        println!("     puis : export NVIDIA_API_KEY=\"ta-clé\" && sparrow setup");
181        println!("   • Groq — tier gratuit généreux : https://console.groq.com/keys");
182        println!("     puis : export GROQ_API_KEY=\"ta-clé\" && sparrow setup");
183        println!("   • Google Gemini — gratuit 1500 req/j : https://aistudio.google.com/app/apikey");
184        println!("     puis : export GEMINI_API_KEY=\"ta-clé\" && sparrow setup\n");
185    }
186
187    if gh_available {
188        println!("   💡 GitHub Copilot détecté. Pour l'activer :");
189        println!("      sparrow auth add copilot");
190    }
191
192    store.save(&updated)?;
193    println!("\n✅ Configuration sauvegardée !");
194    println!("   Lance sparrow pour démarrer.\n");
195
196    Ok(())
197}
198
199// ─── Step-by-step assistant ──────────────────────────────────────────────────
200
201async fn step_by_step(
202    config: &Config,
203    store: &dyn ConfigStore,
204    providers: &[DetectedProvider],
205    gh_available: bool,
206) -> anyhow::Result<()> {
207    println!("\n🧭 Assistant pas à pas — je te guide.\n");
208
209    let auth = crate::auth::store::ChainedAuthStore::new(config.config_dir.clone());
210    let mut updated = config.clone();
211
212    // Group providers by tier for display
213    let mut free_list: Vec<&DetectedProvider> = Vec::new();
214    let mut ready_list: Vec<&DetectedProvider> = Vec::new();
215    let mut need_key_list: Vec<&DetectedProvider> = Vec::new();
216
217    for p in providers {
218        if p.key_found && p.validated == Some(true) {
219            ready_list.push(p);
220        } else if p.tier == ProviderTier::Free {
221            free_list.push(p);
222        } else if p.key_found {
223            need_key_list.push(p);
224        }
225    }
226
227    // First: offer to configure ready providers
228    if !ready_list.is_empty() {
229        println!("── Providers prêts à l'emploi ──");
230        for (i, p) in ready_list.iter().enumerate() {
231            println!("  [{i}] {} — clé validée ✓", p.label);
232        }
233        println!("  [A] Tous les activer");
234        println!("  [N] Aucun, passer à la suite");
235        print!("\nChoix [A]: ");
236        io::stdout().flush().ok();
237
238        let mut answer = String::new();
239        io::stdin().read_line(&mut answer)?;
240        let answer = answer.trim().to_lowercase();
241
242        if answer == "n" {
243            // skip
244        } else if answer == "a" || answer.is_empty() {
245            for p in &ready_list {
246                add_provider_to_config(&mut updated, p);
247                if let Some(env_var) = &p.env_var {
248                    if let Ok(key) = std::env::var(env_var) {
249                        auth.set(&p.id, Credential::api_key(key))?;
250                    }
251                }
252                println!("   ✓ {:<25} configuré", p.label);
253            }
254        } else if let Ok(idx) = answer.parse::<usize>() {
255            if idx < ready_list.len() {
256                let p = ready_list[idx];
257                add_provider_to_config(&mut updated, p);
258                if let Some(env_var) = &p.env_var {
259                    if let Ok(key) = std::env::var(env_var) {
260                        auth.set(&p.id, Credential::api_key(key))?;
261                    }
262                }
263                println!("   ✓ {} configuré", p.label);
264            }
265        }
266    }
267
268    // Second: offer free providers
269    if !free_list.is_empty() && ready_list.is_empty() {
270        println!("\n── Providers gratuits (nécessitent une clé) ──");
271        for (i, p) in free_list.iter().enumerate() {
272            println!("  [{i}] {} — gratuit !", p.label);
273            if let Some(url) = &p.signup_url {
274                println!("       → Inscription : {url}");
275            }
276        }
277        println!("  [N] Passer");
278        print!("\nLequel t'intéresse ? (numéro ou N) [N]: ");
279        io::stdout().flush().ok();
280
281        let mut answer = String::new();
282        io::stdin().read_line(&mut answer)?;
283        let answer = answer.trim().to_lowercase();
284
285        if answer != "n" && !answer.is_empty() {
286            if let Ok(idx) = answer.parse::<usize>() {
287                if idx < free_list.len() {
288                    let p = free_list[idx];
289                    handle_provider_key_prompt(&mut updated, p, &auth).await?;
290                }
291            }
292        }
293    }
294
295    // Third: offer GitHub Copilot
296    if gh_available {
297        println!("\n🐙 GitHub Copilot détecté (gh CLI installé).");
298        print!("Activer GitHub Copilot ? [O/n] ");
299        io::stdout().flush().ok();
300
301        let mut answer = String::new();
302        io::stdin().read_line(&mut answer)?;
303        if !matches!(answer.trim().to_lowercase().as_str(), "n" | "no" | "non") {
304            println!("   → Pour utiliser Copilot, assure-toi d'être connecté : gh auth login");
305            updated.providers.entry("copilot".into()).or_insert_with(|| {
306                ProviderConfig {
307                    adapter: "openai-compatible".into(),
308                    base_url: Some("https://api.githubcopilot.com".into()),
309                    models: vec!["gpt-4o".into()],
310                    api_key_env: Some("COPILOT_TOKEN".into()),
311                }
312            });
313            println!("   ✓ GitHub Copilot ajouté à la config.");
314        }
315    }
316
317    // Fourth: offer custom/other providers
318    println!("\n── Autres providers ──");
319    println!("Tu peux aussi configurer un provider manuellement :");
320    println!("  → sparrow auth add <provider>");
321    println!("  → Ou exporte la clé : export PROVIDER_API_KEY=\"ta-clé\"");
322    println!("\nProviders supportés :");
323    for chunk in crate::config::providers::provider_registry().chunks(5) {
324        let ids: Vec<&str> = chunk.iter().map(|d| d.id.as_str()).collect();
325        println!("  {}", ids.join(", "));
326    }
327
328    store.save(&updated)?;
329    println!("\n✅ Configuration sauvegardée !");
330    println!("   Tu peux la modifier à tout moment : sparrow config --edit\n");
331
332    Ok(())
333}
334
335// ─── Helpers ─────────────────────────────────────────────────────────────────
336
337/// Prompt the user for an API key, validate it, and store it.
338async fn handle_provider_key_prompt(
339    config: &mut Config,
340    provider: &DetectedProvider,
341    auth: &crate::auth::store::ChainedAuthStore,
342) -> anyhow::Result<()> {
343    let env_var_name = provider
344        .env_var
345        .as_deref()
346        .unwrap_or(&provider.id);
347
348    println!();
349    println!("Pour utiliser {}, il te faut une clé API.", provider.label);
350    if let Some(url) = &provider.signup_url {
351        println!("→ Crée une clé ici : {url}");
352    }
353    println!("→ Puis colle-la ci-dessous (ou laisse vide pour passer).");
354    print!("Clé API ({}): ", env_var_name);
355    io::stdout().flush().ok();
356
357    let key = rpassword::read_password().unwrap_or_default();
358    let key = key.trim().to_string();
359
360    if key.is_empty() {
361        println!("   ↳ Passé.");
362        return Ok(());
363    }
364
365    // Validate the key
366    print!("   Validation de la clé... ");
367    io::stdout().flush().ok();
368    match detect::validate_api_key(&provider.id, &key).await {
369        Ok(()) => {
370            println!("✓");
371            auth.set(&provider.id, Credential::api_key(&key))?;
372            // SAFETY: single-threaded setup wizard
373            unsafe { std::env::set_var(env_var_name, &key); }
374            add_provider_to_config(config, provider);
375            println!("   ✓ {} configuré avec succès !", provider.label);
376        }
377        Err(err) => {
378            println!("✗");
379            println!("   Échec de validation : {err}");
380            print!("   Ajouter quand même ? [o/N] ");
381            io::stdout().flush().ok();
382            let mut answer = String::new();
383            io::stdin().read_line(&mut answer)?;
384            if matches!(answer.trim().to_lowercase().as_str(), "o" | "oui" | "y" | "yes") {
385                auth.set(&provider.id, Credential::api_key(&key))?;
386                // SAFETY: single-threaded setup wizard
387                unsafe { std::env::set_var(env_var_name, &key); }
388                add_provider_to_config(config, provider);
389                println!("   ✓ {} ajouté (clé non validée).", provider.label);
390            }
391        }
392    }
393
394    Ok(())
395}
396
397/// Add a detected provider to the config (with its default models).
398fn add_provider_to_config(config: &mut Config, provider: &DetectedProvider) {
399    let def = crate::config::providers::find_provider(&provider.id);
400
401    let entry = config.providers.entry(provider.id.clone()).or_insert_with(|| {
402        ProviderConfig {
403            adapter: def
404                .as_ref()
405                .map(|d| d.adapter.clone())
406                .unwrap_or_else(|| "openai-compatible".into()),
407            base_url: def.as_ref().map(|d| Some(d.base_url.clone())).unwrap_or(None),
408            models: def
409                .as_ref()
410                .map(|d| {
411                    crate::config::providers::default_models(&d.id)
412                })
413                .unwrap_or_default(),
414            api_key_env: provider.env_var.clone(),
415        }
416    });
417
418    // Ensure the adapter and base_url are correct even for existing entries
419    if let Some(d) = &def {
420        if entry.adapter.is_empty() || entry.adapter == "openai-compatible" {
421            entry.adapter = d.adapter.clone();
422        }
423        if entry.base_url.is_none() {
424            entry.base_url = Some(d.base_url.clone());
425        }
426        if entry.api_key_env.is_none() {
427            entry.api_key_env = d.api_key_env.clone();
428        }
429        if entry.models.is_empty() {
430            entry.models = crate::config::providers::default_models(&d.id);
431        }
432    }
433}