1use crate::config::OxiosConfig;
7use crate::credential::CredentialStore;
8use std::io::{self, IsTerminal, Write};
9use std::path::Path;
10
11const TOTAL_STEPS: usize = 5;
14
15const WORKSPACE_SUBDIRS: &[&str] = &[
16 "workspace",
17 "workspace/memory",
18 "workspace/memory/knowledge",
19 "workspace/seeds",
20 "workspace/sessions",
21 "workspace/skills",
22 "workspace/programs",
23];
24
25const NO_KEY_PROVIDERS: &[&str] = &[];
27
28const HIDDEN_PROVIDERS: &[&str] = &[
31 "amazon-bedrock", "azure-openai-responses", "cloudflare-ai-gateway",
34 "cloudflare-workers-ai",
35 "google-vertex", "minimax-cn",
37 "moonshotai-cn",
38 "openai-codex", "opencode-go",
40 "vercel-ai-gateway",
41 "xiaomi",
42];
43
44pub fn has_credentials(config: &OxiosConfig) -> bool {
49 let Some(provider) = CredentialStore::provider_from_model(&config.engine.default_model)
50 else {
51 return false;
52 };
53 CredentialStore::has_credential(provider, config.api_key().as_deref())
54}
55
56pub fn is_interactive() -> bool {
58 io::stdin().is_terminal()
59}
60
61pub fn run_onboarding(oxios_home: &Path, config: &mut OxiosConfig) -> anyhow::Result<bool> {
66 if !config.engine.default_model.is_empty() {
68 if let Some(provider_id) =
69 CredentialStore::provider_from_model(&config.engine.default_model)
70 {
71 if CredentialStore::has_credential(provider_id, config.api_key().as_deref()) {
72 println!();
73 println!(
74 " Already configured as '{}'.",
75 config.engine.default_model
76 );
77 println!(" [K]eep / [M]odify / [R]eset?");
78 print!(" > ");
79 io::stdout().flush()?;
80 let input = read_line();
81 match input.trim().to_lowercase().as_str() {
82 "k" | "keep" | "" => {
83 return Ok(false);
84 }
85 "r" | "reset" => { }
86 _ => { }
87 }
88 }
89 }
90 }
91
92 if !is_interactive() {
94 println!();
95 println!(" Oxios requires initial setup but is not running in a terminal.");
96 println!(" Please run `oxios` in an interactive shell.");
97 println!();
98 return Ok(false);
99 }
100
101 print_banner();
102
103 let env_providers = oxi_sdk::get_all_env_keys();
105 if !env_providers.is_empty() {
106 let detected = env_providers
108 .keys()
109 .find(|p| !oxi_sdk::get_provider_models(p).is_empty());
110
111 if let Some(provider) = detected {
112 println!();
113 let keys = oxi_sdk::find_env_keys(provider);
114 let var_name = keys
115 .and_then(|k| k.first().copied())
116 .unwrap_or(provider);
117 println!(
118 " Detected {} in environment for '{}'.",
119 var_name, provider
120 );
121 if prompt_confirm(" Use this provider?", true) {
122 return finish_with_provider(oxios_home, config, provider);
123 }
124 }
125 }
126
127 let all_providers = oxi_sdk::get_providers();
129 let visible: Vec<&str> = all_providers
130 .iter()
131 .copied()
132 .filter(|p| !HIDDEN_PROVIDERS.contains(p))
133 .collect();
134
135 let provider = prompt_provider(&visible)?;
136 finish_with_provider(oxios_home, config, provider)
137}
138
139fn finish_with_provider(
143 oxios_home: &Path,
144 config: &mut OxiosConfig,
145 provider: &str,
146) -> anyhow::Result<bool> {
147 let mut api_key: Option<String> = None;
148 let mut skip_key = false;
149
150 if let Ok(Some(token)) = oxi_sdk::load_token(provider) {
152 if !token.access_token.is_empty() {
153 println!();
154 println!(
155 " Found existing credentials for '{}' in ~/.oxi/auth.json.",
156 provider
157 );
158 if prompt_confirm(" Use them?", true) {
159 skip_key = true;
160 }
161 }
162 }
163
164 if !skip_key && !NO_KEY_PROVIDERS.contains(&provider) {
166 if let Some(env_key) = oxi_sdk::get_env_api_key(provider) {
168 println!();
169 println!(" Using {} key from environment.", provider);
170 api_key = Some(env_key);
171 skip_key = true;
172 }
173
174 if !skip_key {
175 api_key = Some(prompt_api_key(provider)?);
176 }
177 }
178
179 let model = prompt_model(provider)?;
181
182 let workspace = prompt_workspace()?;
184
185 let key_preview = if skip_key {
187 let key = api_key.as_deref().unwrap_or("(from auth store)");
188 mask_key(key)
189 } else {
190 mask_key(api_key.as_deref().unwrap_or("(none)"))
191 };
192
193 if !confirm_summary(provider, &model, &key_preview, &workspace) {
194 println!();
195 println!(" Setup cancelled.");
196 return Ok(false);
197 }
198
199 persist_config(
201 oxios_home,
202 config,
203 provider,
204 api_key.as_deref().unwrap_or(""),
205 &model,
206 &workspace,
207 )?;
208 print_success(oxios_home, &model);
209 Ok(true)
210}
211
212fn prompt_provider<'a>(providers: &[&'a str]) -> anyhow::Result<&'a str> {
216 println!();
217 println!(" [1/{}] Select an LLM provider:", TOTAL_STEPS);
218 println!();
219
220 for (i, provider) in providers.iter().enumerate() {
222 let mut suffix = String::new();
223 if oxi_sdk::has_env_key(provider) {
224 suffix = " (key detected)".to_string();
225 }
226 let model_count = oxi_sdk::get_provider_models(provider).len();
227 println!(
228 " {:>2}) {} [{} models]{}",
229 i + 1,
230 provider,
231 model_count,
232 suffix
233 );
234 }
235 println!();
236
237 loop {
238 print!(" > ");
239 io::stdout().flush()?;
240 let input = read_line();
241 if let Ok(n) = input.trim().parse::<usize>() {
243 if n >= 1 && n <= providers.len() {
244 return Ok(providers[n - 1]);
245 }
246 }
247 let name = input.trim();
249 if let Some(&p) = providers.iter().find(|&&p| p == name) {
250 return Ok(p);
251 }
252 println!(
253 " Enter a number between 1 and {}, or a provider name.",
254 providers.len()
255 );
256 }
257}
258
259fn prompt_api_key(provider: &str) -> anyhow::Result<String> {
261 println!();
262 println!(" [2/{}] Enter your {} API key:", TOTAL_STEPS, provider);
263 loop {
264 print!(" API key: ");
265 io::stdout().flush()?;
266 let input = read_line();
267 let key = input.trim();
268 if key.is_empty() {
269 println!(" API key is required. (Ctrl+C to cancel)");
270 continue;
271 }
272 return Ok(key.to_string());
273 }
274}
275
276fn prompt_model(provider: &str) -> anyhow::Result<String> {
281 let models = oxi_sdk::get_provider_models(provider);
282
283 println!();
284 println!(" [3/{}] Select a model for {}:", TOTAL_STEPS, provider);
285 println!();
286
287 if models.is_empty() {
288 println!(" No built-in models for this provider.");
290 print!(" Enter model ID: ");
291 io::stdout().flush()?;
292 let input = read_line();
293 let model = input.trim().to_string();
294 if model.is_empty() {
295 anyhow::bail!("Model ID is required.");
296 }
297 return Ok(if model.contains('/') {
298 model
299 } else {
300 format!("{}/{}", provider, model)
301 });
302 }
303
304 let mut shown = Vec::new();
306 for entry in models.iter() {
307 if entry.name.contains("latest") {
309 continue;
310 }
311 shown.push(entry);
312 if shown.len() >= 8 {
313 break;
314 }
315 }
316
317 for (i, entry) in shown.iter().enumerate() {
318 let ctx = if entry.context_window >= 1_000_000 {
319 format!("{}M ctx", entry.context_window / 1_000_000)
320 } else {
321 format!("{}K ctx", entry.context_window / 1000)
322 };
323 let reasoning = if entry.reasoning { " reasoning" } else { "" };
324 println!(" {:>2}) {:<40} {:>8}{}", i + 1, entry.name, ctx, reasoning);
325 }
326 let manual_idx = shown.len() + 1;
327 println!(" {:>2}) Enter model ID manually", manual_idx);
328 println!();
329
330 loop {
331 print!(" > ");
332 io::stdout().flush()?;
333 let input = read_line();
334 match input.trim().parse::<usize>() {
335 Ok(n) if n >= 1 && n <= shown.len() => {
336 let entry = shown[n - 1];
337 return Ok(format!("{}/{}", provider, entry.id));
338 }
339 Ok(n) if n == manual_idx => {
340 print!(" Model ID: ");
341 io::stdout().flush()?;
342 let manual = read_line();
343 let model = manual.trim();
344 if model.is_empty() {
345 println!(" Model ID cannot be empty.");
346 continue;
347 }
348 return Ok(if model.contains('/') {
349 model.to_string()
350 } else {
351 format!("{}/{}", provider, model)
352 });
353 }
354 _ => {
355 println!(
356 " Enter a number between 1 and {}.",
357 manual_idx
358 );
359 continue;
360 }
361 }
362 }
363}
364
365fn prompt_workspace() -> anyhow::Result<String> {
367 let default_workspace = dirs::home_dir()
368 .map(|h| format!("{}/.oxios/workspace", h.display()))
369 .unwrap_or_else(|| "~/.oxios/workspace".to_string());
370
371 println!();
372 println!(" [4/{}] Workspace path (Enter for default):", TOTAL_STEPS);
373 print!(" Workspace [{}]: ", default_workspace);
374 io::stdout().flush()?;
375
376 let input = read_line();
377 let workspace = if input.trim().is_empty() {
378 default_workspace
379 } else {
380 input.trim().to_string()
381 };
382 Ok(crate::config::expand_home(&workspace)
383 .to_string_lossy()
384 .to_string())
385}
386
387fn confirm_summary(
389 provider: &str,
390 model: &str,
391 key_preview: &str,
392 workspace: &str,
393) -> bool {
394 println!();
395 println!(" ┌─────────────────────────────────────────────┐");
396 println!(" │ Configuration Summary │");
397 println!(" ├─────────────────────────────────────────────┤");
398 println!(" │ Provider: {:<32}│", provider);
399 println!(" │ Model: {:<32}│", model);
400 println!(" │ Key: {:<32}│", key_preview);
401 println!(" │ Workspace: {:<32}│", truncate_str(workspace, 32));
402 println!(" └─────────────────────────────────────────────┘");
403 println!();
404 println!(" [5/{}] Write configuration?", TOTAL_STEPS);
405 prompt_confirm(" >", true)
406}
407
408fn persist_config(
412 oxios_home: &Path,
413 config: &mut OxiosConfig,
414 provider: &str,
415 api_key: &str,
416 model: &str,
417 workspace: &str,
418) -> anyhow::Result<()> {
419 print!("\n Saving configuration... ");
420 io::stdout().flush()?;
421
422 if !api_key.is_empty() {
423 CredentialStore::store(provider, api_key)?;
424 }
425
426 std::fs::create_dir_all(workspace)?;
427 for subdir in WORKSPACE_SUBDIRS {
428 std::fs::create_dir_all(Path::new(workspace).join(subdir))?;
429 }
430
431 config.engine.default_model = model.to_string();
432 config.kernel.workspace = workspace.to_string();
433 write_config(oxios_home, config)?;
434
435 println!("done");
436 Ok(())
437}
438
439fn write_config(oxios_home: &Path, config: &OxiosConfig) -> anyhow::Result<()> {
440 std::fs::create_dir_all(oxios_home)?;
441 let toml_str = toml::to_string_pretty(config)
442 .map_err(|e| anyhow::anyhow!("Failed to serialize config: {}", e))?;
443 std::fs::write(oxios_home.join("config.toml"), &toml_str)?;
444 Ok(())
445}
446
447fn print_banner() {
450 println!();
451 println!(" ╔═══════════════════════════════════════════╗");
452 println!(" ║ ⬡ Oxios — First-time Setup ║");
453 println!(" ╚═══════════════════════════════════════════╝");
454 println!();
455 println!(" This wizard will configure your API credentials.");
456 println!(" Press Ctrl+C at any time to cancel.");
457}
458
459fn print_success(oxios_home: &Path, model: &str) {
460 println!();
461 println!(" ╔═══════════════════════════════════════════╗");
462 println!(" ║ Setup Complete! ║");
463 println!(" ╚═══════════════════════════════════════════╝");
464 println!();
465 println!(
466 " Config: {}",
467 oxios_home.join("config.toml").display()
468 );
469 println!(" Model: {}", model);
470 println!();
471 println!(" Next steps:");
472 println!(" oxios → start the daemon");
473 println!(" oxios daemon install → register as system service");
474 println!(" open http://127.0.0.1:4200");
475 println!();
476}
477
478fn read_line() -> String {
481 let mut buf = String::new();
482 io::stdin().read_line(&mut buf).unwrap_or_default();
483 buf.trim_end().to_string()
484}
485
486fn prompt_confirm(prompt: &str, default: bool) -> bool {
487 let suffix = if default { " [Y/n]" } else { " [y/N]" };
488 print!("{}{} ", prompt, suffix);
489 io::stdout().flush().unwrap_or_default();
490 let input = read_line();
491 if input.trim().is_empty() {
492 return default;
493 }
494 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
495}
496
497fn mask_key(key: &str) -> String {
498 if key.len() <= 8 {
499 return key.to_string();
500 }
501 format!("{}...{}", &key[..4], &key[key.len() - 4..])
502}
503
504fn truncate_str(s: &str, max_len: usize) -> String {
505 if s.len() <= max_len {
506 s.to_string()
507 } else {
508 format!("{}...", &s[..max_len.saturating_sub(3)])
509 }
510}