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