Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"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 ruff check src/ tests/ scripts/ --fix)",
"Bash(python -m ruff format src/ tests/ scripts/)",
"Bash(python -m mypy src/ scripts/)",
"Bash(python -m pytest tests/)",
"Bash(python -m pytest tests/*)"
],
Expand All @@ -15,7 +15,7 @@
"hooks": [
{
"type": "command",
"command": "python -m ruff check src/ tests/ --fix && python -m ruff format src/ tests/"
"command": "python -m ruff check src/ tests/ scripts/ --fix && python -m ruff format src/ tests/ scripts/"
}
],
"matcher": "Edit|Write|MultiEdit"
Expand Down
79 changes: 79 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
name: verify
description: Drive this repo's build/release scripts end-to-end without touching the network. Use when verifying changes to scripts/download_cli.py, scripts/build_wheel.py, scripts/update_cli_version.py, or scripts/_cli_version_validation.py.
---

# Verifying the build scripts

The SDK itself is a library (drive it through `import claude_agent_sdk`), but
the `scripts/` directory is a set of **CLIs** run by the release workflows.
Verify them by running them, not by importing them.

## NEVER run the real installer

`scripts/download_cli.py` shells out to `curl https://claude.ai/install.sh | bash`
(and the PowerShell equivalent). Running it for real **overwrites the `claude`
binary on the developer's machine**. Do not run `bash install.sh`, and do not
invoke `download_cli.py` with `latest`/`stable` (or any case variant) against
the real network.

Instead, put stub `curl` / `bash` / `powershell` on a temp `PATH` and drive the
script under `env -i` with an isolated `HOME` (so `find_installed_cli()` cannot
discover the real binary and copy it into `src/claude_agent_sdk/_bundled/`).

## The harness

```bash
SB=$(mktemp -d); mkdir -p $SB/stub $SB/home $SB/repo/src/claude_agent_sdk
cp scripts/{download_cli.py,build_wheel.py,update_cli_version.py,_cli_version_validation.py} $SB/repo/scripts/
printf '__cli_version__ = "2.1.208"\n' > $SB/repo/src/claude_agent_sdk/_cli_version.py

cat > $SB/stub/curl <<'EOF'
#!/bin/bash
out=""; prev=""; for a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done
echo "[stub curl] argv: $*" >> "$MARKER"
[ -n "$out" ] && printf '%b' "${CURL_BODY:-#!/bin/bash\necho hi\n}" > "$out"
exit ${CURL_EXIT:-0}
EOF
cat > $SB/stub/bash <<'EOF'
#!/bin/bash
{ echo "[stub bash] argc=$#"; for a in "$@"; do echo " <$a>"; done; } >> "$MARKER"
EOF
chmod +x $SB/stub/*

cd $SB/repo
env -i HOME=$SB/home PATH=$SB/stub:/usr/bin:/bin MARKER=$SB/m \
CLAUDE_CLI_VERSION=2.1.208 .venv/bin/python scripts/download_cli.py
Comment thread
qing-ant marked this conversation as resolved.
```

`run_command()` captures the child's output, so the stubs must log to a
`$MARKER` file — printing to stderr is swallowed.
Comment thread
qing-ant marked this conversation as resolved.

## Flows worth driving

- **`update_cli_version.py <version>`** — the whole validator surface is
reachable here: a concrete version writes the file; `latest`/`stable`,
`v2.1.207`, `next`, `2.1`, and a quote-breakout string each exit 1 with a
distinct message and leave the file untouched.
- **`build_wheel.py --skip-sdist`** — reads the pin, then chains into
`download_cli.py`. Rewrite `_cli_version.py` in the sandbox to a moving tag /
single quotes / garbage / delete it: each must fail *before* any subprocess
is spawned. `--cli-version <v>` bypasses the pin (intentional escape hatch;
the release workflow does not use it).
- **`download_cli.py`** — set `CURL_BODY` to an HTML error page or an empty
string to prove the body check refuses it with no retry; empty the `PATH` to
prove a missing `curl` fails fast in one attempt; `CURL_EXIT=22` to prove a
genuine transient failure still retries 3×.
- **The Windows path is unreachable on Linux.** It is behind
`platform.system() == "Windows"`. Drive it with a small runner that
`patch.object(m.platform, "system", return_value="Windows")` and a stub
`powershell` that reads the `-Command` text and honours
`$env:CLAUDE_CLI_INSTALL_SCRIPT`. This is the one place where forcing the
platform is legitimate.

