claude-agent-sdk 0.35.0 → 0.37.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +17 -8
  4. data/docs/cli-installer.md +16 -2
  5. data/docs/client.md +44 -4
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +27 -3
  8. data/docs/mcp-servers.md +36 -7
  9. data/docs/rails.md +3 -4
  10. data/docs/sessions.md +149 -34
  11. data/docs/types.md +106 -4
  12. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  13. data/lib/claude_agent_sdk/cli_installer.rb +68 -11
  14. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  15. data/lib/claude_agent_sdk/deprecation.rb +90 -0
  16. data/lib/claude_agent_sdk/errors.rb +8 -0
  17. data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
  18. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  19. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  20. data/lib/claude_agent_sdk/observer.rb +2 -1
  21. data/lib/claude_agent_sdk/option_warnings.rb +2 -2
  22. data/lib/claude_agent_sdk/query.rb +99 -51
  23. data/lib/claude_agent_sdk/railtie.rb +14 -3
  24. data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
  25. data/lib/claude_agent_sdk/session_mutations.rb +28 -16
  26. data/lib/claude_agent_sdk/session_resume.rb +39 -35
  27. data/lib/claude_agent_sdk/session_store.rb +35 -21
  28. data/lib/claude_agent_sdk/session_summary.rb +12 -5
  29. data/lib/claude_agent_sdk/sessions.rb +112 -24
  30. data/lib/claude_agent_sdk/streaming.rb +1 -1
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
  32. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
  33. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
  34. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
  35. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  36. data/lib/claude_agent_sdk/types/base.rb +320 -0
  37. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  38. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  39. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  40. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  41. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  42. data/lib/claude_agent_sdk/types/options.rb +352 -0
  43. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  44. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  45. data/lib/claude_agent_sdk/types.rb +13 -2534
  46. data/lib/claude_agent_sdk/version.rb +1 -1
  47. data/lib/claude_agent_sdk.rb +308 -73
  48. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
  49. metadata +12 -1
data/docs/sessions.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Session Browsing & Mutations
2
2
 
3
- Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. These APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable (an empty value is treated as unset, falling back to `~/.claude`) and auto-detecting git worktrees.
3
+ Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. By default these APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable (an empty value is treated as unset, falling back to `~/.claude`) and auto-detecting git worktrees. On a host with no usable home directory (`HOME` unset with no passwd entry — e.g. `docker --user` in a minimal image — or an empty/relative `HOME`) and no `CLAUDE_CONFIG_DIR`, the local-disk path raises `ClaudeAgentSDK::ConfigDirError`; set `CLAUDE_CONFIG_DIR` there. Every one of them also takes an optional `session_store:` to operate on a [`SessionStore`](#mirroring-to-a-sessionstore) instead (see [Store-backed sessions](#store-backed-sessions)).
4
4
 
5
- Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike.
5
+ Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String.
6
6
 
7
7
  ## Listing Sessions
8
8
 
@@ -25,7 +25,9 @@ ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true)
25
25
 
26
26
  Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at`.
27
27
 
28
- Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths (a blank `cwd` falls back to the project path).
28
+ Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths.
29
+
30
+ When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (worktree listings: the worktree `git worktree list` reports first, i.e. the main worktree).
29
31
 
30
32
  ## Reading Session Messages
31
33
 
@@ -38,7 +40,7 @@ messages.each { |msg| puts "[#{msg.type}] #{msg.message}" }
38
40
  ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...', offset: 10, limit: 20)
39
41
  ```
40
42
 
41
- Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (raw API hash).
43
+ Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)).
42
44
 
43
45
  ## Reading Subagent Transcripts
44
46
 
