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.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +90 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +29 -11
- data/docs/configuration.md +164 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +84 -27
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- data/lib/claude_agent_sdk/query.rb +228 -77
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +356 -39
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +227 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +35 -5
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +94 -46
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +11 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- 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
|
|
112
|
-
:usage
|
|
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
|
|
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 |
|
|
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
|
-
| `
|
|
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.
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
445
|
-
# process contend here exactly like separate
|
|
446
|
-
|
|
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)
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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?(
|
|
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
|