Skip to main content

oxios_kernel/
onboarding.rs

1//! Interactive first-run setup wizard.
2//!
3//! Inspired by @clack/prompts (OpenClaw, Vercel CLI, SvelteKit):
4//!   - intro() / outro() bookends
5//!   - spinner for async work
6//!   - note() for information boxes
7//!   - one question per screen
8//!
9//! Flow:
10//!   Welcome → Provider (auto-detect) → API Key (auto-detect) → Model →
11//!   Embedding download → Summary → Done
12
13use crate::config::OxiosConfig;
14use crate::credential::CredentialStore;
15use console::style;
16use indicatif::{ProgressBar, ProgressStyle};
17use inquire::{Confirm, CustomType, Select, Text};
18use std::io::{self, IsTerminal};
19use std::path::Path;
20
21// ── Constants ───────────────────────────────────────────────────────────────
22
23/// Subdirectories to create under the Oxios home directory during setup.
24pub const WORKSPACE_SUBDIRS: &[&str] = &[
25    "workspace",
26    "workspace/memory",
27    "workspace/memory/knowledge",
28    "workspace/sessions",
29    "workspace/skills",
30];
31
32const NO_KEY_PROVIDERS: &[&str] = &[];
33
34const HIDDEN_PROVIDERS: &[&str] = &[
35    "amazon-bedrock",
36    "azure-openai-responses",
37    "cloudflare-ai-gateway",
38    "cloudflare-workers-ai",
39    "google-vertex",
40    "minimax-cn",
41    "moonshotai-cn",
42    "openai-codex",
43    "opencode-go",
44    "vercel-ai-gateway",
45    "xiaomi",
46];
47
48// ── Theme (clack-inspired) ──────────────────────────────────────────────────
49
50mod theme {
51    #![allow(dead_code)]
52    use console::style;
53    use std::fmt::Display;
54
55    pub fn accent<T: Display>(s: T) -> console::StyledObject<T> {
56        style(s).cyan()
57    }
58
59    pub fn success<T: Display>(s: T) -> console::StyledObject<T> {
60        style(s).green()
61    }
62
63    pub fn warn<T: Display>(s: T) -> console::StyledObject<T> {
64        style(s).yellow()
65    }
66
67    pub fn dim<T: Display>(s: T) -> console::StyledObject<T> {
68        style(s).dim()
69    }
70
71    pub fn bold<T: Display>(s: T) -> console::StyledObject<T> {
72        style(s).bold()
73    }
74
75    pub fn muted<T: Display>(s: T) -> console::StyledObject<T> {
76        style(s).dim()
77    }
78
79    /// Step heading: "  ◇ Provider"
80    pub fn step(name: &str) -> String {
81        format!("  {} {}", style("◇").cyan(), style(name).bold())
82    }
83
84    /// Spinner frame character.
85    pub fn spinner_frame() -> &'static str {
86        "◯"
87    }
88
89    /// Success mark: ✓
90    pub fn ok() -> &'static str {
91        "✓"
92    }
93
94    /// Fail mark: ✗
95    pub fn fail() -> &'static str {
96        "✗"
97    }
98}
99
100// ── Display helpers ─────────────────────────────────────────────────────────
101
102#[derive(Clone)]
103struct ProviderEntry {
104    id: String,
105    display: String,
106    has_env_key: bool,
107}
108
109impl std::fmt::Display for ProviderEntry {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "{}", self.display)
112    }
113}
114
115#[derive(Clone)]
116struct ModelEntry {
117    full_id: String,
118    display: String,
119}
120
121impl std::fmt::Display for ModelEntry {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        write!(f, "{}", self.display)
124    }
125}
126
127const MANUAL_MODEL_DISPLAY: &str = "✎  Enter model ID manually";
128
129// ── Public API ──────────────────────────────────────────────────────────────
130
131/// Check if the system is fully configured (model + credentials).
132pub fn has_credentials(config: &OxiosConfig) -> bool {
133    let Some(provider) = CredentialStore::provider_from_model(&config.engine.default_model) else {
134        return false;
135    };
136    CredentialStore::has_credential(provider, config.api_key().as_deref())
137}
138
139/// Check if stdin is an interactive terminal.
140pub fn is_interactive() -> bool {
141    io::stdin().is_terminal()
142}
143
144/// Result of onboarding.
145pub struct OnboardingResult {
146    /// Config was written successfully.
147    pub configured: bool,
148    /// User chose to skip (cancelled / non-interactive).
149    pub skipped: bool,
150}
151
152/// Run the first-time setup wizard.
153pub fn run_onboarding(
154    oxios_home: &Path,
155    config: &mut OxiosConfig,
156    is_first_run: bool,
157) -> anyhow::Result<OnboardingResult> {
158    // ── Already configured? ──
159    if !config.engine.default_model.is_empty()
160        && let Some(provider_id) =
161            CredentialStore::provider_from_model(&config.engine.default_model)
162        && CredentialStore::has_credential(provider_id, config.api_key().as_deref())
163    {
164        println!();
165        println!(
166            "  {} {}",
167            style("✓").green(),
168            style(&config.engine.default_model).cyan(),
169        );
170
171        let ans = Select::new(
172            "  What next?",
173            vec!["Keep current configuration", "Reconfigure"],
174        )
175        .with_starting_cursor(0)
176        .prompt()?;
177
178        if ans == "Keep current configuration" {
179            return Ok(OnboardingResult {
180                configured: true,
181                skipped: false,
182            });
183        }
184    }
185
186    // ── Non-interactive bail ──
187    if !is_interactive() {
188        println!();
189        println!(
190            "  {} Setup requires a terminal. Run {} interactively.",
191            style("!").yellow(),
192            style("oxios").cyan(),
193        );
194        println!();
195        return Ok(OnboardingResult {
196            configured: false,
197            skipped: true,
198        });
199    }
200
201    // ── intro ──
202    print_intro(is_first_run);
203
204    // ── Auto-detect ──
205    let env_providers = oxi_sdk::get_all_env_keys();
206    if !env_providers.is_empty() {
207        let detected = env_providers
208            .keys()
209            .find(|p| !oxi_sdk::get_provider_models(p).is_empty());
210
211        if let Some(provider) = detected {
212            let keys = oxi_sdk::find_env_keys(provider);
213            let var_name = keys.and_then(|k| k.first().copied()).unwrap_or(provider);
214            println!(
215                "  {} {} {}",
216                theme::accent("◇"),
217                theme::dim(format!("Found {var_name} →")),
218                theme::accent(provider),
219            );
220            let use_it = Confirm::new("  Use this provider?")
221                .with_default(true)
222                .prompt()?;
223            if use_it {
224                return run_provider_flow(oxios_home, config, provider);
225            }
226        }
227    }
228
229    // ── Manual provider selection ──
230    let all_providers = oxi_sdk::get_providers();
231    let visible: Vec<&str> = all_providers
232        .iter()
233        .copied()
234        .filter(|p| !HIDDEN_PROVIDERS.contains(p))
235        .collect();
236
237    let provider = prompt_provider(&visible)?;
238    run_provider_flow(oxios_home, config, provider)
239}
240
241// ── Provider flow ───────────────────────────────────────────────────────────
242
243fn run_provider_flow(
244    oxios_home: &Path,
245    config: &mut OxiosConfig,
246    provider: &str,
247) -> anyhow::Result<OnboardingResult> {
248    // ── API Key ──
249    let (api_key, key_source) = resolve_api_key(provider)?;
250
251    // ── Model ──
252    let model = prompt_model(provider)?;
253
254    // ── Save config (needed for embedding download path) ──
255    with_spinner("Saving configuration...", "Configuration saved", || {
256        persist_config(
257            oxios_home,
258            config,
259            provider,
260            api_key.as_deref().unwrap_or(""),
261            &model,
262        )
263    })?;
264
265    // ── Embedding model ──
266    let embed_status = setup_embedding(config)?;
267
268    // ── Summary + outro ──
269    print_summary(oxios_home, provider, &model, key_source, &embed_status);
270
271    Ok(OnboardingResult {
272        configured: true,
273        skipped: false,
274    })
275}
276
277// ── Step: API Key ────────────────────────────────────────────────────────────
278
279fn resolve_api_key(provider: &str) -> anyhow::Result<(Option<String>, &'static str)> {
280    if NO_KEY_PROVIDERS.contains(&provider) {
281        return Ok((None, "none"));
282    }
283
284    // Try auth.json
285    if let Ok(Some(token)) = oxi_sdk::load_token(provider)
286        && !token.access_token.is_empty()
287    {
288        println!();
289        println!(
290            "  {} Credentials found in {}",
291            theme::step("API Key"),
292            theme::dim("~/.oxi/auth.json"),
293        );
294        let use_it = Confirm::new("  Use them?").with_default(true).prompt()?;
295        if use_it {
296            return Ok((None, "auth.json"));
297        }
298    }
299
300    // Try env var
301    if let Some(env_key) = oxi_sdk::get_env_api_key(provider) {
302        println!();
303        println!(
304            "  {} {}",
305            theme::step("API Key"),
306            theme::dim("Using key from environment"),
307        );
308        return Ok((Some(env_key), "env"));
309    }
310
311    // Manual entry
312    println!();
313    println!("  {}", theme::step("API Key"));
314    println!("  {}", theme::dim("Stored locally, never shared."),);
315
316    let key = CustomType::<String>::new("  →")
317        .with_placeholder("sk-...")
318        .with_error_message("API key is required")
319        .prompt()?;
320    Ok((Some(key), "manual"))
321}
322
323// ── Step: Provider ───────────────────────────────────────────────────────────
324
325fn prompt_provider<'a>(providers: &[&'a str]) -> anyhow::Result<&'a str> {
326    let mut entries: Vec<ProviderEntry> = providers
327        .iter()
328        .map(|&p| {
329            let model_count = oxi_sdk::get_provider_models(p).len();
330            let has_env = oxi_sdk::has_env_key(p);
331            let mut badges = vec![format!("{} models", model_count)];
332            if has_env {
333                badges.push("🔑 detected".into());
334            }
335            ProviderEntry {
336                id: p.to_string(),
337                display: format!(
338                    "  {}  {}",
339                    style(p).bold(),
340                    theme::muted(badges.join(" · ")),
341                ),
342                has_env_key: has_env,
343            }
344        })
345        .collect();
346
347    // Sort: providers with detected env keys first
348    entries.sort_by_key(|b| std::cmp::Reverse(b.has_env_key));
349
350    println!();
351    println!("  {}", theme::step("Provider"));
352    println!("  {}", theme::dim("Which cloud hosts your LLM?"),);
353
354    let selected = Select::new("  →", entries)
355        .with_starting_cursor(0)
356        .prompt()?;
357
358    Ok(providers.iter().find(|&&p| p == selected.id).unwrap())
359}
360
361// ── Step: Model ──────────────────────────────────────────────────────────────
362
363fn prompt_model(provider: &str) -> anyhow::Result<String> {
364    let models = oxi_sdk::get_provider_models(provider);
365
366    println!();
367    println!("  {}", theme::step("Model"));
368
369    if models.is_empty() {
370        let model = Text::new("  → Model ID:").prompt()?;
371        if model.is_empty() {
372            anyhow::bail!("Model ID is required.");
373        }
374        return Ok(if model.contains('/') {
375            model
376        } else {
377            format!("{provider}/{model}")
378        });
379    }
380
381    let mut entries: Vec<ModelEntry> = Vec::new();
382    for entry in models.iter() {
383        if entry.name.contains("latest") {
384            continue;
385        }
386        let full_id = format!("{}/{}", provider, entry.id);
387        let ctx = if entry.context_window >= 1_000_000 {
388            format!("{}M", entry.context_window / 1_000_000)
389        } else {
390            format!("{}K", entry.context_window / 1000)
391        };
392        let reasoning = if entry.reasoning {
393            format!(" {}", style("reasoning").magenta())
394        } else {
395            String::new()
396        };
397        entries.push(ModelEntry {
398            full_id,
399            display: format!(
400                "  {}  {}{}",
401                style(&entry.name).bold(),
402                theme::muted(format!("{ctx} ctx")),
403                reasoning,
404            ),
405        });
406        if entries.len() >= 12 {
407            break;
408        }
409    }
410
411    entries.push(ModelEntry {
412        full_id: String::new(),
413        display: format!("  {MANUAL_MODEL_DISPLAY}"),
414    });
415
416    let selected = Select::new("  →", entries)
417        .with_starting_cursor(0)
418        .prompt()?;
419
420    if selected.display.contains(MANUAL_MODEL_DISPLAY) {
421        let manual = Text::new("  → Model ID:").prompt()?;
422        if manual.is_empty() {
423            anyhow::bail!("Model ID cannot be empty.");
424        }
425        return Ok(if manual.contains('/') {
426            manual
427        } else {
428            format!("{provider}/{manual}")
429        });
430    }
431
432    Ok(selected.full_id.clone())
433}
434
435// ── Step: Embedding model ────────────────────────────────────────────────────
436
437fn setup_embedding(config: &OxiosConfig) -> anyhow::Result<String> {
438    let workspace = crate::config::expand_home(&config.kernel.workspace);
439
440    #[cfg(feature = "embedding-gguf")]
441    {
442        let model_dir =
443            crate::embedding::gguf::GgufModelLoader::model_dir_for_workspace(&workspace);
444
445        if crate::embedding::gguf::GgufModelLoader::is_model_cached(&model_dir) {
446            return Ok("cached".to_string());
447        }
448
449        let display_name = crate::embedding::gguf::MODEL_DISPLAY_NAME;
450        let size_mb = crate::embedding::gguf::MODEL_SIZE_MB;
451
452        println!();
453        println!(
454            "  {} {} model (~{} MB)",
455            theme::step("Embedding"),
456            display_name,
457            size_mb,
458        );
459        println!(
460            "  {}",
461            theme::dim("For semantic memory search. One-time download."),
462        );
463
464        let result = with_spinner(
465            &format!("Downloading {}...", display_name),
466            &format!("{} Downloaded", theme::success(theme::ok())),
467            || crate::embedding::gguf::GgufModelLoader::ensure_model(&model_dir),
468        );
469
470        match result {
471            Ok(path) => {
472                let size_mb = path.metadata().map(|m| m.len() / 1_000_000).unwrap_or(0);
473                println!(
474                    "  {} {} MB",
475                    theme::success(theme::ok()),
476                    theme::accent(size_mb),
477                );
478                Ok("downloaded".to_string())
479            }
480            Err(e) => {
481                println!("  {} {}", theme::warn(theme::fail()), e,);
482                println!("  {} Will retry on first search.", theme::accent("→"),);
483                Ok("failed".to_string())
484            }
485        }
486    }
487
488    #[cfg(not(feature = "embedding-gguf"))]
489    {
490        let _ = (config, workspace);
491        Ok("tfidf".to_string())
492    }
493}
494
495// ── Spinner helper ───────────────────────────────────────────────────────────
496
497/// Run a closure with a spinner. Shows `message` while running,
498/// replaces with `done` on success.
499fn with_spinner<T, F>(message: &str, done: &str, f: F) -> T
500where
501    F: FnOnce() -> T,
502{
503    let pb = ProgressBar::new_spinner();
504    pb.set_style(
505        ProgressStyle::default_spinner()
506            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")
507            .template("  {spinner} {msg}")
508            .unwrap(),
509    );
510    pb.set_message(message.to_string());
511    pb.enable_steady_tick(std::time::Duration::from_millis(80));
512
513    let result = f();
514
515    pb.finish_with_message(done.to_string());
516    result
517}
518
519// ── Persistence ──────────────────────────────────────────────────────────────
520
521fn persist_config(
522    oxios_home: &Path,
523    config: &mut OxiosConfig,
524    provider: &str,
525    api_key: &str,
526    model: &str,
527) -> anyhow::Result<()> {
528    if !api_key.is_empty() {
529        CredentialStore::store(provider, api_key)?;
530    }
531
532    let workspace = crate::config::expand_home(&config.kernel.workspace);
533    std::fs::create_dir_all(&workspace)?;
534    for subdir in WORKSPACE_SUBDIRS {
535        std::fs::create_dir_all(Path::new(&workspace).join(subdir))?;
536    }
537
538    config.engine.default_model = model.to_string();
539
540    std::fs::create_dir_all(oxios_home)?;
541    let toml_str = toml::to_string_pretty(config)
542        .map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
543    std::fs::write(oxios_home.join("config.toml"), &toml_str)?;
544
545    Ok(())
546}
547
548// ── UI: intro / outro ───────────────────────────────────────────────────────
549
550fn print_intro(is_first_run: bool) {
551    println!();
552
553    if is_first_run {
554        println!("  {}", style("⬡ Oxios Agent OS").bold().cyan(),);
555        println!("  {}", theme::dim("Your AI agents, organized."),);
556        println!();
557        println!("  Let's get you set up. About 30 seconds.");
558    } else {
559        println!("  {}", style("⬡ Oxios Setup").bold());
560    }
561
562    println!(
563        "  {}",
564        theme::dim("↑↓ navigate · Enter confirm · Ctrl+C skip"),
565    );
566    println!();
567}
568
569fn print_summary(
570    oxios_home: &Path,
571    provider: &str,
572    model: &str,
573    key_source: &str,
574    embed_status: &str,
575) {
576    println!();
577    println!(
578        "  {}",
579        theme::dim("─────────────────────────────────────────")
580    );
581
582    println!("  {:<14} {}", theme::dim("LLM:"), theme::accent(model),);
583    println!(
584        "  {:<14} {}",
585        theme::dim("Provider:"),
586        theme::muted(provider),
587    );
588    println!("  {:<14} {}", theme::dim("Key:"), theme::muted(key_source),);
589
590    let embed_label = match embed_status {
591        "cached" | "downloaded" => {
592            #[cfg(feature = "embedding-gguf")]
593            {
594                let name = crate::embedding::gguf::MODEL_DISPLAY_NAME;
595                Some(if embed_status == "downloaded" {
596                    format!("{} ✓", name)
597                } else {
598                    format!("{} ✓ (cached)", name)
599                })
600            }
601            #[cfg(not(feature = "embedding-gguf"))]
602            {
603                None
604            }
605        }
606        "failed" => Some("will download on first search".to_string()),
607        _ => None,
608    };
609
610    if let Some(ref label) = embed_label {
611        let styled = if embed_status == "failed" {
612            theme::warn(label).to_string()
613        } else {
614            theme::accent(label).to_string()
615        };
616        println!("  {:<14} {}", theme::dim("Embedding:"), styled);
617    }
618
619    println!(
620        "  {:<14} {}",
621        theme::dim("Home:"),
622        theme::muted(oxios_home.display()),
623    );
624
625    println!(
626        "  {}",
627        theme::dim("─────────────────────────────────────────")
628    );
629    println!();
630}