diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 1f0aac58..99736cd8 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -30,6 +30,24 @@ MINIMUM_CLAUDE_CODE_VERSION = "2.0.0" +def _should_suppress_console_window() -> bool: + """Check if we should suppress console window for subprocesses on Windows. + + Returns True when running on Windows and the parent process has no console + attached (e.g., GUI app, PyInstaller bundle with console=False). + + Returns: + bool: True if CREATE_NO_WINDOW should be used, False otherwise. + """ + if sys.platform != "win32": + return False + + import ctypes + + # GetConsoleWindow() returns NULL (0) if no console is attached + return not ctypes.windll.kernel32.GetConsoleWindow() + + class SubprocessCLITransport(Transport): """Subprocess transport using Claude Code CLI.""" @@ -366,14 +384,25 @@ async def connect(self) -> None: # For backward compat: use debug_stderr file object if no callback and debug is on stderr_dest = PIPE if should_pipe_stderr else None + # Build kwargs dict for anyio.open_process + kwargs = { + "stdin": PIPE, + "stdout": PIPE, + "stderr": stderr_dest, + "cwd": self._cwd, + "env": process_env, + "user": self._options.user, + } + + # Windows: suppress console window if parent has no console + if _should_suppress_console_window(): + import subprocess + + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined] + self._process = await anyio.open_process( cmd, - stdin=PIPE, - stdout=PIPE, - stderr=stderr_dest, - cwd=self._cwd, - env=process_env, - user=self._options.user, + **kwargs, # type: ignore[arg-type] ) if self._process.stdout: @@ -589,10 +618,21 @@ async def _check_claude_version(self) -> None: version_process = None try: with anyio.fail_after(2): # 2 second timeout + # Build kwargs for anyio.open_process + kwargs = { + "stdout": PIPE, + "stderr": PIPE, + } + + # Windows: suppress console window if parent has no console + if _should_suppress_console_window(): + import subprocess + + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined] + version_process = await anyio.open_process( [self._cli_path, "-v"], - stdout=PIPE, - stderr=PIPE, + **kwargs, # type: ignore[arg-type] ) if version_process.stdout: diff --git a/tests/test_windows_console.py b/tests/test_windows_console.py new file mode 100644 index 00000000..2eec2fb8 --- /dev/null +++ b/tests/test_windows_console.py @@ -0,0 +1,44 @@ +"""Tests for Windows console window suppression.""" + +import sys +from unittest.mock import patch + +import pytest + +from claude_agent_sdk._internal.transport.subprocess_cli import ( + _should_suppress_console_window, +) + + +class TestShouldSuppressConsoleWindow: + """Tests for _should_suppress_console_window helper.""" + + def test_returns_false_on_non_windows(self): + """Should return False on non-Windows platforms.""" + with patch("sys.platform", "linux"): + result = _should_suppress_console_window() + assert result is False + + def test_returns_false_on_non_windows_macos(self): + """Should return False on macOS.""" + with patch("sys.platform", "darwin"): + result = _should_suppress_console_window() + assert result is False + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only test") + def test_returns_true_when_no_console_on_windows(self): + """Should return True on Windows when GetConsoleWindow returns 0.""" + # Mock ctypes to simulate no console + with patch("ctypes.windll.kernel32.GetConsoleWindow") as mock_get_console: + mock_get_console.return_value = 0 # No console + result = _should_suppress_console_window() + assert result is True + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only test") + def test_returns_false_when_console_exists_on_windows(self): + """Should return False on Windows when GetConsoleWindow returns non-zero.""" + # Mock ctypes to simulate console exists + with patch("ctypes.windll.kernel32.GetConsoleWindow") as mock_get_console: + mock_get_console.return_value = 12345 # Console handle exists + result = _should_suppress_console_window() + assert result is False diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py new file mode 100644 index 00000000..40035f09 --- /dev/null +++ b/tests/test_windows_integration.py @@ -0,0 +1,89 @@ +"""Integration tests for Windows subprocess creation flags.""" + +import subprocess +import sys +from contextlib import suppress +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only test") +class TestWindowsSubprocessCreation: + """Tests that CREATE_NO_WINDOW is passed on Windows GUI apps.""" + + async def test_connect_passes_creationflags_when_no_console(self, mock_claude_path): + """Test that connect() passes CREATE_NO_WINDOW when parent has no console.""" + from claude_agent_sdk import ClaudeSDKClient + from claude_agent_sdk.types import ClaudeAgentOptions + + # Mock no console attached and anyio.open_process to capture kwargs + with ( + patch( + "claude_agent_sdk._internal.transport.subprocess_cli._should_suppress_console_window", + return_value=True, + ), + patch( + "claude_agent_sdk._internal.transport.subprocess_cli.anyio.open_process" + ) as mock_open_process, + ): + # Setup mock process + mock_process = AsyncMock() + mock_process.stdout = None + mock_process.stderr = None + mock_process.stdin = None + mock_open_process.return_value = mock_process + + # Create client and connect + options = ClaudeAgentOptions(cli_path=mock_claude_path) + client = ClaudeSDKClient(options) + client._transport._cli_path = mock_claude_path + + with suppress(Exception): + await client._transport.connect() + + # Verify creationflags was passed + assert mock_open_process.called + call_kwargs = mock_open_process.call_args[1] + assert "creationflags" in call_kwargs + assert call_kwargs["creationflags"] == subprocess.CREATE_NO_WINDOW + + async def test_version_check_passes_creationflags_when_no_console( + self, mock_claude_path + ): + """Test that version check passes CREATE_NO_WINDOW when parent has no console.""" + from claude_agent_sdk import ClaudeSDKClient + from claude_agent_sdk.types import ClaudeAgentOptions + + # Mock no console attached and anyio.open_process to capture kwargs + with ( + patch( + "claude_agent_sdk._internal.transport.subprocess_cli._should_suppress_console_window", + return_value=True, + ), + patch( + "claude_agent_sdk._internal.transport.subprocess_cli.anyio.open_process" + ) as mock_open_process, + ): + # Setup mock process with version output + mock_process = AsyncMock() + mock_stdout = AsyncMock() + mock_stdout.receive = AsyncMock(return_value=b"2.1.61\n") + mock_process.stdout = mock_stdout + mock_process.terminate = MagicMock() + mock_process.wait = AsyncMock() + mock_open_process.return_value = mock_process + + # Create client and trigger version check + options = ClaudeAgentOptions(cli_path=mock_claude_path) + client = ClaudeSDKClient(options) + client._transport._cli_path = mock_claude_path + + with suppress(Exception): + await client._transport._check_claude_version() + + # Verify creationflags was passed + assert mock_open_process.called + call_kwargs = mock_open_process.call_args[1] + assert "creationflags" in call_kwargs + assert call_kwargs["creationflags"] == subprocess.CREATE_NO_WINDOW