Skip to main content

zeroclaw_tools/
cli_discovery.rs

1//! CLI tool auto-discovery — scans PATH for known CLI tools.
2//! Zero external dependencies (uses `std::process::Command` + `std::env`).
3
4use std::path::PathBuf;
5
6/// Category of a discovered CLI tool.
7#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
8pub enum CliCategory {
9    VersionControl,
10    Language,
11    PackageManager,
12    Container,
13    Build,
14    Cloud,
15    AiAgent,
16    Productivity,
17}
18
19impl std::fmt::Display for CliCategory {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::VersionControl => write!(f, "Version Control"),
23            Self::Language => write!(f, "Language"),
24            Self::PackageManager => write!(f, "Package Manager"),
25            Self::Container => write!(f, "Container"),
26            Self::Build => write!(f, "Build"),
27            Self::Cloud => write!(f, "Cloud"),
28            Self::AiAgent => write!(f, "AI Agent"),
29            Self::Productivity => write!(f, "Productivity"),
30        }
31    }
32}
33
34/// A discovered CLI tool with metadata.
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct DiscoveredCli {
37    pub name: String,
38    pub path: PathBuf,
39    pub version: Option<String>,
40    pub category: CliCategory,
41}
42
43/// Known CLI tools to scan for.
44struct KnownCli {
45    name: &'static str,
46    version_args: &'static [&'static str],
47    category: CliCategory,
48}
49
50const KNOWN_CLIS: &[KnownCli] = &[
51    KnownCli {
52        name: "git",
53        version_args: &["--version"],
54        category: CliCategory::VersionControl,
55    },
56    KnownCli {
57        name: "python",
58        version_args: &["--version"],
59        category: CliCategory::Language,
60    },
61    KnownCli {
62        name: "python3",
63        version_args: &["--version"],
64        category: CliCategory::Language,
65    },
66    KnownCli {
67        name: "node",
68        version_args: &["--version"],
69        category: CliCategory::Language,
70    },
71    KnownCli {
72        name: "npm",
73        version_args: &["--version"],
74        category: CliCategory::PackageManager,
75    },
76    KnownCli {
77        name: "pip",
78        version_args: &["--version"],
79        category: CliCategory::PackageManager,
80    },
81    KnownCli {
82        name: "pip3",
83        version_args: &["--version"],
84        category: CliCategory::PackageManager,
85    },
86    KnownCli {
87        name: "docker",
88        version_args: &["--version"],
89        category: CliCategory::Container,
90    },
91    KnownCli {
92        name: "cargo",
93        version_args: &["--version"],
94        category: CliCategory::Build,
95    },
96    KnownCli {
97        name: "make",
98        version_args: &["--version"],
99        category: CliCategory::Build,
100    },
101    KnownCli {
102        name: "kubectl",
103        version_args: &["version", "--client", "--short"],
104        category: CliCategory::Cloud,
105    },
106    KnownCli {
107        name: "rustc",
108        version_args: &["--version"],
109        category: CliCategory::Language,
110    },
111    KnownCli {
112        name: "claude",
113        version_args: &["--version"],
114        category: CliCategory::AiAgent,
115    },
116    KnownCli {
117        name: "gemini",
118        version_args: &["--version"],
119        category: CliCategory::AiAgent,
120    },
121    KnownCli {
122        name: "kilo",
123        version_args: &["--version"],
124        category: CliCategory::AiAgent,
125    },
126    KnownCli {
127        name: "gws",
128        version_args: &["--version"],
129        category: CliCategory::Productivity,
130    },
131];
132
133/// Discover available CLI tools on the system.
134/// Scans PATH for known tools and returns metadata for each found.
135pub fn discover_cli_tools(additional: &[String], excluded: &[String]) -> Vec<DiscoveredCli> {
136    // Build the probe list first — cheap, no spawns — preserving the
137    // KNOWN_CLIS-then-`additional` ordering that callers and tests rely on.
138    let mut probes: Vec<(&str, &[&str], CliCategory)> = Vec::new();
139
140    for known in KNOWN_CLIS {
141        if excluded.iter().any(|e| e == known.name) {
142            continue;
143        }
144        probes.push((known.name, known.version_args, known.category.clone()));
145    }
146
147    // Append additional user-specified tools, skipping duplicates.
148    for tool_name in additional {
149        if excluded.iter().any(|e| e == tool_name) {
150            continue;
151        }
152        if probes.iter().any(|(n, _, _)| *n == tool_name.as_str()) {
153            continue;
154        }
155        probes.push((tool_name.as_str(), &["--version"], CliCategory::Build));
156    }
157
158    // Probe concurrently. Each tool needs up to two short-lived child
159    // processes (`where`/`which` + `--version`); running them serially made
160    // `/api/cli-tools` visibly slow (wall time ~= sum of every probe). Scoped
161    // threads bound the scan by the slowest single tool instead. Output order
162    // is preserved because handles are joined in spawn order.
163    std::thread::scope(|scope| {
164        let handles: Vec<_> = probes
165            .into_iter()
166            .map(|(name, args, category)| scope.spawn(move || probe_cli(name, args, category)))
167            .collect();
168        handles
169            .into_iter()
170            .filter_map(|h| h.join().ok().flatten())
171            .collect()
172    })
173}
174
175/// Suppress the console window that Windows otherwise spawns for each
176/// short-lived child process. Without this, hitting `/api/cli-tools` from the
177/// web UI flashes a `cmd`/console window for every probe — distracting in GUI
178/// and service contexts. No-op on non-Windows platforms. Mirrors the runtime
179/// shell-command fix from issue #5562.
180#[cfg(target_os = "windows")]
181fn hide_console(cmd: &mut std::process::Command) {
182    use std::os::windows::process::CommandExt;
183    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
184    cmd.creation_flags(CREATE_NO_WINDOW);
185}
186
187#[cfg(not(target_os = "windows"))]
188fn hide_console(_cmd: &mut std::process::Command) {}
189
190/// Probe a single CLI tool: check if it exists and get its version.
191fn probe_cli(name: &str, version_args: &[&str], category: CliCategory) -> Option<DiscoveredCli> {
192    // Try to find the tool using `which` (Unix) or `where` (Windows)
193    let path = find_executable(name)?;
194
195    // Try to get version
196    let version = get_version(name, version_args);
197
198    Some(DiscoveredCli {
199        name: name.to_string(),
200        path,
201        version,
202        category,
203    })
204}
205
206/// Find an executable on PATH.
207fn find_executable(name: &str) -> Option<PathBuf> {
208    #[cfg(target_os = "windows")]
209    let which_cmd = "where";
210    #[cfg(not(target_os = "windows"))]
211    let which_cmd = "which";
212
213    let mut cmd = std::process::Command::new(which_cmd);
214    cmd.arg(name)
215        .stdout(std::process::Stdio::piped())
216        .stderr(std::process::Stdio::null());
217    hide_console(&mut cmd);
218    let output = cmd.output().ok()?;
219
220    if !output.status.success() {
221        return None;
222    }
223
224    let path_str = String::from_utf8_lossy(&output.stdout);
225    let first_line = path_str.lines().next()?.trim();
226    if first_line.is_empty() {
227        return None;
228    }
229    Some(PathBuf::from(first_line))
230}
231
232/// Get the version string of a CLI tool.
233fn get_version(name: &str, args: &[&str]) -> Option<String> {
234    let mut cmd = std::process::Command::new(name);
235    cmd.args(args)
236        .stdout(std::process::Stdio::piped())
237        .stderr(std::process::Stdio::piped());
238    hide_console(&mut cmd);
239    let output = cmd.output().ok()?;
240
241    let stdout = String::from_utf8_lossy(&output.stdout);
242    let stderr = String::from_utf8_lossy(&output.stderr);
243
244    // Some tools print version to stderr (e.g., pip)
245    let version_text = if stdout.trim().is_empty() {
246        stderr.trim().to_string()
247    } else {
248        stdout.trim().to_string()
249    };
250
251    // Extract first line only
252    let first_line = version_text.lines().next()?.trim().to_string();
253    if first_line.is_empty() {
254        None
255    } else {
256        Some(first_line)
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn discover_returns_vec() {
266        // Just verify it runs without panic
267        let results = discover_cli_tools(&[], &[]);
268        // We can't assert specific tools exist in CI, but structure is valid
269        for cli in &results {
270            assert!(!cli.name.is_empty());
271        }
272    }
273
274    #[test]
275    fn excluded_tools_are_skipped() {
276        let results = discover_cli_tools(&[], &["git".to_string()]);
277        assert!(!results.iter().any(|r| r.name == "git"));
278    }
279
280    #[test]
281    fn category_display() {
282        assert_eq!(CliCategory::VersionControl.to_string(), "Version Control");
283        assert_eq!(CliCategory::Language.to_string(), "Language");
284        assert_eq!(CliCategory::PackageManager.to_string(), "Package Manager");
285        assert_eq!(CliCategory::Container.to_string(), "Container");
286        assert_eq!(CliCategory::Build.to_string(), "Build");
287        assert_eq!(CliCategory::Cloud.to_string(), "Cloud");
288        assert_eq!(CliCategory::AiAgent.to_string(), "AI Agent");
289        assert_eq!(CliCategory::Productivity.to_string(), "Productivity");
290    }
291}