1#[cfg(windows)]
9use agentmux_common::win32::NoWindow;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16const CATALOG_JSON: &str = include_str!("../config/tool-catalog.json");
20
21#[derive(Debug, Clone, Deserialize)]
24pub struct ToolCatalog {
25 pub tools: Vec<ToolSpec>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ToolSpec {
30 pub id: String,
31 pub display: String,
32 pub description: String,
33 pub tier: u8,
34 pub version: String,
35 pub check_cmd: String,
36 #[serde(default)]
39 pub version_arg: Option<String>,
40 pub platforms: HashMap<String, PlatformSpec>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct PlatformSpec {
45 pub url: String,
46 pub sha256: String,
47 pub extract: String,
49 pub bin: String,
51 pub bin_in_archive: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
58#[ts(export, export_to = "../../../frontend/types/rpc/")]
59#[serde(rename_all = "snake_case")]
60pub enum ToolStatus {
61 InstalledSystem,
62 InstalledBundled,
63 InstalledManaged,
64 Missing,
65 Unavailable,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
69#[ts(export, export_to = "../../../frontend/types/rpc/")]
70pub struct ToolStatusEntry {
71 pub id: String,
72 pub display: String,
73 pub description: String,
74 pub tier: u8,
75 pub status: ToolStatus,
76 pub version: Option<String>,
77 pub path: Option<String>,
78}
79
80pub fn bundled_tools_dir() -> Option<PathBuf> {
88 let exe = std::env::current_exe().ok()?;
89 let exe_dir = exe.parent()?;
90
91 let exe_str = exe_dir.to_string_lossy();
93 if exe_str.contains("target/debug")
94 || exe_str.contains("target/release")
95 || exe_str.contains("target\\debug")
96 || exe_str.contains("target\\release")
97 {
98 return None;
99 }
100
101 let bundled = exe_dir.join("tools").join("bin");
102 Some(bundled)
103}
104
105pub fn user_tools_dir() -> Option<PathBuf> {
107 let home = dirs::home_dir()?;
108 Some(home.join(".agentmux").join("tools").join("bin"))
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum UserToolsPrecedence {
117 BeforeExisting,
121 AfterExisting,
124}
125
126pub fn compose_tools_path(
130 existing: &str,
131 bundled: Option<&std::path::Path>,
132 user: Option<&std::path::Path>,
133 user_precedence: UserToolsPrecedence,
134) -> Option<String> {
135 if bundled.is_none() && user.is_none() {
136 return None;
137 }
138 let mut parts: Vec<String> = Vec::new();
139 if let Some(d) = bundled {
140 parts.push(d.to_string_lossy().into_owned());
141 }
142 if let (Some(d), UserToolsPrecedence::BeforeExisting) = (user, user_precedence) {
143 parts.push(d.to_string_lossy().into_owned());
144 }
145 if !existing.is_empty() {
146 parts.push(existing.to_string());
147 }
148 if let (Some(d), UserToolsPrecedence::AfterExisting) = (user, user_precedence) {
149 parts.push(d.to_string_lossy().into_owned());
150 }
151 let sep = if cfg!(windows) { ";" } else { ":" };
152 Some(parts.join(sep))
153}
154
155pub fn tools_path(existing: &str, user_precedence: UserToolsPrecedence) -> Option<String> {
157 let bundled = bundled_tools_dir().filter(|d| d.exists());
158 let user = user_tools_dir().filter(|d| d.exists());
159 compose_tools_path(existing, bundled.as_deref(), user.as_deref(), user_precedence)
160}
161
162pub fn bashwrap_path() -> Option<PathBuf> {
168 let name = if cfg!(windows) { "agentmux-bashwrap.exe" } else { "agentmux-bashwrap" };
169 [bundled_tools_dir(), user_tools_dir()]
170 .into_iter()
171 .flatten()
172 .map(|dir| dir.join(name))
173 .find(|p| p.is_file())
174}
175
176fn downloads_dir() -> Option<PathBuf> {
178 let home = dirs::home_dir()?;
179 Some(home.join(".agentmux").join("tools").join("downloads"))
180}
181
182pub fn load_catalog() -> Result<ToolCatalog, String> {
186 serde_json::from_str(CATALOG_JSON).map_err(|e| format!("parse tool-catalog.json: {e}"))
187}
188
189pub fn current_platform() -> Result<&'static str, String> {
193 match (std::env::consts::OS, std::env::consts::ARCH) {
196 ("windows", "x86_64") => Ok("windows-x64"),
197 ("macos", "aarch64") => Ok("macos-arm64"),
198 ("macos", "x86_64") => Ok("macos-x64"),
199 ("linux", "x86_64") => Ok("linux-x64"),
200 ("linux", "aarch64") => Ok("linux-arm64"),
201 (os, arch) => Err(format!("unsupported platform: os={os} arch={arch}")),
202 }
203}
204
205fn probe_system_path(name: &str) -> bool {
209 #[cfg(windows)]
210 {
211 std::process::Command::new("where")
212 .arg(name)
213 .no_window()
214 .output()
215 .map(|o| o.status.success())
216 .unwrap_or(false)
217 }
218 #[cfg(not(windows))]
219 {
220 std::process::Command::new("which")
221 .arg(name)
222 .output()
223 .map(|o| o.status.success())
224 .unwrap_or(false)
225 }
226}
227
228fn system_path_of(name: &str) -> Option<String> {
230 #[cfg(windows)]
231 let output = {
232 std::process::Command::new("where")
233 .arg(name)
234 .no_window()
235 .output()
236 .ok()?
237 };
238 #[cfg(not(windows))]
239 let output = std::process::Command::new("which").arg(name).output().ok()?;
240
241 if output.status.success() {
242 let path = String::from_utf8_lossy(&output.stdout)
243 .lines()
244 .next()
245 .unwrap_or("")
246 .trim()
247 .to_string();
248 if !path.is_empty() {
249 return Some(path);
250 }
251 }
252 None
253}
254
255pub(crate) fn probe_version(cmd: &str, version_arg: &Option<String>) -> Option<String> {
259 let arg = version_arg.as_deref()?;
260 let mut command = std::process::Command::new(cmd);
261 crate::backend::pane_env::sanitize_external_std_command(&mut command);
265 command.arg(arg);
266 #[cfg(windows)]
267 {
268 command.no_window();
269 }
270 let output = command.output().ok()?;
271 let text = if !output.stdout.is_empty() {
273 String::from_utf8_lossy(&output.stdout).into_owned()
274 } else {
275 String::from_utf8_lossy(&output.stderr).into_owned()
276 };
277 for word in text.split_whitespace() {
279 let candidate = word.trim_matches(|c: char| !c.is_ascii_digit() && c != '.');
280 if candidate.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
281 && candidate.contains('.')
282 {
283 return Some(candidate.to_string());
284 }
285 }
286 None
287}
288
289pub fn get_tool_statuses() -> Vec<ToolStatusEntry> {
295 let catalog = match load_catalog() {
296 Ok(c) => c,
297 Err(e) => {
298 tracing::error!(error = %e, "failed to load tool catalog");
299 return Vec::new();
300 }
301 };
302
303 let platform = current_platform().ok();
304
305 catalog
306 .tools
307 .iter()
308 .map(|spec| {
309 let platform_key = match &platform {
311 Some(k) => *k,
312 None => {
313 return ToolStatusEntry {
314 id: spec.id.clone(),
315 display: spec.display.clone(),
316 description: spec.description.clone(),
317 tier: spec.tier,
318 status: ToolStatus::Unavailable,
319 version: None,
320 path: None,
321 };
322 }
323 };
324
325 if !spec.platforms.contains_key(platform_key) {
326 return ToolStatusEntry {
327 id: spec.id.clone(),
328 display: spec.display.clone(),
329 description: spec.description.clone(),
330 tier: spec.tier,
331 status: ToolStatus::Unavailable,
332 version: None,
333 path: None,
334 };
335 }
336
337 let bin_name = &spec.platforms[platform_key].bin;
338
339 if probe_system_path(&spec.check_cmd) {
341 let path = system_path_of(&spec.check_cmd);
342 let version = probe_version(&spec.check_cmd, &spec.version_arg);
344 return ToolStatusEntry {
345 id: spec.id.clone(),
346 display: spec.display.clone(),
347 description: spec.description.clone(),
348 tier: spec.tier,
349 status: ToolStatus::InstalledSystem,
350 version,
351 path,
352 };
353 }
354
355 if let Some(bundled) = bundled_tools_dir() {
357 let p = bundled.join(bin_name);
358 if p.exists() {
359 return ToolStatusEntry {
360 id: spec.id.clone(),
361 display: spec.display.clone(),
362 description: spec.description.clone(),
363 tier: spec.tier,
364 status: ToolStatus::InstalledBundled,
365 version: Some(spec.version.clone()),
366 path: Some(p.to_string_lossy().into_owned()),
367 };
368 }
369 }
370
371 if let Some(user_bin) = user_tools_dir() {
373 let p = user_bin.join(bin_name);
374 if p.exists() {
375 return ToolStatusEntry {
376 id: spec.id.clone(),
377 display: spec.display.clone(),
378 description: spec.description.clone(),
379 tier: spec.tier,
380 status: ToolStatus::InstalledManaged,
381 version: Some(spec.version.clone()),
382 path: Some(p.to_string_lossy().into_owned()),
383 };
384 }
385 }
386
387 ToolStatusEntry {
389 id: spec.id.clone(),
390 display: spec.display.clone(),
391 description: spec.description.clone(),
392 tier: spec.tier,
393 status: ToolStatus::Missing,
394 version: None,
395 path: None,
396 }
397 })
398 .collect()
399}
400
401pub async fn install_tool(id: &str, http_client: &reqwest::Client) -> Result<String, String> {
408 let catalog = load_catalog()?;
409 let spec = catalog
410 .tools
411 .iter()
412 .find(|t| t.id == id)
413 .ok_or_else(|| format!("tool '{id}' not found in catalog"))?;
414
415 let platform_key = current_platform()?;
416 let platform_spec = spec
417 .platforms
418 .get(platform_key)
419 .ok_or_else(|| format!("tool '{id}' has no entry for platform '{platform_key}'"))?;
420
421 let dl_dir = downloads_dir().ok_or("cannot determine downloads dir")?;
423 let bin_dir = user_tools_dir().ok_or("cannot determine user tools dir")?;
424 std::fs::create_dir_all(&dl_dir)
425 .map_err(|e| format!("create downloads dir: {e}"))?;
426 std::fs::create_dir_all(&bin_dir)
427 .map_err(|e| format!("create bin dir: {e}"))?;
428
429 let dl_path = dl_dir.join(format!("{id}-download"));
431 tracing::info!(tool = %id, url = %platform_spec.url, dest = %dl_path.display(), "downloading tool");
432
433 {
434 let bytes = http_client
435 .get(&platform_spec.url)
436 .send()
437 .await
438 .map_err(|e| format!("download '{id}': {e}"))?
439 .error_for_status()
440 .map_err(|e| format!("download '{id}' HTTP error: {e}"))?
441 .bytes()
442 .await
443 .map_err(|e| format!("read download body '{id}': {e}"))?;
444
445 std::fs::write(&dl_path, &bytes)
446 .map_err(|e| format!("write download file: {e}"))?;
447 }
448
449 {
451 let data = std::fs::read(&dl_path)
452 .map_err(|e| format!("read downloaded file for hash: {e}"))?;
453 let mut hasher = Sha256::new();
454 hasher.update(&data);
455 let digest = hex::encode(hasher.finalize());
456 if digest != platform_spec.sha256 {
457 let _ = std::fs::remove_file(&dl_path);
458 return Err(format!(
459 "SHA-256 mismatch for '{id}': expected={} got={digest}",
460 platform_spec.sha256,
461 ));
462 }
463 tracing::debug!(tool = %id, "SHA-256 verified");
464 }
465
466 let dest_path = bin_dir.join(&platform_spec.bin);
468
469 match platform_spec.extract.as_str() {
470 "none" => {
471 std::fs::copy(&dl_path, &dest_path)
472 .map_err(|e| format!("copy '{id}' to bin: {e}"))?;
473 }
474 "zip" => {
475 let archive_in = platform_spec
476 .bin_in_archive
477 .as_deref()
478 .ok_or_else(|| format!("tool '{id}': extract=zip but bin_in_archive is missing"))?;
479
480 let zip_data = std::fs::read(&dl_path)
481 .map_err(|e| format!("read zip file: {e}"))?;
482 let cursor = std::io::Cursor::new(zip_data);
483 let mut archive = zip::ZipArchive::new(cursor)
484 .map_err(|e| format!("open zip archive: {e}"))?;
485
486 let mut entry = archive
487 .by_name(archive_in)
488 .map_err(|e| format!("zip entry '{archive_in}': {e}"))?;
489
490 let mut out = std::fs::File::create(&dest_path)
491 .map_err(|e| format!("create dest file: {e}"))?;
492 std::io::copy(&mut entry, &mut out)
493 .map_err(|e| format!("extract zip entry: {e}"))?;
494 }
495 "tar_gz" => {
496 let archive_in = platform_spec
497 .bin_in_archive
498 .as_deref()
499 .ok_or_else(|| format!("tool '{id}': extract=tar_gz but bin_in_archive is missing"))?;
500
501 let gz_file = std::fs::File::open(&dl_path)
502 .map_err(|e| format!("open tar.gz: {e}"))?;
503 let gz_decoder = flate2::read::GzDecoder::new(gz_file);
504 let mut archive = tar::Archive::new(gz_decoder);
505
506 let mut found = false;
507 for entry in archive.entries().map_err(|e| format!("tar entries: {e}"))? {
508 let mut entry = entry.map_err(|e| format!("tar entry: {e}"))?;
509 let path = entry.path().map_err(|e| format!("tar entry path: {e}"))?;
510 if path.to_string_lossy() == archive_in {
511 entry
512 .unpack(&dest_path)
513 .map_err(|e| format!("unpack tar entry: {e}"))?;
514 found = true;
515 break;
516 }
517 }
518 if !found {
519 return Err(format!(
520 "tar archive does not contain expected entry '{archive_in}'"
521 ));
522 }
523 }
524 other => {
525 return Err(format!("unknown extract mode '{other}' for tool '{id}'"));
526 }
527 }
528
529 #[cfg(unix)]
531 {
532 use std::os::unix::fs::PermissionsExt as _;
533 let mut perms = std::fs::metadata(&dest_path)
534 .map_err(|e| format!("stat installed binary: {e}"))?
535 .permissions();
536 let mode = perms.mode();
537 perms.set_mode(mode | 0o111);
538 std::fs::set_permissions(&dest_path, perms)
539 .map_err(|e| format!("set executable bit: {e}"))?;
540 }
541
542 let _ = std::fs::remove_file(&dl_path);
544
545 let dest_str = dest_path.to_string_lossy().into_owned();
546 tracing::info!(tool = %id, path = %dest_str, "tool installed successfully");
547 Ok(dest_str)
548}
549
550#[cfg(test)]
551mod tool_status_wire_tests {
552 use super::*;
553 use serde_json::json;
554
555 #[test]
561 fn a_tool_entry_always_carries_version_and_path() {
562 let v = serde_json::to_value(ToolStatusEntry {
563 id: "rg".to_string(),
564 display: "ripgrep".to_string(),
565 description: "search".to_string(),
566 tier: 1,
567 status: ToolStatus::Missing,
568 version: None,
569 path: None,
570 })
571 .expect("serializable");
572 let obj = v.as_object().expect("object");
573 assert!(obj.contains_key("version") && obj.contains_key("path"));
574 assert_eq!(v["version"], json!(null));
575 assert_eq!(v["path"], json!(null));
576 }
577
578 #[test]
582 fn tool_status_serializes_to_the_expected_snake_case_tags() {
583 for (variant, tag) in [
584 (ToolStatus::InstalledSystem, "installed_system"),
585 (ToolStatus::InstalledBundled, "installed_bundled"),
586 (ToolStatus::InstalledManaged, "installed_managed"),
587 (ToolStatus::Missing, "missing"),
588 (ToolStatus::Unavailable, "unavailable"),
589 ] {
590 assert_eq!(serde_json::to_value(variant).unwrap(), json!(tag));
591 }
592 }
593}
594
595#[cfg(test)]
596mod tools_path_tests {
597 use super::{compose_tools_path, UserToolsPrecedence};
598 use std::path::Path;
599
600 const SEP: &str = if cfg!(windows) { ";" } else { ":" };
601
602 fn join(parts: &[&str]) -> String {
603 parts.join(SEP)
604 }
605
606 #[test]
607 fn bundled_always_leads_and_user_follows_the_requested_precedence() {
608 let (b, u) = (Path::new("B"), Path::new("U"));
609 assert_eq!(
610 compose_tools_path("SYS", Some(b), Some(u), UserToolsPrecedence::BeforeExisting),
611 Some(join(&["B", "U", "SYS"]))
612 );
613 assert_eq!(
614 compose_tools_path("SYS", Some(b), Some(u), UserToolsPrecedence::AfterExisting),
615 Some(join(&["B", "SYS", "U"]))
616 );
617 }
618
619 #[test]
620 fn no_tool_dirs_leaves_path_untouched() {
621 assert_eq!(compose_tools_path("SYS", None, None, UserToolsPrecedence::BeforeExisting), None);
622 }
623
624 #[test]
625 fn an_empty_inherited_path_adds_no_empty_entry() {
626 assert_eq!(
628 compose_tools_path("", None, Some(Path::new("U")), UserToolsPrecedence::BeforeExisting),
629 Some("U".to_string())
630 );
631 assert_eq!(
632 compose_tools_path("", Some(Path::new("B")), None, UserToolsPrecedence::AfterExisting),
633 Some("B".to_string())
634 );
635 }
636}