claude-agent-sdk 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +51 -1
- data/lib/claude_agent_sdk/cli_installer.rb +459 -0
- data/lib/claude_agent_sdk/errors.rb +5 -0
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +36 -1
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8e1fbf9996a81e6493a2fd8fd15a4922645933fccc93cd7d279763f01658d80
|
|
4
|
+
data.tar.gz: 87a8bbae7de66555c1c9d81f4791fe11ed51c385c233ec94e2541292d81f5870
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d3ad7f14b8b231a4e64cc850acda569818f215e85b6e032e9217903ee650b6d434b7c8e95823fd8d6e3dde78177a3533e683b2cb96dc956c41f921324c3770c
|
|
7
|
+
data.tar.gz: '093dd27e1cd34afb33998236f761a2515ef787d86980a54bc5c9cc5c16635b8419800b0c6f7010e46cc959703abd12acdaf7cbc0135c3c532cdd2d94903a22de'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.30.0] - 2026-08-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`ClaudeAgentSDK::CLIInstaller`** — downloads a pinned `claude` CLI binary into a project-local directory (`vendor/claude` by default) for hermetic deploys, so a Docker image or CI job runs a known CLI version instead of whatever `npm install -g` last put on `PATH`. `CLIInstaller.install(version: '2.1.220', dir: nil)` resolves `stable`/`latest` dist-tags through the official release endpoint, verifies the platform's SHA-256 from the release manifest, streams the ~280MB binary to an unpredictable sibling temp file (opened `O_EXCL`, so a pre-planted path or symlink is never written through) and renames it into place atomically (`0755`), then records the version **and** the verified checksum in a sibling `VERSION` file. `CLIInstaller.installed_path` returns the vendored binary or `nil`. Stdlib only — no new runtime dependency, and no binary shipped inside the gem. Supports macOS and Linux (glibc + musl, x86_64 + arm64, with a Rosetta 2 override so an x86_64 Ruby on Apple Silicon gets the native build); every failure raises the new `ClaudeAgentSDK::CLIInstallError`, filesystem errors included (wrapped, with `cause` preserved).
|
|
14
|
+
- **Idempotent without trusting the recorded version alone**: the shortcut re-hashes the vendored binary and skips the download only when version *and* checksum match, so a truncated or swapped binary is reinstalled rather than used. It makes no network request — repeat boots work offline.
|
|
15
|
+
- **Concurrency-safe**: an exclusive `flock` on `<dir>/.install.lock` covers dist-tag resolution and the whole check → download → record → publish sequence (last resolver wins, so a slow installer cannot downgrade a newer version published while it waited), and the `VERSION` file is written atomically (temp + rename), so parallel installs into one directory can never observe or produce a half-installed state. Temp files abandoned by an install that was killed before it could clean up are swept on the next run. Discovery (`installed_path`, `find_cli`) stays lock-free: the binary only ever changes by an atomic rename of a fully verified file, so a reader sees the intact old binary or the intact new one.
|
|
16
|
+
- **A failed install never breaks a working one**: the binary is downloaded, verified and *recorded* before the rename that publishes it, and nothing can fail after that rename. A failed upgrade therefore leaves the previously installed binary intact and runnable, with the next `install` redoing it cleanly; a failed first install leaves nothing behind.
|
|
17
|
+
- Text responses are size-capped (1KB for the version endpoint, 5MB for the manifest), and the binary download is bounded by the manifest's declared `size` when present — an over-long stream is aborted instead of filling the disk. The binary streams to disk and is never buffered in memory.
|
|
18
|
+
- **CLI discovery now honors `CLAUDE_CLI_PATH` and the vendored binary.** `SubprocessCLITransport#find_cli` probes `CLAUDE_CLI_PATH` (when it points at an executable), then `CLIInstaller.installed_path`, then the existing `which claude` and common-location logic. The vendored copy deliberately outranks `PATH` — that is what makes a pinned install hermetic. `CLINotFoundError` now also mentions both escape hatches.
|
|
19
|
+
|
|
10
20
|
## [0.29.0] - 2026-08-09
|
|
11
21
|
|
|
12
22
|
### Fixed
|
data/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
|
|
|
68
68
|
gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
|
|
69
69
|
|
|
70
70
|
# Or use a stable version from RubyGems
|
|
71
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
71
|
+
gem 'claude-agent-sdk', '~> 0.30.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
|
@@ -78,6 +78,56 @@ Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
|
|
78
78
|
- Node.js
|
|
79
79
|
- Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code`
|
|
80
80
|
|
|
81
|
+
### Vendoring the CLI (hermetic deploys)
|
|
82
|
+
|
|
83
|
+
The SDK runs the `claude` CLI as a subprocess, so a deploy is only reproducible if the CLI version is pinned with it. `CLIInstaller` downloads a pinned binary from the official release endpoint into a project-local directory (`vendor/claude` by default) — checksum-verified, no npm/Node at runtime, and nothing extra shipped inside the gem.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
require 'claude_agent_sdk'
|
|
87
|
+
|
|
88
|
+
# 'stable' (default), 'latest', or a concrete version — pin it in production.
|
|
89
|
+
ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')
|
|
90
|
+
# => "/app/vendor/claude/claude"
|
|
91
|
+
|
|
92
|
+
ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220', dir: '/opt/claude')
|
|
93
|
+
|
|
94
|
+
# nil unless a binary is already installed there
|
|
95
|
+
ClaudeAgentSDK::CLIInstaller.installed_path
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`install` is idempotent and safe to run concurrently, so it fits `bin/setup`, a cached Docker layer, and every process of a multi-process boot:
|
|
99
|
+
|
|
100
|
+
- The install directory's `VERSION` file records the installed version **and** the SHA-256 that was verified at download time. The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when both match — a truncated, swapped or half-written binary is reinstalled instead of trusted. It makes **no network request**, so repeat boots work offline — with a pinned concrete version; `'stable'`/`'latest'` must always re-resolve through the endpoint, which is one more reason to pin in production.
|
|
101
|
+
- An exclusive `flock` on `<dir>/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race; the loser simply observes the finished install.
|
|
102
|
+
|
|
103
|
+
Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`.
|
|
104
|
+
|
|
105
|
+
**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. A first install that fails leaves nothing behind at all.
|
|
106
|
+
|
|
107
|
+
> The vendored directory is trusted input: anything that can write to it can replace the binary the SDK executes. Keep it inside your deploy artifact, owned by the deploy user and not world-writable, exactly as you would treat `bin/`.
|
|
108
|
+
|
|
109
|
+
```dockerfile
|
|
110
|
+
# Dockerfile — pin the CLI in its own cached layer
|
|
111
|
+
RUN bundle exec ruby -e "require 'claude_agent_sdk'; \
|
|
112
|
+
ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
#!/usr/bin/env ruby
|
|
117
|
+
# bin/setup
|
|
118
|
+
require 'claude_agent_sdk'
|
|
119
|
+
puts ClaudeAgentSDK::CLIInstaller.install(version: ENV.fetch('CLAUDE_CLI_VERSION', 'stable'))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Supported platforms: `darwin-arm64`, `darwin-x64` (Rosetta 2 gets the arm64 build), `linux-x64`, `linux-arm64`, and the `-musl` variants. Windows is not supported.
|
|
123
|
+
|
|
124
|
+
**CLI discovery order.** With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in this order:
|
|
125
|
+
|
|
126
|
+
1. `CLAUDE_CLI_PATH` — an explicit path to an executable, no discovery at all (a relative value is resolved against the process's working directory, not `cwd:`)
|
|
127
|
+
2. The vendored binary (`CLIInstaller.installed_path`) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally
|
|
128
|
+
3. `which claude`
|
|
129
|
+
4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …)
|
|
130
|
+
|
|
81
131
|
### Agentic Coding Skill
|
|
82
132
|
|
|
83
133
|
If you're using [Claude Code](https://claude.ai/claude-code), this repo is a Claude Code plugin marketplace. Add it once, then install the skill:
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'open3'
|
|
8
|
+
require 'rbconfig'
|
|
9
|
+
require 'securerandom'
|
|
10
|
+
require 'uri'
|
|
11
|
+
require_relative 'errors'
|
|
12
|
+
|
|
13
|
+
module ClaudeAgentSDK
|
|
14
|
+
# Downloads a pinned Claude Code CLI binary into a project-local directory.
|
|
15
|
+
#
|
|
16
|
+
# Motivation: the SDK shells out to `claude`, so a deploy artifact is only
|
|
17
|
+
# hermetic if the CLI version is pinned alongside it. Vendoring the ~280MB
|
|
18
|
+
# binary into the gem is a non-starter, so instead a Docker build / bin/setup
|
|
19
|
+
# step calls +install+, and SubprocessCLITransport#find_cli prefers that
|
|
20
|
+
# vendored copy over whatever is on PATH.
|
|
21
|
+
#
|
|
22
|
+
# Mirrors what https://claude.ai/install.sh does: resolve a dist-tag to a
|
|
23
|
+
# concrete version, read the release manifest for the platform's SHA-256,
|
|
24
|
+
# stream the binary down, verify it, then move it into place atomically.
|
|
25
|
+
#
|
|
26
|
+
# Stdlib only (net/http, json, digest, fileutils, rbconfig) — the gem gains
|
|
27
|
+
# no runtime dependency for this.
|
|
28
|
+
#
|
|
29
|
+
# @example Pin a version in bin/setup or a Dockerfile build step
|
|
30
|
+
# ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')
|
|
31
|
+
module CLIInstaller
|
|
32
|
+
BASE_URL = 'https://downloads.claude.ai/claude-code-releases'
|
|
33
|
+
# Dist-tags resolved through a GET to BASE_URL/<tag>.
|
|
34
|
+
DIST_TAGS = %w[stable latest].freeze
|
|
35
|
+
# Concrete version, optionally with a pre-release suffix (e.g. 2.1.220-rc1).
|
|
36
|
+
# The suffix is restricted to the semver pre-release character set: every
|
|
37
|
+
# accepted version is interpolated straight into a download URL, and a
|
|
38
|
+
# laxer `\S+` would let "2.1.220-x/../2.1.221" traverse out of the release
|
|
39
|
+
# path — silently installing something other than the pinned version.
|
|
40
|
+
VERSION_PATTERN = /\A\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?\z/
|
|
41
|
+
CHECKSUM_PATTERN = /\A[0-9a-f]{64}\z/
|
|
42
|
+
BINARY_NAME = 'claude'
|
|
43
|
+
VERSION_FILE = 'VERSION'
|
|
44
|
+
LOCK_FILE = '.install.lock'
|
|
45
|
+
# Relative to Dir.pwd, resolved at CALL time by .default_dir — an absolute
|
|
46
|
+
# constant would freeze the working directory as of require time, which is
|
|
47
|
+
# wrong for anything that chdirs (Rake tasks, bin/setup, test suites).
|
|
48
|
+
DEFAULT_DIR = File.join('vendor', 'claude')
|
|
49
|
+
# Response caps. The dist-tag endpoints return a bare version string and
|
|
50
|
+
# manifests are a few KB; anything larger is a misrouted response, not
|
|
51
|
+
# something to buffer in memory. (The binary itself streams to disk.)
|
|
52
|
+
VERSION_RESPONSE_LIMIT = 1024
|
|
53
|
+
MANIFEST_RESPONSE_LIMIT = 5 * 1024 * 1024
|
|
54
|
+
METADATA_READ_LIMIT = 4096
|
|
55
|
+
|
|
56
|
+
# Maps the running Ruby to a release-manifest platform key
|
|
57
|
+
# (darwin-arm64, darwin-x64, linux-x64, linux-arm64, and the -musl
|
|
58
|
+
# variants). Windows is not supported by this gem.
|
|
59
|
+
module Platform
|
|
60
|
+
class << self
|
|
61
|
+
def detect
|
|
62
|
+
target = ruby_platform.to_s.downcase
|
|
63
|
+
os = detect_os(target)
|
|
64
|
+
arch = detect_arch(target)
|
|
65
|
+
# A Ruby built for x86_64 running under Rosetta 2 reports darwin-x64,
|
|
66
|
+
# but the machine is arm64 — install the native binary.
|
|
67
|
+
arch = 'arm64' if os == 'darwin' && arch == 'x64' && rosetta?
|
|
68
|
+
suffix = os == 'linux' && target.include?('musl') ? '-musl' : ''
|
|
69
|
+
"#{os}-#{arch}#{suffix}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def detect_os(target)
|
|
75
|
+
return 'darwin' if target.include?('darwin')
|
|
76
|
+
return 'linux' if target.include?('linux')
|
|
77
|
+
|
|
78
|
+
raise CLIInstallError,
|
|
79
|
+
"Unsupported platform #{ruby_platform.inspect} for the Claude Code CLI " \
|
|
80
|
+
'(supported: macOS and Linux; Windows is not supported by this gem)'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def detect_arch(target)
|
|
84
|
+
case target
|
|
85
|
+
when /aarch64|arm64/ then 'arm64'
|
|
86
|
+
when /x86_64|x64|amd64/ then 'x64'
|
|
87
|
+
else
|
|
88
|
+
raise CLIInstallError,
|
|
89
|
+
"Unsupported CPU architecture in #{ruby_platform.inspect} (supported: x86_64 and arm64)"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Single source of platform truth, and the seam the specs stub.
|
|
94
|
+
# RUBY_PLATFORM carries OS, CPU and libc on CRuby; JRuby reports a
|
|
95
|
+
# bare 'java', so fall back to the RbConfig host description there.
|
|
96
|
+
def ruby_platform
|
|
97
|
+
return RUBY_PLATFORM unless RUBY_PLATFORM == 'java'
|
|
98
|
+
|
|
99
|
+
"#{RbConfig::CONFIG['host_os']}-#{RbConfig::CONFIG['host_cpu']}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def rosetta?
|
|
103
|
+
stdout, status = Open3.capture2('sysctl', '-n', 'sysctl.proc_translated')
|
|
104
|
+
status.success? && stdout.strip == '1'
|
|
105
|
+
rescue StandardError
|
|
106
|
+
# sysctl missing or not permitted — assume native.
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# HTTPS GET with bounded redirects, timeouts, a response-size cap for text
|
|
113
|
+
# and chunked streaming for the binary. Knows nothing about releases; the
|
|
114
|
+
# specs stub .fetch_text / .download_to wholesale so no HTTP stubbing
|
|
115
|
+
# library is needed.
|
|
116
|
+
module Http
|
|
117
|
+
MAX_REDIRECTS = 5
|
|
118
|
+
OPEN_TIMEOUT_SECONDS = 10
|
|
119
|
+
READ_TIMEOUT_SECONDS = 60
|
|
120
|
+
|
|
121
|
+
class << self
|
|
122
|
+
# +limit+ bounds how many bytes are buffered: the caller knows the
|
|
123
|
+
# expected shape of the response, and an unbounded read of a misrouted
|
|
124
|
+
# (or hostile) endpoint is an easy way to exhaust memory.
|
|
125
|
+
def fetch_text(url, limit:)
|
|
126
|
+
with_response(url) do |response|
|
|
127
|
+
body = +''
|
|
128
|
+
response.read_body do |chunk|
|
|
129
|
+
body << chunk
|
|
130
|
+
raise CLIInstallError, "Response from #{url} exceeds the #{limit}-byte limit" if body.bytesize > limit
|
|
131
|
+
end
|
|
132
|
+
body
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Streams the response to +path+ in chunks — the CLI binary is ~280MB
|
|
137
|
+
# and must never be materialized in memory. O_EXCL: +path+ must not
|
|
138
|
+
# exist, so a pre-planted file or symlink is never written through.
|
|
139
|
+
# +max_bytes+ (the manifest's declared size, when it has one) aborts a
|
|
140
|
+
# response that runs long instead of filling the disk before the
|
|
141
|
+
# checksum gets a chance to reject it.
|
|
142
|
+
def download_to(url, path, max_bytes: nil)
|
|
143
|
+
with_response(url) do |response|
|
|
144
|
+
written = 0
|
|
145
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) do |file|
|
|
146
|
+
response.read_body do |chunk|
|
|
147
|
+
written += chunk.bytesize
|
|
148
|
+
raise CLIInstallError, "Download from #{url} exceeds the expected #{max_bytes} bytes" if over?(written, max_bytes)
|
|
149
|
+
|
|
150
|
+
file.write(chunk)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
path
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def over?(written, max_bytes)
|
|
160
|
+
!max_bytes.nil? && written > max_bytes
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def with_response(url, redirects_left = MAX_REDIRECTS, &block)
|
|
164
|
+
uri = url.is_a?(URI::Generic) ? url : URI(url.to_s)
|
|
165
|
+
raise CLIInstallError, "Refusing to fetch non-HTTPS URL: #{uri}" unless uri.is_a?(URI::HTTPS)
|
|
166
|
+
|
|
167
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
168
|
+
open_timeout: OPEN_TIMEOUT_SECONDS,
|
|
169
|
+
read_timeout: READ_TIMEOUT_SECONDS) do |http|
|
|
170
|
+
http.request(Net::HTTP::Get.new(uri)) do |response|
|
|
171
|
+
# Branch on the status BEFORE touching the body: a redirect or an
|
|
172
|
+
# error page must never be streamed into the target file.
|
|
173
|
+
return block.call(response) if response.is_a?(Net::HTTPSuccess)
|
|
174
|
+
return follow_redirect(uri, response, redirects_left, &block) if response.is_a?(Net::HTTPRedirection)
|
|
175
|
+
|
|
176
|
+
raise CLIInstallError, "HTTP #{response.code} #{response.message} for #{uri}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
rescue CLIInstallError
|
|
180
|
+
raise
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
raise CLIInstallError, "Failed to fetch #{url}: #{e.class}: #{e.message}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def follow_redirect(uri, response, redirects_left, &block)
|
|
186
|
+
raise CLIInstallError, "Too many redirects while fetching #{uri}" if redirects_left <= 0
|
|
187
|
+
|
|
188
|
+
location = response['location'].to_s
|
|
189
|
+
raise CLIInstallError, "Redirect from #{uri} is missing a Location header" if location.empty?
|
|
190
|
+
|
|
191
|
+
with_response(URI.join(uri.to_s, location), redirects_left - 1, &block)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Talks to the release service: dist-tag resolution, manifest lookup and
|
|
197
|
+
# URL construction. Pure remote reads — no filesystem, no state.
|
|
198
|
+
module Release
|
|
199
|
+
class << self
|
|
200
|
+
# Local, network-free check of what the caller asked for. Returns the
|
|
201
|
+
# normalized request — a dist-tag name or a concrete version — so bad
|
|
202
|
+
# input fails before the installer touches the filesystem.
|
|
203
|
+
def validate_version(version)
|
|
204
|
+
version = version.to_s.strip
|
|
205
|
+
return version if DIST_TAGS.include?(version) || version.match?(VERSION_PATTERN)
|
|
206
|
+
|
|
207
|
+
raise CLIInstallError,
|
|
208
|
+
"Invalid Claude Code CLI version #{version.inspect}: " \
|
|
209
|
+
"expected #{DIST_TAGS.join('/')} or a version like '2.1.220'"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Turns a validated request into a concrete version: dist-tags go to
|
|
213
|
+
# the network, concrete versions are already the answer.
|
|
214
|
+
def resolve_version(validated)
|
|
215
|
+
DIST_TAGS.include?(validated) ? resolve_dist_tag(validated) : validated
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The manifest's release info for +platform+: the SHA-256 (required,
|
|
219
|
+
# validated as 64 hex chars) and the declared download size (optional
|
|
220
|
+
# — nil when absent or not a positive Integer, in which case the
|
|
221
|
+
# checksum alone vouches for the download).
|
|
222
|
+
def platform_entry(version, platform)
|
|
223
|
+
url = "#{BASE_URL}/#{version}/manifest.json"
|
|
224
|
+
platforms = parse_manifest(Http.fetch_text(url, limit: MANIFEST_RESPONSE_LIMIT), url)['platforms']
|
|
225
|
+
entry = platforms.is_a?(Hash) ? platforms[platform] : nil
|
|
226
|
+
unless entry.is_a?(Hash)
|
|
227
|
+
available = platforms.is_a?(Hash) ? platforms.keys.sort.join(', ') : 'none'
|
|
228
|
+
raise CLIInstallError, "#{url} has no entry for platform #{platform} (available: #{available})"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
checksum = entry['checksum'].to_s.downcase
|
|
232
|
+
raise CLIInstallError, "#{url} has no valid sha256 checksum for #{platform}" unless checksum.match?(CHECKSUM_PATTERN)
|
|
233
|
+
|
|
234
|
+
size = entry['size']
|
|
235
|
+
{ checksum: checksum, size: size.is_a?(Integer) && size.positive? ? size : nil }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def binary_url(version, platform)
|
|
239
|
+
"#{BASE_URL}/#{version}/#{platform}/#{BINARY_NAME}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
# The dist-tag endpoints return a bare version string. Validate it: an
|
|
245
|
+
# HTML error page or a redirect to a login screen would otherwise be
|
|
246
|
+
# pasted straight into the download URLs.
|
|
247
|
+
def resolve_dist_tag(tag)
|
|
248
|
+
url = "#{BASE_URL}/#{tag}"
|
|
249
|
+
body = Http.fetch_text(url, limit: VERSION_RESPONSE_LIMIT).to_s.strip
|
|
250
|
+
return body if body.match?(VERSION_PATTERN)
|
|
251
|
+
|
|
252
|
+
raise CLIInstallError, "#{url} did not return a version string (got #{body[0, 80].inspect})"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def parse_manifest(body, url)
|
|
256
|
+
manifest = JSON.parse(body.to_s)
|
|
257
|
+
raise CLIInstallError, "#{url} is not a JSON object" unless manifest.is_a?(Hash)
|
|
258
|
+
|
|
259
|
+
manifest
|
|
260
|
+
rescue JSON::ParserError => e
|
|
261
|
+
raise CLIInstallError, "#{url} returned malformed JSON: #{e.message}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The VERSION file: line 1 the installed version, line 2 the SHA-256 of the
|
|
267
|
+
# binary that was verified at install time. The checksum is what lets the
|
|
268
|
+
# idempotency shortcut trust the vendored binary without a network call —
|
|
269
|
+
# a truncated, swapped or half-written binary no longer looks installed.
|
|
270
|
+
# An older single-line VERSION file simply reads as "no metadata", which
|
|
271
|
+
# triggers a clean reinstall.
|
|
272
|
+
module Metadata
|
|
273
|
+
class << self
|
|
274
|
+
def read(dir)
|
|
275
|
+
path = File.join(dir, VERSION_FILE)
|
|
276
|
+
return nil unless File.file?(path)
|
|
277
|
+
|
|
278
|
+
version, checksum = File.read(path, METADATA_READ_LIMIT).to_s.split("\n", 3)
|
|
279
|
+
version = version.to_s.strip
|
|
280
|
+
checksum = checksum.to_s.strip.downcase
|
|
281
|
+
return nil unless version.match?(VERSION_PATTERN) && checksum.match?(CHECKSUM_PATTERN)
|
|
282
|
+
|
|
283
|
+
{ version: version, checksum: checksum }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Atomic: an unpredictable temp name opened O_EXCL, then renamed over
|
|
287
|
+
# the old file. Without this a reader could observe a half-written
|
|
288
|
+
# VERSION, or (worse) the previous version paired with a new binary.
|
|
289
|
+
def write(dir, version, checksum)
|
|
290
|
+
tmp = File.join(dir, "#{VERSION_FILE}.#{SecureRandom.hex(8)}.tmp")
|
|
291
|
+
begin
|
|
292
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
293
|
+
file.write("#{version}\n#{checksum}\n")
|
|
294
|
+
end
|
|
295
|
+
File.rename(tmp, File.join(dir, VERSION_FILE))
|
|
296
|
+
ensure
|
|
297
|
+
FileUtils.rm_f(tmp)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
class << self
|
|
304
|
+
# Absolute path of the default install directory, resolved against the
|
|
305
|
+
# current working directory each time it is asked for.
|
|
306
|
+
def default_dir
|
|
307
|
+
File.expand_path(DEFAULT_DIR, Dir.pwd)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Install the CLI into +dir+ and return the absolute path of the binary.
|
|
311
|
+
# +version+ is 'stable', 'latest', or a concrete version like '2.1.220'.
|
|
312
|
+
#
|
|
313
|
+
# Idempotent and safe to run concurrently: an exclusive lock on
|
|
314
|
+
# dir/.install.lock covers the whole check-download-place-record
|
|
315
|
+
# sequence, so parallel boots (Docker layers, `foreman start`, CI matrix
|
|
316
|
+
# jobs sharing a cache) never race each other into a partially written
|
|
317
|
+
# binary — the loser of the race observes a finished install.
|
|
318
|
+
#
|
|
319
|
+
# The shortcut re-hashes the vendored binary (~0.1s for the real 245MB
|
|
320
|
+
# binary) rather than trusting the recorded version alone, and never
|
|
321
|
+
# touches the network: repeat boots must work offline (with a pinned
|
|
322
|
+
# version — a dist-tag has to be re-resolved to be resolved at all).
|
|
323
|
+
#
|
|
324
|
+
# An upgrade never destroys a working install: see #publish.
|
|
325
|
+
def install(version: 'stable', dir: nil)
|
|
326
|
+
dir = File.expand_path(dir || default_dir)
|
|
327
|
+
# Validated (locally) first, so malformed input never creates a
|
|
328
|
+
# directory; RESOLVED inside the lock, so a dist-tag cannot be read
|
|
329
|
+
# before another installer publishes a newer version and then be used
|
|
330
|
+
# to downgrade it. Semantics: last resolver wins.
|
|
331
|
+
requested = Release.validate_version(version)
|
|
332
|
+
binary = File.join(dir, BINARY_NAME)
|
|
333
|
+
FileUtils.mkdir_p(dir)
|
|
334
|
+
with_install_lock(dir) do
|
|
335
|
+
sweep_stale_temp_files(dir)
|
|
336
|
+
resolved = Release.resolve_version(requested)
|
|
337
|
+
next binary if installed?(dir, resolved)
|
|
338
|
+
|
|
339
|
+
platform = Platform.detect
|
|
340
|
+
publish(dir, binary, resolved, platform, Release.platform_entry(resolved, platform))
|
|
341
|
+
binary
|
|
342
|
+
end
|
|
343
|
+
rescue CLIInstallError
|
|
344
|
+
raise
|
|
345
|
+
rescue SystemCallError, IOError => e
|
|
346
|
+
# Filesystem failures (EACCES on the install dir, ENOSPC mid-download,
|
|
347
|
+
# a read-only mount) reach callers as CLIInstallError like every other
|
|
348
|
+
# install failure; `cause` keeps the original for debugging.
|
|
349
|
+
raise CLIInstallError, "Failed to install the Claude Code CLI into #{dir}: #{e.class}: #{e.message}"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Path of an already-installed binary, or nil.
|
|
353
|
+
#
|
|
354
|
+
# Deliberately lock-free, because #publish makes the lock unnecessary
|
|
355
|
+
# for readers: the binary only ever changes by a rename of a
|
|
356
|
+
# fully-downloaded, checksum-verified file, so a concurrent reader (this
|
|
357
|
+
# method, or find_cli, or the CLI being spawned) sees either the intact
|
|
358
|
+
# old binary or the intact new one — never a partial file. Taking the
|
|
359
|
+
# install lock here would put every process start behind an in-progress
|
|
360
|
+
# download for no added safety.
|
|
361
|
+
def installed_path(dir: nil)
|
|
362
|
+
path = File.join(File.expand_path(dir || default_dir), BINARY_NAME)
|
|
363
|
+
File.file?(path) && File.executable?(path) ? path : nil
|
|
364
|
+
rescue SystemCallError
|
|
365
|
+
nil
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
private
|
|
369
|
+
|
|
370
|
+
# Cross-process mutual exclusion for the whole install. flock is
|
|
371
|
+
# advisory and per open file description, so concurrent threads in one
|
|
372
|
+
# process contend here exactly like separate processes do.
|
|
373
|
+
def with_install_lock(dir)
|
|
374
|
+
flags = File::RDWR | File::CREAT
|
|
375
|
+
# Never follow a symlink planted at the lock path.
|
|
376
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
377
|
+
File.open(File.join(dir, LOCK_FILE), flags, 0o644) do |lock|
|
|
378
|
+
lock.flock(File::LOCK_EX)
|
|
379
|
+
begin
|
|
380
|
+
yield
|
|
381
|
+
ensure
|
|
382
|
+
lock.flock(File::LOCK_UN)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Remove temp files abandoned by an earlier install that died before its
|
|
388
|
+
# `ensure` could run (SIGKILL, power loss, OOM) — each one can be the
|
|
389
|
+
# full ~280MB, and every retry picks a fresh random name, so without
|
|
390
|
+
# this they accumulate. Safe because we hold the install lock: no other
|
|
391
|
+
# installer can have a download in flight. Never blocks the install.
|
|
392
|
+
def sweep_stale_temp_files(dir)
|
|
393
|
+
["#{BINARY_NAME}.download.*", "#{VERSION_FILE}.*.tmp"].each do |pattern|
|
|
394
|
+
# base: keeps glob metacharacters in +dir+ (a `[` in a project path)
|
|
395
|
+
# from being interpreted as part of the pattern.
|
|
396
|
+
Dir.glob(pattern, base: dir).each { |name| FileUtils.rm_f(File.join(dir, name)) }
|
|
397
|
+
end
|
|
398
|
+
rescue StandardError
|
|
399
|
+
nil
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# True only when the vendored binary is byte-for-byte the one recorded
|
|
403
|
+
# by a previous install of this exact version. No network access.
|
|
404
|
+
def installed?(dir, version)
|
|
405
|
+
binary = installed_path(dir: dir)
|
|
406
|
+
return false unless binary
|
|
407
|
+
|
|
408
|
+
recorded = Metadata.read(dir)
|
|
409
|
+
return false unless recorded && recorded[:version] == version
|
|
410
|
+
|
|
411
|
+
Digest::SHA256.file(binary).hexdigest == recorded[:checksum]
|
|
412
|
+
rescue SystemCallError
|
|
413
|
+
false
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Download, verify, record, then swap the binary in — in that order.
|
|
417
|
+
#
|
|
418
|
+
# The rename is deliberately LAST, because it is the only step with no
|
|
419
|
+
# possible failure after it. That ordering is what keeps a failed
|
|
420
|
+
# upgrade from destroying a working install:
|
|
421
|
+
#
|
|
422
|
+
# * download/checksum/metadata failure → the old binary and its old
|
|
423
|
+
# metadata are untouched; the install that was already there keeps
|
|
424
|
+
# working, and nothing is left behind but the (removed) temp file.
|
|
425
|
+
# * rename failure or a crash right before it → the old binary is
|
|
426
|
+
# still intact and runnable; the metadata already names the new
|
|
427
|
+
# version, whose checksum the old binary cannot match, so the next
|
|
428
|
+
# install sees "not installed" and redoes it cleanly.
|
|
429
|
+
#
|
|
430
|
+
# (The reverse order — rename then record — briefly published a binary
|
|
431
|
+
# nothing vouched for, and a metadata failure then had to delete the
|
|
432
|
+
# freshly renamed file, taking the previous working install with it.)
|
|
433
|
+
def publish(dir, binary, version, platform, entry)
|
|
434
|
+
tmp = "#{binary}.download.#{SecureRandom.hex(8)}"
|
|
435
|
+
begin
|
|
436
|
+
fetch_verified(version, platform, entry, tmp)
|
|
437
|
+
Metadata.write(dir, version, entry[:checksum])
|
|
438
|
+
File.rename(tmp, binary)
|
|
439
|
+
ensure
|
|
440
|
+
FileUtils.rm_f(tmp)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Download to an unpredictable sibling temp name (same filesystem, so the
|
|
445
|
+
# rename is atomic; O_EXCL, so a pre-planted path or symlink cannot be
|
|
446
|
+
# written through), bounded by the manifest's declared size, then verify
|
|
447
|
+
# and chmod. Leaves the file at +tmp+ for #publish to swap in.
|
|
448
|
+
def fetch_verified(version, platform, entry, tmp)
|
|
449
|
+
url = Release.binary_url(version, platform)
|
|
450
|
+
Http.download_to(url, tmp, max_bytes: entry[:size])
|
|
451
|
+
actual = Digest::SHA256.file(tmp).hexdigest
|
|
452
|
+
expected = entry[:checksum]
|
|
453
|
+
raise CLIInstallError, "Checksum mismatch for #{url}: expected #{expected}, got #{actual}" if actual != expected
|
|
454
|
+
|
|
455
|
+
File.chmod(0o755, tmp)
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -18,6 +18,11 @@ module ClaudeAgentSDK
|
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Raised when CLIInstaller cannot install the Claude Code CLI binary
|
|
22
|
+
# (unsupported platform, invalid/unresolvable version, HTTP failure,
|
|
23
|
+
# missing manifest entry, checksum mismatch).
|
|
24
|
+
class CLIInstallError < ClaudeSDKError; end
|
|
25
|
+
|
|
21
26
|
# Raised when the CLI process fails
|
|
22
27
|
class ProcessError < ClaudeSDKError
|
|
23
28
|
attr_reader :exit_code, :stderr
|
|
@@ -8,6 +8,7 @@ require_relative 'transport'
|
|
|
8
8
|
require_relative 'errors'
|
|
9
9
|
require_relative 'version'
|
|
10
10
|
require_relative 'command_builder'
|
|
11
|
+
require_relative 'cli_installer'
|
|
11
12
|
|
|
12
13
|
module ClaudeAgentSDK
|
|
13
14
|
# Subprocess transport using Claude Code CLI
|
|
@@ -15,6 +16,7 @@ module ClaudeAgentSDK
|
|
|
15
16
|
DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
|
16
17
|
MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
|
|
17
18
|
SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
|
|
19
|
+
CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH'
|
|
18
20
|
VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
|
|
19
21
|
RECENT_STDERR_LINES_LIMIT = 20
|
|
20
22
|
|
|
@@ -110,7 +112,36 @@ module ClaudeAgentSDK
|
|
|
110
112
|
@stdin_mutex = Mutex.new
|
|
111
113
|
end
|
|
112
114
|
|
|
115
|
+
# Probe order (first hit wins):
|
|
116
|
+
# 1. CLAUDE_CLI_PATH — explicit operator override, no discovery at all.
|
|
117
|
+
# 2. A project-local vendored binary (CLIInstaller). Deliberately ahead
|
|
118
|
+
# of PATH: the point of a pinned, vendored CLI is that it beats
|
|
119
|
+
# whatever version happens to be installed globally on the host.
|
|
120
|
+
# 3. `which claude`.
|
|
121
|
+
# 4. Well-known install locations.
|
|
113
122
|
def find_cli
|
|
123
|
+
env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s
|
|
124
|
+
unless env_path.empty?
|
|
125
|
+
# Absolutize against the CURRENT working directory, which is where the
|
|
126
|
+
# checks below resolve a relative path — the CLI is later spawned with
|
|
127
|
+
# `chdir: options.cwd`, where the same relative path would name a
|
|
128
|
+
# different file (or nothing at all). Returning the absolute form makes
|
|
129
|
+
# what we validated and what we execute the same file.
|
|
130
|
+
env_path = File.expand_path(env_path)
|
|
131
|
+
# File.file? as well as executable?: executable? is true for
|
|
132
|
+
# directories, so a directory in CLAUDE_CLI_PATH would otherwise pass
|
|
133
|
+
# here and fail much later with an opaque spawn error.
|
|
134
|
+
return env_path if File.file?(env_path) && File.executable?(env_path)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
vendored = begin
|
|
138
|
+
CLIInstaller.installed_path
|
|
139
|
+
rescue StandardError
|
|
140
|
+
# e.g. Dir.pwd raising because the cwd was removed — fall through.
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
return vendored if vendored
|
|
144
|
+
|
|
114
145
|
# Try which command first (using Open3 for thread safety)
|
|
115
146
|
cli = nil
|
|
116
147
|
begin
|
|
@@ -141,7 +172,11 @@ module ClaudeAgentSDK
|
|
|
141
172
|
"\nIf already installed locally, try:\n" \
|
|
142
173
|
' export PATH="$HOME/node_modules/.bin:$PATH"' \
|
|
143
174
|
"\n\nOr provide the path via ClaudeAgentOptions:\n" \
|
|
144
|
-
" ClaudeAgentOptions.new(cli_path: '/path/to/claude')"
|
|
175
|
+
" ClaudeAgentOptions.new(cli_path: '/path/to/claude')" \
|
|
176
|
+
"\n\nFor hermetic deploys (Docker/CI), vendor a pinned CLI into the project:\n" \
|
|
177
|
+
" ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')" \
|
|
178
|
+
"\n\nOr point the SDK at an existing binary:\n" \
|
|
179
|
+
" export #{CLI_PATH_ENV_VAR}=/path/to/claude"
|
|
145
180
|
)
|
|
146
181
|
end
|
|
147
182
|
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'claude_agent_sdk/configuration'
|
|
|
6
6
|
require_relative 'claude_agent_sdk/types'
|
|
7
7
|
require_relative 'claude_agent_sdk/observer'
|
|
8
8
|
require_relative 'claude_agent_sdk/transport'
|
|
9
|
+
require_relative 'claude_agent_sdk/cli_installer'
|
|
9
10
|
require_relative 'claude_agent_sdk/subprocess_cli_transport'
|
|
10
11
|
require_relative 'claude_agent_sdk/message_parser'
|
|
11
12
|
require_relative 'claude_agent_sdk/query'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-agent-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Community Contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- docs/types.md
|
|
122
122
|
- lib/claude-agent-sdk.rb
|
|
123
123
|
- lib/claude_agent_sdk.rb
|
|
124
|
+
- lib/claude_agent_sdk/cli_installer.rb
|
|
124
125
|
- lib/claude_agent_sdk/command_builder.rb
|
|
125
126
|
- lib/claude_agent_sdk/configuration.rb
|
|
126
127
|
- lib/claude_agent_sdk/errors.rb
|