## Gotchas

- `${CURL_BODY:-default}` treats an *empty* body as unset. Use a separate stub
that does `: > "$out"` to test the empty-body branch.
- The retry path really sleeps (jitter up to 5s, then 2s + 4s). A full
three-attempt run takes ~10s; budget for it rather than assuming a hang.
4 changes: 4 additions & 0 deletions .github/workflows/build-wheel-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
paths:
- 'scripts/build_wheel.py'
- 'scripts/download_cli.py'
# Imported by download_cli.py: it decides which versions the build accepts.
- 'scripts/_cli_version_validation.py'
# The pin build_wheel.py reads to decide which CLI goes into the wheels.
- 'src/claude_agent_sdk/_cli_version.py'
- '.github/workflows/build-and-publish.yml'
- '.github/workflows/build-wheel-check.yml'
- 'pyproject.toml'
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ jobs:

- name: Run ruff
run: |
ruff check src/ tests/
ruff format --check src/ tests/
ruff check src/ tests/ scripts/
ruff format --check src/ tests/ scripts/

- name: Run mypy
run: |
mypy src/
mypy src/ scripts/
Comment thread
qing-ant marked this conversation as resolved.
Comment thread
qing-ant marked this conversation as resolved.
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ jobs:

- name: Run ruff
run: |
ruff check src/ tests/
ruff format --check src/ tests/
ruff check src/ tests/ scripts/
ruff format --check src/ tests/ scripts/

- name: Run mypy
run: |
mypy src/
mypy src/ scripts/

get-previous-tag:
runs-on: ubuntu-latest
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ jobs:

- name: Install Claude Code (Linux/macOS)
if: runner.os == 'Linux' || runner.os == 'macOS'
# `shell: bash` runs with -eo pipefail. Without pipefail the pipeline
# takes its status from `bash`, which exits 0 on an empty script, so a
# failed `curl` shows up later as a confusing "claude: not found" in a
# different step. With it, the step that actually broke is the one that
# fails.
shell: bash
run: |
curl -fsSL https://claude.ai/install.sh | bash
echo "$HOME/.local/bin" >> $GITHUB_PATH
Expand Down Expand Up @@ -149,6 +155,8 @@ jobs:

- name: Install Claude Code (Linux)
if: runner.os == 'Linux'
# `shell: bash` runs with -eo pipefail; see the note in the test job.
shell: bash
run: |
curl -fsSL https://claude.ai/install.sh | bash
echo "$HOME/.local/bin" >> $GITHUB_PATH
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
```bash
# Lint and style
# Check for issues and fix automatically
python -m ruff check src/ tests/ --fix
python -m ruff format src/ tests/
python -m ruff check src/ tests/ scripts/ --fix
python -m ruff format src/ tests/ scripts/

# Typecheck (only done for src/)
python -m mypy src/
# Typecheck
python -m mypy src/ scripts/
Comment thread
qing-ant marked this conversation as resolved.

# Run all tests
python -m pytest tests/
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

FROM python:3.12-slim

# Docker's default `RUN` shell is `/bin/sh -c`, which reports only the last
# status of a pipeline: a failed `curl` would be masked by a successful `bash`,
# leaving a green build with no CLI installed. Fail on any element instead.
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

# Install dependencies for Claude CLI and git (needed for some tests)
RUN apt-get update && apt-get install -y \
curl \
Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ only-include = ["src/claude_agent_sdk"]
include = [
"/src",
"/tests",
# tests/test_build_wheel.py, test_download_cli.py and test_update_cli_version.py
# load their subject out of scripts/ at import time, so an sdist that ships
# /tests without /scripts cannot even collect its own test suite.
"/scripts",
"/examples",
"/README.md",
"/LICENSE",
Expand Down Expand Up @@ -101,6 +105,12 @@ strict_equality = true
module = ["opentelemetry", "opentelemetry.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
# twine is not a declared dependency (not even in the [dev] extra); the
# import in scripts/build_wheel.py is guarded by try/except ImportError.
module = ["twine"]
ignore_missing_imports = true

[tool.ruff]
target-version = "py310"
line-length = 88
Expand Down
119 changes: 119 additions & 0 deletions scripts/_cli_version_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
r"""Shared validation for Claude Code CLI version strings.

update_cli_version.py writes the version into src/claude_agent_sdk/_cli_version.py
and download_cli.py reads it back out and hands it to an installer, so the rule
lives here once rather than in two copies that could drift apart.

The installer is the authority on what a version may be. install.sh (and
install.ps1) enforce

^(stable|latest|[0-9]+\.[0-9]+\.[0-9]+(-[^[:space:]]+)?)$

VERSION_PATTERN admits a deliberate *subset* of that: the installer's `-[^\s]+`
suffix would accept quotes, backslashes and semicolons, so the suffix here is
narrowed to the characters real versions use. Never widen it back toward the
installer's -- update_cli_version.py writes this value into a Python string
literal in a real source file.

"latest" and "stable" are the installer's dist-tags, and they are *moving*:
fine for a download, wrong for a pin, since _cli_version.py is the only record
of which build went into the wheels. Hence ``allow_dist_tag``.

VERSION_PATTERN is deliberately unanchored, and matched with fullmatch(): with
"^...$" a swap to match() would silently accept a trailing newline ("1.0.0\n");
unanchored, the same swap accepts an obvious prefix like "1.0.0; id" and fails
loudly in tests.
"""

import re

VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.+-]+)?")
Comment thread
qing-ant marked this conversation as resolved.

# Matched exactly: install.sh's grammar is case-sensitive, so "Latest" is not a
# tag it would resolve, and accepting it here would widen what reaches a real
# network install.
DIST_TAGS = ("latest", "stable")

# Anything word-shaped that is not a version: "next", "beta", "nightly". Named
# so the error can say *why* it was rejected instead of printing a regex.
_DIST_TAG_SHAPED = re.compile(r"[A-Za-z][0-9A-Za-z-]*")

_SUPPORTED_TAGS = ", ".join(repr(tag) for tag in DIST_TAGS)


def _expected(allow_dist_tag: bool) -> str:
"""The phrase naming what the caller should have passed instead."""
if allow_dist_tag:
return f"{_SUPPORTED_TAGS}, or a concrete version"
return "a concrete version"


def _rejection(version: str, allow_dist_tag: bool) -> str:
"""Why ``version`` is unusable -- the most specific reason that applies.

The caller prefixes "Invalid <source>: ", so each reason reads as the rest
of that sentence.
"""
candidate = version.strip()

# Reachable only when a tag is not a legal answer: validate_version()
# returns the tag otherwise.
if candidate in DIST_TAGS:
return (
f"{candidate!r} is a moving dist-tag, not a concrete version. A pinned "
f"version must name the one build that goes into the wheels. Expected "
f"a version matching {VERSION_PATTERN.pattern}"
)

# Only where a tag is a legal answer at all; when pinning, the
# dist-tag-shaped branch below correctly says to use a concrete version.
if allow_dist_tag and candidate.lower() in DIST_TAGS:
return f"{candidate!r}. Did you mean {candidate.lower()!r}?"

# Never normalized away: the caller asked for something we do not support,
# and silently installing a different string is worse.
if candidate[:1] in ("v", "V") and VERSION_PATTERN.fullmatch(candidate[1:]):
return f"{candidate!r}. Did you mean {candidate[1:]!r}? (no leading 'v')"

if _DIST_TAG_SHAPED.fullmatch(candidate):
return (
f"{candidate!r} is not a supported dist-tag; "
f"use {_expected(allow_dist_tag)}"
)

# Name the raw value, not the stripped one: if the whitespace is the
# problem, the reader has to be able to see it.
return (
f"{version!r}. Expected {_expected(allow_dist_tag)} "
f"matching {VERSION_PATTERN.pattern}"
)


def validate_version(version: str, *, source: str, allow_dist_tag: bool) -> str:
"""Return the usable form of ``version``, or raise.

Surrounding whitespace is stripped first -- a trailing "\\n" from a file
read or a "\\r" from a CRLF checkout is unambiguous in intent -- and the
stripped value is what the caller gets back and must use downstream.

Args:
version: The candidate version string.
source: Where the value came from, named in the error message
(e.g. "CLAUDE_CLI_VERSION").
allow_dist_tag: Whether a moving dist-tag ("latest", "stable") is
acceptable. It is for a download, which resolves it at install
time; it is not for a value pinned into _cli_version.py.

Raises:
ValueError: If ``version`` is neither an allowed dist-tag nor a
fullmatch of VERSION_PATTERN.
"""
candidate = version.strip()

if candidate in DIST_TAGS and allow_dist_tag:
return candidate

if VERSION_PATTERN.fullmatch(candidate):
return candidate

raise ValueError(f"Invalid {source}: {_rejection(version, allow_dist_tag)}")
Loading
Loading