diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8d54991 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,33 @@ +name: Lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run ruff + run: | + ruff check src/ tests/ + ruff format --check src/ tests/ + + - name: Run mypy + run: | + mypy src/ \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ff9da9a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,111 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g., 0.1.0)' + required: true + type: string + test_pypi: + description: 'Publish to Test PyPI first' + required: false + type: boolean + default: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + python -m pytest tests/ -v + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run ruff + run: | + ruff check src/ tests/ + ruff format --check src/ tests/ + + - name: Run mypy + run: | + mypy src/ + + publish: + needs: [test, lint] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Update version + run: | + # Update version in pyproject.toml + sed -i 's/version = ".*"/version = "${{ github.event.inputs.version }}"/' pyproject.toml + sed -i 's/__version__ = ".*"/__version__ = "${{ github.event.inputs.version }}"/' src/claude_code_sdk/__init__.py + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Check package + run: twine check dist/* + + - name: Publish to Test PyPI + if: ${{ github.event.inputs.test_pypi == 'true' }} + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + run: | + twine upload --repository testpypi dist/* + echo "Package published to Test PyPI" + echo "Install with: pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ claude-code-sdk==${{ github.event.inputs.version }}" + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + twine upload dist/* + echo "Package published to PyPI" + echo "Install with: pip install claude-code-sdk==${{ github.event.inputs.version }}" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9684596 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + python -m pytest tests/ -v --cov=claude_code_sdk --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c50630d --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +htmlcov/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3fa6a64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index cd5de8a..b1e0c95 100644 --- a/README.md +++ b/README.md @@ -1 +1,132 @@ -# claude-code-sdk-python \ No newline at end of file +# Claude Code SDK for Python + +Python SDK for Claude Code. See the [Claude Code SDK documentation](https://docs.anthropic.com/en/docs/claude-code/sdk) for more information. + +## Installation + +```bash +pip install claude-code-sdk +``` + +**Prerequisites:** +- Python 3.10+ +- Node.js +- Claude Code: `npm install -g @anthropic-ai/claude-code` + +## Quick Start + +```python +import anyio +from claude_code_sdk import query + +async def main(): + async for message in query(prompt="What is 2 + 2?"): + print(message) + +anyio.run(main) +``` + +## Usage + +### Basic Query + +```python +from claude_code_sdk import query, ClaudeCodeOptions, AssistantMessage, TextBlock + +# Simple query +async for message in query(prompt="Hello Claude"): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(block.text) + +# With options +options = ClaudeCodeOptions( + system_prompt="You are a helpful assistant", + max_turns=1 +) + +async for message in query(prompt="Tell me a joke", options=options): + print(message) +``` + +### Using Tools + +```python +options = ClaudeCodeOptions( + allowed_tools=["Read", "Write", "Bash"], + permission_mode='acceptEdits' # auto-accept file edits +) + +async for message in query( + prompt="Create a hello.py file", + options=options +): + # Process tool use and results + pass +``` + +### Working Directory + +```python +from pathlib import Path + +options = ClaudeCodeOptions( + cwd="/path/to/project" # or Path("/path/to/project") +) +``` + +## API Reference + +### `query(prompt, options=None)` + +Main async function for querying Claude. + +**Parameters:** +- `prompt` (str): The prompt to send to Claude +- `options` (ClaudeCodeOptions): Optional configuration + +**Returns:** AsyncIterator[Message] - Stream of response messages + +### Types + +See [src/claude_code_sdk/types.py](src/claude_code_sdk/types.py) for complete type definitions: +- `ClaudeCodeOptions` - Configuration options +- `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types +- `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks + +## Error Handling + +```python +from claude_code_sdk import ( + ClaudeSDKError, # Base error + CLINotFoundError, # Claude Code not installed + CLIConnectionError, # Connection issues + ProcessError, # Process failed + CLIJSONDecodeError, # JSON parsing issues +) + +try: + async for message in query(prompt="Hello"): + pass +except CLINotFoundError: + print("Please install Claude Code") +except ProcessError as e: + print(f"Process failed with exit code: {e.exit_code}") +except CLIJSONDecodeError as e: + print(f"Failed to parse response: {e}") +``` + +See [src/claude_code_sdk/_errors.py](src/claude_code_sdk/_errors.py) for all error types. + +## Available Tools + +See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/security#tools-available-to-claude) for a complete list of available tools. + +## Examples + +See [examples/quick_start.py](examples/quick_start.py) for a complete working example. + +## License + +MIT \ No newline at end of file diff --git a/examples/quick_start.py b/examples/quick_start.py new file mode 100644 index 0000000..c043182 --- /dev/null +++ b/examples/quick_start.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Quick start example for Claude Code SDK.""" + +import anyio + +from claude_code_sdk import ( + AssistantMessage, + ClaudeCodeOptions, + ResultMessage, + TextBlock, + query, +) + + +async def basic_example(): + """Basic example - simple question.""" + print("=== Basic Example ===") + + async for message in query(prompt="What is 2 + 2?"): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def with_options_example(): + """Example with custom options.""" + print("=== With Options Example ===") + + options = ClaudeCodeOptions( + system_prompt="You are a helpful assistant that explains things simply.", + max_turns=1, + ) + + async for message in query( + prompt="Explain what Python is in one sentence.", + options=options + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def with_tools_example(): + """Example using tools.""" + print("=== With Tools Example ===") + + options = ClaudeCodeOptions( + allowed_tools=["Read", "Write"], + system_prompt="You are a helpful file assistant.", + ) + + async for message in query( + prompt="Create a file called hello.txt with 'Hello, World!' in it", + options=options + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage) and message.cost_usd > 0: + print(f"\nCost: ${message.cost_usd:.4f}") + print() + + +async def main(): + """Run all examples.""" + await basic_example() + await with_options_example() + await with_tools_example() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..75a07d2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,106 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "claude-code-sdk" +version = "0.0.10" +description = "Python SDK for Claude Code" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "Anthropic", email = "support@anthropic.com"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +keywords = ["claude", "ai", "sdk", "anthropic"] +dependencies = [ + "anyio>=4.0.0", + "typing_extensions>=4.0.0; python_version<'3.11'", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.20.0", + "anyio[trio]>=4.0.0", + "pytest-cov>=4.0.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[project.urls] +Homepage = "https://github.com/anthropics/claude-code-sdk-python" +Documentation = "https://docs.anthropic.com/en/docs/claude-code/sdk" +Issues = "https://github.com/anthropics/claude-code-sdk-python/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/claude_code_sdk"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/README.md", + "/LICENSE", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = [ + "--import-mode=importlib", +] + +[tool.pytest-asyncio] +asyncio_mode = "auto" + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "PTH", # flake8-use-pathlib + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["claude_code_sdk"] \ No newline at end of file diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py new file mode 100644 index 0000000..4f780f5 --- /dev/null +++ b/src/claude_code_sdk/__init__.py @@ -0,0 +1,102 @@ +"""Claude SDK for Python.""" + +import os +from collections.abc import AsyncIterator + +from ._errors import ( + ClaudeSDKError, + CLIConnectionError, + CLIJSONDecodeError, + CLINotFoundError, + ProcessError, +) +from ._internal.client import InternalClient +from .types import ( + AssistantMessage, + ClaudeCodeOptions, + ContentBlock, + McpServerConfig, + Message, + PermissionMode, + ResultMessage, + SystemMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +__version__ = "0.0.10" + +__all__ = [ + # Main function + "query", + # Types + "PermissionMode", + "McpServerConfig", + "UserMessage", + "AssistantMessage", + "SystemMessage", + "ResultMessage", + "Message", + "ClaudeCodeOptions", + "TextBlock", + "ToolUseBlock", + "ToolResultBlock", + "ContentBlock", + # Errors + "ClaudeSDKError", + "CLIConnectionError", + "CLINotFoundError", + "ProcessError", + "CLIJSONDecodeError", +] + + +async def query( + *, prompt: str, options: ClaudeCodeOptions | None = None +) -> AsyncIterator[Message]: + """ + Query Claude Code. + + Python SDK for interacting with Claude Code. + + Args: + prompt: The prompt to send to Claude + options: Optional configuration (defaults to ClaudeCodeOptions() if None). + Set options.permission_mode to control tool execution: + - 'default': CLI prompts for dangerous tools + - 'acceptEdits': Auto-accept file edits + - 'bypassPermissions': Allow all tools (use with caution) + Set options.cwd for working directory. + + Yields: + Messages from the conversation + + + Example: + ```python + # Simple usage + async for message in query(prompt="Hello"): + print(message) + + # With options + async for message in query( + prompt="Hello", + options=ClaudeCodeOptions( + system_prompt="You are helpful", + cwd="/home/user" + ) + ): + print(message) + ``` + """ + if options is None: + options = ClaudeCodeOptions() + + os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py" + + client = InternalClient() + + async for message in client.process_query(prompt=prompt, options=options): + yield message diff --git a/src/claude_code_sdk/_errors.py b/src/claude_code_sdk/_errors.py new file mode 100644 index 0000000..e832757 --- /dev/null +++ b/src/claude_code_sdk/_errors.py @@ -0,0 +1,46 @@ +"""Error types for Claude SDK.""" + + +class ClaudeSDKError(Exception): + """Base exception for all Claude SDK errors.""" + + +class CLIConnectionError(ClaudeSDKError): + """Raised when unable to connect to Claude Code.""" + + +class CLINotFoundError(CLIConnectionError): + """Raised when Claude Code is not found or not installed.""" + + def __init__( + self, message: str = "Claude Code not found", cli_path: str | None = None + ): + if cli_path: + message = f"{message}: {cli_path}" + super().__init__(message) + + +class ProcessError(ClaudeSDKError): + """Raised when the CLI process fails.""" + + def __init__( + self, message: str, exit_code: int | None = None, stderr: str | None = None + ): + self.exit_code = exit_code + self.stderr = stderr + + if exit_code is not None: + message = f"{message} (exit code: {exit_code})" + if stderr: + message = f"{message}\nError output: {stderr}" + + super().__init__(message) + + +class CLIJSONDecodeError(ClaudeSDKError): + """Raised when unable to decode JSON from CLI output.""" + + def __init__(self, line: str, original_error: Exception): + self.line = line + self.original_error = original_error + super().__init__(f"Failed to decode JSON: {line[:100]}...") diff --git a/src/claude_code_sdk/_internal/__init__.py b/src/claude_code_sdk/_internal/__init__.py new file mode 100644 index 0000000..62791d7 --- /dev/null +++ b/src/claude_code_sdk/_internal/__init__.py @@ -0,0 +1 @@ +"""Internal implementation details.""" diff --git a/src/claude_code_sdk/_internal/client.py b/src/claude_code_sdk/_internal/client.py new file mode 100644 index 0000000..57c75e1 --- /dev/null +++ b/src/claude_code_sdk/_internal/client.py @@ -0,0 +1,99 @@ +"""Internal client implementation.""" + +from collections.abc import AsyncIterator +from typing import Any + +from ..types import ( + AssistantMessage, + ClaudeCodeOptions, + ContentBlock, + Message, + ResultMessage, + SystemMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) +from .transport.subprocess_cli import SubprocessCLITransport + + +class InternalClient: + """Internal client implementation.""" + + def __init__(self) -> None: + """Initialize the internal client.""" + + async def process_query( + self, prompt: str, options: ClaudeCodeOptions + ) -> AsyncIterator[Message]: + """Process a query through transport.""" + + transport = SubprocessCLITransport(prompt=prompt, options=options) + + try: + await transport.connect() + + async for data in transport.receive_messages(): + message = self._parse_message(data) + if message: + yield message + + finally: + await transport.disconnect() + + def _parse_message(self, data: dict[str, Any]) -> Message | None: + """Parse message from CLI output, trusting the structure.""" + + match data["type"]: + case "user": + # Extract just the content from the nested structure + return UserMessage( + content=data["message"]["content"] + ) + + case "assistant": + # Parse content blocks + content_blocks: list[ContentBlock] = [] + for block in data["message"]["content"]: + match block["type"]: + case "text": + content_blocks.append(TextBlock(text=block["text"])) + case "tool_use": + content_blocks.append(ToolUseBlock( + id=block["id"], + name=block["name"], + input=block["input"] + )) + case "tool_result": + content_blocks.append(ToolResultBlock( + tool_use_id=block["tool_use_id"], + content=block.get("content"), + is_error=block.get("is_error") + )) + + return AssistantMessage(content=content_blocks) + + case "system": + return SystemMessage( + subtype=data["subtype"], + data=data # Pass through all data + ) + + case "result": + # Map total_cost to total_cost_usd for consistency + return ResultMessage( + subtype=data["subtype"], + cost_usd=data["cost_usd"], + duration_ms=data["duration_ms"], + duration_api_ms=data["duration_api_ms"], + is_error=data["is_error"], + num_turns=data["num_turns"], + session_id=data["session_id"], + total_cost_usd=data["total_cost"], + usage=data.get("usage"), + result=data.get("result") + ) + + case _: + return None diff --git a/src/claude_code_sdk/_internal/transport/__init__.py b/src/claude_code_sdk/_internal/transport/__init__.py new file mode 100644 index 0000000..cd7188c --- /dev/null +++ b/src/claude_code_sdk/_internal/transport/__init__.py @@ -0,0 +1,39 @@ +"""Transport implementations for Claude SDK.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from typing import Any + + +class Transport(ABC): + """Abstract transport for Claude communication.""" + + @abstractmethod + async def connect(self) -> None: + """Initialize connection.""" + pass + + @abstractmethod + async def disconnect(self) -> None: + """Close connection.""" + pass + + @abstractmethod + async def send_request( + self, messages: list[dict[str, Any]], options: dict[str, Any] + ) -> None: + """Send request to Claude.""" + pass + + @abstractmethod + def receive_messages(self) -> AsyncIterator[dict[str, Any]]: + """Receive messages from Claude.""" + pass + + @abstractmethod + def is_connected(self) -> bool: + """Check if transport is connected.""" + pass + + +__all__ = ["Transport"] diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py new file mode 100644 index 0000000..36f6178 --- /dev/null +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -0,0 +1,213 @@ +"""Subprocess transport implementation using Claude Code CLI.""" + +import json +import os +import shutil +from collections.abc import AsyncIterator +from pathlib import Path +from subprocess import PIPE +from typing import Any + +import anyio +from anyio.abc import Process +from anyio.streams.text import TextReceiveStream + +from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError +from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError +from ...types import ClaudeCodeOptions +from . import Transport + + +class SubprocessCLITransport(Transport): + """Subprocess transport using Claude Code CLI.""" + + def __init__( + self, prompt: str, options: ClaudeCodeOptions, cli_path: str | Path | None = None + ): + self._prompt = prompt + self._options = options + self._cli_path = str(cli_path) if cli_path else self._find_cli() + self._cwd = str(options.cwd) if options.cwd else None + self._process: Process | None = None + self._stdout_stream: TextReceiveStream | None = None + self._stderr_stream: TextReceiveStream | None = None + + def _find_cli(self) -> str: + """Find Claude Code CLI binary.""" + if cli := shutil.which("claude"): + return cli + + locations = [ + Path.home() / ".npm-global/bin/claude", + Path("/usr/local/bin/claude"), + Path.home() / ".local/bin/claude", + Path.home() / "node_modules/.bin/claude", + Path.home() / ".yarn/bin/claude", + ] + + for path in locations: + if path.exists() and path.is_file(): + return str(path) + + node_installed = shutil.which("node") is not None + + if not node_installed: + error_msg = "Claude Code requires Node.js, which is not installed.\n\n" + error_msg += "Install Node.js from: https://nodejs.org/\n" + error_msg += "\nAfter installing Node.js, install Claude Code:\n" + error_msg += " npm install -g @anthropic-ai/claude-code" + raise CLINotFoundError(error_msg) + + raise CLINotFoundError( + "Claude Code not found. Install with:\n" + " npm install -g @anthropic-ai/claude-code\n" + "\nIf already installed locally, try:\n" + ' export PATH="$HOME/node_modules/.bin:$PATH"\n' + "\nOr specify the path when creating transport:\n" + " SubprocessCLITransport(..., cli_path='/path/to/claude')" + ) + + def _build_command(self) -> list[str]: + """Build CLI command with arguments.""" + cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"] + + if self._options.system_prompt: + cmd.extend(["--system-prompt", self._options.system_prompt]) + + if self._options.append_system_prompt: + cmd.extend(["--append-system-prompt", self._options.append_system_prompt]) + + if self._options.allowed_tools: + cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)]) + + if self._options.max_turns: + cmd.extend(["--max-turns", str(self._options.max_turns)]) + + if self._options.disallowed_tools: + cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)]) + + if self._options.model: + cmd.extend(["--model", self._options.model]) + + if self._options.permission_prompt_tool_name: + cmd.extend( + ["--permission-prompt-tool", self._options.permission_prompt_tool_name] + ) + + if self._options.permission_mode: + cmd.extend(["--permission-mode", self._options.permission_mode]) + + if self._options.continue_conversation: + cmd.append("--continue") + + if self._options.resume: + cmd.extend(["--resume", self._options.resume]) + + if self._options.mcp_servers: + cmd.extend( + ["--mcp-config", json.dumps({"mcpServers": self._options.mcp_servers})] + ) + + cmd.extend(["--print", self._prompt]) + return cmd + + async def connect(self) -> None: + """Start subprocess.""" + if self._process: + return + + cmd = self._build_command() + try: + self._process = await anyio.open_process( + cmd, + stdin=None, + stdout=PIPE, + stderr=PIPE, + cwd=self._cwd, + env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "sdk-py"}, + ) + + if self._process.stdout: + self._stdout_stream = TextReceiveStream(self._process.stdout) + if self._process.stderr: + self._stderr_stream = TextReceiveStream(self._process.stderr) + + except FileNotFoundError as e: + raise CLINotFoundError(f"Claude Code not found at: {self._cli_path}") from e + except Exception as e: + raise CLIConnectionError(f"Failed to start Claude Code: {e}") from e + + async def disconnect(self) -> None: + """Terminate subprocess.""" + if not self._process: + return + + if self._process.returncode is None: + try: + self._process.terminate() + with anyio.fail_after(5.0): + await self._process.wait() + except TimeoutError: + self._process.kill() + await self._process.wait() + except ProcessLookupError: + pass + + self._process = None + self._stdout_stream = None + self._stderr_stream = None + + async def send_request(self, messages: list[Any], options: dict[str, Any]) -> None: + """Not used for CLI transport - args passed via command line.""" + + async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: + """Receive messages from CLI.""" + if not self._process or not self._stdout_stream: + raise CLIConnectionError("Not connected") + + stderr_lines = [] + + async def read_stderr() -> None: + """Read stderr in background.""" + if self._stderr_stream: + try: + async for line in self._stderr_stream: + stderr_lines.append(line.strip()) + except anyio.ClosedResourceError: + pass + + async with anyio.create_task_group() as tg: + tg.start_soon(read_stderr) + + try: + async for line in self._stdout_stream: + line_str = line.strip() + if not line_str: + continue + + try: + data = json.loads(line_str) + yield data + except json.JSONDecodeError as e: + if line_str.startswith("{") or line_str.startswith("["): + raise SDKJSONDecodeError(line_str, e) from e + continue + + except anyio.ClosedResourceError: + pass + finally: + tg.cancel_scope.cancel() + + await self._process.wait() + if self._process.returncode is not None and self._process.returncode != 0: + stderr_output = "\n".join(stderr_lines) + if stderr_output and "error" in stderr_output.lower(): + raise ProcessError( + "CLI process failed", + exit_code=self._process.returncode, + stderr=stderr_output, + ) + + def is_connected(self) -> bool: + """Check if subprocess is running.""" + return self._process is not None and self._process.returncode is None diff --git a/src/claude_code_sdk/py.typed b/src/claude_code_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py new file mode 100644 index 0000000..ab6ec4d --- /dev/null +++ b/src/claude_code_sdk/types.py @@ -0,0 +1,100 @@ +"""Type definitions for Claude SDK.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, TypedDict + +from typing_extensions import NotRequired # For Python < 3.11 compatibility + +# Permission modes +PermissionMode = Literal["default", "acceptEdits", "bypassPermissions"] + + +# MCP Server config +class McpServerConfig(TypedDict): + """MCP server configuration.""" + transport: list[str] + env: NotRequired[dict[str, Any]] + + +# Content block types +@dataclass +class TextBlock: + """Text content block.""" + text: str + + +@dataclass +class ToolUseBlock: + """Tool use content block.""" + id: str + name: str + input: dict[str, Any] + + +@dataclass +class ToolResultBlock: + """Tool result content block.""" + tool_use_id: str + content: str | list[dict[str, Any]] | None = None + is_error: bool | None = None + + +ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock + + +# Message types +@dataclass +class UserMessage: + """User message.""" + content: str + + +@dataclass +class AssistantMessage: + """Assistant message with content blocks.""" + content: list[ContentBlock] + + +@dataclass +class SystemMessage: + """System message with metadata.""" + subtype: str + data: dict[str, Any] + + +@dataclass +class ResultMessage: + """Result message with cost and usage information.""" + subtype: str + cost_usd: float + duration_ms: int + duration_api_ms: int + is_error: bool + num_turns: int + session_id: str + total_cost_usd: float + usage: dict[str, Any] | None = None + result: str | None = None + + +Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage + + +@dataclass +class ClaudeCodeOptions: + """Query options for Claude SDK.""" + allowed_tools: list[str] = field(default_factory=list) + max_thinking_tokens: int = 8000 + system_prompt: str | None = None + append_system_prompt: str | None = None + mcp_tools: list[str] = field(default_factory=list) + mcp_servers: dict[str, McpServerConfig] = field(default_factory=dict) + permission_mode: PermissionMode | None = None + continue_conversation: bool = False + resume: str | None = None + max_turns: int | None = None + disallowed_tools: list[str] = field(default_factory=list) + model: str | None = None + permission_prompt_tool_name: str | None = None + cwd: str | Path | None = None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..15d60ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +"""Pytest configuration for tests.""" + + +# No async plugin needed since we're using sync tests with anyio.run() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..98c0050 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,115 @@ +"""Tests for Claude SDK client functionality.""" + +from unittest.mock import AsyncMock, patch + +import anyio + +from claude_code_sdk import AssistantMessage, ClaudeCodeOptions, query +from claude_code_sdk.types import TextBlock + + +class TestQueryFunction: + """Test the main query function.""" + + def test_query_single_prompt(self): + """Test query with a single prompt.""" + async def _test(): + with patch('claude_code_sdk._internal.client.InternalClient.process_query') as mock_process: + # Mock the async generator + async def mock_generator(): + yield AssistantMessage( + content=[TextBlock(text="4")] + ) + + mock_process.return_value = mock_generator() + + messages = [] + async for msg in query(prompt="What is 2+2?"): + messages.append(msg) + + assert len(messages) == 1 + assert isinstance(messages[0], AssistantMessage) + assert messages[0].content[0].text == "4" + + anyio.run(_test) + + def test_query_with_options(self): + """Test query with various options.""" + async def _test(): + with patch('claude_code_sdk._internal.client.InternalClient.process_query') as mock_process: + async def mock_generator(): + yield AssistantMessage( + content=[TextBlock(text="Hello!")] + ) + + mock_process.return_value = mock_generator() + + options = ClaudeCodeOptions( + allowed_tools=["Read", "Write"], + system_prompt="You are helpful", + permission_mode="acceptEdits", + max_turns=5 + ) + + messages = [] + async for msg in query( + prompt="Hi", + options=options + ): + messages.append(msg) + + # Verify process_query was called with correct prompt and options + mock_process.assert_called_once() + call_args = mock_process.call_args + assert call_args[1]['prompt'] == "Hi" + assert call_args[1]['options'] == options + + anyio.run(_test) + + def test_query_with_cwd(self): + """Test query with custom working directory.""" + async def _test(): + with patch('claude_code_sdk._internal.client.SubprocessCLITransport') as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Done"}] + } + } + yield { + "type": "result", + "subtype": "success", + "cost_usd": 0.001, + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test-session", + "total_cost": 0.001 + } + + mock_transport.receive_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.disconnect = AsyncMock() + + options = ClaudeCodeOptions(cwd="/custom/path") + messages = [] + async for msg in query( + prompt="test", + options=options + ): + messages.append(msg) + + # Verify transport was created with correct parameters + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs['prompt'] == "test" + assert call_kwargs['options'].cwd == "/custom/path" + + anyio.run(_test) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..1eee01d --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,52 @@ +"""Tests for Claude SDK error handling.""" + +from claude_code_sdk import ( + ClaudeSDKError, + CLIConnectionError, + CLIJSONDecodeError, + CLINotFoundError, + ProcessError, +) + + +class TestErrorTypes: + """Test error types and their properties.""" + + def test_base_error(self): + """Test base ClaudeSDKError.""" + error = ClaudeSDKError("Something went wrong") + assert str(error) == "Something went wrong" + assert isinstance(error, Exception) + + def test_cli_not_found_error(self): + """Test CLINotFoundError.""" + error = CLINotFoundError("Claude Code not found") + assert isinstance(error, ClaudeSDKError) + assert "Claude Code not found" in str(error) + + def test_connection_error(self): + """Test CLIConnectionError.""" + error = CLIConnectionError("Failed to connect to CLI") + assert isinstance(error, ClaudeSDKError) + assert "Failed to connect to CLI" in str(error) + + def test_process_error(self): + """Test ProcessError with exit code and stderr.""" + error = ProcessError("Process failed", exit_code=1, stderr="Command not found") + assert error.exit_code == 1 + assert error.stderr == "Command not found" + assert "Process failed" in str(error) + assert "exit code: 1" in str(error) + assert "Command not found" in str(error) + + def test_json_decode_error(self): + """Test CLIJSONDecodeError.""" + import json + + try: + json.loads("{invalid json}") + except json.JSONDecodeError as e: + error = CLIJSONDecodeError("{invalid json}", e) + assert error.line == "{invalid json}" + assert error.original_error == e + assert "Failed to decode JSON" in str(error) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..2ffef54 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,191 @@ +"""Integration tests for Claude SDK. + +These tests verify end-to-end functionality with mocked CLI responses. +""" + +from unittest.mock import AsyncMock, patch + +import anyio +import pytest + +from claude_code_sdk import ( + AssistantMessage, + ClaudeCodeOptions, + CLINotFoundError, + ResultMessage, + query, +) +from claude_code_sdk.types import ToolUseBlock + + +class TestIntegration: + """End-to-end integration tests.""" + + def test_simple_query_response(self): + """Test a simple query with text response.""" + async def _test(): + with patch("claude_code_sdk._internal.client.SubprocessCLITransport") as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "2 + 2 equals 4"}], + }, + } + yield { + "type": "result", + "subtype": "success", + "cost_usd": 0.001, + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test-session", + "total_cost": 0.001, + } + + mock_transport.receive_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.disconnect = AsyncMock() + + # Run query + messages = [] + async for msg in query(prompt="What is 2 + 2?"): + messages.append(msg) + + # Verify results + assert len(messages) == 2 + + # Check assistant message + assert isinstance(messages[0], AssistantMessage) + assert len(messages[0].content) == 1 + assert messages[0].content[0].text == "2 + 2 equals 4" + + # Check result message + assert isinstance(messages[1], ResultMessage) + assert messages[1].cost_usd == 0.001 + assert messages[1].session_id == "test-session" + + + anyio.run(_test) + def test_query_with_tool_use(self): + """Test query that uses tools.""" + async def _test(): + with patch("claude_code_sdk._internal.client.SubprocessCLITransport") as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream with tool use + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Let me read that file for you.", + }, + { + "type": "tool_use", + "id": "tool-123", + "name": "Read", + "input": {"file_path": "/test.txt"}, + }, + ], + }, + } + yield { + "type": "result", + "subtype": "success", + "cost_usd": 0.002, + "duration_ms": 1500, + "duration_api_ms": 1200, + "is_error": False, + "num_turns": 1, + "session_id": "test-session-2", + "total_cost": 0.002, + } + + mock_transport.receive_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.disconnect = AsyncMock() + + # Run query with tools enabled + messages = [] + async for msg in query( + prompt="Read /test.txt", + options=ClaudeCodeOptions(allowed_tools=["Read"]), + ): + messages.append(msg) + + # Verify results + assert len(messages) == 2 + + # Check assistant message with tool use + assert isinstance(messages[0], AssistantMessage) + assert len(messages[0].content) == 2 + assert messages[0].content[0].text == "Let me read that file for you." + assert isinstance(messages[0].content[1], ToolUseBlock) + assert messages[0].content[1].name == "Read" + assert messages[0].content[1].input["file_path"] == "/test.txt" + + + anyio.run(_test) + def test_cli_not_found(self): + """Test handling when CLI is not found.""" + async def _test(): + with patch("shutil.which", return_value=None), patch( + "pathlib.Path.exists", return_value=False + ), pytest.raises(CLINotFoundError) as exc_info: + async for _ in query(prompt="test"): + pass + + assert "Claude Code requires Node.js" in str(exc_info.value) + + + anyio.run(_test) + def test_continuation_option(self): + """Test query with continue_conversation option.""" + async def _test(): + with patch("claude_code_sdk._internal.client.SubprocessCLITransport") as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Continuing from previous conversation", + } + ], + }, + } + + mock_transport.receive_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.disconnect = AsyncMock() + + # Run query with continuation + messages = [] + async for msg in query( + prompt="Continue", options=ClaudeCodeOptions(continue_conversation=True) + ): + messages.append(msg) + + # Verify transport was created with continuation option + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs['options'].continue_conversation is True + + anyio.run(_test) diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..1ea865c --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,129 @@ +"""Tests for Claude SDK transport layer.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest + +from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport +from claude_code_sdk.types import ClaudeCodeOptions + + +class TestSubprocessCLITransport: + """Test subprocess transport implementation.""" + + def test_find_cli_not_found(self): + """Test CLI not found error.""" + from claude_code_sdk._errors import CLINotFoundError + + with patch("shutil.which", return_value=None), patch( + "pathlib.Path.exists", return_value=False + ), pytest.raises(CLINotFoundError) as exc_info: + SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions() + ) + + assert "Claude Code requires Node.js" in str(exc_info.value) + + def test_build_command_basic(self): + """Test building basic CLI command.""" + transport = SubprocessCLITransport( + prompt="Hello", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) + + cmd = transport._build_command() + assert cmd[0] == "/usr/bin/claude" + assert "--output-format" in cmd + assert "stream-json" in cmd + assert "--print" in cmd + assert "Hello" in cmd + + def test_cli_path_accepts_pathlib_path(self): + """Test that cli_path accepts pathlib.Path objects.""" + from pathlib import Path + + transport = SubprocessCLITransport( + prompt="Hello", options=ClaudeCodeOptions(), cli_path=Path("/usr/bin/claude") + ) + + assert transport._cli_path == "/usr/bin/claude" + + def test_build_command_with_options(self): + """Test building CLI command with options.""" + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeCodeOptions( + system_prompt="Be helpful", + allowed_tools=["Read", "Write"], + disallowed_tools=["Bash"], + model="claude-3-5-sonnet", + permission_mode="acceptEdits", + max_turns=5, + ), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + assert "--system-prompt" in cmd + assert "Be helpful" in cmd + assert "--allowedTools" in cmd + assert "Read,Write" in cmd + assert "--disallowedTools" in cmd + assert "Bash" in cmd + assert "--model" in cmd + assert "claude-3-5-sonnet" in cmd + assert "--permission-mode" in cmd + assert "acceptEdits" in cmd + assert "--max-turns" in cmd + assert "5" in cmd + + def test_session_continuation(self): + """Test session continuation options.""" + transport = SubprocessCLITransport( + prompt="Continue from before", + options=ClaudeCodeOptions(continue_conversation=True, resume="session-123"), + cli_path="/usr/bin/claude", + ) + + cmd = transport._build_command() + assert "--continue" in cmd + assert "--resume" in cmd + assert "session-123" in cmd + + def test_connect_disconnect(self): + """Test connect and disconnect lifecycle.""" + async def _test(): + with patch("anyio.open_process") as mock_exec: + mock_process = MagicMock() + mock_process.returncode = None + mock_process.terminate = MagicMock() + mock_process.wait = AsyncMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_exec.return_value = mock_process + + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) + + await transport.connect() + assert transport._process is not None + assert transport.is_connected() + + await transport.disconnect() + mock_process.terminate.assert_called_once() + + anyio.run(_test) + + def test_receive_messages(self): + """Test parsing messages from CLI output.""" + # This test is simplified to just test the parsing logic + # The full async stream handling is tested in integration tests + transport = SubprocessCLITransport( + prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude" + ) + + # The actual message parsing is done by the client, not the transport + # So we just verify the transport can be created and basic structure is correct + assert transport._prompt == "test" + assert transport._cli_path == "/usr/bin/claude" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..fe1f4b0 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,117 @@ +"""Tests for Claude SDK type definitions.""" + +from claude_code_sdk import ( + AssistantMessage, + ClaudeCodeOptions, + ResultMessage, +) +from claude_code_sdk.types import TextBlock, ToolResultBlock, ToolUseBlock, UserMessage + + +class TestMessageTypes: + """Test message type creation and validation.""" + + def test_user_message_creation(self): + """Test creating a UserMessage.""" + msg = UserMessage(content="Hello, Claude!") + assert msg.content == "Hello, Claude!" + + def test_assistant_message_with_text(self): + """Test creating an AssistantMessage with text content.""" + text_block = TextBlock(text="Hello, human!") + msg = AssistantMessage(content=[text_block]) + assert len(msg.content) == 1 + assert msg.content[0].text == "Hello, human!" + + def test_tool_use_block(self): + """Test creating a ToolUseBlock.""" + block = ToolUseBlock( + id="tool-123", + name="Read", + input={"file_path": "/test.txt"} + ) + assert block.id == "tool-123" + assert block.name == "Read" + assert block.input["file_path"] == "/test.txt" + + def test_tool_result_block(self): + """Test creating a ToolResultBlock.""" + block = ToolResultBlock( + tool_use_id="tool-123", + content="File contents here", + is_error=False + ) + assert block.tool_use_id == "tool-123" + assert block.content == "File contents here" + assert block.is_error is False + + def test_result_message(self): + """Test creating a ResultMessage.""" + msg = ResultMessage( + subtype="success", + cost_usd=0.01, + duration_ms=1500, + duration_api_ms=1200, + is_error=False, + num_turns=1, + session_id="session-123", + total_cost_usd=0.01 + ) + assert msg.subtype == "success" + assert msg.cost_usd == 0.01 + assert msg.session_id == "session-123" + + +class TestOptions: + """Test Options configuration.""" + + def test_default_options(self): + """Test Options with default values.""" + options = ClaudeCodeOptions() + assert options.allowed_tools == [] + assert options.max_thinking_tokens == 8000 + assert options.system_prompt is None + assert options.permission_mode is None + assert options.continue_conversation is False + assert options.disallowed_tools == [] + + def test_claude_code_options_with_tools(self): + """Test Options with built-in tools.""" + options = ClaudeCodeOptions( + allowed_tools=["Read", "Write", "Edit"], + disallowed_tools=["Bash"] + ) + assert options.allowed_tools == ["Read", "Write", "Edit"] + assert options.disallowed_tools == ["Bash"] + + def test_claude_code_options_with_permission_mode(self): + """Test Options with permission mode.""" + options = ClaudeCodeOptions(permission_mode="bypassPermissions") + assert options.permission_mode == "bypassPermissions" + + def test_claude_code_options_with_system_prompt(self): + """Test Options with system prompt.""" + options = ClaudeCodeOptions( + system_prompt="You are a helpful assistant.", + append_system_prompt="Be concise." + ) + assert options.system_prompt == "You are a helpful assistant." + assert options.append_system_prompt == "Be concise." + + def test_claude_code_options_with_session_continuation(self): + """Test Options with session continuation.""" + options = ClaudeCodeOptions( + continue_conversation=True, + resume="session-123" + ) + assert options.continue_conversation is True + assert options.resume == "session-123" + + def test_claude_code_options_with_model_specification(self): + """Test Options with model specification.""" + options = ClaudeCodeOptions( + model="claude-3-5-sonnet-20241022", + permission_prompt_tool_name="CustomTool" + ) + assert options.model == "claude-3-5-sonnet-20241022" + assert options.permission_prompt_tool_name == "CustomTool"