1use std::collections::HashSet;
21use std::ffi::OsString;
22use std::path::{Path, PathBuf};
23use std::sync::Mutex;
24use std::time::{Duration, Instant};
25
26use crate::backend::blockcontroller::{META_KEY_TERM_LOCAL_SHELL_OPTS, META_KEY_TERM_LOCAL_SHELL_PATH};
27use crate::backend::obj::MetaMapType;
28use crate::backend::rpc_types::{EffectiveShell, ShellInfo, ShellList};
29use crate::backend::wconfig::SettingsType;
30
31const VERSION_TIMEOUT: Duration = Duration::from_secs(3);
33const CACHE_TTL: Duration = Duration::from_secs(30);
35
36const SEPARATORS: [char; 2] = ['\\', '/'];
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum Kind {
40 Pwsh,
41 PowerShell,
42 Cmd,
43 GitBash,
44 Bash,
45 Zsh,
46 Fish,
47 Nu,
48 Sh,
49 Other,
50}
51
52fn file_name(path: &Path) -> String {
54 let s = path.to_string_lossy();
55 s.rsplit(SEPARATORS).next().unwrap_or(&s).to_string()
56}
57
58impl Kind {
59 fn of(path: &Path) -> Kind {
60 let lower = path.to_string_lossy().to_ascii_lowercase();
61 let name = file_name(Path::new(&lower));
62 let stem = name.strip_suffix(".exe").unwrap_or(&name);
63 let in_git = lower.split(SEPARATORS).any(|part| part == "git");
64 match stem {
65 "pwsh" => Kind::Pwsh,
66 "powershell" => Kind::PowerShell,
67 "cmd" => Kind::Cmd,
68 "bash" if in_git => Kind::GitBash,
69 "bash" => Kind::Bash,
70 "zsh" => Kind::Zsh,
71 "fish" => Kind::Fish,
72 "nu" => Kind::Nu,
73 "sh" | "dash" => Kind::Sh,
74 _ => Kind::Other,
75 }
76 }
77
78 fn id(self) -> &'static str {
79 match self {
80 Kind::Pwsh => "pwsh",
81 Kind::PowerShell => "powershell",
82 Kind::Cmd => "cmd",
83 Kind::GitBash => "gitbash",
84 Kind::Bash => "bash",
85 Kind::Zsh => "zsh",
86 Kind::Fish => "fish",
87 Kind::Nu => "nu",
88 Kind::Sh => "sh",
89 Kind::Other => "other",
90 }
91 }
92
93 fn name(self, path: &Path) -> String {
94 match self {
95 Kind::Pwsh => "PowerShell".into(),
96 Kind::PowerShell => "Windows PowerShell".into(),
97 Kind::Cmd => "Command Prompt".into(),
98 Kind::GitBash => "Git Bash".into(),
99 Kind::Bash => "Bash".into(),
100 Kind::Zsh => "Zsh".into(),
101 Kind::Fish => "fish".into(),
102 Kind::Nu => "Nushell".into(),
103 Kind::Sh | Kind::Other => file_name(path),
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110struct Candidate {
111 path: PathBuf,
112 kind: Kind,
113}
114
115fn candidate(path: PathBuf) -> Candidate {
116 let kind = Kind::of(&path);
117 Candidate { path, kind }
118}
119
120fn dedupe(cands: Vec<Candidate>, canonical: &dyn Fn(&Path) -> PathBuf) -> Vec<Candidate> {
123 let mut seen = HashSet::new();
124 cands
125 .into_iter()
126 .filter(|c| seen.insert(canonical(&c.path).to_string_lossy().to_ascii_lowercase()))
127 .collect()
128}
129
130fn wjoin(base: &str, rel: &str) -> String {
133 format!("{}\\{}", base.trim_end_matches(SEPARATORS), rel)
134}
135
136fn wparent(p: &str) -> Option<&str> {
138 let p = p.trim_end_matches(SEPARATORS);
139 p.rfind(SEPARATORS).map(|i| &p[..i])
140}
141
142#[cfg_attr(not(windows), allow(dead_code))]
145fn windows_candidates(env: &dyn Fn(&str) -> Option<OsString>, is_file: &dyn Fn(&Path) -> bool) -> Vec<Candidate> {
146 let var = |k: &str| env(k).map(|v| v.to_string_lossy().into_owned()).filter(|v| !v.is_empty());
147 let path: Vec<String> = var("PATH")
148 .map(|v| {
149 v.split(';')
150 .map(|s| s.trim().trim_matches('"').to_string())
151 .filter(|s| !s.is_empty())
152 .collect()
153 })
154 .unwrap_or_default();
155 let is = |p: &str| is_file(Path::new(p));
156 let on_path = |exe: &str| path.iter().map(|d| wjoin(d, exe)).filter(|p| is(p)).collect::<Vec<_>>();
157 let program_roots: Vec<String> = ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"]
158 .into_iter()
159 .filter_map(var)
160 .collect();
161 let system_root = var("SystemRoot").unwrap_or_else(|| r"C:\Windows".to_string());
162
163 let mut out: Vec<String> = Vec::new();
164 out.extend(on_path("pwsh.exe"));
167 for root in &program_roots {
168 for dir in ["7", "7-preview"] {
169 out.push(wjoin(root, &format!(r"PowerShell\{dir}\pwsh.exe")));
170 }
171 }
172 if let Some(local) = var("LOCALAPPDATA") {
173 out.push(wjoin(&local, r"Microsoft\WindowsApps\pwsh.exe"));
174 }
175 out.push(wjoin(&system_root, r"System32\WindowsPowerShell\v1.0\powershell.exe"));
176 out.push(var("ComSpec").unwrap_or_else(|| wjoin(&system_root, r"System32\cmd.exe")));
177 for git in on_path("git.exe") {
181 let mut dir = wparent(&git).and_then(wparent);
182 for _ in 0..3 {
183 let Some(root) = dir else { break };
184 out.push(wjoin(root, r"bin\bash.exe"));
185 dir = wparent(root);
186 }
187 }
188 for root in &program_roots {
189 out.push(wjoin(root, r"Git\bin\bash.exe"));
190 }
191 if let Some(local) = var("LOCALAPPDATA") {
192 out.push(wjoin(&local, r"Programs\Git\bin\bash.exe"));
193 }
194 out.push(r"C:\msys64\usr\bin\bash.exe".to_string());
195 out.push(r"C:\cygwin64\bin\bash.exe".to_string());
196 out.extend(on_path("nu.exe"));
197 out.extend(on_path("fish.exe"));
198 out.into_iter().filter(|p| is(p)).map(|p| candidate(PathBuf::from(p))).collect()
199}
200
201#[cfg_attr(windows, allow(dead_code))]
204fn unix_candidates(login: Option<PathBuf>, etc_shells: &str, is_file: &dyn Fn(&Path) -> bool) -> Vec<Candidate> {
205 let mut out: Vec<PathBuf> = login.into_iter().collect();
206 out.extend(
207 etc_shells
208 .lines()
209 .map(str::trim)
210 .filter(|l| l.starts_with('/'))
211 .map(PathBuf::from),
212 );
213 for dir in ["/opt/homebrew/bin", "/usr/local/bin"] {
214 for sh in ["zsh", "bash", "fish", "nu"] {
215 out.push(Path::new(dir).join(sh));
216 }
217 }
218 out.into_iter()
219 .filter(|p| is_file(p))
220 .filter(|p| !matches!(file_name(p).as_str(), "nologin" | "false" | "git-shell"))
222 .map(candidate)
223 .collect()
224}
225
226fn real_env(k: &str) -> Option<OsString> {
227 std::env::var_os(k)
228}
229
230fn real_is_file(p: &Path) -> bool {
231 p.is_file()
232}
233
234fn real_canonical(p: &Path) -> PathBuf {
235 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
236}
237
238fn candidates() -> Vec<Candidate> {
239 let found = if cfg!(windows) {
240 windows_candidates(&real_env, &real_is_file)
241 } else {
242 let login = std::env::var_os("SHELL").filter(|s| !s.is_empty()).map(PathBuf::from);
243 let etc = std::fs::read_to_string("/etc/shells").unwrap_or_default();
244 unix_candidates(login, &etc, &real_is_file)
245 };
246 dedupe(found, &real_canonical)
247}
248
249pub fn automatic() -> PathBuf {
253 if cfg!(windows) {
254 for exe in ["pwsh", "powershell"] {
255 if let Ok(p) = which::which(exe) {
256 return p;
257 }
258 }
259 std::env::var_os("ComSpec")
260 .filter(|s| !s.is_empty())
261 .map(PathBuf::from)
262 .unwrap_or_else(|| PathBuf::from("cmd.exe"))
263 } else {
264 std::env::var_os("SHELL")
265 .filter(|s| !s.is_empty())
266 .map(PathBuf::from)
267 .unwrap_or_else(|| PathBuf::from("/bin/bash"))
268 }
269}
270
271fn locate(configured: &str) -> Option<PathBuf> {
274 if configured.contains(SEPARATORS) {
275 let p = PathBuf::from(configured);
276 p.is_file().then_some(p)
277 } else {
278 which::which(configured).ok()
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct SpawnShell {
285 pub path: PathBuf,
286 pub source: &'static str,
288 pub missing: Option<String>,
290}
291
292pub fn resolve_for_spawn(block_setting: &str, global_setting: &str) -> SpawnShell {
295 resolve_with(block_setting, global_setting, &locate, &automatic)
296}
297
298fn resolve_with(
299 block_setting: &str,
300 global_setting: &str,
301 locate: &dyn Fn(&str) -> Option<PathBuf>,
302 automatic: &dyn Fn() -> PathBuf,
303) -> SpawnShell {
304 let (configured, source) = match (block_setting.trim(), global_setting.trim()) {
305 (b, _) if !b.is_empty() => (b, "block"),
306 (_, g) if !g.is_empty() => (g, "setting"),
307 _ => return SpawnShell { path: automatic(), source: "automatic", missing: None },
308 };
309 match locate(configured) {
310 Some(path) => SpawnShell { path, source, missing: None },
311 None => SpawnShell { path: automatic(), source: "fallback", missing: Some(configured.to_string()) },
312 }
313}
314
315pub fn for_block(block_id: &str, block_meta: &MetaMapType, settings: &SettingsType) -> (PathBuf, Vec<String>) {
320 let own_path = block_meta.get(META_KEY_TERM_LOCAL_SHELL_PATH).and_then(|v| v.as_str()).unwrap_or("");
321 let shell = resolve_for_spawn(own_path, &settings.term_local_shell_path);
322 if let Some(missing) = &shell.missing {
323 tracing::warn!(block_id, missing = %missing, using = %shell.path.display(), "configured shell not found; using the automatic one");
324 }
325 (shell.path, shell_opts(block_meta, &settings.term_local_shell_opts))
326}
327
328fn shell_opts(block_meta: &MetaMapType, setting: &[String]) -> Vec<String> {
331 match block_meta.get(META_KEY_TERM_LOCAL_SHELL_OPTS).and_then(|v| v.as_array()) {
332 Some(own) => own.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),
333 None => setting.to_vec(),
334 }
335}
336
337pub fn with_user_opts(integration: &[String], opts: &[String]) -> Vec<String> {
342 let at = integration
343 .iter()
344 .position(|a| a.eq_ignore_ascii_case("-File"))
345 .unwrap_or(integration.len());
346 let mut out = integration[..at].to_vec();
347 out.extend_from_slice(opts);
348 out.extend_from_slice(&integration[at..]);
349 out
350}
351
352fn version_of(c: &Candidate) -> Option<String> {
354 let args: &[&str] = match c.kind {
355 Kind::Pwsh | Kind::PowerShell => &["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
356 Kind::Cmd | Kind::Other => return None,
357 _ => &["--version"],
358 };
359 let mut cmd = std::process::Command::new(&c.path);
360 cmd.args(args)
361 .stdin(std::process::Stdio::null())
362 .stdout(std::process::Stdio::piped())
363 .stderr(std::process::Stdio::null());
364 #[cfg(windows)]
365 {
366 use agentmux_common::win32::NoWindow;
367 cmd.no_window();
368 }
369 let mut child = cmd.spawn().ok()?;
370 let deadline = Instant::now() + VERSION_TIMEOUT;
371 loop {
372 match child.try_wait() {
373 Ok(Some(_)) => break,
374 Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
375 _ => {
376 let _ = child.kill();
377 let _ = child.wait();
378 return None;
379 }
380 }
381 }
382 let out = child.wait_with_output().ok()?;
383 let text = String::from_utf8_lossy(&out.stdout);
384 crate::backend::cli_notice::version_token(text.lines().next().unwrap_or_default())
385}
386
387static CACHE: Mutex<Option<(Instant, Vec<ShellInfo>)>> = Mutex::new(None);
388
389fn installed() -> Vec<ShellInfo> {
391 if let Some((at, shells)) = CACHE.lock().unwrap().as_ref() {
392 if at.elapsed() < CACHE_TTL {
393 return shells.clone();
394 }
395 }
396 let cands = candidates();
397 let versions: Vec<Option<String>> = std::thread::scope(|s| {
398 let handles: Vec<_> = cands.iter().map(|c| s.spawn(move || version_of(c))).collect();
399 handles.into_iter().map(|h| h.join().ok().flatten()).collect()
400 });
401 let shells: Vec<ShellInfo> = cands
402 .iter()
403 .zip(versions)
404 .map(|(c, version)| ShellInfo {
405 path: c.path.display().to_string(),
406 name: c.kind.name(&c.path),
407 kind: c.kind.id().to_string(),
408 version,
409 })
410 .collect();
411 *CACHE.lock().unwrap() = Some((Instant::now(), shells.clone()));
412 shells
413}
414
415pub fn list(global_setting: &str) -> ShellList {
419 let shells = installed();
420 let spawn = resolve_for_spawn("", global_setting);
421 ShellList {
422 shells,
423 automatic: automatic().display().to_string(),
424 effective: EffectiveShell {
425 path: spawn.path.display().to_string(),
426 source: spawn.source.to_string(),
427 missing: spawn.missing,
428 },
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use std::collections::HashMap;
436
437 fn env_of(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
438 let m: HashMap<String, String> = vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
439 move |k: &str| m.get(k).map(OsString::from)
440 }
441
442 fn files(paths: &[&str]) -> impl Fn(&Path) -> bool {
443 let set: HashSet<String> = paths.iter().map(|p| p.to_ascii_lowercase()).collect();
444 move |p: &Path| set.contains(&p.to_string_lossy().to_ascii_lowercase())
445 }
446
447 #[test]
448 fn windows_lists_the_familiar_shells_in_order() {
449 let env = env_of(&[
450 ("PATH", r"C:\Program Files\PowerShell\7;C:\Program Files\Git\cmd;C:\tools\nu"),
451 ("ProgramFiles", r"C:\Program Files"),
452 ("SystemRoot", r"C:\Windows"),
453 ("ComSpec", r"C:\Windows\System32\cmd.exe"),
454 ]);
455 let is_file = files(&[
456 r"C:\Program Files\PowerShell\7\pwsh.exe",
457 r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
458 r"C:\Windows\System32\cmd.exe",
459 r"C:\Program Files\Git\cmd\git.exe",
460 r"C:\Program Files\Git\bin\bash.exe",
461 r"C:\tools\nu\nu.exe",
462 ]);
463 let found = dedupe(windows_candidates(&env, &is_file), &|p| p.to_path_buf());
464 let kinds: Vec<&str> = found.iter().map(|c| c.kind.id()).collect();
465 assert_eq!(kinds, ["pwsh", "powershell", "cmd", "gitbash", "nu"]);
466 }
467
468 #[test]
469 fn windows_lists_nothing_that_isnt_there() {
470 let env = env_of(&[("SystemRoot", r"C:\Windows")]);
471 let found = windows_candidates(&env, &files(&[r"C:\Windows\System32\cmd.exe"]));
472 assert_eq!(found.iter().map(|c| c.kind.id()).collect::<Vec<_>>(), ["cmd"]);
473 }
474
475 #[test]
476 fn unix_lists_the_login_shell_first_and_skips_non_shells() {
477 let etc = "# /etc/shells\n/bin/sh\n/bin/bash\n/usr/bin/zsh\n/usr/sbin/nologin\n/bin/false\n";
478 let is_file = files(&["/bin/sh", "/bin/bash", "/usr/bin/zsh", "/usr/sbin/nologin", "/bin/false"]);
479 let found = unix_candidates(Some(PathBuf::from("/usr/bin/zsh")), etc, &is_file);
480 let found = dedupe(found, &|p| p.to_path_buf());
481 let paths: Vec<String> = found.iter().map(|c| c.path.display().to_string()).collect();
482 assert_eq!(paths, ["/usr/bin/zsh", "/bin/sh", "/bin/bash"]);
483 }
484
485 #[test]
486 fn the_same_file_reached_twice_is_listed_once() {
487 let etc = "/bin/bash\n/usr/bin/bash\n";
488 let is_file = files(&["/bin/bash", "/usr/bin/bash"]);
489 let canonical = |p: &Path| {
490 if p == Path::new("/bin/bash") {
491 PathBuf::from("/usr/bin/bash")
492 } else {
493 p.to_path_buf()
494 }
495 };
496 let found = dedupe(unix_candidates(None, etc, &is_file), &canonical);
497 assert_eq!(found.len(), 1);
498 }
499
500 #[test]
501 fn names_say_what_the_shell_is() {
502 assert_eq!(Kind::of(Path::new(r"C:\Program Files\Git\bin\bash.exe")), Kind::GitBash);
503 assert_eq!(Kind::of(Path::new(r"C:\msys64\usr\bin\bash.exe")), Kind::Bash);
504 assert_eq!(Kind::of(Path::new("/bin/bash")), Kind::Bash);
505 assert_eq!(Kind::of(Path::new(r"C:\Windows\System32\cmd.exe")), Kind::Cmd);
506 assert_eq!(Kind::GitBash.name(Path::new("x")), "Git Bash");
507 assert_eq!(Kind::Other.name(Path::new(r"C:\tools\xonsh.exe")), "xonsh.exe");
508 }
509
510 #[test]
511 fn the_blocks_own_options_win_over_the_setting() {
512 let mut meta = MetaMapType::new();
513 let setting = vec!["-NoLogo".to_string()];
514 assert_eq!(shell_opts(&meta, &setting), setting);
515 meta.insert(META_KEY_TERM_LOCAL_SHELL_OPTS.into(), serde_json::json!(["-l"]));
516 assert_eq!(shell_opts(&meta, &setting), vec!["-l".to_string()]);
517 meta.insert(META_KEY_TERM_LOCAL_SHELL_OPTS.into(), serde_json::json!([]));
519 assert!(shell_opts(&meta, &setting).is_empty());
520 }
521
522 fn strings(v: &[&str]) -> Vec<String> {
523 v.iter().map(|s| s.to_string()).collect()
524 }
525
526 #[test]
527 fn user_options_go_before_powershells_file_and_after_bashs_rcfile() {
528 let pwsh = strings(&["-ExecutionPolicy", "Bypass", "-NoExit", "-File", "wavepwsh.ps1"]);
529 assert_eq!(
530 with_user_opts(&pwsh, &strings(&["-NoLogo"])),
531 strings(&["-ExecutionPolicy", "Bypass", "-NoExit", "-NoLogo", "-File", "wavepwsh.ps1"])
532 );
533 let bash = strings(&["--rcfile", "bashrc"]);
534 assert_eq!(with_user_opts(&bash, &strings(&["-l"])), strings(&["--rcfile", "bashrc", "-l"]));
535 assert_eq!(with_user_opts(&[], &strings(&["/K"])), strings(&["/K"]));
536 }
537
538 fn auto() -> PathBuf {
539 PathBuf::from("auto-shell")
540 }
541
542 #[test]
543 fn spawn_uses_the_block_then_the_setting_then_automatic() {
544 let locate = |s: &str| Some(PathBuf::from(s));
545 assert_eq!(resolve_with("blk", "glob", &locate, &auto).source, "block");
546 assert_eq!(resolve_with("", "glob", &locate, &auto).path, PathBuf::from("glob"));
547 let a = resolve_with("", " ", &locate, &auto);
548 assert_eq!((a.path, a.source), (auto(), "automatic"));
549 }
550
551 #[test]
552 fn a_shell_that_isnt_there_falls_back_to_automatic_and_says_so() {
553 let locate = |_: &str| None;
554 let r = resolve_with("", r"C:\gone\nu.exe", &locate, &auto);
555 assert_eq!(r.path, auto());
556 assert_eq!(r.source, "fallback");
557 assert_eq!(r.missing.as_deref(), Some(r"C:\gone\nu.exe"));
558 }
559}