diff --git a/.claude/agents/test-agent.md b/.claude/agents/test-agent.md new file mode 100644 index 0000000..6515827 --- /dev/null +++ b/.claude/agents/test-agent.md @@ -0,0 +1,9 @@ +--- +name: test-agent +description: A simple test agent for SDK testing +tools: Read +--- + +# Test Agent + +You are a simple test agent. When asked a question, provide a brief, helpful answer. diff --git a/.claude/commands/generate-changelog.md b/.claude/commands/generate-changelog.md new file mode 100644 index 0000000..3a67279 --- /dev/null +++ b/.claude/commands/generate-changelog.md @@ -0,0 +1,19 @@ +--- +allowed-tools: Edit, Bash(git add:*), Bash(git commit:*) +description: Generate changelog for a new release version +--- + +You are updating the changelog for the new release. + +Update CHANGELOG.md to add a new section for the new version at the top of the file, right after the '# Changelog' heading. + +Review the recent commits and merged pull requests since the last release to generate meaningful changelog content for the new version. Follow the existing format in CHANGELOG.md with sections like: +- Breaking Changes (if any) +- New Features +- Bug Fixes +- Documentation +- Internal/Other changes + +Include only the sections that are relevant based on the actual changes. Write clear, user-focused descriptions. + +After updating CHANGELOG.md, commit the changes with the message "docs: update changelog for v{new_version}". diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d013f1b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Git +.git +.gitignore + +# 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 + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing/Coverage +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ + +# Misc +*.log +.DS_Store diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml index 8d6b8e1..c50abab 100644 --- a/.github/workflows/create-release-tag.yml +++ b/.github/workflows/create-release-tag.yml @@ -24,12 +24,6 @@ jobs: VERSION="${BRANCH_NAME#release/v}" echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Get previous release tag - id: previous_tag - run: | - PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") - echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT - - name: Create and push tag run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" @@ -46,14 +40,34 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Create release with auto-generated notes - gh release create "v${{ steps.extract_version.outputs.version }}" \ - --title "Release v${{ steps.extract_version.outputs.version }}" \ - --generate-notes \ - --notes-start-tag "${{ steps.previous_tag.outputs.previous_tag }}" \ - --notes "Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ steps.extract_version.outputs.version }}/ + VERSION="${{ steps.extract_version.outputs.version }}" - ### Installation - \`\`\`bash - pip install claude-agent-sdk==${{ steps.extract_version.outputs.version }} - \`\`\`" + # Extract changelog section for this version to a temp file + awk -v ver="$VERSION" ' + /^## / { + if (found) exit + if ($2 == ver) found=1 + next + } + found { print } + ' CHANGELOG.md > release_notes.md + + # Append install instructions + { + echo "" + echo "---" + echo "" + echo "**PyPI:** https://pypi.org/project/claude-agent-sdk/VERSION/" + echo "" + echo '```bash' + echo "pip install claude-agent-sdk==VERSION" + echo '```' + } >> release_notes.md + + # Replace VERSION placeholder + sed -i "s/VERSION/$VERSION/g" release_notes.md + + # Create release with notes from file + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file release_notes.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad70da5..b8b7e93 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: "Version to publish (e.g., 0.1.0)" + description: 'Package version to publish (e.g., 0.1.4)' required: true type: string jobs: @@ -56,128 +56,167 @@ jobs: run: | mypy src/ - publish: + build-wheels: needs: [test, lint] + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine wheel + shell: bash + + - name: Build wheel with bundled CLI + run: | + python scripts/build_wheel.py \ + --version "${{ github.event.inputs.version }}" \ + --skip-sdist \ + --clean + shell: bash + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.os }} + path: dist/*.whl + if-no-files-found: error + compression-level: 0 + + publish: + needs: [build-wheels] runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 # Fetch all history including tags (necessary for changelog generation) + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 # Fetch all history including tags for changelog generation - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' - - name: Set version - id: version - run: | - VERSION="${{ github.event.inputs.version }}" - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Set version + id: version + run: | + VERSION="${{ github.event.inputs.version }}" + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Update version - run: | - python scripts/update_version.py "${{ env.VERSION }}" + - name: Update version + run: | + python scripts/update_version.py "${{ env.VERSION }}" - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install build twine + - name: Read CLI version from code + id: cli_version + run: | + CLI_VERSION=$(python -c "import re; print(re.search(r'__cli_version__ = \"([^\"]+)\"', open('src/claude_agent_sdk/_cli_version.py').read()).group(1))") + echo "cli_version=$CLI_VERSION" >> $GITHUB_OUTPUT + echo "Bundled CLI version: $CLI_VERSION" - - name: Build package - run: python -m build + - name: Download all wheel artifacts + uses: actions/download-artifact@v4 + with: + path: dist + pattern: wheel-* + merge-multiple: true - - name: Check package - run: twine check dist/* + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine - - 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-agent-sdk==${{ env.VERSION }}" + - name: Build source distribution + run: python -m build --sdist - - name: Get previous release tag - id: previous_tag - run: | - PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT - echo "Previous release: $PREVIOUS_TAG" + - 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-agent-sdk==${{ env.VERSION }}" + + - name: Get previous release tag + id: previous_tag + run: | + PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "Previous release: $PREVIOUS_TAG" - - name: Create release branch and commit version changes - run: | - # Create a new branch for the version update - BRANCH_NAME="release/v${{ env.VERSION }}" - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + - name: Create release branch and commit version changes + run: | + # Create a new branch for the version update + BRANCH_NAME="release/v${{ env.VERSION }}" + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - # Configure git - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" + # Configure git + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" - # Create and switch to new branch - git checkout -b "$BRANCH_NAME" + # Create and switch to new branch + git checkout -b "$BRANCH_NAME" - # Commit version changes - git add pyproject.toml src/claude_agent_sdk/_version.py - git commit -m "chore: bump version to ${{ env.VERSION }}" + # Commit version changes + git add pyproject.toml src/claude_agent_sdk/_version.py + git commit -m "chore: release v${{ env.VERSION }}" - - name: Update changelog with Claude - continue-on-error: true - uses: anthropics/claude-code-action@v1 - with: - prompt: | - You are updating the changelog for the new release v${{ env.VERSION }}. + - name: Update changelog with Claude + continue-on-error: true + uses: anthropics/claude-code-action@v1 + with: + prompt: "/generate-changelog new version: ${{ env.VERSION }}, old version: ${{ steps.previous_tag.outputs.previous_tag }}" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + claude_args: | + --model claude-opus-4-5 + --allowedTools 'Bash(git add:*),Bash(git commit:*),Edit' - Update CHANGELOG.md to add a new section for version ${{ env.VERSION }} at the top of the file, right after the '# Changelog' heading. + - name: Push branch and create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Push the branch with all commits + git push origin "${{ env.BRANCH_NAME }}" - Review the recent commits and merged pull requests since the last release (${{ steps.previous_tag.outputs.previous_tag }}) to generate meaningful changelog content for v${{ env.VERSION }}. Follow the existing format in CHANGELOG.md with sections like: - - Breaking Changes (if any) - - New Features - - Bug Fixes - - Documentation - - Internal/Other changes + # Create PR using GitHub CLI + PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI. - Include only the sections that are relevant based on the actual changes. Write clear, user-focused descriptions. + ## Changes + - Updated version in \`pyproject.toml\` to ${{ env.VERSION }} + - Updated version in \`src/claude_agent_sdk/_version.py\` to ${{ env.VERSION }} + - Updated \`CHANGELOG.md\` with release notes - After updating CHANGELOG.md, commit the changes with the message "docs: update changelog for v${{ env.VERSION }}". - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - claude_args: | - --allowedTools 'Bash(git add:*),Bash(git commit:*),Edit' + ## Release Information + - Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ env.VERSION }}/ + - Bundled CLI version: ${{ steps.cli_version.outputs.cli_version }} + - Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\` - - name: Push branch and create PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Push the branch with all commits - git push origin "${{ env.BRANCH_NAME }}" + 🤖 Generated by GitHub Actions" - # Create PR using GitHub CLI - PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI. + PR_URL=$(gh pr create \ + --title "chore: release v${{ env.VERSION }}" \ + --body "$PR_BODY" \ + --base main \ + --head "${{ env.BRANCH_NAME }}") - ## Changes - - Updated version in \`pyproject.toml\` - - Updated version in \`src/claude_agent_sdk/_version.py\` - - Updated \`CHANGELOG.md\` with release notes - - ## Release Information - - Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ env.VERSION }}/ - - Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\` - - 🤖 Generated by GitHub Actions" - - PR_URL=$(gh pr create \ - --title "chore: release v${{ env.VERSION }}" \ - --body "$PR_BODY" \ - --base main \ - --head "${{ env.BRANCH_NAME }}") - - echo "PR created: $PR_URL" + echo "PR created: $PR_URL" diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml new file mode 100644 index 0000000..675dd93 --- /dev/null +++ b/.github/workflows/slack-issue-notification.yml @@ -0,0 +1,36 @@ +name: Post new issues to Slack + +on: + issues: + types: [opened] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Post to Slack + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # 2.1.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + { + "channel": "C09HY5E0K60", + "text": "New issue opened in ${{ github.repository }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*New Issue:* <${{ github.event.issue.html_url }}|#${{ github.event.issue.number }} ${{ github.event.issue.title }}>" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Author:* ${{ github.event.issue.user.login }}" + } + } + ] + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 097d2ad..d581a8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,12 +81,30 @@ jobs: run: | python -m pytest e2e-tests/ -v -m e2e + test-e2e-docker: + runs-on: ubuntu-latest + needs: test # Run after unit tests pass + # Run e2e tests in Docker to catch container-specific issues like #406 + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker test image + run: docker build -f Dockerfile.test -t claude-sdk-test . + + - name: Run e2e tests in Docker + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + test-examples: runs-on: ubuntu-latest needs: test-e2e # Run after e2e tests strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.13"] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 7e6d2df..6be5cc4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ venv/ ENV/ env/ .venv +uv.lock # IDEs .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d763c43..bfade18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,113 @@ # Changelog +## 0.1.18 + +### Internal/Other Changes + +- **Docker-based test infrastructure**: Added Docker support for running e2e tests in containerized environments, helping catch Docker-specific issues (#424) +- Updated bundled Claude CLI to version 2.0.72 + +## 0.1.17 + +### New Features + +- **UserMessage UUID field**: Added `uuid` field to `UserMessage` response type, making it easier to use the `rewind_files()` method by providing direct access to message identifiers needed for file checkpointing (#418) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.70 + +## 0.1.16 + +### Bug Fixes + +- **Rate limit detection**: Fixed parsing of the `error` field in `AssistantMessage`, enabling applications to detect and handle API errors like rate limits. Previously, the `error` field was defined but never populated from CLI responses (#405) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.68 + +## 0.1.15 + +### New Features + +- **File checkpointing and rewind**: Added `enable_file_checkpointing` option to `ClaudeAgentOptions` and `rewind_files(user_message_id)` method to `ClaudeSDKClient` and `Query`. This enables reverting file changes made during a session back to a specific checkpoint, useful for exploring different approaches or recovering from unwanted modifications (#395) + +### Documentation + +- Added license and terms section to README (#399) + +## 0.1.14 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.62 + +## 0.1.13 + +### Bug Fixes + +- **Faster error handling**: CLI errors (e.g., invalid session ID) now propagate to pending requests immediately instead of waiting for the 60-second timeout (#388) +- **Pydantic 2.12+ compatibility**: Fixed `PydanticUserError` caused by `McpServer` type only being imported under `TYPE_CHECKING` (#385) +- **Concurrent subagent writes**: Added write lock to prevent `BusyResourceError` when multiple subagents invoke MCP tools in parallel (#391) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.59 + +## 0.1.12 + +### New Features + +- **Tools option**: Added `tools` option to `ClaudeAgentOptions` for controlling the base set of available tools, matching the TypeScript SDK functionality. Supports three modes: + - Array of tool names to specify which tools should be available (e.g., `["Read", "Edit", "Bash"]`) + - Empty array `[]` to disable all built-in tools + - Preset object `{"type": "preset", "preset": "claude_code"}` to use the default Claude Code toolset +- **SDK beta support**: Added `betas` option to `ClaudeAgentOptions` for enabling Anthropic API beta features. Currently supports `"context-1m-2025-08-07"` for extended context window + +## 0.1.11 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.57 + +## 0.1.10 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.53 + +## 0.1.9 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.49 + +## 0.1.8 + +### Features + +- Claude Code is now included by default in the package, removing the requirement to install it separately. If you do wish to use a separately installed build, use the `cli_path` field in `Options`. + +## 0.1.7 + +### Features + +- **Structured outputs support**: Agents can now return validated JSON matching your schema. See https://docs.claude.com/en/docs/agent-sdk/structured-outputs. (#340) +- **Fallback model handling**: Added automatic fallback model handling for improved reliability and parity with the TypeScript SDK. When the primary model is unavailable, the SDK will automatically use a fallback model (#317) +- **Local Claude CLI support**: Added support for using a locally installed Claude CLI from `~/.claude/local/claude`, enabling development and testing with custom Claude CLI builds (#302) + +## 0.1.6 + +### Features + +- **Max budget control**: Added `max_budget_usd` option to set a maximum spending limit in USD for SDK sessions. When the budget is exceeded, the session will automatically terminate, helping prevent unexpected costs (#293) +- **Extended thinking configuration**: Added `max_thinking_tokens` option to control the maximum number of tokens allocated for Claude's internal reasoning process. This allows fine-tuning of the balance between response quality and token usage (#298) + +### Bug Fixes + +- **System prompt defaults**: Fixed issue where a default system prompt was being used when none was specified. The SDK now correctly uses an empty system prompt by default, giving users full control over agent behavior (#290) + ## 0.1.5 ### Features diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..22adf2e --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,29 @@ +# Dockerfile for running SDK tests in a containerized environment +# This helps catch Docker-specific issues like #406 + +FROM python:3.12-slim + +# Install dependencies for Claude CLI and git (needed for some tests) +RUN apt-get update && apt-get install -y \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude Code CLI +RUN curl -fsSL https://claude.ai/install.sh | bash +ENV PATH="/root/.local/bin:$PATH" + +# Set up working directory +WORKDIR /app + +# Copy the SDK source +COPY . . + +# Install SDK with dev dependencies +RUN pip install -e ".[dev]" + +# Verify CLI installation +RUN claude -v + +# Default: run unit tests +CMD ["python", "-m", "pytest", "tests/", "-v"] diff --git a/README.md b/README.md index 1fcb30d..bcbe969 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,13 @@ pip install claude-agent-sdk ``` **Prerequisites:** + - Python 3.10+ -- Node.js -- Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code` + +**Note:** The Claude Code CLI is automatically bundled with the package - no separate installation required! The SDK will use the bundled CLI by default. If you prefer to use a system-wide installation or a specific version, you can: + +- Install Claude Code separately: `curl -fsSL https://claude.ai/install.sh | bash` +- Specify a custom path: `ClaudeAgentOptions(cli_path="/path/to/claude")` ## Quick Start @@ -179,7 +183,7 @@ options = ClaudeAgentOptions( ### Hooks -A **hook** is a Python function that the Claude Code *application* (*not* Claude) invokes at specific points of the Claude agent loop. Hooks can provide deterministic processing and automated feedback for Claude. Read more in [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks). +A **hook** is a Python function that the Claude Code _application_ (_not_ Claude) invokes at specific points of the Claude agent loop. Hooks can provide deterministic processing and automated feedback for Claude. Read more in [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks). For more examples, see examples/hooks.py. @@ -229,10 +233,10 @@ async with ClaudeSDKClient(options=options) as client: print(msg) ``` - ## Types See [src/claude_agent_sdk/types.py](src/claude_agent_sdk/types.py) for complete type definitions: + - `ClaudeAgentOptions` - Configuration options - `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types - `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks @@ -259,7 +263,7 @@ except CLIJSONDecodeError as e: print(f"Failed to parse response: {e}") ``` -See [src/claude_agent_sdk/_errors.py](src/claude_agent_sdk/_errors.py) for all error types. +See [src/claude_agent_sdk/\_errors.py](src/claude_agent_sdk/_errors.py) for all error types. ## Available Tools @@ -290,6 +294,63 @@ If you're contributing to this project, run the initial setup script to install This installs a pre-push hook that runs lint checks before pushing, matching the CI workflow. To skip the hook temporarily, use `git push --no-verify`. -## License +### Building Wheels Locally -MIT +To build wheels with the bundled Claude Code CLI: + +```bash +# Install build dependencies +pip install build twine + +# Build wheel with bundled CLI +python scripts/build_wheel.py + +# Build with specific version +python scripts/build_wheel.py --version 0.1.4 + +# Build with specific CLI version +python scripts/build_wheel.py --cli-version 2.0.0 + +# Clean bundled CLI after building +python scripts/build_wheel.py --clean + +# Skip CLI download (use existing) +python scripts/build_wheel.py --skip-download +``` + +The build script: + +1. Downloads Claude Code CLI for your platform +2. Bundles it in the wheel +3. Builds both wheel and source distribution +4. Checks the package with twine + +See `python scripts/build_wheel.py --help` for all options. + +### Release Workflow + +The package is published to PyPI via the GitHub Actions workflow in `.github/workflows/publish.yml`. To create a new release: + +1. **Trigger the workflow** manually from the Actions tab with two inputs: + - `version`: The package version to publish (e.g., `0.1.5`) + - `claude_code_version`: The Claude Code CLI version to bundle (e.g., `2.0.0` or `latest`) + +2. **The workflow will**: + - Build platform-specific wheels for macOS, Linux, and Windows + - Bundle the specified Claude Code CLI version in each wheel + - Build a source distribution + - Publish all artifacts to PyPI + - Create a release branch with version updates + - Open a PR to main with: + - Updated `pyproject.toml` version + - Updated `src/claude_agent_sdk/_version.py` + - Updated `src/claude_agent_sdk/_cli_version.py` with bundled CLI version + - Auto-generated `CHANGELOG.md` entry + +3. **Review and merge** the release PR to update main with the new version information + +The workflow tracks both the package version and the bundled CLI version separately, allowing you to release a new package version with an updated CLI without code changes. + +## License and terms + +Use of this SDK is governed by Anthropic's [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms), including when you use it to power products and services that you make available to your own customers and end users, except to the extent a specific component or dependency is covered by a different license as indicated in that component's LICENSE file. diff --git a/e2e-tests/test_agents_and_settings.py b/e2e-tests/test_agents_and_settings.py index 6e04066..3f6fc80 100644 --- a/e2e-tests/test_agents_and_settings.py +++ b/e2e-tests/test_agents_and_settings.py @@ -38,15 +38,88 @@ async def test_agent_definition(): async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": agents = message.data.get("agents", []) - assert isinstance( - agents, list - ), f"agents should be a list of strings, got: {type(agents)}" - assert ( - "test-agent" in agents - ), f"test-agent should be available, got: {agents}" + assert isinstance(agents, list), ( + f"agents should be a list of strings, got: {type(agents)}" + ) + assert "test-agent" in agents, ( + f"test-agent should be available, got: {agents}" + ) break +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_filesystem_agent_loading(): + """Test that filesystem-based agents load via setting_sources and produce full response. + + This is the core test for issue #406. It verifies that when using + setting_sources=["project"] with a .claude/agents/ directory containing + agent definitions, the SDK: + 1. Loads the agents (they appear in init message) + 2. Produces a full response with AssistantMessage + 3. Completes with a ResultMessage + + The bug in #406 causes the iterator to complete after only the + init SystemMessage, never yielding AssistantMessage or ResultMessage. + """ + with tempfile.TemporaryDirectory() as tmpdir: + # Create a temporary project with a filesystem agent + project_dir = Path(tmpdir) + agents_dir = project_dir / ".claude" / "agents" + agents_dir.mkdir(parents=True) + + # Create a test agent file + agent_file = agents_dir / "fs-test-agent.md" + agent_file.write_text( + """--- +name: fs-test-agent +description: A filesystem test agent for SDK testing +tools: Read +--- + +# Filesystem Test Agent + +You are a simple test agent. When asked a question, provide a brief, helpful answer. +""" + ) + + options = ClaudeAgentOptions( + setting_sources=["project"], + cwd=project_dir, + max_turns=1, + ) + + messages = [] + async with ClaudeSDKClient(options=options) as client: + await client.query("Say hello in exactly 3 words") + async for msg in client.receive_response(): + messages.append(msg) + + # Must have at least init, assistant, result + message_types = [type(m).__name__ for m in messages] + + assert "SystemMessage" in message_types, "Missing SystemMessage (init)" + assert "AssistantMessage" in message_types, ( + f"Missing AssistantMessage - got only: {message_types}. " + "This may indicate issue #406 (silent failure with filesystem agents)." + ) + assert "ResultMessage" in message_types, "Missing ResultMessage" + + # Find the init message and check for the filesystem agent + for msg in messages: + if isinstance(msg, SystemMessage) and msg.subtype == "init": + agents = msg.data.get("agents", []) + # Agents are returned as strings (just names) + assert "fs-test-agent" in agents, ( + f"fs-test-agent not loaded from filesystem. Found: {agents}" + ) + break + + # On Windows, wait for file handles to be released before cleanup + if sys.platform == "win32": + await asyncio.sleep(0.5) + + @pytest.mark.e2e @pytest.mark.asyncio async def test_setting_sources_default(): @@ -74,12 +147,12 @@ async def test_setting_sources_default(): async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": output_style = message.data.get("output_style") - assert ( - output_style != "local-test-style" - ), f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}" - assert ( - output_style == "default" - ), f"outputStyle should be 'default', got: {output_style}" + assert output_style != "local-test-style", ( + f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}" + ) + assert output_style == "default", ( + f"outputStyle should be 'default', got: {output_style}" + ) break # On Windows, wait for file handles to be released before cleanup @@ -121,9 +194,9 @@ This is a test command. async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": commands = message.data.get("slash_commands", []) - assert ( - "testcmd" not in commands - ), f"testcmd should NOT be available with user-only sources, got: {commands}" + assert "testcmd" not in commands, ( + f"testcmd should NOT be available with user-only sources, got: {commands}" + ) break # On Windows, wait for file handles to be released before cleanup @@ -159,11 +232,11 @@ async def test_setting_sources_project_included(): async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": output_style = message.data.get("output_style") - assert ( - output_style == "local-test-style" - ), f"outputStyle should be from local settings, got: {output_style}" + assert output_style == "local-test-style", ( + f"outputStyle should be from local settings, got: {output_style}" + ) break # On Windows, wait for file handles to be released before cleanup if sys.platform == "win32": - await asyncio.sleep(0.5) \ No newline at end of file + await asyncio.sleep(0.5) diff --git a/e2e-tests/test_structured_output.py b/e2e-tests/test_structured_output.py new file mode 100644 index 0000000..32e7ba2 --- /dev/null +++ b/e2e-tests/test_structured_output.py @@ -0,0 +1,204 @@ +"""End-to-end tests for structured output with real Claude API calls. + +These tests verify that the output_schema feature works correctly by making +actual API calls to Claude with JSON Schema validation. +""" + +import tempfile + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ResultMessage, + query, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_simple_structured_output(): + """Test structured output with file counting requiring tool use.""" + + # Define schema for file analysis + schema = { + "type": "object", + "properties": { + "file_count": {"type": "number"}, + "has_tests": {"type": "boolean"}, + "test_file_count": {"type": "number"}, + }, + "required": ["file_count", "has_tests"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + cwd=".", # Use current directory + ) + + # Agent must use Glob/Bash to count files + result_message = None + async for message in query( + prompt="Count how many Python files are in src/claude_agent_sdk/ and check if there are any test files. Use tools to explore the filesystem.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None, "No result message received" + assert not result_message.is_error, f"Query failed: {result_message.result}" + assert result_message.subtype == "success" + + # Verify structured output is present and valid + assert result_message.structured_output is not None, "No structured output in result" + assert "file_count" in result_message.structured_output + assert "has_tests" in result_message.structured_output + assert isinstance(result_message.structured_output["file_count"], (int, float)) + assert isinstance(result_message.structured_output["has_tests"], bool) + + # Should find Python files in src/ + assert result_message.structured_output["file_count"] > 0 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_nested_structured_output(): + """Test structured output with nested objects and arrays.""" + + # Define a schema with nested structure + schema = { + "type": "object", + "properties": { + "analysis": { + "type": "object", + "properties": { + "word_count": {"type": "number"}, + "character_count": {"type": "number"}, + }, + "required": ["word_count", "character_count"], + }, + "words": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["analysis", "words"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + ) + + result_message = None + async for message in query( + prompt="Analyze this text: 'Hello world'. Provide word count, character count, and list of words.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None + assert not result_message.is_error + assert result_message.structured_output is not None + + # Check nested structure + output = result_message.structured_output + assert "analysis" in output + assert "words" in output + assert output["analysis"]["word_count"] == 2 + assert output["analysis"]["character_count"] == 11 # "Hello world" + assert len(output["words"]) == 2 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_structured_output_with_enum(): + """Test structured output with enum constraints requiring code analysis.""" + + schema = { + "type": "object", + "properties": { + "has_tests": {"type": "boolean"}, + "test_framework": { + "type": "string", + "enum": ["pytest", "unittest", "nose", "unknown"], + }, + "test_count": {"type": "number"}, + }, + "required": ["has_tests", "test_framework"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + cwd=".", + ) + + result_message = None + async for message in query( + prompt="Search for test files in the tests/ directory. Determine which test framework is being used (pytest/unittest/nose) and count how many test files exist. Use Grep to search for framework imports.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None + assert not result_message.is_error + assert result_message.structured_output is not None + + # Check enum values are valid + output = result_message.structured_output + assert output["test_framework"] in ["pytest", "unittest", "nose", "unknown"] + assert isinstance(output["has_tests"], bool) + + # This repo uses pytest + assert output["has_tests"] is True + assert output["test_framework"] == "pytest" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_structured_output_with_tools(): + """Test structured output when agent uses tools.""" + + # Schema for file analysis + schema = { + "type": "object", + "properties": { + "file_count": {"type": "number"}, + "has_readme": {"type": "boolean"}, + }, + "required": ["file_count", "has_readme"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + cwd=tempfile.gettempdir(), # Cross-platform temp directory + ) + + result_message = None + async for message in query( + prompt="Count how many files are in the current directory and check if there's a README file. Use tools as needed.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None + assert not result_message.is_error + assert result_message.structured_output is not None + + # Check structure + output = result_message.structured_output + assert "file_count" in output + assert "has_readme" in output + assert isinstance(output["file_count"], (int, float)) + assert isinstance(output["has_readme"], bool) + assert output["file_count"] >= 0 # Should be non-negative diff --git a/examples/filesystem_agents.py b/examples/filesystem_agents.py new file mode 100644 index 0000000..e5f6904 --- /dev/null +++ b/examples/filesystem_agents.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Example of loading filesystem-based agents via setting_sources. + +This example demonstrates how to load agents defined in .claude/agents/ files +using the setting_sources option. This is different from inline AgentDefinition +objects - these agents are loaded from markdown files on disk. + +This example tests the scenario from issue #406 where filesystem-based agents +loaded via setting_sources=["project"] may silently fail in certain environments. + +Usage: +./examples/filesystem_agents.py +""" + +import asyncio +from pathlib import Path + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ResultMessage, + SystemMessage, + TextBlock, +) + + +def extract_agents(msg: SystemMessage) -> list[str]: + """Extract agent names from system message init data.""" + if msg.subtype == "init": + agents = msg.data.get("agents", []) + # Agents can be either strings or dicts with a 'name' field + result = [] + for a in agents: + if isinstance(a, str): + result.append(a) + elif isinstance(a, dict): + result.append(a.get("name", "")) + return result + return [] + + +async def main(): + """Test loading filesystem-based agents.""" + print("=== Filesystem Agents Example ===") + print("Testing: setting_sources=['project'] with .claude/agents/test-agent.md") + print() + + # Use the SDK repo directory which has .claude/agents/test-agent.md + sdk_dir = Path(__file__).parent.parent + + options = ClaudeAgentOptions( + setting_sources=["project"], + cwd=sdk_dir, + ) + + message_types: list[str] = [] + agents_found: list[str] = [] + + async with ClaudeSDKClient(options=options) as client: + await client.query("Say hello in exactly 3 words") + + async for msg in client.receive_response(): + message_types.append(type(msg).__name__) + + if isinstance(msg, SystemMessage) and msg.subtype == "init": + agents_found = extract_agents(msg) + print(f"Init message received. Agents loaded: {agents_found}") + + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Assistant: {block.text}") + + elif isinstance(msg, ResultMessage): + print( + f"Result: subtype={msg.subtype}, cost=${msg.total_cost_usd or 0:.4f}" + ) + + print() + print("=== Summary ===") + print(f"Message types received: {message_types}") + print(f"Total messages: {len(message_types)}") + + # Validate the results + has_init = "SystemMessage" in message_types + has_assistant = "AssistantMessage" in message_types + has_result = "ResultMessage" in message_types + has_test_agent = "test-agent" in agents_found + + print() + if has_init and has_assistant and has_result: + print("SUCCESS: Received full response (init, assistant, result)") + else: + print("FAILURE: Did not receive full response") + print(f" - Init: {has_init}") + print(f" - Assistant: {has_assistant}") + print(f" - Result: {has_result}") + + if has_test_agent: + print("SUCCESS: test-agent was loaded from filesystem") + else: + print("WARNING: test-agent was NOT loaded (may not exist in .claude/agents/)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/max_budget_usd.py b/examples/max_budget_usd.py new file mode 100644 index 0000000..bb9777e --- /dev/null +++ b/examples/max_budget_usd.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Example demonstrating max_budget_usd option for cost control.""" + +import anyio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + TextBlock, + query, +) + + +async def without_budget(): + """Example without budget limit.""" + print("=== Without Budget Limit ===") + + 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}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"Total cost: ${message.total_cost_usd:.4f}") + print(f"Status: {message.subtype}") + print() + + +async def with_reasonable_budget(): + """Example with budget that won't be exceeded.""" + print("=== With Reasonable Budget ($0.10) ===") + + options = ClaudeAgentOptions( + max_budget_usd=0.10, # 10 cents - plenty for a simple query + ) + + async for message in query(prompt="What is 2 + 2?", options=options): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"Total cost: ${message.total_cost_usd:.4f}") + print(f"Status: {message.subtype}") + print() + + +async def with_tight_budget(): + """Example with very tight budget that will likely be exceeded.""" + print("=== With Tight Budget ($0.0001) ===") + + options = ClaudeAgentOptions( + max_budget_usd=0.0001, # Very small budget - will be exceeded quickly + ) + + async for message in query( + prompt="Read the README.md file and summarize 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): + if message.total_cost_usd: + print(f"Total cost: ${message.total_cost_usd:.4f}") + print(f"Status: {message.subtype}") + + # Check if budget was exceeded + if message.subtype == "error_max_budget_usd": + print("⚠️ Budget limit exceeded!") + print( + "Note: The cost may exceed the budget by up to one API call's worth" + ) + print() + + +async def main(): + """Run all examples.""" + print("This example demonstrates using max_budget_usd to control API costs.\n") + + await without_budget() + await with_reasonable_budget() + await with_tight_budget() + + print( + "\nNote: Budget checking happens after each API call completes,\n" + "so the final cost may slightly exceed the specified budget.\n" + ) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/tools_option.py b/examples/tools_option.py new file mode 100644 index 0000000..204676f --- /dev/null +++ b/examples/tools_option.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Example demonstrating the tools option and verifying tools in system message.""" + +import anyio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + SystemMessage, + TextBlock, + query, +) + + +async def tools_array_example(): + """Example with tools as array of specific tool names.""" + print("=== Tools Array Example ===") + print("Setting tools=['Read', 'Glob', 'Grep']") + print() + + options = ClaudeAgentOptions( + tools=["Read", "Glob", "Grep"], + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message: {tools}") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def tools_empty_array_example(): + """Example with tools as empty array (disables all built-in tools).""" + print("=== Tools Empty Array Example ===") + print("Setting tools=[] (disables all built-in tools)") + print() + + options = ClaudeAgentOptions( + tools=[], + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message: {tools}") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def tools_preset_example(): + """Example with tools preset (all default Claude Code tools).""" + print("=== Tools Preset Example ===") + print("Setting tools={'type': 'preset', 'preset': 'claude_code'}") + print() + + options = ClaudeAgentOptions( + tools={"type": "preset", "preset": "claude_code"}, + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message ({len(tools)} tools): {tools[:5]}...") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def main(): + """Run all examples.""" + await tools_array_example() + await tools_empty_array_example() + await tools_preset_example() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/pyproject.toml b/pyproject.toml index c28566a..9058f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.5" +version = "0.1.18" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" @@ -47,6 +47,7 @@ Issues = "https://github.com/anthropics/claude-agent-sdk-python/issues" [tool.hatch.build.targets.wheel] packages = ["src/claude_agent_sdk"] +only-include = ["src/claude_agent_sdk"] [tool.hatch.build.targets.sdist] include = [ diff --git a/scripts/build_wheel.py b/scripts/build_wheel.py new file mode 100755 index 0000000..ec6799a --- /dev/null +++ b/scripts/build_wheel.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Build wheel with bundled Claude Code CLI. + +This script handles the complete wheel building process: +1. Optionally updates version +2. Downloads Claude Code CLI +3. Builds the wheel +4. Optionally cleans up the bundled CLI + +Usage: + python scripts/build_wheel.py # Build with current version + python scripts/build_wheel.py --version 0.1.4 # Build with specific version + python scripts/build_wheel.py --clean # Clean bundled CLI after build + python scripts/build_wheel.py --skip-download # Skip CLI download (use existing) +""" + +import argparse +import os +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +try: + import twine # noqa: F401 + + HAS_TWINE = True +except ImportError: + HAS_TWINE = False + + +def run_command(cmd: list[str], description: str) -> None: + """Run a command and handle errors.""" + print(f"\n{'=' * 60}") + print(f"{description}") + print(f"{'=' * 60}") + print(f"$ {' '.join(cmd)}") + print() + + try: + result = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Error: {description} failed", file=sys.stderr) + print(e.stdout, file=sys.stderr) + sys.exit(1) + + +def update_version(version: str) -> None: + """Update package version.""" + script_dir = Path(__file__).parent + update_script = script_dir / "update_version.py" + + if not update_script.exists(): + print("Warning: update_version.py not found, skipping version update") + return + + run_command( + [sys.executable, str(update_script), version], + f"Updating version to {version}", + ) + + +def get_bundled_cli_version() -> str: + """Get the CLI version that should be bundled from _cli_version.py.""" + version_file = Path("src/claude_agent_sdk/_cli_version.py") + if not version_file.exists(): + return "latest" + + content = version_file.read_text() + match = re.search(r'__cli_version__ = "([^"]+)"', content) + if match: + return match.group(1) + return "latest" + + +def download_cli(cli_version: str | None = None) -> None: + """Download Claude Code CLI.""" + # Use provided version, or fall back to version from _cli_version.py + if cli_version is None: + cli_version = get_bundled_cli_version() + + script_dir = Path(__file__).parent + download_script = script_dir / "download_cli.py" + + # Set environment variable for download script + os.environ["CLAUDE_CLI_VERSION"] = cli_version + + run_command( + [sys.executable, str(download_script)], + f"Downloading Claude Code CLI ({cli_version})", + ) + + +def clean_dist() -> None: + """Clean dist directory.""" + dist_dir = Path("dist") + if dist_dir.exists(): + print(f"\n{'=' * 60}") + print("Cleaning dist directory") + print(f"{'=' * 60}") + shutil.rmtree(dist_dir) + print("Cleaned dist/") + + +def get_platform_tag() -> str: + """Get the appropriate platform tag for the current platform. + + Uses minimum compatible versions for broad compatibility: + - macOS: 11.0 (Big Sur) as minimum + - Linux: manylinux_2_17 (widely compatible) + - Windows: Standard tags + """ + system = platform.system() + machine = platform.machine().lower() + + if system == "Darwin": + # macOS - use minimum version 11.0 (Big Sur) for broad compatibility + if machine == "arm64": + return "macosx_11_0_arm64" + else: + return "macosx_11_0_x86_64" + elif system == "Linux": + # Linux - use manylinux for broad compatibility + if machine in ["x86_64", "amd64"]: + return "manylinux_2_17_x86_64" + elif machine in ["aarch64", "arm64"]: + return "manylinux_2_17_aarch64" + else: + return f"linux_{machine}" + elif system == "Windows": + # Windows + if machine in ["x86_64", "amd64"]: + return "win_amd64" + elif machine == "arm64": + return "win_arm64" + else: + return "win32" + else: + # Unknown platform, use generic + return f"{system.lower()}_{machine}" + + +def retag_wheel(wheel_path: Path, platform_tag: str) -> Path: + """Retag a wheel with the correct platform tag using wheel package.""" + print(f"\n{'=' * 60}") + print("Retagging wheel as platform-specific") + print(f"{'=' * 60}") + print(f"Old: {wheel_path.name}") + + # Use wheel package to properly retag (updates both filename and metadata) + result = subprocess.run( + [ + sys.executable, + "-m", + "wheel", + "tags", + "--platform-tag", + platform_tag, + "--remove", + str(wheel_path), + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"Warning: Failed to retag wheel: {result.stderr}") + return wheel_path + + # Find the newly tagged wheel + dist_dir = wheel_path.parent + # The wheel package creates a new file with the platform tag + new_wheels = list(dist_dir.glob(f"*{platform_tag}.whl")) + + if new_wheels: + new_path = new_wheels[0] + print(f"New: {new_path.name}") + print("Wheel retagged successfully") + + # Remove the old wheel + if wheel_path.exists() and wheel_path != new_path: + wheel_path.unlink() + + return new_path + else: + print("Warning: Could not find retagged wheel") + return wheel_path + + +def build_wheel() -> None: + """Build the wheel.""" + run_command( + [sys.executable, "-m", "build", "--wheel"], + "Building wheel", + ) + + # Check if we have a bundled CLI - if so, retag the wheel as platform-specific + bundled_cli = Path("src/claude_agent_sdk/_bundled/claude") + bundled_cli_exe = Path("src/claude_agent_sdk/_bundled/claude.exe") + + if bundled_cli.exists() or bundled_cli_exe.exists(): + # Find the built wheel + dist_dir = Path("dist") + wheels = list(dist_dir.glob("*.whl")) + + if wheels: + # Get platform tag + platform_tag = get_platform_tag() + + # Retag each wheel (should only be one) + for wheel in wheels: + if "-any.whl" in wheel.name: + retag_wheel(wheel, platform_tag) + else: + print("Warning: No wheel found to retag") + else: + print("\nNo bundled CLI found - wheel will be platform-independent") + + +def build_sdist() -> None: + """Build the source distribution.""" + run_command( + [sys.executable, "-m", "build", "--sdist"], + "Building source distribution", + ) + + +def check_package() -> None: + """Check package with twine.""" + if not HAS_TWINE: + print("\nWarning: twine not installed, skipping package check") + print("Install with: pip install twine") + return + + print(f"\n{'=' * 60}") + print("Checking package with twine") + print(f"{'=' * 60}") + print(f"$ {sys.executable} -m twine check dist/*") + print() + + try: + result = subprocess.run( + [sys.executable, "-m", "twine", "check", "dist/*"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print(result.stdout) + + if result.returncode != 0: + print("\nWarning: twine check reported issues") + print("Note: 'License-File' warnings are false positives from twine 6.x") + print("PyPI will accept these packages without issues") + else: + print("Package check passed") + except Exception as e: + print(f"Warning: Failed to run twine check: {e}") + + +def clean_bundled_cli() -> None: + """Clean bundled CLI.""" + bundled_dir = Path("src/claude_agent_sdk/_bundled") + cli_files = list(bundled_dir.glob("claude*")) + + if cli_files: + print(f"\n{'=' * 60}") + print("Cleaning bundled CLI") + print(f"{'=' * 60}") + for cli_file in cli_files: + if cli_file.name != ".gitignore": + cli_file.unlink() + print(f"Removed {cli_file}") + else: + print("\nNo bundled CLI to clean") + + +def list_artifacts() -> None: + """List built artifacts.""" + dist_dir = Path("dist") + if not dist_dir.exists(): + return + + print(f"\n{'=' * 60}") + print("Built Artifacts") + print(f"{'=' * 60}") + + artifacts = sorted(dist_dir.iterdir()) + if not artifacts: + print("No artifacts found") + return + + for artifact in artifacts: + size_mb = artifact.stat().st_size / (1024 * 1024) + print(f" {artifact.name:<50} {size_mb:>8.2f} MB") + + total_size = sum(f.stat().st_size for f in artifacts) / (1024 * 1024) + print(f"\n {'Total:':<50} {total_size:>8.2f} MB") + + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Build wheel with bundled Claude Code CLI" + ) + parser.add_argument( + "--version", + help="Version to set before building (e.g., 0.1.4)", + ) + parser.add_argument( + "--cli-version", + default=None, + help="Claude Code CLI version to download (default: read from _cli_version.py)", + ) + parser.add_argument( + "--skip-download", + action="store_true", + help="Skip downloading CLI (use existing bundled CLI)", + ) + parser.add_argument( + "--skip-sdist", + action="store_true", + help="Skip building source distribution", + ) + parser.add_argument( + "--clean", + action="store_true", + help="Clean bundled CLI after building", + ) + parser.add_argument( + "--clean-dist", + action="store_true", + help="Clean dist directory before building", + ) + + args = parser.parse_args() + + print("\n" + "=" * 60) + print("Claude Agent SDK - Wheel Builder") + print("=" * 60) + + # Clean dist if requested + if args.clean_dist: + clean_dist() + + # Update version if specified + if args.version: + update_version(args.version) + + # Download CLI unless skipped + if not args.skip_download: + download_cli(args.cli_version) + else: + print("\nSkipping CLI download (using existing)") + + # Build wheel + build_wheel() + + # Build sdist unless skipped + if not args.skip_sdist: + build_sdist() + + # Check package + check_package() + + # Clean bundled CLI if requested + if args.clean: + clean_bundled_cli() + + # List artifacts + list_artifacts() + + print(f"\n{'=' * 60}") + print("Build complete!") + print(f"{'=' * 60}") + print("\nNext steps:") + print(" 1. Test the wheel: pip install dist/*.whl") + print(" 2. Run tests: python -m pytest tests/") + print(" 3. Publish: twine upload dist/*") + + +if __name__ == "__main__": + main() diff --git a/scripts/download_cli.py b/scripts/download_cli.py new file mode 100755 index 0000000..45d39df --- /dev/null +++ b/scripts/download_cli.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Download Claude Code CLI binary for bundling in wheel. + +This script is run during the wheel build process to fetch the Claude Code CLI +binary using the official install script and place it in the package directory. +""" + +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +def get_cli_version() -> str: + """Get the CLI version to download from environment or default.""" + return os.environ.get("CLAUDE_CLI_VERSION", "latest") + + +def find_installed_cli() -> Path | None: + """Find the installed Claude CLI binary.""" + system = platform.system() + + if system == "Windows": + # Windows installation locations (matches test.yml: $USERPROFILE\.local\bin) + locations = [ + Path.home() / ".local" / "bin" / "claude.exe", + Path(os.environ.get("LOCALAPPDATA", "")) / "Claude" / "claude.exe", + ] + else: + # Unix installation locations + locations = [ + Path.home() / ".local" / "bin" / "claude", + Path("/usr/local/bin/claude"), + Path.home() / "node_modules" / ".bin" / "claude", + ] + + # Also check PATH + cli_path = shutil.which("claude") + if cli_path: + return Path(cli_path) + + for path in locations: + if path.exists() and path.is_file(): + return path + + return None + + +def download_cli() -> None: + """Download Claude Code CLI using the official install script.""" + version = get_cli_version() + system = platform.system() + + print(f"Downloading Claude Code CLI version: {version}") + + # Build install command based on platform + if system == "Windows": + # Use PowerShell installer on Windows + if version == "latest": + install_cmd = [ + "powershell", + "-ExecutionPolicy", + "Bypass", + "-Command", + "irm https://claude.ai/install.ps1 | iex", + ] + else: + install_cmd = [ + "powershell", + "-ExecutionPolicy", + "Bypass", + "-Command", + f"& ([scriptblock]::Create((irm https://claude.ai/install.ps1))) {version}", + ] + else: + # Use bash installer on Unix-like systems + if version == "latest": + install_cmd = ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"] + else: + install_cmd = [ + "bash", + "-c", + f"curl -fsSL https://claude.ai/install.sh | bash -s {version}", + ] + + try: + subprocess.run( + install_cmd, + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + print(f"Error downloading CLI: {e}", file=sys.stderr) + print(f"stdout: {e.stdout.decode()}", file=sys.stderr) + print(f"stderr: {e.stderr.decode()}", file=sys.stderr) + sys.exit(1) + + +def copy_cli_to_bundle() -> None: + """Copy the installed CLI to the package _bundled directory.""" + # Find project root (parent of scripts directory) + script_dir = Path(__file__).parent + project_root = script_dir.parent + bundle_dir = project_root / "src" / "claude_agent_sdk" / "_bundled" + + # Ensure bundle directory exists + bundle_dir.mkdir(parents=True, exist_ok=True) + + # Find installed CLI + cli_path = find_installed_cli() + if not cli_path: + print("Error: Could not find installed Claude CLI binary", file=sys.stderr) + sys.exit(1) + + print(f"Found CLI at: {cli_path}") + + # Determine target filename based on platform + system = platform.system() + target_name = "claude.exe" if system == "Windows" else "claude" + target_path = bundle_dir / target_name + + # Copy the binary + print(f"Copying CLI to: {target_path}") + shutil.copy2(cli_path, target_path) + + # Make it executable (Unix-like systems) + if system != "Windows": + target_path.chmod(0o755) + + print(f"Successfully bundled CLI binary: {target_path}") + + # Print size info + size_mb = target_path.stat().st_size / (1024 * 1024) + print(f"Binary size: {size_mb:.2f} MB") + + +def main() -> None: + """Main entry point.""" + print("=" * 60) + print("Claude Code CLI Download Script") + print("=" * 60) + + # Download CLI + download_cli() + + # Copy to bundle directory + copy_cli_to_bundle() + + print("=" * 60) + print("CLI download and bundling complete!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-docker.sh b/scripts/test-docker.sh new file mode 100755 index 0000000..2cf9889 --- /dev/null +++ b/scripts/test-docker.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Run SDK tests in a Docker container +# This helps catch Docker-specific issues like #406 +# +# Usage: +# ./scripts/test-docker.sh [unit|e2e|all] +# +# Examples: +# ./scripts/test-docker.sh unit # Run unit tests only +# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh e2e # Run e2e tests +# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh all # Run all tests + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +usage() { + echo "Usage: $0 [unit|e2e|all]" + echo "" + echo "Commands:" + echo " unit - Run unit tests only (no API key needed)" + echo " e2e - Run e2e tests (requires ANTHROPIC_API_KEY)" + echo " all - Run both unit and e2e tests" + echo "" + echo "Examples:" + echo " $0 unit" + echo " ANTHROPIC_API_KEY=sk-... $0 e2e" + exit 1 +} + +echo "Building Docker test image..." +docker build -f Dockerfile.test -t claude-sdk-test . + +case "${1:-unit}" in + unit) + echo "" + echo "Running unit tests in Docker..." + docker run --rm claude-sdk-test \ + python -m pytest tests/ -v + ;; + e2e) + if [ -z "$ANTHROPIC_API_KEY" ]; then + echo "Error: ANTHROPIC_API_KEY environment variable is required for e2e tests" + echo "" + echo "Usage: ANTHROPIC_API_KEY=sk-... $0 e2e" + exit 1 + fi + echo "" + echo "Running e2e tests in Docker..." + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + ;; + all) + echo "" + echo "Running unit tests in Docker..." + docker run --rm claude-sdk-test \ + python -m pytest tests/ -v + + echo "" + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "Running e2e tests in Docker..." + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + else + echo "Skipping e2e tests (ANTHROPIC_API_KEY not set)" + fi + ;; + *) + usage + ;; +esac + +echo "" +echo "Done!" diff --git a/scripts/update_cli_version.py b/scripts/update_cli_version.py new file mode 100755 index 0000000..1ef17c7 --- /dev/null +++ b/scripts/update_cli_version.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Update Claude Code CLI version in _cli_version.py.""" + +import re +import sys +from pathlib import Path + + +def update_cli_version(new_version: str) -> None: + """Update CLI version in _cli_version.py.""" + # Update _cli_version.py + version_path = Path("src/claude_agent_sdk/_cli_version.py") + content = version_path.read_text() + + content = re.sub( + r'__cli_version__ = "[^"]+"', + f'__cli_version__ = "{new_version}"', + content, + count=1, + flags=re.MULTILINE, + ) + + version_path.write_text(content) + print(f"Updated {version_path} to {new_version}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python scripts/update_cli_version.py ") + sys.exit(1) + + update_cli_version(sys.argv[1]) diff --git a/scripts/update_version.py b/scripts/update_version.py index 743b40f..b980d52 100755 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Update version in pyproject.toml and __init__.py files.""" -import sys import re +import sys from pathlib import Path @@ -18,7 +18,7 @@ def update_version(new_version: str) -> None: f'version = "{new_version}"', content, count=1, - flags=re.MULTILINE + flags=re.MULTILINE, ) pyproject_path.write_text(content) @@ -34,7 +34,7 @@ def update_version(new_version: str) -> None: f'__version__ = "{new_version}"', content, count=1, - flags=re.MULTILINE + flags=re.MULTILINE, ) version_path.write_text(content) @@ -45,5 +45,5 @@ if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: python scripts/update_version.py ") sys.exit(1) - - update_version(sys.argv[1]) \ No newline at end of file + + update_version(sys.argv[1]) diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index cde28be..4898bc0 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -39,6 +39,10 @@ from .types import ( PreCompactHookInput, PreToolUseHookInput, ResultMessage, + SandboxIgnoreViolations, + SandboxNetworkConfig, + SandboxSettings, + SdkBeta, SdkPluginConfig, SettingSource, StopHookInput, @@ -215,7 +219,7 @@ def create_sdk_mcp_server( tool_map = {tool_def.name: tool_def for tool_def in tools} # Register list_tools handler to expose available tools - @server.list_tools() # type: ignore[no-untyped-call,misc] + @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] async def list_tools() -> list[Tool]: """Return the list of available tools.""" tool_list = [] @@ -261,7 +265,7 @@ def create_sdk_mcp_server( return tool_list # Register call_tool handler to execute tools - @server.call_tool() # type: ignore[misc] + @server.call_tool() # type: ignore[untyped-decorator] async def call_tool(name: str, arguments: dict[str, Any]) -> Any: """Execute a tool by name with given arguments.""" if name not in tool_map: @@ -342,6 +346,12 @@ __all__ = [ "SettingSource", # Plugin support "SdkPluginConfig", + # Beta support + "SdkBeta", + # Sandbox support + "SandboxSettings", + "SandboxNetworkConfig", + "SandboxIgnoreViolations", # MCP Server Support "create_sdk_mcp_server", "tool", diff --git a/src/claude_agent_sdk/_bundled/.gitignore b/src/claude_agent_sdk/_bundled/.gitignore new file mode 100644 index 0000000..b8f0354 --- /dev/null +++ b/src/claude_agent_sdk/_bundled/.gitignore @@ -0,0 +1,3 @@ +# Ignore bundled CLI binaries (downloaded during build) +claude +claude.exe diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py new file mode 100644 index 0000000..8e7a72d --- /dev/null +++ b/src/claude_agent_sdk/_cli_version.py @@ -0,0 +1,3 @@ +"""Bundled Claude Code CLI version.""" + +__cli_version__ = "2.0.74" diff --git a/src/claude_agent_sdk/_internal/client.py b/src/claude_agent_sdk/_internal/client.py index 6dbc877..5246627 100644 --- a/src/claude_agent_sdk/_internal/client.py +++ b/src/claude_agent_sdk/_internal/client.py @@ -31,10 +31,12 @@ class InternalClient: internal_hooks[event] = [] for matcher in matchers: # Convert HookMatcher to internal dict format - internal_matcher = { + internal_matcher: dict[str, Any] = { "matcher": matcher.matcher if hasattr(matcher, "matcher") else None, "hooks": matcher.hooks if hasattr(matcher, "hooks") else [], } + if hasattr(matcher, "timeout") and matcher.timeout is not None: + internal_matcher["timeout"] = matcher.timeout internal_hooks[event].append(internal_matcher) return internal_hooks diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 6532a20..4bfe814 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -48,6 +48,7 @@ def parse_message(data: dict[str, Any]) -> Message: case "user": try: parent_tool_use_id = data.get("parent_tool_use_id") + uuid = data.get("uuid") if isinstance(data["message"]["content"], list): user_content_blocks: list[ContentBlock] = [] for block in data["message"]["content"]: @@ -74,10 +75,12 @@ def parse_message(data: dict[str, Any]) -> Message: ) return UserMessage( content=user_content_blocks, + uuid=uuid, parent_tool_use_id=parent_tool_use_id, ) return UserMessage( content=data["message"]["content"], + uuid=uuid, parent_tool_use_id=parent_tool_use_id, ) except KeyError as e: @@ -120,6 +123,7 @@ def parse_message(data: dict[str, Any]) -> Message: content=content_blocks, model=data["message"]["model"], parent_tool_use_id=data.get("parent_tool_use_id"), + error=data["message"].get("error"), ) except KeyError as e: raise MessageParseError( @@ -149,6 +153,7 @@ def parse_message(data: dict[str, Any]) -> Message: total_cost_usd=data.get("total_cost_usd"), usage=data.get("usage"), result=data.get("result"), + structured_output=data.get("structured_output"), ) except KeyError as e: raise MessageParseError( diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 7646010..c30fc15 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -72,6 +72,7 @@ class Query: | None = None, hooks: dict[str, list[dict[str, Any]]] | None = None, sdk_mcp_servers: dict[str, "McpServer"] | None = None, + initialize_timeout: float = 60.0, ): """Initialize Query with transport and callbacks. @@ -81,7 +82,9 @@ class Query: can_use_tool: Optional callback for tool permission requests hooks: Optional hook configurations sdk_mcp_servers: Optional SDK MCP server instances + initialize_timeout: Timeout in seconds for the initialize request """ + self._initialize_timeout = initialize_timeout self.transport = transport self.is_streaming_mode = is_streaming_mode self.can_use_tool = can_use_tool @@ -104,6 +107,12 @@ class Query: self._closed = False self._initialization_result: dict[str, Any] | None = None + # Track first result for proper stream closure with SDK MCP servers + self._first_result_event = anyio.Event() + self._stream_close_timeout = ( + float(os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000")) / 1000.0 + ) # Convert ms to seconds + async def initialize(self) -> dict[str, Any] | None: """Initialize control protocol if in streaming mode. @@ -126,12 +135,13 @@ class Query: self.next_callback_id += 1 self.hook_callbacks[callback_id] = callback callback_ids.append(callback_id) - hooks_config[event].append( - { - "matcher": matcher.get("matcher"), - "hookCallbackIds": callback_ids, - } - ) + hook_matcher_config: dict[str, Any] = { + "matcher": matcher.get("matcher"), + "hookCallbackIds": callback_ids, + } + if matcher.get("timeout") is not None: + hook_matcher_config["timeout"] = matcher.get("timeout") + hooks_config[event].append(hook_matcher_config) # Send initialize request request = { @@ -139,7 +149,10 @@ class Query: "hooks": hooks_config if hooks_config else None, } - response = await self._send_control_request(request) + # Use longer timeout for initialize since MCP servers may take time to start + response = await self._send_control_request( + request, timeout=self._initialize_timeout + ) self._initialized = True self._initialization_result = response # Store for later access return response @@ -188,6 +201,10 @@ class Query: # TODO: Implement cancellation support continue + # Track results for proper stream closure + if msg_type == "result": + self._first_result_event.set() + # Regular SDK messages go to the stream await self._message_send.send(message) @@ -197,6 +214,11 @@ class Query: raise # Re-raise to properly handle cancellation except Exception as e: logger.error(f"Fatal error in message reader: {e}") + # Signal all pending control requests so they fail fast instead of timing out + for request_id, event in list(self.pending_control_responses.items()): + if request_id not in self.pending_control_results: + self.pending_control_results[request_id] = e + event.set() # Put error in stream so iterators can handle it await self._message_send.send({"type": "error", "error": str(e)}) finally: @@ -314,8 +336,15 @@ class Query: } await self.transport.write(json.dumps(error_response) + "\n") - async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any]: - """Send control request to CLI and wait for response.""" + async def _send_control_request( + self, request: dict[str, Any], timeout: float = 60.0 + ) -> dict[str, Any]: + """Send control request to CLI and wait for response. + + Args: + request: The control request to send + timeout: Timeout in seconds to wait for response (default 60s) + """ if not self.is_streaming_mode: raise Exception("Control requests require streaming mode") @@ -338,7 +367,7 @@ class Query: # Wait for response try: - with anyio.fail_after(60.0): + with anyio.fail_after(timeout): await event.wait() result = self.pending_control_results.pop(request_id) @@ -510,14 +539,51 @@ class Query: } ) + async def rewind_files(self, user_message_id: str) -> None: + """Rewind tracked files to their state at a specific user message. + + Requires file checkpointing to be enabled via the `enable_file_checkpointing` option. + + Args: + user_message_id: UUID of the user message to rewind to + """ + await self._send_control_request( + { + "subtype": "rewind_files", + "user_message_id": user_message_id, + } + ) + async def stream_input(self, stream: AsyncIterable[dict[str, Any]]) -> None: - """Stream input messages to transport.""" + """Stream input messages to transport. + + If SDK MCP servers or hooks are present, waits for the first result + before closing stdin to allow bidirectional control protocol communication. + """ try: async for message in stream: if self._closed: break await self.transport.write(json.dumps(message) + "\n") - # After all messages sent, end input + + # If we have SDK MCP servers or hooks that need bidirectional communication, + # wait for first result before closing the channel + has_hooks = bool(self.hooks) + if self.sdk_mcp_servers or has_hooks: + logger.debug( + f"Waiting for first result before closing stdin " + f"(sdk_mcp_servers={len(self.sdk_mcp_servers)}, has_hooks={has_hooks})" + ) + try: + with anyio.move_on_after(self._stream_close_timeout): + await self._first_result_event.wait() + logger.debug("Received first result, closing input stream") + except Exception: + logger.debug( + "Timed out waiting for first result, closing input stream" + ) + + # After all messages sent (and result received if needed), end input await self.transport.end_input() except Exception as e: logger.debug(f"Error streaming input: {e}") diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 1ec352f..a4882db 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -65,9 +65,16 @@ class SubprocessCLITransport(Transport): else _DEFAULT_MAX_BUFFER_SIZE ) self._temp_files: list[str] = [] # Track temporary files for cleanup + self._write_lock: anyio.Lock = anyio.Lock() def _find_cli(self) -> str: """Find Claude Code CLI binary.""" + # First, check for bundled CLI + bundled_cli = self._find_bundled_cli() + if bundled_cli: + return bundled_cli + + # Fall back to system-wide search if cli := shutil.which("claude"): return cli @@ -77,6 +84,7 @@ class SubprocessCLITransport(Transport): Path.home() / ".local/bin/claude", Path.home() / "node_modules/.bin/claude", Path.home() / ".yarn/bin/claude", + Path.home() / ".claude/local/claude", ] for path in locations: @@ -92,12 +100,81 @@ class SubprocessCLITransport(Transport): " ClaudeAgentOptions(cli_path='/path/to/claude')" ) + def _find_bundled_cli(self) -> str | None: + """Find bundled CLI binary if it exists.""" + # Determine the CLI binary name based on platform + cli_name = "claude.exe" if platform.system() == "Windows" else "claude" + + # Get the path to the bundled CLI + # The _bundled directory is in the same package as this module + bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name + + if bundled_path.exists() and bundled_path.is_file(): + logger.info(f"Using bundled Claude Code CLI: {bundled_path}") + return str(bundled_path) + + return None + + def _build_settings_value(self) -> str | None: + """Build settings value, merging sandbox settings if provided. + + Returns the settings value as either: + - A JSON string (if sandbox is provided or settings is JSON) + - A file path (if only settings path is provided without sandbox) + - None if neither settings nor sandbox is provided + """ + has_settings = self._options.settings is not None + has_sandbox = self._options.sandbox is not None + + if not has_settings and not has_sandbox: + return None + + # If only settings path and no sandbox, pass through as-is + if has_settings and not has_sandbox: + return self._options.settings + + # If we have sandbox settings, we need to merge into a JSON object + settings_obj: dict[str, Any] = {} + + if has_settings: + assert self._options.settings is not None + settings_str = self._options.settings.strip() + # Check if settings is a JSON string or a file path + if settings_str.startswith("{") and settings_str.endswith("}"): + # Parse JSON string + try: + settings_obj = json.loads(settings_str) + except json.JSONDecodeError: + # If parsing fails, treat as file path + logger.warning( + f"Failed to parse settings as JSON, treating as file path: {settings_str}" + ) + # Read the file + settings_path = Path(settings_str) + if settings_path.exists(): + with settings_path.open(encoding="utf-8") as f: + settings_obj = json.load(f) + else: + # It's a file path - read and parse + settings_path = Path(settings_str) + if settings_path.exists(): + with settings_path.open(encoding="utf-8") as f: + settings_obj = json.load(f) + else: + logger.warning(f"Settings file not found: {settings_path}") + + # Merge sandbox settings + if has_sandbox: + settings_obj["sandbox"] = self._options.sandbox + + return json.dumps(settings_obj) + 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 is None: - pass + cmd.extend(["--system-prompt", ""]) elif isinstance(self._options.system_prompt, str): cmd.extend(["--system-prompt", self._options.system_prompt]) else: @@ -109,18 +186,39 @@ class SubprocessCLITransport(Transport): ["--append-system-prompt", self._options.system_prompt["append"]] ) + # Handle tools option (base set of tools) + if self._options.tools is not None: + tools = self._options.tools + if isinstance(tools, list): + if len(tools) == 0: + cmd.extend(["--tools", ""]) + else: + cmd.extend(["--tools", ",".join(tools)]) + else: + # Preset object - 'claude_code' preset maps to 'default' + cmd.extend(["--tools", "default"]) + 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.max_budget_usd is not None: + cmd.extend(["--max-budget-usd", str(self._options.max_budget_usd)]) + 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.fallback_model: + cmd.extend(["--fallback-model", self._options.fallback_model]) + + if self._options.betas: + cmd.extend(["--betas", ",".join(self._options.betas)]) + if self._options.permission_prompt_tool_name: cmd.extend( ["--permission-prompt-tool", self._options.permission_prompt_tool_name] @@ -135,8 +233,10 @@ class SubprocessCLITransport(Transport): if self._options.resume: cmd.extend(["--resume", self._options.resume]) - if self._options.settings: - cmd.extend(["--settings", self._options.settings]) + # Handle settings and sandbox: merge sandbox into settings if both are provided + settings_value = self._build_settings_value() + if settings_value: + cmd.extend(["--settings", settings_value]) if self._options.add_dirs: # Convert all paths to strings and add each directory @@ -208,7 +308,24 @@ class SubprocessCLITransport(Transport): # Flag with value cmd.extend([f"--{flag}", str(value)]) + if self._options.max_thinking_tokens is not None: + cmd.extend( + ["--max-thinking-tokens", str(self._options.max_thinking_tokens)] + ) + + # Extract schema from output_format structure if provided + # Expected: {"type": "json_schema", "schema": {...}} + if ( + self._options.output_format is not None + and isinstance(self._options.output_format, dict) + and self._options.output_format.get("type") == "json_schema" + ): + schema = self._options.output_format.get("schema") + if schema is not None: + cmd.extend(["--json-schema", json.dumps(schema)]) + # Add prompt handling based on mode + # IMPORTANT: This must come AFTER all flags because everything after "--" is treated as arguments if self._is_streaming: # Streaming mode: use --input-format stream-json cmd.extend(["--input-format", "stream-json"]) @@ -267,6 +384,10 @@ class SubprocessCLITransport(Transport): "CLAUDE_AGENT_SDK_VERSION": __version__, } + # Enable file checkpointing if requested + if self._options.enable_file_checkpointing: + process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true" + if self._cwd: process_env["PWD"] = self._cwd @@ -355,8 +476,6 @@ class SubprocessCLITransport(Transport): async def close(self) -> None: """Close the transport and clean up resources.""" - self._ready = False - # Clean up temporary files first (before early return) for temp_file in self._temp_files: with suppress(Exception): @@ -364,6 +483,7 @@ class SubprocessCLITransport(Transport): self._temp_files.clear() if not self._process: + self._ready = False return # Close stderr task group if active @@ -373,21 +493,19 @@ class SubprocessCLITransport(Transport): await self._stderr_task_group.__aexit__(None, None, None) self._stderr_task_group = None - # Close streams - if self._stdin_stream: - with suppress(Exception): - await self._stdin_stream.aclose() - self._stdin_stream = None + # Close stdin stream (acquire lock to prevent race with concurrent writes) + async with self._write_lock: + self._ready = False # Set inside lock to prevent TOCTOU with write() + if self._stdin_stream: + with suppress(Exception): + await self._stdin_stream.aclose() + self._stdin_stream = None if self._stderr_stream: with suppress(Exception): await self._stderr_stream.aclose() self._stderr_stream = None - if self._process.stdin: - with suppress(Exception): - await self._process.stdin.aclose() - # Terminate and wait for process if self._process.returncode is None: with suppress(ProcessLookupError): @@ -405,37 +523,37 @@ class SubprocessCLITransport(Transport): async def write(self, data: str) -> None: """Write raw data to the transport.""" - # Check if ready (like TypeScript) - if not self._ready or not self._stdin_stream: - raise CLIConnectionError("ProcessTransport is not ready for writing") + async with self._write_lock: + # All checks inside lock to prevent TOCTOU races with close()/end_input() + if not self._ready or not self._stdin_stream: + raise CLIConnectionError("ProcessTransport is not ready for writing") - # Check if process is still alive (like TypeScript) - if self._process and self._process.returncode is not None: - raise CLIConnectionError( - f"Cannot write to terminated process (exit code: {self._process.returncode})" - ) + if self._process and self._process.returncode is not None: + raise CLIConnectionError( + f"Cannot write to terminated process (exit code: {self._process.returncode})" + ) - # Check for exit errors (like TypeScript) - if self._exit_error: - raise CLIConnectionError( - f"Cannot write to process that exited with error: {self._exit_error}" - ) from self._exit_error + if self._exit_error: + raise CLIConnectionError( + f"Cannot write to process that exited with error: {self._exit_error}" + ) from self._exit_error - try: - await self._stdin_stream.send(data) - except Exception as e: - self._ready = False # Mark as not ready (like TypeScript) - self._exit_error = CLIConnectionError( - f"Failed to write to process stdin: {e}" - ) - raise self._exit_error from e + try: + await self._stdin_stream.send(data) + except Exception as e: + self._ready = False + self._exit_error = CLIConnectionError( + f"Failed to write to process stdin: {e}" + ) + raise self._exit_error from e async def end_input(self) -> None: """End the input stream (close stdin).""" - if self._stdin_stream: - with suppress(Exception): - await self._stdin_stream.aclose() - self._stdin_stream = None + async with self._write_lock: + if self._stdin_stream: + with suppress(Exception): + await self._stdin_stream.aclose() + self._stdin_stream = None def read_messages(self) -> AsyncIterator[dict[str, Any]]: """Read and parse messages from the transport.""" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index f241736..de9a16c 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.5" +__version__ = "0.1.18" diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index f95b50b..18ab818 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -75,10 +75,12 @@ class ClaudeSDKClient: internal_hooks[event] = [] for matcher in matchers: # Convert HookMatcher to internal dict format - internal_matcher = { + internal_matcher: dict[str, Any] = { "matcher": matcher.matcher if hasattr(matcher, "matcher") else None, "hooks": matcher.hooks if hasattr(matcher, "hooks") else [], } + if hasattr(matcher, "timeout") and matcher.timeout is not None: + internal_matcher["timeout"] = matcher.timeout internal_hooks[event].append(internal_matcher) return internal_hooks @@ -138,6 +140,13 @@ class ClaudeSDKClient: if isinstance(config, dict) and config.get("type") == "sdk": sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item] + # Calculate initialize timeout from CLAUDE_CODE_STREAM_CLOSE_TIMEOUT env var if set + # CLAUDE_CODE_STREAM_CLOSE_TIMEOUT is in milliseconds, convert to seconds + initialize_timeout_ms = int( + os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000") + ) + initialize_timeout = max(initialize_timeout_ms / 1000.0, 60.0) + # Create Query to handle control protocol self._query = Query( transport=self._transport, @@ -147,6 +156,7 @@ class ClaudeSDKClient: if self.options.hooks else None, sdk_mcp_servers=sdk_mcp_servers, + initialize_timeout=initialize_timeout, ) # Start reading messages and initialize @@ -251,6 +261,38 @@ class ClaudeSDKClient: raise CLIConnectionError("Not connected. Call connect() first.") await self._query.set_model(model) + async def rewind_files(self, user_message_id: str) -> None: + """Rewind tracked files to their state at a specific user message. + + Requires: + - `enable_file_checkpointing=True` to track file changes + - `extra_args={"replay-user-messages": None}` to receive UserMessage + objects with `uuid` in the response stream + + Args: + user_message_id: UUID of the user message to rewind to. This should be + the `uuid` field from a `UserMessage` received during the conversation. + + Example: + ```python + options = ClaudeAgentOptions( + enable_file_checkpointing=True, + extra_args={"replay-user-messages": None}, + ) + async with ClaudeSDKClient(options) as client: + await client.query("Make some changes to my files") + async for msg in client.receive_response(): + if isinstance(msg, UserMessage) and msg.uuid: + checkpoint_id = msg.uuid # Save this for later + + # Later, rewind to that point + await client.rewind_files(checkpoint_id) + ``` + """ + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + await self._query.rewind_files(user_message_id) + async def get_server_info(self) -> dict[str, Any] | None: """Get server initialization info including available commands and output styles. diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index efeaf70..9c09345 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -10,10 +10,16 @@ from typing_extensions import NotRequired if TYPE_CHECKING: from mcp.server import Server as McpServer +else: + # Runtime placeholder for forward reference resolution in Pydantic 2.12+ + McpServer = Any # Permission modes PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] +# SDK Beta features - see https://docs.anthropic.com/en/api/beta-headers +SdkBeta = Literal["context-1m-2025-08-07"] + # Agent definitions SettingSource = Literal["user", "project", "local"] @@ -26,6 +32,13 @@ class SystemPromptPreset(TypedDict): append: NotRequired[str] +class ToolsPreset(TypedDict): + """Tools preset configuration.""" + + type: Literal["preset"] + preset: Literal["claude_code"] + + @dataclass class AgentDefinition: """Agent definition configuration.""" @@ -366,6 +379,9 @@ class HookMatcher: # A list of Python functions with function signature HookCallback hooks: list[HookCallback] = field(default_factory=list) + # Timeout in seconds for all hooks in this matcher (default: 60) + timeout: float | None = None + # MCP Server config class McpStdioServerConfig(TypedDict): @@ -416,6 +432,83 @@ class SdkPluginConfig(TypedDict): path: str +# Sandbox configuration types +class SandboxNetworkConfig(TypedDict, total=False): + """Network configuration for sandbox. + + Attributes: + allowUnixSockets: Unix socket paths accessible in sandbox (e.g., SSH agents). + allowAllUnixSockets: Allow all Unix sockets (less secure). + allowLocalBinding: Allow binding to localhost ports (macOS only). + httpProxyPort: HTTP proxy port if bringing your own proxy. + socksProxyPort: SOCKS5 proxy port if bringing your own proxy. + """ + + allowUnixSockets: list[str] + allowAllUnixSockets: bool + allowLocalBinding: bool + httpProxyPort: int + socksProxyPort: int + + +class SandboxIgnoreViolations(TypedDict, total=False): + """Violations to ignore in sandbox. + + Attributes: + file: File paths for which violations should be ignored. + network: Network hosts for which violations should be ignored. + """ + + file: list[str] + network: list[str] + + +class SandboxSettings(TypedDict, total=False): + """Sandbox settings configuration. + + This controls how Claude Code sandboxes bash commands for filesystem + and network isolation. + + **Important:** Filesystem and network restrictions are configured via permission + rules, not via these sandbox settings: + - Filesystem read restrictions: Use Read deny rules + - Filesystem write restrictions: Use Edit allow/deny rules + - Network restrictions: Use WebFetch allow/deny rules + + Attributes: + enabled: Enable bash sandboxing (macOS/Linux only). Default: False + autoAllowBashIfSandboxed: Auto-approve bash commands when sandboxed. Default: True + excludedCommands: Commands that should run outside the sandbox (e.g., ["git", "docker"]) + allowUnsandboxedCommands: Allow commands to bypass sandbox via dangerouslyDisableSandbox. + When False, all commands must run sandboxed (or be in excludedCommands). Default: True + network: Network configuration for sandbox. + ignoreViolations: Violations to ignore. + enableWeakerNestedSandbox: Enable weaker sandbox for unprivileged Docker environments + (Linux only). Reduces security. Default: False + + Example: + ```python + sandbox_settings: SandboxSettings = { + "enabled": True, + "autoAllowBashIfSandboxed": True, + "excludedCommands": ["docker"], + "network": { + "allowUnixSockets": ["/var/run/docker.sock"], + "allowLocalBinding": True + } + } + ``` + """ + + enabled: bool + autoAllowBashIfSandboxed: bool + excludedCommands: list[str] + allowUnsandboxedCommands: bool + network: SandboxNetworkConfig + ignoreViolations: SandboxIgnoreViolations + enableWeakerNestedSandbox: bool + + # Content block types @dataclass class TextBlock: @@ -454,11 +547,22 @@ ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock # Message types +AssistantMessageError = Literal[ + "authentication_failed", + "billing_error", + "rate_limit", + "invalid_request", + "server_error", + "unknown", +] + + @dataclass class UserMessage: """User message.""" content: str | list[ContentBlock] + uuid: str | None = None parent_tool_use_id: str | None = None @@ -469,6 +573,7 @@ class AssistantMessage: content: list[ContentBlock] model: str parent_tool_use_id: str | None = None + error: AssistantMessageError | None = None @dataclass @@ -492,6 +597,7 @@ class ResultMessage: total_cost_usd: float | None = None usage: dict[str, Any] | None = None result: str | None = None + structured_output: Any = None @dataclass @@ -511,6 +617,7 @@ Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | Strea class ClaudeAgentOptions: """Query options for Claude SDK.""" + tools: list[str] | ToolsPreset | None = None allowed_tools: list[str] = field(default_factory=list) system_prompt: str | SystemPromptPreset | None = None mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict) @@ -518,8 +625,12 @@ class ClaudeAgentOptions: continue_conversation: bool = False resume: str | None = None max_turns: int | None = None + max_budget_usd: float | None = None disallowed_tools: list[str] = field(default_factory=list) model: str | None = None + fallback_model: str | None = None + # Beta features - see https://docs.anthropic.com/en/api/beta-headers + betas: list[SdkBeta] = field(default_factory=list) permission_prompt_tool_name: str | None = None cwd: str | Path | None = None cli_path: str | Path | None = None @@ -552,8 +663,21 @@ class ClaudeAgentOptions: agents: dict[str, AgentDefinition] | None = None # Setting sources to load (user, project, local) setting_sources: list[SettingSource] | None = None + # Sandbox configuration for bash command isolation. + # Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch), + # not from these sandbox settings. + sandbox: SandboxSettings | None = None # Plugin configurations for custom plugins plugins: list[SdkPluginConfig] = field(default_factory=list) + # Max tokens for thinking blocks + max_thinking_tokens: int | None = None + # Output format for structured outputs (matches Messages API structure) + # Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}} + output_format: dict[str, Any] | None = None + # Enable file checkpointing to track file changes during the session. + # When enabled, files can be rewound to their state at any user message + # using `ClaudeSDKClient.rewind_files()`. + enable_file_checkpointing: bool = False # SDK Control Protocol @@ -594,6 +718,11 @@ class SDKControlMcpMessageRequest(TypedDict): message: Any +class SDKControlRewindFilesRequest(TypedDict): + subtype: Literal["rewind_files"] + user_message_id: str + + class SDKControlRequest(TypedDict): type: Literal["control_request"] request_id: str @@ -604,6 +733,7 @@ class SDKControlRequest(TypedDict): | SDKControlSetPermissionModeRequest | SDKHookCallbackRequest | SDKControlMcpMessageRequest + | SDKControlRewindFilesRequest ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 8531c9e..1f237dc 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -212,3 +212,73 @@ class TestIntegration: assert call_kwargs["options"].continue_conversation is True anyio.run(_test) + + def test_max_budget_usd_option(self): + """Test query with max_budget_usd option.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream that exceeds budget + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "Starting to read..."} + ], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "result", + "subtype": "error_max_budget_usd", + "duration_ms": 500, + "duration_api_ms": 400, + "is_error": False, + "num_turns": 1, + "session_id": "test-session-budget", + "total_cost_usd": 0.0002, + "usage": { + "input_tokens": 100, + "output_tokens": 50, + }, + } + + mock_transport.read_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + + # Run query with very small budget + messages = [] + async for msg in query( + prompt="Read the readme", + options=ClaudeAgentOptions(max_budget_usd=0.0001), + ): + messages.append(msg) + + # Verify results + assert len(messages) == 2 + + # Check result message + assert isinstance(messages[1], ResultMessage) + assert messages[1].subtype == "error_max_budget_usd" + assert messages[1].is_error is False + assert messages[1].total_cost_usd == 0.0002 + assert messages[1].total_cost_usd is not None + assert messages[1].total_cost_usd > 0 + + # Verify transport was created with max_budget_usd option + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["options"].max_budget_usd == 0.0001 + + anyio.run(_test) diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 60bcc53..cd18952 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -31,6 +31,21 @@ class TestMessageParser: assert isinstance(message.content[0], TextBlock) assert message.content[0].text == "Hello" + def test_parse_user_message_with_uuid(self): + """Test parsing a user message with uuid field (issue #414). + + The uuid field is needed for file checkpointing with rewind_files(). + """ + data = { + "type": "user", + "uuid": "msg-abc123-def456", + "message": {"content": [{"type": "text", "text": "Hello"}]}, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert message.uuid == "msg-abc123-def456" + assert len(message.content) == 1 + def test_parse_user_message_with_tool_use(self): """Test parsing a user message with tool_use block.""" data = { diff --git a/tests/test_transport.py b/tests/test_transport.py index f23dcbf..fe9b6b2 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -46,6 +46,8 @@ class TestSubprocessCLITransport: assert "stream-json" in cmd assert "--print" in cmd assert "Hello" in cmd + assert "--system-prompt" in cmd + assert cmd[cmd.index("--system-prompt") + 1] == "" def test_cli_path_accepts_pathlib_path(self): """Test that cli_path accepts pathlib.Path objects.""" @@ -129,6 +131,33 @@ class TestSubprocessCLITransport: assert "--max-turns" in cmd assert "5" in cmd + def test_build_command_with_fallback_model(self): + """Test building CLI command with fallback_model option.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options( + model="opus", + fallback_model="sonnet", + ), + ) + + cmd = transport._build_command() + assert "--model" in cmd + assert "opus" in cmd + assert "--fallback-model" in cmd + assert "sonnet" in cmd + + def test_build_command_with_max_thinking_tokens(self): + """Test building CLI command with max_thinking_tokens option.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(max_thinking_tokens=5000), + ) + + cmd = transport._build_command() + assert "--max-thinking-tokens" in cmd + assert "5000" in cmd + def test_build_command_with_add_dirs(self): """Test building CLI command with add_dirs option.""" from pathlib import Path @@ -471,3 +500,329 @@ class TestSubprocessCLITransport: assert user_passed == "claude" anyio.run(_test) + + def test_build_command_with_sandbox_only(self): + """Test building CLI command with sandbox settings (no existing settings).""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = { + "enabled": True, + "autoAllowBashIfSandboxed": True, + "network": { + "allowLocalBinding": True, + "allowUnixSockets": ["/var/run/docker.sock"], + }, + } + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + + # Should have --settings with sandbox merged in + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + # Parse and verify + parsed = json.loads(settings_value) + assert "sandbox" in parsed + assert parsed["sandbox"]["enabled"] is True + assert parsed["sandbox"]["autoAllowBashIfSandboxed"] is True + assert parsed["sandbox"]["network"]["allowLocalBinding"] is True + assert parsed["sandbox"]["network"]["allowUnixSockets"] == [ + "/var/run/docker.sock" + ] + + def test_build_command_with_sandbox_and_settings_json(self): + """Test building CLI command with sandbox merged into existing settings JSON.""" + import json + + from claude_agent_sdk import SandboxSettings + + # Existing settings as JSON string + existing_settings = ( + '{"permissions": {"allow": ["Bash(ls:*)"]}, "verbose": true}' + ) + + sandbox: SandboxSettings = { + "enabled": True, + "excludedCommands": ["git", "docker"], + } + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(settings=existing_settings, sandbox=sandbox), + ) + + cmd = transport._build_command() + + # Should have merged settings + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + parsed = json.loads(settings_value) + + # Original settings should be preserved + assert parsed["permissions"] == {"allow": ["Bash(ls:*)"]} + assert parsed["verbose"] is True + + # Sandbox should be merged in + assert "sandbox" in parsed + assert parsed["sandbox"]["enabled"] is True + assert parsed["sandbox"]["excludedCommands"] == ["git", "docker"] + + def test_build_command_with_settings_file_and_no_sandbox(self): + """Test that settings file path is passed through when no sandbox.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(settings="/path/to/settings.json"), + ) + + cmd = transport._build_command() + + # Should pass path directly, not parse it + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + assert cmd[settings_idx + 1] == "/path/to/settings.json" + + def test_build_command_sandbox_minimal(self): + """Test sandbox with minimal configuration.""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = {"enabled": True} + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + parsed = json.loads(settings_value) + assert parsed == {"sandbox": {"enabled": True}} + + def test_sandbox_network_config(self): + """Test sandbox with full network configuration.""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = { + "enabled": True, + "network": { + "allowUnixSockets": ["/tmp/ssh-agent.sock"], + "allowAllUnixSockets": False, + "allowLocalBinding": True, + "httpProxyPort": 8080, + "socksProxyPort": 8081, + }, + } + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + parsed = json.loads(settings_value) + network = parsed["sandbox"]["network"] + + assert network["allowUnixSockets"] == ["/tmp/ssh-agent.sock"] + assert network["allowAllUnixSockets"] is False + assert network["allowLocalBinding"] is True + assert network["httpProxyPort"] == 8080 + assert network["socksProxyPort"] == 8081 + + def test_build_command_with_tools_array(self): + """Test building CLI command with tools as array of tool names.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools=["Read", "Edit", "Bash"]), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "Read,Edit,Bash" + + def test_build_command_with_tools_empty_array(self): + """Test building CLI command with tools as empty array (disables all tools).""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools=[]), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "" + + def test_build_command_with_tools_preset(self): + """Test building CLI command with tools preset.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools={"type": "preset", "preset": "claude_code"}), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "default" + + def test_build_command_without_tools(self): + """Test building CLI command without tools option (default None).""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(), + ) + + cmd = transport._build_command() + assert "--tools" not in cmd + + def test_concurrent_writes_are_serialized(self): + """Test that concurrent write() calls are serialized by the lock. + + When parallel subagents invoke MCP tools, they trigger concurrent write() + calls. Without the _write_lock, trio raises BusyResourceError. + + Uses a real subprocess with the same stream setup as production: + process.stdin -> TextSendStream + """ + + async def _test(): + import sys + from subprocess import PIPE + + from anyio.streams.text import TextSendStream + + # Create a real subprocess that consumes stdin (cross-platform) + process = await anyio.open_process( + [sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + ) + + try: + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeAgentOptions(cli_path="/usr/bin/claude"), + ) + + # Same setup as production: TextSendStream wrapping process.stdin + transport._ready = True + transport._process = MagicMock(returncode=None) + transport._stdin_stream = TextSendStream(process.stdin) + + # Spawn concurrent writes - the lock should serialize them + num_writes = 10 + errors: list[Exception] = [] + + async def do_write(i: int): + try: + await transport.write(f'{{"msg": {i}}}\n') + except Exception as e: + errors.append(e) + + async with anyio.create_task_group() as tg: + for i in range(num_writes): + tg.start_soon(do_write, i) + + # All writes should succeed - the lock serializes them + assert len(errors) == 0, f"Got errors: {errors}" + finally: + process.terminate() + await process.wait() + + anyio.run(_test, backend="trio") + + def test_concurrent_writes_fail_without_lock(self): + """Verify that without the lock, concurrent writes cause BusyResourceError. + + Uses a real subprocess with the same stream setup as production. + """ + + async def _test(): + import sys + from contextlib import asynccontextmanager + from subprocess import PIPE + + from anyio.streams.text import TextSendStream + + # Create a real subprocess that consumes stdin (cross-platform) + process = await anyio.open_process( + [sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + ) + + try: + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeAgentOptions(cli_path="/usr/bin/claude"), + ) + + # Same setup as production + transport._ready = True + transport._process = MagicMock(returncode=None) + transport._stdin_stream = TextSendStream(process.stdin) + + # Replace lock with no-op to trigger the race condition + class NoOpLock: + @asynccontextmanager + async def __call__(self): + yield + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + transport._write_lock = NoOpLock() + + # Spawn concurrent writes - should fail without lock + num_writes = 10 + errors: list[Exception] = [] + + async def do_write(i: int): + try: + await transport.write(f'{{"msg": {i}}}\n') + except Exception as e: + errors.append(e) + + async with anyio.create_task_group() as tg: + for i in range(num_writes): + tg.start_soon(do_write, i) + + # Should have gotten errors due to concurrent access + assert len(errors) > 0, ( + "Expected errors from concurrent access, but got none" + ) + + # Check that at least one error mentions the concurrent access + error_strs = [str(e) for e in errors] + assert any("another task" in s for s in error_strs), ( + f"Expected 'another task' error, got: {error_strs}" + ) + finally: + process.terminate() + await process.wait() + + anyio.run(_test, backend="trio")