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