mirror of
https://github.com/anthropics/claude-code-sdk-python.git
synced 2025-12-23 09:19:52 +00:00
Compare commits
123 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 | ||
|
|
5656aeadd6 | ||
|
|
9b8576158d | ||
|
|
41e220cc2c | ||
|
|
923d3d4620 | ||
|
|
f27c4ab4c7 | ||
|
|
f896cd6f7f | ||
|
|
aebcf9d6a4 | ||
|
|
20c1b89734 | ||
|
|
1f080748d4 | ||
|
|
d754e5cc1d | ||
|
|
48b62a05a3 | ||
|
|
67e77e928a | ||
|
|
e8d7e71a0a | ||
|
|
5bea2dc27d | ||
|
|
dcd51c9ecb | ||
|
|
71a85ac9aa | ||
|
|
c2b72f1cc5 | ||
|
|
6793e40264 | ||
|
|
14f0714a8c | ||
|
|
333491eef5 | ||
|
|
24408f9ddd | ||
|
|
70358589cf | ||
|
|
2a9693e258 | ||
|
|
af870623e7 | ||
|
|
d9dd841912 | ||
|
|
9d4659c97f | ||
|
|
4b9cfc76e4 | ||
|
|
d86c47f2d6 | ||
|
|
b3656b1765 | ||
|
|
180d64887a | ||
|
|
62289d2dce | ||
|
|
0d2404e5d9 | ||
|
|
dbb153b1f6 | ||
|
|
507e22cb4a | ||
|
|
233cefa3e1 | ||
|
|
cfdd28a254 | ||
|
|
12fdca9b1b | ||
|
|
e8832f115a | ||
|
|
f550c21a7e | ||
|
|
607921dcb0 | ||
|
|
fd98d12f94 | ||
|
|
3010aaf092 | ||
|
|
db6f167280 | ||
|
|
0aab45be7d | ||
|
|
4ea71cfb97 | ||
|
|
839300404f | ||
|
|
d3190f12d3 | ||
|
|
9377faa943 | ||
|
|
73f861235e |
78 changed files with 7799 additions and 1187 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.
|
||||||
17
.claude/commands/commit.md
Normal file
17
.claude/commands/commit.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
|
||||||
|
description: Create a git commit
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Current git status: !`git status`
|
||||||
|
- Current git diff (staged and unstaged changes): !`git diff HEAD`
|
||||||
|
- Current branch: !`git branch --show-current`
|
||||||
|
- Recent commits: !`git log --oneline -10`
|
||||||
|
|
||||||
|
## Your task
|
||||||
|
|
||||||
|
Based on the above changes, create a single git commit.
|
||||||
|
|
||||||
|
You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.
|
||||||
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}".
|
||||||
25
.claude/settings.json
Normal file
25
.claude/settings.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python -m ruff check src/ tests/ --fix)",
|
||||||
|
"Bash(python -m ruff format src/ tests/)",
|
||||||
|
"Bash(python -m mypy src/)",
|
||||||
|
"Bash(python -m pytest tests/)",
|
||||||
|
"Bash(python -m pytest tests/*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python -m ruff check src/ tests/ --fix && python -m ruff format src/ tests/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": "Edit|Write|MultiEdit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
52
.github/workflows/create-release-tag.yml
vendored
52
.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"
|
||||||
|
|
@ -43,23 +37,37 @@ jobs:
|
||||||
git push origin "v${{ steps.extract_version.outputs.version }}"
|
git push origin "v${{ steps.extract_version.outputs.version }}"
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
run: |
|
||||||
tag_name: v${{ steps.extract_version.outputs.version }}
|
VERSION="${{ steps.extract_version.outputs.version }}"
|
||||||
release_name: Release v${{ steps.extract_version.outputs.version }}
|
|
||||||
body: |
|
|
||||||
## Release v${{ steps.extract_version.outputs.version }}
|
|
||||||
|
|
||||||
Published to PyPI: https://pypi.org/project/claude-code-sdk/${{ steps.extract_version.outputs.version }}/
|
# Extract changelog section for this version to a temp file
|
||||||
|
awk -v ver="$VERSION" '
|
||||||
|
/^## / {
|
||||||
|
if (found) exit
|
||||||
|
if ($2 == ver) found=1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
found { print }
|
||||||
|
' CHANGELOG.md > release_notes.md
|
||||||
|
|
||||||
### Installation
|
# Append install instructions
|
||||||
```bash
|
{
|
||||||
pip install claude-code-sdk==${{ steps.extract_version.outputs.version }}
|
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
|
||||||
|
|
||||||
### What's Changed
|
# Replace VERSION placeholder
|
||||||
See the [full changelog](https://github.com/${{ github.repository }}/compare/${{ steps.previous_tag.outputs.previous_tag }}...v${{ steps.extract_version.outputs.version }})
|
sed -i "s/VERSION/$VERSION/g" release_notes.md
|
||||||
draft: false
|
|
||||||
prerelease: false
|
# Create release with notes from file
|
||||||
|
gh release create "v$VERSION" \
|
||||||
|
--title "v$VERSION" \
|
||||||
|
--notes-file release_notes.md
|
||||||
|
|
|
||||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
|
|
@ -1,10 +1,10 @@
|
||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
|
|
||||||
153
.github/workflows/publish.yml
vendored
153
.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:
|
||||||
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.10', '3.11', '3.12', '3.13']
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -40,7 +40,7 @@ jobs:
|
||||||
- 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: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -56,8 +56,48 @@ 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
|
||||||
|
|
@ -67,6 +107,7 @@ jobs:
|
||||||
- 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 for changelog generation
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
|
|
@ -84,16 +125,27 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
python scripts/update_version.py "${{ env.VERSION }}"
|
python scripts/update_version.py "${{ env.VERSION }}"
|
||||||
|
|
||||||
|
- name: Read CLI version from code
|
||||||
|
id: cli_version
|
||||||
|
run: |
|
||||||
|
CLI_VERSION=$(python -c "import re; print(re.search(r'__cli_version__ = \"([^\"]+)\"', open('src/claude_agent_sdk/_cli_version.py').read()).group(1))")
|
||||||
|
echo "cli_version=$CLI_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Bundled CLI version: $CLI_VERSION"
|
||||||
|
|
||||||
|
- name: Download all wheel artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
pattern: wheel-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install build twine
|
pip install build twine
|
||||||
|
|
||||||
- name: Build package
|
- name: Build source distribution
|
||||||
run: python -m build
|
run: python -m build --sdist
|
||||||
|
|
||||||
- name: Check package
|
|
||||||
run: twine check dist/*
|
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
env:
|
env:
|
||||||
|
|
@ -102,68 +154,69 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
twine upload dist/*
|
twine upload dist/*
|
||||||
echo "Package published to PyPI"
|
echo "Package published to PyPI"
|
||||||
echo "Install with: pip install claude-code-sdk==${{ env.VERSION }}"
|
echo "Install with: pip install claude-agent-sdk==${{ env.VERSION }}"
|
||||||
|
|
||||||
- name: Create version update PR
|
- name: Get previous release tag
|
||||||
env:
|
id: previous_tag
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
run: |
|
||||||
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "Previous release: $PREVIOUS_TAG"
|
||||||
|
|
||||||
|
- name: Create release branch and commit version changes
|
||||||
run: |
|
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
|
||||||
|
|
||||||
# Create branch via API
|
# Configure git
|
||||||
BASE_SHA=$(git rev-parse HEAD)
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
gh api \
|
git config --local user.name "github-actions[bot]"
|
||||||
--method POST \
|
|
||||||
/repos/$GITHUB_REPOSITORY/git/refs \
|
|
||||||
-f ref="refs/heads/$BRANCH_NAME" \
|
|
||||||
-f sha="$BASE_SHA"
|
|
||||||
|
|
||||||
# Get current SHA values of files
|
# Create and switch to new branch
|
||||||
echo "Getting SHA for pyproject.toml"
|
git checkout -b "$BRANCH_NAME"
|
||||||
PYPROJECT_SHA=$(gh api /repos/$GITHUB_REPOSITORY/contents/pyproject.toml --jq '.sha')
|
|
||||||
echo "Getting SHA for __init__.py"
|
|
||||||
INIT_SHA=$(gh api /repos/$GITHUB_REPOSITORY/contents/src/claude_code_sdk/__init__.py --jq '.sha')
|
|
||||||
|
|
||||||
# Commit pyproject.toml via GitHub API (this creates signed commits)
|
# Commit version changes
|
||||||
message="chore: bump version to ${{ env.VERSION }}"
|
git add pyproject.toml src/claude_agent_sdk/_version.py
|
||||||
base64 -i pyproject.toml > pyproject.toml.b64
|
git commit -m "chore: release v${{ env.VERSION }}"
|
||||||
gh api \
|
|
||||||
--method PUT \
|
|
||||||
/repos/$GITHUB_REPOSITORY/contents/pyproject.toml \
|
|
||||||
-f message="$message" \
|
|
||||||
-F content=@pyproject.toml.b64 \
|
|
||||||
-f sha="$PYPROJECT_SHA" \
|
|
||||||
-f branch="$BRANCH_NAME"
|
|
||||||
|
|
||||||
# Commit __init__.py via GitHub API
|
- name: Update changelog with Claude
|
||||||
base64 -i src/claude_code_sdk/__init__.py > init.py.b64
|
continue-on-error: true
|
||||||
gh api \
|
uses: anthropics/claude-code-action@v1
|
||||||
--method PUT \
|
with:
|
||||||
/repos/$GITHUB_REPOSITORY/contents/src/claude_code_sdk/__init__.py \
|
prompt: "/generate-changelog new version: ${{ env.VERSION }}, old version: ${{ steps.previous_tag.outputs.previous_tag }}"
|
||||||
-f message="$message" \
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
-F content=@init.py.b64 \
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
-f sha="$INIT_SHA" \
|
claude_args: |
|
||||||
-f branch="$BRANCH_NAME"
|
--model claude-opus-4-5
|
||||||
|
--allowedTools 'Bash(git add:*),Bash(git commit:*),Edit'
|
||||||
|
|
||||||
|
- 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 }}"
|
||||||
|
|
||||||
# Create PR using GitHub CLI
|
# Create PR using GitHub CLI
|
||||||
PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI.
|
PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI.
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Updated version in \`pyproject.toml\`
|
- Updated version in \`pyproject.toml\` to ${{ env.VERSION }}
|
||||||
- Updated version in \`src/claude_code_sdk/__init__.py\`
|
- Updated version in \`src/claude_agent_sdk/_version.py\` to ${{ env.VERSION }}
|
||||||
|
- Updated \`CHANGELOG.md\` with release notes
|
||||||
|
|
||||||
## Release Information
|
## Release Information
|
||||||
- Published to PyPI: https://pypi.org/project/claude-code-sdk/${{ env.VERSION }}/
|
- Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ env.VERSION }}/
|
||||||
- Install with: \`pip install claude-code-sdk==${{ env.VERSION }}\`
|
- Bundled CLI version: ${{ steps.cli_version.outputs.cli_version }}
|
||||||
|
- Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\`
|
||||||
|
|
||||||
🤖 Generated by GitHub Actions"
|
🤖 Generated by GitHub Actions"
|
||||||
|
|
||||||
PR_URL=$(gh pr create \
|
PR_URL=$(gh pr create \
|
||||||
--title "chore: bump version to ${{ env.VERSION }}" \
|
--title "chore: release v${{ env.VERSION }}" \
|
||||||
--body "$PR_BODY" \
|
--body "$PR_BODY" \
|
||||||
--base main \
|
--base main \
|
||||||
--head "$BRANCH_NAME")
|
--head "${{ env.BRANCH_NAME }}")
|
||||||
|
|
||||||
echo "PR created: $PR_URL"
|
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 }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
144
.github/workflows/test.yml
vendored
144
.github/workflows/test.yml
vendored
|
|
@ -1,17 +1,18 @@
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.10', '3.11', '3.12', '3.13']
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -28,10 +29,141 @@ jobs:
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
python -m pytest tests/ -v --cov=claude_code_sdk --cov-report=xml
|
python -m pytest tests/ -v --cov=claude_agent_sdk --cov-report=xml
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
needs: test # Run after unit tests pass
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install Claude Code (Linux/macOS)
|
||||||
|
if: runner.os == 'Linux' || runner.os == 'macOS'
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://claude.ai/install.sh | bash
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install Claude Code (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: |
|
||||||
|
irm https://claude.ai/install.ps1 | iex
|
||||||
|
$claudePath = "$env:USERPROFILE\.local\bin"
|
||||||
|
echo "$claudePath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Verify Claude Code installation
|
||||||
|
run: claude -v
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run end-to-end tests with real API
|
||||||
|
env:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
run: |
|
||||||
|
python -m pytest e2e-tests/ -v -m e2e
|
||||||
|
|
||||||
|
test-e2e-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test # Run after unit tests pass
|
||||||
|
# Run e2e tests in Docker to catch container-specific issues like #406
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build Docker test image
|
||||||
|
run: docker build -f Dockerfile.test -t claude-sdk-test .
|
||||||
|
|
||||||
|
- name: Run e2e tests in Docker
|
||||||
|
env:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
run: |
|
||||||
|
docker run --rm -e ANTHROPIC_API_KEY \
|
||||||
|
claude-sdk-test python -m pytest e2e-tests/ -v -m e2e
|
||||||
|
|
||||||
|
test-examples:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test-e2e # Run after e2e tests
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.13"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install Claude Code (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://claude.ai/install.sh | bash
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install Claude Code (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: |
|
||||||
|
irm https://claude.ai/install.ps1 | iex
|
||||||
|
$claudePath = "$env:USERPROFILE\.local\bin"
|
||||||
|
echo "$claudePath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Verify Claude Code installation
|
||||||
|
run: claude -v
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
- name: Run example scripts (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
env:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
run: |
|
||||||
|
python examples/quick_start.py
|
||||||
|
timeout 120 python examples/streaming_mode.py all
|
||||||
|
timeout 120 python examples/hooks.py PreToolUse
|
||||||
|
timeout 120 python examples/hooks.py DecisionFields
|
||||||
|
|
||||||
|
- name: Run example scripts (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
env:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
run: |
|
||||||
|
python examples/quick_start.py
|
||||||
|
$job = Start-Job { python examples/streaming_mode.py all }
|
||||||
|
Wait-Job $job -Timeout 120 | Out-Null
|
||||||
|
Stop-Job $job
|
||||||
|
Receive-Job $job
|
||||||
|
|
||||||
|
$job = Start-Job { python examples/hooks.py PreToolUse }
|
||||||
|
Wait-Job $job -Timeout 120 | Out-Null
|
||||||
|
Stop-Job $job
|
||||||
|
Receive-Job $job
|
||||||
|
|
||||||
|
$job = Start-Job { python examples/hooks.py DecisionFields }
|
||||||
|
Wait-Job $job -Timeout 120 | Out-Null
|
||||||
|
Stop-Job $job
|
||||||
|
Receive-Job $job
|
||||||
|
shell: pwsh
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,6 +26,7 @@ venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
.venv
|
.venv
|
||||||
|
uv.lock
|
||||||
|
|
||||||
# IDEs
|
# IDEs
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
207
CHANGELOG.md
207
CHANGELOG.md
|
|
@ -1,5 +1,211 @@
|
||||||
# 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
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Skip version check**: Added `CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK` environment variable to allow users to disable the Claude Code version check. Set this environment variable to skip the minimum version validation when the SDK connects to Claude Code. (Only recommended if you already have Claude Code 2.0.0 or higher installed, otherwise some functionality may break)
|
||||||
|
- SDK MCP server tool calls can now return image content blocks
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Strongly-typed hook inputs**: Added typed hook input structures (`PreToolUseHookInput`, `PostToolUseHookInput`, `UserPromptSubmitHookInput`, etc.) using TypedDict for better IDE autocomplete and type safety. Hook callbacks now receive fully typed input parameters
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Hook output field conversion**: Fixed bug where Python-safe field names (`async_`, `continue_`) in hook outputs were not being converted to CLI format (`async`, `continue`). This caused hook control fields to be silently ignored, preventing proper hook behavior. The SDK now automatically converts field names when communicating with the CLI
|
||||||
|
|
||||||
|
### Internal/Other Changes
|
||||||
|
|
||||||
|
- **CI/CD**: Re-enabled Windows testing in the end-to-end test workflow. Windows CI had been temporarily disabled but is now fully operational across all test suites
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Hook output fields**: Added missing hook output fields to match the TypeScript SDK, including `reason`, `continue_`, `suppressOutput`, and `stopReason`. The `decision` field now properly supports both "approve" and "block" values. Added `AsyncHookJSONOutput` type for deferred hook execution and proper typing for `hookSpecificOutput` with discriminated unions
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Minimum Claude Code version check**: Added version validation to ensure Claude Code 2.0.0+ is installed. The SDK will display a warning if an older version is detected, helping prevent compatibility issues
|
||||||
|
- **Updated PermissionResult types**: Aligned permission result types with the latest control protocol for better type safety and compatibility
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Model references**: Updated all examples and tests to use the simplified `claude-sonnet-4-5` model identifier instead of dated version strings
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
Introducing the Claude Agent SDK! The Claude Code SDK has been renamed to better reflect its capabilities for building AI agents across all domains, not just coding.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
#### Type Name Changes
|
||||||
|
|
||||||
|
- **ClaudeCodeOptions renamed to ClaudeAgentOptions**: The options type has been renamed to match the new SDK branding. Update all imports and type references:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before
|
||||||
|
from claude_agent_sdk import query, ClaudeCodeOptions
|
||||||
|
options = ClaudeCodeOptions(...)
|
||||||
|
|
||||||
|
# After
|
||||||
|
from claude_agent_sdk import query, ClaudeAgentOptions
|
||||||
|
options = ClaudeAgentOptions(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### System Prompt Changes
|
||||||
|
|
||||||
|
- **Merged prompt options**: The `custom_system_prompt` and `append_system_prompt` fields have been merged into a single `system_prompt` field for simpler configuration
|
||||||
|
- **No default system prompt**: The Claude Code system prompt is no longer included by default, giving you full control over agent behavior. To use the Claude Code system prompt, explicitly set:
|
||||||
|
```python
|
||||||
|
system_prompt={"type": "preset", "preset": "claude_code"}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Settings Isolation
|
||||||
|
|
||||||
|
- **No filesystem settings by default**: Settings files (`settings.json`, `CLAUDE.md`), slash commands, and subagents are no longer loaded automatically. This ensures SDK applications have predictable behavior independent of local filesystem configurations
|
||||||
|
- **Explicit settings control**: Use the new `setting_sources` field to specify which settings locations to load: `["user", "project", "local"]`
|
||||||
|
|
||||||
|
For full migration instructions, see our [migration guide](https://docs.claude.com/en/docs/claude-code/sdk/migration-guide).
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Programmatic subagents**: Subagents can now be defined inline in code using the `agents` option, enabling dynamic agent creation without filesystem dependencies. [Learn more](https://docs.claude.com/en/api/agent-sdk/subagents)
|
||||||
|
- **Session forking**: Resume sessions with the new `fork_session` option to branch conversations and explore different approaches from the same starting point. [Learn more](https://docs.claude.com/en/api/agent-sdk/sessions)
|
||||||
|
- **Granular settings control**: The `setting_sources` option gives you fine-grained control over which filesystem settings to load, improving isolation for CI/CD, testing, and production deployments
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Comprehensive documentation now available in the [API Guide](https://docs.claude.com/en/api/agent-sdk/overview)
|
||||||
|
- New guides for [Custom Tools](https://docs.claude.com/en/api/agent-sdk/custom-tools), [Permissions](https://docs.claude.com/en/api/agent-sdk/permissions), [Session Management](https://docs.claude.com/en/api/agent-sdk/sessions), and more
|
||||||
|
- Complete [Python API reference](https://docs.claude.com/en/api/agent-sdk/python)
|
||||||
|
|
||||||
|
## 0.0.22
|
||||||
|
|
||||||
|
- Introduce custom tools, implemented as in-process MCP servers.
|
||||||
|
- Introduce hooks.
|
||||||
|
- Update internal `Transport` class to lower-level interface.
|
||||||
|
- `ClaudeSDKClient` can no longer be run in different async contexts.
|
||||||
|
|
||||||
## 0.0.19
|
## 0.0.19
|
||||||
|
|
||||||
- Add `ClaudeCodeOptions.add_dirs` for `--add-dir`
|
- Add `ClaudeCodeOptions.add_dirs` for `--add-dir`
|
||||||
|
|
@ -30,4 +236,3 @@
|
||||||
- Fix multi-line buffering issue
|
- Fix multi-line buffering issue
|
||||||
- Rename cost_usd to total_cost_usd in API responses
|
- Rename cost_usd to total_cost_usd in API responses
|
||||||
- Fix optional cost fields handling
|
- Fix optional cost fields handling
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ python -m pytest tests/test_client.py
|
||||||
|
|
||||||
# Codebase Structure
|
# Codebase Structure
|
||||||
|
|
||||||
- `src/claude_code_sdk/` - Main package
|
- `src/claude_agent_sdk/` - Main package
|
||||||
- `client.py` - ClaudeSDKClient for interactive sessions
|
- `client.py` - ClaudeSDKClient for interactive sessions
|
||||||
- `query.py` - One-shot query function
|
- `query.py` - One-shot query function
|
||||||
- `types.py` - Type definitions
|
- `types.py` - Type definitions
|
||||||
|
|
|
||||||
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"]
|
||||||
275
README.md
275
README.md
|
|
@ -1,23 +1,27 @@
|
||||||
# Claude Code SDK for Python
|
# Claude Agent SDK for Python
|
||||||
|
|
||||||
Python SDK for Claude Code. See the [Claude Code SDK documentation](https://docs.anthropic.com/en/docs/claude-code/sdk) for more information.
|
Python SDK for Claude Agent. See the [Claude Agent SDK documentation](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-python) for more information.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install claude-code-sdk
|
pip install claude-agent-sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- Node.js
|
|
||||||
- Claude Code: `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
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import anyio
|
import anyio
|
||||||
from claude_code_sdk import query
|
from claude_agent_sdk import query
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
async for message in query(prompt="What is 2 + 2?"):
|
async for message in query(prompt="What is 2 + 2?"):
|
||||||
|
|
@ -26,12 +30,12 @@ async def main():
|
||||||
anyio.run(main)
|
anyio.run(main)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Basic Usage: query()
|
||||||
|
|
||||||
### Basic Query
|
`query()` is an async function for querying Claude Code. It returns an `AsyncIterator` of response messages. See [src/claude_agent_sdk/query.py](src/claude_agent_sdk/query.py).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from claude_code_sdk import query, ClaudeCodeOptions, AssistantMessage, TextBlock
|
from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock
|
||||||
|
|
||||||
# Simple query
|
# Simple query
|
||||||
async for message in query(prompt="Hello Claude"):
|
async for message in query(prompt="Hello Claude"):
|
||||||
|
|
@ -41,7 +45,7 @@ async for message in query(prompt="Hello Claude"):
|
||||||
print(block.text)
|
print(block.text)
|
||||||
|
|
||||||
# With options
|
# With options
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
system_prompt="You are a helpful assistant",
|
system_prompt="You are a helpful assistant",
|
||||||
max_turns=1
|
max_turns=1
|
||||||
)
|
)
|
||||||
|
|
@ -53,7 +57,7 @@ async for message in query(prompt="Tell me a joke", options=options):
|
||||||
### Using Tools
|
### Using Tools
|
||||||
|
|
||||||
```python
|
```python
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
allowed_tools=["Read", "Write", "Bash"],
|
allowed_tools=["Read", "Write", "Bash"],
|
||||||
permission_mode='acceptEdits' # auto-accept file edits
|
permission_mode='acceptEdits' # auto-accept file edits
|
||||||
)
|
)
|
||||||
|
|
@ -71,35 +75,176 @@ async for message in query(
|
||||||
```python
|
```python
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
cwd="/path/to/project" # or Path("/path/to/project")
|
cwd="/path/to/project" # or Path("/path/to/project")
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ClaudeSDKClient
|
||||||
|
|
||||||
## API Reference
|
`ClaudeSDKClient` supports bidirectional, interactive conversations with Claude
|
||||||
|
Code. See [src/claude_agent_sdk/client.py](src/claude_agent_sdk/client.py).
|
||||||
|
|
||||||
### `query(prompt, options=None)`
|
Unlike `query()`, `ClaudeSDKClient` additionally enables **custom tools** and **hooks**, both of which can be defined as Python functions.
|
||||||
|
|
||||||
Main async function for querying Claude.
|
### Custom Tools (as In-Process SDK MCP Servers)
|
||||||
|
|
||||||
**Parameters:**
|
A **custom tool** is a Python function that you can offer to Claude, for Claude to invoke as needed.
|
||||||
- `prompt` (str): The prompt to send to Claude
|
|
||||||
- `options` (ClaudeCodeOptions): Optional configuration
|
|
||||||
|
|
||||||
**Returns:** AsyncIterator[Message] - Stream of response messages
|
Custom tools are implemented in-process MCP servers that run directly within your Python application, eliminating the need for separate processes that regular MCP servers require.
|
||||||
|
|
||||||
### Types
|
For an end-to-end example, see [MCP Calculator](examples/mcp_calculator.py).
|
||||||
|
|
||||||
See [src/claude_code_sdk/types.py](src/claude_code_sdk/types.py) for complete type definitions:
|
#### Creating a Simple Tool
|
||||||
- `ClaudeCodeOptions` - Configuration options
|
|
||||||
|
```python
|
||||||
|
from claude_agent_sdk import tool, create_sdk_mcp_server, ClaudeAgentOptions, ClaudeSDKClient
|
||||||
|
|
||||||
|
# Define a tool using the @tool decorator
|
||||||
|
@tool("greet", "Greet a user", {"name": str})
|
||||||
|
async def greet_user(args):
|
||||||
|
return {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": f"Hello, {args['name']}!"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create an SDK MCP server
|
||||||
|
server = create_sdk_mcp_server(
|
||||||
|
name="my-tools",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=[greet_user]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use it with Claude
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"tools": server},
|
||||||
|
allowed_tools=["mcp__tools__greet"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Greet Alice")
|
||||||
|
|
||||||
|
# Extract and print response
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
print(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Benefits Over External MCP Servers
|
||||||
|
|
||||||
|
- **No subprocess management** - Runs in the same process as your application
|
||||||
|
- **Better performance** - No IPC overhead for tool calls
|
||||||
|
- **Simpler deployment** - Single Python process instead of multiple
|
||||||
|
- **Easier debugging** - All code runs in the same process
|
||||||
|
- **Type safety** - Direct Python function calls with type hints
|
||||||
|
|
||||||
|
#### Migration from External Servers
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BEFORE: External MCP server (separate process)
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={
|
||||||
|
"calculator": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "calculator_server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# AFTER: SDK MCP server (in-process)
|
||||||
|
from my_tools import add, subtract # Your tool functions
|
||||||
|
|
||||||
|
calculator = create_sdk_mcp_server(
|
||||||
|
name="calculator",
|
||||||
|
tools=[add, subtract]
|
||||||
|
)
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"calculator": calculator}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mixed Server Support
|
||||||
|
|
||||||
|
You can use both SDK and external MCP servers together:
|
||||||
|
|
||||||
|
```python
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={
|
||||||
|
"internal": sdk_server, # In-process SDK server
|
||||||
|
"external": { # External subprocess server
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "external-server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher
|
||||||
|
|
||||||
|
async def check_bash_command(input_data, tool_use_id, context):
|
||||||
|
tool_name = input_data["tool_name"]
|
||||||
|
tool_input = input_data["tool_input"]
|
||||||
|
if tool_name != "Bash":
|
||||||
|
return {}
|
||||||
|
command = tool_input.get("command", "")
|
||||||
|
block_patterns = ["foo.sh"]
|
||||||
|
for pattern in block_patterns:
|
||||||
|
if pattern in command:
|
||||||
|
return {
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": f"Command contains invalid pattern: {pattern}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Bash"],
|
||||||
|
hooks={
|
||||||
|
"PreToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[check_bash_command]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
# Test 1: Command with forbidden pattern (will be blocked)
|
||||||
|
await client.query("Run the bash command: ./foo.sh --help")
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50 + "\n")
|
||||||
|
|
||||||
|
# Test 2: Safe command that should work
|
||||||
|
await client.query("Run the bash command: echo 'Hello from hooks example!'")
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
print(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
See [src/claude_agent_sdk/types.py](src/claude_agent_sdk/types.py) for complete type definitions:
|
||||||
|
|
||||||
|
- `ClaudeAgentOptions` - Configuration options
|
||||||
- `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types
|
- `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types
|
||||||
- `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks
|
- `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
ClaudeSDKError, # Base error
|
ClaudeSDKError, # Base error
|
||||||
CLINotFoundError, # Claude Code not installed
|
CLINotFoundError, # Claude Code not installed
|
||||||
CLIConnectionError, # Connection issues
|
CLIConnectionError, # Connection issues
|
||||||
|
|
@ -118,7 +263,7 @@ except CLIJSONDecodeError as e:
|
||||||
print(f"Failed to parse response: {e}")
|
print(f"Failed to parse response: {e}")
|
||||||
```
|
```
|
||||||
|
|
||||||
See [src/claude_code_sdk/_errors.py](src/claude_code_sdk/_errors.py) for all error types.
|
See [src/claude_agent_sdk/\_errors.py](src/claude_agent_sdk/_errors.py) for all error types.
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
|
|
@ -128,6 +273,84 @@ See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-co
|
||||||
|
|
||||||
See [examples/quick_start.py](examples/quick_start.py) for a complete working example.
|
See [examples/quick_start.py](examples/quick_start.py) for a complete working example.
|
||||||
|
|
||||||
## License
|
See [examples/streaming_mode.py](examples/streaming_mode.py) for comprehensive examples involving `ClaudeSDKClient`. You can even run interactive examples in IPython from [examples/streaming_mode_ipython.py](examples/streaming_mode_ipython.py).
|
||||||
|
|
||||||
MIT
|
## Migrating from Claude Code SDK
|
||||||
|
|
||||||
|
If you're upgrading from the Claude Code SDK (versions < 0.1.0), please see the [CHANGELOG.md](CHANGELOG.md#010) for details on breaking changes and new features, including:
|
||||||
|
|
||||||
|
- `ClaudeCodeOptions` → `ClaudeAgentOptions` rename
|
||||||
|
- Merged system prompt configuration
|
||||||
|
- Settings isolation and explicit control
|
||||||
|
- New programmatic subagents and session forking features
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
If you're contributing to this project, run the initial setup script to install git hooks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/initial-setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs a pre-push hook that runs lint checks before pushing, matching the CI workflow. To skip the hook temporarily, use `git push --no-verify`.
|
||||||
|
|
||||||
|
### Building Wheels Locally
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
|
||||||
102
e2e-tests/README.md
Normal file
102
e2e-tests/README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# End-to-End Tests for Claude Code SDK
|
||||||
|
|
||||||
|
This directory contains end-to-end tests that run against the actual Claude API to verify real-world functionality.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### API Key (REQUIRED)
|
||||||
|
|
||||||
|
These tests require a valid Anthropic API key. The tests will **fail** if `ANTHROPIC_API_KEY` is not set.
|
||||||
|
|
||||||
|
Set your API key before running tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_API_KEY="your-api-key-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Install the development dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Tests
|
||||||
|
|
||||||
|
### Run all e2e tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest e2e-tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with e2e marker only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest e2e-tests/ -v -m e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run a specific test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest e2e-tests/test_mcp_calculator.py::test_basic_addition -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost Considerations
|
||||||
|
|
||||||
|
⚠️ **Important**: These tests make actual API calls to Claude, which incur costs based on your Anthropic pricing plan.
|
||||||
|
|
||||||
|
- Each test typically uses 1-3 API calls
|
||||||
|
- Tests use simple prompts to minimize token usage
|
||||||
|
- The complete test suite should cost less than $0.10 to run
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
### MCP Calculator Tests (`test_mcp_calculator.py`)
|
||||||
|
|
||||||
|
Tests the MCP (Model Context Protocol) integration with calculator tools:
|
||||||
|
|
||||||
|
- **test_basic_addition**: Verifies the add tool executes correctly
|
||||||
|
- **test_division**: Tests division with decimal results
|
||||||
|
- **test_square_root**: Validates square root calculations
|
||||||
|
- **test_power**: Tests exponentiation
|
||||||
|
- **test_multi_step_calculation**: Verifies multiple tools can be used in sequence
|
||||||
|
- **test_tool_permissions_enforced**: Ensures permission system works correctly
|
||||||
|
|
||||||
|
Each test validates:
|
||||||
|
1. Tools are actually called (ToolUseBlock present in response)
|
||||||
|
2. Correct tool inputs are provided
|
||||||
|
3. Expected results are returned
|
||||||
|
4. Permission system is enforced
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
These tests run automatically on:
|
||||||
|
- Pushes to `main` branch (via GitHub Actions)
|
||||||
|
- Manual workflow dispatch
|
||||||
|
|
||||||
|
The workflow uses `ANTHROPIC_API_KEY` from GitHub Secrets.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "ANTHROPIC_API_KEY environment variable is required" error
|
||||||
|
- Set your API key: `export ANTHROPIC_API_KEY=sk-ant-...`
|
||||||
|
- The tests will not skip - they require the key to run
|
||||||
|
|
||||||
|
### Tests timing out
|
||||||
|
- Check your API key is valid and has quota available
|
||||||
|
- Ensure network connectivity to api.anthropic.com
|
||||||
|
|
||||||
|
### Permission denied errors
|
||||||
|
- Verify the `allowed_tools` parameter includes the necessary MCP tools
|
||||||
|
- Check that tool names match the expected format (e.g., `mcp__calc__add`)
|
||||||
|
|
||||||
|
## Adding New E2E Tests
|
||||||
|
|
||||||
|
When adding new e2e tests:
|
||||||
|
|
||||||
|
1. Mark tests with `@pytest.mark.e2e` decorator
|
||||||
|
2. Use the `api_key` fixture to ensure API key is available
|
||||||
|
3. Keep prompts simple to minimize costs
|
||||||
|
4. Verify actual tool execution, not just mocked responses
|
||||||
|
5. Document any special setup requirements in this README
|
||||||
30
e2e-tests/conftest.py
Normal file
30
e2e-tests/conftest.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Pytest configuration for e2e tests."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def api_key():
|
||||||
|
"""Ensure ANTHROPIC_API_KEY is set for e2e tests."""
|
||||||
|
key = os.environ.get("ANTHROPIC_API_KEY")
|
||||||
|
if not key:
|
||||||
|
pytest.fail(
|
||||||
|
"ANTHROPIC_API_KEY environment variable is required for e2e tests. "
|
||||||
|
"Set it before running: export ANTHROPIC_API_KEY=your-key-here"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop_policy():
|
||||||
|
"""Use the default event loop policy for all async tests."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
return asyncio.get_event_loop_policy()
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Add e2e marker."""
|
||||||
|
config.addinivalue_line("markers", "e2e: marks tests as e2e tests requiring API key")
|
||||||
242
e2e-tests/test_agents_and_settings.py
Normal file
242
e2e-tests/test_agents_and_settings.py
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"""End-to-end tests for agents and setting sources with real Claude API calls."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AgentDefinition,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
SystemMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_definition():
|
||||||
|
"""Test that custom agent definitions work."""
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
agents={
|
||||||
|
"test-agent": AgentDefinition(
|
||||||
|
description="A test agent for verification",
|
||||||
|
prompt="You are a test agent. Always respond with 'Test agent activated'",
|
||||||
|
tools=["Read"],
|
||||||
|
model="sonnet",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("What is 2 + 2?")
|
||||||
|
|
||||||
|
# Check that agent is available in init message
|
||||||
|
async for message in client.receive_response():
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
agents = message.data.get("agents", [])
|
||||||
|
assert isinstance(agents, list), (
|
||||||
|
f"agents should be a list of strings, got: {type(agents)}"
|
||||||
|
)
|
||||||
|
assert "test-agent" in agents, (
|
||||||
|
f"test-agent should be available, got: {agents}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filesystem_agent_loading():
|
||||||
|
"""Test that filesystem-based agents load via setting_sources and produce full response.
|
||||||
|
|
||||||
|
This is the core test for issue #406. It verifies that when using
|
||||||
|
setting_sources=["project"] with a .claude/agents/ directory containing
|
||||||
|
agent definitions, the SDK:
|
||||||
|
1. Loads the agents (they appear in init message)
|
||||||
|
2. Produces a full response with AssistantMessage
|
||||||
|
3. Completes with a ResultMessage
|
||||||
|
|
||||||
|
The bug in #406 causes the iterator to complete after only the
|
||||||
|
init SystemMessage, never yielding AssistantMessage or ResultMessage.
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a temporary project with a filesystem agent
|
||||||
|
project_dir = Path(tmpdir)
|
||||||
|
agents_dir = project_dir / ".claude" / "agents"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Create a test agent file
|
||||||
|
agent_file = agents_dir / "fs-test-agent.md"
|
||||||
|
agent_file.write_text(
|
||||||
|
"""---
|
||||||
|
name: fs-test-agent
|
||||||
|
description: A filesystem test agent for SDK testing
|
||||||
|
tools: Read
|
||||||
|
---
|
||||||
|
|
||||||
|
# Filesystem Test Agent
|
||||||
|
|
||||||
|
You are a simple test agent. When asked a question, provide a brief, helpful answer.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
setting_sources=["project"],
|
||||||
|
cwd=project_dir,
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Say hello in exactly 3 words")
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
messages.append(msg)
|
||||||
|
|
||||||
|
# Must have at least init, assistant, result
|
||||||
|
message_types = [type(m).__name__ for m in messages]
|
||||||
|
|
||||||
|
assert "SystemMessage" in message_types, "Missing SystemMessage (init)"
|
||||||
|
assert "AssistantMessage" in message_types, (
|
||||||
|
f"Missing AssistantMessage - got only: {message_types}. "
|
||||||
|
"This may indicate issue #406 (silent failure with filesystem agents)."
|
||||||
|
)
|
||||||
|
assert "ResultMessage" in message_types, "Missing ResultMessage"
|
||||||
|
|
||||||
|
# Find the init message and check for the filesystem agent
|
||||||
|
for msg in messages:
|
||||||
|
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||||
|
agents = msg.data.get("agents", [])
|
||||||
|
# Agents are returned as strings (just names)
|
||||||
|
assert "fs-test-agent" in agents, (
|
||||||
|
f"fs-test-agent not loaded from filesystem. Found: {agents}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# On Windows, wait for file handles to be released before cleanup
|
||||||
|
if sys.platform == "win32":
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setting_sources_default():
|
||||||
|
"""Test that default (no setting_sources) loads no settings."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a temporary project with local settings
|
||||||
|
project_dir = Path(tmpdir)
|
||||||
|
claude_dir = project_dir / ".claude"
|
||||||
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Create local settings with custom outputStyle
|
||||||
|
settings_file = claude_dir / "settings.local.json"
|
||||||
|
settings_file.write_text('{"outputStyle": "local-test-style"}')
|
||||||
|
|
||||||
|
# Don't provide setting_sources - should default to no settings
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
cwd=project_dir,
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("What is 2 + 2?")
|
||||||
|
|
||||||
|
# Check that settings were NOT loaded
|
||||||
|
async for message in client.receive_response():
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
output_style = message.data.get("output_style")
|
||||||
|
assert output_style != "local-test-style", (
|
||||||
|
f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}"
|
||||||
|
)
|
||||||
|
assert output_style == "default", (
|
||||||
|
f"outputStyle should be 'default', got: {output_style}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# On Windows, wait for file handles to be released before cleanup
|
||||||
|
if sys.platform == "win32":
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setting_sources_user_only():
|
||||||
|
"""Test that setting_sources=['user'] excludes project settings."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a temporary project with a slash command
|
||||||
|
project_dir = Path(tmpdir)
|
||||||
|
commands_dir = project_dir / ".claude" / "commands"
|
||||||
|
commands_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
test_command = commands_dir / "testcmd.md"
|
||||||
|
test_command.write_text(
|
||||||
|
"""---
|
||||||
|
description: Test command
|
||||||
|
---
|
||||||
|
|
||||||
|
This is a test command.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use setting_sources=["user"] to exclude project settings
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
setting_sources=["user"],
|
||||||
|
cwd=project_dir,
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("What is 2 + 2?")
|
||||||
|
|
||||||
|
# Check that project command is NOT available
|
||||||
|
async for message in client.receive_response():
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
commands = message.data.get("slash_commands", [])
|
||||||
|
assert "testcmd" not in commands, (
|
||||||
|
f"testcmd should NOT be available with user-only sources, got: {commands}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# On Windows, wait for file handles to be released before cleanup
|
||||||
|
if sys.platform == "win32":
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setting_sources_project_included():
|
||||||
|
"""Test that setting_sources=['user', 'project'] includes project settings."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a temporary project with local settings
|
||||||
|
project_dir = Path(tmpdir)
|
||||||
|
claude_dir = project_dir / ".claude"
|
||||||
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Create local settings with custom outputStyle
|
||||||
|
settings_file = claude_dir / "settings.local.json"
|
||||||
|
settings_file.write_text('{"outputStyle": "local-test-style"}')
|
||||||
|
|
||||||
|
# Use setting_sources=["user", "project", "local"] to include local settings
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
setting_sources=["user", "project", "local"],
|
||||||
|
cwd=project_dir,
|
||||||
|
max_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("What is 2 + 2?")
|
||||||
|
|
||||||
|
# Check that settings WERE loaded
|
||||||
|
async for message in client.receive_response():
|
||||||
|
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||||
|
output_style = message.data.get("output_style")
|
||||||
|
assert output_style == "local-test-style", (
|
||||||
|
f"outputStyle should be from local settings, got: {output_style}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# On Windows, wait for file handles to be released before cleanup
|
||||||
|
if sys.platform == "win32":
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
97
e2e-tests/test_dynamic_control.py
Normal file
97
e2e-tests/test_dynamic_control.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""End-to-end tests for dynamic control features with real Claude API calls."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_permission_mode():
|
||||||
|
"""Test that permission mode can be changed dynamically during a session."""
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
permission_mode="default",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
# Change permission mode to acceptEdits
|
||||||
|
await client.set_permission_mode("acceptEdits")
|
||||||
|
|
||||||
|
# Make a query that would normally require permission
|
||||||
|
await client.query("What is 2+2? Just respond with the number.")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Got message: {message}")
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Change back to default
|
||||||
|
await client.set_permission_mode("default")
|
||||||
|
|
||||||
|
# Make another query
|
||||||
|
await client.query("What is 3+3? Just respond with the number.")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Got message: {message}")
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_model():
|
||||||
|
"""Test that model can be changed dynamically during a session."""
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions()
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
# Start with default model
|
||||||
|
await client.query("What is 1+1? Just the number.")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Default model response: {message}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Switch to Haiku model
|
||||||
|
await client.set_model("claude-3-5-haiku-20241022")
|
||||||
|
|
||||||
|
await client.query("What is 2+2? Just the number.")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Haiku model response: {message}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Switch back to default (None means default)
|
||||||
|
await client.set_model(None)
|
||||||
|
|
||||||
|
await client.query("What is 3+3? Just the number.")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Back to default model: {message}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interrupt():
|
||||||
|
"""Test that interrupt can be sent during a session."""
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions()
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
# Start a query
|
||||||
|
await client.query("Count from 1 to 100 slowly.")
|
||||||
|
|
||||||
|
# Send interrupt (may or may not stop the response depending on timing)
|
||||||
|
try:
|
||||||
|
await client.interrupt()
|
||||||
|
print("Interrupt sent successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Interrupt resulted in: {e}")
|
||||||
|
|
||||||
|
# Consume any remaining messages
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Got message after interrupt: {message}")
|
||||||
|
pass
|
||||||
150
e2e-tests/test_hooks.py
Normal file
150
e2e-tests/test_hooks.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"""End-to-end tests for hook callbacks with real Claude API calls."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
HookContext,
|
||||||
|
HookInput,
|
||||||
|
HookJSONOutput,
|
||||||
|
HookMatcher,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_with_permission_decision_and_reason():
|
||||||
|
"""Test that hooks with permissionDecision and reason fields work end-to-end."""
|
||||||
|
hook_invocations = []
|
||||||
|
|
||||||
|
async def test_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""Hook that uses permissionDecision and reason fields."""
|
||||||
|
tool_name = input_data.get("tool_name", "")
|
||||||
|
print(f"Hook called for tool: {tool_name}")
|
||||||
|
hook_invocations.append(tool_name)
|
||||||
|
|
||||||
|
# Block Bash commands for this test
|
||||||
|
if tool_name == "Bash":
|
||||||
|
return {
|
||||||
|
"reason": "Bash commands are blocked in this test for safety",
|
||||||
|
"systemMessage": "⚠️ Command blocked by hook",
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": "Security policy: Bash blocked",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reason": "Tool approved by security review",
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "allow",
|
||||||
|
"permissionDecisionReason": "Tool passed security checks",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Bash", "Write"],
|
||||||
|
hooks={
|
||||||
|
"PreToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[test_hook]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Run this bash command: echo 'hello'")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Got message: {message}")
|
||||||
|
|
||||||
|
print(f"Hook invocations: {hook_invocations}")
|
||||||
|
# Verify hook was called
|
||||||
|
assert "Bash" in hook_invocations, f"Hook should have been invoked for Bash tool, got: {hook_invocations}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_with_continue_and_stop_reason():
|
||||||
|
"""Test that hooks with continue_=False and stopReason fields work end-to-end."""
|
||||||
|
hook_invocations = []
|
||||||
|
|
||||||
|
async def post_tool_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""PostToolUse hook that stops execution with stopReason."""
|
||||||
|
tool_name = input_data.get("tool_name", "")
|
||||||
|
hook_invocations.append(tool_name)
|
||||||
|
|
||||||
|
# Actually test continue_=False and stopReason fields
|
||||||
|
return {
|
||||||
|
"continue_": False,
|
||||||
|
"stopReason": "Execution halted by test hook for validation",
|
||||||
|
"reason": "Testing continue and stopReason fields",
|
||||||
|
"systemMessage": "🛑 Test hook stopped execution",
|
||||||
|
}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Bash"],
|
||||||
|
hooks={
|
||||||
|
"PostToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[post_tool_hook]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Run: echo 'test message'")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Got message: {message}")
|
||||||
|
|
||||||
|
print(f"Hook invocations: {hook_invocations}")
|
||||||
|
# Verify hook was called
|
||||||
|
assert "Bash" in hook_invocations, f"PostToolUse hook should have been invoked, got: {hook_invocations}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_with_additional_context():
|
||||||
|
"""Test that hooks with hookSpecificOutput work end-to-end."""
|
||||||
|
hook_invocations = []
|
||||||
|
|
||||||
|
async def context_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""Hook that provides additional context."""
|
||||||
|
hook_invocations.append("context_added")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"systemMessage": "Additional context provided by hook",
|
||||||
|
"reason": "Hook providing monitoring feedback",
|
||||||
|
"suppressOutput": False,
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PostToolUse",
|
||||||
|
"additionalContext": "The command executed successfully with hook monitoring",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Bash"],
|
||||||
|
hooks={
|
||||||
|
"PostToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[context_hook]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Run: echo 'testing hooks'")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Got message: {message}")
|
||||||
|
|
||||||
|
print(f"Hook invocations: {hook_invocations}")
|
||||||
|
# Verify hook was called
|
||||||
|
assert "context_added" in hook_invocations, "Hook with hookSpecificOutput should have been invoked"
|
||||||
154
e2e-tests/test_include_partial_messages.py
Normal file
154
e2e-tests/test_include_partial_messages.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""End-to-end tests for include_partial_messages option with real Claude API calls.
|
||||||
|
|
||||||
|
These tests verify that the SDK properly handles partial message streaming,
|
||||||
|
including StreamEvent parsing and message interleaving.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeSDKClient
|
||||||
|
from claude_agent_sdk.types import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
StreamEvent,
|
||||||
|
AssistantMessage,
|
||||||
|
SystemMessage,
|
||||||
|
ResultMessage,
|
||||||
|
ThinkingBlock,
|
||||||
|
TextBlock,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_include_partial_messages_stream_events():
|
||||||
|
"""Test that include_partial_messages produces StreamEvent messages."""
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
include_partial_messages=True,
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
max_turns=2,
|
||||||
|
env={
|
||||||
|
"MAX_THINKING_TOKENS": "8000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
collected_messages: List[Any] = []
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options) as client:
|
||||||
|
# Send a simple prompt that will generate streaming response with thinking
|
||||||
|
await client.query("Think of three jokes, then tell one")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
collected_messages.append(message)
|
||||||
|
|
||||||
|
# Verify we got the expected message types
|
||||||
|
message_types = [type(msg).__name__ for msg in collected_messages]
|
||||||
|
|
||||||
|
# Should have SystemMessage(init) at the start
|
||||||
|
assert message_types[0] == "SystemMessage"
|
||||||
|
assert isinstance(collected_messages[0], SystemMessage)
|
||||||
|
assert collected_messages[0].subtype == "init"
|
||||||
|
|
||||||
|
# Should have multiple StreamEvent messages
|
||||||
|
stream_events = [msg for msg in collected_messages if isinstance(msg, StreamEvent)]
|
||||||
|
assert len(stream_events) > 0, "No StreamEvent messages received"
|
||||||
|
|
||||||
|
# Check for expected StreamEvent types
|
||||||
|
event_types = [event.event.get("type") for event in stream_events]
|
||||||
|
assert "message_start" in event_types, "No message_start StreamEvent"
|
||||||
|
assert "content_block_start" in event_types, "No content_block_start StreamEvent"
|
||||||
|
assert "content_block_delta" in event_types, "No content_block_delta StreamEvent"
|
||||||
|
assert "content_block_stop" in event_types, "No content_block_stop StreamEvent"
|
||||||
|
assert "message_stop" in event_types, "No message_stop StreamEvent"
|
||||||
|
|
||||||
|
# Should have AssistantMessage messages with thinking and text
|
||||||
|
assistant_messages = [msg for msg in collected_messages if isinstance(msg, AssistantMessage)]
|
||||||
|
assert len(assistant_messages) >= 1, "No AssistantMessage received"
|
||||||
|
|
||||||
|
# Check for thinking block in at least one AssistantMessage
|
||||||
|
has_thinking = any(
|
||||||
|
any(isinstance(block, ThinkingBlock) for block in msg.content)
|
||||||
|
for msg in assistant_messages
|
||||||
|
)
|
||||||
|
assert has_thinking, "No ThinkingBlock found in AssistantMessages"
|
||||||
|
|
||||||
|
# Check for text block (the joke) in at least one AssistantMessage
|
||||||
|
has_text = any(
|
||||||
|
any(isinstance(block, TextBlock) for block in msg.content)
|
||||||
|
for msg in assistant_messages
|
||||||
|
)
|
||||||
|
assert has_text, "No TextBlock found in AssistantMessages"
|
||||||
|
|
||||||
|
# Should end with ResultMessage
|
||||||
|
assert isinstance(collected_messages[-1], ResultMessage)
|
||||||
|
assert collected_messages[-1].subtype == "success"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_include_partial_messages_thinking_deltas():
|
||||||
|
"""Test that thinking content is streamed incrementally via deltas."""
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
include_partial_messages=True,
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
max_turns=2,
|
||||||
|
env={
|
||||||
|
"MAX_THINKING_TOKENS": "8000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
thinking_deltas = []
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options) as client:
|
||||||
|
await client.query("Think step by step about what 2 + 2 equals")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
if isinstance(message, StreamEvent):
|
||||||
|
event = message.event
|
||||||
|
if event.get("type") == "content_block_delta":
|
||||||
|
delta = event.get("delta", {})
|
||||||
|
if delta.get("type") == "thinking_delta":
|
||||||
|
thinking_deltas.append(delta.get("thinking", ""))
|
||||||
|
|
||||||
|
# Should have received multiple thinking deltas
|
||||||
|
assert len(thinking_deltas) > 0, "No thinking deltas received"
|
||||||
|
|
||||||
|
# Combined thinking should form coherent text
|
||||||
|
combined_thinking = "".join(thinking_deltas)
|
||||||
|
assert len(combined_thinking) > 10, "Thinking content too short"
|
||||||
|
|
||||||
|
# Should contain some reasoning about the calculation
|
||||||
|
assert "2" in combined_thinking.lower(), "Thinking doesn't mention the numbers"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_messages_disabled_by_default():
|
||||||
|
"""Test that partial messages are not included when option is not set."""
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
# include_partial_messages not set (defaults to False)
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
max_turns=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
collected_messages: List[Any] = []
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options) as client:
|
||||||
|
await client.query("Say hello")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
collected_messages.append(message)
|
||||||
|
|
||||||
|
# Should NOT have any StreamEvent messages
|
||||||
|
stream_events = [msg for msg in collected_messages if isinstance(msg, StreamEvent)]
|
||||||
|
assert len(stream_events) == 0, "StreamEvent messages present when partial messages disabled"
|
||||||
|
|
||||||
|
# Should still have the regular messages
|
||||||
|
assert any(isinstance(msg, SystemMessage) for msg in collected_messages)
|
||||||
|
assert any(isinstance(msg, AssistantMessage) for msg in collected_messages)
|
||||||
|
assert any(isinstance(msg, ResultMessage) for msg in collected_messages)
|
||||||
166
e2e-tests/test_sdk_mcp_tools.py
Normal file
166
e2e-tests/test_sdk_mcp_tools.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""End-to-end tests for SDK MCP (inline) tools with real Claude API calls.
|
||||||
|
|
||||||
|
These tests verify that SDK-created MCP tools work correctly through the full stack,
|
||||||
|
focusing on tool execution mechanics rather than specific tool functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
create_sdk_mcp_server,
|
||||||
|
tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sdk_mcp_tool_execution():
|
||||||
|
"""Test that SDK MCP tools can be called and executed with allowed_tools."""
|
||||||
|
executions = []
|
||||||
|
|
||||||
|
@tool("echo", "Echo back the input text", {"text": str})
|
||||||
|
async def echo_tool(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Echo back whatever text is provided."""
|
||||||
|
executions.append("echo")
|
||||||
|
return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]}
|
||||||
|
|
||||||
|
server = create_sdk_mcp_server(
|
||||||
|
name="test",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=[echo_tool],
|
||||||
|
)
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"test": server},
|
||||||
|
allowed_tools=["mcp__test__echo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Call the mcp__test__echo tool with any text")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Check if the actual Python function was called
|
||||||
|
assert "echo" in executions, "Echo tool function was not executed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sdk_mcp_permission_enforcement():
|
||||||
|
"""Test that disallowed_tools prevents SDK MCP tool execution."""
|
||||||
|
executions = []
|
||||||
|
|
||||||
|
@tool("echo", "Echo back the input text", {"text": str})
|
||||||
|
async def echo_tool(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Echo back whatever text is provided."""
|
||||||
|
executions.append("echo")
|
||||||
|
return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]}
|
||||||
|
|
||||||
|
@tool("greet", "Greet a person by name", {"name": str})
|
||||||
|
async def greet_tool(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Greet someone by name."""
|
||||||
|
executions.append("greet")
|
||||||
|
return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]}
|
||||||
|
|
||||||
|
server = create_sdk_mcp_server(
|
||||||
|
name="test",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=[echo_tool, greet_tool],
|
||||||
|
)
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"test": server},
|
||||||
|
disallowed_tools=["mcp__test__echo"], # Block echo tool
|
||||||
|
allowed_tools=["mcp__test__greet"], # But allow greet
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query(
|
||||||
|
"Use the echo tool to echo 'test' and use greet tool to greet 'Alice'"
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Check actual function executions
|
||||||
|
assert "echo" not in executions, "Disallowed echo tool was executed"
|
||||||
|
assert "greet" in executions, "Allowed greet tool was not executed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sdk_mcp_multiple_tools():
|
||||||
|
"""Test that multiple SDK MCP tools can be called in sequence."""
|
||||||
|
executions = []
|
||||||
|
|
||||||
|
@tool("echo", "Echo back the input text", {"text": str})
|
||||||
|
async def echo_tool(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Echo back whatever text is provided."""
|
||||||
|
executions.append("echo")
|
||||||
|
return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]}
|
||||||
|
|
||||||
|
@tool("greet", "Greet a person by name", {"name": str})
|
||||||
|
async def greet_tool(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Greet someone by name."""
|
||||||
|
executions.append("greet")
|
||||||
|
return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]}
|
||||||
|
|
||||||
|
server = create_sdk_mcp_server(
|
||||||
|
name="multi",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=[echo_tool, greet_tool],
|
||||||
|
)
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"multi": server},
|
||||||
|
allowed_tools=["mcp__multi__echo", "mcp__multi__greet"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query(
|
||||||
|
"Call mcp__multi__echo with text='test' and mcp__multi__greet with name='Bob'"
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Both tools should have been executed
|
||||||
|
assert "echo" in executions, "Echo tool was not executed"
|
||||||
|
assert "greet" in executions, "Greet tool was not executed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sdk_mcp_without_permissions():
|
||||||
|
"""Test SDK MCP tool behavior without explicit allowed_tools."""
|
||||||
|
executions = []
|
||||||
|
|
||||||
|
@tool("echo", "Echo back the input text", {"text": str})
|
||||||
|
async def echo_tool(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Echo back whatever text is provided."""
|
||||||
|
executions.append("echo")
|
||||||
|
return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]}
|
||||||
|
|
||||||
|
server = create_sdk_mcp_server(
|
||||||
|
name="noperm",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=[echo_tool],
|
||||||
|
)
|
||||||
|
|
||||||
|
# No allowed_tools specified
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"noperm": server},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Call the mcp__noperm__echo tool")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
assert "echo" not in executions, "SDK MCP tool was executed"
|
||||||
49
e2e-tests/test_stderr_callback.py
Normal file
49
e2e-tests/test_stderr_callback.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""End-to-end test for stderr callback functionality."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeAgentOptions, query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stderr_callback_captures_debug_output():
|
||||||
|
"""Test that stderr callback receives debug output when enabled."""
|
||||||
|
stderr_lines = []
|
||||||
|
|
||||||
|
def capture_stderr(line: str):
|
||||||
|
stderr_lines.append(line)
|
||||||
|
|
||||||
|
# Enable debug mode to generate stderr output
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
stderr=capture_stderr,
|
||||||
|
extra_args={"debug-to-stderr": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run a simple query
|
||||||
|
async for _ in query(prompt="What is 1+1?", options=options):
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Verify we captured debug output
|
||||||
|
assert len(stderr_lines) > 0, "Should capture stderr output with debug enabled"
|
||||||
|
assert any("[DEBUG]" in line for line in stderr_lines), "Should contain DEBUG messages"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stderr_callback_without_debug():
|
||||||
|
"""Test that stderr callback works but receives no output without debug mode."""
|
||||||
|
stderr_lines = []
|
||||||
|
|
||||||
|
def capture_stderr(line: str):
|
||||||
|
stderr_lines.append(line)
|
||||||
|
|
||||||
|
# No debug mode enabled
|
||||||
|
options = ClaudeAgentOptions(stderr=capture_stderr)
|
||||||
|
|
||||||
|
# Run a simple query
|
||||||
|
async for _ in query(prompt="What is 1+1?", options=options):
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Should work but capture minimal/no output without debug
|
||||||
|
assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode"
|
||||||
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
|
||||||
43
e2e-tests/test_tool_permissions.py
Normal file
43
e2e-tests/test_tool_permissions.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""End-to-end tests for tool permission callbacks with real Claude API calls."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
PermissionResultAllow,
|
||||||
|
PermissionResultDeny,
|
||||||
|
ToolPermissionContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permission_callback_gets_called():
|
||||||
|
"""Test that can_use_tool callback gets invoked."""
|
||||||
|
callback_invocations = []
|
||||||
|
|
||||||
|
async def permission_callback(
|
||||||
|
tool_name: str,
|
||||||
|
input_data: dict,
|
||||||
|
context: ToolPermissionContext
|
||||||
|
) -> PermissionResultAllow | PermissionResultDeny:
|
||||||
|
"""Track callback invocation."""
|
||||||
|
print(f"Permission callback called for: {tool_name}, input: {input_data}")
|
||||||
|
callback_invocations.append(tool_name)
|
||||||
|
return PermissionResultAllow()
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
can_use_tool=permission_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("Write 'hello world' to /tmp/test.txt")
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(f"Got message: {message}")
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
print(f'Callback invocations: {callback_invocations}')
|
||||||
|
# Verify callback was invoked
|
||||||
|
assert "Write" in callback_invocations, f"can_use_tool callback should have been invoked for Write tool, got: {callback_invocations}"
|
||||||
124
examples/agents.py
Normal file
124
examples/agents.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example of using custom agents with Claude Code SDK.
|
||||||
|
|
||||||
|
This example demonstrates how to define and use custom agents with specific
|
||||||
|
tools, prompts, and models.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./examples/agents.py - Run the example
|
||||||
|
"""
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AgentDefinition,
|
||||||
|
AssistantMessage,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ResultMessage,
|
||||||
|
TextBlock,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def code_reviewer_example():
|
||||||
|
"""Example using a custom code reviewer agent."""
|
||||||
|
print("=== Code Reviewer Agent Example ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
agents={
|
||||||
|
"code-reviewer": AgentDefinition(
|
||||||
|
description="Reviews code for best practices and potential issues",
|
||||||
|
prompt="You are a code reviewer. Analyze code for bugs, performance issues, "
|
||||||
|
"security vulnerabilities, and adherence to best practices. "
|
||||||
|
"Provide constructive feedback.",
|
||||||
|
tools=["Read", "Grep"],
|
||||||
|
model="sonnet",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(
|
||||||
|
prompt="Use the code-reviewer agent to review the code in src/claude_agent_sdk/types.py",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
|
||||||
|
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def documentation_writer_example():
|
||||||
|
"""Example using a documentation writer agent."""
|
||||||
|
print("=== Documentation Writer Agent Example ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
agents={
|
||||||
|
"doc-writer": AgentDefinition(
|
||||||
|
description="Writes comprehensive documentation",
|
||||||
|
prompt="You are a technical documentation expert. Write clear, comprehensive "
|
||||||
|
"documentation with examples. Focus on clarity and completeness.",
|
||||||
|
tools=["Read", "Write", "Edit"],
|
||||||
|
model="sonnet",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(
|
||||||
|
prompt="Use the doc-writer agent to explain what AgentDefinition is used for",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
|
||||||
|
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def multiple_agents_example():
|
||||||
|
"""Example with multiple custom agents."""
|
||||||
|
print("=== Multiple Agents Example ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
agents={
|
||||||
|
"analyzer": AgentDefinition(
|
||||||
|
description="Analyzes code structure and patterns",
|
||||||
|
prompt="You are a code analyzer. Examine code structure, patterns, and architecture.",
|
||||||
|
tools=["Read", "Grep", "Glob"],
|
||||||
|
),
|
||||||
|
"tester": AgentDefinition(
|
||||||
|
description="Creates and runs tests",
|
||||||
|
prompt="You are a testing expert. Write comprehensive tests and ensure code quality.",
|
||||||
|
tools=["Read", "Write", "Bash"],
|
||||||
|
model="sonnet",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
setting_sources=["user", "project"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async for message in query(
|
||||||
|
prompt="Use the analyzer agent to find all Python files in the examples/ directory",
|
||||||
|
options=options,
|
||||||
|
):
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
|
||||||
|
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all agent examples."""
|
||||||
|
await code_reviewer_example()
|
||||||
|
await documentation_writer_example()
|
||||||
|
await multiple_agents_example()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
anyio.run(main)
|
||||||
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())
|
||||||
350
examples/hooks.py
Normal file
350
examples/hooks.py
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""Example of using hooks with Claude Code SDK via ClaudeAgentOptions.
|
||||||
|
|
||||||
|
This file demonstrates various hook patterns using the hooks parameter
|
||||||
|
in ClaudeAgentOptions instead of decorator-based hooks.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./examples/hooks.py - List the examples
|
||||||
|
./examples/hooks.py all - Run all examples
|
||||||
|
./examples/hooks.py PreToolUse - Run a specific example
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||||
|
from claude_agent_sdk.types import (
|
||||||
|
AssistantMessage,
|
||||||
|
HookContext,
|
||||||
|
HookInput,
|
||||||
|
HookJSONOutput,
|
||||||
|
HookMatcher,
|
||||||
|
Message,
|
||||||
|
ResultMessage,
|
||||||
|
TextBlock,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up logging to see what's happening
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def display_message(msg: Message) -> None:
|
||||||
|
"""Standardized message display function."""
|
||||||
|
if isinstance(msg, AssistantMessage):
|
||||||
|
for block in msg.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(msg, ResultMessage):
|
||||||
|
print("Result ended")
|
||||||
|
|
||||||
|
|
||||||
|
##### Hook callback functions
|
||||||
|
async def check_bash_command(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""Prevent certain bash commands from being executed."""
|
||||||
|
tool_name = input_data["tool_name"]
|
||||||
|
tool_input = input_data["tool_input"]
|
||||||
|
|
||||||
|
if tool_name != "Bash":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
command = tool_input.get("command", "")
|
||||||
|
block_patterns = ["foo.sh"]
|
||||||
|
|
||||||
|
for pattern in block_patterns:
|
||||||
|
if pattern in command:
|
||||||
|
logger.warning(f"Blocked command: {command}")
|
||||||
|
return {
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": f"Command contains invalid pattern: {pattern}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def add_custom_instructions(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""Add custom instructions when a session starts."""
|
||||||
|
return {
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "SessionStart",
|
||||||
|
"additionalContext": "My favorite color is hot pink",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def review_tool_output(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""Review tool output and provide additional context or warnings."""
|
||||||
|
tool_response = input_data.get("tool_response", "")
|
||||||
|
|
||||||
|
# If the tool produced an error, add helpful context
|
||||||
|
if "error" in str(tool_response).lower():
|
||||||
|
return {
|
||||||
|
"systemMessage": "⚠️ The command produced an error",
|
||||||
|
"reason": "Tool execution failed - consider checking the command syntax",
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PostToolUse",
|
||||||
|
"additionalContext": "The command encountered an error. You may want to try a different approach.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def strict_approval_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""Demonstrates using permissionDecision to control tool execution."""
|
||||||
|
tool_name = input_data.get("tool_name")
|
||||||
|
tool_input = input_data.get("tool_input", {})
|
||||||
|
|
||||||
|
# Block any Write operations to specific files
|
||||||
|
if tool_name == "Write":
|
||||||
|
file_path = tool_input.get("file_path", "")
|
||||||
|
if "important" in file_path.lower():
|
||||||
|
logger.warning(f"Blocked Write to: {file_path}")
|
||||||
|
return {
|
||||||
|
"reason": "Writes to files containing 'important' in the name are not allowed for safety",
|
||||||
|
"systemMessage": "🚫 Write operation blocked by security policy",
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": "Security policy blocks writes to important files",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allow everything else explicitly
|
||||||
|
return {
|
||||||
|
"reason": "Tool use approved after security review",
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "allow",
|
||||||
|
"permissionDecisionReason": "Tool passed security checks",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_on_error_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""Demonstrates using continue=False to stop execution on certain conditions."""
|
||||||
|
tool_response = input_data.get("tool_response", "")
|
||||||
|
|
||||||
|
# Stop execution if we see a critical error
|
||||||
|
if "critical" in str(tool_response).lower():
|
||||||
|
logger.error("Critical error detected - stopping execution")
|
||||||
|
return {
|
||||||
|
"continue_": False,
|
||||||
|
"stopReason": "Critical error detected in tool output - execution halted for safety",
|
||||||
|
"systemMessage": "🛑 Execution stopped due to critical error",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"continue_": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def example_pretooluse() -> None:
|
||||||
|
"""Basic example demonstrating hook protection."""
|
||||||
|
print("=== PreToolUse Example ===")
|
||||||
|
print("This example demonstrates how PreToolUse can block some bash commands but not others.\n")
|
||||||
|
|
||||||
|
# Configure hooks using ClaudeAgentOptions
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Bash"],
|
||||||
|
hooks={
|
||||||
|
"PreToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[check_bash_command]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
# Test 1: Command with forbidden pattern (will be blocked)
|
||||||
|
print("Test 1: Trying a command that our PreToolUse hook should block...")
|
||||||
|
print("User: Run the bash command: ./foo.sh --help")
|
||||||
|
await client.query("Run the bash command: ./foo.sh --help")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
display_message(msg)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50 + "\n")
|
||||||
|
|
||||||
|
# Test 2: Safe command that should work
|
||||||
|
print("Test 2: Trying a command that our PreToolUse hook should allow...")
|
||||||
|
print("User: Run the bash command: echo 'Hello from hooks example!'")
|
||||||
|
await client.query("Run the bash command: echo 'Hello from hooks example!'")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
display_message(msg)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50 + "\n")
|
||||||
|
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_userpromptsubmit() -> None:
|
||||||
|
"""Demonstrate context retention across conversation."""
|
||||||
|
print("=== UserPromptSubmit Example ===")
|
||||||
|
print("This example shows how a UserPromptSubmit hook can add context.\n")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
hooks={
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
HookMatcher(matcher=None, hooks=[add_custom_instructions]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
print("User: What's my favorite color?")
|
||||||
|
await client.query("What's my favorite color?")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
display_message(msg)
|
||||||
|
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_posttooluse() -> None:
|
||||||
|
"""Demonstrate PostToolUse hook with reason and systemMessage fields."""
|
||||||
|
print("=== PostToolUse Example ===")
|
||||||
|
print("This example shows how PostToolUse can provide feedback with reason and systemMessage.\n")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Bash"],
|
||||||
|
hooks={
|
||||||
|
"PostToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[review_tool_output]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
print("User: Run a command that will produce an error: ls /nonexistent_directory")
|
||||||
|
await client.query("Run this command: ls /nonexistent_directory")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
display_message(msg)
|
||||||
|
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_decision_fields() -> None:
|
||||||
|
"""Demonstrate permissionDecision, reason, and systemMessage fields."""
|
||||||
|
print("=== Permission Decision Example ===")
|
||||||
|
print("This example shows how to use permissionDecision='allow'/'deny' with reason and systemMessage.\n")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Write", "Bash"],
|
||||||
|
model="claude-sonnet-4-5-20250929",
|
||||||
|
hooks={
|
||||||
|
"PreToolUse": [
|
||||||
|
HookMatcher(matcher="Write", hooks=[strict_approval_hook]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
# Test 1: Try to write to a file with "important" in the name (should be blocked)
|
||||||
|
print("Test 1: Trying to write to important_config.txt (should be blocked)...")
|
||||||
|
print("User: Write 'test' to important_config.txt")
|
||||||
|
await client.query("Write the text 'test data' to a file called important_config.txt")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
display_message(msg)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50 + "\n")
|
||||||
|
|
||||||
|
# Test 2: Write to a regular file (should be approved)
|
||||||
|
print("Test 2: Trying to write to regular_file.txt (should be approved)...")
|
||||||
|
print("User: Write 'test' to regular_file.txt")
|
||||||
|
await client.query("Write the text 'test data' to a file called regular_file.txt")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
display_message(msg)
|
||||||
|
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_continue_control() -> None:
|
||||||
|
"""Demonstrate continue and stopReason fields for execution control."""
|
||||||
|
print("=== Continue/Stop Control Example ===")
|
||||||
|
print("This example shows how to use continue_=False with stopReason to halt execution.\n")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
allowed_tools=["Bash"],
|
||||||
|
hooks={
|
||||||
|
"PostToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[stop_on_error_hook]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
print("User: Run a command that outputs 'CRITICAL ERROR'")
|
||||||
|
await client.query("Run this bash command: echo 'CRITICAL ERROR: system failure'")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
display_message(msg)
|
||||||
|
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Run all examples or a specific example based on command line argument."""
|
||||||
|
examples = {
|
||||||
|
"PreToolUse": example_pretooluse,
|
||||||
|
"UserPromptSubmit": example_userpromptsubmit,
|
||||||
|
"PostToolUse": example_posttooluse,
|
||||||
|
"DecisionFields": example_decision_fields,
|
||||||
|
"ContinueControl": example_continue_control,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
# List available examples
|
||||||
|
print("Usage: python hooks.py <example_name>")
|
||||||
|
print("\nAvailable examples:")
|
||||||
|
print(" all - Run all examples")
|
||||||
|
for name in examples:
|
||||||
|
print(f" {name}")
|
||||||
|
print("\nExample descriptions:")
|
||||||
|
print(" PreToolUse - Block commands using PreToolUse hook")
|
||||||
|
print(" UserPromptSubmit - Add context at prompt submission")
|
||||||
|
print(" PostToolUse - Review tool output with reason and systemMessage")
|
||||||
|
print(" DecisionFields - Use permissionDecision='allow'/'deny' with reason")
|
||||||
|
print(" ContinueControl - Control execution with continue_ and stopReason")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
example_name = sys.argv[1]
|
||||||
|
|
||||||
|
if example_name == "all":
|
||||||
|
# Run all examples
|
||||||
|
for example in examples.values():
|
||||||
|
await example()
|
||||||
|
print("-" * 50 + "\n")
|
||||||
|
elif example_name in examples:
|
||||||
|
# Run specific example
|
||||||
|
await examples[example_name]()
|
||||||
|
else:
|
||||||
|
print(f"Error: Unknown example '{example_name}'")
|
||||||
|
print("\nAvailable examples:")
|
||||||
|
print(" all - Run all examples")
|
||||||
|
for name in examples:
|
||||||
|
print(f" {name}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Starting Claude SDK Hooks Examples...")
|
||||||
|
print("=" * 50 + "\n")
|
||||||
|
asyncio.run(main())
|
||||||
62
examples/include_partial_messages.py
Normal file
62
examples/include_partial_messages.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Example of using the "include_partial_messages" option to stream partial messages
|
||||||
|
from Claude Code SDK.
|
||||||
|
|
||||||
|
This feature allows you to receive stream events that contain incremental
|
||||||
|
updates as Claude generates responses. This is useful for:
|
||||||
|
- Building real-time UIs that show text as it's being generated
|
||||||
|
- Monitoring tool use progress
|
||||||
|
- Getting early results before the full response is complete
|
||||||
|
|
||||||
|
Note: Partial message streaming requires the CLI to support it, and the
|
||||||
|
messages will include StreamEvent messages interspersed with regular messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from claude_agent_sdk import ClaudeSDKClient
|
||||||
|
from claude_agent_sdk.types import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
StreamEvent,
|
||||||
|
AssistantMessage,
|
||||||
|
UserMessage,
|
||||||
|
SystemMessage,
|
||||||
|
ResultMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Enable partial message streaming
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
include_partial_messages=True,
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
max_turns=2,
|
||||||
|
env={
|
||||||
|
"MAX_THINKING_TOKENS": "8000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = ClaudeSDKClient(options)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
# Send a prompt that will generate a streaming response
|
||||||
|
# prompt = "Run a bash command to sleep for 5 seconds"
|
||||||
|
prompt = "Think of three jokes, then tell one"
|
||||||
|
print(f"Prompt: {prompt}\n")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
await client.query(prompt)
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Partial Message Streaming Example")
|
||||||
|
print("=" * 50)
|
||||||
|
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)
|
||||||
193
examples/mcp_calculator.py
Normal file
193
examples/mcp_calculator.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example: Calculator MCP Server.
|
||||||
|
|
||||||
|
This example demonstrates how to create an in-process MCP server with
|
||||||
|
calculator tools using the Claude Code Python SDK.
|
||||||
|
|
||||||
|
Unlike external MCP servers that require separate processes, this server
|
||||||
|
runs directly within your Python application, providing better performance
|
||||||
|
and simpler deployment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
create_sdk_mcp_server,
|
||||||
|
tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define calculator tools using the @tool decorator
|
||||||
|
|
||||||
|
|
||||||
|
@tool("add", "Add two numbers", {"a": float, "b": float})
|
||||||
|
async def add_numbers(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Add two numbers together."""
|
||||||
|
result = args["a"] + args["b"]
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"{args['a']} + {args['b']} = {result}"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("subtract", "Subtract one number from another", {"a": float, "b": float})
|
||||||
|
async def subtract_numbers(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Subtract b from a."""
|
||||||
|
result = args["a"] - args["b"]
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"{args['a']} - {args['b']} = {result}"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("multiply", "Multiply two numbers", {"a": float, "b": float})
|
||||||
|
async def multiply_numbers(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Multiply two numbers."""
|
||||||
|
result = args["a"] * args["b"]
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"{args['a']} × {args['b']} = {result}"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("divide", "Divide one number by another", {"a": float, "b": float})
|
||||||
|
async def divide_numbers(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Divide a by b."""
|
||||||
|
if args["b"] == 0:
|
||||||
|
return {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Error: Division by zero is not allowed"}
|
||||||
|
],
|
||||||
|
"is_error": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = args["a"] / args["b"]
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"{args['a']} ÷ {args['b']} = {result}"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("sqrt", "Calculate square root", {"n": float})
|
||||||
|
async def square_root(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Calculate the square root of a number."""
|
||||||
|
n = args["n"]
|
||||||
|
if n < 0:
|
||||||
|
return {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": f"Error: Cannot calculate square root of negative number {n}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_error": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
result = math.sqrt(n)
|
||||||
|
return {"content": [{"type": "text", "text": f"√{n} = {result}"}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("power", "Raise a number to a power", {"base": float, "exponent": float})
|
||||||
|
async def power(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Raise base to the exponent power."""
|
||||||
|
result = args["base"] ** args["exponent"]
|
||||||
|
return {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": f"{args['base']}^{args['exponent']} = {result}"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def display_message(msg):
|
||||||
|
"""Display message content in a clean format."""
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
|
ResultMessage,
|
||||||
|
SystemMessage,
|
||||||
|
TextBlock,
|
||||||
|
ToolResultBlock,
|
||||||
|
ToolUseBlock,
|
||||||
|
UserMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(msg, UserMessage):
|
||||||
|
for block in msg.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"User: {block.text}")
|
||||||
|
elif isinstance(block, ToolResultBlock):
|
||||||
|
print(
|
||||||
|
f"Tool Result: {block.content[:100] if block.content else 'None'}..."
|
||||||
|
)
|
||||||
|
elif isinstance(msg, AssistantMessage):
|
||||||
|
for block in msg.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
elif isinstance(block, ToolUseBlock):
|
||||||
|
print(f"Using tool: {block.name}")
|
||||||
|
# Show tool inputs for calculator
|
||||||
|
if block.input:
|
||||||
|
print(f" Input: {block.input}")
|
||||||
|
elif isinstance(msg, SystemMessage):
|
||||||
|
# Ignore system messages
|
||||||
|
pass
|
||||||
|
elif isinstance(msg, ResultMessage):
|
||||||
|
print("Result ended")
|
||||||
|
if msg.total_cost_usd:
|
||||||
|
print(f"Cost: ${msg.total_cost_usd:.6f}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run example calculations using the SDK MCP server with streaming client."""
|
||||||
|
from claude_agent_sdk import ClaudeSDKClient
|
||||||
|
|
||||||
|
# Create the calculator server with all tools
|
||||||
|
calculator = create_sdk_mcp_server(
|
||||||
|
name="calculator",
|
||||||
|
version="2.0.0",
|
||||||
|
tools=[
|
||||||
|
add_numbers,
|
||||||
|
subtract_numbers,
|
||||||
|
multiply_numbers,
|
||||||
|
divide_numbers,
|
||||||
|
square_root,
|
||||||
|
power,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure Claude to use the calculator server with allowed tools
|
||||||
|
# Pre-approve all calculator MCP tools so they can be used without permission prompts
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"calc": calculator},
|
||||||
|
allowed_tools=[
|
||||||
|
"mcp__calc__add",
|
||||||
|
"mcp__calc__subtract",
|
||||||
|
"mcp__calc__multiply",
|
||||||
|
"mcp__calc__divide",
|
||||||
|
"mcp__calc__sqrt",
|
||||||
|
"mcp__calc__power",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Example prompts to demonstrate calculator usage
|
||||||
|
prompts = [
|
||||||
|
"List your tools",
|
||||||
|
"Calculate 15 + 27",
|
||||||
|
"What is 100 divided by 7?",
|
||||||
|
"Calculate the square root of 144",
|
||||||
|
"What is 2 raised to the power of 8?",
|
||||||
|
"Calculate (12 + 8) * 3 - 10", # Complex calculation
|
||||||
|
]
|
||||||
|
|
||||||
|
for prompt in prompts:
|
||||||
|
print(f"\n{'=' * 50}")
|
||||||
|
print(f"Prompt: {prompt}")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query(prompt)
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
display_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.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.
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
query,
|
query,
|
||||||
|
|
@ -28,7 +28,7 @@ async def with_options_example():
|
||||||
"""Example with custom options."""
|
"""Example with custom options."""
|
||||||
print("=== With Options Example ===")
|
print("=== With Options Example ===")
|
||||||
|
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
system_prompt="You are a helpful assistant that explains things simply.",
|
system_prompt="You are a helpful assistant that explains things simply.",
|
||||||
max_turns=1,
|
max_turns=1,
|
||||||
)
|
)
|
||||||
|
|
@ -47,7 +47,7 @@ async def with_tools_example():
|
||||||
"""Example using tools."""
|
"""Example using tools."""
|
||||||
print("=== With Tools Example ===")
|
print("=== With Tools Example ===")
|
||||||
|
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
allowed_tools=["Read", "Write"],
|
allowed_tools=["Read", "Write"],
|
||||||
system_prompt="You are a helpful file assistant.",
|
system_prompt="You are a helpful file assistant.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
174
examples/setting_sources.py
Normal file
174
examples/setting_sources.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example demonstrating setting sources control.
|
||||||
|
|
||||||
|
This example shows how to use the setting_sources option to control which
|
||||||
|
settings are loaded, including custom slash commands, agents, and other
|
||||||
|
configurations.
|
||||||
|
|
||||||
|
Setting sources determine where Claude Code loads configurations from:
|
||||||
|
- "user": Global user settings (~/.claude/)
|
||||||
|
- "project": Project-level settings (.claude/ in project)
|
||||||
|
- "local": Local gitignored settings (.claude-local/)
|
||||||
|
|
||||||
|
IMPORTANT: When setting_sources is not provided (None), NO settings are loaded
|
||||||
|
by default. This creates an isolated environment. To load settings, explicitly
|
||||||
|
specify which sources to use.
|
||||||
|
|
||||||
|
By controlling which sources are loaded, you can:
|
||||||
|
- Create isolated environments with no custom settings (default)
|
||||||
|
- Load only user settings, excluding project-specific configurations
|
||||||
|
- Combine multiple sources as needed
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./examples/setting_sources.py - List the examples
|
||||||
|
./examples/setting_sources.py all - Run all examples
|
||||||
|
./examples/setting_sources.py default - Run a specific example
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
SystemMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_slash_commands(msg: SystemMessage) -> list[str]:
|
||||||
|
"""Extract slash command names from system message."""
|
||||||
|
if msg.subtype == "init":
|
||||||
|
commands = msg.data.get("slash_commands", [])
|
||||||
|
return commands
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def example_default():
|
||||||
|
"""Default behavior - no settings loaded."""
|
||||||
|
print("=== Default Behavior Example ===")
|
||||||
|
print("Setting sources: None (default)")
|
||||||
|
print("Expected: No custom slash commands will be available\n")
|
||||||
|
|
||||||
|
sdk_dir = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
cwd=sdk_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("What is 2 + 2?")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||||
|
commands = extract_slash_commands(msg)
|
||||||
|
print(f"Available slash commands: {commands}")
|
||||||
|
if "commit" in commands:
|
||||||
|
print("❌ /commit is available (unexpected)")
|
||||||
|
else:
|
||||||
|
print("✓ /commit is NOT available (expected - no settings loaded)")
|
||||||
|
break
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def example_user_only():
|
||||||
|
"""Load only user-level settings, excluding project settings."""
|
||||||
|
print("=== User Settings Only Example ===")
|
||||||
|
print("Setting sources: ['user']")
|
||||||
|
print("Expected: Project slash commands (like /commit) will NOT be available\n")
|
||||||
|
|
||||||
|
# Use the SDK repo directory which has .claude/commands/commit.md
|
||||||
|
sdk_dir = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
setting_sources=["user"],
|
||||||
|
cwd=sdk_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
# Send a simple query
|
||||||
|
await client.query("What is 2 + 2?")
|
||||||
|
|
||||||
|
# Check the initialize message for available commands
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||||
|
commands = extract_slash_commands(msg)
|
||||||
|
print(f"Available slash commands: {commands}")
|
||||||
|
if "commit" in commands:
|
||||||
|
print("❌ /commit is available (unexpected)")
|
||||||
|
else:
|
||||||
|
print("✓ /commit is NOT available (expected)")
|
||||||
|
break
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def example_project_and_user():
|
||||||
|
"""Load both project and user settings."""
|
||||||
|
print("=== Project + User Settings Example ===")
|
||||||
|
print("Setting sources: ['user', 'project']")
|
||||||
|
print("Expected: Project slash commands (like /commit) WILL be available\n")
|
||||||
|
|
||||||
|
sdk_dir = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
setting_sources=["user", "project"],
|
||||||
|
cwd=sdk_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query("What is 2 + 2?")
|
||||||
|
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||||
|
commands = extract_slash_commands(msg)
|
||||||
|
print(f"Available slash commands: {commands}")
|
||||||
|
if "commit" in commands:
|
||||||
|
print("✓ /commit is available (expected)")
|
||||||
|
else:
|
||||||
|
print("❌ /commit is NOT available (unexpected)")
|
||||||
|
break
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all examples or a specific example based on command line argument."""
|
||||||
|
examples = {
|
||||||
|
"default": example_default,
|
||||||
|
"user_only": example_user_only,
|
||||||
|
"project_and_user": example_project_and_user,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python setting_sources.py <example_name>")
|
||||||
|
print("\nAvailable examples:")
|
||||||
|
print(" all - Run all examples")
|
||||||
|
for name in examples:
|
||||||
|
print(f" {name}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
example_name = sys.argv[1]
|
||||||
|
|
||||||
|
if example_name == "all":
|
||||||
|
for example in examples.values():
|
||||||
|
await example()
|
||||||
|
print("-" * 50 + "\n")
|
||||||
|
elif example_name in examples:
|
||||||
|
await examples[example_name]()
|
||||||
|
else:
|
||||||
|
print(f"Error: Unknown example '{example_name}'")
|
||||||
|
print("\nAvailable examples:")
|
||||||
|
print(" all - Run all examples")
|
||||||
|
for name in examples:
|
||||||
|
print(f" {name}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Starting Claude SDK Setting Sources Examples...")
|
||||||
|
print("=" * 50 + "\n")
|
||||||
|
asyncio.run(main())
|
||||||
44
examples/stderr_callback_example.py
Normal file
44
examples/stderr_callback_example.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Simple example demonstrating stderr callback for capturing CLI debug output."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeAgentOptions, query
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Capture stderr output from the CLI using a callback."""
|
||||||
|
|
||||||
|
# Collect stderr messages
|
||||||
|
stderr_messages = []
|
||||||
|
|
||||||
|
def stderr_callback(message: str):
|
||||||
|
"""Callback that receives each line of stderr output."""
|
||||||
|
stderr_messages.append(message)
|
||||||
|
# Optionally print specific messages
|
||||||
|
if "[ERROR]" in message:
|
||||||
|
print(f"Error detected: {message}")
|
||||||
|
|
||||||
|
# Create options with stderr callback and enable debug mode
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
stderr=stderr_callback,
|
||||||
|
extra_args={"debug-to-stderr": None} # Enable debug output
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run a query
|
||||||
|
print("Running query with stderr capture...")
|
||||||
|
async for message in query(
|
||||||
|
prompt="What is 2+2?",
|
||||||
|
options=options
|
||||||
|
):
|
||||||
|
if hasattr(message, 'content'):
|
||||||
|
if isinstance(message.content, str):
|
||||||
|
print(f"Response: {message.content}")
|
||||||
|
|
||||||
|
# Show what we captured
|
||||||
|
print(f"\nCaptured {len(stderr_messages)} stderr lines")
|
||||||
|
if stderr_messages:
|
||||||
|
print("First stderr line:", stderr_messages[0][:100])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -19,9 +19,9 @@ import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
ClaudeSDKClient,
|
ClaudeSDKClient,
|
||||||
CLIConnectionError,
|
CLIConnectionError,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
|
|
@ -211,16 +211,15 @@ async def example_manual_message_handling():
|
||||||
|
|
||||||
|
|
||||||
async def example_with_options():
|
async def example_with_options():
|
||||||
"""Use ClaudeCodeOptions to configure the client."""
|
"""Use ClaudeAgentOptions to configure the client."""
|
||||||
print("=== Custom Options Example ===")
|
print("=== Custom Options Example ===")
|
||||||
|
|
||||||
# Configure options
|
# Configure options
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
allowed_tools=["Read", "Write"], # Allow file operations
|
allowed_tools=["Read", "Write"], # Allow file operations
|
||||||
max_thinking_tokens=10000,
|
|
||||||
system_prompt="You are a helpful coding assistant.",
|
system_prompt="You are a helpful coding assistant.",
|
||||||
env={
|
env={
|
||||||
"ANTHROPIC_MODEL": "claude-3-7-sonnet-20250219",
|
"ANTHROPIC_MODEL": "claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ bash commands, edit files, search the web, fetch web content) to accomplish.
|
||||||
# BASIC STREAMING
|
# BASIC STREAMING
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeSDKClient, ResultMessage, TextBlock
|
from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, ResultMessage, TextBlock
|
||||||
|
|
||||||
async with ClaudeSDKClient() as client:
|
async with ClaudeSDKClient() as client:
|
||||||
print("User: What is 2+2?")
|
print("User: What is 2+2?")
|
||||||
|
|
@ -33,7 +33,7 @@ async with ClaudeSDKClient() as client:
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
||||||
|
|
||||||
async with ClaudeSDKClient() as client:
|
async with ClaudeSDKClient() as client:
|
||||||
async def send_and_receive(prompt):
|
async def send_and_receive(prompt):
|
||||||
|
|
@ -54,7 +54,7 @@ async with ClaudeSDKClient() as client:
|
||||||
# PERSISTENT CLIENT FOR MULTIPLE QUESTIONS
|
# PERSISTENT CLIENT FOR MULTIPLE QUESTIONS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
||||||
|
|
||||||
# Create client
|
# Create client
|
||||||
client = ClaudeSDKClient()
|
client = ClaudeSDKClient()
|
||||||
|
|
@ -89,7 +89,7 @@ await client.disconnect()
|
||||||
# IMPORTANT: Interrupts require active message consumption. You must be
|
# IMPORTANT: Interrupts require active message consumption. You must be
|
||||||
# consuming messages from the client for the interrupt to be processed.
|
# consuming messages from the client for the interrupt to be processed.
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
||||||
|
|
||||||
async with ClaudeSDKClient() as client:
|
async with ClaudeSDKClient() as client:
|
||||||
print("\n--- Sending initial message ---\n")
|
print("\n--- Sending initial message ---\n")
|
||||||
|
|
@ -141,7 +141,7 @@ async with ClaudeSDKClient() as client:
|
||||||
# ERROR HANDLING PATTERN
|
# ERROR HANDLING PATTERN
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with ClaudeSDKClient() as client:
|
async with ClaudeSDKClient() as client:
|
||||||
|
|
@ -168,7 +168,7 @@ except Exception as e:
|
||||||
# SENDING ASYNC ITERABLE OF MESSAGES
|
# SENDING ASYNC ITERABLE OF MESSAGES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
||||||
|
|
||||||
|
|
||||||
async def message_generator():
|
async def message_generator():
|
||||||
|
|
@ -210,7 +210,7 @@ async with ClaudeSDKClient() as client:
|
||||||
# COLLECTING ALL MESSAGES INTO A LIST
|
# COLLECTING ALL MESSAGES INTO A LIST
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock
|
||||||
|
|
||||||
async with ClaudeSDKClient() as client:
|
async with ClaudeSDKClient() as client:
|
||||||
print("User: What are the primary colors?")
|
print("User: What are the primary colors?")
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ Claude's responses.
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
ClaudeSDKClient,
|
ClaudeSDKClient,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
|
|
@ -46,7 +46,7 @@ def display_message(msg):
|
||||||
async def multi_turn_conversation():
|
async def multi_turn_conversation():
|
||||||
"""Example of a multi-turn conversation using trio."""
|
"""Example of a multi-turn conversation using trio."""
|
||||||
async with ClaudeSDKClient(
|
async with ClaudeSDKClient(
|
||||||
options=ClaudeCodeOptions(model="claude-3-5-sonnet-20241022")
|
options=ClaudeAgentOptions(model="claude-sonnet-4-5")
|
||||||
) as client:
|
) as client:
|
||||||
print("=== Multi-turn Conversation with Trio ===\n")
|
print("=== Multi-turn Conversation with Trio ===\n")
|
||||||
|
|
||||||
|
|
|
||||||
87
examples/system_prompt.py
Normal file
87
examples/system_prompt.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example demonstrating different system_prompt configurations."""
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
TextBlock,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def no_system_prompt():
|
||||||
|
"""Example with no system_prompt (vanilla Claude)."""
|
||||||
|
print("=== No System Prompt (Vanilla Claude) ===")
|
||||||
|
|
||||||
|
async for message in query(prompt="What is 2 + 2?"):
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"Claude: {block.text}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def string_system_prompt():
|
||||||
|
"""Example with system_prompt as a string."""
|
||||||
|
print("=== String System Prompt ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
system_prompt="You are a pirate assistant. Respond in pirate speak.",
|
||||||
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def preset_system_prompt():
|
||||||
|
"""Example with system_prompt preset (uses default Claude Code prompt)."""
|
||||||
|
print("=== Preset System Prompt (Default) ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
system_prompt={"type": "preset", "preset": "claude_code"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def preset_with_append():
|
||||||
|
"""Example with system_prompt preset and append."""
|
||||||
|
print("=== Preset System Prompt with Append ===")
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
system_prompt={
|
||||||
|
"type": "preset",
|
||||||
|
"preset": "claude_code",
|
||||||
|
"append": "Always end your response with a fun fact.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all examples."""
|
||||||
|
await no_system_prompt()
|
||||||
|
await string_system_prompt()
|
||||||
|
await preset_system_prompt()
|
||||||
|
await preset_with_append()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
anyio.run(main)
|
||||||
158
examples/tool_permission_callback.py
Normal file
158
examples/tool_permission_callback.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example: Tool Permission Callbacks.
|
||||||
|
|
||||||
|
This example demonstrates how to use tool permission callbacks to control
|
||||||
|
which tools Claude can use and modify their inputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
PermissionResultAllow,
|
||||||
|
PermissionResultDeny,
|
||||||
|
ResultMessage,
|
||||||
|
TextBlock,
|
||||||
|
ToolPermissionContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track tool usage for demonstration
|
||||||
|
tool_usage_log = []
|
||||||
|
|
||||||
|
|
||||||
|
async def my_permission_callback(
|
||||||
|
tool_name: str,
|
||||||
|
input_data: dict,
|
||||||
|
context: ToolPermissionContext
|
||||||
|
) -> PermissionResultAllow | PermissionResultDeny:
|
||||||
|
"""Control tool permissions based on tool type and input."""
|
||||||
|
|
||||||
|
# Log the tool request
|
||||||
|
tool_usage_log.append({
|
||||||
|
"tool": tool_name,
|
||||||
|
"input": input_data,
|
||||||
|
"suggestions": context.suggestions
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"\n🔧 Tool Permission Request: {tool_name}")
|
||||||
|
print(f" Input: {json.dumps(input_data, indent=2)}")
|
||||||
|
|
||||||
|
# Always allow read operations
|
||||||
|
if tool_name in ["Read", "Glob", "Grep"]:
|
||||||
|
print(f" ✅ Automatically allowing {tool_name} (read-only operation)")
|
||||||
|
return PermissionResultAllow()
|
||||||
|
|
||||||
|
# Deny write operations to system directories
|
||||||
|
if tool_name in ["Write", "Edit", "MultiEdit"]:
|
||||||
|
file_path = input_data.get("file_path", "")
|
||||||
|
if file_path.startswith("/etc/") or file_path.startswith("/usr/"):
|
||||||
|
print(f" ❌ Denying write to system directory: {file_path}")
|
||||||
|
return PermissionResultDeny(
|
||||||
|
message=f"Cannot write to system directory: {file_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect writes to a safe directory
|
||||||
|
if not file_path.startswith("/tmp/") and not file_path.startswith("./"):
|
||||||
|
safe_path = f"./safe_output/{file_path.split('/')[-1]}"
|
||||||
|
print(f" ⚠️ Redirecting write from {file_path} to {safe_path}")
|
||||||
|
modified_input = input_data.copy()
|
||||||
|
modified_input["file_path"] = safe_path
|
||||||
|
return PermissionResultAllow(
|
||||||
|
updated_input=modified_input
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check dangerous bash commands
|
||||||
|
if tool_name == "Bash":
|
||||||
|
command = input_data.get("command", "")
|
||||||
|
dangerous_commands = ["rm -rf", "sudo", "chmod 777", "dd if=", "mkfs"]
|
||||||
|
|
||||||
|
for dangerous in dangerous_commands:
|
||||||
|
if dangerous in command:
|
||||||
|
print(f" ❌ Denying dangerous command: {command}")
|
||||||
|
return PermissionResultDeny(
|
||||||
|
message=f"Dangerous command pattern detected: {dangerous}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allow but log the command
|
||||||
|
print(f" ✅ Allowing bash command: {command}")
|
||||||
|
return PermissionResultAllow()
|
||||||
|
|
||||||
|
# For all other tools, ask the user
|
||||||
|
print(f" ❓ Unknown tool: {tool_name}")
|
||||||
|
print(f" Input: {json.dumps(input_data, indent=6)}")
|
||||||
|
user_input = input(" Allow this tool? (y/N): ").strip().lower()
|
||||||
|
|
||||||
|
if user_input in ("y", "yes"):
|
||||||
|
return PermissionResultAllow()
|
||||||
|
else:
|
||||||
|
return PermissionResultDeny(
|
||||||
|
message="User denied permission"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run example with tool permission callbacks."""
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Tool Permission Callback Example")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\nThis example demonstrates how to:")
|
||||||
|
print("1. Allow/deny tools based on type")
|
||||||
|
print("2. Modify tool inputs for safety")
|
||||||
|
print("3. Log tool usage")
|
||||||
|
print("4. Prompt for unknown tools")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Configure options with our callback
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
can_use_tool=my_permission_callback,
|
||||||
|
# Use default permission mode to ensure callbacks are invoked
|
||||||
|
permission_mode="default",
|
||||||
|
cwd="." # Set working directory
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create client and send a query that will use multiple tools
|
||||||
|
async with ClaudeSDKClient(options) as client:
|
||||||
|
print("\n📝 Sending query to Claude...")
|
||||||
|
await client.query(
|
||||||
|
"Please do the following:\n"
|
||||||
|
"1. List the files in the current directory\n"
|
||||||
|
"2. Create a simple Python hello world script at hello.py\n"
|
||||||
|
"3. Run the script to test it"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n📨 Receiving response...")
|
||||||
|
message_count = 0
|
||||||
|
|
||||||
|
async for message in client.receive_response():
|
||||||
|
message_count += 1
|
||||||
|
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
# Print Claude's text responses
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"\n💬 Claude: {block.text}")
|
||||||
|
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
print("\n✅ Task completed!")
|
||||||
|
print(f" Duration: {message.duration_ms}ms")
|
||||||
|
if message.total_cost_usd:
|
||||||
|
print(f" Cost: ${message.total_cost_usd:.4f}")
|
||||||
|
print(f" Messages processed: {message_count}")
|
||||||
|
|
||||||
|
# Print tool usage summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Tool Usage Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
for i, usage in enumerate(tool_usage_log, 1):
|
||||||
|
print(f"\n{i}. Tool: {usage['tool']}")
|
||||||
|
print(f" Input: {json.dumps(usage['input'], indent=6)}")
|
||||||
|
if usage['suggestions']:
|
||||||
|
print(f" Suggestions: {usage['suggestions']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
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)
|
||||||
|
|
@ -3,8 +3,8 @@ requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "claude-code-sdk"
|
name = "claude-agent-sdk"
|
||||||
version = "0.0.21"
|
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"
|
||||||
|
|
@ -41,12 +41,13 @@ dev = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/anthropics/claude-code-sdk-python"
|
Homepage = "https://github.com/anthropics/claude-agent-sdk-python"
|
||||||
Documentation = "https://docs.anthropic.com/en/docs/claude-code/sdk"
|
Documentation = "https://docs.anthropic.com/en/docs/claude-code/sdk"
|
||||||
Issues = "https://github.com/anthropics/claude-code-sdk-python/issues"
|
Issues = "https://github.com/anthropics/claude-agent-sdk-python/issues"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/claude_code_sdk"]
|
packages = ["src/claude_agent_sdk"]
|
||||||
|
only-include = ["src/claude_agent_sdk"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
include = [
|
include = [
|
||||||
|
|
@ -105,4 +106,4 @@ ignore = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["claude_code_sdk"]
|
known-first-party = ["claude_agent_sdk"]
|
||||||
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()
|
||||||
22
scripts/initial-setup.sh
Executable file
22
scripts/initial-setup.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Initial setup script for installing git hooks
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
echo "Setting up git hooks..."
|
||||||
|
|
||||||
|
# Install pre-push hook
|
||||||
|
echo "→ Installing pre-push hook..."
|
||||||
|
cp "$SCRIPT_DIR/pre-push" "$REPO_ROOT/.git/hooks/pre-push"
|
||||||
|
chmod +x "$REPO_ROOT/.git/hooks/pre-push"
|
||||||
|
echo "✓ pre-push hook installed"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "The pre-push hook will now run lint checks before each push."
|
||||||
|
echo "To skip the hook temporarily, use: git push --no-verify"
|
||||||
30
scripts/pre-push
Executable file
30
scripts/pre-push
Executable file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Pre-push hook to run lint checks (matches .github/workflows/lint.yml)
|
||||||
|
|
||||||
|
echo "Running lint checks before push..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run ruff check
|
||||||
|
echo "→ Running ruff check..."
|
||||||
|
python -m ruff check src/ tests/
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ruff check failed. Fix lint issues before pushing."
|
||||||
|
echo " Run: python -m ruff check src/ tests/ --fix"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run ruff format check
|
||||||
|
echo "→ Running ruff format check..."
|
||||||
|
python -m ruff format --check src/ tests/
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ruff format check failed. Fix formatting before pushing."
|
||||||
|
echo " Run: python -m ruff format src/ tests/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ All lint checks passed!"
|
||||||
|
exit 0
|
||||||
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,15 +18,15 @@ 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)
|
||||||
print(f"Updated pyproject.toml to version {new_version}")
|
print(f"Updated pyproject.toml to version {new_version}")
|
||||||
|
|
||||||
# Update __init__.py
|
# Update _version.py
|
||||||
init_path = Path("src/claude_code_sdk/__init__.py")
|
version_path = Path("src/claude_agent_sdk/_version.py")
|
||||||
content = init_path.read_text()
|
content = version_path.read_text()
|
||||||
|
|
||||||
# Only update __version__ assignment
|
# Only update __version__ assignment
|
||||||
content = re.sub(
|
content = re.sub(
|
||||||
|
|
@ -34,11 +34,11 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
init_path.write_text(content)
|
version_path.write_text(content)
|
||||||
print(f"Updated __init__.py to version {new_version}")
|
print(f"Updated _version.py to version {new_version}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,21 @@ from ._errors import (
|
||||||
ProcessError,
|
ProcessError,
|
||||||
)
|
)
|
||||||
from ._internal.transport import Transport
|
from ._internal.transport import Transport
|
||||||
|
from ._version import __version__
|
||||||
from .client import ClaudeSDKClient
|
from .client import ClaudeSDKClient
|
||||||
from .query import query
|
from .query import query
|
||||||
from .types import (
|
from .types import (
|
||||||
|
AgentDefinition,
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ClaudeCodeOptions,
|
BaseHookInput,
|
||||||
|
CanUseTool,
|
||||||
|
ClaudeAgentOptions,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
|
HookCallback,
|
||||||
|
HookContext,
|
||||||
|
HookInput,
|
||||||
|
HookJSONOutput,
|
||||||
|
HookMatcher,
|
||||||
McpSdkServerConfig,
|
McpSdkServerConfig,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
Message,
|
Message,
|
||||||
|
|
@ -26,13 +35,26 @@ from .types import (
|
||||||
PermissionResultAllow,
|
PermissionResultAllow,
|
||||||
PermissionResultDeny,
|
PermissionResultDeny,
|
||||||
PermissionUpdate,
|
PermissionUpdate,
|
||||||
|
PostToolUseHookInput,
|
||||||
|
PreCompactHookInput,
|
||||||
|
PreToolUseHookInput,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
|
SandboxIgnoreViolations,
|
||||||
|
SandboxNetworkConfig,
|
||||||
|
SandboxSettings,
|
||||||
|
SdkBeta,
|
||||||
|
SdkPluginConfig,
|
||||||
|
SettingSource,
|
||||||
|
StopHookInput,
|
||||||
|
SubagentStopHookInput,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
|
ToolPermissionContext,
|
||||||
ToolResultBlock,
|
ToolResultBlock,
|
||||||
ToolUseBlock,
|
ToolUseBlock,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
|
UserPromptSubmitHookInput,
|
||||||
)
|
)
|
||||||
|
|
||||||
# MCP Server Support
|
# MCP Server Support
|
||||||
|
|
@ -136,7 +158,7 @@ def create_sdk_mcp_server(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
McpSdkServerConfig: A configuration object that can be passed to
|
McpSdkServerConfig: A configuration object that can be passed to
|
||||||
ClaudeCodeOptions.mcp_servers. This config contains the server
|
ClaudeAgentOptions.mcp_servers. This config contains the server
|
||||||
instance and metadata needed for the SDK to route tool calls.
|
instance and metadata needed for the SDK to route tool calls.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
@ -156,7 +178,7 @@ def create_sdk_mcp_server(
|
||||||
... )
|
... )
|
||||||
>>>
|
>>>
|
||||||
>>> # Use with Claude
|
>>> # Use with Claude
|
||||||
>>> options = ClaudeCodeOptions(
|
>>> options = ClaudeAgentOptions(
|
||||||
... mcp_servers={"calc": calculator},
|
... mcp_servers={"calc": calculator},
|
||||||
... allowed_tools=["add", "multiply"]
|
... allowed_tools=["add", "multiply"]
|
||||||
... )
|
... )
|
||||||
|
|
@ -183,10 +205,10 @@ def create_sdk_mcp_server(
|
||||||
|
|
||||||
See Also:
|
See Also:
|
||||||
- tool(): Decorator for creating tool functions
|
- tool(): Decorator for creating tool functions
|
||||||
- ClaudeCodeOptions: Configuration for using servers with query()
|
- ClaudeAgentOptions: Configuration for using servers with query()
|
||||||
"""
|
"""
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.types import TextContent, Tool
|
from mcp.types import ImageContent, TextContent, Tool
|
||||||
|
|
||||||
# Create MCP server instance
|
# Create MCP server instance
|
||||||
server = Server(name, version=version)
|
server = Server(name, version=version)
|
||||||
|
|
@ -197,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 = []
|
||||||
|
|
@ -243,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:
|
||||||
|
|
@ -256,11 +278,19 @@ def create_sdk_mcp_server(
|
||||||
# Convert result to MCP format
|
# Convert result to MCP format
|
||||||
# The decorator expects us to return the content, not a CallToolResult
|
# The decorator expects us to return the content, not a CallToolResult
|
||||||
# It will wrap our return value in CallToolResult
|
# It will wrap our return value in CallToolResult
|
||||||
content = []
|
content: list[TextContent | ImageContent] = []
|
||||||
if "content" in result:
|
if "content" in result:
|
||||||
for item in result["content"]:
|
for item in result["content"]:
|
||||||
if item.get("type") == "text":
|
if item.get("type") == "text":
|
||||||
content.append(TextContent(type="text", text=item["text"]))
|
content.append(TextContent(type="text", text=item["text"]))
|
||||||
|
if item.get("type") == "image":
|
||||||
|
content.append(
|
||||||
|
ImageContent(
|
||||||
|
type="image",
|
||||||
|
data=item["data"],
|
||||||
|
mimeType=item["mimeType"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Return just the content list - the decorator wraps it
|
# Return just the content list - the decorator wraps it
|
||||||
return content
|
return content
|
||||||
|
|
@ -269,11 +299,10 @@ def create_sdk_mcp_server(
|
||||||
return McpSdkServerConfig(type="sdk", name=name, instance=server)
|
return McpSdkServerConfig(type="sdk", name=name, instance=server)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.0.21"
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Main exports
|
# Main exports
|
||||||
"query",
|
"query",
|
||||||
|
"__version__",
|
||||||
# Transport
|
# Transport
|
||||||
"Transport",
|
"Transport",
|
||||||
"ClaudeSDKClient",
|
"ClaudeSDKClient",
|
||||||
|
|
@ -286,17 +315,47 @@ __all__ = [
|
||||||
"SystemMessage",
|
"SystemMessage",
|
||||||
"ResultMessage",
|
"ResultMessage",
|
||||||
"Message",
|
"Message",
|
||||||
"ClaudeCodeOptions",
|
"ClaudeAgentOptions",
|
||||||
"TextBlock",
|
"TextBlock",
|
||||||
"ThinkingBlock",
|
"ThinkingBlock",
|
||||||
"ToolUseBlock",
|
"ToolUseBlock",
|
||||||
"ToolResultBlock",
|
"ToolResultBlock",
|
||||||
"ContentBlock",
|
"ContentBlock",
|
||||||
# Permission results (keep these as they may be used by internal callbacks)
|
# Tool callbacks
|
||||||
|
"CanUseTool",
|
||||||
|
"ToolPermissionContext",
|
||||||
"PermissionResult",
|
"PermissionResult",
|
||||||
"PermissionResultAllow",
|
"PermissionResultAllow",
|
||||||
"PermissionResultDeny",
|
"PermissionResultDeny",
|
||||||
"PermissionUpdate",
|
"PermissionUpdate",
|
||||||
|
# Hook support
|
||||||
|
"HookCallback",
|
||||||
|
"HookContext",
|
||||||
|
"HookInput",
|
||||||
|
"BaseHookInput",
|
||||||
|
"PreToolUseHookInput",
|
||||||
|
"PostToolUseHookInput",
|
||||||
|
"UserPromptSubmitHookInput",
|
||||||
|
"StopHookInput",
|
||||||
|
"SubagentStopHookInput",
|
||||||
|
"PreCompactHookInput",
|
||||||
|
"HookJSONOutput",
|
||||||
|
"HookMatcher",
|
||||||
|
# Agent support
|
||||||
|
"AgentDefinition",
|
||||||
|
"SettingSource",
|
||||||
|
# Plugin support
|
||||||
|
"SdkPluginConfig",
|
||||||
|
# Beta support
|
||||||
|
"SdkBeta",
|
||||||
|
# Sandbox support
|
||||||
|
"SandboxSettings",
|
||||||
|
"SandboxNetworkConfig",
|
||||||
|
"SandboxIgnoreViolations",
|
||||||
|
# MCP Server Support
|
||||||
|
"create_sdk_mcp_server",
|
||||||
|
"tool",
|
||||||
|
"SdkMcpTool",
|
||||||
# Errors
|
# Errors
|
||||||
"ClaudeSDKError",
|
"ClaudeSDKError",
|
||||||
"CLIConnectionError",
|
"CLIConnectionError",
|
||||||
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"
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"""Internal client implementation."""
|
"""Internal client implementation."""
|
||||||
|
|
||||||
from collections.abc import AsyncIterable, AsyncIterator
|
from collections.abc import AsyncIterable, AsyncIterator
|
||||||
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..types import (
|
from ..types import (
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
|
HookEvent,
|
||||||
|
HookMatcher,
|
||||||
Message,
|
Message,
|
||||||
)
|
)
|
||||||
from .message_parser import parse_message
|
from .message_parser import parse_message
|
||||||
|
|
@ -20,7 +23,7 @@ class InternalClient:
|
||||||
"""Initialize the internal client."""
|
"""Initialize the internal client."""
|
||||||
|
|
||||||
def _convert_hooks_to_internal_format(
|
def _convert_hooks_to_internal_format(
|
||||||
self, hooks: dict[str, list[Any]]
|
self, hooks: dict[HookEvent, list[HookMatcher]]
|
||||||
) -> dict[str, list[dict[str, Any]]]:
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
"""Convert HookMatcher format to internal Query format."""
|
"""Convert HookMatcher format to internal Query format."""
|
||||||
internal_hooks: dict[str, list[dict[str, Any]]] = {}
|
internal_hooks: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
@ -28,34 +31,61 @@ 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
|
||||||
|
|
||||||
async def process_query(
|
async def process_query(
|
||||||
self,
|
self,
|
||||||
prompt: str | AsyncIterable[dict[str, Any]],
|
prompt: str | AsyncIterable[dict[str, Any]],
|
||||||
options: ClaudeCodeOptions,
|
options: ClaudeAgentOptions,
|
||||||
transport: Transport | None = None,
|
transport: Transport | None = None,
|
||||||
) -> AsyncIterator[Message]:
|
) -> AsyncIterator[Message]:
|
||||||
"""Process a query through transport and Query."""
|
"""Process a query through transport and Query."""
|
||||||
|
|
||||||
|
# Validate and configure permission settings (matching TypeScript SDK logic)
|
||||||
|
configured_options = options
|
||||||
|
if options.can_use_tool:
|
||||||
|
# canUseTool callback requires streaming mode (AsyncIterable prompt)
|
||||||
|
if isinstance(prompt, str):
|
||||||
|
raise ValueError(
|
||||||
|
"can_use_tool callback requires streaming mode. "
|
||||||
|
"Please provide prompt as an AsyncIterable instead of a string."
|
||||||
|
)
|
||||||
|
|
||||||
|
# canUseTool and permission_prompt_tool_name are mutually exclusive
|
||||||
|
if options.permission_prompt_tool_name:
|
||||||
|
raise ValueError(
|
||||||
|
"can_use_tool callback cannot be used with permission_prompt_tool_name. "
|
||||||
|
"Please use one or the other."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Automatically set permission_prompt_tool_name to "stdio" for control protocol
|
||||||
|
configured_options = replace(options, permission_prompt_tool_name="stdio")
|
||||||
|
|
||||||
# Use provided transport or create subprocess transport
|
# Use provided transport or create subprocess transport
|
||||||
if transport is not None:
|
if transport is not None:
|
||||||
chosen_transport = transport
|
chosen_transport = transport
|
||||||
else:
|
else:
|
||||||
chosen_transport = SubprocessCLITransport(prompt=prompt, options=options)
|
chosen_transport = SubprocessCLITransport(
|
||||||
|
prompt=prompt,
|
||||||
|
options=configured_options,
|
||||||
|
)
|
||||||
|
|
||||||
# Connect transport
|
# Connect transport
|
||||||
await chosen_transport.connect()
|
await chosen_transport.connect()
|
||||||
|
|
||||||
# Extract SDK MCP servers from options
|
# Extract SDK MCP servers from configured options
|
||||||
sdk_mcp_servers = {}
|
sdk_mcp_servers = {}
|
||||||
if options.mcp_servers and isinstance(options.mcp_servers, dict):
|
if configured_options.mcp_servers and isinstance(
|
||||||
for name, config in options.mcp_servers.items():
|
configured_options.mcp_servers, dict
|
||||||
|
):
|
||||||
|
for name, config in configured_options.mcp_servers.items():
|
||||||
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]
|
||||||
|
|
||||||
|
|
@ -64,9 +94,9 @@ class InternalClient:
|
||||||
query = Query(
|
query = Query(
|
||||||
transport=chosen_transport,
|
transport=chosen_transport,
|
||||||
is_streaming_mode=is_streaming,
|
is_streaming_mode=is_streaming,
|
||||||
can_use_tool=options.can_use_tool,
|
can_use_tool=configured_options.can_use_tool,
|
||||||
hooks=self._convert_hooks_to_internal_format(options.hooks)
|
hooks=self._convert_hooks_to_internal_format(configured_options.hooks)
|
||||||
if options.hooks
|
if configured_options.hooks
|
||||||
else None,
|
else None,
|
||||||
sdk_mcp_servers=sdk_mcp_servers,
|
sdk_mcp_servers=sdk_mcp_servers,
|
||||||
)
|
)
|
||||||
|
|
@ -9,6 +9,7 @@ from ..types import (
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
Message,
|
Message,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
|
StreamEvent,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
|
|
@ -46,6 +47,8 @@ def parse_message(data: dict[str, Any]) -> Message:
|
||||||
match message_type:
|
match message_type:
|
||||||
case "user":
|
case "user":
|
||||||
try:
|
try:
|
||||||
|
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"]:
|
||||||
|
|
@ -70,8 +73,16 @@ def parse_message(data: dict[str, Any]) -> Message:
|
||||||
is_error=block.get("is_error"),
|
is_error=block.get("is_error"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return UserMessage(content=user_content_blocks)
|
return UserMessage(
|
||||||
return UserMessage(content=data["message"]["content"])
|
content=user_content_blocks,
|
||||||
|
uuid=uuid,
|
||||||
|
parent_tool_use_id=parent_tool_use_id,
|
||||||
|
)
|
||||||
|
return UserMessage(
|
||||||
|
content=data["message"]["content"],
|
||||||
|
uuid=uuid,
|
||||||
|
parent_tool_use_id=parent_tool_use_id,
|
||||||
|
)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise MessageParseError(
|
raise MessageParseError(
|
||||||
f"Missing required field in user message: {e}", data
|
f"Missing required field in user message: {e}", data
|
||||||
|
|
@ -109,7 +120,10 @@ def parse_message(data: dict[str, Any]) -> Message:
|
||||||
)
|
)
|
||||||
|
|
||||||
return AssistantMessage(
|
return AssistantMessage(
|
||||||
content=content_blocks, model=data["message"]["model"]
|
content=content_blocks,
|
||||||
|
model=data["message"]["model"],
|
||||||
|
parent_tool_use_id=data.get("parent_tool_use_id"),
|
||||||
|
error=data["message"].get("error"),
|
||||||
)
|
)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise MessageParseError(
|
raise MessageParseError(
|
||||||
|
|
@ -139,11 +153,25 @@ 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(
|
||||||
f"Missing required field in result message: {e}", data
|
f"Missing required field in result message: {e}", data
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
case "stream_event":
|
||||||
|
try:
|
||||||
|
return StreamEvent(
|
||||||
|
uuid=data["uuid"],
|
||||||
|
session_id=data["session_id"],
|
||||||
|
event=data["event"],
|
||||||
|
parent_tool_use_id=data.get("parent_tool_use_id"),
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
raise MessageParseError(
|
||||||
|
f"Missing required field in stream_event message: {e}", data
|
||||||
|
) from e
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
raise MessageParseError(f"Unknown message type: {message_type}", data)
|
raise MessageParseError(f"Unknown message type: {message_type}", data)
|
||||||
|
|
@ -31,6 +31,25 @@ if TYPE_CHECKING:
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_hook_output_for_cli(hook_output: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Convert Python-safe field names to CLI-expected field names.
|
||||||
|
|
||||||
|
The Python SDK uses `async_` and `continue_` to avoid keyword conflicts,
|
||||||
|
but the CLI expects `async` and `continue`. This function performs the
|
||||||
|
necessary conversion.
|
||||||
|
"""
|
||||||
|
converted = {}
|
||||||
|
for key, value in hook_output.items():
|
||||||
|
# Convert Python-safe names to JavaScript names
|
||||||
|
if key == "async_":
|
||||||
|
converted["async"] = value
|
||||||
|
elif key == "continue_":
|
||||||
|
converted["continue"] = value
|
||||||
|
else:
|
||||||
|
converted[key] = value
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
class Query:
|
class Query:
|
||||||
"""Handles bidirectional control protocol on top of Transport.
|
"""Handles bidirectional control protocol on top of Transport.
|
||||||
|
|
||||||
|
|
@ -53,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.
|
||||||
|
|
||||||
|
|
@ -62,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
|
||||||
|
|
@ -85,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.
|
||||||
|
|
||||||
|
|
@ -107,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 = {
|
||||||
|
|
@ -120,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
|
||||||
|
|
@ -169,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)
|
||||||
|
|
||||||
|
|
@ -178,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:
|
||||||
|
|
@ -195,6 +236,7 @@ class Query:
|
||||||
|
|
||||||
if subtype == "can_use_tool":
|
if subtype == "can_use_tool":
|
||||||
permission_request: SDKControlPermissionRequest = request_data # type: ignore[assignment]
|
permission_request: SDKControlPermissionRequest = request_data # type: ignore[assignment]
|
||||||
|
original_input = permission_request["input"]
|
||||||
# Handle tool permission request
|
# Handle tool permission request
|
||||||
if not self.can_use_tool:
|
if not self.can_use_tool:
|
||||||
raise Exception("canUseTool callback is not provided")
|
raise Exception("canUseTool callback is not provided")
|
||||||
|
|
@ -213,13 +255,23 @@ class Query:
|
||||||
|
|
||||||
# Convert PermissionResult to expected dict format
|
# Convert PermissionResult to expected dict format
|
||||||
if isinstance(response, PermissionResultAllow):
|
if isinstance(response, PermissionResultAllow):
|
||||||
response_data = {"allow": True}
|
response_data = {
|
||||||
if response.updated_input is not None:
|
"behavior": "allow",
|
||||||
response_data["input"] = response.updated_input
|
"updatedInput": (
|
||||||
# TODO: Handle updatedPermissions when control protocol supports it
|
response.updated_input
|
||||||
|
if response.updated_input is not None
|
||||||
|
else original_input
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if response.updated_permissions is not None:
|
||||||
|
response_data["updatedPermissions"] = [
|
||||||
|
permission.to_dict()
|
||||||
|
for permission in response.updated_permissions
|
||||||
|
]
|
||||||
elif isinstance(response, PermissionResultDeny):
|
elif isinstance(response, PermissionResultDeny):
|
||||||
response_data = {"allow": False, "reason": response.message}
|
response_data = {"behavior": "deny", "message": response.message}
|
||||||
# TODO: Handle interrupt flag when control protocol supports it
|
if response.interrupt:
|
||||||
|
response_data["interrupt"] = response.interrupt
|
||||||
else:
|
else:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}"
|
f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}"
|
||||||
|
|
@ -233,11 +285,13 @@ class Query:
|
||||||
if not callback:
|
if not callback:
|
||||||
raise Exception(f"No hook callback found for ID: {callback_id}")
|
raise Exception(f"No hook callback found for ID: {callback_id}")
|
||||||
|
|
||||||
response_data = await callback(
|
hook_output = await callback(
|
||||||
request_data.get("input"),
|
request_data.get("input"),
|
||||||
request_data.get("tool_use_id"),
|
request_data.get("tool_use_id"),
|
||||||
{"signal": None}, # TODO: Add abort signal support
|
{"signal": None}, # TODO: Add abort signal support
|
||||||
)
|
)
|
||||||
|
# Convert Python-safe field names (async_, continue_) to CLI-expected names (async, continue)
|
||||||
|
response_data = _convert_hook_output_for_cli(hook_output)
|
||||||
|
|
||||||
elif subtype == "mcp_message":
|
elif subtype == "mcp_message":
|
||||||
# Handle SDK MCP request
|
# Handle SDK MCP request
|
||||||
|
|
@ -250,9 +304,11 @@ class Query:
|
||||||
# Type narrowing - we've verified these are not None above
|
# Type narrowing - we've verified these are not None above
|
||||||
assert isinstance(server_name, str)
|
assert isinstance(server_name, str)
|
||||||
assert isinstance(mcp_message, dict)
|
assert isinstance(mcp_message, dict)
|
||||||
response_data = await self._handle_sdk_mcp_request(
|
mcp_response = await self._handle_sdk_mcp_request(
|
||||||
server_name, mcp_message
|
server_name, mcp_message
|
||||||
)
|
)
|
||||||
|
# Wrap the MCP response as expected by the control protocol
|
||||||
|
response_data = {"mcp_response": mcp_response}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Unsupported control request subtype: {subtype}")
|
raise Exception(f"Unsupported control request subtype: {subtype}")
|
||||||
|
|
@ -280,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")
|
||||||
|
|
||||||
|
|
@ -304,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)
|
||||||
|
|
@ -357,7 +420,24 @@ class Query:
|
||||||
#
|
#
|
||||||
# This forces us to manually route methods. When Python MCP adds Transport
|
# This forces us to manually route methods. When Python MCP adds Transport
|
||||||
# support, we can refactor to match the TypeScript approach.
|
# support, we can refactor to match the TypeScript approach.
|
||||||
if method == "tools/list":
|
if method == "initialize":
|
||||||
|
# Handle MCP initialization - hardcoded for tools only, no listChanged
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message.get("id"),
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {} # Tools capability without listChanged
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": server.name,
|
||||||
|
"version": server.version or "1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "tools/list":
|
||||||
request = ListToolsRequest(method=method)
|
request = ListToolsRequest(method=method)
|
||||||
handler = server.request_handlers.get(ListToolsRequest)
|
handler = server.request_handlers.get(ListToolsRequest)
|
||||||
if handler:
|
if handler:
|
||||||
|
|
@ -367,7 +447,11 @@ class Query:
|
||||||
{
|
{
|
||||||
"name": tool.name,
|
"name": tool.name,
|
||||||
"description": tool.description,
|
"description": tool.description,
|
||||||
"inputSchema": tool.inputSchema.model_dump() # type: ignore[union-attr]
|
"inputSchema": (
|
||||||
|
tool.inputSchema.model_dump()
|
||||||
|
if hasattr(tool.inputSchema, "model_dump")
|
||||||
|
else tool.inputSchema
|
||||||
|
)
|
||||||
if tool.inputSchema
|
if tool.inputSchema
|
||||||
else {},
|
else {},
|
||||||
}
|
}
|
||||||
|
|
@ -413,6 +497,10 @@ class Query:
|
||||||
"result": response_data,
|
"result": response_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elif method == "notifications/initialized":
|
||||||
|
# Handle initialized notification - just acknowledge it
|
||||||
|
return {"jsonrpc": "2.0", "result": {}}
|
||||||
|
|
||||||
# Add more methods here as MCP SDK adds them (resources, prompts, etc.)
|
# Add more methods here as MCP SDK adds them (resources, prompts, etc.)
|
||||||
# This is the limitation Ashwin pointed out - we have to manually update
|
# This is the limitation Ashwin pointed out - we have to manually update
|
||||||
|
|
||||||
|
|
@ -442,14 +530,60 @@ class Query:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def set_model(self, model: str | None) -> None:
|
||||||
|
"""Change the AI model."""
|
||||||
|
await self._send_control_request(
|
||||||
|
{
|
||||||
|
"subtype": "set_model",
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
672
src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Normal file
672
src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Normal file
|
|
@ -0,0 +1,672 @@
|
||||||
|
"""Subprocess transport implementation using Claude Code CLI."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import AsyncIterable, AsyncIterator
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import PIPE
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import anyio.abc
|
||||||
|
from anyio.abc import Process
|
||||||
|
from anyio.streams.text import TextReceiveStream, TextSendStream
|
||||||
|
|
||||||
|
from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError
|
||||||
|
from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError
|
||||||
|
from ..._version import __version__
|
||||||
|
from ...types import ClaudeAgentOptions
|
||||||
|
from . import Transport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
||||||
|
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"
|
||||||
|
|
||||||
|
# Platform-specific command line length limits
|
||||||
|
# Windows cmd.exe has a limit of 8191 characters, use 8000 for safety
|
||||||
|
# Other platforms have much higher limits
|
||||||
|
_CMD_LENGTH_LIMIT = 8000 if platform.system() == "Windows" else 100000
|
||||||
|
|
||||||
|
|
||||||
|
class SubprocessCLITransport(Transport):
|
||||||
|
"""Subprocess transport using Claude Code CLI."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
prompt: str | AsyncIterable[dict[str, Any]],
|
||||||
|
options: ClaudeAgentOptions,
|
||||||
|
):
|
||||||
|
self._prompt = prompt
|
||||||
|
self._is_streaming = not isinstance(prompt, str)
|
||||||
|
self._options = options
|
||||||
|
self._cli_path = (
|
||||||
|
str(options.cli_path) if options.cli_path is not None else self._find_cli()
|
||||||
|
)
|
||||||
|
self._cwd = str(options.cwd) if options.cwd else None
|
||||||
|
self._process: Process | None = None
|
||||||
|
self._stdout_stream: TextReceiveStream | None = None
|
||||||
|
self._stdin_stream: TextSendStream | None = None
|
||||||
|
self._stderr_stream: TextReceiveStream | None = None
|
||||||
|
self._stderr_task_group: anyio.abc.TaskGroup | None = None
|
||||||
|
self._ready = False
|
||||||
|
self._exit_error: Exception | None = None # Track process exit errors
|
||||||
|
self._max_buffer_size = (
|
||||||
|
options.max_buffer_size
|
||||||
|
if options.max_buffer_size is not None
|
||||||
|
else _DEFAULT_MAX_BUFFER_SIZE
|
||||||
|
)
|
||||||
|
self._temp_files: list[str] = [] # Track temporary files for cleanup
|
||||||
|
self._write_lock: anyio.Lock = anyio.Lock()
|
||||||
|
|
||||||
|
def _find_cli(self) -> str:
|
||||||
|
"""Find Claude Code CLI binary."""
|
||||||
|
# First, check for bundled CLI
|
||||||
|
bundled_cli = self._find_bundled_cli()
|
||||||
|
if bundled_cli:
|
||||||
|
return bundled_cli
|
||||||
|
|
||||||
|
# Fall back to system-wide search
|
||||||
|
if cli := shutil.which("claude"):
|
||||||
|
return cli
|
||||||
|
|
||||||
|
locations = [
|
||||||
|
Path.home() / ".npm-global/bin/claude",
|
||||||
|
Path("/usr/local/bin/claude"),
|
||||||
|
Path.home() / ".local/bin/claude",
|
||||||
|
Path.home() / "node_modules/.bin/claude",
|
||||||
|
Path.home() / ".yarn/bin/claude",
|
||||||
|
Path.home() / ".claude/local/claude",
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in locations:
|
||||||
|
if path.exists() and path.is_file():
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
raise CLINotFoundError(
|
||||||
|
"Claude Code not found. Install with:\n"
|
||||||
|
" npm install -g @anthropic-ai/claude-code\n"
|
||||||
|
"\nIf already installed locally, try:\n"
|
||||||
|
' export PATH="$HOME/node_modules/.bin:$PATH"\n'
|
||||||
|
"\nOr provide the path via ClaudeAgentOptions:\n"
|
||||||
|
" ClaudeAgentOptions(cli_path='/path/to/claude')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_bundled_cli(self) -> str | None:
|
||||||
|
"""Find bundled CLI binary if it exists."""
|
||||||
|
# Determine the CLI binary name based on platform
|
||||||
|
cli_name = "claude.exe" if platform.system() == "Windows" else "claude"
|
||||||
|
|
||||||
|
# Get the path to the bundled CLI
|
||||||
|
# The _bundled directory is in the same package as this module
|
||||||
|
bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name
|
||||||
|
|
||||||
|
if bundled_path.exists() and bundled_path.is_file():
|
||||||
|
logger.info(f"Using bundled Claude Code CLI: {bundled_path}")
|
||||||
|
return str(bundled_path)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_settings_value(self) -> str | None:
|
||||||
|
"""Build settings value, merging sandbox settings if provided.
|
||||||
|
|
||||||
|
Returns the settings value as either:
|
||||||
|
- A JSON string (if sandbox is provided or settings is JSON)
|
||||||
|
- A file path (if only settings path is provided without sandbox)
|
||||||
|
- None if neither settings nor sandbox is provided
|
||||||
|
"""
|
||||||
|
has_settings = self._options.settings is not None
|
||||||
|
has_sandbox = self._options.sandbox is not None
|
||||||
|
|
||||||
|
if not has_settings and not has_sandbox:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If only settings path and no sandbox, pass through as-is
|
||||||
|
if has_settings and not has_sandbox:
|
||||||
|
return self._options.settings
|
||||||
|
|
||||||
|
# If we have sandbox settings, we need to merge into a JSON object
|
||||||
|
settings_obj: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if has_settings:
|
||||||
|
assert self._options.settings is not None
|
||||||
|
settings_str = self._options.settings.strip()
|
||||||
|
# Check if settings is a JSON string or a file path
|
||||||
|
if settings_str.startswith("{") and settings_str.endswith("}"):
|
||||||
|
# Parse JSON string
|
||||||
|
try:
|
||||||
|
settings_obj = json.loads(settings_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# If parsing fails, treat as file path
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to parse settings as JSON, treating as file path: {settings_str}"
|
||||||
|
)
|
||||||
|
# Read the file
|
||||||
|
settings_path = Path(settings_str)
|
||||||
|
if settings_path.exists():
|
||||||
|
with settings_path.open(encoding="utf-8") as f:
|
||||||
|
settings_obj = json.load(f)
|
||||||
|
else:
|
||||||
|
# It's a file path - read and parse
|
||||||
|
settings_path = Path(settings_str)
|
||||||
|
if settings_path.exists():
|
||||||
|
with settings_path.open(encoding="utf-8") as f:
|
||||||
|
settings_obj = json.load(f)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Settings file not found: {settings_path}")
|
||||||
|
|
||||||
|
# Merge sandbox settings
|
||||||
|
if has_sandbox:
|
||||||
|
settings_obj["sandbox"] = self._options.sandbox
|
||||||
|
|
||||||
|
return json.dumps(settings_obj)
|
||||||
|
|
||||||
|
def _build_command(self) -> list[str]:
|
||||||
|
"""Build CLI command with arguments."""
|
||||||
|
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
|
||||||
|
|
||||||
|
if self._options.system_prompt is None:
|
||||||
|
cmd.extend(["--system-prompt", ""])
|
||||||
|
elif isinstance(self._options.system_prompt, str):
|
||||||
|
cmd.extend(["--system-prompt", self._options.system_prompt])
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
self._options.system_prompt.get("type") == "preset"
|
||||||
|
and "append" in self._options.system_prompt
|
||||||
|
):
|
||||||
|
cmd.extend(
|
||||||
|
["--append-system-prompt", self._options.system_prompt["append"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle tools option (base set of tools)
|
||||||
|
if self._options.tools is not None:
|
||||||
|
tools = self._options.tools
|
||||||
|
if isinstance(tools, list):
|
||||||
|
if len(tools) == 0:
|
||||||
|
cmd.extend(["--tools", ""])
|
||||||
|
else:
|
||||||
|
cmd.extend(["--tools", ",".join(tools)])
|
||||||
|
else:
|
||||||
|
# Preset object - 'claude_code' preset maps to 'default'
|
||||||
|
cmd.extend(["--tools", "default"])
|
||||||
|
|
||||||
|
if self._options.allowed_tools:
|
||||||
|
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
|
||||||
|
|
||||||
|
if self._options.max_turns:
|
||||||
|
cmd.extend(["--max-turns", str(self._options.max_turns)])
|
||||||
|
|
||||||
|
if self._options.max_budget_usd is not None:
|
||||||
|
cmd.extend(["--max-budget-usd", str(self._options.max_budget_usd)])
|
||||||
|
|
||||||
|
if self._options.disallowed_tools:
|
||||||
|
cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])
|
||||||
|
|
||||||
|
if self._options.model:
|
||||||
|
cmd.extend(["--model", self._options.model])
|
||||||
|
|
||||||
|
if self._options.fallback_model:
|
||||||
|
cmd.extend(["--fallback-model", self._options.fallback_model])
|
||||||
|
|
||||||
|
if self._options.betas:
|
||||||
|
cmd.extend(["--betas", ",".join(self._options.betas)])
|
||||||
|
|
||||||
|
if self._options.permission_prompt_tool_name:
|
||||||
|
cmd.extend(
|
||||||
|
["--permission-prompt-tool", self._options.permission_prompt_tool_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._options.permission_mode:
|
||||||
|
cmd.extend(["--permission-mode", self._options.permission_mode])
|
||||||
|
|
||||||
|
if self._options.continue_conversation:
|
||||||
|
cmd.append("--continue")
|
||||||
|
|
||||||
|
if self._options.resume:
|
||||||
|
cmd.extend(["--resume", self._options.resume])
|
||||||
|
|
||||||
|
# Handle settings and sandbox: merge sandbox into settings if both are provided
|
||||||
|
settings_value = self._build_settings_value()
|
||||||
|
if settings_value:
|
||||||
|
cmd.extend(["--settings", settings_value])
|
||||||
|
|
||||||
|
if self._options.add_dirs:
|
||||||
|
# Convert all paths to strings and add each directory
|
||||||
|
for directory in self._options.add_dirs:
|
||||||
|
cmd.extend(["--add-dir", str(directory)])
|
||||||
|
|
||||||
|
if self._options.mcp_servers:
|
||||||
|
if isinstance(self._options.mcp_servers, dict):
|
||||||
|
# Process all servers, stripping instance field from SDK servers
|
||||||
|
servers_for_cli: dict[str, Any] = {}
|
||||||
|
for name, config in self._options.mcp_servers.items():
|
||||||
|
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||||
|
# For SDK servers, pass everything except the instance field
|
||||||
|
sdk_config: dict[str, object] = {
|
||||||
|
k: v for k, v in config.items() if k != "instance"
|
||||||
|
}
|
||||||
|
servers_for_cli[name] = sdk_config
|
||||||
|
else:
|
||||||
|
# For external servers, pass as-is
|
||||||
|
servers_for_cli[name] = config
|
||||||
|
|
||||||
|
# Pass all servers to CLI
|
||||||
|
if servers_for_cli:
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"--mcp-config",
|
||||||
|
json.dumps({"mcpServers": servers_for_cli}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# String or Path format: pass directly as file path or JSON string
|
||||||
|
cmd.extend(["--mcp-config", str(self._options.mcp_servers)])
|
||||||
|
|
||||||
|
if self._options.include_partial_messages:
|
||||||
|
cmd.append("--include-partial-messages")
|
||||||
|
|
||||||
|
if self._options.fork_session:
|
||||||
|
cmd.append("--fork-session")
|
||||||
|
|
||||||
|
if self._options.agents:
|
||||||
|
agents_dict = {
|
||||||
|
name: {k: v for k, v in asdict(agent_def).items() if v is not None}
|
||||||
|
for name, agent_def in self._options.agents.items()
|
||||||
|
}
|
||||||
|
agents_json = json.dumps(agents_dict)
|
||||||
|
cmd.extend(["--agents", agents_json])
|
||||||
|
|
||||||
|
sources_value = (
|
||||||
|
",".join(self._options.setting_sources)
|
||||||
|
if self._options.setting_sources is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
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
|
||||||
|
for flag, value in self._options.extra_args.items():
|
||||||
|
if value is None:
|
||||||
|
# Boolean flag without value
|
||||||
|
cmd.append(f"--{flag}")
|
||||||
|
else:
|
||||||
|
# Flag with value
|
||||||
|
cmd.extend([f"--{flag}", str(value)])
|
||||||
|
|
||||||
|
if self._options.max_thinking_tokens is not None:
|
||||||
|
cmd.extend(
|
||||||
|
["--max-thinking-tokens", str(self._options.max_thinking_tokens)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract schema from output_format structure if provided
|
||||||
|
# Expected: {"type": "json_schema", "schema": {...}}
|
||||||
|
if (
|
||||||
|
self._options.output_format is not None
|
||||||
|
and isinstance(self._options.output_format, dict)
|
||||||
|
and self._options.output_format.get("type") == "json_schema"
|
||||||
|
):
|
||||||
|
schema = self._options.output_format.get("schema")
|
||||||
|
if schema is not None:
|
||||||
|
cmd.extend(["--json-schema", json.dumps(schema)])
|
||||||
|
|
||||||
|
# Add prompt handling based on mode
|
||||||
|
# IMPORTANT: This must come AFTER all flags because everything after "--" is treated as arguments
|
||||||
|
if self._is_streaming:
|
||||||
|
# Streaming mode: use --input-format stream-json
|
||||||
|
cmd.extend(["--input-format", "stream-json"])
|
||||||
|
else:
|
||||||
|
# String mode: use --print with the prompt
|
||||||
|
cmd.extend(["--print", "--", str(self._prompt)])
|
||||||
|
|
||||||
|
# Check if command line is too long (Windows limitation)
|
||||||
|
cmd_str = " ".join(cmd)
|
||||||
|
if len(cmd_str) > _CMD_LENGTH_LIMIT and self._options.agents:
|
||||||
|
# Command is too long - use temp file for agents
|
||||||
|
# Find the --agents argument and replace its value with @filepath
|
||||||
|
try:
|
||||||
|
agents_idx = cmd.index("--agents")
|
||||||
|
agents_json_value = cmd[agents_idx + 1]
|
||||||
|
|
||||||
|
# Create a temporary file
|
||||||
|
# ruff: noqa: SIM115
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||||
|
)
|
||||||
|
temp_file.write(agents_json_value)
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
# Track for cleanup
|
||||||
|
self._temp_files.append(temp_file.name)
|
||||||
|
|
||||||
|
# Replace agents JSON with @filepath reference
|
||||||
|
cmd[agents_idx + 1] = f"@{temp_file.name}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Command line length ({len(cmd_str)}) exceeds limit ({_CMD_LENGTH_LIMIT}). "
|
||||||
|
f"Using temp file for --agents: {temp_file.name}"
|
||||||
|
)
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
# This shouldn't happen, but log it just in case
|
||||||
|
logger.warning(f"Failed to optimize command line length: {e}")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Start subprocess."""
|
||||||
|
if self._process:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.environ.get("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"):
|
||||||
|
await self._check_claude_version()
|
||||||
|
|
||||||
|
cmd = self._build_command()
|
||||||
|
try:
|
||||||
|
# Merge environment variables: system -> user -> SDK required
|
||||||
|
process_env = {
|
||||||
|
**os.environ,
|
||||||
|
**self._options.env, # User-provided env vars
|
||||||
|
"CLAUDE_CODE_ENTRYPOINT": "sdk-py",
|
||||||
|
"CLAUDE_AGENT_SDK_VERSION": __version__,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enable file checkpointing if requested
|
||||||
|
if self._options.enable_file_checkpointing:
|
||||||
|
process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true"
|
||||||
|
|
||||||
|
if self._cwd:
|
||||||
|
process_env["PWD"] = self._cwd
|
||||||
|
|
||||||
|
# Pipe stderr if we have a callback OR debug mode is enabled
|
||||||
|
should_pipe_stderr = (
|
||||||
|
self._options.stderr is not None
|
||||||
|
or "debug-to-stderr" in self._options.extra_args
|
||||||
|
)
|
||||||
|
|
||||||
|
# For backward compat: use debug_stderr file object if no callback and debug is on
|
||||||
|
stderr_dest = PIPE if should_pipe_stderr else None
|
||||||
|
|
||||||
|
self._process = await anyio.open_process(
|
||||||
|
cmd,
|
||||||
|
stdin=PIPE,
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=stderr_dest,
|
||||||
|
cwd=self._cwd,
|
||||||
|
env=process_env,
|
||||||
|
user=self._options.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._process.stdout:
|
||||||
|
self._stdout_stream = TextReceiveStream(self._process.stdout)
|
||||||
|
|
||||||
|
# Setup stderr stream if piped
|
||||||
|
if should_pipe_stderr and self._process.stderr:
|
||||||
|
self._stderr_stream = TextReceiveStream(self._process.stderr)
|
||||||
|
# Start async task to read stderr
|
||||||
|
self._stderr_task_group = anyio.create_task_group()
|
||||||
|
await self._stderr_task_group.__aenter__()
|
||||||
|
self._stderr_task_group.start_soon(self._handle_stderr)
|
||||||
|
|
||||||
|
# Setup stdin for streaming mode
|
||||||
|
if self._is_streaming and self._process.stdin:
|
||||||
|
self._stdin_stream = TextSendStream(self._process.stdin)
|
||||||
|
elif not self._is_streaming and self._process.stdin:
|
||||||
|
# String mode: close stdin immediately
|
||||||
|
await self._process.stdin.aclose()
|
||||||
|
|
||||||
|
self._ready = True
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
# Check if the error comes from the working directory or the CLI
|
||||||
|
if self._cwd and not Path(self._cwd).exists():
|
||||||
|
error = CLIConnectionError(
|
||||||
|
f"Working directory does not exist: {self._cwd}"
|
||||||
|
)
|
||||||
|
self._exit_error = error
|
||||||
|
raise error from e
|
||||||
|
error = CLINotFoundError(f"Claude Code not found at: {self._cli_path}")
|
||||||
|
self._exit_error = error
|
||||||
|
raise error from e
|
||||||
|
except Exception as e:
|
||||||
|
error = CLIConnectionError(f"Failed to start Claude Code: {e}")
|
||||||
|
self._exit_error = error
|
||||||
|
raise error from e
|
||||||
|
|
||||||
|
async def _handle_stderr(self) -> None:
|
||||||
|
"""Handle stderr stream - read and invoke callbacks."""
|
||||||
|
if not self._stderr_stream:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for line in self._stderr_stream:
|
||||||
|
line_str = line.rstrip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Call the stderr callback if provided
|
||||||
|
if self._options.stderr:
|
||||||
|
self._options.stderr(line_str)
|
||||||
|
|
||||||
|
# For backward compatibility: write to debug_stderr if in debug mode
|
||||||
|
elif (
|
||||||
|
"debug-to-stderr" in self._options.extra_args
|
||||||
|
and self._options.debug_stderr
|
||||||
|
):
|
||||||
|
self._options.debug_stderr.write(line_str + "\n")
|
||||||
|
if hasattr(self._options.debug_stderr, "flush"):
|
||||||
|
self._options.debug_stderr.flush()
|
||||||
|
except anyio.ClosedResourceError:
|
||||||
|
pass # Stream closed, exit normally
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore other errors during stderr reading
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the transport and clean up resources."""
|
||||||
|
# Clean up temporary files first (before early return)
|
||||||
|
for temp_file in self._temp_files:
|
||||||
|
with suppress(Exception):
|
||||||
|
Path(temp_file).unlink(missing_ok=True)
|
||||||
|
self._temp_files.clear()
|
||||||
|
|
||||||
|
if not self._process:
|
||||||
|
self._ready = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# Close stderr task group if active
|
||||||
|
if self._stderr_task_group:
|
||||||
|
with suppress(Exception):
|
||||||
|
self._stderr_task_group.cancel_scope.cancel()
|
||||||
|
await self._stderr_task_group.__aexit__(None, None, None)
|
||||||
|
self._stderr_task_group = None
|
||||||
|
|
||||||
|
# Close stdin stream (acquire lock to prevent race with concurrent writes)
|
||||||
|
async with self._write_lock:
|
||||||
|
self._ready = False # Set inside lock to prevent TOCTOU with write()
|
||||||
|
if self._stdin_stream:
|
||||||
|
with suppress(Exception):
|
||||||
|
await self._stdin_stream.aclose()
|
||||||
|
self._stdin_stream = None
|
||||||
|
|
||||||
|
if self._stderr_stream:
|
||||||
|
with suppress(Exception):
|
||||||
|
await self._stderr_stream.aclose()
|
||||||
|
self._stderr_stream = None
|
||||||
|
|
||||||
|
# Terminate and wait for process
|
||||||
|
if self._process.returncode is None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
self._process.terminate()
|
||||||
|
# Wait for process to finish with timeout
|
||||||
|
with suppress(Exception):
|
||||||
|
# Just try to wait, but don't block if it fails
|
||||||
|
await self._process.wait()
|
||||||
|
|
||||||
|
self._process = None
|
||||||
|
self._stdout_stream = None
|
||||||
|
self._stdin_stream = None
|
||||||
|
self._stderr_stream = None
|
||||||
|
self._exit_error = None
|
||||||
|
|
||||||
|
async def write(self, data: str) -> None:
|
||||||
|
"""Write raw data to the transport."""
|
||||||
|
async with self._write_lock:
|
||||||
|
# All checks inside lock to prevent TOCTOU races with close()/end_input()
|
||||||
|
if not self._ready or not self._stdin_stream:
|
||||||
|
raise CLIConnectionError("ProcessTransport is not ready for writing")
|
||||||
|
|
||||||
|
if self._process and self._process.returncode is not None:
|
||||||
|
raise CLIConnectionError(
|
||||||
|
f"Cannot write to terminated process (exit code: {self._process.returncode})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._exit_error:
|
||||||
|
raise CLIConnectionError(
|
||||||
|
f"Cannot write to process that exited with error: {self._exit_error}"
|
||||||
|
) from self._exit_error
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._stdin_stream.send(data)
|
||||||
|
except Exception as e:
|
||||||
|
self._ready = False
|
||||||
|
self._exit_error = CLIConnectionError(
|
||||||
|
f"Failed to write to process stdin: {e}"
|
||||||
|
)
|
||||||
|
raise self._exit_error from e
|
||||||
|
|
||||||
|
async def end_input(self) -> None:
|
||||||
|
"""End the input stream (close stdin)."""
|
||||||
|
async with self._write_lock:
|
||||||
|
if self._stdin_stream:
|
||||||
|
with suppress(Exception):
|
||||||
|
await self._stdin_stream.aclose()
|
||||||
|
self._stdin_stream = None
|
||||||
|
|
||||||
|
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
"""Read and parse messages from the transport."""
|
||||||
|
return self._read_messages_impl()
|
||||||
|
|
||||||
|
async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
"""Internal implementation of read_messages."""
|
||||||
|
if not self._process or not self._stdout_stream:
|
||||||
|
raise CLIConnectionError("Not connected")
|
||||||
|
|
||||||
|
json_buffer = ""
|
||||||
|
|
||||||
|
# Process stdout messages
|
||||||
|
try:
|
||||||
|
async for line in self._stdout_stream:
|
||||||
|
line_str = line.strip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Accumulate partial JSON until we can parse it
|
||||||
|
# Note: TextReceiveStream can truncate long lines, so we need to buffer
|
||||||
|
# and speculatively parse until we get a complete JSON object
|
||||||
|
json_lines = line_str.split("\n")
|
||||||
|
|
||||||
|
for json_line in json_lines:
|
||||||
|
json_line = json_line.strip()
|
||||||
|
if not json_line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Keep accumulating partial JSON until we can parse it
|
||||||
|
json_buffer += json_line
|
||||||
|
|
||||||
|
if len(json_buffer) > self._max_buffer_size:
|
||||||
|
buffer_length = len(json_buffer)
|
||||||
|
json_buffer = ""
|
||||||
|
raise SDKJSONDecodeError(
|
||||||
|
f"JSON message exceeded maximum buffer size of {self._max_buffer_size} bytes",
|
||||||
|
ValueError(
|
||||||
|
f"Buffer size {buffer_length} exceeds limit {self._max_buffer_size}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(json_buffer)
|
||||||
|
json_buffer = ""
|
||||||
|
yield data
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# We are speculatively decoding the buffer until we get
|
||||||
|
# a full JSON object. If there is an actual issue, we
|
||||||
|
# raise an error after exceeding the configured limit.
|
||||||
|
continue
|
||||||
|
|
||||||
|
except anyio.ClosedResourceError:
|
||||||
|
pass
|
||||||
|
except GeneratorExit:
|
||||||
|
# Client disconnected
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check process completion and handle errors
|
||||||
|
try:
|
||||||
|
returncode = await self._process.wait()
|
||||||
|
except Exception:
|
||||||
|
returncode = -1
|
||||||
|
|
||||||
|
# Use exit code for error detection
|
||||||
|
if returncode is not None and returncode != 0:
|
||||||
|
self._exit_error = ProcessError(
|
||||||
|
f"Command failed with exit code {returncode}",
|
||||||
|
exit_code=returncode,
|
||||||
|
stderr="Check stderr output for details",
|
||||||
|
)
|
||||||
|
raise self._exit_error
|
||||||
|
|
||||||
|
async def _check_claude_version(self) -> None:
|
||||||
|
"""Check Claude Code version and warn if below minimum."""
|
||||||
|
version_process = None
|
||||||
|
try:
|
||||||
|
with anyio.fail_after(2): # 2 second timeout
|
||||||
|
version_process = await anyio.open_process(
|
||||||
|
[self._cli_path, "-v"],
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if version_process.stdout:
|
||||||
|
stdout_bytes = await version_process.stdout.receive()
|
||||||
|
version_output = stdout_bytes.decode().strip()
|
||||||
|
|
||||||
|
match = re.match(r"([0-9]+\.[0-9]+\.[0-9]+)", version_output)
|
||||||
|
if match:
|
||||||
|
version = match.group(1)
|
||||||
|
version_parts = [int(x) for x in version.split(".")]
|
||||||
|
min_parts = [
|
||||||
|
int(x) for x in MINIMUM_CLAUDE_CODE_VERSION.split(".")
|
||||||
|
]
|
||||||
|
|
||||||
|
if version_parts < min_parts:
|
||||||
|
warning = (
|
||||||
|
f"Warning: Claude Code version {version} is unsupported in the Agent SDK. "
|
||||||
|
f"Minimum required version is {MINIMUM_CLAUDE_CODE_VERSION}. "
|
||||||
|
"Some features may not work correctly."
|
||||||
|
)
|
||||||
|
logger.warning(warning)
|
||||||
|
print(warning, file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if version_process:
|
||||||
|
with suppress(Exception):
|
||||||
|
version_process.terminate()
|
||||||
|
with suppress(Exception):
|
||||||
|
await version_process.wait()
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
"""Check if transport is ready for communication."""
|
||||||
|
return self._ready
|
||||||
3
src/claude_agent_sdk/_version.py
Normal file
3
src/claude_agent_sdk/_version.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Version information for claude-agent-sdk."""
|
||||||
|
|
||||||
|
__version__ = "0.1.18"
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncIterable, AsyncIterator
|
from collections.abc import AsyncIterable, AsyncIterator
|
||||||
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from . import Transport
|
||||||
from ._errors import CLIConnectionError
|
from ._errors import CLIConnectionError
|
||||||
from .types import ClaudeCodeOptions, Message, ResultMessage
|
from .types import ClaudeAgentOptions, HookEvent, HookMatcher, Message, ResultMessage
|
||||||
|
|
||||||
|
|
||||||
class ClaudeSDKClient:
|
class ClaudeSDKClient:
|
||||||
|
|
@ -38,70 +40,34 @@ class ClaudeSDKClient:
|
||||||
- When all inputs are known upfront
|
- When all inputs are known upfront
|
||||||
- Stateless operations
|
- Stateless operations
|
||||||
|
|
||||||
Example - Interactive conversation:
|
See examples/streaming_mode.py for full examples of ClaudeSDKClient in
|
||||||
```python
|
different scenarios.
|
||||||
# Automatically connects with empty stream for interactive use
|
|
||||||
async with ClaudeSDKClient() as client:
|
|
||||||
# Send initial message
|
|
||||||
await client.query("Let's solve a math problem step by step")
|
|
||||||
|
|
||||||
# Receive and process response
|
Caveat: As of v0.0.20, you cannot use a ClaudeSDKClient instance across
|
||||||
async for message in client.receive_messages():
|
different async runtime contexts (e.g., different trio nurseries or asyncio
|
||||||
if "ready" in str(message.content).lower():
|
task groups). The client internally maintains a persistent anyio task group
|
||||||
break
|
for reading messages that remains active from connect() until disconnect().
|
||||||
|
This means you must complete all operations with the client within the same
|
||||||
# Send follow-up based on response
|
async context where it was connected. Ideally, this limitation should not
|
||||||
await client.query("What's 15% of 80?")
|
exist.
|
||||||
|
|
||||||
# Continue conversation...
|
|
||||||
# Automatically disconnects
|
|
||||||
```
|
|
||||||
|
|
||||||
Example - With interrupt:
|
|
||||||
```python
|
|
||||||
async with ClaudeSDKClient() as client:
|
|
||||||
# Start a long task
|
|
||||||
await client.query("Count to 1000")
|
|
||||||
|
|
||||||
# Interrupt after 2 seconds
|
|
||||||
await anyio.sleep(2)
|
|
||||||
await client.interrupt()
|
|
||||||
|
|
||||||
# Send new instruction
|
|
||||||
await client.query("Never mind, what's 2+2?")
|
|
||||||
```
|
|
||||||
|
|
||||||
Example - Manual connection:
|
|
||||||
```python
|
|
||||||
client = ClaudeSDKClient()
|
|
||||||
|
|
||||||
# Connect with initial message stream
|
|
||||||
async def message_stream():
|
|
||||||
yield {"type": "user", "message": {"role": "user", "content": "Hello"}}
|
|
||||||
|
|
||||||
await client.connect(message_stream())
|
|
||||||
|
|
||||||
# Send additional messages dynamically
|
|
||||||
await client.query("What's the weather?")
|
|
||||||
|
|
||||||
async for message in client.receive_messages():
|
|
||||||
print(message)
|
|
||||||
|
|
||||||
await client.disconnect()
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, options: ClaudeCodeOptions | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
options: ClaudeAgentOptions | None = None,
|
||||||
|
transport: Transport | None = None,
|
||||||
|
):
|
||||||
"""Initialize Claude SDK client."""
|
"""Initialize Claude SDK client."""
|
||||||
if options is None:
|
if options is None:
|
||||||
options = ClaudeCodeOptions()
|
options = ClaudeAgentOptions()
|
||||||
self.options = options
|
self.options = options
|
||||||
self._transport: Any | None = None
|
self._custom_transport = transport
|
||||||
|
self._transport: Transport | None = None
|
||||||
self._query: Any | None = None
|
self._query: Any | None = None
|
||||||
os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client"
|
os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client"
|
||||||
|
|
||||||
def _convert_hooks_to_internal_format(
|
def _convert_hooks_to_internal_format(
|
||||||
self, hooks: dict[str, list[Any]]
|
self, hooks: dict[HookEvent, list[HookMatcher]]
|
||||||
) -> dict[str, list[dict[str, Any]]]:
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
"""Convert HookMatcher format to internal Query format."""
|
"""Convert HookMatcher format to internal Query format."""
|
||||||
internal_hooks: dict[str, list[dict[str, Any]]] = {}
|
internal_hooks: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
@ -109,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
|
||||||
|
|
||||||
|
|
@ -134,9 +102,34 @@ class ClaudeSDKClient:
|
||||||
|
|
||||||
actual_prompt = _empty_stream() if prompt is None else prompt
|
actual_prompt = _empty_stream() if prompt is None else prompt
|
||||||
|
|
||||||
|
# Validate and configure permission settings (matching TypeScript SDK logic)
|
||||||
|
if self.options.can_use_tool:
|
||||||
|
# canUseTool callback requires streaming mode (AsyncIterable prompt)
|
||||||
|
if isinstance(prompt, str):
|
||||||
|
raise ValueError(
|
||||||
|
"can_use_tool callback requires streaming mode. "
|
||||||
|
"Please provide prompt as an AsyncIterable instead of a string."
|
||||||
|
)
|
||||||
|
|
||||||
|
# canUseTool and permission_prompt_tool_name are mutually exclusive
|
||||||
|
if self.options.permission_prompt_tool_name:
|
||||||
|
raise ValueError(
|
||||||
|
"can_use_tool callback cannot be used with permission_prompt_tool_name. "
|
||||||
|
"Please use one or the other."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Automatically set permission_prompt_tool_name to "stdio" for control protocol
|
||||||
|
options = replace(self.options, permission_prompt_tool_name="stdio")
|
||||||
|
else:
|
||||||
|
options = self.options
|
||||||
|
|
||||||
|
# Use provided custom transport or create subprocess transport
|
||||||
|
if self._custom_transport:
|
||||||
|
self._transport = self._custom_transport
|
||||||
|
else:
|
||||||
self._transport = SubprocessCLITransport(
|
self._transport = SubprocessCLITransport(
|
||||||
prompt=actual_prompt,
|
prompt=actual_prompt,
|
||||||
options=self.options,
|
options=options,
|
||||||
)
|
)
|
||||||
await self._transport.connect()
|
await self._transport.connect()
|
||||||
|
|
||||||
|
|
@ -147,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,
|
||||||
|
|
@ -156,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
|
||||||
|
|
@ -212,6 +213,86 @@ class ClaudeSDKClient:
|
||||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||||
await self._query.interrupt()
|
await self._query.interrupt()
|
||||||
|
|
||||||
|
async def set_permission_mode(self, mode: str) -> None:
|
||||||
|
"""Change permission mode during conversation (only works with streaming mode).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: The permission mode to set. Valid options:
|
||||||
|
- 'default': CLI prompts for dangerous tools
|
||||||
|
- 'acceptEdits': Auto-accept file edits
|
||||||
|
- 'bypassPermissions': Allow all tools (use with caution)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with ClaudeSDKClient() as client:
|
||||||
|
# Start with default permissions
|
||||||
|
await client.query("Help me analyze this codebase")
|
||||||
|
|
||||||
|
# Review mode done, switch to auto-accept edits
|
||||||
|
await client.set_permission_mode('acceptEdits')
|
||||||
|
await client.query("Now implement the fix we discussed")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if not self._query:
|
||||||
|
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||||
|
await self._query.set_permission_mode(mode)
|
||||||
|
|
||||||
|
async def set_model(self, model: str | None = None) -> None:
|
||||||
|
"""Change the AI model during conversation (only works with streaming mode).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: The model to use, or None to use default. Examples:
|
||||||
|
- 'claude-sonnet-4-5'
|
||||||
|
- 'claude-opus-4-1-20250805'
|
||||||
|
- 'claude-opus-4-20250514'
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with ClaudeSDKClient() as client:
|
||||||
|
# Start with default model
|
||||||
|
await client.query("Help me understand this problem")
|
||||||
|
|
||||||
|
# Switch to a different model for implementation
|
||||||
|
await client.set_model('claude-sonnet-4-5')
|
||||||
|
await client.query("Now implement the solution")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if not self._query:
|
||||||
|
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||||
|
await self._query.set_model(model)
|
||||||
|
|
||||||
|
async def rewind_files(self, user_message_id: str) -> None:
|
||||||
|
"""Rewind tracked files to their state at a specific user message.
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- `enable_file_checkpointing=True` to track file changes
|
||||||
|
- `extra_args={"replay-user-messages": None}` to receive UserMessage
|
||||||
|
objects with `uuid` in the response stream
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message_id: UUID of the user message to rewind to. This should be
|
||||||
|
the `uuid` field from a `UserMessage` received during the conversation.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
enable_file_checkpointing=True,
|
||||||
|
extra_args={"replay-user-messages": None},
|
||||||
|
)
|
||||||
|
async with ClaudeSDKClient(options) as client:
|
||||||
|
await client.query("Make some changes to my files")
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
if isinstance(msg, UserMessage) and msg.uuid:
|
||||||
|
checkpoint_id = msg.uuid # Save this for later
|
||||||
|
|
||||||
|
# Later, rewind to that point
|
||||||
|
await client.rewind_files(checkpoint_id)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if not self._query:
|
||||||
|
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||||
|
await self._query.rewind_files(user_message_id)
|
||||||
|
|
||||||
async def get_server_info(self) -> dict[str, Any] | None:
|
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.
|
||||||
|
|
||||||
|
|
@ -6,13 +6,13 @@ from typing import Any
|
||||||
|
|
||||||
from ._internal.client import InternalClient
|
from ._internal.client import InternalClient
|
||||||
from ._internal.transport import Transport
|
from ._internal.transport import Transport
|
||||||
from .types import ClaudeCodeOptions, Message
|
from .types import ClaudeAgentOptions, Message
|
||||||
|
|
||||||
|
|
||||||
async def query(
|
async def query(
|
||||||
*,
|
*,
|
||||||
prompt: str | AsyncIterable[dict[str, Any]],
|
prompt: str | AsyncIterable[dict[str, Any]],
|
||||||
options: ClaudeCodeOptions | None = None,
|
options: ClaudeAgentOptions | None = None,
|
||||||
transport: Transport | None = None,
|
transport: Transport | None = None,
|
||||||
) -> AsyncIterator[Message]:
|
) -> AsyncIterator[Message]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -52,7 +52,7 @@ async def query(
|
||||||
"parent_tool_use_id": None,
|
"parent_tool_use_id": None,
|
||||||
"session_id": "..."
|
"session_id": "..."
|
||||||
}
|
}
|
||||||
options: Optional configuration (defaults to ClaudeCodeOptions() if None).
|
options: Optional configuration (defaults to ClaudeAgentOptions() if None).
|
||||||
Set options.permission_mode to control tool execution:
|
Set options.permission_mode to control tool execution:
|
||||||
- 'default': CLI prompts for dangerous tools
|
- 'default': CLI prompts for dangerous tools
|
||||||
- 'acceptEdits': Auto-accept file edits
|
- 'acceptEdits': Auto-accept file edits
|
||||||
|
|
@ -77,7 +77,7 @@ async def query(
|
||||||
# Code generation with specific settings
|
# Code generation with specific settings
|
||||||
async for message in query(
|
async for message in query(
|
||||||
prompt="Create a Python web server",
|
prompt="Create a Python web server",
|
||||||
options=ClaudeCodeOptions(
|
options=ClaudeAgentOptions(
|
||||||
system_prompt="You are an expert Python developer",
|
system_prompt="You are an expert Python developer",
|
||||||
cwd="/home/user/project"
|
cwd="/home/user/project"
|
||||||
)
|
)
|
||||||
|
|
@ -98,7 +98,7 @@ async def query(
|
||||||
|
|
||||||
Example - With custom transport:
|
Example - With custom transport:
|
||||||
```python
|
```python
|
||||||
from claude_code_sdk import query, Transport
|
from claude_agent_sdk import query, Transport
|
||||||
|
|
||||||
class MyCustomTransport(Transport):
|
class MyCustomTransport(Transport):
|
||||||
# Implement custom transport logic
|
# Implement custom transport logic
|
||||||
|
|
@ -114,7 +114,7 @@ async def query(
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if options is None:
|
if options is None:
|
||||||
options = ClaudeCodeOptions()
|
options = ClaudeAgentOptions()
|
||||||
|
|
||||||
os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py"
|
os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py"
|
||||||
|
|
||||||
754
src/claude_agent_sdk/types.py
Normal file
754
src/claude_agent_sdk/types.py
Normal file
|
|
@ -0,0 +1,754 @@
|
||||||
|
"""Type definitions for Claude SDK."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
||||||
|
|
||||||
|
from typing_extensions import NotRequired
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mcp.server import Server as McpServer
|
||||||
|
else:
|
||||||
|
# Runtime placeholder for forward reference resolution in Pydantic 2.12+
|
||||||
|
McpServer = Any
|
||||||
|
|
||||||
|
# Permission modes
|
||||||
|
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
||||||
|
|
||||||
|
# SDK Beta features - see https://docs.anthropic.com/en/api/beta-headers
|
||||||
|
SdkBeta = Literal["context-1m-2025-08-07"]
|
||||||
|
|
||||||
|
# Agent definitions
|
||||||
|
SettingSource = Literal["user", "project", "local"]
|
||||||
|
|
||||||
|
|
||||||
|
class SystemPromptPreset(TypedDict):
|
||||||
|
"""System prompt preset configuration."""
|
||||||
|
|
||||||
|
type: Literal["preset"]
|
||||||
|
preset: Literal["claude_code"]
|
||||||
|
append: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsPreset(TypedDict):
|
||||||
|
"""Tools preset configuration."""
|
||||||
|
|
||||||
|
type: Literal["preset"]
|
||||||
|
preset: Literal["claude_code"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentDefinition:
|
||||||
|
"""Agent definition configuration."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
prompt: str
|
||||||
|
tools: list[str] | None = None
|
||||||
|
model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Permission Update types (matching TypeScript SDK)
|
||||||
|
PermissionUpdateDestination = Literal[
|
||||||
|
"userSettings", "projectSettings", "localSettings", "session"
|
||||||
|
]
|
||||||
|
|
||||||
|
PermissionBehavior = Literal["allow", "deny", "ask"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PermissionRuleValue:
|
||||||
|
"""Permission rule value."""
|
||||||
|
|
||||||
|
tool_name: str
|
||||||
|
rule_content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PermissionUpdate:
|
||||||
|
"""Permission update configuration."""
|
||||||
|
|
||||||
|
type: Literal[
|
||||||
|
"addRules",
|
||||||
|
"replaceRules",
|
||||||
|
"removeRules",
|
||||||
|
"setMode",
|
||||||
|
"addDirectories",
|
||||||
|
"removeDirectories",
|
||||||
|
]
|
||||||
|
rules: list[PermissionRuleValue] | None = None
|
||||||
|
behavior: PermissionBehavior | None = None
|
||||||
|
mode: PermissionMode | None = None
|
||||||
|
directories: list[str] | None = None
|
||||||
|
destination: PermissionUpdateDestination | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert PermissionUpdate to dictionary format matching TypeScript control protocol."""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"type": self.type,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add destination for all variants
|
||||||
|
if self.destination is not None:
|
||||||
|
result["destination"] = self.destination
|
||||||
|
|
||||||
|
# Handle different type variants
|
||||||
|
if self.type in ["addRules", "replaceRules", "removeRules"]:
|
||||||
|
# Rules-based variants require rules and behavior
|
||||||
|
if self.rules is not None:
|
||||||
|
result["rules"] = [
|
||||||
|
{
|
||||||
|
"toolName": rule.tool_name,
|
||||||
|
"ruleContent": rule.rule_content,
|
||||||
|
}
|
||||||
|
for rule in self.rules
|
||||||
|
]
|
||||||
|
if self.behavior is not None:
|
||||||
|
result["behavior"] = self.behavior
|
||||||
|
|
||||||
|
elif self.type == "setMode":
|
||||||
|
# Mode variant requires mode
|
||||||
|
if self.mode is not None:
|
||||||
|
result["mode"] = self.mode
|
||||||
|
|
||||||
|
elif self.type in ["addDirectories", "removeDirectories"]:
|
||||||
|
# Directory variants require directories
|
||||||
|
if self.directories is not None:
|
||||||
|
result["directories"] = self.directories
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Tool callback types
|
||||||
|
@dataclass
|
||||||
|
class ToolPermissionContext:
|
||||||
|
"""Context information for tool permission callbacks."""
|
||||||
|
|
||||||
|
signal: Any | None = None # Future: abort signal support
|
||||||
|
suggestions: list[PermissionUpdate] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # Permission suggestions from CLI
|
||||||
|
|
||||||
|
|
||||||
|
# Match TypeScript's PermissionResult structure
|
||||||
|
@dataclass
|
||||||
|
class PermissionResultAllow:
|
||||||
|
"""Allow permission result."""
|
||||||
|
|
||||||
|
behavior: Literal["allow"] = "allow"
|
||||||
|
updated_input: dict[str, Any] | None = None
|
||||||
|
updated_permissions: list[PermissionUpdate] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PermissionResultDeny:
|
||||||
|
"""Deny permission result."""
|
||||||
|
|
||||||
|
behavior: Literal["deny"] = "deny"
|
||||||
|
message: str = ""
|
||||||
|
interrupt: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
PermissionResult = PermissionResultAllow | PermissionResultDeny
|
||||||
|
|
||||||
|
CanUseTool = Callable[
|
||||||
|
[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
##### Hook types
|
||||||
|
# Supported hook event types. Due to setup limitations, the Python SDK does not
|
||||||
|
# support SessionStart, SessionEnd, and Notification hooks.
|
||||||
|
HookEvent = (
|
||||||
|
Literal["PreToolUse"]
|
||||||
|
| Literal["PostToolUse"]
|
||||||
|
| Literal["UserPromptSubmit"]
|
||||||
|
| Literal["Stop"]
|
||||||
|
| Literal["SubagentStop"]
|
||||||
|
| Literal["PreCompact"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Hook input types - strongly typed for each hook event
|
||||||
|
class BaseHookInput(TypedDict):
|
||||||
|
"""Base hook input fields present across many hook events."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
transcript_path: str
|
||||||
|
cwd: str
|
||||||
|
permission_mode: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PreToolUseHookInput(BaseHookInput):
|
||||||
|
"""Input data for PreToolUse hook events."""
|
||||||
|
|
||||||
|
hook_event_name: Literal["PreToolUse"]
|
||||||
|
tool_name: str
|
||||||
|
tool_input: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class PostToolUseHookInput(BaseHookInput):
|
||||||
|
"""Input data for PostToolUse hook events."""
|
||||||
|
|
||||||
|
hook_event_name: Literal["PostToolUse"]
|
||||||
|
tool_name: str
|
||||||
|
tool_input: dict[str, Any]
|
||||||
|
tool_response: Any
|
||||||
|
|
||||||
|
|
||||||
|
class UserPromptSubmitHookInput(BaseHookInput):
|
||||||
|
"""Input data for UserPromptSubmit hook events."""
|
||||||
|
|
||||||
|
hook_event_name: Literal["UserPromptSubmit"]
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
class StopHookInput(BaseHookInput):
|
||||||
|
"""Input data for Stop hook events."""
|
||||||
|
|
||||||
|
hook_event_name: Literal["Stop"]
|
||||||
|
stop_hook_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SubagentStopHookInput(BaseHookInput):
|
||||||
|
"""Input data for SubagentStop hook events."""
|
||||||
|
|
||||||
|
hook_event_name: Literal["SubagentStop"]
|
||||||
|
stop_hook_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PreCompactHookInput(BaseHookInput):
|
||||||
|
"""Input data for PreCompact hook events."""
|
||||||
|
|
||||||
|
hook_event_name: Literal["PreCompact"]
|
||||||
|
trigger: Literal["manual", "auto"]
|
||||||
|
custom_instructions: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# Union type for all hook inputs
|
||||||
|
HookInput = (
|
||||||
|
PreToolUseHookInput
|
||||||
|
| PostToolUseHookInput
|
||||||
|
| UserPromptSubmitHookInput
|
||||||
|
| StopHookInput
|
||||||
|
| SubagentStopHookInput
|
||||||
|
| PreCompactHookInput
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Hook-specific output types
|
||||||
|
class PreToolUseHookSpecificOutput(TypedDict):
|
||||||
|
"""Hook-specific output for PreToolUse events."""
|
||||||
|
|
||||||
|
hookEventName: Literal["PreToolUse"]
|
||||||
|
permissionDecision: NotRequired[Literal["allow", "deny", "ask"]]
|
||||||
|
permissionDecisionReason: NotRequired[str]
|
||||||
|
updatedInput: NotRequired[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class PostToolUseHookSpecificOutput(TypedDict):
|
||||||
|
"""Hook-specific output for PostToolUse events."""
|
||||||
|
|
||||||
|
hookEventName: Literal["PostToolUse"]
|
||||||
|
additionalContext: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class UserPromptSubmitHookSpecificOutput(TypedDict):
|
||||||
|
"""Hook-specific output for UserPromptSubmit events."""
|
||||||
|
|
||||||
|
hookEventName: Literal["UserPromptSubmit"]
|
||||||
|
additionalContext: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStartHookSpecificOutput(TypedDict):
|
||||||
|
"""Hook-specific output for SessionStart events."""
|
||||||
|
|
||||||
|
hookEventName: Literal["SessionStart"]
|
||||||
|
additionalContext: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
HookSpecificOutput = (
|
||||||
|
PreToolUseHookSpecificOutput
|
||||||
|
| PostToolUseHookSpecificOutput
|
||||||
|
| UserPromptSubmitHookSpecificOutput
|
||||||
|
| SessionStartHookSpecificOutput
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output
|
||||||
|
# for documentation of the output types.
|
||||||
|
#
|
||||||
|
# IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid
|
||||||
|
# Python keyword conflicts. These fields are automatically converted to `async` and
|
||||||
|
# `continue` when sent to the CLI. You should use the underscore versions in your
|
||||||
|
# Python code.
|
||||||
|
class AsyncHookJSONOutput(TypedDict):
|
||||||
|
"""Async hook output that defers hook execution.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
async_: Set to True to defer hook execution. Note: This is converted to
|
||||||
|
"async" when sent to the CLI - use "async_" in your Python code.
|
||||||
|
asyncTimeout: Optional timeout in milliseconds for the async operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async_: Literal[
|
||||||
|
True
|
||||||
|
] # Using async_ to avoid Python keyword (converted to "async" for CLI)
|
||||||
|
asyncTimeout: NotRequired[int]
|
||||||
|
|
||||||
|
|
||||||
|
class SyncHookJSONOutput(TypedDict):
|
||||||
|
"""Synchronous hook output with control and decision fields.
|
||||||
|
|
||||||
|
This defines the structure for hook callbacks to control execution and provide
|
||||||
|
feedback to Claude.
|
||||||
|
|
||||||
|
Common Control Fields:
|
||||||
|
continue_: Whether Claude should proceed after hook execution (default: True).
|
||||||
|
Note: This is converted to "continue" when sent to the CLI.
|
||||||
|
suppressOutput: Hide stdout from transcript mode (default: False).
|
||||||
|
stopReason: Message shown when continue is False.
|
||||||
|
|
||||||
|
Decision Fields:
|
||||||
|
decision: Set to "block" to indicate blocking behavior.
|
||||||
|
systemMessage: Warning message displayed to the user.
|
||||||
|
reason: Feedback message for Claude about the decision.
|
||||||
|
|
||||||
|
Hook-Specific Output:
|
||||||
|
hookSpecificOutput: Event-specific controls (e.g., permissionDecision for
|
||||||
|
PreToolUse, additionalContext for PostToolUse).
|
||||||
|
|
||||||
|
Note: The CLI documentation shows field names without underscores ("async", "continue"),
|
||||||
|
but Python code should use the underscore versions ("async_", "continue_") as they
|
||||||
|
are automatically converted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Common control fields
|
||||||
|
continue_: NotRequired[
|
||||||
|
bool
|
||||||
|
] # Using continue_ to avoid Python keyword (converted to "continue" for CLI)
|
||||||
|
suppressOutput: NotRequired[bool]
|
||||||
|
stopReason: NotRequired[str]
|
||||||
|
|
||||||
|
# Decision fields
|
||||||
|
# Note: "approve" is deprecated for PreToolUse (use permissionDecision instead)
|
||||||
|
# For other hooks, only "block" is meaningful
|
||||||
|
decision: NotRequired[Literal["block"]]
|
||||||
|
systemMessage: NotRequired[str]
|
||||||
|
reason: NotRequired[str]
|
||||||
|
|
||||||
|
# Hook-specific outputs
|
||||||
|
hookSpecificOutput: NotRequired[HookSpecificOutput]
|
||||||
|
|
||||||
|
|
||||||
|
HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput
|
||||||
|
|
||||||
|
|
||||||
|
class HookContext(TypedDict):
|
||||||
|
"""Context information for hook callbacks.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
signal: Reserved for future abort signal support. Currently always None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
signal: Any | None # Future: abort signal support
|
||||||
|
|
||||||
|
|
||||||
|
HookCallback = Callable[
|
||||||
|
# HookCallback input parameters:
|
||||||
|
# - input: Strongly-typed hook input with discriminated unions based on hook_event_name
|
||||||
|
# - tool_use_id: Optional tool use identifier
|
||||||
|
# - context: Hook context with abort signal support (currently placeholder)
|
||||||
|
[HookInput, str | None, HookContext],
|
||||||
|
Awaitable[HookJSONOutput],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Hook matcher configuration
|
||||||
|
@dataclass
|
||||||
|
class HookMatcher:
|
||||||
|
"""Hook matcher configuration."""
|
||||||
|
|
||||||
|
# See https://docs.anthropic.com/en/docs/claude-code/hooks#structure for the
|
||||||
|
# expected string value. For example, for PreToolUse, the matcher can be
|
||||||
|
# a tool name like "Bash" or a combination of tool names like
|
||||||
|
# "Write|MultiEdit|Edit".
|
||||||
|
matcher: str | None = None
|
||||||
|
|
||||||
|
# A list of Python functions with function signature HookCallback
|
||||||
|
hooks: list[HookCallback] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Timeout in seconds for all hooks in this matcher (default: 60)
|
||||||
|
timeout: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# MCP Server config
|
||||||
|
class McpStdioServerConfig(TypedDict):
|
||||||
|
"""MCP stdio server configuration."""
|
||||||
|
|
||||||
|
type: NotRequired[Literal["stdio"]] # Optional for backwards compatibility
|
||||||
|
command: str
|
||||||
|
args: NotRequired[list[str]]
|
||||||
|
env: NotRequired[dict[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class McpSSEServerConfig(TypedDict):
|
||||||
|
"""MCP SSE server configuration."""
|
||||||
|
|
||||||
|
type: Literal["sse"]
|
||||||
|
url: str
|
||||||
|
headers: NotRequired[dict[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class McpHttpServerConfig(TypedDict):
|
||||||
|
"""MCP HTTP server configuration."""
|
||||||
|
|
||||||
|
type: Literal["http"]
|
||||||
|
url: str
|
||||||
|
headers: NotRequired[dict[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class McpSdkServerConfig(TypedDict):
|
||||||
|
"""SDK MCP server configuration."""
|
||||||
|
|
||||||
|
type: Literal["sdk"]
|
||||||
|
name: str
|
||||||
|
instance: "McpServer"
|
||||||
|
|
||||||
|
|
||||||
|
McpServerConfig = (
|
||||||
|
McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
@dataclass
|
||||||
|
class TextBlock:
|
||||||
|
"""Text content block."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThinkingBlock:
|
||||||
|
"""Thinking content block."""
|
||||||
|
|
||||||
|
thinking: str
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolUseBlock:
|
||||||
|
"""Tool use content block."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
input: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolResultBlock:
|
||||||
|
"""Tool result content block."""
|
||||||
|
|
||||||
|
tool_use_id: str
|
||||||
|
content: str | list[dict[str, Any]] | None = None
|
||||||
|
is_error: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
|
||||||
|
|
||||||
|
|
||||||
|
# Message types
|
||||||
|
AssistantMessageError = Literal[
|
||||||
|
"authentication_failed",
|
||||||
|
"billing_error",
|
||||||
|
"rate_limit",
|
||||||
|
"invalid_request",
|
||||||
|
"server_error",
|
||||||
|
"unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserMessage:
|
||||||
|
"""User message."""
|
||||||
|
|
||||||
|
content: str | list[ContentBlock]
|
||||||
|
uuid: str | None = None
|
||||||
|
parent_tool_use_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssistantMessage:
|
||||||
|
"""Assistant message with content blocks."""
|
||||||
|
|
||||||
|
content: list[ContentBlock]
|
||||||
|
model: str
|
||||||
|
parent_tool_use_id: str | None = None
|
||||||
|
error: AssistantMessageError | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SystemMessage:
|
||||||
|
"""System message with metadata."""
|
||||||
|
|
||||||
|
subtype: str
|
||||||
|
data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResultMessage:
|
||||||
|
"""Result message with cost and usage information."""
|
||||||
|
|
||||||
|
subtype: str
|
||||||
|
duration_ms: int
|
||||||
|
duration_api_ms: int
|
||||||
|
is_error: bool
|
||||||
|
num_turns: int
|
||||||
|
session_id: str
|
||||||
|
total_cost_usd: float | None = None
|
||||||
|
usage: dict[str, Any] | None = None
|
||||||
|
result: str | None = None
|
||||||
|
structured_output: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamEvent:
|
||||||
|
"""Stream event for partial message updates during streaming."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
session_id: str
|
||||||
|
event: dict[str, Any] # The raw Anthropic API stream event
|
||||||
|
parent_tool_use_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | StreamEvent
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClaudeAgentOptions:
|
||||||
|
"""Query options for Claude SDK."""
|
||||||
|
|
||||||
|
tools: list[str] | ToolsPreset | None = None
|
||||||
|
allowed_tools: list[str] = field(default_factory=list)
|
||||||
|
system_prompt: str | SystemPromptPreset | None = None
|
||||||
|
mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict)
|
||||||
|
permission_mode: PermissionMode | None = None
|
||||||
|
continue_conversation: bool = False
|
||||||
|
resume: str | None = None
|
||||||
|
max_turns: int | None = None
|
||||||
|
max_budget_usd: float | None = None
|
||||||
|
disallowed_tools: list[str] = field(default_factory=list)
|
||||||
|
model: str | None = None
|
||||||
|
fallback_model: str | None = None
|
||||||
|
# Beta features - see https://docs.anthropic.com/en/api/beta-headers
|
||||||
|
betas: list[SdkBeta] = field(default_factory=list)
|
||||||
|
permission_prompt_tool_name: str | None = None
|
||||||
|
cwd: str | Path | None = None
|
||||||
|
cli_path: str | Path | None = None
|
||||||
|
settings: str | None = None
|
||||||
|
add_dirs: list[str | Path] = field(default_factory=list)
|
||||||
|
env: dict[str, str] = field(default_factory=dict)
|
||||||
|
extra_args: dict[str, str | None] = field(
|
||||||
|
default_factory=dict
|
||||||
|
) # Pass arbitrary CLI flags
|
||||||
|
max_buffer_size: int | None = None # Max bytes when buffering CLI stdout
|
||||||
|
debug_stderr: Any = (
|
||||||
|
sys.stderr
|
||||||
|
) # Deprecated: File-like object for debug output. Use stderr callback instead.
|
||||||
|
stderr: Callable[[str], None] | None = None # Callback for stderr output from CLI
|
||||||
|
|
||||||
|
# Tool permission callback
|
||||||
|
can_use_tool: CanUseTool | None = None
|
||||||
|
|
||||||
|
# Hook configurations
|
||||||
|
hooks: dict[HookEvent, list[HookMatcher]] | None = None
|
||||||
|
|
||||||
|
user: str | None = None
|
||||||
|
|
||||||
|
# Partial message streaming support
|
||||||
|
include_partial_messages: bool = False
|
||||||
|
# When true resumed sessions will fork to a new session ID rather than
|
||||||
|
# continuing the previous session.
|
||||||
|
fork_session: bool = False
|
||||||
|
# Agent definitions for custom agents
|
||||||
|
agents: dict[str, AgentDefinition] | None = None
|
||||||
|
# Setting sources to load (user, project, local)
|
||||||
|
setting_sources: list[SettingSource] | None = None
|
||||||
|
# Sandbox configuration for bash command isolation.
|
||||||
|
# Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch),
|
||||||
|
# not from these sandbox settings.
|
||||||
|
sandbox: SandboxSettings | None = None
|
||||||
|
# Plugin configurations for custom plugins
|
||||||
|
plugins: list[SdkPluginConfig] = field(default_factory=list)
|
||||||
|
# Max tokens for thinking blocks
|
||||||
|
max_thinking_tokens: int | None = None
|
||||||
|
# Output format for structured outputs (matches Messages API structure)
|
||||||
|
# Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}}
|
||||||
|
output_format: dict[str, Any] | None = None
|
||||||
|
# Enable file checkpointing to track file changes during the session.
|
||||||
|
# When enabled, files can be rewound to their state at any user message
|
||||||
|
# using `ClaudeSDKClient.rewind_files()`.
|
||||||
|
enable_file_checkpointing: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# SDK Control Protocol
|
||||||
|
class SDKControlInterruptRequest(TypedDict):
|
||||||
|
subtype: Literal["interrupt"]
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlPermissionRequest(TypedDict):
|
||||||
|
subtype: Literal["can_use_tool"]
|
||||||
|
tool_name: str
|
||||||
|
input: dict[str, Any]
|
||||||
|
# TODO: Add PermissionUpdate type here
|
||||||
|
permission_suggestions: list[Any] | None
|
||||||
|
blocked_path: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlInitializeRequest(TypedDict):
|
||||||
|
subtype: Literal["initialize"]
|
||||||
|
hooks: dict[HookEvent, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlSetPermissionModeRequest(TypedDict):
|
||||||
|
subtype: Literal["set_permission_mode"]
|
||||||
|
# TODO: Add PermissionMode
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
|
||||||
|
class SDKHookCallbackRequest(TypedDict):
|
||||||
|
subtype: Literal["hook_callback"]
|
||||||
|
callback_id: str
|
||||||
|
input: Any
|
||||||
|
tool_use_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlMcpMessageRequest(TypedDict):
|
||||||
|
subtype: Literal["mcp_message"]
|
||||||
|
server_name: str
|
||||||
|
message: Any
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlRewindFilesRequest(TypedDict):
|
||||||
|
subtype: Literal["rewind_files"]
|
||||||
|
user_message_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlRequest(TypedDict):
|
||||||
|
type: Literal["control_request"]
|
||||||
|
request_id: str
|
||||||
|
request: (
|
||||||
|
SDKControlInterruptRequest
|
||||||
|
| SDKControlPermissionRequest
|
||||||
|
| SDKControlInitializeRequest
|
||||||
|
| SDKControlSetPermissionModeRequest
|
||||||
|
| SDKHookCallbackRequest
|
||||||
|
| SDKControlMcpMessageRequest
|
||||||
|
| SDKControlRewindFilesRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlResponse(TypedDict):
|
||||||
|
subtype: Literal["success"]
|
||||||
|
request_id: str
|
||||||
|
response: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class ControlErrorResponse(TypedDict):
|
||||||
|
subtype: Literal["error"]
|
||||||
|
request_id: str
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
|
class SDKControlResponse(TypedDict):
|
||||||
|
type: Literal["control_response"]
|
||||||
|
response: ControlResponse | ControlErrorResponse
|
||||||
|
|
@ -1,366 +0,0 @@
|
||||||
"""Subprocess transport implementation using Claude Code CLI."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from collections.abc import AsyncIterable, AsyncIterator
|
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
|
||||||
from subprocess import PIPE
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import anyio
|
|
||||||
from anyio.abc import Process
|
|
||||||
from anyio.streams.text import TextReceiveStream, TextSendStream
|
|
||||||
|
|
||||||
from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError
|
|
||||||
from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError
|
|
||||||
from ...types import ClaudeCodeOptions
|
|
||||||
from . import Transport
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
|
||||||
|
|
||||||
|
|
||||||
class SubprocessCLITransport(Transport):
|
|
||||||
"""Subprocess transport using Claude Code CLI."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
prompt: str | AsyncIterable[dict[str, Any]],
|
|
||||||
options: ClaudeCodeOptions,
|
|
||||||
cli_path: str | Path | None = None,
|
|
||||||
):
|
|
||||||
self._prompt = prompt
|
|
||||||
self._is_streaming = not isinstance(prompt, str)
|
|
||||||
self._options = options
|
|
||||||
self._cli_path = str(cli_path) if cli_path else self._find_cli()
|
|
||||||
self._cwd = str(options.cwd) if options.cwd else None
|
|
||||||
self._process: Process | None = None
|
|
||||||
self._stdout_stream: TextReceiveStream | None = None
|
|
||||||
self._stdin_stream: TextSendStream | None = None
|
|
||||||
self._ready = False
|
|
||||||
self._exit_error: Exception | None = None # Track process exit errors
|
|
||||||
|
|
||||||
def _find_cli(self) -> str:
|
|
||||||
"""Find Claude Code CLI binary."""
|
|
||||||
if cli := shutil.which("claude"):
|
|
||||||
return cli
|
|
||||||
|
|
||||||
locations = [
|
|
||||||
Path.home() / ".npm-global/bin/claude",
|
|
||||||
Path("/usr/local/bin/claude"),
|
|
||||||
Path.home() / ".local/bin/claude",
|
|
||||||
Path.home() / "node_modules/.bin/claude",
|
|
||||||
Path.home() / ".yarn/bin/claude",
|
|
||||||
]
|
|
||||||
|
|
||||||
for path in locations:
|
|
||||||
if path.exists() and path.is_file():
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
node_installed = shutil.which("node") is not None
|
|
||||||
|
|
||||||
if not node_installed:
|
|
||||||
error_msg = "Claude Code requires Node.js, which is not installed.\n\n"
|
|
||||||
error_msg += "Install Node.js from: https://nodejs.org/\n"
|
|
||||||
error_msg += "\nAfter installing Node.js, install Claude Code:\n"
|
|
||||||
error_msg += " npm install -g @anthropic-ai/claude-code"
|
|
||||||
raise CLINotFoundError(error_msg)
|
|
||||||
|
|
||||||
raise CLINotFoundError(
|
|
||||||
"Claude Code not found. Install with:\n"
|
|
||||||
" npm install -g @anthropic-ai/claude-code\n"
|
|
||||||
"\nIf already installed locally, try:\n"
|
|
||||||
' export PATH="$HOME/node_modules/.bin:$PATH"\n'
|
|
||||||
"\nOr specify the path when creating transport:\n"
|
|
||||||
" SubprocessCLITransport(..., cli_path='/path/to/claude')"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_command(self) -> list[str]:
|
|
||||||
"""Build CLI command with arguments."""
|
|
||||||
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
|
|
||||||
|
|
||||||
if self._options.system_prompt:
|
|
||||||
cmd.extend(["--system-prompt", self._options.system_prompt])
|
|
||||||
|
|
||||||
if self._options.append_system_prompt:
|
|
||||||
cmd.extend(["--append-system-prompt", self._options.append_system_prompt])
|
|
||||||
|
|
||||||
if self._options.allowed_tools:
|
|
||||||
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
|
|
||||||
|
|
||||||
if self._options.max_turns:
|
|
||||||
cmd.extend(["--max-turns", str(self._options.max_turns)])
|
|
||||||
|
|
||||||
if self._options.disallowed_tools:
|
|
||||||
cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])
|
|
||||||
|
|
||||||
if self._options.model:
|
|
||||||
cmd.extend(["--model", self._options.model])
|
|
||||||
|
|
||||||
if self._options.permission_prompt_tool_name:
|
|
||||||
cmd.extend(
|
|
||||||
["--permission-prompt-tool", self._options.permission_prompt_tool_name]
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._options.permission_mode:
|
|
||||||
cmd.extend(["--permission-mode", self._options.permission_mode])
|
|
||||||
|
|
||||||
if self._options.continue_conversation:
|
|
||||||
cmd.append("--continue")
|
|
||||||
|
|
||||||
if self._options.resume:
|
|
||||||
cmd.extend(["--resume", self._options.resume])
|
|
||||||
|
|
||||||
if self._options.settings:
|
|
||||||
cmd.extend(["--settings", self._options.settings])
|
|
||||||
|
|
||||||
if self._options.add_dirs:
|
|
||||||
# Convert all paths to strings and add each directory
|
|
||||||
for directory in self._options.add_dirs:
|
|
||||||
cmd.extend(["--add-dir", str(directory)])
|
|
||||||
|
|
||||||
if self._options.mcp_servers:
|
|
||||||
if isinstance(self._options.mcp_servers, dict):
|
|
||||||
# Filter out SDK servers - they're handled in-process
|
|
||||||
external_servers = {
|
|
||||||
name: config
|
|
||||||
for name, config in self._options.mcp_servers.items()
|
|
||||||
if not (isinstance(config, dict) and config.get("type") == "sdk")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only pass external servers to CLI
|
|
||||||
if external_servers:
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"--mcp-config",
|
|
||||||
json.dumps({"mcpServers": external_servers}),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# String or Path format: pass directly as file path or JSON string
|
|
||||||
cmd.extend(["--mcp-config", str(self._options.mcp_servers)])
|
|
||||||
|
|
||||||
# Add extra args for future CLI flags
|
|
||||||
for flag, value in self._options.extra_args.items():
|
|
||||||
if value is None:
|
|
||||||
# Boolean flag without value
|
|
||||||
cmd.append(f"--{flag}")
|
|
||||||
else:
|
|
||||||
# Flag with value
|
|
||||||
cmd.extend([f"--{flag}", str(value)])
|
|
||||||
|
|
||||||
# Add prompt handling based on mode
|
|
||||||
if self._is_streaming:
|
|
||||||
# Streaming mode: use --input-format stream-json
|
|
||||||
cmd.extend(["--input-format", "stream-json"])
|
|
||||||
else:
|
|
||||||
# String mode: use --print with the prompt
|
|
||||||
cmd.extend(["--print", "--", str(self._prompt)])
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
"""Start subprocess."""
|
|
||||||
if self._process:
|
|
||||||
return
|
|
||||||
|
|
||||||
cmd = self._build_command()
|
|
||||||
try:
|
|
||||||
# Merge environment variables: system -> user -> SDK required
|
|
||||||
process_env = {
|
|
||||||
**os.environ,
|
|
||||||
**self._options.env, # User-provided env vars
|
|
||||||
"CLAUDE_CODE_ENTRYPOINT": "sdk-py",
|
|
||||||
}
|
|
||||||
|
|
||||||
if self._cwd:
|
|
||||||
process_env["PWD"] = self._cwd
|
|
||||||
|
|
||||||
# Only output stderr if customer explicitly requested debug output and provided a file object
|
|
||||||
stderr_dest = (
|
|
||||||
self._options.debug_stderr
|
|
||||||
if "debug-to-stderr" in self._options.extra_args
|
|
||||||
and self._options.debug_stderr
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
self._process = await anyio.open_process(
|
|
||||||
cmd,
|
|
||||||
stdin=PIPE,
|
|
||||||
stdout=PIPE,
|
|
||||||
stderr=stderr_dest,
|
|
||||||
cwd=self._cwd,
|
|
||||||
env=process_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._process.stdout:
|
|
||||||
self._stdout_stream = TextReceiveStream(self._process.stdout)
|
|
||||||
|
|
||||||
# Setup stdin for streaming mode
|
|
||||||
if self._is_streaming and self._process.stdin:
|
|
||||||
self._stdin_stream = TextSendStream(self._process.stdin)
|
|
||||||
elif not self._is_streaming and self._process.stdin:
|
|
||||||
# String mode: close stdin immediately
|
|
||||||
await self._process.stdin.aclose()
|
|
||||||
|
|
||||||
self._ready = True
|
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
# Check if the error comes from the working directory or the CLI
|
|
||||||
if self._cwd and not Path(self._cwd).exists():
|
|
||||||
error = CLIConnectionError(
|
|
||||||
f"Working directory does not exist: {self._cwd}"
|
|
||||||
)
|
|
||||||
self._exit_error = error
|
|
||||||
raise error from e
|
|
||||||
error = CLINotFoundError(f"Claude Code not found at: {self._cli_path}")
|
|
||||||
self._exit_error = error
|
|
||||||
raise error from e
|
|
||||||
except Exception as e:
|
|
||||||
error = CLIConnectionError(f"Failed to start Claude Code: {e}")
|
|
||||||
self._exit_error = error
|
|
||||||
raise error from e
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
"""Close the transport and clean up resources."""
|
|
||||||
self._ready = False
|
|
||||||
|
|
||||||
if not self._process:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Close streams
|
|
||||||
if self._stdin_stream:
|
|
||||||
with suppress(Exception):
|
|
||||||
await self._stdin_stream.aclose()
|
|
||||||
self._stdin_stream = None
|
|
||||||
|
|
||||||
if self._process.stdin:
|
|
||||||
with suppress(Exception):
|
|
||||||
await self._process.stdin.aclose()
|
|
||||||
|
|
||||||
# Terminate and wait for process
|
|
||||||
if self._process.returncode is None:
|
|
||||||
with suppress(ProcessLookupError):
|
|
||||||
self._process.terminate()
|
|
||||||
# Wait for process to finish with timeout
|
|
||||||
with suppress(Exception):
|
|
||||||
# Just try to wait, but don't block if it fails
|
|
||||||
await self._process.wait()
|
|
||||||
|
|
||||||
self._process = None
|
|
||||||
self._stdout_stream = None
|
|
||||||
self._stdin_stream = None
|
|
||||||
self._exit_error = None
|
|
||||||
|
|
||||||
async def write(self, data: str) -> None:
|
|
||||||
"""Write raw data to the transport."""
|
|
||||||
# Check if ready (like TypeScript)
|
|
||||||
if not self._ready or not self._stdin_stream:
|
|
||||||
raise CLIConnectionError("ProcessTransport is not ready for writing")
|
|
||||||
|
|
||||||
# Check if process is still alive (like TypeScript)
|
|
||||||
if self._process and self._process.returncode is not None:
|
|
||||||
raise CLIConnectionError(
|
|
||||||
f"Cannot write to terminated process (exit code: {self._process.returncode})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for exit errors (like TypeScript)
|
|
||||||
if self._exit_error:
|
|
||||||
raise CLIConnectionError(
|
|
||||||
f"Cannot write to process that exited with error: {self._exit_error}"
|
|
||||||
) from self._exit_error
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._stdin_stream.send(data)
|
|
||||||
except Exception as e:
|
|
||||||
self._ready = False # Mark as not ready (like TypeScript)
|
|
||||||
self._exit_error = CLIConnectionError(
|
|
||||||
f"Failed to write to process stdin: {e}"
|
|
||||||
)
|
|
||||||
raise self._exit_error from e
|
|
||||||
|
|
||||||
async def end_input(self) -> None:
|
|
||||||
"""End the input stream (close stdin)."""
|
|
||||||
if self._stdin_stream:
|
|
||||||
with suppress(Exception):
|
|
||||||
await self._stdin_stream.aclose()
|
|
||||||
self._stdin_stream = None
|
|
||||||
|
|
||||||
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
|
||||||
"""Read and parse messages from the transport."""
|
|
||||||
return self._read_messages_impl()
|
|
||||||
|
|
||||||
async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]:
|
|
||||||
"""Internal implementation of read_messages."""
|
|
||||||
if not self._process or not self._stdout_stream:
|
|
||||||
raise CLIConnectionError("Not connected")
|
|
||||||
|
|
||||||
json_buffer = ""
|
|
||||||
|
|
||||||
# Process stdout messages
|
|
||||||
try:
|
|
||||||
async for line in self._stdout_stream:
|
|
||||||
line_str = line.strip()
|
|
||||||
if not line_str:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Accumulate partial JSON until we can parse it
|
|
||||||
# Note: TextReceiveStream can truncate long lines, so we need to buffer
|
|
||||||
# and speculatively parse until we get a complete JSON object
|
|
||||||
json_lines = line_str.split("\n")
|
|
||||||
|
|
||||||
for json_line in json_lines:
|
|
||||||
json_line = json_line.strip()
|
|
||||||
if not json_line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Keep accumulating partial JSON until we can parse it
|
|
||||||
json_buffer += json_line
|
|
||||||
|
|
||||||
if len(json_buffer) > _MAX_BUFFER_SIZE:
|
|
||||||
json_buffer = ""
|
|
||||||
raise SDKJSONDecodeError(
|
|
||||||
f"JSON message exceeded maximum buffer size of {_MAX_BUFFER_SIZE} bytes",
|
|
||||||
ValueError(
|
|
||||||
f"Buffer size {len(json_buffer)} exceeds limit {_MAX_BUFFER_SIZE}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(json_buffer)
|
|
||||||
json_buffer = ""
|
|
||||||
yield data
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# We are speculatively decoding the buffer until we get
|
|
||||||
# a full JSON object. If there is an actual issue, we
|
|
||||||
# raise an error after _MAX_BUFFER_SIZE.
|
|
||||||
continue
|
|
||||||
|
|
||||||
except anyio.ClosedResourceError:
|
|
||||||
pass
|
|
||||||
except GeneratorExit:
|
|
||||||
# Client disconnected
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check process completion and handle errors
|
|
||||||
try:
|
|
||||||
returncode = await self._process.wait()
|
|
||||||
except Exception:
|
|
||||||
returncode = -1
|
|
||||||
|
|
||||||
# Use exit code for error detection
|
|
||||||
if returncode is not None and returncode != 0:
|
|
||||||
self._exit_error = ProcessError(
|
|
||||||
f"Command failed with exit code {returncode}",
|
|
||||||
exit_code=returncode,
|
|
||||||
stderr="Check stderr output for details",
|
|
||||||
)
|
|
||||||
raise self._exit_error
|
|
||||||
|
|
||||||
def is_ready(self) -> bool:
|
|
||||||
"""Check if transport is ready for communication."""
|
|
||||||
return self._ready
|
|
||||||
|
|
@ -1,331 +0,0 @@
|
||||||
"""Type definitions for Claude SDK."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
|
||||||
|
|
||||||
from typing_extensions import NotRequired
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from mcp.server import Server as McpServer
|
|
||||||
|
|
||||||
# Permission modes
|
|
||||||
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
|
||||||
|
|
||||||
|
|
||||||
# Permission Update types (matching TypeScript SDK)
|
|
||||||
PermissionUpdateDestination = Literal[
|
|
||||||
"userSettings", "projectSettings", "localSettings", "session"
|
|
||||||
]
|
|
||||||
|
|
||||||
PermissionBehavior = Literal["allow", "deny", "ask"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PermissionRuleValue:
|
|
||||||
"""Permission rule value."""
|
|
||||||
|
|
||||||
tool_name: str
|
|
||||||
rule_content: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PermissionUpdate:
|
|
||||||
"""Permission update configuration."""
|
|
||||||
|
|
||||||
type: Literal[
|
|
||||||
"addRules",
|
|
||||||
"replaceRules",
|
|
||||||
"removeRules",
|
|
||||||
"setMode",
|
|
||||||
"addDirectories",
|
|
||||||
"removeDirectories",
|
|
||||||
]
|
|
||||||
rules: list[PermissionRuleValue] | None = None
|
|
||||||
behavior: PermissionBehavior | None = None
|
|
||||||
mode: PermissionMode | None = None
|
|
||||||
directories: list[str] | None = None
|
|
||||||
destination: PermissionUpdateDestination | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# Tool callback types
|
|
||||||
@dataclass
|
|
||||||
class ToolPermissionContext:
|
|
||||||
"""Context information for tool permission callbacks."""
|
|
||||||
|
|
||||||
signal: Any | None = None # Future: abort signal support
|
|
||||||
suggestions: list[PermissionUpdate] = field(
|
|
||||||
default_factory=list
|
|
||||||
) # Permission suggestions from CLI
|
|
||||||
|
|
||||||
|
|
||||||
# Match TypeScript's PermissionResult structure
|
|
||||||
@dataclass
|
|
||||||
class PermissionResultAllow:
|
|
||||||
"""Allow permission result."""
|
|
||||||
|
|
||||||
behavior: Literal["allow"] = "allow"
|
|
||||||
updated_input: dict[str, Any] | None = None
|
|
||||||
updated_permissions: list[PermissionUpdate] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PermissionResultDeny:
|
|
||||||
"""Deny permission result."""
|
|
||||||
|
|
||||||
behavior: Literal["deny"] = "deny"
|
|
||||||
message: str = ""
|
|
||||||
interrupt: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
PermissionResult = PermissionResultAllow | PermissionResultDeny
|
|
||||||
|
|
||||||
CanUseTool = Callable[
|
|
||||||
[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Hook callback types
|
|
||||||
@dataclass
|
|
||||||
class HookContext:
|
|
||||||
"""Context information for hook callbacks."""
|
|
||||||
|
|
||||||
signal: Any | None = None # Future: abort signal support
|
|
||||||
|
|
||||||
|
|
||||||
HookCallback = Callable[
|
|
||||||
[dict[str, Any], str | None, HookContext], # input, tool_use_id, context
|
|
||||||
Awaitable[dict[str, Any]], # response data
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Hook matcher configuration
|
|
||||||
@dataclass
|
|
||||||
class HookMatcher:
|
|
||||||
"""Hook matcher configuration."""
|
|
||||||
|
|
||||||
matcher: dict[str, Any] | None = None # Matcher criteria
|
|
||||||
hooks: list[HookCallback] = field(default_factory=list) # Callbacks to invoke
|
|
||||||
|
|
||||||
|
|
||||||
# MCP Server config
|
|
||||||
class McpStdioServerConfig(TypedDict):
|
|
||||||
"""MCP stdio server configuration."""
|
|
||||||
|
|
||||||
type: NotRequired[Literal["stdio"]] # Optional for backwards compatibility
|
|
||||||
command: str
|
|
||||||
args: NotRequired[list[str]]
|
|
||||||
env: NotRequired[dict[str, str]]
|
|
||||||
|
|
||||||
|
|
||||||
class McpSSEServerConfig(TypedDict):
|
|
||||||
"""MCP SSE server configuration."""
|
|
||||||
|
|
||||||
type: Literal["sse"]
|
|
||||||
url: str
|
|
||||||
headers: NotRequired[dict[str, str]]
|
|
||||||
|
|
||||||
|
|
||||||
class McpHttpServerConfig(TypedDict):
|
|
||||||
"""MCP HTTP server configuration."""
|
|
||||||
|
|
||||||
type: Literal["http"]
|
|
||||||
url: str
|
|
||||||
headers: NotRequired[dict[str, str]]
|
|
||||||
|
|
||||||
|
|
||||||
class McpSdkServerConfig(TypedDict):
|
|
||||||
"""SDK MCP server configuration."""
|
|
||||||
|
|
||||||
type: Literal["sdk"]
|
|
||||||
name: str
|
|
||||||
instance: "McpServer"
|
|
||||||
|
|
||||||
|
|
||||||
McpServerConfig = (
|
|
||||||
McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfig
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Content block types
|
|
||||||
@dataclass
|
|
||||||
class TextBlock:
|
|
||||||
"""Text content block."""
|
|
||||||
|
|
||||||
text: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ThinkingBlock:
|
|
||||||
"""Thinking content block."""
|
|
||||||
|
|
||||||
thinking: str
|
|
||||||
signature: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ToolUseBlock:
|
|
||||||
"""Tool use content block."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
input: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ToolResultBlock:
|
|
||||||
"""Tool result content block."""
|
|
||||||
|
|
||||||
tool_use_id: str
|
|
||||||
content: str | list[dict[str, Any]] | None = None
|
|
||||||
is_error: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
|
|
||||||
|
|
||||||
|
|
||||||
# Message types
|
|
||||||
@dataclass
|
|
||||||
class UserMessage:
|
|
||||||
"""User message."""
|
|
||||||
|
|
||||||
content: str | list[ContentBlock]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AssistantMessage:
|
|
||||||
"""Assistant message with content blocks."""
|
|
||||||
|
|
||||||
content: list[ContentBlock]
|
|
||||||
model: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SystemMessage:
|
|
||||||
"""System message with metadata."""
|
|
||||||
|
|
||||||
subtype: str
|
|
||||||
data: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResultMessage:
|
|
||||||
"""Result message with cost and usage information."""
|
|
||||||
|
|
||||||
subtype: str
|
|
||||||
duration_ms: int
|
|
||||||
duration_api_ms: int
|
|
||||||
is_error: bool
|
|
||||||
num_turns: int
|
|
||||||
session_id: str
|
|
||||||
total_cost_usd: float | None = None
|
|
||||||
usage: dict[str, Any] | None = None
|
|
||||||
result: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ClaudeCodeOptions:
|
|
||||||
"""Query options for Claude SDK."""
|
|
||||||
|
|
||||||
allowed_tools: list[str] = field(default_factory=list)
|
|
||||||
max_thinking_tokens: int = 8000
|
|
||||||
system_prompt: str | None = None
|
|
||||||
append_system_prompt: str | None = None
|
|
||||||
mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict)
|
|
||||||
permission_mode: PermissionMode | None = None
|
|
||||||
continue_conversation: bool = False
|
|
||||||
resume: str | None = None
|
|
||||||
max_turns: int | None = None
|
|
||||||
disallowed_tools: list[str] = field(default_factory=list)
|
|
||||||
model: str | None = None
|
|
||||||
permission_prompt_tool_name: str | None = None
|
|
||||||
cwd: str | Path | None = None
|
|
||||||
settings: str | None = None
|
|
||||||
add_dirs: list[str | Path] = field(default_factory=list)
|
|
||||||
env: dict[str, str] = field(default_factory=dict)
|
|
||||||
extra_args: dict[str, str | None] = field(
|
|
||||||
default_factory=dict
|
|
||||||
) # Pass arbitrary CLI flags
|
|
||||||
debug_stderr: Any = (
|
|
||||||
sys.stderr
|
|
||||||
) # File-like object for debug output when debug-to-stderr is set
|
|
||||||
|
|
||||||
# Tool permission callback
|
|
||||||
can_use_tool: CanUseTool | None = None
|
|
||||||
|
|
||||||
# Hook configurations
|
|
||||||
hooks: dict[str, list[HookMatcher]] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# SDK Control Protocol
|
|
||||||
class SDKControlInterruptRequest(TypedDict):
|
|
||||||
subtype: Literal["interrupt"]
|
|
||||||
|
|
||||||
|
|
||||||
class SDKControlPermissionRequest(TypedDict):
|
|
||||||
subtype: Literal["can_use_tool"]
|
|
||||||
tool_name: str
|
|
||||||
input: dict[str, Any]
|
|
||||||
# TODO: Add PermissionUpdate type here
|
|
||||||
permission_suggestions: list[Any] | None
|
|
||||||
blocked_path: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class SDKControlInitializeRequest(TypedDict):
|
|
||||||
subtype: Literal["initialize"]
|
|
||||||
# TODO: Use HookEvent names as the key.
|
|
||||||
hooks: dict[str, Any] | None
|
|
||||||
|
|
||||||
|
|
||||||
class SDKControlSetPermissionModeRequest(TypedDict):
|
|
||||||
subtype: Literal["set_permission_mode"]
|
|
||||||
# TODO: Add PermissionMode
|
|
||||||
mode: str
|
|
||||||
|
|
||||||
|
|
||||||
class SDKHookCallbackRequest(TypedDict):
|
|
||||||
subtype: Literal["hook_callback"]
|
|
||||||
callback_id: str
|
|
||||||
input: Any
|
|
||||||
tool_use_id: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class SDKControlMcpMessageRequest(TypedDict):
|
|
||||||
subtype: Literal["mcp_message"]
|
|
||||||
server_name: str
|
|
||||||
message: Any
|
|
||||||
|
|
||||||
|
|
||||||
class SDKControlRequest(TypedDict):
|
|
||||||
type: Literal["control_request"]
|
|
||||||
request_id: str
|
|
||||||
request: (
|
|
||||||
SDKControlInterruptRequest
|
|
||||||
| SDKControlPermissionRequest
|
|
||||||
| SDKControlInitializeRequest
|
|
||||||
| SDKControlSetPermissionModeRequest
|
|
||||||
| SDKHookCallbackRequest
|
|
||||||
| SDKControlMcpMessageRequest
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ControlResponse(TypedDict):
|
|
||||||
subtype: Literal["success"]
|
|
||||||
request_id: str
|
|
||||||
response: dict[str, Any] | None
|
|
||||||
|
|
||||||
|
|
||||||
class ControlErrorResponse(TypedDict):
|
|
||||||
subtype: Literal["error"]
|
|
||||||
request_id: str
|
|
||||||
error: str
|
|
||||||
|
|
||||||
|
|
||||||
class SDKControlResponse(TypedDict):
|
|
||||||
type: Literal["control_response"]
|
|
||||||
response: ControlResponse | ControlErrorResponse
|
|
||||||
|
|
@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
|
||||||
from claude_code_sdk import AssistantMessage, ClaudeCodeOptions, query
|
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, query
|
||||||
from claude_code_sdk.types import TextBlock
|
from claude_agent_sdk.types import TextBlock
|
||||||
|
|
||||||
|
|
||||||
class TestQueryFunction:
|
class TestQueryFunction:
|
||||||
|
|
@ -16,7 +16,7 @@ class TestQueryFunction:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.client.InternalClient.process_query"
|
"claude_agent_sdk._internal.client.InternalClient.process_query"
|
||||||
) as mock_process:
|
) as mock_process:
|
||||||
# Mock the async generator
|
# Mock the async generator
|
||||||
async def mock_generator():
|
async def mock_generator():
|
||||||
|
|
@ -41,7 +41,7 @@ class TestQueryFunction:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.client.InternalClient.process_query"
|
"claude_agent_sdk._internal.client.InternalClient.process_query"
|
||||||
) as mock_process:
|
) as mock_process:
|
||||||
|
|
||||||
async def mock_generator():
|
async def mock_generator():
|
||||||
|
|
@ -52,7 +52,7 @@ class TestQueryFunction:
|
||||||
|
|
||||||
mock_process.return_value = mock_generator()
|
mock_process.return_value = mock_generator()
|
||||||
|
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
allowed_tools=["Read", "Write"],
|
allowed_tools=["Read", "Write"],
|
||||||
system_prompt="You are helpful",
|
system_prompt="You are helpful",
|
||||||
permission_mode="acceptEdits",
|
permission_mode="acceptEdits",
|
||||||
|
|
@ -76,7 +76,7 @@ class TestQueryFunction:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.client.SubprocessCLITransport"
|
"claude_agent_sdk._internal.client.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = AsyncMock()
|
mock_transport = AsyncMock()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -109,7 +109,7 @@ class TestQueryFunction:
|
||||||
mock_transport.write = AsyncMock()
|
mock_transport.write = AsyncMock()
|
||||||
mock_transport.is_ready = Mock(return_value=True)
|
mock_transport.is_ready = Mock(return_value=True)
|
||||||
|
|
||||||
options = ClaudeCodeOptions(cwd="/custom/path")
|
options = ClaudeAgentOptions(cwd="/custom/path")
|
||||||
messages = []
|
messages = []
|
||||||
async for msg in query(prompt="test", options=options):
|
async for msg in query(prompt="test", options=options):
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Tests for Claude SDK error handling."""
|
"""Tests for Claude SDK error handling."""
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
ClaudeSDKError,
|
ClaudeSDKError,
|
||||||
CLIConnectionError,
|
CLIConnectionError,
|
||||||
CLIJSONDecodeError,
|
CLIJSONDecodeError,
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
CLINotFoundError,
|
CLINotFoundError,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
from claude_code_sdk.types import ToolUseBlock
|
from claude_agent_sdk.types import ToolUseBlock
|
||||||
|
|
||||||
|
|
||||||
class TestIntegration:
|
class TestIntegration:
|
||||||
|
|
@ -26,7 +26,7 @@ class TestIntegration:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.client.SubprocessCLITransport"
|
"claude_agent_sdk._internal.client.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = AsyncMock()
|
mock_transport = AsyncMock()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -84,7 +84,7 @@ class TestIntegration:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.client.SubprocessCLITransport"
|
"claude_agent_sdk._internal.client.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = AsyncMock()
|
mock_transport = AsyncMock()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -132,7 +132,7 @@ class TestIntegration:
|
||||||
messages = []
|
messages = []
|
||||||
async for msg in query(
|
async for msg in query(
|
||||||
prompt="Read /test.txt",
|
prompt="Read /test.txt",
|
||||||
options=ClaudeCodeOptions(allowed_tools=["Read"]),
|
options=ClaudeAgentOptions(allowed_tools=["Read"]),
|
||||||
):
|
):
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
|
||||||
|
|
@ -161,7 +161,7 @@ class TestIntegration:
|
||||||
async for _ in query(prompt="test"):
|
async for _ in query(prompt="test"):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert "Claude Code requires Node.js" in str(exc_info.value)
|
assert "Claude Code not found" in str(exc_info.value)
|
||||||
|
|
||||||
anyio.run(_test)
|
anyio.run(_test)
|
||||||
|
|
||||||
|
|
@ -170,7 +170,7 @@ class TestIntegration:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.client.SubprocessCLITransport"
|
"claude_agent_sdk._internal.client.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = AsyncMock()
|
mock_transport = AsyncMock()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -202,7 +202,7 @@ class TestIntegration:
|
||||||
messages = []
|
messages = []
|
||||||
async for msg in query(
|
async for msg in query(
|
||||||
prompt="Continue",
|
prompt="Continue",
|
||||||
options=ClaudeCodeOptions(continue_conversation=True),
|
options=ClaudeAgentOptions(continue_conversation=True),
|
||||||
):
|
):
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from claude_code_sdk._errors import MessageParseError
|
from claude_agent_sdk._errors import MessageParseError
|
||||||
from claude_code_sdk._internal.message_parser import parse_message
|
from claude_agent_sdk._internal.message_parser import parse_message
|
||||||
from claude_code_sdk.types import (
|
from claude_agent_sdk.types import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
|
|
@ -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 = {
|
||||||
|
|
@ -130,6 +145,17 @@ class TestMessageParser:
|
||||||
assert isinstance(message.content[2], ToolResultBlock)
|
assert isinstance(message.content[2], ToolResultBlock)
|
||||||
assert isinstance(message.content[3], TextBlock)
|
assert isinstance(message.content[3], TextBlock)
|
||||||
|
|
||||||
|
def test_parse_user_message_inside_subagent(self):
|
||||||
|
"""Test parsing a valid user message."""
|
||||||
|
data = {
|
||||||
|
"type": "user",
|
||||||
|
"message": {"content": [{"type": "text", "text": "Hello"}]},
|
||||||
|
"parent_tool_use_id": "toolu_01Xrwd5Y13sEHtzScxR77So8",
|
||||||
|
}
|
||||||
|
message = parse_message(data)
|
||||||
|
assert isinstance(message, UserMessage)
|
||||||
|
assert message.parent_tool_use_id == "toolu_01Xrwd5Y13sEHtzScxR77So8"
|
||||||
|
|
||||||
def test_parse_valid_assistant_message(self):
|
def test_parse_valid_assistant_message(self):
|
||||||
"""Test parsing a valid assistant message."""
|
"""Test parsing a valid assistant message."""
|
||||||
data = {
|
data = {
|
||||||
|
|
@ -185,6 +211,28 @@ class TestMessageParser:
|
||||||
assert isinstance(message, SystemMessage)
|
assert isinstance(message, SystemMessage)
|
||||||
assert message.subtype == "start"
|
assert message.subtype == "start"
|
||||||
|
|
||||||
|
def test_parse_assistant_message_inside_subagent(self):
|
||||||
|
"""Test parsing a valid assistant message."""
|
||||||
|
data = {
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Hello"},
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "tool_123",
|
||||||
|
"name": "Read",
|
||||||
|
"input": {"file_path": "/test.txt"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"model": "claude-opus-4-1-20250805",
|
||||||
|
},
|
||||||
|
"parent_tool_use_id": "toolu_01Xrwd5Y13sEHtzScxR77So8",
|
||||||
|
}
|
||||||
|
message = parse_message(data)
|
||||||
|
assert isinstance(message, AssistantMessage)
|
||||||
|
assert message.parent_tool_use_id == "toolu_01Xrwd5Y13sEHtzScxR77So8"
|
||||||
|
|
||||||
def test_parse_valid_result_message(self):
|
def test_parse_valid_result_message(self):
|
||||||
"""Test parsing a valid result message."""
|
"""Test parsing a valid result message."""
|
||||||
data = {
|
data = {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ This test file verifies that SDK MCP servers work correctly through the full sta
|
||||||
matching the TypeScript SDK test/sdk.test.ts pattern.
|
matching the TypeScript SDK test/sdk.test.ts pattern.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp.types import CallToolRequest, CallToolRequestParams
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
create_sdk_mcp_server,
|
create_sdk_mcp_server,
|
||||||
tool,
|
tool,
|
||||||
)
|
)
|
||||||
|
|
@ -159,7 +161,7 @@ async def test_mixed_servers():
|
||||||
# Create configuration with both SDK and external servers
|
# Create configuration with both SDK and external servers
|
||||||
external_server = {"type": "stdio", "command": "echo", "args": ["test"]}
|
external_server = {"type": "stdio", "command": "echo", "args": ["test"]}
|
||||||
|
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
mcp_servers={"sdk": sdk_server, "external": external_server}
|
mcp_servers={"sdk": sdk_server, "external": external_server}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -191,3 +193,73 @@ async def test_server_creation():
|
||||||
|
|
||||||
# When no tools are provided, the handlers are not registered
|
# When no tools are provided, the handlers are not registered
|
||||||
assert ListToolsRequest not in instance.request_handlers
|
assert ListToolsRequest not in instance.request_handlers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_content_support():
|
||||||
|
"""Test that tools can return image content with base64 data."""
|
||||||
|
|
||||||
|
# Create sample base64 image data (a simple 1x1 pixel PNG)
|
||||||
|
png_data = base64.b64encode(
|
||||||
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\tpHYs\x00\x00\x0b\x13"
|
||||||
|
b"\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\x00\x00\x00\x0cIDATx\x9cc```"
|
||||||
|
b"\x00\x00\x00\x04\x00\x01]U!\x1c\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
# Track tool executions
|
||||||
|
tool_executions: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Create a tool that returns both text and image content
|
||||||
|
@tool(
|
||||||
|
"generate_chart", "Generates a chart and returns it as an image", {"title": str}
|
||||||
|
)
|
||||||
|
async def generate_chart(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
tool_executions.append({"name": "generate_chart", "args": args})
|
||||||
|
return {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": f"Generated chart: {args['title']}"},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"data": png_data,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
server_config = create_sdk_mcp_server(
|
||||||
|
name="image-test-server", version="1.0.0", tools=[generate_chart]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the server instance
|
||||||
|
server = server_config["instance"]
|
||||||
|
|
||||||
|
call_handler = server.request_handlers[CallToolRequest]
|
||||||
|
|
||||||
|
# Call the chart generation tool
|
||||||
|
chart_request = CallToolRequest(
|
||||||
|
method="tools/call",
|
||||||
|
params=CallToolRequestParams(
|
||||||
|
name="generate_chart", arguments={"title": "Sales Report"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = await call_handler(chart_request)
|
||||||
|
|
||||||
|
# Verify the result contains both text and image content
|
||||||
|
assert len(result.root.content) == 2
|
||||||
|
|
||||||
|
# Check text content
|
||||||
|
text_content = result.root.content[0]
|
||||||
|
assert text_content.type == "text"
|
||||||
|
assert text_content.text == "Generated chart: Sales Report"
|
||||||
|
|
||||||
|
# Check image content
|
||||||
|
image_content = result.root.content[1]
|
||||||
|
assert image_content.type == "image"
|
||||||
|
assert image_content.data == png_data
|
||||||
|
assert image_content.mimeType == "image/png"
|
||||||
|
|
||||||
|
# Verify the tool was executed correctly
|
||||||
|
assert len(tool_executions) == 1
|
||||||
|
assert tool_executions[0]["name"] == "generate_chart"
|
||||||
|
assert tool_executions[0]["args"]["title"] == "Sales Report"
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
ClaudeSDKClient,
|
ClaudeSDKClient,
|
||||||
CLIConnectionError,
|
CLIConnectionError,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
|
|
@ -20,7 +20,7 @@ from claude_code_sdk import (
|
||||||
UserMessage,
|
UserMessage,
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
|
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
|
||||||
|
|
||||||
|
|
||||||
def create_mock_transport(with_init_response=True):
|
def create_mock_transport(with_init_response=True):
|
||||||
|
|
@ -115,7 +115,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -135,7 +135,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -159,7 +159,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -178,7 +178,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -206,7 +206,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -240,7 +240,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -279,7 +279,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -348,7 +348,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -430,7 +430,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -471,14 +471,14 @@ class TestClaudeSDKClientStreaming:
|
||||||
"""Test client initialization with options."""
|
"""Test client initialization with options."""
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
cwd="/custom/path",
|
cwd="/custom/path",
|
||||||
allowed_tools=["Read", "Write"],
|
allowed_tools=["Read", "Write"],
|
||||||
system_prompt="Be helpful",
|
system_prompt="Be helpful",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -497,7 +497,7 @@ class TestClaudeSDKClientStreaming:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -633,20 +633,27 @@ assert '"Second"' in stdin_messages[1]
|
||||||
print('{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}')
|
print('{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}')
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Make script executable (Unix-style systems)
|
||||||
|
if sys.platform != "win32":
|
||||||
Path(test_script).chmod(0o755)
|
Path(test_script).chmod(0o755)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Mock _find_cli to return python executing our test script
|
# Mock _find_cli to return the test script path directly
|
||||||
with patch.object(
|
with patch.object(
|
||||||
SubprocessCLITransport, "_find_cli", return_value=sys.executable
|
SubprocessCLITransport, "_find_cli", return_value=test_script
|
||||||
):
|
):
|
||||||
# Mock _build_command to add our test script as first argument
|
# Mock _build_command to properly execute Python script
|
||||||
original_build_command = SubprocessCLITransport._build_command
|
original_build_command = SubprocessCLITransport._build_command
|
||||||
|
|
||||||
def mock_build_command(self):
|
def mock_build_command(self):
|
||||||
# Get original command
|
# Get original command
|
||||||
cmd = original_build_command(self)
|
cmd = original_build_command(self)
|
||||||
# Replace the CLI path with python + script
|
# On Windows, we need to use python interpreter to run the script
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Replace first element with python interpreter and script
|
||||||
|
cmd[0:1] = [sys.executable, test_script]
|
||||||
|
else:
|
||||||
|
# On Unix, just use the script directly
|
||||||
cmd[0] = test_script
|
cmd[0] = test_script
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
@ -699,7 +706,7 @@ class TestClaudeSDKClientEdgeCases:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
# Create a new mock transport for each call
|
# Create a new mock transport for each call
|
||||||
mock_transport_class.side_effect = [
|
mock_transport_class.side_effect = [
|
||||||
|
|
@ -732,7 +739,7 @@ class TestClaudeSDKClientEdgeCases:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
@ -751,7 +758,7 @@ class TestClaudeSDKClientEdgeCases:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch(
|
with patch(
|
||||||
"claude_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
|
||||||
) as mock_transport_class:
|
) as mock_transport_class:
|
||||||
mock_transport = create_mock_transport()
|
mock_transport = create_mock_transport()
|
||||||
mock_transport_class.return_value = mock_transport
|
mock_transport_class.return_value = mock_transport
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,21 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from claude_code_sdk._errors import CLIJSONDecodeError
|
from claude_agent_sdk._errors import CLIJSONDecodeError
|
||||||
from claude_code_sdk._internal.transport.subprocess_cli import (
|
from claude_agent_sdk._internal.transport.subprocess_cli import (
|
||||||
_MAX_BUFFER_SIZE,
|
_DEFAULT_MAX_BUFFER_SIZE,
|
||||||
SubprocessCLITransport,
|
SubprocessCLITransport,
|
||||||
)
|
)
|
||||||
from claude_code_sdk.types import ClaudeCodeOptions
|
from claude_agent_sdk.types import ClaudeAgentOptions
|
||||||
|
|
||||||
|
DEFAULT_CLI_PATH = "/usr/bin/claude"
|
||||||
|
|
||||||
|
|
||||||
|
def make_options(**kwargs: object) -> ClaudeAgentOptions:
|
||||||
|
"""Construct ClaudeAgentOptions with a default CLI path for tests."""
|
||||||
|
|
||||||
|
cli_path = kwargs.pop("cli_path", DEFAULT_CLI_PATH)
|
||||||
|
return ClaudeAgentOptions(cli_path=cli_path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MockTextReceiveStream:
|
class MockTextReceiveStream:
|
||||||
|
|
@ -50,9 +59,7 @@ class TestSubprocessBuffering:
|
||||||
|
|
||||||
buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2)
|
buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2)
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
|
|
@ -85,9 +92,7 @@ class TestSubprocessBuffering:
|
||||||
|
|
||||||
buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2)
|
buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2)
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
|
|
@ -115,9 +120,7 @@ class TestSubprocessBuffering:
|
||||||
|
|
||||||
buffered_line = json.dumps(json_obj1) + "\n\n\n" + json.dumps(json_obj2)
|
buffered_line = json.dumps(json_obj1) + "\n\n\n" + json.dumps(json_obj2)
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
|
|
@ -161,9 +164,7 @@ class TestSubprocessBuffering:
|
||||||
part2 = complete_json[100:250]
|
part2 = complete_json[100:250]
|
||||||
part3 = complete_json[250:]
|
part3 = complete_json[250:]
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
|
|
@ -209,9 +210,7 @@ class TestSubprocessBuffering:
|
||||||
for i in range(0, len(complete_json), chunk_size)
|
for i in range(0, len(complete_json), chunk_size)
|
||||||
]
|
]
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
|
|
@ -237,11 +236,9 @@ class TestSubprocessBuffering:
|
||||||
"""Test that exceeding buffer size raises an appropriate error."""
|
"""Test that exceeding buffer size raises an appropriate error."""
|
||||||
|
|
||||||
async def _test() -> None:
|
async def _test() -> None:
|
||||||
huge_incomplete = '{"data": "' + "x" * (_MAX_BUFFER_SIZE + 1000)
|
huge_incomplete = '{"data": "' + "x" * (_DEFAULT_MAX_BUFFER_SIZE + 1000)
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
|
|
@ -260,6 +257,33 @@ class TestSubprocessBuffering:
|
||||||
|
|
||||||
anyio.run(_test)
|
anyio.run(_test)
|
||||||
|
|
||||||
|
def test_buffer_size_option(self) -> None:
|
||||||
|
"""Test that the configurable buffer size option is respected."""
|
||||||
|
|
||||||
|
async def _test() -> None:
|
||||||
|
custom_limit = 512
|
||||||
|
huge_incomplete = '{"data": "' + "x" * (custom_limit + 10)
|
||||||
|
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(max_buffer_size=custom_limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_process = MagicMock()
|
||||||
|
mock_process.returncode = None
|
||||||
|
mock_process.wait = AsyncMock(return_value=None)
|
||||||
|
transport._process = mock_process
|
||||||
|
transport._stdout_stream = MockTextReceiveStream([huge_incomplete])
|
||||||
|
transport._stderr_stream = MockTextReceiveStream([])
|
||||||
|
|
||||||
|
with pytest.raises(CLIJSONDecodeError) as exc_info:
|
||||||
|
async for _ in transport.read_messages():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert f"maximum buffer size of {custom_limit} bytes" in str(exc_info.value)
|
||||||
|
|
||||||
|
anyio.run(_test)
|
||||||
|
|
||||||
def test_mixed_complete_and_split_json(self) -> None:
|
def test_mixed_complete_and_split_json(self) -> None:
|
||||||
"""Test handling a mix of complete and split JSON messages."""
|
"""Test handling a mix of complete and split JSON messages."""
|
||||||
|
|
||||||
|
|
@ -281,9 +305,7 @@ class TestSubprocessBuffering:
|
||||||
large_json[3000:] + "\n" + msg3,
|
large_json[3000:] + "\n" + msg3,
|
||||||
]
|
]
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
|
|
|
||||||
488
tests/test_tool_callbacks.py
Normal file
488
tests/test_tool_callbacks.py
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
"""Tests for tool permission callbacks and hook callbacks."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
HookContext,
|
||||||
|
HookInput,
|
||||||
|
HookJSONOutput,
|
||||||
|
HookMatcher,
|
||||||
|
PermissionResultAllow,
|
||||||
|
PermissionResultDeny,
|
||||||
|
ToolPermissionContext,
|
||||||
|
)
|
||||||
|
from claude_agent_sdk._internal.query import Query
|
||||||
|
from claude_agent_sdk._internal.transport import Transport
|
||||||
|
|
||||||
|
|
||||||
|
class MockTransport(Transport):
|
||||||
|
"""Mock transport for testing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.written_messages = []
|
||||||
|
self.messages_to_read = []
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self._connected = True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
async def write(self, data: str) -> None:
|
||||||
|
self.written_messages.append(data)
|
||||||
|
|
||||||
|
async def end_input(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def read_messages(self):
|
||||||
|
async def _read():
|
||||||
|
for msg in self.messages_to_read:
|
||||||
|
yield msg
|
||||||
|
|
||||||
|
return _read()
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolPermissionCallbacks:
|
||||||
|
"""Test tool permission callback functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permission_callback_allow(self):
|
||||||
|
"""Test callback that allows tool execution."""
|
||||||
|
callback_invoked = False
|
||||||
|
|
||||||
|
async def allow_callback(
|
||||||
|
tool_name: str, input_data: dict, context: ToolPermissionContext
|
||||||
|
) -> PermissionResultAllow:
|
||||||
|
nonlocal callback_invoked
|
||||||
|
callback_invoked = True
|
||||||
|
assert tool_name == "TestTool"
|
||||||
|
assert input_data == {"param": "value"}
|
||||||
|
return PermissionResultAllow()
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
query = Query(
|
||||||
|
transport=transport,
|
||||||
|
is_streaming_mode=True,
|
||||||
|
can_use_tool=allow_callback,
|
||||||
|
hooks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate control request
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-1",
|
||||||
|
"request": {
|
||||||
|
"subtype": "can_use_tool",
|
||||||
|
"tool_name": "TestTool",
|
||||||
|
"input": {"param": "value"},
|
||||||
|
"permission_suggestions": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check callback was invoked
|
||||||
|
assert callback_invoked
|
||||||
|
|
||||||
|
# Check response was sent
|
||||||
|
assert len(transport.written_messages) == 1
|
||||||
|
response = transport.written_messages[0]
|
||||||
|
assert '"behavior": "allow"' in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permission_callback_deny(self):
|
||||||
|
"""Test callback that denies tool execution."""
|
||||||
|
|
||||||
|
async def deny_callback(
|
||||||
|
tool_name: str, input_data: dict, context: ToolPermissionContext
|
||||||
|
) -> PermissionResultDeny:
|
||||||
|
return PermissionResultDeny(message="Security policy violation")
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
query = Query(
|
||||||
|
transport=transport,
|
||||||
|
is_streaming_mode=True,
|
||||||
|
can_use_tool=deny_callback,
|
||||||
|
hooks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-2",
|
||||||
|
"request": {
|
||||||
|
"subtype": "can_use_tool",
|
||||||
|
"tool_name": "DangerousTool",
|
||||||
|
"input": {"command": "rm -rf /"},
|
||||||
|
"permission_suggestions": ["deny"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check response
|
||||||
|
assert len(transport.written_messages) == 1
|
||||||
|
response = transport.written_messages[0]
|
||||||
|
assert '"behavior": "deny"' in response
|
||||||
|
assert '"message": "Security policy violation"' in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permission_callback_input_modification(self):
|
||||||
|
"""Test callback that modifies tool input."""
|
||||||
|
|
||||||
|
async def modify_callback(
|
||||||
|
tool_name: str, input_data: dict, context: ToolPermissionContext
|
||||||
|
) -> PermissionResultAllow:
|
||||||
|
# Modify the input to add safety flag
|
||||||
|
modified_input = input_data.copy()
|
||||||
|
modified_input["safe_mode"] = True
|
||||||
|
return PermissionResultAllow(updated_input=modified_input)
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
query = Query(
|
||||||
|
transport=transport,
|
||||||
|
is_streaming_mode=True,
|
||||||
|
can_use_tool=modify_callback,
|
||||||
|
hooks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-3",
|
||||||
|
"request": {
|
||||||
|
"subtype": "can_use_tool",
|
||||||
|
"tool_name": "WriteTool",
|
||||||
|
"input": {"file_path": "/etc/passwd"},
|
||||||
|
"permission_suggestions": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check response includes modified input
|
||||||
|
assert len(transport.written_messages) == 1
|
||||||
|
response = transport.written_messages[0]
|
||||||
|
assert '"behavior": "allow"' in response
|
||||||
|
assert '"safe_mode": true' in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_exception_handling(self):
|
||||||
|
"""Test that callback exceptions are properly handled."""
|
||||||
|
|
||||||
|
async def error_callback(
|
||||||
|
tool_name: str, input_data: dict, context: ToolPermissionContext
|
||||||
|
) -> PermissionResultAllow:
|
||||||
|
raise ValueError("Callback error")
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
query = Query(
|
||||||
|
transport=transport,
|
||||||
|
is_streaming_mode=True,
|
||||||
|
can_use_tool=error_callback,
|
||||||
|
hooks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-5",
|
||||||
|
"request": {
|
||||||
|
"subtype": "can_use_tool",
|
||||||
|
"tool_name": "TestTool",
|
||||||
|
"input": {},
|
||||||
|
"permission_suggestions": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check error response was sent
|
||||||
|
assert len(transport.written_messages) == 1
|
||||||
|
response = transport.written_messages[0]
|
||||||
|
assert '"subtype": "error"' in response
|
||||||
|
assert "Callback error" in response
|
||||||
|
|
||||||
|
|
||||||
|
class TestHookCallbacks:
|
||||||
|
"""Test hook callback functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_execution(self):
|
||||||
|
"""Test that hooks are called at appropriate times."""
|
||||||
|
hook_calls = []
|
||||||
|
|
||||||
|
async def test_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> dict:
|
||||||
|
hook_calls.append({"input": input_data, "tool_use_id": tool_use_id})
|
||||||
|
return {"processed": True}
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
|
||||||
|
# Create hooks configuration
|
||||||
|
hooks = {
|
||||||
|
"tool_use_start": [{"matcher": {"tool": "TestTool"}, "hooks": [test_hook]}]
|
||||||
|
}
|
||||||
|
|
||||||
|
query = Query(
|
||||||
|
transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
|
||||||
|
)
|
||||||
|
|
||||||
|
# Manually register the hook callback to avoid needing the full initialize flow
|
||||||
|
callback_id = "test_hook_0"
|
||||||
|
query.hook_callbacks[callback_id] = test_hook
|
||||||
|
|
||||||
|
# Simulate hook callback request
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-hook-1",
|
||||||
|
"request": {
|
||||||
|
"subtype": "hook_callback",
|
||||||
|
"callback_id": callback_id,
|
||||||
|
"input": {"test": "data"},
|
||||||
|
"tool_use_id": "tool-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check hook was called
|
||||||
|
assert len(hook_calls) == 1
|
||||||
|
assert hook_calls[0]["input"] == {"test": "data"}
|
||||||
|
assert hook_calls[0]["tool_use_id"] == "tool-123"
|
||||||
|
|
||||||
|
# Check response
|
||||||
|
assert len(transport.written_messages) > 0
|
||||||
|
last_response = transport.written_messages[-1]
|
||||||
|
assert '"processed": true' in last_response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_output_fields(self):
|
||||||
|
"""Test that all SyncHookJSONOutput fields are properly handled."""
|
||||||
|
|
||||||
|
# Test all SyncHookJSONOutput fields together
|
||||||
|
async def comprehensive_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
return {
|
||||||
|
# Control fields
|
||||||
|
"continue_": True,
|
||||||
|
"suppressOutput": False,
|
||||||
|
"stopReason": "Test stop reason",
|
||||||
|
# Decision fields
|
||||||
|
"decision": "block",
|
||||||
|
"systemMessage": "Test system message",
|
||||||
|
"reason": "Test reason for blocking",
|
||||||
|
# Hook-specific output with all PreToolUse fields
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": "Security policy violation",
|
||||||
|
"updatedInput": {"modified": "input"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
hooks = {
|
||||||
|
"PreToolUse": [
|
||||||
|
{"matcher": {"tool": "TestTool"}, "hooks": [comprehensive_hook]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
query = Query(
|
||||||
|
transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_id = "test_comprehensive_hook"
|
||||||
|
query.hook_callbacks[callback_id] = comprehensive_hook
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-comprehensive",
|
||||||
|
"request": {
|
||||||
|
"subtype": "hook_callback",
|
||||||
|
"callback_id": callback_id,
|
||||||
|
"input": {"test": "data"},
|
||||||
|
"tool_use_id": "tool-456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check response contains all the fields
|
||||||
|
assert len(transport.written_messages) > 0
|
||||||
|
last_response = transport.written_messages[-1]
|
||||||
|
|
||||||
|
# Parse the JSON response
|
||||||
|
response_data = json.loads(last_response)
|
||||||
|
# The hook result is nested at response.response
|
||||||
|
result = response_data["response"]["response"]
|
||||||
|
|
||||||
|
# Verify control fields are present and converted to CLI format
|
||||||
|
assert result.get("continue") is True, (
|
||||||
|
"continue_ should be converted to continue"
|
||||||
|
)
|
||||||
|
assert "continue_" not in result, "continue_ should not appear in CLI output"
|
||||||
|
assert result.get("suppressOutput") is False
|
||||||
|
assert result.get("stopReason") == "Test stop reason"
|
||||||
|
|
||||||
|
# Verify decision fields are present
|
||||||
|
assert result.get("decision") == "block"
|
||||||
|
assert result.get("reason") == "Test reason for blocking"
|
||||||
|
assert result.get("systemMessage") == "Test system message"
|
||||||
|
|
||||||
|
# Verify hook-specific output is present
|
||||||
|
hook_output = result.get("hookSpecificOutput", {})
|
||||||
|
assert hook_output.get("hookEventName") == "PreToolUse"
|
||||||
|
assert hook_output.get("permissionDecision") == "deny"
|
||||||
|
assert (
|
||||||
|
hook_output.get("permissionDecisionReason") == "Security policy violation"
|
||||||
|
)
|
||||||
|
assert "updatedInput" in hook_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_hook_output(self):
|
||||||
|
"""Test AsyncHookJSONOutput type with proper async fields."""
|
||||||
|
|
||||||
|
async def async_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
# Test that async hooks properly use async_ and asyncTimeout fields
|
||||||
|
return {
|
||||||
|
"async_": True,
|
||||||
|
"asyncTimeout": 5000,
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
hooks = {"PreToolUse": [{"matcher": None, "hooks": [async_hook]}]}
|
||||||
|
|
||||||
|
query = Query(
|
||||||
|
transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_id = "test_async_hook"
|
||||||
|
query.hook_callbacks[callback_id] = async_hook
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-async",
|
||||||
|
"request": {
|
||||||
|
"subtype": "hook_callback",
|
||||||
|
"callback_id": callback_id,
|
||||||
|
"input": {"test": "async_data"},
|
||||||
|
"tool_use_id": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check response contains async fields
|
||||||
|
assert len(transport.written_messages) > 0
|
||||||
|
last_response = transport.written_messages[-1]
|
||||||
|
|
||||||
|
# Parse the JSON response
|
||||||
|
response_data = json.loads(last_response)
|
||||||
|
# The hook result is nested at response.response
|
||||||
|
result = response_data["response"]["response"]
|
||||||
|
|
||||||
|
# The SDK should convert async_ to "async" for CLI compatibility
|
||||||
|
assert result.get("async") is True, "async_ should be converted to async"
|
||||||
|
assert "async_" not in result, "async_ should not appear in CLI output"
|
||||||
|
assert result.get("asyncTimeout") == 5000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_field_name_conversion(self):
|
||||||
|
"""Test that Python-safe field names (async_, continue_) are converted to CLI format (async, continue)."""
|
||||||
|
|
||||||
|
async def conversion_test_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
# Return both async_ and continue_ to test conversion
|
||||||
|
return {
|
||||||
|
"async_": True,
|
||||||
|
"asyncTimeout": 10000,
|
||||||
|
"continue_": False,
|
||||||
|
"stopReason": "Testing field conversion",
|
||||||
|
"systemMessage": "Fields should be converted",
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = MockTransport()
|
||||||
|
hooks = {"PreToolUse": [{"matcher": None, "hooks": [conversion_test_hook]}]}
|
||||||
|
|
||||||
|
query = Query(
|
||||||
|
transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_id = "test_conversion"
|
||||||
|
query.hook_callbacks[callback_id] = conversion_test_hook
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"type": "control_request",
|
||||||
|
"request_id": "test-conversion",
|
||||||
|
"request": {
|
||||||
|
"subtype": "hook_callback",
|
||||||
|
"callback_id": callback_id,
|
||||||
|
"input": {"test": "data"},
|
||||||
|
"tool_use_id": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await query._handle_control_request(request)
|
||||||
|
|
||||||
|
# Check response has converted field names
|
||||||
|
assert len(transport.written_messages) > 0
|
||||||
|
last_response = transport.written_messages[-1]
|
||||||
|
|
||||||
|
response_data = json.loads(last_response)
|
||||||
|
result = response_data["response"]["response"]
|
||||||
|
|
||||||
|
# Verify async_ was converted to async
|
||||||
|
assert result.get("async") is True, "async_ should be converted to async"
|
||||||
|
assert "async_" not in result, "async_ should not appear in output"
|
||||||
|
|
||||||
|
# Verify continue_ was converted to continue
|
||||||
|
assert result.get("continue") is False, (
|
||||||
|
"continue_ should be converted to continue"
|
||||||
|
)
|
||||||
|
assert "continue_" not in result, "continue_ should not appear in output"
|
||||||
|
|
||||||
|
# Verify other fields are unchanged
|
||||||
|
assert result.get("asyncTimeout") == 10000
|
||||||
|
assert result.get("stopReason") == "Testing field conversion"
|
||||||
|
assert result.get("systemMessage") == "Fields should be converted"
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaudeAgentOptionsIntegration:
|
||||||
|
"""Test that callbacks work through ClaudeAgentOptions."""
|
||||||
|
|
||||||
|
def test_options_with_callbacks(self):
|
||||||
|
"""Test creating options with callbacks."""
|
||||||
|
|
||||||
|
async def my_callback(
|
||||||
|
tool_name: str, input_data: dict, context: ToolPermissionContext
|
||||||
|
) -> PermissionResultAllow:
|
||||||
|
return PermissionResultAllow()
|
||||||
|
|
||||||
|
async def my_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> dict:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
can_use_tool=my_callback,
|
||||||
|
hooks={
|
||||||
|
"tool_use_start": [
|
||||||
|
HookMatcher(matcher={"tool": "Bash"}, hooks=[my_hook])
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert options.can_use_tool == my_callback
|
||||||
|
assert "tool_use_start" in options.hooks
|
||||||
|
assert len(options.hooks["tool_use_start"]) == 1
|
||||||
|
assert options.hooks["tool_use_start"][0].hooks[0] == my_hook
|
||||||
|
|
@ -7,8 +7,17 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
|
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
|
||||||
from claude_code_sdk.types import ClaudeCodeOptions
|
from claude_agent_sdk.types import ClaudeAgentOptions
|
||||||
|
|
||||||
|
DEFAULT_CLI_PATH = "/usr/bin/claude"
|
||||||
|
|
||||||
|
|
||||||
|
def make_options(**kwargs: object) -> ClaudeAgentOptions:
|
||||||
|
"""Construct options using the standard CLI path unless overridden."""
|
||||||
|
|
||||||
|
cli_path = kwargs.pop("cli_path", DEFAULT_CLI_PATH)
|
||||||
|
return ClaudeAgentOptions(cli_path=cli_path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TestSubprocessCLITransport:
|
class TestSubprocessCLITransport:
|
||||||
|
|
@ -16,22 +25,20 @@ class TestSubprocessCLITransport:
|
||||||
|
|
||||||
def test_find_cli_not_found(self):
|
def test_find_cli_not_found(self):
|
||||||
"""Test CLI not found error."""
|
"""Test CLI not found error."""
|
||||||
from claude_code_sdk._errors import CLINotFoundError
|
from claude_agent_sdk._errors import CLINotFoundError
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("shutil.which", return_value=None),
|
patch("shutil.which", return_value=None),
|
||||||
patch("pathlib.Path.exists", return_value=False),
|
patch("pathlib.Path.exists", return_value=False),
|
||||||
pytest.raises(CLINotFoundError) as exc_info,
|
pytest.raises(CLINotFoundError) as exc_info,
|
||||||
):
|
):
|
||||||
SubprocessCLITransport(prompt="test", options=ClaudeCodeOptions())
|
SubprocessCLITransport(prompt="test", options=ClaudeAgentOptions())
|
||||||
|
|
||||||
assert "Claude Code requires Node.js" in str(exc_info.value)
|
assert "Claude Code not found" in str(exc_info.value)
|
||||||
|
|
||||||
def test_build_command_basic(self):
|
def test_build_command_basic(self):
|
||||||
"""Test building basic CLI command."""
|
"""Test building basic CLI command."""
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="Hello", options=make_options())
|
||||||
prompt="Hello", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
assert cmd[0] == "/usr/bin/claude"
|
assert cmd[0] == "/usr/bin/claude"
|
||||||
|
|
@ -39,72 +46,146 @@ 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."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path("/usr/bin/claude")
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="Hello",
|
prompt="Hello",
|
||||||
options=ClaudeCodeOptions(),
|
options=ClaudeAgentOptions(cli_path=path),
|
||||||
cli_path=Path("/usr/bin/claude"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert transport._cli_path == "/usr/bin/claude"
|
# Path object is converted to string, compare with str(path)
|
||||||
|
assert transport._cli_path == str(path)
|
||||||
|
|
||||||
def test_build_command_with_options(self):
|
def test_build_command_with_system_prompt_string(self):
|
||||||
"""Test building CLI command with options."""
|
"""Test building CLI command with system prompt as string."""
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(
|
options=make_options(
|
||||||
system_prompt="Be helpful",
|
system_prompt="Be helpful",
|
||||||
allowed_tools=["Read", "Write"],
|
|
||||||
disallowed_tools=["Bash"],
|
|
||||||
model="claude-3-5-sonnet",
|
|
||||||
permission_mode="acceptEdits",
|
|
||||||
max_turns=5,
|
|
||||||
),
|
),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
assert "--system-prompt" in cmd
|
assert "--system-prompt" in cmd
|
||||||
assert "Be helpful" in cmd
|
assert "Be helpful" in cmd
|
||||||
|
|
||||||
|
def test_build_command_with_system_prompt_preset(self):
|
||||||
|
"""Test building CLI command with system prompt preset."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(
|
||||||
|
system_prompt={"type": "preset", "preset": "claude_code"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
assert "--system-prompt" not in cmd
|
||||||
|
assert "--append-system-prompt" not in cmd
|
||||||
|
|
||||||
|
def test_build_command_with_system_prompt_preset_and_append(self):
|
||||||
|
"""Test building CLI command with system prompt preset and append."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(
|
||||||
|
system_prompt={
|
||||||
|
"type": "preset",
|
||||||
|
"preset": "claude_code",
|
||||||
|
"append": "Be concise.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
assert "--system-prompt" not in cmd
|
||||||
|
assert "--append-system-prompt" in cmd
|
||||||
|
assert "Be concise." in cmd
|
||||||
|
|
||||||
|
def test_build_command_with_options(self):
|
||||||
|
"""Test building CLI command with options."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(
|
||||||
|
allowed_tools=["Read", "Write"],
|
||||||
|
disallowed_tools=["Bash"],
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
permission_mode="acceptEdits",
|
||||||
|
max_turns=5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
assert "--allowedTools" in cmd
|
assert "--allowedTools" in cmd
|
||||||
assert "Read,Write" in cmd
|
assert "Read,Write" in cmd
|
||||||
assert "--disallowedTools" in cmd
|
assert "--disallowedTools" in cmd
|
||||||
assert "Bash" in cmd
|
assert "Bash" in cmd
|
||||||
assert "--model" in cmd
|
assert "--model" in cmd
|
||||||
assert "claude-3-5-sonnet" in cmd
|
assert "claude-sonnet-4-5" in cmd
|
||||||
assert "--permission-mode" in cmd
|
assert "--permission-mode" in cmd
|
||||||
assert "acceptEdits" in cmd
|
assert "acceptEdits" in cmd
|
||||||
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
|
||||||
|
|
||||||
|
dir1 = "/path/to/dir1"
|
||||||
|
dir2 = Path("/path/to/dir2")
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(
|
options=make_options(add_dirs=[dir1, dir2]),
|
||||||
add_dirs=["/path/to/dir1", Path("/path/to/dir2")]
|
|
||||||
),
|
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
cmd_str = " ".join(cmd)
|
|
||||||
|
|
||||||
# Check that the command string contains the expected --add-dir flags
|
# Check that both directories are in the command
|
||||||
assert "--add-dir /path/to/dir1 --add-dir /path/to/dir2" in cmd_str
|
assert "--add-dir" in cmd
|
||||||
|
add_dir_indices = [i for i, x in enumerate(cmd) if x == "--add-dir"]
|
||||||
|
assert len(add_dir_indices) == 2
|
||||||
|
|
||||||
|
# The directories should appear after --add-dir flags
|
||||||
|
dirs_in_cmd = [cmd[i + 1] for i in add_dir_indices]
|
||||||
|
assert dir1 in dirs_in_cmd
|
||||||
|
assert str(dir2) in dirs_in_cmd
|
||||||
|
|
||||||
def test_session_continuation(self):
|
def test_session_continuation(self):
|
||||||
"""Test session continuation options."""
|
"""Test session continuation options."""
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="Continue from before",
|
prompt="Continue from before",
|
||||||
options=ClaudeCodeOptions(continue_conversation=True, resume="session-123"),
|
options=make_options(continue_conversation=True, resume="session-123"),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
|
|
@ -117,6 +198,16 @@ class TestSubprocessCLITransport:
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
with patch("anyio.open_process") as mock_exec:
|
with patch("anyio.open_process") as mock_exec:
|
||||||
|
# Mock version check process
|
||||||
|
mock_version_process = MagicMock()
|
||||||
|
mock_version_process.stdout = MagicMock()
|
||||||
|
mock_version_process.stdout.receive = AsyncMock(
|
||||||
|
return_value=b"2.0.0 (Claude Code)"
|
||||||
|
)
|
||||||
|
mock_version_process.terminate = MagicMock()
|
||||||
|
mock_version_process.wait = AsyncMock()
|
||||||
|
|
||||||
|
# Mock main process
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
mock_process.terminate = MagicMock()
|
mock_process.terminate = MagicMock()
|
||||||
|
|
@ -129,12 +220,12 @@ class TestSubprocessCLITransport:
|
||||||
mock_stdin.aclose = AsyncMock()
|
mock_stdin.aclose = AsyncMock()
|
||||||
mock_process.stdin = mock_stdin
|
mock_process.stdin = mock_stdin
|
||||||
|
|
||||||
mock_exec.return_value = mock_process
|
# Return version process first, then main process
|
||||||
|
mock_exec.side_effect = [mock_version_process, mock_process]
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(),
|
options=make_options(),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await transport.connect()
|
await transport.connect()
|
||||||
|
|
@ -150,9 +241,7 @@ class TestSubprocessCLITransport:
|
||||||
"""Test reading messages from CLI output."""
|
"""Test reading messages from CLI output."""
|
||||||
# This test is simplified to just test the transport creation
|
# This test is simplified to just test the transport creation
|
||||||
# The full async stream handling is tested in integration tests
|
# The full async stream handling is tested in integration tests
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(prompt="test", options=make_options())
|
||||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
# The transport now just provides raw message reading via read_messages()
|
# The transport now just provides raw message reading via read_messages()
|
||||||
# So we just verify the transport can be created and basic structure is correct
|
# So we just verify the transport can be created and basic structure is correct
|
||||||
|
|
@ -161,13 +250,12 @@ class TestSubprocessCLITransport:
|
||||||
|
|
||||||
def test_connect_with_nonexistent_cwd(self):
|
def test_connect_with_nonexistent_cwd(self):
|
||||||
"""Test that connect raises CLIConnectionError when cwd doesn't exist."""
|
"""Test that connect raises CLIConnectionError when cwd doesn't exist."""
|
||||||
from claude_code_sdk._errors import CLIConnectionError
|
from claude_agent_sdk._errors import CLIConnectionError
|
||||||
|
|
||||||
async def _test():
|
async def _test():
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(cwd="/this/directory/does/not/exist"),
|
options=make_options(cwd="/this/directory/does/not/exist"),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(CLIConnectionError) as exc_info:
|
with pytest.raises(CLIConnectionError) as exc_info:
|
||||||
|
|
@ -181,8 +269,7 @@ class TestSubprocessCLITransport:
|
||||||
"""Test building CLI command with settings as file path."""
|
"""Test building CLI command with settings as file path."""
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(settings="/path/to/settings.json"),
|
options=make_options(settings="/path/to/settings.json"),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
|
|
@ -194,8 +281,7 @@ class TestSubprocessCLITransport:
|
||||||
settings_json = '{"permissions": {"allow": ["Bash(ls:*)"]}}'
|
settings_json = '{"permissions": {"allow": ["Bash(ls:*)"]}}'
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(settings=settings_json),
|
options=make_options(settings=settings_json),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
|
|
@ -206,14 +292,13 @@ class TestSubprocessCLITransport:
|
||||||
"""Test building CLI command with extra_args for future flags."""
|
"""Test building CLI command with extra_args for future flags."""
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(
|
options=make_options(
|
||||||
extra_args={
|
extra_args={
|
||||||
"new-flag": "value",
|
"new-flag": "value",
|
||||||
"boolean-flag": None,
|
"boolean-flag": None,
|
||||||
"another-option": "test-value",
|
"another-option": "test-value",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
|
|
@ -244,8 +329,7 @@ class TestSubprocessCLITransport:
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(mcp_servers=mcp_servers),
|
options=make_options(mcp_servers=mcp_servers),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
|
|
@ -265,36 +349,36 @@ class TestSubprocessCLITransport:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Test with string path
|
# Test with string path
|
||||||
|
string_path = "/path/to/mcp-config.json"
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(mcp_servers="/path/to/mcp-config.json"),
|
options=make_options(mcp_servers=string_path),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
assert "--mcp-config" in cmd
|
assert "--mcp-config" in cmd
|
||||||
mcp_idx = cmd.index("--mcp-config")
|
mcp_idx = cmd.index("--mcp-config")
|
||||||
assert cmd[mcp_idx + 1] == "/path/to/mcp-config.json"
|
assert cmd[mcp_idx + 1] == string_path
|
||||||
|
|
||||||
# Test with Path object
|
# Test with Path object
|
||||||
|
path_obj = Path("/path/to/mcp-config.json")
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(mcp_servers=Path("/path/to/mcp-config.json")),
|
options=make_options(mcp_servers=path_obj),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
assert "--mcp-config" in cmd
|
assert "--mcp-config" in cmd
|
||||||
mcp_idx = cmd.index("--mcp-config")
|
mcp_idx = cmd.index("--mcp-config")
|
||||||
assert cmd[mcp_idx + 1] == "/path/to/mcp-config.json"
|
# Path object gets converted to string, compare with str(path_obj)
|
||||||
|
assert cmd[mcp_idx + 1] == str(path_obj)
|
||||||
|
|
||||||
def test_build_command_with_mcp_servers_as_json_string(self):
|
def test_build_command_with_mcp_servers_as_json_string(self):
|
||||||
"""Test building CLI command with mcp_servers as JSON string."""
|
"""Test building CLI command with mcp_servers as JSON string."""
|
||||||
json_config = '{"mcpServers": {"server": {"type": "stdio", "command": "test"}}}'
|
json_config = '{"mcpServers": {"server": {"type": "stdio", "command": "test"}}}'
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=ClaudeCodeOptions(mcp_servers=json_config),
|
options=make_options(mcp_servers=json_config),
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = transport._build_command()
|
cmd = transport._build_command()
|
||||||
|
|
@ -311,33 +395,46 @@ class TestSubprocessCLITransport:
|
||||||
"MY_TEST_VAR": test_value,
|
"MY_TEST_VAR": test_value,
|
||||||
}
|
}
|
||||||
|
|
||||||
options = ClaudeCodeOptions(env=custom_env)
|
options = make_options(env=custom_env)
|
||||||
|
|
||||||
# Mock the subprocess to capture the env argument
|
# Mock the subprocess to capture the env argument
|
||||||
with patch(
|
with patch(
|
||||||
"anyio.open_process", new_callable=AsyncMock
|
"anyio.open_process", new_callable=AsyncMock
|
||||||
) as mock_open_process:
|
) as mock_open_process:
|
||||||
|
# Mock version check process
|
||||||
|
mock_version_process = MagicMock()
|
||||||
|
mock_version_process.stdout = MagicMock()
|
||||||
|
mock_version_process.stdout.receive = AsyncMock(
|
||||||
|
return_value=b"2.0.0 (Claude Code)"
|
||||||
|
)
|
||||||
|
mock_version_process.terminate = MagicMock()
|
||||||
|
mock_version_process.wait = AsyncMock()
|
||||||
|
|
||||||
|
# Mock main process
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.stdout = MagicMock()
|
mock_process.stdout = MagicMock()
|
||||||
mock_stdin = MagicMock()
|
mock_stdin = MagicMock()
|
||||||
mock_stdin.aclose = AsyncMock() # Add async aclose method
|
mock_stdin.aclose = AsyncMock() # Add async aclose method
|
||||||
mock_process.stdin = mock_stdin
|
mock_process.stdin = mock_stdin
|
||||||
mock_process.returncode = None
|
mock_process.returncode = None
|
||||||
mock_open_process.return_value = mock_process
|
|
||||||
|
# Return version process first, then main process
|
||||||
|
mock_open_process.side_effect = [mock_version_process, mock_process]
|
||||||
|
|
||||||
transport = SubprocessCLITransport(
|
transport = SubprocessCLITransport(
|
||||||
prompt="test",
|
prompt="test",
|
||||||
options=options,
|
options=options,
|
||||||
cli_path="/usr/bin/claude",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await transport.connect()
|
await transport.connect()
|
||||||
|
|
||||||
# Verify open_process was called with correct env vars
|
# Verify open_process was called twice (version check + main process)
|
||||||
mock_open_process.assert_called_once()
|
assert mock_open_process.call_count == 2
|
||||||
call_kwargs = mock_open_process.call_args.kwargs
|
|
||||||
assert "env" in call_kwargs
|
# Check the second call (main process) for env vars
|
||||||
env_passed = call_kwargs["env"]
|
second_call_kwargs = mock_open_process.call_args_list[1].kwargs
|
||||||
|
assert "env" in second_call_kwargs
|
||||||
|
env_passed = second_call_kwargs["env"]
|
||||||
|
|
||||||
# Check that custom env var was passed
|
# Check that custom env var was passed
|
||||||
assert env_passed["MY_TEST_VAR"] == test_value
|
assert env_passed["MY_TEST_VAR"] == test_value
|
||||||
|
|
@ -352,3 +449,380 @@ class TestSubprocessCLITransport:
|
||||||
assert env_passed["PATH"] == os.environ["PATH"]
|
assert env_passed["PATH"] == os.environ["PATH"]
|
||||||
|
|
||||||
anyio.run(_test)
|
anyio.run(_test)
|
||||||
|
|
||||||
|
def test_connect_as_different_user(self):
|
||||||
|
"""Test connect as different user."""
|
||||||
|
|
||||||
|
async def _test():
|
||||||
|
custom_user = "claude"
|
||||||
|
options = make_options(user=custom_user)
|
||||||
|
|
||||||
|
# Mock the subprocess to capture the env argument
|
||||||
|
with patch(
|
||||||
|
"anyio.open_process", new_callable=AsyncMock
|
||||||
|
) as mock_open_process:
|
||||||
|
# Mock version check process
|
||||||
|
mock_version_process = MagicMock()
|
||||||
|
mock_version_process.stdout = MagicMock()
|
||||||
|
mock_version_process.stdout.receive = AsyncMock(
|
||||||
|
return_value=b"2.0.0 (Claude Code)"
|
||||||
|
)
|
||||||
|
mock_version_process.terminate = MagicMock()
|
||||||
|
mock_version_process.wait = AsyncMock()
|
||||||
|
|
||||||
|
# Mock main process
|
||||||
|
mock_process = MagicMock()
|
||||||
|
mock_process.stdout = MagicMock()
|
||||||
|
mock_stdin = MagicMock()
|
||||||
|
mock_stdin.aclose = AsyncMock() # Add async aclose method
|
||||||
|
mock_process.stdin = mock_stdin
|
||||||
|
mock_process.returncode = None
|
||||||
|
|
||||||
|
# Return version process first, then main process
|
||||||
|
mock_open_process.side_effect = [mock_version_process, mock_process]
|
||||||
|
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
await transport.connect()
|
||||||
|
|
||||||
|
# Verify open_process was called twice (version check + main process)
|
||||||
|
assert mock_open_process.call_count == 2
|
||||||
|
|
||||||
|
# Check the second call (main process) for user
|
||||||
|
second_call_kwargs = mock_open_process.call_args_list[1].kwargs
|
||||||
|
assert "user" in second_call_kwargs
|
||||||
|
user_passed = second_call_kwargs["user"]
|
||||||
|
|
||||||
|
# Check that user was passed
|
||||||
|
assert user_passed == "claude"
|
||||||
|
|
||||||
|
anyio.run(_test)
|
||||||
|
|
||||||
|
def test_build_command_with_sandbox_only(self):
|
||||||
|
"""Test building CLI command with sandbox settings (no existing settings)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from claude_agent_sdk import SandboxSettings
|
||||||
|
|
||||||
|
sandbox: SandboxSettings = {
|
||||||
|
"enabled": True,
|
||||||
|
"autoAllowBashIfSandboxed": True,
|
||||||
|
"network": {
|
||||||
|
"allowLocalBinding": True,
|
||||||
|
"allowUnixSockets": ["/var/run/docker.sock"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(sandbox=sandbox),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
|
||||||
|
# Should have --settings with sandbox merged in
|
||||||
|
assert "--settings" in cmd
|
||||||
|
settings_idx = cmd.index("--settings")
|
||||||
|
settings_value = cmd[settings_idx + 1]
|
||||||
|
|
||||||
|
# Parse and verify
|
||||||
|
parsed = json.loads(settings_value)
|
||||||
|
assert "sandbox" in parsed
|
||||||
|
assert parsed["sandbox"]["enabled"] is True
|
||||||
|
assert parsed["sandbox"]["autoAllowBashIfSandboxed"] is True
|
||||||
|
assert parsed["sandbox"]["network"]["allowLocalBinding"] is True
|
||||||
|
assert parsed["sandbox"]["network"]["allowUnixSockets"] == [
|
||||||
|
"/var/run/docker.sock"
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_build_command_with_sandbox_and_settings_json(self):
|
||||||
|
"""Test building CLI command with sandbox merged into existing settings JSON."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from claude_agent_sdk import SandboxSettings
|
||||||
|
|
||||||
|
# Existing settings as JSON string
|
||||||
|
existing_settings = (
|
||||||
|
'{"permissions": {"allow": ["Bash(ls:*)"]}, "verbose": true}'
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox: SandboxSettings = {
|
||||||
|
"enabled": True,
|
||||||
|
"excludedCommands": ["git", "docker"],
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(settings=existing_settings, sandbox=sandbox),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
|
||||||
|
# Should have merged settings
|
||||||
|
assert "--settings" in cmd
|
||||||
|
settings_idx = cmd.index("--settings")
|
||||||
|
settings_value = cmd[settings_idx + 1]
|
||||||
|
|
||||||
|
parsed = json.loads(settings_value)
|
||||||
|
|
||||||
|
# Original settings should be preserved
|
||||||
|
assert parsed["permissions"] == {"allow": ["Bash(ls:*)"]}
|
||||||
|
assert parsed["verbose"] is True
|
||||||
|
|
||||||
|
# Sandbox should be merged in
|
||||||
|
assert "sandbox" in parsed
|
||||||
|
assert parsed["sandbox"]["enabled"] is True
|
||||||
|
assert parsed["sandbox"]["excludedCommands"] == ["git", "docker"]
|
||||||
|
|
||||||
|
def test_build_command_with_settings_file_and_no_sandbox(self):
|
||||||
|
"""Test that settings file path is passed through when no sandbox."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(settings="/path/to/settings.json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
|
||||||
|
# Should pass path directly, not parse it
|
||||||
|
assert "--settings" in cmd
|
||||||
|
settings_idx = cmd.index("--settings")
|
||||||
|
assert cmd[settings_idx + 1] == "/path/to/settings.json"
|
||||||
|
|
||||||
|
def test_build_command_sandbox_minimal(self):
|
||||||
|
"""Test sandbox with minimal configuration."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from claude_agent_sdk import SandboxSettings
|
||||||
|
|
||||||
|
sandbox: SandboxSettings = {"enabled": True}
|
||||||
|
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(sandbox=sandbox),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
|
||||||
|
assert "--settings" in cmd
|
||||||
|
settings_idx = cmd.index("--settings")
|
||||||
|
settings_value = cmd[settings_idx + 1]
|
||||||
|
|
||||||
|
parsed = json.loads(settings_value)
|
||||||
|
assert parsed == {"sandbox": {"enabled": True}}
|
||||||
|
|
||||||
|
def test_sandbox_network_config(self):
|
||||||
|
"""Test sandbox with full network configuration."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from claude_agent_sdk import SandboxSettings
|
||||||
|
|
||||||
|
sandbox: SandboxSettings = {
|
||||||
|
"enabled": True,
|
||||||
|
"network": {
|
||||||
|
"allowUnixSockets": ["/tmp/ssh-agent.sock"],
|
||||||
|
"allowAllUnixSockets": False,
|
||||||
|
"allowLocalBinding": True,
|
||||||
|
"httpProxyPort": 8080,
|
||||||
|
"socksProxyPort": 8081,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(sandbox=sandbox),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
settings_idx = cmd.index("--settings")
|
||||||
|
settings_value = cmd[settings_idx + 1]
|
||||||
|
|
||||||
|
parsed = json.loads(settings_value)
|
||||||
|
network = parsed["sandbox"]["network"]
|
||||||
|
|
||||||
|
assert network["allowUnixSockets"] == ["/tmp/ssh-agent.sock"]
|
||||||
|
assert network["allowAllUnixSockets"] is False
|
||||||
|
assert network["allowLocalBinding"] is True
|
||||||
|
assert network["httpProxyPort"] == 8080
|
||||||
|
assert network["socksProxyPort"] == 8081
|
||||||
|
|
||||||
|
def test_build_command_with_tools_array(self):
|
||||||
|
"""Test building CLI command with tools as array of tool names."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(tools=["Read", "Edit", "Bash"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
assert "--tools" in cmd
|
||||||
|
tools_idx = cmd.index("--tools")
|
||||||
|
assert cmd[tools_idx + 1] == "Read,Edit,Bash"
|
||||||
|
|
||||||
|
def test_build_command_with_tools_empty_array(self):
|
||||||
|
"""Test building CLI command with tools as empty array (disables all tools)."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(tools=[]),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
assert "--tools" in cmd
|
||||||
|
tools_idx = cmd.index("--tools")
|
||||||
|
assert cmd[tools_idx + 1] == ""
|
||||||
|
|
||||||
|
def test_build_command_with_tools_preset(self):
|
||||||
|
"""Test building CLI command with tools preset."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(tools={"type": "preset", "preset": "claude_code"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
assert "--tools" in cmd
|
||||||
|
tools_idx = cmd.index("--tools")
|
||||||
|
assert cmd[tools_idx + 1] == "default"
|
||||||
|
|
||||||
|
def test_build_command_without_tools(self):
|
||||||
|
"""Test building CLI command without tools option (default None)."""
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=make_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = transport._build_command()
|
||||||
|
assert "--tools" not in cmd
|
||||||
|
|
||||||
|
def test_concurrent_writes_are_serialized(self):
|
||||||
|
"""Test that concurrent write() calls are serialized by the lock.
|
||||||
|
|
||||||
|
When parallel subagents invoke MCP tools, they trigger concurrent write()
|
||||||
|
calls. Without the _write_lock, trio raises BusyResourceError.
|
||||||
|
|
||||||
|
Uses a real subprocess with the same stream setup as production:
|
||||||
|
process.stdin -> TextSendStream
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _test():
|
||||||
|
import sys
|
||||||
|
from subprocess import PIPE
|
||||||
|
|
||||||
|
from anyio.streams.text import TextSendStream
|
||||||
|
|
||||||
|
# Create a real subprocess that consumes stdin (cross-platform)
|
||||||
|
process = await anyio.open_process(
|
||||||
|
[sys.executable, "-c", "import sys; sys.stdin.read()"],
|
||||||
|
stdin=PIPE,
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=ClaudeAgentOptions(cli_path="/usr/bin/claude"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same setup as production: TextSendStream wrapping process.stdin
|
||||||
|
transport._ready = True
|
||||||
|
transport._process = MagicMock(returncode=None)
|
||||||
|
transport._stdin_stream = TextSendStream(process.stdin)
|
||||||
|
|
||||||
|
# Spawn concurrent writes - the lock should serialize them
|
||||||
|
num_writes = 10
|
||||||
|
errors: list[Exception] = []
|
||||||
|
|
||||||
|
async def do_write(i: int):
|
||||||
|
try:
|
||||||
|
await transport.write(f'{{"msg": {i}}}\n')
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
for i in range(num_writes):
|
||||||
|
tg.start_soon(do_write, i)
|
||||||
|
|
||||||
|
# All writes should succeed - the lock serializes them
|
||||||
|
assert len(errors) == 0, f"Got errors: {errors}"
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
anyio.run(_test, backend="trio")
|
||||||
|
|
||||||
|
def test_concurrent_writes_fail_without_lock(self):
|
||||||
|
"""Verify that without the lock, concurrent writes cause BusyResourceError.
|
||||||
|
|
||||||
|
Uses a real subprocess with the same stream setup as production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _test():
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from subprocess import PIPE
|
||||||
|
|
||||||
|
from anyio.streams.text import TextSendStream
|
||||||
|
|
||||||
|
# Create a real subprocess that consumes stdin (cross-platform)
|
||||||
|
process = await anyio.open_process(
|
||||||
|
[sys.executable, "-c", "import sys; sys.stdin.read()"],
|
||||||
|
stdin=PIPE,
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
transport = SubprocessCLITransport(
|
||||||
|
prompt="test",
|
||||||
|
options=ClaudeAgentOptions(cli_path="/usr/bin/claude"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same setup as production
|
||||||
|
transport._ready = True
|
||||||
|
transport._process = MagicMock(returncode=None)
|
||||||
|
transport._stdin_stream = TextSendStream(process.stdin)
|
||||||
|
|
||||||
|
# Replace lock with no-op to trigger the race condition
|
||||||
|
class NoOpLock:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def __call__(self):
|
||||||
|
yield
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
transport._write_lock = NoOpLock()
|
||||||
|
|
||||||
|
# Spawn concurrent writes - should fail without lock
|
||||||
|
num_writes = 10
|
||||||
|
errors: list[Exception] = []
|
||||||
|
|
||||||
|
async def do_write(i: int):
|
||||||
|
try:
|
||||||
|
await transport.write(f'{{"msg": {i}}}\n')
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
for i in range(num_writes):
|
||||||
|
tg.start_soon(do_write, i)
|
||||||
|
|
||||||
|
# Should have gotten errors due to concurrent access
|
||||||
|
assert len(errors) > 0, (
|
||||||
|
"Expected errors from concurrent access, but got none"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that at least one error mentions the concurrent access
|
||||||
|
error_strs = [str(e) for e in errors]
|
||||||
|
assert any("another task" in s for s in error_strs), (
|
||||||
|
f"Expected 'another task' error, got: {error_strs}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
anyio.run(_test, backend="trio")
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""Tests for Claude SDK type definitions."""
|
"""Tests for Claude SDK type definitions."""
|
||||||
|
|
||||||
from claude_code_sdk import (
|
from claude_agent_sdk import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ClaudeCodeOptions,
|
ClaudeAgentOptions,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
)
|
)
|
||||||
from claude_code_sdk.types import (
|
from claude_agent_sdk.types import (
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
ToolResultBlock,
|
ToolResultBlock,
|
||||||
|
|
@ -78,9 +78,8 @@ class TestOptions:
|
||||||
|
|
||||||
def test_default_options(self):
|
def test_default_options(self):
|
||||||
"""Test Options with default values."""
|
"""Test Options with default values."""
|
||||||
options = ClaudeCodeOptions()
|
options = ClaudeAgentOptions()
|
||||||
assert options.allowed_tools == []
|
assert options.allowed_tools == []
|
||||||
assert options.max_thinking_tokens == 8000
|
|
||||||
assert options.system_prompt is None
|
assert options.system_prompt is None
|
||||||
assert options.permission_mode is None
|
assert options.permission_mode is None
|
||||||
assert options.continue_conversation is False
|
assert options.continue_conversation is False
|
||||||
|
|
@ -88,7 +87,7 @@ class TestOptions:
|
||||||
|
|
||||||
def test_claude_code_options_with_tools(self):
|
def test_claude_code_options_with_tools(self):
|
||||||
"""Test Options with built-in tools."""
|
"""Test Options with built-in tools."""
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
allowed_tools=["Read", "Write", "Edit"], disallowed_tools=["Bash"]
|
allowed_tools=["Read", "Write", "Edit"], disallowed_tools=["Bash"]
|
||||||
)
|
)
|
||||||
assert options.allowed_tools == ["Read", "Write", "Edit"]
|
assert options.allowed_tools == ["Read", "Write", "Edit"]
|
||||||
|
|
@ -96,37 +95,57 @@ class TestOptions:
|
||||||
|
|
||||||
def test_claude_code_options_with_permission_mode(self):
|
def test_claude_code_options_with_permission_mode(self):
|
||||||
"""Test Options with permission mode."""
|
"""Test Options with permission mode."""
|
||||||
options = ClaudeCodeOptions(permission_mode="bypassPermissions")
|
options = ClaudeAgentOptions(permission_mode="bypassPermissions")
|
||||||
assert options.permission_mode == "bypassPermissions"
|
assert options.permission_mode == "bypassPermissions"
|
||||||
|
|
||||||
options_plan = ClaudeCodeOptions(permission_mode="plan")
|
options_plan = ClaudeAgentOptions(permission_mode="plan")
|
||||||
assert options_plan.permission_mode == "plan"
|
assert options_plan.permission_mode == "plan"
|
||||||
|
|
||||||
options_default = ClaudeCodeOptions(permission_mode="default")
|
options_default = ClaudeAgentOptions(permission_mode="default")
|
||||||
assert options_default.permission_mode == "default"
|
assert options_default.permission_mode == "default"
|
||||||
|
|
||||||
options_accept = ClaudeCodeOptions(permission_mode="acceptEdits")
|
options_accept = ClaudeAgentOptions(permission_mode="acceptEdits")
|
||||||
assert options_accept.permission_mode == "acceptEdits"
|
assert options_accept.permission_mode == "acceptEdits"
|
||||||
|
|
||||||
def test_claude_code_options_with_system_prompt(self):
|
def test_claude_code_options_with_system_prompt_string(self):
|
||||||
"""Test Options with system prompt."""
|
"""Test Options with system prompt as string."""
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
system_prompt="You are a helpful assistant.",
|
system_prompt="You are a helpful assistant.",
|
||||||
append_system_prompt="Be concise.",
|
|
||||||
)
|
)
|
||||||
assert options.system_prompt == "You are a helpful assistant."
|
assert options.system_prompt == "You are a helpful assistant."
|
||||||
assert options.append_system_prompt == "Be concise."
|
|
||||||
|
def test_claude_code_options_with_system_prompt_preset(self):
|
||||||
|
"""Test Options with system prompt preset."""
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
system_prompt={"type": "preset", "preset": "claude_code"},
|
||||||
|
)
|
||||||
|
assert options.system_prompt == {"type": "preset", "preset": "claude_code"}
|
||||||
|
|
||||||
|
def test_claude_code_options_with_system_prompt_preset_and_append(self):
|
||||||
|
"""Test Options with system prompt preset and append."""
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
system_prompt={
|
||||||
|
"type": "preset",
|
||||||
|
"preset": "claude_code",
|
||||||
|
"append": "Be concise.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert options.system_prompt == {
|
||||||
|
"type": "preset",
|
||||||
|
"preset": "claude_code",
|
||||||
|
"append": "Be concise.",
|
||||||
|
}
|
||||||
|
|
||||||
def test_claude_code_options_with_session_continuation(self):
|
def test_claude_code_options_with_session_continuation(self):
|
||||||
"""Test Options with session continuation."""
|
"""Test Options with session continuation."""
|
||||||
options = ClaudeCodeOptions(continue_conversation=True, resume="session-123")
|
options = ClaudeAgentOptions(continue_conversation=True, resume="session-123")
|
||||||
assert options.continue_conversation is True
|
assert options.continue_conversation is True
|
||||||
assert options.resume == "session-123"
|
assert options.resume == "session-123"
|
||||||
|
|
||||||
def test_claude_code_options_with_model_specification(self):
|
def test_claude_code_options_with_model_specification(self):
|
||||||
"""Test Options with model specification."""
|
"""Test Options with model specification."""
|
||||||
options = ClaudeCodeOptions(
|
options = ClaudeAgentOptions(
|
||||||
model="claude-3-5-sonnet-20241022", permission_prompt_tool_name="CustomTool"
|
model="claude-sonnet-4-5", permission_prompt_tool_name="CustomTool"
|
||||||
)
|
)
|
||||||
assert options.model == "claude-3-5-sonnet-20241022"
|
assert options.model == "claude-sonnet-4-5"
|
||||||
assert options.permission_prompt_tool_name == "CustomTool"
|
assert options.permission_prompt_tool_name == "CustomTool"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue