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