From 72280d8b4f74a4d3569dd2dcda6f892024f8dbda Mon Sep 17 00:00:00 2001 From: Lina Tawfik Date: Thu, 3 Jul 2025 11:15:41 -0700 Subject: [PATCH 01/47] Add Claude issue triage workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Uses claude-code-base-action for automated issue triage - Analyzes new issues and applies appropriate labels - Uses GitHub MCP server for issue operations - Requires ANTHROPIC_API_KEY secret to be configured 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Rushil Patel --- .github/workflows/claude-issue-triage.yml | 106 ++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/claude-issue-triage.yml diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml new file mode 100644 index 00000000..1ad47672 --- /dev/null +++ b/.github/workflows/claude-issue-triage.yml @@ -0,0 +1,106 @@ +name: Claude Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage-issue: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create triage prompt + run: | + mkdir -p /tmp/claude-prompts + cat > /tmp/claude-prompts/triage-prompt.txt << 'EOF' + You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list. + + IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels. + + Issue Information: + - REPO: ${{ github.repository }} + - ISSUE_NUMBER: ${{ github.event.issue.number }} + + TASK OVERVIEW: + + 1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else. + + 2. Next, use the GitHub tools to get context about the issue: + - You have access to these tools: + - mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels + - mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments + - mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting) + - mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues + - mcp__github__list_issues: Use this to understand patterns in how other issues are labeled + - Start by using mcp__github__get_issue to get the issue details + + 3. Analyze the issue content, considering: + - The issue title and description + - The type of issue (bug report, feature request, question, etc.) + - Technical areas mentioned + - Severity or priority indicators + - User impact + - Components affected + + 4. Select appropriate labels from the available labels list provided above: + - Choose labels that accurately reflect the issue's nature + - Be specific but comprehensive + - Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority) + - Consider platform labels (android, ios) if applicable + - If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue. + + 5. Apply the selected labels: + - Use mcp__github__update_issue to apply your selected labels + - DO NOT post any comments explaining your decision + - DO NOT communicate directly with users + - If no labels are clearly applicable, do not apply any labels + + IMPORTANT GUIDELINES: + - Be thorough in your analysis + - Only select labels from the provided list above + - DO NOT post any comments to the issue + - Your ONLY action should be to apply labels using mcp__github__update_issue + - It's okay to not add any labels if none are clearly applicable + EOF + + - name: Setup GitHub MCP Server + run: | + mkdir -p /tmp/mcp-config + cat > /tmp/mcp-config/mcp-servers.json << 'EOF' + { + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:sha-7aced2b" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" + } + } + } + } + EOF + + - name: Run Claude Code for Issue Triage + uses: anthropics/claude-code-base-action@beta + with: + prompt_file: /tmp/claude-prompts/triage-prompt.txt + allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues" + timeout_minutes: "5" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + mcp_config: /tmp/mcp-config/mcp-servers.json + claude_env: | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 6b3ed6eaf0c1c79bb3f3d8ee0807627b41bea799 Mon Sep 17 00:00:00 2001 From: Romain Gehrig Date: Thu, 26 Jun 2025 12:09:25 +0200 Subject: [PATCH 02/47] Explicit error if the cwd does not exist Previously was raised as a CLINotFoundError Signed-off-by: Rushil Patel --- .../_internal/transport/subprocess_cli.py | 5 +++++ tests/test_transport.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index f4fbc58a..c283f425 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -141,6 +141,11 @@ async def connect(self) -> None: self._stderr_stream = TextReceiveStream(self._process.stderr) except FileNotFoundError as e: + # Check if the error comes from the working directory or the CLI + if self._cwd and not Path(self._cwd).exists(): + raise CLIConnectionError( + f"Working directory does not exist: {self._cwd}" + ) from e raise CLINotFoundError(f"Claude Code not found at: {self._cli_path}") from e except Exception as e: raise CLIConnectionError(f"Failed to start Claude Code: {e}") from e diff --git a/tests/test_transport.py b/tests/test_transport.py index 65702bc7..c8d8e51f 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -132,3 +132,21 @@ def test_receive_messages(self): # So we just verify the transport can be created and basic structure is correct assert transport._prompt == "test" assert transport._cli_path == "/usr/bin/claude" + + def test_connect_with_nonexistent_cwd(self): + """Test that connect raises CLIConnectionError when cwd doesn't exist.""" + from claude_code_sdk._errors import CLIConnectionError + + async def _test(): + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(cwd="/this/directory/does/not/exist"), + cli_path="/usr/bin/claude", + ) + + with pytest.raises(CLIConnectionError) as exc_info: + await transport.connect() + + assert "/this/directory/does/not/exist" in str(exc_info.value) + + anyio.run(_test) From 17b46692c6c1a33002ca40211ae0210f8086af41 Mon Sep 17 00:00:00 2001 From: Rushil Patel Date: Wed, 16 Jul 2025 15:42:35 -0700 Subject: [PATCH 03/47] fix: expose transport interface Signed-off-by: Rushil Patel --- src/claude_code_sdk/__init__.py | 19 +++++++++++++++++-- src/claude_code_sdk/_internal/client.py | 15 ++++++++++----- .../_internal/transport/__init__.py | 1 + src/claude_code_sdk/types.py | 2 +- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index b8a11525..9d1544d9 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -11,6 +11,7 @@ ProcessError, ) from ._internal.client import InternalClient +from ._internal.transport import Transport from .types import ( AssistantMessage, ClaudeCodeOptions, @@ -31,6 +32,8 @@ __all__ = [ # Main function "query", + # Transport + "Transport", # Types "PermissionMode", "McpServerConfig", @@ -54,7 +57,7 @@ async def query( - *, prompt: str, options: ClaudeCodeOptions | None = None + *, prompt: str, options: ClaudeCodeOptions | None = None, transport: Transport | None = None ) -> AsyncIterator[Message]: """ Query Claude Code. @@ -69,6 +72,8 @@ async def query( - 'acceptEdits': Auto-accept file edits - 'bypassPermissions': Allow all tools (use with caution) Set options.cwd for working directory. + transport: Optional transport implementation. If provided, this will be used + instead of the default transport selection based on options. Yields: Messages from the conversation @@ -89,6 +94,16 @@ async def query( ) ): print(message) + + # With custom transport + async for message in query( + prompt="Hello", + transport=MyCustomTransport() + ): + print(message) + + async for message in query(prompt="Hello", transport=transport): + print(message) ``` """ if options is None: @@ -98,5 +113,5 @@ async def query( client = InternalClient() - async for message in client.process_query(prompt=prompt, options=options): + async for message in client.process_query(prompt=prompt, options=options, transport=transport): yield message diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py index ef1070d0..59fa1748 100644 --- a/src/claude_code_sdk/_internal/client.py +++ b/src/claude_code_sdk/_internal/client.py @@ -15,6 +15,7 @@ ToolUseBlock, UserMessage, ) +from .transport import Transport from .transport.subprocess_cli import SubprocessCLITransport @@ -25,22 +26,26 @@ def __init__(self) -> None: """Initialize the internal client.""" async def process_query( - self, prompt: str, options: ClaudeCodeOptions + self, prompt: str, options: ClaudeCodeOptions, transport: Transport | None = None ) -> AsyncIterator[Message]: """Process a query through transport.""" - transport = SubprocessCLITransport(prompt=prompt, options=options) + # Use provided transport or choose one based on configuration + if transport is not None: + chosen_transport = transport + else: + chosen_transport = SubprocessCLITransport(prompt=prompt, options=options) try: - await transport.connect() + await chosen_transport.connect() - async for data in transport.receive_messages(): + async for data in chosen_transport.receive_messages(): message = self._parse_message(data) if message: yield message finally: - await transport.disconnect() + await chosen_transport.disconnect() def _parse_message(self, data: dict[str, Any]) -> Message | None: """Parse message from CLI output, trusting the structure.""" diff --git a/src/claude_code_sdk/_internal/transport/__init__.py b/src/claude_code_sdk/_internal/transport/__init__.py index cd7188c3..745a11b9 100644 --- a/src/claude_code_sdk/_internal/transport/__init__.py +++ b/src/claude_code_sdk/_internal/transport/__init__.py @@ -36,4 +36,5 @@ def is_connected(self) -> bool: pass +# Import implementations __all__ = ["Transport"] diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index bd3c7267..617b2a8b 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -126,4 +126,4 @@ class ClaudeCodeOptions: disallowed_tools: list[str] = field(default_factory=list) model: str | None = None permission_prompt_tool_name: str | None = None - cwd: str | Path | None = None + cwd: str | Path | None = None \ No newline at end of file From 16762731bf537c1e8df738af592d064c72e8fb17 Mon Sep 17 00:00:00 2001 From: Rushil Patel Date: Sat, 26 Jul 2025 12:04:39 -0700 Subject: [PATCH 04/47] feat: enable custom transports to be provided Signed-off-by: Rushil Patel --- src/claude_code_sdk/__init__.py | 13 ++-- src/claude_code_sdk/_internal/client.py | 4 +- .../_internal/transport/__init__.py | 7 +++ .../_internal/transport/subprocess_cli.py | 22 ++++--- tests/test_client.py | 6 +- tests/test_integration.py | 8 ++- tests/test_subprocess_buffering.py | 35 +++++------ tests/test_transport.py | 60 ++++++++++--------- 8 files changed, 83 insertions(+), 72 deletions(-) diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 9d1544d9..49b3ed56 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -12,6 +12,7 @@ ) from ._internal.client import InternalClient from ._internal.transport import Transport +from ._internal.transport.subprocess_cli import SubprocessCLITransport from .types import ( AssistantMessage, ClaudeCodeOptions, @@ -34,6 +35,7 @@ "query", # Transport "Transport", + "SubprocessCLITransport", # Types "PermissionMode", "McpServerConfig", @@ -74,6 +76,7 @@ async def query( Set options.cwd for working directory. transport: Optional transport implementation. If provided, this will be used instead of the default transport selection based on options. + The transport will be automatically configured with the prompt and options. Yields: Messages from the conversation @@ -95,15 +98,15 @@ async def query( ): print(message) - # With custom transport + # With custom transport (no need to pass prompt to transport) + from claude_code_sdk import SubprocessCLITransport + + transport = SubprocessCLITransport(cli_path="/custom/path/to/claude") async for message in query( prompt="Hello", - transport=MyCustomTransport() + transport=transport ): print(message) - - async for message in query(prompt="Hello", transport=transport): - print(message) ``` """ if options is None: diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py index 59fa1748..3674bece 100644 --- a/src/claude_code_sdk/_internal/client.py +++ b/src/claude_code_sdk/_internal/client.py @@ -34,9 +34,11 @@ async def process_query( if transport is not None: chosen_transport = transport else: - chosen_transport = SubprocessCLITransport(prompt=prompt, options=options) + chosen_transport = SubprocessCLITransport() try: + # Configure the transport with prompt and options + chosen_transport.configure(prompt, options) await chosen_transport.connect() async for data in chosen_transport.receive_messages(): diff --git a/src/claude_code_sdk/_internal/transport/__init__.py b/src/claude_code_sdk/_internal/transport/__init__.py index 745a11b9..ea54d81a 100644 --- a/src/claude_code_sdk/_internal/transport/__init__.py +++ b/src/claude_code_sdk/_internal/transport/__init__.py @@ -4,10 +4,17 @@ from collections.abc import AsyncIterator from typing import Any +from claude_code_sdk.types import ClaudeCodeOptions + class Transport(ABC): """Abstract transport for Claude communication.""" + @abstractmethod + def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: + """Configure transport with prompt and options.""" + pass + @abstractmethod async def connect(self) -> None: """Initialize connection.""" diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index c283f425..d6138538 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -26,20 +26,21 @@ class SubprocessCLITransport(Transport): """Subprocess transport using Claude Code CLI.""" - def __init__( - self, - prompt: str, - options: ClaudeCodeOptions, - cli_path: str | Path | None = None, - ): - self._prompt = prompt - self._options = options + def __init__(self, cli_path: str | Path | None = None): self._cli_path = str(cli_path) if cli_path else self._find_cli() - self._cwd = str(options.cwd) if options.cwd else None + self._prompt: str | None = None + self._options: ClaudeCodeOptions | None = None + self._cwd: str | None = None self._process: Process | None = None self._stdout_stream: TextReceiveStream | None = None self._stderr_stream: TextReceiveStream | None = None + def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: + """Configure transport with prompt and options.""" + self._prompt = prompt + self._options = options + self._cwd = str(options.cwd) if options.cwd else None + def _find_cli(self) -> str: """Find Claude Code CLI binary.""" if cli := shutil.which("claude"): @@ -77,6 +78,9 @@ def _find_cli(self) -> str: def _build_command(self) -> list[str]: """Build CLI command with arguments.""" + if not self._prompt or not self._options: + raise CLIConnectionError("Transport not configured. Call configure() first.") + cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"] if self._options.system_prompt: diff --git a/tests/test_client.py b/tests/test_client.py index 3282ea1a..cfcc872a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -106,9 +106,7 @@ async def mock_receive(): messages.append(msg) # Verify transport was created with correct parameters - mock_transport_class.assert_called_once() - call_kwargs = mock_transport_class.call_args.kwargs - assert call_kwargs["prompt"] == "test" - assert call_kwargs["options"].cwd == "/custom/path" + mock_transport_class.assert_called_once_with() # No parameters to constructor + mock_transport.configure.assert_called_once_with("test", options) anyio.run(_test) diff --git a/tests/test_integration.py b/tests/test_integration.py index a185335f..9a327d40 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -195,8 +195,10 @@ async def mock_receive(): messages.append(msg) # Verify transport was created with continuation option - mock_transport_class.assert_called_once() - call_kwargs = mock_transport_class.call_args.kwargs - assert call_kwargs["options"].continue_conversation is True + mock_transport_class.assert_called_once_with() # No parameters to constructor + mock_transport.configure.assert_called_once() + configure_call_args = mock_transport.configure.call_args + assert configure_call_args[0][0] == "Continue" # prompt argument + assert configure_call_args[0][1].continue_conversation is True # options argument anyio.run(_test) diff --git a/tests/test_subprocess_buffering.py b/tests/test_subprocess_buffering.py index 426d42e5..649a5c24 100644 --- a/tests/test_subprocess_buffering.py +++ b/tests/test_subprocess_buffering.py @@ -50,9 +50,8 @@ async def _test() -> None: buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) mock_process = MagicMock() mock_process.returncode = None @@ -85,9 +84,8 @@ async def _test() -> None: buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) mock_process = MagicMock() mock_process.returncode = None @@ -115,9 +113,8 @@ async def _test() -> None: buffered_line = json.dumps(json_obj1) + "\n\n\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) mock_process = MagicMock() mock_process.returncode = None @@ -161,9 +158,8 @@ async def _test() -> None: part2 = complete_json[100:250] part3 = complete_json[250:] - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) mock_process = MagicMock() mock_process.returncode = None @@ -209,9 +205,8 @@ async def _test() -> None: for i in range(0, len(complete_json), chunk_size) ] - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) mock_process = MagicMock() mock_process.returncode = None @@ -239,9 +234,8 @@ def test_buffer_size_exceeded(self) -> None: async def _test() -> None: huge_incomplete = '{"data": "' + "x" * (_MAX_BUFFER_SIZE + 1000) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) mock_process = MagicMock() mock_process.returncode = None @@ -281,9 +275,8 @@ async def _test() -> None: large_json[3000:] + "\n" + msg3, ] - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) mock_process = MagicMock() mock_process.returncode = None diff --git a/tests/test_transport.py b/tests/test_transport.py index c8d8e51f..4b5c5bf0 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -21,15 +21,14 @@ def test_find_cli_not_found(self): patch("pathlib.Path.exists", return_value=False), pytest.raises(CLINotFoundError) as exc_info, ): - SubprocessCLITransport(prompt="test", options=ClaudeCodeOptions()) + SubprocessCLITransport() assert "Claude Code requires Node.js" in str(exc_info.value) def test_build_command_basic(self): """Test building basic CLI command.""" - transport = SubprocessCLITransport( - prompt="Hello", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("Hello", ClaudeCodeOptions()) cmd = transport._build_command() assert cmd[0] == "/usr/bin/claude" @@ -42,19 +41,16 @@ def test_cli_path_accepts_pathlib_path(self): """Test that cli_path accepts pathlib.Path objects.""" from pathlib import Path - transport = SubprocessCLITransport( - prompt="Hello", - options=ClaudeCodeOptions(), - cli_path=Path("/usr/bin/claude"), - ) + transport = SubprocessCLITransport(cli_path=Path("/usr/bin/claude")) assert transport._cli_path == "/usr/bin/claude" def test_build_command_with_options(self): """Test building CLI command with options.""" - transport = SubprocessCLITransport( - prompt="test", - options=ClaudeCodeOptions( + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure( + "test", + ClaudeCodeOptions( system_prompt="Be helpful", allowed_tools=["Read", "Write"], disallowed_tools=["Bash"], @@ -62,7 +58,6 @@ def test_build_command_with_options(self): permission_mode="acceptEdits", max_turns=5, ), - cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -81,10 +76,10 @@ def test_build_command_with_options(self): def test_session_continuation(self): """Test session continuation options.""" - transport = SubprocessCLITransport( - prompt="Continue from before", - options=ClaudeCodeOptions(continue_conversation=True, resume="session-123"), - cli_path="/usr/bin/claude", + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure( + "Continue from before", + ClaudeCodeOptions(continue_conversation=True, resume="session-123"), ) cmd = transport._build_command() @@ -105,11 +100,8 @@ async def _test(): mock_process.stderr = MagicMock() mock_exec.return_value = mock_process - transport = SubprocessCLITransport( - prompt="test", - options=ClaudeCodeOptions(), - cli_path="/usr/bin/claude", - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) await transport.connect() assert transport._process is not None @@ -124,9 +116,8 @@ def test_receive_messages(self): """Test parsing messages from CLI output.""" # This test is simplified to just test the parsing logic # The full async stream handling is tested in integration tests - transport = SubprocessCLITransport( - prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure("test", ClaudeCodeOptions()) # The actual message parsing is done by the client, not the transport # So we just verify the transport can be created and basic structure is correct @@ -138,10 +129,10 @@ def test_connect_with_nonexistent_cwd(self): from claude_code_sdk._errors import CLIConnectionError async def _test(): - transport = SubprocessCLITransport( - prompt="test", - options=ClaudeCodeOptions(cwd="/this/directory/does/not/exist"), - cli_path="/usr/bin/claude", + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + transport.configure( + "test", + ClaudeCodeOptions(cwd="/this/directory/does/not/exist"), ) with pytest.raises(CLIConnectionError) as exc_info: @@ -150,3 +141,14 @@ async def _test(): assert "/this/directory/does/not/exist" in str(exc_info.value) anyio.run(_test) + + def test_build_command_without_configure(self): + """Test that _build_command raises error if not configured.""" + from claude_code_sdk._errors import CLIConnectionError + + transport = SubprocessCLITransport(cli_path="/usr/bin/claude") + + with pytest.raises(CLIConnectionError) as exc_info: + transport._build_command() + + assert "Transport not configured" in str(exc_info.value) From e12fc7c86acd6d9cf863c8eca2c80dfac001cfca Mon Sep 17 00:00:00 2001 From: Rushil Patel Date: Sat, 26 Jul 2025 12:19:06 -0700 Subject: [PATCH 05/47] remove from package exports SubprocessCLITransport Signed-off-by: Rushil Patel --- src/claude_code_sdk/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 49b3ed56..3527d944 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -12,7 +12,6 @@ ) from ._internal.client import InternalClient from ._internal.transport import Transport -from ._internal.transport.subprocess_cli import SubprocessCLITransport from .types import ( AssistantMessage, ClaudeCodeOptions, @@ -35,7 +34,6 @@ "query", # Transport "Transport", - "SubprocessCLITransport", # Types "PermissionMode", "McpServerConfig", @@ -99,12 +97,9 @@ async def query( print(message) # With custom transport (no need to pass prompt to transport) - from claude_code_sdk import SubprocessCLITransport - transport = SubprocessCLITransport(cli_path="/custom/path/to/claude") async for message in query( prompt="Hello", - transport=transport ): print(message) ``` From c0841d8d071769f38d95a5ecf0fa1c7752ab7455 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Thu, 17 Jul 2025 13:44:31 -0700 Subject: [PATCH 06/47] Initial implementation of bidi streaming --- .../_internal/transport/subprocess_cli.py | 121 ++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index d6138538..63653d56 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -4,14 +4,14 @@ import logging import os import shutil -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, AsyncIterable from pathlib import Path from subprocess import PIPE -from typing import Any +from typing import Any, Union import anyio from anyio.abc import Process -from anyio.streams.text import TextReceiveStream +from anyio.streams.text import TextReceiveStream, TextSendStream from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError @@ -26,14 +26,24 @@ class SubprocessCLITransport(Transport): """Subprocess transport using Claude Code CLI.""" - def __init__(self, cli_path: str | Path | None = None): + + def __init__( + self, + prompt: Union[str, AsyncIterable[dict[str, Any]]], + options: ClaudeCodeOptions, + cli_path: str | Path | None = None, + ): + self._prompt = prompt + self._is_streaming = not isinstance(prompt, str) + self._options = options self._cli_path = str(cli_path) if cli_path else self._find_cli() - self._prompt: str | None = None - self._options: ClaudeCodeOptions | None = None self._cwd: str | None = None self._process: Process | None = None self._stdout_stream: TextReceiveStream | None = None self._stderr_stream: TextReceiveStream | None = None + self._stdin_stream: TextSendStream | None = None + self._pending_control_responses: dict[str, Any] = {} + self._request_counter = 0 def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: """Configure transport with prompt and options.""" @@ -120,7 +130,14 @@ def _build_command(self) -> list[str]: ["--mcp-config", json.dumps({"mcpServers": self._options.mcp_servers})] ) - cmd.extend(["--print", self._prompt]) + # Add prompt handling based on mode + if self._is_streaming: + # Streaming mode: use --input-format stream-json + cmd.extend(["--input-format", "stream-json"]) + else: + # String mode: use --print with the prompt + cmd.extend(["--print", self._prompt]) + return cmd async def connect(self) -> None: @@ -130,9 +147,10 @@ async def connect(self) -> None: cmd = self._build_command() try: + # Enable stdin pipe for both modes (but we'll close it for string mode) self._process = await anyio.open_process( cmd, - stdin=None, + stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=self._cwd, @@ -143,6 +161,18 @@ async def connect(self) -> None: self._stdout_stream = TextReceiveStream(self._process.stdout) if self._process.stderr: self._stderr_stream = TextReceiveStream(self._process.stderr) + + # Handle stdin based on mode + if self._is_streaming: + # Streaming mode: keep stdin open and start streaming task + if self._process.stdin: + self._stdin_stream = TextSendStream(self._process.stdin) + # Start streaming messages to stdin + anyio.start_soon(self._stream_to_stdin) + else: + # String mode: close stdin immediately (backward compatible) + if self._process.stdin: + await self._process.stdin.aclose() except FileNotFoundError as e: # Check if the error comes from the working directory or the CLI @@ -173,10 +203,32 @@ async def disconnect(self) -> None: self._process = None self._stdout_stream = None self._stderr_stream = None + self._stdin_stream = None async def send_request(self, messages: list[Any], options: dict[str, Any]) -> None: """Not used for CLI transport - args passed via command line.""" + async def _stream_to_stdin(self) -> None: + """Stream messages to stdin for streaming mode.""" + if not self._stdin_stream or not isinstance(self._prompt, AsyncIterable): + return + + try: + async for message in self._prompt: + if not self._stdin_stream: + break + await self._stdin_stream.send(json.dumps(message) + "\n") + + # Close stdin when done + if self._stdin_stream: + await self._stdin_stream.aclose() + self._stdin_stream = None + except Exception as e: + logger.debug(f"Error streaming to stdin: {e}") + if self._stdin_stream: + await self._stdin_stream.aclose() + self._stdin_stream = None + async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: """Receive messages from CLI.""" if not self._process or not self._stdout_stream: @@ -217,6 +269,15 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: try: data = json.loads(json_buffer) json_buffer = "" + + # Handle control responses separately + if data.get("type") == "control_response": + request_id = data.get("response", {}).get("request_id") + if request_id and request_id in self._pending_control_responses: + # Store the response for the pending request + self._pending_control_responses[request_id] = data.get("response", {}) + continue + try: yield data except GeneratorExit: @@ -284,3 +345,47 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: def is_connected(self) -> bool: """Check if subprocess is running.""" return self._process is not None and self._process.returncode is None + + async def interrupt(self) -> None: + """Send interrupt control request (only works in streaming mode).""" + if not self._is_streaming: + raise CLIConnectionError("Interrupt requires streaming mode (AsyncIterable prompt)") + + if not self._stdin_stream: + raise CLIConnectionError("Not connected or stdin not available") + + await self._send_control_request({"subtype": "interrupt"}) + + async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any]: + """Send a control request and wait for response.""" + if not self._stdin_stream: + raise CLIConnectionError("Stdin not available") + + # Generate unique request ID + self._request_counter += 1 + request_id = f"req_{self._request_counter}_{os.urandom(4).hex()}" + + # Build control request + control_request = { + "type": "control_request", + "request_id": request_id, + "request": request + } + + # Send request + await self._stdin_stream.send(json.dumps(control_request) + "\n") + + # Wait for response with timeout + try: + with anyio.fail_after(30.0): # 30 second timeout + while request_id not in self._pending_control_responses: + await anyio.sleep(0.1) + + response = self._pending_control_responses.pop(request_id) + + if response.get("subtype") == "error": + raise CLIConnectionError(f"Control request failed: {response.get('error')}") + + return response + except TimeoutError: + raise CLIConnectionError("Control request timed out") from None From 055d60f68b867e81aad6eb84114544fe43a708c5 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Fri, 18 Jul 2025 00:16:18 -0700 Subject: [PATCH 07/47] Finalize streaming impl Signed-off-by: Rushil Patel --- .../_internal/transport/subprocess_cli.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 63653d56..953252d8 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -167,8 +167,9 @@ async def connect(self) -> None: # Streaming mode: keep stdin open and start streaming task if self._process.stdin: self._stdin_stream = TextSendStream(self._process.stdin) - # Start streaming messages to stdin - anyio.start_soon(self._stream_to_stdin) + # Start streaming messages to stdin in background + import asyncio + asyncio.create_task(self._stream_to_stdin()) else: # String mode: close stdin immediately (backward compatible) if self._process.stdin: @@ -219,10 +220,8 @@ async def _stream_to_stdin(self) -> None: break await self._stdin_stream.send(json.dumps(message) + "\n") - # Close stdin when done - if self._stdin_stream: - await self._stdin_stream.aclose() - self._stdin_stream = None + # Signal EOF but keep the stream open for control messages + # This matches the TypeScript implementation which calls stdin.end() except Exception as e: logger.debug(f"Error streaming to stdin: {e}") if self._stdin_stream: From 5f90eb84b321f5c7575a4e309180f06544e35681 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 10:43:23 -0700 Subject: [PATCH 08/47] Implement proper client and bidi streaming --- examples/streaming_mode_example.py | 192 ++++++++++++++++ src/claude_code_sdk/__init__.py | 70 +----- src/claude_code_sdk/_internal/client.py | 65 +----- .../_internal/message_parser.py | 77 +++++++ .../_internal/transport/subprocess_cli.py | 66 ++++-- src/claude_code_sdk/client.py | 208 ++++++++++++++++++ src/claude_code_sdk/query.py | 99 +++++++++ 7 files changed, 633 insertions(+), 144 deletions(-) create mode 100644 examples/streaming_mode_example.py create mode 100644 src/claude_code_sdk/_internal/message_parser.py create mode 100644 src/claude_code_sdk/client.py create mode 100644 src/claude_code_sdk/query.py diff --git a/examples/streaming_mode_example.py b/examples/streaming_mode_example.py new file mode 100644 index 00000000..ed782a91 --- /dev/null +++ b/examples/streaming_mode_example.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Example demonstrating streaming mode with bidirectional communication.""" + +import asyncio +from collections.abc import AsyncIterator + +from claude_code_sdk import ClaudeCodeOptions, ClaudeSDKClient, query + + +async def create_message_stream() -> AsyncIterator[dict]: + """Create an async stream of user messages.""" + # Example messages to send + messages = [ + { + "type": "user", + "message": { + "role": "user", + "content": "Hello! Please tell me a bit about Python async programming.", + }, + "parent_tool_use_id": None, + "session_id": "example-session-1", + }, + # Add a delay to simulate interactive conversation + None, # We'll use this as a signal to delay + { + "type": "user", + "message": { + "role": "user", + "content": "Can you give me a simple code example?", + }, + "parent_tool_use_id": None, + "session_id": "example-session-1", + }, + ] + + for msg in messages: + if msg is None: + await asyncio.sleep(2) # Simulate user thinking time + continue + yield msg + + +async def example_string_mode(): + """Example using traditional string mode (backward compatible).""" + print("=== String Mode Example ===") + + # Option 1: Using query function + async for message in query( + prompt="What is 2+2? Please give a brief answer.", options=ClaudeCodeOptions() + ): + print(f"Received: {type(message).__name__}") + if hasattr(message, "content"): + print(f" Content: {message.content}") + + print("Completed\n") + + +async def example_streaming_mode(): + """Example using new streaming mode with async iterable.""" + print("=== Streaming Mode Example ===") + + options = ClaudeCodeOptions() + + # Create message stream + message_stream = create_message_stream() + + # Use query with async iterable + message_count = 0 + async for message in query(prompt=message_stream, options=options): + message_count += 1 + msg_type = type(message).__name__ + + print(f"\nMessage #{message_count} ({msg_type}):") + + if hasattr(message, "content"): + content = message.content + if isinstance(content, list): + for block in content: + if hasattr(block, "text"): + print(f" {block.text}") + else: + print(f" {content}") + elif hasattr(message, "subtype"): + print(f" Subtype: {message.subtype}") + + print("\nCompleted") + + +async def example_with_context_manager(): + """Example using context manager for cleaner code.""" + print("=== Context Manager Example ===") + + # Simple one-shot query with automatic cleanup + async with ClaudeSDKClient() as client: + await client.send_message("What is the meaning of life?") + async for message in client.receive_messages(): + if hasattr(message, "content"): + print(f"Response: {message.content}") + + print("\nCompleted with automatic cleanup\n") + + +async def example_with_interrupt(): + """Example demonstrating interrupt functionality.""" + print("=== Streaming Mode with Interrupt Example ===") + + options = ClaudeCodeOptions() + client = ClaudeSDKClient(options=options) + + async def interruptible_stream(): + """Stream that we'll interrupt.""" + yield { + "type": "user", + "message": { + "role": "user", + "content": "Count to 1000 slowly, saying each number.", + }, + "parent_tool_use_id": None, + "session_id": "interrupt-example", + } + # Keep the stream open by waiting indefinitely + # This prevents stdin from being closed + await asyncio.Event().wait() + + try: + await client.connect(interruptible_stream()) + print("Connected - will interrupt after 3 seconds") + + # Create tasks for receiving and interrupting + async def receive_and_interrupt(): + # Start a background task to continuously receive messages + async def receive_messages(): + async for message in client.receive_messages(): + msg_type = type(message).__name__ + print(f"Received: {msg_type}") + + if hasattr(message, "content") and isinstance( + message.content, list + ): + for block in message.content: + if hasattr(block, "text"): + print(f" {block.text[:50]}...") # First 50 chars + + # Start receiving in background + receive_task = asyncio.create_task(receive_messages()) + + # Wait 3 seconds then interrupt + await asyncio.sleep(3) + print("\nSending interrupt signal...") + + try: + await client.interrupt() + print("Interrupt sent successfully") + except Exception as e: + print(f"Interrupt error: {e}") + + # Give some time to see any final messages + await asyncio.sleep(2) + + # Cancel the receive task + receive_task.cancel() + try: + await receive_task + except asyncio.CancelledError: + pass + + await receive_and_interrupt() + + except Exception as e: + print(f"Error: {e}") + finally: + await client.disconnect() + print("\nDisconnected") + + +async def main(): + """Run all examples.""" + # Run string mode example + await example_string_mode() + + # Run streaming mode example + await example_streaming_mode() + + # Run context manager example + await example_with_context_manager() + + # Run interrupt example + await example_with_interrupt() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 3527d944..506c05fe 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -1,7 +1,5 @@ """Claude SDK for Python.""" -import os -from collections.abc import AsyncIterator from ._errors import ( ClaudeSDKError, @@ -10,8 +8,12 @@ CLINotFoundError, ProcessError, ) -from ._internal.client import InternalClient + from ._internal.transport import Transport + +from .client import ClaudeSDKClient +from .query import query + from .types import ( AssistantMessage, ClaudeCodeOptions, @@ -30,10 +32,11 @@ __version__ = "0.0.14" __all__ = [ - # Main function + # Main exports "query", # Transport "Transport", + "ClaudeSDKClient", # Types "PermissionMode", "McpServerConfig", @@ -54,62 +57,3 @@ "ProcessError", "CLIJSONDecodeError", ] - - -async def query( - *, prompt: str, options: ClaudeCodeOptions | None = None, transport: Transport | None = None -) -> AsyncIterator[Message]: - """ - Query Claude Code. - - Python SDK for interacting with Claude Code. - - Args: - prompt: The prompt to send to Claude - options: Optional configuration (defaults to ClaudeCodeOptions() if None). - Set options.permission_mode to control tool execution: - - 'default': CLI prompts for dangerous tools - - 'acceptEdits': Auto-accept file edits - - 'bypassPermissions': Allow all tools (use with caution) - Set options.cwd for working directory. - transport: Optional transport implementation. If provided, this will be used - instead of the default transport selection based on options. - The transport will be automatically configured with the prompt and options. - - Yields: - Messages from the conversation - - - Example: - ```python - # Simple usage - async for message in query(prompt="Hello"): - print(message) - - # With options - async for message in query( - prompt="Hello", - options=ClaudeCodeOptions( - system_prompt="You are helpful", - cwd="/home/user" - ) - ): - print(message) - - # With custom transport (no need to pass prompt to transport) - - async for message in query( - prompt="Hello", - ): - print(message) - ``` - """ - if options is None: - options = ClaudeCodeOptions() - - os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py" - - client = InternalClient() - - async for message in client.process_query(prompt=prompt, options=options, transport=transport): - yield message diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py index 3674bece..a0272a53 100644 --- a/src/claude_code_sdk/_internal/client.py +++ b/src/claude_code_sdk/_internal/client.py @@ -1,8 +1,9 @@ """Internal client implementation.""" -from collections.abc import AsyncIterator +from collections.abc import AsyncIterable, AsyncIterator from typing import Any + from ..types import ( AssistantMessage, ClaudeCodeOptions, @@ -16,6 +17,10 @@ UserMessage, ) from .transport import Transport + +from ..types import ClaudeCodeOptions, Message +from .message_parser import parse_message + from .transport.subprocess_cli import SubprocessCLITransport @@ -34,7 +39,7 @@ async def process_query( if transport is not None: chosen_transport = transport else: - chosen_transport = SubprocessCLITransport() + chosen_transport = SubprocessCLITransport(prompt, options) try: # Configure the transport with prompt and options @@ -42,63 +47,9 @@ async def process_query( await chosen_transport.connect() async for data in chosen_transport.receive_messages(): - message = self._parse_message(data) + message = parse_message(data) if message: yield message finally: await chosen_transport.disconnect() - - def _parse_message(self, data: dict[str, Any]) -> Message | None: - """Parse message from CLI output, trusting the structure.""" - - match data["type"]: - case "user": - return UserMessage(content=data["message"]["content"]) - - case "assistant": - content_blocks: list[ContentBlock] = [] - for block in data["message"]["content"]: - match block["type"]: - case "text": - content_blocks.append(TextBlock(text=block["text"])) - case "tool_use": - content_blocks.append( - ToolUseBlock( - id=block["id"], - name=block["name"], - input=block["input"], - ) - ) - case "tool_result": - content_blocks.append( - ToolResultBlock( - tool_use_id=block["tool_use_id"], - content=block.get("content"), - is_error=block.get("is_error"), - ) - ) - - return AssistantMessage(content=content_blocks) - - case "system": - return SystemMessage( - subtype=data["subtype"], - data=data, - ) - - case "result": - return ResultMessage( - subtype=data["subtype"], - duration_ms=data["duration_ms"], - duration_api_ms=data["duration_api_ms"], - is_error=data["is_error"], - num_turns=data["num_turns"], - session_id=data["session_id"], - total_cost_usd=data.get("total_cost_usd"), - usage=data.get("usage"), - result=data.get("result"), - ) - - case _: - return None diff --git a/src/claude_code_sdk/_internal/message_parser.py b/src/claude_code_sdk/_internal/message_parser.py new file mode 100644 index 00000000..a2b88d29 --- /dev/null +++ b/src/claude_code_sdk/_internal/message_parser.py @@ -0,0 +1,77 @@ +"""Message parser for Claude Code SDK responses.""" + +from typing import Any + +from ..types import ( + AssistantMessage, + ContentBlock, + Message, + ResultMessage, + SystemMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + + +def parse_message(data: dict[str, Any]) -> Message | None: + """ + Parse message from CLI output into typed Message objects. + + Args: + data: Raw message dictionary from CLI output + + Returns: + Parsed Message object or None if type is unrecognized + """ + match data["type"]: + case "user": + return UserMessage(content=data["message"]["content"]) + + case "assistant": + content_blocks: list[ContentBlock] = [] + for block in data["message"]["content"]: + match block["type"]: + case "text": + content_blocks.append(TextBlock(text=block["text"])) + case "tool_use": + content_blocks.append( + ToolUseBlock( + id=block["id"], + name=block["name"], + input=block["input"], + ) + ) + case "tool_result": + content_blocks.append( + ToolResultBlock( + tool_use_id=block["tool_use_id"], + content=block.get("content"), + is_error=block.get("is_error"), + ) + ) + + return AssistantMessage(content=content_blocks) + + case "system": + return SystemMessage( + subtype=data["subtype"], + data=data, + ) + + case "result": + return ResultMessage( + subtype=data["subtype"], + duration_ms=data["duration_ms"], + duration_api_ms=data["duration_api_ms"], + is_error=data["is_error"], + num_turns=data["num_turns"], + session_id=data["session_id"], + total_cost_usd=data.get("total_cost_usd"), + usage=data.get("usage"), + result=data.get("result"), + ) + + case _: + return None diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 953252d8..57516a6e 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -4,10 +4,10 @@ import logging import os import shutil -from collections.abc import AsyncIterator, AsyncIterable +from collections.abc import AsyncIterable, AsyncIterator from pathlib import Path from subprocess import PIPE -from typing import Any, Union +from typing import Any import anyio from anyio.abc import Process @@ -29,7 +29,7 @@ class SubprocessCLITransport(Transport): def __init__( self, - prompt: Union[str, AsyncIterable[dict[str, Any]]], + prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeCodeOptions, cli_path: str | Path | None = None, ): @@ -136,8 +136,8 @@ def _build_command(self) -> list[str]: cmd.extend(["--input-format", "stream-json"]) else: # String mode: use --print with the prompt - cmd.extend(["--print", self._prompt]) - + cmd.extend(["--print", str(self._prompt)]) + return cmd async def connect(self) -> None: @@ -161,7 +161,7 @@ async def connect(self) -> None: self._stdout_stream = TextReceiveStream(self._process.stdout) if self._process.stderr: self._stderr_stream = TextReceiveStream(self._process.stderr) - + # Handle stdin based on mode if self._is_streaming: # Streaming mode: keep stdin open and start streaming task @@ -207,21 +207,39 @@ async def disconnect(self) -> None: self._stdin_stream = None async def send_request(self, messages: list[Any], options: dict[str, Any]) -> None: - """Not used for CLI transport - args passed via command line.""" + """Send additional messages in streaming mode.""" + if not self._is_streaming: + raise CLIConnectionError("send_request only works in streaming mode") + + if not self._stdin_stream: + raise CLIConnectionError("stdin not available - stream may have ended") + + # Send each message as a user message + for message in messages: + # Ensure message has required structure + if not isinstance(message, dict): + message = { + "type": "user", + "message": {"role": "user", "content": str(message)}, + "parent_tool_use_id": None, + "session_id": options.get("session_id", "default") + } + + await self._stdin_stream.send(json.dumps(message) + "\n") async def _stream_to_stdin(self) -> None: """Stream messages to stdin for streaming mode.""" if not self._stdin_stream or not isinstance(self._prompt, AsyncIterable): return - + try: async for message in self._prompt: if not self._stdin_stream: break await self._stdin_stream.send(json.dumps(message) + "\n") - - # Signal EOF but keep the stream open for control messages - # This matches the TypeScript implementation which calls stdin.end() + + # Don't close stdin - keep it open for send_request + # Users can explicitly call disconnect() when done except Exception as e: logger.debug(f"Error streaming to stdin: {e}") if self._stdin_stream: @@ -268,7 +286,7 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: try: data = json.loads(json_buffer) json_buffer = "" - + # Handle control responses separately if data.get("type") == "control_response": request_id = data.get("response", {}).get("request_id") @@ -276,7 +294,7 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: # Store the response for the pending request self._pending_control_responses[request_id] = data.get("response", {}) continue - + try: yield data except GeneratorExit: @@ -344,47 +362,47 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: def is_connected(self) -> bool: """Check if subprocess is running.""" return self._process is not None and self._process.returncode is None - + async def interrupt(self) -> None: """Send interrupt control request (only works in streaming mode).""" if not self._is_streaming: raise CLIConnectionError("Interrupt requires streaming mode (AsyncIterable prompt)") - + if not self._stdin_stream: raise CLIConnectionError("Not connected or stdin not available") - + await self._send_control_request({"subtype": "interrupt"}) - + async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any]: """Send a control request and wait for response.""" if not self._stdin_stream: raise CLIConnectionError("Stdin not available") - + # Generate unique request ID self._request_counter += 1 request_id = f"req_{self._request_counter}_{os.urandom(4).hex()}" - + # Build control request control_request = { "type": "control_request", "request_id": request_id, "request": request } - + # Send request await self._stdin_stream.send(json.dumps(control_request) + "\n") - + # Wait for response with timeout try: with anyio.fail_after(30.0): # 30 second timeout while request_id not in self._pending_control_responses: await anyio.sleep(0.1) - + response = self._pending_control_responses.pop(request_id) - + if response.get("subtype") == "error": raise CLIConnectionError(f"Control request failed: {response.get('error')}") - + return response except TimeoutError: raise CLIConnectionError("Control request timed out") from None diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py new file mode 100644 index 00000000..a75eece2 --- /dev/null +++ b/src/claude_code_sdk/client.py @@ -0,0 +1,208 @@ +"""Claude SDK Client for interacting with Claude Code.""" + +import os +from collections.abc import AsyncIterable, AsyncIterator + +from ._errors import CLIConnectionError +from .types import ClaudeCodeOptions, Message, ResultMessage + + +class ClaudeSDKClient: + """ + Client for bidirectional, interactive conversations with Claude Code. + + This client provides full control over the conversation flow with support + for streaming, interrupts, and dynamic message sending. For simple one-shot + queries, consider using the query() function instead. + + Key features: + - **Bidirectional**: Send and receive messages at any time + - **Stateful**: Maintains conversation context across messages + - **Interactive**: Send follow-ups based on responses + - **Control flow**: Support for interrupts and session management + + When to use ClaudeSDKClient: + - Building chat interfaces or conversational UIs + - Interactive debugging or exploration sessions + - Multi-turn conversations with context + - When you need to react to Claude's responses + - Real-time applications with user input + - When you need interrupt capabilities + + When to use query() instead: + - Simple one-off questions + - Batch processing of prompts + - Fire-and-forget automation scripts + - When all inputs are known upfront + - Stateless operations + + Example - Interactive conversation: + ```python + # Automatically connects with empty stream for interactive use + async with ClaudeSDKClient() as client: + # Send initial message + await client.send_message("Let's solve a math problem step by step") + + # Receive and process response + async for message in client.receive_messages(): + if "ready" in str(message.content).lower(): + break + + # Send follow-up based on response + await client.send_message("What's 15% of 80?") + + # Continue conversation... + # Automatically disconnects + ``` + + Example - With interrupt: + ```python + async with ClaudeSDKClient() as client: + # Start a long task + await client.send_message("Count to 1000") + + # Interrupt after 2 seconds + await asyncio.sleep(2) + await client.interrupt() + + # Send new instruction + await client.send_message("Never mind, what's 2+2?") + ``` + + Example - Manual connection: + ```python + client = ClaudeSDKClient() + + # Connect with initial message stream + async def message_stream(): + yield {"type": "user", "message": {"role": "user", "content": "Hello"}} + + await client.connect(message_stream()) + + # Send additional messages dynamically + await client.send_message("What's the weather?") + + async for message in client.receive_messages(): + print(message) + + await client.disconnect() + ``` + """ + + def __init__(self, options: ClaudeCodeOptions | None = None): + """Initialize Claude SDK client.""" + if options is None: + options = ClaudeCodeOptions() + self.options = options + self._transport = None + os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client" + + async def connect(self, prompt: str | AsyncIterable[dict] | None = None) -> None: + """Connect to Claude with a prompt or message stream.""" + from ._internal.transport.subprocess_cli import SubprocessCLITransport + + # Auto-connect with empty async iterable if no prompt is provided + async def _empty_stream(): + # Never yields, but indicates that this function is an iterator and + # keeps the connection open. + if False: + yield + + self._transport = SubprocessCLITransport( + prompt=_empty_stream() if prompt is None else prompt, + options=self.options, + ) + await self._transport.connect() + + async def receive_messages(self) -> AsyncIterator[Message]: + """Receive all messages from Claude.""" + if not self._transport: + raise CLIConnectionError("Not connected. Call connect() first.") + + from ._internal.message_parser import parse_message + + async for data in self._transport.receive_messages(): + message = parse_message(data) + if message: + yield message + + async def send_message(self, content: str, session_id: str = "default") -> None: + """Send a new message in streaming mode.""" + if not self._transport: + raise CLIConnectionError("Not connected. Call connect() first.") + + message = { + "type": "user", + "message": {"role": "user", "content": content}, + "parent_tool_use_id": None, + "session_id": session_id, + } + + await self._transport.send_request([message], {"session_id": session_id}) + + async def interrupt(self) -> None: + """Send interrupt signal (only works with streaming mode).""" + if not self._transport: + raise CLIConnectionError("Not connected. Call connect() first.") + await self._transport.interrupt() + + async def receive_response(self) -> tuple[list[Message], ResultMessage | None]: + """ + Receive a complete response from Claude, collecting all messages until ResultMessage. + + Compared to receive_messages(), this is a convenience method that + handles the common pattern of receiving messages until Claude completes + its response. It collects all messages and returns them along with the + final ResultMessage. + + Returns: + tuple: A tuple of (messages, result) where: + - messages: List of all messages received (UserMessage, AssistantMessage, SystemMessage) + - result: The final ResultMessage if received, None if stream ended without result + + Example: + ```python + async with ClaudeSDKClient() as client: + # First turn + await client.send_message("What's the capital of France?") + messages, result = await client.receive_response() + + # Extract assistant's response + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + # Second turn + await client.send_message("What's the population?") + messages, result = await client.receive_response() + # ... process response + ``` + """ + from .types import ResultMessage + + messages = [] + async for message in self.receive_messages(): + messages.append(message) + if isinstance(message, ResultMessage): + return messages, message + + # Stream ended without ResultMessage + return messages, None + + async def disconnect(self) -> None: + """Disconnect from Claude.""" + if self._transport: + await self._transport.disconnect() + self._transport = None + + async def __aenter__(self): + """Enter async context - automatically connects with empty stream for interactive use.""" + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Exit async context - always disconnects.""" + await self.disconnect() + return False diff --git a/src/claude_code_sdk/query.py b/src/claude_code_sdk/query.py new file mode 100644 index 00000000..4bd7e96c --- /dev/null +++ b/src/claude_code_sdk/query.py @@ -0,0 +1,99 @@ +"""Query function for one-shot interactions with Claude Code.""" + +import os +from collections.abc import AsyncIterable, AsyncIterator + +from ._internal.client import InternalClient +from .types import ClaudeCodeOptions, Message + + +async def query( + *, prompt: str | AsyncIterable[dict], options: ClaudeCodeOptions | None = None +) -> AsyncIterator[Message]: + """ + Query Claude Code for one-shot or unidirectional streaming interactions. + + This function is ideal for simple, stateless queries where you don't need + bidirectional communication or conversation management. For interactive, + stateful conversations, use ClaudeSDKClient instead. + + Key differences from ClaudeSDKClient: + - **Unidirectional**: Send all messages upfront, receive all responses + - **Stateless**: Each query is independent, no conversation state + - **Simple**: Fire-and-forget style, no connection management + - **No interrupts**: Cannot interrupt or send follow-up messages + + When to use query(): + - Simple one-off questions ("What is 2+2?") + - Batch processing of independent prompts + - Code generation or analysis tasks + - Automated scripts and CI/CD pipelines + - When you know all inputs upfront + + When to use ClaudeSDKClient: + - Interactive conversations with follow-ups + - Chat applications or REPL-like interfaces + - When you need to send messages based on responses + - When you need interrupt capabilities + - Long-running sessions with state + + Args: + prompt: The prompt to send to Claude. Can be a string for single-shot queries + or an AsyncIterable[dict] for streaming mode with continuous interaction. + In streaming mode, each dict should have the structure: + { + "type": "user", + "message": {"role": "user", "content": "..."}, + "parent_tool_use_id": None, + "session_id": "..." + } + options: Optional configuration (defaults to ClaudeCodeOptions() if None). + Set options.permission_mode to control tool execution: + - 'default': CLI prompts for dangerous tools + - 'acceptEdits': Auto-accept file edits + - 'bypassPermissions': Allow all tools (use with caution) + Set options.cwd for working directory. + + Yields: + Messages from the conversation + + Example - Simple query: + ```python + # One-off question + async for message in query(prompt="What is the capital of France?"): + print(message) + ``` + + Example - With options: + ```python + # Code generation with specific settings + async for message in query( + prompt="Create a Python web server", + options=ClaudeCodeOptions( + system_prompt="You are an expert Python developer", + cwd="/home/user/project" + ) + ): + print(message) + ``` + + Example - Streaming mode (still unidirectional): + ```python + async def prompts(): + yield {"type": "user", "message": {"role": "user", "content": "Hello"}} + yield {"type": "user", "message": {"role": "user", "content": "How are you?"}} + + # All prompts are sent, then all responses received + async for message in query(prompt=prompts()): + print(message) + ``` + """ + if options is None: + options = ClaudeCodeOptions() + + os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py" + + client = InternalClient() + + async for message in client.process_query(prompt=prompt, options=options): + yield message From 7a240876e20293068f69d813430d470924db2e20 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 13:23:53 -0700 Subject: [PATCH 09/47] Working examples Signed-off-by: Rushil Patel --- examples/streaming_mode.py | 328 ++++++++++++++++++ examples/streaming_mode_example.py | 192 ---------- examples/streaming_mode_ipython.py | 153 ++++++++ .../_internal/transport/subprocess_cli.py | 7 +- 4 files changed, 485 insertions(+), 195 deletions(-) create mode 100644 examples/streaming_mode.py delete mode 100644 examples/streaming_mode_example.py create mode 100644 examples/streaming_mode_ipython.py diff --git a/examples/streaming_mode.py b/examples/streaming_mode.py new file mode 100644 index 00000000..cfa4455a --- /dev/null +++ b/examples/streaming_mode.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +Comprehensive examples of using ClaudeSDKClient for streaming mode. + +This file demonstrates various patterns for building applications with +the ClaudeSDKClient streaming interface. +""" + +import asyncio +import contextlib + +from claude_code_sdk import ( + AssistantMessage, + ClaudeCodeOptions, + ClaudeSDKClient, + ResultMessage, + TextBlock, +) + + +async def example_basic_streaming(): + """Basic streaming with context manager.""" + print("=== Basic Streaming Example ===") + + async with ClaudeSDKClient() as client: + # Send a message + await client.send_message("What is 2+2?") + + # Receive complete response using the helper method + messages, result = await client.receive_response() + + # Extract text from assistant's response + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + # Print cost if available + if result and result.total_cost_usd: + print(f"Cost: ${result.total_cost_usd:.4f}") + + print("Session ended\n") + + +async def example_multi_turn_conversation(): + """Multi-turn conversation using receive_response helper.""" + print("=== Multi-Turn Conversation Example ===") + + async with ClaudeSDKClient() as client: + # First turn + print("User: What's the capital of France?") + await client.send_message("What's the capital of France?") + + messages, _ = await client.receive_response() + + # Extract and print response + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + # Second turn - follow-up + print("\nUser: What's the population of that city?") + await client.send_message("What's the population of that city?") + + messages, _ = await client.receive_response() + + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + print("\nConversation ended\n") + + +async def example_concurrent_responses(): + """Handle responses while sending new messages.""" + print("=== Concurrent Send/Receive Example ===") + + async with ClaudeSDKClient() as client: + # Background task to continuously receive messages + async def receive_messages(): + async for message in client.receive_messages(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + # Start receiving in background + receive_task = asyncio.create_task(receive_messages()) + + # Send multiple messages with delays + questions = [ + "What is 2 + 2?", + "What is the square root of 144?", + "What is 15% of 80?", + ] + + for question in questions: + print(f"\nUser: {question}") + await client.send_message(question) + await asyncio.sleep(3) # Wait between messages + + # Give time for final responses + await asyncio.sleep(2) + + # Clean up + receive_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await receive_task + + print("\nSession ended\n") + + +async def example_with_interrupt(): + """Demonstrate interrupt capability.""" + print("=== Interrupt Example ===") + print("IMPORTANT: Interrupts require active message consumption.") + + async with ClaudeSDKClient() as client: + # Start a long-running task + print("\nUser: Count from 1 to 100 slowly") + await client.send_message( + "Count from 1 to 100 slowly, with a brief pause between each number" + ) + + # Create a background task to consume messages + messages_received = [] + interrupt_sent = False + + async def consume_messages(): + """Consume messages in the background to enable interrupt processing.""" + async for message in client.receive_messages(): + messages_received.append(message) + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + # Print first few numbers + print(f"Claude: {block.text[:50]}...") + + # Stop when we get a result after interrupt + if isinstance(message, ResultMessage) and interrupt_sent: + break + + # Start consuming messages in the background + consume_task = asyncio.create_task(consume_messages()) + + # Wait 2 seconds then send interrupt + await asyncio.sleep(2) + print("\n[After 2 seconds, sending interrupt...]") + interrupt_sent = True + await client.interrupt() + + # Wait for the consume task to finish processing the interrupt + await consume_task + + # Send new instruction after interrupt + print("\nUser: Never mind, just tell me a quick joke") + await client.send_message("Never mind, just tell me a quick joke") + + # Get the joke + messages, result = await client.receive_response() + + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + print("\nSession ended\n") + + +async def example_manual_message_handling(): + """Manually handle message stream for custom logic.""" + print("=== Manual Message Handling Example ===") + + async with ClaudeSDKClient() as client: + await client.send_message( + "List 5 programming languages and their main use cases" + ) + + # Manually process messages with custom logic + languages_found = [] + + async for message in client.receive_messages(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + text = block.text + # Custom logic: extract language names + for lang in [ + "Python", + "JavaScript", + "Java", + "C++", + "Go", + "Rust", + "Ruby", + ]: + if lang in text and lang not in languages_found: + languages_found.append(lang) + print(f"Found language: {lang}") + + elif isinstance(message, ResultMessage): + print(f"\nTotal languages mentioned: {len(languages_found)}") + break + + print("\nSession ended\n") + + +async def example_with_options(): + """Use ClaudeCodeOptions to configure the client.""" + print("=== Custom Options Example ===") + + # Configure options + options = ClaudeCodeOptions( + allowed_tools=["Read", "Write"], # Allow file operations + max_thinking_tokens=10000, + system_prompt="You are a helpful coding assistant.", + ) + + async with ClaudeSDKClient(options=options) as client: + await client.send_message( + "Create a simple hello.txt file with a greeting message" + ) + + messages, result = await client.receive_response() + + tool_uses = [] + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif hasattr(block, "name"): # ToolUseBlock + tool_uses.append(getattr(block, "name", "")) + + if tool_uses: + print(f"\nTools used: {', '.join(tool_uses)}") + + print("\nSession ended\n") + + +async def example_error_handling(): + """Demonstrate proper error handling.""" + print("=== Error Handling Example ===") + + client = ClaudeSDKClient() + + try: + # Connect with custom stream + async def message_stream(): + yield { + "type": "user", + "message": {"role": "user", "content": "Hello"}, + "parent_tool_use_id": None, + "session_id": "error-demo", + } + + await client.connect(message_stream()) + + # Create a background task to consume messages (required for interrupt to work) + consume_task = None + + async def consume_messages(): + """Background message consumer.""" + async for msg in client.receive_messages(): + if isinstance(msg, AssistantMessage): + print("Received response from Claude") + + # Receive messages with timeout + try: + # Start consuming messages in background + consume_task = asyncio.create_task(consume_messages()) + + # Wait for response with timeout + await asyncio.wait_for(consume_task, timeout=30.0) + + except asyncio.TimeoutError: + print("Response timeout - sending interrupt") + # Note: interrupt requires active message consumption + # Since we're already consuming in the background task, interrupt will work + await client.interrupt() + + # Cancel the consume task + if consume_task: + consume_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await consume_task + + except Exception as e: + print(f"Error: {e}") + + finally: + # Always disconnect + await client.disconnect() + + print("\nSession ended\n") + + +async def main(): + """Run all examples.""" + examples = [ + example_basic_streaming, + example_multi_turn_conversation, + example_concurrent_responses, + example_with_interrupt, + example_manual_message_handling, + example_with_options, + example_error_handling, + ] + + for example in examples: + await example() + print("-" * 50 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/streaming_mode_example.py b/examples/streaming_mode_example.py deleted file mode 100644 index ed782a91..00000000 --- a/examples/streaming_mode_example.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 -"""Example demonstrating streaming mode with bidirectional communication.""" - -import asyncio -from collections.abc import AsyncIterator - -from claude_code_sdk import ClaudeCodeOptions, ClaudeSDKClient, query - - -async def create_message_stream() -> AsyncIterator[dict]: - """Create an async stream of user messages.""" - # Example messages to send - messages = [ - { - "type": "user", - "message": { - "role": "user", - "content": "Hello! Please tell me a bit about Python async programming.", - }, - "parent_tool_use_id": None, - "session_id": "example-session-1", - }, - # Add a delay to simulate interactive conversation - None, # We'll use this as a signal to delay - { - "type": "user", - "message": { - "role": "user", - "content": "Can you give me a simple code example?", - }, - "parent_tool_use_id": None, - "session_id": "example-session-1", - }, - ] - - for msg in messages: - if msg is None: - await asyncio.sleep(2) # Simulate user thinking time - continue - yield msg - - -async def example_string_mode(): - """Example using traditional string mode (backward compatible).""" - print("=== String Mode Example ===") - - # Option 1: Using query function - async for message in query( - prompt="What is 2+2? Please give a brief answer.", options=ClaudeCodeOptions() - ): - print(f"Received: {type(message).__name__}") - if hasattr(message, "content"): - print(f" Content: {message.content}") - - print("Completed\n") - - -async def example_streaming_mode(): - """Example using new streaming mode with async iterable.""" - print("=== Streaming Mode Example ===") - - options = ClaudeCodeOptions() - - # Create message stream - message_stream = create_message_stream() - - # Use query with async iterable - message_count = 0 - async for message in query(prompt=message_stream, options=options): - message_count += 1 - msg_type = type(message).__name__ - - print(f"\nMessage #{message_count} ({msg_type}):") - - if hasattr(message, "content"): - content = message.content - if isinstance(content, list): - for block in content: - if hasattr(block, "text"): - print(f" {block.text}") - else: - print(f" {content}") - elif hasattr(message, "subtype"): - print(f" Subtype: {message.subtype}") - - print("\nCompleted") - - -async def example_with_context_manager(): - """Example using context manager for cleaner code.""" - print("=== Context Manager Example ===") - - # Simple one-shot query with automatic cleanup - async with ClaudeSDKClient() as client: - await client.send_message("What is the meaning of life?") - async for message in client.receive_messages(): - if hasattr(message, "content"): - print(f"Response: {message.content}") - - print("\nCompleted with automatic cleanup\n") - - -async def example_with_interrupt(): - """Example demonstrating interrupt functionality.""" - print("=== Streaming Mode with Interrupt Example ===") - - options = ClaudeCodeOptions() - client = ClaudeSDKClient(options=options) - - async def interruptible_stream(): - """Stream that we'll interrupt.""" - yield { - "type": "user", - "message": { - "role": "user", - "content": "Count to 1000 slowly, saying each number.", - }, - "parent_tool_use_id": None, - "session_id": "interrupt-example", - } - # Keep the stream open by waiting indefinitely - # This prevents stdin from being closed - await asyncio.Event().wait() - - try: - await client.connect(interruptible_stream()) - print("Connected - will interrupt after 3 seconds") - - # Create tasks for receiving and interrupting - async def receive_and_interrupt(): - # Start a background task to continuously receive messages - async def receive_messages(): - async for message in client.receive_messages(): - msg_type = type(message).__name__ - print(f"Received: {msg_type}") - - if hasattr(message, "content") and isinstance( - message.content, list - ): - for block in message.content: - if hasattr(block, "text"): - print(f" {block.text[:50]}...") # First 50 chars - - # Start receiving in background - receive_task = asyncio.create_task(receive_messages()) - - # Wait 3 seconds then interrupt - await asyncio.sleep(3) - print("\nSending interrupt signal...") - - try: - await client.interrupt() - print("Interrupt sent successfully") - except Exception as e: - print(f"Interrupt error: {e}") - - # Give some time to see any final messages - await asyncio.sleep(2) - - # Cancel the receive task - receive_task.cancel() - try: - await receive_task - except asyncio.CancelledError: - pass - - await receive_and_interrupt() - - except Exception as e: - print(f"Error: {e}") - finally: - await client.disconnect() - print("\nDisconnected") - - -async def main(): - """Run all examples.""" - # Run string mode example - await example_string_mode() - - # Run streaming mode example - await example_streaming_mode() - - # Run context manager example - await example_with_context_manager() - - # Run interrupt example - await example_with_interrupt() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/streaming_mode_ipython.py b/examples/streaming_mode_ipython.py new file mode 100644 index 00000000..fc2f7cf4 --- /dev/null +++ b/examples/streaming_mode_ipython.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +IPython-friendly code snippets for ClaudeSDKClient streaming mode. + +These examples are designed to be copy-pasted directly into IPython. +Each example is self-contained and can be run independently. +""" + +# ============================================================================ +# BASIC STREAMING +# ============================================================================ + +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock + +async with ClaudeSDKClient() as client: + await client.send_message("What is 2+2?") + messages, result = await client.receive_response() + + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + +# ============================================================================ +# STREAMING WITH REAL-TIME DISPLAY +# ============================================================================ + +import asyncio +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock + +async with ClaudeSDKClient() as client: + async def receive_response(): + messages, _ = await client.receive_response() + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + await client.send_message("Tell me a short joke") + await receive_response() + await client.send_message("Now tell me a fun fact") + await receive_response() + + +# ============================================================================ +# PERSISTENT CLIENT FOR MULTIPLE QUESTIONS +# ============================================================================ + +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock + +# Create client +client = ClaudeSDKClient() +await client.connect() + + +# Helper to get response +async def get_response(): + messages, result = await client.receive_response() + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + +# Use it multiple times +await client.send_message("What's 2+2?") +await get_response() + +await client.send_message("What's 10*10?") +await get_response() + +# Don't forget to disconnect when done +await client.disconnect() + + +# ============================================================================ +# WITH INTERRUPT CAPABILITY +# ============================================================================ +# IMPORTANT: Interrupts require active message consumption. You must be +# consuming messages from the client for the interrupt to be processed. + +import asyncio +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage + +async with ClaudeSDKClient() as client: + print("\n--- Sending initial message ---\n") + + # Send a long-running task + await client.send_message("Count from 1 to 100 slowly using bash sleep") + + # Create a background task to consume messages + messages_received = [] + interrupt_sent = False + + async def consume_messages(): + async for msg in client.receive_messages(): + messages_received.append(msg) + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + # Check if we got a result after interrupt + if isinstance(msg, ResultMessage) and interrupt_sent: + break + + # Start consuming messages in the background + consume_task = asyncio.create_task(consume_messages()) + + # Wait a bit then send interrupt + await asyncio.sleep(10) + print("\n--- Sending interrupt ---\n") + interrupt_sent = True + await client.interrupt() + + # Wait for the consume task to finish + await consume_task + + # Send a new message after interrupt + print("\n--- After interrupt, sending new message ---\n") + await client.send_message("Just say 'Hello! I was interrupted.'") + messages, result = await client.receive_response() + + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + +# ============================================================================ +# ERROR HANDLING PATTERN +# ============================================================================ + +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock + +try: + async with ClaudeSDKClient() as client: + await client.send_message("Run a bash sleep command for 60 seconds") + + # Timeout after 30 seconds + messages, result = await asyncio.wait_for( + client.receive_response(), timeout=20.0 + ) + +except asyncio.TimeoutError: + print("Request timed out") +except Exception as e: + print(f"Error: {e}") \ No newline at end of file diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 57516a6e..bd634d38 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -289,10 +289,11 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: # Handle control responses separately if data.get("type") == "control_response": - request_id = data.get("response", {}).get("request_id") - if request_id and request_id in self._pending_control_responses: + response = data.get("response", {}) + request_id = response.get("request_id") + if request_id: # Store the response for the pending request - self._pending_control_responses[request_id] = data.get("response", {}) + self._pending_control_responses[request_id] = response continue try: From 28a115ffb2c6da2705558265381d5fc84497e0c3 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 13:44:53 -0700 Subject: [PATCH 10/47] Fix examples Signed-off-by: Rushil Patel --- examples/streaming_mode.py | 106 ++++++++++------------------- examples/streaming_mode_ipython.py | 67 ++++++++++++------ src/claude_code_sdk/client.py | 45 ++++++------ 3 files changed, 103 insertions(+), 115 deletions(-) diff --git a/examples/streaming_mode.py b/examples/streaming_mode.py index cfa4455a..239024db 100644 --- a/examples/streaming_mode.py +++ b/examples/streaming_mode.py @@ -13,6 +13,7 @@ AssistantMessage, ClaudeCodeOptions, ClaudeSDKClient, + CLIConnectionError, ResultMessage, TextBlock, ) @@ -27,18 +28,13 @@ async def example_basic_streaming(): await client.send_message("What is 2+2?") # Receive complete response using the helper method - messages, result = await client.receive_response() - - # Extract text from assistant's response - for msg in messages: + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): print(f"Claude: {block.text}") - - # Print cost if available - if result and result.total_cost_usd: - print(f"Cost: ${result.total_cost_usd:.4f}") + elif isinstance(msg, ResultMessage) and msg.total_cost_usd: + print(f"Cost: ${msg.total_cost_usd:.4f}") print("Session ended\n") @@ -52,32 +48,22 @@ async def example_multi_turn_conversation(): print("User: What's the capital of France?") await client.send_message("What's the capital of France?") - messages, _ = await client.receive_response() - # Extract and print response - for msg in messages: - if isinstance(msg, AssistantMessage): - for block in msg.content: - if isinstance(block, TextBlock): - print(f"Claude: {block.text}") + async for msg in client.receive_response(): + content_blocks = getattr(msg, 'content', []) + for block in content_blocks: + if isinstance(block, TextBlock): + print(f"{block.text}") # Second turn - follow-up print("\nUser: What's the population of that city?") await client.send_message("What's the population of that city?") - messages, _ = await client.receive_response() - - for msg in messages: - if isinstance(msg, AssistantMessage): - for block in msg.content: - if isinstance(block, TextBlock): - print(f"Claude: {block.text}") - - for msg in messages: - if isinstance(msg, AssistantMessage): - for block in msg.content: - if isinstance(block, TextBlock): - print(f"Claude: {block.text}") + async for msg in client.receive_response(): + content_blocks = getattr(msg, 'content', []) + for block in content_blocks: + if isinstance(block, TextBlock): + print(f"{block.text}") print("\nConversation ended\n") @@ -102,7 +88,7 @@ async def receive_messages(): questions = [ "What is 2 + 2?", "What is the square root of 144?", - "What is 15% of 80?", + "What is 10% of 80?", ] for question in questions: @@ -168,9 +154,7 @@ async def consume_messages(): await client.send_message("Never mind, just tell me a quick joke") # Get the joke - messages, result = await client.receive_response() - - for msg in messages: + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): @@ -233,10 +217,8 @@ async def example_with_options(): "Create a simple hello.txt file with a greeting message" ) - messages, result = await client.receive_response() - tool_uses = [] - for msg in messages: + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): @@ -257,48 +239,34 @@ async def example_error_handling(): client = ClaudeSDKClient() try: - # Connect with custom stream - async def message_stream(): - yield { - "type": "user", - "message": {"role": "user", "content": "Hello"}, - "parent_tool_use_id": None, - "session_id": "error-demo", - } - - await client.connect(message_stream()) + await client.connect() - # Create a background task to consume messages (required for interrupt to work) - consume_task = None + # Send a message that will take time to process + await client.send_message("Run a bash sleep command for 60 seconds") - async def consume_messages(): - """Background message consumer.""" - async for msg in client.receive_messages(): - if isinstance(msg, AssistantMessage): - print("Received response from Claude") - - # Receive messages with timeout + # Try to receive response with a short timeout try: - # Start consuming messages in background - consume_task = asyncio.create_task(consume_messages()) - - # Wait for response with timeout - await asyncio.wait_for(consume_task, timeout=30.0) + messages = [] + async with asyncio.timeout(10.0): + async for msg in client.receive_response(): + messages.append(msg) + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text[:50]}...") + elif isinstance(msg, ResultMessage): + print("Received complete response") + break except asyncio.TimeoutError: - print("Response timeout - sending interrupt") - # Note: interrupt requires active message consumption - # Since we're already consuming in the background task, interrupt will work - await client.interrupt() + print("\nResponse timeout after 10 seconds - demonstrating graceful handling") + print(f"Received {len(messages)} messages before timeout") - # Cancel the consume task - if consume_task: - consume_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await consume_task + except CLIConnectionError as e: + print(f"Connection error: {e}") except Exception as e: - print(f"Error: {e}") + print(f"Unexpected error: {e}") finally: # Always disconnect diff --git a/examples/streaming_mode_ipython.py b/examples/streaming_mode_ipython.py index fc2f7cf4..6b2b554e 100644 --- a/examples/streaming_mode_ipython.py +++ b/examples/streaming_mode_ipython.py @@ -10,17 +10,18 @@ # BASIC STREAMING # ============================================================================ -from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage async with ClaudeSDKClient() as client: await client.send_message("What is 2+2?") - messages, result = await client.receive_response() - for msg in messages: + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): print(f"Claude: {block.text}") + elif isinstance(msg, ResultMessage) and msg.total_cost_usd: + print(f"Cost: ${msg.total_cost_usd:.4f}") # ============================================================================ @@ -31,18 +32,17 @@ from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock async with ClaudeSDKClient() as client: - async def receive_response(): - messages, _ = await client.receive_response() - for msg in messages: + async def send_and_receive(prompt): + await client.send_message(prompt) + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): print(f"Claude: {block.text}") - await client.send_message("Tell me a short joke") - await receive_response() - await client.send_message("Now tell me a fun fact") - await receive_response() + await send_and_receive("Tell me a short joke") + print("\n---\n") + await send_and_receive("Now tell me a fun fact") # ============================================================================ @@ -58,8 +58,7 @@ async def receive_response(): # Helper to get response async def get_response(): - messages, result = await client.receive_response() - for msg in messages: + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): @@ -123,9 +122,8 @@ async def consume_messages(): # Send a new message after interrupt print("\n--- After interrupt, sending new message ---\n") await client.send_message("Just say 'Hello! I was interrupted.'") - messages, result = await client.receive_response() - for msg in messages: + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): @@ -142,12 +140,41 @@ async def consume_messages(): async with ClaudeSDKClient() as client: await client.send_message("Run a bash sleep command for 60 seconds") - # Timeout after 30 seconds - messages, result = await asyncio.wait_for( - client.receive_response(), timeout=20.0 - ) + # Timeout after 20 seconds + messages = [] + async with asyncio.timeout(20.0): + async for msg in client.receive_response(): + messages.append(msg) + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") except asyncio.TimeoutError: - print("Request timed out") + print("Request timed out after 20 seconds") except Exception as e: - print(f"Error: {e}") \ No newline at end of file + print(f"Error: {e}") + + +# ============================================================================ +# COLLECTING ALL MESSAGES INTO A LIST +# ============================================================================ + +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage + +async with ClaudeSDKClient() as client: + await client.send_message("What are the primary colors?") + + # Collect all messages into a list + messages = [msg async for msg in client.receive_response()] + + # Process them afterwards + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, ResultMessage): + print(f"Total messages: {len(messages)}") + if msg.total_cost_usd: + print(f"Cost: ${msg.total_cost_usd:.4f}") diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py index a75eece2..db7c494a 100644 --- a/src/claude_code_sdk/client.py +++ b/src/claude_code_sdk/client.py @@ -146,50 +146,43 @@ async def interrupt(self) -> None: raise CLIConnectionError("Not connected. Call connect() first.") await self._transport.interrupt() - async def receive_response(self) -> tuple[list[Message], ResultMessage | None]: + async def receive_response(self) -> AsyncIterator[Message]: """ - Receive a complete response from Claude, collecting all messages until ResultMessage. + Receive messages from Claude until a ResultMessage is received. - Compared to receive_messages(), this is a convenience method that - handles the common pattern of receiving messages until Claude completes - its response. It collects all messages and returns them along with the - final ResultMessage. + This is an async iterator that yields all messages including the final ResultMessage. + It's a convenience method over receive_messages() that automatically stops iteration + after receiving a ResultMessage. - Returns: - tuple: A tuple of (messages, result) where: - - messages: List of all messages received (UserMessage, AssistantMessage, SystemMessage) - - result: The final ResultMessage if received, None if stream ended without result + Yields: + Message: Each message received (UserMessage, AssistantMessage, SystemMessage, ResultMessage) Example: ```python async with ClaudeSDKClient() as client: - # First turn + # Send message and process response await client.send_message("What's the capital of France?") - messages, result = await client.receive_response() - # Extract assistant's response - for msg in messages: + async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): print(f"Claude: {block.text}") + elif isinstance(msg, ResultMessage): + print(f"Cost: ${msg.total_cost_usd:.4f}") + ``` - # Second turn - await client.send_message("What's the population?") - messages, result = await client.receive_response() - # ... process response + Note: + The iterator will automatically stop after yielding a ResultMessage. + If you need to collect all messages into a list, use: + ```python + messages = [msg async for msg in client.receive_response()] ``` """ - from .types import ResultMessage - - messages = [] async for message in self.receive_messages(): - messages.append(message) + yield message if isinstance(message, ResultMessage): - return messages, message - - # Stream ended without ResultMessage - return messages, None + return async def disconnect(self) -> None: """Disconnect from Claude.""" From f8cab19c8691c309d5bf24b614e3ea85e1701f28 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 13:57:52 -0700 Subject: [PATCH 11/47] Add tests Signed-off-by: Rushil Patel --- .../_internal/transport/subprocess_cli.py | 6 +- tests/test_streaming_client.py | 674 ++++++++++++++++++ tests/test_transport.py | 6 + 3 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 tests/test_streaming_client.py diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index bd634d38..c4942f20 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -210,10 +210,10 @@ async def send_request(self, messages: list[Any], options: dict[str, Any]) -> No """Send additional messages in streaming mode.""" if not self._is_streaming: raise CLIConnectionError("send_request only works in streaming mode") - + if not self._stdin_stream: raise CLIConnectionError("stdin not available - stream may have ended") - + # Send each message as a user message for message in messages: # Ensure message has required structure @@ -224,7 +224,7 @@ async def send_request(self, messages: list[Any], options: dict[str, Any]) -> No "parent_tool_use_id": None, "session_id": options.get("session_id", "default") } - + await self._stdin_stream.send(json.dumps(message) + "\n") async def _stream_to_stdin(self) -> None: diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py new file mode 100644 index 00000000..4f625453 --- /dev/null +++ b/tests/test_streaming_client.py @@ -0,0 +1,674 @@ +"""Tests for ClaudeSDKClient streaming functionality and query() with async iterables.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest + +from claude_code_sdk import ( + AssistantMessage, + ClaudeCodeOptions, + ClaudeSDKClient, + CLIConnectionError, + ResultMessage, + SystemMessage, + TextBlock, + UserMessage, + query, +) + + +class TestClaudeSDKClientStreaming: + """Test ClaudeSDKClient streaming functionality.""" + + def test_auto_connect_with_context_manager(self): + """Test automatic connection when using context manager.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + # Verify connect was called + mock_transport.connect.assert_called_once() + assert client._transport is mock_transport + + # Verify disconnect was called on exit + mock_transport.disconnect.assert_called_once() + + anyio.run(_test) + + def test_manual_connect_disconnect(self): + """Test manual connect and disconnect.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + client = ClaudeSDKClient() + await client.connect() + + # Verify connect was called + mock_transport.connect.assert_called_once() + assert client._transport is mock_transport + + await client.disconnect() + # Verify disconnect was called + mock_transport.disconnect.assert_called_once() + assert client._transport is None + + anyio.run(_test) + + def test_connect_with_string_prompt(self): + """Test connecting with a string prompt.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + client = ClaudeSDKClient() + await client.connect("Hello Claude") + + # Verify transport was created with string prompt + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["prompt"] == "Hello Claude" + + anyio.run(_test) + + def test_connect_with_async_iterable(self): + """Test connecting with an async iterable.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + async def message_stream(): + yield {"type": "user", "message": {"role": "user", "content": "Hi"}} + yield {"type": "user", "message": {"role": "user", "content": "Bye"}} + + client = ClaudeSDKClient() + stream = message_stream() + await client.connect(stream) + + # Verify transport was created with async iterable + call_kwargs = mock_transport_class.call_args.kwargs + # Should be the same async iterator + assert call_kwargs["prompt"] is stream + + anyio.run(_test) + + def test_send_message(self): + """Test sending a message.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + await client.send_message("Test message") + + # Verify send_request was called with correct format + mock_transport.send_request.assert_called_once() + call_args = mock_transport.send_request.call_args + messages, options = call_args[0] + assert len(messages) == 1 + assert messages[0]["type"] == "user" + assert messages[0]["message"]["content"] == "Test message" + assert options["session_id"] == "default" + + anyio.run(_test) + + def test_send_message_with_session_id(self): + """Test sending a message with custom session ID.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + await client.send_message("Test", session_id="custom-session") + + call_args = mock_transport.send_request.call_args + messages, options = call_args[0] + assert messages[0]["session_id"] == "custom-session" + assert options["session_id"] == "custom-session" + + anyio.run(_test) + + def test_send_message_not_connected(self): + """Test sending message when not connected raises error.""" + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + await client.send_message("Test") + + anyio.run(_test) + + def test_receive_messages(self): + """Test receiving messages.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Hello!"}], + }, + } + yield { + "type": "user", + "message": {"role": "user", "content": "Hi there"}, + } + + mock_transport.receive_messages = mock_receive + + async with ClaudeSDKClient() as client: + messages = [] + async for msg in client.receive_messages(): + messages.append(msg) + if len(messages) == 2: + break + + assert len(messages) == 2 + assert isinstance(messages[0], AssistantMessage) + assert isinstance(messages[0].content[0], TextBlock) + assert messages[0].content[0].text == "Hello!" + assert isinstance(messages[1], UserMessage) + assert messages[1].content == "Hi there" + + anyio.run(_test) + + def test_receive_response(self): + """Test receive_response stops at ResultMessage.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Answer"}], + }, + } + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test", + "total_cost_usd": 0.001, + } + # This should not be yielded + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Should not see this"}], + }, + } + + mock_transport.receive_messages = mock_receive + + async with ClaudeSDKClient() as client: + messages = [] + async for msg in client.receive_response(): + messages.append(msg) + + # Should only get 2 messages (assistant + result) + assert len(messages) == 2 + assert isinstance(messages[0], AssistantMessage) + assert isinstance(messages[1], ResultMessage) + + anyio.run(_test) + + def test_interrupt(self): + """Test interrupt functionality.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + await client.interrupt() + mock_transport.interrupt.assert_called_once() + + anyio.run(_test) + + def test_interrupt_not_connected(self): + """Test interrupt when not connected raises error.""" + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + await client.interrupt() + + anyio.run(_test) + + def test_client_with_options(self): + """Test client initialization with options.""" + async def _test(): + options = ClaudeCodeOptions( + cwd="/custom/path", + allowed_tools=["Read", "Write"], + system_prompt="Be helpful", + ) + + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + client = ClaudeSDKClient(options=options) + await client.connect() + + # Verify options were passed to transport + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["options"] is options + + anyio.run(_test) + + def test_concurrent_send_receive(self): + """Test concurrent sending and receiving messages.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock receive to wait then yield messages + async def mock_receive(): + await asyncio.sleep(0.1) + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Response 1"}], + }, + } + await asyncio.sleep(0.1) + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test", + "total_cost_usd": 0.001, + } + + mock_transport.receive_messages = mock_receive + + async with ClaudeSDKClient() as client: + # Helper to get next message + async def get_next_message(): + return await client.receive_response().__anext__() + + # Start receiving in background + receive_task = asyncio.create_task(get_next_message()) + + # Send message while receiving + await client.send_message("Question 1") + + # Wait for first message + first_msg = await receive_task + assert isinstance(first_msg, AssistantMessage) + + anyio.run(_test) + + +class TestQueryWithAsyncIterable: + """Test query() function with async iterable inputs.""" + + def test_query_with_async_iterable(self): + """Test query with async iterable of messages.""" + async def _test(): + async def message_stream(): + yield {"type": "user", "message": {"role": "user", "content": "First"}} + yield {"type": "user", "message": {"role": "user", "content": "Second"}} + + with patch( + "claude_code_sdk.query.InternalClient" + ) as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + # Mock the async generator response + async def mock_process(): + yield AssistantMessage( + content=[TextBlock(text="Response to both messages")] + ) + yield ResultMessage( + subtype="success", + duration_ms=1000, + duration_api_ms=800, + is_error=False, + num_turns=2, + session_id="test", + total_cost_usd=0.002, + ) + + mock_client.process_query.return_value = mock_process() + + # Run query with async iterable + messages = [] + async for msg in query(prompt=message_stream()): + messages.append(msg) + + assert len(messages) == 2 + assert isinstance(messages[0], AssistantMessage) + assert isinstance(messages[1], ResultMessage) + + # Verify process_query was called with async iterable + call_kwargs = mock_client.process_query.call_args.kwargs + # The prompt should be an async generator + assert hasattr(call_kwargs["prompt"], "__aiter__") + + anyio.run(_test) + + def test_query_async_iterable_with_options(self): + """Test query with async iterable and custom options.""" + async def _test(): + async def complex_stream(): + yield { + "type": "user", + "message": {"role": "user", "content": "Setup"}, + "parent_tool_use_id": None, + "session_id": "session-1", + } + yield { + "type": "user", + "message": {"role": "user", "content": "Execute"}, + "parent_tool_use_id": None, + "session_id": "session-1", + } + + options = ClaudeCodeOptions( + cwd="/workspace", + permission_mode="acceptEdits", + max_turns=10, + ) + + with patch( + "claude_code_sdk.query.InternalClient" + ) as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + # Mock response + async def mock_process(): + yield AssistantMessage(content=[TextBlock(text="Done")]) + + mock_client.process_query.return_value = mock_process() + + # Run query + messages = [] + async for msg in query(prompt=complex_stream(), options=options): + messages.append(msg) + + # Verify options were passed + call_kwargs = mock_client.process_query.call_args.kwargs + assert call_kwargs["options"] is options + + anyio.run(_test) + + def test_query_empty_async_iterable(self): + """Test query with empty async iterable.""" + async def _test(): + async def empty_stream(): + # Never yields anything + if False: + yield + + with patch( + "claude_code_sdk.query.InternalClient" + ) as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + # Mock response + async def mock_process(): + yield SystemMessage( + subtype="info", + data={"message": "No input provided"} + ) + + mock_client.process_query.return_value = mock_process() + + # Run query with empty stream + messages = [] + async for msg in query(prompt=empty_stream()): + messages.append(msg) + + assert len(messages) == 1 + assert isinstance(messages[0], SystemMessage) + + anyio.run(_test) + + def test_query_async_iterable_with_delay(self): + """Test query with async iterable that has delays between yields.""" + async def _test(): + async def delayed_stream(): + yield {"type": "user", "message": {"role": "user", "content": "Start"}} + await asyncio.sleep(0.1) + yield {"type": "user", "message": {"role": "user", "content": "Middle"}} + await asyncio.sleep(0.1) + yield {"type": "user", "message": {"role": "user", "content": "End"}} + + with patch( + "claude_code_sdk.query.InternalClient" + ) as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + # Track if the stream was consumed + stream_consumed = False + + # Mock process_query to consume the input stream + async def mock_process_query(prompt, options): + nonlocal stream_consumed + # Consume the async iterable to trigger delays + items = [] + async for item in prompt: + items.append(item) + stream_consumed = True + # Then yield response + yield AssistantMessage( + content=[TextBlock(text="Processing all messages")] + ) + + mock_client.process_query = mock_process_query + + # Time the execution + import time + start_time = time.time() + messages = [] + async for msg in query(prompt=delayed_stream()): + messages.append(msg) + elapsed = time.time() - start_time + + # Should have taken at least 0.2 seconds due to delays + assert elapsed >= 0.2 + assert len(messages) == 1 + assert stream_consumed + + anyio.run(_test) + + def test_query_async_iterable_exception_handling(self): + """Test query handles exceptions in async iterable.""" + async def _test(): + async def failing_stream(): + yield {"type": "user", "message": {"role": "user", "content": "First"}} + raise ValueError("Stream error") + + with patch( + "claude_code_sdk.query.InternalClient" + ) as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + # The internal client should receive the failing stream + # and handle the error appropriately + async def mock_process(): + # Simulate processing until error + yield AssistantMessage(content=[TextBlock(text="Error occurred")]) + + mock_client.process_query.return_value = mock_process() + + # Query should handle the error gracefully + messages = [] + async for msg in query(prompt=failing_stream()): + messages.append(msg) + + assert len(messages) == 1 + + anyio.run(_test) + + +class TestClaudeSDKClientEdgeCases: + """Test edge cases and error scenarios.""" + + def test_receive_messages_not_connected(self): + """Test receiving messages when not connected.""" + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + async for _ in client.receive_messages(): + pass + + anyio.run(_test) + + def test_receive_response_not_connected(self): + """Test receive_response when not connected.""" + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + async for _ in client.receive_response(): + pass + + anyio.run(_test) + + def test_double_connect(self): + """Test connecting twice.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + client = ClaudeSDKClient() + await client.connect() + # Second connect should create new transport + await client.connect() + + # Should have been called twice + assert mock_transport_class.call_count == 2 + + anyio.run(_test) + + def test_disconnect_without_connect(self): + """Test disconnecting without connecting first.""" + async def _test(): + client = ClaudeSDKClient() + # Should not raise error + await client.disconnect() + + anyio.run(_test) + + def test_context_manager_with_exception(self): + """Test context manager cleans up on exception.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + with pytest.raises(ValueError): + async with ClaudeSDKClient(): + raise ValueError("Test error") + + # Disconnect should still be called + mock_transport.disconnect.assert_called_once() + + anyio.run(_test) + + def test_receive_response_list_comprehension(self): + """Test collecting messages with list comprehension as shown in examples.""" + async def _test(): + with patch( + "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Hello"}], + }, + } + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "World"}], + }, + } + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test", + "total_cost_usd": 0.001, + } + + mock_transport.receive_messages = mock_receive + + async with ClaudeSDKClient() as client: + # Test list comprehension pattern from docstring + messages = [msg async for msg in client.receive_response()] + + assert len(messages) == 3 + assert all(isinstance(msg, AssistantMessage | ResultMessage) for msg in messages) + assert isinstance(messages[-1], ResultMessage) + + anyio.run(_test) diff --git a/tests/test_transport.py b/tests/test_transport.py index 4b5c5bf0..ba134e21 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -98,6 +98,12 @@ async def _test(): mock_process.wait = AsyncMock() mock_process.stdout = MagicMock() mock_process.stderr = MagicMock() + + # Mock stdin with aclose method + mock_stdin = MagicMock() + mock_stdin.aclose = AsyncMock() + mock_process.stdin = mock_stdin + mock_exec.return_value = mock_process transport = SubprocessCLITransport(cli_path="/usr/bin/claude") From e7b9e8ce9b3980709070c7d6c6fa34f3cdb0b03b Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 15:01:43 -0700 Subject: [PATCH 12/47] Close stdin for query() --- src/claude_code_sdk/_internal/client.py | 6 ++- .../_internal/transport/subprocess_cli.py | 9 +++- tests/test_streaming_client.py | 54 +++++++++++++++---- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py index a0272a53..1e91b76e 100644 --- a/src/claude_code_sdk/_internal/client.py +++ b/src/claude_code_sdk/_internal/client.py @@ -39,7 +39,11 @@ async def process_query( if transport is not None: chosen_transport = transport else: - chosen_transport = SubprocessCLITransport(prompt, options) + chosen_transport = SubprocessCLITransport( + prompt=prompt, + options=options, + close_stdin_after_prompt=True + ) try: # Configure the transport with prompt and options diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index c4942f20..9759bbe3 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -32,6 +32,7 @@ def __init__( prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeCodeOptions, cli_path: str | Path | None = None, + close_stdin_after_prompt: bool = False, ): self._prompt = prompt self._is_streaming = not isinstance(prompt, str) @@ -44,6 +45,7 @@ def __init__( self._stdin_stream: TextSendStream | None = None self._pending_control_responses: dict[str, Any] = {} self._request_counter = 0 + self._close_stdin_after_prompt = close_stdin_after_prompt def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: """Configure transport with prompt and options.""" @@ -238,8 +240,11 @@ async def _stream_to_stdin(self) -> None: break await self._stdin_stream.send(json.dumps(message) + "\n") - # Don't close stdin - keep it open for send_request - # Users can explicitly call disconnect() when done + # Close stdin after prompt if requested (e.g., for query() one-shot mode) + if self._close_stdin_after_prompt and self._stdin_stream: + await self._stdin_stream.aclose() + self._stdin_stream = None + # Otherwise keep stdin open for send_request (ClaudeSDKClient interactive mode) except Exception as e: logger.debug(f"Error streaming to stdin: {e}") if self._stdin_stream: diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index 4f625453..ed83bcd4 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -24,6 +24,7 @@ class TestClaudeSDKClientStreaming: def test_auto_connect_with_context_manager(self): """Test automatic connection when using context manager.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -43,6 +44,7 @@ async def _test(): def test_manual_connect_disconnect(self): """Test manual connect and disconnect.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -66,6 +68,7 @@ async def _test(): def test_connect_with_string_prompt(self): """Test connecting with a string prompt.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -84,6 +87,7 @@ async def _test(): def test_connect_with_async_iterable(self): """Test connecting with an async iterable.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -93,7 +97,10 @@ async def _test(): async def message_stream(): yield {"type": "user", "message": {"role": "user", "content": "Hi"}} - yield {"type": "user", "message": {"role": "user", "content": "Bye"}} + yield { + "type": "user", + "message": {"role": "user", "content": "Bye"}, + } client = ClaudeSDKClient() stream = message_stream() @@ -108,6 +115,7 @@ async def message_stream(): def test_send_message(self): """Test sending a message.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -131,6 +139,7 @@ async def _test(): def test_send_message_with_session_id(self): """Test sending a message with custom session ID.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -150,6 +159,7 @@ async def _test(): def test_send_message_not_connected(self): """Test sending message when not connected raises error.""" + async def _test(): client = ClaudeSDKClient() with pytest.raises(CLIConnectionError, match="Not connected"): @@ -159,6 +169,7 @@ async def _test(): def test_receive_messages(self): """Test receiving messages.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -200,6 +211,7 @@ async def mock_receive(): def test_receive_response(self): """Test receive_response stops at ResultMessage.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -231,7 +243,9 @@ async def mock_receive(): "type": "assistant", "message": { "role": "assistant", - "content": [{"type": "text", "text": "Should not see this"}], + "content": [ + {"type": "text", "text": "Should not see this"} + ], }, } @@ -251,6 +265,7 @@ async def mock_receive(): def test_interrupt(self): """Test interrupt functionality.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -266,6 +281,7 @@ async def _test(): def test_interrupt_not_connected(self): """Test interrupt when not connected raises error.""" + async def _test(): client = ClaudeSDKClient() with pytest.raises(CLIConnectionError, match="Not connected"): @@ -275,6 +291,7 @@ async def _test(): def test_client_with_options(self): """Test client initialization with options.""" + async def _test(): options = ClaudeCodeOptions( cwd="/custom/path", @@ -299,6 +316,7 @@ async def _test(): def test_concurrent_send_receive(self): """Test concurrent sending and receiving messages.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -334,7 +352,7 @@ async def mock_receive(): # Helper to get next message async def get_next_message(): return await client.receive_response().__anext__() - + # Start receiving in background receive_task = asyncio.create_task(get_next_message()) @@ -353,13 +371,14 @@ class TestQueryWithAsyncIterable: def test_query_with_async_iterable(self): """Test query with async iterable of messages.""" + async def _test(): async def message_stream(): yield {"type": "user", "message": {"role": "user", "content": "First"}} yield {"type": "user", "message": {"role": "user", "content": "Second"}} with patch( - "claude_code_sdk.query.InternalClient" + "claude_code_sdk._internal.client.InternalClient" ) as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -399,6 +418,7 @@ async def mock_process(): def test_query_async_iterable_with_options(self): """Test query with async iterable and custom options.""" + async def _test(): async def complex_stream(): yield { @@ -421,7 +441,7 @@ async def complex_stream(): ) with patch( - "claude_code_sdk.query.InternalClient" + "claude_code_sdk._internal.client.InternalClient" ) as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -445,6 +465,7 @@ async def mock_process(): def test_query_empty_async_iterable(self): """Test query with empty async iterable.""" + async def _test(): async def empty_stream(): # Never yields anything @@ -452,7 +473,7 @@ async def empty_stream(): yield with patch( - "claude_code_sdk.query.InternalClient" + "claude_code_sdk._internal.client.InternalClient" ) as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -460,8 +481,7 @@ async def empty_stream(): # Mock response async def mock_process(): yield SystemMessage( - subtype="info", - data={"message": "No input provided"} + subtype="info", data={"message": "No input provided"} ) mock_client.process_query.return_value = mock_process() @@ -478,6 +498,7 @@ async def mock_process(): def test_query_async_iterable_with_delay(self): """Test query with async iterable that has delays between yields.""" + async def _test(): async def delayed_stream(): yield {"type": "user", "message": {"role": "user", "content": "Start"}} @@ -487,7 +508,7 @@ async def delayed_stream(): yield {"type": "user", "message": {"role": "user", "content": "End"}} with patch( - "claude_code_sdk.query.InternalClient" + "claude_code_sdk._internal.client.InternalClient" ) as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -512,6 +533,7 @@ async def mock_process_query(prompt, options): # Time the execution import time + start_time = time.time() messages = [] async for msg in query(prompt=delayed_stream()): @@ -527,13 +549,14 @@ async def mock_process_query(prompt, options): def test_query_async_iterable_exception_handling(self): """Test query handles exceptions in async iterable.""" + async def _test(): async def failing_stream(): yield {"type": "user", "message": {"role": "user", "content": "First"}} raise ValueError("Stream error") with patch( - "claude_code_sdk.query.InternalClient" + "claude_code_sdk._internal.client.InternalClient" ) as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -561,6 +584,7 @@ class TestClaudeSDKClientEdgeCases: def test_receive_messages_not_connected(self): """Test receiving messages when not connected.""" + async def _test(): client = ClaudeSDKClient() with pytest.raises(CLIConnectionError, match="Not connected"): @@ -571,6 +595,7 @@ async def _test(): def test_receive_response_not_connected(self): """Test receive_response when not connected.""" + async def _test(): client = ClaudeSDKClient() with pytest.raises(CLIConnectionError, match="Not connected"): @@ -581,6 +606,7 @@ async def _test(): def test_double_connect(self): """Test connecting twice.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -600,6 +626,7 @@ async def _test(): def test_disconnect_without_connect(self): """Test disconnecting without connecting first.""" + async def _test(): client = ClaudeSDKClient() # Should not raise error @@ -609,6 +636,7 @@ async def _test(): def test_context_manager_with_exception(self): """Test context manager cleans up on exception.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -627,6 +655,7 @@ async def _test(): def test_receive_response_list_comprehension(self): """Test collecting messages with list comprehension as shown in examples.""" + async def _test(): with patch( "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" @@ -668,7 +697,10 @@ async def mock_receive(): messages = [msg async for msg in client.receive_response()] assert len(messages) == 3 - assert all(isinstance(msg, AssistantMessage | ResultMessage) for msg in messages) + assert all( + isinstance(msg, AssistantMessage | ResultMessage) + for msg in messages + ) assert isinstance(messages[-1], ResultMessage) anyio.run(_test) From 4175e4a5a4e6403204f5d82dc9702d3034753367 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 15:20:02 -0700 Subject: [PATCH 13/47] Fix test Signed-off-by: Rushil Patel --- tests/test_streaming_client.py | 299 +++++++++++---------------------- 1 file changed, 102 insertions(+), 197 deletions(-) diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index ed83bcd4..9dc131d3 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -1,7 +1,11 @@ """Tests for ClaudeSDKClient streaming functionality and query() with async iterables.""" import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +import sys +import tempfile +import textwrap +from pathlib import Path +from unittest.mock import AsyncMock, patch import anyio import pytest @@ -12,11 +16,11 @@ ClaudeSDKClient, CLIConnectionError, ResultMessage, - SystemMessage, TextBlock, UserMessage, query, ) +from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport class TestClaudeSDKClientStreaming: @@ -369,212 +373,113 @@ async def get_next_message(): class TestQueryWithAsyncIterable: """Test query() function with async iterable inputs.""" - def test_query_with_async_iterable(self): - """Test query with async iterable of messages.""" - - async def _test(): - async def message_stream(): - yield {"type": "user", "message": {"role": "user", "content": "First"}} - yield {"type": "user", "message": {"role": "user", "content": "Second"}} - - with patch( - "claude_code_sdk._internal.client.InternalClient" - ) as mock_client_class: - mock_client = MagicMock() - mock_client_class.return_value = mock_client - - # Mock the async generator response - async def mock_process(): - yield AssistantMessage( - content=[TextBlock(text="Response to both messages")] - ) - yield ResultMessage( - subtype="success", - duration_ms=1000, - duration_api_ms=800, - is_error=False, - num_turns=2, - session_id="test", - total_cost_usd=0.002, - ) - - mock_client.process_query.return_value = mock_process() - - # Run query with async iterable - messages = [] - async for msg in query(prompt=message_stream()): - messages.append(msg) - - assert len(messages) == 2 - assert isinstance(messages[0], AssistantMessage) - assert isinstance(messages[1], ResultMessage) - - # Verify process_query was called with async iterable - call_kwargs = mock_client.process_query.call_args.kwargs - # The prompt should be an async generator - assert hasattr(call_kwargs["prompt"], "__aiter__") - - anyio.run(_test) - - def test_query_async_iterable_with_options(self): - """Test query with async iterable and custom options.""" - - async def _test(): - async def complex_stream(): - yield { - "type": "user", - "message": {"role": "user", "content": "Setup"}, - "parent_tool_use_id": None, - "session_id": "session-1", - } - yield { - "type": "user", - "message": {"role": "user", "content": "Execute"}, - "parent_tool_use_id": None, - "session_id": "session-1", - } - - options = ClaudeCodeOptions( - cwd="/workspace", - permission_mode="acceptEdits", - max_turns=10, + def _create_test_script( + self, expected_messages=None, response=None, should_error=False + ): + """Create a test script that validates CLI args and stdin messages. + + Args: + expected_messages: List of expected message content strings, or None to skip validation + response: Custom response to output, defaults to a success result + should_error: If True, script will exit with error after reading stdin + + Returns: + Path to the test script + """ + if response is None: + response = '{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}' + + script_content = textwrap.dedent(""" + #!/usr/bin/env python3 + import sys + import json + import time + + # Check command line args + args = sys.argv[1:] + assert "--output-format" in args + assert "stream-json" in args + + # Read stdin messages + stdin_messages = [] + stdin_closed = False + try: + while True: + line = sys.stdin.readline() + if not line: + stdin_closed = True + break + stdin_messages.append(line.strip()) + except: + stdin_closed = True + """, + ) + + if expected_messages is not None: + script_content += textwrap.dedent(f""" + # Verify we got the expected messages + assert len(stdin_messages) == {len(expected_messages)} + """, ) + for i, msg in enumerate(expected_messages): + script_content += f'''assert '"{msg}"' in stdin_messages[{i}]\n''' - with patch( - "claude_code_sdk._internal.client.InternalClient" - ) as mock_client_class: - mock_client = MagicMock() - mock_client_class.return_value = mock_client - - # Mock response - async def mock_process(): - yield AssistantMessage(content=[TextBlock(text="Done")]) - - mock_client.process_query.return_value = mock_process() - - # Run query - messages = [] - async for msg in query(prompt=complex_stream(), options=options): - messages.append(msg) - - # Verify options were passed - call_kwargs = mock_client.process_query.call_args.kwargs - assert call_kwargs["options"] is options - - anyio.run(_test) - - def test_query_empty_async_iterable(self): - """Test query with empty async iterable.""" - - async def _test(): - async def empty_stream(): - # Never yields anything - if False: - yield - - with patch( - "claude_code_sdk._internal.client.InternalClient" - ) as mock_client_class: - mock_client = MagicMock() - mock_client_class.return_value = mock_client - - # Mock response - async def mock_process(): - yield SystemMessage( - subtype="info", data={"message": "No input provided"} - ) - - mock_client.process_query.return_value = mock_process() - - # Run query with empty stream - messages = [] - async for msg in query(prompt=empty_stream()): - messages.append(msg) - - assert len(messages) == 1 - assert isinstance(messages[0], SystemMessage) - - anyio.run(_test) - - def test_query_async_iterable_with_delay(self): - """Test query with async iterable that has delays between yields.""" - - async def _test(): - async def delayed_stream(): - yield {"type": "user", "message": {"role": "user", "content": "Start"}} - await asyncio.sleep(0.1) - yield {"type": "user", "message": {"role": "user", "content": "Middle"}} - await asyncio.sleep(0.1) - yield {"type": "user", "message": {"role": "user", "content": "End"}} - - with patch( - "claude_code_sdk._internal.client.InternalClient" - ) as mock_client_class: - mock_client = MagicMock() - mock_client_class.return_value = mock_client - - # Track if the stream was consumed - stream_consumed = False - - # Mock process_query to consume the input stream - async def mock_process_query(prompt, options): - nonlocal stream_consumed - # Consume the async iterable to trigger delays - items = [] - async for item in prompt: - items.append(item) - stream_consumed = True - # Then yield response - yield AssistantMessage( - content=[TextBlock(text="Processing all messages")] - ) - - mock_client.process_query = mock_process_query - - # Time the execution - import time - - start_time = time.time() - messages = [] - async for msg in query(prompt=delayed_stream()): - messages.append(msg) - elapsed = time.time() - start_time + if should_error: + script_content += textwrap.dedent(""" + sys.exit(1) + """, + ) + else: + script_content += textwrap.dedent(f""" + # Output response + print('{response}') + """, + ) - # Should have taken at least 0.2 seconds due to delays - assert elapsed >= 0.2 - assert len(messages) == 1 - assert stream_consumed + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + test_script = f.name + f.write(script_content) - anyio.run(_test) + Path(test_script).chmod(0o755) + return test_script - def test_query_async_iterable_exception_handling(self): - """Test query handles exceptions in async iterable.""" + def test_query_with_async_iterable(self): + """Test query with async iterable of messages.""" async def _test(): - async def failing_stream(): + async def message_stream(): yield {"type": "user", "message": {"role": "user", "content": "First"}} - raise ValueError("Stream error") - - with patch( - "claude_code_sdk._internal.client.InternalClient" - ) as mock_client_class: - mock_client = MagicMock() - mock_client_class.return_value = mock_client - - # The internal client should receive the failing stream - # and handle the error appropriately - async def mock_process(): - # Simulate processing until error - yield AssistantMessage(content=[TextBlock(text="Error occurred")]) + yield {"type": "user", "message": {"role": "user", "content": "Second"}} - mock_client.process_query.return_value = mock_process() + test_script = self._create_test_script( + expected_messages=["First", "Second"] + ) - # Query should handle the error gracefully - messages = [] - async for msg in query(prompt=failing_stream()): - messages.append(msg) + try: + # Mock _build_command to return our test script + with patch.object( + SubprocessCLITransport, + "_build_command", + return_value=[ + sys.executable, + test_script, + "--output-format", + "stream-json", + "--verbose", + ], + ): + # Run query with async iterable + messages = [] + async for msg in query(prompt=message_stream()): + messages.append(msg) - assert len(messages) == 1 + # Should get the result message + assert len(messages) == 1 + assert isinstance(messages[0], ResultMessage) + assert messages[0].subtype == "success" + finally: + # Clean up + Path(test_script).unlink() anyio.run(_test) From 0d23841b7d93a7adcc16f5e254588fdf49b42536 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 15:21:02 -0700 Subject: [PATCH 14/47] Ruff --- src/claude_code_sdk/__init__.py | 1 - src/claude_code_sdk/_internal/client.py | 4 +--- .../_internal/transport/subprocess_cli.py | 13 +++++++++---- tests/test_streaming_client.py | 12 ++++++++---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 506c05fe..90076dd8 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -1,6 +1,5 @@ """Claude SDK for Python.""" - from ._errors import ( ClaudeSDKError, CLIConnectionError, diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py index 1e91b76e..b4da9334 100644 --- a/src/claude_code_sdk/_internal/client.py +++ b/src/claude_code_sdk/_internal/client.py @@ -40,9 +40,7 @@ async def process_query( chosen_transport = transport else: chosen_transport = SubprocessCLITransport( - prompt=prompt, - options=options, - close_stdin_after_prompt=True + prompt=prompt, options=options, close_stdin_after_prompt=True ) try: diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 9759bbe3..577853aa 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -171,6 +171,7 @@ async def connect(self) -> None: self._stdin_stream = TextSendStream(self._process.stdin) # Start streaming messages to stdin in background import asyncio + asyncio.create_task(self._stream_to_stdin()) else: # String mode: close stdin immediately (backward compatible) @@ -224,7 +225,7 @@ async def send_request(self, messages: list[Any], options: dict[str, Any]) -> No "type": "user", "message": {"role": "user", "content": str(message)}, "parent_tool_use_id": None, - "session_id": options.get("session_id", "default") + "session_id": options.get("session_id", "default"), } await self._stdin_stream.send(json.dumps(message) + "\n") @@ -372,7 +373,9 @@ def is_connected(self) -> bool: async def interrupt(self) -> None: """Send interrupt control request (only works in streaming mode).""" if not self._is_streaming: - raise CLIConnectionError("Interrupt requires streaming mode (AsyncIterable prompt)") + raise CLIConnectionError( + "Interrupt requires streaming mode (AsyncIterable prompt)" + ) if not self._stdin_stream: raise CLIConnectionError("Not connected or stdin not available") @@ -392,7 +395,7 @@ async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any] control_request = { "type": "control_request", "request_id": request_id, - "request": request + "request": request, } # Send request @@ -407,7 +410,9 @@ async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any] response = self._pending_control_responses.pop(request_id) if response.get("subtype") == "error": - raise CLIConnectionError(f"Control request failed: {response.get('error')}") + raise CLIConnectionError( + f"Control request failed: {response.get('error')}" + ) return response except TimeoutError: diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index 9dc131d3..cf1c6a52 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -389,7 +389,8 @@ def _create_test_script( if response is None: response = '{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}' - script_content = textwrap.dedent(""" + script_content = textwrap.dedent( + """ #!/usr/bin/env python3 import sys import json @@ -416,7 +417,8 @@ def _create_test_script( ) if expected_messages is not None: - script_content += textwrap.dedent(f""" + script_content += textwrap.dedent( + f""" # Verify we got the expected messages assert len(stdin_messages) == {len(expected_messages)} """, @@ -425,12 +427,14 @@ def _create_test_script( script_content += f'''assert '"{msg}"' in stdin_messages[{i}]\n''' if should_error: - script_content += textwrap.dedent(""" + script_content += textwrap.dedent( + """ sys.exit(1) """, ) else: - script_content += textwrap.dedent(f""" + script_content += textwrap.dedent( + f""" # Output response print('{response}') """, From 8ea3a125f00ce20ce933085de5ae3e910ef017ab Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 15:25:34 -0700 Subject: [PATCH 15/47] Fix test Signed-off-by: Rushil Patel --- tests/test_streaming_client.py | 148 ++++++++++++--------------------- 1 file changed, 52 insertions(+), 96 deletions(-) diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index cf1c6a52..c196c56a 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -3,7 +3,6 @@ import asyncio import sys import tempfile -import textwrap from pathlib import Path from unittest.mock import AsyncMock, patch @@ -373,80 +372,6 @@ async def get_next_message(): class TestQueryWithAsyncIterable: """Test query() function with async iterable inputs.""" - def _create_test_script( - self, expected_messages=None, response=None, should_error=False - ): - """Create a test script that validates CLI args and stdin messages. - - Args: - expected_messages: List of expected message content strings, or None to skip validation - response: Custom response to output, defaults to a success result - should_error: If True, script will exit with error after reading stdin - - Returns: - Path to the test script - """ - if response is None: - response = '{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}' - - script_content = textwrap.dedent( - """ - #!/usr/bin/env python3 - import sys - import json - import time - - # Check command line args - args = sys.argv[1:] - assert "--output-format" in args - assert "stream-json" in args - - # Read stdin messages - stdin_messages = [] - stdin_closed = False - try: - while True: - line = sys.stdin.readline() - if not line: - stdin_closed = True - break - stdin_messages.append(line.strip()) - except: - stdin_closed = True - """, - ) - - if expected_messages is not None: - script_content += textwrap.dedent( - f""" - # Verify we got the expected messages - assert len(stdin_messages) == {len(expected_messages)} - """, - ) - for i, msg in enumerate(expected_messages): - script_content += f'''assert '"{msg}"' in stdin_messages[{i}]\n''' - - if should_error: - script_content += textwrap.dedent( - """ - sys.exit(1) - """, - ) - else: - script_content += textwrap.dedent( - f""" - # Output response - print('{response}') - """, - ) - - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - test_script = f.name - f.write(script_content) - - Path(test_script).chmod(0o755) - return test_script - def test_query_with_async_iterable(self): """Test query with async iterable of messages.""" @@ -455,32 +380,63 @@ async def message_stream(): yield {"type": "user", "message": {"role": "user", "content": "First"}} yield {"type": "user", "message": {"role": "user", "content": "Second"}} - test_script = self._create_test_script( - expected_messages=["First", "Second"] - ) + # Create a simple test script that validates stdin and outputs a result + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + test_script = f.name + f.write("""#!/usr/bin/env python3 +import sys +import json + +# Read stdin messages +stdin_messages = [] +while True: + line = sys.stdin.readline() + if not line: + break + stdin_messages.append(line.strip()) + +# Verify we got 2 messages +assert len(stdin_messages) == 2 +assert '"First"' in stdin_messages[0] +assert '"Second"' in stdin_messages[1] + +# Output a valid result +print('{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}') +""") + + Path(test_script).chmod(0o755) try: - # Mock _build_command to return our test script + # Mock _find_cli to return python executing our test script with patch.object( SubprocessCLITransport, - "_build_command", - return_value=[ - sys.executable, - test_script, - "--output-format", - "stream-json", - "--verbose", - ], + "_find_cli", + return_value=sys.executable ): - # Run query with async iterable - messages = [] - async for msg in query(prompt=message_stream()): - messages.append(msg) - - # Should get the result message - assert len(messages) == 1 - assert isinstance(messages[0], ResultMessage) - assert messages[0].subtype == "success" + # Mock _build_command to add our test script as first argument + original_build_command = SubprocessCLITransport._build_command + + def mock_build_command(self): + # Get original command + cmd = original_build_command(self) + # Replace the CLI path with python + script + cmd[0] = test_script + return cmd + + with patch.object( + SubprocessCLITransport, + "_build_command", + mock_build_command + ): + # Run query with async iterable + messages = [] + async for msg in query(prompt=message_stream()): + messages.append(msg) + + # Should get the result message + assert len(messages) == 1 + assert isinstance(messages[0], ResultMessage) + assert messages[0].subtype == "success" finally: # Clean up Path(test_script).unlink() From 5ffc20ffa0064a346a8ae283c868baa088041872 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 15:26:30 -0700 Subject: [PATCH 16/47] Fix lint Signed-off-by: Rushil Patel --- tests/test_streaming_client.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index c196c56a..49a52911 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -403,30 +403,26 @@ async def message_stream(): # Output a valid result print('{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}') """) - + Path(test_script).chmod(0o755) try: # Mock _find_cli to return python executing our test script with patch.object( - SubprocessCLITransport, - "_find_cli", - return_value=sys.executable + SubprocessCLITransport, "_find_cli", return_value=sys.executable ): # Mock _build_command to add our test script as first argument original_build_command = SubprocessCLITransport._build_command - + def mock_build_command(self): # Get original command cmd = original_build_command(self) # Replace the CLI path with python + script cmd[0] = test_script return cmd - + with patch.object( - SubprocessCLITransport, - "_build_command", - mock_build_command + SubprocessCLITransport, "_build_command", mock_build_command ): # Run query with async iterable messages = [] From 8796e2d50313bca4a6e6997d44ebcf040a9b1fd4 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 18:47:07 -0700 Subject: [PATCH 17/47] Fix types Signed-off-by: Rushil Patel --- .../_internal/transport/subprocess_cli.py | 2 +- src/claude_code_sdk/client.py | 16 +++++++++------- src/claude_code_sdk/query.py | 3 ++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 577853aa..b5eb3c90 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -43,7 +43,7 @@ def __init__( self._stdout_stream: TextReceiveStream | None = None self._stderr_stream: TextReceiveStream | None = None self._stdin_stream: TextSendStream | None = None - self._pending_control_responses: dict[str, Any] = {} + self._pending_control_responses: dict[str, dict[str, Any]] = {} self._request_counter = 0 self._close_stdin_after_prompt = close_stdin_after_prompt diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py index db7c494a..213519e5 100644 --- a/src/claude_code_sdk/client.py +++ b/src/claude_code_sdk/client.py @@ -2,6 +2,7 @@ import os from collections.abc import AsyncIterable, AsyncIterator +from typing import Any from ._errors import CLIConnectionError from .types import ClaudeCodeOptions, Message, ResultMessage @@ -94,19 +95,20 @@ def __init__(self, options: ClaudeCodeOptions | None = None): if options is None: options = ClaudeCodeOptions() self.options = options - self._transport = None + self._transport: Any | None = None os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client" - async def connect(self, prompt: str | AsyncIterable[dict] | None = None) -> None: + async def connect(self, prompt: str | AsyncIterable[dict[str, Any]] | None = None) -> None: """Connect to Claude with a prompt or message stream.""" from ._internal.transport.subprocess_cli import SubprocessCLITransport # Auto-connect with empty async iterable if no prompt is provided - async def _empty_stream(): + async def _empty_stream() -> AsyncIterator[dict[str, Any]]: # Never yields, but indicates that this function is an iterator and # keeps the connection open. - if False: - yield + # This yield is never reached but makes this an async generator + return + yield {} # type: ignore[unreachable] self._transport = SubprocessCLITransport( prompt=_empty_stream() if prompt is None else prompt, @@ -190,12 +192,12 @@ async def disconnect(self) -> None: await self._transport.disconnect() self._transport = None - async def __aenter__(self): + async def __aenter__(self) -> "ClaudeSDKClient": """Enter async context - automatically connects with empty stream for interactive use.""" await self.connect() return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: """Exit async context - always disconnects.""" await self.disconnect() return False diff --git a/src/claude_code_sdk/query.py b/src/claude_code_sdk/query.py index 4bd7e96c..37327626 100644 --- a/src/claude_code_sdk/query.py +++ b/src/claude_code_sdk/query.py @@ -2,13 +2,14 @@ import os from collections.abc import AsyncIterable, AsyncIterator +from typing import Any from ._internal.client import InternalClient from .types import ClaudeCodeOptions, Message async def query( - *, prompt: str | AsyncIterable[dict], options: ClaudeCodeOptions | None = None + *, prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeCodeOptions | None = None ) -> AsyncIterator[Message]: """ Query Claude Code for one-shot or unidirectional streaming interactions. From 3408cb6da3b86f9fb18ca0a4dec9fe01bc4be601 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 19:12:07 -0700 Subject: [PATCH 18/47] PR feedback Signed-off-by: Rushil Patel --- .../_internal/message_parser.py | 106 +++++++++++------- .../_internal/transport/subprocess_cli.py | 7 +- src/claude_code_sdk/client.py | 61 ++++++---- 3 files changed, 115 insertions(+), 59 deletions(-) diff --git a/src/claude_code_sdk/_internal/message_parser.py b/src/claude_code_sdk/_internal/message_parser.py index a2b88d29..c5f4fc0f 100644 --- a/src/claude_code_sdk/_internal/message_parser.py +++ b/src/claude_code_sdk/_internal/message_parser.py @@ -1,5 +1,6 @@ """Message parser for Claude Code SDK responses.""" +import logging from typing import Any from ..types import ( @@ -14,6 +15,8 @@ UserMessage, ) +logger = logging.getLogger(__name__) + def parse_message(data: dict[str, Any]) -> Message | None: """ @@ -23,55 +26,82 @@ def parse_message(data: dict[str, Any]) -> Message | None: data: Raw message dictionary from CLI output Returns: - Parsed Message object or None if type is unrecognized + Parsed Message object or None if type is unrecognized or parsing fails """ - match data["type"]: + try: + message_type = data.get("type") + if not message_type: + logger.warning("Message missing 'type' field: %s", data) + return None + + except AttributeError: + logger.error("Invalid message data type (expected dict): %s", type(data)) + return None + + match message_type: case "user": - return UserMessage(content=data["message"]["content"]) + try: + return UserMessage(content=data["message"]["content"]) + except KeyError as e: + logger.error("Missing required field in user message: %s", e) + return None case "assistant": - content_blocks: list[ContentBlock] = [] - for block in data["message"]["content"]: - match block["type"]: - case "text": - content_blocks.append(TextBlock(text=block["text"])) - case "tool_use": - content_blocks.append( - ToolUseBlock( - id=block["id"], - name=block["name"], - input=block["input"], + try: + content_blocks: list[ContentBlock] = [] + for block in data["message"]["content"]: + match block["type"]: + case "text": + content_blocks.append(TextBlock(text=block["text"])) + case "tool_use": + content_blocks.append( + ToolUseBlock( + id=block["id"], + name=block["name"], + input=block["input"], + ) ) - ) - case "tool_result": - content_blocks.append( - ToolResultBlock( - tool_use_id=block["tool_use_id"], - content=block.get("content"), - is_error=block.get("is_error"), + case "tool_result": + content_blocks.append( + ToolResultBlock( + tool_use_id=block["tool_use_id"], + content=block.get("content"), + is_error=block.get("is_error"), + ) ) - ) - return AssistantMessage(content=content_blocks) + return AssistantMessage(content=content_blocks) + except KeyError as e: + logger.error("Missing required field in assistant message: %s", e) + return None case "system": - return SystemMessage( - subtype=data["subtype"], - data=data, - ) + try: + return SystemMessage( + subtype=data["subtype"], + data=data, + ) + except KeyError as e: + logger.error("Missing required field in system message: %s", e) + return None case "result": - return ResultMessage( - subtype=data["subtype"], - duration_ms=data["duration_ms"], - duration_api_ms=data["duration_api_ms"], - is_error=data["is_error"], - num_turns=data["num_turns"], - session_id=data["session_id"], - total_cost_usd=data.get("total_cost_usd"), - usage=data.get("usage"), - result=data.get("result"), - ) + try: + return ResultMessage( + subtype=data["subtype"], + duration_ms=data["duration_ms"], + duration_api_ms=data["duration_api_ms"], + is_error=data["is_error"], + num_turns=data["num_turns"], + session_id=data["session_id"], + total_cost_usd=data.get("total_cost_usd"), + usage=data.get("usage"), + result=data.get("result"), + ) + except KeyError as e: + logger.error("Missing required field in result message: %s", e) + return None case _: + logger.debug("Unknown message type: %s", message_type) return None diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index b5eb3c90..d960b323 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -306,7 +306,12 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: yield data except GeneratorExit: return - except json.JSONDecodeError: + except json.JSONDecodeError as e: + logger.warning( + f"Failed to parse JSON from CLI output: {e}. Buffer content: {json_buffer[:200]}..." + ) + # Clear buffer to avoid repeated parse attempts on malformed data + json_buffer = "" continue except anyio.ClosedResourceError: diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py index 213519e5..dbf9aa6f 100644 --- a/src/claude_code_sdk/client.py +++ b/src/claude_code_sdk/client.py @@ -128,19 +128,37 @@ async def receive_messages(self) -> AsyncIterator[Message]: if message: yield message - async def send_message(self, content: str, session_id: str = "default") -> None: - """Send a new message in streaming mode.""" + async def send_message(self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default") -> None: + """ + Send a new message in streaming mode. + + Args: + prompt: Either a string message or an async iterable of message dictionaries + session_id: Session identifier for the conversation + """ if not self._transport: raise CLIConnectionError("Not connected. Call connect() first.") - message = { - "type": "user", - "message": {"role": "user", "content": content}, - "parent_tool_use_id": None, - "session_id": session_id, - } - - await self._transport.send_request([message], {"session_id": session_id}) + # Handle string prompts + if isinstance(prompt, str): + message = { + "type": "user", + "message": {"role": "user", "content": prompt}, + "parent_tool_use_id": None, + "session_id": session_id, + } + await self._transport.send_request([message], {"session_id": session_id}) + else: + # Handle AsyncIterable prompts + messages = [] + async for msg in prompt: + # Ensure session_id is set on each message + if "session_id" not in msg: + msg["session_id"] = session_id + messages.append(msg) + + if messages: + await self._transport.send_request(messages, {"session_id": session_id}) async def interrupt(self) -> None: """Send interrupt signal (only works with streaming mode).""" @@ -150,11 +168,17 @@ async def interrupt(self) -> None: async def receive_response(self) -> AsyncIterator[Message]: """ - Receive messages from Claude until a ResultMessage is received. + Receive messages from Claude until and including a ResultMessage. - This is an async iterator that yields all messages including the final ResultMessage. - It's a convenience method over receive_messages() that automatically stops iteration - after receiving a ResultMessage. + This async iterator yields all messages in sequence and automatically terminates + after yielding a ResultMessage (which indicates the response is complete). + It's a convenience method over receive_messages() for single-response workflows. + + **Stopping Behavior:** + - Yields each message as it's received + - Terminates immediately after yielding a ResultMessage + - The ResultMessage IS included in the yielded messages + - If no ResultMessage is received, the iterator continues indefinitely Yields: Message: Each message received (UserMessage, AssistantMessage, SystemMessage, ResultMessage) @@ -162,7 +186,6 @@ async def receive_response(self) -> AsyncIterator[Message]: Example: ```python async with ClaudeSDKClient() as client: - # Send message and process response await client.send_message("What's the capital of France?") async for msg in client.receive_response(): @@ -172,14 +195,12 @@ async def receive_response(self) -> AsyncIterator[Message]: print(f"Claude: {block.text}") elif isinstance(msg, ResultMessage): print(f"Cost: ${msg.total_cost_usd:.4f}") + # Iterator will terminate after this message ``` Note: - The iterator will automatically stop after yielding a ResultMessage. - If you need to collect all messages into a list, use: - ```python - messages = [msg async for msg in client.receive_response()] - ``` + To collect all messages: `messages = [msg async for msg in client.receive_response()]` + The final message in the list will always be a ResultMessage. """ async for message in self.receive_messages(): yield message From bd62813f168a7fe6361b7560106c7eefe1422076 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 19:12:15 -0700 Subject: [PATCH 19/47] CLAUDE.md Signed-off-by: Rushil Patel --- CLAUDE.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..69f23fb5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,27 @@ +# Workflow + +```bash +# Lint and style +# Check for issues and fix automatically +python -m ruff check src/ test/ --fix +python -m ruff format src/ test/ + +# Typecheck (only done for src/) +python -m mypy src/ + +# Run all tests +python -m pytest tests/ + +# Run specific test file +python -m pytest tests/test_client.py +``` + +# Codebase Structure + +- `src/claude_code_sdk/` - Main package + - `client.py` - ClaudeSDKClient for interactive sessions + - `query.py` - One-shot query function + - `types.py` - Type definitions + - `_internal/` - Internal implementation details + - `transport/subprocess_cli.py` - CLI subprocess management + - `message_parser.py` - Message parsing logic From ccdb2724722cf57afed22a433383ce0bb2cf55ff Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 19:57:17 -0700 Subject: [PATCH 20/47] Improve examples Signed-off-by: Rushil Patel --- examples/streaming_mode.py | 228 +++++++++++++++++++++-------- examples/streaming_mode_ipython.py | 72 +++++++-- src/claude_code_sdk/client.py | 16 +- tests/test_streaming_client.py | 12 +- 4 files changed, 237 insertions(+), 91 deletions(-) mode change 100644 => 100755 examples/streaming_mode.py diff --git a/examples/streaming_mode.py b/examples/streaming_mode.py old mode 100644 new mode 100755 index 239024db..73eb4109 --- a/examples/streaming_mode.py +++ b/examples/streaming_mode.py @@ -4,10 +4,20 @@ This file demonstrates various patterns for building applications with the ClaudeSDKClient streaming interface. + +The queries are intentionally simplistic. In reality, a query can be a more +complex task that Claude SDK uses its agentic capabilities and tools (e.g. run +bash commands, edit files, search the web, fetch web content) to accomplish. + +Usage: +./examples/streaming_mode.py - List the examples +./examples/streaming_mode.py all - Run all examples +./examples/streaming_mode.py basic_streaming - Run a specific example """ import asyncio import contextlib +import sys from claude_code_sdk import ( AssistantMessage, @@ -15,28 +25,48 @@ ClaudeSDKClient, CLIConnectionError, ResultMessage, + SystemMessage, TextBlock, + UserMessage, ) +def display_message(msg): + """Standardized message display function. + + - UserMessage: "User: " + - AssistantMessage: "Claude: " + - SystemMessage: ignored + - ResultMessage: "Result ended" + cost if available + """ + if isinstance(msg, UserMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"User: {block.text}") + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, SystemMessage): + # Ignore system messages + pass + elif isinstance(msg, ResultMessage): + print("Result ended") + + async def example_basic_streaming(): """Basic streaming with context manager.""" print("=== Basic Streaming Example ===") async with ClaudeSDKClient() as client: - # Send a message - await client.send_message("What is 2+2?") + print("User: What is 2+2?") + await client.query("What is 2+2?") # Receive complete response using the helper method async for msg in client.receive_response(): - if isinstance(msg, AssistantMessage): - for block in msg.content: - if isinstance(block, TextBlock): - print(f"Claude: {block.text}") - elif isinstance(msg, ResultMessage) and msg.total_cost_usd: - print(f"Cost: ${msg.total_cost_usd:.4f}") + display_message(msg) - print("Session ended\n") + print("\n") async def example_multi_turn_conversation(): @@ -46,26 +76,20 @@ async def example_multi_turn_conversation(): async with ClaudeSDKClient() as client: # First turn print("User: What's the capital of France?") - await client.send_message("What's the capital of France?") + await client.query("What's the capital of France?") # Extract and print response async for msg in client.receive_response(): - content_blocks = getattr(msg, 'content', []) - for block in content_blocks: - if isinstance(block, TextBlock): - print(f"{block.text}") + display_message(msg) # Second turn - follow-up print("\nUser: What's the population of that city?") - await client.send_message("What's the population of that city?") + await client.query("What's the population of that city?") async for msg in client.receive_response(): - content_blocks = getattr(msg, 'content', []) - for block in content_blocks: - if isinstance(block, TextBlock): - print(f"{block.text}") + display_message(msg) - print("\nConversation ended\n") + print("\n") async def example_concurrent_responses(): @@ -76,10 +100,7 @@ async def example_concurrent_responses(): # Background task to continuously receive messages async def receive_messages(): async for message in client.receive_messages(): - if isinstance(message, AssistantMessage): - for block in message.content: - if isinstance(block, TextBlock): - print(f"Claude: {block.text}") + display_message(message) # Start receiving in background receive_task = asyncio.create_task(receive_messages()) @@ -93,7 +114,7 @@ async def receive_messages(): for question in questions: print(f"\nUser: {question}") - await client.send_message(question) + await client.query(question) await asyncio.sleep(3) # Wait between messages # Give time for final responses @@ -104,7 +125,7 @@ async def receive_messages(): with contextlib.suppress(asyncio.CancelledError): await receive_task - print("\nSession ended\n") + print("\n") async def example_with_interrupt(): @@ -115,7 +136,7 @@ async def example_with_interrupt(): async with ClaudeSDKClient() as client: # Start a long-running task print("\nUser: Count from 1 to 100 slowly") - await client.send_message( + await client.query( "Count from 1 to 100 slowly, with a brief pause between each number" ) @@ -132,10 +153,10 @@ async def consume_messages(): if isinstance(block, TextBlock): # Print first few numbers print(f"Claude: {block.text[:50]}...") - - # Stop when we get a result after interrupt - if isinstance(message, ResultMessage) and interrupt_sent: - break + elif isinstance(message, ResultMessage): + display_message(message) + if interrupt_sent: + break # Start consuming messages in the background consume_task = asyncio.create_task(consume_messages()) @@ -151,16 +172,13 @@ async def consume_messages(): # Send new instruction after interrupt print("\nUser: Never mind, just tell me a quick joke") - await client.send_message("Never mind, just tell me a quick joke") + await client.query("Never mind, just tell me a quick joke") # Get the joke async for msg in client.receive_response(): - if isinstance(msg, AssistantMessage): - for block in msg.content: - if isinstance(block, TextBlock): - print(f"Claude: {block.text}") + display_message(msg) - print("\nSession ended\n") + print("\n") async def example_manual_message_handling(): @@ -168,7 +186,7 @@ async def example_manual_message_handling(): print("=== Manual Message Handling Example ===") async with ClaudeSDKClient() as client: - await client.send_message( + await client.query( "List 5 programming languages and their main use cases" ) @@ -180,6 +198,7 @@ async def example_manual_message_handling(): for block in message.content: if isinstance(block, TextBlock): text = block.text + print(f"Claude: {text}") # Custom logic: extract language names for lang in [ "Python", @@ -193,12 +212,12 @@ async def example_manual_message_handling(): if lang in text and lang not in languages_found: languages_found.append(lang) print(f"Found language: {lang}") - elif isinstance(message, ResultMessage): - print(f"\nTotal languages mentioned: {len(languages_found)}") + display_message(message) + print(f"Total languages mentioned: {len(languages_found)}") break - print("\nSession ended\n") + print("\n") async def example_with_options(): @@ -213,23 +232,75 @@ async def example_with_options(): ) async with ClaudeSDKClient(options=options) as client: - await client.send_message( + print("User: Create a simple hello.txt file with a greeting message") + await client.query( "Create a simple hello.txt file with a greeting message" ) tool_uses = [] async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): + display_message(msg) for block in msg.content: - if isinstance(block, TextBlock): - print(f"Claude: {block.text}") - elif hasattr(block, "name"): # ToolUseBlock + if hasattr(block, "name") and not isinstance( + block, TextBlock + ): # ToolUseBlock tool_uses.append(getattr(block, "name", "")) + else: + display_message(msg) if tool_uses: - print(f"\nTools used: {', '.join(tool_uses)}") + print(f"Tools used: {', '.join(tool_uses)}") + + print("\n") + + +async def example_async_iterable_prompt(): + """Demonstrate send_message with async iterable.""" + print("=== Async Iterable Prompt Example ===") + + async def create_message_stream(): + """Generate a stream of messages.""" + print("User: Hello! I have multiple questions.") + yield { + "type": "user", + "message": {"role": "user", "content": "Hello! I have multiple questions."}, + "parent_tool_use_id": None, + "session_id": "qa-session", + } + + print("User: First, what's the capital of Japan?") + yield { + "type": "user", + "message": { + "role": "user", + "content": "First, what's the capital of Japan?", + }, + "parent_tool_use_id": None, + "session_id": "qa-session", + } + + print("User: Second, what's 15% of 200?") + yield { + "type": "user", + "message": {"role": "user", "content": "Second, what's 15% of 200?"}, + "parent_tool_use_id": None, + "session_id": "qa-session", + } + + async with ClaudeSDKClient() as client: + # Send async iterable of messages + await client.query(create_message_stream()) + + # Receive the three responses + async for msg in client.receive_response(): + display_message(msg) + async for msg in client.receive_response(): + display_message(msg) + async for msg in client.receive_response(): + display_message(msg) - print("\nSession ended\n") + print("\n") async def example_error_handling(): @@ -242,7 +313,8 @@ async def example_error_handling(): await client.connect() # Send a message that will take time to process - await client.send_message("Run a bash sleep command for 60 seconds") + print("User: Run a bash sleep command for 60 seconds") + await client.query("Run a bash sleep command for 60 seconds") # Try to receive response with a short timeout try: @@ -255,11 +327,13 @@ async def example_error_handling(): if isinstance(block, TextBlock): print(f"Claude: {block.text[:50]}...") elif isinstance(msg, ResultMessage): - print("Received complete response") + display_message(msg) break except asyncio.TimeoutError: - print("\nResponse timeout after 10 seconds - demonstrating graceful handling") + print( + "\nResponse timeout after 10 seconds - demonstrating graceful handling" + ) print(f"Received {len(messages)} messages before timeout") except CLIConnectionError as e: @@ -272,24 +346,48 @@ async def example_error_handling(): # Always disconnect await client.disconnect() - print("\nSession ended\n") + print("\n") async def main(): - """Run all examples.""" - examples = [ - example_basic_streaming, - example_multi_turn_conversation, - example_concurrent_responses, - example_with_interrupt, - example_manual_message_handling, - example_with_options, - example_error_handling, - ] - - for example in examples: - await example() - print("-" * 50 + "\n") + """Run all examples or a specific example based on command line argument.""" + examples = { + "basic_streaming": example_basic_streaming, + "multi_turn_conversation": example_multi_turn_conversation, + "concurrent_responses": example_concurrent_responses, + "with_interrupt": example_with_interrupt, + "manual_message_handling": example_manual_message_handling, + "with_options": example_with_options, + "async_iterable_prompt": example_async_iterable_prompt, + "error_handling": example_error_handling, + } + + if len(sys.argv) < 2: + # List available examples + print("Usage: python streaming_mode.py ") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + sys.exit(0) + + example_name = sys.argv[1] + + if example_name == "all": + # Run all examples + for example in examples.values(): + await example() + print("-" * 50 + "\n") + elif example_name in examples: + # Run specific example + await examples[example_name]() + else: + print(f"Error: Unknown example '{example_name}'") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + sys.exit(1) if __name__ == "__main__": diff --git a/examples/streaming_mode_ipython.py b/examples/streaming_mode_ipython.py index 6b2b554e..7265afa4 100644 --- a/examples/streaming_mode_ipython.py +++ b/examples/streaming_mode_ipython.py @@ -4,6 +4,10 @@ These examples are designed to be copy-pasted directly into IPython. Each example is self-contained and can be run independently. + +The queries are intentionally simplistic. In reality, a query can be a more +complex task that Claude SDK uses its agentic capabilities and tools (e.g. run +bash commands, edit files, search the web, fetch web content) to accomplish. """ # ============================================================================ @@ -13,15 +17,14 @@ from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage async with ClaudeSDKClient() as client: - await client.send_message("What is 2+2?") + print("User: What is 2+2?") + await client.query("What is 2+2?") async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): print(f"Claude: {block.text}") - elif isinstance(msg, ResultMessage) and msg.total_cost_usd: - print(f"Cost: ${msg.total_cost_usd:.4f}") # ============================================================================ @@ -33,7 +36,8 @@ async with ClaudeSDKClient() as client: async def send_and_receive(prompt): - await client.send_message(prompt) + print(f"User: {prompt}") + await client.query(prompt) async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): for block in msg.content: @@ -66,10 +70,12 @@ async def get_response(): # Use it multiple times -await client.send_message("What's 2+2?") +print("User: What's 2+2?") +await client.query("What's 2+2?") await get_response() -await client.send_message("What's 10*10?") +print("User: What's 10*10?") +await client.query("What's 10*10?") await get_response() # Don't forget to disconnect when done @@ -89,7 +95,8 @@ async def get_response(): print("\n--- Sending initial message ---\n") # Send a long-running task - await client.send_message("Count from 1 to 100 slowly using bash sleep") + print("User: Count from 1 to 100, run bash sleep for 1 second in between") + await client.query("Count from 1 to 100, run bash sleep for 1 second in between") # Create a background task to consume messages messages_received = [] @@ -121,7 +128,7 @@ async def consume_messages(): # Send a new message after interrupt print("\n--- After interrupt, sending new message ---\n") - await client.send_message("Just say 'Hello! I was interrupted.'") + await client.query("Just say 'Hello! I was interrupted.'") async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): @@ -138,7 +145,8 @@ async def consume_messages(): try: async with ClaudeSDKClient() as client: - await client.send_message("Run a bash sleep command for 60 seconds") + print("User: Run a bash sleep command for 60 seconds") + await client.query("Run a bash sleep command for 60 seconds") # Timeout after 20 seconds messages = [] @@ -156,6 +164,47 @@ async def consume_messages(): print(f"Error: {e}") +# ============================================================================ +# SENDING ASYNC ITERABLE OF MESSAGES +# ============================================================================ + +from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock + +async def message_generator(): + """Generate multiple messages as an async iterable.""" + print("User: I have two math questions.") + yield { + "type": "user", + "message": {"role": "user", "content": "I have two math questions."}, + "parent_tool_use_id": None, + "session_id": "math-session" + } + print("User: What is 25 * 4?") + yield { + "type": "user", + "message": {"role": "user", "content": "What is 25 * 4?"}, + "parent_tool_use_id": None, + "session_id": "math-session" + } + print("User: What is 100 / 5?") + yield { + "type": "user", + "message": {"role": "user", "content": "What is 100 / 5?"}, + "parent_tool_use_id": None, + "session_id": "math-session" + } + +async with ClaudeSDKClient() as client: + # Send async iterable instead of string + await client.query(message_generator()) + + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + # ============================================================================ # COLLECTING ALL MESSAGES INTO A LIST # ============================================================================ @@ -163,7 +212,8 @@ async def consume_messages(): from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage async with ClaudeSDKClient() as client: - await client.send_message("What are the primary colors?") + print("User: What are the primary colors?") + await client.query("What are the primary colors?") # Collect all messages into a list messages = [msg async for msg in client.receive_response()] @@ -176,5 +226,3 @@ async def consume_messages(): print(f"Claude: {block.text}") elif isinstance(msg, ResultMessage): print(f"Total messages: {len(messages)}") - if msg.total_cost_usd: - print(f"Cost: ${msg.total_cost_usd:.4f}") diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py index dbf9aa6f..3c24cb19 100644 --- a/src/claude_code_sdk/client.py +++ b/src/claude_code_sdk/client.py @@ -42,7 +42,7 @@ class ClaudeSDKClient: # Automatically connects with empty stream for interactive use async with ClaudeSDKClient() as client: # Send initial message - await client.send_message("Let's solve a math problem step by step") + await client.query("Let's solve a math problem step by step") # Receive and process response async for message in client.receive_messages(): @@ -50,7 +50,7 @@ class ClaudeSDKClient: break # Send follow-up based on response - await client.send_message("What's 15% of 80?") + await client.query("What's 15% of 80?") # Continue conversation... # Automatically disconnects @@ -60,14 +60,14 @@ class ClaudeSDKClient: ```python async with ClaudeSDKClient() as client: # Start a long task - await client.send_message("Count to 1000") + await client.query("Count to 1000") # Interrupt after 2 seconds await asyncio.sleep(2) await client.interrupt() # Send new instruction - await client.send_message("Never mind, what's 2+2?") + await client.query("Never mind, what's 2+2?") ``` Example - Manual connection: @@ -81,7 +81,7 @@ async def message_stream(): await client.connect(message_stream()) # Send additional messages dynamically - await client.send_message("What's the weather?") + await client.query("What's the weather?") async for message in client.receive_messages(): print(message) @@ -128,9 +128,9 @@ async def receive_messages(self) -> AsyncIterator[Message]: if message: yield message - async def send_message(self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default") -> None: + async def query(self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default") -> None: """ - Send a new message in streaming mode. + Send a new request in streaming mode. Args: prompt: Either a string message or an async iterable of message dictionaries @@ -186,7 +186,7 @@ async def receive_response(self) -> AsyncIterator[Message]: Example: ```python async with ClaudeSDKClient() as client: - await client.send_message("What's the capital of France?") + await client.query("What's the capital of France?") async for msg in client.receive_response(): if isinstance(msg, AssistantMessage): diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index 49a52911..884d7c4e 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -116,8 +116,8 @@ async def message_stream(): anyio.run(_test) - def test_send_message(self): - """Test sending a message.""" + def test_query(self): + """Test sending a query.""" async def _test(): with patch( @@ -127,7 +127,7 @@ async def _test(): mock_transport_class.return_value = mock_transport async with ClaudeSDKClient() as client: - await client.send_message("Test message") + await client.query("Test message") # Verify send_request was called with correct format mock_transport.send_request.assert_called_once() @@ -151,7 +151,7 @@ async def _test(): mock_transport_class.return_value = mock_transport async with ClaudeSDKClient() as client: - await client.send_message("Test", session_id="custom-session") + await client.query("Test", session_id="custom-session") call_args = mock_transport.send_request.call_args messages, options = call_args[0] @@ -166,7 +166,7 @@ def test_send_message_not_connected(self): async def _test(): client = ClaudeSDKClient() with pytest.raises(CLIConnectionError, match="Not connected"): - await client.send_message("Test") + await client.query("Test") anyio.run(_test) @@ -360,7 +360,7 @@ async def get_next_message(): receive_task = asyncio.create_task(get_next_message()) # Send message while receiving - await client.send_message("Question 1") + await client.query("Question 1") # Wait for first message first_msg = await receive_task From bce29e4f26f4b1d8d9de5fbebe4ec82fb75e6027 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 20:04:58 -0700 Subject: [PATCH 21/47] Fix lint and test Signed-off-by: Rushil Patel --- CLAUDE.md | 4 ++-- .../_internal/transport/subprocess_cli.py | 9 +++------ src/claude_code_sdk/client.py | 8 ++++++-- src/claude_code_sdk/query.py | 4 +++- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 69f23fb5..fb9ed473 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,8 @@ ```bash # Lint and style # Check for issues and fix automatically -python -m ruff check src/ test/ --fix -python -m ruff format src/ test/ +python -m ruff check src/ tests/ --fix +python -m ruff format src/ tests/ # Typecheck (only done for src/) python -m mypy src/ diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index d960b323..70c16229 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -306,12 +306,9 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: yield data except GeneratorExit: return - except json.JSONDecodeError as e: - logger.warning( - f"Failed to parse JSON from CLI output: {e}. Buffer content: {json_buffer[:200]}..." - ) - # Clear buffer to avoid repeated parse attempts on malformed data - json_buffer = "" + except json.JSONDecodeError: + # Don't clear buffer - we might be in the middle of a split JSON message + # The buffer will be cleared when we successfully parse or hit size limit continue except anyio.ClosedResourceError: diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py index 3c24cb19..a4c81ed4 100644 --- a/src/claude_code_sdk/client.py +++ b/src/claude_code_sdk/client.py @@ -98,7 +98,9 @@ def __init__(self, options: ClaudeCodeOptions | None = None): self._transport: Any | None = None os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client" - async def connect(self, prompt: str | AsyncIterable[dict[str, Any]] | None = None) -> None: + async def connect( + self, prompt: str | AsyncIterable[dict[str, Any]] | None = None + ) -> None: """Connect to Claude with a prompt or message stream.""" from ._internal.transport.subprocess_cli import SubprocessCLITransport @@ -128,7 +130,9 @@ async def receive_messages(self) -> AsyncIterator[Message]: if message: yield message - async def query(self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default") -> None: + async def query( + self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default" + ) -> None: """ Send a new request in streaming mode. diff --git a/src/claude_code_sdk/query.py b/src/claude_code_sdk/query.py index 37327626..ad77a1b1 100644 --- a/src/claude_code_sdk/query.py +++ b/src/claude_code_sdk/query.py @@ -9,7 +9,9 @@ async def query( - *, prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeCodeOptions | None = None + *, + prompt: str | AsyncIterable[dict[str, Any]], + options: ClaudeCodeOptions | None = None, ) -> AsyncIterator[Message]: """ Query Claude Code for one-shot or unidirectional streaming interactions. From b68d154320de12cf66c4226912d0adc205aaa8b1 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 20:16:45 -0700 Subject: [PATCH 22/47] Fix json error handling --- src/claude_code_sdk/_errors.py | 8 ++ src/claude_code_sdk/_internal/client.py | 5 +- .../_internal/message_parser.py | 47 ++++--- .../_internal/transport/subprocess_cli.py | 5 +- src/claude_code_sdk/client.py | 4 +- tests/test_message_parser.py | 118 ++++++++++++++++++ 6 files changed, 159 insertions(+), 28 deletions(-) create mode 100644 tests/test_message_parser.py diff --git a/src/claude_code_sdk/_errors.py b/src/claude_code_sdk/_errors.py index e8327577..8f3e7598 100644 --- a/src/claude_code_sdk/_errors.py +++ b/src/claude_code_sdk/_errors.py @@ -44,3 +44,11 @@ def __init__(self, line: str, original_error: Exception): self.line = line self.original_error = original_error super().__init__(f"Failed to decode JSON: {line[:100]}...") + + +class MessageParseError(ClaudeSDKError): + """Raised when unable to parse a message from CLI output.""" + + def __init__(self, message: str, data: dict | None = None): + self.data = data + super().__init__(message) diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py index b4da9334..ec700cc2 100644 --- a/src/claude_code_sdk/_internal/client.py +++ b/src/claude_code_sdk/_internal/client.py @@ -49,9 +49,8 @@ async def process_query( await chosen_transport.connect() async for data in chosen_transport.receive_messages(): - message = parse_message(data) - if message: - yield message + yield parse_message(data) + finally: await chosen_transport.disconnect() diff --git a/src/claude_code_sdk/_internal/message_parser.py b/src/claude_code_sdk/_internal/message_parser.py index c5f4fc0f..858e24fa 100644 --- a/src/claude_code_sdk/_internal/message_parser.py +++ b/src/claude_code_sdk/_internal/message_parser.py @@ -3,6 +3,7 @@ import logging from typing import Any +from .._errors import MessageParseError from ..types import ( AssistantMessage, ContentBlock, @@ -18,7 +19,7 @@ logger = logging.getLogger(__name__) -def parse_message(data: dict[str, Any]) -> Message | None: +def parse_message(data: dict[str, Any]) -> Message: """ Parse message from CLI output into typed Message objects. @@ -26,25 +27,29 @@ def parse_message(data: dict[str, Any]) -> Message | None: data: Raw message dictionary from CLI output Returns: - Parsed Message object or None if type is unrecognized or parsing fails + Parsed Message object + + Raises: + MessageParseError: If parsing fails or message type is unrecognized """ - try: - message_type = data.get("type") - if not message_type: - logger.warning("Message missing 'type' field: %s", data) - return None + if not isinstance(data, dict): + raise MessageParseError( + f"Invalid message data type (expected dict, got {type(data).__name__})", + data, + ) - except AttributeError: - logger.error("Invalid message data type (expected dict): %s", type(data)) - return None + message_type = data.get("type") + if not message_type: + raise MessageParseError("Message missing 'type' field", data) match message_type: case "user": try: return UserMessage(content=data["message"]["content"]) except KeyError as e: - logger.error("Missing required field in user message: %s", e) - return None + raise MessageParseError( + f"Missing required field in user message: {e}", data + ) from e case "assistant": try: @@ -72,8 +77,9 @@ def parse_message(data: dict[str, Any]) -> Message | None: return AssistantMessage(content=content_blocks) except KeyError as e: - logger.error("Missing required field in assistant message: %s", e) - return None + raise MessageParseError( + f"Missing required field in assistant message: {e}", data + ) from e case "system": try: @@ -82,8 +88,9 @@ def parse_message(data: dict[str, Any]) -> Message | None: data=data, ) except KeyError as e: - logger.error("Missing required field in system message: %s", e) - return None + raise MessageParseError( + f"Missing required field in system message: {e}", data + ) from e case "result": try: @@ -99,9 +106,9 @@ def parse_message(data: dict[str, Any]) -> Message | None: result=data.get("result"), ) except KeyError as e: - logger.error("Missing required field in result message: %s", e) - return None + raise MessageParseError( + f"Missing required field in result message: {e}", data + ) from e case _: - logger.debug("Unknown message type: %s", message_type) - return None + raise MessageParseError(f"Unknown message type: {message_type}", data) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 70c16229..1777d859 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -307,8 +307,9 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: except GeneratorExit: return except json.JSONDecodeError: - # Don't clear buffer - we might be in the middle of a split JSON message - # The buffer will be cleared when we successfully parse or hit size limit + # We are speculatively decoding the buffer until we get + # a full JSON object. If there is an actual issue, we + # raise an error after _MAX_BUFFER_SIZE. continue except anyio.ClosedResourceError: diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py index a4c81ed4..8e86ba7c 100644 --- a/src/claude_code_sdk/client.py +++ b/src/claude_code_sdk/client.py @@ -126,9 +126,7 @@ async def receive_messages(self) -> AsyncIterator[Message]: from ._internal.message_parser import parse_message async for data in self._transport.receive_messages(): - message = parse_message(data) - if message: - yield message + yield parse_message(data) async def query( self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default" diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py new file mode 100644 index 00000000..47bd5216 --- /dev/null +++ b/tests/test_message_parser.py @@ -0,0 +1,118 @@ +"""Tests for message parser error handling.""" + +import pytest + +from claude_code_sdk._errors import MessageParseError +from claude_code_sdk._internal.message_parser import parse_message +from claude_code_sdk.types import ( + AssistantMessage, + ResultMessage, + SystemMessage, + TextBlock, + ToolUseBlock, + UserMessage, +) + + +class TestMessageParser: + """Test message parsing with the new exception behavior.""" + + def test_parse_valid_user_message(self): + """Test parsing a valid user message.""" + data = {"type": "user", "message": {"content": [{"type": "text", "text": "Hello"}]}} + message = parse_message(data) + assert isinstance(message, UserMessage) + + def test_parse_valid_assistant_message(self): + """Test parsing a valid assistant message.""" + data = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Hello"}, + { + "type": "tool_use", + "id": "tool_123", + "name": "Read", + "input": {"file_path": "/test.txt"}, + }, + ] + }, + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert len(message.content) == 2 + assert isinstance(message.content[0], TextBlock) + assert isinstance(message.content[1], ToolUseBlock) + + def test_parse_valid_system_message(self): + """Test parsing a valid system message.""" + data = {"type": "system", "subtype": "start"} + message = parse_message(data) + assert isinstance(message, SystemMessage) + assert message.subtype == "start" + + def test_parse_valid_result_message(self): + """Test parsing a valid result message.""" + data = { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 500, + "is_error": False, + "num_turns": 2, + "session_id": "session_123", + } + message = parse_message(data) + assert isinstance(message, ResultMessage) + assert message.subtype == "success" + + def test_parse_invalid_data_type(self): + """Test that non-dict data raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message("not a dict") # type: ignore + assert "Invalid message data type" in str(exc_info.value) + assert "expected dict, got str" in str(exc_info.value) + + def test_parse_missing_type_field(self): + """Test that missing 'type' field raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"message": {"content": []}}) + assert "Message missing 'type' field" in str(exc_info.value) + + def test_parse_unknown_message_type(self): + """Test that unknown message type raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "unknown_type"}) + assert "Unknown message type: unknown_type" in str(exc_info.value) + + def test_parse_user_message_missing_fields(self): + """Test that user message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "user"}) + assert "Missing required field in user message" in str(exc_info.value) + + def test_parse_assistant_message_missing_fields(self): + """Test that assistant message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "assistant"}) + assert "Missing required field in assistant message" in str(exc_info.value) + + def test_parse_system_message_missing_fields(self): + """Test that system message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "system"}) + assert "Missing required field in system message" in str(exc_info.value) + + def test_parse_result_message_missing_fields(self): + """Test that result message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "result", "subtype": "success"}) + assert "Missing required field in result message" in str(exc_info.value) + + def test_message_parse_error_contains_data(self): + """Test that MessageParseError contains the original data.""" + data = {"type": "unknown", "some": "data"} + with pytest.raises(MessageParseError) as exc_info: + parse_message(data) + assert exc_info.value.data == data From 3a22f48288ff1603b5713c117e70e621a73aa7e6 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 20:19:40 -0700 Subject: [PATCH 23/47] Lint Signed-off-by: Rushil Patel --- tests/test_message_parser.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 47bd5216..0eb43542 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -19,7 +19,10 @@ class TestMessageParser: def test_parse_valid_user_message(self): """Test parsing a valid user message.""" - data = {"type": "user", "message": {"content": [{"type": "text", "text": "Hello"}]}} + data = { + "type": "user", + "message": {"content": [{"type": "text", "text": "Hello"}]}, + } message = parse_message(data) assert isinstance(message, UserMessage) From 2eec6a0b685fada44638e36f2dcf700cf170a682 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Sat, 19 Jul 2025 20:43:07 -0700 Subject: [PATCH 24/47] Remove hardcoded timeout for control messages to match Typescript SDK Signed-off-by: Rushil Patel --- src/claude_code_sdk/_errors.py | 4 +++- .../_internal/transport/subprocess_cli.py | 20 +++++++------------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/claude_code_sdk/_errors.py b/src/claude_code_sdk/_errors.py index 8f3e7598..c86bf235 100644 --- a/src/claude_code_sdk/_errors.py +++ b/src/claude_code_sdk/_errors.py @@ -1,5 +1,7 @@ """Error types for Claude SDK.""" +from typing import Any + class ClaudeSDKError(Exception): """Base exception for all Claude SDK errors.""" @@ -49,6 +51,6 @@ def __init__(self, line: str, original_error: Exception): class MessageParseError(ClaudeSDKError): """Raised when unable to parse a message from CLI output.""" - def __init__(self, message: str, data: dict | None = None): + def __init__(self, message: str, data: dict[str, Any] | None = None): self.data = data super().__init__(message) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 1777d859..132e5642 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -404,19 +404,13 @@ async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any] # Send request await self._stdin_stream.send(json.dumps(control_request) + "\n") - # Wait for response with timeout - try: - with anyio.fail_after(30.0): # 30 second timeout - while request_id not in self._pending_control_responses: - await anyio.sleep(0.1) + # Wait for response + while request_id not in self._pending_control_responses: + await anyio.sleep(0.1) - response = self._pending_control_responses.pop(request_id) + response = self._pending_control_responses.pop(request_id) - if response.get("subtype") == "error": - raise CLIConnectionError( - f"Control request failed: {response.get('error')}" - ) + if response.get("subtype") == "error": + raise CLIConnectionError(f"Control request failed: {response.get('error')}") - return response - except TimeoutError: - raise CLIConnectionError("Control request timed out") from None + return response From a520063d480487283e6ca541379bd3eda6b80c72 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Mon, 21 Jul 2025 09:53:24 -0700 Subject: [PATCH 25/47] Fix "Publish to PyPI" workflow: Add commit signing and improve diff (#82) * Adds commit signing * Converts sed pattern matching to a script to ensure we don't update version values unrelated to PyPI * Remove the `Publish to Test PyPI first'` step since it no longer works Signed-off-by: Rushil Patel --- .github/workflows/publish.yml | 110 +++++++++++++++++++--------------- scripts/update_version.py | 49 +++++++++++++++ 2 files changed, 112 insertions(+), 47 deletions(-) create mode 100755 scripts/update_version.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1822a896..c0a2d541 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,12 +7,6 @@ on: description: 'Version to publish (e.g., 0.1.0)' required: true type: string - test_pypi: - description: 'Publish to Test PyPI first' - required: false - type: boolean - default: true - jobs: test: runs-on: ubuntu-latest @@ -79,11 +73,16 @@ jobs: with: python-version: '3.12' + - name: Set version + id: version + run: | + VERSION="${{ github.event.inputs.version }}" + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Update version run: | - # Update version in pyproject.toml - sed -i 's/version = ".*"/version = "${{ github.event.inputs.version }}"/' pyproject.toml - sed -i 's/__version__ = ".*"/__version__ = "${{ github.event.inputs.version }}"/' src/claude_code_sdk/__init__.py + python scripts/update_version.py "${{ env.VERSION }}" - name: Install build dependencies run: | @@ -96,16 +95,6 @@ jobs: - name: Check package run: twine check dist/* - - name: Publish to Test PyPI - if: ${{ github.event.inputs.test_pypi == 'true' }} - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} - run: | - twine upload --repository testpypi dist/* - echo "Package published to Test PyPI" - echo "Install with: pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ claude-code-sdk==${{ github.event.inputs.version }}" - - name: Publish to PyPI env: TWINE_USERNAME: __token__ @@ -113,41 +102,68 @@ jobs: run: | twine upload dist/* echo "Package published to PyPI" - echo "Install with: pip install claude-code-sdk==${{ github.event.inputs.version }}" + echo "Install with: pip install claude-code-sdk==${{ env.VERSION }}" - name: Create version update PR env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Create a new branch for the version update - BRANCH_NAME="release/v${{ github.event.inputs.version }}" - git checkout -b "$BRANCH_NAME" + BRANCH_NAME="release/v${{ env.VERSION }}" + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - # Configure git - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" + # Create branch via API + BASE_SHA=$(git rev-parse HEAD) + gh api \ + --method POST \ + /repos/$GITHUB_REPOSITORY/git/refs \ + -f ref="refs/heads/$BRANCH_NAME" \ + -f sha="$BASE_SHA" - # Commit the version changes - git add pyproject.toml src/claude_code_sdk/__init__.py - git commit -m "chore: bump version to ${{ github.event.inputs.version }}" + # Get current SHA values of files + echo "Getting SHA for pyproject.toml" + PYPROJECT_SHA=$(gh api /repos/$GITHUB_REPOSITORY/contents/pyproject.toml --jq '.sha') + echo "Getting SHA for __init__.py" + INIT_SHA=$(gh api /repos/$GITHUB_REPOSITORY/contents/src/claude_code_sdk/__init__.py --jq '.sha') - # Push the branch - git push origin "$BRANCH_NAME" + # Commit pyproject.toml via GitHub API (this creates signed commits) + message="chore: bump version to ${{ env.VERSION }}" + base64 -i pyproject.toml > pyproject.toml.b64 + gh api \ + --method PUT \ + /repos/$GITHUB_REPOSITORY/contents/pyproject.toml \ + -f message="$message" \ + -F content=@pyproject.toml.b64 \ + -f sha="$PYPROJECT_SHA" \ + -f branch="$BRANCH_NAME" - # Create PR using GitHub CLI (gh) - gh pr create \ - --title "chore: bump version to ${{ github.event.inputs.version }}" \ - --body "This PR updates the version to ${{ github.event.inputs.version }} after publishing to PyPI. - - ## Changes - - Updated version in \`pyproject.toml\` - - Updated version in \`src/claude_code_sdk/__init__.py\` - - ## Release Information - - Published to PyPI: https://pypi.org/project/claude-code-sdk/${{ github.event.inputs.version }}/ - - Install with: \`pip install claude-code-sdk==${{ github.event.inputs.version }}\` - - ## Next Steps - After merging this PR, a release tag will be created automatically." \ + # Commit __init__.py via GitHub API + base64 -i src/claude_code_sdk/__init__.py > init.py.b64 + gh api \ + --method PUT \ + /repos/$GITHUB_REPOSITORY/contents/src/claude_code_sdk/__init__.py \ + -f message="$message" \ + -F content=@init.py.b64 \ + -f sha="$INIT_SHA" \ + -f branch="$BRANCH_NAME" + + # Create PR using GitHub CLI + PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI. + + ## Changes + - Updated version in \`pyproject.toml\` + - Updated version in \`src/claude_code_sdk/__init__.py\` + + ## Release Information + - Published to PyPI: https://pypi.org/project/claude-code-sdk/${{ env.VERSION }}/ + - Install with: \`pip install claude-code-sdk==${{ env.VERSION }}\` + + 🤖 Generated by GitHub Actions" + + PR_URL=$(gh pr create \ + --title "chore: bump version to ${{ env.VERSION }}" \ + --body "$PR_BODY" \ --base main \ - --head "$BRANCH_NAME" \ No newline at end of file + --head "$BRANCH_NAME") + + echo "PR created: $PR_URL" \ No newline at end of file diff --git a/scripts/update_version.py b/scripts/update_version.py new file mode 100755 index 00000000..9d92a817 --- /dev/null +++ b/scripts/update_version.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Update version in pyproject.toml and __init__.py files.""" + +import sys +import re +from pathlib import Path + + +def update_version(new_version: str) -> None: + """Update version in project files.""" + # Update pyproject.toml + pyproject_path = Path("pyproject.toml") + content = pyproject_path.read_text() + + # Only update the version field in [project] section + content = re.sub( + r'^version = "[^"]*"', + f'version = "{new_version}"', + content, + count=1, + flags=re.MULTILINE + ) + + pyproject_path.write_text(content) + print(f"Updated pyproject.toml to version {new_version}") + + # Update __init__.py + init_path = Path("src/claude_code_sdk/__init__.py") + content = init_path.read_text() + + # Only update __version__ assignment + content = re.sub( + r'^__version__ = "[^"]*"', + f'__version__ = "{new_version}"', + content, + count=1, + flags=re.MULTILINE + ) + + init_path.write_text(content) + print(f"Updated __init__.py to version {new_version}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python scripts/update_version.py ") + sys.exit(1) + + update_version(sys.argv[1]) \ No newline at end of file From b8fbe03c72f1fe0c81026a5f982468e3f1316520 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:55:50 -0700 Subject: [PATCH 26/47] chore: bump version to 0.0.16 (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.0.16 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` - Updated version in `src/claude_code_sdk/__init__.py` ## Release Information - Published to PyPI: https://pypi.org/project/claude-code-sdk/0.0.16/ - Install with: `pip install claude-code-sdk==0.0.16` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rushil Patel --- pyproject.toml | 2 +- src/claude_code_sdk/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb270fb3..f2b300ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-code-sdk" -version = "0.0.14" +version = "0.0.16" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 90076dd8..da9521f7 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -28,7 +28,7 @@ UserMessage, ) -__version__ = "0.0.14" +__version__ = "0.0.16" __all__ = [ # Main exports From d89a06c174c6a5f51bd09a41bbb4df30eeae5462 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Mon, 21 Jul 2025 10:53:01 -0700 Subject: [PATCH 27/47] Add changelog and changelog format check (#77) Signed-off-by: Rushil Patel --- CHANGELOG.md | 20 ++++++++++ tests/test_changelog.py | 85 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 tests/test_changelog.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..b394833b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## 0.0.16 + +- Introduce ClaudeSDKClient for bidirectional streaming conversation +- Support Message input, not just string prompts, in query() +- Raise explicit error if the cwd does not exist + +## 0.0.14 + +- Add safety limits to Claude Code CLI stderr reading +- Improve handling of output JSON messages split across multiple stream reads + +## 0.0.13 + +- Update MCP (Model Context Protocol) types to align with Claude Code expectations +- Fix multi-line buffering issue +- Rename cost_usd to total_cost_usd in API responses +- Fix optional cost fields handling + diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 00000000..be0f219c --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,85 @@ +import re +from pathlib import Path + + +class TestChangelog: + def setup_method(self): + self.changelog_path = Path(__file__).parent.parent / "CHANGELOG.md" + + def test_changelog_exists(self): + assert self.changelog_path.exists(), "CHANGELOG.md file should exist" + + def test_changelog_starts_with_header(self): + content = self.changelog_path.read_text() + assert content.startswith("# Changelog"), ( + "Changelog should start with '# Changelog'" + ) + + def test_changelog_has_valid_version_format(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + version_pattern = re.compile(r"^## \d+\.\d+\.\d+(?:\s+\(\d{4}-\d{2}-\d{2}\))?$") + versions = [] + + for line in lines: + if line.startswith("## "): + assert version_pattern.match(line), f"Invalid version format: {line}" + version_match = re.match(r"^## (\d+\.\d+\.\d+)", line) + if version_match: + versions.append(version_match.group(1)) + + assert len(versions) > 0, "Changelog should contain at least one version" + + def test_changelog_has_bullet_points(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + in_version_section = False + has_bullet_points = False + + for i, line in enumerate(lines): + if line.startswith("## "): + if in_version_section and not has_bullet_points: + raise AssertionError( + "Previous version section should have at least one bullet point" + ) + in_version_section = True + has_bullet_points = False + elif in_version_section and line.startswith("- "): + has_bullet_points = True + elif in_version_section and line.strip() == "" and i == len(lines) - 1: + # Last line check + assert has_bullet_points, ( + "Each version should have at least one bullet point" + ) + + # Check the last section + if in_version_section: + assert has_bullet_points, ( + "Last version section should have at least one bullet point" + ) + + def test_changelog_versions_in_descending_order(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + versions = [] + for line in lines: + if line.startswith("## "): + version_match = re.match(r"^## (\d+)\.(\d+)\.(\d+)", line) + if version_match: + versions.append(tuple(map(int, version_match.groups()))) + + for i in range(1, len(versions)): + assert versions[i - 1] > versions[i], ( + f"Versions should be in descending order: {versions[i - 1]} should be > {versions[i]}" + ) + + def test_changelog_no_empty_bullet_points(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + for line in lines: + if line.strip() == "-": + raise AssertionError("Changelog should not have empty bullet points") From e649a98a4fad3d0e754a3c1ad7861db5a04e96c5 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Tue, 22 Jul 2025 23:31:42 -0700 Subject: [PATCH 28/47] Make streaming implementation trio-compatible (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace asyncio.create_task() with anyio task group for trio compatibility - Update client.py docstring example to use anyio.sleep - Add trio example demonstrating multi-turn conversation ## Details The SDK already uses anyio for most async operations, but one line was using asyncio.create_task() which broke trio compatibility. This PR fixes that by using anyio's task group API with proper lifecycle management. ### Changes: 1. **subprocess_cli.py**: Replace asyncio.create_task() with anyio task group, ensuring proper cleanup on disconnect 2. **client.py**: Update docstring example to use anyio.sleep instead of asyncio.sleep 3. **streaming_mode_trio.py**: Add new example showing how to use the SDK with trio ## Test plan - [x] All existing tests pass - [x] Manually tested with trio runtime (created test script that successfully runs multi-turn conversation) - [x] Linting and type checking pass 🤖 Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Rushil Patel --- examples/streaming_mode_trio.py | 80 +++++++++++++++++++ .../_internal/transport/subprocess_cli.py | 13 ++- src/claude_code_sdk/client.py | 2 +- 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 examples/streaming_mode_trio.py diff --git a/examples/streaming_mode_trio.py b/examples/streaming_mode_trio.py new file mode 100644 index 00000000..50366f7f --- /dev/null +++ b/examples/streaming_mode_trio.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Example of multi-turn conversation using trio with the Claude SDK. + +This demonstrates how to use the ClaudeSDKClient with trio for interactive, +stateful conversations where you can send follow-up messages based on +Claude's responses. +""" + +import trio + +from claude_code_sdk import ( + AssistantMessage, + ClaudeCodeOptions, + ClaudeSDKClient, + ResultMessage, + SystemMessage, + TextBlock, + UserMessage, +) + + +def display_message(msg): + """Standardized message display function. + + - UserMessage: "User: " + - AssistantMessage: "Claude: " + - SystemMessage: ignored + - ResultMessage: "Result ended" + cost if available + """ + if isinstance(msg, UserMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"User: {block.text}") + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, SystemMessage): + # Ignore system messages + pass + elif isinstance(msg, ResultMessage): + print("Result ended") + + +async def multi_turn_conversation(): + """Example of a multi-turn conversation using trio.""" + async with ClaudeSDKClient( + options=ClaudeCodeOptions(model="claude-3-5-sonnet-20241022") + ) as client: + print("=== Multi-turn Conversation with Trio ===\n") + + # First turn: Simple math question + print("User: What's 15 + 27?") + await client.query("What's 15 + 27?") + + async for message in client.receive_response(): + display_message(message) + print() + + # Second turn: Follow-up calculation + print("User: Now multiply that result by 2") + await client.query("Now multiply that result by 2") + + async for message in client.receive_response(): + display_message(message) + print() + + # Third turn: One more operation + print("User: Divide that by 7 and round to 2 decimal places") + await client.query("Divide that by 7 and round to 2 decimal places") + + async for message in client.receive_response(): + display_message(message) + + print("\nConversation complete!") + + +if __name__ == "__main__": + trio.run(multi_turn_conversation) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 132e5642..7a503811 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -46,6 +46,7 @@ def __init__( self._pending_control_responses: dict[str, dict[str, Any]] = {} self._request_counter = 0 self._close_stdin_after_prompt = close_stdin_after_prompt + self._task_group: anyio.abc.TaskGroup | None = None def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: """Configure transport with prompt and options.""" @@ -170,9 +171,9 @@ async def connect(self) -> None: if self._process.stdin: self._stdin_stream = TextSendStream(self._process.stdin) # Start streaming messages to stdin in background - import asyncio - - asyncio.create_task(self._stream_to_stdin()) + self._task_group = anyio.create_task_group() + await self._task_group.__aenter__() + self._task_group.start_soon(self._stream_to_stdin) else: # String mode: close stdin immediately (backward compatible) if self._process.stdin: @@ -193,6 +194,12 @@ async def disconnect(self) -> None: if not self._process: return + # Cancel task group if it exists + if self._task_group: + self._task_group.cancel_scope.cancel() + await self._task_group.__aexit__(None, None, None) + self._task_group = None + if self._process.returncode is None: try: self._process.terminate() diff --git a/src/claude_code_sdk/client.py b/src/claude_code_sdk/client.py index 8e86ba7c..cf668fdc 100644 --- a/src/claude_code_sdk/client.py +++ b/src/claude_code_sdk/client.py @@ -63,7 +63,7 @@ class ClaudeSDKClient: await client.query("Count to 1000") # Interrupt after 2 seconds - await asyncio.sleep(2) + await anyio.sleep(2) await client.interrupt() # Send new instruction From 97ee71e590d949ce8db8a02f5c8201ad10837ef4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:52:20 -0700 Subject: [PATCH 29/47] chore: bump version to 0.0.17 (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.0.17 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` - Updated version in `src/claude_code_sdk/__init__.py` ## Release Information - Published to PyPI: https://pypi.org/project/claude-code-sdk/0.0.17/ - Install with: `pip install claude-code-sdk==0.0.17` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rushil Patel --- pyproject.toml | 2 +- src/claude_code_sdk/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f2b300ab..a4f7181b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-code-sdk" -version = "0.0.16" +version = "0.0.17" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index da9521f7..3cd35e63 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -28,7 +28,7 @@ UserMessage, ) -__version__ = "0.0.16" +__version__ = "0.0.17" __all__ = [ # Main exports From a517faba41ebbc4fb46ad2f1be272014ca0582f5 Mon Sep 17 00:00:00 2001 From: Rushil Patel Date: Sat, 26 Jul 2025 12:57:05 -0700 Subject: [PATCH 30/47] fix tests and add transport param to query Signed-off-by: Rushil Patel --- src/claude_code_sdk/query.py | 23 ++++++++++- tests/test_client.py | 8 ++-- tests/test_integration.py | 10 ++--- tests/test_subprocess_buffering.py | 37 ++++++++++-------- tests/test_transport.py | 62 +++++++++++++++--------------- 5 files changed, 83 insertions(+), 57 deletions(-) diff --git a/src/claude_code_sdk/query.py b/src/claude_code_sdk/query.py index ad77a1b1..a893c5e6 100644 --- a/src/claude_code_sdk/query.py +++ b/src/claude_code_sdk/query.py @@ -5,6 +5,7 @@ from typing import Any from ._internal.client import InternalClient +from ._internal.transport import Transport from .types import ClaudeCodeOptions, Message @@ -12,6 +13,7 @@ async def query( *, prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeCodeOptions | None = None, + transport: Transport | None = None, ) -> AsyncIterator[Message]: """ Query Claude Code for one-shot or unidirectional streaming interactions. @@ -56,6 +58,9 @@ async def query( - 'acceptEdits': Auto-accept file edits - 'bypassPermissions': Allow all tools (use with caution) Set options.cwd for working directory. + transport: Optional transport implementation. If provided, this will be used + instead of the default transport selection based on options. + The transport will be automatically configured with the prompt and options. Yields: Messages from the conversation @@ -90,6 +95,22 @@ async def prompts(): async for message in query(prompt=prompts()): print(message) ``` + + Example - With custom transport: + ```python + from claude_code_sdk import query, Transport + + class MyCustomTransport(Transport): + # Implement custom transport logic + pass + + transport = MyCustomTransport() + async for message in query( + prompt="Hello", + transport=transport + ): + print(message) + ``` """ if options is None: options = ClaudeCodeOptions() @@ -98,5 +119,5 @@ async def prompts(): client = InternalClient() - async for message in client.process_query(prompt=prompt, options=options): + async for message in client.process_query(prompt=prompt, options=options, transport=transport): yield message diff --git a/tests/test_client.py b/tests/test_client.py index cfcc872a..bdb65cd9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -106,7 +106,9 @@ async def mock_receive(): messages.append(msg) # Verify transport was created with correct parameters - mock_transport_class.assert_called_once_with() # No parameters to constructor - mock_transport.configure.assert_called_once_with("test", options) + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["prompt"] == "test" + assert call_kwargs["options"].cwd == "/custom/path" - anyio.run(_test) + anyio.run(_test) \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py index 9a327d40..68dccae6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -195,10 +195,8 @@ async def mock_receive(): messages.append(msg) # Verify transport was created with continuation option - mock_transport_class.assert_called_once_with() # No parameters to constructor - mock_transport.configure.assert_called_once() - configure_call_args = mock_transport.configure.call_args - assert configure_call_args[0][0] == "Continue" # prompt argument - assert configure_call_args[0][1].continue_conversation is True # options argument + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["options"].continue_conversation is True - anyio.run(_test) + anyio.run(_test) \ No newline at end of file diff --git a/tests/test_subprocess_buffering.py b/tests/test_subprocess_buffering.py index 649a5c24..b18ac068 100644 --- a/tests/test_subprocess_buffering.py +++ b/tests/test_subprocess_buffering.py @@ -50,8 +50,9 @@ async def _test() -> None: buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) mock_process = MagicMock() mock_process.returncode = None @@ -84,8 +85,9 @@ async def _test() -> None: buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) mock_process = MagicMock() mock_process.returncode = None @@ -113,8 +115,9 @@ async def _test() -> None: buffered_line = json.dumps(json_obj1) + "\n\n\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) mock_process = MagicMock() mock_process.returncode = None @@ -158,8 +161,9 @@ async def _test() -> None: part2 = complete_json[100:250] part3 = complete_json[250:] - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) mock_process = MagicMock() mock_process.returncode = None @@ -205,8 +209,9 @@ async def _test() -> None: for i in range(0, len(complete_json), chunk_size) ] - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) mock_process = MagicMock() mock_process.returncode = None @@ -234,8 +239,9 @@ def test_buffer_size_exceeded(self) -> None: async def _test() -> None: huge_incomplete = '{"data": "' + "x" * (_MAX_BUFFER_SIZE + 1000) - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) mock_process = MagicMock() mock_process.returncode = None @@ -275,8 +281,9 @@ async def _test() -> None: large_json[3000:] + "\n" + msg3, ] - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) mock_process = MagicMock() mock_process.returncode = None @@ -297,4 +304,4 @@ async def _test() -> None: assert messages[2]["type"] == "system" assert messages[2]["subtype"] == "end" - anyio.run(_test) + anyio.run(_test) \ No newline at end of file diff --git a/tests/test_transport.py b/tests/test_transport.py index ba134e21..d6ace8f2 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -21,14 +21,15 @@ def test_find_cli_not_found(self): patch("pathlib.Path.exists", return_value=False), pytest.raises(CLINotFoundError) as exc_info, ): - SubprocessCLITransport() + SubprocessCLITransport(prompt="test", options=ClaudeCodeOptions()) assert "Claude Code requires Node.js" in str(exc_info.value) def test_build_command_basic(self): """Test building basic CLI command.""" - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("Hello", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="Hello", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) cmd = transport._build_command() assert cmd[0] == "/usr/bin/claude" @@ -41,16 +42,19 @@ def test_cli_path_accepts_pathlib_path(self): """Test that cli_path accepts pathlib.Path objects.""" from pathlib import Path - transport = SubprocessCLITransport(cli_path=Path("/usr/bin/claude")) + transport = SubprocessCLITransport( + prompt="Hello", + options=ClaudeCodeOptions(), + cli_path=Path("/usr/bin/claude"), + ) assert transport._cli_path == "/usr/bin/claude" def test_build_command_with_options(self): """Test building CLI command with options.""" - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure( - "test", - ClaudeCodeOptions( + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions( system_prompt="Be helpful", allowed_tools=["Read", "Write"], disallowed_tools=["Bash"], @@ -58,6 +62,7 @@ def test_build_command_with_options(self): permission_mode="acceptEdits", max_turns=5, ), + cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -76,10 +81,10 @@ def test_build_command_with_options(self): def test_session_continuation(self): """Test session continuation options.""" - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure( - "Continue from before", - ClaudeCodeOptions(continue_conversation=True, resume="session-123"), + transport = SubprocessCLITransport( + prompt="Continue from before", + options=ClaudeCodeOptions(continue_conversation=True, resume="session-123"), + cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -106,8 +111,11 @@ async def _test(): mock_exec.return_value = mock_process - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(), + cli_path="/usr/bin/claude", + ) await transport.connect() assert transport._process is not None @@ -122,8 +130,9 @@ def test_receive_messages(self): """Test parsing messages from CLI output.""" # This test is simplified to just test the parsing logic # The full async stream handling is tested in integration tests - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure("test", ClaudeCodeOptions()) + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) # The actual message parsing is done by the client, not the transport # So we just verify the transport can be created and basic structure is correct @@ -135,10 +144,10 @@ def test_connect_with_nonexistent_cwd(self): from claude_code_sdk._errors import CLIConnectionError async def _test(): - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - transport.configure( - "test", - ClaudeCodeOptions(cwd="/this/directory/does/not/exist"), + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(cwd="/this/directory/does/not/exist"), + cli_path="/usr/bin/claude", ) with pytest.raises(CLIConnectionError) as exc_info: @@ -146,15 +155,4 @@ async def _test(): assert "/this/directory/does/not/exist" in str(exc_info.value) - anyio.run(_test) - - def test_build_command_without_configure(self): - """Test that _build_command raises error if not configured.""" - from claude_code_sdk._errors import CLIConnectionError - - transport = SubprocessCLITransport(cli_path="/usr/bin/claude") - - with pytest.raises(CLIConnectionError) as exc_info: - transport._build_command() - - assert "Transport not configured" in str(exc_info.value) + anyio.run(_test) \ No newline at end of file From 611d332be9bc972de4f23fdf83447b0c5632a0de Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Wed, 30 Jul 2025 23:59:21 -0700 Subject: [PATCH 31/47] Add settings option to ClaudeCodeOptions (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `settings` field to `ClaudeCodeOptions` to expose the `--settings` CLI flag - Allow SDK users to specify custom settings configuration path - Added `settings: str | None = None` field to `ClaudeCodeOptions` dataclass - Added CLI argument conversion logic in `SubprocessCLITransport` to pass `--settings` flag to Claude Code CLI - [x] All existing tests pass - [x] Linting passes (`python -m ruff check`) - [x] Type checking passes (`python -m mypy src/`) 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude --- CHANGELOG.md | 8 ++++++++ src/claude_code_sdk/_internal/transport/subprocess_cli.py | 3 +++ src/claude_code_sdk/types.py | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b394833b..fd84654a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.0.18 + +- Add `ClaudeCodeOptions.settings` for `--settings` + +## 0.0.17 + +- Remove dependency on asyncio for Trio compatibility + ## 0.0.16 - Introduce ClaudeSDKClient for bidirectional streaming conversation diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 7a503811..1dab079b 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -128,6 +128,9 @@ def _build_command(self) -> list[str]: if self._options.resume: cmd.extend(["--resume", self._options.resume]) + if self._options.settings: + cmd.extend(["--settings", self._options.settings]) + if self._options.mcp_servers: cmd.extend( ["--mcp-config", json.dumps({"mcpServers": self._options.mcp_servers})] diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 617b2a8b..d04204ff 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -126,4 +126,5 @@ class ClaudeCodeOptions: disallowed_tools: list[str] = field(default_factory=list) model: str | None = None permission_prompt_tool_name: str | None = None - cwd: str | Path | None = None \ No newline at end of file + cwd: str | Path | None = None + settings: str | None = None From a56711f88acf68e93d98795840f1be494d6e1169 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Thu, 31 Jul 2025 07:51:39 -0700 Subject: [PATCH 32/47] Improve UserMessage types to include `ToolResultBlock` (#101) Fixes https://github.com/anthropics/claude-code-sdk-python/issues/90 Signed-off-by: Rushil Patel --- examples/streaming_mode.py | 47 ++++++++ .../_internal/message_parser.py | 25 +++++ src/claude_code_sdk/types.py | 2 +- tests/test_message_parser.py | 103 ++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/examples/streaming_mode.py b/examples/streaming_mode.py index 73eb4109..da2bc1b8 100755 --- a/examples/streaming_mode.py +++ b/examples/streaming_mode.py @@ -27,6 +27,8 @@ ResultMessage, SystemMessage, TextBlock, + ToolResultBlock, + ToolUseBlock, UserMessage, ) @@ -303,6 +305,50 @@ async def create_message_stream(): print("\n") +async def example_bash_command(): + """Example showing tool use blocks when running bash commands.""" + print("=== Bash Command Example ===") + + async with ClaudeSDKClient() as client: + print("User: Run a bash echo command") + await client.query("Run a bash echo command that says 'Hello from bash!'") + + # Track all message types received + message_types = [] + + async for msg in client.receive_messages(): + message_types.append(type(msg).__name__) + + if isinstance(msg, UserMessage): + # User messages can contain tool results + for block in msg.content: + if isinstance(block, TextBlock): + print(f"User: {block.text}") + elif isinstance(block, ToolResultBlock): + print(f"Tool Result (id: {block.tool_use_id}): {block.content[:100] if block.content else 'None'}...") + + elif isinstance(msg, AssistantMessage): + # Assistant messages can contain tool use blocks + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(block, ToolUseBlock): + print(f"Tool Use: {block.name} (id: {block.id})") + if block.name == "Bash": + command = block.input.get("command", "") + print(f" Command: {command}") + + elif isinstance(msg, ResultMessage): + print("Result ended") + if msg.total_cost_usd: + print(f"Cost: ${msg.total_cost_usd:.4f}") + break + + print(f"\nMessage types received: {', '.join(set(message_types))}") + + print("\n") + + async def example_error_handling(): """Demonstrate proper error handling.""" print("=== Error Handling Example ===") @@ -359,6 +405,7 @@ async def main(): "manual_message_handling": example_manual_message_handling, "with_options": example_with_options, "async_iterable_prompt": example_async_iterable_prompt, + "bash_command": example_bash_command, "error_handling": example_error_handling, } diff --git a/src/claude_code_sdk/_internal/message_parser.py b/src/claude_code_sdk/_internal/message_parser.py index 858e24fa..8477e51a 100644 --- a/src/claude_code_sdk/_internal/message_parser.py +++ b/src/claude_code_sdk/_internal/message_parser.py @@ -45,6 +45,31 @@ def parse_message(data: dict[str, Any]) -> Message: match message_type: case "user": try: + if isinstance(data["message"]["content"], list): + user_content_blocks: list[ContentBlock] = [] + for block in data["message"]["content"]: + match block["type"]: + case "text": + user_content_blocks.append( + TextBlock(text=block["text"]) + ) + case "tool_use": + user_content_blocks.append( + ToolUseBlock( + id=block["id"], + name=block["name"], + input=block["input"], + ) + ) + case "tool_result": + user_content_blocks.append( + ToolResultBlock( + tool_use_id=block["tool_use_id"], + content=block.get("content"), + is_error=block.get("is_error"), + ) + ) + return UserMessage(content=user_content_blocks) return UserMessage(content=data["message"]["content"]) except KeyError as e: raise MessageParseError( diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index d04204ff..3e14fbf7 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -73,7 +73,7 @@ class ToolResultBlock: class UserMessage: """User message.""" - content: str + content: str | list[ContentBlock] @dataclass diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 0eb43542..b6475301 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -9,6 +9,7 @@ ResultMessage, SystemMessage, TextBlock, + ToolResultBlock, ToolUseBlock, UserMessage, ) @@ -25,6 +26,108 @@ def test_parse_valid_user_message(self): } message = parse_message(data) assert isinstance(message, UserMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], TextBlock) + assert message.content[0].text == "Hello" + + def test_parse_user_message_with_tool_use(self): + """Test parsing a user message with tool_use block.""" + data = { + "type": "user", + "message": { + "content": [ + {"type": "text", "text": "Let me read this file"}, + { + "type": "tool_use", + "id": "tool_456", + "name": "Read", + "input": {"file_path": "/example.txt"}, + }, + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 2 + assert isinstance(message.content[0], TextBlock) + assert isinstance(message.content[1], ToolUseBlock) + assert message.content[1].id == "tool_456" + assert message.content[1].name == "Read" + assert message.content[1].input == {"file_path": "/example.txt"} + + def test_parse_user_message_with_tool_result(self): + """Test parsing a user message with tool_result block.""" + data = { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_789", + "content": "File contents here", + } + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], ToolResultBlock) + assert message.content[0].tool_use_id == "tool_789" + assert message.content[0].content == "File contents here" + + def test_parse_user_message_with_tool_result_error(self): + """Test parsing a user message with error tool_result block.""" + data = { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_error", + "content": "File not found", + "is_error": True, + } + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], ToolResultBlock) + assert message.content[0].tool_use_id == "tool_error" + assert message.content[0].content == "File not found" + assert message.content[0].is_error is True + + def test_parse_user_message_with_mixed_content(self): + """Test parsing a user message with mixed content blocks.""" + data = { + "type": "user", + "message": { + "content": [ + {"type": "text", "text": "Here's what I found:"}, + { + "type": "tool_use", + "id": "use_1", + "name": "Search", + "input": {"query": "test"}, + }, + { + "type": "tool_result", + "tool_use_id": "use_1", + "content": "Search results", + }, + {"type": "text", "text": "What do you think?"}, + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 4 + assert isinstance(message.content[0], TextBlock) + assert isinstance(message.content[1], ToolUseBlock) + assert isinstance(message.content[2], ToolResultBlock) + assert isinstance(message.content[3], TextBlock) def test_parse_valid_assistant_message(self): """Test parsing a valid assistant message.""" From 85038c790144f29848c15dcca2f3844ffa7a0cba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:02:29 -0700 Subject: [PATCH 33/47] chore: bump version to 0.0.18 (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.0.18 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` - Updated version in `src/claude_code_sdk/__init__.py` ## Release Information - Published to PyPI: https://pypi.org/project/claude-code-sdk/0.0.18/ - Install with: `pip install claude-code-sdk==0.0.18` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rushil Patel --- pyproject.toml | 2 +- src/claude_code_sdk/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4f7181b..58258222 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-code-sdk" -version = "0.0.17" +version = "0.0.18" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 3cd35e63..d22694b8 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -28,7 +28,7 @@ UserMessage, ) -__version__ = "0.0.17" +__version__ = "0.0.18" __all__ = [ # Main exports From 01e51c1cc566479808767649cb5324324958fe36 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Thu, 31 Jul 2025 11:42:20 -0700 Subject: [PATCH 34/47] Fix subprocess deadlock with MCP servers via stderr redirection (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a critical deadlock issue that occurs when MCP servers produce verbose stderr output. The SDK would hang indefinitely when the stderr pipe buffer filled up. ## The Problem The deadlock occurred due to sequential reading of subprocess streams: 1. SDK reads stdout completely before reading stderr 2. When stderr pipe buffer fills (64KB on Linux, 16KB on macOS), subprocess blocks on write 3. Subprocess can't continue to stdout, parent waits for stdout → **DEADLOCK** 🔒 ## The Solution Redirect stderr to a temporary file instead of a pipe: - **No pipe buffer** = no possibility of deadlock - Temp file can grow as needed (no 64KB limit) - Still capture stderr for error reporting (last 100 lines) - Works consistently across all async backends ## Implementation Details - `stderr=tempfile.NamedTemporaryFile()` instead of `stderr=PIPE` - Use `deque(maxlen=100)` to keep only recent stderr lines in memory - Temp file is automatically cleaned up on disconnect - Add `[stderr truncated, showing last 100 lines]` message when buffer is full ## Testing - Verified no deadlock with 150+ lines of stderr output - Confirmed stderr is still captured for error reporting - All existing tests pass - Works with asyncio, trio, and other anyio backends ## Impact - Fixes consistent hangs in production with MCP servers - No functional regression - stderr handling is preserved - Simpler than concurrent reading alternatives - More robust than pipe-based solutions Fixes the issue reported in Slack where SDK would hang indefinitely when receiving messages from MCP servers with verbose logging. 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Signed-off-by: Rushil Patel --- .../_internal/transport/subprocess_cli.py | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 1dab079b..5e1f7914 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -4,6 +4,8 @@ import logging import os import shutil +import tempfile +from collections import deque from collections.abc import AsyncIterable, AsyncIterator from pathlib import Path from subprocess import PIPE @@ -47,6 +49,7 @@ def __init__( self._request_counter = 0 self._close_stdin_after_prompt = close_stdin_after_prompt self._task_group: anyio.abc.TaskGroup | None = None + self._stderr_file: Any = None # tempfile.NamedTemporaryFile def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: """Configure transport with prompt and options.""" @@ -153,20 +156,24 @@ async def connect(self) -> None: cmd = self._build_command() try: + # Create a temp file for stderr to avoid pipe buffer deadlock + # We can't use context manager as we need it for the subprocess lifetime + self._stderr_file = tempfile.NamedTemporaryFile( # noqa: SIM115 + mode="w+", prefix="claude_stderr_", suffix=".log", delete=False + ) + # Enable stdin pipe for both modes (but we'll close it for string mode) self._process = await anyio.open_process( cmd, stdin=PIPE, stdout=PIPE, - stderr=PIPE, + stderr=self._stderr_file, cwd=self._cwd, env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "sdk-py"}, ) if self._process.stdout: self._stdout_stream = TextReceiveStream(self._process.stdout) - if self._process.stderr: - self._stderr_stream = TextReceiveStream(self._process.stderr) # Handle stdin based on mode if self._is_streaming: @@ -214,6 +221,15 @@ async def disconnect(self) -> None: except ProcessLookupError: pass + # Clean up temp file + if self._stderr_file: + try: + self._stderr_file.close() + Path(self._stderr_file.name).unlink() + except Exception: + pass + self._stderr_file = None + self._process = None self._stdout_stream = None self._stderr_stream = None @@ -267,10 +283,6 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: if not self._process or not self._stdout_stream: raise CLIConnectionError("Not connected") - # Safety constants - max_stderr_size = 10 * 1024 * 1024 # 10MB - stderr_timeout = 30.0 # 30 seconds - json_buffer = "" # Process stdout messages first @@ -328,36 +340,19 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: # Client disconnected - still need to clean up pass - # Process stderr with safety limits - stderr_lines = [] - stderr_size = 0 - - if self._stderr_stream: + # Read stderr from temp file (keep only last N lines for memory efficiency) + stderr_lines: deque[str] = deque(maxlen=100) # Keep last 100 lines + if self._stderr_file: try: - # Use timeout to prevent hanging - with anyio.fail_after(stderr_timeout): - async for line in self._stderr_stream: - line_text = line.strip() - line_size = len(line_text) - - # Enforce memory limit - if stderr_size + line_size > max_stderr_size: - stderr_lines.append( - f"[stderr truncated after {stderr_size} bytes]" - ) - # Drain rest of stream without storing - async for _ in self._stderr_stream: - pass - break - + # Flush any pending writes + self._stderr_file.flush() + # Read from the beginning + self._stderr_file.seek(0) + for line in self._stderr_file: + line_text = line.strip() + if line_text: stderr_lines.append(line_text) - stderr_size += line_size - - except TimeoutError: - stderr_lines.append( - f"[stderr collection timed out after {stderr_timeout}s]" - ) - except anyio.ClosedResourceError: + except Exception: pass # Check process completion and handle errors @@ -366,7 +361,13 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: except Exception: returncode = -1 - stderr_output = "\n".join(stderr_lines) if stderr_lines else "" + # Convert deque to string for error reporting + stderr_output = "\n".join(list(stderr_lines)) if stderr_lines else "" + if len(stderr_lines) == stderr_lines.maxlen: + stderr_output = ( + f"[stderr truncated, showing last {stderr_lines.maxlen} lines]\n" + + stderr_output + ) # Use exit code for error detection, not string matching if returncode is not None and returncode != 0: From 6fb59c919be38cee9c9531e7abe03ca4d1fa7065 Mon Sep 17 00:00:00 2001 From: Rushil Patel Date: Thu, 31 Jul 2025 16:52:09 -0700 Subject: [PATCH 35/47] chore: address pr comments Signed-off-by: Rushil Patel --- src/claude_code_sdk/_internal/transport/__init__.py | 11 +++++++---- src/claude_code_sdk/types.py | 1 + tests/test_client.py | 2 +- tests/test_integration.py | 2 +- tests/test_subprocess_buffering.py | 2 +- tests/test_transport.py | 2 +- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/claude_code_sdk/_internal/transport/__init__.py b/src/claude_code_sdk/_internal/transport/__init__.py index ea54d81a..434f9ee6 100644 --- a/src/claude_code_sdk/_internal/transport/__init__.py +++ b/src/claude_code_sdk/_internal/transport/__init__.py @@ -4,11 +4,15 @@ from collections.abc import AsyncIterator from typing import Any -from claude_code_sdk.types import ClaudeCodeOptions - class Transport(ABC): - """Abstract transport for Claude communication.""" + """Abstract transport for Claude communication. + + WARNING: This internal API is exposed for custom transport implementations + (e.g., remote Claude Code connections). The Claude Code team may change or + or remove this abstract class in any future release. Custom implementations + must be updated to match interface changes. + """ @abstractmethod def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: @@ -43,5 +47,4 @@ def is_connected(self) -> bool: pass -# Import implementations __all__ = ["Transport"] diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 3e14fbf7..9b02a257 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -128,3 +128,4 @@ class ClaudeCodeOptions: permission_prompt_tool_name: str | None = None cwd: str | Path | None = None settings: str | None = None + \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index bdb65cd9..3282ea1a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -111,4 +111,4 @@ async def mock_receive(): assert call_kwargs["prompt"] == "test" assert call_kwargs["options"].cwd == "/custom/path" - anyio.run(_test) \ No newline at end of file + anyio.run(_test) diff --git a/tests/test_integration.py b/tests/test_integration.py index 68dccae6..a185335f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -199,4 +199,4 @@ async def mock_receive(): call_kwargs = mock_transport_class.call_args.kwargs assert call_kwargs["options"].continue_conversation is True - anyio.run(_test) \ No newline at end of file + anyio.run(_test) diff --git a/tests/test_subprocess_buffering.py b/tests/test_subprocess_buffering.py index b18ac068..426d42e5 100644 --- a/tests/test_subprocess_buffering.py +++ b/tests/test_subprocess_buffering.py @@ -304,4 +304,4 @@ async def _test() -> None: assert messages[2]["type"] == "system" assert messages[2]["subtype"] == "end" - anyio.run(_test) \ No newline at end of file + anyio.run(_test) diff --git a/tests/test_transport.py b/tests/test_transport.py index d6ace8f2..aa9e4328 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -155,4 +155,4 @@ async def _test(): assert "/this/directory/does/not/exist" in str(exc_info.value) - anyio.run(_test) \ No newline at end of file + anyio.run(_test) From b6ddaeac6e639cf809d33181ace5c77b5ad80fc6 Mon Sep 17 00:00:00 2001 From: Rushil Patel Date: Thu, 31 Jul 2025 16:54:06 -0700 Subject: [PATCH 36/47] chore: add missing eof newlines Signed-off-by: Rushil Patel --- src/claude_code_sdk/types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 9b02a257..3e14fbf7 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -128,4 +128,3 @@ class ClaudeCodeOptions: permission_prompt_tool_name: str | None = None cwd: str | Path | None = None settings: str | None = None - \ No newline at end of file From 590ea7afcdad4f84615713c61f4342496e96908c Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Thu, 31 Jul 2025 20:52:30 -0700 Subject: [PATCH 37/47] Support --add-dir flag (#104) Signed-off-by: Rushil Patel --- CHANGELOG.md | 5 +++++ .../_internal/transport/subprocess_cli.py | 5 +++++ src/claude_code_sdk/types.py | 1 + tests/test_transport.py | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd84654a..3f557b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.19 + +- Add `ClaudeCodeOptions.add_dirs` for `--add-dir` +- Fix ClaudeCodeSDK hanging when MCP servers log to Claude Code stderr + ## 0.0.18 - Add `ClaudeCodeOptions.settings` for `--settings` diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 5e1f7914..33d026f5 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -134,6 +134,11 @@ def _build_command(self) -> list[str]: if self._options.settings: cmd.extend(["--settings", self._options.settings]) + if self._options.add_dirs: + # Convert all paths to strings and add each directory + for directory in self._options.add_dirs: + cmd.extend(["--add-dir", str(directory)]) + if self._options.mcp_servers: cmd.extend( ["--mcp-config", json.dumps({"mcpServers": self._options.mcp_servers})] diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 3e14fbf7..81df3cd5 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -128,3 +128,4 @@ class ClaudeCodeOptions: permission_prompt_tool_name: str | None = None cwd: str | Path | None = None settings: str | None = None + add_dirs: list[str | Path] = field(default_factory=list) diff --git a/tests/test_transport.py b/tests/test_transport.py index aa9e4328..5bc5d7aa 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -79,6 +79,24 @@ def test_build_command_with_options(self): assert "--max-turns" in cmd assert "5" in cmd + def test_build_command_with_add_dirs(self): + """Test building CLI command with add_dirs option.""" + from pathlib import Path + + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions( + add_dirs=["/path/to/dir1", Path("/path/to/dir2")] + ), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + cmd_str = " ".join(cmd) + + # Check that the command string contains the expected --add-dir flags + assert "--add-dir /path/to/dir1 --add-dir /path/to/dir2" in cmd_str + def test_session_continuation(self): """Test session continuation options.""" transport = SubprocessCLITransport( From 9a4f082d7dd72f6d44ff7611d2a2db7b7838faa5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:37:33 -0700 Subject: [PATCH 38/47] chore: bump version to 0.0.19 (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.0.19 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` - Updated version in `src/claude_code_sdk/__init__.py` ## Release Information - Published to PyPI: https://pypi.org/project/claude-code-sdk/0.0.19/ - Install with: `pip install claude-code-sdk==0.0.19` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rushil Patel --- pyproject.toml | 2 +- src/claude_code_sdk/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58258222..3dcfec25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-code-sdk" -version = "0.0.18" +version = "0.0.19" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index d22694b8..b59f4ddf 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -28,7 +28,7 @@ UserMessage, ) -__version__ = "0.0.18" +__version__ = "0.0.19" __all__ = [ # Main exports From 8236c50df65f233a2a9d3fc8fee4f4c3f618dda7 Mon Sep 17 00:00:00 2001 From: Sam Fu Date: Mon, 4 Aug 2025 11:14:01 -0700 Subject: [PATCH 39/47] remove mcp_tools field from ClaudeCodeOptions (#110) the `mcp_tools` field in `ClaudeCodeOptions` seems to have been there since the initial commit but I couldn't find any references in the repo, so I believe it may be vestigial and unused Signed-off-by: Rushil Patel --- src/claude_code_sdk/types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 81df3cd5..849154df 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -117,7 +117,6 @@ class ClaudeCodeOptions: max_thinking_tokens: int = 8000 system_prompt: str | None = None append_system_prompt: str | None = None - mcp_tools: list[str] = field(default_factory=list) mcp_servers: dict[str, McpServerConfig] = field(default_factory=dict) permission_mode: PermissionMode | None = None continue_conversation: bool = False From 2ed25bf2dfc34afc2898b4baf47e5fd0c2fb0a67 Mon Sep 17 00:00:00 2001 From: Dickson Tsai Date: Mon, 4 Aug 2025 22:47:24 -0700 Subject: [PATCH 40/47] Add extra_args field to ClaudeCodeOptions for forward compatibility (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `extra_args` field to `ClaudeCodeOptions` to support passing arbitrary CLI flags - Enables forward compatibility with future CLI flags without requiring SDK updates - Supports both valued flags (`--flag value`) and boolean flags (`--flag`) ## Changes - Add `extra_args: dict[str, str | None]` field to `ClaudeCodeOptions` - Implement logic in `SubprocessCLITransport` to handle extra args: - `None` values create boolean flags (e.g., `{"verbose": None}` → `--verbose`) - String values create flags with arguments (e.g., `{"output": "json"}` → `--output json`) - Add comprehensive tests for the new functionality ## Test plan - [x] Added unit tests for settings file path handling - [x] Added unit tests for settings JSON object handling - [x] Added unit tests for extra_args with both valued and boolean flags - [x] All tests pass (`python -m pytest tests/`) - [x] Type checking passes (`python -m mypy src/`) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude Signed-off-by: Rushil Patel --- .../_internal/transport/subprocess_cli.py | 9 ++++ src/claude_code_sdk/types.py | 3 ++ tests/test_transport.py | 53 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 33d026f5..774ce933 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -144,6 +144,15 @@ def _build_command(self) -> list[str]: ["--mcp-config", json.dumps({"mcpServers": self._options.mcp_servers})] ) + # Add extra args for future CLI flags + for flag, value in self._options.extra_args.items(): + if value is None: + # Boolean flag without value + cmd.append(f"--{flag}") + else: + # Flag with value + cmd.extend([f"--{flag}", str(value)]) + # Add prompt handling based on mode if self._is_streaming: # Streaming mode: use --input-format stream-json diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 849154df..1dfce38c 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -128,3 +128,6 @@ class ClaudeCodeOptions: cwd: str | Path | None = None settings: str | None = None add_dirs: list[str | Path] = field(default_factory=list) + extra_args: dict[str, str | None] = field( + default_factory=dict + ) # Pass arbitrary CLI flags diff --git a/tests/test_transport.py b/tests/test_transport.py index 5bc5d7aa..ea5cca2e 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -174,3 +174,56 @@ async def _test(): assert "/this/directory/does/not/exist" in str(exc_info.value) anyio.run(_test) + + def test_build_command_with_settings_file(self): + """Test building CLI command with settings as file path.""" + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(settings="/path/to/settings.json"), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + assert "--settings" in cmd + assert "/path/to/settings.json" in cmd + + def test_build_command_with_settings_json(self): + """Test building CLI command with settings as JSON object.""" + settings_json = '{"permissions": {"allow": ["Bash(ls:*)"]}}' + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(settings=settings_json), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + assert "--settings" in cmd + assert settings_json in cmd + + def test_build_command_with_extra_args(self): + """Test building CLI command with extra_args for future flags.""" + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions( + extra_args={ + "new-flag": "value", + "boolean-flag": None, + "another-option": "test-value", + } + ), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + cmd_str = " ".join(cmd) + + # Check flags with values + assert "--new-flag value" in cmd_str + assert "--another-option test-value" in cmd_str + + # Check boolean flag (no value) + assert "--boolean-flag" in cmd + # Make sure boolean flag doesn't have a value after it + boolean_idx = cmd.index("--boolean-flag") + # Either it's the last element or the next element is another flag + assert boolean_idx == len(cmd) - 1 or cmd[boolean_idx + 1].startswith("--") From 6e10b6de5a561e89da6eb8e6f7e61f8b082939d4 Mon Sep 17 00:00:00 2001 From: Sam Fu Date: Wed, 6 Aug 2025 14:13:59 -0700 Subject: [PATCH 41/47] teach ClaudeCodeOptions.mcp_servers to accept a filepath (#114) `claude`'s `--mcp-config` takes either a JSON file or string, so support this in `ClaudeCodeOptions` ``` --mcp-config Load MCP servers from a JSON file or string ``` --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Sam Fu Signed-off-by: Rushil Patel --- .../_internal/transport/subprocess_cli.py | 14 +++- src/claude_code_sdk/types.py | 2 +- tests/test_transport.py | 72 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 774ce933..2ea0e91c 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -140,9 +140,17 @@ def _build_command(self) -> list[str]: cmd.extend(["--add-dir", str(directory)]) if self._options.mcp_servers: - cmd.extend( - ["--mcp-config", json.dumps({"mcpServers": self._options.mcp_servers})] - ) + if isinstance(self._options.mcp_servers, dict): + # Dict format: serialize to JSON + cmd.extend( + [ + "--mcp-config", + json.dumps({"mcpServers": self._options.mcp_servers}), + ] + ) + else: + # String or Path format: pass directly as file path or JSON string + cmd.extend(["--mcp-config", str(self._options.mcp_servers)]) # Add extra args for future CLI flags for flag, value in self._options.extra_args.items(): diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 1dfce38c..fe905d56 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -117,7 +117,7 @@ class ClaudeCodeOptions: max_thinking_tokens: int = 8000 system_prompt: str | None = None append_system_prompt: str | None = None - mcp_servers: dict[str, McpServerConfig] = field(default_factory=dict) + mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict) permission_mode: PermissionMode | None = None continue_conversation: bool = False resume: str | None = None diff --git a/tests/test_transport.py b/tests/test_transport.py index ea5cca2e..6ab1363d 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -227,3 +227,75 @@ def test_build_command_with_extra_args(self): boolean_idx = cmd.index("--boolean-flag") # Either it's the last element or the next element is another flag assert boolean_idx == len(cmd) - 1 or cmd[boolean_idx + 1].startswith("--") + + def test_build_command_with_mcp_servers(self): + """Test building CLI command with mcp_servers option.""" + import json + + mcp_servers = { + "test-server": { + "type": "stdio", + "command": "/path/to/server", + "args": ["--option", "value"], + } + } + + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(mcp_servers=mcp_servers), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + + # Find the --mcp-config flag and its value + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + mcp_config_value = cmd[mcp_idx + 1] + + # Parse the JSON and verify structure + config = json.loads(mcp_config_value) + assert "mcpServers" in config + assert config["mcpServers"] == mcp_servers + + def test_build_command_with_mcp_servers_as_file_path(self): + """Test building CLI command with mcp_servers as file path.""" + from pathlib import Path + + # Test with string path + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(mcp_servers="/path/to/mcp-config.json"), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + assert cmd[mcp_idx + 1] == "/path/to/mcp-config.json" + + # Test with Path object + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(mcp_servers=Path("/path/to/mcp-config.json")), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + assert cmd[mcp_idx + 1] == "/path/to/mcp-config.json" + + def test_build_command_with_mcp_servers_as_json_string(self): + """Test building CLI command with mcp_servers as JSON string.""" + json_config = '{"mcpServers": {"server": {"type": "stdio", "command": "test"}}}' + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions(mcp_servers=json_config), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + assert cmd[mcp_idx + 1] == json_config From ab185e00719cd96d60a743bcae264c8c7516ed84 Mon Sep 17 00:00:00 2001 From: Sam Fu Date: Wed, 6 Aug 2025 15:29:34 -0700 Subject: [PATCH 42/47] allow claude gh action to lint, typecheck, and run tests (#115) Co-authored-by: Ashwin Bhat Signed-off-by: Rushil Patel --- .github/workflows/claude.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 8f085a06..92b0f229 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -29,6 +29,16 @@ jobs: with: fetch-depth: 1 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run Claude Code id: claude uses: anthropics/claude-code-action@beta @@ -44,8 +54,12 @@ jobs: # Optional: Trigger when specific user is assigned to an issue # assignee_trigger: "claude-bot" - # Optional: Allow Claude to run specific commands - # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + # Allow Claude to run linters, typecheckers, and tests + allowed_tools: | + Bash(python -m ruff check:*) + Bash(python -m ruff format:*) + Bash(python -m mypy:*) + Bash(python -m pytest *) # Optional: Add custom instructions for Claude to customize its behavior for your project # custom_instructions: | From 4e8c11a0bce81385e9ffb2fa454cc8db5bd1e365 Mon Sep 17 00:00:00 2001 From: Sam Fu Date: Wed, 6 Aug 2025 16:03:30 -0700 Subject: [PATCH 43/47] fix pytest allowed tool (#117) Signed-off-by: Rushil Patel --- .github/workflows/claude.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 92b0f229..453b6d41 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -59,7 +59,7 @@ jobs: Bash(python -m ruff check:*) Bash(python -m ruff format:*) Bash(python -m mypy:*) - Bash(python -m pytest *) + Bash(python -m pytest:*) # Optional: Add custom instructions for Claude to customize its behavior for your project # custom_instructions: | From fc96e51a441caa915188bc5cf4458e8f67ed4330 Mon Sep 17 00:00:00 2001 From: yokomotod Date: Thu, 7 Aug 2025 11:00:02 +0900 Subject: [PATCH 44/47] fix: add support for thinking content blocks (#28) ## Summary Fixes an issue where `thinking` content blocks in Claude Code responses were not being parsed, resulting in empty `AssistantMessage` content arrays. ## Changes - Added `ThinkingBlock` dataclass to handle thinking content with `thinking` and `signature` fields - Updated client parsing logic in `_internal/client.py` to recognize and create `ThinkingBlock` instances - Added comprehensive test coverage for thinking block functionality ## Before ```python # Claude Code response with thinking block resulted in: AssistantMessage(content=[]) # Empty content! ``` ## After ```python # Now correctly parses to: AssistantMessage(content=[ ThinkingBlock(thinking="...", signature="...") ]) ``` Fixes #27 --------- Co-authored-by: Dickson Tsai Signed-off-by: Rushil Patel --- src/claude_code_sdk/__init__.py | 2 ++ .../_internal/message_parser.py | 12 ++++++++++- src/claude_code_sdk/types.py | 11 +++++++++- tests/test_client.py | 10 ++++++++-- tests/test_integration.py | 3 +++ tests/test_message_parser.py | 3 ++- tests/test_streaming_client.py | 6 ++++++ tests/test_types.py | 20 +++++++++++++++++-- 8 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index b59f4ddf..a0d862ac 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -23,6 +23,7 @@ ResultMessage, SystemMessage, TextBlock, + ThinkingBlock, ToolResultBlock, ToolUseBlock, UserMessage, @@ -46,6 +47,7 @@ "Message", "ClaudeCodeOptions", "TextBlock", + "ThinkingBlock", "ToolUseBlock", "ToolResultBlock", "ContentBlock", diff --git a/src/claude_code_sdk/_internal/message_parser.py b/src/claude_code_sdk/_internal/message_parser.py index 8477e51a..71736502 100644 --- a/src/claude_code_sdk/_internal/message_parser.py +++ b/src/claude_code_sdk/_internal/message_parser.py @@ -11,6 +11,7 @@ ResultMessage, SystemMessage, TextBlock, + ThinkingBlock, ToolResultBlock, ToolUseBlock, UserMessage, @@ -53,6 +54,13 @@ def parse_message(data: dict[str, Any]) -> Message: user_content_blocks.append( TextBlock(text=block["text"]) ) + case "thinking": + user_content_blocks.append( + ThinkingBlock( + thinking=block["thinking"], + signature=block["signature"], + ) + ) case "tool_use": user_content_blocks.append( ToolUseBlock( @@ -100,7 +108,9 @@ def parse_message(data: dict[str, Any]) -> Message: ) ) - return AssistantMessage(content=content_blocks) + return AssistantMessage( + content=content_blocks, model=data["message"]["model"] + ) except KeyError as e: raise MessageParseError( f"Missing required field in assistant message: {e}", data diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index fe905d56..e42048f5 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -47,6 +47,14 @@ class TextBlock: text: str +@dataclass +class ThinkingBlock: + """Thinking content block.""" + + thinking: str + signature: str + + @dataclass class ToolUseBlock: """Tool use content block.""" @@ -65,7 +73,7 @@ class ToolResultBlock: is_error: bool | None = None -ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock +ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock # Message types @@ -81,6 +89,7 @@ class AssistantMessage: """Assistant message with content blocks.""" content: list[ContentBlock] + model: str @dataclass diff --git a/tests/test_client.py b/tests/test_client.py index 3282ea1a..81560100 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,7 +20,9 @@ async def _test(): ) as mock_process: # Mock the async generator async def mock_generator(): - yield AssistantMessage(content=[TextBlock(text="4")]) + yield AssistantMessage( + content=[TextBlock(text="4")], model="claude-opus-4-1-20250805" + ) mock_process.return_value = mock_generator() @@ -43,7 +45,10 @@ async def _test(): ) as mock_process: async def mock_generator(): - yield AssistantMessage(content=[TextBlock(text="Hello!")]) + yield AssistantMessage( + content=[TextBlock(text="Hello!")], + model="claude-opus-4-1-20250805", + ) mock_process.return_value = mock_generator() @@ -83,6 +88,7 @@ async def mock_receive(): "message": { "role": "assistant", "content": [{"type": "text", "text": "Done"}], + "model": "claude-opus-4-1-20250805", }, } yield { diff --git a/tests/test_integration.py b/tests/test_integration.py index a185335f..aa6d12e5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -38,6 +38,7 @@ async def mock_receive(): "message": { "role": "assistant", "content": [{"type": "text", "text": "2 + 2 equals 4"}], + "model": "claude-opus-4-1-20250805", }, } yield { @@ -103,6 +104,7 @@ async def mock_receive(): "input": {"file_path": "/test.txt"}, }, ], + "model": "claude-opus-4-1-20250805", }, } yield { @@ -179,6 +181,7 @@ async def mock_receive(): "text": "Continuing from previous conversation", } ], + "model": "claude-opus-4-1-20250805", }, } diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index b6475301..4e0892ee 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -142,7 +142,8 @@ def test_parse_valid_assistant_message(self): "name": "Read", "input": {"file_path": "/test.txt"}, }, - ] + ], + "model": "claude-opus-4-1-20250805", }, } message = parse_message(data) diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index 884d7c4e..a9c2bb3c 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -187,6 +187,7 @@ async def mock_receive(): "message": { "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], + "model": "claude-opus-4-1-20250805", }, } yield { @@ -229,6 +230,7 @@ async def mock_receive(): "message": { "role": "assistant", "content": [{"type": "text", "text": "Answer"}], + "model": "claude-opus-4-1-20250805", }, } yield { @@ -250,6 +252,7 @@ async def mock_receive(): {"type": "text", "text": "Should not see this"} ], }, + "model": "claude-opus-4-1-20250805", } mock_transport.receive_messages = mock_receive @@ -335,6 +338,7 @@ async def mock_receive(): "message": { "role": "assistant", "content": [{"type": "text", "text": "Response 1"}], + "model": "claude-opus-4-1-20250805", }, } await asyncio.sleep(0.1) @@ -531,6 +535,7 @@ async def mock_receive(): "message": { "role": "assistant", "content": [{"type": "text", "text": "Hello"}], + "model": "claude-opus-4-1-20250805", }, } yield { @@ -538,6 +543,7 @@ async def mock_receive(): "message": { "role": "assistant", "content": [{"type": "text", "text": "World"}], + "model": "claude-opus-4-1-20250805", }, } yield { diff --git a/tests/test_types.py b/tests/test_types.py index 60462923..3e4f5890 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -5,7 +5,13 @@ ClaudeCodeOptions, ResultMessage, ) -from claude_code_sdk.types import TextBlock, ToolResultBlock, ToolUseBlock, UserMessage +from claude_code_sdk.types import ( + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) class TestMessageTypes: @@ -19,10 +25,20 @@ def test_user_message_creation(self): def test_assistant_message_with_text(self): """Test creating an AssistantMessage with text content.""" text_block = TextBlock(text="Hello, human!") - msg = AssistantMessage(content=[text_block]) + msg = AssistantMessage(content=[text_block], model="claude-opus-4-1-20250805") assert len(msg.content) == 1 assert msg.content[0].text == "Hello, human!" + def test_assistant_message_with_thinking(self): + """Test creating an AssistantMessage with thinking content.""" + thinking_block = ThinkingBlock(thinking="I'm thinking...", signature="sig-123") + msg = AssistantMessage( + content=[thinking_block], model="claude-opus-4-1-20250805" + ) + assert len(msg.content) == 1 + assert msg.content[0].thinking == "I'm thinking..." + assert msg.content[0].signature == "sig-123" + def test_tool_use_block(self): """Test creating a ToolUseBlock.""" block = ToolUseBlock( From 6f4ca74b7cb34ee22c60802e4edda211e850e074 Mon Sep 17 00:00:00 2001 From: Michael Gendy <50384638+Mng-dev-ai@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:06:05 +0300 Subject: [PATCH 45/47] Add support for plan permission mode (#116) Signed-off-by: Rushil Patel --- src/claude_code_sdk/types.py | 2 +- tests/test_types.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index e42048f5..98afdef8 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -7,7 +7,7 @@ from typing_extensions import NotRequired # For Python < 3.11 compatibility # Permission modes -PermissionMode = Literal["default", "acceptEdits", "bypassPermissions"] +PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] # MCP Server config diff --git a/tests/test_types.py b/tests/test_types.py index 3e4f5890..42aea656 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -99,6 +99,15 @@ def test_claude_code_options_with_permission_mode(self): options = ClaudeCodeOptions(permission_mode="bypassPermissions") assert options.permission_mode == "bypassPermissions" + options_plan = ClaudeCodeOptions(permission_mode="plan") + assert options_plan.permission_mode == "plan" + + options_default = ClaudeCodeOptions(permission_mode="default") + assert options_default.permission_mode == "default" + + options_accept = ClaudeCodeOptions(permission_mode="acceptEdits") + assert options_accept.permission_mode == "acceptEdits" + def test_claude_code_options_with_system_prompt(self): """Test Options with system prompt.""" options = ClaudeCodeOptions( From 2ed4d031dfeaad82af58d29fef13324670dcb2eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 08:45:28 -0700 Subject: [PATCH 46/47] chore: bump version to 0.0.20 (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.0.20 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` - Updated version in `src/claude_code_sdk/__init__.py` ## Release Information - Published to PyPI: https://pypi.org/project/claude-code-sdk/0.0.20/ - Install with: `pip install claude-code-sdk==0.0.20` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rushil Patel --- pyproject.toml | 2 +- src/claude_code_sdk/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3dcfec25..0967e8a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-code-sdk" -version = "0.0.19" +version = "0.0.20" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index a0d862ac..7a8d9acb 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -29,7 +29,7 @@ UserMessage, ) -__version__ = "0.0.19" +__version__ = "0.0.20" __all__ = [ # Main exports From c0d4e1d702f748bc611cf2c61e5ea95c2acd7928 Mon Sep 17 00:00:00 2001 From: Rushil Patel Date: Sun, 17 Aug 2025 13:20:24 -0700 Subject: [PATCH 47/47] fix: post rebase cleanup --- src/claude_code_sdk/__init__.py | 3 --- src/claude_code_sdk/_internal/client.py | 27 ++++++------------- .../_internal/transport/__init__.py | 5 ---- .../_internal/transport/subprocess_cli.py | 10 ------- src/claude_code_sdk/query.py | 8 +++--- 5 files changed, 13 insertions(+), 40 deletions(-) diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 7a8d9acb..f2b9bdba 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -7,12 +7,9 @@ CLINotFoundError, ProcessError, ) - from ._internal.transport import Transport - from .client import ClaudeSDKClient from .query import query - from .types import ( AssistantMessage, ClaudeCodeOptions, diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py index ec700cc2..90e23252 100644 --- a/src/claude_code_sdk/_internal/client.py +++ b/src/claude_code_sdk/_internal/client.py @@ -1,26 +1,13 @@ """Internal client implementation.""" -from collections.abc import AsyncIterable, AsyncIterator -from typing import Any - +from collections.abc import AsyncIterator from ..types import ( - AssistantMessage, ClaudeCodeOptions, - ContentBlock, Message, - ResultMessage, - SystemMessage, - TextBlock, - ToolResultBlock, - ToolUseBlock, - UserMessage, ) -from .transport import Transport - -from ..types import ClaudeCodeOptions, Message from .message_parser import parse_message - +from .transport import Transport from .transport.subprocess_cli import SubprocessCLITransport @@ -31,7 +18,10 @@ def __init__(self) -> None: """Initialize the internal client.""" async def process_query( - self, prompt: str, options: ClaudeCodeOptions, transport: Transport | None = None + self, + prompt: str, + options: ClaudeCodeOptions, + transport: Transport | None = None, ) -> AsyncIterator[Message]: """Process a query through transport.""" @@ -40,8 +30,8 @@ async def process_query( chosen_transport = transport else: chosen_transport = SubprocessCLITransport( - prompt=prompt, options=options, close_stdin_after_prompt=True - ) + prompt=prompt, options=options, close_stdin_after_prompt=True + ) try: # Configure the transport with prompt and options @@ -51,6 +41,5 @@ async def process_query( async for data in chosen_transport.receive_messages(): yield parse_message(data) - finally: await chosen_transport.disconnect() diff --git a/src/claude_code_sdk/_internal/transport/__init__.py b/src/claude_code_sdk/_internal/transport/__init__.py index 434f9ee6..09a10f8e 100644 --- a/src/claude_code_sdk/_internal/transport/__init__.py +++ b/src/claude_code_sdk/_internal/transport/__init__.py @@ -14,11 +14,6 @@ class Transport(ABC): must be updated to match interface changes. """ - @abstractmethod - def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: - """Configure transport with prompt and options.""" - pass - @abstractmethod async def connect(self) -> None: """Initialize connection.""" diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index 2ea0e91c..0b22c48a 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -28,7 +28,6 @@ class SubprocessCLITransport(Transport): """Subprocess transport using Claude Code CLI.""" - def __init__( self, prompt: str | AsyncIterable[dict[str, Any]], @@ -51,12 +50,6 @@ def __init__( self._task_group: anyio.abc.TaskGroup | None = None self._stderr_file: Any = None # tempfile.NamedTemporaryFile - def configure(self, prompt: str, options: ClaudeCodeOptions) -> None: - """Configure transport with prompt and options.""" - self._prompt = prompt - self._options = options - self._cwd = str(options.cwd) if options.cwd else None - def _find_cli(self) -> str: """Find Claude Code CLI binary.""" if cli := shutil.which("claude"): @@ -94,9 +87,6 @@ def _find_cli(self) -> str: def _build_command(self) -> list[str]: """Build CLI command with arguments.""" - if not self._prompt or not self._options: - raise CLIConnectionError("Transport not configured. Call configure() first.") - cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"] if self._options.system_prompt: diff --git a/src/claude_code_sdk/query.py b/src/claude_code_sdk/query.py index a893c5e6..07b1baf1 100644 --- a/src/claude_code_sdk/query.py +++ b/src/claude_code_sdk/query.py @@ -99,11 +99,11 @@ async def prompts(): Example - With custom transport: ```python from claude_code_sdk import query, Transport - + class MyCustomTransport(Transport): # Implement custom transport logic pass - + transport = MyCustomTransport() async for message in query( prompt="Hello", @@ -119,5 +119,7 @@ class MyCustomTransport(Transport): client = InternalClient() - async for message in client.process_query(prompt=prompt, options=options, transport=transport): + async for message in client.process_query( + prompt=prompt, options=options, transport=transport + ): yield message