1use std::collections::HashMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14const CATALOG_JSON: &str = include_str!("../config/tool-catalog.json");
18
19#[derive(Debug, Clone, Deserialize)]
22pub struct ToolCatalog {
23 pub tools: Vec<ToolSpec>,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub struct ToolSpec {
28 pub id: String,
29 pub display: String,
30 pub description: String,
31 pub tier: u8,
32 pub version: String,
33 pub check_cmd: String,
34 #[serde(default)]
37 pub version_arg: Option<String>,
38 pub bundled: bool,
39 pub platforms: HashMap<String, PlatformSpec>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct PlatformSpec {
44 pub url: String,
45 pub sha256: String,
46 pub extract: String,
48 pub bin: String,
50 pub bin_in_archive: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[serde(rename_all = "snake_case")]
58pub enum ToolStatus {
59 InstalledSystem,
60 InstalledBundled,
61 InstalledManaged,
62 Missing,
63 Unavailable,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ToolStatusEntry {
68 pub id: String,
69 pub display: String,
70 pub description: String,
71 pub tier: u8,
72 pub status: ToolStatus,
73 pub version: Option<String>,
74 pub path: Option<String>,
75}
76
77pub fn bundled_tools_dir() -> Option<PathBuf> {
85 let exe = std::env::current_exe().ok()?;
86 let exe_dir = exe.parent()?;
87
88 let exe_str = exe_dir.to_string_lossy();
90 if exe_str.contains("target/debug")
91 || exe_str.contains("target/release")
92 || exe_str.contains("target\\debug")
93 || exe_str.contains("target\\release")
94 {
95 return None;
96 }
97
98 let bundled = exe_dir.join("tools").join("bin");
99 Some(bundled)
100}
101
102pub fn user_tools_dir() -> Option<PathBuf> {
104 let home = dirs::home_dir()?;
105 Some(home.join(".agentmux").join("tools").join("bin"))
106}
107
108fn downloads_dir() -> Option<PathBuf> {
110 let home = dirs::home_dir()?;
111 Some(home.join(".agentmux").join("tools").join("downloads"))
112}
113
114pub fn load_catalog() -> Result<ToolCatalog, String> {
118 serde_json::from_str(CATALOG_JSON).map_err(|e| format!("parse tool-catalog.json: {e}"))
119}
120
121pub fn current_platform() -> Result<&'static str, String> {
125 match (std::env::consts::OS, std::env::consts::ARCH) {
128 ("windows", "x86_64") => Ok("windows-x64"),
129 ("macos", "aarch64") => Ok("macos-arm64"),
130 ("macos", "x86_64") => Ok("macos-x64"),
131 ("linux", "x86_64") => Ok("linux-x64"),
132 ("linux", "aarch64") => Ok("linux-arm64"),
133 (os, arch) => Err(format!("unsupported platform: os={os} arch={arch}")),
134 }
135}
136
137fn probe_system_path(name: &str) -> bool {
141 #[cfg(windows)]
142 {
143 use std::os::windows::process::CommandExt;
144 std::process::Command::new("where")
145 .arg(name)
146 .creation_flags(0x08000000)
147 .output()
148 .map(|o| o.status.success())
149 .unwrap_or(false)
150 }
151 #[cfg(not(windows))]
152 {
153 std::process::Command::new("which")
154 .arg(name)
155 .output()
156 .map(|o| o.status.success())
157 .unwrap_or(false)
158 }
159}
160
161fn system_path_of(name: &str) -> Option<String> {
163 #[cfg(windows)]
164 let output = {
165 use std::os::windows::process::CommandExt;
166 std::process::Command::new("where")
167 .arg(name)
168 .creation_flags(0x08000000)
169 .output()
170 .ok()?
171 };
172 #[cfg(not(windows))]
173 let output = std::process::Command::new("which").arg(name).output().ok()?;
174
175 if output.status.success() {
176 let path = String::from_utf8_lossy(&output.stdout)
177 .lines()
178 .next()
179 .unwrap_or("")
180 .trim()
181 .to_string();
182 if !path.is_empty() {
183 return Some(path);
184 }
185 }
186 None
187}
188
189fn probe_version(cmd: &str, version_arg: &Option<String>) -> Option<String> {
193 let arg = version_arg.as_deref()?;
194 let mut command = std::process::Command::new(cmd);
195 command.arg(arg);
196 #[cfg(windows)]
197 {
198 use std::os::windows::process::CommandExt;
199 command.creation_flags(0x08000000);
200 }
201 let output = command.output().ok()?;
202 let text = if !output.stdout.is_empty() {
204 String::from_utf8_lossy(&output.stdout).into_owned()
205 } else {
206 String::from_utf8_lossy(&output.stderr).into_owned()
207 };
208 for word in text.split_whitespace() {
210 let candidate = word.trim_matches(|c: char| !c.is_ascii_digit() && c != '.');
211 if candidate.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
212 && candidate.contains('.')
213 {
214 return Some(candidate.to_string());
215 }
216 }
217 None
218}
219
220pub fn get_tool_statuses() -> Vec<ToolStatusEntry> {
226 let catalog = match load_catalog() {
227 Ok(c) => c,
228 Err(e) => {
229 tracing::error!(error = %e, "failed to load tool catalog");
230 return Vec::new();
231 }
232 };
233
234 let platform = current_platform().ok();
235
236 catalog
237 .tools
238 .iter()
239 .map(|spec| {
240 let platform_key = match &platform {
242 Some(k) => *k,
243 None => {
244 return ToolStatusEntry {
245 id: spec.id.clone(),
246 display: spec.display.clone(),
247 description: spec.description.clone(),
248 tier: spec.tier,
249 status: ToolStatus::Unavailable,
250 version: None,
251 path: None,
252 };
253 }
254 };
255
256 if !spec.platforms.contains_key(platform_key) {
257 return ToolStatusEntry {
258 id: spec.id.clone(),
259 display: spec.display.clone(),
260 description: spec.description.clone(),
261 tier: spec.tier,
262 status: ToolStatus::Unavailable,
263 version: None,
264 path: None,
265 };
266 }
267
268 let bin_name = &spec.platforms[platform_key].bin;
269
270 if probe_system_path(&spec.check_cmd) {
272 let path = system_path_of(&spec.check_cmd);
273 let version = probe_version(&spec.check_cmd, &spec.version_arg);
275 return ToolStatusEntry {
276 id: spec.id.clone(),
277 display: spec.display.clone(),
278 description: spec.description.clone(),
279 tier: spec.tier,
280 status: ToolStatus::InstalledSystem,
281 version,
282 path,
283 };
284 }
285
286 if let Some(bundled) = bundled_tools_dir() {
288 let p = bundled.join(bin_name);
289 if p.exists() {
290 return ToolStatusEntry {
291 id: spec.id.clone(),
292 display: spec.display.clone(),
293 description: spec.description.clone(),
294 tier: spec.tier,
295 status: ToolStatus::InstalledBundled,
296 version: Some(spec.version.clone()),
297 path: Some(p.to_string_lossy().into_owned()),
298 };
299 }
300 }
301
302 if let Some(user_bin) = user_tools_dir() {
304 let p = user_bin.join(bin_name);
305 if p.exists() {
306 return ToolStatusEntry {
307 id: spec.id.clone(),
308 display: spec.display.clone(),
309 description: spec.description.clone(),
310 tier: spec.tier,
311 status: ToolStatus::InstalledManaged,
312 version: Some(spec.version.clone()),
313 path: Some(p.to_string_lossy().into_owned()),
314 };
315 }
316 }
317
318 ToolStatusEntry {
320 id: spec.id.clone(),
321 display: spec.display.clone(),
322 description: spec.description.clone(),
323 tier: spec.tier,
324 status: ToolStatus::Missing,
325 version: None,
326 path: None,
327 }
328 })
329 .collect()
330}
331
332pub async fn install_tool(id: &str, http_client: &reqwest::Client) -> Result<String, String> {
339 let catalog = load_catalog()?;
340 let spec = catalog
341 .tools
342 .iter()
343 .find(|t| t.id == id)
344 .ok_or_else(|| format!("tool '{id}' not found in catalog"))?;
345
346 let platform_key = current_platform()?;
347 let platform_spec = spec
348 .platforms
349 .get(platform_key)
350 .ok_or_else(|| format!("tool '{id}' has no entry for platform '{platform_key}'"))?;
351
352 let dl_dir = downloads_dir().ok_or("cannot determine downloads dir")?;
354 let bin_dir = user_tools_dir().ok_or("cannot determine user tools dir")?;
355 std::fs::create_dir_all(&dl_dir)
356 .map_err(|e| format!("create downloads dir: {e}"))?;
357 std::fs::create_dir_all(&bin_dir)
358 .map_err(|e| format!("create bin dir: {e}"))?;
359
360 let dl_path = dl_dir.join(format!("{id}-download"));
362 tracing::info!(tool = %id, url = %platform_spec.url, dest = %dl_path.display(), "downloading tool");
363
364 {
365 let bytes = http_client
366 .get(&platform_spec.url)
367 .send()
368 .await
369 .map_err(|e| format!("download '{id}': {e}"))?
370 .error_for_status()
371 .map_err(|e| format!("download '{id}' HTTP error: {e}"))?
372 .bytes()
373 .await
374 .map_err(|e| format!("read download body '{id}': {e}"))?;
375
376 std::fs::write(&dl_path, &bytes)
377 .map_err(|e| format!("write download file: {e}"))?;
378 }
379
380 {
382 let data = std::fs::read(&dl_path)
383 .map_err(|e| format!("read downloaded file for hash: {e}"))?;
384 let mut hasher = Sha256::new();
385 hasher.update(&data);
386 let digest = hex::encode(hasher.finalize());
387 if digest != platform_spec.sha256 {
388 let _ = std::fs::remove_file(&dl_path);
389 return Err(format!(
390 "SHA-256 mismatch for '{id}': expected={} got={digest}",
391 platform_spec.sha256,
392 ));
393 }
394 tracing::debug!(tool = %id, "SHA-256 verified");
395 }
396
397 let dest_path = bin_dir.join(&platform_spec.bin);
399
400 match platform_spec.extract.as_str() {
401 "none" => {
402 std::fs::copy(&dl_path, &dest_path)
403 .map_err(|e| format!("copy '{id}' to bin: {e}"))?;
404 }
405 "zip" => {
406 let archive_in = platform_spec
407 .bin_in_archive
408 .as_deref()
409 .ok_or_else(|| format!("tool '{id}': extract=zip but bin_in_archive is missing"))?;
410
411 let zip_data = std::fs::read(&dl_path)
412 .map_err(|e| format!("read zip file: {e}"))?;
413 let cursor = std::io::Cursor::new(zip_data);
414 let mut archive = zip::ZipArchive::new(cursor)
415 .map_err(|e| format!("open zip archive: {e}"))?;
416
417 let mut entry = archive
418 .by_name(archive_in)
419 .map_err(|e| format!("zip entry '{archive_in}': {e}"))?;
420
421 let mut out = std::fs::File::create(&dest_path)
422 .map_err(|e| format!("create dest file: {e}"))?;
423 std::io::copy(&mut entry, &mut out)
424 .map_err(|e| format!("extract zip entry: {e}"))?;
425 }
426 "tar_gz" => {
427 let archive_in = platform_spec
428 .bin_in_archive
429 .as_deref()
430 .ok_or_else(|| format!("tool '{id}': extract=tar_gz but bin_in_archive is missing"))?;
431
432 let gz_file = std::fs::File::open(&dl_path)
433 .map_err(|e| format!("open tar.gz: {e}"))?;
434 let gz_decoder = flate2::read::GzDecoder::new(gz_file);
435 let mut archive = tar::Archive::new(gz_decoder);
436
437 let mut found = false;
438 for entry in archive.entries().map_err(|e| format!("tar entries: {e}"))? {
439 let mut entry = entry.map_err(|e| format!("tar entry: {e}"))?;
440 let path = entry.path().map_err(|e| format!("tar entry path: {e}"))?;
441 if path.to_string_lossy() == archive_in {
442 entry
443 .unpack(&dest_path)
444 .map_err(|e| format!("unpack tar entry: {e}"))?;
445 found = true;
446 break;
447 }
448 }
449 if !found {
450 return Err(format!(
451 "tar archive does not contain expected entry '{archive_in}'"
452 ));
453 }
454 }
455 other => {
456 return Err(format!("unknown extract mode '{other}' for tool '{id}'"));
457 }
458 }
459
460 #[cfg(unix)]
462 {
463 use std::os::unix::fs::PermissionsExt as _;
464 let mut perms = std::fs::metadata(&dest_path)
465 .map_err(|e| format!("stat installed binary: {e}"))?
466 .permissions();
467 let mode = perms.mode();
468 perms.set_mode(mode | 0o111);
469 std::fs::set_permissions(&dest_path, perms)
470 .map_err(|e| format!("set executable bit: {e}"))?;
471 }
472
473 let _ = std::fs::remove_file(&dl_path);
475
476 let dest_str = dest_path.to_string_lossy().into_owned();
477 tracing::info!(tool = %id, path = %dest_str, "tool installed successfully");
478 Ok(dest_str)
479}