claude-agent-sdk 1.1.0 → 1.2.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +90 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +29 -11
  7. data/docs/configuration.md +164 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +84 -27
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/query.rb +228 -77
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +227 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  37. data/lib/claude_agent_sdk/types/options.rb +35 -5
  38. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  39. data/lib/claude_agent_sdk/version.rb +1 -1
  40. data/lib/claude_agent_sdk.rb +94 -46
  41. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  42. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  43. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  44. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  45. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  46. metadata +6 -4
data/docs/types.md CHANGED
@@ -82,9 +82,24 @@ currently plain classes, not `Type`s: use their snake_case accessors
82
82
  ```ruby
83
83
  # Union type of all possible messages
84
84
  Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage |
85
- StreamEvent | RateLimitEvent | ConversationResetMessage
85
+ StreamEvent | RateLimitEvent | ConversationResetMessage |
86
+ ToolProgressMessage | AuthStatusMessage | ToolUseSummaryMessage |
87
+ PromptSuggestionMessage
86
88
  ```
87
89
 
90
+ This is everything the block of `query()`, `ask`, `Client#receive_messages` and
91
+ `Client#receive_response` can receive. `SystemMessage` stands for its typed
92
+ subclasses as well (`InitMessage`, `TaskStartedMessage`, ...; see
93
+ [System and progress messages](#system-and-progress-messages)): each of them
94
+ `is_a?(SystemMessage)`. The other ten classes share nothing but the SDK's
95
+ `Type` base class.
96
+
97
+ A message type this SDK version does not know is skipped before it reaches
98
+ your block, and a `system` message with an unknown `subtype` arrives as a plain
99
+ `SystemMessage` (`subtype` and `data` set). Give a `case` over messages an
100
+ `else` that ignores the rest: a later gem release can add a class to this
101
+ list.
102
+
88
103
  ### UserMessage
89
104
 
90
105
  User input message.
@@ -108,11 +123,20 @@ class AssistantMessage
108
123
  attr_accessor :content, # Array<ContentBlock>
109
124
  :model, # String
110
125
  :parent_tool_use_id, # String | nil
111
- :error, # String | nil ('authentication_failed', 'billing_error', 'rate_limit', 'invalid_request', 'server_error', 'unknown')
112
- :usage # Hash | nil - Token usage info from the API response
126
+ :error, # String | nil - see ASSISTANT_MESSAGE_ERRORS below
127
+ :usage, # Hash | nil - Token usage info from the API response
128
+ :message_id, # String | nil - the API message id
129
+ :stop_reason, # String | nil
130
+ :session_id, # String | nil
131
+ :uuid # String | nil - UUID of this message in the transcript
113
132
  end
114
133
  ```
115
134
 
135
+ `error` is passed through from the CLI. The values this SDK version knows are
136
+ in `ASSISTANT_MESSAGE_ERRORS`: `authentication_failed`, `billing_error`,
137
+ `rate_limit`, `invalid_request`, `server_error`, `max_output_tokens`,
138
+ `unknown`. A newer CLI can send others.
139
+
116
140
  ### SystemMessage
117
141
 
118
142
  System message with metadata. Task lifecycle events are typed subclasses.
@@ -120,7 +144,7 @@ System message with metadata. Task lifecycle events are typed subclasses.
120
144
  ```ruby
121
145
  class SystemMessage
122
146
  attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', 'task_updated', etc.)
123
- :data # Hash
147
+ :data # Hash - the frame's own `data` value when that is neither nil nor false, otherwise the whole frame (see below)
124
148
  end
125
149
 
126
150
  # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works)
@@ -175,6 +199,40 @@ end
175
199
 
176
200
  See [subagent capabilities](subagents.md) for the contracts behind these fields.
177
201
 
202
+ ### System and progress messages
203
+
204
+ The remaining typed messages. An attribute the CLI did not send reads `nil`,
205
+ with two exceptions on `RateLimitEvent`: `rate_limit_info` is then an empty
206
+ `RateLimitInfo`, and `RateLimitEvent#data` is always the whole event as a
207
+ Symbol-keyed Hash. The first twelve are `SystemMessage` subclasses (wire
208
+ `type` is `system`), so they also have `subtype` and `data`. Their `data` is
209
+ the whole frame as a Symbol-keyed Hash, unless the frame carries a `data`
210
+ value of its own that is neither `nil` nor `false`: then `data` is that
211
+ value, and the frame is not kept. A frame whose `data` is `nil` or `false`
212
+ reads like one without the key: `data` is the whole frame. The last six are
213
+ message types of their own.
214
+
215
+ | Class | Wire type | Attributes | Notes |
216
+ |-------|-----------|------------|-------|
217
+ | `InitMessage` | `system` / `init` | `uuid`, `session_id`, `model`, `cwd`, `tools`, `mcp_servers`, `agents`, `skills`, `plugins`, `slash_commands`, `permission_mode`, `claude_code_version`, `api_key_source`, `betas`, `output_style`, `fast_mode_state` | Start of every turn, with the session as the CLI sees it (so a multi-query `Client` session receives one per query) |
218
+ | `CompactBoundaryMessage` | `system` / `compact_boundary` | `uuid`, `session_id`, `compact_metadata` (a `CompactMetadata`: `pre_tokens`, `post_tokens`, `trigger`, `preserved_segment`, `custom_instructions`) | Context compaction completed |
219
+ | `StatusMessage` | `system` / `status` | `uuid`, `session_id`, `status`, `permission_mode` | Compacting status, permission mode changes |
220
+ | `APIRetryMessage` | `system` / `api_retry` | `uuid`, `session_id`, `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error` | The CLI is retrying an API request |
221
+ | `LocalCommandOutputMessage` | `system` / `local_command_output` | `uuid`, `session_id`, `content` | Output of a local command |
222
+ | `HookStartedMessage` | `system` / `hook_started` | `uuid`, `session_id`, `hook_id`, `hook_name`, `hook_event` | Hook lifecycle; `include_hook_events: true` asks the CLI for all of these |
223
+ | `HookProgressMessage` | `system` / `hook_progress` | the `HookStartedMessage` attributes, `stdout`, `stderr`, `output` | |
224
+ | `HookResponseMessage` | `system` / `hook_response` | the `HookProgressMessage` attributes, `exit_code`, `outcome` (`'success'`, `'error'`, `'cancelled'`) | |
225
+ | `SessionStateChangedMessage` | `system` / `session_state_changed` | `uuid`, `session_id`, `state` (`'idle'`, `'running'`, `'requires_action'`) | Reaches your block only with `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` in `env` |
226
+ | `FilesPersistedMessage` | `system` / `files_persisted` | `uuid`, `session_id`, `files`, `failed`, `processed_at` | |
227
+ | `ElicitationCompleteMessage` | `system` / `elicitation_complete` | `uuid`, `session_id`, `mcp_server_name`, `elicitation_id` | |
228
+ | `MirrorErrorMessage` | `system` / `mirror_error` | `uuid`, `session_id`, `error`, `key` | Produced by the SDK, not the CLI: a `session_store` mirror batch was dropped (see [Sessions](sessions.md#mirroring-to-a-sessionstore)) |
229
+ | `ToolProgressMessage` | `tool_progress` | `uuid`, `session_id`, `tool_use_id`, `tool_name`, `parent_tool_use_id`, `elapsed_time_seconds`, `task_id` | Progress of a running tool call |
230
+ | `ToolUseSummaryMessage` | `tool_use_summary` | `uuid`, `session_id`, `summary`, `preceding_tool_use_ids` | |
231
+ | `AuthStatusMessage` | `auth_status` | `uuid`, `session_id`, `is_authenticating`, `output`, `error` | |
232
+ | `PromptSuggestionMessage` | `prompt_suggestion` | `uuid`, `session_id`, `suggestion` | |
233
+ | `StreamEvent` | `stream_event` | `uuid`, `session_id`, `event` (the raw API stream event, Symbol keys), `parent_tool_use_id` | Partial message chunks; only with `include_partial_messages: true` |
234
+ | `RateLimitEvent` | `rate_limit_event` | `uuid`, `session_id`, `rate_limit_info` (a `RateLimitInfo`: `status`, `resets_at`, `rate_limit_type`, `utilization`, `overage_status`, `overage_resets_at`, `overage_disabled_reason`, `raw`), `data` (the whole event) | Rate limit information changed |
235
+
178
236
  ### ResultMessage
179
237
 
180
238
  Final result message with cost and usage information.
@@ -199,10 +257,16 @@ class ResultMessage
199
257
  :fast_mode_state, # String | nil ('off', 'cooldown', 'on')
200
258
  :api_error_status, # Integer | nil (HTTP status on api_error subtype)
201
259
  :terminal_reason, # String | nil (see below)
202
- :origin # Hash | nil - origin of the triggering user message (see below)
260
+ :origin, # Hash | nil - origin of the triggering user message (see below)
261
+ :deferred_tool_use # DeferredToolUse | nil (see below)
203
262
  end
204
263
  ```
205
264
 
265
+ `deferred_tool_use` is set when a `PreToolUse` hook answered a tool call with
266
+ `permissionDecision: 'defer'`: a `DeferredToolUse` with the `id`, `name` and
267
+ `input` of the call that was put off. The session can be resumed later to run
268
+ the deferred call.
269
+
206
270
  `terminal_reason` says why the query loop ended (`"completed"`, `"max_turns"`,
207
271
  `"aborted_streaming"`, ...). `"aborted_streaming"` / `"aborted_tools"` mean the
208
272
  turn was cancelled via `Client#interrupt`. `nil` when the CLI did not report
@@ -287,7 +351,8 @@ cached session title). Read the new session id from the next message.
287
351
 
288
352
  ```ruby
289
353
  # Union type of all content blocks
290
- ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock
354
+ ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock |
355
+ ServerToolUseBlock | ServerToolResultBlock | UnknownBlock
291
356
  ```
292
357
 
293
358
  ### TextBlock
@@ -333,6 +398,30 @@ class ToolResultBlock
333
398
  end
334
399
  ```
335
400
 
401
+ ### ServerToolUseBlock and ServerToolResultBlock
402
+
403
+ `ServerToolUseBlock` is a call to a tool that runs server-side (wire type
404
+ `server_tool_use`), and `ServerToolResultBlock` is the advisor tool's result
405
+ (wire type `advisor_tool_result`). An
406
+ [advisor](configuration.md#advisor-model) consultation shows up as a
407
+ `ServerToolUseBlock` named `'advisor'` and a `ServerToolResultBlock`. Any
408
+ other block type, a different server-side result included, arrives as an
409
+ `UnknownBlock`.
410
+
411
+ ```ruby
412
+ class ServerToolUseBlock
413
+ attr_accessor :id, # String
414
+ :name, # String ('advisor', ...)
415
+ :input # Hash
416
+ end
417
+
418
+ class ServerToolResultBlock
419
+ attr_accessor :tool_use_id, # String
420
+ :content, # the result payload, as the CLI sent it
421
+ :is_error # Boolean | nil
422
+ end
423
+ ```
424
+
336
425
  ### UnknownBlock
337
426
 
338
427
  Generic content block for types the SDK doesn't explicitly handle (e.g., `document` for PDFs, `image` for inline images). Preserves the raw data for forward compatibility with newer CLI versions.
@@ -349,7 +438,7 @@ end
349
438
  | Type | Description |
350
439
  |------|-------------|
351
440
  | `Configuration` | Global defaults via `ClaudeAgentSDK.configure` block |
352
- | `ClaudeAgentOptions` | Main configuration for queries and clients |
441
+ | `ClaudeAgentOptions` | Main configuration for queries and clients. Every option is listed in the [options reference](options.md) |
353
442
  | `HookMatcher` | Hook configuration with matcher pattern and timeout |
354
443
  | `PermissionResultAllow` | Permission callback result to allow tool use |
355
444
  | `PermissionResultDeny` | Permission callback result to deny tool use |
@@ -368,11 +457,11 @@ end
368
457
  | `McpToolInfo` | MCP tool name, description, and annotations |
369
458
  | `McpToolAnnotations` | MCP tool annotation hints (`read_only`, `destructive`, `open_world`) |
370
459
  | `TaskUsage` | Typed usage data (`total_tokens`, `tool_uses`, `duration_ms`) with `from_hash` factory |
371
- | `SDKSessionInfo` | Session metadata from `list_sessions` |
460
+ | `SDKSessionInfo` | Session metadata from `list_sessions` and `get_session_info` |
372
461
  | `SessionMessage` | Single message from `get_session_messages` |
373
- | `SandboxSettings` | Sandbox settings for isolated command execution |
462
+ | `SandboxSettings` | Sandbox settings for isolated command execution. `ignore_violations` (which violations to ignore) is a plain Hash |
374
463
  | `SandboxNetworkConfig` | Network configuration for sandbox |
375
- | `SandboxIgnoreViolations` | Configure which sandbox violations to ignore |
464
+ | `SandboxFilesystemConfig` | Filesystem configuration for sandbox (`allow_write`, `deny_write`, `deny_read`, `allow_read`, `allow_managed_read_paths_only`) |
376
465
  | `SystemPromptPreset` | System prompt preset configuration (`preset`, `append`, `exclude_dynamic_sections`, `snapshot`) |
377
466
  | `SystemPromptCustom` | Custom system prompt configuration — the object form of a String prompt, so `snapshot` can be set alongside it |
378
467
  | `SystemPromptFile` | System prompt loaded from a file path |
@@ -403,7 +492,7 @@ Accepted: Symbol or String keys, snake_case or camelCase spellings, and the fixe
403
492
 
404
493
  `#[]`, `#[]=` and the camelCase readers (`msg[:session_id]`, `msg['sessionId']`, `msg.sessionId`) are public API for a type's **attributes**: the fields it declares, plus predicates such as `options.forkSession?`. Methods your own code adds to a subclass (an `attr_accessor`, a hand-written reader or setter, a mixin's accessors, a singleton method) count as attributes too.
405
494
 
406
- A name that is not an attribute behaves like an undefined one: `#[]` returns `nil`, `#[]=` ignores it (on the strict types above it raises `ArgumentError`), a camelCase call raises `NoMethodError`, and `respond_to?` answers `false`. So `msg[:to_h]` is `nil`, `msg['freeze']` does not freeze the message, and `msg.toH` raises; call the method directly instead (`msg.to_h`). `UserMessage#text` and `AssistantMessage#text` are convenience methods, not attributes.
495
+ A name that is not an attribute behaves like an undefined one: `#[]` returns `nil`, `#[]=` ignores it (on the strict types above it raises `ArgumentError`), a camelCase call raises `NoMethodError`, and `respond_to?` answers `false`. So `msg[:to_h]` is `nil`, `msg['freeze']` does not freeze the message, and `msg.toH` raises; call the method directly instead (`msg.to_h`). `#[]` only reads: a writer's name, as in `msg['session_id=']`, is `nil` too. `UserMessage#text` and `AssistantMessage#text` are convenience methods, not attributes.
407
496
 
408
497
  (Through 0.37 these accessors reached any public method, with a one-time warning in 0.37.)
409
498
 
@@ -53,13 +53,18 @@ module ClaudeAgentSDK
53
53
  # Single source of truth: bumped here (and only here) by
54
54
  # .github/workflows/cli-pin-bump.yml or a Python-sync release, so a
55
55
  # Dependabot bump of the gem carries the CLI forward with it.
56
- PINNED_CLI_VERSION = '2.1.285'
56
+ PINNED_CLI_VERSION = '2.1.288'
57
57
  # @api private
58
58
  BINARY_NAME = 'claude'
59
59
  # @api private
60
60
  VERSION_FILE = 'VERSION'
61
61
  # @api private
62
62
  LOCK_FILE = '.install.lock'
63
+ # How often a waiting installer retries the install lock. See
64
+ # .with_install_lock for why it polls instead of blocking in flock.
65
+ #
66
+ # @api private
67
+ LOCK_POLL_SECONDS = 0.05
63
68
  # Relative to .root (Dir.pwd when unset), resolved at CALL time by
64
69
  # .default_dir — an absolute constant would freeze the working directory
65
70
  # as of require time, which is wrong for anything that chdirs (Rake
@@ -168,6 +173,11 @@ module ClaudeAgentSDK
168
173
  # +max_bytes+ (the manifest's declared size, when it has one) aborts a
169
174
  # response that runs long instead of filling the disk before the
170
175
  # checksum gets a chance to reject it.
176
+ #
177
+ # The file is fsynced before it is closed: the caller renames it into
178
+ # place, and a rename only orders metadata. Without the sync, a power
179
+ # loss shortly after an install could leave the published name
180
+ # pointing at an empty or partial file.
171
181
  def download_to(url, path, max_bytes: nil)
172
182
  with_response(url) do |response|
173
183
  written = 0
@@ -180,6 +190,7 @@ module ClaudeAgentSDK
180
190
 
181
191
  file.write(chunk)
182
192
  end
193
+ file.fsync
183
194
  end
184
195
  end
185
196
  path
@@ -195,9 +206,9 @@ module ClaudeAgentSDK
195
206
  uri = url.is_a?(URI::Generic) ? url : URI(url.to_s)
196
207
  raise CLIInstallError, "Refusing to fetch non-HTTPS URL: #{uri}" unless uri.is_a?(URI::HTTPS)
197
208
 
198
- Net::HTTP.start(uri.host, uri.port, use_ssl: true,
199
- open_timeout: OPEN_TIMEOUT_SECONDS,
200
- read_timeout: READ_TIMEOUT_SECONDS) do |http|
209
+ Net::HTTP.start(uri.host, uri.port, *proxy_args(uri), use_ssl: true,
210
+ open_timeout: OPEN_TIMEOUT_SECONDS,
211
+ read_timeout: READ_TIMEOUT_SECONDS) do |http|
201
212
  http.request(Net::HTTP::Get.new(uri)) do |response|
202
213
  # Branch on the status BEFORE touching the body: a redirect or an
203
214
  # error page must never be streamed into the target file.
@@ -213,6 +224,35 @@ module ClaudeAgentSDK
213
224
  raise CLIInstallError, "Failed to fetch #{url}: #{e.class}: #{e.message}"
214
225
  end
215
226
 
227
+ # The proxy arguments for Net::HTTP.start: address, port, user,
228
+ # password — or none.
229
+ #
230
+ # Left to itself, Net::HTTP looks its proxy up as if for an http://
231
+ # URL: it reads http_proxy even though this connection is TLS. An
232
+ # environment that exports only HTTPS_PROXY (what curl, RubyGems and
233
+ # the CLI itself read for an https URL) was therefore bypassed.
234
+ # URI#find_proxy on the https URL reads https_proxy / HTTPS_PROXY and
235
+ # applies no_proxy / NO_PROXY; the proxy it names is passed
236
+ # explicitly, its credentials percent-decoded the way Net::HTTP
237
+ # decodes the ones it finds itself.
238
+ #
239
+ # No arguments means "as before": Net::HTTP's own lookup (http_proxy)
240
+ # stays in charge. That is the answer when the variable is unset or
241
+ # no_proxy excludes the host, and also when its value is nothing
242
+ # Net::HTTP can use as an HTTP proxy — no scheme, socks5://, https://,
243
+ # not a URL at all. Such a value was ignored before and still is,
244
+ # rather than turning a download that works directly into a failure.
245
+ # ALL_PROXY is not consulted.
246
+ def proxy_args(uri)
247
+ proxy = uri.find_proxy
248
+ return [] unless proxy.instance_of?(URI::HTTP) && !proxy.hostname.to_s.empty?
249
+
250
+ credentials = [proxy.user, proxy.password].map { |part| part && URI.decode_www_form_component(part) }
251
+ [proxy.hostname, proxy.port, *credentials]
252
+ rescue URI::InvalidURIError
253
+ []
254
+ end
255
+
216
256
  def follow_redirect(uri, response, redirects_left, &)
217
257
  raise CLIInstallError, "Too many redirects while fetching #{uri}" if redirects_left <= 0
218
258
 
@@ -322,11 +362,14 @@ module ClaudeAgentSDK
322
362
  # Atomic: an unpredictable temp name opened O_EXCL, then renamed over
323
363
  # the old file. Without this a reader could observe a half-written
324
364
  # VERSION, or (worse) the previous version paired with a new binary.
365
+ # Fsynced before the rename, so that after a power loss VERSION is
366
+ # the old file or the new one, not an empty one.
325
367
  def write(dir, version, checksum, platform)
326
368
  tmp = File.join(dir, "#{VERSION_FILE}.#{SecureRandom.hex(8)}.tmp")
327
369
  begin
328
370
  File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
329
371
  file.write("#{version}\n#{checksum}\n#{platform}\n")
372
+ file.fsync
330
373
  end
331
374
  File.rename(tmp, File.join(dir, VERSION_FILE))
332
375
  ensure
@@ -376,7 +419,7 @@ module ClaudeAgentSDK
376
419
  # +version+ is 'stable', 'latest', or a concrete version like '2.1.220'.
377
420
  #
378
421
  # Idempotent and safe to run concurrently: an exclusive lock on
379
- # dir/.install.lock covers the whole check-download-place-record
422
+ # dir/.install.lock covers the whole check-download-record-place
380
423
  # sequence, so parallel boots (Docker layers, `foreman start`, CI matrix
381
424
  # jobs sharing a cache) never race each other into a partially written
382
425
  # binary — the loser of the race observes a finished install.
@@ -386,6 +429,12 @@ module ClaudeAgentSDK
386
429
  # touches the network: repeat boots must work offline (with a pinned
387
430
  # version — a dist-tag has to be re-resolved to be resolved at all).
388
431
  #
432
+ # That shortcut also holds in a directory this process cannot write (an
433
+ # image built as root and run as another user, a read-only root
434
+ # filesystem): a concrete version that is already installed and intact
435
+ # is returned. Anything that would need a write — a dist-tag, another
436
+ # version, a damaged binary — raises there.
437
+ #
389
438
  # An upgrade never destroys a working install: see #publish.
390
439
  def install(version: 'stable', dir: nil)
391
440
  dir = File.expand_path(dir || default_dir)
@@ -396,7 +445,19 @@ module ClaudeAgentSDK
396
445
  requested = Release.validate_version(version)
397
446
  binary = File.join(dir, BINARY_NAME)
398
447
  FileUtils.mkdir_p(dir)
399
- with_install_lock(dir) do
448
+ begin
449
+ lock = open_lock_file(dir)
450
+ rescue Errno::EACCES, Errno::EROFS, Errno::EPERM
451
+ # The lock file cannot be opened for writing, so nothing can be
452
+ # installed here — and nothing has to be when the requested version
453
+ # already is (see .installed_without_lock?). This rescue covers
454
+ # OPENING the lock file and nothing else: a permission error raised
455
+ # inside the critical section must keep failing the install.
456
+ raise unless installed_without_lock?(dir, requested)
457
+
458
+ return binary
459
+ end
460
+ with_install_lock(lock) do
400
461
  sweep_stale_temp_files(dir)
401
462
  resolved = Release.resolve_version(requested)
402
463
  platform = Platform.detect
@@ -410,8 +471,11 @@ module ClaudeAgentSDK
410
471
  rescue SystemCallError, IOError => e
411
472
  # Filesystem failures (EACCES on the install dir, ENOSPC mid-download,
412
473
  # a read-only mount) reach callers as CLIInstallError like every other
413
- # install failure; `cause` keeps the original for debugging.
414
- raise CLIInstallError, "Failed to install the Claude Code CLI into #{dir}: #{e.class}: #{e.message}"
474
+ # install failure; `cause` keeps the original for debugging. +dir+ is
475
+ # still nil when resolving the default directory is what failed (the
476
+ # working directory was deleted): name it by its relative path then.
477
+ raise CLIInstallError,
478
+ "Failed to install the Claude Code CLI into #{dir || DEFAULT_DIR}: #{e.class}: #{e.message}"
415
479
  end
416
480
 
417
481
  # Install PINNED_CLI_VERSION — the version this gem release was tested
@@ -441,20 +505,50 @@ module ClaudeAgentSDK
441
505
  private
442
506
 
443
507
  # Cross-process mutual exclusion for the whole install. flock is
444
- # advisory and per open file description, so concurrent threads in one
445
- # process contend here exactly like separate processes do.
446
- def with_install_lock(dir)
508
+ # advisory and per open file description, so concurrent threads — and
509
+ # concurrent fibers — in one process contend here exactly like separate
510
+ # processes do.
511
+ #
512
+ # The lock is polled (LOCK_NB + sleep), never taken with a blocking
513
+ # LOCK_EX. File#flock has no Fiber-scheduler hook, so a blocking call
514
+ # parks the whole reactor THREAD; when the holder is another fiber of
515
+ # that reactor, waiting inside the critical section for the network, it
516
+ # is never resumed and neither install returns. Kernel#sleep yields to
517
+ # a scheduler and is an ordinary sleep on a plain thread. Mutual
518
+ # exclusion is the same either way; like flock itself, the order in
519
+ # which waiters get the lock is unspecified.
520
+ #
521
+ # +lock+ is the open lock file (see .open_lock_file); it is closed here.
522
+ def with_install_lock(lock)
523
+ sleep(LOCK_POLL_SECONDS) until lock.flock(File::LOCK_EX | File::LOCK_NB)
524
+ begin
525
+ yield
526
+ ensure
527
+ lock.flock(File::LOCK_UN)
528
+ end
529
+ ensure
530
+ lock.close
531
+ end
532
+
533
+ # Opened read-write and created when missing. Kept apart from
534
+ # .with_install_lock so that .install can tell a lock file that cannot
535
+ # be opened from a failure inside the critical section.
536
+ def open_lock_file(dir)
447
537
  flags = File::RDWR | File::CREAT
448
538
  # Never follow a symlink planted at the lock path.
449
539
  flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
450
- File.open(File.join(dir, LOCK_FILE), flags, 0o644) do |lock|
451
- lock.flock(File::LOCK_EX)
452
- begin
453
- yield
454
- ensure
455
- lock.flock(File::LOCK_UN)
456
- end
457
- end
540
+ File.open(File.join(dir, LOCK_FILE), flags, 0o644)
541
+ end
542
+
543
+ # Whether .install can answer +requested+ without the install lock: it
544
+ # has to be a concrete version — a dist-tag must be resolved and may
545
+ # have to be published, which needs the lock — whose binary is in place
546
+ # and re-hashes to the checksum recorded for it. This is the lock-free
547
+ # read .installed_path already documents (#publish only ever renames a
548
+ # complete, verified binary into place) plus the re-hash, and it writes
549
+ # nothing.
550
+ def installed_without_lock?(dir, requested)
551
+ !DIST_TAGS.include?(requested) && installed?(dir, requested, Platform.detect)
458
552
  end
459
553
 
460
554
  # Remove temp files abandoned by an earlier install that died before its
@@ -503,17 +597,38 @@ module ClaudeAgentSDK
503
597
  # (The reverse order — rename then record — briefly published a binary
504
598
  # nothing vouched for, and a metadata failure then had to delete the
505
599
  # freshly renamed file, taking the previous working install with it.)
600
+ #
601
+ # Across a power loss, a rename only orders metadata. So the downloaded
602
+ # bytes (Http.download_to), the binary's executable mode (after its
603
+ # chmod, #fetch_verified) and the VERSION bytes (Metadata.write) are
604
+ # fsynced before their renames: each name then holds a complete file,
605
+ # the old one or the new one, never an empty, partial or
606
+ # non-executable one. The
607
+ # directory is synced after the rename so that an install that returned
608
+ # is still there afterwards; that sync is best-effort and cannot fail
609
+ # the install, which keeps the rename the last step that can.
506
610
  def publish(dir, binary, version, platform, entry)
507
611
  tmp = "#{binary}.download.#{SecureRandom.hex(8)}"
508
612
  begin
509
613
  fetch_verified(version, platform, entry, tmp)
510
614
  Metadata.write(dir, version, entry[:checksum], platform)
511
615
  File.rename(tmp, binary)
616
+ sync_directory(dir)
512
617
  ensure
513
618
  FileUtils.rm_f(tmp)
514
619
  end
515
620
  end
516
621
 
622
+ # Makes the renames in +dir+ durable (VERSION's and the binary's).
623
+ # Best-effort by necessity: it runs after the rename, where nothing may
624
+ # fail the install, and not every platform or filesystem can fsync a
625
+ # directory — some refuse to open one, others answer EINVAL or EBADF.
626
+ def sync_directory(dir)
627
+ File.open(dir, File::RDONLY, &:fsync)
628
+ rescue SystemCallError, IOError, NotImplementedError
629
+ nil
630
+ end
631
+
517
632
  # Download to an unpredictable sibling temp name (same filesystem, so the
518
633
  # rename is atomic; O_EXCL, so a pre-planted path or symlink cannot be
519
634
  # written through), bounded by the manifest's declared size, then verify
@@ -526,6 +641,12 @@ module ClaudeAgentSDK
526
641
  raise CLIInstallError, "Checksum mismatch for #{url}: expected #{expected}, got #{actual}" if actual != expected
527
642
 
528
643
  File.chmod(0o755, tmp)
644
+ # The mode is metadata of the file itself: the data fsync in
645
+ # Http.download_to came before it, and the directory sync after the
646
+ # rename does not cover it. Without this sync a power loss could bring
647
+ # the published binary back without its executable bit, and
648
+ # installed_path would skip it.
649
+ File.open(tmp, File::RDONLY, &:fsync)
529
650
  end
530
651
  end
531
652
  end
@@ -76,7 +76,7 @@ module ClaudeAgentSDK
76
76
  when String
77
77
  cmd.push('--system-prompt', @options.system_prompt)
78
78
  when SystemPromptFile
79
- cmd.push('--system-prompt-file', @options.system_prompt.path)
79
+ cmd.push('--system-prompt-file', path_string(@options.system_prompt.path))
80
80
  when SystemPromptCustom
81
81
  # The object form of a String prompt; snapshot travels on the
82
82
  # initialize request, not as a CLI flag.
@@ -90,12 +90,25 @@ module ClaudeAgentSDK
90
90
  end
91
91
  end
92
92
 
93
+ # The type tag of a Hash option, as a String: `type: :preset` is the
94
+ # natural Ruby spelling of `type: 'preset'`, and thinking and the MCP
95
+ # server configs already read it that way. A missing tag is '', which
96
+ # matches no branch, like any other tag the SDK does not know.
97
+ def hash_type(hash)
98
+ (hash[:type] || hash['type']).to_s
99
+ end
100
+
101
+ # A path as the String the command line takes. A Pathname (anything that
102
+ # answers #to_path) is converted; every other value is returned as it is.
103
+ def path_string(path)
104
+ path.respond_to?(:to_path) ? path.to_path : path
105
+ end
106
+
93
107
  def append_hash_system_prompt(cmd, prompt_hash)
94
- prompt_type = prompt_hash[:type] || prompt_hash['type']
95
- case prompt_type
108
+ case hash_type(prompt_hash)
96
109
  when 'file'
97
110
  prompt_path = prompt_hash[:path] || prompt_hash['path']
98
- cmd.push('--system-prompt-file', prompt_path) if prompt_path
111
+ cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path
99
112
  when 'custom'
100
113
  prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] }
101
114
  cmd.push('--system-prompt', custom_prompt_text(prompt))
@@ -129,8 +142,12 @@ module ClaudeAgentSDK
129
142
  # Each listed name is validated before being formatted into a rule (see
130
143
  # #validate_skill_name). Both SDKs reject non-list, non-'all' shapes
131
144
  # loudly; this raises ArgumentError where Python raises TypeError.
145
+ #
146
+ # allowed_tools, like disallowed_tools, add_dirs and extra_args below, is
147
+ # nil when it was set to nil after construction (the readers are signed
148
+ # nilable); each use site reads nil as the constructor's default.
132
149
  def skills_defaults
133
- allowed_tools = @options.allowed_tools.dup
150
+ allowed_tools = (@options.allowed_tools || []).dup
134
151
  setting_sources = @options.setting_sources&.dup
135
152
  skills = @options.skills
136
153
  return [allowed_tools, setting_sources] if skills.nil?
@@ -210,7 +227,8 @@ module ClaudeAgentSDK
210
227
  end
211
228
 
212
229
  def append_disallowed_tools(cmd)
213
- cmd.push('--disallowedTools', @options.disallowed_tools.join(',')) unless @options.disallowed_tools.empty?
230
+ disallowed_tools = @options.disallowed_tools || []
231
+ cmd.push('--disallowedTools', disallowed_tools.join(',')) unless disallowed_tools.empty?
214
232
  end
215
233
 
216
234
  def append_max_turns(cmd)
@@ -301,16 +319,14 @@ module ClaudeAgentSDK
301
319
  settings_is_path = false
302
320
 
303
321
  if @options.settings
304
- if @options.settings.is_a?(String)
322
+ if @options.settings.respond_to?(:to_path)
323
+ # A Pathname names a settings file; it is never tried as inline JSON.
324
+ settings_hash, settings_is_path = settings_file(cmd, @options.settings.to_path)
325
+ elsif @options.settings.is_a?(String)
305
326
  begin
306
327
  settings_hash = JSON.parse(@options.settings)
307
328
  rescue JSON::ParserError
308
- if @options.sandbox.nil? # rubocop:disable Metrics/BlockNesting -- settings-is-a-path fallback inside the JSON parse rescue
309
- settings_is_path = true
310
- cmd.push('--settings', @options.settings)
311
- else
312
- settings_hash = load_settings_file(@options.settings)
313
- end
329
+ settings_hash, settings_is_path = settings_file(cmd, @options.settings)
314
330
  end
315
331
  elsif @options.settings.is_a?(Hash)
316
332
  settings_hash = @options.settings.dup
@@ -318,12 +334,40 @@ module ClaudeAgentSDK
318
334
  end
319
335
 
320
336
  if !settings_is_path && !@options.sandbox.nil?
321
- settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
337
+ # The option replaces any sandbox section the settings carry. Settings
338
+ # read from JSON spell that key as a String; left next to the Symbol
339
+ # key below it would be written twice (json 3.x raises on that).
340
+ settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' }
341
+ settings_hash[:sandbox] = sandbox_section(@options.sandbox)
322
342
  end
323
343
 
324
344
  cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
325
345
  end
326
346
 
347
+ # --settings for a settings file, as [settings_hash, settings_is_path].
348
+ # Without a sandbox option the path itself is passed and the CLI reads
349
+ # the file. With one, the file is read here, so that the option can be
350
+ # folded into its content.
351
+ def settings_file(cmd, path)
352
+ return [load_settings_file(path), false] unless @options.sandbox.nil?
353
+
354
+ cmd.push('--settings', path)
355
+ [{}, true]
356
+ end
357
+
358
+ # The sandbox section as the CLI reads it. A Hash stands for the
359
+ # SandboxSettings with the same fields: the CLI only knows the camelCase
360
+ # keys that class writes, and it ignores the others without an error, so
361
+ # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the
362
+ # typed value would be (SandboxKeys). Booleans go out as they are.
363
+ def sandbox_section(sandbox)
364
+ case sandbox
365
+ when SandboxSettings then sandbox.to_h
366
+ when Hash then SandboxKeys.normalize(sandbox)
367
+ else sandbox
368
+ end
369
+ end
370
+
327
371
  def append_budget(cmd)
328
372
  cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd
329
373
 
@@ -408,10 +452,13 @@ module ClaudeAgentSDK
408
452
  when Array
409
453
  tools_value = @options.tools.empty? ? '' : @options.tools.join(',')
410
454
  cmd.push('--tools', tools_value)
455
+ when String
456
+ # The CLI's own syntax ("Read,Grep", "default", ""): passed as written.
457
+ cmd.push('--tools', @options.tools)
411
458
  when ToolsPreset
412
459
  cmd.push('--tools', 'default')
413
460
  when Hash
414
- if (@options.tools[:type] || @options.tools['type']) == 'preset'
461
+ if hash_type(@options.tools) == 'preset'
415
462
  cmd.push('--tools', 'default')
416
463
  else
417
464
  cmd.push('--tools', JSON.generate(@options.tools))
@@ -422,13 +469,7 @@ module ClaudeAgentSDK
422
469
  def append_output_format(cmd)
423
470
  return unless @options.output_format
424
471
 
425
- schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_schema'
426
- @options.output_format[:schema]
427
- elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema'
428
- @options.output_format['schema']
429
- else
430
- @options.output_format
431
- end
472
+ schema = output_schema(@options.output_format)
432
473
  # A json_schema output_format with a nil/absent schema must skip the
433
474
  # flag — `--json-schema null` is rejected by the CLI (Python guards
434
475
  # `schema is not None`).
@@ -438,8 +479,24 @@ module ClaudeAgentSDK
438
479
  cmd.push('--json-schema', schema_json)
439
480
  end
440
481
 
482
+ # The schema of a { type: 'json_schema', schema: ... } output format; any
483
+ # other value is the schema itself. The tag may be a Symbol, and `schema`
484
+ # is read under the key style `type` was written in, or under the other
485
+ # one when that key is absent ({ 'type' => 'json_schema', schema: {...} }).
486
+ def output_schema(format)
487
+ return format unless format.is_a?(Hash)
488
+
489
+ if format[:type].to_s == 'json_schema'
490
+ format.fetch(:schema) { format['schema'] }
491
+ elsif format['type'].to_s == 'json_schema'
492
+ format.fetch('schema') { format[:schema] }
493
+ else
494
+ format
495
+ end
496
+ end
497
+
441
498
  def append_additional_dirs(cmd)
442
- @options.add_dirs.each { |dir| cmd.push('--add-dir', dir.to_s) }
499
+ (@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) }
443
500
  end
