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::sync::Arc;
7
8/// Handle `oxicode completions <bash|zsh|fish>` — print shell completion script.
9pub fn handle_completions(shell: &str) -> Result<()> {
10    use clap::CommandFactory;
11
12    let shell = match shell {
13        "bash" => clap_complete::Shell::Bash,
14        "zsh" => clap_complete::Shell::Zsh,
15        "fish" => clap_complete::Shell::Fish,
16        "elvish" => clap_complete::Shell::Elvish,
17        "powershell" => clap_complete::Shell::PowerShell,
18        _ => {
19            anyhow::bail!("Unknown shell: {shell}. Supported: bash, zsh, fish, elvish, powershell")
20        }
21    };
22
23    let mut cmd = crate::cli::CliArgs::command();
24    let name = cmd.get_name().to_string();
25    clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
26    Ok(())
27}
28
29/// Handle `oxicode install <source>` — dispatch to `ext install` or `pkg install`.
30pub async fn handle_install(source: &str) -> Result<()> {
31    use crate::cli::{ExtCommands, PkgCommands};
32
33    // Local paths or npm: prefix → pkg install
34    if source.starts_with('.')
35        || source.starts_with('/')
36        || source.starts_with('~')
37        || source.starts_with("npm:")
38    {
39        super::pkg::handle_pkg(&PkgCommands::Install {
40            source: source.to_string(),
41        })?;
42    } else {
43        // GitHub repo spec (owner/repo) → ext install
44        super::ext::handle_ext(&ExtCommands::Install {
45            source: source.to_string(),
46            prerelease: false,
47        })
48        .await?;
49    }
50    Ok(())
51}
52
53/// Handle `oxicode update [--check]` — check for and install updates.
54pub async fn handle_update(check: bool) -> Result<()> {
55    #[cfg(feature = "self-update")]
56    {
57        use self_update::cargo_crate_version;
58
59        let current = cargo_crate_version!();
60        println!("Current version: v{current}");
61
62        if check {
63            return Ok(());
64        }
65
66        // Use cargo install via `cargo install oxicode-cli --force`
67        println!("Updating oxicode...");
68        let status = tokio::process::Command::new("cargo")
69            .args(["install", "oxicode-cli", "--force"])
70            .stdout(std::process::Stdio::inherit())
71            .stderr(std::process::Stdio::inherit())
72            .status()
73            .await?;
74
75        if !status.success() {
76            anyhow::bail!("Update failed: cargo install exited with {status}");
77        }
78
79        println!("✅ oxicode updated successfully. Restart to use the new version.");
80        Ok(())
81    }
82
83    #[cfg(not(feature = "self-update"))]
84    {
85        let _ = check;
86        anyhow::bail!("Self-update is not available (compiled without `self-update` feature)");
87    }
88}
89
90/// Handle `oxicode commit [--push] [--dry-run] [-c <context>]`.
91pub async fn handle_commit(push: bool, dry_run: bool, context: Option<&str>) -> Result<()> {
92    use oxicode_agent::AgentTool;
93    use oxicode_agent::tools::ToolContext;
94    use oxicode_agent::tools::commit::CommitTool;
95    use serde_json::json;
96
97    // Check for staged changes
98    let diff_output = tokio::process::Command::new("git")
99        .args(["diff", "--cached"])
100        .output()
101        .await
102        .with_context(|| "Failed to run git diff --cached")?;
103
104    if diff_output.stdout.is_empty() && diff_output.stderr.is_empty() {
105        let has_changes = tokio::process::Command::new("git")
106            .args(["status", "--porcelain"])
107            .output()
108            .await
109            .with_context(|| "Failed to run git status")?;
110        if has_changes.stdout.is_empty() {
111            anyhow::bail!("Nothing to commit. Working tree is clean.");
112        }
113        anyhow::bail!(
114            "No staged changes. Use `git add` to stage files, or include unstaged changes with `git commit -a`."
115        );
116    }
117
118    // Run CommitTool (deterministic-only in CLI mode — no agent context)
119    let cwd = std::env::current_dir().context("Failed to get current directory")?;
120    let ctx = ToolContext::new(cwd.clone());
121    let tool = CommitTool::unconfigured();
122    let params = json!({
123        "dry_run": dry_run,
124        "push": push,
125        "context": context.unwrap_or(""),
126    });
127    let result = tool
128        .execute("cli", params, None, &ctx)
129        .await
130        .map_err(|e| anyhow::anyhow!("Commit tool failed: {e}"))?;
131
132    if dry_run {
133        println!("{}", result.output);
134        return Ok(());
135    }
136
137    // Actual commit: run `git commit -m "<message>"`
138    let message = result
139        .output
140        .lines()
141        .find(|l| {
142            l.starts_with("feat")
143                || l.starts_with("fix")
144                || l.starts_with("chore")
145                || l.starts_with("docs")
146                || l.starts_with("refactor")
147                || l.starts_with("test")
148                || l.starts_with("perf")
149                || l.starts_with("build")
150                || l.starts_with("ci")
151                || l.starts_with("style")
152                || l.starts_with("revert")
153        })
154        .unwrap_or("feat: commit")
155        .to_string();
156
157    let status = tokio::process::Command::new("git")
158        .args(["commit", "-m", &message])
159        .stdout(std::process::Stdio::inherit())
160        .stderr(std::process::Stdio::inherit())
161        .status()
162        .await
163        .with_context(|| "Failed to run git commit")?;
164
165    if !status.success() {
166        anyhow::bail!("Commit failed.\nProposed message was:\n{message}");
167    }
168
169    println!("Committed: {message}");
170
171    if push {
172        let push_status = tokio::process::Command::new("git")
173            .args(["push"])
174            .stdout(std::process::Stdio::inherit())
175            .stderr(std::process::Stdio::inherit())
176            .status()
177            .await
178            .with_context(|| "Failed to run git push")?;
179        if !push_status.success() {
180            anyhow::bail!("Commit succeeded but push failed.");
181        }
182        println!("Pushed.");
183    }
184
185    Ok(())
186}
187
188/// Handle `oxicode refresh` — force-refresh the model catalog from models.dev.
189///
190/// Performs a conditional GET (ETag). The refreshed cache takes effect
191/// on the next process start (the in-memory catalog is immutable).
192///
193/// Uses the catalog port (`FileModelCatalog`) directly. The `App`'s
194/// catalog would be equivalent but this command runs standalone (no App).
195pub async fn handle_refresh() -> Result<()> {
196    use oxicode_sdk::ModelCatalog;
197    use oxicode_sdk::ports::catalog::RefreshOutcome;
198    use oxicode_sdk::ports::fs::{CatalogConfig, FileModelCatalog};
199
200    let paths = crate::services::OxicodePaths::default_paths()?;
201    let config = CatalogConfig {
202        cache_path: paths.home.join("cache").join("models-dev.json"),
203        etag_path: paths.home.join("cache").join("models-dev.json.etag"),
204        override_path: paths.home.join("catalog").join("overrides.toml"),
205        // Bypass the mtime window so we always issue a conditional GET.
206        mtime_window: std::time::Duration::ZERO,
207        ..Default::default()
208    };
209    // We don't run `init`'s optional pre-refresh — load SNAP+cache, then
210    // explicitly call refresh to issue a conditional GET.
211    let cat = FileModelCatalog::init(config).await?;
212
213    println!("Refreshing model catalog from models.dev...");
214    match cat.refresh().await? {
215        RefreshOutcome::Updated {
216            provider_count,
217            model_count,
218        } => {
219            println!(
220                "✓ Catalog updated: {} providers, {} models.",
221                provider_count, model_count
222            );
223        }
224        RefreshOutcome::Unchanged => {
225            println!("✓ Catalog already up to date.");
226        }
227        RefreshOutcome::Offline { reason } => {
228            println!("⚠ Catalog refresh skipped (offline: {reason}).");
229        }
230        RefreshOutcome::Failed { reason } => {
231            println!("✗ Catalog refresh failed: {reason}.");
232        }
233    }
234    Ok(())
235}
236
237/// Handle `oxicode models [--provider <name>]`
238pub async fn handle_models(provider: &Option<String>) -> Result<()> {
239    use oxicode_sdk::ModelCatalog;
240
241    // If a custom provider is specified, also try to fetch models dynamically
242    if let Some(ref provider_name) = *provider {
243        let settings = Settings::load().unwrap_or_default();
244        if let Some(cp) = settings
245            .custom_providers
246            .iter()
247            .find(|cp| cp.name == *provider_name)
248        {
249            let auth = crate::store::auth_storage::shared_auth_storage();
250            let api_key = auth.get_api_key(&cp.name);
251            if let Some(ref key) = api_key {
252                match oxicode_sdk::fetch_models_blocking(&cp.base_url, key) {
253                    Ok(model_ids) => {
254                        let api_type = match cp.api.to_lowercase().as_str() {
255                            "openai-responses" | "responses" => oxicode_sdk::Api::OpenAiResponses,
256                            _ => oxicode_sdk::Api::OpenAiCompletions,
257                        };
258                        for model_id in &model_ids {
259                            // Cross-fill real metadata from models.dev when
260                            // the id matches an upstream model (see
261                            // bootstrap::fetch_and_register_models).
262                            let known = oxicode_sdk::find_entry_by_model_id(model_id);
263                            let model = oxicode_sdk::Model {
264                                id: model_id.clone(),
265                                name: model_id.clone(),
266                                api: api_type,
267                                provider: cp.name.clone(),
268                                base_url: cp.base_url.clone(),
269                                reasoning: known.map(|e| e.reasoning).unwrap_or(false),
270                                input: vec![oxicode_sdk::InputModality::Text],
271                                cost: known
272                                    .map(|e| oxicode_sdk::Cost {
273                                        input: e.cost_input.max(0.0),
274                                        output: e.cost_output.max(0.0),
275                                        cache_read: e.cost_cache_read.max(0.0),
276                                        cache_write: e.cost_cache_write.max(0.0),
277                                    })
278                                    .unwrap_or_default(),
279                                context_window: known
280                                    .map(|e| e.context_window as usize)
281                                    .unwrap_or(128_000),
282                                max_tokens: known.map(|e| e.max_tokens as usize).unwrap_or(8_192),
283                                headers: Default::default(),
284                                compat: None,
285                            };
286                            oxicode_sdk::register_model(model);
287                        }
288                        if model_ids.is_empty() {
289                            println!("No models found for provider '{}'.", provider_name);
290                        } else {
291                            println!(
292                                "Models from '{}' ({} fetched):",
293                                provider_name,
294                                model_ids.len()
295                            );
296                            for id in &model_ids {
297                                println!("  {}", id);
298                            }
299                        }
300                        return Ok(());
301                    }
302                    Err(e) => {
303                        eprintln!(
304                            "[oxicode] warning: failed to resolve models for {}: {}",
305                            provider_name, e
306                        );
307                    }
308                }
309            } else {
310                eprintln!(
311                    "[oxicode] API key not set for provider '{}' (expected: {})",
312                    provider_name, cp.api_key_env
313                );
314            }
315        }
316
317        // Fallback: show catalog models for this provider via the port.
318        let cat = build_catalog_for_cli().await?;
319        let models = cat.list_models(provider_name).await?;
320        if models.is_empty() {
321            println!(
322                "No models found for provider '{}' (static or dynamic).",
323                provider_name
324            );
325        } else {
326            println!(
327                "Models for provider '{}' ({}):",
328                provider_name,
329                models.len()
330            );
331            for m in models {
332                println!("  {} ({})", m.model_id, m.name);
333            }
334        }
335        return Ok(());
336    }
337
338    // No provider filter: show everything via the catalog port.
339    let cat = build_catalog_for_cli().await?;
340    let all = cat.search("").await?;
341    let count = cat.model_count().await?;
342    println!("Available models ({} total):", count);
343    for entry in &all {
344        println!("  {}/{} — {}", entry.provider, entry.model_id, entry.name);
345    }
346    Ok(())
347}
348
349/// Build a catalog port for `oxicode models` / `oxicode refresh` standalone commands.
350///
351/// These commands run without an `App`; we construct a fresh `FileModelCatalog`
352/// rooted at the conventional oxicode home directory. The result is a short-lived
353/// catalog used only for this one command.
354pub(crate) async fn build_catalog_for_cli() -> Result<Arc<oxicode_sdk::FileModelCatalog>> {
355    use oxicode_sdk::ports::fs::CatalogConfig;
356    let paths = crate::services::OxicodePaths::default_paths()?;
357    let config = CatalogConfig {
358        cache_path: paths.home.join("cache").join("models-dev.json"),
359        etag_path: paths.home.join("cache").join("models-dev.json.etag"),
360        override_path: paths.home.join("catalog").join("overrides.toml"),
361        // Don't trigger a refresh during `oxicode models`; users who want fresh
362        // data should run `oxicode refresh` first.
363        fetch_enabled: false,
364        ..Default::default()
365    };
366    Ok(oxicode_sdk::FileModelCatalog::init(config).await?)
367}