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/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
49mod 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 pub fn step(name: &str) -> String {
82 format!(" {} {}", style("◇").cyan(), style(name).bold())
83 }
84
85 pub fn spinner_frame() -> &'static str {
87 "◯"
88 }
89
90 pub fn ok() -> &'static str {
92 "✓"
93 }
94
95 pub fn fail() -> &'static str {
97 "✗"
98 }
99}
100
101#[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
130pub 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
140pub fn is_interactive() -> bool {
142 io::stdin().is_terminal()
143}
144
145pub struct OnboardingResult {
147 pub configured: bool,
149 pub skipped: bool,
151}
152
153pub fn run_onboarding(
155 oxios_home: &Path,
156 config: &mut OxiosConfig,
157 is_first_run: bool,
158) -> anyhow::Result<OnboardingResult> {
159 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 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 print_intro(is_first_run);
204
205 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 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
242fn run_provider_flow(
245 oxios_home: &Path,
246 config: &mut OxiosConfig,
247 provider: &str,
248) -> anyhow::Result<OnboardingResult> {
249 let (api_key, key_source) = resolve_api_key(provider)?;
251
252 let model = prompt_model(provider)?;
254
255 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 let embed_status = setup_embedding(config)?;
268
269 print_summary(oxios_home, provider, &model, key_source, &embed_status);
271
272 Ok(OnboardingResult {
273 configured: true,
274 skipped: false,
275 })
276}
277
278fn 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 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 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 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
324fn 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 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
362fn 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
436fn 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
496fn 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
520fn 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
549fn 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}