From bbd6ca9f580a2164fa7840af1dac59c54db64444 Mon Sep 17 00:00:00 2001 From: HamzaETTH <73552421+HamzaETTH@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:30:29 -0500 Subject: [PATCH] feat(shell): add configurable shell support for Windows Add [shell] configuration section to allow Windows users to use bash (Git Bash, MSYS2, Cygwin, WSL) instead of being forced to use PowerShell. Motivation: LLMs tend to generate bash commands (e.g., with &&, ||, export) that fail in PowerShell. This change allows Windows users to use bash for better command compatibility with LLM-generated commands. Changes: - Add ShellConfig class with 'path' and 'preferred' options - Implement hybrid shell detection with priority order: 1. Explicit config path 2. SHELL environment variable (only when preferred='auto') 3. Auto-detect based on preferred setting 4. PowerShell fallback (backwards compatible) - Auto-detect bash from common Windows locations (Git Bash, MSYS2, Cygwin, WSL) - Treat fish as unknown shell (not bash-compatible) - Update shell tool to use correct args per shell type - Log warning when explicit shell path not found - Add comprehensive tests for shell detection - Update documentation (EN/ZH) Backwards compatible: default behavior unchanged (PowerShell on Windows). Example config: [shell] path = "C:/Program Files/Git/bin/bash.exe" # OR preferred = "bash" # auto, powershell, bash --- CHANGELOG.md | 2 + docs/en/configuration/config-files.md | 34 ++++ docs/en/release-notes/changelog.md | 2 + docs/zh/configuration/config-files.md | 34 ++++ docs/zh/release-notes/changelog.md | 2 + src/kimi_cli/config.py | 25 +++ src/kimi_cli/soul/agent.py | 2 +- src/kimi_cli/tools/shell/__init__.py | 4 +- src/kimi_cli/utils/environment.py | 140 ++++++++++++-- tests/core/test_config.py | 3 + tests/utils/test_utils_environment.py | 257 ++++++++++++++++++++++++++ 11 files changed, 484 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2affdefb..aa1e6a4d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Config: Auto-detect bash on Windows (Git Bash, MSYS2, Cygwin, WSL) and add `[shell]` configuration for explicit control + ## 1.5 (2026-01-30) - Web: Add Git diff status bar showing uncommitted changes in session working directory diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 1934bfdae..ca320ac9e 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -31,6 +31,7 @@ The configuration file contains the following top-level configuration items: | `loop_control` | `table` | Agent loop control parameters | | `services` | `table` | External service configuration (search, fetch) | | `mcp` | `table` | MCP client configuration | +| `shell` | `table` | Shell configuration (Windows only) | ### Complete configuration example @@ -156,6 +157,39 @@ When configuring the Kimi Code platform using the `/login` command, search and f | --- | --- | --- | --- | | `client.tool_call_timeout_ms` | `integer` | `60000` | MCP tool call timeout (milliseconds) | +### `shell` + +::: warning +This configuration is **Windows only**. On Unix-like systems, the shell is always determined in this priority order: `config.path` → `SHELL` environment variable → auto-detection. +::: + +`shell` configures the shell used for executing shell commands on Windows. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `string` | `null` | Explicit path to the shell executable. Highest priority. Example: `C:/Program Files/Git/bin/bash.exe` | +| `preferred` | `string` | `"auto"` | Preferred shell when path is not set. Options: `"auto"`, `"powershell"`, `"bash"` | + +**`preferred` options:** + +- `"auto"`: Check `SHELL` environment variable, then auto-detect bash from common locations (Git Bash, MSYS2, Cygwin), fallback to PowerShell +- `"powershell"`: Use PowerShell, ignoring `SHELL` environment variable +- `"bash"`: Auto-detect bash from common locations, ignoring `SHELL` environment variable + +Example: + +```toml +[shell] +path = "C:/Program Files/Git/bin/bash.exe" +``` + +Or use auto-detection: + +```toml +[shell] +preferred = "bash" # Auto-detect bash, ignoring SHELL environment variable +``` + ## JSON configuration migration If `~/.kimi/config.toml` doesn't exist but `~/.kimi/config.json` exists, Kimi Code CLI will automatically migrate the JSON configuration to TOML format and backup the original file as `config.json.bak`. diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index b61f9bf23..42d86f4ce 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Config: Add `[shell]` configuration section for Windows users to use bash (Git Bash, MSYS2, Cygwin) instead of PowerShell + ## 1.5 (2026-01-30) - Web: Add Git diff status bar showing uncommitted changes in session working directory diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 41846a3e7..e6fedf0ac 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -31,6 +31,7 @@ kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models" | `loop_control` | `table` | Agent 循环控制参数 | | `services` | `table` | 外部服务配置(搜索、抓取) | | `mcp` | `table` | MCP 客户端配置 | +| `shell` | `table` | Shell 配置(仅 Windows) | ### 完整配置示例 @@ -156,6 +157,39 @@ capabilities = ["thinking", "image_in"] | --- | --- | --- | --- | | `client.tool_call_timeout_ms` | `integer` | `60000` | MCP 工具调用超时时间(毫秒) | +### `shell` + +::: warning +此配置**仅适用于 Windows**。在类 Unix 系统上,Shell 始终按以下优先级确定:`config.path` → `SHELL` 环境变量 → 自动检测。 +::: + +`shell` 配置在 Windows 上执行 Shell 命令时使用的 Shell。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `path` | `string` | `null` | Shell 可执行文件的显式路径。优先级最高。示例:`C:/Program Files/Git/bin/bash.exe` | +| `preferred` | `string` | `"auto"` | 未设置 path 时的首选 Shell。选项:`"auto"`、`"powershell"`、`"bash"` | + +**`preferred` 选项说明:** + +- `"auto"`:检查 `SHELL` 环境变量,然后从常见位置自动检测 bash(Git Bash、MSYS2、Cygwin),最后回退到 PowerShell +- `"powershell"`:使用 PowerShell,忽略 `SHELL` 环境变量 +- `"bash"`:从常见位置自动检测 bash,忽略 `SHELL` 环境变量 + +示例: + +```toml +[shell] +path = "C:/Program Files/Git/bin/bash.exe" +``` + +或使用自动检测: + +```toml +[shell] +preferred = "bash" # 自动检测 bash,忽略 SHELL 环境变量 +``` + ## JSON 配置迁移 如果 `~/.kimi/config.toml` 不存在但 `~/.kimi/config.json` 存在,Kimi Code CLI 会自动将 JSON 配置迁移到 TOML 格式,并将原文件备份为 `config.json.bak`。 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 057dc1cb6..dc2c15f45 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Config:添加 `[shell]` 配置节,Windows 用户可使用 bash(Git Bash、MSYS2、Cygwin)替代 PowerShell + ## 1.5 (2026-01-30) - Web:添加 Git diff 状态栏,显示会话工作目录中的未提交更改 diff --git a/src/kimi_cli/config.py b/src/kimi_cli/config.py index 22cc5855b..942ed0094 100644 --- a/src/kimi_cli/config.py +++ b/src/kimi_cli/config.py @@ -129,6 +129,28 @@ class MCPConfig(BaseModel): ) +class ShellConfig(BaseModel): + """Shell configuration for command execution. + + Note: The `preferred` option is Windows-only. On Unix-like systems, + the shell is always determined by: config.path > SHELL env var > auto-detect. + """ + + path: str | None = Field(default=None, description="Explicit path to shell executable") + """Explicit path to shell executable. Highest priority on all platforms. + Example: 'C:/Program Files/Git/bin/bash.exe'""" + + preferred: Literal["auto", "powershell", "bash"] = Field(default="auto") + """Preferred shell when path is not set (Windows only). + - "auto": Check SHELL env var, then auto-detect bash, then PowerShell fallback + - "powershell": Use PowerShell, bypass SHELL env var + - "bash": Auto-detect bash, bypass SHELL env var + + On Unix-like systems, this option is ignored; shell selection follows + the standard priority: config.path > SHELL env var > auto-detect. + """ + + class Config(BaseModel): """Main configuration structure.""" @@ -146,6 +168,8 @@ class Config(BaseModel): loop_control: LoopControl = Field(default_factory=LoopControl, description="Agent loop control") services: Services = Field(default_factory=Services, description="Services configuration") mcp: MCPConfig = Field(default_factory=MCPConfig, description="MCP configuration") + shell: ShellConfig = Field(default_factory=ShellConfig, description="Shell configuration") + """Shell configuration for command execution.""" @model_validator(mode="after") def validate_model(self) -> Self: @@ -169,6 +193,7 @@ def get_default_config() -> Config: models={}, providers={}, services=Services(), + shell=ShellConfig(), ) diff --git a/src/kimi_cli/soul/agent.py b/src/kimi_cli/soul/agent.py index 2236070da..601de26dd 100644 --- a/src/kimi_cli/soul/agent.py +++ b/src/kimi_cli/soul/agent.py @@ -86,7 +86,7 @@ async def create( ls_output, agents_md, environment = await asyncio.gather( list_directory(session.work_dir), load_agents_md(session.work_dir), - Environment.detect(), + Environment.detect(config.shell), ) # Discover and format skills diff --git a/src/kimi_cli/tools/shell/__init__.py b/src/kimi_cli/tools/shell/__init__.py index 15b279d9a..6a302872a 100644 --- a/src/kimi_cli/tools/shell/__init__.py +++ b/src/kimi_cli/tools/shell/__init__.py @@ -122,6 +122,8 @@ async def _read_stream(stream: AsyncReadable, cb: Callable[[bytes], None]): raise def _shell_args(self, command: str) -> tuple[str, ...]: + """Generate shell arguments based on shell type.""" if self._is_powershell: - return (str(self._shell_path), "-command", command) + return (str(self._shell_path), "-Command", command) + # bash, zsh, fish, and sh all use -c flag return (str(self._shell_path), "-c", command) diff --git a/src/kimi_cli/utils/environment.py b/src/kimi_cli/utils/environment.py index 6b4bb78ad..66e1d90b6 100644 --- a/src/kimi_cli/utils/environment.py +++ b/src/kimi_cli/utils/environment.py @@ -1,22 +1,28 @@ from __future__ import annotations +import os import platform from dataclasses import dataclass from typing import Literal from kaos.path import KaosPath +from kimi_cli.config import ShellConfig +from kimi_cli.utils.logging import logger + @dataclass(slots=True, frozen=True, kw_only=True) class Environment: os_kind: Literal["Windows", "Linux", "macOS"] | str os_arch: str os_version: str - shell_name: Literal["bash", "sh", "Windows PowerShell"] + shell_name: Literal["bash", "sh", "Windows PowerShell", "zsh"] shell_path: KaosPath @staticmethod - async def detect() -> Environment: + async def detect(shell_config: ShellConfig | None = None) -> Environment: + """Detect environment with optional shell configuration.""" + # Detect OS match platform.system(): case "Darwin": os_kind = "macOS" @@ -30,29 +36,125 @@ async def detect() -> Environment: os_arch = platform.machine() os_version = platform.version() + # Determine shell based on OS if os_kind == "Windows": - shell_name = "Windows PowerShell" - shell_path = KaosPath("powershell.exe") + shell_name, shell_path = await Environment._determine_windows_shell(shell_config) else: - possible_paths = [ - KaosPath("/bin/bash"), - KaosPath("/usr/bin/bash"), - KaosPath("/usr/local/bin/bash"), - ] - fallback_path = KaosPath("/bin/sh") - for path in possible_paths: - if await path.is_file(): - shell_name = "bash" - shell_path = path - break - else: - shell_name = "sh" - shell_path = fallback_path + shell_name, shell_path = await Environment._determine_unix_shell(shell_config) return Environment( os_kind=os_kind, os_arch=os_arch, os_version=os_version, - shell_name=shell_name, + shell_name=shell_name, # type: ignore[reportReturnType] shell_path=shell_path, ) + + @staticmethod + async def _determine_windows_shell( + shell_config: ShellConfig | None = None, + ) -> tuple[str, KaosPath]: + """Determine shell on Windows with priority: config > env var > auto-detect > fallback.""" + config = shell_config or ShellConfig() + + # Priority 1: Explicit path in config + if config.path: + path = KaosPath(config.path.replace("\\", "/")) + if await path.is_file(): + shell_name = Environment._infer_shell_name(str(path)) + return shell_name, path + logger.warning( + "Configured shell path not found: {path}, will use fallback shell detection", + path=config.path, + ) + + # Priority 2: SHELL environment variable (only when preferred="auto") + # This respects the user's terminal setup without requiring config changes + if config.preferred == "auto" and (env_shell := os.environ.get("SHELL")): + path = KaosPath(env_shell.replace("\\", "/")) + if await path.is_file(): + shell_name = Environment._infer_shell_name(str(path)) + return shell_name, path + + # Priority 3: Auto-detect bash if preferred is "auto" or "bash" + if config.preferred in ("auto", "bash"): + bash_paths = [ + # Git Bash - standard locations + KaosPath("C:/Program Files/Git/bin/bash.exe"), + KaosPath("C:/Program Files (x86)/Git/bin/bash.exe"), + KaosPath(os.path.expanduser("~/AppData/Local/Programs/Git/bin/bash.exe")), + # MSYS2 + KaosPath("C:/msys64/usr/bin/bash.exe"), + KaosPath("C:/msys32/usr/bin/bash.exe"), + # Cygwin + KaosPath("C:/cygwin64/bin/bash.exe"), + KaosPath("C:/cygwin/bin/bash.exe"), + # WSL + KaosPath("C:/Windows/System32/bash.exe"), + ] + + for path in bash_paths: + if await path.is_file(): + return "bash", path + + # Priority 4: Fallback to PowerShell (backwards compatible default) + return "Windows PowerShell", KaosPath("powershell.exe") + + @staticmethod + async def _determine_unix_shell( + shell_config: ShellConfig | None = None, + ) -> tuple[str, KaosPath]: + """Determine shell on Unix-like systems. + + Note: config.preferred is intentionally not used on Unix. The standard + Unix convention is to respect SHELL env var and auto-detect from well-known + paths. Use config.path for explicit control. + """ + config = shell_config or ShellConfig() + + # Priority 1: Explicit path in config + if config.path: + path = KaosPath(config.path) + if await path.is_file(): + shell_name = Environment._infer_shell_name(str(path)) + return shell_name, path + logger.warning( + "Configured shell path not found: {path}, will use fallback shell detection", + path=config.path, + ) + + # Priority 2: SHELL environment variable + if env_shell := os.environ.get("SHELL"): + path = KaosPath(env_shell) + if await path.is_file(): + shell_name = Environment._infer_shell_name(str(path)) + return shell_name, path + + # Priority 3: Auto-detect common shells + possible_paths = [ + KaosPath("/bin/bash"), + KaosPath("/usr/bin/bash"), + KaosPath("/usr/local/bin/bash"), + ] + fallback_path = KaosPath("/bin/sh") + + for path in possible_paths: + if await path.is_file(): + return "bash", path + + return "sh", fallback_path + + @staticmethod + def _infer_shell_name( + path: str, + ) -> Literal["bash", "sh", "Windows PowerShell", "zsh"]: + """Infer shell name from executable path.""" + path_lower = path.lower() + if "powershell" in path_lower or "pwsh" in path_lower: + return "Windows PowerShell" + elif "bash" in path_lower: + return "bash" + elif "zsh" in path_lower: + return "zsh" + else: + return "sh" diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 4f81f7093..a8f8d9422 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -6,6 +6,7 @@ from kimi_cli.config import ( Config, Services, + ShellConfig, get_default_config, load_config_from_string, ) @@ -21,6 +22,7 @@ def test_default_config(): models={}, providers={}, services=Services(), + shell=ShellConfig(), ) ) @@ -41,6 +43,7 @@ def test_default_config_dump(): }, "services": {"moonshot_search": None, "moonshot_fetch": None}, "mcp": {"client": {"tool_call_timeout_ms": 60000}}, + "shell": {"path": None, "preferred": "auto"}, } ) diff --git a/tests/utils/test_utils_environment.py b/tests/utils/test_utils_environment.py index abb3a4ae4..840041219 100644 --- a/tests/utils/test_utils_environment.py +++ b/tests/utils/test_utils_environment.py @@ -1,11 +1,23 @@ +"""Tests for environment detection utilities.""" + import platform import pytest from kaos.path import KaosPath +from kimi_cli.config import ShellConfig +from kimi_cli.utils import environment as environment_module + + +@pytest.fixture(autouse=True) +def reset_environment_cache(): + """Reset any module-level caches before each test.""" + yield + @pytest.mark.skipif(platform.system() == "Windows", reason="Skipping test on Windows") async def test_environment_detection(monkeypatch): + """Test basic environment detection on non-Windows systems.""" monkeypatch.setattr(platform, "system", lambda: "Linux") monkeypatch.setattr(platform, "machine", lambda: "x86_64") monkeypatch.setattr(platform, "version", lambda: "5.15.0-123-generic") @@ -27,10 +39,25 @@ async def _mock_is_file(self: KaosPath) -> bool: @pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") async def test_environment_detection_windows(monkeypatch): + """Test basic environment detection on Windows without config - uses real system state. + + Note: This test verifies that the detection works with the actual system configuration. + If Git Bash is installed and SHELL is set, bash will be detected. + If not, PowerShell will be used as fallback. + """ monkeypatch.setattr(platform, "system", lambda: "Windows") monkeypatch.setattr(platform, "machine", lambda: "AMD64") monkeypatch.setattr(platform, "version", lambda: "10.0.19044") + # Remove SHELL env var to test fallback behavior + monkeypatch.delenv("SHELL", raising=False) + + # Mock out all bash paths so no bash is found + async def _mock_is_file_no_bash(self: KaosPath) -> bool: + return False + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file_no_bash) + from kimi_cli.utils.environment import Environment env = await Environment.detect() @@ -39,3 +66,233 @@ async def test_environment_detection_windows(monkeypatch): assert env.os_version == "10.0.19044" assert env.shell_name == "Windows PowerShell" assert str(env.shell_path) == "powershell.exe" + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_config_path_explicit(monkeypatch): + """Test Windows shell detection with explicit config path.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.delenv("SHELL", raising=False) + + async def _mock_is_file(self: KaosPath) -> bool: + return "Git/bin/bash.exe" in str(self).replace("\\", "/") + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + + config = ShellConfig(path="C:/Program Files/Git/bin/bash.exe") + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + assert shell_name == "bash" + assert "bash.exe" in str(shell_path) + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_config_preferred_bash(monkeypatch): + """Test Windows shell detection with preferred=bash.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.delenv("SHELL", raising=False) + + async def _mock_is_file(self: KaosPath) -> bool: + return "Git/bin/bash.exe" in str(self).replace("\\", "/") + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + + config = ShellConfig(preferred="bash") + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + assert shell_name == "bash" + assert "bash.exe" in str(shell_path) + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_config_preferred_powershell(monkeypatch): + """Test Windows shell detection with preferred=powershell.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.delenv("SHELL", raising=False) + + # Mock out bash detection so it doesn't accidentally find bash + async def _mock_is_file(self: KaosPath) -> bool: + return False + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + + config = ShellConfig(preferred="powershell") + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + assert shell_name == "Windows PowerShell" + assert str(shell_path) == "powershell.exe" + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_preferred_powershell_ignores_shell_env_var(monkeypatch): + """Test that preferred=powershell ignores SHELL environment variable.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + + # SHELL is set to bash, but preferred is "powershell" + async def _mock_is_file(self: KaosPath) -> bool: + return "bash.exe" in str(self).replace("\\", "/") + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + monkeypatch.setenv("SHELL", "C:/Program Files/Git/bin/bash.exe") + + config = ShellConfig(preferred="powershell") + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + # Should use PowerShell, not bash from SHELL env var + assert shell_name == "Windows PowerShell" + assert str(shell_path) == "powershell.exe" + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_env_var_shell(monkeypatch): + """Test Windows shell detection respects SHELL env var.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + + async def _mock_is_file(self: KaosPath) -> bool: + return "custom/bash.exe" in str(self).replace("\\", "/") + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + monkeypatch.setenv("SHELL", "C:/custom/bash.exe") + + config = ShellConfig() # default auto + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + assert shell_name == "bash" + assert "custom" in str(shell_path) and "bash.exe" in str(shell_path) + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_config_path_takes_precedence_over_env(monkeypatch): + """Test that config.path takes precedence over SHELL env var.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + + async def _mock_is_file(self: KaosPath) -> bool: + path_str = str(self).replace("\\", "/") + return "Git/bin/bash.exe" in path_str or "env/bash.exe" in path_str + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + monkeypatch.setenv("SHELL", "C:/env/bash.exe") + + config = ShellConfig(path="C:/Program Files/Git/bin/bash.exe") + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + assert shell_name == "bash" + assert "Git" in str(shell_path) and "bash.exe" in str(shell_path) + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_fallback_when_explicit_path_missing(monkeypatch): + """Test fallback to PowerShell when explicit path doesn't exist.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.delenv("SHELL", raising=False) + + async def _mock_is_file(self: KaosPath) -> bool: + return False # No bash found + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + + config = ShellConfig(path="C:/NonExistent/bash.exe") + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + assert shell_name == "Windows PowerShell" + assert str(shell_path) == "powershell.exe" + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Skipping test on non-Windows") +async def test_windows_shell_config_path_backslash_normalized(monkeypatch): + """Test that backslashes in config path are normalized to forward slashes.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.delenv("SHELL", raising=False) + + async def _mock_is_file(self: KaosPath) -> bool: + return "Git/bin/bash.exe" in str(self).replace("\\", "/") + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + + # Use backslashes in path + config = ShellConfig(path="C:\\Program Files\\Git\\bin\\bash.exe") + shell_name, shell_path = await environment_module.Environment._determine_windows_shell(config) + + assert shell_name == "bash" + # Path normalization converts backslashes to forward slashes for KaosPath + assert "bash.exe" in str(shell_path) + + +def test_infer_shell_name(): + """Test shell name inference from path.""" + assert environment_module.Environment._infer_shell_name("/bin/bash") == "bash" + assert environment_module.Environment._infer_shell_name("/usr/bin/bash") == "bash" + assert ( + environment_module.Environment._infer_shell_name("C:/Program Files/Git/bin/bash.exe") + == "bash" + ) + assert ( + environment_module.Environment._infer_shell_name("powershell.exe") == "Windows PowerShell" + ) + assert ( + environment_module.Environment._infer_shell_name( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" + ) + == "Windows PowerShell" + ) + assert environment_module.Environment._infer_shell_name("pwsh") == "Windows PowerShell" + assert environment_module.Environment._infer_shell_name("/bin/zsh") == "zsh" + assert environment_module.Environment._infer_shell_name("/usr/bin/zsh") == "zsh" + # Fish is treated as unknown shell (defaults to sh) since it's not bash-compatible + assert environment_module.Environment._infer_shell_name("/bin/fish") == "sh" + assert environment_module.Environment._infer_shell_name("/bin/sh") == "sh" + assert environment_module.Environment._infer_shell_name("/unknown/shell") == "sh" + + +@pytest.mark.skipif(platform.system() == "Windows", reason="Skipping test on Windows") +async def test_unix_shell_config_path_explicit(monkeypatch): + """Test Unix shell detection with explicit config path.""" + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.delenv("SHELL", raising=False) + + async def _mock_is_file(self: KaosPath) -> bool: + return str(self) == "/custom/zsh" + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + + config = ShellConfig(path="/custom/zsh") + shell_name, shell_path = await environment_module.Environment._determine_unix_shell(config) + + assert shell_name == "zsh" + assert str(shell_path) == "/custom/zsh" + + +@pytest.mark.skipif(platform.system() == "Windows", reason="Skipping test on Windows") +async def test_unix_shell_env_var(monkeypatch): + """Test Unix shell detection respects SHELL env var.""" + monkeypatch.setattr(platform, "system", lambda: "Linux") + + async def _mock_is_file(self: KaosPath) -> bool: + return str(self) == "/usr/bin/zsh" + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + monkeypatch.setenv("SHELL", "/usr/bin/zsh") + + config = ShellConfig() + shell_name, shell_path = await environment_module.Environment._determine_unix_shell(config) + + assert shell_name == "zsh" + assert str(shell_path) == "/usr/bin/zsh" + + +@pytest.mark.skipif(platform.system() == "Windows", reason="Skipping test on Windows") +async def test_unix_shell_fallback_to_sh(monkeypatch): + """Test Unix shell fallback to sh when no bash found.""" + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.delenv("SHELL", raising=False) + + async def _mock_is_file(self: KaosPath) -> bool: + return False # No bash found + + monkeypatch.setattr(KaosPath, "is_file", _mock_is_file) + + config = ShellConfig() + shell_name, shell_path = await environment_module.Environment._determine_unix_shell(config) + + assert shell_name == "sh" + assert str(shell_path) == "/bin/sh"