claude-agent-sdk 0.29.0 → 0.31.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 +36 -0
- data/README.md +54 -4
- data/docs/configuration.md +17 -0
- data/docs/errors.md +53 -0
- data/docs/sessions.md +42 -0
- data/docs/types.md +71 -3
- data/lib/claude_agent_sdk/cli_installer.rb +459 -0
- data/lib/claude_agent_sdk/command_builder.rb +24 -0
- data/lib/claude_agent_sdk/errors.rb +152 -0
- data/lib/claude_agent_sdk/message_parser.rb +38 -3
- data/lib/claude_agent_sdk/query.rb +69 -29
- data/lib/claude_agent_sdk/session_resume.rb +223 -33
- data/lib/claude_agent_sdk/sessions.rb +112 -19
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +36 -1
- data/lib/claude_agent_sdk/types.rb +180 -0
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +33 -27
- metadata +3 -2
|
@@ -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
|
|
@@ -222,6 +222,7 @@ module ClaudeAgentSDK
|
|
|
222
222
|
# flags. The equals form always binds the value to its flag.
|
|
223
223
|
cmd.push("--resume=#{@options.resume}") if @options.resume
|
|
224
224
|
append_resume_session_at(cmd)
|
|
225
|
+
append_resume_drops_turn(cmd)
|
|
225
226
|
cmd.push("--session-id=#{@options.session_id}") if @options.session_id
|
|
226
227
|
end
|
|
227
228
|
|
|
@@ -240,6 +241,29 @@ module ClaudeAgentSDK
|
|
|
240
241
|
cmd.push("--resume-session-at=#{@options.resume_session_at}")
|
|
241
242
|
end
|
|
242
243
|
|
|
244
|
+
# `--resume-drops-turn=<prompt-uuid>` declares, alongside
|
|
245
|
+
# `--resume-session-at`, which user prompt's turn this truncating resume
|
|
246
|
+
# intends to discard. The CLI validates at load time that every transcript
|
|
247
|
+
# entry after the fork point is attributable to that turn and refuses the
|
|
248
|
+
# resume otherwise — so a caller can rewind to "before my last prompt"
|
|
249
|
+
# without silently dropping a queued message or task notification the
|
|
250
|
+
# session absorbed mid-turn that the caller never observed. A refusal
|
|
251
|
+
# surfaces as an `error_during_execution` result whose message starts with
|
|
252
|
+
# `Resume rejected by --resume-drops-turn:`.
|
|
253
|
+
#
|
|
254
|
+
# No SDK-side validation of the option combination (resume /
|
|
255
|
+
# resume_session_at): like the TypeScript and Python SDKs this defers to
|
|
256
|
+
# the CLI.
|
|
257
|
+
def append_resume_drops_turn(cmd)
|
|
258
|
+
# `.nil?`, not truthiness: an empty string is forwarded so the CLI
|
|
259
|
+
# rejects it as a malformed declaration. Dropping it here would silently
|
|
260
|
+
# disarm the guard the caller believes is armed.
|
|
261
|
+
return if @options.resume_drops_turn.nil?
|
|
262
|
+
|
|
263
|
+
# Equals form for the same reason as --resume above.
|
|
264
|
+
cmd.push("--resume-drops-turn=#{@options.resume_drops_turn}")
|
|
265
|
+
end
|
|
266
|
+
|
|
243
267
|
# Sandbox gating is `!nil?` throughout — Python's `sandbox is not None`.
|
|
244
268
|
# Booleans and {} are forwarded verbatim: an explicit `sandbox: false`
|
|
245
269
|
# must reach the CLI so it can override a sandbox enabled in the
|
|
@@ -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
|
|
@@ -33,6 +38,153 @@ module ClaudeAgentSDK
|
|
|
33
38
|
end
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
# Raised when the CLI exits after reporting a terminal error result.
|
|
42
|
+
#
|
|
43
|
+
# The CLI ends a failed run by emitting a +result+ message with
|
|
44
|
+
# +is_error: true+ (yielded to you as a ResultMessage) and *then* exiting
|
|
45
|
+
# non-zero, on purpose, for shell-script consumers. This exception replaces
|
|
46
|
+
# the bare "exit code 1" ProcessError for that case and carries the
|
|
47
|
+
# result's payload, so callers can branch on *why* the run failed without
|
|
48
|
+
# string matching:
|
|
49
|
+
#
|
|
50
|
+
# begin
|
|
51
|
+
# ClaudeAgentSDK.query(prompt: '...') { |message| ... }
|
|
52
|
+
# rescue ClaudeAgentSDK::ResultError => e
|
|
53
|
+
# if e.terminal_reason == 'api_error' # e.g. overloaded / timeout
|
|
54
|
+
# retry_later
|
|
55
|
+
# elsif e.subtype == 'error_max_turns'
|
|
56
|
+
# ...
|
|
57
|
+
# end
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# It subclasses ProcessError, so existing +rescue ProcessError+ handlers
|
|
61
|
+
# keep working.
|
|
62
|
+
#
|
|
63
|
+
# Every structured field is type-narrowed: a payload whose +subtype+ is not
|
|
64
|
+
# a String (or whose +api_error_status+ is not an Integer, ...) reads back
|
|
65
|
+
# as nil rather than leaking the raw value, so callers can branch on these
|
|
66
|
+
# without re-validating. #data always holds the payload as the CLI sent it.
|
|
67
|
+
class ResultError < ProcessError
|
|
68
|
+
# The result subtype ("error_max_turns", "error_during_execution", ... —
|
|
69
|
+
# or "success" when the agent loop itself completed but the last turn was
|
|
70
|
+
# an API error).
|
|
71
|
+
attr_reader :subtype
|
|
72
|
+
|
|
73
|
+
# Error strings reported by the CLI (may be empty). Normalized the same
|
|
74
|
+
# way the exception text is built, so the two never disagree.
|
|
75
|
+
attr_reader :errors
|
|
76
|
+
|
|
77
|
+
# The result text, if any. For API failures this holds the
|
|
78
|
+
# "API Error: ..." prose.
|
|
79
|
+
attr_reader :result
|
|
80
|
+
|
|
81
|
+
# HTTP status of the failing API call, if any.
|
|
82
|
+
attr_reader :api_error_status
|
|
83
|
+
|
|
84
|
+
# Why the run ended (e.g. "api_error", "max_turns"), if reported.
|
|
85
|
+
attr_reader :terminal_reason
|
|
86
|
+
|
|
87
|
+
# Session the result belongs to, if reported.
|
|
88
|
+
attr_reader :session_id
|
|
89
|
+
|
|
90
|
+
# The raw +result+ message payload as emitted by the CLI.
|
|
91
|
+
attr_reader :data
|
|
92
|
+
|
|
93
|
+
# The ProcessError this replaced (the bare "exit code 1" exit).
|
|
94
|
+
#
|
|
95
|
+
# Ruby only populates #cause for an exception raised inside a rescue
|
|
96
|
+
# block; the read loop hands this one to the message queue instead of
|
|
97
|
+
# raising it there, so #cause is nil and the original exit error would be
|
|
98
|
+
# lost without an explicit accessor. Mirrors Python's __cause__ chaining.
|
|
99
|
+
attr_reader :original_error
|
|
100
|
+
|
|
101
|
+
# Reading a `result` payload: shared by the structured attributes below
|
|
102
|
+
# and by .error_text, which is the whole point — the exception's fields
|
|
103
|
+
# and its message are derived from the same normalization, so they can
|
|
104
|
+
# never disagree. Private to ResultError (Python keeps the equivalent
|
|
105
|
+
# helpers module-private as _normalize_result_errors); callers outside
|
|
106
|
+
# go through .error_text.
|
|
107
|
+
module Payload
|
|
108
|
+
module_function
|
|
109
|
+
|
|
110
|
+
# Normalize the +errors+ field of a +result+ frame to clean strings.
|
|
111
|
+
#
|
|
112
|
+
# The CLI emits an Array of Strings; tolerate a bare String (older or
|
|
113
|
+
# buggy emitters), treat anything else as empty, and drop non-String or
|
|
114
|
+
# blank entries.
|
|
115
|
+
def normalize_errors(raw)
|
|
116
|
+
raw = [raw] if raw.is_a?(String)
|
|
117
|
+
return [] unless raw.is_a?(Array)
|
|
118
|
+
|
|
119
|
+
raw.filter_map { |e| e.strip if e.is_a?(String) && !e.strip.empty? }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Read a payload field, tolerating both key forms.
|
|
123
|
+
#
|
|
124
|
+
# Wire messages reach the SDK with symbolized keys, but a payload
|
|
125
|
+
# reconstructed by a caller (or replayed from JSON.parse without
|
|
126
|
+
# symbolize_names) uses Strings.
|
|
127
|
+
def field(data, key)
|
|
128
|
+
return nil unless data.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
data.key?(key) ? data[key] : data[key.to_s]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_constant :Payload
|
|
134
|
+
|
|
135
|
+
# Pick the most informative text from a `result` frame with is_error.
|
|
136
|
+
#
|
|
137
|
+
# Terminal errors the CLI raises itself (error_max_turns,
|
|
138
|
+
# error_during_execution, ...) carry their prose in errors[]. A run that
|
|
139
|
+
# ends on an API failure instead arrives as subtype "success" with
|
|
140
|
+
# is_error true, an empty errors[] and the "API Error: ..." prose in
|
|
141
|
+
# `result` — falling back to the subtype there produced the self-
|
|
142
|
+
# contradictory "Claude Code returned an error result: success". Prefer
|
|
143
|
+
# errors[], then `result`, then a non-success subtype, then the HTTP
|
|
144
|
+
# status, mirroring the TypeScript SDK's choice of `result` for the
|
|
145
|
+
# `success` subtype.
|
|
146
|
+
#
|
|
147
|
+
# Public because the read loop builds the exception message from it, and
|
|
148
|
+
# because it is the documented way to get the same one-line summary out
|
|
149
|
+
# of a raw error result you already hold (an is_error ResultMessage the
|
|
150
|
+
# CLI emitted before exiting). Mirrors Python's _error_result_text.
|
|
151
|
+
def self.error_text(data)
|
|
152
|
+
errors = Payload.normalize_errors(Payload.field(data, :errors))
|
|
153
|
+
return errors.join('; ') unless errors.empty?
|
|
154
|
+
|
|
155
|
+
result = Payload.field(data, :result)
|
|
156
|
+
return result.strip if result.is_a?(String) && !result.strip.empty?
|
|
157
|
+
|
|
158
|
+
subtype = Payload.field(data, :subtype)
|
|
159
|
+
return subtype if subtype.is_a?(String) && !subtype.empty? && subtype != 'success'
|
|
160
|
+
|
|
161
|
+
status = Payload.field(data, :api_error_status)
|
|
162
|
+
return "API error (HTTP #{status})" unless status.nil?
|
|
163
|
+
|
|
164
|
+
'unknown error'
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def initialize(message, data: nil, exit_code: nil, stderr: nil, original_error: nil)
|
|
168
|
+
data = {} unless data.is_a?(Hash)
|
|
169
|
+
@data = data
|
|
170
|
+
@original_error = original_error
|
|
171
|
+
|
|
172
|
+
subtype = Payload.field(data, :subtype)
|
|
173
|
+
@subtype = subtype.is_a?(String) ? subtype : nil
|
|
174
|
+
@errors = Payload.normalize_errors(Payload.field(data, :errors))
|
|
175
|
+
result = Payload.field(data, :result)
|
|
176
|
+
@result = result.is_a?(String) ? result : nil
|
|
177
|
+
status = Payload.field(data, :api_error_status)
|
|
178
|
+
@api_error_status = status.is_a?(Integer) ? status : nil
|
|
179
|
+
reason = Payload.field(data, :terminal_reason)
|
|
180
|
+
@terminal_reason = reason.is_a?(String) ? reason : nil
|
|
181
|
+
session_id = Payload.field(data, :session_id)
|
|
182
|
+
@session_id = session_id.is_a?(String) ? session_id : nil
|
|
183
|
+
|
|
184
|
+
super(message, exit_code: exit_code, stderr: stderr)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
36
188
|
# Raised when unable to decode JSON from CLI output
|
|
37
189
|
class CLIJSONDecodeError < ClaudeSDKError
|
|
38
190
|
attr_reader :line, :original_error
|