From e4a4b83d5bd5f449a03585249df3b491b5679aca Mon Sep 17 00:00:00 2001 From: MilkClouds Date: Sun, 31 May 2026 15:24:19 +0900 Subject: [PATCH] fix(shell): default to PowerShell on native Windows without the wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the bare `agf` binary on Windows (no `agf setup`) crashed with `Error: program not found`. With `AGF_SHELL` unset, `from_env` assumed POSIX on every OS, so resume/new/open generated `cd ... && claude` and the delivery path tried to exec it via `sh -c`. Windows has no `sh`, so the spawn failed with Rust's "program not found" io error. Infer the default shell from the OS when `AGF_SHELL` is absent: POSIX off Windows or under a Git Bash / MSYS2 layer (`MSYSTEM`); otherwise native Windows, which needs PowerShell. A present `SHELL` is a POSIX-layer signal (native Windows shells don't export it, e.g. Cygwin), so its basename is classified through `from_name` — keeping the powershell/pwsh names in one place and treating any non-PowerShell shell as POSIX. The wrapper still sets `AGF_SHELL` explicitly, so the installed path is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/shell.rs | 101 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/src/shell.rs b/src/shell.rs index a49a24a..fcdd026 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -15,16 +15,22 @@ pub enum CommandShell { } impl CommandShell { - /// Resolve the active shell once per process. The wrapper sets - /// `AGF_SHELL` before exec'ing `agf`, so the value never changes for the - /// lifetime of this binary — caching avoids per-frame env lookups in the - /// TUI render path and per-call lookups in `action.rs`. + /// Resolve the active shell once per process. The wrapper sets `AGF_SHELL` + /// before exec'ing `agf`; without it we fall back to `default_shell`. + /// Cached so the TUI render path and `action.rs` don't re-read env. pub fn from_env() -> Self { static CACHE: OnceLock = OnceLock::new(); - *CACHE.get_or_init(|| Self::from_name(std::env::var("AGF_SHELL").ok().as_deref())) + *CACHE.get_or_init(|| match std::env::var("AGF_SHELL").ok().as_deref() { + Some(name) if !name.is_empty() => Self::from_name(Some(name)), + _ => Self::default_shell( + cfg!(windows), + std::env::var_os("MSYSTEM").is_some(), + std::env::var("SHELL").ok().as_deref(), + ), + }) } - /// Pure helper behind `from_env` — classifies a shell name string. + /// Pure helper behind `from_env` — classifies an explicit shell name string. /// Exposed so tests can drive it without mutating process env. fn from_name(name: Option<&str>) -> Self { match name { @@ -33,6 +39,28 @@ impl CommandShell { } } + /// Default shell when `AGF_SHELL` is unset. Native Windows needs PowerShell + /// (no `sh`, no `&&`); a POSIX layer signals itself via `MSYSTEM` or, for + /// ones like Cygwin that don't, a `SHELL` that native shells never export. + /// Args are passed in so tests stay pure. + fn default_shell(is_windows: bool, msystem: bool, shell: Option<&str>) -> Self { + if !is_windows || msystem { + return Self::Posix; + } + // Classify SHELL's basename via `from_name` so shell names live in one place. + match shell { + Some(s) if !s.is_empty() => { + let base = s + .rsplit(['/', '\\']) + .next() + .unwrap_or(s) + .to_ascii_lowercase(); + Self::from_name(Some(base.trim_end_matches(".exe"))) + } + _ => Self::PowerShell, + } + } + /// Escape a string so it can be interpolated into a single-quoted /// literal for this shell. /// @@ -337,6 +365,67 @@ mod tests { assert!(!pwsh.is_cd_only("Set-Location '/tmp'; if ($?) { claude }")); } + #[test] + fn default_shell_picks_powershell_on_native_windows() { + assert_eq!( + CommandShell::default_shell(true, false, None), + CommandShell::PowerShell + ); + } + + #[test] + fn default_shell_honors_posix_layer_on_windows() { + assert_eq!( + CommandShell::default_shell(true, true, None), + CommandShell::Posix + ); + assert_eq!( + CommandShell::default_shell(true, false, Some("/usr/bin/bash")), + CommandShell::Posix + ); + assert_eq!( + CommandShell::default_shell(true, false, Some("/bin/sh")), + CommandShell::Posix + ); + } + + #[test] + fn default_shell_classifies_shell_as_powershell_only_for_pwsh() { + assert_eq!( + CommandShell::default_shell(true, false, Some("pwsh")), + CommandShell::PowerShell + ); + assert_eq!( + CommandShell::default_shell(true, false, Some("/bin/csh")), + CommandShell::Posix + ); + } + + #[test] + fn default_shell_matches_basename_not_path_substring() { + assert_eq!( + CommandShell::default_shell(true, false, Some(r"C:\Git\usr\bin\bash.exe")), + CommandShell::Posix + ); + assert_eq!( + CommandShell::default_shell(true, false, Some(r"C:\Users\bashfan\bin\pwsh.exe")), + CommandShell::PowerShell + ); + } + + #[test] + fn default_shell_is_posix_off_windows() { + assert_eq!( + CommandShell::default_shell(false, false, None), + CommandShell::Posix + ); + // Even a Windows-looking SHELL must not flip a non-Windows host. + assert_eq!( + CommandShell::default_shell(false, true, Some("pwsh")), + CommandShell::Posix + ); + } + #[test] fn from_name_classifies_shells() { assert_eq!(