Module: ClaudeAgentSDK::CLIInstaller
- Defined in:
- lib/claude_agent_sdk/cli_installer.rb
Overview
Downloads a pinned Claude Code CLI binary into a project-local directory.
Motivation: the SDK shells out to claude, so a deploy artifact is only
hermetic if the CLI version is pinned alongside it. Vendoring the ~280MB
binary into the gem is a non-starter, so instead a Docker build / bin/setup
step calls install, and SubprocessCLITransport#find_cli prefers that
vendored copy over whatever is on PATH.
Mirrors what https://claude.ai/install.sh does: resolve a dist-tag to a concrete version, read the release manifest for the platform's SHA-256, stream the binary down, verify it, then move it into place atomically.
Stdlib only (net/http, json, digest, fileutils, rbconfig) — the gem gains no runtime dependency for this.
Defined Under Namespace
Modules: Http, Metadata, Platform, Release
Constant Summary collapse
- BASE_URL =
'https://downloads.claude.ai/claude-code-releases'- DIST_TAGS =
Dist-tags resolved through a GET to BASE_URL/
. %w[stable latest].freeze
- VERSION_PATTERN =
Concrete version, optionally with a pre-release suffix (e.g. 2.1.220-rc1). The suffix is restricted to the semver pre-release character set: every accepted version is interpolated straight into a download URL, and a laxer
\S+would let "2.1.220-x/../2.1.221" traverse out of the release path — silently installing something other than the pinned version. /\A\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?\z/- CHECKSUM_PATTERN =
/\A[0-9a-f]{64}\z/- BINARY_NAME =
'claude'- VERSION_FILE =
'VERSION'- LOCK_FILE =
'.install.lock'- DEFAULT_DIR =
Relative to Dir.pwd, resolved at CALL time by .default_dir — an absolute constant would freeze the working directory as of require time, which is wrong for anything that chdirs (Rake tasks, bin/setup, test suites).
File.join('vendor', 'claude')
- VERSION_RESPONSE_LIMIT =
Response caps. The dist-tag endpoints return a bare version string and manifests are a few KB; anything larger is a misrouted response, not something to buffer in memory. (The binary itself streams to disk.)
1024- MANIFEST_RESPONSE_LIMIT =
5 * 1024 * 1024
- METADATA_READ_LIMIT =
4096
Class Method Summary collapse
-
.default_dir ⇒ Object
Absolute path of the default install directory, resolved against the current working directory each time it is asked for.
-
.install(version: 'stable', dir: nil) ⇒ Object
Install the CLI into
dirand return the absolute path of the binary. -
.installed_path(dir: nil) ⇒ Object
Path of an already-installed binary, or nil.
Class Method Details
.default_dir ⇒ Object
Absolute path of the default install directory, resolved against the current working directory each time it is asked for.
306 307 308 |
# File 'lib/claude_agent_sdk/cli_installer.rb', line 306 def default_dir File.(DEFAULT_DIR, Dir.pwd) end |
.install(version: 'stable', dir: nil) ⇒ Object
Install the CLI into dir and return the absolute path of the binary.
version is 'stable', 'latest', or a concrete version like '2.1.220'.
Idempotent and safe to run concurrently: an exclusive lock on
dir/.install.lock covers the whole check-download-place-record
sequence, so parallel boots (Docker layers, foreman start, CI matrix
jobs sharing a cache) never race each other into a partially written
binary — the loser of the race observes a finished install.
The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) rather than trusting the recorded version alone, and never touches the network: repeat boots must work offline (with a pinned version — a dist-tag has to be re-resolved to be resolved at all).
An upgrade never destroys a working install: see #publish.
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'lib/claude_agent_sdk/cli_installer.rb', line 325 def install(version: 'stable', dir: nil) dir = File.(dir || default_dir) # Validated (locally) first, so malformed input never creates a # directory; RESOLVED inside the lock, so a dist-tag cannot be read # before another installer publishes a newer version and then be used # to downgrade it. Semantics: last resolver wins. requested = Release.validate_version(version) binary = File.join(dir, BINARY_NAME) FileUtils.mkdir_p(dir) with_install_lock(dir) do sweep_stale_temp_files(dir) resolved = Release.resolve_version(requested) next binary if installed?(dir, resolved) platform = Platform.detect publish(dir, binary, resolved, platform, Release.platform_entry(resolved, platform)) binary end rescue CLIInstallError raise rescue SystemCallError, IOError => e # Filesystem failures (EACCES on the install dir, ENOSPC mid-download, # a read-only mount) reach callers as CLIInstallError like every other # install failure; `cause` keeps the original for debugging. raise CLIInstallError, "Failed to install the Claude Code CLI into #{dir}: #{e.class}: #{e.}" end |
.installed_path(dir: nil) ⇒ Object
Path of an already-installed binary, or nil.
Deliberately lock-free, because #publish makes the lock unnecessary for readers: the binary only ever changes by a rename of a fully-downloaded, checksum-verified file, so a concurrent reader (this method, or find_cli, or the CLI being spawned) sees either the intact old binary or the intact new one — never a partial file. Taking the install lock here would put every process start behind an in-progress download for no added safety.
361 362 363 364 365 366 |
# File 'lib/claude_agent_sdk/cli_installer.rb', line 361 def installed_path(dir: nil) path = File.join(File.(dir || default_dir), BINARY_NAME) File.file?(path) && File.executable?(path) ? path : nil rescue SystemCallError nil end |