Skip to main content

oxios_kernel/
onboarding.rs

1//! Interactive first-run setup wizard.
2//!
3//! Uses oxi-sdk's provider/model catalog and env key detection.
4//! No hardcoded provider lists — everything comes from oxi-ai.
5//!
6//! UI powered by `inquire` — arrow-key navigation for all selections.
7
8use crate::config::OxiosConfig;
9use crate::credential::CredentialStore;
10use console::style;
11use inquire::{Confirm, CustomType, Select, Text};
12use std::io::{self, IsTerminal, Write};
13use std::path::Path;
14
15// ── Constants ───────────────────────────────────────────────────────────────
16
17const TOTAL_STEPS: usize = 5;
18
19const WORKSPACE_SUBDIRS: &[&str] = &[
20    "workspace",
21    "workspace/memory",
22    "workspace/memory/knowledge",
23    "workspace/seeds",
24    "workspace/sessions",
25    "workspace/skills",
26    "workspace/programs",
27];
28
29/// Providers that don't need an API key (e.g., local models).
30const NO_KEY_PROVIDERS: &[&str] = &[];
31
32/// Providers to exclude from the interactive list (cloud gateways, regional variants).
33/// Users who need these can set them up via config.toml directly.
34const HIDDEN_PROVIDERS: &[&str] = &[
35    "amazon-bedrock",         // requires AWS setup, not a simple API key
36    "azure-openai-responses", // requires Azure deployment
37    "cloudflare-ai-gateway",
38    "cloudflare-workers-ai",
39    "google-vertex", // requires ADC, not a simple API key
40    "minimax-cn",
41    "moonshotai-cn",
42    "openai-codex", // subset of openai
43    "opencode-go",
44    "vercel-ai-gateway",
45    "xiaomi",
46];
47
48// ── Helpers for formatted display items ─────────────────────────────────────
49
50/// A provider entry in the selection list.
51#[derive(Clone)]
52struct ProviderEntry {
53    id: String,
54    display: String,
55}
56
57impl std::fmt::Display for ProviderEntry {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        write!(f, "{}", self.display)
60    }
61}
62
63/// A model entry in the selection list.
64#[derive(Clone)]
65struct ModelEntry {
66    /// Full model ID: "provider/model-id"
67    full_id: String,
68    /// Display label
69    display: String,
70}
71
72impl std::fmt::Display for ModelEntry {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}", self.display)
75    }
76}
77
78/// Manual model entry sentinel.
79const MANUAL_MODEL_DISPLAY: &str = "✎ Enter model ID manually...";
80
81// ── Public API ──────────────────────────────────────────────────────────────
82
83/// Check if the system is fully configured (model + credentials).
84/// Returns `true` when onboarding should be skipped.
85pub fn has_credentials(config: &OxiosConfig) -> bool {
86    let Some(provider) = CredentialStore::provider_from_model(&config.engine.default_model) else {
87        return false;
88    };
89    CredentialStore::has_credential(provider, config.api_key().as_deref())
90}
91
92/// Check if stdin is an interactive terminal.
93pub fn is_interactive() -> bool {
94    io::stdin().is_terminal()
95}
96
97/// Run the first-time setup wizard.
98///
99/// Returns `Ok(true)` — onboarding completed, config written.
100/// Returns `Ok(false)` — skipped (already configured, non-interactive, or cancelled).
101pub fn run_onboarding(oxios_home: &Path, config: &mut OxiosConfig) -> anyhow::Result<bool> {
102    // ── Re-run detection ──
103    if !config.engine.default_model.is_empty() {
104        if let Some(provider_id) =
105            CredentialStore::provider_from_model(&config.engine.default_model)
106        {
107            if CredentialStore::has_credential(provider_id, config.api_key().as_deref()) {
108                println!();
109                println!("  Already configured as '{}'.", config.engine.default_model);
110
111                let ans = Select::new(
112                    "  What would you like to do?",
113                    vec![
114                        "Keep current configuration",
115                        "Modify (re-run wizard)",
116                        "Reset (clear everything)",
117                    ],
118                )
119                .with_starting_cursor(0)
120                .prompt()?;
121
122                match ans {
123                    "Keep current configuration" => {
124                        return Ok(false);
125                    }
126                    "Reset (clear everything)" => { /* fall through to wizard */ }
127                    _ => { /* modify — fall through */ }
128                }
129            }
130        }
131    }
132
133    // ── Need a terminal for interactive input ──
134    if !is_interactive() {
135        println!();
136        println!("  Oxios requires initial setup but is not running in a terminal.");
137        println!("  Please run `oxios` in an interactive shell.");
138        println!();
139        return Ok(false);
140    }
141
142    print_banner();
143
144    // ── Step 0 [auto]: Check for env vars and existing auth tokens ──
145    let env_providers = oxi_sdk::get_all_env_keys();
146    if !env_providers.is_empty() {
147        // Find the first provider that also has models in the registry
148        let detected = env_providers
149            .keys()
150            .find(|p| !oxi_sdk::get_provider_models(p).is_empty());
151
152        if let Some(provider) = detected {
153            println!();
154            let keys = oxi_sdk::find_env_keys(provider);
155            let var_name = keys.and_then(|k| k.first().copied()).unwrap_or(provider);
156            println!("  Detected {} in environment for '{}'.", var_name, provider);
157            let use_it = Confirm::new("  Use this provider?")
158                .with_default(true)
159                .prompt()?;
160            if use_it {
161                return finish_with_provider(oxios_home, config, provider);
162            }
163        }
164    }
165
166    // ── Step 1: Provider selection from oxi model DB ──
167    let all_providers = oxi_sdk::get_providers();
168    let visible: Vec<&str> = all_providers
169        .iter()
170        .copied()
171        .filter(|p| !HIDDEN_PROVIDERS.contains(p))
172        .collect();
173
174    let provider = prompt_provider(&visible)?;
175    finish_with_provider(oxios_home, config, provider)
176}
177
178// ── Core flow for a chosen provider ─────────────────────────────────────────
179
180/// Complete onboarding for a given provider: key → model → workspace → write.
181fn finish_with_provider(
182    oxios_home: &Path,
183    config: &mut OxiosConfig,
184    provider: &str,
185) -> anyhow::Result<bool> {
186    let mut api_key: Option<String> = None;
187    let mut skip_key = false;
188
189    // ── Check existing auth.json ──
190    if let Ok(Some(token)) = oxi_sdk::load_token(provider) {
191        if !token.access_token.is_empty() {
192            println!();
193            println!(
194                "  Found existing credentials for '{}' in ~/.oxi/auth.json.",
195                provider
196            );
197            let use_it = Confirm::new("  Use them?").with_default(true).prompt()?;
198            if use_it {
199                skip_key = true;
200            }
201        }
202    }
203
204    // ── Step 2: API key ──
205    if !skip_key && !NO_KEY_PROVIDERS.contains(&provider) {
206        // Check if env var already has the key
207        if let Some(env_key) = oxi_sdk::get_env_api_key(provider) {
208            println!();
209            println!("  Using {} key from environment.", provider);
210            api_key = Some(env_key);
211            skip_key = true;
212        }
213
214        if !skip_key {
215            api_key = Some(prompt_api_key(provider)?);
216        }
217    }
218
219    // ── Step 3: Model selection from oxi model DB ──
220    let model = prompt_model(provider)?;
221
222    // ── Step 4: Workspace ──
223    let workspace = prompt_workspace()?;
224
225    // ── Summary ──
226    let key_preview = if skip_key {
227        let key = api_key.as_deref().unwrap_or("(from auth store)");
228        mask_key(key)
229    } else {
230        mask_key(api_key.as_deref().unwrap_or("(none)"))
231    };
232
233    if !confirm_summary(provider, &model, &key_preview, &workspace)? {
234        println!();
235        println!("  Setup cancelled.");
236        return Ok(false);
237    }
238
239    // ── Step 5: Write ──
240    persist_config(
241        oxios_home,
242        config,
243        provider,
244        api_key.as_deref().unwrap_or(""),
245        &model,
246        &workspace,
247    )?;
248    print_success(oxios_home, &model);
249    Ok(true)
250}
251
252// ── Prompt steps ─────────────────────────────────────────────────────────────
253
254/// Step 1: Show providers from oxi model DB — arrow-key selection.
255fn prompt_provider<'a>(providers: &[&'a str]) -> anyhow::Result<&'a str> {
256    let entries: Vec<ProviderEntry> = providers
257        .iter()
258        .map(|&p| {
259            let mut suffix = String::new();
260            if oxi_sdk::has_env_key(p) {
261                suffix = " 🔑".to_string();
262            }
263            let model_count = oxi_sdk::get_provider_models(p).len();
264            ProviderEntry {
265                id: p.to_string(),
266                display: format!("{} [{} models]{}", p, model_count, suffix),
267            }
268        })
269        .collect();
270
271    println!();
272    println!("  [1/{}] Select an LLM provider:", TOTAL_STEPS);
273
274    let selected = Select::new("  Provider:", entries)
275        .with_starting_cursor(0)
276        .prompt()?;
277
278    // Find the original &str by matching the id
279    Ok(providers.iter().find(|&&p| p == selected.id).unwrap())
280}
281
282/// Step 2: Prompt for API key (masked input).
283fn prompt_api_key(provider: &str) -> anyhow::Result<String> {
284    println!();
285    println!("  [2/{}] Enter your {} API key:", TOTAL_STEPS, provider);
286
287    let key = CustomType::<String>::new("  API key:")
288        .with_placeholder("sk-...")
289        .with_error_message("API key is required")
290        .prompt()?;
291    Ok(key)
292}
293
294/// Step 3: Model selection — arrow-key selection with manual entry option.
295fn prompt_model(provider: &str) -> anyhow::Result<String> {
296    let models = oxi_sdk::get_provider_models(provider);
297
298    println!();
299    println!("  [3/{}] Select a model for {}:", TOTAL_STEPS, provider);
300
301    if models.is_empty() {
302        // Unknown provider — manual entry
303        let model = Text::new("  Enter model ID:").prompt()?;
304        if model.is_empty() {
305            anyhow::bail!("Model ID is required.");
306        }
307        return Ok(if model.contains('/') {
308            model
309        } else {
310            format!("{}/{}", provider, model)
311        });
312    }
313
314    // Build model entries (skip "latest" aliases, up to 8)
315    let mut entries: Vec<ModelEntry> = Vec::new();
316    for entry in models.iter() {
317        if entry.name.contains("latest") {
318            continue;
319        }
320        let ctx = if entry.context_window >= 1_000_000 {
321            format!("{}M ctx", entry.context_window / 1_000_000)
322        } else {
323            format!("{}K ctx", entry.context_window / 1000)
324        };
325        let reasoning = if entry.reasoning { " ✦reasoning" } else { "" };
326        entries.push(ModelEntry {
327            full_id: format!("{}/{}", provider, entry.id),
328            display: format!("{:<40} {:>10}{}", entry.name, ctx, reasoning),
329        });
330        if entries.len() >= 8 {
331            break;
332        }
333    }
334
335    // Add manual entry option
336    let manual_entry = ModelEntry {
337        full_id: String::new(), // sentinel
338        display: MANUAL_MODEL_DISPLAY.to_string(),
339    };
340    entries.push(manual_entry);
341
342    let selected = Select::new("  Model:", entries)
343        .with_starting_cursor(0)
344        .prompt()?;
345
346    if selected.display == MANUAL_MODEL_DISPLAY {
347        let manual = Text::new("  Model ID:").prompt()?;
348        if manual.is_empty() {
349            anyhow::bail!("Model ID cannot be empty.");
350        }
351        return Ok(if manual.contains('/') {
352            manual
353        } else {
354            format!("{}/{}", provider, manual)
355        });
356    }
357
358    Ok(selected.full_id.clone())
359}
360
361/// Step 4: Workspace path with default.
362fn prompt_workspace() -> anyhow::Result<String> {
363    let default_workspace = dirs::home_dir()
364        .map(|h| format!("{}/.oxios/workspace", h.display()))
365        .unwrap_or_else(|| "~/.oxios/workspace".to_string());
366
367    println!();
368    println!("  [4/{}] Workspace path:", TOTAL_STEPS);
369
370    let workspace = Text::new("  Workspace:")
371        .with_default(&default_workspace)
372        .prompt()?;
373
374    Ok(crate::config::expand_home(&workspace)
375        .to_string_lossy()
376        .to_string())
377}
378
379/// Step 5: Summary confirmation.
380fn confirm_summary(
381    provider: &str,
382    model: &str,
383    key_preview: &str,
384    workspace: &str,
385) -> anyhow::Result<bool> {
386    println!();
387    println!("  ┌─────────────────────────────────────────────┐");
388    println!("  │            Configuration Summary             │");
389    println!("  ├─────────────────────────────────────────────┤");
390    println!("  │  Provider:  {:<32}│", provider);
391    println!("  │  Model:     {:<32}│", model);
392    println!("  │  Key:       {:<32}│", key_preview);
393    println!("  │  Workspace: {:<32}│", truncate_str(workspace, 32));
394    println!("  └─────────────────────────────────────────────┘");
395    println!();
396    println!("  [5/{}] Write configuration?", TOTAL_STEPS);
397
398    Confirm::new("  Save this configuration?")
399        .with_default(true)
400        .prompt()
401        .map_err(Into::into)
402}
403
404// ── Persistence ──────────────────────────────────────────────────────────────
405
406/// Write everything to disk.
407fn persist_config(
408    oxios_home: &Path,
409    config: &mut OxiosConfig,
410    provider: &str,
411    api_key: &str,
412    model: &str,
413    workspace: &str,
414) -> anyhow::Result<()> {
415    print!("\n  Saving configuration... ");
416    std::io::stdout().flush()?;
417
418    if !api_key.is_empty() {
419        CredentialStore::store(provider, api_key)?;
420    }
421
422    std::fs::create_dir_all(workspace)?;
423    for subdir in WORKSPACE_SUBDIRS {
424        std::fs::create_dir_all(Path::new(workspace).join(subdir))?;
425    }
426
427    config.engine.default_model = model.to_string();
428    config.kernel.workspace = workspace.to_string();
429    write_config(oxios_home, config)?;
430
431    println!("done");
432    Ok(())
433}
434
435fn write_config(oxios_home: &Path, config: &OxiosConfig) -> anyhow::Result<()> {
436    std::fs::create_dir_all(oxios_home)?;
437    let toml_str = toml::to_string_pretty(config)
438        .map_err(|e| anyhow::anyhow!("Failed to serialize config: {}", e))?;
439    std::fs::write(oxios_home.join("config.toml"), &toml_str)?;
440    Ok(())
441}
442
443// ── UI ───────────────────────────────────────────────────────────────────────
444
445fn print_banner() {
446    println!();
447    println!("  ╔═══════════════════════════════════════════╗");
448    println!(
449        "  ║       {}  ║",
450        style("⬡  Oxios — First-time Setup").bold()
451    );
452    println!("  ╚═══════════════════════════════════════════╝");
453    println!();
454    println!("  This wizard will configure your API credentials.");
455    println!(
456        "  Use {} arrow keys to navigate, Enter to confirm.",
457        style("↑↓").cyan()
458    );
459    println!(
460        "  Press {} at any time to cancel.",
461        style("Ctrl+C").yellow()
462    );
463}
464
465fn print_success(oxios_home: &Path, model: &str) {
466    println!();
467    println!("  ╔═══════════════════════════════════════════╗");
468    println!(
469        "  ║             {}               ║",
470        style("Setup Complete!").green().bold()
471    );
472    println!("  ╚═══════════════════════════════════════════╝");
473    println!();
474    println!(
475        "    {}   {}",
476        style("Config:").dim(),
477        oxios_home.join("config.toml").display()
478    );
479    println!("    {}    {}", style("Model:").dim(), style(model).cyan());
480    println!();
481    println!("  Next steps:");
482    println!("    {} → start the daemon", style("oxios start").cyan());
483    println!(
484        "    {} → register as system service",
485        style("oxios daemon install").cyan()
486    );
487    println!(
488        "    {} → open web dashboard",
489        style("open http://127.0.0.1:4200").cyan()
490    );
491    println!();
492}
493
494// ── Helpers ──────────────────────────────────────────────────────────────────
495
496fn mask_key(key: &str) -> String {
497    if key.len() <= 8 {
498        return key.to_string();
499    }
500    format!("{}...{}", &key[..4], &key[key.len() - 4..])
501}
502
503fn truncate_str(s: &str, max_len: usize) -> String {
504    if s.len() <= max_len {
505        s.to_string()
506    } else {
507        format!("{}...", &s[..max_len.saturating_sub(3)])
508    }
509}