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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2722d8b996148ef678484d587c1fcd88aaa68d9f3a380b342ad81b784f558cc
4
- data.tar.gz: ce8a7d155a9c21fcddd4ad4dee0ec2b728d5397bb378d0b4c53f867febbc9cf0
3
+ metadata.gz: e8e1fbf9996a81e6493a2fd8fd15a4922645933fccc93cd7d279763f01658d80
4
+ data.tar.gz: 87a8bbae7de66555c1c9d81f4791fe11ed51c385c233ec94e2541292d81f5870
5
5
  SHA512:
6
- metadata.gz: 4a73b55a0228259e19e847683c4acccc6ac319f355f7d7fe9c8fd01dbc642e6ff7de767217b770dd95b8e3e847f280f46b49a7888f534278529b576412c29a7a
7
- data.tar.gz: 5a1012c3ebd9a59f2a97476210b41873d616f053e7c02ba7c2bb385968f8134fb13b9f13730bb0c404b41877cc169dbde7c94f2d87b461671377492934511d0f
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.29.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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.29.0'
4
+ VERSION = '0.30.0'
5
5
  end
@@ -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.29.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-08 00:00:00.000000000 Z
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