mirror of
https://github.com/anthropics/claude-code-sdk-python.git
synced 2025-12-23 09:19:52 +00:00
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:
parent
6f209075bc
commit
ce99e9d2eb
10 changed files with 787 additions and 95 deletions
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("\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
123
scripts/download_cli.py
Executable 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
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
|
||||
"""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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue