├── src └── claude_code_sdk │ ├── py.typed │ ├── _internal │ ├── __init__.py │ ├── client.py │ ├── transport │ │ ├── __init__.py │ │ └── subprocess_cli.py │ └── message_parser.py │ ├── __init__.py │ ├── _errors.py │ ├── types.py │ ├── query.py │ └── client.py ├── tests ├── conftest.py ├── test_errors.py ├── test_changelog.py ├── test_types.py ├── test_client.py ├── test_transport.py ├── test_integration.py ├── test_message_parser.py ├── test_subprocess_buffering.py └── test_streaming_client.py ├── .gitignore ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ ├── claude.yml │ ├── create-release-tag.yml │ ├── claude-code-review.yml │ ├── claude-issue-triage.yml │ └── publish.yml ├── CLAUDE.md ├── CHANGELOG.md ├── LICENSE ├── scripts └── update_version.py ├── examples ├── quick_start.py ├── streaming_mode_trio.py ├── streaming_mode_ipython.py └── streaming_mode.py ├── pyproject.toml ├── README.md ├── .kiro └── specs │ └── claude-code-sdk-rust-migration │ ├── tasks.md │ └── requirements.md └── MIGRATION_PLAN_HIGHLEVEL.md /src/claude_code_sdk/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/claude_code_sdk/_internal/__init__.py: -------------------------------------------------------------------------------- 1 | """Internal implementation details.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration for tests.""" 2 | 3 | 4 | # No async plugin needed since we're using sync tests with anyio.run() 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | MANIFEST 23 | 24 | # Virtual environments 25 | venv/ 26 | ENV/ 27 | env/ 28 | .venv 29 | 30 | # IDEs 31 | .vscode/ 32 | .idea/ 33 | *.swp 34 | *.swo 35 | *~ 36 | 37 | # Testing 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | .pytest_cache/ 43 | htmlcov/ 44 | 45 | # Type checking 46 | .mypy_cache/ 47 | .dmypy.json 48 | dmypy.json 49 | .pyre/ -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.12' 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -e ".[dev]" 25 | 26 | - name: Run ruff 27 | run: | 28 | ruff check src/ tests/ 29 | ruff format --check src/ tests/ 30 | 31 | - name: Run mypy 32 | run: | 33 | mypy src/ -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Workflow 2 | 3 | ```bash 4 | # Lint and style 5 | # Check for issues and fix automatically 6 | python -m ruff check src/ tests/ --fix 7 | python -m ruff format src/ tests/ 8 | 9 | # Typecheck (only done for src/) 10 | python -m mypy src/ 11 | 12 | # Run all tests 13 | python -m pytest tests/ 14 | 15 | # Run specific test file 16 | python -m pytest tests/test_client.py 17 | ``` 18 | 19 | # Codebase Structure 20 | 21 | - `src/claude_code_sdk/` - Main package 22 | - `client.py` - ClaudeSDKClient for interactive sessions 23 | - `query.py` - One-shot query function 24 | - `types.py` - Type definitions 25 | - `_internal/` - Internal implementation details 26 | - `transport/subprocess_cli.py` - CLI subprocess management 27 | - `message_parser.py` - Message parsing logic 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.0.18 4 | 5 | - Add `ClaudeCodeOptions.settings` for `--settings` 6 | 7 | ## 0.0.17 8 | 9 | - Remove dependency on asyncio for Trio compatibility 10 | 11 | ## 0.0.16 12 | 13 | - Introduce ClaudeSDKClient for bidirectional streaming conversation 14 | - Support Message input, not just string prompts, in query() 15 | - Raise explicit error if the cwd does not exist 16 | 17 | ## 0.0.14 18 | 19 | - Add safety limits to Claude Code CLI stderr reading 20 | - Improve handling of output JSON messages split across multiple stream reads 21 | 22 | ## 0.0.13 23 | 24 | - Update MCP (Model Context Protocol) types to align with Claude Code expectations 25 | - Fix multi-line buffering issue 26 | - Rename cost_usd to total_cost_usd in API responses 27 | - Fix optional cost fields handling 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.10', '3.11', '3.12', '3.13'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -e ".[dev]" 28 | 29 | - name: Run tests 30 | run: | 31 | python -m pytest tests/ -v --cov=claude_code_sdk --cov-report=xml 32 | 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v4 35 | with: 36 | file: ./coverage.xml 37 | fail_ci_if_error: false -------------------------------------------------------------------------------- /src/claude_code_sdk/_internal/client.py: -------------------------------------------------------------------------------- 1 | """Internal client implementation.""" 2 | 3 | from collections.abc import AsyncIterable, AsyncIterator 4 | from typing import Any 5 | 6 | from ..types import ClaudeCodeOptions, Message 7 | from .message_parser import parse_message 8 | from .transport.subprocess_cli import SubprocessCLITransport 9 | 10 | 11 | class InternalClient: 12 | """Internal client implementation.""" 13 | 14 | def __init__(self) -> None: 15 | """Initialize the internal client.""" 16 | 17 | async def process_query( 18 | self, prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeCodeOptions 19 | ) -> AsyncIterator[Message]: 20 | """Process a query through transport.""" 21 | 22 | transport = SubprocessCLITransport( 23 | prompt=prompt, options=options, close_stdin_after_prompt=True 24 | ) 25 | 26 | try: 27 | await transport.connect() 28 | 29 | async for data in transport.receive_messages(): 30 | yield parse_message(data) 31 | 32 | finally: 33 | await transport.disconnect() 34 | -------------------------------------------------------------------------------- /src/claude_code_sdk/_internal/transport/__init__.py: -------------------------------------------------------------------------------- 1 | """Transport implementations for Claude SDK.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from collections.abc import AsyncIterator 5 | from typing import Any 6 | 7 | 8 | class Transport(ABC): 9 | """Abstract transport for Claude communication.""" 10 | 11 | @abstractmethod 12 | async def connect(self) -> None: 13 | """Initialize connection.""" 14 | pass 15 | 16 | @abstractmethod 17 | async def disconnect(self) -> None: 18 | """Close connection.""" 19 | pass 20 | 21 | @abstractmethod 22 | async def send_request( 23 | self, messages: list[dict[str, Any]], options: dict[str, Any] 24 | ) -> None: 25 | """Send request to Claude.""" 26 | pass 27 | 28 | @abstractmethod 29 | def receive_messages(self) -> AsyncIterator[dict[str, Any]]: 30 | """Receive messages from Claude.""" 31 | pass 32 | 33 | @abstractmethod 34 | def is_connected(self) -> bool: 35 | """Check if transport is connected.""" 36 | pass 37 | 38 | 39 | __all__ = ["Transport"] 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Anthropic, PBC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/claude_code_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | """Claude SDK for Python.""" 2 | 3 | from ._errors import ( 4 | ClaudeSDKError, 5 | CLIConnectionError, 6 | CLIJSONDecodeError, 7 | CLINotFoundError, 8 | ProcessError, 9 | ) 10 | from .client import ClaudeSDKClient 11 | from .query import query 12 | from .types import ( 13 | AssistantMessage, 14 | ClaudeCodeOptions, 15 | ContentBlock, 16 | McpServerConfig, 17 | Message, 18 | PermissionMode, 19 | ResultMessage, 20 | SystemMessage, 21 | TextBlock, 22 | ToolResultBlock, 23 | ToolUseBlock, 24 | UserMessage, 25 | ) 26 | 27 | __version__ = "0.0.17" 28 | 29 | __all__ = [ 30 | # Main exports 31 | "query", 32 | "ClaudeSDKClient", 33 | # Types 34 | "PermissionMode", 35 | "McpServerConfig", 36 | "UserMessage", 37 | "AssistantMessage", 38 | "SystemMessage", 39 | "ResultMessage", 40 | "Message", 41 | "ClaudeCodeOptions", 42 | "TextBlock", 43 | "ToolUseBlock", 44 | "ToolResultBlock", 45 | "ContentBlock", 46 | # Errors 47 | "ClaudeSDKError", 48 | "CLIConnectionError", 49 | "CLINotFoundError", 50 | "ProcessError", 51 | "CLIJSONDecodeError", 52 | ] 53 | -------------------------------------------------------------------------------- /scripts/update_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Update version in pyproject.toml and __init__.py files.""" 3 | 4 | import sys 5 | import re 6 | from pathlib import Path 7 | 8 | 9 | def update_version(new_version: str) -> None: 10 | """Update version in project files.""" 11 | # Update pyproject.toml 12 | pyproject_path = Path("pyproject.toml") 13 | content = pyproject_path.read_text() 14 | 15 | # Only update the version field in [project] section 16 | content = re.sub( 17 | r'^version = "[^"]*"', 18 | f'version = "{new_version}"', 19 | content, 20 | count=1, 21 | flags=re.MULTILINE 22 | ) 23 | 24 | pyproject_path.write_text(content) 25 | print(f"Updated pyproject.toml to version {new_version}") 26 | 27 | # Update __init__.py 28 | init_path = Path("src/claude_code_sdk/__init__.py") 29 | content = init_path.read_text() 30 | 31 | # Only update __version__ assignment 32 | content = re.sub( 33 | r'^__version__ = "[^"]*"', 34 | f'__version__ = "{new_version}"', 35 | content, 36 | count=1, 37 | flags=re.MULTILINE 38 | ) 39 | 40 | init_path.write_text(content) 41 | print(f"Updated __init__.py to version {new_version}") 42 | 43 | 44 | if __name__ == "__main__": 45 | if len(sys.argv) != 2: 46 | print("Usage: python scripts/update_version.py ") 47 | sys.exit(1) 48 | 49 | update_version(sys.argv[1]) -------------------------------------------------------------------------------- /src/claude_code_sdk/_errors.py: -------------------------------------------------------------------------------- 1 | """Error types for Claude SDK.""" 2 | 3 | from typing import Any 4 | 5 | 6 | class ClaudeSDKError(Exception): 7 | """Base exception for all Claude SDK errors.""" 8 | 9 | 10 | class CLIConnectionError(ClaudeSDKError): 11 | """Raised when unable to connect to Claude Code.""" 12 | 13 | 14 | class CLINotFoundError(CLIConnectionError): 15 | """Raised when Claude Code is not found or not installed.""" 16 | 17 | def __init__( 18 | self, message: str = "Claude Code not found", cli_path: str | None = None 19 | ): 20 | if cli_path: 21 | message = f"{message}: {cli_path}" 22 | super().__init__(message) 23 | 24 | 25 | class ProcessError(ClaudeSDKError): 26 | """Raised when the CLI process fails.""" 27 | 28 | def __init__( 29 | self, message: str, exit_code: int | None = None, stderr: str | None = None 30 | ): 31 | self.exit_code = exit_code 32 | self.stderr = stderr 33 | 34 | if exit_code is not None: 35 | message = f"{message} (exit code: {exit_code})" 36 | if stderr: 37 | message = f"{message}\nError output: {stderr}" 38 | 39 | super().__init__(message) 40 | 41 | 42 | class CLIJSONDecodeError(ClaudeSDKError): 43 | """Raised when unable to decode JSON from CLI output.""" 44 | 45 | def __init__(self, line: str, original_error: Exception): 46 | self.line = line 47 | self.original_error = original_error 48 | super().__init__(f"Failed to decode JSON: {line[:100]}...") 49 | 50 | 51 | class MessageParseError(ClaudeSDKError): 52 | """Raised when unable to parse a message from CLI output.""" 53 | 54 | def __init__(self, message: str, data: dict[str, Any] | None = None): 55 | self.data = data 56 | super().__init__(message) 57 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | """Tests for Claude SDK error handling.""" 2 | 3 | from claude_code_sdk import ( 4 | ClaudeSDKError, 5 | CLIConnectionError, 6 | CLIJSONDecodeError, 7 | CLINotFoundError, 8 | ProcessError, 9 | ) 10 | 11 | 12 | class TestErrorTypes: 13 | """Test error types and their properties.""" 14 | 15 | def test_base_error(self): 16 | """Test base ClaudeSDKError.""" 17 | error = ClaudeSDKError("Something went wrong") 18 | assert str(error) == "Something went wrong" 19 | assert isinstance(error, Exception) 20 | 21 | def test_cli_not_found_error(self): 22 | """Test CLINotFoundError.""" 23 | error = CLINotFoundError("Claude Code not found") 24 | assert isinstance(error, ClaudeSDKError) 25 | assert "Claude Code not found" in str(error) 26 | 27 | def test_connection_error(self): 28 | """Test CLIConnectionError.""" 29 | error = CLIConnectionError("Failed to connect to CLI") 30 | assert isinstance(error, ClaudeSDKError) 31 | assert "Failed to connect to CLI" in str(error) 32 | 33 | def test_process_error(self): 34 | """Test ProcessError with exit code and stderr.""" 35 | error = ProcessError("Process failed", exit_code=1, stderr="Command not found") 36 | assert error.exit_code == 1 37 | assert error.stderr == "Command not found" 38 | assert "Process failed" in str(error) 39 | assert "exit code: 1" in str(error) 40 | assert "Command not found" in str(error) 41 | 42 | def test_json_decode_error(self): 43 | """Test CLIJSONDecodeError.""" 44 | import json 45 | 46 | try: 47 | json.loads("{invalid json}") 48 | except json.JSONDecodeError as e: 49 | error = CLIJSONDecodeError("{invalid json}", e) 50 | assert error.line == "{invalid json}" 51 | assert error.original_error == e 52 | assert "Failed to decode JSON" in str(error) 53 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | 38 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 39 | # model: "claude-opus-4-20250514" 40 | 41 | # Optional: Customize the trigger phrase (default: @claude) 42 | # trigger_phrase: "/claude" 43 | 44 | # Optional: Trigger when specific user is assigned to an issue 45 | # assignee_trigger: "claude-bot" 46 | 47 | # Optional: Allow Claude to run specific commands 48 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 49 | 50 | # Optional: Add custom instructions for Claude to customize its behavior for your project 51 | # custom_instructions: | 52 | # Follow our coding standards 53 | # Ensure all new code has tests 54 | # Use TypeScript for new files 55 | 56 | # Optional: Custom environment variables for Claude 57 | # claude_env: | 58 | # NODE_ENV: test -------------------------------------------------------------------------------- /examples/quick_start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Quick start example for Claude Code SDK.""" 3 | 4 | import anyio 5 | 6 | from claude_code_sdk import ( 7 | AssistantMessage, 8 | ClaudeCodeOptions, 9 | ResultMessage, 10 | TextBlock, 11 | query, 12 | ) 13 | 14 | 15 | async def basic_example(): 16 | """Basic example - simple question.""" 17 | print("=== Basic Example ===") 18 | 19 | async for message in query(prompt="What is 2 + 2?"): 20 | if isinstance(message, AssistantMessage): 21 | for block in message.content: 22 | if isinstance(block, TextBlock): 23 | print(f"Claude: {block.text}") 24 | print() 25 | 26 | 27 | async def with_options_example(): 28 | """Example with custom options.""" 29 | print("=== With Options Example ===") 30 | 31 | options = ClaudeCodeOptions( 32 | system_prompt="You are a helpful assistant that explains things simply.", 33 | max_turns=1, 34 | ) 35 | 36 | async for message in query( 37 | prompt="Explain what Python is in one sentence.", options=options 38 | ): 39 | if isinstance(message, AssistantMessage): 40 | for block in message.content: 41 | if isinstance(block, TextBlock): 42 | print(f"Claude: {block.text}") 43 | print() 44 | 45 | 46 | async def with_tools_example(): 47 | """Example using tools.""" 48 | print("=== With Tools Example ===") 49 | 50 | options = ClaudeCodeOptions( 51 | allowed_tools=["Read", "Write"], 52 | system_prompt="You are a helpful file assistant.", 53 | ) 54 | 55 | async for message in query( 56 | prompt="Create a file called hello.txt with 'Hello, World!' in it", 57 | options=options, 58 | ): 59 | if isinstance(message, AssistantMessage): 60 | for block in message.content: 61 | if isinstance(block, TextBlock): 62 | print(f"Claude: {block.text}") 63 | elif isinstance(message, ResultMessage) and message.total_cost_usd > 0: 64 | print(f"\nCost: ${message.total_cost_usd:.4f}") 65 | print() 66 | 67 | 68 | async def main(): 69 | """Run all examples.""" 70 | await basic_example() 71 | await with_options_example() 72 | await with_tools_example() 73 | 74 | 75 | if __name__ == "__main__": 76 | anyio.run(main) 77 | -------------------------------------------------------------------------------- /.github/workflows/create-release-tag.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Tag 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | branches: [main] 7 | 8 | jobs: 9 | create-tag: 10 | if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v') 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Extract version from branch name 21 | id: extract_version 22 | run: | 23 | BRANCH_NAME="${{ github.event.pull_request.head.ref }}" 24 | VERSION="${BRANCH_NAME#release/v}" 25 | echo "version=$VERSION" >> $GITHUB_OUTPUT 26 | 27 | - name: Get previous release tag 28 | id: previous_tag 29 | run: | 30 | PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") 31 | echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT 32 | 33 | - name: Create and push tag 34 | run: | 35 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 36 | git config --local user.name "github-actions[bot]" 37 | 38 | # Create annotated tag 39 | git tag -a "v${{ steps.extract_version.outputs.version }}" \ 40 | -m "Release v${{ steps.extract_version.outputs.version }}" 41 | 42 | # Push tag 43 | git push origin "v${{ steps.extract_version.outputs.version }}" 44 | 45 | - name: Create GitHub Release 46 | uses: actions/create-release@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | tag_name: v${{ steps.extract_version.outputs.version }} 51 | release_name: Release v${{ steps.extract_version.outputs.version }} 52 | body: | 53 | ## Release v${{ steps.extract_version.outputs.version }} 54 | 55 | Published to PyPI: https://pypi.org/project/claude-code-sdk/${{ steps.extract_version.outputs.version }}/ 56 | 57 | ### Installation 58 | ```bash 59 | pip install claude-code-sdk==${{ steps.extract_version.outputs.version }} 60 | ``` 61 | 62 | ### What's Changed 63 | See the [full changelog](https://github.com/${{ github.repository }}/compare/${{ steps.previous_tag.outputs.previous_tag }}...v${{ steps.extract_version.outputs.version }}) 64 | draft: false 65 | prerelease: false 66 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 41 | # model: "claude-opus-4-20250514" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Customize review based on file types 55 | # direct_prompt: | 56 | # Review this PR focusing on: 57 | # - For TypeScript files: Type safety and proper interface usage 58 | # - For API endpoints: Security, input validation, and error handling 59 | # - For React components: Performance, accessibility, and reusability 60 | # - For test files: Coverage, edge cases, and test quality 61 | 62 | # Optional: If automated review posts public comments 63 | # no_comments: true 64 | 65 | # Optional: Create a summary comment on the PR 66 | # summary_comment: true 67 | 68 | # Optional: Allow Claude to suggest code changes 69 | # allow_code_suggestions: true 70 | 71 | # Optional: Limit Claude review scope 72 | # max_files_to_review: 10 73 | # max_lines_per_file: 500 -------------------------------------------------------------------------------- /examples/streaming_mode_trio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example of multi-turn conversation using trio with the Claude SDK. 4 | 5 | This demonstrates how to use the ClaudeSDKClient with trio for interactive, 6 | stateful conversations where you can send follow-up messages based on 7 | Claude's responses. 8 | """ 9 | 10 | import trio 11 | 12 | from claude_code_sdk import ( 13 | AssistantMessage, 14 | ClaudeCodeOptions, 15 | ClaudeSDKClient, 16 | ResultMessage, 17 | SystemMessage, 18 | TextBlock, 19 | UserMessage, 20 | ) 21 | 22 | 23 | def display_message(msg): 24 | """Standardized message display function. 25 | 26 | - UserMessage: "User: " 27 | - AssistantMessage: "Claude: " 28 | - SystemMessage: ignored 29 | - ResultMessage: "Result ended" + cost if available 30 | """ 31 | if isinstance(msg, UserMessage): 32 | for block in msg.content: 33 | if isinstance(block, TextBlock): 34 | print(f"User: {block.text}") 35 | elif isinstance(msg, AssistantMessage): 36 | for block in msg.content: 37 | if isinstance(block, TextBlock): 38 | print(f"Claude: {block.text}") 39 | elif isinstance(msg, SystemMessage): 40 | # Ignore system messages 41 | pass 42 | elif isinstance(msg, ResultMessage): 43 | print("Result ended") 44 | 45 | 46 | async def multi_turn_conversation(): 47 | """Example of a multi-turn conversation using trio.""" 48 | async with ClaudeSDKClient( 49 | options=ClaudeCodeOptions(model="claude-3-5-sonnet-20241022") 50 | ) as client: 51 | print("=== Multi-turn Conversation with Trio ===\n") 52 | 53 | # First turn: Simple math question 54 | print("User: What's 15 + 27?") 55 | await client.query("What's 15 + 27?") 56 | 57 | async for message in client.receive_response(): 58 | display_message(message) 59 | print() 60 | 61 | # Second turn: Follow-up calculation 62 | print("User: Now multiply that result by 2") 63 | await client.query("Now multiply that result by 2") 64 | 65 | async for message in client.receive_response(): 66 | display_message(message) 67 | print() 68 | 69 | # Third turn: One more operation 70 | print("User: Divide that by 7 and round to 2 decimal places") 71 | await client.query("Divide that by 7 and round to 2 decimal places") 72 | 73 | async for message in client.receive_response(): 74 | display_message(message) 75 | 76 | print("\nConversation complete!") 77 | 78 | 79 | if __name__ == "__main__": 80 | trio.run(multi_turn_conversation) 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "claude-code-sdk" 7 | version = "0.0.17" 8 | description = "Python SDK for Claude Code" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "Anthropic", email = "support@anthropic.com"}, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Typing :: Typed", 25 | ] 26 | keywords = ["claude", "ai", "sdk", "anthropic"] 27 | dependencies = [ 28 | "anyio>=4.0.0", 29 | "typing_extensions>=4.0.0; python_version<'3.11'", 30 | ] 31 | 32 | [project.optional-dependencies] 33 | dev = [ 34 | "pytest>=7.0.0", 35 | "pytest-asyncio>=0.20.0", 36 | "anyio[trio]>=4.0.0", 37 | "pytest-cov>=4.0.0", 38 | "mypy>=1.0.0", 39 | "ruff>=0.1.0", 40 | ] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/anthropics/claude-code-sdk-python" 44 | Documentation = "https://docs.anthropic.com/en/docs/claude-code/sdk" 45 | Issues = "https://github.com/anthropics/claude-code-sdk-python/issues" 46 | 47 | [tool.hatch.build.targets.wheel] 48 | packages = ["src/claude_code_sdk"] 49 | 50 | [tool.hatch.build.targets.sdist] 51 | include = [ 52 | "/src", 53 | "/tests", 54 | "/README.md", 55 | "/LICENSE", 56 | ] 57 | 58 | [tool.pytest.ini_options] 59 | testpaths = ["tests"] 60 | pythonpath = ["src"] 61 | addopts = [ 62 | "--import-mode=importlib", 63 | ] 64 | 65 | [tool.pytest-asyncio] 66 | asyncio_mode = "auto" 67 | 68 | [tool.mypy] 69 | python_version = "3.10" 70 | strict = true 71 | warn_return_any = true 72 | warn_unused_configs = true 73 | disallow_untyped_defs = true 74 | disallow_incomplete_defs = true 75 | check_untyped_defs = true 76 | disallow_untyped_decorators = true 77 | no_implicit_optional = true 78 | warn_redundant_casts = true 79 | warn_unused_ignores = true 80 | warn_no_return = true 81 | warn_unreachable = true 82 | strict_equality = true 83 | 84 | [tool.ruff] 85 | target-version = "py310" 86 | line-length = 88 87 | 88 | [tool.ruff.lint] 89 | select = [ 90 | "E", # pycodestyle errors 91 | "W", # pycodestyle warnings 92 | "F", # pyflakes 93 | "I", # isort 94 | "N", # pep8-naming 95 | "UP", # pyupgrade 96 | "B", # flake8-bugbear 97 | "C4", # flake8-comprehensions 98 | "PTH", # flake8-use-pathlib 99 | "SIM", # flake8-simplify 100 | ] 101 | ignore = [ 102 | "E501", # line too long (handled by formatter) 103 | ] 104 | 105 | [tool.ruff.lint.isort] 106 | known-first-party = ["claude_code_sdk"] -------------------------------------------------------------------------------- /tests/test_changelog.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | 5 | class TestChangelog: 6 | def setup_method(self): 7 | self.changelog_path = Path(__file__).parent.parent / "CHANGELOG.md" 8 | 9 | def test_changelog_exists(self): 10 | assert self.changelog_path.exists(), "CHANGELOG.md file should exist" 11 | 12 | def test_changelog_starts_with_header(self): 13 | content = self.changelog_path.read_text() 14 | assert content.startswith("# Changelog"), ( 15 | "Changelog should start with '# Changelog'" 16 | ) 17 | 18 | def test_changelog_has_valid_version_format(self): 19 | content = self.changelog_path.read_text() 20 | lines = content.split("\n") 21 | 22 | version_pattern = re.compile(r"^## \d+\.\d+\.\d+(?:\s+\(\d{4}-\d{2}-\d{2}\))?$") 23 | versions = [] 24 | 25 | for line in lines: 26 | if line.startswith("## "): 27 | assert version_pattern.match(line), f"Invalid version format: {line}" 28 | version_match = re.match(r"^## (\d+\.\d+\.\d+)", line) 29 | if version_match: 30 | versions.append(version_match.group(1)) 31 | 32 | assert len(versions) > 0, "Changelog should contain at least one version" 33 | 34 | def test_changelog_has_bullet_points(self): 35 | content = self.changelog_path.read_text() 36 | lines = content.split("\n") 37 | 38 | in_version_section = False 39 | has_bullet_points = False 40 | 41 | for i, line in enumerate(lines): 42 | if line.startswith("## "): 43 | if in_version_section and not has_bullet_points: 44 | raise AssertionError( 45 | "Previous version section should have at least one bullet point" 46 | ) 47 | in_version_section = True 48 | has_bullet_points = False 49 | elif in_version_section and line.startswith("- "): 50 | has_bullet_points = True 51 | elif in_version_section and line.strip() == "" and i == len(lines) - 1: 52 | # Last line check 53 | assert has_bullet_points, ( 54 | "Each version should have at least one bullet point" 55 | ) 56 | 57 | # Check the last section 58 | if in_version_section: 59 | assert has_bullet_points, ( 60 | "Last version section should have at least one bullet point" 61 | ) 62 | 63 | def test_changelog_versions_in_descending_order(self): 64 | content = self.changelog_path.read_text() 65 | lines = content.split("\n") 66 | 67 | versions = [] 68 | for line in lines: 69 | if line.startswith("## "): 70 | version_match = re.match(r"^## (\d+)\.(\d+)\.(\d+)", line) 71 | if version_match: 72 | versions.append(tuple(map(int, version_match.groups()))) 73 | 74 | for i in range(1, len(versions)): 75 | assert versions[i - 1] > versions[i], ( 76 | f"Versions should be in descending order: {versions[i - 1]} should be > {versions[i]}" 77 | ) 78 | 79 | def test_changelog_no_empty_bullet_points(self): 80 | content = self.changelog_path.read_text() 81 | lines = content.split("\n") 82 | 83 | for line in lines: 84 | if line.strip() == "-": 85 | raise AssertionError("Changelog should not have empty bullet points") 86 | -------------------------------------------------------------------------------- /src/claude_code_sdk/types.py: -------------------------------------------------------------------------------- 1 | """Type definitions for Claude SDK.""" 2 | 3 | from dataclasses import dataclass, field 4 | from pathlib import Path 5 | from typing import Any, Literal, TypedDict 6 | 7 | from typing_extensions import NotRequired # For Python < 3.11 compatibility 8 | 9 | # Permission modes 10 | PermissionMode = Literal["default", "acceptEdits", "bypassPermissions"] 11 | 12 | 13 | # MCP Server config 14 | class McpStdioServerConfig(TypedDict): 15 | """MCP stdio server configuration.""" 16 | 17 | type: NotRequired[Literal["stdio"]] # Optional for backwards compatibility 18 | command: str 19 | args: NotRequired[list[str]] 20 | env: NotRequired[dict[str, str]] 21 | 22 | 23 | class McpSSEServerConfig(TypedDict): 24 | """MCP SSE server configuration.""" 25 | 26 | type: Literal["sse"] 27 | url: str 28 | headers: NotRequired[dict[str, str]] 29 | 30 | 31 | class McpHttpServerConfig(TypedDict): 32 | """MCP HTTP server configuration.""" 33 | 34 | type: Literal["http"] 35 | url: str 36 | headers: NotRequired[dict[str, str]] 37 | 38 | 39 | McpServerConfig = McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig 40 | 41 | 42 | # Content block types 43 | @dataclass 44 | class TextBlock: 45 | """Text content block.""" 46 | 47 | text: str 48 | 49 | 50 | @dataclass 51 | class ToolUseBlock: 52 | """Tool use content block.""" 53 | 54 | id: str 55 | name: str 56 | input: dict[str, Any] 57 | 58 | 59 | @dataclass 60 | class ToolResultBlock: 61 | """Tool result content block.""" 62 | 63 | tool_use_id: str 64 | content: str | list[dict[str, Any]] | None = None 65 | is_error: bool | None = None 66 | 67 | 68 | ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock 69 | 70 | 71 | # Message types 72 | @dataclass 73 | class UserMessage: 74 | """User message.""" 75 | 76 | content: str | list[ContentBlock] 77 | 78 | 79 | @dataclass 80 | class AssistantMessage: 81 | """Assistant message with content blocks.""" 82 | 83 | content: list[ContentBlock] 84 | 85 | 86 | @dataclass 87 | class SystemMessage: 88 | """System message with metadata.""" 89 | 90 | subtype: str 91 | data: dict[str, Any] 92 | 93 | 94 | @dataclass 95 | class ResultMessage: 96 | """Result message with cost and usage information.""" 97 | 98 | subtype: str 99 | duration_ms: int 100 | duration_api_ms: int 101 | is_error: bool 102 | num_turns: int 103 | session_id: str 104 | total_cost_usd: float | None = None 105 | usage: dict[str, Any] | None = None 106 | result: str | None = None 107 | 108 | 109 | Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage 110 | 111 | 112 | @dataclass 113 | class ClaudeCodeOptions: 114 | """Query options for Claude SDK.""" 115 | 116 | allowed_tools: list[str] = field(default_factory=list) 117 | max_thinking_tokens: int = 8000 118 | system_prompt: str | None = None 119 | append_system_prompt: str | None = None 120 | mcp_tools: list[str] = field(default_factory=list) 121 | mcp_servers: dict[str, McpServerConfig] = field(default_factory=dict) 122 | permission_mode: PermissionMode | None = None 123 | continue_conversation: bool = False 124 | resume: str | None = None 125 | max_turns: int | None = None 126 | disallowed_tools: list[str] = field(default_factory=list) 127 | model: str | None = None 128 | permission_prompt_tool_name: str | None = None 129 | cwd: str | Path | None = None 130 | settings: str | None = None 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Claude Code SDK for Python 2 | 3 | Python SDK for Claude Code. See the [Claude Code SDK documentation](https://docs.anthropic.com/en/docs/claude-code/sdk) for more information. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pip install claude-code-sdk 9 | ``` 10 | 11 | **Prerequisites:** 12 | - Python 3.10+ 13 | - Node.js 14 | - Claude Code: `npm install -g @anthropic-ai/claude-code` 15 | 16 | ## Quick Start 17 | 18 | ```python 19 | import anyio 20 | from claude_code_sdk import query 21 | 22 | async def main(): 23 | async for message in query(prompt="What is 2 + 2?"): 24 | print(message) 25 | 26 | anyio.run(main) 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Basic Query 32 | 33 | ```python 34 | from claude_code_sdk import query, ClaudeCodeOptions, AssistantMessage, TextBlock 35 | 36 | # Simple query 37 | async for message in query(prompt="Hello Claude"): 38 | if isinstance(message, AssistantMessage): 39 | for block in message.content: 40 | if isinstance(block, TextBlock): 41 | print(block.text) 42 | 43 | # With options 44 | options = ClaudeCodeOptions( 45 | system_prompt="You are a helpful assistant", 46 | max_turns=1 47 | ) 48 | 49 | async for message in query(prompt="Tell me a joke", options=options): 50 | print(message) 51 | ``` 52 | 53 | ### Using Tools 54 | 55 | ```python 56 | options = ClaudeCodeOptions( 57 | allowed_tools=["Read", "Write", "Bash"], 58 | permission_mode='acceptEdits' # auto-accept file edits 59 | ) 60 | 61 | async for message in query( 62 | prompt="Create a hello.py file", 63 | options=options 64 | ): 65 | # Process tool use and results 66 | pass 67 | ``` 68 | 69 | ### Working Directory 70 | 71 | ```python 72 | from pathlib import Path 73 | 74 | options = ClaudeCodeOptions( 75 | cwd="/path/to/project" # or Path("/path/to/project") 76 | ) 77 | ``` 78 | 79 | ## API Reference 80 | 81 | ### `query(prompt, options=None)` 82 | 83 | Main async function for querying Claude. 84 | 85 | **Parameters:** 86 | - `prompt` (str): The prompt to send to Claude 87 | - `options` (ClaudeCodeOptions): Optional configuration 88 | 89 | **Returns:** AsyncIterator[Message] - Stream of response messages 90 | 91 | ### Types 92 | 93 | See [src/claude_code_sdk/types.py](src/claude_code_sdk/types.py) for complete type definitions: 94 | - `ClaudeCodeOptions` - Configuration options 95 | - `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types 96 | - `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks 97 | 98 | ## Error Handling 99 | 100 | ```python 101 | from claude_code_sdk import ( 102 | ClaudeSDKError, # Base error 103 | CLINotFoundError, # Claude Code not installed 104 | CLIConnectionError, # Connection issues 105 | ProcessError, # Process failed 106 | CLIJSONDecodeError, # JSON parsing issues 107 | ) 108 | 109 | try: 110 | async for message in query(prompt="Hello"): 111 | pass 112 | except CLINotFoundError: 113 | print("Please install Claude Code") 114 | except ProcessError as e: 115 | print(f"Process failed with exit code: {e.exit_code}") 116 | except CLIJSONDecodeError as e: 117 | print(f"Failed to parse response: {e}") 118 | ``` 119 | 120 | See [src/claude_code_sdk/_errors.py](src/claude_code_sdk/_errors.py) for all error types. 121 | 122 | ## Available Tools 123 | 124 | See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude) for a complete list of available tools. 125 | 126 | ## Examples 127 | 128 | See [examples/quick_start.py](examples/quick_start.py) for a complete working example. 129 | 130 | ## License 131 | 132 | MIT 133 | -------------------------------------------------------------------------------- /src/claude_code_sdk/query.py: -------------------------------------------------------------------------------- 1 | """Query function for one-shot interactions with Claude Code.""" 2 | 3 | import os 4 | from collections.abc import AsyncIterable, AsyncIterator 5 | from typing import Any 6 | 7 | from ._internal.client import InternalClient 8 | from .types import ClaudeCodeOptions, Message 9 | 10 | 11 | async def query( 12 | *, 13 | prompt: str | AsyncIterable[dict[str, Any]], 14 | options: ClaudeCodeOptions | None = None, 15 | ) -> AsyncIterator[Message]: 16 | """ 17 | Query Claude Code for one-shot or unidirectional streaming interactions. 18 | 19 | This function is ideal for simple, stateless queries where you don't need 20 | bidirectional communication or conversation management. For interactive, 21 | stateful conversations, use ClaudeSDKClient instead. 22 | 23 | Key differences from ClaudeSDKClient: 24 | - **Unidirectional**: Send all messages upfront, receive all responses 25 | - **Stateless**: Each query is independent, no conversation state 26 | - **Simple**: Fire-and-forget style, no connection management 27 | - **No interrupts**: Cannot interrupt or send follow-up messages 28 | 29 | When to use query(): 30 | - Simple one-off questions ("What is 2+2?") 31 | - Batch processing of independent prompts 32 | - Code generation or analysis tasks 33 | - Automated scripts and CI/CD pipelines 34 | - When you know all inputs upfront 35 | 36 | When to use ClaudeSDKClient: 37 | - Interactive conversations with follow-ups 38 | - Chat applications or REPL-like interfaces 39 | - When you need to send messages based on responses 40 | - When you need interrupt capabilities 41 | - Long-running sessions with state 42 | 43 | Args: 44 | prompt: The prompt to send to Claude. Can be a string for single-shot queries 45 | or an AsyncIterable[dict] for streaming mode with continuous interaction. 46 | In streaming mode, each dict should have the structure: 47 | { 48 | "type": "user", 49 | "message": {"role": "user", "content": "..."}, 50 | "parent_tool_use_id": None, 51 | "session_id": "..." 52 | } 53 | options: Optional configuration (defaults to ClaudeCodeOptions() if None). 54 | Set options.permission_mode to control tool execution: 55 | - 'default': CLI prompts for dangerous tools 56 | - 'acceptEdits': Auto-accept file edits 57 | - 'bypassPermissions': Allow all tools (use with caution) 58 | Set options.cwd for working directory. 59 | 60 | Yields: 61 | Messages from the conversation 62 | 63 | Example - Simple query: 64 | ```python 65 | # One-off question 66 | async for message in query(prompt="What is the capital of France?"): 67 | print(message) 68 | ``` 69 | 70 | Example - With options: 71 | ```python 72 | # Code generation with specific settings 73 | async for message in query( 74 | prompt="Create a Python web server", 75 | options=ClaudeCodeOptions( 76 | system_prompt="You are an expert Python developer", 77 | cwd="/home/user/project" 78 | ) 79 | ): 80 | print(message) 81 | ``` 82 | 83 | Example - Streaming mode (still unidirectional): 84 | ```python 85 | async def prompts(): 86 | yield {"type": "user", "message": {"role": "user", "content": "Hello"}} 87 | yield {"type": "user", "message": {"role": "user", "content": "How are you?"}} 88 | 89 | # All prompts are sent, then all responses received 90 | async for message in query(prompt=prompts()): 91 | print(message) 92 | ``` 93 | """ 94 | if options is None: 95 | options = ClaudeCodeOptions() 96 | 97 | os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py" 98 | 99 | client = InternalClient() 100 | 101 | async for message in client.process_query(prompt=prompt, options=options): 102 | yield message 103 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | """Tests for Claude SDK type definitions.""" 2 | 3 | from claude_code_sdk import ( 4 | AssistantMessage, 5 | ClaudeCodeOptions, 6 | ResultMessage, 7 | ) 8 | from claude_code_sdk.types import TextBlock, ToolResultBlock, ToolUseBlock, UserMessage 9 | 10 | 11 | class TestMessageTypes: 12 | """Test message type creation and validation.""" 13 | 14 | def test_user_message_creation(self): 15 | """Test creating a UserMessage.""" 16 | msg = UserMessage(content="Hello, Claude!") 17 | assert msg.content == "Hello, Claude!" 18 | 19 | def test_assistant_message_with_text(self): 20 | """Test creating an AssistantMessage with text content.""" 21 | text_block = TextBlock(text="Hello, human!") 22 | msg = AssistantMessage(content=[text_block]) 23 | assert len(msg.content) == 1 24 | assert msg.content[0].text == "Hello, human!" 25 | 26 | def test_tool_use_block(self): 27 | """Test creating a ToolUseBlock.""" 28 | block = ToolUseBlock( 29 | id="tool-123", name="Read", input={"file_path": "/test.txt"} 30 | ) 31 | assert block.id == "tool-123" 32 | assert block.name == "Read" 33 | assert block.input["file_path"] == "/test.txt" 34 | 35 | def test_tool_result_block(self): 36 | """Test creating a ToolResultBlock.""" 37 | block = ToolResultBlock( 38 | tool_use_id="tool-123", content="File contents here", is_error=False 39 | ) 40 | assert block.tool_use_id == "tool-123" 41 | assert block.content == "File contents here" 42 | assert block.is_error is False 43 | 44 | def test_result_message(self): 45 | """Test creating a ResultMessage.""" 46 | msg = ResultMessage( 47 | subtype="success", 48 | duration_ms=1500, 49 | duration_api_ms=1200, 50 | is_error=False, 51 | num_turns=1, 52 | session_id="session-123", 53 | total_cost_usd=0.01, 54 | ) 55 | assert msg.subtype == "success" 56 | assert msg.total_cost_usd == 0.01 57 | assert msg.session_id == "session-123" 58 | 59 | 60 | class TestOptions: 61 | """Test Options configuration.""" 62 | 63 | def test_default_options(self): 64 | """Test Options with default values.""" 65 | options = ClaudeCodeOptions() 66 | assert options.allowed_tools == [] 67 | assert options.max_thinking_tokens == 8000 68 | assert options.system_prompt is None 69 | assert options.permission_mode is None 70 | assert options.continue_conversation is False 71 | assert options.disallowed_tools == [] 72 | 73 | def test_claude_code_options_with_tools(self): 74 | """Test Options with built-in tools.""" 75 | options = ClaudeCodeOptions( 76 | allowed_tools=["Read", "Write", "Edit"], disallowed_tools=["Bash"] 77 | ) 78 | assert options.allowed_tools == ["Read", "Write", "Edit"] 79 | assert options.disallowed_tools == ["Bash"] 80 | 81 | def test_claude_code_options_with_permission_mode(self): 82 | """Test Options with permission mode.""" 83 | options = ClaudeCodeOptions(permission_mode="bypassPermissions") 84 | assert options.permission_mode == "bypassPermissions" 85 | 86 | def test_claude_code_options_with_system_prompt(self): 87 | """Test Options with system prompt.""" 88 | options = ClaudeCodeOptions( 89 | system_prompt="You are a helpful assistant.", 90 | append_system_prompt="Be concise.", 91 | ) 92 | assert options.system_prompt == "You are a helpful assistant." 93 | assert options.append_system_prompt == "Be concise." 94 | 95 | def test_claude_code_options_with_session_continuation(self): 96 | """Test Options with session continuation.""" 97 | options = ClaudeCodeOptions(continue_conversation=True, resume="session-123") 98 | assert options.continue_conversation is True 99 | assert options.resume == "session-123" 100 | 101 | def test_claude_code_options_with_model_specification(self): 102 | """Test Options with model specification.""" 103 | options = ClaudeCodeOptions( 104 | model="claude-3-5-sonnet-20241022", permission_prompt_tool_name="CustomTool" 105 | ) 106 | assert options.model == "claude-3-5-sonnet-20241022" 107 | assert options.permission_prompt_tool_name == "CustomTool" 108 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | """Tests for Claude SDK client functionality.""" 2 | 3 | from unittest.mock import AsyncMock, patch 4 | 5 | import anyio 6 | 7 | from claude_code_sdk import AssistantMessage, ClaudeCodeOptions, query 8 | from claude_code_sdk.types import TextBlock 9 | 10 | 11 | class TestQueryFunction: 12 | """Test the main query function.""" 13 | 14 | def test_query_single_prompt(self): 15 | """Test query with a single prompt.""" 16 | 17 | async def _test(): 18 | with patch( 19 | "claude_code_sdk._internal.client.InternalClient.process_query" 20 | ) as mock_process: 21 | # Mock the async generator 22 | async def mock_generator(): 23 | yield AssistantMessage(content=[TextBlock(text="4")]) 24 | 25 | mock_process.return_value = mock_generator() 26 | 27 | messages = [] 28 | async for msg in query(prompt="What is 2+2?"): 29 | messages.append(msg) 30 | 31 | assert len(messages) == 1 32 | assert isinstance(messages[0], AssistantMessage) 33 | assert messages[0].content[0].text == "4" 34 | 35 | anyio.run(_test) 36 | 37 | def test_query_with_options(self): 38 | """Test query with various options.""" 39 | 40 | async def _test(): 41 | with patch( 42 | "claude_code_sdk._internal.client.InternalClient.process_query" 43 | ) as mock_process: 44 | 45 | async def mock_generator(): 46 | yield AssistantMessage(content=[TextBlock(text="Hello!")]) 47 | 48 | mock_process.return_value = mock_generator() 49 | 50 | options = ClaudeCodeOptions( 51 | allowed_tools=["Read", "Write"], 52 | system_prompt="You are helpful", 53 | permission_mode="acceptEdits", 54 | max_turns=5, 55 | ) 56 | 57 | messages = [] 58 | async for msg in query(prompt="Hi", options=options): 59 | messages.append(msg) 60 | 61 | # Verify process_query was called with correct prompt and options 62 | mock_process.assert_called_once() 63 | call_args = mock_process.call_args 64 | assert call_args[1]["prompt"] == "Hi" 65 | assert call_args[1]["options"] == options 66 | 67 | anyio.run(_test) 68 | 69 | def test_query_with_cwd(self): 70 | """Test query with custom working directory.""" 71 | 72 | async def _test(): 73 | with patch( 74 | "claude_code_sdk._internal.client.SubprocessCLITransport" 75 | ) as mock_transport_class: 76 | mock_transport = AsyncMock() 77 | mock_transport_class.return_value = mock_transport 78 | 79 | # Mock the message stream 80 | async def mock_receive(): 81 | yield { 82 | "type": "assistant", 83 | "message": { 84 | "role": "assistant", 85 | "content": [{"type": "text", "text": "Done"}], 86 | }, 87 | } 88 | yield { 89 | "type": "result", 90 | "subtype": "success", 91 | "duration_ms": 1000, 92 | "duration_api_ms": 800, 93 | "is_error": False, 94 | "num_turns": 1, 95 | "session_id": "test-session", 96 | "total_cost_usd": 0.001, 97 | } 98 | 99 | mock_transport.receive_messages = mock_receive 100 | mock_transport.connect = AsyncMock() 101 | mock_transport.disconnect = AsyncMock() 102 | 103 | options = ClaudeCodeOptions(cwd="/custom/path") 104 | messages = [] 105 | async for msg in query(prompt="test", options=options): 106 | messages.append(msg) 107 | 108 | # Verify transport was created with correct parameters 109 | mock_transport_class.assert_called_once() 110 | call_kwargs = mock_transport_class.call_args.kwargs 111 | assert call_kwargs["prompt"] == "test" 112 | assert call_kwargs["options"].cwd == "/custom/path" 113 | 114 | anyio.run(_test) 115 | -------------------------------------------------------------------------------- /.github/workflows/claude-issue-triage.yml: -------------------------------------------------------------------------------- 1 | name: Claude Issue Triage 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | triage-issue: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | permissions: 12 | contents: read 13 | issues: write 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Create triage prompt 20 | run: | 21 | mkdir -p /tmp/claude-prompts 22 | cat > /tmp/claude-prompts/triage-prompt.txt << 'EOF' 23 | You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list. 24 | 25 | IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels. 26 | 27 | Issue Information: 28 | - REPO: ${{ github.repository }} 29 | - ISSUE_NUMBER: ${{ github.event.issue.number }} 30 | 31 | TASK OVERVIEW: 32 | 33 | 1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else. 34 | 35 | 2. Next, use the GitHub tools to get context about the issue: 36 | - You have access to these tools: 37 | - mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels 38 | - mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments 39 | - mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting) 40 | - mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues 41 | - mcp__github__list_issues: Use this to understand patterns in how other issues are labeled 42 | - Start by using mcp__github__get_issue to get the issue details 43 | 44 | 3. Analyze the issue content, considering: 45 | - The issue title and description 46 | - The type of issue (bug report, feature request, question, etc.) 47 | - Technical areas mentioned 48 | - Severity or priority indicators 49 | - User impact 50 | - Components affected 51 | 52 | 4. Select appropriate labels from the available labels list provided above: 53 | - Choose labels that accurately reflect the issue's nature 54 | - Be specific but comprehensive 55 | - Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority) 56 | - Consider platform labels (android, ios) if applicable 57 | - 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. 58 | 59 | 5. Apply the selected labels: 60 | - Use mcp__github__update_issue to apply your selected labels 61 | - DO NOT post any comments explaining your decision 62 | - DO NOT communicate directly with users 63 | - If no labels are clearly applicable, do not apply any labels 64 | 65 | IMPORTANT GUIDELINES: 66 | - Be thorough in your analysis 67 | - Only select labels from the provided list above 68 | - DO NOT post any comments to the issue 69 | - Your ONLY action should be to apply labels using mcp__github__update_issue 70 | - It's okay to not add any labels if none are clearly applicable 71 | EOF 72 | 73 | - name: Setup GitHub MCP Server 74 | run: | 75 | mkdir -p /tmp/mcp-config 76 | cat > /tmp/mcp-config/mcp-servers.json << 'EOF' 77 | { 78 | "mcpServers": { 79 | "github": { 80 | "command": "docker", 81 | "args": [ 82 | "run", 83 | "-i", 84 | "--rm", 85 | "-e", 86 | "GITHUB_PERSONAL_ACCESS_TOKEN", 87 | "ghcr.io/github/github-mcp-server:sha-7aced2b" 88 | ], 89 | "env": { 90 | "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" 91 | } 92 | } 93 | } 94 | } 95 | EOF 96 | 97 | - name: Run Claude Code for Issue Triage 98 | uses: anthropics/claude-code-base-action@beta 99 | with: 100 | prompt_file: /tmp/claude-prompts/triage-prompt.txt 101 | 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" 102 | timeout_minutes: "5" 103 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 104 | mcp_config: /tmp/mcp-config/mcp-servers.json 105 | claude_env: | 106 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to publish (e.g., 0.1.0)' 8 | required: true 9 | type: string 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ['3.10', '3.11', '3.12', '3.13'] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -e ".[dev]" 29 | 30 | - name: Run tests 31 | run: | 32 | python -m pytest tests/ -v 33 | 34 | lint: 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | - name: Set up Python 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: '3.12' 44 | 45 | - name: Install dependencies 46 | run: | 47 | python -m pip install --upgrade pip 48 | pip install -e ".[dev]" 49 | 50 | - name: Run ruff 51 | run: | 52 | ruff check src/ tests/ 53 | ruff format --check src/ tests/ 54 | 55 | - name: Run mypy 56 | run: | 57 | mypy src/ 58 | 59 | publish: 60 | needs: [test, lint] 61 | runs-on: ubuntu-latest 62 | permissions: 63 | contents: write 64 | pull-requests: write 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | with: 69 | token: ${{ secrets.GITHUB_TOKEN }} 70 | 71 | - name: Set up Python 72 | uses: actions/setup-python@v5 73 | with: 74 | python-version: '3.12' 75 | 76 | - name: Set version 77 | id: version 78 | run: | 79 | VERSION="${{ github.event.inputs.version }}" 80 | echo "VERSION=$VERSION" >> $GITHUB_ENV 81 | echo "version=$VERSION" >> $GITHUB_OUTPUT 82 | 83 | - name: Update version 84 | run: | 85 | python scripts/update_version.py "${{ env.VERSION }}" 86 | 87 | - name: Install build dependencies 88 | run: | 89 | python -m pip install --upgrade pip 90 | pip install build twine 91 | 92 | - name: Build package 93 | run: python -m build 94 | 95 | - name: Check package 96 | run: twine check dist/* 97 | 98 | - name: Publish to PyPI 99 | env: 100 | TWINE_USERNAME: __token__ 101 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 102 | run: | 103 | twine upload dist/* 104 | echo "Package published to PyPI" 105 | echo "Install with: pip install claude-code-sdk==${{ env.VERSION }}" 106 | 107 | - name: Create version update PR 108 | env: 109 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 110 | run: | 111 | # Create a new branch for the version update 112 | BRANCH_NAME="release/v${{ env.VERSION }}" 113 | echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV 114 | 115 | # Create branch via API 116 | BASE_SHA=$(git rev-parse HEAD) 117 | gh api \ 118 | --method POST \ 119 | /repos/$GITHUB_REPOSITORY/git/refs \ 120 | -f ref="refs/heads/$BRANCH_NAME" \ 121 | -f sha="$BASE_SHA" 122 | 123 | # Get current SHA values of files 124 | echo "Getting SHA for pyproject.toml" 125 | PYPROJECT_SHA=$(gh api /repos/$GITHUB_REPOSITORY/contents/pyproject.toml --jq '.sha') 126 | echo "Getting SHA for __init__.py" 127 | INIT_SHA=$(gh api /repos/$GITHUB_REPOSITORY/contents/src/claude_code_sdk/__init__.py --jq '.sha') 128 | 129 | # Commit pyproject.toml via GitHub API (this creates signed commits) 130 | message="chore: bump version to ${{ env.VERSION }}" 131 | base64 -i pyproject.toml > pyproject.toml.b64 132 | gh api \ 133 | --method PUT \ 134 | /repos/$GITHUB_REPOSITORY/contents/pyproject.toml \ 135 | -f message="$message" \ 136 | -F content=@pyproject.toml.b64 \ 137 | -f sha="$PYPROJECT_SHA" \ 138 | -f branch="$BRANCH_NAME" 139 | 140 | # Commit __init__.py via GitHub API 141 | base64 -i src/claude_code_sdk/__init__.py > init.py.b64 142 | gh api \ 143 | --method PUT \ 144 | /repos/$GITHUB_REPOSITORY/contents/src/claude_code_sdk/__init__.py \ 145 | -f message="$message" \ 146 | -F content=@init.py.b64 \ 147 | -f sha="$INIT_SHA" \ 148 | -f branch="$BRANCH_NAME" 149 | 150 | # Create PR using GitHub CLI 151 | PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI. 152 | 153 | ## Changes 154 | - Updated version in \`pyproject.toml\` 155 | - Updated version in \`src/claude_code_sdk/__init__.py\` 156 | 157 | ## Release Information 158 | - Published to PyPI: https://pypi.org/project/claude-code-sdk/${{ env.VERSION }}/ 159 | - Install with: \`pip install claude-code-sdk==${{ env.VERSION }}\` 160 | 161 | 🤖 Generated by GitHub Actions" 162 | 163 | PR_URL=$(gh pr create \ 164 | --title "chore: bump version to ${{ env.VERSION }}" \ 165 | --body "$PR_BODY" \ 166 | --base main \ 167 | --head "$BRANCH_NAME") 168 | 169 | echo "PR created: $PR_URL" -------------------------------------------------------------------------------- /src/claude_code_sdk/_internal/message_parser.py: -------------------------------------------------------------------------------- 1 | """Message parser for Claude Code SDK responses.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from .._errors import MessageParseError 7 | from ..types import ( 8 | AssistantMessage, 9 | ContentBlock, 10 | Message, 11 | ResultMessage, 12 | SystemMessage, 13 | TextBlock, 14 | ToolResultBlock, 15 | ToolUseBlock, 16 | UserMessage, 17 | ) 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def parse_message(data: dict[str, Any]) -> Message: 23 | """ 24 | Parse message from CLI output into typed Message objects. 25 | 26 | Args: 27 | data: Raw message dictionary from CLI output 28 | 29 | Returns: 30 | Parsed Message object 31 | 32 | Raises: 33 | MessageParseError: If parsing fails or message type is unrecognized 34 | """ 35 | if not isinstance(data, dict): 36 | raise MessageParseError( 37 | f"Invalid message data type (expected dict, got {type(data).__name__})", 38 | data, 39 | ) 40 | 41 | message_type = data.get("type") 42 | if not message_type: 43 | raise MessageParseError("Message missing 'type' field", data) 44 | 45 | match message_type: 46 | case "user": 47 | try: 48 | if isinstance(data["message"]["content"], list): 49 | user_content_blocks: list[ContentBlock] = [] 50 | for block in data["message"]["content"]: 51 | match block["type"]: 52 | case "text": 53 | user_content_blocks.append( 54 | TextBlock(text=block["text"]) 55 | ) 56 | case "tool_use": 57 | user_content_blocks.append( 58 | ToolUseBlock( 59 | id=block["id"], 60 | name=block["name"], 61 | input=block["input"], 62 | ) 63 | ) 64 | case "tool_result": 65 | user_content_blocks.append( 66 | ToolResultBlock( 67 | tool_use_id=block["tool_use_id"], 68 | content=block.get("content"), 69 | is_error=block.get("is_error"), 70 | ) 71 | ) 72 | return UserMessage(content=user_content_blocks) 73 | return UserMessage(content=data["message"]["content"]) 74 | except KeyError as e: 75 | raise MessageParseError( 76 | f"Missing required field in user message: {e}", data 77 | ) from e 78 | 79 | case "assistant": 80 | try: 81 | content_blocks: list[ContentBlock] = [] 82 | for block in data["message"]["content"]: 83 | match block["type"]: 84 | case "text": 85 | content_blocks.append(TextBlock(text=block["text"])) 86 | case "tool_use": 87 | content_blocks.append( 88 | ToolUseBlock( 89 | id=block["id"], 90 | name=block["name"], 91 | input=block["input"], 92 | ) 93 | ) 94 | case "tool_result": 95 | content_blocks.append( 96 | ToolResultBlock( 97 | tool_use_id=block["tool_use_id"], 98 | content=block.get("content"), 99 | is_error=block.get("is_error"), 100 | ) 101 | ) 102 | 103 | return AssistantMessage(content=content_blocks) 104 | except KeyError as e: 105 | raise MessageParseError( 106 | f"Missing required field in assistant message: {e}", data 107 | ) from e 108 | 109 | case "system": 110 | try: 111 | return SystemMessage( 112 | subtype=data["subtype"], 113 | data=data, 114 | ) 115 | except KeyError as e: 116 | raise MessageParseError( 117 | f"Missing required field in system message: {e}", data 118 | ) from e 119 | 120 | case "result": 121 | try: 122 | return ResultMessage( 123 | subtype=data["subtype"], 124 | duration_ms=data["duration_ms"], 125 | duration_api_ms=data["duration_api_ms"], 126 | is_error=data["is_error"], 127 | num_turns=data["num_turns"], 128 | session_id=data["session_id"], 129 | total_cost_usd=data.get("total_cost_usd"), 130 | usage=data.get("usage"), 131 | result=data.get("result"), 132 | ) 133 | except KeyError as e: 134 | raise MessageParseError( 135 | f"Missing required field in result message: {e}", data 136 | ) from e 137 | 138 | case _: 139 | raise MessageParseError(f"Unknown message type: {message_type}", data) 140 | -------------------------------------------------------------------------------- /tests/test_transport.py: -------------------------------------------------------------------------------- 1 | """Tests for Claude SDK transport layer.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | 5 | import anyio 6 | import pytest 7 | 8 | from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport 9 | from claude_code_sdk.types import ClaudeCodeOptions 10 | 11 | 12 | class TestSubprocessCLITransport: 13 | """Test subprocess transport implementation.""" 14 | 15 | def test_find_cli_not_found(self): 16 | """Test CLI not found error.""" 17 | from claude_code_sdk._errors import CLINotFoundError 18 | 19 | with ( 20 | patch("shutil.which", return_value=None), 21 | patch("pathlib.Path.exists", return_value=False), 22 | pytest.raises(CLINotFoundError) as exc_info, 23 | ): 24 | SubprocessCLITransport(prompt="test", options=ClaudeCodeOptions()) 25 | 26 | assert "Claude Code requires Node.js" in str(exc_info.value) 27 | 28 | def test_build_command_basic(self): 29 | """Test building basic CLI command.""" 30 | transport = SubprocessCLITransport( 31 | prompt="Hello", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 32 | ) 33 | 34 | cmd = transport._build_command() 35 | assert cmd[0] == "/usr/bin/claude" 36 | assert "--output-format" in cmd 37 | assert "stream-json" in cmd 38 | assert "--print" in cmd 39 | assert "Hello" in cmd 40 | 41 | def test_cli_path_accepts_pathlib_path(self): 42 | """Test that cli_path accepts pathlib.Path objects.""" 43 | from pathlib import Path 44 | 45 | transport = SubprocessCLITransport( 46 | prompt="Hello", 47 | options=ClaudeCodeOptions(), 48 | cli_path=Path("/usr/bin/claude"), 49 | ) 50 | 51 | assert transport._cli_path == "/usr/bin/claude" 52 | 53 | def test_build_command_with_options(self): 54 | """Test building CLI command with options.""" 55 | transport = SubprocessCLITransport( 56 | prompt="test", 57 | options=ClaudeCodeOptions( 58 | system_prompt="Be helpful", 59 | allowed_tools=["Read", "Write"], 60 | disallowed_tools=["Bash"], 61 | model="claude-3-5-sonnet", 62 | permission_mode="acceptEdits", 63 | max_turns=5, 64 | ), 65 | cli_path="/usr/bin/claude", 66 | ) 67 | 68 | cmd = transport._build_command() 69 | assert "--system-prompt" in cmd 70 | assert "Be helpful" in cmd 71 | assert "--allowedTools" in cmd 72 | assert "Read,Write" in cmd 73 | assert "--disallowedTools" in cmd 74 | assert "Bash" in cmd 75 | assert "--model" in cmd 76 | assert "claude-3-5-sonnet" in cmd 77 | assert "--permission-mode" in cmd 78 | assert "acceptEdits" in cmd 79 | assert "--max-turns" in cmd 80 | assert "5" in cmd 81 | 82 | def test_session_continuation(self): 83 | """Test session continuation options.""" 84 | transport = SubprocessCLITransport( 85 | prompt="Continue from before", 86 | options=ClaudeCodeOptions(continue_conversation=True, resume="session-123"), 87 | cli_path="/usr/bin/claude", 88 | ) 89 | 90 | cmd = transport._build_command() 91 | assert "--continue" in cmd 92 | assert "--resume" in cmd 93 | assert "session-123" in cmd 94 | 95 | def test_connect_disconnect(self): 96 | """Test connect and disconnect lifecycle.""" 97 | 98 | async def _test(): 99 | with patch("anyio.open_process") as mock_exec: 100 | mock_process = MagicMock() 101 | mock_process.returncode = None 102 | mock_process.terminate = MagicMock() 103 | mock_process.wait = AsyncMock() 104 | mock_process.stdout = MagicMock() 105 | mock_process.stderr = MagicMock() 106 | 107 | # Mock stdin with aclose method 108 | mock_stdin = MagicMock() 109 | mock_stdin.aclose = AsyncMock() 110 | mock_process.stdin = mock_stdin 111 | 112 | mock_exec.return_value = mock_process 113 | 114 | transport = SubprocessCLITransport( 115 | prompt="test", 116 | options=ClaudeCodeOptions(), 117 | cli_path="/usr/bin/claude", 118 | ) 119 | 120 | await transport.connect() 121 | assert transport._process is not None 122 | assert transport.is_connected() 123 | 124 | await transport.disconnect() 125 | mock_process.terminate.assert_called_once() 126 | 127 | anyio.run(_test) 128 | 129 | def test_receive_messages(self): 130 | """Test parsing messages from CLI output.""" 131 | # This test is simplified to just test the parsing logic 132 | # The full async stream handling is tested in integration tests 133 | transport = SubprocessCLITransport( 134 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 135 | ) 136 | 137 | # The actual message parsing is done by the client, not the transport 138 | # So we just verify the transport can be created and basic structure is correct 139 | assert transport._prompt == "test" 140 | assert transport._cli_path == "/usr/bin/claude" 141 | 142 | def test_connect_with_nonexistent_cwd(self): 143 | """Test that connect raises CLIConnectionError when cwd doesn't exist.""" 144 | from claude_code_sdk._errors import CLIConnectionError 145 | 146 | async def _test(): 147 | transport = SubprocessCLITransport( 148 | prompt="test", 149 | options=ClaudeCodeOptions(cwd="/this/directory/does/not/exist"), 150 | cli_path="/usr/bin/claude", 151 | ) 152 | 153 | with pytest.raises(CLIConnectionError) as exc_info: 154 | await transport.connect() 155 | 156 | assert "/this/directory/does/not/exist" in str(exc_info.value) 157 | 158 | anyio.run(_test) 159 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for Claude SDK. 2 | 3 | These tests verify end-to-end functionality with mocked CLI responses. 4 | """ 5 | 6 | from unittest.mock import AsyncMock, patch 7 | 8 | import anyio 9 | import pytest 10 | 11 | from claude_code_sdk import ( 12 | AssistantMessage, 13 | ClaudeCodeOptions, 14 | CLINotFoundError, 15 | ResultMessage, 16 | query, 17 | ) 18 | from claude_code_sdk.types import ToolUseBlock 19 | 20 | 21 | class TestIntegration: 22 | """End-to-end integration tests.""" 23 | 24 | def test_simple_query_response(self): 25 | """Test a simple query with text response.""" 26 | 27 | async def _test(): 28 | with patch( 29 | "claude_code_sdk._internal.client.SubprocessCLITransport" 30 | ) as mock_transport_class: 31 | mock_transport = AsyncMock() 32 | mock_transport_class.return_value = mock_transport 33 | 34 | # Mock the message stream 35 | async def mock_receive(): 36 | yield { 37 | "type": "assistant", 38 | "message": { 39 | "role": "assistant", 40 | "content": [{"type": "text", "text": "2 + 2 equals 4"}], 41 | }, 42 | } 43 | yield { 44 | "type": "result", 45 | "subtype": "success", 46 | "duration_ms": 1000, 47 | "duration_api_ms": 800, 48 | "is_error": False, 49 | "num_turns": 1, 50 | "session_id": "test-session", 51 | "total_cost_usd": 0.001, 52 | } 53 | 54 | mock_transport.receive_messages = mock_receive 55 | mock_transport.connect = AsyncMock() 56 | mock_transport.disconnect = AsyncMock() 57 | 58 | # Run query 59 | messages = [] 60 | async for msg in query(prompt="What is 2 + 2?"): 61 | messages.append(msg) 62 | 63 | # Verify results 64 | assert len(messages) == 2 65 | 66 | # Check assistant message 67 | assert isinstance(messages[0], AssistantMessage) 68 | assert len(messages[0].content) == 1 69 | assert messages[0].content[0].text == "2 + 2 equals 4" 70 | 71 | # Check result message 72 | assert isinstance(messages[1], ResultMessage) 73 | assert messages[1].total_cost_usd == 0.001 74 | assert messages[1].session_id == "test-session" 75 | 76 | anyio.run(_test) 77 | 78 | def test_query_with_tool_use(self): 79 | """Test query that uses tools.""" 80 | 81 | async def _test(): 82 | with patch( 83 | "claude_code_sdk._internal.client.SubprocessCLITransport" 84 | ) as mock_transport_class: 85 | mock_transport = AsyncMock() 86 | mock_transport_class.return_value = mock_transport 87 | 88 | # Mock the message stream with tool use 89 | async def mock_receive(): 90 | yield { 91 | "type": "assistant", 92 | "message": { 93 | "role": "assistant", 94 | "content": [ 95 | { 96 | "type": "text", 97 | "text": "Let me read that file for you.", 98 | }, 99 | { 100 | "type": "tool_use", 101 | "id": "tool-123", 102 | "name": "Read", 103 | "input": {"file_path": "/test.txt"}, 104 | }, 105 | ], 106 | }, 107 | } 108 | yield { 109 | "type": "result", 110 | "subtype": "success", 111 | "duration_ms": 1500, 112 | "duration_api_ms": 1200, 113 | "is_error": False, 114 | "num_turns": 1, 115 | "session_id": "test-session-2", 116 | "total_cost_usd": 0.002, 117 | } 118 | 119 | mock_transport.receive_messages = mock_receive 120 | mock_transport.connect = AsyncMock() 121 | mock_transport.disconnect = AsyncMock() 122 | 123 | # Run query with tools enabled 124 | messages = [] 125 | async for msg in query( 126 | prompt="Read /test.txt", 127 | options=ClaudeCodeOptions(allowed_tools=["Read"]), 128 | ): 129 | messages.append(msg) 130 | 131 | # Verify results 132 | assert len(messages) == 2 133 | 134 | # Check assistant message with tool use 135 | assert isinstance(messages[0], AssistantMessage) 136 | assert len(messages[0].content) == 2 137 | assert messages[0].content[0].text == "Let me read that file for you." 138 | assert isinstance(messages[0].content[1], ToolUseBlock) 139 | assert messages[0].content[1].name == "Read" 140 | assert messages[0].content[1].input["file_path"] == "/test.txt" 141 | 142 | anyio.run(_test) 143 | 144 | def test_cli_not_found(self): 145 | """Test handling when CLI is not found.""" 146 | 147 | async def _test(): 148 | with ( 149 | patch("shutil.which", return_value=None), 150 | patch("pathlib.Path.exists", return_value=False), 151 | pytest.raises(CLINotFoundError) as exc_info, 152 | ): 153 | async for _ in query(prompt="test"): 154 | pass 155 | 156 | assert "Claude Code requires Node.js" in str(exc_info.value) 157 | 158 | anyio.run(_test) 159 | 160 | def test_continuation_option(self): 161 | """Test query with continue_conversation option.""" 162 | 163 | async def _test(): 164 | with patch( 165 | "claude_code_sdk._internal.client.SubprocessCLITransport" 166 | ) as mock_transport_class: 167 | mock_transport = AsyncMock() 168 | mock_transport_class.return_value = mock_transport 169 | 170 | # Mock the message stream 171 | async def mock_receive(): 172 | yield { 173 | "type": "assistant", 174 | "message": { 175 | "role": "assistant", 176 | "content": [ 177 | { 178 | "type": "text", 179 | "text": "Continuing from previous conversation", 180 | } 181 | ], 182 | }, 183 | } 184 | 185 | mock_transport.receive_messages = mock_receive 186 | mock_transport.connect = AsyncMock() 187 | mock_transport.disconnect = AsyncMock() 188 | 189 | # Run query with continuation 190 | messages = [] 191 | async for msg in query( 192 | prompt="Continue", 193 | options=ClaudeCodeOptions(continue_conversation=True), 194 | ): 195 | messages.append(msg) 196 | 197 | # Verify transport was created with continuation option 198 | mock_transport_class.assert_called_once() 199 | call_kwargs = mock_transport_class.call_args.kwargs 200 | assert call_kwargs["options"].continue_conversation is True 201 | 202 | anyio.run(_test) 203 | -------------------------------------------------------------------------------- /.kiro/specs/claude-code-sdk-rust-migration/tasks.md: -------------------------------------------------------------------------------- 1 | # Implementation Plan 2 | 3 | - [ ] 1. Initialize Rust project structure and dependencies 4 | 5 | - Create new Cargo library project with proper metadata and dependencies 6 | - Configure Cargo.toml with tokio, serde, thiserror, and other required crates 7 | - Set up basic project structure with src/lib.rs and module declarations 8 | - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ 9 | 10 | - [ ] 2. Implement comprehensive error handling system 11 | 12 | - Define SdkError enum using thiserror with all error variants 13 | - Implement error conversion traits and helpful error messages 14 | - Add structured error data for debugging (original JSON, context) 15 | - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 11.4_ 16 | 17 | - [ ] 3. Implement core type system with serde support 18 | 19 | - [ ] 3.1 Create message type definitions 20 | 21 | - Define UserMessage, AssistantMessage, SystemMessage, and ResultMessage structs 22 | - Implement serde serialization/deserialization with proper field attributes 23 | - Create Message enum with tagged variants for type discrimination 24 | - _Requirements: 1.1, 1.2, 1.3_ 25 | 26 | - [ ] 3.2 Implement content block system 27 | 28 | - Define TextBlock, ToolUseBlock, and ToolResultBlock structs 29 | - Create ContentBlock enum with proper serde tagging 30 | - Implement MessageContent enum for string vs blocks handling 31 | - _Requirements: 1.1, 1.2_ 32 | 33 | - [ ] 3.3 Create configuration types with builder pattern 34 | - Define PermissionMode enum and McpServerConfig variants 35 | - Implement ClaudeCodeOptions struct with all configuration fields 36 | - Create ClaudeCodeOptionsBuilder with fluent method chaining 37 | - _Requirements: 1.3, 1.4, 11.3_ 38 | 39 | - [ ] 4. Create CLI discovery and process management 40 | 41 | - [ ] 4.1 Implement CLI discovery logic 42 | 43 | - Create find_cli function that searches standard installation paths 44 | - Provide helpful error messages for missing Node.js or claude-code CLI 45 | - Support custom CLI path specification 46 | - _Requirements: 9.1, 9.2, 9.3, 9.4_ 47 | 48 | - [ ] 4.2 Implement command building 49 | - Create build_command method that converts ClaudeCodeOptions to CLI arguments 50 | - Handle streaming vs string mode argument differences 51 | - Support all configuration options from the Python SDK 52 | - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_ 53 | 54 | - [ ] 5. Build transport layer with robust JSON handling 55 | 56 | - [ ] 5.1 Create SubprocessCliTransport structure 57 | 58 | - Define transport struct with process handles and stream management 59 | - Implement connect/disconnect lifecycle methods 60 | - Add proper resource cleanup via Drop trait 61 | - _Requirements: 3.1, 3.2, 10.1, 10.2, 10.3_ 62 | 63 | - [ ] 5.2 Implement JSON buffering and parsing with async streams 64 | 65 | - Create JsonBuffer for handling split and concatenated JSON messages 66 | - Implement receive_messages method returning async Stream with robust buffering logic 67 | - Add proper backpressure handling and stream cancellation support 68 | - Add buffer size limits and memory protection 69 | - _Requirements: 3.3, 6.1, 6.2, 6.5, 7.1, 7.2, 7.3, 7.4_ 70 | 71 | - [ ] 5.3 Add process management and error handling 72 | - Implement graceful process termination with timeout handling 73 | - Add concurrent stderr collection for error reporting 74 | - Handle process exit codes and error propagation 75 | - _Requirements: 3.4, 3.5, 10.5_ 76 | 77 | - [ ] 6. Create message parsing and validation 78 | 79 | - Implement parse_message function for converting JSON to typed Messages 80 | - Add parsing logic for each message type with proper error handling 81 | - Handle content block parsing and validation 82 | - _Requirements: 6.3, 6.4_ 83 | 84 | - [ ] 7. Build internal client for one-shot queries 85 | 86 | - [ ] 7.1 Implement InternalClient structure 87 | 88 | - Create InternalClient with process_query method 89 | - Implement async stream processing for message handling 90 | - Add automatic resource cleanup after query completion 91 | - _Requirements: 4.1, 4.2, 4.3_ 92 | 93 | - [ ] 7.2 Integrate transport and message parsing 94 | - Connect transport layer with message parser 95 | - Handle error propagation through the stream 96 | - Ensure proper cleanup on both success and failure 97 | - _Requirements: 4.4, 4.5_ 98 | 99 | - [ ] 8. Implement interactive ClaudeSDKClient 100 | 101 | - [ ] 8.1 Create client structure and connection management 102 | 103 | - Define ClaudeSDKClient struct with transport ownership 104 | - Implement connect method with optional prompt handling 105 | - Add proper state management for connection lifecycle 106 | - _Requirements: 5.1, 5.2, 10.4_ 107 | 108 | - [ ] 8.2 Add message sending and receiving capabilities with stream integration 109 | 110 | - Implement query method for sending messages in interactive mode 111 | - Create receive_messages method returning async stream with proper flow control 112 | - Add receive_response convenience method that stops at ResultMessage 113 | - Ensure stream cancellation and early termination work correctly 114 | - _Requirements: 5.3, 5.4, 7.3, 7.4, 7.5_ 115 | 116 | - [ ] 8.3 Implement control flow features 117 | - Add interrupt method for sending control signals 118 | - Handle control request/response correlation 119 | - Implement proper session management 120 | - _Requirements: 5.5_ 121 | 122 | - [ ] 9. Create public API with ergonomic interfaces 123 | 124 | - [ ] 9.1 Implement top-level query function 125 | 126 | - Create query function accepting AsRef for flexible string handling 127 | - Integrate with InternalClient for one-shot query processing 128 | - Return async stream of Message results 129 | - _Requirements: 4.1, 4.2, 11.1_ 130 | 131 | - [ ] 9.2 Set up module exports and documentation 132 | - Configure lib.rs with proper re-exports 133 | - Add comprehensive doc comments for all public APIs 134 | - Ensure consistent error handling across all public interfaces 135 | - _Requirements: 7.1, 7.2_ 136 | 137 | - [ ] 10. Create comprehensive test suite 138 | 139 | - [ ] 10.1 Write unit tests for type system 140 | 141 | - Test serde serialization/deserialization for all message types 142 | - Verify builder pattern functionality for ClaudeCodeOptions 143 | - Test error handling and error message formatting 144 | - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3_ 145 | 146 | - [ ] 10.2 Create integration tests with mock CLI 147 | 148 | - Build mock CLI script that simulates claude-code behavior 149 | - Test transport layer with various JSON buffering scenarios 150 | - Verify process lifecycle management and cleanup 151 | - _Requirements: 3.1, 3.2, 3.3, 6.1, 6.2_ 152 | 153 | - [ ] 10.3 Add stream handling and client tests 154 | - Test async stream behavior and cancellation 155 | - Verify interactive client functionality and state management 156 | - Test error propagation through stream processing 157 | - _Requirements: 5.1, 5.2, 5.3, 5.4, 7.1, 7.2_ 158 | 159 | - [ ] 11. Add examples and documentation 160 | 161 | - [ ] 11.1 Create runnable examples 162 | 163 | - Write quick_start.rs example demonstrating basic query usage 164 | - Create streaming_mode.rs example showing interactive client usage 165 | - Add error_handling.rs example demonstrating proper error management 166 | - _Requirements: 4.1, 5.1, 2.1_ 167 | 168 | - [ ] 11.2 Write comprehensive README and documentation 169 | - Create README.md with installation instructions and usage examples 170 | - Add doc comments with examples for all public APIs 171 | - Document error handling patterns and best practices 172 | - _Requirements: 9.1, 9.2, 9.3, 11.4_ 173 | 174 | - [ ] 12. Finalize packaging and release preparation 175 | 176 | - [ ] 12.1 Configure Cargo.toml for publication 177 | 178 | - Set proper metadata fields for crates.io publication 179 | - Configure package includes and excludes 180 | - Set appropriate version and license information 181 | - _Requirements: 1.1, 1.2_ 182 | 183 | - [ ] 12.2 Run final quality checks 184 | - Execute cargo fmt for consistent code formatting 185 | - Run cargo clippy and address all warnings 186 | - Verify all tests pass with cargo test 187 | - Generate and review documentation with cargo doc 188 | - _Requirements: 10.1, 10.2, 10.3_ 189 | -------------------------------------------------------------------------------- /examples/streaming_mode_ipython.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | IPython-friendly code snippets for ClaudeSDKClient streaming mode. 4 | 5 | These examples are designed to be copy-pasted directly into IPython. 6 | Each example is self-contained and can be run independently. 7 | 8 | The queries are intentionally simplistic. In reality, a query can be a more 9 | complex task that Claude SDK uses its agentic capabilities and tools (e.g. run 10 | bash commands, edit files, search the web, fetch web content) to accomplish. 11 | """ 12 | 13 | # ============================================================================ 14 | # BASIC STREAMING 15 | # ============================================================================ 16 | 17 | from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage 18 | 19 | async with ClaudeSDKClient() as client: 20 | print("User: What is 2+2?") 21 | await client.query("What is 2+2?") 22 | 23 | async for msg in client.receive_response(): 24 | if isinstance(msg, AssistantMessage): 25 | for block in msg.content: 26 | if isinstance(block, TextBlock): 27 | print(f"Claude: {block.text}") 28 | 29 | 30 | # ============================================================================ 31 | # STREAMING WITH REAL-TIME DISPLAY 32 | # ============================================================================ 33 | 34 | import asyncio 35 | from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock 36 | 37 | async with ClaudeSDKClient() as client: 38 | async def send_and_receive(prompt): 39 | print(f"User: {prompt}") 40 | await client.query(prompt) 41 | async for msg in client.receive_response(): 42 | if isinstance(msg, AssistantMessage): 43 | for block in msg.content: 44 | if isinstance(block, TextBlock): 45 | print(f"Claude: {block.text}") 46 | 47 | await send_and_receive("Tell me a short joke") 48 | print("\n---\n") 49 | await send_and_receive("Now tell me a fun fact") 50 | 51 | 52 | # ============================================================================ 53 | # PERSISTENT CLIENT FOR MULTIPLE QUESTIONS 54 | # ============================================================================ 55 | 56 | from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock 57 | 58 | # Create client 59 | client = ClaudeSDKClient() 60 | await client.connect() 61 | 62 | 63 | # Helper to get response 64 | async def get_response(): 65 | async for msg in client.receive_response(): 66 | if isinstance(msg, AssistantMessage): 67 | for block in msg.content: 68 | if isinstance(block, TextBlock): 69 | print(f"Claude: {block.text}") 70 | 71 | 72 | # Use it multiple times 73 | print("User: What's 2+2?") 74 | await client.query("What's 2+2?") 75 | await get_response() 76 | 77 | print("User: What's 10*10?") 78 | await client.query("What's 10*10?") 79 | await get_response() 80 | 81 | # Don't forget to disconnect when done 82 | await client.disconnect() 83 | 84 | 85 | # ============================================================================ 86 | # WITH INTERRUPT CAPABILITY 87 | # ============================================================================ 88 | # IMPORTANT: Interrupts require active message consumption. You must be 89 | # consuming messages from the client for the interrupt to be processed. 90 | 91 | import asyncio 92 | from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage 93 | 94 | async with ClaudeSDKClient() as client: 95 | print("\n--- Sending initial message ---\n") 96 | 97 | # Send a long-running task 98 | print("User: Count from 1 to 100, run bash sleep for 1 second in between") 99 | await client.query("Count from 1 to 100, run bash sleep for 1 second in between") 100 | 101 | # Create a background task to consume messages 102 | messages_received = [] 103 | interrupt_sent = False 104 | 105 | async def consume_messages(): 106 | async for msg in client.receive_messages(): 107 | messages_received.append(msg) 108 | if isinstance(msg, AssistantMessage): 109 | for block in msg.content: 110 | if isinstance(block, TextBlock): 111 | print(f"Claude: {block.text}") 112 | 113 | # Check if we got a result after interrupt 114 | if isinstance(msg, ResultMessage) and interrupt_sent: 115 | break 116 | 117 | # Start consuming messages in the background 118 | consume_task = asyncio.create_task(consume_messages()) 119 | 120 | # Wait a bit then send interrupt 121 | await asyncio.sleep(10) 122 | print("\n--- Sending interrupt ---\n") 123 | interrupt_sent = True 124 | await client.interrupt() 125 | 126 | # Wait for the consume task to finish 127 | await consume_task 128 | 129 | # Send a new message after interrupt 130 | print("\n--- After interrupt, sending new message ---\n") 131 | await client.query("Just say 'Hello! I was interrupted.'") 132 | 133 | async for msg in client.receive_response(): 134 | if isinstance(msg, AssistantMessage): 135 | for block in msg.content: 136 | if isinstance(block, TextBlock): 137 | print(f"Claude: {block.text}") 138 | 139 | 140 | # ============================================================================ 141 | # ERROR HANDLING PATTERN 142 | # ============================================================================ 143 | 144 | from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock 145 | 146 | try: 147 | async with ClaudeSDKClient() as client: 148 | print("User: Run a bash sleep command for 60 seconds") 149 | await client.query("Run a bash sleep command for 60 seconds") 150 | 151 | # Timeout after 20 seconds 152 | messages = [] 153 | async with asyncio.timeout(20.0): 154 | async for msg in client.receive_response(): 155 | messages.append(msg) 156 | if isinstance(msg, AssistantMessage): 157 | for block in msg.content: 158 | if isinstance(block, TextBlock): 159 | print(f"Claude: {block.text}") 160 | 161 | except asyncio.TimeoutError: 162 | print("Request timed out after 20 seconds") 163 | except Exception as e: 164 | print(f"Error: {e}") 165 | 166 | 167 | # ============================================================================ 168 | # SENDING ASYNC ITERABLE OF MESSAGES 169 | # ============================================================================ 170 | 171 | from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock 172 | 173 | async def message_generator(): 174 | """Generate multiple messages as an async iterable.""" 175 | print("User: I have two math questions.") 176 | yield { 177 | "type": "user", 178 | "message": {"role": "user", "content": "I have two math questions."}, 179 | "parent_tool_use_id": None, 180 | "session_id": "math-session" 181 | } 182 | print("User: What is 25 * 4?") 183 | yield { 184 | "type": "user", 185 | "message": {"role": "user", "content": "What is 25 * 4?"}, 186 | "parent_tool_use_id": None, 187 | "session_id": "math-session" 188 | } 189 | print("User: What is 100 / 5?") 190 | yield { 191 | "type": "user", 192 | "message": {"role": "user", "content": "What is 100 / 5?"}, 193 | "parent_tool_use_id": None, 194 | "session_id": "math-session" 195 | } 196 | 197 | async with ClaudeSDKClient() as client: 198 | # Send async iterable instead of string 199 | await client.query(message_generator()) 200 | 201 | async for msg in client.receive_response(): 202 | if isinstance(msg, AssistantMessage): 203 | for block in msg.content: 204 | if isinstance(block, TextBlock): 205 | print(f"Claude: {block.text}") 206 | 207 | 208 | # ============================================================================ 209 | # COLLECTING ALL MESSAGES INTO A LIST 210 | # ============================================================================ 211 | 212 | from claude_code_sdk import ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage 213 | 214 | async with ClaudeSDKClient() as client: 215 | print("User: What are the primary colors?") 216 | await client.query("What are the primary colors?") 217 | 218 | # Collect all messages into a list 219 | messages = [msg async for msg in client.receive_response()] 220 | 221 | # Process them afterwards 222 | for msg in messages: 223 | if isinstance(msg, AssistantMessage): 224 | for block in msg.content: 225 | if isinstance(block, TextBlock): 226 | print(f"Claude: {block.text}") 227 | elif isinstance(msg, ResultMessage): 228 | print(f"Total messages: {len(messages)}") 229 | -------------------------------------------------------------------------------- /src/claude_code_sdk/client.py: -------------------------------------------------------------------------------- 1 | """Claude SDK Client for interacting with Claude Code.""" 2 | 3 | import os 4 | from collections.abc import AsyncIterable, AsyncIterator 5 | from typing import Any 6 | 7 | from ._errors import CLIConnectionError 8 | from .types import ClaudeCodeOptions, Message, ResultMessage 9 | 10 | 11 | class ClaudeSDKClient: 12 | """ 13 | Client for bidirectional, interactive conversations with Claude Code. 14 | 15 | This client provides full control over the conversation flow with support 16 | for streaming, interrupts, and dynamic message sending. For simple one-shot 17 | queries, consider using the query() function instead. 18 | 19 | Key features: 20 | - **Bidirectional**: Send and receive messages at any time 21 | - **Stateful**: Maintains conversation context across messages 22 | - **Interactive**: Send follow-ups based on responses 23 | - **Control flow**: Support for interrupts and session management 24 | 25 | When to use ClaudeSDKClient: 26 | - Building chat interfaces or conversational UIs 27 | - Interactive debugging or exploration sessions 28 | - Multi-turn conversations with context 29 | - When you need to react to Claude's responses 30 | - Real-time applications with user input 31 | - When you need interrupt capabilities 32 | 33 | When to use query() instead: 34 | - Simple one-off questions 35 | - Batch processing of prompts 36 | - Fire-and-forget automation scripts 37 | - When all inputs are known upfront 38 | - Stateless operations 39 | 40 | Example - Interactive conversation: 41 | ```python 42 | # Automatically connects with empty stream for interactive use 43 | async with ClaudeSDKClient() as client: 44 | # Send initial message 45 | await client.query("Let's solve a math problem step by step") 46 | 47 | # Receive and process response 48 | async for message in client.receive_messages(): 49 | if "ready" in str(message.content).lower(): 50 | break 51 | 52 | # Send follow-up based on response 53 | await client.query("What's 15% of 80?") 54 | 55 | # Continue conversation... 56 | # Automatically disconnects 57 | ``` 58 | 59 | Example - With interrupt: 60 | ```python 61 | async with ClaudeSDKClient() as client: 62 | # Start a long task 63 | await client.query("Count to 1000") 64 | 65 | # Interrupt after 2 seconds 66 | await anyio.sleep(2) 67 | await client.interrupt() 68 | 69 | # Send new instruction 70 | await client.query("Never mind, what's 2+2?") 71 | ``` 72 | 73 | Example - Manual connection: 74 | ```python 75 | client = ClaudeSDKClient() 76 | 77 | # Connect with initial message stream 78 | async def message_stream(): 79 | yield {"type": "user", "message": {"role": "user", "content": "Hello"}} 80 | 81 | await client.connect(message_stream()) 82 | 83 | # Send additional messages dynamically 84 | await client.query("What's the weather?") 85 | 86 | async for message in client.receive_messages(): 87 | print(message) 88 | 89 | await client.disconnect() 90 | ``` 91 | """ 92 | 93 | def __init__(self, options: ClaudeCodeOptions | None = None): 94 | """Initialize Claude SDK client.""" 95 | if options is None: 96 | options = ClaudeCodeOptions() 97 | self.options = options 98 | self._transport: Any | None = None 99 | os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client" 100 | 101 | async def connect( 102 | self, prompt: str | AsyncIterable[dict[str, Any]] | None = None 103 | ) -> None: 104 | """Connect to Claude with a prompt or message stream.""" 105 | from ._internal.transport.subprocess_cli import SubprocessCLITransport 106 | 107 | # Auto-connect with empty async iterable if no prompt is provided 108 | async def _empty_stream() -> AsyncIterator[dict[str, Any]]: 109 | # Never yields, but indicates that this function is an iterator and 110 | # keeps the connection open. 111 | # This yield is never reached but makes this an async generator 112 | return 113 | yield {} # type: ignore[unreachable] 114 | 115 | self._transport = SubprocessCLITransport( 116 | prompt=_empty_stream() if prompt is None else prompt, 117 | options=self.options, 118 | ) 119 | await self._transport.connect() 120 | 121 | async def receive_messages(self) -> AsyncIterator[Message]: 122 | """Receive all messages from Claude.""" 123 | if not self._transport: 124 | raise CLIConnectionError("Not connected. Call connect() first.") 125 | 126 | from ._internal.message_parser import parse_message 127 | 128 | async for data in self._transport.receive_messages(): 129 | yield parse_message(data) 130 | 131 | async def query( 132 | self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default" 133 | ) -> None: 134 | """ 135 | Send a new request in streaming mode. 136 | 137 | Args: 138 | prompt: Either a string message or an async iterable of message dictionaries 139 | session_id: Session identifier for the conversation 140 | """ 141 | if not self._transport: 142 | raise CLIConnectionError("Not connected. Call connect() first.") 143 | 144 | # Handle string prompts 145 | if isinstance(prompt, str): 146 | message = { 147 | "type": "user", 148 | "message": {"role": "user", "content": prompt}, 149 | "parent_tool_use_id": None, 150 | "session_id": session_id, 151 | } 152 | await self._transport.send_request([message], {"session_id": session_id}) 153 | else: 154 | # Handle AsyncIterable prompts 155 | messages = [] 156 | async for msg in prompt: 157 | # Ensure session_id is set on each message 158 | if "session_id" not in msg: 159 | msg["session_id"] = session_id 160 | messages.append(msg) 161 | 162 | if messages: 163 | await self._transport.send_request(messages, {"session_id": session_id}) 164 | 165 | async def interrupt(self) -> None: 166 | """Send interrupt signal (only works with streaming mode).""" 167 | if not self._transport: 168 | raise CLIConnectionError("Not connected. Call connect() first.") 169 | await self._transport.interrupt() 170 | 171 | async def receive_response(self) -> AsyncIterator[Message]: 172 | """ 173 | Receive messages from Claude until and including a ResultMessage. 174 | 175 | This async iterator yields all messages in sequence and automatically terminates 176 | after yielding a ResultMessage (which indicates the response is complete). 177 | It's a convenience method over receive_messages() for single-response workflows. 178 | 179 | **Stopping Behavior:** 180 | - Yields each message as it's received 181 | - Terminates immediately after yielding a ResultMessage 182 | - The ResultMessage IS included in the yielded messages 183 | - If no ResultMessage is received, the iterator continues indefinitely 184 | 185 | Yields: 186 | Message: Each message received (UserMessage, AssistantMessage, SystemMessage, ResultMessage) 187 | 188 | Example: 189 | ```python 190 | async with ClaudeSDKClient() as client: 191 | await client.query("What's the capital of France?") 192 | 193 | async for msg in client.receive_response(): 194 | if isinstance(msg, AssistantMessage): 195 | for block in msg.content: 196 | if isinstance(block, TextBlock): 197 | print(f"Claude: {block.text}") 198 | elif isinstance(msg, ResultMessage): 199 | print(f"Cost: ${msg.total_cost_usd:.4f}") 200 | # Iterator will terminate after this message 201 | ``` 202 | 203 | Note: 204 | To collect all messages: `messages = [msg async for msg in client.receive_response()]` 205 | The final message in the list will always be a ResultMessage. 206 | """ 207 | async for message in self.receive_messages(): 208 | yield message 209 | if isinstance(message, ResultMessage): 210 | return 211 | 212 | async def disconnect(self) -> None: 213 | """Disconnect from Claude.""" 214 | if self._transport: 215 | await self._transport.disconnect() 216 | self._transport = None 217 | 218 | async def __aenter__(self) -> "ClaudeSDKClient": 219 | """Enter async context - automatically connects with empty stream for interactive use.""" 220 | await self.connect() 221 | return self 222 | 223 | async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: 224 | """Exit async context - always disconnects.""" 225 | await self.disconnect() 226 | return False 227 | -------------------------------------------------------------------------------- /.kiro/specs/claude-code-sdk-rust-migration/requirements.md: -------------------------------------------------------------------------------- 1 | # Requirements Document 2 | 3 | ## Introduction 4 | 5 | This document outlines the requirements for migrating the Claude Code SDK from Python to Rust. The migration aims to provide a high-performance, memory-safe, and idiomatic Rust implementation that maintains full API compatibility and feature parity with the existing Python SDK. The Rust SDK will enable Rust developers to interact with Claude Code through both one-shot queries and interactive bidirectional conversations, while leveraging Rust's type safety and performance characteristics. 6 | 7 | ## Requirements 8 | 9 | ### Requirement 1: Core Type System 10 | 11 | **User Story:** As a Rust developer, I want strongly-typed message and configuration structures, so that I can benefit from compile-time safety and clear API contracts. 12 | 13 | #### Acceptance Criteria 14 | 15 | 1. WHEN defining message types THEN the system SHALL provide `UserMessage`, `AssistantMessage`, `SystemMessage`, and `ResultMessage` structs with serde serialization support 16 | 2. WHEN defining content blocks THEN the system SHALL provide `TextBlock`, `ToolUseBlock`, and `ToolResultBlock` structs with a `ContentBlock` enum wrapper 17 | 3. WHEN configuring options THEN the system SHALL provide a `ClaudeCodeOptions` struct with builder pattern implementation 18 | 4. WHEN handling MCP server configurations THEN the system SHALL support stdio, SSE, and HTTP server types through typed enums 19 | 5. WHEN working with permission modes THEN the system SHALL provide a `PermissionMode` enum with variants for default, acceptEdits, and bypassPermissions 20 | 21 | ### Requirement 2: Error Handling System 22 | 23 | **User Story:** As a Rust developer, I want comprehensive error handling that follows Rust idioms, so that I can handle failures gracefully and understand what went wrong. 24 | 25 | #### Acceptance Criteria 26 | 27 | 1. WHEN errors occur THEN the system SHALL provide a `SdkError` enum using thiserror for all failure modes 28 | 2. WHEN CLI is not found THEN the system SHALL raise `CliNotFound` error with helpful installation instructions 29 | 3. WHEN CLI process fails THEN the system SHALL raise `Process` error with exit code and stderr information 30 | 4. WHEN JSON parsing fails THEN the system SHALL raise `JsonDecode` error with context about the failed data 31 | 5. WHEN message parsing fails THEN the system SHALL raise `MessageParse` error with the problematic data included 32 | 33 | ### Requirement 3: Transport Layer 34 | 35 | **User Story:** As a developer, I want reliable subprocess communication with the Claude CLI, so that I can send requests and receive responses without data corruption or loss. 36 | 37 | #### Acceptance Criteria 38 | 39 | 1. WHEN discovering CLI THEN the system SHALL search standard installation paths and provide clear error messages if not found 40 | 2. WHEN spawning subprocess THEN the system SHALL use tokio::process::Command with proper stdin/stdout/stderr configuration 41 | 3. WHEN receiving messages THEN the system SHALL implement robust JSON buffering to handle split and concatenated messages 42 | 4. WHEN process terminates THEN the system SHALL capture stderr output and include it in error messages for non-zero exit codes 43 | 5. WHEN disconnecting THEN the system SHALL properly terminate child processes and clean up resources 44 | 45 | ### Requirement 4: One-Shot Query API 46 | 47 | **User Story:** As a Rust developer, I want a simple query function for one-off interactions, so that I can quickly get responses from Claude without managing connection state. 48 | 49 | #### Acceptance Criteria 50 | 51 | 1. WHEN calling query function THEN the system SHALL accept a prompt string and optional ClaudeCodeOptions 52 | 2. WHEN processing query THEN the system SHALL return an async Stream of Message results 53 | 3. WHEN query completes THEN the system SHALL automatically clean up transport resources 54 | 4. WHEN query fails THEN the system SHALL propagate errors through the Result type system 55 | 5. WHEN using string prompts THEN the system SHALL handle the conversion to the CLI's expected message format 56 | 57 | ### Requirement 5: Interactive Client API 58 | 59 | **User Story:** As a Rust developer, I want a stateful client for bidirectional conversations, so that I can build interactive applications with follow-up messages and interrupts. 60 | 61 | #### Acceptance Criteria 62 | 63 | 1. WHEN creating client THEN the system SHALL provide ClaudeSDKClient struct with connection management 64 | 2. WHEN connecting THEN the system SHALL support both initial prompt and empty stream for interactive use 65 | 3. WHEN sending messages THEN the system SHALL provide query method that accepts strings or async iterables 66 | 4. WHEN receiving messages THEN the system SHALL provide receive_messages method returning async Stream 67 | 5. WHEN interrupting THEN the system SHALL provide interrupt method that sends control signals to CLI 68 | 6. WHEN using async context THEN the system SHALL implement proper async Drop semantics for resource cleanup 69 | 70 | ### Requirement 6: Message Streaming and Parsing 71 | 72 | **User Story:** As a developer, I want reliable message parsing from CLI output, so that I can receive structured data without worrying about JSON formatting issues. 73 | 74 | #### Acceptance Criteria 75 | 76 | 1. WHEN parsing messages THEN the system SHALL handle line-by-line JSON parsing with proper buffering 77 | 2. WHEN encountering partial JSON THEN the system SHALL accumulate data until complete objects are formed 78 | 3. WHEN parsing different message types THEN the system SHALL correctly map to appropriate Rust structs 79 | 4. WHEN handling control responses THEN the system SHALL manage request/response correlation for interrupts 80 | 5. WHEN buffer exceeds limits THEN the system SHALL raise appropriate errors to prevent memory exhaustion 81 | 82 | ### Requirement 7: Async Stream Integration 83 | 84 | **User Story:** As a Rust developer, I want native async Stream support, so that I can integrate with Rust's async ecosystem and use familiar patterns. 85 | 86 | #### Acceptance Criteria 87 | 88 | 1. WHEN returning message streams THEN the system SHALL use tokio-stream's Stream trait 89 | 2. WHEN processing async iterables THEN the system SHALL accept any type implementing AsyncIterable 90 | 3. WHEN handling backpressure THEN the system SHALL properly manage flow control in streaming scenarios 91 | 4. WHEN cancelling streams THEN the system SHALL handle early termination gracefully 92 | 5. WHEN chaining operations THEN the system SHALL support standard Stream combinators 93 | 94 | ### Requirement 8: Configuration and Options 95 | 96 | **User Story:** As a Rust developer, I want ergonomic configuration options, so that I can easily customize Claude's behavior for my specific use case. 97 | 98 | #### Acceptance Criteria 99 | 100 | 1. WHEN building options THEN the system SHALL provide builder pattern with method chaining 101 | 2. WHEN setting system prompts THEN the system SHALL support both system_prompt and append_system_prompt options 102 | 3. WHEN configuring tools THEN the system SHALL support allowed_tools and disallowed_tools lists 103 | 4. WHEN setting MCP servers THEN the system SHALL support typed server configurations with proper validation 104 | 5. WHEN specifying working directory THEN the system SHALL accept Path types and handle path conversion 105 | 106 | ### Requirement 9: CLI Integration and Discovery 107 | 108 | **User Story:** As a user, I want automatic CLI discovery and helpful error messages, so that I can quickly identify and resolve installation issues. 109 | 110 | #### Acceptance Criteria 111 | 112 | 1. WHEN CLI is missing THEN the system SHALL provide installation instructions for Node.js and claude-code 113 | 2. WHEN CLI is in non-standard location THEN the system SHALL search common installation paths 114 | 3. WHEN Node.js is missing THEN the system SHALL detect this condition and provide specific guidance 115 | 4. WHEN working directory is invalid THEN the system SHALL provide clear error messages 116 | 5. WHEN CLI version is incompatible THEN the system SHALL detect and report version mismatches 117 | 118 | ### Requirement 10: Resource Management 119 | 120 | **User Story:** As a Rust developer, I want reliable resource cleanup with both explicit and automatic options, so that I can ensure processes are properly terminated while having fallback protection. 121 | 122 | #### Acceptance Criteria 123 | 124 | 1. WHEN calling disconnect explicitly THEN the system SHALL guarantee child process termination and resource cleanup 125 | 2. WHEN client goes out of scope THEN the system SHALL provide best-effort automatic cleanup via Drop trait 126 | 3. WHEN connection fails THEN the system SHALL clean up any partially created resources 127 | 4. WHEN using RAII patterns THEN the system SHALL implement Drop trait with documented limitations for async cleanup 128 | 5. WHEN managing timeouts THEN the system SHALL prevent indefinite blocking on process operations 129 | 130 | ### Requirement 11: API Ergonomics 131 | 132 | **User Story:** As a Rust developer, I want ergonomic APIs that work with various string types and follow Rust conventions, so that I can integrate the SDK seamlessly into my applications. 133 | 134 | #### Acceptance Criteria 135 | 136 | 1. WHEN calling query function THEN the system SHALL accept any type implementing AsRef for the prompt parameter 137 | 2. WHEN creating empty streams THEN the system SHALL use tokio_stream::pending() for clean never-yielding streams 138 | 3. WHEN building configurations THEN the system SHALL provide fluent builder pattern with method chaining 139 | 4. WHEN handling errors THEN the system SHALL include original data and actionable guidance in error messages 140 | 5. WHEN working with paths THEN the system SHALL accept both String and PathBuf types for file system operations -------------------------------------------------------------------------------- /tests/test_message_parser.py: -------------------------------------------------------------------------------- 1 | """Tests for message parser error handling.""" 2 | 3 | import pytest 4 | 5 | from claude_code_sdk._errors import MessageParseError 6 | from claude_code_sdk._internal.message_parser import parse_message 7 | from claude_code_sdk.types import ( 8 | AssistantMessage, 9 | ResultMessage, 10 | SystemMessage, 11 | TextBlock, 12 | ToolResultBlock, 13 | ToolUseBlock, 14 | UserMessage, 15 | ) 16 | 17 | 18 | class TestMessageParser: 19 | """Test message parsing with the new exception behavior.""" 20 | 21 | def test_parse_valid_user_message(self): 22 | """Test parsing a valid user message.""" 23 | data = { 24 | "type": "user", 25 | "message": {"content": [{"type": "text", "text": "Hello"}]}, 26 | } 27 | message = parse_message(data) 28 | assert isinstance(message, UserMessage) 29 | assert len(message.content) == 1 30 | assert isinstance(message.content[0], TextBlock) 31 | assert message.content[0].text == "Hello" 32 | 33 | def test_parse_user_message_with_tool_use(self): 34 | """Test parsing a user message with tool_use block.""" 35 | data = { 36 | "type": "user", 37 | "message": { 38 | "content": [ 39 | {"type": "text", "text": "Let me read this file"}, 40 | { 41 | "type": "tool_use", 42 | "id": "tool_456", 43 | "name": "Read", 44 | "input": {"file_path": "/example.txt"}, 45 | }, 46 | ] 47 | }, 48 | } 49 | message = parse_message(data) 50 | assert isinstance(message, UserMessage) 51 | assert len(message.content) == 2 52 | assert isinstance(message.content[0], TextBlock) 53 | assert isinstance(message.content[1], ToolUseBlock) 54 | assert message.content[1].id == "tool_456" 55 | assert message.content[1].name == "Read" 56 | assert message.content[1].input == {"file_path": "/example.txt"} 57 | 58 | def test_parse_user_message_with_tool_result(self): 59 | """Test parsing a user message with tool_result block.""" 60 | data = { 61 | "type": "user", 62 | "message": { 63 | "content": [ 64 | { 65 | "type": "tool_result", 66 | "tool_use_id": "tool_789", 67 | "content": "File contents here", 68 | } 69 | ] 70 | }, 71 | } 72 | message = parse_message(data) 73 | assert isinstance(message, UserMessage) 74 | assert len(message.content) == 1 75 | assert isinstance(message.content[0], ToolResultBlock) 76 | assert message.content[0].tool_use_id == "tool_789" 77 | assert message.content[0].content == "File contents here" 78 | 79 | def test_parse_user_message_with_tool_result_error(self): 80 | """Test parsing a user message with error tool_result block.""" 81 | data = { 82 | "type": "user", 83 | "message": { 84 | "content": [ 85 | { 86 | "type": "tool_result", 87 | "tool_use_id": "tool_error", 88 | "content": "File not found", 89 | "is_error": True, 90 | } 91 | ] 92 | }, 93 | } 94 | message = parse_message(data) 95 | assert isinstance(message, UserMessage) 96 | assert len(message.content) == 1 97 | assert isinstance(message.content[0], ToolResultBlock) 98 | assert message.content[0].tool_use_id == "tool_error" 99 | assert message.content[0].content == "File not found" 100 | assert message.content[0].is_error is True 101 | 102 | def test_parse_user_message_with_mixed_content(self): 103 | """Test parsing a user message with mixed content blocks.""" 104 | data = { 105 | "type": "user", 106 | "message": { 107 | "content": [ 108 | {"type": "text", "text": "Here's what I found:"}, 109 | { 110 | "type": "tool_use", 111 | "id": "use_1", 112 | "name": "Search", 113 | "input": {"query": "test"}, 114 | }, 115 | { 116 | "type": "tool_result", 117 | "tool_use_id": "use_1", 118 | "content": "Search results", 119 | }, 120 | {"type": "text", "text": "What do you think?"}, 121 | ] 122 | }, 123 | } 124 | message = parse_message(data) 125 | assert isinstance(message, UserMessage) 126 | assert len(message.content) == 4 127 | assert isinstance(message.content[0], TextBlock) 128 | assert isinstance(message.content[1], ToolUseBlock) 129 | assert isinstance(message.content[2], ToolResultBlock) 130 | assert isinstance(message.content[3], TextBlock) 131 | 132 | def test_parse_valid_assistant_message(self): 133 | """Test parsing a valid assistant message.""" 134 | data = { 135 | "type": "assistant", 136 | "message": { 137 | "content": [ 138 | {"type": "text", "text": "Hello"}, 139 | { 140 | "type": "tool_use", 141 | "id": "tool_123", 142 | "name": "Read", 143 | "input": {"file_path": "/test.txt"}, 144 | }, 145 | ] 146 | }, 147 | } 148 | message = parse_message(data) 149 | assert isinstance(message, AssistantMessage) 150 | assert len(message.content) == 2 151 | assert isinstance(message.content[0], TextBlock) 152 | assert isinstance(message.content[1], ToolUseBlock) 153 | 154 | def test_parse_valid_system_message(self): 155 | """Test parsing a valid system message.""" 156 | data = {"type": "system", "subtype": "start"} 157 | message = parse_message(data) 158 | assert isinstance(message, SystemMessage) 159 | assert message.subtype == "start" 160 | 161 | def test_parse_valid_result_message(self): 162 | """Test parsing a valid result message.""" 163 | data = { 164 | "type": "result", 165 | "subtype": "success", 166 | "duration_ms": 1000, 167 | "duration_api_ms": 500, 168 | "is_error": False, 169 | "num_turns": 2, 170 | "session_id": "session_123", 171 | } 172 | message = parse_message(data) 173 | assert isinstance(message, ResultMessage) 174 | assert message.subtype == "success" 175 | 176 | def test_parse_invalid_data_type(self): 177 | """Test that non-dict data raises MessageParseError.""" 178 | with pytest.raises(MessageParseError) as exc_info: 179 | parse_message("not a dict") # type: ignore 180 | assert "Invalid message data type" in str(exc_info.value) 181 | assert "expected dict, got str" in str(exc_info.value) 182 | 183 | def test_parse_missing_type_field(self): 184 | """Test that missing 'type' field raises MessageParseError.""" 185 | with pytest.raises(MessageParseError) as exc_info: 186 | parse_message({"message": {"content": []}}) 187 | assert "Message missing 'type' field" in str(exc_info.value) 188 | 189 | def test_parse_unknown_message_type(self): 190 | """Test that unknown message type raises MessageParseError.""" 191 | with pytest.raises(MessageParseError) as exc_info: 192 | parse_message({"type": "unknown_type"}) 193 | assert "Unknown message type: unknown_type" in str(exc_info.value) 194 | 195 | def test_parse_user_message_missing_fields(self): 196 | """Test that user message with missing fields raises MessageParseError.""" 197 | with pytest.raises(MessageParseError) as exc_info: 198 | parse_message({"type": "user"}) 199 | assert "Missing required field in user message" in str(exc_info.value) 200 | 201 | def test_parse_assistant_message_missing_fields(self): 202 | """Test that assistant message with missing fields raises MessageParseError.""" 203 | with pytest.raises(MessageParseError) as exc_info: 204 | parse_message({"type": "assistant"}) 205 | assert "Missing required field in assistant message" in str(exc_info.value) 206 | 207 | def test_parse_system_message_missing_fields(self): 208 | """Test that system message with missing fields raises MessageParseError.""" 209 | with pytest.raises(MessageParseError) as exc_info: 210 | parse_message({"type": "system"}) 211 | assert "Missing required field in system message" in str(exc_info.value) 212 | 213 | def test_parse_result_message_missing_fields(self): 214 | """Test that result message with missing fields raises MessageParseError.""" 215 | with pytest.raises(MessageParseError) as exc_info: 216 | parse_message({"type": "result", "subtype": "success"}) 217 | assert "Missing required field in result message" in str(exc_info.value) 218 | 219 | def test_message_parse_error_contains_data(self): 220 | """Test that MessageParseError contains the original data.""" 221 | data = {"type": "unknown", "some": "data"} 222 | with pytest.raises(MessageParseError) as exc_info: 223 | parse_message(data) 224 | assert exc_info.value.data == data 225 | -------------------------------------------------------------------------------- /MIGRATION_PLAN_HIGHLEVEL.md: -------------------------------------------------------------------------------- 1 | # Claude Code SDK - Highlevel Migration Plan 2 | 3 | ## 🚀 Phase 0: Project Setup & Foundation 4 | 5 | This initial phase establishes the project structure, dependencies, and core data types, which are the building blocks for the entire SDK. 6 | 7 | ### 1\. Initialize Cargo Project 8 | 9 | Create a new Rust library project: 10 | 11 | ```bash 12 | cargo new claude-code-sdk-rs --lib 13 | ``` 14 | 15 | ### 2\. Configure `Cargo.toml` 16 | 17 | Add the necessary dependencies. The initial setup should include: 18 | 19 | ```toml 20 | [package] 21 | name = "claude-code-sdk" 22 | version = "0.1.0" 23 | edition = "2021" 24 | authors = ["Anthropic "] 25 | description = "Rust SDK for Claude Code" 26 | license = "MIT" 27 | repository = "https://github.com/your-repo/claude-code-sdk-rs" # Placeholder 28 | keywords = ["claude", "ai", "sdk", "anthropic"] 29 | categories = ["api-bindings", "development-tools"] 30 | 31 | [dependencies] 32 | # Async runtime and utilities 33 | tokio = { version = "1", features = ["full"] } 34 | tokio-stream = "0.1" 35 | futures = "0.3" 36 | 37 | # Serialization and Deserialization 38 | serde = { version = "1.0", features = ["derive"] } 39 | serde_json = "1.0" 40 | 41 | # Error Handling 42 | thiserror = "1.0" 43 | 44 | # Logging and Diagnostics 45 | tracing = "0.1" 46 | 47 | # Utility for finding the CLI executable 48 | which = "6.0" 49 | 50 | [dev-dependencies] 51 | # For running tests and examples 52 | anyhow = "1.0" 53 | 54 | # A testing framework similar to pytest 55 | rstest = "0.21" 56 | ``` 57 | 58 | ### 3\. Define Core Types (`src/types.rs`) 59 | 60 | Translate the Python dataclasses from `types.py` into Rust structs. Use `serde` to make them serializable and deserializable. 61 | 62 | * **Message Types:** `UserMessage`, `AssistantMessage`, `SystemMessage`, `ResultMessage`. 63 | * **Content Blocks:** `TextBlock`, `ToolUseBlock`, `ToolResultBlock`. Create an enum `ContentBlock` to represent these variants. 64 | * **Configuration:** Create a `ClaudeCodeOptions` struct. Implement the **builder pattern** for ergonomic construction, as it's more idiomatic in Rust than a struct with many public optional fields. 65 | 66 | **Example: `ResultMessage`** 67 | 68 | ```rust 69 | // src/types.rs 70 | use serde::{Deserialize, Serialize}; 71 | use std::collections::HashMap; 72 | 73 | #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] 74 | pub struct ResultMessage { 75 | pub subtype: String, 76 | pub duration_ms: i64, 77 | pub duration_api_ms: i64, 78 | pub is_error: bool, 79 | pub num_turns: i32, 80 | pub session_id: String, 81 | #[serde(skip_serializing_if = "Option::is_none")] 82 | pub total_cost_usd: Option, 83 | #[serde(skip_serializing_if = "Option::is_none")] 84 | pub usage: Option>, 85 | #[serde(skip_serializing_if = "Option::is_none")] 86 | pub result: Option, 87 | } 88 | ``` 89 | 90 | ### 4\. Define Custom Errors (`src/errors.rs`) 91 | 92 | Create a dedicated error enum using `thiserror` to represent all possible failure modes, mirroring `_errors.py`. 93 | 94 | **Example: `SdkError`** 95 | 96 | ```rust 97 | // src/errors.rs 98 | use thiserror::Error; 99 | 100 | #[derive(Error, Debug)] 101 | pub enum SdkError { 102 | #[error("Claude Code CLI not found. Please ensure it is installed and in your PATH.")] 103 | CliNotFound(#[from] which::Error), 104 | 105 | #[error("Failed to connect to or start the Claude Code CLI process.")] 106 | CliConnection(#[from] std::io::Error), 107 | 108 | #[error("The CLI process failed with exit code {exit_code:?}: {stderr}")] 109 | Process { 110 | exit_code: Option, 111 | stderr: String, 112 | }, 113 | 114 | #[error("Failed to decode JSON from CLI output: {0}")] 115 | JsonDecode(#[from] serde_json::Error), 116 | 117 | #[error("Failed to parse message from data: {message}")] 118 | MessageParse { 119 | message: String, 120 | data: serde_json::Value, 121 | }, 122 | } 123 | ``` 124 | 125 | ----- 126 | 127 | ## ⚙️ Phase 1: Core Transport Layer 128 | 129 | This phase focuses on the low-level interaction with the `claude-code` Node.js subprocess. The goal is to create a reliable mechanism for spawning, managing, and communicating with the CLI. 130 | 131 | ### 1\. Create Transport Module (`src/transport.rs`) 132 | 133 | This module will house the `SubprocessCliTransport` struct. 134 | 135 | ### 2\. Implement CLI Discovery 136 | 137 | In the `SubprocessCliTransport::new()`, replicate the logic from `_find_cli` using the `which` crate to locate the `claude` executable. 138 | 139 | ### 3\. Implement Process Management 140 | 141 | * **Spawning:** Use `tokio::process::Command` to spawn the `claude` CLI. 142 | * **Command Building:** Create a private `build_command` method that constructs the command arguments from the `ClaudeCodeOptions` builder. 143 | * **I/O Handling:** Configure the command's `stdin`, `stdout`, and `stderr` to be `piped`. Store the resulting `ChildStdin`, `ChildStdout`, and `ChildStderr` handles. 144 | 145 | ### 4\. Implement `connect` and `disconnect` 146 | 147 | * `async fn connect(&mut self)` will execute the `Command` and set up the I/O stream readers/writers. 148 | * `async fn disconnect(&mut self)` will terminate the child process. Implement the `Drop` trait on the transport to ensure the child process is cleaned up automatically if the transport goes out of scope. 149 | 150 | ----- 151 | 152 | ## 📨 Phase 2: Message Handling & Parsing 153 | 154 | With the transport layer in place, this phase handles the stream of data coming from the CLI. 155 | 156 | ### 1\. Implement Message Streaming 157 | 158 | The `SubprocessCliTransport` will expose an `async fn receive_messages(&mut self)` method. 159 | 160 | * **Return Type:** This function will return a `impl Stream>`. The `tokio-stream` crate will be essential here. 161 | * **Parsing Logic:** 162 | 1. Read from the `ChildStdout` stream line by line. 163 | 2. Implement robust buffering to handle JSON objects that are split across multiple reads or concatenated into a single read. This is a critical feature from the Python SDK (`test_subprocess_buffering.py`). 164 | 3. For each complete JSON string, parse it using `serde_json::from_str`. 165 | 4. Map the raw `serde_json::Value` to the strongly-typed Rust `Message` structs defined in Phase 0. 166 | * **Error Handling:** Any I/O or parsing errors should be propagated as variants of the `SdkError` enum. 167 | 168 | ### 2\. Implement Stderr Handling 169 | 170 | Read the `stderr` stream concurrently to capture any error messages from the CLI. If the process exits with a non-zero code, include the captured `stderr` content in the `SdkError::Process` error. 171 | 172 | ----- 173 | 174 | ## 🧑‍💻 Phase 3: High-Level Client & API 175 | 176 | This phase builds the public-facing API that developers will use. The goal is to create an ergonomic and idiomatic Rust interface. 177 | 178 | ### 1\. One-Shot `query` Function (`src/lib.rs`) 179 | 180 | Create a top-level async function similar to the Python version for simple, stateless use cases. 181 | 182 | ```rust 183 | // src/lib.rs 184 | use crate::types::{ClaudeCodeOptions, Message}; 185 | use crate::errors::SdkError; 186 | use tokio_stream::Stream; 187 | 188 | pub async fn query( 189 | prompt: String, 190 | options: ClaudeCodeOptions, 191 | ) -> impl Stream> { 192 | // 1. Create and configure transport 193 | // 2. Connect 194 | // 3. Return the message stream 195 | // The transport will be dropped and cleaned up when the stream is fully consumed. 196 | // ... implementation ... 197 | } 198 | ``` 199 | 200 | ### 2\. Interactive `ClaudeSdkClient` (`src/client.rs`) 201 | 202 | Create the `ClaudeSdkClient` struct for stateful, bidirectional conversations. 203 | 204 | * **State:** The client will own the `SubprocessCliTransport`. 205 | * **Methods:** 206 | * `new(options: ClaudeCodeOptions) -> Self` 207 | * `connect(&mut self, prompt: Option>)` 208 | * `disconnect(&mut self)` 209 | * `query(&mut self, prompt: String)`: Sends a message to the running process's `stdin`. 210 | * `interrupt(&mut self)`: Sends a special control message to `stdin`. 211 | * `receive_messages(&mut self) -> impl Stream>`: Streams all incoming messages. 212 | * `receive_response(&mut self) -> impl Stream>`: A convenience wrapper that streams messages until a `ResultMessage` is received. 213 | 214 | ----- 215 | 216 | ## ✅ Phase 4: Testing 217 | 218 | A robust test suite is crucial for a reliable SDK. The goal is to port the logic from the Python tests to ensure all features and edge cases are covered. 219 | 220 | ### 1\. Unit Tests 221 | 222 | Place unit tests within their respective modules (e.g., `#[cfg(test)] mod tests { ... }` at the bottom of `types.rs`, `transport.rs`, etc.). 223 | 224 | * Test the `ClaudeCodeOptions` builder. 225 | * Test the serialization and deserialization of all `Message` types. 226 | 227 | ### 2\. Integration Tests (`tests/`) 228 | 229 | Create a `tests` directory next to `src/`. 230 | 231 | * **Mock CLI:** Create a simple script (Python or shell script) that mimics the `claude-code` CLI's behavior. This script can be configured to produce specific stdout/stderr output, including split JSON and error conditions. 232 | * **Test Cases:** 233 | * `test_client_lifecycle.rs`: Verify `connect`, `disconnect`, and `query` behavior. 234 | * `test_streaming.rs`: Test `receive_messages` and `receive_response`, especially the buffering logic for split/merged JSON. 235 | * `test_errors.rs`: Test all `SdkError` variants by having the mock CLI produce corresponding error conditions. 236 | * `test_query_function.rs`: Test the top-level `query` function. 237 | 238 | ----- 239 | 240 | ## 📚 Phase 5: Documentation & Examples 241 | 242 | Clear documentation and examples are essential for adoption. 243 | 244 | ### 1\. Code Documentation 245 | 246 | Add comprehensive doc comments (`///`) to all public structs, enums, functions, and methods. Explain what each item does, its parameters, and what it returns. Use `#[doc(hidden)]` for internal-only components. 247 | 248 | ### 2\. `README.md` 249 | 250 | Adapt the Python `README.md` for a Rust audience. Include: 251 | 252 | * Installation instructions (`cargo add claude-code-sdk`). 253 | * Prerequisites (Node.js and `claude-code` CLI). 254 | * A "Quick Start" example. 255 | * Detailed usage examples for both the `query` function and the `ClaudeSdkClient`. 256 | * A section on error handling using `Result` and `match`. 257 | 258 | ### 3\. Examples (`examples/`) 259 | 260 | Create an `examples` directory and add runnable examples that mirror the Python SDK's examples, such as `quick_start.rs` and `streaming_mode.rs`. Use `tokio`'s `main` macro. 261 | 262 | **Example: `quick_start.rs`** 263 | 264 | ```rust 265 | // examples/quick_start.rs 266 | use claude_code_sdk::{query, ClaudeCodeOptions}; 267 | use tokio_stream::StreamExt; 268 | 269 | #[tokio::main] 270 | async fn main() -> anyhow::Result<()> { 271 | let options = ClaudeCodeOptions::builder().build(); // Using the builder 272 | let mut stream = query("What is 2 + 2?".to_string(), options).await; 273 | 274 | while let Some(message_result) = stream.next().await { 275 | match message_result { 276 | Ok(message) => println!("{:?}", message), 277 | Err(e) => eprintln!("Error: {}", e), 278 | } 279 | } 280 | 281 | Ok(()) 282 | } 283 | ``` 284 | 285 | ----- 286 | 287 | \#\#📦 Phase 6: Packaging & Release 288 | 289 | The final step is to prepare the crate for publishing. 290 | 291 | ### 1\. Finalize `Cargo.toml` 292 | 293 | Ensure all metadata fields are correct and complete. Set a `0.1.0` version for the initial release. 294 | 295 | ### 2\. Run Final Checks 296 | 297 | * `cargo fmt` to ensure consistent formatting. 298 | * `cargo clippy` to catch common mistakes and improve the code. 299 | * `cargo test` to run all tests. 300 | * `cargo doc --open` to review the generated documentation. 301 | 302 | ### 3\. Publish to Crates.io 303 | 304 | ```bash 305 | # Perform a dry run first to check for any packaging issues 306 | cargo publish --dry-run 307 | 308 | # If the dry run is successful, publish the crate 309 | cargo publish 310 | ``` -------------------------------------------------------------------------------- /tests/test_subprocess_buffering.py: -------------------------------------------------------------------------------- 1 | """Tests for subprocess transport buffering edge cases.""" 2 | 3 | import json 4 | from collections.abc import AsyncIterator 5 | from typing import Any 6 | from unittest.mock import AsyncMock, MagicMock 7 | 8 | import anyio 9 | import pytest 10 | 11 | from claude_code_sdk._errors import CLIJSONDecodeError 12 | from claude_code_sdk._internal.transport.subprocess_cli import ( 13 | _MAX_BUFFER_SIZE, 14 | SubprocessCLITransport, 15 | ) 16 | from claude_code_sdk.types import ClaudeCodeOptions 17 | 18 | 19 | class MockTextReceiveStream: 20 | """Mock TextReceiveStream for testing.""" 21 | 22 | def __init__(self, lines: list[str]) -> None: 23 | self.lines = lines 24 | self.index = 0 25 | 26 | def __aiter__(self) -> AsyncIterator[str]: 27 | return self 28 | 29 | async def __anext__(self) -> str: 30 | if self.index >= len(self.lines): 31 | raise StopAsyncIteration 32 | line = self.lines[self.index] 33 | self.index += 1 34 | return line 35 | 36 | 37 | class TestSubprocessBuffering: 38 | """Test subprocess transport handling of buffered output.""" 39 | 40 | def test_multiple_json_objects_on_single_line(self) -> None: 41 | """Test parsing when multiple JSON objects are concatenated on a single line. 42 | 43 | In some environments, stdout buffering can cause multiple distinct JSON 44 | objects to be delivered as a single line with embedded newlines. 45 | """ 46 | 47 | async def _test() -> None: 48 | json_obj1 = {"type": "message", "id": "msg1", "content": "First message"} 49 | json_obj2 = {"type": "result", "id": "res1", "status": "completed"} 50 | 51 | buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) 52 | 53 | transport = SubprocessCLITransport( 54 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 55 | ) 56 | 57 | mock_process = MagicMock() 58 | mock_process.returncode = None 59 | mock_process.wait = AsyncMock(return_value=None) 60 | transport._process = mock_process 61 | 62 | transport._stdout_stream = MockTextReceiveStream([buffered_line]) # type: ignore[assignment] 63 | transport._stderr_stream = MockTextReceiveStream([]) # type: ignore[assignment] 64 | 65 | messages: list[Any] = [] 66 | async for msg in transport.receive_messages(): 67 | messages.append(msg) 68 | 69 | assert len(messages) == 2 70 | assert messages[0]["type"] == "message" 71 | assert messages[0]["id"] == "msg1" 72 | assert messages[0]["content"] == "First message" 73 | assert messages[1]["type"] == "result" 74 | assert messages[1]["id"] == "res1" 75 | assert messages[1]["status"] == "completed" 76 | 77 | anyio.run(_test) 78 | 79 | def test_json_with_embedded_newlines(self) -> None: 80 | """Test parsing JSON objects that contain newline characters in string values.""" 81 | 82 | async def _test() -> None: 83 | json_obj1 = {"type": "message", "content": "Line 1\nLine 2\nLine 3"} 84 | json_obj2 = {"type": "result", "data": "Some\nMultiline\nContent"} 85 | 86 | buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) 87 | 88 | transport = SubprocessCLITransport( 89 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 90 | ) 91 | 92 | mock_process = MagicMock() 93 | mock_process.returncode = None 94 | mock_process.wait = AsyncMock(return_value=None) 95 | transport._process = mock_process 96 | transport._stdout_stream = MockTextReceiveStream([buffered_line]) 97 | transport._stderr_stream = MockTextReceiveStream([]) 98 | 99 | messages: list[Any] = [] 100 | async for msg in transport.receive_messages(): 101 | messages.append(msg) 102 | 103 | assert len(messages) == 2 104 | assert messages[0]["content"] == "Line 1\nLine 2\nLine 3" 105 | assert messages[1]["data"] == "Some\nMultiline\nContent" 106 | 107 | anyio.run(_test) 108 | 109 | def test_multiple_newlines_between_objects(self) -> None: 110 | """Test parsing with multiple newlines between JSON objects.""" 111 | 112 | async def _test() -> None: 113 | json_obj1 = {"type": "message", "id": "msg1"} 114 | json_obj2 = {"type": "result", "id": "res1"} 115 | 116 | buffered_line = json.dumps(json_obj1) + "\n\n\n" + json.dumps(json_obj2) 117 | 118 | transport = SubprocessCLITransport( 119 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 120 | ) 121 | 122 | mock_process = MagicMock() 123 | mock_process.returncode = None 124 | mock_process.wait = AsyncMock(return_value=None) 125 | transport._process = mock_process 126 | transport._stdout_stream = MockTextReceiveStream([buffered_line]) 127 | transport._stderr_stream = MockTextReceiveStream([]) 128 | 129 | messages: list[Any] = [] 130 | async for msg in transport.receive_messages(): 131 | messages.append(msg) 132 | 133 | assert len(messages) == 2 134 | assert messages[0]["id"] == "msg1" 135 | assert messages[1]["id"] == "res1" 136 | 137 | anyio.run(_test) 138 | 139 | def test_split_json_across_multiple_reads(self) -> None: 140 | """Test parsing when a single JSON object is split across multiple stream reads.""" 141 | 142 | async def _test() -> None: 143 | json_obj = { 144 | "type": "assistant", 145 | "message": { 146 | "content": [ 147 | {"type": "text", "text": "x" * 1000}, 148 | { 149 | "type": "tool_use", 150 | "id": "tool_123", 151 | "name": "Read", 152 | "input": {"file_path": "/test.txt"}, 153 | }, 154 | ] 155 | }, 156 | } 157 | 158 | complete_json = json.dumps(json_obj) 159 | 160 | part1 = complete_json[:100] 161 | part2 = complete_json[100:250] 162 | part3 = complete_json[250:] 163 | 164 | transport = SubprocessCLITransport( 165 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 166 | ) 167 | 168 | mock_process = MagicMock() 169 | mock_process.returncode = None 170 | mock_process.wait = AsyncMock(return_value=None) 171 | transport._process = mock_process 172 | transport._stdout_stream = MockTextReceiveStream([part1, part2, part3]) 173 | transport._stderr_stream = MockTextReceiveStream([]) 174 | 175 | messages: list[Any] = [] 176 | async for msg in transport.receive_messages(): 177 | messages.append(msg) 178 | 179 | assert len(messages) == 1 180 | assert messages[0]["type"] == "assistant" 181 | assert len(messages[0]["message"]["content"]) == 2 182 | 183 | anyio.run(_test) 184 | 185 | def test_large_minified_json(self) -> None: 186 | """Test parsing a large minified JSON (simulating the reported issue).""" 187 | 188 | async def _test() -> None: 189 | large_data = {"data": [{"id": i, "value": "x" * 100} for i in range(1000)]} 190 | json_obj = { 191 | "type": "user", 192 | "message": { 193 | "role": "user", 194 | "content": [ 195 | { 196 | "tool_use_id": "toolu_016fed1NhiaMLqnEvrj5NUaj", 197 | "type": "tool_result", 198 | "content": json.dumps(large_data), 199 | } 200 | ], 201 | }, 202 | } 203 | 204 | complete_json = json.dumps(json_obj) 205 | 206 | chunk_size = 64 * 1024 207 | chunks = [ 208 | complete_json[i : i + chunk_size] 209 | for i in range(0, len(complete_json), chunk_size) 210 | ] 211 | 212 | transport = SubprocessCLITransport( 213 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 214 | ) 215 | 216 | mock_process = MagicMock() 217 | mock_process.returncode = None 218 | mock_process.wait = AsyncMock(return_value=None) 219 | transport._process = mock_process 220 | transport._stdout_stream = MockTextReceiveStream(chunks) 221 | transport._stderr_stream = MockTextReceiveStream([]) 222 | 223 | messages: list[Any] = [] 224 | async for msg in transport.receive_messages(): 225 | messages.append(msg) 226 | 227 | assert len(messages) == 1 228 | assert messages[0]["type"] == "user" 229 | assert ( 230 | messages[0]["message"]["content"][0]["tool_use_id"] 231 | == "toolu_016fed1NhiaMLqnEvrj5NUaj" 232 | ) 233 | 234 | anyio.run(_test) 235 | 236 | def test_buffer_size_exceeded(self) -> None: 237 | """Test that exceeding buffer size raises an appropriate error.""" 238 | 239 | async def _test() -> None: 240 | huge_incomplete = '{"data": "' + "x" * (_MAX_BUFFER_SIZE + 1000) 241 | 242 | transport = SubprocessCLITransport( 243 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 244 | ) 245 | 246 | mock_process = MagicMock() 247 | mock_process.returncode = None 248 | mock_process.wait = AsyncMock(return_value=None) 249 | transport._process = mock_process 250 | transport._stdout_stream = MockTextReceiveStream([huge_incomplete]) 251 | transport._stderr_stream = MockTextReceiveStream([]) 252 | 253 | with pytest.raises(Exception) as exc_info: 254 | messages: list[Any] = [] 255 | async for msg in transport.receive_messages(): 256 | messages.append(msg) 257 | 258 | assert isinstance(exc_info.value, CLIJSONDecodeError) 259 | assert "exceeded maximum buffer size" in str(exc_info.value) 260 | 261 | anyio.run(_test) 262 | 263 | def test_mixed_complete_and_split_json(self) -> None: 264 | """Test handling a mix of complete and split JSON messages.""" 265 | 266 | async def _test() -> None: 267 | msg1 = json.dumps({"type": "system", "subtype": "start"}) 268 | 269 | large_msg = { 270 | "type": "assistant", 271 | "message": {"content": [{"type": "text", "text": "y" * 5000}]}, 272 | } 273 | large_json = json.dumps(large_msg) 274 | 275 | msg3 = json.dumps({"type": "system", "subtype": "end"}) 276 | 277 | lines = [ 278 | msg1 + "\n", 279 | large_json[:1000], 280 | large_json[1000:3000], 281 | large_json[3000:] + "\n" + msg3, 282 | ] 283 | 284 | transport = SubprocessCLITransport( 285 | prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" 286 | ) 287 | 288 | mock_process = MagicMock() 289 | mock_process.returncode = None 290 | mock_process.wait = AsyncMock(return_value=None) 291 | transport._process = mock_process 292 | transport._stdout_stream = MockTextReceiveStream(lines) 293 | transport._stderr_stream = MockTextReceiveStream([]) 294 | 295 | messages: list[Any] = [] 296 | async for msg in transport.receive_messages(): 297 | messages.append(msg) 298 | 299 | assert len(messages) == 3 300 | assert messages[0]["type"] == "system" 301 | assert messages[0]["subtype"] == "start" 302 | assert messages[1]["type"] == "assistant" 303 | assert len(messages[1]["message"]["content"][0]["text"]) == 5000 304 | assert messages[2]["type"] == "system" 305 | assert messages[2]["subtype"] == "end" 306 | 307 | anyio.run(_test) 308 | -------------------------------------------------------------------------------- /examples/streaming_mode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Comprehensive examples of using ClaudeSDKClient for streaming mode. 4 | 5 | This file demonstrates various patterns for building applications with 6 | the ClaudeSDKClient streaming interface. 7 | 8 | The queries are intentionally simplistic. In reality, a query can be a more 9 | complex task that Claude SDK uses its agentic capabilities and tools (e.g. run 10 | bash commands, edit files, search the web, fetch web content) to accomplish. 11 | 12 | Usage: 13 | ./examples/streaming_mode.py - List the examples 14 | ./examples/streaming_mode.py all - Run all examples 15 | ./examples/streaming_mode.py basic_streaming - Run a specific example 16 | """ 17 | 18 | import asyncio 19 | import contextlib 20 | import sys 21 | 22 | from claude_code_sdk import ( 23 | AssistantMessage, 24 | ClaudeCodeOptions, 25 | ClaudeSDKClient, 26 | CLIConnectionError, 27 | ResultMessage, 28 | SystemMessage, 29 | TextBlock, 30 | ToolResultBlock, 31 | ToolUseBlock, 32 | UserMessage, 33 | ) 34 | 35 | 36 | def display_message(msg): 37 | """Standardized message display function. 38 | 39 | - UserMessage: "User: " 40 | - AssistantMessage: "Claude: " 41 | - SystemMessage: ignored 42 | - ResultMessage: "Result ended" + cost if available 43 | """ 44 | if isinstance(msg, UserMessage): 45 | for block in msg.content: 46 | if isinstance(block, TextBlock): 47 | print(f"User: {block.text}") 48 | elif isinstance(msg, AssistantMessage): 49 | for block in msg.content: 50 | if isinstance(block, TextBlock): 51 | print(f"Claude: {block.text}") 52 | elif isinstance(msg, SystemMessage): 53 | # Ignore system messages 54 | pass 55 | elif isinstance(msg, ResultMessage): 56 | print("Result ended") 57 | 58 | 59 | async def example_basic_streaming(): 60 | """Basic streaming with context manager.""" 61 | print("=== Basic Streaming Example ===") 62 | 63 | async with ClaudeSDKClient() as client: 64 | print("User: What is 2+2?") 65 | await client.query("What is 2+2?") 66 | 67 | # Receive complete response using the helper method 68 | async for msg in client.receive_response(): 69 | display_message(msg) 70 | 71 | print("\n") 72 | 73 | 74 | async def example_multi_turn_conversation(): 75 | """Multi-turn conversation using receive_response helper.""" 76 | print("=== Multi-Turn Conversation Example ===") 77 | 78 | async with ClaudeSDKClient() as client: 79 | # First turn 80 | print("User: What's the capital of France?") 81 | await client.query("What's the capital of France?") 82 | 83 | # Extract and print response 84 | async for msg in client.receive_response(): 85 | display_message(msg) 86 | 87 | # Second turn - follow-up 88 | print("\nUser: What's the population of that city?") 89 | await client.query("What's the population of that city?") 90 | 91 | async for msg in client.receive_response(): 92 | display_message(msg) 93 | 94 | print("\n") 95 | 96 | 97 | async def example_concurrent_responses(): 98 | """Handle responses while sending new messages.""" 99 | print("=== Concurrent Send/Receive Example ===") 100 | 101 | async with ClaudeSDKClient() as client: 102 | # Background task to continuously receive messages 103 | async def receive_messages(): 104 | async for message in client.receive_messages(): 105 | display_message(message) 106 | 107 | # Start receiving in background 108 | receive_task = asyncio.create_task(receive_messages()) 109 | 110 | # Send multiple messages with delays 111 | questions = [ 112 | "What is 2 + 2?", 113 | "What is the square root of 144?", 114 | "What is 10% of 80?", 115 | ] 116 | 117 | for question in questions: 118 | print(f"\nUser: {question}") 119 | await client.query(question) 120 | await asyncio.sleep(3) # Wait between messages 121 | 122 | # Give time for final responses 123 | await asyncio.sleep(2) 124 | 125 | # Clean up 126 | receive_task.cancel() 127 | with contextlib.suppress(asyncio.CancelledError): 128 | await receive_task 129 | 130 | print("\n") 131 | 132 | 133 | async def example_with_interrupt(): 134 | """Demonstrate interrupt capability.""" 135 | print("=== Interrupt Example ===") 136 | print("IMPORTANT: Interrupts require active message consumption.") 137 | 138 | async with ClaudeSDKClient() as client: 139 | # Start a long-running task 140 | print("\nUser: Count from 1 to 100 slowly") 141 | await client.query( 142 | "Count from 1 to 100 slowly, with a brief pause between each number" 143 | ) 144 | 145 | # Create a background task to consume messages 146 | messages_received = [] 147 | interrupt_sent = False 148 | 149 | async def consume_messages(): 150 | """Consume messages in the background to enable interrupt processing.""" 151 | async for message in client.receive_messages(): 152 | messages_received.append(message) 153 | if isinstance(message, AssistantMessage): 154 | for block in message.content: 155 | if isinstance(block, TextBlock): 156 | # Print first few numbers 157 | print(f"Claude: {block.text[:50]}...") 158 | elif isinstance(message, ResultMessage): 159 | display_message(message) 160 | if interrupt_sent: 161 | break 162 | 163 | # Start consuming messages in the background 164 | consume_task = asyncio.create_task(consume_messages()) 165 | 166 | # Wait 2 seconds then send interrupt 167 | await asyncio.sleep(2) 168 | print("\n[After 2 seconds, sending interrupt...]") 169 | interrupt_sent = True 170 | await client.interrupt() 171 | 172 | # Wait for the consume task to finish processing the interrupt 173 | await consume_task 174 | 175 | # Send new instruction after interrupt 176 | print("\nUser: Never mind, just tell me a quick joke") 177 | await client.query("Never mind, just tell me a quick joke") 178 | 179 | # Get the joke 180 | async for msg in client.receive_response(): 181 | display_message(msg) 182 | 183 | print("\n") 184 | 185 | 186 | async def example_manual_message_handling(): 187 | """Manually handle message stream for custom logic.""" 188 | print("=== Manual Message Handling Example ===") 189 | 190 | async with ClaudeSDKClient() as client: 191 | await client.query( 192 | "List 5 programming languages and their main use cases" 193 | ) 194 | 195 | # Manually process messages with custom logic 196 | languages_found = [] 197 | 198 | async for message in client.receive_messages(): 199 | if isinstance(message, AssistantMessage): 200 | for block in message.content: 201 | if isinstance(block, TextBlock): 202 | text = block.text 203 | print(f"Claude: {text}") 204 | # Custom logic: extract language names 205 | for lang in [ 206 | "Python", 207 | "JavaScript", 208 | "Java", 209 | "C++", 210 | "Go", 211 | "Rust", 212 | "Ruby", 213 | ]: 214 | if lang in text and lang not in languages_found: 215 | languages_found.append(lang) 216 | print(f"Found language: {lang}") 217 | elif isinstance(message, ResultMessage): 218 | display_message(message) 219 | print(f"Total languages mentioned: {len(languages_found)}") 220 | break 221 | 222 | print("\n") 223 | 224 | 225 | async def example_with_options(): 226 | """Use ClaudeCodeOptions to configure the client.""" 227 | print("=== Custom Options Example ===") 228 | 229 | # Configure options 230 | options = ClaudeCodeOptions( 231 | allowed_tools=["Read", "Write"], # Allow file operations 232 | max_thinking_tokens=10000, 233 | system_prompt="You are a helpful coding assistant.", 234 | ) 235 | 236 | async with ClaudeSDKClient(options=options) as client: 237 | print("User: Create a simple hello.txt file with a greeting message") 238 | await client.query( 239 | "Create a simple hello.txt file with a greeting message" 240 | ) 241 | 242 | tool_uses = [] 243 | async for msg in client.receive_response(): 244 | if isinstance(msg, AssistantMessage): 245 | display_message(msg) 246 | for block in msg.content: 247 | if hasattr(block, "name") and not isinstance( 248 | block, TextBlock 249 | ): # ToolUseBlock 250 | tool_uses.append(getattr(block, "name", "")) 251 | else: 252 | display_message(msg) 253 | 254 | if tool_uses: 255 | print(f"Tools used: {', '.join(tool_uses)}") 256 | 257 | print("\n") 258 | 259 | 260 | async def example_async_iterable_prompt(): 261 | """Demonstrate send_message with async iterable.""" 262 | print("=== Async Iterable Prompt Example ===") 263 | 264 | async def create_message_stream(): 265 | """Generate a stream of messages.""" 266 | print("User: Hello! I have multiple questions.") 267 | yield { 268 | "type": "user", 269 | "message": {"role": "user", "content": "Hello! I have multiple questions."}, 270 | "parent_tool_use_id": None, 271 | "session_id": "qa-session", 272 | } 273 | 274 | print("User: First, what's the capital of Japan?") 275 | yield { 276 | "type": "user", 277 | "message": { 278 | "role": "user", 279 | "content": "First, what's the capital of Japan?", 280 | }, 281 | "parent_tool_use_id": None, 282 | "session_id": "qa-session", 283 | } 284 | 285 | print("User: Second, what's 15% of 200?") 286 | yield { 287 | "type": "user", 288 | "message": {"role": "user", "content": "Second, what's 15% of 200?"}, 289 | "parent_tool_use_id": None, 290 | "session_id": "qa-session", 291 | } 292 | 293 | async with ClaudeSDKClient() as client: 294 | # Send async iterable of messages 295 | await client.query(create_message_stream()) 296 | 297 | # Receive the three responses 298 | async for msg in client.receive_response(): 299 | display_message(msg) 300 | async for msg in client.receive_response(): 301 | display_message(msg) 302 | async for msg in client.receive_response(): 303 | display_message(msg) 304 | 305 | print("\n") 306 | 307 | 308 | async def example_bash_command(): 309 | """Example showing tool use blocks when running bash commands.""" 310 | print("=== Bash Command Example ===") 311 | 312 | async with ClaudeSDKClient() as client: 313 | print("User: Run a bash echo command") 314 | await client.query("Run a bash echo command that says 'Hello from bash!'") 315 | 316 | # Track all message types received 317 | message_types = [] 318 | 319 | async for msg in client.receive_messages(): 320 | message_types.append(type(msg).__name__) 321 | 322 | if isinstance(msg, UserMessage): 323 | # User messages can contain tool results 324 | for block in msg.content: 325 | if isinstance(block, TextBlock): 326 | print(f"User: {block.text}") 327 | elif isinstance(block, ToolResultBlock): 328 | print(f"Tool Result (id: {block.tool_use_id}): {block.content[:100] if block.content else 'None'}...") 329 | 330 | elif isinstance(msg, AssistantMessage): 331 | # Assistant messages can contain tool use blocks 332 | for block in msg.content: 333 | if isinstance(block, TextBlock): 334 | print(f"Claude: {block.text}") 335 | elif isinstance(block, ToolUseBlock): 336 | print(f"Tool Use: {block.name} (id: {block.id})") 337 | if block.name == "Bash": 338 | command = block.input.get("command", "") 339 | print(f" Command: {command}") 340 | 341 | elif isinstance(msg, ResultMessage): 342 | print("Result ended") 343 | if msg.total_cost_usd: 344 | print(f"Cost: ${msg.total_cost_usd:.4f}") 345 | break 346 | 347 | print(f"\nMessage types received: {', '.join(set(message_types))}") 348 | 349 | print("\n") 350 | 351 | 352 | async def example_error_handling(): 353 | """Demonstrate proper error handling.""" 354 | print("=== Error Handling Example ===") 355 | 356 | client = ClaudeSDKClient() 357 | 358 | try: 359 | await client.connect() 360 | 361 | # Send a message that will take time to process 362 | print("User: Run a bash sleep command for 60 seconds") 363 | await client.query("Run a bash sleep command for 60 seconds") 364 | 365 | # Try to receive response with a short timeout 366 | try: 367 | messages = [] 368 | async with asyncio.timeout(10.0): 369 | async for msg in client.receive_response(): 370 | messages.append(msg) 371 | if isinstance(msg, AssistantMessage): 372 | for block in msg.content: 373 | if isinstance(block, TextBlock): 374 | print(f"Claude: {block.text[:50]}...") 375 | elif isinstance(msg, ResultMessage): 376 | display_message(msg) 377 | break 378 | 379 | except asyncio.TimeoutError: 380 | print( 381 | "\nResponse timeout after 10 seconds - demonstrating graceful handling" 382 | ) 383 | print(f"Received {len(messages)} messages before timeout") 384 | 385 | except CLIConnectionError as e: 386 | print(f"Connection error: {e}") 387 | 388 | except Exception as e: 389 | print(f"Unexpected error: {e}") 390 | 391 | finally: 392 | # Always disconnect 393 | await client.disconnect() 394 | 395 | print("\n") 396 | 397 | 398 | async def main(): 399 | """Run all examples or a specific example based on command line argument.""" 400 | examples = { 401 | "basic_streaming": example_basic_streaming, 402 | "multi_turn_conversation": example_multi_turn_conversation, 403 | "concurrent_responses": example_concurrent_responses, 404 | "with_interrupt": example_with_interrupt, 405 | "manual_message_handling": example_manual_message_handling, 406 | "with_options": example_with_options, 407 | "async_iterable_prompt": example_async_iterable_prompt, 408 | "bash_command": example_bash_command, 409 | "error_handling": example_error_handling, 410 | } 411 | 412 | if len(sys.argv) < 2: 413 | # List available examples 414 | print("Usage: python streaming_mode.py ") 415 | print("\nAvailable examples:") 416 | print(" all - Run all examples") 417 | for name in examples: 418 | print(f" {name}") 419 | sys.exit(0) 420 | 421 | example_name = sys.argv[1] 422 | 423 | if example_name == "all": 424 | # Run all examples 425 | for example in examples.values(): 426 | await example() 427 | print("-" * 50 + "\n") 428 | elif example_name in examples: 429 | # Run specific example 430 | await examples[example_name]() 431 | else: 432 | print(f"Error: Unknown example '{example_name}'") 433 | print("\nAvailable examples:") 434 | print(" all - Run all examples") 435 | for name in examples: 436 | print(f" {name}") 437 | sys.exit(1) 438 | 439 | 440 | if __name__ == "__main__": 441 | asyncio.run(main()) 442 | -------------------------------------------------------------------------------- /src/claude_code_sdk/_internal/transport/subprocess_cli.py: -------------------------------------------------------------------------------- 1 | """Subprocess transport implementation using Claude Code CLI.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import shutil 7 | from collections.abc import AsyncIterable, AsyncIterator 8 | from pathlib import Path 9 | from subprocess import PIPE 10 | from typing import Any 11 | 12 | import anyio 13 | from anyio.abc import Process 14 | from anyio.streams.text import TextReceiveStream, TextSendStream 15 | 16 | from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError 17 | from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError 18 | from ...types import ClaudeCodeOptions 19 | from . import Transport 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | _MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit 24 | 25 | 26 | class SubprocessCLITransport(Transport): 27 | """Subprocess transport using Claude Code CLI.""" 28 | 29 | def __init__( 30 | self, 31 | prompt: str | AsyncIterable[dict[str, Any]], 32 | options: ClaudeCodeOptions, 33 | cli_path: str | Path | None = None, 34 | close_stdin_after_prompt: bool = False, 35 | ): 36 | self._prompt = prompt 37 | self._is_streaming = not isinstance(prompt, str) 38 | self._options = options 39 | self._cli_path = str(cli_path) if cli_path else self._find_cli() 40 | self._cwd = str(options.cwd) if options.cwd else None 41 | self._process: Process | None = None 42 | self._stdout_stream: TextReceiveStream | None = None 43 | self._stderr_stream: TextReceiveStream | None = None 44 | self._stdin_stream: TextSendStream | None = None 45 | self._pending_control_responses: dict[str, dict[str, Any]] = {} 46 | self._request_counter = 0 47 | self._close_stdin_after_prompt = close_stdin_after_prompt 48 | self._task_group: anyio.abc.TaskGroup | None = None 49 | 50 | def _find_cli(self) -> str: 51 | """Find Claude Code CLI binary.""" 52 | if cli := shutil.which("claude"): 53 | return cli 54 | 55 | locations = [ 56 | Path.home() / ".npm-global/bin/claude", 57 | Path("/usr/local/bin/claude"), 58 | Path.home() / ".local/bin/claude", 59 | Path.home() / "node_modules/.bin/claude", 60 | Path.home() / ".yarn/bin/claude", 61 | ] 62 | 63 | for path in locations: 64 | if path.exists() and path.is_file(): 65 | return str(path) 66 | 67 | node_installed = shutil.which("node") is not None 68 | 69 | if not node_installed: 70 | error_msg = "Claude Code requires Node.js, which is not installed.\n\n" 71 | error_msg += "Install Node.js from: https://nodejs.org/\n" 72 | error_msg += "\nAfter installing Node.js, install Claude Code:\n" 73 | error_msg += " npm install -g @anthropic-ai/claude-code" 74 | raise CLINotFoundError(error_msg) 75 | 76 | raise CLINotFoundError( 77 | "Claude Code not found. Install with:\n" 78 | " npm install -g @anthropic-ai/claude-code\n" 79 | "\nIf already installed locally, try:\n" 80 | ' export PATH="$HOME/node_modules/.bin:$PATH"\n' 81 | "\nOr specify the path when creating transport:\n" 82 | " SubprocessCLITransport(..., cli_path='/path/to/claude')" 83 | ) 84 | 85 | def _build_command(self) -> list[str]: 86 | """Build CLI command with arguments.""" 87 | cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"] 88 | 89 | if self._options.system_prompt: 90 | cmd.extend(["--system-prompt", self._options.system_prompt]) 91 | 92 | if self._options.append_system_prompt: 93 | cmd.extend(["--append-system-prompt", self._options.append_system_prompt]) 94 | 95 | if self._options.allowed_tools: 96 | cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)]) 97 | 98 | if self._options.max_turns: 99 | cmd.extend(["--max-turns", str(self._options.max_turns)]) 100 | 101 | if self._options.disallowed_tools: 102 | cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)]) 103 | 104 | if self._options.model: 105 | cmd.extend(["--model", self._options.model]) 106 | 107 | if self._options.permission_prompt_tool_name: 108 | cmd.extend( 109 | ["--permission-prompt-tool", self._options.permission_prompt_tool_name] 110 | ) 111 | 112 | if self._options.permission_mode: 113 | cmd.extend(["--permission-mode", self._options.permission_mode]) 114 | 115 | if self._options.continue_conversation: 116 | cmd.append("--continue") 117 | 118 | if self._options.resume: 119 | cmd.extend(["--resume", self._options.resume]) 120 | 121 | if self._options.settings: 122 | cmd.extend(["--settings", self._options.settings]) 123 | 124 | if self._options.mcp_servers: 125 | cmd.extend( 126 | ["--mcp-config", json.dumps({"mcpServers": self._options.mcp_servers})] 127 | ) 128 | 129 | # Add prompt handling based on mode 130 | if self._is_streaming: 131 | # Streaming mode: use --input-format stream-json 132 | cmd.extend(["--input-format", "stream-json"]) 133 | else: 134 | # String mode: use --print with the prompt 135 | cmd.extend(["--print", str(self._prompt)]) 136 | 137 | return cmd 138 | 139 | async def connect(self) -> None: 140 | """Start subprocess.""" 141 | if self._process: 142 | return 143 | 144 | cmd = self._build_command() 145 | try: 146 | # Enable stdin pipe for both modes (but we'll close it for string mode) 147 | self._process = await anyio.open_process( 148 | cmd, 149 | stdin=PIPE, 150 | stdout=PIPE, 151 | stderr=PIPE, 152 | cwd=self._cwd, 153 | env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "sdk-py"}, 154 | ) 155 | 156 | if self._process.stdout: 157 | self._stdout_stream = TextReceiveStream(self._process.stdout) 158 | if self._process.stderr: 159 | self._stderr_stream = TextReceiveStream(self._process.stderr) 160 | 161 | # Handle stdin based on mode 162 | if self._is_streaming: 163 | # Streaming mode: keep stdin open and start streaming task 164 | if self._process.stdin: 165 | self._stdin_stream = TextSendStream(self._process.stdin) 166 | # Start streaming messages to stdin in background 167 | self._task_group = anyio.create_task_group() 168 | await self._task_group.__aenter__() 169 | self._task_group.start_soon(self._stream_to_stdin) 170 | else: 171 | # String mode: close stdin immediately (backward compatible) 172 | if self._process.stdin: 173 | await self._process.stdin.aclose() 174 | 175 | except FileNotFoundError as e: 176 | # Check if the error comes from the working directory or the CLI 177 | if self._cwd and not Path(self._cwd).exists(): 178 | raise CLIConnectionError( 179 | f"Working directory does not exist: {self._cwd}" 180 | ) from e 181 | raise CLINotFoundError(f"Claude Code not found at: {self._cli_path}") from e 182 | except Exception as e: 183 | raise CLIConnectionError(f"Failed to start Claude Code: {e}") from e 184 | 185 | async def disconnect(self) -> None: 186 | """Terminate subprocess.""" 187 | if not self._process: 188 | return 189 | 190 | # Cancel task group if it exists 191 | if self._task_group: 192 | self._task_group.cancel_scope.cancel() 193 | await self._task_group.__aexit__(None, None, None) 194 | self._task_group = None 195 | 196 | if self._process.returncode is None: 197 | try: 198 | self._process.terminate() 199 | with anyio.fail_after(5.0): 200 | await self._process.wait() 201 | except TimeoutError: 202 | self._process.kill() 203 | await self._process.wait() 204 | except ProcessLookupError: 205 | pass 206 | 207 | self._process = None 208 | self._stdout_stream = None 209 | self._stderr_stream = None 210 | self._stdin_stream = None 211 | 212 | async def send_request(self, messages: list[Any], options: dict[str, Any]) -> None: 213 | """Send additional messages in streaming mode.""" 214 | if not self._is_streaming: 215 | raise CLIConnectionError("send_request only works in streaming mode") 216 | 217 | if not self._stdin_stream: 218 | raise CLIConnectionError("stdin not available - stream may have ended") 219 | 220 | # Send each message as a user message 221 | for message in messages: 222 | # Ensure message has required structure 223 | if not isinstance(message, dict): 224 | message = { 225 | "type": "user", 226 | "message": {"role": "user", "content": str(message)}, 227 | "parent_tool_use_id": None, 228 | "session_id": options.get("session_id", "default"), 229 | } 230 | 231 | await self._stdin_stream.send(json.dumps(message) + "\n") 232 | 233 | async def _stream_to_stdin(self) -> None: 234 | """Stream messages to stdin for streaming mode.""" 235 | if not self._stdin_stream or not isinstance(self._prompt, AsyncIterable): 236 | return 237 | 238 | try: 239 | async for message in self._prompt: 240 | if not self._stdin_stream: 241 | break 242 | await self._stdin_stream.send(json.dumps(message) + "\n") 243 | 244 | # Close stdin after prompt if requested (e.g., for query() one-shot mode) 245 | if self._close_stdin_after_prompt and self._stdin_stream: 246 | await self._stdin_stream.aclose() 247 | self._stdin_stream = None 248 | # Otherwise keep stdin open for send_request (ClaudeSDKClient interactive mode) 249 | except Exception as e: 250 | logger.debug(f"Error streaming to stdin: {e}") 251 | if self._stdin_stream: 252 | await self._stdin_stream.aclose() 253 | self._stdin_stream = None 254 | 255 | async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: 256 | """Receive messages from CLI.""" 257 | if not self._process or not self._stdout_stream: 258 | raise CLIConnectionError("Not connected") 259 | 260 | # Safety constants 261 | max_stderr_size = 10 * 1024 * 1024 # 10MB 262 | stderr_timeout = 30.0 # 30 seconds 263 | 264 | json_buffer = "" 265 | 266 | # Process stdout messages first 267 | try: 268 | async for line in self._stdout_stream: 269 | line_str = line.strip() 270 | if not line_str: 271 | continue 272 | 273 | json_lines = line_str.split("\n") 274 | 275 | for json_line in json_lines: 276 | json_line = json_line.strip() 277 | if not json_line: 278 | continue 279 | 280 | # Keep accumulating partial JSON until we can parse it 281 | json_buffer += json_line 282 | 283 | if len(json_buffer) > _MAX_BUFFER_SIZE: 284 | json_buffer = "" 285 | raise SDKJSONDecodeError( 286 | f"JSON message exceeded maximum buffer size of {_MAX_BUFFER_SIZE} bytes", 287 | ValueError( 288 | f"Buffer size {len(json_buffer)} exceeds limit {_MAX_BUFFER_SIZE}" 289 | ), 290 | ) 291 | 292 | try: 293 | data = json.loads(json_buffer) 294 | json_buffer = "" 295 | 296 | # Handle control responses separately 297 | if data.get("type") == "control_response": 298 | response = data.get("response", {}) 299 | request_id = response.get("request_id") 300 | if request_id: 301 | # Store the response for the pending request 302 | self._pending_control_responses[request_id] = response 303 | continue 304 | 305 | try: 306 | yield data 307 | except GeneratorExit: 308 | return 309 | except json.JSONDecodeError: 310 | # We are speculatively decoding the buffer until we get 311 | # a full JSON object. If there is an actual issue, we 312 | # raise an error after _MAX_BUFFER_SIZE. 313 | continue 314 | 315 | except anyio.ClosedResourceError: 316 | pass 317 | except GeneratorExit: 318 | # Client disconnected - still need to clean up 319 | pass 320 | 321 | # Process stderr with safety limits 322 | stderr_lines = [] 323 | stderr_size = 0 324 | 325 | if self._stderr_stream: 326 | try: 327 | # Use timeout to prevent hanging 328 | with anyio.fail_after(stderr_timeout): 329 | async for line in self._stderr_stream: 330 | line_text = line.strip() 331 | line_size = len(line_text) 332 | 333 | # Enforce memory limit 334 | if stderr_size + line_size > max_stderr_size: 335 | stderr_lines.append( 336 | f"[stderr truncated after {stderr_size} bytes]" 337 | ) 338 | # Drain rest of stream without storing 339 | async for _ in self._stderr_stream: 340 | pass 341 | break 342 | 343 | stderr_lines.append(line_text) 344 | stderr_size += line_size 345 | 346 | except TimeoutError: 347 | stderr_lines.append( 348 | f"[stderr collection timed out after {stderr_timeout}s]" 349 | ) 350 | except anyio.ClosedResourceError: 351 | pass 352 | 353 | # Check process completion and handle errors 354 | try: 355 | returncode = await self._process.wait() 356 | except Exception: 357 | returncode = -1 358 | 359 | stderr_output = "\n".join(stderr_lines) if stderr_lines else "" 360 | 361 | # Use exit code for error detection, not string matching 362 | if returncode is not None and returncode != 0: 363 | raise ProcessError( 364 | f"Command failed with exit code {returncode}", 365 | exit_code=returncode, 366 | stderr=stderr_output, 367 | ) 368 | elif stderr_output: 369 | # Log stderr for debugging but don't fail on non-zero exit 370 | logger.debug(f"Process stderr: {stderr_output}") 371 | 372 | def is_connected(self) -> bool: 373 | """Check if subprocess is running.""" 374 | return self._process is not None and self._process.returncode is None 375 | 376 | async def interrupt(self) -> None: 377 | """Send interrupt control request (only works in streaming mode).""" 378 | if not self._is_streaming: 379 | raise CLIConnectionError( 380 | "Interrupt requires streaming mode (AsyncIterable prompt)" 381 | ) 382 | 383 | if not self._stdin_stream: 384 | raise CLIConnectionError("Not connected or stdin not available") 385 | 386 | await self._send_control_request({"subtype": "interrupt"}) 387 | 388 | async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any]: 389 | """Send a control request and wait for response.""" 390 | if not self._stdin_stream: 391 | raise CLIConnectionError("Stdin not available") 392 | 393 | # Generate unique request ID 394 | self._request_counter += 1 395 | request_id = f"req_{self._request_counter}_{os.urandom(4).hex()}" 396 | 397 | # Build control request 398 | control_request = { 399 | "type": "control_request", 400 | "request_id": request_id, 401 | "request": request, 402 | } 403 | 404 | # Send request 405 | await self._stdin_stream.send(json.dumps(control_request) + "\n") 406 | 407 | # Wait for response 408 | while request_id not in self._pending_control_responses: 409 | await anyio.sleep(0.1) 410 | 411 | response = self._pending_control_responses.pop(request_id) 412 | 413 | if response.get("subtype") == "error": 414 | raise CLIConnectionError(f"Control request failed: {response.get('error')}") 415 | 416 | return response 417 | -------------------------------------------------------------------------------- /tests/test_streaming_client.py: -------------------------------------------------------------------------------- 1 | """Tests for ClaudeSDKClient streaming functionality and query() with async iterables.""" 2 | 3 | import asyncio 4 | import sys 5 | import tempfile 6 | from pathlib import Path 7 | from unittest.mock import AsyncMock, patch 8 | 9 | import anyio 10 | import pytest 11 | 12 | from claude_code_sdk import ( 13 | AssistantMessage, 14 | ClaudeCodeOptions, 15 | ClaudeSDKClient, 16 | CLIConnectionError, 17 | ResultMessage, 18 | TextBlock, 19 | UserMessage, 20 | query, 21 | ) 22 | from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport 23 | 24 | 25 | class TestClaudeSDKClientStreaming: 26 | """Test ClaudeSDKClient streaming functionality.""" 27 | 28 | def test_auto_connect_with_context_manager(self): 29 | """Test automatic connection when using context manager.""" 30 | 31 | async def _test(): 32 | with patch( 33 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 34 | ) as mock_transport_class: 35 | mock_transport = AsyncMock() 36 | mock_transport_class.return_value = mock_transport 37 | 38 | async with ClaudeSDKClient() as client: 39 | # Verify connect was called 40 | mock_transport.connect.assert_called_once() 41 | assert client._transport is mock_transport 42 | 43 | # Verify disconnect was called on exit 44 | mock_transport.disconnect.assert_called_once() 45 | 46 | anyio.run(_test) 47 | 48 | def test_manual_connect_disconnect(self): 49 | """Test manual connect and disconnect.""" 50 | 51 | async def _test(): 52 | with patch( 53 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 54 | ) as mock_transport_class: 55 | mock_transport = AsyncMock() 56 | mock_transport_class.return_value = mock_transport 57 | 58 | client = ClaudeSDKClient() 59 | await client.connect() 60 | 61 | # Verify connect was called 62 | mock_transport.connect.assert_called_once() 63 | assert client._transport is mock_transport 64 | 65 | await client.disconnect() 66 | # Verify disconnect was called 67 | mock_transport.disconnect.assert_called_once() 68 | assert client._transport is None 69 | 70 | anyio.run(_test) 71 | 72 | def test_connect_with_string_prompt(self): 73 | """Test connecting with a string prompt.""" 74 | 75 | async def _test(): 76 | with patch( 77 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 78 | ) as mock_transport_class: 79 | mock_transport = AsyncMock() 80 | mock_transport_class.return_value = mock_transport 81 | 82 | client = ClaudeSDKClient() 83 | await client.connect("Hello Claude") 84 | 85 | # Verify transport was created with string prompt 86 | call_kwargs = mock_transport_class.call_args.kwargs 87 | assert call_kwargs["prompt"] == "Hello Claude" 88 | 89 | anyio.run(_test) 90 | 91 | def test_connect_with_async_iterable(self): 92 | """Test connecting with an async iterable.""" 93 | 94 | async def _test(): 95 | with patch( 96 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 97 | ) as mock_transport_class: 98 | mock_transport = AsyncMock() 99 | mock_transport_class.return_value = mock_transport 100 | 101 | async def message_stream(): 102 | yield {"type": "user", "message": {"role": "user", "content": "Hi"}} 103 | yield { 104 | "type": "user", 105 | "message": {"role": "user", "content": "Bye"}, 106 | } 107 | 108 | client = ClaudeSDKClient() 109 | stream = message_stream() 110 | await client.connect(stream) 111 | 112 | # Verify transport was created with async iterable 113 | call_kwargs = mock_transport_class.call_args.kwargs 114 | # Should be the same async iterator 115 | assert call_kwargs["prompt"] is stream 116 | 117 | anyio.run(_test) 118 | 119 | def test_query(self): 120 | """Test sending a query.""" 121 | 122 | async def _test(): 123 | with patch( 124 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 125 | ) as mock_transport_class: 126 | mock_transport = AsyncMock() 127 | mock_transport_class.return_value = mock_transport 128 | 129 | async with ClaudeSDKClient() as client: 130 | await client.query("Test message") 131 | 132 | # Verify send_request was called with correct format 133 | mock_transport.send_request.assert_called_once() 134 | call_args = mock_transport.send_request.call_args 135 | messages, options = call_args[0] 136 | assert len(messages) == 1 137 | assert messages[0]["type"] == "user" 138 | assert messages[0]["message"]["content"] == "Test message" 139 | assert options["session_id"] == "default" 140 | 141 | anyio.run(_test) 142 | 143 | def test_send_message_with_session_id(self): 144 | """Test sending a message with custom session ID.""" 145 | 146 | async def _test(): 147 | with patch( 148 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 149 | ) as mock_transport_class: 150 | mock_transport = AsyncMock() 151 | mock_transport_class.return_value = mock_transport 152 | 153 | async with ClaudeSDKClient() as client: 154 | await client.query("Test", session_id="custom-session") 155 | 156 | call_args = mock_transport.send_request.call_args 157 | messages, options = call_args[0] 158 | assert messages[0]["session_id"] == "custom-session" 159 | assert options["session_id"] == "custom-session" 160 | 161 | anyio.run(_test) 162 | 163 | def test_send_message_not_connected(self): 164 | """Test sending message when not connected raises error.""" 165 | 166 | async def _test(): 167 | client = ClaudeSDKClient() 168 | with pytest.raises(CLIConnectionError, match="Not connected"): 169 | await client.query("Test") 170 | 171 | anyio.run(_test) 172 | 173 | def test_receive_messages(self): 174 | """Test receiving messages.""" 175 | 176 | async def _test(): 177 | with patch( 178 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 179 | ) as mock_transport_class: 180 | mock_transport = AsyncMock() 181 | mock_transport_class.return_value = mock_transport 182 | 183 | # Mock the message stream 184 | async def mock_receive(): 185 | yield { 186 | "type": "assistant", 187 | "message": { 188 | "role": "assistant", 189 | "content": [{"type": "text", "text": "Hello!"}], 190 | }, 191 | } 192 | yield { 193 | "type": "user", 194 | "message": {"role": "user", "content": "Hi there"}, 195 | } 196 | 197 | mock_transport.receive_messages = mock_receive 198 | 199 | async with ClaudeSDKClient() as client: 200 | messages = [] 201 | async for msg in client.receive_messages(): 202 | messages.append(msg) 203 | if len(messages) == 2: 204 | break 205 | 206 | assert len(messages) == 2 207 | assert isinstance(messages[0], AssistantMessage) 208 | assert isinstance(messages[0].content[0], TextBlock) 209 | assert messages[0].content[0].text == "Hello!" 210 | assert isinstance(messages[1], UserMessage) 211 | assert messages[1].content == "Hi there" 212 | 213 | anyio.run(_test) 214 | 215 | def test_receive_response(self): 216 | """Test receive_response stops at ResultMessage.""" 217 | 218 | async def _test(): 219 | with patch( 220 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 221 | ) as mock_transport_class: 222 | mock_transport = AsyncMock() 223 | mock_transport_class.return_value = mock_transport 224 | 225 | # Mock the message stream 226 | async def mock_receive(): 227 | yield { 228 | "type": "assistant", 229 | "message": { 230 | "role": "assistant", 231 | "content": [{"type": "text", "text": "Answer"}], 232 | }, 233 | } 234 | yield { 235 | "type": "result", 236 | "subtype": "success", 237 | "duration_ms": 1000, 238 | "duration_api_ms": 800, 239 | "is_error": False, 240 | "num_turns": 1, 241 | "session_id": "test", 242 | "total_cost_usd": 0.001, 243 | } 244 | # This should not be yielded 245 | yield { 246 | "type": "assistant", 247 | "message": { 248 | "role": "assistant", 249 | "content": [ 250 | {"type": "text", "text": "Should not see this"} 251 | ], 252 | }, 253 | } 254 | 255 | mock_transport.receive_messages = mock_receive 256 | 257 | async with ClaudeSDKClient() as client: 258 | messages = [] 259 | async for msg in client.receive_response(): 260 | messages.append(msg) 261 | 262 | # Should only get 2 messages (assistant + result) 263 | assert len(messages) == 2 264 | assert isinstance(messages[0], AssistantMessage) 265 | assert isinstance(messages[1], ResultMessage) 266 | 267 | anyio.run(_test) 268 | 269 | def test_interrupt(self): 270 | """Test interrupt functionality.""" 271 | 272 | async def _test(): 273 | with patch( 274 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 275 | ) as mock_transport_class: 276 | mock_transport = AsyncMock() 277 | mock_transport_class.return_value = mock_transport 278 | 279 | async with ClaudeSDKClient() as client: 280 | await client.interrupt() 281 | mock_transport.interrupt.assert_called_once() 282 | 283 | anyio.run(_test) 284 | 285 | def test_interrupt_not_connected(self): 286 | """Test interrupt when not connected raises error.""" 287 | 288 | async def _test(): 289 | client = ClaudeSDKClient() 290 | with pytest.raises(CLIConnectionError, match="Not connected"): 291 | await client.interrupt() 292 | 293 | anyio.run(_test) 294 | 295 | def test_client_with_options(self): 296 | """Test client initialization with options.""" 297 | 298 | async def _test(): 299 | options = ClaudeCodeOptions( 300 | cwd="/custom/path", 301 | allowed_tools=["Read", "Write"], 302 | system_prompt="Be helpful", 303 | ) 304 | 305 | with patch( 306 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 307 | ) as mock_transport_class: 308 | mock_transport = AsyncMock() 309 | mock_transport_class.return_value = mock_transport 310 | 311 | client = ClaudeSDKClient(options=options) 312 | await client.connect() 313 | 314 | # Verify options were passed to transport 315 | call_kwargs = mock_transport_class.call_args.kwargs 316 | assert call_kwargs["options"] is options 317 | 318 | anyio.run(_test) 319 | 320 | def test_concurrent_send_receive(self): 321 | """Test concurrent sending and receiving messages.""" 322 | 323 | async def _test(): 324 | with patch( 325 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 326 | ) as mock_transport_class: 327 | mock_transport = AsyncMock() 328 | mock_transport_class.return_value = mock_transport 329 | 330 | # Mock receive to wait then yield messages 331 | async def mock_receive(): 332 | await asyncio.sleep(0.1) 333 | yield { 334 | "type": "assistant", 335 | "message": { 336 | "role": "assistant", 337 | "content": [{"type": "text", "text": "Response 1"}], 338 | }, 339 | } 340 | await asyncio.sleep(0.1) 341 | yield { 342 | "type": "result", 343 | "subtype": "success", 344 | "duration_ms": 1000, 345 | "duration_api_ms": 800, 346 | "is_error": False, 347 | "num_turns": 1, 348 | "session_id": "test", 349 | "total_cost_usd": 0.001, 350 | } 351 | 352 | mock_transport.receive_messages = mock_receive 353 | 354 | async with ClaudeSDKClient() as client: 355 | # Helper to get next message 356 | async def get_next_message(): 357 | return await client.receive_response().__anext__() 358 | 359 | # Start receiving in background 360 | receive_task = asyncio.create_task(get_next_message()) 361 | 362 | # Send message while receiving 363 | await client.query("Question 1") 364 | 365 | # Wait for first message 366 | first_msg = await receive_task 367 | assert isinstance(first_msg, AssistantMessage) 368 | 369 | anyio.run(_test) 370 | 371 | 372 | class TestQueryWithAsyncIterable: 373 | """Test query() function with async iterable inputs.""" 374 | 375 | def test_query_with_async_iterable(self): 376 | """Test query with async iterable of messages.""" 377 | 378 | async def _test(): 379 | async def message_stream(): 380 | yield {"type": "user", "message": {"role": "user", "content": "First"}} 381 | yield {"type": "user", "message": {"role": "user", "content": "Second"}} 382 | 383 | # Create a simple test script that validates stdin and outputs a result 384 | with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: 385 | test_script = f.name 386 | f.write("""#!/usr/bin/env python3 387 | import sys 388 | import json 389 | 390 | # Read stdin messages 391 | stdin_messages = [] 392 | while True: 393 | line = sys.stdin.readline() 394 | if not line: 395 | break 396 | stdin_messages.append(line.strip()) 397 | 398 | # Verify we got 2 messages 399 | assert len(stdin_messages) == 2 400 | assert '"First"' in stdin_messages[0] 401 | assert '"Second"' in stdin_messages[1] 402 | 403 | # Output a valid result 404 | 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}') 405 | """) 406 | 407 | Path(test_script).chmod(0o755) 408 | 409 | try: 410 | # Mock _find_cli to return python executing our test script 411 | with patch.object( 412 | SubprocessCLITransport, "_find_cli", return_value=sys.executable 413 | ): 414 | # Mock _build_command to add our test script as first argument 415 | original_build_command = SubprocessCLITransport._build_command 416 | 417 | def mock_build_command(self): 418 | # Get original command 419 | cmd = original_build_command(self) 420 | # Replace the CLI path with python + script 421 | cmd[0] = test_script 422 | return cmd 423 | 424 | with patch.object( 425 | SubprocessCLITransport, "_build_command", mock_build_command 426 | ): 427 | # Run query with async iterable 428 | messages = [] 429 | async for msg in query(prompt=message_stream()): 430 | messages.append(msg) 431 | 432 | # Should get the result message 433 | assert len(messages) == 1 434 | assert isinstance(messages[0], ResultMessage) 435 | assert messages[0].subtype == "success" 436 | finally: 437 | # Clean up 438 | Path(test_script).unlink() 439 | 440 | anyio.run(_test) 441 | 442 | 443 | class TestClaudeSDKClientEdgeCases: 444 | """Test edge cases and error scenarios.""" 445 | 446 | def test_receive_messages_not_connected(self): 447 | """Test receiving messages when not connected.""" 448 | 449 | async def _test(): 450 | client = ClaudeSDKClient() 451 | with pytest.raises(CLIConnectionError, match="Not connected"): 452 | async for _ in client.receive_messages(): 453 | pass 454 | 455 | anyio.run(_test) 456 | 457 | def test_receive_response_not_connected(self): 458 | """Test receive_response when not connected.""" 459 | 460 | async def _test(): 461 | client = ClaudeSDKClient() 462 | with pytest.raises(CLIConnectionError, match="Not connected"): 463 | async for _ in client.receive_response(): 464 | pass 465 | 466 | anyio.run(_test) 467 | 468 | def test_double_connect(self): 469 | """Test connecting twice.""" 470 | 471 | async def _test(): 472 | with patch( 473 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 474 | ) as mock_transport_class: 475 | mock_transport = AsyncMock() 476 | mock_transport_class.return_value = mock_transport 477 | 478 | client = ClaudeSDKClient() 479 | await client.connect() 480 | # Second connect should create new transport 481 | await client.connect() 482 | 483 | # Should have been called twice 484 | assert mock_transport_class.call_count == 2 485 | 486 | anyio.run(_test) 487 | 488 | def test_disconnect_without_connect(self): 489 | """Test disconnecting without connecting first.""" 490 | 491 | async def _test(): 492 | client = ClaudeSDKClient() 493 | # Should not raise error 494 | await client.disconnect() 495 | 496 | anyio.run(_test) 497 | 498 | def test_context_manager_with_exception(self): 499 | """Test context manager cleans up on exception.""" 500 | 501 | async def _test(): 502 | with patch( 503 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 504 | ) as mock_transport_class: 505 | mock_transport = AsyncMock() 506 | mock_transport_class.return_value = mock_transport 507 | 508 | with pytest.raises(ValueError): 509 | async with ClaudeSDKClient(): 510 | raise ValueError("Test error") 511 | 512 | # Disconnect should still be called 513 | mock_transport.disconnect.assert_called_once() 514 | 515 | anyio.run(_test) 516 | 517 | def test_receive_response_list_comprehension(self): 518 | """Test collecting messages with list comprehension as shown in examples.""" 519 | 520 | async def _test(): 521 | with patch( 522 | "claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" 523 | ) as mock_transport_class: 524 | mock_transport = AsyncMock() 525 | mock_transport_class.return_value = mock_transport 526 | 527 | # Mock the message stream 528 | async def mock_receive(): 529 | yield { 530 | "type": "assistant", 531 | "message": { 532 | "role": "assistant", 533 | "content": [{"type": "text", "text": "Hello"}], 534 | }, 535 | } 536 | yield { 537 | "type": "assistant", 538 | "message": { 539 | "role": "assistant", 540 | "content": [{"type": "text", "text": "World"}], 541 | }, 542 | } 543 | yield { 544 | "type": "result", 545 | "subtype": "success", 546 | "duration_ms": 1000, 547 | "duration_api_ms": 800, 548 | "is_error": False, 549 | "num_turns": 1, 550 | "session_id": "test", 551 | "total_cost_usd": 0.001, 552 | } 553 | 554 | mock_transport.receive_messages = mock_receive 555 | 556 | async with ClaudeSDKClient() as client: 557 | # Test list comprehension pattern from docstring 558 | messages = [msg async for msg in client.receive_response()] 559 | 560 | assert len(messages) == 3 561 | assert all( 562 | isinstance(msg, AssistantMessage | ResultMessage) 563 | for msg in messages 564 | ) 565 | assert isinstance(messages[-1], ResultMessage) 566 | 567 | anyio.run(_test) 568 | --------------------------------------------------------------------------------