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