Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions tests/test_windows_console.py
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions tests/test_windows_integration.py
Original file line number Diff line number Diff line change
@@ -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