diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6b6e16a3..8b629a50 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: ubuntu-latest + 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: - token: ${{ secrets.GITHUB_TOKEN }} - 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: Set version - id: version - run: | - VERSION="${{ github.event.inputs.version }}" - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "version=$VERSION" >> $GITHUB_OUTPUT + - 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 - - 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: Build package - run: python -m build - - - name: Check package - run: twine check dist/* - - - 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 - - # 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" - - # Commit version changes - git add pyproject.toml src/claude_agent_sdk/_version.py - git commit -m "chore: bump version to ${{ env.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: 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. - - ## Changes - - Updated version in \`pyproject.toml\` - - Updated version in \`src/claude_agent_sdk/_version.py\` - - Updated \`CHANGELOG.md\` with release notes - - ## Release Information - - Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ env.VERSION }}/ - - Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\` - - 🤖 Generated by GitHub Actions" - - PR_URL=$(gh pr create \ - --title "chore: release v${{ env.VERSION }}" \ - --body "$PR_BODY" \ - --base main \ - --head "${{ env.BRANCH_NAME }}") + publish: + needs: [build-wheels] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write - echo "PR created: $PR_URL" + steps: + - 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 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 CLI version + run: | + python scripts/update_cli_version.py "${{ github.event.inputs.claude_code_version }}" + + - name: Download all wheel artifacts + uses: actions/download-artifact@v4 + with: + path: dist + pattern: wheel-* + merge-multiple: true + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build source distribution + run: python -m build --sdist + + - 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 + + # 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" + + # 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: 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. + + ## 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 }}/ + - Bundled CLI version: ${{ github.event.inputs.claude_code_version }} + - Install with: \`pip install claude-agent-sdk==${{ env.VERSION }}\` + + 🤖 Generated by GitHub Actions" + + PR_URL=$(gh pr create \ + --title "chore: release v${{ env.VERSION }}" \ + --body "$PR_BODY" \ + --base main \ + --head "${{ env.BRANCH_NAME }}") + + echo "PR created: $PR_URL" diff --git a/README.md b/README.md index 1fcb30d7..21119861 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index cc9b99d9..499ddadb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/scripts/build_wheel.py b/scripts/build_wheel.py new file mode 100755 index 00000000..b5a6b41e --- /dev/null +++ b/scripts/build_wheel.py @@ -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() diff --git a/scripts/download_cli.py b/scripts/download_cli.py new file mode 100755 index 00000000..c542c1fe --- /dev/null +++ b/scripts/download_cli.py @@ -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() diff --git a/scripts/update_cli_version.py b/scripts/update_cli_version.py new file mode 100755 index 00000000..1ef17c7e --- /dev/null +++ b/scripts/update_cli_version.py @@ -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 ") + sys.exit(1) + + update_cli_version(sys.argv[1]) diff --git a/scripts/update_version.py b/scripts/update_version.py index 743b40f8..b980d522 100755 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -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 @@ def update_version(new_version: str) -> None: if len(sys.argv) != 2: print("Usage: python scripts/update_version.py ") sys.exit(1) - - update_version(sys.argv[1]) \ No newline at end of file + + update_version(sys.argv[1]) diff --git a/src/claude_agent_sdk/_bundled/.gitignore b/src/claude_agent_sdk/_bundled/.gitignore new file mode 100644 index 00000000..b8f03540 --- /dev/null +++ b/src/claude_agent_sdk/_bundled/.gitignore @@ -0,0 +1,3 @@ +# Ignore bundled CLI binaries (downloaded during build) +claude +claude.exe diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py new file mode 100644 index 00000000..5666367e --- /dev/null +++ b/src/claude_agent_sdk/_cli_version.py @@ -0,0 +1,3 @@ +"""Bundled Claude Code CLI version.""" + +__cli_version__ = "latest" diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index d669a412..6c000d44 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -68,6 +68,12 @@ def __init__( 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 @@ def _find_cli(self) -> str: " 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"]