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.

Examples:

Pin a version in bin/setup or a Dockerfile build step

ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')

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

Class Method Details

.default_dirObject

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.expand_path(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.expand_path(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.message}"
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.expand_path(dir || default_dir), BINARY_NAME)
  File.file?(path) && File.executable?(path) ? path : nil
rescue SystemCallError
  nil
end