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        && let Some(provider_id) =
162            CredentialStore::provider_from_model(&config.engine.default_model)
163        && CredentialStore::has_credential(provider_id, config.api_key().as_deref())
164    {
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    // ── Non-interactive bail ──
188    if !is_interactive() {
189        println!();
190        println!(
191            "  {} Setup requires a terminal. Run {} interactively.",
192            style("!").yellow(),
193            style("oxios").cyan(),
194        );
195        println!();
196        return Ok(OnboardingResult {
197            configured: false,
198            skipped: true,
199        });
200    }
201
202    // ── intro ──
203    print_intro(is_first_run);
204
205    // ── Auto-detect ──
206    let env_providers = oxi_sdk::get_all_env_keys();
207    if !env_providers.is_empty() {
208        let detected = env_providers
209            .keys()
210            .find(|p| !oxi_sdk::get_provider_models(p).is_empty());
211
212        if let Some(provider) = detected {
213            let keys = oxi_sdk::find_env_keys(provider);
214            let var_name = keys.and_then(|k| k.first().copied()).unwrap_or(provider);
215            println!(
216                "  {} {} {}",
217                theme::accent("◇"),
218                theme::dim(format!("Found {var_name} →")),
219                theme::accent(provider),
220            );
221            let use_it = Confirm::new("  Use this provider?")
222                .with_default(true)
223                .prompt()?;
224            if use_it {
225                return run_provider_flow(oxios_home, config, provider);
226            }
227        }
228    }
229
230    // ── Manual provider selection ──
231    let all_providers = oxi_sdk::get_providers();
232    let visible: Vec<&str> = all_providers
233        .iter()
234        .copied()
235        .filter(|p| !HIDDEN_PROVIDERS.contains(p))
236        .collect();
237
238    let provider = prompt_provider(&visible)?;
239    run_provider_flow(oxios_home, config, provider)
240}
241
242// ── Provider flow ───────────────────────────────────────────────────────────
243
244fn run_provider_flow(
245    oxios_home: &Path,
246    config: &mut OxiosConfig,
247    provider: &str,
248) -> anyhow::Result<OnboardingResult> {
249    // ── API Key ──
250    let (api_key, key_source) = resolve_api_key(provider)?;
251
252    // ── Model ──
253    let model = prompt_model(provider)?;
254
255    // ── Save config (needed for embedding download path) ──
256    with_spinner("Saving configuration...", "Configuration saved", || {
257        persist_config(
258            oxios_home,
259            config,
260            provider,
261            api_key.as_deref().unwrap_or(""),
262            &model,
263        )
264    })?;
265
266    // ── Embedding model ──
267    let embed_status = setup_embedding(config)?;
268
269    // ── Summary + outro ──
270    print_summary(oxios_home, provider, &model, key_source, &embed_status);
271
272    Ok(OnboardingResult {
273        configured: true,
274        skipped: false,
275    })
276}
277
278// ── Step: API Key ────────────────────────────────────────────────────────────
279
280fn resolve_api_key(provider: &str) -> anyhow::Result<(Option<String>, &'static str)> {
281    if NO_KEY_PROVIDERS.contains(&provider) {
282        return Ok((None, "none"));
283    }
284
285    // Try auth.json
286    if let Ok(Some(token)) = oxi_sdk::load_token(provider)
287        && !token.access_token.is_empty()
288    {
289        println!();
290        println!(
291            "  {} Credentials found in {}",
292            theme::step("API Key"),
293            theme::dim("~/.oxi/auth.json"),
294        );
295        let use_it = Confirm::new("  Use them?").with_default(true).prompt()?;
296        if use_it {
297            return Ok((None, "auth.json"));
298        }
299    }
300
301    // Try env var
302    if let Some(env_key) = oxi_sdk::get_env_api_key(provider) {
303        println!();
304        println!(
305            "  {} {}",
306            theme::step("API Key"),
307            theme::dim("Using key from environment"),
308        );
309        return Ok((Some(env_key), "env"));
310    }
311
312    // Manual entry
313    println!();
314    println!("  {}", theme::step("API Key"));
315    println!("  {}", theme::dim("Stored locally, never shared."),);
316
317    let key = CustomType::<String>::new("  →")
318        .with_placeholder("sk-...")
319        .with_error_message("API key is required")
320        .prompt()?;
321    Ok((Some(key), "manual"))
322}
323
324// ── Step: Provider ───────────────────────────────────────────────────────────
325
326fn prompt_provider<'a>(providers: &[&'a str]) -> anyhow::Result<&'a str> {
327    let mut entries: Vec<ProviderEntry> = providers
328        .iter()
329        .map(|&p| {
330            let model_count = oxi_sdk::get_provider_models(p).len();
331            let has_env = oxi_sdk::has_env_key(p);
332            let mut badges = vec![format!("{} models", model_count)];
333            if has_env {
334                badges.push("🔑 detected".into());
335            }
336            ProviderEntry {
337                id: p.to_string(),
338                display: format!(
339                    "  {}  {}",
340                    style(p).bold(),
341                    theme::muted(badges.join(" · ")),
342                ),
343                has_env_key: has_env,
344            }
345        })
346        .collect();
347
348    // Sort: providers with detected env keys first
349    entries.sort_by_key(|b| std::cmp::Reverse(b.has_env_key));
350
351    println!();
352    println!("  {}", theme::step("Provider"));
353    println!("  {}", theme::dim("Which cloud hosts your LLM?"),);
354
355    let selected = Select::new("  →", entries)
356        .with_starting_cursor(0)
357        .prompt()?;
358
359    Ok(providers.iter().find(|&&p| p == selected.id).unwrap())
360}
361
362// ── Step: Model ──────────────────────────────────────────────────────────────
363
364fn prompt_model(provider: &str) -> anyhow::Result<String> {
365    let models = oxi_sdk::get_provider_models(provider);
366
367    println!();
368    println!("  {}", theme::step("Model"));
369
370    if models.is_empty() {
371        let model = Text::new("  → Model ID:").prompt()?;
372        if model.is_empty() {
373            anyhow::bail!("Model ID is required.");
374        }
375        return Ok(if model.contains('/') {
376            model
377        } else {
378            format!("{provider}/{model}")
379        });
380    }
381
382    let mut entries: Vec<ModelEntry> = Vec::new();
383    for entry in models.iter() {
384        if entry.name.contains("latest") {
385            continue;
386        }
387        let full_id = format!("{}/{}", provider, entry.id);
388        let ctx = if entry.context_window >= 1_000_000 {
389            format!("{}M", entry.context_window / 1_000_000)
390        } else {
391            format!("{}K", entry.context_window / 1000)
392        };
393        let reasoning = if entry.reasoning {
394            format!(" {}", style("reasoning").magenta())
395        } else {
396            String::new()
397        };
398        entries.push(ModelEntry {
399            full_id,
400            display: format!(
401                "  {}  {}{}",
402                style(&entry.name).bold(),
403                theme::muted(format!("{ctx} ctx")),
404                reasoning,
405            ),
406        });
407        if entries.len() >= 12 {
408            break;
409        }
410    }
411
412    entries.push(ModelEntry {
413        full_id: String::new(),
414        display: format!("  {MANUAL_MODEL_DISPLAY}"),
415    });
416
417    let selected = Select::new("  →", entries)
418        .with_starting_cursor(0)
419        .prompt()?;
420
421    if selected.display.contains(MANUAL_MODEL_DISPLAY) {
422        let manual = Text::new("  → Model ID:").prompt()?;
423        if manual.is_empty() {
424            anyhow::bail!("Model ID cannot be empty.");
425        }
426        return Ok(if manual.contains('/') {
427            manual
428        } else {
429            format!("{provider}/{manual}")
430        });
431    }
432
433    Ok(selected.full_id.clone())
434}
435
436// ── Step: Embedding model ────────────────────────────────────────────────────
437
438fn setup_embedding(config: &OxiosConfig) -> anyhow::Result<String> {
439    let workspace = crate::config::expand_home(&config.kernel.workspace);
440
441    #[cfg(feature = "embedding-gguf")]
442    {
443        let model_dir =
444            crate::embedding::gguf::GgufModelLoader::model_dir_for_workspace(&workspace);
445
446        if crate::embedding::gguf::GgufModelLoader::is_model_cached(&model_dir) {
447            return Ok("cached".to_string());
448        }
449
450        let display_name = crate::embedding::gguf::MODEL_DISPLAY_NAME;
451        let size_mb = crate::embedding::gguf::MODEL_SIZE_MB;
452
453        println!();
454        println!(
455            "  {} {} model (~{} MB)",
456            theme::step("Embedding"),
457            display_name,
458            size_mb,
459        );
460        println!(
461            "  {}",
462            theme::dim("For semantic memory search. One-time download."),
463        );
464
465        let result = with_spinner(
466            &format!("Downloading {}...", display_name),
467            &format!("{} Downloaded", theme::success(theme::ok())),
468            || crate::embedding::gguf::GgufModelLoader::ensure_model(&model_dir),
469        );
470
471        match result {
472            Ok(path) => {
473                let size_mb = path.metadata().map(|m| m.len() / 1_000_000).unwrap_or(0);
474                println!(
475                    "  {} {} MB",
476                    theme::success(theme::ok()),
477                    theme::accent(size_mb),
478                );
479                Ok("downloaded".to_string())
480            }
481            Err(e) => {
482                println!("  {} {}", theme::warn(theme::fail()), e,);
483                println!("  {} Will retry on first search.", theme::accent("→"),);
484                Ok("failed".to_string())
485            }
486        }
487    }
488
489    #[cfg(not(feature = "embedding-gguf"))]
490    {
491        let _ = (config, workspace);
492        Ok("tfidf".to_string())
493    }
494}
495
496// ── Spinner helper ───────────────────────────────────────────────────────────
497
498/// Run a closure with a spinner. Shows `message` while running,
499/// replaces with `done` on success.
500fn with_spinner<T, F>(message: &str, done: &str, f: F) -> T
501where
502    F: FnOnce() -> T,
503{
504    let pb = ProgressBar::new_spinner();
505    pb.set_style(
506        ProgressStyle::default_spinner()
507            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")
508            .template("  {spinner} {msg}")
509            .unwrap(),
510    );
511    pb.set_message(message.to_string());
512    pb.enable_steady_tick(std::time::Duration::from_millis(80));
513
514    let result = f();
515
516    pb.finish_with_message(done.to_string());
517    result
518}
519
520// ── Persistence ──────────────────────────────────────────────────────────────
521
522fn persist_config(
523    oxios_home: &Path,
524    config: &mut OxiosConfig,
525    provider: &str,
526    api_key: &str,
527    model: &str,
528) -> anyhow::Result<()> {
529    if !api_key.is_empty() {
530        CredentialStore::store(provider, api_key)?;
531    }
532
533    let workspace = crate::config::expand_home(&config.kernel.workspace);
534    std::fs::create_dir_all(&workspace)?;
535    for subdir in WORKSPACE_SUBDIRS {
536        std::fs::create_dir_all(Path::new(&workspace).join(subdir))?;
537    }
538
539    config.engine.default_model = model.to_string();
540
541    std::fs::create_dir_all(oxios_home)?;
542    let toml_str = toml::to_string_pretty(config)
543        .map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
544    std::fs::write(oxios_home.join("config.toml"), &toml_str)?;
545
546    Ok(())
547}
548
549// ── UI: intro / outro ───────────────────────────────────────────────────────
550
551fn print_intro(is_first_run: bool) {
552    println!();
553
554    if is_first_run {
555        println!("  {}", style("⬡ Oxios Agent OS").bold().cyan(),);
556        println!("  {}", theme::dim("Your AI agents, organized."),);
557        println!();
558        println!("  Let's get you set up. About 30 seconds.");
559    } else {
560        println!("  {}", style("⬡ Oxios Setup").bold());
561    }
562
563    println!(
564        "  {}",
565        theme::dim("↑↓ navigate · Enter confirm · Ctrl+C skip"),
566    );
567    println!();
568}
569
570fn print_summary(
571    oxios_home: &Path,
572    provider: &str,
573    model: &str,
574    key_source: &str,
575    embed_status: &str,
576) {
577    println!();
578    println!(
579        "  {}",
580        theme::dim("─────────────────────────────────────────")
581    );
582
583    println!("  {:<14} {}", theme::dim("LLM:"), theme::accent(model),);
584    println!(
585        "  {:<14} {}",
586        theme::dim("Provider:"),
587        theme::muted(provider),
588    );
589    println!("  {:<14} {}", theme::dim("Key:"), theme::muted(key_source),);
590
591    let embed_label = match embed_status {
592        "cached" | "downloaded" => {
593            #[cfg(feature = "embedding-gguf")]
594            {
595                let name = crate::embedding::gguf::MODEL_DISPLAY_NAME;
596                Some(if embed_status == "downloaded" {
597                    format!("{} ✓", name)
598                } else {
599                    format!("{} ✓ (cached)", name)
600                })
601            }
602            #[cfg(not(feature = "embedding-gguf"))]
603            {
604                None
605            }
606        }
607        "failed" => Some("will download on first search".to_string()),
608        _ => None,
609    };
610
611    if let Some(ref label) = embed_label {
612        let styled = if embed_status == "failed" {
613            theme::warn(label).to_string()
614        } else {
615            theme::accent(label).to_string()
616        };
617        println!("  {:<14} {}", theme::dim("Embedding:"), styled);
618    }
619
620    println!(
621        "  {:<14} {}",
622        theme::dim("Home:"),
623        theme::muted(oxios_home.display()),
624    );
625
626    println!(
627        "  {}",
628        theme::dim("─────────────────────────────────────────")
629    );
630    println!();
631}