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

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()