1use std::fmt;
28use std::path::{Path, PathBuf};
29
30const KNOWN_CLIS: &[&str] = &[
32 "claude", "codex", "gemini", "aider", "vibe", "qwen", "amp", "opencode", "cline", "droid",
33 "pi", "junie", "cursor", "copilot", "cn", "kilo", "kimi",
34];
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum CliSource {
39 Detected,
41 Custom,
43}
44
45impl fmt::Display for CliSource {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::Detected => write!(f, "detected"),
49 Self::Custom => write!(f, "custom"),
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct CliInfo {
57 pub display_name: String,
59 pub binary_name: String,
61 pub path: PathBuf,
63 pub source: CliSource,
65}
66
67#[derive(Debug, Clone)]
72pub struct CustomCliDef {
73 pub name: String,
75 pub command: String,
77 pub display_name: Option<String>,
79}
80
81fn derive_display_name(binary_name: &str) -> String {
83 let mut chars = binary_name.chars();
84 match chars.next() {
85 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
86 None => String::new(),
87 }
88}
89
90#[allow(dead_code)]
101fn resolve_command(command: &str) -> Option<PathBuf> {
102 resolve_command_in(command, std::env::var_os("PATH").as_ref())
103}
104
105fn resolve_command_in(command: &str, path: Option<&std::ffi::OsString>) -> Option<PathBuf> {
106 let path_obj = Path::new(command);
107 if path_obj.is_absolute() && path_obj.exists() {
108 return Some(path_obj.to_path_buf());
109 }
110
111 let mut cmd = std::process::Command::new("which");
113 cmd.arg(command);
114
115 let final_path = if let Some(path_str) = path {
117 let path_string = path_str.to_string_lossy().into_owned();
119 format!("{path_string}:/usr/bin:/bin:/usr/local/bin")
121 } else {
122 "/usr/bin:/bin:/usr/local/bin".to_string()
124 };
125
126 cmd.env("PATH", final_path);
127
128 match cmd.output() {
129 Ok(output) if output.status.success() => {
130 let path_str = String::from_utf8_lossy(&output.stdout);
131 let path_str = path_str.trim();
132 if !path_str.is_empty() {
133 return Some(PathBuf::from(path_str));
134 }
135 }
136 _ => {}
137 }
138
139 None
140}
141
142pub fn detect_known_clis() -> Vec<CliInfo> {
146 detect_known_clis_in(std::env::var_os("PATH").as_ref())
147}
148
149fn detect_known_clis_in(path: Option<&std::ffi::OsString>) -> Vec<CliInfo> {
150 KNOWN_CLIS
151 .iter()
152 .filter_map(|&name| {
153 resolve_command_in(name, path).map(|path| CliInfo {
154 display_name: derive_display_name(name),
155 binary_name: name.to_string(),
156 path,
157 source: CliSource::Detected,
158 })
159 })
160 .collect()
161}
162
163pub fn resolve_custom_clis(custom: &[CustomCliDef]) -> Vec<CliInfo> {
171 resolve_custom_clis_in(custom, std::env::var_os("PATH").as_ref())
172}
173
174fn resolve_custom_clis_in(
175 custom: &[CustomCliDef],
176 path: Option<&std::ffi::OsString>,
177) -> Vec<CliInfo> {
178 custom
179 .iter()
180 .filter_map(|def| {
181 if let Some(p) = resolve_command_in(&def.command, path) {
182 let display = def
183 .display_name
184 .clone()
185 .unwrap_or_else(|| derive_display_name(&def.name));
186 Some(CliInfo {
187 display_name: display,
188 binary_name: def.name.clone(),
189 path: p,
190 source: CliSource::Custom,
191 })
192 } else {
193 eprintln!(
194 "warning: custom CLI '{}' not found at '{}', skipping",
195 def.name, def.command
196 );
197 None
198 }
199 })
200 .collect()
201}
202
203pub fn detect_clis(custom: &[CustomCliDef]) -> Vec<CliInfo> {
208 detect_clis_in(custom, std::env::var_os("PATH").as_ref())
209}
210
211fn detect_clis_in(custom: &[CustomCliDef], path: Option<&std::ffi::OsString>) -> Vec<CliInfo> {
212 let detected = detect_known_clis_in(path);
213 let custom_resolved = resolve_custom_clis_in(custom, path);
214
215 let mut by_name = std::collections::HashMap::new();
216 for cli in detected {
217 by_name.insert(cli.binary_name.clone(), cli);
218 }
219 for cli in custom_resolved {
220 by_name.insert(cli.binary_name.clone(), cli);
221 }
222
223 let mut result: Vec<CliInfo> = by_name.into_values().collect();
224 result.sort_by(|a, b| {
225 a.display_name
226 .to_lowercase()
227 .cmp(&b.display_name.to_lowercase())
228 });
229 result
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use std::fs;
236 use std::os::unix::fs::PermissionsExt;
237
238 fn fake_path_with_binaries(names: &[&str]) -> (tempfile::TempDir, PathBuf) {
241 let dir = tempfile::tempdir().expect("failed to create temp dir");
242 for name in names {
243 let bin_path = dir.path().join(name);
244 fs::write(&bin_path, "#!/bin/sh\n").expect("failed to write fake binary");
245 fs::set_permissions(&bin_path, fs::Permissions::from_mode(0o755))
246 .expect("failed to set permissions");
247 }
248 let path = dir.path().to_path_buf();
249 (dir, path)
250 }
251
252 #[test]
255 fn all_known_clis_detected_when_present() {
256 let all_names = [
257 "claude", "codex", "gemini", "aider", "vibe", "qwen", "amp", "opencode", "cline",
258 "droid", "pi", "junie", "cursor", "copilot", "cn", "kilo", "kimi",
259 ];
260 let (_dir, path) = fake_path_with_binaries(&all_names);
261
262 let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
263
264 assert_eq!(result.len(), all_names.len());
265 for name in &all_names {
266 assert!(
267 result.iter().any(|c| c.binary_name == *name),
268 "expected '{name}' to be detected"
269 );
270 }
271 for cli in &result {
272 assert_eq!(cli.source, CliSource::Detected);
273 assert!(!cli.display_name.is_empty());
274 assert!(cli.path.exists());
275 }
276 }
277
278 #[test]
281 fn returns_empty_when_no_known_clis_on_path() {
282 let (_dir, path) = fake_path_with_binaries(&[]);
283
284 let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
285
286 assert!(result.is_empty());
287 }
288
289 #[test]
292 fn detects_subset_of_known_clis() {
293 let (_dir, path) = fake_path_with_binaries(&["claude", "aider"]);
294
295 let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
296
297 assert_eq!(result.len(), 2);
298 assert!(result.iter().any(|c| c.binary_name == "claude"));
299 assert!(result.iter().any(|c| c.binary_name == "aider"));
300 }
301
302 #[test]
305 fn custom_clis_merged_with_detected() {
306 let (_dir, path) = fake_path_with_binaries(&["claude", "my-agent"]);
307 let custom = vec![CustomCliDef {
308 name: "my-agent".to_string(),
309 command: "my-agent".to_string(),
310 display_name: Some("My Agent".to_string()),
311 }];
312
313 let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
314
315 assert_eq!(result.len(), 2);
316 assert!(
317 result
318 .iter()
319 .any(|c| c.binary_name == "claude" && c.source == CliSource::Detected)
320 );
321 assert!(
322 result
323 .iter()
324 .any(|c| c.binary_name == "my-agent" && c.source == CliSource::Custom)
325 );
326 }
327
328 #[test]
331 fn custom_cli_excluded_when_binary_missing() {
332 let (_dir, path) = fake_path_with_binaries(&[]);
333 let custom = vec![CustomCliDef {
334 name: "ghost-agent".to_string(),
335 command: "/nonexistent/ghost-agent".to_string(),
336 display_name: None,
337 }];
338
339 let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
340
341 assert!(result.is_empty());
342 }
343
344 #[test]
347 fn custom_cli_overrides_detected_with_same_binary_name() {
348 let (_dir, path) = fake_path_with_binaries(&["claude"]);
349 let custom = vec![CustomCliDef {
350 name: "claude".to_string(),
351 command: "claude".to_string(),
352 display_name: Some("My Custom Claude".to_string()),
353 }];
354
355 let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
356
357 assert_eq!(result.len(), 1);
358 assert_eq!(result[0].binary_name, "claude");
359 assert_eq!(result[0].source, CliSource::Custom);
360 assert_eq!(result[0].display_name, "My Custom Claude");
361 }
362
363 #[test]
366 fn detected_cli_has_all_fields() {
367 let (_dir, path) = fake_path_with_binaries(&["gemini"]);
368
369 let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
370
371 assert_eq!(result.len(), 1);
372 let cli = &result[0];
373 assert_eq!(cli.binary_name, "gemini");
374 assert_eq!(cli.display_name, "Gemini");
375 assert!(cli.path.exists());
376 assert_eq!(cli.source, CliSource::Detected);
377 }
378
379 #[test]
380 fn custom_cli_has_all_fields() {
381 let (_dir, path) = fake_path_with_binaries(&["my-tool"]);
382 let custom = vec![CustomCliDef {
383 name: "my-tool".to_string(),
384 command: "my-tool".to_string(),
385 display_name: Some("My Tool".to_string()),
386 }];
387
388 let result = resolve_custom_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
389
390 assert_eq!(result.len(), 1);
391 let cli = &result[0];
392 assert_eq!(cli.binary_name, "my-tool");
393 assert_eq!(cli.display_name, "My Tool");
394 assert!(cli.path.exists());
395 assert_eq!(cli.source, CliSource::Custom);
396 }
397
398 #[test]
401 fn custom_cli_resolved_by_absolute_path() {
402 let (_dir, path) = fake_path_with_binaries(&["my-agent"]);
403 let abs = path.join("my-agent");
404 let custom = vec![CustomCliDef {
405 name: "my-agent".to_string(),
406 command: abs.to_string_lossy().to_string(),
407 display_name: Some("My Agent".to_string()),
408 }];
409
410 let result = resolve_custom_clis(&custom);
411
412 assert_eq!(result.len(), 1);
413 assert_eq!(result[0].path, abs);
414 }
415
416 #[test]
419 fn custom_cli_display_name_defaults_to_capitalised_name() {
420 let (_dir, path) = fake_path_with_binaries(&["my-agent"]);
421 let custom = vec![CustomCliDef {
422 name: "my-agent".to_string(),
423 command: "my-agent".to_string(),
424 display_name: None,
425 }];
426
427 let result = resolve_custom_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
428
429 assert_eq!(result[0].display_name, "My-agent");
430 }
431
432 #[test]
435 fn results_sorted_by_display_name() {
436 let (_dir, path) = fake_path_with_binaries(&["qwen", "aider", "zebra"]);
437 let custom = vec![CustomCliDef {
438 name: "zebra".to_string(),
439 command: "zebra".to_string(),
440 display_name: Some("Zebra".to_string()),
441 }];
442
443 let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
444
445 let names: Vec<&str> = result.iter().map(|c| c.display_name.as_str()).collect();
446 assert_eq!(names, vec!["Aider", "Qwen", "Zebra"]);
447 }
448
449 #[test]
452 fn cli_source_display_format() {
453 assert_eq!(format!("{}", CliSource::Detected), "detected");
454 assert_eq!(format!("{}", CliSource::Custom), "custom");
455 }
456}