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 if let Some(provider_id) =
162 CredentialStore::provider_from_model(&config.engine.default_model)
163 {
164 if CredentialStore::has_credential(provider_id, config.api_key().as_deref()) {
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 }
188
189 if !is_interactive() {
191 println!();
192 println!(
193 " {} Setup requires a terminal. Run {} interactively.",
194 style("!").yellow(),
195 style("oxios").cyan(),
196 );
197 println!();
198 return Ok(OnboardingResult {
199 configured: false,
200 skipped: true,
201 });
202 }
203
204 print_intro(is_first_run);
206
207 let env_providers = oxi_sdk::get_all_env_keys();
209 if !env_providers.is_empty() {
210 let detected = env_providers
211 .keys()
212 .find(|p| !oxi_sdk::get_provider_models(p).is_empty());
213
214 if let Some(provider) = detected {
215 let keys = oxi_sdk::find_env_keys(provider);
216 let var_name = keys.and_then(|k| k.first().copied()).unwrap_or(provider);
217 println!(
218 " {} {} {}",
219 theme::accent("◇"),
220 theme::dim(format!("Found {var_name} →")),
221 theme::accent(provider),
222 );
223 let use_it = Confirm::new(" Use this provider?")
224 .with_default(true)
225 .prompt()?;
226 if use_it {
227 return run_provider_flow(oxios_home, config, provider);
228 }
229 }
230 }
231
232 let all_providers = oxi_sdk::get_providers();
234 let visible: Vec<&str> = all_providers
235 .iter()
236 .copied()
237 .filter(|p| !HIDDEN_PROVIDERS.contains(p))
238 .collect();
239
240 let provider = prompt_provider(&visible)?;
241 run_provider_flow(oxios_home, config, provider)
242}
243
244fn run_provider_flow(
247 oxios_home: &Path,
248 config: &mut OxiosConfig,
249 provider: &str,
250) -> anyhow::Result<OnboardingResult> {
251 let (api_key, key_source) = resolve_api_key(provider)?;
253
254 let model = prompt_model(provider)?;
256
257 with_spinner("Saving configuration...", "Configuration saved", || {
259 persist_config(
260 oxios_home,
261 config,
262 provider,
263 api_key.as_deref().unwrap_or(""),
264 &model,
265 )
266 })?;
267
268 let embed_status = setup_embedding(config)?;
270
271 print_summary(oxios_home, provider, &model, key_source, &embed_status);
273
274 Ok(OnboardingResult {
275 configured: true,
276 skipped: false,
277 })
278}
279
280fn resolve_api_key(provider: &str) -> anyhow::Result<(Option<String>, &'static str)> {
283 if NO_KEY_PROVIDERS.contains(&provider) {
284 return Ok((None, "none"));
285 }
286
287 if let Ok(Some(token)) = oxi_sdk::load_token(provider) {
289 if !token.access_token.is_empty() {
290 println!();
291 println!(
292 " {} Credentials found in {}",
293 theme::step("API Key"),
294 theme::dim("~/.oxi/auth.json"),
295 );
296 let use_it = Confirm::new(" Use them?").with_default(true).prompt()?;
297 if use_it {
298 return Ok((None, "auth.json"));
299 }
300 }
301 }
302
303 if let Some(env_key) = oxi_sdk::get_env_api_key(provider) {
305 println!();
306 println!(
307 " {} {}",
308 theme::step("API Key"),
309 theme::dim("Using key from environment"),
310 );
311 return Ok((Some(env_key), "env"));
312 }
313
314 println!();
316 println!(" {}", theme::step("API Key"));
317 println!(" {}", theme::dim("Stored locally, never shared."),);
318
319 let key = CustomType::<String>::new(" →")
320 .with_placeholder("sk-...")
321 .with_error_message("API key is required")
322 .prompt()?;
323 Ok((Some(key), "manual"))
324}
325
326fn prompt_provider<'a>(providers: &[&'a str]) -> anyhow::Result<&'a str> {
329 let mut entries: Vec<ProviderEntry> = providers
330 .iter()
331 .map(|&p| {
332 let model_count = oxi_sdk::get_provider_models(p).len();
333 let has_env = oxi_sdk::has_env_key(p);
334 let mut badges = vec![format!("{} models", model_count)];
335 if has_env {
336 badges.push("🔑 detected".into());
337 }
338 ProviderEntry {
339 id: p.to_string(),
340 display: format!(
341 " {} {}",
342 style(p).bold(),
343 theme::muted(badges.join(" · ")),
344 ),
345 has_env_key: has_env,
346 }
347 })
348 .collect();
349
350 entries.sort_by_key(|b| std::cmp::Reverse(b.has_env_key));
352
353 println!();
354 println!(" {}", theme::step("Provider"));
355 println!(" {}", theme::dim("Which cloud hosts your LLM?"),);
356
357 let selected = Select::new(" →", entries)
358 .with_starting_cursor(0)
359 .prompt()?;
360
361 Ok(providers.iter().find(|&&p| p == selected.id).unwrap())
362}
363
364fn prompt_model(provider: &str) -> anyhow::Result<String> {
367 let models = oxi_sdk::get_provider_models(provider);
368
369 println!();
370 println!(" {}", theme::step("Model"));
371
372 if models.is_empty() {
373 let model = Text::new(" → Model ID:").prompt()?;
374 if model.is_empty() {
375 anyhow::bail!("Model ID is required.");
376 }
377 return Ok(if model.contains('/') {
378 model
379 } else {
380 format!("{provider}/{model}")
381 });
382 }
383
384 let mut entries: Vec<ModelEntry> = Vec::new();
385 for entry in models.iter() {
386 if entry.name.contains("latest") {
387 continue;
388 }
389 let full_id = format!("{}/{}", provider, entry.id);
390 let ctx = if entry.context_window >= 1_000_000 {
391 format!("{}M", entry.context_window / 1_000_000)
392 } else {
393 format!("{}K", entry.context_window / 1000)
394 };
395 let reasoning = if entry.reasoning {
396 format!(" {}", style("reasoning").magenta())
397 } else {
398 String::new()
399 };
400 entries.push(ModelEntry {
401 full_id,
402 display: format!(
403 " {} {}{}",
404 style(&entry.name).bold(),
405 theme::muted(format!("{ctx} ctx")),
406 reasoning,
407 ),
408 });
409 if entries.len() >= 12 {
410 break;
411 }
412 }
413
414 entries.push(ModelEntry {
415 full_id: String::new(),
416 display: format!(" {MANUAL_MODEL_DISPLAY}"),
417 });
418
419 let selected = Select::new(" →", entries)
420 .with_starting_cursor(0)
421 .prompt()?;
422
423 if selected.display.contains(MANUAL_MODEL_DISPLAY) {
424 let manual = Text::new(" → Model ID:").prompt()?;
425 if manual.is_empty() {
426 anyhow::bail!("Model ID cannot be empty.");
427 }
428 return Ok(if manual.contains('/') {
429 manual
430 } else {
431 format!("{provider}/{manual}")
432 });
433 }
434
435 Ok(selected.full_id.clone())
436}
437
438fn setup_embedding(config: &OxiosConfig) -> anyhow::Result<String> {
441 let workspace = crate::config::expand_home(&config.kernel.workspace);
442
443 #[cfg(feature = "embedding-gguf")]
444 {
445 let model_dir =
446 crate::embedding::gguf::GgufModelLoader::model_dir_for_workspace(&workspace);
447
448 if crate::embedding::gguf::GgufModelLoader::is_model_cached(&model_dir) {
449 return Ok("cached".to_string());
450 }
451
452 let display_name = crate::embedding::gguf::MODEL_DISPLAY_NAME;
453 let size_mb = crate::embedding::gguf::MODEL_SIZE_MB;
454
455 println!();
456 println!(
457 " {} {} model (~{} MB)",
458 theme::step("Embedding"),
459 display_name,
460 size_mb,
461 );
462 println!(
463 " {}",
464 theme::dim("For semantic memory search. One-time download."),
465 );
466
467 let result = with_spinner(
468 &format!("Downloading {}...", display_name),
469 &format!("{} Downloaded", theme::success(theme::ok()).to_string()),
470 || crate::embedding::gguf::GgufModelLoader::ensure_model(&model_dir),
471 );
472
473 match result {
474 Ok(path) => {
475 let size_mb = path.metadata().map(|m| m.len() / 1_000_000).unwrap_or(0);
476 println!(
477 " {} {} MB",
478 theme::success(theme::ok()),
479 theme::accent(size_mb),
480 );
481 Ok("downloaded".to_string())
482 }
483 Err(e) => {
484 println!(" {} {}", theme::warn(theme::fail()), e,);
485 println!(" {} Will retry on first search.", theme::accent("→"),);
486 Ok("failed".to_string())
487 }
488 }
489 }
490
491 #[cfg(not(feature = "embedding-gguf"))]
492 {
493 let _ = (config, workspace);
494 Ok("tfidf".to_string())
495 }
496}
497
498fn with_spinner<T, F>(message: &str, done: &str, f: F) -> T
503where
504 F: FnOnce() -> T,
505{
506 let pb = ProgressBar::new_spinner();
507 pb.set_style(
508 ProgressStyle::default_spinner()
509 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")
510 .template(" {spinner} {msg}")
511 .unwrap(),
512 );
513 pb.set_message(message.to_string());
514 pb.enable_steady_tick(std::time::Duration::from_millis(80));
515
516 let result = f();
517
518 pb.finish_with_message(done.to_string());
519 result
520}
521
522fn persist_config(
525 oxios_home: &Path,
526 config: &mut OxiosConfig,
527 provider: &str,
528 api_key: &str,
529 model: &str,
530) -> anyhow::Result<()> {
531 if !api_key.is_empty() {
532 CredentialStore::store(provider, api_key)?;
533 }
534
535 let workspace = crate::config::expand_home(&config.kernel.workspace);
536 std::fs::create_dir_all(&workspace)?;
537 for subdir in WORKSPACE_SUBDIRS {
538 std::fs::create_dir_all(Path::new(&workspace).join(subdir))?;
539 }
540
541 config.engine.default_model = model.to_string();
542
543 std::fs::create_dir_all(oxios_home)?;
544 let toml_str = toml::to_string_pretty(config)
545 .map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
546 std::fs::write(oxios_home.join("config.toml"), &toml_str)?;
547
548 Ok(())
549}
550
551fn print_intro(is_first_run: bool) {
554 println!();
555
556 if is_first_run {
557 println!(" {}", style("⬡ Oxios Agent OS").bold().cyan(),);
558 println!(" {}", theme::dim("Your AI agents, organized."),);
559 println!();
560 println!(" Let's get you set up. About 30 seconds.");
561 } else {
562 println!(" {}", style("⬡ Oxios Setup").bold());
563 }
564
565 println!(
566 " {}",
567 theme::dim("↑↓ navigate · Enter confirm · Ctrl+C skip"),
568 );
569 println!();
570}
571
572fn print_summary(
573 oxios_home: &Path,
574 provider: &str,
575 model: &str,
576 key_source: &str,
577 embed_status: &str,
578) {
579 println!();
580 println!(
581 " {}",
582 theme::dim("─────────────────────────────────────────")
583 );
584
585 println!(" {:<14} {}", theme::dim("LLM:"), theme::accent(model),);
586 println!(
587 " {:<14} {}",
588 theme::dim("Provider:"),
589 theme::muted(provider),
590 );
591 println!(" {:<14} {}", theme::dim("Key:"), theme::muted(key_source),);
592
593 let embed_label = match embed_status {
594 "cached" | "downloaded" => {
595 #[cfg(feature = "embedding-gguf")]
596 {
597 let name = crate::embedding::gguf::MODEL_DISPLAY_NAME;
598 Some(if embed_status == "downloaded" {
599 format!("{} ✓", name)
600 } else {
601 format!("{} ✓ (cached)", name)
602 })
603 }
604 #[cfg(not(feature = "embedding-gguf"))]
605 {
606 None
607 }
608 }
609 "failed" => Some("will download on first search".to_string()),
610 _ => None,
611 };
612
613 if let Some(ref label) = embed_label {
614 let styled = if embed_status == "failed" {
615 theme::warn(label).to_string()
616 } else {
617 theme::accent(label).to_string()
618 };
619 println!(" {:<14} {}", theme::dim("Embedding:"), styled);
620 }
621
622 println!(
623 " {:<14} {}",
624 theme::dim("Home:"),
625 theme::muted(oxios_home.display()),
626 );
627
628 println!(
629 " {}",
630 theme::dim("─────────────────────────────────────────")
631 );
632 println!();
633}