1use crate::store::settings::Settings;
5use anyhow::{Context, Result};
6use std::path::PathBuf;
7use std::sync::Arc;
8
9pub fn handle_completions(shell: &str) -> Result<()> {
11 use clap::CommandFactory;
12
13 let shell = match shell {
14 "bash" => clap_complete::Shell::Bash,
15 "zsh" => clap_complete::Shell::Zsh,
16 "fish" => clap_complete::Shell::Fish,
17 "elvish" => clap_complete::Shell::Elvish,
18 "powershell" => clap_complete::Shell::PowerShell,
19 _ => {
20 anyhow::bail!("Unknown shell: {shell}. Supported: bash, zsh, fish, elvish, powershell")
21 }
22 };
23
24 let mut cmd = crate::cli::CliArgs::command();
25 let name = cmd.get_name().to_string();
26 clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
27 Ok(())
28}
29
30pub async fn handle_install(source: &str) -> Result<()> {
32 use crate::cli::{ExtCommands, PkgCommands};
33
34 if source.starts_with('.')
36 || source.starts_with('/')
37 || source.starts_with('~')
38 || source.starts_with("npm:")
39 {
40 super::pkg::handle_pkg(&PkgCommands::Install {
41 source: source.to_string(),
42 })?;
43 } else {
44 super::ext::handle_ext(&ExtCommands::Install {
46 source: source.to_string(),
47 prerelease: false,
48 })
49 .await?;
50 }
51 Ok(())
52}
53
54pub async fn handle_update(check: bool) -> Result<()> {
64 #[cfg(feature = "self-update")]
65 {
66 use self_update::cargo_crate_version;
67
68 let current = cargo_crate_version!();
69 println!("Current version: v{current}");
70
71 if check {
72 report_layout_status();
73 return Ok(());
74 }
75
76 println!("Updating oxicode via `cargo install oxicode-cli --force`...");
78 let status = tokio::process::Command::new("cargo")
79 .args(["install", "oxicode-cli", "--force"])
80 .stdout(std::process::Stdio::inherit())
81 .stderr(std::process::Stdio::inherit())
82 .status()
83 .await?;
84
85 if !status.success() {
86 anyhow::bail!("Update failed: cargo install exited {status}");
87 }
88
89 match adopt_into_managed_layout().await {
91 Ok(Some(launcher)) => println!(
92 "✅ oxicode converged into the managed layout at {} (cargo bin repointed).",
93 launcher.display(),
94 ),
95 Ok(None) => {
96 println!("✅ oxicode updated successfully. Restart to use the new version.")
97 }
98 Err(e) => println!(
99 "warning: oxicode updated but adopt into the managed layout failed: {e} \
100 (the cargo-installed binary still works at its previous location)"
101 ),
102 }
103 Ok(())
104 }
105
106 #[cfg(not(feature = "self-update"))]
107 {
108 let _ = check;
109 anyhow::bail!("Self-update is not available (compiled without `self-update` feature)");
110 }
111}
112
113fn report_layout_status() {
116 use crate::managed_install;
117 let Some(home) = oxicode_catalog::oxi_home::oxicode_home() else {
118 println!("managed layout: (no resolvable Oxi home — set $OXI_HOME or $OXICODE_HOME)");
119 return;
120 };
121 let launcher = managed_install::launcher_path(&home);
122 if launcher.is_file() {
123 let target = std::fs::read_link(&launcher).unwrap_or_default();
124 println!(
125 "managed layout: {} → {}",
126 launcher.display(),
127 target.display()
128 );
129 let versions = managed_install::versions_dir(&home);
130 if let Ok(entries) = std::fs::read_dir(&versions) {
131 let mut names: Vec<String> = entries
132 .filter_map(|e| e.ok())
133 .filter_map(|e| e.file_name().to_string_lossy().into_owned().into())
134 .filter(|n: &String| managed_install::parse_version_dir(n).is_some())
135 .collect();
136 names.sort();
137 println!(
138 "versions: {}",
139 if names.is_empty() {
140 "(none)".into()
141 } else {
142 names.join(", ")
143 }
144 );
145 }
146 } else {
147 println!("managed layout: (no launcher at {})", launcher.display());
148 }
149 let shadows = shadow_roots();
150 if !shadows.is_empty() {
151 println!(
152 "shadowed by: {}",
153 shadows
154 .iter()
155 .map(|p| p.display().to_string())
156 .collect::<Vec<_>>()
157 .join(", ")
158 );
159 }
160}
161
162async fn adopt_into_managed_layout() -> Result<Option<PathBuf>> {
163 use crate::managed_install;
164 let Some(home) = oxicode_catalog::oxi_home::oxicode_home() else {
165 return Ok(None);
166 };
167 let cargo_bin = managed_install::cargo_oxicode_bin();
168 let Some(cargo_bin) = cargo_bin else {
169 return Ok(None);
170 };
171 if !cargo_bin.is_file() {
172 return Ok(None);
173 }
174 let Some(version) = managed_install::version_of(&cargo_bin) else {
175 return Ok(None);
176 };
177 let launcher = tokio::task::spawn_blocking({
178 let home = home.clone();
179 let cargo_bin = cargo_bin.clone();
180 let relink = cargo_bin.clone();
181 move || managed_install::adopt_binary(&home, &cargo_bin, &version, Some(&relink))
182 })
183 .await
184 .context("managed layout adopt task panicked")??;
185 Ok(Some(launcher))
186}
187
188fn shadow_roots() -> Vec<PathBuf> {
192 use crate::managed_install;
193 let mut out = Vec::new();
194 let winner = managed_install::launcher_path(
195 &oxicode_catalog::oxi_home::oxicode_home().unwrap_or_else(|| std::path::PathBuf::from(".")),
196 );
197 let mut seen = Vec::new();
198 let push = |path: PathBuf, seen: &mut Vec<PathBuf>, out: &mut Vec<PathBuf>| {
199 if path == winner || !path.is_file() || seen.contains(&path) {
200 return;
201 }
202 seen.push(path.clone());
203 out.push(path);
204 };
205 if let Some(cargo_bin) = managed_install::cargo_oxicode_bin() {
206 push(cargo_bin, &mut seen, &mut out);
207 }
208 if let Some(path_var) = std::env::var_os("PATH") {
209 for dir in std::env::split_paths(&path_var) {
210 push(dir.join("oxicode"), &mut seen, &mut out);
211 }
212 }
213 if let Some(legacy) = oxicode_catalog::oxi_home::legacy_home_dir() {
214 push(legacy.join("bin/oxicode"), &mut seen, &mut out);
215 }
216 out
217}
218
219pub async fn handle_commit(push: bool, dry_run: bool, context: Option<&str>) -> Result<()> {
221 use oxicode_agent::AgentTool;
222 use oxicode_agent::tools::ToolContext;
223 use oxicode_agent::tools::commit::CommitTool;
224 use serde_json::json;
225
226 let diff_output = tokio::process::Command::new("git")
228 .args(["diff", "--cached"])
229 .output()
230 .await
231 .with_context(|| "Failed to run git diff --cached")?;
232
233 if diff_output.stdout.is_empty() && diff_output.stderr.is_empty() {
234 let has_changes = tokio::process::Command::new("git")
235 .args(["status", "--porcelain"])
236 .output()
237 .await
238 .with_context(|| "Failed to run git status")?;
239 if has_changes.stdout.is_empty() {
240 anyhow::bail!("Nothing to commit. Working tree is clean.");
241 }
242 anyhow::bail!(
243 "No staged changes. Use `git add` to stage files, or include unstaged changes with `git commit -a`."
244 );
245 }
246
247 let cwd = std::env::current_dir().context("Failed to get current directory")?;
249 let ctx = ToolContext::new(cwd.clone());
250 let tool = CommitTool::unconfigured();
251 let params = json!({
252 "dry_run": dry_run,
253 "push": push,
254 "context": context.unwrap_or(""),
255 });
256 let result = tool
257 .execute("cli", params, None, &ctx)
258 .await
259 .map_err(|e| anyhow::anyhow!("Commit tool failed: {e}"))?;
260
261 if dry_run {
262 println!("{}", result.output);
263 return Ok(());
264 }
265
266 let message = result
268 .output
269 .lines()
270 .find(|l| {
271 l.starts_with("feat")
272 || l.starts_with("fix")
273 || l.starts_with("chore")
274 || l.starts_with("docs")
275 || l.starts_with("refactor")
276 || l.starts_with("test")
277 || l.starts_with("perf")
278 || l.starts_with("build")
279 || l.starts_with("ci")
280 || l.starts_with("style")
281 || l.starts_with("revert")
282 })
283 .unwrap_or("feat: commit")
284 .to_string();
285
286 let status = tokio::process::Command::new("git")
287 .args(["commit", "-m", &message])
288 .stdout(std::process::Stdio::inherit())
289 .stderr(std::process::Stdio::inherit())
290 .status()
291 .await
292 .with_context(|| "Failed to run git commit")?;
293
294 if !status.success() {
295 anyhow::bail!("Commit failed.\nProposed message was:\n{message}");
296 }
297
298 println!("Committed: {message}");
299
300 if push {
301 let push_status = tokio::process::Command::new("git")
302 .args(["push"])
303 .stdout(std::process::Stdio::inherit())
304 .stderr(std::process::Stdio::inherit())
305 .status()
306 .await
307 .with_context(|| "Failed to run git push")?;
308 if !push_status.success() {
309 anyhow::bail!("Commit succeeded but push failed.");
310 }
311 println!("Pushed.");
312 }
313
314 Ok(())
315}
316
317pub async fn handle_refresh() -> Result<()> {
325 use oxicode_sdk::ModelCatalog;
326 use oxicode_sdk::ports::catalog::RefreshOutcome;
327 use oxicode_sdk::ports::fs::{CatalogConfig, FileModelCatalog};
328
329 let paths = crate::services::OxicodePaths::default_paths()?;
330 let config = CatalogConfig {
331 cache_path: paths.home.join("cache").join("models-dev.json"),
332 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
333 override_path: paths.home.join("catalog").join("overrides.toml"),
334 mtime_window: std::time::Duration::ZERO,
336 ..Default::default()
337 };
338 let cat = FileModelCatalog::init(config).await?;
341
342 println!("Refreshing model catalog from models.dev...");
343 match cat.refresh().await? {
344 RefreshOutcome::Updated {
345 provider_count,
346 model_count,
347 } => {
348 println!(
349 "✓ Catalog updated: {} providers, {} models.",
350 provider_count, model_count
351 );
352 }
353 RefreshOutcome::Unchanged => {
354 println!("✓ Catalog already up to date.");
355 }
356 RefreshOutcome::Offline { reason } => {
357 println!("⚠ Catalog refresh skipped (offline: {reason}).");
358 }
359 RefreshOutcome::Failed { reason } => {
360 println!("✗ Catalog refresh failed: {reason}.");
361 }
362 }
363 Ok(())
364}
365
366pub async fn handle_models(provider: &Option<String>) -> Result<()> {
368 use oxicode_sdk::ModelCatalog;
369
370 if let Some(ref provider_name) = *provider {
372 let settings = Settings::load().unwrap_or_default();
373 if let Some(cp) = settings
374 .custom_providers
375 .iter()
376 .find(|cp| cp.name == *provider_name)
377 {
378 let auth = crate::store::auth_storage::shared_auth_storage();
379 let api_key = auth.get_api_key(&cp.name);
380 if let Some(ref key) = api_key {
381 match oxicode_sdk::fetch_models_blocking(&cp.base_url, key) {
382 Ok(model_ids) => {
383 let api_type = match cp.api.to_lowercase().as_str() {
384 "openai-responses" | "responses" => oxicode_sdk::Api::OpenAiResponses,
385 _ => oxicode_sdk::Api::OpenAiCompletions,
386 };
387 for model_id in &model_ids {
388 let known = oxicode_sdk::find_entry_by_model_id(model_id);
392 let model = oxicode_sdk::Model {
393 id: model_id.clone(),
394 name: model_id.clone(),
395 api: api_type,
396 provider: cp.name.clone(),
397 base_url: cp.base_url.clone(),
398 reasoning: known.map(|e| e.reasoning).unwrap_or(false),
399 input: vec![oxicode_sdk::InputModality::Text],
400 cost: known
401 .map(|e| oxicode_sdk::Cost {
402 input: e.cost_input.max(0.0),
403 output: e.cost_output.max(0.0),
404 cache_read: e.cost_cache_read.max(0.0),
405 cache_write: e.cost_cache_write.max(0.0),
406 })
407 .unwrap_or_default(),
408 context_window: known
409 .map(|e| e.context_window as usize)
410 .unwrap_or(128_000),
411 max_tokens: known.map(|e| e.max_tokens as usize).unwrap_or(8_192),
412 headers: Default::default(),
413 compat: None,
414 };
415 oxicode_sdk::register_model(model);
416 }
417 if model_ids.is_empty() {
418 println!("No models found for provider '{}'.", provider_name);
419 } else {
420 println!(
421 "Models from '{}' ({} fetched):",
422 provider_name,
423 model_ids.len()
424 );
425 for id in &model_ids {
426 println!(" {}", id);
427 }
428 }
429 return Ok(());
430 }
431 Err(e) => {
432 eprintln!(
433 "[oxicode] warning: failed to resolve models for {}: {}",
434 provider_name, e
435 );
436 }
437 }
438 } else {
439 eprintln!(
440 "[oxicode] API key not set for provider '{}' (expected: {})",
441 provider_name, cp.api_key_env
442 );
443 }
444 }
445
446 let cat = build_catalog_for_cli().await?;
448 let models = cat.list_models(provider_name).await?;
449 if models.is_empty() {
450 println!(
451 "No models found for provider '{}' (static or dynamic).",
452 provider_name
453 );
454 } else {
455 println!(
456 "Models for provider '{}' ({}):",
457 provider_name,
458 models.len()
459 );
460 for m in models {
461 println!(" {} ({})", m.model_id, m.name);
462 }
463 }
464 return Ok(());
465 }
466
467 let cat = build_catalog_for_cli().await?;
469 let all = cat.search("").await?;
470 let count = cat.model_count().await?;
471 println!("Available models ({} total):", count);
472 for entry in &all {
473 println!(" {}/{} — {}", entry.provider, entry.model_id, entry.name);
474 }
475 Ok(())
476}
477
478pub(crate) async fn build_catalog_for_cli() -> Result<Arc<oxicode_sdk::FileModelCatalog>> {
484 use oxicode_sdk::ports::fs::CatalogConfig;
485 let paths = crate::services::OxicodePaths::default_paths()?;
486 let config = CatalogConfig {
487 cache_path: paths.home.join("cache").join("models-dev.json"),
488 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
489 override_path: paths.home.join("catalog").join("overrides.toml"),
490 fetch_enabled: false,
493 ..Default::default()
494 };
495 Ok(oxicode_sdk::FileModelCatalog::init(config).await?)
496}