@@ -436,6 +436,81 @@ def find_command_robust(cmd: str, fallback_paths: list[str] | None = None) -> st
436436 return None
437437
438438
439+ def find_bash_windows () -> str | None :
440+ """Find Git Bash on Windows.
441+
442+ Git Bash is required for Claude Code on Windows and provides consistent
443+ cross-platform bash behavior for CLI command execution.
444+
445+ Returns:
446+ Full path to bash.exe if found, None otherwise.
447+ """
448+ # Check CLAUDE_CODE_GIT_BASH_PATH env var
449+ env_path = os .environ .get ('CLAUDE_CODE_GIT_BASH_PATH' )
450+ if env_path and Path (env_path ).exists ():
451+ return str (Path (env_path ).resolve ())
452+
453+ # Check if bash is in PATH
454+ bash_path = find_command ('bash.exe' )
455+ if bash_path :
456+ return bash_path
457+
458+ # Check common locations
459+ common_paths = [
460+ r'C:\Program Files\Git\bin\bash.exe' ,
461+ r'C:\Program Files\Git\usr\bin\bash.exe' ,
462+ r'C:\Program Files (x86)\Git\bin\bash.exe' ,
463+ r'C:\Program Files (x86)\Git\usr\bin\bash.exe' ,
464+ os .path .expandvars (r'%LOCALAPPDATA%\Programs\Git\bin\bash.exe' ),
465+ os .path .expandvars (r'%LOCALAPPDATA%\Programs\Git\usr\bin\bash.exe' ),
466+ ]
467+
468+ for path in common_paths :
469+ expanded = os .path .expandvars (path )
470+ if Path (expanded ).exists ():
471+ return str (Path (expanded ).resolve ())
472+
473+ return None
474+
475+
476+ def run_bash_command (
477+ command : str ,
478+ capture_output : bool = True ,
479+ login_shell : bool = False ,
480+ ) -> subprocess .CompletedProcess [str ]:
481+ """Execute command via bash (Git Bash on Windows, native bash on Unix).
482+
483+ Provides consistent cross-platform behavior for CLI command execution.
484+ Uses Git Bash on Windows and native bash on Unix systems.
485+
486+ Args:
487+ command: The bash command string to execute
488+ capture_output: Whether to capture stdout/stderr
489+ login_shell: Whether to use login shell (-l flag)
490+
491+ Returns:
492+ subprocess.CompletedProcess with the result
493+ """
494+ if sys .platform == 'win32' :
495+ bash_path = find_bash_windows ()
496+ else :
497+ bash_path = shutil .which ('bash' )
498+
499+ if not bash_path :
500+ error ('Bash not found!' )
501+ return subprocess .CompletedProcess ([], 1 , '' , 'bash not found' )
502+
503+ args = [bash_path ]
504+ if login_shell :
505+ args .append ('-l' )
506+ args .extend (['-c' , command ])
507+
508+ try :
509+ return subprocess .run (args , capture_output = capture_output , text = True )
510+ except FileNotFoundError :
511+ return subprocess .CompletedProcess (args , 1 , '' , f'bash not found: { bash_path } ' )
512+
513+
439514def expand_tildes_in_command (command : str ) -> str :
440515 """Expand tilde paths in a shell command.
441516
@@ -2544,7 +2619,8 @@ def execute_dependency(dep: str) -> bool:
25442619 parts_with_force = parts [:3 ] + ['--force' ] + parts [3 :]
25452620 result = run_command (parts_with_force , capture_output = False )
25462621 else :
2547- result = run_command (['powershell' , '-Command' , dep ], capture_output = False )
2622+ # Use bash for consistent cross-platform behavior
2623+ result = run_bash_command (dep , capture_output = False )
25482624 else :
25492625 if parts [0 ] == 'uv' and len (parts ) >= 3 and parts [1 ] == 'tool' and parts [2 ] == 'install' :
25502626 dep_with_force = dep .replace ('uv tool install' , 'uv tool install --force' )
@@ -3338,72 +3414,35 @@ def configure_mcp_server(server: dict[str, Any]) -> bool:
33383414 if header :
33393415 base_cmd .extend (['--header' , header ])
33403416
3341- # Try with PowerShell environment reload on Windows
3417+ # Windows HTTP transport - use bash for consistent cross-platform behavior
3418+ # This eliminates PowerShell's exit code quirks and CMD escaping issues
33423419 if system == 'Windows' :
33433420 # Build explicit PATH including Node.js location
3344- # This fixes Windows 10+ PATH propagation bug where MSI registry updates
3345- # don't propagate to running processes via WM_SETTINGCHANGE
33463421 nodejs_path = r'C:\Program Files\nodejs'
33473422 current_path = os .environ .get ('PATH' , '' )
33483423
3349- # Ensure Node.js is in PATH
3424+ # Use POSIX path separator (:) since we're running in bash
33503425 if Path (nodejs_path ).exists () and nodejs_path not in current_path :
3351- explicit_path = f'{ nodejs_path } ; { current_path } '
3426+ explicit_path = f'{ nodejs_path } : { current_path } '
33523427 else :
33533428 explicit_path = current_path
33543429
3355- # On Windows, we need to spawn a completely new shell process
3356- # Use explicit PATH instead of reading from registry
3357- # Build env flags for PowerShell command
33583430 env_flags = ' ' .join (f'--env "{ e } "' for e in env_list ) if env_list else ''
33593431 env_part = f' { env_flags } ' if env_flags else ''
3432+ header_part = f' --header "{ header } "' if header else ''
33603433
3361- ps_script = f'''
3362- $env:Path = "{ explicit_path } "
3363- & "{ claude_cmd } " mcp add --scope { scope } { name } { env_part } --transport { transport } "{ url } "
3364- exit $LASTEXITCODE
3365- '''
3366- if header :
3367- ps_script = f'''
3368- $env:Path = "{ explicit_path } "
3369- & "{ claude_cmd } " mcp add --scope { scope } { name } { env_part } --transport { transport } --header "{ header } " "{ url } "
3370- exit $LASTEXITCODE
3371- '''
3372- result = run_command (
3373- [
3374- 'powershell' ,
3375- '-NoProfile' ,
3376- '-Command' ,
3377- ps_script ,
3378- ],
3379- capture_output = True ,
3434+ bash_cmd = (
3435+ f'export PATH="{ explicit_path } :$PATH" && '
3436+ f'"{ claude_cmd } " mcp add --scope { scope } { name } { env_part } '
3437+ f'--transport { transport } { header_part } "{ url } "'
33803438 )
33813439
3382- # Also try with direct execution using shell=True
3383- if result .returncode != 0 :
3384- info ('Trying direct execution...' )
3385- # Use shell=True with double-quoted URL for Windows CMD compatibility
3386- # This ensures & in URLs is not interpreted as a command separator
3387- url_quoted = f'"{ url } "'
3388- header_part = f' --header "{ header } "' if header else ''
3389- cmd_str = (
3390- f'"{ claude_cmd } " mcp add --scope { scope } { name } { env_part } '
3391- f'--transport { transport } { header_part } { url_quoted } '
3392- )
3393- result = subprocess .run (cmd_str , shell = True , capture_output = True , text = True )
3440+ result = run_bash_command (bash_cmd , capture_output = True , login_shell = True )
33943441 else :
3395- # On Unix, spawn new bash with updated PATH
3442+ # On Unix, use bash with updated PATH (consistent with Windows)
33963443 parent_dir = Path (claude_cmd ).parent
33973444 bash_cmd = f'export PATH="{ parent_dir } :$PATH" && ' + ' ' .join (shlex .quote (str (arg )) for arg in base_cmd )
3398- result = run_command (
3399- [
3400- 'bash' ,
3401- '-l' ,
3402- '-c' ,
3403- bash_cmd ,
3404- ],
3405- capture_output = True ,
3406- )
3445+ result = run_bash_command (bash_cmd , capture_output = True , login_shell = True )
34073446 elif command :
34083447 # Stdio transport (command)
34093448
@@ -3454,20 +3493,18 @@ def configure_mcp_server(server: dict[str, Any]) -> bool:
34543493 time .sleep (2 )
34553494
34563495 # Direct execution with full path
3457- # On Windows with HTTP transport, use shell=True with double-quoted URL
3496+ # On Windows with HTTP transport, use bash for consistent behavior
34583497 if sys .platform == 'win32' and transport and url :
3459- # Use shell=True with double-quoted URL for Windows CMD compatibility
3460- # This ensures & in URLs is not interpreted as a command separator
3461- url_quoted = f'"{ url } "'
34623498 env_flags = ' ' .join (f'--env "{ e } "' for e in env_list ) if env_list else ''
34633499 env_part_retry = f' { env_flags } ' if env_flags else ''
34643500 header_part = f' --header "{ header } "' if header else ''
3465- cmd_str = (
3501+
3502+ bash_cmd = (
34663503 f'"{ claude_cmd } " mcp add --scope { scope } { name } { env_part_retry } '
3467- f'--transport { transport } { header_part } { url_quoted } '
3504+ f'--transport { transport } { header_part } " { url } " '
34683505 )
3469- info (f'Retrying with direct command: { cmd_str } ' )
3470- result = subprocess . run ( cmd_str , shell = True , capture_output = False , text = True )
3506+ info (f'Retrying with bash command: { bash_cmd } ' )
3507+ result = run_bash_command ( bash_cmd , capture_output = False )
34713508 else :
34723509 info (f"Retrying with direct command: { ' ' .join (str (arg ) for arg in base_cmd )} " )
34733510 result = run_command (base_cmd , capture_output = False ) # Show output for debugging
0 commit comments