1use std::path::PathBuf;
5
6#[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#[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
43struct 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
133pub fn discover_cli_tools(additional: &[String], excluded: &[String]) -> Vec<DiscoveredCli> {
136 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 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 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#[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
190fn probe_cli(name: &str, version_args: &[&str], category: CliCategory) -> Option<DiscoveredCli> {
192 let path = find_executable(name)?;
194
195 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
206fn 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
232fn 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 let version_text = if stdout.trim().is_empty() {
246 stderr.trim().to_string()
247 } else {
248 stdout.trim().to_string()
249 };
250
251 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 let results = discover_cli_tools(&[], &[]);
268 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}