feat: bundle Claude Code CLI in pip package (#283)

Bundle platform-specific Claude Code CLI binaries directly in the Python
package, eliminating the need for separate CLI installation.

## Changes

### Build System
- Created `scripts/download_cli.py` to fetch CLI during build
- Created `scripts/build_wheel.py` for building platform-specific wheels
- Created `scripts/update_cli_version.py` to track bundled CLI version
- Updated `pyproject.toml` to properly bundle CLI without duplicate file
warnings
- Made twine check non-blocking (License-File warnings are false
positives)

### Runtime
- Modified `subprocess_cli.py` to check for bundled CLI first
- Added `_cli_version.py` to track which CLI version is bundled
- SDK automatically uses bundled CLI, falling back to system
installation if not found
- Users can still override with `cli_path` option

### Release Workflow
- Updated GitHub workflow to build separate wheels per platform (macOS,
Linux, Windows)
- Workflow now accepts two inputs:
  - `version`: Package version to publish (e.g., `0.1.5`)
- `claude_code_version`: CLI version to bundle (e.g., `2.0.0` or
`latest`)
- Workflow builds platform-specific wheels with bundled CLI
- Creates release PR that updates:
  - `pyproject.toml` version
  - `src/claude_agent_sdk/_version.py`
  - `src/claude_agent_sdk/_cli_version.py` with bundled CLI version
  - `CHANGELOG.md` with auto-generated release notes

### Documentation
- Updated README to reflect bundled CLI (removed Node.js requirement)
- Added release workflow documentation
- Added local wheel building instructions

## Benefits

- **Zero external dependencies**: No need for Node.js or npm
- **Easier installation**: Single `pip install` gets everything
- **Version control**: Track exactly which CLI version is bundled
- **Flexible releases**: Can release new package versions with updated
CLI without code changes
- **Better user experience**: Works out of the box with no setup

Platform-specific wheels are automatically selected by pip during
installation based on the user's OS and architecture.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ashwin Bhat 2025-11-18 17:21:15 -08:00 committed by GitHub
parent 6f209075bc
commit ce99e9d2eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 787 additions and 95 deletions

View file

@ -4,9 +4,14 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g., 0.1.0)"
description: 'Package version to publish (e.g., 0.1.4)'
required: true
type: string
claude_code_version:
description: 'Claude Code CLI version to bundle (e.g., 2.0.0 or latest)'
required: false
type: string
default: 'latest'
jobs:
test:
runs-on: ubuntu-latest
@ -56,114 +61,165 @@ jobs:
run: |
mypy src/
publish:
build-wheels:
needs: [test, lint]
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history including tags (necessary for changelog generation)
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
shell: bash
- name: Build wheel with bundled CLI
run: |
python scripts/build_wheel.py \
--version "${{ github.event.inputs.version }}" \
--cli-version "${{ github.event.inputs.claude_code_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
publish:
needs: [build-wheels]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch all history including tags (necessary for changelog generation)
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Set version
id: version
run: |
VERSION="${{ github.event.inputs.version }}"
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set version
id: version
run: |
VERSION="${{ github.event.inputs.version }}"
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update version
run: |
python scripts/update_version.py "${{ env.VERSION }}"
- name: Update version
run: |
python scripts/update_version.py "${{ env.VERSION }}"
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Update CLI version
run: |
python scripts/update_cli_version.py "${{ github.event.inputs.claude_code_version }}"
- name: Build package
run: python -m build
- name: Download all wheel artifacts
uses: actions/download-artifact@v4
with:
path: dist
pattern: wheel-*
merge-multiple: true
- name: Check package
run: twine check dist/*
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
twine upload dist/*
echo "Package published to PyPI"
echo "Install with: pip install claude-agent-sdk==${{ env.VERSION }}"
- name: Build source distribution
run: python -m build --sdist
- name: Get previous release tag
id: previous_tag
run: |
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
echo "Previous release: $PREVIOUS_TAG"
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
twine upload dist/*
echo "Package published to PyPI"
echo "Install with: pip install claude-agent-sdk==${{ env.VERSION }}"
- name: Get previous release tag
id: previous_tag
run: |
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
echo "Previous release: $PREVIOUS_TAG"
- name: Create release branch and commit version changes
run: |
# Create a new branch for the version update
BRANCH_NAME="release/v${{ env.VERSION }}"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
- name: Create release branch and commit version changes
run: |
# Create a new branch for the version update
BRANCH_NAME="release/v${{ env.VERSION }}"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
# Configure git
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
# Configure git
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
# Create and switch to new branch
git checkout -b "$BRANCH_NAME"
# Create and switch to new branch
git checkout -b "$BRANCH_NAME"
# Commit version changes
git add pyproject.toml src/claude_agent_sdk/_version.py
git commit -m "chore: bump version to ${{ env.VERSION }}"
# Commit version changes
git add pyproject.toml src/claude_agent_sdk/_version.py src/claude_agent_sdk/_cli_version.py
git commit -m "chore: bump version to ${{ env.VERSION }} with CLI ${{ github.event.inputs.claude_code_version }}"
- name: Update changelog with Claude
continue-on-error: true
uses: anthropics/claude-code-action@v1
with:
prompt: "/generate-changelog new version: ${{ env.VERSION }}, old version: ${{ steps.previous_tag.outputs.previous_tag }}"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_args: |
--allowedTools 'Bash(git add:*),Bash(git commit:*),Edit'
- name: Update changelog with Claude
continue-on-error: true
uses: anthropics/claude-code-action@v1
with:
prompt: "/generate-changelog new version: ${{ env.VERSION }}, old version: ${{ steps.previous_tag.outputs.previous_tag }}"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_args: |
--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 }}"
- 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
PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI.
# Create PR using GitHub CLI
PR_BODY="This PR updates the version to ${{ env.VERSION }} after publishing to PyPI.
## Changes
- Updated version in \`pyproject.toml\`
- Updated version in \`src/claude_agent_sdk/_version.py\`
- Updated \`CHANGELOG.md\` with release notes
## Changes
- Updated version in \`pyproject.toml\` to ${{ env.VERSION }}
- Updated version in \`src/claude_agent_sdk/_version.py\` to ${{ env.VERSION }}
- Updated bundled CLI version in \`src/claude_agent_sdk/_cli_version.py\` to ${{ github.event.inputs.claude_code_version }}
- Updated \`CHANGELOG.md\` with release notes
## Release Information
- Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ env.VERSION }}/
- Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\`
## Release Information
- Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ env.VERSION }}/
- Bundled CLI version: ${{ github.event.inputs.claude_code_version }}
- Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\`
🤖 Generated by GitHub Actions"
🤖 Generated by GitHub Actions"
PR_URL=$(gh pr create \
--title "chore: release v${{ env.VERSION }}" \
--body "$PR_BODY" \
--base main \
--head "${{ env.BRANCH_NAME }}")
PR_URL=$(gh pr create \
--title "chore: release v${{ env.VERSION }}" \
--body "$PR_BODY" \
--base main \
--head "${{ env.BRANCH_NAME }}")
echo "PR created: $PR_URL"
echo "PR created: $PR_URL"

View file

@ -9,9 +9,13 @@ pip install claude-agent-sdk
```
**Prerequisites:**
- Python 3.10+
- Node.js
- Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code`
**Note:** The Claude Code CLI is automatically bundled with the package - no separate installation required! The SDK will use the bundled CLI by default. If you prefer to use a system-wide installation or a specific version, you can:
- Install Claude Code separately: `curl -fsSL https://claude.ai/install.sh | bash`
- Specify a custom path: `ClaudeAgentOptions(cli_path="/path/to/claude")`
## Quick Start
@ -179,7 +183,7 @@ options = ClaudeAgentOptions(
### Hooks
A **hook** is a Python function that the Claude Code *application* (*not* Claude) invokes at specific points of the Claude agent loop. Hooks can provide deterministic processing and automated feedback for Claude. Read more in [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks).
A **hook** is a Python function that the Claude Code _application_ (_not_ Claude) invokes at specific points of the Claude agent loop. Hooks can provide deterministic processing and automated feedback for Claude. Read more in [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks).
For more examples, see examples/hooks.py.
@ -229,10 +233,10 @@ async with ClaudeSDKClient(options=options) as client:
print(msg)
```
## Types
See [src/claude_agent_sdk/types.py](src/claude_agent_sdk/types.py) for complete type definitions:
- `ClaudeAgentOptions` - Configuration options
- `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types
- `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks
@ -259,7 +263,7 @@ except CLIJSONDecodeError as e:
print(f"Failed to parse response: {e}")
```
See [src/claude_agent_sdk/_errors.py](src/claude_agent_sdk/_errors.py) for all error types.
See [src/claude_agent_sdk/\_errors.py](src/claude_agent_sdk/_errors.py) for all error types.
## Available Tools
@ -290,6 +294,63 @@ If you're contributing to this project, run the initial setup script to install
This installs a pre-push hook that runs lint checks before pushing, matching the CI workflow. To skip the hook temporarily, use `git push --no-verify`.
### 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
MIT

View file

@ -47,6 +47,7 @@ Issues = "https://github.com/anthropics/claude-agent-sdk-python/issues"
[tool.hatch.build.targets.wheel]
packages = ["src/claude_agent_sdk"]
only-include = ["src/claude_agent_sdk"]
[tool.hatch.build.targets.sdist]
include = [

392
scripts/build_wheel.py Executable file
View 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("\n No 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("\n⚠️ Warning: 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("\n⚠️ Warning: 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("\n No 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("\n Skipping 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()

123
scripts/download_cli.py Executable file
View file

@ -0,0 +1,123 @@
#!/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."""
# Check common 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()
print(f"Downloading Claude Code CLI version: {version}")
# Download and run install script
install_script = "curl -fsSL https://claude.ai/install.sh | bash"
if version != "latest":
install_script = f"curl -fsSL https://claude.ai/install.sh | bash -s {version}"
try:
subprocess.run(
install_script,
shell=True,
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()

32
scripts/update_cli_version.py Executable file
View 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])

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Update version in pyproject.toml and __init__.py files."""
import sys
import re
import sys
from pathlib import Path
@ -18,7 +18,7 @@ def update_version(new_version: str) -> None:
f'version = "{new_version}"',
content,
count=1,
flags=re.MULTILINE
flags=re.MULTILINE,
)
pyproject_path.write_text(content)
@ -34,7 +34,7 @@ def update_version(new_version: str) -> None:
f'__version__ = "{new_version}"',
content,
count=1,
flags=re.MULTILINE
flags=re.MULTILINE,
)
version_path.write_text(content)
@ -45,5 +45,5 @@ if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python scripts/update_version.py <version>")
sys.exit(1)
update_version(sys.argv[1])
update_version(sys.argv[1])

View file

@ -0,0 +1,3 @@
# Ignore bundled CLI binaries (downloaded during build)
claude
claude.exe

View file

@ -0,0 +1,3 @@
"""Bundled Claude Code CLI version."""
__cli_version__ = "latest"

View file

@ -68,6 +68,12 @@ class SubprocessCLITransport(Transport):
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
@ -93,6 +99,21 @@ class SubprocessCLITransport(Transport):
" ClaudeAgentOptions(cli_path='/path/to/claude')"
)
def _find_bundled_cli(self) -> str | None:
"""Find bundled CLI binary if it exists."""
# Determine the CLI binary name based on platform
cli_name = "claude.exe" if platform.system() == "Windows" else "claude"
# Get the path to the bundled CLI
# The _bundled directory is in the same package as this module
bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name
if bundled_path.exists() and bundled_path.is_file():
logger.info(f"Using bundled Claude Code CLI: {bundled_path}")
return str(bundled_path)
return None
def _build_command(self) -> list[str]:
"""Build CLI command with arguments."""
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]