diff --git a/.claude/agents/test-agent.md b/.claude/agents/test-agent.md deleted file mode 100644 index 6515827..0000000 --- a/.claude/agents/test-agent.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -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 deleted file mode 100644 index 3a67279..0000000 --- a/.claude/commands/generate-changelog.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -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 deleted file mode 100644 index d013f1b..0000000 --- a/.dockerignore +++ /dev/null @@ -1,49 +0,0 @@ -# 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 c50abab..8d6b8e1 100644 --- a/.github/workflows/create-release-tag.yml +++ b/.github/workflows/create-release-tag.yml @@ -24,6 +24,12 @@ 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" @@ -40,34 +46,14 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${{ steps.extract_version.outputs.version }}" + # 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 }}/ - # 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 + ### Installation + \`\`\`bash + pip install claude-agent-sdk==${{ steps.extract_version.outputs.version }} + \`\`\`" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8b7e93..ad70da5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: 'Package version to publish (e.g., 0.1.4)' + description: "Version to publish (e.g., 0.1.0)" required: true type: string jobs: @@ -56,167 +56,128 @@ jobs: run: | mypy src/ - 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] + needs: [test, lint] 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 for changelog generation + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 # Fetch all history including tags (necessary 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: 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: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine - - name: Download all wheel artifacts - uses: actions/download-artifact@v4 - with: - path: dist - pattern: wheel-* - merge-multiple: true + - name: Build package + run: python -m build - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install build twine + - name: Check package + run: twine check dist/* - - name: Build source distribution - run: python -m build --sdist + - 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: 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: 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: release v${{ env.VERSION }}" + # Commit version changes + git add pyproject.toml src/claude_agent_sdk/_version.py + git commit -m "chore: bump version to ${{ 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' + - 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: Push branch and create PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Push the branch with all commits - git push origin "${{ env.BRANCH_NAME }}" + Update CHANGELOG.md to add a new section for version ${{ env.VERSION }} at the top of the file, right after the '# Changelog' heading. - # Create PR using GitHub CLI - PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI. + 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 - ## 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 + Include only the sections that are relevant based on the actual changes. Write clear, user-focused descriptions. - ## 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 }}\` + 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' - 🤖 Generated by GitHub Actions" + - 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 }}" - PR_URL=$(gh pr create \ - --title "chore: release v${{ env.VERSION }}" \ - --body "$PR_BODY" \ - --base main \ - --head "${{ env.BRANCH_NAME }}") + # Create PR using GitHub CLI + PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI. - echo "PR created: $PR_URL" + ## 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" diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml deleted file mode 100644 index 675dd93..0000000 --- a/.github/workflows/slack-issue-notification.yml +++ /dev/null @@ -1,36 +0,0 @@ -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 d581a8f..097d2ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,30 +81,12 @@ 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.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 6be5cc4..7e6d2df 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,6 @@ venv/ ENV/ env/ .venv -uv.lock # IDEs .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bfade18..d763c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,113 +1,5 @@ # 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 deleted file mode 100644 index 22adf2e..0000000 --- a/Dockerfile.test +++ /dev/null @@ -1,29 +0,0 @@ -# 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 bcbe969..1fcb30d 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,9 @@ pip install claude-agent-sdk ``` **Prerequisites:** - - Python 3.10+ - -**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")` +- Node.js +- Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code` ## Quick Start @@ -183,7 +179,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. @@ -233,10 +229,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 @@ -263,7 +259,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 @@ -294,63 +290,6 @@ 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`. -### Building Wheels Locally +## License -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. +MIT diff --git a/e2e-tests/test_agents_and_settings.py b/e2e-tests/test_agents_and_settings.py index 3f6fc80..6e04066 100644 --- a/e2e-tests/test_agents_and_settings.py +++ b/e2e-tests/test_agents_and_settings.py @@ -38,88 +38,15 @@ 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(): @@ -147,12 +74,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 @@ -194,9 +121,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 @@ -232,11 +159,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) + await asyncio.sleep(0.5) \ No newline at end of file diff --git a/e2e-tests/test_structured_output.py b/e2e-tests/test_structured_output.py deleted file mode 100644 index 32e7ba2..0000000 --- a/e2e-tests/test_structured_output.py +++ /dev/null @@ -1,204 +0,0 @@ -"""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 deleted file mode 100644 index e5f6904..0000000 --- a/examples/filesystem_agents.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/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 deleted file mode 100644 index bb9777e..0000000 --- a/examples/max_budget_usd.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/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 deleted file mode 100644 index 204676f..0000000 --- a/examples/tools_option.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/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 9058f3e..c28566a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.18" +version = "0.1.5" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" @@ -47,7 +47,6 @@ 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 deleted file mode 100755 index ec6799a..0000000 --- a/scripts/build_wheel.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/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 deleted file mode 100755 index 45d39df..0000000 --- a/scripts/download_cli.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/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 deleted file mode 100755 index 2cf9889..0000000 --- a/scripts/test-docker.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/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 deleted file mode 100755 index 1ef17c7..0000000 --- a/scripts/update_cli_version.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/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 b980d52..743b40f 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 re import sys +import re 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]) + + update_version(sys.argv[1]) \ No newline at end of file diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 4898bc0..cde28be 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -39,10 +39,6 @@ from .types import ( PreCompactHookInput, PreToolUseHookInput, ResultMessage, - SandboxIgnoreViolations, - SandboxNetworkConfig, - SandboxSettings, - SdkBeta, SdkPluginConfig, SettingSource, StopHookInput, @@ -219,7 +215,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,untyped-decorator] + @server.list_tools() # type: ignore[no-untyped-call,misc] async def list_tools() -> list[Tool]: """Return the list of available tools.""" tool_list = [] @@ -265,7 +261,7 @@ def create_sdk_mcp_server( return tool_list # Register call_tool handler to execute tools - @server.call_tool() # type: ignore[untyped-decorator] + @server.call_tool() # type: ignore[misc] 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: @@ -346,12 +342,6 @@ __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 deleted file mode 100644 index b8f0354..0000000 --- a/src/claude_agent_sdk/_bundled/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 8e7a72d..0000000 --- a/src/claude_agent_sdk/_cli_version.py +++ /dev/null @@ -1,3 +0,0 @@ -"""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 5246627..6dbc877 100644 --- a/src/claude_agent_sdk/_internal/client.py +++ b/src/claude_agent_sdk/_internal/client.py @@ -31,12 +31,10 @@ class InternalClient: internal_hooks[event] = [] for matcher in matchers: # Convert HookMatcher to internal dict format - internal_matcher: dict[str, Any] = { + internal_matcher = { "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 4bfe814..6532a20 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -48,7 +48,6 @@ 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"]: @@ -75,12 +74,10 @@ 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: @@ -123,7 +120,6 @@ 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( @@ -153,7 +149,6 @@ 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 c30fc15..7646010 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -72,7 +72,6 @@ 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. @@ -82,9 +81,7 @@ 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 @@ -107,12 +104,6 @@ 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. @@ -135,13 +126,12 @@ class Query: self.next_callback_id += 1 self.hook_callbacks[callback_id] = callback callback_ids.append(callback_id) - 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) + hooks_config[event].append( + { + "matcher": matcher.get("matcher"), + "hookCallbackIds": callback_ids, + } + ) # Send initialize request request = { @@ -149,10 +139,7 @@ class Query: "hooks": hooks_config if hooks_config else None, } - # Use longer timeout for initialize since MCP servers may take time to start - response = await self._send_control_request( - request, timeout=self._initialize_timeout - ) + response = await self._send_control_request(request) self._initialized = True self._initialization_result = response # Store for later access return response @@ -201,10 +188,6 @@ 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) @@ -214,11 +197,6 @@ 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: @@ -336,15 +314,8 @@ class Query: } await self.transport.write(json.dumps(error_response) + "\n") - 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) - """ + async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any]: + """Send control request to CLI and wait for response.""" if not self.is_streaming_mode: raise Exception("Control requests require streaming mode") @@ -367,7 +338,7 @@ class Query: # Wait for response try: - with anyio.fail_after(timeout): + with anyio.fail_after(60.0): await event.wait() result = self.pending_control_results.pop(request_id) @@ -539,51 +510,14 @@ 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. - - If SDK MCP servers or hooks are present, waits for the first result - before closing stdin to allow bidirectional control protocol communication. - """ + """Stream input messages to transport.""" try: async for message in stream: if self._closed: break await self.transport.write(json.dumps(message) + "\n") - - # 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 + # After all messages sent, 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 a4882db..1ec352f 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -65,16 +65,9 @@ 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 @@ -84,7 +77,6 @@ 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: @@ -100,81 +92,12 @@ 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: - cmd.extend(["--system-prompt", ""]) + pass elif isinstance(self._options.system_prompt, str): cmd.extend(["--system-prompt", self._options.system_prompt]) else: @@ -186,39 +109,18 @@ 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] @@ -233,10 +135,8 @@ class SubprocessCLITransport(Transport): if self._options.resume: cmd.extend(["--resume", self._options.resume]) - # 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.settings: + cmd.extend(["--settings", self._options.settings]) if self._options.add_dirs: # Convert all paths to strings and add each directory @@ -308,24 +208,7 @@ 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"]) @@ -384,10 +267,6 @@ 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 @@ -476,6 +355,8 @@ 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): @@ -483,7 +364,6 @@ class SubprocessCLITransport(Transport): self._temp_files.clear() if not self._process: - self._ready = False return # Close stderr task group if active @@ -493,19 +373,21 @@ class SubprocessCLITransport(Transport): await self._stderr_task_group.__aexit__(None, None, None) self._stderr_task_group = 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 + # Close streams + 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): @@ -523,37 +405,37 @@ class SubprocessCLITransport(Transport): async def write(self, data: str) -> None: """Write raw data to the transport.""" - 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 ready (like TypeScript) + if not self._ready or not self._stdin_stream: + raise CLIConnectionError("ProcessTransport is not ready for writing") - if self._process and self._process.returncode is not None: - raise CLIConnectionError( - f"Cannot write to terminated process (exit code: {self._process.returncode})" - ) + # 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._exit_error: - raise CLIConnectionError( - f"Cannot write to process that exited with error: {self._exit_error}" - ) from self._exit_error + # 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 - 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 + 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 async def end_input(self) -> None: """End the input stream (close stdin).""" - async with self._write_lock: - if self._stdin_stream: - with suppress(Exception): - await self._stdin_stream.aclose() - self._stdin_stream = None + 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 de9a16c..f241736 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.18" +__version__ = "0.1.5" diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index 18ab818..f95b50b 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -75,12 +75,10 @@ class ClaudeSDKClient: internal_hooks[event] = [] for matcher in matchers: # Convert HookMatcher to internal dict format - internal_matcher: dict[str, Any] = { + internal_matcher = { "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 @@ -140,13 +138,6 @@ 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, @@ -156,7 +147,6 @@ class ClaudeSDKClient: if self.options.hooks else None, sdk_mcp_servers=sdk_mcp_servers, - initialize_timeout=initialize_timeout, ) # Start reading messages and initialize @@ -261,38 +251,6 @@ 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 9c09345..efeaf70 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -10,16 +10,10 @@ 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"] @@ -32,13 +26,6 @@ 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.""" @@ -379,9 +366,6 @@ 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): @@ -432,83 +416,6 @@ 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: @@ -547,22 +454,11 @@ 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 @@ -573,7 +469,6 @@ class AssistantMessage: content: list[ContentBlock] model: str parent_tool_use_id: str | None = None - error: AssistantMessageError | None = None @dataclass @@ -597,7 +492,6 @@ class ResultMessage: total_cost_usd: float | None = None usage: dict[str, Any] | None = None result: str | None = None - structured_output: Any = None @dataclass @@ -617,7 +511,6 @@ 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) @@ -625,12 +518,8 @@ 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 @@ -663,21 +552,8 @@ 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 @@ -718,11 +594,6 @@ 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 @@ -733,7 +604,6 @@ class SDKControlRequest(TypedDict): | SDKControlSetPermissionModeRequest | SDKHookCallbackRequest | SDKControlMcpMessageRequest - | SDKControlRewindFilesRequest ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 1f237dc..8531c9e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -212,73 +212,3 @@ 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 cd18952..60bcc53 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -31,21 +31,6 @@ 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 fe9b6b2..f23dcbf 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -46,8 +46,6 @@ 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.""" @@ -131,33 +129,6 @@ 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 @@ -500,329 +471,3 @@ 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")