@@ -152,7 +152,7 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
152152 // preflight and classifier to this loop.
153153 if let Some ( cli) = runtime. underlying_cli {
154154 if crate :: managed_agents:: resolve_command ( cli) . is_none ( ) {
155- for cmd in runtime. cli_install_commands {
155+ for cmd in runtime. cli_install_commands_for_os ( ) {
156156 let result = run_install_command ( "cli" , cmd) ;
157157 let success = result. success ;
158158 steps. push ( result) ;
@@ -541,14 +541,14 @@ fn persist_last_error_on_install(
541541/// the shell selection and environment cleanup shared by `run_install_command`
542542/// and `resolve_npm_prefix` — keeping them in sync so the hermit-strip list
543543/// can't drift between the two paths.
544- fn install_shell_command ( command : & str ) -> std :: process :: Command {
545- let shell = if std :: path :: Path :: new ( "/bin/zsh" ) . exists ( ) {
546- "/bin/zsh"
547- } else {
548- "/bin/bash"
549- } ;
544+ ///
545+ /// On Windows, resolves Git Bash via `resolve_bash_path` (skips `BUZZ_SHELL`
546+ /// since install commands require bash syntax). Returns `Err` when no shell
547+ /// can be found.
548+ fn install_shell_command ( command : & str ) -> Result < std :: process :: Command , String > {
549+ let shell : std :: path :: PathBuf = resolve_install_shell ( ) ? ;
550550
551- let mut cmd = std:: process:: Command :: new ( shell) ;
551+ let mut cmd = std:: process:: Command :: new ( & shell) ;
552552 cmd. args ( [ "-l" , "-c" , command] ) ;
553553
554554 // Strip hermit env vars so npm/node use the user's normal registry and
@@ -575,11 +575,62 @@ fn install_shell_command(command: &str) -> std::process::Command {
575575 }
576576 }
577577
578- cmd
578+ // Suppress the console window on Windows.
579+ #[ cfg( windows) ]
580+ {
581+ use std:: os:: windows:: process:: CommandExt ;
582+ const CREATE_NO_WINDOW : u32 = 0x0800_0000 ;
583+ cmd. creation_flags ( CREATE_NO_WINDOW ) ;
584+ }
585+
586+ Ok ( cmd)
587+ }
588+
589+ /// Resolve the shell binary for install commands.
590+ ///
591+ /// Unix: `/bin/zsh` if present, else `/bin/bash`.
592+ /// Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because install
593+ /// commands use bash-only `-l -c` syntax. A `BUZZ_SHELL=pwsh` user gets a green
594+ /// Doctor prereq (their agents work) but installs use the Git Bash fallback chain.
595+ fn resolve_install_shell ( ) -> Result < std:: path:: PathBuf , String > {
596+ #[ cfg( not( windows) ) ]
597+ {
598+ if std:: path:: Path :: new ( "/bin/zsh" ) . exists ( ) {
599+ return Ok ( std:: path:: PathBuf :: from ( "/bin/zsh" ) ) ;
600+ }
601+ Ok ( std:: path:: PathBuf :: from ( "/bin/bash" ) )
602+ }
603+
604+ #[ cfg( windows) ]
605+ {
606+ install_shell_from ( crate :: managed_agents:: git_bash:: resolve_bash_path ( ) )
607+ }
608+ }
609+
610+ /// Pure mapping from a resolved bash path to the install-shell result.
611+ /// `None` → `Err(GIT_BASH_INSTALL_HINT)`, `Some(path)` → `Ok(path)`.
612+ #[ cfg( windows) ]
613+ pub ( crate ) fn install_shell_from (
614+ resolved : Option < std:: path:: PathBuf > ,
615+ ) -> Result < std:: path:: PathBuf , String > {
616+ resolved. ok_or_else ( || crate :: managed_agents:: git_bash:: GIT_BASH_INSTALL_HINT . to_string ( ) )
579617}
580618
581619fn run_install_command ( step : & str , command : & str ) -> InstallStepResult {
582- let mut cmd = install_shell_command ( command) ;
620+ let mut cmd = match install_shell_command ( command) {
621+ Ok ( cmd) => cmd,
622+ Err ( hint) => {
623+ return InstallStepResult {
624+ step : step. to_string ( ) ,
625+ command : command. to_string ( ) ,
626+ success : false ,
627+ stdout : String :: new ( ) ,
628+ stderr : "no suitable shell found for install commands" . to_string ( ) ,
629+ exit_code : None ,
630+ hint : Some ( hint) ,
631+ } ;
632+ }
633+ } ;
583634
584635 let mut child = match cmd
585636 . stdin ( std:: process:: Stdio :: null ( ) )
@@ -641,6 +692,10 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult {
641692 unsafe {
642693 libc:: kill ( child_pid as i32 , libc:: SIGTERM ) ;
643694 }
695+ #[ cfg( windows) ]
696+ {
697+ let _ = crate :: managed_agents:: taskkill_tree ( child_pid) ;
698+ }
644699 drop ( rx) ;
645700 let _ = wait_thread. join ( ) ;
646701 let _ = stdout_thread. join ( ) ;
@@ -776,7 +831,10 @@ enum NpmPrefix {
776831/// `npm prefix -g` to discover where npm would install global packages.
777832#[ cfg( unix) ]
778833fn resolve_npm_prefix ( ) -> NpmPrefix {
779- let mut cmd = install_shell_command ( "npm prefix -g" ) ;
834+ let mut cmd = match install_shell_command ( "npm prefix -g" ) {
835+ Ok ( cmd) => cmd,
836+ Err ( _) => return NpmPrefix :: Unavailable ,
837+ } ;
780838 let mut child = match cmd
781839 . stdin ( std:: process:: Stdio :: null ( ) )
782840 . stdout ( std:: process:: Stdio :: piped ( ) )
@@ -1343,6 +1401,114 @@ mod tests {
13431401 "non-codex agent (None stamp) must never trigger drift badge"
13441402 ) ;
13451403 }
1404+
1405+ // ── Phase A: install shell selection ─────────────────────────────────────
1406+
1407+ /// On Unix, resolve_install_shell always succeeds (returns zsh or bash).
1408+ #[ cfg( unix) ]
1409+ #[ test]
1410+ fn test_resolve_install_shell_succeeds_on_unix ( ) {
1411+ let result = super :: resolve_install_shell ( ) ;
1412+ assert ! ( result. is_ok( ) , "Unix must always resolve a shell" ) ;
1413+ let shell = result. unwrap ( ) ;
1414+ assert ! (
1415+ shell == std:: path:: Path :: new( "/bin/zsh" ) || shell == std:: path:: Path :: new( "/bin/bash" ) ,
1416+ "expected /bin/zsh or /bin/bash, got {shell:?}"
1417+ ) ;
1418+ }
1419+
1420+ /// install_shell_command returns a valid Command on Unix.
1421+ #[ cfg( unix) ]
1422+ #[ test]
1423+ fn test_install_shell_command_returns_ok_on_unix ( ) {
1424+ let result = super :: install_shell_command ( "echo test" ) ;
1425+ assert ! ( result. is_ok( ) , "install_shell_command must succeed on Unix" ) ;
1426+ }
1427+
1428+ // ── Phase A: Windows install shell selection ───────────────────────────────
1429+
1430+ /// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds.
1431+ #[ cfg( windows) ]
1432+ #[ test]
1433+ fn test_resolve_install_shell_succeeds_on_windows_with_git ( ) {
1434+ let result = super :: resolve_install_shell ( ) ;
1435+ assert ! (
1436+ result. is_ok( ) ,
1437+ "Windows CI runner has Git — resolve_install_shell must succeed; got: {:?}" ,
1438+ result. err( )
1439+ ) ;
1440+ let shell = result. unwrap ( ) ;
1441+ // The resolved path must end with bash.exe (Git Bash).
1442+ let fname = shell. file_name ( ) . and_then ( |n| n. to_str ( ) ) . unwrap_or ( "" ) ;
1443+ assert ! (
1444+ fname. eq_ignore_ascii_case( "bash.exe" ) ,
1445+ "Windows install shell must be bash.exe, got: {shell:?}"
1446+ ) ;
1447+ }
1448+
1449+ /// On Windows, when no Git Bash is found, the error carries the Doctor hint.
1450+ #[ cfg( windows) ]
1451+ #[ test]
1452+ fn test_resolve_install_shell_error_contains_doctor_hint ( ) {
1453+ // We can't force resolve_install_shell to fail on CI (Git is installed),
1454+ // but we can verify the error string it would use matches the hint.
1455+ let hint = crate :: managed_agents:: git_bash:: GIT_BASH_INSTALL_HINT ;
1456+ assert ! (
1457+ hint. contains( "Git for Windows" ) ,
1458+ "GIT_BASH_INSTALL_HINT must mention Git for Windows; got: {hint}"
1459+ ) ;
1460+ assert ! (
1461+ hint. contains( "PATH" ) ,
1462+ "GIT_BASH_INSTALL_HINT must mention PATH option; got: {hint}"
1463+ ) ;
1464+ }
1465+
1466+ /// install_shell_command returns a valid Command on Windows.
1467+ #[ cfg( windows) ]
1468+ #[ test]
1469+ fn test_install_shell_command_returns_ok_on_windows ( ) {
1470+ let result = super :: install_shell_command ( "echo test" ) ;
1471+ assert ! (
1472+ result. is_ok( ) ,
1473+ "install_shell_command must succeed on Windows with Git; got: {:?}" ,
1474+ result. err( )
1475+ ) ;
1476+ }
1477+
1478+ // ── Phase B: per-OS install commands ──────────────────────────────────────
1479+
1480+ /// On non-Windows, cli_install_commands_for_os returns the default commands.
1481+ #[ cfg( not( windows) ) ]
1482+ #[ test]
1483+ fn test_cli_install_commands_for_os_returns_default_on_unix ( ) {
1484+ let claude = crate :: managed_agents:: known_acp_runtime_exact ( "claude" ) . unwrap ( ) ;
1485+ assert_eq ! (
1486+ claude. cli_install_commands_for_os( ) ,
1487+ claude. cli_install_commands,
1488+ "on Unix, cli_install_commands_for_os must return the default install.sh commands"
1489+ ) ;
1490+ }
1491+
1492+ /// Goose install commands are the same on all platforms (script is Windows-aware).
1493+ #[ test]
1494+ fn test_goose_install_commands_same_on_all_platforms ( ) {
1495+ let goose = crate :: managed_agents:: known_acp_runtime_exact ( "goose" ) . unwrap ( ) ;
1496+ assert_eq ! (
1497+ goose. cli_install_commands_for_os( ) ,
1498+ goose. cli_install_commands,
1499+ "goose install commands must be identical across platforms"
1500+ ) ;
1501+ }
1502+
1503+ /// buzz-agent has no install commands on any platform.
1504+ #[ test]
1505+ fn test_buzz_agent_has_no_install_commands ( ) {
1506+ let buzz = crate :: managed_agents:: known_acp_runtime_exact ( "buzz-agent" ) . unwrap ( ) ;
1507+ assert ! (
1508+ buzz. cli_install_commands_for_os( ) . is_empty( ) ,
1509+ "buzz-agent ships with the app — must never have install commands"
1510+ ) ;
1511+ }
13461512}
13471513
13481514/// Returns the Windows-only Git Bash prerequisite used by buzz-agent's shell MCP.
0 commit comments