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.
@@ -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