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