mirror of
https://github.com/anthropics/claude-code-sdk-python.git
synced 2025-12-23 09:19:52 +00:00
Compare commits
74 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb12c5a37 | ||
|
|
57e8b6ecd5 | ||
|
|
04347495b8 | ||
|
|
a3df944128 | ||
|
|
91e65b1927 | ||
|
|
27575ae2ca | ||
|
|
a0ce44a3fa | ||
|
|
904c2ec33c | ||
|
|
eba5675328 | ||
|
|
5752f38834 | ||
|
|
0ae5c3285c | ||
|
|
f834ba9e15 | ||
|
|
a1c338726f | ||
|
|
d2b3477a4e | ||
|
|
3cbb9e56be | ||
|
|
5b912962e2 | ||
|
|
53482d8955 | ||
|
|
4acfcc2d39 | ||
|
|
b5447d999d | ||
|
|
ccff8ddf48 | ||
|
|
db4a6f7c28 | ||
|
|
5625286212 | ||
|
|
cf6b85fc5d | ||
|
|
00b5730be6 | ||
|
|
1b3e35d14e | ||
|
|
69a310cc3f | ||
|
|
6791efec93 | ||
|
|
2d67166cae | ||
|
|
00332f32dc | ||
|
|
ea0ef25e71 | ||
|
|
4e56cb12a9 | ||
|
|
243703531b | ||
|
|
9809fb6b54 | ||
|
|
afa39dc1bf | ||
|
|
2a53aba5b9 | ||
|
|
6e1769f8fd | ||
|
|
a2f24a3101 | ||
|
|
49482e1dfd | ||
|
|
f21f63e181 | ||
|
|
d553184ef6 | ||
|
|
e2f8d814ea | ||
|
|
be915896af | ||
|
|
d15f26da91 | ||
|
|
112f3aa959 | ||
|
|
493f49fad9 | ||
|
|
7a5b413159 | ||
|
|
23183a2698 | ||
|
|
41ceacd807 | ||
|
|
f446e3e42a | ||
|
|
36c75374ec | ||
|
|
b0fb5b082a | ||
|
|
ab83878f5a | ||
|
|
179818235a | ||
|
|
84edd73041 | ||
|
|
bf528a1221 | ||
|
|
d5dc615bd4 | ||
|
|
bab98e717e | ||
|
|
ddc37c7330 | ||
|
|
58cfffc623 | ||
|
|
35dd5b4bcc | ||
|
|
ce99e9d2eb | ||
|
|
6f209075bc | ||
|
|
50d68409c6 | ||
|
|
ff425b293d | ||
|
|
5a4cc2f41a | ||
|
|
c30ffbeb56 | ||
|
|
841f8c0614 | ||
|
|
5256af2dac | ||
|
|
edad138cb0 | ||
|
|
ae800c5ec8 | ||
|
|
7be296f12e | ||
|
|
fd4e33d4b9 | ||
|
|
68eb68b740 | ||
|
|
c5957634ac |
38 changed files with 2768 additions and 204 deletions
9
.claude/agents/test-agent.md
Normal file
9
.claude/agents/test-agent.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
name: test-agent
|
||||||
|
description: A simple test agent for SDK testing
|
||||||
|
tools: Read
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Agent
|
||||||
|
|
||||||
|
You are a simple test agent. When asked a question, provide a brief, helpful answer.
|
||||||
19
.claude/commands/generate-changelog.md
Normal file
19
.claude/commands/generate-changelog.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
allowed-tools: Edit, Bash(git add:*), Bash(git commit:*)
|
||||||
|
description: Generate changelog for a new release version
|
||||||
|
---
|
||||||
|
|
||||||
|
You are updating the changelog for the new release.
|
||||||
|
|
||||||
|
Update CHANGELOG.md to add a new section for the new version at the top of the file, right after the '# Changelog' heading.
|
||||||
|
|
||||||
|
Review the recent commits and merged pull requests since the last release to generate meaningful changelog content for the new version. Follow the existing format in CHANGELOG.md with sections like:
|
||||||
|
- Breaking Changes (if any)
|
||||||
|
- New Features
|
||||||
|
- Bug Fixes
|
||||||
|
- Documentation
|
||||||
|
- Internal/Other changes
|
||||||
|
|
||||||
|
Include only the sections that are relevant based on the actual changes. Write clear, user-focused descriptions.
|
||||||
|
|
||||||
|
After updating CHANGELOG.md, commit the changes with the message "docs: update changelog for v{new_version}".
|
||||||
49
.dockerignore
Normal file
49
.dockerignore
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Testing/Coverage
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
46
.github/workflows/create-release-tag.yml
vendored
46
.github/workflows/create-release-tag.yml
vendored
|
|
@ -24,12 +24,6 @@ jobs:
|
||||||
VERSION="${BRANCH_NAME#release/v}"
|
VERSION="${BRANCH_NAME#release/v}"
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
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
|
- name: Create and push tag
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
@ -46,14 +40,34 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# Create release with auto-generated notes
|
VERSION="${{ steps.extract_version.outputs.version }}"
|
||||||
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 }}/
|
|
||||||
|
|
||||||
### Installation
|
# Extract changelog section for this version to a temp file
|
||||||
\`\`\`bash
|
awk -v ver="$VERSION" '
|
||||||
pip install claude-agent-sdk==${{ steps.extract_version.outputs.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
|
||||||
|
|
|
||||||
237
.github/workflows/publish.yml
vendored
237
.github/workflows/publish.yml
vendored
|
|
@ -4,7 +4,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to publish (e.g., 0.1.0)"
|
description: 'Package version to publish (e.g., 0.1.4)'
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -56,128 +56,167 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
mypy src/
|
mypy src/
|
||||||
|
|
||||||
publish:
|
build-wheels:
|
||||||
needs: [test, lint]
|
needs: [test, lint]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install build twine wheel
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Build wheel with bundled CLI
|
||||||
|
run: |
|
||||||
|
python scripts/build_wheel.py \
|
||||||
|
--version "${{ github.event.inputs.version }}" \
|
||||||
|
--skip-sdist \
|
||||||
|
--clean
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload wheel artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wheel-${{ matrix.os }}
|
||||||
|
path: dist/*.whl
|
||||||
|
if-no-files-found: error
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [build-wheels]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0 # Fetch all history including tags (necessary for changelog generation)
|
fetch-depth: 0 # Fetch all history including tags for changelog generation
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Set version
|
- name: Set version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ github.event.inputs.version }}"
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Update version
|
- name: Update version
|
||||||
run: |
|
run: |
|
||||||
python scripts/update_version.py "${{ env.VERSION }}"
|
python scripts/update_version.py "${{ env.VERSION }}"
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Read CLI version from code
|
||||||
run: |
|
id: cli_version
|
||||||
python -m pip install --upgrade pip
|
run: |
|
||||||
pip install build twine
|
CLI_VERSION=$(python -c "import re; print(re.search(r'__cli_version__ = \"([^\"]+)\"', open('src/claude_agent_sdk/_cli_version.py').read()).group(1))")
|
||||||
|
echo "cli_version=$CLI_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Bundled CLI version: $CLI_VERSION"
|
||||||
|
|
||||||
- name: Build package
|
- name: Download all wheel artifacts
|
||||||
run: python -m build
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
pattern: wheel-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Check package
|
- name: Install build dependencies
|
||||||
run: twine check dist/*
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install build twine
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Build source distribution
|
||||||
env:
|
run: python -m build --sdist
|
||||||
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
|
- name: Publish to PyPI
|
||||||
id: previous_tag
|
env:
|
||||||
run: |
|
TWINE_USERNAME: __token__
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
|
run: |
|
||||||
echo "Previous release: $PREVIOUS_TAG"
|
twine upload dist/*
|
||||||
|
echo "Package published to PyPI"
|
||||||
|
echo "Install with: pip install claude-agent-sdk==${{ env.VERSION }}"
|
||||||
|
|
||||||
|
- name: Get previous release tag
|
||||||
|
id: previous_tag
|
||||||
|
run: |
|
||||||
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "Previous release: $PREVIOUS_TAG"
|
||||||
|
|
||||||
- name: Create release branch and commit version changes
|
- name: Create release branch and commit version changes
|
||||||
run: |
|
run: |
|
||||||
# Create a new branch for the version update
|
# Create a new branch for the version update
|
||||||
BRANCH_NAME="release/v${{ env.VERSION }}"
|
BRANCH_NAME="release/v${{ env.VERSION }}"
|
||||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||||
|
|
||||||
# Configure git
|
# Configure git
|
||||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git config --local user.name "github-actions[bot]"
|
git config --local user.name "github-actions[bot]"
|
||||||
|
|
||||||
# Create and switch to new branch
|
# Create and switch to new branch
|
||||||
git checkout -b "$BRANCH_NAME"
|
git checkout -b "$BRANCH_NAME"
|
||||||
|
|
||||||
# Commit version changes
|
# Commit version changes
|
||||||
git add pyproject.toml src/claude_agent_sdk/_version.py
|
git add pyproject.toml src/claude_agent_sdk/_version.py
|
||||||
git commit -m "chore: bump version to ${{ env.VERSION }}"
|
git commit -m "chore: release v${{ env.VERSION }}"
|
||||||
|
|
||||||
- name: Update changelog with Claude
|
- name: Update changelog with Claude
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@v1
|
||||||
with:
|
with:
|
||||||
prompt: |
|
prompt: "/generate-changelog new version: ${{ env.VERSION }}, old version: ${{ steps.previous_tag.outputs.previous_tag }}"
|
||||||
You are updating the changelog for the new release v${{ env.VERSION }}.
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
claude_args: |
|
||||||
|
--model claude-opus-4-5
|
||||||
|
--allowedTools 'Bash(git add:*),Bash(git commit:*),Edit'
|
||||||
|
|
||||||
Update CHANGELOG.md to add a new section for version ${{ env.VERSION }} at the top of the file, right after the '# Changelog' heading.
|
- name: Push branch and create PR
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Push the branch with all commits
|
||||||
|
git push origin "${{ env.BRANCH_NAME }}"
|
||||||
|
|
||||||
Review the recent commits and merged pull requests since the last release (${{ steps.previous_tag.outputs.previous_tag }}) to generate meaningful changelog content for v${{ env.VERSION }}. Follow the existing format in CHANGELOG.md with sections like:
|
# Create PR using GitHub CLI
|
||||||
- Breaking Changes (if any)
|
PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI.
|
||||||
- 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.
|
## Changes
|
||||||
|
- Updated version in \`pyproject.toml\` to ${{ env.VERSION }}
|
||||||
|
- Updated version in \`src/claude_agent_sdk/_version.py\` to ${{ env.VERSION }}
|
||||||
|
- Updated \`CHANGELOG.md\` with release notes
|
||||||
|
|
||||||
After updating CHANGELOG.md, commit the changes with the message "docs: update changelog for v${{ env.VERSION }}".
|
## Release Information
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
- Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ env.VERSION }}/
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
- Bundled CLI version: ${{ steps.cli_version.outputs.cli_version }}
|
||||||
claude_args: |
|
- Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\`
|
||||||
--allowedTools 'Bash(git add:*),Bash(git commit:*),Edit'
|
|
||||||
|
|
||||||
- name: Push branch and create PR
|
🤖 Generated by GitHub Actions"
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
# Push the branch with all commits
|
|
||||||
git push origin "${{ env.BRANCH_NAME }}"
|
|
||||||
|
|
||||||
# Create PR using GitHub CLI
|
PR_URL=$(gh pr create \
|
||||||
PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI.
|
--title "chore: release v${{ env.VERSION }}" \
|
||||||
|
--body "$PR_BODY" \
|
||||||
|
--base main \
|
||||||
|
--head "${{ env.BRANCH_NAME }}")
|
||||||
|
|
||||||
## Changes
|
echo "PR created: $PR_URL"
|
||||||
- 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"
|
|
||||||
|
|
|
||||||
36
.github/workflows/slack-issue-notification.yml
vendored
Normal file
36
.github/workflows/slack-issue-notification.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
name: Post new issues to Slack
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Post to Slack
|
||||||
|
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # 2.1.1
|
||||||
|
with:
|
||||||
|
method: chat.postMessage
|
||||||
|
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"channel": "C09HY5E0K60",
|
||||||
|
"text": "New issue opened in ${{ github.repository }}",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "*New Issue:* <${{ github.event.issue.html_url }}|#${{ github.event.issue.number }} ${{ github.event.issue.title }}>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "*Author:* ${{ github.event.issue.user.login }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
|
|
@ -81,12 +81,30 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
python -m pytest e2e-tests/ -v -m e2e
|
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:
|
test-examples:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test-e2e # Run after e2e tests
|
needs: test-e2e # Run after e2e tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
python-version: ["3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,6 +26,7 @@ venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
.venv
|
.venv
|
||||||
|
uv.lock
|
||||||
|
|
||||||
# IDEs
|
# IDEs
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
114
CHANGELOG.md
114
CHANGELOG.md
|
|
@ -1,5 +1,119 @@
|
||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- **Plugin support**: Added the ability to load Claude Code plugins programmatically through the SDK. Plugins can be specified using the new `plugins` field in `ClaudeAgentOptions` with a `SdkPluginConfig` type that supports loading local plugins by path. This enables SDK applications to extend functionality with custom commands and capabilities defined in plugin directories
|
||||||
|
|
||||||
## 0.1.4
|
## 0.1.4
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
29
Dockerfile.test
Normal file
29
Dockerfile.test
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Dockerfile for running SDK tests in a containerized environment
|
||||||
|
# This helps catch Docker-specific issues like #406
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Install dependencies for Claude CLI and git (needed for some tests)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Claude Code CLI
|
||||||
|
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||||
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# Set up working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the SDK source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install SDK with dev dependencies
|
||||||
|
RUN pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Verify CLI installation
|
||||||
|
RUN claude -v
|
||||||
|
|
||||||
|
# Default: run unit tests
|
||||||
|
CMD ["python", "-m", "pytest", "tests/", "-v"]
|
||||||
75
README.md
75
README.md
|
|
@ -9,9 +9,13 @@ pip install claude-agent-sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- Node.js
|
|
||||||
- Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code`
|
**Note:** The Claude Code CLI is automatically bundled with the package - no separate installation required! The SDK will use the bundled CLI by default. If you prefer to use a system-wide installation or a specific version, you can:
|
||||||
|
|
||||||
|
- Install Claude Code separately: `curl -fsSL https://claude.ai/install.sh | bash`
|
||||||
|
- Specify a custom path: `ClaudeAgentOptions(cli_path="/path/to/claude")`
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -179,7 +183,7 @@ options = ClaudeAgentOptions(
|
||||||
|
|
||||||
### Hooks
|
### 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.
|
For more examples, see examples/hooks.py.
|
||||||
|
|
||||||
|
|
@ -229,10 +233,10 @@ async with ClaudeSDKClient(options=options) as client:
|
||||||
print(msg)
|
print(msg)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Types
|
## Types
|
||||||
|
|
||||||
See [src/claude_agent_sdk/types.py](src/claude_agent_sdk/types.py) for complete type definitions:
|
See [src/claude_agent_sdk/types.py](src/claude_agent_sdk/types.py) for complete type definitions:
|
||||||
|
|
||||||
- `ClaudeAgentOptions` - Configuration options
|
- `ClaudeAgentOptions` - Configuration options
|
||||||
- `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types
|
- `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types
|
||||||
- `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks
|
- `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks
|
||||||
|
|
@ -259,7 +263,7 @@ except CLIJSONDecodeError as e:
|
||||||
print(f"Failed to parse response: {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
|
## Available Tools
|
||||||
|
|
||||||
|
|
@ -290,6 +294,63 @@ If you're contributing to this project, run the initial setup script to install
|
||||||
|
|
||||||
This installs a pre-push hook that runs lint checks before pushing, matching the CI workflow. To skip the hook temporarily, use `git push --no-verify`.
|
This installs a pre-push hook that runs lint checks before pushing, matching the CI workflow. To skip the hook temporarily, use `git push --no-verify`.
|
||||||
|
|
||||||
## License
|
### Building Wheels Locally
|
||||||
|
|
||||||
MIT
|
To build wheels with the bundled Claude Code CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install build dependencies
|
||||||
|
pip install build twine
|
||||||
|
|
||||||
|
# Build wheel with bundled CLI
|
||||||
|
python scripts/build_wheel.py
|
||||||
|
|
||||||
|
# Build with specific version
|
||||||
|
python scripts/build_wheel.py --version 0.1.4
|
||||||
|
|
||||||
|
# Build with specific CLI version
|
||||||
|
python scripts/build_wheel.py --cli-version 2.0.0
|
||||||
|
|
||||||
|
# Clean bundled CLI after building
|
||||||
|
python scripts/build_wheel.py --clean
|
||||||
|
|
||||||
|
# Skip CLI download (use existing)
|
||||||
|
python scripts/build_wheel.py --skip-download
|
||||||
|
```
|
||||||
|
|
||||||
|
The build script:
|
||||||
|
|
||||||
|
1. Downloads Claude Code CLI for your platform
|
||||||
|
2. Bundles it in the wheel
|
||||||
|
3. Builds both wheel and source distribution
|
||||||
|
4. Checks the package with twine
|
||||||
|
|
||||||
|
See `python scripts/build_wheel.py --help` for all options.
|
||||||
|
|
||||||
|
### Release Workflow
|
||||||
|
|
||||||
|
The package is published to PyPI via the GitHub Actions workflow in `.github/workflows/publish.yml`. To create a new release:
|
||||||
|
|
||||||
|
1. **Trigger the workflow** manually from the Actions tab with two inputs:
|
||||||
|
- `version`: The package version to publish (e.g., `0.1.5`)
|
||||||
|
- `claude_code_version`: The Claude Code CLI version to bundle (e.g., `2.0.0` or `latest`)
|
||||||
|
|
||||||
|
2. **The workflow will**:
|
||||||
|
- Build platform-specific wheels for macOS, Linux, and Windows
|
||||||
|
- Bundle the specified Claude Code CLI version in each wheel
|
||||||
|
- Build a source distribution
|
||||||
|
- Publish all artifacts to PyPI
|
||||||
|
- Create a release branch with version updates
|
||||||
|
- Open a PR to main with:
|
||||||
|
- Updated `pyproject.toml` version
|
||||||
|
- Updated `src/claude_agent_sdk/_version.py`
|
||||||
|
- Updated `src/claude_agent_sdk/_cli_version.py` with bundled CLI version
|
||||||
|
- Auto-generated `CHANGELOG.md` entry
|
||||||
|
|
||||||
|
3. **Review and merge** the release PR to update main with the new version information
|
||||||
|
|
||||||
|
The workflow tracks both the package version and the bundled CLI version separately, allowing you to release a new package version with an updated CLI without code changes.
|
||||||
|
|
||||||
|
## License and terms
|
||||||
|
|
||||||
|
Use of this SDK is governed by Anthropic's [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms), including when you use it to power products and services that you make available to your own customers and end users, except to the extent a specific component or dependency is covered by a different license as indicated in that component's LICENSE file.
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,88 @@ async def test_agent_definition():
|
||||||
async for message in client.receive_response():
|
async for message in client.receive_response():
|
||||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
agents = message.data.get("agents", [])
|
agents = message.data.get("agents", [])
|
||||||
assert isinstance(
|
assert isinstance(agents, list), (
|
||||||
agents, list
|
f"agents should be a list of strings, got: {type(agents)}"
|
||||||
), f"agents should be a list of strings, got: {type(agents)}"
|
)
|
||||||
assert (
|
assert "test-agent" in agents, (
|
||||||
"test-agent" in agents
|
f"test-agent should be available, got: {agents}"
|
||||||
), f"test-agent should be available, got: {agents}"
|
)
|
||||||
break
|
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.e2e
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_setting_sources_default():
|
async def test_setting_sources_default():
|
||||||
|
|
@ -74,12 +147,12 @@ async def test_setting_sources_default():
|
||||||
async for message in client.receive_response():
|
async for message in client.receive_response():
|
||||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
output_style = message.data.get("output_style")
|
output_style = message.data.get("output_style")
|
||||||
assert (
|
assert output_style != "local-test-style", (
|
||||||
output_style != "local-test-style"
|
f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}"
|
||||||
), f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}"
|
)
|
||||||
assert (
|
assert output_style == "default", (
|
||||||
output_style == "default"
|
f"outputStyle should be 'default', got: {output_style}"
|
||||||
), f"outputStyle should be 'default', got: {output_style}"
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# On Windows, wait for file handles to be released before cleanup
|
# On Windows, wait for file handles to be released before cleanup
|
||||||
|
|
@ -121,9 +194,9 @@ This is a test command.
|
||||||
async for message in client.receive_response():
|
async for message in client.receive_response():
|
||||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
commands = message.data.get("slash_commands", [])
|
commands = message.data.get("slash_commands", [])
|
||||||
assert (
|
assert "testcmd" not in commands, (
|
||||||
"testcmd" not in commands
|
f"testcmd should NOT be available with user-only sources, got: {commands}"
|
||||||
), f"testcmd should NOT be available with user-only sources, got: {commands}"
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# On Windows, wait for file handles to be released before cleanup
|
# On Windows, wait for file handles to be released before cleanup
|
||||||
|
|
@ -159,11 +232,11 @@ async def test_setting_sources_project_included():
|
||||||
async for message in client.receive_response():
|
async for message in client.receive_response():
|
||||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
output_style = message.data.get("output_style")
|
output_style = message.data.get("output_style")
|
||||||
assert (
|
assert output_style == "local-test-style", (
|
||||||
output_style == "local-test-style"
|
f"outputStyle should be from local settings, got: {output_style}"
|
||||||
), f"outputStyle should be from local settings, got: {output_style}"
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# On Windows, wait for file handles to be released before cleanup
|
# On Windows, wait for file handles to be released before cleanup
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
|
||||||
204
e2e-tests/test_structured_output.py
Normal file
204
e2e-tests/test_structured_output.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""End-to-end tests for structured output with real Claude API calls.
|
||||||
|
|
||||||
|
These tests verify that the output_schema feature works correctly by making
|
||||||
|
actual API calls to Claude with JSON Schema validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ResultMessage,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_simple_structured_output():
|
||||||
|
"""Test structured output with file counting requiring tool use."""
|
||||||
|
|
||||||
|
# Define schema for file analysis
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_count": {"type": "number"},
|
||||||
|
"has_tests": {"type": "boolean"},
|
||||||
|
"test_file_count": {"type": "number"},
|
||||||
|
},
|
||||||
|
"required": ["file_count", "has_tests"],
|
||||||
|
}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
output_format={"type": "json_schema", "schema": schema},
|
||||||
|
permission_mode="acceptEdits",
|
||||||
|
cwd=".", # Use current directory
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agent must use Glob/Bash to count files
|
||||||
|
result_message = None
|
||||||
|
async for message in query(
|
||||||
|
prompt="Count how many Python files are in src/claude_agent_sdk/ and check if there are any test files. Use tools to explore the filesystem.",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, ResultMessage):
|
||||||
|
result_message = message
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert result_message is not None, "No result message received"
|
||||||
|
assert not result_message.is_error, f"Query failed: {result_message.result}"
|
||||||
|
assert result_message.subtype == "success"
|
||||||
|
|
||||||
|
# Verify structured output is present and valid
|
||||||
|
assert result_message.structured_output is not None, "No structured output in result"
|
||||||
|
assert "file_count" in result_message.structured_output
|
||||||
|
assert "has_tests" in result_message.structured_output
|
||||||
|
assert isinstance(result_message.structured_output["file_count"], (int, float))
|
||||||
|
assert isinstance(result_message.structured_output["has_tests"], bool)
|
||||||
|
|
||||||
|
# Should find Python files in src/
|
||||||
|
assert result_message.structured_output["file_count"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nested_structured_output():
|
||||||
|
"""Test structured output with nested objects and arrays."""
|
||||||
|
|
||||||
|
# Define a schema with nested structure
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"analysis": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"word_count": {"type": "number"},
|
||||||
|
"character_count": {"type": "number"},
|
||||||
|
},
|
||||||
|
"required": ["word_count", "character_count"],
|
||||||
|
},
|
||||||
|
"words": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["analysis", "words"],
|
||||||
|
}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
output_format={"type": "json_schema", "schema": schema},
|
||||||
|
permission_mode="acceptEdits",
|
||||||
|
)
|
||||||
|
|
||||||
|
result_message = None
|
||||||
|
async for message in query(
|
||||||
|
prompt="Analyze this text: 'Hello world'. Provide word count, character count, and list of words.",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, ResultMessage):
|
||||||
|
result_message = message
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert result_message is not None
|
||||||
|
assert not result_message.is_error
|
||||||
|
assert result_message.structured_output is not None
|
||||||
|
|
||||||
|
# Check nested structure
|
||||||
|
output = result_message.structured_output
|
||||||
|
assert "analysis" in output
|
||||||
|
assert "words" in output
|
||||||
|
assert output["analysis"]["word_count"] == 2
|
||||||
|
assert output["analysis"]["character_count"] == 11 # "Hello world"
|
||||||
|
assert len(output["words"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_structured_output_with_enum():
|
||||||
|
"""Test structured output with enum constraints requiring code analysis."""
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"has_tests": {"type": "boolean"},
|
||||||
|
"test_framework": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["pytest", "unittest", "nose", "unknown"],
|
||||||
|
},
|
||||||
|
"test_count": {"type": "number"},
|
||||||
|
},
|
||||||
|
"required": ["has_tests", "test_framework"],
|
||||||
|
}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
output_format={"type": "json_schema", "schema": schema},
|
||||||
|
permission_mode="acceptEdits",
|
||||||
|
cwd=".",
|
||||||
|
)
|
||||||
|
|
||||||
|
result_message = None
|
||||||
|
async for message in query(
|
||||||
|
prompt="Search for test files in the tests/ directory. Determine which test framework is being used (pytest/unittest/nose) and count how many test files exist. Use Grep to search for framework imports.",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, ResultMessage):
|
||||||
|
result_message = message
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert result_message is not None
|
||||||
|
assert not result_message.is_error
|
||||||
|
assert result_message.structured_output is not None
|
||||||
|
|
||||||
|
# Check enum values are valid
|
||||||
|
output = result_message.structured_output
|
||||||
|
assert output["test_framework"] in ["pytest", "unittest", "nose", "unknown"]
|
||||||
|
assert isinstance(output["has_tests"], bool)
|
||||||
|
|
||||||
|
# This repo uses pytest
|
||||||
|
assert output["has_tests"] is True
|
||||||
|
assert output["test_framework"] == "pytest"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_structured_output_with_tools():
|
||||||
|
"""Test structured output when agent uses tools."""
|
||||||
|
|
||||||
|
# Schema for file analysis
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_count": {"type": "number"},
|
||||||
|
"has_readme": {"type": "boolean"},
|
||||||
|
},
|
||||||
|
"required": ["file_count", "has_readme"],
|
||||||
|
}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
output_format={"type": "json_schema", "schema": schema},
|
||||||
|
permission_mode="acceptEdits",
|
||||||
|
cwd=tempfile.gettempdir(), # Cross-platform temp directory
|
||||||
|
)
|
||||||
|
|
||||||
|
result_message = None
|
||||||
|
async for message in query(
|
||||||
|
prompt="Count how many files are in the current directory and check if there's a README file. Use tools as needed.",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, ResultMessage):
|
||||||
|
result_message = message
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert result_message is not None
|
||||||
|
assert not result_message.is_error
|
||||||
|
assert result_message.structured_output is not None
|
||||||
|
|
||||||
|
# Check structure
|
||||||
|
output = result_message.structured_output
|
||||||
|
assert "file_count" in output
|
||||||
|
assert "has_readme" in output
|
||||||
|
assert isinstance(output["file_count"], (int, float))
|
||||||
|
assert isinstance(output["has_readme"], bool)
|
||||||
|
assert output["file_count"] >= 0 # Should be non-negative
|
||||||
107
examples/filesystem_agents.py
Normal file
107
examples/filesystem_agents.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example of loading filesystem-based agents via setting_sources.
|
||||||
|
|
||||||
|
This example demonstrates how to load agents defined in .claude/agents/ files
|
||||||
|
using the setting_sources option. This is different from inline AgentDefinition
|
||||||
|
objects - these agents are loaded from markdown files on disk.
|
||||||
|
|
||||||
|
This example tests the scenario from issue #406 where filesystem-based agents
|
||||||
|
loaded via setting_sources=["project"] may silently fail in certain environments.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./examples/filesystem_agents.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
ResultMessage,
|
||||||
|
SystemMessage,
|
||||||
|
TextBlock,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_agents(msg: SystemMessage) -> list[str]:
|
||||||
|
"""Extract agent names from system message init data."""
|
||||||
|
if msg.subtype == "init":
|
||||||
|
agents = msg.data.get("agents", [])
|
||||||
|
# Agents can be either strings or dicts with a 'name' field
|
||||||
|
result = []
|
||||||
|
for a in agents:
|
||||||
|
if isinstance(a, str):
|
||||||
|
result.append(a)
|
||||||
|
elif isinstance(a, dict):
|
||||||
|
result.append(a.get("name", ""))
|
||||||
|
return result
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Test loading filesystem-based agents."""
|
||||||
|
print("=== Filesystem Agents Example ===")
|
||||||
|
print("Testing: setting_sources=['project'] with .claude/agents/test-agent.md")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Use the SDK repo directory which has .claude/agents/test-agent.md
|
||||||
|
sdk_dir = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
setting_sources=["project"],
|
||||||
|
cwd=sdk_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
message_types: list[str] = []
|
||||||
|
agents_found: list[str] = []
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Say hello in exactly 3 words")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
message_types.append(type(msg).__name__)
|
||||||
|
|
||||||
|
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||||
|
agents_found = extract_agents(msg)
|
||||||
|
print(f"Init message received. Agents loaded: {agents_found}")
|
||||||
|
|
||||||
|
elif isinstance(msg, AssistantMessage):
|
||||||
|
for block in msg.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Assistant: {block.text}")
|
||||||
|
|
||||||
|
elif isinstance(msg, ResultMessage):
|
||||||
|
print(
|
||||||
|
f"Result: subtype={msg.subtype}, cost=${msg.total_cost_usd or 0:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Summary ===")
|
||||||
|
print(f"Message types received: {message_types}")
|
||||||
|
print(f"Total messages: {len(message_types)}")
|
||||||
|
|
||||||
|
# Validate the results
|
||||||
|
has_init = "SystemMessage" in message_types
|
||||||
|
has_assistant = "AssistantMessage" in message_types
|
||||||
|
has_result = "ResultMessage" in message_types
|
||||||
|
has_test_agent = "test-agent" in agents_found
|
||||||
|
|
||||||
|
print()
|
||||||
|
if has_init and has_assistant and has_result:
|
||||||
|
print("SUCCESS: Received full response (init, assistant, result)")
|
||||||
|
else:
|
||||||
|
print("FAILURE: Did not receive full response")
|
||||||
|
print(f" - Init: {has_init}")
|
||||||
|
print(f" - Assistant: {has_assistant}")
|
||||||
|
print(f" - Result: {has_result}")
|
||||||
|
|
||||||
|
if has_test_agent:
|
||||||
|
print("SUCCESS: test-agent was loaded from filesystem")
|
||||||
|
else:
|
||||||
|
print("WARNING: test-agent was NOT loaded (may not exist in .claude/agents/)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
95
examples/max_budget_usd.py
Normal file
95
examples/max_budget_usd.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example demonstrating max_budget_usd option for cost control."""
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ResultMessage,
|
||||||
|
TextBlock,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def without_budget():
|
||||||
|
"""Example without budget limit."""
|
||||||
|
print("=== Without Budget Limit ===")
|
||||||
|
|
||||||
|
async for message in query(prompt="What is 2 + 2?"):
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
if message.total_cost_usd:
|
||||||
|
print(f"Total cost: ${message.total_cost_usd:.4f}")
|
||||||
|
print(f"Status: {message.subtype}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def with_reasonable_budget():
|
||||||
|
"""Example with budget that won't be exceeded."""
|
||||||
|
print("=== With Reasonable Budget ($0.10) ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
max_budget_usd=0.10, # 10 cents - plenty for a simple query
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(prompt="What is 2 + 2?", options=options):
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
if message.total_cost_usd:
|
||||||
|
print(f"Total cost: ${message.total_cost_usd:.4f}")
|
||||||
|
print(f"Status: {message.subtype}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def with_tight_budget():
|
||||||
|
"""Example with very tight budget that will likely be exceeded."""
|
||||||
|
print("=== With Tight Budget ($0.0001) ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
max_budget_usd=0.0001, # Very small budget - will be exceeded quickly
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(
|
||||||
|
prompt="Read the README.md file and summarize it", options=options
|
||||||
|
):
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
if message.total_cost_usd:
|
||||||
|
print(f"Total cost: ${message.total_cost_usd:.4f}")
|
||||||
|
print(f"Status: {message.subtype}")
|
||||||
|
|
||||||
|
# Check if budget was exceeded
|
||||||
|
if message.subtype == "error_max_budget_usd":
|
||||||
|
print("⚠️ Budget limit exceeded!")
|
||||||
|
print(
|
||||||
|
"Note: The cost may exceed the budget by up to one API call's worth"
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all examples."""
|
||||||
|
print("This example demonstrates using max_budget_usd to control API costs.\n")
|
||||||
|
|
||||||
|
await without_budget()
|
||||||
|
await with_reasonable_budget()
|
||||||
|
await with_tight_budget()
|
||||||
|
|
||||||
|
print(
|
||||||
|
"\nNote: Budget checking happens after each API call completes,\n"
|
||||||
|
"so the final cost may slightly exceed the specified budget.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
anyio.run(main)
|
||||||
71
examples/plugin_example.py
Normal file
71
examples/plugin_example.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example demonstrating how to use plugins with Claude Code SDK.
|
||||||
|
|
||||||
|
Plugins allow you to extend Claude Code with custom commands, agents, skills,
|
||||||
|
and hooks. This example shows how to load a local plugin and verify it's
|
||||||
|
loaded by checking the system message.
|
||||||
|
|
||||||
|
The demo plugin is located in examples/plugins/demo-plugin/ and provides
|
||||||
|
a custom /greet command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
SystemMessage,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def plugin_example():
|
||||||
|
"""Example showing plugins being loaded in the system message."""
|
||||||
|
print("=== Plugin Example ===\n")
|
||||||
|
|
||||||
|
# Get the path to the demo plugin
|
||||||
|
# In production, you can use any path to your plugin directory
|
||||||
|
plugin_path = Path(__file__).parent / "plugins" / "demo-plugin"
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
plugins=[
|
||||||
|
{
|
||||||
|
"type": "local",
|
||||||
|
"path": str(plugin_path),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_turns=1, # Limit to one turn for quick demo
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Loading plugin from: {plugin_path}\n")
|
||||||
|
|
||||||
|
found_plugins = False
|
||||||
|
async for message in query(prompt="Hello!", options=options):
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
print("System initialized!")
|
||||||
|
print(f"System message data keys: {list(message.data.keys())}\n")
|
||||||
|
|
||||||
|
# Check for plugins in the system message
|
||||||
|
plugins_data = message.data.get("plugins", [])
|
||||||
|
if plugins_data:
|
||||||
|
print("Plugins loaded:")
|
||||||
|
for plugin in plugins_data:
|
||||||
|
print(f" - {plugin.get('name')} (path: {plugin.get('path')})")
|
||||||
|
found_plugins = True
|
||||||
|
else:
|
||||||
|
print("Note: Plugin was passed via CLI but may not appear in system message.")
|
||||||
|
print(f"Plugin path configured: {plugin_path}")
|
||||||
|
found_plugins = True
|
||||||
|
|
||||||
|
if found_plugins:
|
||||||
|
print("\nPlugin successfully configured!\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all plugin examples."""
|
||||||
|
await plugin_example()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
anyio.run(main)
|
||||||
8
examples/plugins/demo-plugin/.claude-plugin/plugin.json
Normal file
8
examples/plugins/demo-plugin/.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "demo-plugin",
|
||||||
|
"description": "A demo plugin showing how to extend Claude Code with custom commands",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Claude Code Team"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
examples/plugins/demo-plugin/commands/greet.md
Normal file
5
examples/plugins/demo-plugin/commands/greet.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Greet Command
|
||||||
|
|
||||||
|
This is a custom greeting command from the demo plugin.
|
||||||
|
|
||||||
|
When the user runs this command, greet them warmly and explain that this message came from a custom plugin loaded via the Python SDK. Tell them that plugins can be used to extend Claude Code with custom commands, agents, skills, and hooks.
|
||||||
111
examples/tools_option.py
Normal file
111
examples/tools_option.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example demonstrating the tools option and verifying tools in system message."""
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ResultMessage,
|
||||||
|
SystemMessage,
|
||||||
|
TextBlock,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def tools_array_example():
|
||||||
|
"""Example with tools as array of specific tool names."""
|
||||||
|
print("=== Tools Array Example ===")
|
||||||
|
print("Setting tools=['Read', 'Glob', 'Grep']")
|
||||||
|
print()
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
tools=["Read", "Glob", "Grep"],
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(
|
||||||
|
prompt="What tools do you have available? Just list them briefly.",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
tools = message.data.get("tools", [])
|
||||||
|
print(f"Tools from system message: {tools}")
|
||||||
|
print()
|
||||||
|
elif isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
if message.total_cost_usd:
|
||||||
|
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def tools_empty_array_example():
|
||||||
|
"""Example with tools as empty array (disables all built-in tools)."""
|
||||||
|
print("=== Tools Empty Array Example ===")
|
||||||
|
print("Setting tools=[] (disables all built-in tools)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
tools=[],
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(
|
||||||
|
prompt="What tools do you have available? Just list them briefly.",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
tools = message.data.get("tools", [])
|
||||||
|
print(f"Tools from system message: {tools}")
|
||||||
|
print()
|
||||||
|
elif isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
if message.total_cost_usd:
|
||||||
|
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def tools_preset_example():
|
||||||
|
"""Example with tools preset (all default Claude Code tools)."""
|
||||||
|
print("=== Tools Preset Example ===")
|
||||||
|
print("Setting tools={'type': 'preset', 'preset': 'claude_code'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
tools={"type": "preset", "preset": "claude_code"},
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(
|
||||||
|
prompt="What tools do you have available? Just list them briefly.",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
tools = message.data.get("tools", [])
|
||||||
|
print(f"Tools from system message ({len(tools)} tools): {tools[:5]}...")
|
||||||
|
print()
|
||||||
|
elif isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
if message.total_cost_usd:
|
||||||
|
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all examples."""
|
||||||
|
await tools_array_example()
|
||||||
|
await tools_empty_array_example()
|
||||||
|
await tools_preset_example()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
anyio.run(main)
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "claude-agent-sdk"
|
name = "claude-agent-sdk"
|
||||||
version = "0.1.4"
|
version = "0.1.18"
|
||||||
description = "Python SDK for Claude Code"
|
description = "Python SDK for Claude Code"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
@ -47,6 +47,7 @@ Issues = "https://github.com/anthropics/claude-agent-sdk-python/issues"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/claude_agent_sdk"]
|
packages = ["src/claude_agent_sdk"]
|
||||||
|
only-include = ["src/claude_agent_sdk"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
include = [
|
include = [
|
||||||
|
|
|
||||||
392
scripts/build_wheel.py
Executable file
392
scripts/build_wheel.py
Executable file
|
|
@ -0,0 +1,392 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build wheel with bundled Claude Code CLI.
|
||||||
|
|
||||||
|
This script handles the complete wheel building process:
|
||||||
|
1. Optionally updates version
|
||||||
|
2. Downloads Claude Code CLI
|
||||||
|
3. Builds the wheel
|
||||||
|
4. Optionally cleans up the bundled CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/build_wheel.py # Build with current version
|
||||||
|
python scripts/build_wheel.py --version 0.1.4 # Build with specific version
|
||||||
|
python scripts/build_wheel.py --clean # Clean bundled CLI after build
|
||||||
|
python scripts/build_wheel.py --skip-download # Skip CLI download (use existing)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import twine # noqa: F401
|
||||||
|
|
||||||
|
HAS_TWINE = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_TWINE = False
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(cmd: list[str], description: str) -> None:
|
||||||
|
"""Run a command and handle errors."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"{description}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"$ {' '.join(cmd)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
print(result.stdout)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error: {description} failed", file=sys.stderr)
|
||||||
|
print(e.stdout, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def update_version(version: str) -> None:
|
||||||
|
"""Update package version."""
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
update_script = script_dir / "update_version.py"
|
||||||
|
|
||||||
|
if not update_script.exists():
|
||||||
|
print("Warning: update_version.py not found, skipping version update")
|
||||||
|
return
|
||||||
|
|
||||||
|
run_command(
|
||||||
|
[sys.executable, str(update_script), version],
|
||||||
|
f"Updating version to {version}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_bundled_cli_version() -> str:
|
||||||
|
"""Get the CLI version that should be bundled from _cli_version.py."""
|
||||||
|
version_file = Path("src/claude_agent_sdk/_cli_version.py")
|
||||||
|
if not version_file.exists():
|
||||||
|
return "latest"
|
||||||
|
|
||||||
|
content = version_file.read_text()
|
||||||
|
match = re.search(r'__cli_version__ = "([^"]+)"', content)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return "latest"
|
||||||
|
|
||||||
|
|
||||||
|
def download_cli(cli_version: str | None = None) -> None:
|
||||||
|
"""Download Claude Code CLI."""
|
||||||
|
# Use provided version, or fall back to version from _cli_version.py
|
||||||
|
if cli_version is None:
|
||||||
|
cli_version = get_bundled_cli_version()
|
||||||
|
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
download_script = script_dir / "download_cli.py"
|
||||||
|
|
||||||
|
# Set environment variable for download script
|
||||||
|
os.environ["CLAUDE_CLI_VERSION"] = cli_version
|
||||||
|
|
||||||
|
run_command(
|
||||||
|
[sys.executable, str(download_script)],
|
||||||
|
f"Downloading Claude Code CLI ({cli_version})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_dist() -> None:
|
||||||
|
"""Clean dist directory."""
|
||||||
|
dist_dir = Path("dist")
|
||||||
|
if dist_dir.exists():
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Cleaning dist directory")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
shutil.rmtree(dist_dir)
|
||||||
|
print("Cleaned dist/")
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform_tag() -> str:
|
||||||
|
"""Get the appropriate platform tag for the current platform.
|
||||||
|
|
||||||
|
Uses minimum compatible versions for broad compatibility:
|
||||||
|
- macOS: 11.0 (Big Sur) as minimum
|
||||||
|
- Linux: manylinux_2_17 (widely compatible)
|
||||||
|
- Windows: Standard tags
|
||||||
|
"""
|
||||||
|
system = platform.system()
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
|
||||||
|
if system == "Darwin":
|
||||||
|
# macOS - use minimum version 11.0 (Big Sur) for broad compatibility
|
||||||
|
if machine == "arm64":
|
||||||
|
return "macosx_11_0_arm64"
|
||||||
|
else:
|
||||||
|
return "macosx_11_0_x86_64"
|
||||||
|
elif system == "Linux":
|
||||||
|
# Linux - use manylinux for broad compatibility
|
||||||
|
if machine in ["x86_64", "amd64"]:
|
||||||
|
return "manylinux_2_17_x86_64"
|
||||||
|
elif machine in ["aarch64", "arm64"]:
|
||||||
|
return "manylinux_2_17_aarch64"
|
||||||
|
else:
|
||||||
|
return f"linux_{machine}"
|
||||||
|
elif system == "Windows":
|
||||||
|
# Windows
|
||||||
|
if machine in ["x86_64", "amd64"]:
|
||||||
|
return "win_amd64"
|
||||||
|
elif machine == "arm64":
|
||||||
|
return "win_arm64"
|
||||||
|
else:
|
||||||
|
return "win32"
|
||||||
|
else:
|
||||||
|
# Unknown platform, use generic
|
||||||
|
return f"{system.lower()}_{machine}"
|
||||||
|
|
||||||
|
|
||||||
|
def retag_wheel(wheel_path: Path, platform_tag: str) -> Path:
|
||||||
|
"""Retag a wheel with the correct platform tag using wheel package."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Retagging wheel as platform-specific")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"Old: {wheel_path.name}")
|
||||||
|
|
||||||
|
# Use wheel package to properly retag (updates both filename and metadata)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"wheel",
|
||||||
|
"tags",
|
||||||
|
"--platform-tag",
|
||||||
|
platform_tag,
|
||||||
|
"--remove",
|
||||||
|
str(wheel_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Warning: Failed to retag wheel: {result.stderr}")
|
||||||
|
return wheel_path
|
||||||
|
|
||||||
|
# Find the newly tagged wheel
|
||||||
|
dist_dir = wheel_path.parent
|
||||||
|
# The wheel package creates a new file with the platform tag
|
||||||
|
new_wheels = list(dist_dir.glob(f"*{platform_tag}.whl"))
|
||||||
|
|
||||||
|
if new_wheels:
|
||||||
|
new_path = new_wheels[0]
|
||||||
|
print(f"New: {new_path.name}")
|
||||||
|
print("Wheel retagged successfully")
|
||||||
|
|
||||||
|
# Remove the old wheel
|
||||||
|
if wheel_path.exists() and wheel_path != new_path:
|
||||||
|
wheel_path.unlink()
|
||||||
|
|
||||||
|
return new_path
|
||||||
|
else:
|
||||||
|
print("Warning: Could not find retagged wheel")
|
||||||
|
return wheel_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_wheel() -> None:
|
||||||
|
"""Build the wheel."""
|
||||||
|
run_command(
|
||||||
|
[sys.executable, "-m", "build", "--wheel"],
|
||||||
|
"Building wheel",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we have a bundled CLI - if so, retag the wheel as platform-specific
|
||||||
|
bundled_cli = Path("src/claude_agent_sdk/_bundled/claude")
|
||||||
|
bundled_cli_exe = Path("src/claude_agent_sdk/_bundled/claude.exe")
|
||||||
|
|
||||||
|
if bundled_cli.exists() or bundled_cli_exe.exists():
|
||||||
|
# Find the built wheel
|
||||||
|
dist_dir = Path("dist")
|
||||||
|
wheels = list(dist_dir.glob("*.whl"))
|
||||||
|
|
||||||
|
if wheels:
|
||||||
|
# Get platform tag
|
||||||
|
platform_tag = get_platform_tag()
|
||||||
|
|
||||||
|
# Retag each wheel (should only be one)
|
||||||
|
for wheel in wheels:
|
||||||
|
if "-any.whl" in wheel.name:
|
||||||
|
retag_wheel(wheel, platform_tag)
|
||||||
|
else:
|
||||||
|
print("Warning: No wheel found to retag")
|
||||||
|
else:
|
||||||
|
print("\nNo bundled CLI found - wheel will be platform-independent")
|
||||||
|
|
||||||
|
|
||||||
|
def build_sdist() -> None:
|
||||||
|
"""Build the source distribution."""
|
||||||
|
run_command(
|
||||||
|
[sys.executable, "-m", "build", "--sdist"],
|
||||||
|
"Building source distribution",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_package() -> None:
|
||||||
|
"""Check package with twine."""
|
||||||
|
if not HAS_TWINE:
|
||||||
|
print("\nWarning: twine not installed, skipping package check")
|
||||||
|
print("Install with: pip install twine")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Checking package with twine")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"$ {sys.executable} -m twine check dist/*")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "twine", "check", "dist/*"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
print(result.stdout)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("\nWarning: twine check reported issues")
|
||||||
|
print("Note: 'License-File' warnings are false positives from twine 6.x")
|
||||||
|
print("PyPI will accept these packages without issues")
|
||||||
|
else:
|
||||||
|
print("Package check passed")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to run twine check: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_bundled_cli() -> None:
|
||||||
|
"""Clean bundled CLI."""
|
||||||
|
bundled_dir = Path("src/claude_agent_sdk/_bundled")
|
||||||
|
cli_files = list(bundled_dir.glob("claude*"))
|
||||||
|
|
||||||
|
if cli_files:
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Cleaning bundled CLI")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
for cli_file in cli_files:
|
||||||
|
if cli_file.name != ".gitignore":
|
||||||
|
cli_file.unlink()
|
||||||
|
print(f"Removed {cli_file}")
|
||||||
|
else:
|
||||||
|
print("\nNo bundled CLI to clean")
|
||||||
|
|
||||||
|
|
||||||
|
def list_artifacts() -> None:
|
||||||
|
"""List built artifacts."""
|
||||||
|
dist_dir = Path("dist")
|
||||||
|
if not dist_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Built Artifacts")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
artifacts = sorted(dist_dir.iterdir())
|
||||||
|
if not artifacts:
|
||||||
|
print("No artifacts found")
|
||||||
|
return
|
||||||
|
|
||||||
|
for artifact in artifacts:
|
||||||
|
size_mb = artifact.stat().st_size / (1024 * 1024)
|
||||||
|
print(f" {artifact.name:<50} {size_mb:>8.2f} MB")
|
||||||
|
|
||||||
|
total_size = sum(f.stat().st_size for f in artifacts) / (1024 * 1024)
|
||||||
|
print(f"\n {'Total:':<50} {total_size:>8.2f} MB")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build wheel with bundled Claude Code CLI"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
help="Version to set before building (e.g., 0.1.4)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cli-version",
|
||||||
|
default=None,
|
||||||
|
help="Claude Code CLI version to download (default: read from _cli_version.py)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-download",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip downloading CLI (use existing bundled CLI)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-sdist",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip building source distribution",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--clean",
|
||||||
|
action="store_true",
|
||||||
|
help="Clean bundled CLI after building",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--clean-dist",
|
||||||
|
action="store_true",
|
||||||
|
help="Clean dist directory before building",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Claude Agent SDK - Wheel Builder")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Clean dist if requested
|
||||||
|
if args.clean_dist:
|
||||||
|
clean_dist()
|
||||||
|
|
||||||
|
# Update version if specified
|
||||||
|
if args.version:
|
||||||
|
update_version(args.version)
|
||||||
|
|
||||||
|
# Download CLI unless skipped
|
||||||
|
if not args.skip_download:
|
||||||
|
download_cli(args.cli_version)
|
||||||
|
else:
|
||||||
|
print("\nSkipping CLI download (using existing)")
|
||||||
|
|
||||||
|
# Build wheel
|
||||||
|
build_wheel()
|
||||||
|
|
||||||
|
# Build sdist unless skipped
|
||||||
|
if not args.skip_sdist:
|
||||||
|
build_sdist()
|
||||||
|
|
||||||
|
# Check package
|
||||||
|
check_package()
|
||||||
|
|
||||||
|
# Clean bundled CLI if requested
|
||||||
|
if args.clean:
|
||||||
|
clean_bundled_cli()
|
||||||
|
|
||||||
|
# List artifacts
|
||||||
|
list_artifacts()
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Build complete!")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print("\nNext steps:")
|
||||||
|
print(" 1. Test the wheel: pip install dist/*.whl")
|
||||||
|
print(" 2. Run tests: python -m pytest tests/")
|
||||||
|
print(" 3. Publish: twine upload dist/*")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
157
scripts/download_cli.py
Executable file
157
scripts/download_cli.py
Executable file
|
|
@ -0,0 +1,157 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download Claude Code CLI binary for bundling in wheel.
|
||||||
|
|
||||||
|
This script is run during the wheel build process to fetch the Claude Code CLI
|
||||||
|
binary using the official install script and place it in the package directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli_version() -> str:
|
||||||
|
"""Get the CLI version to download from environment or default."""
|
||||||
|
return os.environ.get("CLAUDE_CLI_VERSION", "latest")
|
||||||
|
|
||||||
|
|
||||||
|
def find_installed_cli() -> Path | None:
|
||||||
|
"""Find the installed Claude CLI binary."""
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == "Windows":
|
||||||
|
# Windows installation locations (matches test.yml: $USERPROFILE\.local\bin)
|
||||||
|
locations = [
|
||||||
|
Path.home() / ".local" / "bin" / "claude.exe",
|
||||||
|
Path(os.environ.get("LOCALAPPDATA", "")) / "Claude" / "claude.exe",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# Unix installation locations
|
||||||
|
locations = [
|
||||||
|
Path.home() / ".local" / "bin" / "claude",
|
||||||
|
Path("/usr/local/bin/claude"),
|
||||||
|
Path.home() / "node_modules" / ".bin" / "claude",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Also check PATH
|
||||||
|
cli_path = shutil.which("claude")
|
||||||
|
if cli_path:
|
||||||
|
return Path(cli_path)
|
||||||
|
|
||||||
|
for path in locations:
|
||||||
|
if path.exists() and path.is_file():
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def download_cli() -> None:
|
||||||
|
"""Download Claude Code CLI using the official install script."""
|
||||||
|
version = get_cli_version()
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
print(f"Downloading Claude Code CLI version: {version}")
|
||||||
|
|
||||||
|
# Build install command based on platform
|
||||||
|
if system == "Windows":
|
||||||
|
# Use PowerShell installer on Windows
|
||||||
|
if version == "latest":
|
||||||
|
install_cmd = [
|
||||||
|
"powershell",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
"irm https://claude.ai/install.ps1 | iex",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
install_cmd = [
|
||||||
|
"powershell",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
f"& ([scriptblock]::Create((irm https://claude.ai/install.ps1))) {version}",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# Use bash installer on Unix-like systems
|
||||||
|
if version == "latest":
|
||||||
|
install_cmd = ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"]
|
||||||
|
else:
|
||||||
|
install_cmd = [
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
f"curl -fsSL https://claude.ai/install.sh | bash -s {version}",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
install_cmd,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error downloading CLI: {e}", file=sys.stderr)
|
||||||
|
print(f"stdout: {e.stdout.decode()}", file=sys.stderr)
|
||||||
|
print(f"stderr: {e.stderr.decode()}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_cli_to_bundle() -> None:
|
||||||
|
"""Copy the installed CLI to the package _bundled directory."""
|
||||||
|
# Find project root (parent of scripts directory)
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
project_root = script_dir.parent
|
||||||
|
bundle_dir = project_root / "src" / "claude_agent_sdk" / "_bundled"
|
||||||
|
|
||||||
|
# Ensure bundle directory exists
|
||||||
|
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Find installed CLI
|
||||||
|
cli_path = find_installed_cli()
|
||||||
|
if not cli_path:
|
||||||
|
print("Error: Could not find installed Claude CLI binary", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Found CLI at: {cli_path}")
|
||||||
|
|
||||||
|
# Determine target filename based on platform
|
||||||
|
system = platform.system()
|
||||||
|
target_name = "claude.exe" if system == "Windows" else "claude"
|
||||||
|
target_path = bundle_dir / target_name
|
||||||
|
|
||||||
|
# Copy the binary
|
||||||
|
print(f"Copying CLI to: {target_path}")
|
||||||
|
shutil.copy2(cli_path, target_path)
|
||||||
|
|
||||||
|
# Make it executable (Unix-like systems)
|
||||||
|
if system != "Windows":
|
||||||
|
target_path.chmod(0o755)
|
||||||
|
|
||||||
|
print(f"Successfully bundled CLI binary: {target_path}")
|
||||||
|
|
||||||
|
# Print size info
|
||||||
|
size_mb = target_path.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"Binary size: {size_mb:.2f} MB")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Claude Code CLI Download Script")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Download CLI
|
||||||
|
download_cli()
|
||||||
|
|
||||||
|
# Copy to bundle directory
|
||||||
|
copy_cli_to_bundle()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("CLI download and bundling complete!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
77
scripts/test-docker.sh
Executable file
77
scripts/test-docker.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Run SDK tests in a Docker container
|
||||||
|
# This helps catch Docker-specific issues like #406
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/test-docker.sh [unit|e2e|all]
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./scripts/test-docker.sh unit # Run unit tests only
|
||||||
|
# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh e2e # Run e2e tests
|
||||||
|
# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh all # Run all tests
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 [unit|e2e|all]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " unit - Run unit tests only (no API key needed)"
|
||||||
|
echo " e2e - Run e2e tests (requires ANTHROPIC_API_KEY)"
|
||||||
|
echo " all - Run both unit and e2e tests"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 unit"
|
||||||
|
echo " ANTHROPIC_API_KEY=sk-... $0 e2e"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Building Docker test image..."
|
||||||
|
docker build -f Dockerfile.test -t claude-sdk-test .
|
||||||
|
|
||||||
|
case "${1:-unit}" in
|
||||||
|
unit)
|
||||||
|
echo ""
|
||||||
|
echo "Running unit tests in Docker..."
|
||||||
|
docker run --rm claude-sdk-test \
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
;;
|
||||||
|
e2e)
|
||||||
|
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||||
|
echo "Error: ANTHROPIC_API_KEY environment variable is required for e2e tests"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: ANTHROPIC_API_KEY=sk-... $0 e2e"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Running e2e tests in Docker..."
|
||||||
|
docker run --rm -e ANTHROPIC_API_KEY \
|
||||||
|
claude-sdk-test python -m pytest e2e-tests/ -v -m e2e
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
echo ""
|
||||||
|
echo "Running unit tests in Docker..."
|
||||||
|
docker run --rm claude-sdk-test \
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ -n "$ANTHROPIC_API_KEY" ]; then
|
||||||
|
echo "Running e2e tests in Docker..."
|
||||||
|
docker run --rm -e ANTHROPIC_API_KEY \
|
||||||
|
claude-sdk-test python -m pytest e2e-tests/ -v -m e2e
|
||||||
|
else
|
||||||
|
echo "Skipping e2e tests (ANTHROPIC_API_KEY not set)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done!"
|
||||||
32
scripts/update_cli_version.py
Executable file
32
scripts/update_cli_version.py
Executable file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Update Claude Code CLI version in _cli_version.py."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def update_cli_version(new_version: str) -> None:
|
||||||
|
"""Update CLI version in _cli_version.py."""
|
||||||
|
# Update _cli_version.py
|
||||||
|
version_path = Path("src/claude_agent_sdk/_cli_version.py")
|
||||||
|
content = version_path.read_text()
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'__cli_version__ = "[^"]+"',
|
||||||
|
f'__cli_version__ = "{new_version}"',
|
||||||
|
content,
|
||||||
|
count=1,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
version_path.write_text(content)
|
||||||
|
print(f"Updated {version_path} to {new_version}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python scripts/update_cli_version.py <version>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
update_cli_version(sys.argv[1])
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Update version in pyproject.toml and __init__.py files."""
|
"""Update version in pyproject.toml and __init__.py files."""
|
||||||
|
|
||||||
import sys
|
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ def update_version(new_version: str) -> None:
|
||||||
f'version = "{new_version}"',
|
f'version = "{new_version}"',
|
||||||
content,
|
content,
|
||||||
count=1,
|
count=1,
|
||||||
flags=re.MULTILINE
|
flags=re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
pyproject_path.write_text(content)
|
pyproject_path.write_text(content)
|
||||||
|
|
@ -34,7 +34,7 @@ def update_version(new_version: str) -> None:
|
||||||
f'__version__ = "{new_version}"',
|
f'__version__ = "{new_version}"',
|
||||||
content,
|
content,
|
||||||
count=1,
|
count=1,
|
||||||
flags=re.MULTILINE
|
flags=re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
version_path.write_text(content)
|
version_path.write_text(content)
|
||||||
|
|
@ -45,5 +45,5 @@ if __name__ == "__main__":
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("Usage: python scripts/update_version.py <version>")
|
print("Usage: python scripts/update_version.py <version>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
update_version(sys.argv[1])
|
update_version(sys.argv[1])
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ from .types import (
|
||||||
PreCompactHookInput,
|
PreCompactHookInput,
|
||||||
PreToolUseHookInput,
|
PreToolUseHookInput,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
|
SandboxIgnoreViolations,
|
||||||
|
SandboxNetworkConfig,
|
||||||
|
SandboxSettings,
|
||||||
|
SdkBeta,
|
||||||
|
SdkPluginConfig,
|
||||||
SettingSource,
|
SettingSource,
|
||||||
StopHookInput,
|
StopHookInput,
|
||||||
SubagentStopHookInput,
|
SubagentStopHookInput,
|
||||||
|
|
@ -214,7 +219,7 @@ def create_sdk_mcp_server(
|
||||||
tool_map = {tool_def.name: tool_def for tool_def in tools}
|
tool_map = {tool_def.name: tool_def for tool_def in tools}
|
||||||
|
|
||||||
# Register list_tools handler to expose available tools
|
# Register list_tools handler to expose available tools
|
||||||
@server.list_tools() # type: ignore[no-untyped-call,misc]
|
@server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
|
||||||
async def list_tools() -> list[Tool]:
|
async def list_tools() -> list[Tool]:
|
||||||
"""Return the list of available tools."""
|
"""Return the list of available tools."""
|
||||||
tool_list = []
|
tool_list = []
|
||||||
|
|
@ -260,7 +265,7 @@ def create_sdk_mcp_server(
|
||||||
return tool_list
|
return tool_list
|
||||||
|
|
||||||
# Register call_tool handler to execute tools
|
# Register call_tool handler to execute tools
|
||||||
@server.call_tool() # type: ignore[misc]
|
@server.call_tool() # type: ignore[untyped-decorator]
|
||||||
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
|
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
|
||||||
"""Execute a tool by name with given arguments."""
|
"""Execute a tool by name with given arguments."""
|
||||||
if name not in tool_map:
|
if name not in tool_map:
|
||||||
|
|
@ -339,6 +344,14 @@ __all__ = [
|
||||||
# Agent support
|
# Agent support
|
||||||
"AgentDefinition",
|
"AgentDefinition",
|
||||||
"SettingSource",
|
"SettingSource",
|
||||||
|
# Plugin support
|
||||||
|
"SdkPluginConfig",
|
||||||
|
# Beta support
|
||||||
|
"SdkBeta",
|
||||||
|
# Sandbox support
|
||||||
|
"SandboxSettings",
|
||||||
|
"SandboxNetworkConfig",
|
||||||
|
"SandboxIgnoreViolations",
|
||||||
# MCP Server Support
|
# MCP Server Support
|
||||||
"create_sdk_mcp_server",
|
"create_sdk_mcp_server",
|
||||||
"tool",
|
"tool",
|
||||||
|
|
|
||||||
3
src/claude_agent_sdk/_bundled/.gitignore
vendored
Normal file
3
src/claude_agent_sdk/_bundled/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Ignore bundled CLI binaries (downloaded during build)
|
||||||
|
claude
|
||||||
|
claude.exe
|
||||||
3
src/claude_agent_sdk/_cli_version.py
Normal file
3
src/claude_agent_sdk/_cli_version.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Bundled Claude Code CLI version."""
|
||||||
|
|
||||||
|
__cli_version__ = "2.0.74"
|
||||||
|
|
@ -31,10 +31,12 @@ class InternalClient:
|
||||||
internal_hooks[event] = []
|
internal_hooks[event] = []
|
||||||
for matcher in matchers:
|
for matcher in matchers:
|
||||||
# Convert HookMatcher to internal dict format
|
# Convert HookMatcher to internal dict format
|
||||||
internal_matcher = {
|
internal_matcher: dict[str, Any] = {
|
||||||
"matcher": matcher.matcher if hasattr(matcher, "matcher") else None,
|
"matcher": matcher.matcher if hasattr(matcher, "matcher") else None,
|
||||||
"hooks": matcher.hooks if hasattr(matcher, "hooks") else [],
|
"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)
|
internal_hooks[event].append(internal_matcher)
|
||||||
return internal_hooks
|
return internal_hooks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ def parse_message(data: dict[str, Any]) -> Message:
|
||||||
case "user":
|
case "user":
|
||||||
try:
|
try:
|
||||||
parent_tool_use_id = data.get("parent_tool_use_id")
|
parent_tool_use_id = data.get("parent_tool_use_id")
|
||||||
|
uuid = data.get("uuid")
|
||||||
if isinstance(data["message"]["content"], list):
|
if isinstance(data["message"]["content"], list):
|
||||||
user_content_blocks: list[ContentBlock] = []
|
user_content_blocks: list[ContentBlock] = []
|
||||||
for block in data["message"]["content"]:
|
for block in data["message"]["content"]:
|
||||||
|
|
@ -74,10 +75,12 @@ def parse_message(data: dict[str, Any]) -> Message:
|
||||||
)
|
)
|
||||||
return UserMessage(
|
return UserMessage(
|
||||||
content=user_content_blocks,
|
content=user_content_blocks,
|
||||||
|
uuid=uuid,
|
||||||
parent_tool_use_id=parent_tool_use_id,
|
parent_tool_use_id=parent_tool_use_id,
|
||||||
)
|
)
|
||||||
return UserMessage(
|
return UserMessage(
|
||||||
content=data["message"]["content"],
|
content=data["message"]["content"],
|
||||||
|
uuid=uuid,
|
||||||
parent_tool_use_id=parent_tool_use_id,
|
parent_tool_use_id=parent_tool_use_id,
|
||||||
)
|
)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
|
|
@ -120,6 +123,7 @@ def parse_message(data: dict[str, Any]) -> Message:
|
||||||
content=content_blocks,
|
content=content_blocks,
|
||||||
model=data["message"]["model"],
|
model=data["message"]["model"],
|
||||||
parent_tool_use_id=data.get("parent_tool_use_id"),
|
parent_tool_use_id=data.get("parent_tool_use_id"),
|
||||||
|
error=data["message"].get("error"),
|
||||||
)
|
)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise MessageParseError(
|
raise MessageParseError(
|
||||||
|
|
@ -149,6 +153,7 @@ def parse_message(data: dict[str, Any]) -> Message:
|
||||||
total_cost_usd=data.get("total_cost_usd"),
|
total_cost_usd=data.get("total_cost_usd"),
|
||||||
usage=data.get("usage"),
|
usage=data.get("usage"),
|
||||||
result=data.get("result"),
|
result=data.get("result"),
|
||||||
|
structured_output=data.get("structured_output"),
|
||||||
)
|
)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise MessageParseError(
|
raise MessageParseError(
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class Query:
|
||||||
| None = None,
|
| None = None,
|
||||||
hooks: dict[str, list[dict[str, Any]]] | None = None,
|
hooks: dict[str, list[dict[str, Any]]] | None = None,
|
||||||
sdk_mcp_servers: dict[str, "McpServer"] | None = None,
|
sdk_mcp_servers: dict[str, "McpServer"] | None = None,
|
||||||
|
initialize_timeout: float = 60.0,
|
||||||
):
|
):
|
||||||
"""Initialize Query with transport and callbacks.
|
"""Initialize Query with transport and callbacks.
|
||||||
|
|
||||||
|
|
@ -81,7 +82,9 @@ class Query:
|
||||||
can_use_tool: Optional callback for tool permission requests
|
can_use_tool: Optional callback for tool permission requests
|
||||||
hooks: Optional hook configurations
|
hooks: Optional hook configurations
|
||||||
sdk_mcp_servers: Optional SDK MCP server instances
|
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.transport = transport
|
||||||
self.is_streaming_mode = is_streaming_mode
|
self.is_streaming_mode = is_streaming_mode
|
||||||
self.can_use_tool = can_use_tool
|
self.can_use_tool = can_use_tool
|
||||||
|
|
@ -104,6 +107,12 @@ class Query:
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._initialization_result: dict[str, Any] | None = None
|
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:
|
async def initialize(self) -> dict[str, Any] | None:
|
||||||
"""Initialize control protocol if in streaming mode.
|
"""Initialize control protocol if in streaming mode.
|
||||||
|
|
||||||
|
|
@ -126,12 +135,13 @@ class Query:
|
||||||
self.next_callback_id += 1
|
self.next_callback_id += 1
|
||||||
self.hook_callbacks[callback_id] = callback
|
self.hook_callbacks[callback_id] = callback
|
||||||
callback_ids.append(callback_id)
|
callback_ids.append(callback_id)
|
||||||
hooks_config[event].append(
|
hook_matcher_config: dict[str, Any] = {
|
||||||
{
|
"matcher": matcher.get("matcher"),
|
||||||
"matcher": matcher.get("matcher"),
|
"hookCallbackIds": callback_ids,
|
||||||
"hookCallbackIds": callback_ids,
|
}
|
||||||
}
|
if matcher.get("timeout") is not None:
|
||||||
)
|
hook_matcher_config["timeout"] = matcher.get("timeout")
|
||||||
|
hooks_config[event].append(hook_matcher_config)
|
||||||
|
|
||||||
# Send initialize request
|
# Send initialize request
|
||||||
request = {
|
request = {
|
||||||
|
|
@ -139,7 +149,10 @@ class Query:
|
||||||
"hooks": hooks_config if hooks_config else None,
|
"hooks": hooks_config if hooks_config else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await self._send_control_request(request)
|
# Use longer timeout for initialize since MCP servers may take time to start
|
||||||
|
response = await self._send_control_request(
|
||||||
|
request, timeout=self._initialize_timeout
|
||||||
|
)
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
self._initialization_result = response # Store for later access
|
self._initialization_result = response # Store for later access
|
||||||
return response
|
return response
|
||||||
|
|
@ -188,6 +201,10 @@ class Query:
|
||||||
# TODO: Implement cancellation support
|
# TODO: Implement cancellation support
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Track results for proper stream closure
|
||||||
|
if msg_type == "result":
|
||||||
|
self._first_result_event.set()
|
||||||
|
|
||||||
# Regular SDK messages go to the stream
|
# Regular SDK messages go to the stream
|
||||||
await self._message_send.send(message)
|
await self._message_send.send(message)
|
||||||
|
|
||||||
|
|
@ -197,6 +214,11 @@ class Query:
|
||||||
raise # Re-raise to properly handle cancellation
|
raise # Re-raise to properly handle cancellation
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fatal error in message reader: {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
|
# Put error in stream so iterators can handle it
|
||||||
await self._message_send.send({"type": "error", "error": str(e)})
|
await self._message_send.send({"type": "error", "error": str(e)})
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -314,8 +336,15 @@ class Query:
|
||||||
}
|
}
|
||||||
await self.transport.write(json.dumps(error_response) + "\n")
|
await self.transport.write(json.dumps(error_response) + "\n")
|
||||||
|
|
||||||
async def _send_control_request(self, request: dict[str, Any]) -> dict[str, Any]:
|
async def _send_control_request(
|
||||||
"""Send control request to CLI and wait for response."""
|
self, request: dict[str, Any], timeout: float = 60.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send control request to CLI and wait for response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The control request to send
|
||||||
|
timeout: Timeout in seconds to wait for response (default 60s)
|
||||||
|
"""
|
||||||
if not self.is_streaming_mode:
|
if not self.is_streaming_mode:
|
||||||
raise Exception("Control requests require streaming mode")
|
raise Exception("Control requests require streaming mode")
|
||||||
|
|
||||||
|
|
@ -338,7 +367,7 @@ class Query:
|
||||||
|
|
||||||
# Wait for response
|
# Wait for response
|
||||||
try:
|
try:
|
||||||
with anyio.fail_after(60.0):
|
with anyio.fail_after(timeout):
|
||||||
await event.wait()
|
await event.wait()
|
||||||
|
|
||||||
result = self.pending_control_results.pop(request_id)
|
result = self.pending_control_results.pop(request_id)
|
||||||
|
|
@ -510,14 +539,51 @@ class Query:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def rewind_files(self, user_message_id: str) -> None:
|
||||||
|
"""Rewind tracked files to their state at a specific user message.
|
||||||
|
|
||||||
|
Requires file checkpointing to be enabled via the `enable_file_checkpointing` option.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message_id: UUID of the user message to rewind to
|
||||||
|
"""
|
||||||
|
await self._send_control_request(
|
||||||
|
{
|
||||||
|
"subtype": "rewind_files",
|
||||||
|
"user_message_id": user_message_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def stream_input(self, stream: AsyncIterable[dict[str, Any]]) -> None:
|
async def stream_input(self, stream: AsyncIterable[dict[str, Any]]) -> None:
|
||||||
"""Stream input messages to transport."""
|
"""Stream input messages to transport.
|
||||||
|
|
||||||
|
If SDK MCP servers or hooks are present, waits for the first result
|
||||||
|
before closing stdin to allow bidirectional control protocol communication.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
async for message in stream:
|
async for message in stream:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
break
|
break
|
||||||
await self.transport.write(json.dumps(message) + "\n")
|
await self.transport.write(json.dumps(message) + "\n")
|
||||||
# After all messages sent, end input
|
|
||||||
|
# If we have SDK MCP servers or hooks that need bidirectional communication,
|
||||||
|
# wait for first result before closing the channel
|
||||||
|
has_hooks = bool(self.hooks)
|
||||||
|
if self.sdk_mcp_servers or has_hooks:
|
||||||
|
logger.debug(
|
||||||
|
f"Waiting for first result before closing stdin "
|
||||||
|
f"(sdk_mcp_servers={len(self.sdk_mcp_servers)}, has_hooks={has_hooks})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with anyio.move_on_after(self._stream_close_timeout):
|
||||||
|
await self._first_result_event.wait()
|
||||||
|
logger.debug("Received first result, closing input stream")
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"Timed out waiting for first result, closing input stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
# After all messages sent (and result received if needed), end input
|
||||||
await self.transport.end_input()
|
await self.transport.end_input()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error streaming input: {e}")
|
logger.debug(f"Error streaming input: {e}")
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,16 @@ class SubprocessCLITransport(Transport):
|
||||||
else _DEFAULT_MAX_BUFFER_SIZE
|
else _DEFAULT_MAX_BUFFER_SIZE
|
||||||
)
|
)
|
||||||
self._temp_files: list[str] = [] # Track temporary files for cleanup
|
self._temp_files: list[str] = [] # Track temporary files for cleanup
|
||||||
|
self._write_lock: anyio.Lock = anyio.Lock()
|
||||||
|
|
||||||
def _find_cli(self) -> str:
|
def _find_cli(self) -> str:
|
||||||
"""Find Claude Code CLI binary."""
|
"""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"):
|
if cli := shutil.which("claude"):
|
||||||
return cli
|
return cli
|
||||||
|
|
||||||
|
|
@ -77,6 +84,7 @@ class SubprocessCLITransport(Transport):
|
||||||
Path.home() / ".local/bin/claude",
|
Path.home() / ".local/bin/claude",
|
||||||
Path.home() / "node_modules/.bin/claude",
|
Path.home() / "node_modules/.bin/claude",
|
||||||
Path.home() / ".yarn/bin/claude",
|
Path.home() / ".yarn/bin/claude",
|
||||||
|
Path.home() / ".claude/local/claude",
|
||||||
]
|
]
|
||||||
|
|
||||||
for path in locations:
|
for path in locations:
|
||||||
|
|
@ -92,12 +100,81 @@ class SubprocessCLITransport(Transport):
|
||||||
" ClaudeAgentOptions(cli_path='/path/to/claude')"
|
" 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]:
|
def _build_command(self) -> list[str]:
|
||||||
"""Build CLI command with arguments."""
|
"""Build CLI command with arguments."""
|
||||||
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
|
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
|
||||||
|
|
||||||
if self._options.system_prompt is None:
|
if self._options.system_prompt is None:
|
||||||
pass
|
cmd.extend(["--system-prompt", ""])
|
||||||
elif isinstance(self._options.system_prompt, str):
|
elif isinstance(self._options.system_prompt, str):
|
||||||
cmd.extend(["--system-prompt", self._options.system_prompt])
|
cmd.extend(["--system-prompt", self._options.system_prompt])
|
||||||
else:
|
else:
|
||||||
|
|
@ -109,18 +186,39 @@ class SubprocessCLITransport(Transport):
|
||||||
["--append-system-prompt", self._options.system_prompt["append"]]
|
["--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:
|
if self._options.allowed_tools:
|
||||||
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
|
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
|
||||||
|
|
||||||
if self._options.max_turns:
|
if self._options.max_turns:
|
||||||
cmd.extend(["--max-turns", str(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:
|
if self._options.disallowed_tools:
|
||||||
cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])
|
cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])
|
||||||
|
|
||||||
if self._options.model:
|
if self._options.model:
|
||||||
cmd.extend(["--model", 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:
|
if self._options.permission_prompt_tool_name:
|
||||||
cmd.extend(
|
cmd.extend(
|
||||||
["--permission-prompt-tool", self._options.permission_prompt_tool_name]
|
["--permission-prompt-tool", self._options.permission_prompt_tool_name]
|
||||||
|
|
@ -135,8 +233,10 @@ class SubprocessCLITransport(Transport):
|
||||||
if self._options.resume:
|
if self._options.resume:
|
||||||
cmd.extend(["--resume", self._options.resume])
|
cmd.extend(["--resume", self._options.resume])
|
||||||
|
|
||||||
if self._options.settings:
|
# Handle settings and sandbox: merge sandbox into settings if both are provided
|
||||||
cmd.extend(["--settings", self._options.settings])
|
settings_value = self._build_settings_value()
|
||||||
|
if settings_value:
|
||||||
|
cmd.extend(["--settings", settings_value])
|
||||||
|
|
||||||
if self._options.add_dirs:
|
if self._options.add_dirs:
|
||||||
# Convert all paths to strings and add each directory
|
# Convert all paths to strings and add each directory
|
||||||
|
|
@ -191,6 +291,14 @@ class SubprocessCLITransport(Transport):
|
||||||
)
|
)
|
||||||
cmd.extend(["--setting-sources", sources_value])
|
cmd.extend(["--setting-sources", sources_value])
|
||||||
|
|
||||||
|
# Add plugin directories
|
||||||
|
if self._options.plugins:
|
||||||
|
for plugin in self._options.plugins:
|
||||||
|
if plugin["type"] == "local":
|
||||||
|
cmd.extend(["--plugin-dir", plugin["path"]])
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported plugin type: {plugin['type']}")
|
||||||
|
|
||||||
# Add extra args for future CLI flags
|
# Add extra args for future CLI flags
|
||||||
for flag, value in self._options.extra_args.items():
|
for flag, value in self._options.extra_args.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -200,7 +308,24 @@ class SubprocessCLITransport(Transport):
|
||||||
# Flag with value
|
# Flag with value
|
||||||
cmd.extend([f"--{flag}", str(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
|
# Add prompt handling based on mode
|
||||||
|
# IMPORTANT: This must come AFTER all flags because everything after "--" is treated as arguments
|
||||||
if self._is_streaming:
|
if self._is_streaming:
|
||||||
# Streaming mode: use --input-format stream-json
|
# Streaming mode: use --input-format stream-json
|
||||||
cmd.extend(["--input-format", "stream-json"])
|
cmd.extend(["--input-format", "stream-json"])
|
||||||
|
|
@ -259,6 +384,10 @@ class SubprocessCLITransport(Transport):
|
||||||
"CLAUDE_AGENT_SDK_VERSION": __version__,
|
"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:
|
if self._cwd:
|
||||||
process_env["PWD"] = self._cwd
|
process_env["PWD"] = self._cwd
|
||||||
|
|
||||||
|
|
@ -347,8 +476,6 @@ class SubprocessCLITransport(Transport):
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the transport and clean up resources."""
|
"""Close the transport and clean up resources."""
|
||||||
self._ready = False
|
|
||||||
|
|
||||||
# Clean up temporary files first (before early return)
|
# Clean up temporary files first (before early return)
|
||||||
for temp_file in self._temp_files:
|
for temp_file in self._temp_files:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
|
|
@ -356,6 +483,7 @@ class SubprocessCLITransport(Transport):
|
||||||
self._temp_files.clear()
|
self._temp_files.clear()
|
||||||
|
|
||||||
if not self._process:
|
if not self._process:
|
||||||
|
self._ready = False
|
||||||
return
|
return
|
||||||
|
|
||||||
# Close stderr task group if active
|
# Close stderr task group if active
|
||||||
|
|
@ -365,21 +493,19 @@ class SubprocessCLITransport(Transport):
|
||||||
await self._stderr_task_group.__aexit__(None, None, None)
|
await self._stderr_task_group.__aexit__(None, None, None)
|
||||||
self._stderr_task_group = None
|
self._stderr_task_group = None
|
||||||
|
|
||||||
# Close streams
|
# Close stdin stream (acquire lock to prevent race with concurrent writes)
|
||||||
if self._stdin_stream:
|
async with self._write_lock:
|
||||||
with suppress(Exception):
|
self._ready = False # Set inside lock to prevent TOCTOU with write()
|
||||||
await self._stdin_stream.aclose()
|
if self._stdin_stream:
|
||||||
self._stdin_stream = None
|
with suppress(Exception):
|
||||||
|
await self._stdin_stream.aclose()
|
||||||
|
self._stdin_stream = None
|
||||||
|
|
||||||
if self._stderr_stream:
|
if self._stderr_stream:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await self._stderr_stream.aclose()
|
await self._stderr_stream.aclose()
|
||||||
self._stderr_stream = None
|
self._stderr_stream = None
|
||||||
|
|
||||||
if self._process.stdin:
|
|
||||||
with suppress(Exception):
|
|
||||||
await self._process.stdin.aclose()
|
|
||||||
|
|
||||||
# Terminate and wait for process
|
# Terminate and wait for process
|
||||||
if self._process.returncode is None:
|
if self._process.returncode is None:
|
||||||
with suppress(ProcessLookupError):
|
with suppress(ProcessLookupError):
|
||||||
|
|
@ -397,37 +523,37 @@ class SubprocessCLITransport(Transport):
|
||||||
|
|
||||||
async def write(self, data: str) -> None:
|
async def write(self, data: str) -> None:
|
||||||
"""Write raw data to the transport."""
|
"""Write raw data to the transport."""
|
||||||
# Check if ready (like TypeScript)
|
async with self._write_lock:
|
||||||
if not self._ready or not self._stdin_stream:
|
# All checks inside lock to prevent TOCTOU races with close()/end_input()
|
||||||
raise CLIConnectionError("ProcessTransport is not ready for writing")
|
if not self._ready or not self._stdin_stream:
|
||||||
|
raise CLIConnectionError("ProcessTransport is not ready for writing")
|
||||||
|
|
||||||
# Check if process is still alive (like TypeScript)
|
if self._process and self._process.returncode is not None:
|
||||||
if self._process and self._process.returncode is not None:
|
raise CLIConnectionError(
|
||||||
raise CLIConnectionError(
|
f"Cannot write to terminated process (exit code: {self._process.returncode})"
|
||||||
f"Cannot write to terminated process (exit code: {self._process.returncode})"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Check for exit errors (like TypeScript)
|
if self._exit_error:
|
||||||
if self._exit_error:
|
raise CLIConnectionError(
|
||||||
raise CLIConnectionError(
|
f"Cannot write to process that exited with error: {self._exit_error}"
|
||||||
f"Cannot write to process that exited with error: {self._exit_error}"
|
) from self._exit_error
|
||||||
) from self._exit_error
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._stdin_stream.send(data)
|
await self._stdin_stream.send(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._ready = False # Mark as not ready (like TypeScript)
|
self._ready = False
|
||||||
self._exit_error = CLIConnectionError(
|
self._exit_error = CLIConnectionError(
|
||||||
f"Failed to write to process stdin: {e}"
|
f"Failed to write to process stdin: {e}"
|
||||||
)
|
)
|
||||||
raise self._exit_error from e
|
raise self._exit_error from e
|
||||||
|
|
||||||
async def end_input(self) -> None:
|
async def end_input(self) -> None:
|
||||||
"""End the input stream (close stdin)."""
|
"""End the input stream (close stdin)."""
|
||||||
if self._stdin_stream:
|
async with self._write_lock:
|
||||||
with suppress(Exception):
|
if self._stdin_stream:
|
||||||
await self._stdin_stream.aclose()
|
with suppress(Exception):
|
||||||
self._stdin_stream = None
|
await self._stdin_stream.aclose()
|
||||||
|
self._stdin_stream = None
|
||||||
|
|
||||||
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
||||||
"""Read and parse messages from the transport."""
|
"""Read and parse messages from the transport."""
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""Version information for claude-agent-sdk."""
|
"""Version information for claude-agent-sdk."""
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.18"
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,12 @@ class ClaudeSDKClient:
|
||||||
internal_hooks[event] = []
|
internal_hooks[event] = []
|
||||||
for matcher in matchers:
|
for matcher in matchers:
|
||||||
# Convert HookMatcher to internal dict format
|
# Convert HookMatcher to internal dict format
|
||||||
internal_matcher = {
|
internal_matcher: dict[str, Any] = {
|
||||||
"matcher": matcher.matcher if hasattr(matcher, "matcher") else None,
|
"matcher": matcher.matcher if hasattr(matcher, "matcher") else None,
|
||||||
"hooks": matcher.hooks if hasattr(matcher, "hooks") else [],
|
"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)
|
internal_hooks[event].append(internal_matcher)
|
||||||
return internal_hooks
|
return internal_hooks
|
||||||
|
|
||||||
|
|
@ -138,6 +140,13 @@ class ClaudeSDKClient:
|
||||||
if isinstance(config, dict) and config.get("type") == "sdk":
|
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||||
sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item]
|
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
|
# Create Query to handle control protocol
|
||||||
self._query = Query(
|
self._query = Query(
|
||||||
transport=self._transport,
|
transport=self._transport,
|
||||||
|
|
@ -147,6 +156,7 @@ class ClaudeSDKClient:
|
||||||
if self.options.hooks
|
if self.options.hooks
|
||||||
else None,
|
else None,
|
||||||
sdk_mcp_servers=sdk_mcp_servers,
|
sdk_mcp_servers=sdk_mcp_servers,
|
||||||
|
initialize_timeout=initialize_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start reading messages and initialize
|
# Start reading messages and initialize
|
||||||
|
|
@ -251,6 +261,38 @@ class ClaudeSDKClient:
|
||||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||||
await self._query.set_model(model)
|
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:
|
async def get_server_info(self) -> dict[str, Any] | None:
|
||||||
"""Get server initialization info including available commands and output styles.
|
"""Get server initialization info including available commands and output styles.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,16 @@ from typing_extensions import NotRequired
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server import Server as McpServer
|
from mcp.server import Server as McpServer
|
||||||
|
else:
|
||||||
|
# Runtime placeholder for forward reference resolution in Pydantic 2.12+
|
||||||
|
McpServer = Any
|
||||||
|
|
||||||
# Permission modes
|
# Permission modes
|
||||||
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
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
|
# Agent definitions
|
||||||
SettingSource = Literal["user", "project", "local"]
|
SettingSource = Literal["user", "project", "local"]
|
||||||
|
|
||||||
|
|
@ -26,6 +32,13 @@ class SystemPromptPreset(TypedDict):
|
||||||
append: NotRequired[str]
|
append: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsPreset(TypedDict):
|
||||||
|
"""Tools preset configuration."""
|
||||||
|
|
||||||
|
type: Literal["preset"]
|
||||||
|
preset: Literal["claude_code"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AgentDefinition:
|
class AgentDefinition:
|
||||||
"""Agent definition configuration."""
|
"""Agent definition configuration."""
|
||||||
|
|
@ -366,6 +379,9 @@ class HookMatcher:
|
||||||
# A list of Python functions with function signature HookCallback
|
# A list of Python functions with function signature HookCallback
|
||||||
hooks: list[HookCallback] = field(default_factory=list)
|
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
|
# MCP Server config
|
||||||
class McpStdioServerConfig(TypedDict):
|
class McpStdioServerConfig(TypedDict):
|
||||||
|
|
@ -406,6 +422,93 @@ McpServerConfig = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SdkPluginConfig(TypedDict):
|
||||||
|
"""SDK plugin configuration.
|
||||||
|
|
||||||
|
Currently only local plugins are supported via the 'local' type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: Literal["local"]
|
||||||
|
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
|
# Content block types
|
||||||
@dataclass
|
@dataclass
|
||||||
class TextBlock:
|
class TextBlock:
|
||||||
|
|
@ -444,11 +547,22 @@ ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
|
||||||
|
|
||||||
|
|
||||||
# Message types
|
# Message types
|
||||||
|
AssistantMessageError = Literal[
|
||||||
|
"authentication_failed",
|
||||||
|
"billing_error",
|
||||||
|
"rate_limit",
|
||||||
|
"invalid_request",
|
||||||
|
"server_error",
|
||||||
|
"unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UserMessage:
|
class UserMessage:
|
||||||
"""User message."""
|
"""User message."""
|
||||||
|
|
||||||
content: str | list[ContentBlock]
|
content: str | list[ContentBlock]
|
||||||
|
uuid: str | None = None
|
||||||
parent_tool_use_id: str | None = None
|
parent_tool_use_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -459,6 +573,7 @@ class AssistantMessage:
|
||||||
content: list[ContentBlock]
|
content: list[ContentBlock]
|
||||||
model: str
|
model: str
|
||||||
parent_tool_use_id: str | None = None
|
parent_tool_use_id: str | None = None
|
||||||
|
error: AssistantMessageError | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -482,6 +597,7 @@ class ResultMessage:
|
||||||
total_cost_usd: float | None = None
|
total_cost_usd: float | None = None
|
||||||
usage: dict[str, Any] | None = None
|
usage: dict[str, Any] | None = None
|
||||||
result: str | None = None
|
result: str | None = None
|
||||||
|
structured_output: Any = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -501,6 +617,7 @@ Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | Strea
|
||||||
class ClaudeAgentOptions:
|
class ClaudeAgentOptions:
|
||||||
"""Query options for Claude SDK."""
|
"""Query options for Claude SDK."""
|
||||||
|
|
||||||
|
tools: list[str] | ToolsPreset | None = None
|
||||||
allowed_tools: list[str] = field(default_factory=list)
|
allowed_tools: list[str] = field(default_factory=list)
|
||||||
system_prompt: str | SystemPromptPreset | None = None
|
system_prompt: str | SystemPromptPreset | None = None
|
||||||
mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict)
|
mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict)
|
||||||
|
|
@ -508,8 +625,12 @@ class ClaudeAgentOptions:
|
||||||
continue_conversation: bool = False
|
continue_conversation: bool = False
|
||||||
resume: str | None = None
|
resume: str | None = None
|
||||||
max_turns: int | None = None
|
max_turns: int | None = None
|
||||||
|
max_budget_usd: float | None = None
|
||||||
disallowed_tools: list[str] = field(default_factory=list)
|
disallowed_tools: list[str] = field(default_factory=list)
|
||||||
model: str | None = None
|
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
|
permission_prompt_tool_name: str | None = None
|
||||||
cwd: str | Path | None = None
|
cwd: str | Path | None = None
|
||||||
cli_path: str | Path | None = None
|
cli_path: str | Path | None = None
|
||||||
|
|
@ -542,6 +663,21 @@ class ClaudeAgentOptions:
|
||||||
agents: dict[str, AgentDefinition] | None = None
|
agents: dict[str, AgentDefinition] | None = None
|
||||||
# Setting sources to load (user, project, local)
|
# Setting sources to load (user, project, local)
|
||||||
setting_sources: list[SettingSource] | None = None
|
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
|
# SDK Control Protocol
|
||||||
|
|
@ -582,6 +718,11 @@ class SDKControlMcpMessageRequest(TypedDict):
|
||||||
message: Any
|
message: Any
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlRewindFilesRequest(TypedDict):
|
||||||
|
subtype: Literal["rewind_files"]
|
||||||
|
user_message_id: str
|
||||||
|
|
||||||
|
|
||||||
class SDKControlRequest(TypedDict):
|
class SDKControlRequest(TypedDict):
|
||||||
type: Literal["control_request"]
|
type: Literal["control_request"]
|
||||||
request_id: str
|
request_id: str
|
||||||
|
|
@ -592,6 +733,7 @@ class SDKControlRequest(TypedDict):
|
||||||
| SDKControlSetPermissionModeRequest
|
| SDKControlSetPermissionModeRequest
|
||||||
| SDKHookCallbackRequest
|
| SDKHookCallbackRequest
|
||||||
| SDKControlMcpMessageRequest
|
| SDKControlMcpMessageRequest
|
||||||
|
| SDKControlRewindFilesRequest
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -212,3 +212,73 @@ class TestIntegration:
|
||||||
assert call_kwargs["options"].continue_conversation is True
|
assert call_kwargs["options"].continue_conversation is True
|
||||||
|
|
||||||
anyio.run(_test)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,21 @@ class TestMessageParser:
|
||||||
assert isinstance(message.content[0], TextBlock)
|
assert isinstance(message.content[0], TextBlock)
|
||||||
assert message.content[0].text == "Hello"
|
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):
|
def test_parse_user_message_with_tool_use(self):
|
||||||
"""Test parsing a user message with tool_use block."""
|
"""Test parsing a user message with tool_use block."""
|
||||||
data = {
|
data = {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ class TestSubprocessCLITransport:
|
||||||
assert "stream-json" in cmd
|
assert "stream-json" in cmd
|
||||||
assert "--print" in cmd
|
assert "--print" in cmd
|
||||||
assert "Hello" 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):
|
def test_cli_path_accepts_pathlib_path(self):
|
||||||
"""Test that cli_path accepts pathlib.Path objects."""
|
"""Test that cli_path accepts pathlib.Path objects."""
|
||||||
|
|
@ -129,6 +131,33 @@ class TestSubprocessCLITransport:
|
||||||
assert "--max-turns" in cmd
|
assert "--max-turns" in cmd
|
||||||
assert "5" 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):
|
def test_build_command_with_add_dirs(self):
|
||||||
"""Test building CLI command with add_dirs option."""
|
"""Test building CLI command with add_dirs option."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -471,3 +500,329 @@ class TestSubprocessCLITransport:
|
||||||
assert user_passed == "claude"
|
assert user_passed == "claude"
|
||||||
|
|
||||||
anyio.run(_test)
|
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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue