Skip to main content

oxicode/cli/commands/
misc.rs

1//! Miscellaneous subcommand handlers: completions, install, update, commit,
2//! models, refresh, and the catalog builder used by `models` / `refresh`.
3
4use crate::store::settings::Settings;
5use anyhow::{Context, Result};
6use std::path::PathBuf;
7use std::sync::Arc;
8
9/// Handle `oxicode completions <bash|zsh|fish>` — print shell completion script.
10pub 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
30/// Handle `oxicode install <source>` — dispatch to `ext install` or `pkg install`.
31pub async fn handle_install(source: &str) -> Result<()> {
32    use crate::cli::{ExtCommands, PkgCommands};
33
34    // Local paths or npm: prefix → pkg install
35    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        // GitHub repo spec (owner/repo) → ext install
45        super::ext::handle_ext(&ExtCommands::Install {
46            source: source.to_string(),
47            prerelease: false,
48        })
49        .await?;
50    }
51    Ok(())
52}
53
54/// Handle `oxicode update [--check]` — refresh the binary and converge it
55/// into the ecosystem-standard managed layout
56/// (`~/.oxi/oxicode/{bin,versions}`).
57///
58/// The fetch channel is `cargo install oxicode-cli --force` — crates.io and
59/// `binstall` already sign and pre-build the binary. Once the new
60/// binary is in place, [`managed_install::adopt_binary`] moves it under
61/// `~/.oxi/oxicode/versions/<v>/`, flips the launcher, repoints the
62/// cargo bin copy at the launcher, and prunes older versions (keep 2).
63pub 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        // 1. Fetch through the supported distribution channel.
77        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        // 2. Adopt the freshly-installed binary into the managed layout.
90        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
113/// Print the current managed-layout status: launcher, current version,
114/// known shadow roots. Used by `oxicode update --check`.
115fn 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
188/// Other recognized `oxicode` locations (cargo bin, alternate PATH
189/// entries, legacy `~/.oxicode` if it held a binary) — for shadow
190/// diagnostics. Excludes the managed launcher.
191fn 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
219/// Handle `oxicode commit [--push] [--dry-run] [-c <context>]`.
220pub 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    // Check for staged changes
227    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    // Run CommitTool (deterministic-only in CLI mode — no agent context)
248    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    // Actual commit: run `git commit -m "<message>"`
267    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
317/// Handle `oxicode refresh` — force-refresh the model catalog from models.dev.
318///
319/// Performs a conditional GET (ETag). The refreshed cache takes effect
320/// on the next process start (the in-memory catalog is immutable).
321///
322/// Uses the catalog port (`FileModelCatalog`) directly. The `App`'s
323/// catalog would be equivalent but this command runs standalone (no App).
324pub 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        // Bypass the mtime window so we always issue a conditional GET.
335        mtime_window: std::time::Duration::ZERO,
336        ..Default::default()
337    };
338    // We don't run `init`'s optional pre-refresh — load SNAP+cache, then
339    // explicitly call refresh to issue a conditional GET.
340    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
366/// Handle `oxicode models [--provider <name>]`
367pub async fn handle_models(provider: &Option<String>) -> Result<()> {
368    use oxicode_sdk::ModelCatalog;
369
370    // If a custom provider is specified, also try to fetch models dynamically
371    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                            // Cross-fill real metadata from models.dev when
389                            // the id matches an upstream model (see
390                            // bootstrap::fetch_and_register_models).
391                            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        // Fallback: show catalog models for this provider via the port.
447        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    // No provider filter: show everything via the catalog port.
468    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
478/// Build a catalog port for `oxicode models` / `oxicode refresh` standalone commands.
479///
480/// These commands run without an `App`; we construct a fresh `FileModelCatalog`
481/// rooted at the conventional oxicode home directory. The result is a short-lived
482/// catalog used only for this one command.
483pub(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        // Don't trigger a refresh during `oxicode models`; users who want fresh
491        // data should run `oxicode refresh` first.
492        fetch_enabled: false,
493        ..Default::default()
494    };
495    Ok(oxicode_sdk::FileModelCatalog::init(config).await?)
496}