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