444
501
 
445
502
  def append_mcp_servers(cmd)
@@ -484,15 +541,15 @@ module ClaudeAgentSDK
484
541
 
485
542
  @options.plugins.each do |plugin|
486
543
  plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin
487
- plugin_type = plugin_config[:type] || plugin_config['type']
488
544
  plugin_path = plugin_config[:path] || plugin_config['path']
489
545
 
490
- unless %w[local plugin].include?(plugin_type)
546
+ unless %w[local plugin].include?(hash_type(plugin_config))
547
+ plugin_type = plugin_config[:type] || plugin_config['type']
491
548
  raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}"
492
549
  end
493
550
  next unless plugin_path
494
551
 
495
- cmd.push('--plugin-dir', plugin_path)
552
+ cmd.push('--plugin-dir', path_string(plugin_path))
496
553
  end
497
554
  end
498
555
 
@@ -503,7 +560,7 @@ module ClaudeAgentSDK
503
560
  end
504
561
 
505
562
  def append_extra_args(cmd)
506
- @options.extra_args.each do |flag, value|
563
+ (@options.extra_args || {}).each do |flag, value|
507
564
  unless EXTRA_ARG_FLAG_REGEXP.match?(flag)
508
565
  raise ArgumentError, "Invalid extra_args flag name: #{flag.inspect} (expected lowercase kebab-case)"
509
566
  end