@@ -49,7 +51,7 @@ ids = ClaudeAgentSDK.list_subagents(session_id: "uuid-here", directory: "/path/t
49
51
  messages = ClaudeAgentSDK.get_subagent_messages(session_id: "uuid-here", agent_id: ids.first, limit: 50)
50
52
  ```
51
53
 
52
- With `directory:` given, only that project and its git worktrees are searched (no global fallback). Store-backed counterparts: `list_subagents_from_store` / `get_subagent_messages_from_store`.
54
+ With `directory:` given, only that project and its git worktrees are searched (no global fallback). Pass `session_store:` to read the subagents mirrored into a store instead.
53
55
 
54
56
  > Each returned `SessionMessage` carries `parent_tool_use_id` — the id of the Agent `tool_use` block in the parent session that spawned this subagent — and `parent_agent_id`, the spawning subagent's id for nested subagents. Both are read from the `agent-<id>.meta.json` sidecar beside the transcript (or the `agent_metadata` entry in a `SessionStore`), and are `nil` when it is missing or unusable.
55
57
 
@@ -57,8 +59,8 @@ With `directory:` given, only that project and its git worktrees are searched (n
57
59
 
58
60
  ```ruby
59
61
  meta = ClaudeAgentSDK.get_subagent_metadata(session_id: session_id, agent_id: agent_id, directory: project)
60
- meta = ClaudeAgentSDK.get_subagent_metadata_from_store(
61
- session_store: store, session_id: session_id, agent_id: agent_id, directory: project
62
+ meta = ClaudeAgentSDK.get_subagent_metadata(
63
+ session_id: session_id, agent_id: agent_id, directory: project, session_store: store
62
64
  )
63
65
  meta&.dig('toolUseId') # spawning Agent tool call; not task_id
64
66
  meta&.dig('parentAgentId')
@@ -211,6 +213,14 @@ ClaudeAgentSDK.query(
211
213
  ) { |message| }
212
214
  ```
213
215
 
216
+ The mirror maps each transcript file the CLI reports to a store key relative to
217
+ the subprocess's projects dir: `CLAUDE_CONFIG_DIR` from `options.env` (else
218
+ `ENV`), else `~/.claude` under the `HOME` the subprocess sees (`options.env`'s
219
+ `HOME` when it sets one). When neither exists — no `CLAUDE_CONFIG_DIR` and no
220
+ usable home — the session still runs, but nothing is mirrored: each unmappable
221
+ batch is reported as a `MirrorErrorMessage` (with a `nil` key) telling you to
222
+ set `CLAUDE_CONFIG_DIR`.
223
+
214
224
  Relevant options: `session_store`, `session_store_flush` (`"batched"` default, or
215
225
  `"eager"` to flush each frame as soon as the store is free — frames arriving
216
226
  while an append is in flight are coalesced into the next append, so a slow
@@ -228,7 +238,8 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
228
238
  > **Store-backed resume runs against a temp `CLAUDE_CONFIG_DIR`.** The SDK
229
239
  > materializes the session transcript (plus subagent transcripts, when the
230
240
  > store implements `#list_subkeys`) into it and seeds it from your real config
231
- > dir (`CLAUDE_CONFIG_DIR` from `options.env`/`ENV`, else `~/.claude`):
241
+ > dir (`CLAUDE_CONFIG_DIR` from `options.env`/`ENV`, else `~/.claude` under the
242
+ > `HOME` the subprocess will see — `options.env`'s `HOME` when it sets one):
232
243
  >
233
244
  > - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed
234
245
  > subprocess can't consume it. On macOS with the default config dir and no
@@ -264,8 +275,11 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
264
275
  Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and
265
276
  `#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and
266
277
  `#list_session_summaries` are optional and probed via `SessionStore.implements?`.
267
- Report `mtime` as epoch milliseconds; the SDK also orders numeric-string and
268
- ISO-8601-string mtimes correctly, but anything else sorts as oldest. Subagent
278
+ Report `mtime` as epoch milliseconds; the SDK also accepts numeric-string,
279
+ ISO-8601-string and `Time` mtimes (ordering them correctly and reporting them
280
+ as Integer epoch ms in `last_modified`), but anything else sorts as oldest and
281
+ reads as `0`. `continue_conversation` picks the newest candidate by the same
282
+ rule as the listings (equal mtimes: lowest `session_id`). Subagent
269
283
  transcripts arrive under a `subpath` key such as `subagents/agent-<agent_id>`
270
284
  (or nested `subagents/workflows/<runId>/agent-<agent_id>`); on a store without
271
285
  `#list_subkeys` the subagent readers build `subagents/agent-<agent_id>` from the
@@ -278,6 +292,65 @@ require 'claude_agent_sdk/testing/session_store_conformance'
278
292
  ClaudeAgentSDK::Testing.run_session_store_conformance(-> { MyStore.new(...) })
279
293
  ```
280
294
 
295
+ Keys and entries cross the adapter boundary with **String** keys (see
296
+ [Hash keys](types.md#hash-keys)): a key is `{ 'project_key' => ..., 'session_id' => ... }`,
297
+ plus `'subpath'` for a subagent transcript, and entries are the raw JSONL
298
+ objects. Persist entries verbatim and treat `entry['uuid']` as an idempotency
299
+ key, since a retried or re-imported batch can repeat earlier writes.
300
+
301
+ #### Helpers for adapter authors
302
+
303
+ `ClaudeAgentSDK.project_key_for_directory(directory = nil)` returns the
304
+ `project_key` the SDK uses for a directory (a String or Pathname; `nil` means
305
+ the current working directory). It applies the CLI's project-directory naming
306
+ (realpath, Unicode NFC, then the CLI's sanitization), so a key you build
307
+ matches the keys of live-mirrored transcripts:
308
+
309
+ ```ruby
310
+ key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory('/path/to/project'),
311
+ 'session_id' => '550e8400-e29b-41d4-a716-446655440000' }
312
+ store.load(key)
313
+ ```
314
+
315
+ `ClaudeAgentSDK.fold_session_summary(prev, key, entries)` maintains a
316
+ per-session summary incrementally, so an adapter can implement
317
+ `#list_session_summaries` and `list_sessions(session_store:)` can read every
318
+ session's metadata in one call instead of one `#load` per session. Call it
319
+ from `#append`:
320
+
321
+ ```ruby
322
+ def append(key, entries)
323
+ return if entries.nil? || entries.empty?
324
+
325
+ write_entries(key, entries)
326
+ return unless key['subpath'].nil? # subagent transcripts never feed the summary
327
+
328
+ summary = ClaudeAgentSDK.fold_session_summary(read_summary(key), key, entries)
329
+ summary['mtime'] = write_time_ms # the clock #list_sessions reports
330
+ write_summary(key, summary)
331
+ end
332
+
333
+ def list_session_summaries(project_key)
334
+ read_summaries(project_key) # => [{ 'session_id' => ..., 'mtime' => ..., 'data' => {...} }, ...]
335
+ end
336
+ ```
337
+
338
+ - `prev` is the summary you stored for the same key on the previous append,
339
+ or `nil` on the first one. `entries` are the entries being appended.
340
+ - It returns a new `{ 'session_id', 'mtime', 'data' }` Hash and leaves `prev`
341
+ unchanged. Every derived field is set-once or last-wins, so the fold never
342
+ needs earlier entries again.
343
+ - Only call it for main-transcript keys (no `'subpath'`).
344
+ - It does not set `mtime`: it carries `prev`'s value, or `0` for a new
345
+ session. Stamp it after persisting, from the same clock as the `mtime`
346
+ your `#list_sessions` returns. When the store also implements
347
+ `#list_sessions`, a summary whose `mtime` is older than the listed one is
348
+ treated as stale and the SDK re-derives it from the transcript.
349
+ - `data` is opaque. Store it as returned; its String keys survive a JSON
350
+ round-trip (JSONB, Redis).
351
+
352
+ `InMemorySessionStore#append` is a working reference.
353
+
281
354
  Copy-in reference adapters for **S3, Redis, and Postgres** live in
282
355
  [`examples/session_stores/`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md), each with a
283
356
  production checklist.
@@ -340,30 +413,72 @@ thread for default adapters, inside the cooperative timeout for inline
340
413
  declarers (the cancellation passes through the wrapper un-swallowed and the
341
414
  wrapper's `ensure` runs at cancellation).
342
415
 
343
- ### Store-backed helpers
344
-
345
- The browsing/mutation helpers above have store-backed counterparts that take a
346
- `session_store:` and operate on the store instead of local disk:
347
-
348
- - Reads: `list_sessions_from_store`, `get_session_info_from_store`,
349
- `get_session_messages_from_store`, `list_subagents_from_store`,
350
- `get_subagent_messages_from_store`. Unlike the disk readers (where a nil
351
- `directory:` searches every project directory), the store helpers key every
352
- read by `project_key` and a nil `directory:` defaults to the **current
353
- working directory** — the `SessionStore` interface has no way to enumerate
354
- project keys (parity with the Python SDK).
355
- - Mutations: `rename_session_via_store`, `tag_session_via_store`,
356
- `delete_session_via_store` (a no-op on append-only stores without `#delete`),
357
- `fork_session_via_store`. Like their disk counterparts, rename, tag, and fork
358
- raise `Errno::ENOENT` for a session the store has never seen (`#load`
359
- returns nil or `[]`) instead of appending to — and so creating — a phantom
360
- session. Rename/tag probe with one `#load` before appending; the probe is
361
- check-then-act, so a session deleted concurrently between the probe and the
362
- append can still be recreated by that append.
363
- - Migration: `import_session_to_store` replays a local on-disk session (and its
364
- subagents) into a store.
416
+ ### Store-backed sessions
417
+
418
+ Every browsing/mutation function above takes an optional `session_store:`.
419
+ Omitted (or `nil`), it works on local disk as described above; given a store,
420
+ it operates on the store instead, with the same arguments:
365
421
 
366
422
  ```ruby
367
- ClaudeAgentSDK.rename_session_via_store(session_store: store, session_id: '550e8400-...', title: 'Renamed')
368
- forked = ClaudeAgentSDK.fork_session_via_store(session_store: store, session_id: '550e8400-...')
423
+ ClaudeAgentSDK.list_sessions(session_store: store, limit: 10)
424
+ ClaudeAgentSDK.get_session_messages(session_id: '550e8400-...', session_store: store)
425
+ ClaudeAgentSDK.rename_session(session_id: '550e8400-...', title: 'Renamed', session_store: store)
426
+ forked = ClaudeAgentSDK.fork_session(session_id: '550e8400-...', session_store: store)
369
427
  ```
428
+
429
+ Where the store path differs from the disk path:
430
+
431
+ - **`directory: nil` means the current working directory.** The disk readers
432
+ search every project directory when `directory:` is nil; a store keys every
433
+ read and write by `project_key` and has no way to enumerate project keys
434
+ (parity with the Python SDK).
435
+ - **`include_worktrees:` is disk-only.** A store has no worktrees, so with
436
+ `session_store:` only the default `true` is accepted; `false` or `nil`
437
+ raises `ArgumentError` rather than being silently ignored.
438
+ - `list_sessions` uses the store's `#list_session_summaries` when implemented,
439
+ else `#list_sessions` plus one `#load` per listed session; a store with
440
+ neither raises `ArgumentError`. `list_subagents` requires `#list_subkeys`.
441
+ - Rename, tag, and fork raise `Errno::ENOENT` for a session the store has
442
+ never seen (`#load` returns nil or `[]`) instead of appending to — and so
443
+ creating — a phantom session, like their disk counterparts. Rename/tag probe
444
+ with one `#load` before appending; the probe is check-then-act, so a session
445
+ deleted concurrently between the probe and the append can still be
446
+ recreated by that append. The appended entries carry a fresh `uuid` and
447
+ `timestamp`, so adapters that dedupe by `uuid` treat them correctly.
448
+ - `delete_session` is a no-op on append-only stores without `#delete` (the
449
+ disk path raises `Errno::ENOENT` for an unknown session); whether subagent
450
+ entries are removed too depends on the store's delete cascade.
451
+
452
+ To migrate, `import_session_to_store` replays a local on-disk session (and its
453
+ subagents) into a store:
454
+
455
+ ```ruby
456
+ ClaudeAgentSDK.import_session_to_store(
457
+ session_id: '550e8400-...',
458
+ session_store: store,
459
+ directory: '/path/to/project', # optional; nil searches every project
460
+ include_subagents: true, # default
461
+ batch_size: 500 # default
462
+ )
463
+ ```
464
+
465
+ It streams the transcript and calls `store.append` once per batch. A batch ends
466
+ at `batch_size` entries (default **500**; `nil` or a non-positive value also
467
+ means 500) or at about 1 MiB of JSONL, whichever comes first. Entries are
468
+ keyed under the on-disk project directory name, so the imported session can
469
+ be resumed with `session_store:` + `resume:` from the original directory.
470
+ Re-importing appends the entries again, so adapters should dedupe by
471
+ `entry['uuid']`. It raises `ArgumentError` for an invalid `session_id` and
472
+ `Errno::ENOENT` when the transcript cannot be found; an unparseable line is
473
+ skipped with a warning.
474
+
475
+ > **Deprecated:** the separate store functions (`list_sessions_from_store`,
476
+ > `get_session_info_from_store`, `get_session_messages_from_store`,
477
+ > `list_subagents_from_store`, `get_subagent_metadata_from_store`,
478
+ > `get_subagent_messages_from_store`, `rename_session_via_store`,
479
+ > `tag_session_via_store`, `delete_session_via_store`,
480
+ > `fork_session_via_store`) still work unchanged but print a one-time
481
+ > deprecation warning and will be removed in 1.0. Replace
482
+ > `ClaudeAgentSDK.x_from_store(session_store: store, ...)` or
483
+ > `x_via_store(session_store: store, ...)` with
484
+ > `ClaudeAgentSDK.x(..., session_store: store)`.
data/docs/types.md CHANGED
@@ -1,6 +1,81 @@
1
1
  # Types Reference
2
2
 
3
- See [lib/claude_agent_sdk/types.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types.rb) for complete type definitions.
3
+ See [lib/claude_agent_sdk/types/](https://github.com/ya-luotao/claude-agent-sdk-ruby/tree/main/lib/claude_agent_sdk/types) for complete type definitions (one file per area; `types.rb` loads them all).
4
+
5
+ ## Hash keys
6
+
7
+ Where the SDK hands you a plain Hash rather than a typed object, its key form
8
+ depends on where the data came from. One rule covers every case:
9
+
10
+ | Source | Key form | Examples |
11
+ |--------|----------|----------|
12
+ | The CLI's live stream-JSON, passed through as-is | **Symbols**, spelled exactly as on the wire | `UserMessage#origin`, `ResultMessage#origin`, `AssistantMessage#usage`, `ResultMessage#usage`, `ResultMessage#model_usage`, `ResultMessage#structured_output`, `UserMessage#tool_use_result`, `SystemMessage#data`, hook `tool_input`, the `can_use_tool` `input`, SDK MCP tool and prompt `args`, `Client#mcp_status`, `Client#context_usage` |
13
+ | Transcripts read from disk or a `SessionStore` | **Strings**, spelled as in the JSONL | `SessionMessage#message`, `get_subagent_metadata`, `SessionStore` keys and entries, `fold_session_summary` input and output |
14
+
15
+ "As on the wire" means the SDK does not rewrite key names. Structures the CLI
16
+ generates use camelCase (`origin[:fromSession]`, `model_usage` values'
17
+ `:inputTokens` / `:costUSD`, `client.mcp_status[:mcpServers]`), while objects
18
+ the CLI relays from the API keep their snake_case (`usage[:input_tokens]`).
19
+ Nesting follows the same rule all the way down, including keys that are data
20
+ rather than field names: `model_usage` is keyed by model-name Symbols
21
+ (`result.model_usage.each { |model, u| puts "#{model}: $#{u[:costUSD]}" }`).
22
+
23
+ The wrong key form reads as `nil` rather than raising, so look up the source
24
+ before indexing: `tool_input['command']` on a hook input, or
25
+ `meta[:toolUseId]` on subagent metadata, silently returns `nil`. The Python
26
+ SDK uses String keys everywhere; do not port its lookups literally.
27
+
28
+ The Symbol side of the rule relies on the transport parsing each line with
29
+ `JSON.parse(line, symbolize_names: true)`. The built-in
30
+ `SubprocessCLITransport` does; a [custom transport](client.md#custom-transport)
31
+ must too.
32
+
33
+ ## Reading and writing attributes
34
+
35
+ The SDK's typed objects (messages, content blocks, hook inputs and outputs,
36
+ option objects: everything built on `ClaudeAgentSDK::Type`) accept the same
37
+ attribute name in several spellings. These accessors are public API:
38
+
39
+ ```ruby
40
+ msg.session_id # the attr_accessor
41
+ msg[:session_id] # Symbol or String, snake_case or camelCase:
42
+ msg['session_id'] # all four read the same attribute
43
+ msg[:sessionId]
44
+ msg['sessionId']
45
+ msg.sessionId # camelCase reader (also answers respond_to?)
46
+ ```
47
+
48
+ `#[]` returns `nil` for a name the type does not define, while a misspelled
49
+ method call such as `msg.nope` raises `NoMethodError`. These accessors reach a type's
50
+ **attributes** only; any other method reached this way warns in 0.37 and stops
51
+ working in 1.0 — see [Attributes Only](#attributes-only).
52
+
53
+ `#[]=` assigns through the attribute's setter, with the same name
54
+ normalization, and returns the assigned value:
55
+
56
+ ```ruby
57
+ msg[:result] = 'edited' # same as msg.result = 'edited'
58
+ ```
59
+
60
+ - It **changes the object you received**. Messages are not frozen or copied
61
+ on delivery, so a change is visible to anything else holding the same
62
+ object (for example an observer that received it before your block did).
63
+ Copy first if you need the original.
64
+ - A name the type does not define is ignored on the types the SDK parses from
65
+ CLI output. `ClaudeAgentOptions` raises `ArgumentError` for an unknown key (as
66
+ its constructor and `dup_with` do), and the value types you build and pass in
67
+ warn once and will raise in 1.0 — see [Unknown Keys](#unknown-keys).
68
+ - Discriminator fields (`type` on the MCP server and system-prompt configs,
69
+ `behavior` on `PermissionResultAllow` / `PermissionResultDeny`,
70
+ `hook_event_name` on hook inputs and outputs) are read-only, so
71
+ assigning them has no effect.
72
+
73
+ Constructors accept the same spellings: `ResultMessage.new('sessionId' => 'abc')`
74
+ is equivalent to `ResultMessage.new(session_id: 'abc')`.
75
+
76
+ `SDKSessionInfo` and `SessionMessage` (returned by the session functions) are
77
+ currently plain classes, not `Type`s: use their snake_case accessors
78
+ (`info.session_id`); they have no `#[]`, `#[]=` or camelCase readers.
4
79
 
5
80
  ## Message Types
6
81
 
@@ -134,8 +209,9 @@ turn was cancelled via `Client#interrupt`. `nil` when the CLI did not report
134
209
  one (older CLIs, or a result that bypassed the query loop such as a local
135
210
  slash command).
136
211
 
137
- `model_usage` values are passed through verbatim from the CLI, so their keys
138
- are camelCase (the TypeScript/Python SDKs' `ModelUsage` shape): `inputTokens`,
212
+ `model_usage` is passed through verbatim from the CLI (see [Hash keys](#hash-keys)):
213
+ it is keyed by model-name Symbols (`:"claude-sonnet-4-5"`), and each value's
214
+ keys are camelCase Symbols (the TypeScript/Python SDKs' `ModelUsage` shape): `inputTokens`,
139
215
  `outputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`,
140
216
  `webSearchRequests`, `costUSD`, `contextWindow`, `maxOutputTokens`, plus
141
217
  optional `canonicalModel` (canonical id used for the pricing lookup, which can
@@ -287,7 +363,7 @@ end
287
363
  | `McpHttpServerConfig` | MCP server config for HTTP transport |
288
364
  | `SdkPluginConfig` | SDK plugin configuration |
289
365
  | `McpServerStatus` | Status of a single MCP server connection (with `.parse`) |
290
- | `McpStatusResponse` | Response from `get_mcp_status` containing all server statuses (with `.parse`) |
366
+ | `McpStatusResponse` | Typed view of the `Client#mcp_status` / `#get_mcp_status` Hash: `McpStatusResponse.parse(client.mcp_status).mcp_servers` is an Array of `McpServerStatus`. The client itself returns the raw Hash (see [client.md](client.md#mcp-status-and-context-usage-return-hashes)) |
291
367
  | `McpServerInfo` | MCP server name and version |
292
368
  | `McpToolInfo` | MCP tool name, description, and annotations |
293
369
  | `McpToolAnnotations` | MCP tool annotation hints (`read_only`, `destructive`, `open_world`) |
@@ -302,6 +378,32 @@ end
302
378
  | `SystemPromptFile` | System prompt loaded from a file path |
303
379
  | `ToolsPreset` | Tools preset configuration for base tools selection |
304
380
 
381
+ ### Unknown Keys
382
+
383
+ `ClaudeAgentOptions` raises `ArgumentError` on an unknown key. The value types you build and pass *in* used to drop a misspelled key silently; they now print a warning, once per class and key, pointing at your call:
384
+
385
+ ```
386
+ app/agents/reviewer.rb:12: warning: ClaudeAgentSDK::HookMatcher: unknown attribute :matchr ignored; this will raise ArgumentError in 1.0 (known: hooks, matcher, timeout)
387
+ ```
388
+
389
+ **In 1.0 the same call raises `ArgumentError`.** This covers `.new` and `#[]=` on:
390
+
391
+ - option values: `AgentDefinition`, `SandboxSettings`, `SandboxNetworkConfig`, `SandboxFilesystemConfig`, `ThinkingConfigAdaptive` / `Enabled` / `Disabled`, `TaskBudget`, `SystemPromptPreset` / `Custom` / `File`, `ToolsPreset`, `SdkPluginConfig`, `McpStdioServerConfig`, `McpSSEServerConfig`, `McpHttpServerConfig`, `McpSdkServerConfig`
392
+ - `HookMatcher` and hook outputs: `SyncHookJSONOutput`, `AsyncHookJSONOutput`, every `*HookSpecificOutput`
393
+ - `PermissionResultAllow`, `PermissionResultDeny`, `PermissionUpdate`, `PermissionRuleValue`
394
+
395
+ Accepted without a warning: Symbol or String keys, snake_case or camelCase spellings, and the fixed discriminator a type sets itself (`type`, `hook_event_name`, `behavior`), so `klass.new(value.to_h)` round-trips. Types the SDK parses from CLI output (messages, content blocks, hook inputs, `ToolPermissionContext`, the MCP status types) stay lenient, so a field added by a newer CLI never warns, and so does every construction through `.from_hash` or `.wrap`. The warning goes through `Kernel#warn`, so `-W0` or `$VERBOSE = nil` silences it.
396
+
397
+ ### Attributes Only
398
+
399
+ `#[]`, `#[]=` 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. Until now they reached any public method, so `msg[:to_h]` returned a Hash, `msg['freeze']` froze the message and `msg.toH` worked. Such a call still works in 0.37 but warns once per class and name:
400
+
401
+ ```
402
+ app/jobs/sync.rb:8: warning: ClaudeAgentSDK::ResultMessage#[]: :to_h is not an attribute; Type#[] will only read attributes in 1.0
403
+ ```
404
+
405
+ **In 1.0 a name that is not an attribute behaves like an undefined one:** `#[]` returns `nil`, `#[]=` ignores it (on the strict types above it raises `ArgumentError`), and a camelCase call raises `NoMethodError`. Call the method directly instead (`msg.to_h`). Undefined names already behave that way today and do not warn. `UserMessage#text` and `AssistantMessage#text` are convenience methods, not attributes.
406
+
305
407
  ## Constants
306
408
 
307
409
  | Constant | Description |
@@ -20,7 +20,8 @@ module ClaudeAgentSDK
20
20
  cancelled?
21
21
  end
22
22
 
23
- # @api private Called by the SDK when the request is no longer actionable.
23
+ # Called by the SDK when the request is no longer actionable.
24
+ # @api private
24
25
  def cancel
25
26
  @queue.close
26
27
  end
@@ -30,16 +30,22 @@ module ClaudeAgentSDK
30
30
  # ClaudeAgentSDK::CLIInstaller.install_pinned
31
31
  # @example Pin a version of your own
32
32
  # ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')
33
- module CLIInstaller
33
+ module CLIInstaller # rubocop:disable Metrics/ModuleLength -- Http/Platform/Release/Metadata submodules in one file
34
+ # @api private
34
35
  BASE_URL = 'https://downloads.claude.ai/claude-code-releases'
35
36
  # Dist-tags resolved through a GET to BASE_URL/<tag>.
37
+ #
38
+ # @api private
36
39
  DIST_TAGS = %w[stable latest].freeze
37
40
  # Concrete version, optionally with a pre-release suffix (e.g. 2.1.220-rc1).
38
41
  # The suffix is restricted to the semver pre-release character set: every
39
42
  # accepted version is interpolated straight into a download URL, and a
40
43
  # laxer `\S+` would let "2.1.220-x/../2.1.221" traverse out of the release
41
44
  # path — silently installing something other than the pinned version.
45
+ #
46
+ # @api private
42
47
  VERSION_PATTERN = /\A\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?\z/
48
+ # @api private
43
49
  CHECKSUM_PATTERN = /\A[0-9a-f]{64}\z/
44
50
  # The CLI version this gem release is developed and tested against — the
45
51
  # Ruby equivalent of the Python SDK's bundled-CLI pin (_cli_version.py),
@@ -48,23 +54,35 @@ module ClaudeAgentSDK
48
54
  # .github/workflows/cli-pin-bump.yml or a Python-sync release, so a
49
55
  # Dependabot bump of the gem carries the CLI forward with it.
50
56
  PINNED_CLI_VERSION = '2.1.280'
57
+ # @api private
51
58
  BINARY_NAME = 'claude'
59
+ # @api private
52
60
  VERSION_FILE = 'VERSION'
61
+ # @api private
53
62
  LOCK_FILE = '.install.lock'
54
- # Relative to Dir.pwd, resolved at CALL time by .default_dir — an absolute
55
- # constant would freeze the working directory as of require time, which is
56
- # wrong for anything that chdirs (Rake tasks, bin/setup, test suites).
63
+ # Relative to .root (Dir.pwd when unset), resolved at CALL time by
64
+ # .default_dir — an absolute constant would freeze the working directory
65
+ # as of require time, which is wrong for anything that chdirs (Rake
66
+ # tasks, bin/setup, test suites).
67
+ #
68
+ # @api private
57
69
  DEFAULT_DIR = File.join('vendor', 'claude')
58
70
  # Response caps. The dist-tag endpoints return a bare version string and
59
71
  # manifests are a few KB; anything larger is a misrouted response, not
60
72
  # something to buffer in memory. (The binary itself streams to disk.)
73
+ #
74
+ # @api private
61
75
  VERSION_RESPONSE_LIMIT = 1024
76
+ # @api private
62
77
  MANIFEST_RESPONSE_LIMIT = 5 * 1024 * 1024
78
+ # @api private
63
79
  METADATA_READ_LIMIT = 4096
64
80
 
65
81
  # Maps the running Ruby to a release-manifest platform key
66
82
  # (darwin-arm64, darwin-x64, linux-x64, linux-arm64, and the -musl
67
83
  # variants). Windows is not supported by this gem.
84
+ #
85
+ # @api private
68
86
  module Platform
69
87
  class << self
70
88
  def detect
@@ -122,6 +140,8 @@ module ClaudeAgentSDK
122
140
  # and chunked streaming for the binary. Knows nothing about releases; the
123
141
  # specs stub .fetch_text / .download_to wholesale so no HTTP stubbing
124
142
  # library is needed.
143
+ #
144
+ # @api private
125
145
  module Http
126
146
  MAX_REDIRECTS = 5
127
147
  OPEN_TIMEOUT_SECONDS = 10
@@ -154,7 +174,9 @@ module ClaudeAgentSDK
154
174
  File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) do |file|
155
175
  response.read_body do |chunk|
156
176
  written += chunk.bytesize
157
- raise CLIInstallError, "Download from #{url} exceeds the expected #{max_bytes} bytes" if over?(written, max_bytes)
177
+ if over?(written, max_bytes)
178
+ raise CLIInstallError, "Download from #{url} exceeds the expected #{max_bytes} bytes"
179
+ end
158
180
 
159
181
  file.write(chunk)
160
182
  end
@@ -191,19 +213,21 @@ module ClaudeAgentSDK
191
213
  raise CLIInstallError, "Failed to fetch #{url}: #{e.class}: #{e.message}"
192
214
  end
193
215
 
194
- def follow_redirect(uri, response, redirects_left, &block)
216
+ def follow_redirect(uri, response, redirects_left, &)
195
217
  raise CLIInstallError, "Too many redirects while fetching #{uri}" if redirects_left <= 0
196
218
 
197
219
  location = response['location'].to_s
198
220
  raise CLIInstallError, "Redirect from #{uri} is missing a Location header" if location.empty?
199
221
 
200
- with_response(URI.join(uri.to_s, location), redirects_left - 1, &block)
222
+ with_response(URI.join(uri.to_s, location), redirects_left - 1, &)
201
223
  end
202
224
  end
203
225
  end
204
226
 
205
227
  # Talks to the release service: dist-tag resolution, manifest lookup and
206
228
  # URL construction. Pure remote reads — no filesystem, no state.
229
+ #
230
+ # @api private
207
231
  module Release
208
232
  class << self
209
233
  # Local, network-free check of what the caller asked for. Returns the
@@ -238,7 +262,9 @@ module ClaudeAgentSDK
238
262
  end
239
263
 
240
264
  checksum = entry['checksum'].to_s.downcase
241
- raise CLIInstallError, "#{url} has no valid sha256 checksum for #{platform}" unless checksum.match?(CHECKSUM_PATTERN)
265
+ unless checksum.match?(CHECKSUM_PATTERN)
266
+ raise CLIInstallError, "#{url} has no valid sha256 checksum for #{platform}"
267
+ end
242
268
 
243
269
  size = entry['size']
244
270
  { checksum: checksum, size: size.is_a?(Integer) && size.positive? ? size : nil }
@@ -276,6 +302,8 @@ module ClaudeAgentSDK
276
302
  # per line. Both platform and checksum must match before trusting a cached
277
303
  # binary offline: a cache copied between OS/CPU/libc targets is not usable.
278
304
  # Older one- or two-line files lack that proof and trigger a clean reinstall.
305
+ #
306
+ # @api private
279
307
  module Metadata
280
308
  class << self
281
309
  def read(dir)
@@ -309,10 +337,39 @@ module ClaudeAgentSDK
309
337
  end
310
338
 
311
339
  class << self
312
- # Absolute path of the default install directory, resolved against the
313
- # current working directory each time it is asked for.
340
+ # The directory DEFAULT_DIR is resolved against, or nil (the default)
341
+ # for the current working directory at call time.
342
+ #
343
+ # Set it when the process cwd is not the project root — a daemonized
344
+ # worker, a job runner started from /, a systemd unit without
345
+ # WorkingDirectory — so .default_dir, and with it .installed_path and
346
+ # SubprocessCLITransport's discovery of the vendored binary, still
347
+ # point at <root>/vendor/claude. The Rails Railtie sets it to
348
+ # Rails.root unless something already has.
349
+ #
350
+ # Safe to read from any thread without a lock: the value is a single
351
+ # frozen String reference (or nil), replaced whole by .root=, so a
352
+ # reader sees either the old root or the new one, never a partial one.
353
+ #
354
+ # @return [String, nil] an absolute path, or nil
355
+ attr_reader :root
356
+
357
+ # @param path [String, Pathname, nil] the project root. A relative path
358
+ # is absolutized against the working directory NOW, once, so a later
359
+ # chdir cannot move it. nil restores the Dir.pwd default.
360
+ # @raise [ArgumentError] for an empty path (which would silently pin
361
+ # the current working directory)
362
+ def root=(path)
363
+ raise ArgumentError, 'CLIInstaller.root must be a non-empty path or nil' if path&.to_s&.empty?
364
+
365
+ @root = path && File.expand_path(path).freeze
366
+ end
367
+
368
+ # Absolute path of the default install directory: vendor/claude under
369
+ # .root, or under the current working directory (resolved each time it
370
+ # is asked for) while .root is unset.
314
371
  def default_dir
315
- File.expand_path(DEFAULT_DIR, Dir.pwd)
372
+ File.expand_path(DEFAULT_DIR, root || Dir.pwd)
316
373
  end
317
374
 
318
375
  # Install the CLI into +dir+ and return the absolute path of the binary.