agentmux_srv\backend/
tool_store.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tool store: ensures CLI tools (jq, rg, etc.) are available to agent
5//! subprocesses. Reads a bundled catalog JSON, checks system/bundled/managed
6//! install paths, and can download + verify tools on demand.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14// Embedded catalog — path is relative to THIS FILE's location.
15// File: agentmux-srv/src/backend/tool_store.rs
16// Catalog: agentmux-srv/src/config/tool-catalog.json
17const CATALOG_JSON: &str = include_str!("../config/tool-catalog.json");
18
19// ---- Catalog structs ----
20
21#[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    /// Flag (e.g. `"--version"`) to pass to the binary to get its version string.
35    /// If absent, version probing is skipped and the catalog version is reported.
36    #[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    /// "none" | "zip" | "tar_gz"
47    pub extract: String,
48    /// Final filename placed in the bin/ directory.
49    pub bin: String,
50    /// Path of the target file inside the archive (zip/tar_gz only).
51    pub bin_in_archive: Option<String>,
52}
53
54// ---- Status types ----
55
56#[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
77// ---- Directory helpers ----
78
79/// Returns the bundled tools bin dir: `<exe_dir>/tools/bin/`.
80///
81/// In development (binary lives under `target/debug/` or `target/release/`)
82/// we return `None` so dev builds don't accidentally depend on a non-existent
83/// bundled dir.
84pub fn bundled_tools_dir() -> Option<PathBuf> {
85    let exe = std::env::current_exe().ok()?;
86    let exe_dir = exe.parent()?;
87
88    // Skip if we're running from a Cargo target directory.
89    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
102/// Returns `~/.agentmux/tools/bin/`.
103pub fn user_tools_dir() -> Option<PathBuf> {
104    let home = dirs::home_dir()?;
105    Some(home.join(".agentmux").join("tools").join("bin"))
106}
107
108/// Returns `~/.agentmux/tools/downloads/`.
109fn downloads_dir() -> Option<PathBuf> {
110    let home = dirs::home_dir()?;
111    Some(home.join(".agentmux").join("tools").join("downloads"))
112}
113
114// ---- Catalog ----
115
116/// Load and parse the embedded catalog.
117pub fn load_catalog() -> Result<ToolCatalog, String> {
118    serde_json::from_str(CATALOG_JSON).map_err(|e| format!("parse tool-catalog.json: {e}"))
119}
120
121// ---- Platform detection ----
122
123/// Returns the platform key for the current compile target.
124pub fn current_platform() -> Result<&'static str, String> {
125    // Use std::env::consts at runtime so this compiles on every platform
126    // without unreachable-expression warnings.
127    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
137// ---- System PATH probe ----
138
139/// Returns true if `name` can be found on the system PATH.
140fn 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
161/// Returns the system PATH location of `name`, or None.
162fn 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
189/// Run `cmd --version-arg` and extract the first version-looking token.
190/// Returns `Some(version_string)` on success, `None` if the command fails or
191/// the output contains no recognisable version token.
192fn 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    // Some tools write version to stderr (e.g. older jq), try both.
203    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    // Extract first token that looks like a semver number.
209    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
220// ---- Status query ----
221
222/// Get the install status of every tool in the catalog.
223///
224/// Priority: system PATH > bundled dir > user-managed dir.
225pub 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            // Check platform availability first so we can return Unavailable early.
241            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            // 1. System PATH wins.
271            if probe_system_path(&spec.check_cmd) {
272                let path = system_path_of(&spec.check_cmd);
273                // Probe the actual installed version rather than assuming the catalog version.
274                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            // 2. Bundled dir.
287            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            // 3. User-managed store.
303            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            // 4. Not found.
319            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
332// ---- Installation ----
333
334/// Download, verify, and install a single tool by ID into the user-managed
335/// store (`~/.agentmux/tools/bin/`).
336///
337/// Returns the installed binary path on success.
338pub 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    // Ensure directories exist.
353    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    // Download to a temp file.
361    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    // Verify SHA-256.
381    {
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    // Extract / copy into bin dir.
398    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    // Set executable bit on Unix.
461    #[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    // Clean up download.
474    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}