oxicode/cli/commands/
misc.rs1use crate::store::settings::Settings;
5use anyhow::{Context, Result};
6use std::sync::Arc;
7
8pub 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
29pub async fn handle_install(source: &str) -> Result<()> {
31 use crate::cli::{ExtCommands, PkgCommands};
32
33 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 super::ext::handle_ext(&ExtCommands::Install {
45 source: source.to_string(),
46 prerelease: false,
47 })
48 .await?;
49 }
50 Ok(())
51}
52
53pub 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 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
90pub 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 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 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 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
188pub 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 mtime_window: std::time::Duration::ZERO,
207 ..Default::default()
208 };
209 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
237pub async fn handle_models(provider: &Option<String>) -> Result<()> {
239 use oxicode_sdk::ModelCatalog;
240
241 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 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 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 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
349pub(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 fetch_enabled: false,
364 ..Default::default()
365 };
366 Ok(oxicode_sdk::FileModelCatalog::init(config).await?)
367}