claude-agent-sdk 0.34.0 → 0.36.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +68 -24
  4. data/docs/cli-installer.md +38 -1
  5. data/docs/client.md +44 -20
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +22 -0
  8. data/docs/mcp-servers.md +37 -7
  9. data/docs/rails.md +92 -54
  10. data/docs/sessions.md +69 -33
  11. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  12. data/lib/claude_agent_sdk/cli_installer.rb +38 -8
  13. data/lib/claude_agent_sdk/deprecation.rb +51 -0
  14. data/lib/claude_agent_sdk/errors.rb +8 -0
  15. data/lib/claude_agent_sdk/fiber_boundary.rb +111 -2
  16. data/lib/claude_agent_sdk/option_warnings.rb +0 -2
  17. data/lib/claude_agent_sdk/query.rb +49 -8
  18. data/lib/claude_agent_sdk/railtie.rb +105 -0
  19. data/lib/claude_agent_sdk/sdk_mcp_server.rb +36 -25
  20. data/lib/claude_agent_sdk/session_mutations.rb +10 -10
  21. data/lib/claude_agent_sdk/session_resume.rb +19 -24
  22. data/lib/claude_agent_sdk/session_store.rb +28 -18
  23. data/lib/claude_agent_sdk/session_summary.rb +8 -3
  24. data/lib/claude_agent_sdk/sessions.rb +104 -18
  25. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +4 -13
  26. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +37 -0
  27. data/lib/claude_agent_sdk/tasks.rb +13 -0
  28. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +1 -1
  29. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +17 -1
  30. data/lib/claude_agent_sdk/types.rb +219 -3
  31. data/lib/claude_agent_sdk/version.rb +1 -1
  32. data/lib/claude_agent_sdk.rb +261 -56
  33. data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
  34. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
  35. metadata +17 -6
@@ -20,6 +20,11 @@ require_relative 'claude_agent_sdk/session_resume'
20
20
  require_relative 'claude_agent_sdk/session_mutations'
21
21
  require_relative 'claude_agent_sdk/fiber_boundary'
22
22
  require_relative 'claude_agent_sdk/option_warnings'
23
+ require_relative 'claude_agent_sdk/deprecation'
24
+ # Rails apps only: Bundler.require runs after `require 'rails'`, so the
25
+ # Railtie (rake tasks; the generator lives under lib/generators) is picked up
26
+ # there and nowhere else.
27
+ require_relative 'claude_agent_sdk/railtie' if defined?(Rails::Railtie)
23
28
  require 'async'
24
29
  require 'securerandom'
25
30
 
@@ -36,6 +41,7 @@ module ClaudeAgentSDK
36
41
  # skipped — most commonly a Class passed instead of an instance, which
37
42
  # previously produced silent zero instrumentation (every notify raised
38
43
  # NoMethodError, swallowed by notify_observers' error containment).
44
+ # @api private
39
45
  def self.resolve_observers(observers)
40
46
  Array(observers).filter_map do |obs|
41
47
  resolved = obs.respond_to?(:call) ? obs.call : obs
@@ -56,6 +62,7 @@ module ClaudeAgentSDK
56
62
  # configs may use String or Symbol keys (and a Symbol :sdk type) — the
57
63
  # recognition rule must match CommandBuilder#append_mcp_servers, which
58
64
  # strips the instance from exactly these entries.
65
+ # @api private
59
66
  def self.extract_sdk_mcp_servers(mcp_servers)
60
67
  return {} unless mcp_servers.is_a?(Hash)
61
68
 
@@ -71,6 +78,7 @@ module ClaudeAgentSDK
71
78
 
72
79
  # Internal: normalize hook lists for the control protocol. An absent or
73
80
  # disabled event must not become an empty registration in initialize.
81
+ # @api private
74
82
  def self.convert_hooks_to_internal_format(hooks)
75
83
  return nil unless hooks
76
84
 
@@ -104,6 +112,7 @@ module ClaudeAgentSDK
104
112
  # guarantees for can_use_tool — the permission round-trip works. The old
105
113
  # "requires streaming mode" ArgumentError was a needless restriction
106
114
  # (Python #1204).
115
+ # @api private
107
116
  def self.configure_can_use_tool(options)
108
117
  return options unless options.can_use_tool
109
118
 
@@ -120,6 +129,7 @@ module ClaudeAgentSDK
120
129
  # Internal: pull exclude_dynamic_sections out of a preset system prompt for
121
130
  # the initialize request (older CLIs ignore unknown initialize fields).
122
131
  # Shared by Client#connect and the one-shot query() path.
132
+ # @api private
123
133
  def self.extract_exclude_dynamic_sections(system_prompt)
124
134
  if system_prompt.is_a?(SystemPromptPreset)
125
135
  eds = system_prompt.exclude_dynamic_sections
@@ -139,6 +149,7 @@ module ClaudeAgentSDK
139
149
  # String or file prompt has no snapshot, and only a genuine true/false is
140
150
  # forwarded — `snapshot: false` is the primary use case, so the Hash lookup
141
151
  # must not collapse it to nil. Shared by Client#connect and query().
152
+ # @api private
142
153
  def self.extract_system_prompt_snapshot(system_prompt)
143
154
  case system_prompt
144
155
  when SystemPromptPreset, SystemPromptCustom
@@ -158,6 +169,7 @@ module ClaudeAgentSDK
158
169
  # Each observer is invoked through FiberBoundary so that user code runs
159
170
  # on a plain thread (no Fiber scheduler) even when called from inside
160
171
  # the SDK's Async reactor — or in place when scheduling is :inline.
172
+ # @api private
161
173
  def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
162
174
  observers.each do |obs|
163
175
  FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) }
@@ -184,8 +196,8 @@ module ClaudeAgentSDK
184
196
  #
185
197
  # @example Inside an inline-mode tool handler
186
198
  # ClaudeAgentSDK.offload { blocking_db_call }
187
- def self.offload(&block)
188
- FiberBoundary.invoke(&block)
199
+ def self.offload(&)
200
+ FiberBoundary.invoke(&)
189
201
  end
190
202
 
191
203
  # Guards the once-per-process flag below: two sessions connecting
@@ -198,6 +210,7 @@ module ClaudeAgentSDK
198
210
  # almost certainly violates inline mode's fiber-isolation precondition
199
211
  # (solid_queue fiber workers require isolation_level = :fiber).
200
212
  # defined? probing only; the SDK never loads ActiveSupport itself.
213
+ # @api private
201
214
  def self.check_inline_isolation(scheduling)
202
215
  return unless scheduling == :inline
203
216
  return unless defined?(ActiveSupport::IsolatedExecutionState)
@@ -220,6 +233,7 @@ module ClaudeAgentSDK
220
233
  # when there is none (non-user messages, tool_result-only content, …).
221
234
  # Only Hash and JSON-string items are inspected; arbitrary objects written
222
235
  # via to_s are never notified.
236
+ # @api private
223
237
  def self.extract_user_prompt_text(message)
224
238
  data = case message
225
239
  when Hash then message
@@ -250,6 +264,7 @@ module ClaudeAgentSDK
250
264
  # when there is no extractable text — on_user_prompt('') would latch
251
265
  # OTelObserver's first-prompt buffer while never setting the attribute,
252
266
  # permanently suppressing later real prompts.
267
+ # @api private
253
268
  def self.prompt_text_from_content(content)
254
269
  case content
255
270
  when String
@@ -269,6 +284,7 @@ module ClaudeAgentSDK
269
284
  # Wrap a streaming-input enumerable so observers get on_user_prompt for
270
285
  # each user message before it is written to stdin. Identity when no
271
286
  # observers are configured.
287
+ # @api private
272
288
  def self.observing_prompt_stream(prompt, observers, scheduling: :thread, wrapper: nil)
273
289
  return prompt if observers.empty?
274
290
 
@@ -283,6 +299,7 @@ module ClaudeAgentSDK
283
299
 
284
300
  # Look up a value in a hash that may use symbol or string keys in camelCase or snake_case.
285
301
  # Returns the first non-nil value found, preserving false as a meaningful value.
302
+ # @api private
286
303
  def self.flexible_fetch(hash, camel_key, snake_key)
287
304
  val = hash[camel_key.to_sym]
288
305
  val = hash[camel_key.to_s] if val.nil?
@@ -291,93 +308,211 @@ module ClaudeAgentSDK
291
308
  val
292
309
  end
293
310
 
294
- # List sessions for a directory (or all sessions)
295
- # @param directory [String, nil] Working directory to list sessions for
311
+ # ---- Session browsing & mutation ----
312
+ #
313
+ # One function per operation. By default each reads or writes the local-disk
314
+ # transcripts under CLAUDE_CONFIG_DIR; pass +session_store:+ to operate on a
315
+ # SessionStore instead. The two paths differ in a few documented ways:
316
+ #
317
+ # - +directory: nil+ searches every project directory on disk, but means the
318
+ # current working directory with a store (a SessionStore is keyed by
319
+ # project_key and cannot enumerate projects — parity with the Python SDK).
320
+ # - +include_worktrees:+ filters only on disk (list_sessions); with a
321
+ # +session_store:+, anything but the default +true+ raises ArgumentError.
322
+
323
+ # List sessions for a directory (or all sessions), newest first.
324
+ # @param directory [String, nil] Working directory to list sessions for. On
325
+ # disk, nil lists every project; with a session_store, nil means the
326
+ # current working directory.
296
327
  # @param limit [Integer, nil] Maximum number of sessions to return
297
328
  # @param offset [Integer] Number of sessions to skip (for pagination)
298
- # @param include_worktrees [Boolean] Whether to include git worktree sessions
329
+ # @param include_worktrees [Boolean] Disk only: also list the project's git
330
+ # worktree sessions. A store has no worktrees, so with a session_store only
331
+ # the default true is accepted; false or nil raises ArgumentError (the
332
+ # store path cannot apply the filter the caller asked for).
333
+ # @param session_store [SessionStore, nil] List from this store instead of
334
+ # local disk. Uses the store's list_session_summaries when implemented,
335
+ # else list_sessions + one load per listed session.
299
336
  # @return [Array<SDKSessionInfo>] Sessions sorted by last_modified descending
300
- def self.list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true)
337
+ # @raise [ArgumentError] if include_worktrees is not true with a session_store,
338
+ # or the store implements neither list_session_summaries nor list_sessions
339
+ def self.list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true, session_store: nil)
340
+ unless session_store.nil?
341
+ unless include_worktrees == true
342
+ raise ArgumentError, "include_worktrees: #{include_worktrees.inspect} applies only to local-disk " \
343
+ 'listing; a session_store is keyed by project and has no worktrees to exclude'
344
+ end
345
+
346
+ return Sessions.list_sessions_from_store(session_store: session_store, directory: directory,
347
+ limit: limit, offset: offset)
348
+ end
349
+
301
350
  Sessions.list_sessions(directory: directory, limit: limit, offset: offset, include_worktrees: include_worktrees)
302
351
  end
303
352
 
304
353
  # Read metadata for a single session by ID (no full directory scan)
305
354
  # @param session_id [String] UUID of the session to look up
306
- # @param directory [String, nil] Project directory path
307
- # @return [SDKSessionInfo, nil] Session info, or nil if not found
308
- def self.get_session_info(session_id:, directory: nil)
355
+ # @param directory [String, nil] Project directory path. On disk, nil
356
+ # searches every project; with a session_store, nil means the current
357
+ # working directory.
358
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
359
+ # @return [SDKSessionInfo, nil] Session info, or nil if not found / sidechain / no summary
360
+ def self.get_session_info(session_id:, directory: nil, session_store: nil)
361
+ unless session_store.nil?
362
+ return Sessions.get_session_info_from_store(session_store: session_store, session_id: session_id,
363
+ directory: directory)
364
+ end
365
+
309
366
  Sessions.get_session_info(session_id: session_id, directory: directory)
310
367
  end
311
368
 
312
369
  # Get messages from a session transcript
313
370
  # @param session_id [String] The session UUID
314
- # @param directory [String, nil] Working directory to search in
371
+ # @param directory [String, nil] Working directory to search in. On disk,
372
+ # nil searches every project; with a session_store, nil means the current
373
+ # working directory.
315
374
  # @param limit [Integer, nil] Maximum number of messages
316
375
  # @param offset [Integer] Number of messages to skip
376
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
317
377
  # @return [Array<SessionMessage>] Ordered messages from the session
318
- def self.get_session_messages(session_id:, directory: nil, limit: nil, offset: 0)
378
+ def self.get_session_messages(session_id:, directory: nil, limit: nil, offset: 0, session_store: nil)
379
+ unless session_store.nil?
380
+ return Sessions.get_session_messages_from_store(session_store: session_store, session_id: session_id,
381
+ directory: directory, limit: limit, offset: offset)
382
+ end
383
+
319
384
  Sessions.get_session_messages(session_id: session_id, directory: directory, limit: limit, offset: offset)
320
385
  end
321
386
 
322
- # List subagent IDs recorded for a session on local disk
387
+ # List subagent IDs recorded for a session
323
388
  # @param session_id [String] The session UUID
324
- # @param directory [String, nil] Working directory to search in
389
+ # @param directory [String, nil] Working directory to search in. On disk,
390
+ # nil searches every project; with a session_store, nil means the current
391
+ # working directory.
392
+ # @param session_store [SessionStore, nil] Read from this store instead of
393
+ # local disk (the store must implement list_subkeys)
325
394
  # @return [Array<String>] Subagent IDs
326
- def self.list_subagents(session_id:, directory: nil)
395
+ # @raise [ArgumentError] if the session_store does not implement list_subkeys
396
+ def self.list_subagents(session_id:, directory: nil, session_store: nil)
397
+ unless session_store.nil?
398
+ return Sessions.list_subagents_from_store(session_store: session_store, session_id: session_id,
399
+ directory: directory)
400
+ end
401
+
327
402
  Sessions.list_subagents(session_id: session_id, directory: directory)
328
403
  end
329
404
 
330
- # Read a subagent's optional metadata (not live status) from local disk.
405
+ # Read a subagent's optional metadata (not live status). With a
406
+ # session_store, the last agent_metadata entry wins and its synthetic type
407
+ # marker is omitted.
331
408
  # @param session_id [String] The parent session UUID
332
409
  # @param agent_id [String] The subagent ID, without the agent- prefix
333
- # @param directory [String, nil] Project directory to search in
410
+ # @param directory [String, nil] Project directory to search in. On disk,
411
+ # nil searches every project; with a session_store, nil means the current
412
+ # working directory.
413
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
334
414
  # @return [Hash{String => Object}, nil] CLI metadata, or nil if unavailable
335
- def self.get_subagent_metadata(session_id:, agent_id:, directory: nil)
415
+ def self.get_subagent_metadata(session_id:, agent_id:, directory: nil, session_store: nil)
416
+ unless session_store.nil?
417
+ return Sessions.get_subagent_metadata_from_store(session_store: session_store, session_id: session_id,
418
+ agent_id: agent_id, directory: directory)
419
+ end
420
+
336
421
  Sessions.get_subagent_metadata(session_id: session_id, agent_id: agent_id, directory: directory)
337
422
  end
338
423
 
339
- # Read a subagent's conversation messages from local disk
424
+ # Read a subagent's conversation messages
340
425
  # @param session_id [String] The session UUID
341
426
  # @param agent_id [String] The subagent ID (without the agent- prefix)
342
- # @param directory [String, nil] Working directory to search in
427
+ # @param directory [String, nil] Working directory to search in. On disk,
428
+ # nil searches every project; with a session_store, nil means the current
429
+ # working directory.
343
430
  # @param limit [Integer, nil] Maximum number of messages
344
431
  # @param offset [Integer] Number of messages to skip
432
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
345
433
  # @return [Array<SessionMessage>] Ordered messages from the subagent
346
- def self.get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
434
+ def self.get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0, session_store: nil)
435
+ unless session_store.nil?
436
+ return Sessions.get_subagent_messages_from_store(session_store: session_store, session_id: session_id,
437
+ agent_id: agent_id, directory: directory,
438
+ limit: limit, offset: offset)
439
+ end
440
+
347
441
  Sessions.get_subagent_messages(session_id: session_id, agent_id: agent_id,
348
442
  directory: directory, limit: limit, offset: offset)
349
443
  end
350
444
 
351
- # Rename a session by appending a custom-title entry
445
+ # Rename a session by appending a custom-title entry. With a session_store
446
+ # the entry is appended via SessionStore#append and carries a fresh uuid +
447
+ # timestamp (so uuid-deduping adapters treat it correctly).
352
448
  # @param session_id [String] UUID of the session to rename
353
449
  # @param title [String] New session title
354
- # @param directory [String, nil] Project directory path
355
- def self.rename_session(session_id:, title:, directory: nil)
450
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
451
+ # @param session_store [SessionStore, nil] Rename in this store instead of on local disk
452
+ # @raise [ArgumentError] if session_id is invalid or title is empty
453
+ # @raise [Errno::ENOENT] if the session is not found (nothing is written)
454
+ def self.rename_session(session_id:, title:, directory: nil, session_store: nil)
455
+ unless session_store.nil?
456
+ return SessionMutations.rename_session_via_store(session_store: session_store, session_id: session_id,
457
+ title: title, directory: directory)
458
+ end
459
+
356
460
  SessionMutations.rename_session(session_id: session_id, title: title, directory: directory)
357
461
  end
358
462
 
359
463
  # Tag a session. Pass nil to clear the tag.
360
464
  # @param session_id [String] UUID of the session to tag
361
465
  # @param tag [String, nil] Tag string, or nil to clear
362
- # @param directory [String, nil] Project directory path
363
- def self.tag_session(session_id:, tag:, directory: nil)
466
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
467
+ # @param session_store [SessionStore, nil] Tag in this store instead of on local disk
468
+ # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
469
+ # @raise [Errno::ENOENT] if the session is not found (nothing is written)
470
+ def self.tag_session(session_id:, tag:, directory: nil, session_store: nil)
471
+ unless session_store.nil?
472
+ return SessionMutations.tag_session_via_store(session_store: session_store, session_id: session_id,
473
+ tag: tag, directory: directory)
474
+ end
475
+
364
476
  SessionMutations.tag_session(session_id: session_id, tag: tag, directory: directory)
365
477
  end
366
478
 
367
- # Delete a session by removing its JSONL file (hard delete).
479
+ # Delete a session (hard delete). On disk, removes its JSONL file and
480
+ # subagent directory, raising Errno::ENOENT if the session is not found.
481
+ # With a session_store, calls SessionStore#delete — a no-op when the store
482
+ # does not implement #delete (WORM/append-only backends); whether subagent
483
+ # subkeys are removed too depends on the store's cascade semantics.
368
484
  # @param session_id [String] UUID of the session to delete
369
- # @param directory [String, nil] Project directory path
370
- def self.delete_session(session_id:, directory: nil)
485
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
486
+ # @param session_store [SessionStore, nil] Delete from this store instead of local disk
487
+ # @raise [ArgumentError] if session_id is invalid
488
+ # @raise [Errno::ENOENT] on disk, if the session file cannot be found
489
+ def self.delete_session(session_id:, directory: nil, session_store: nil)
490
+ unless session_store.nil?
491
+ return SessionMutations.delete_session_via_store(session_store: session_store, session_id: session_id,
492
+ directory: directory)
493
+ end
494
+
371
495
  SessionMutations.delete_session(session_id: session_id, directory: directory)
372
496
  end
373
497
 
374
- # Fork a session into a new branch with fresh UUIDs.
498
+ # Fork a session into a new branch with fresh UUIDs. With a session_store
499
+ # the fork transform runs over the store's entries and the fork is appended
500
+ # to the same store.
375
501
  # @param session_id [String] UUID of the session to fork
376
- # @param directory [String, nil] Project directory path
502
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
377
503
  # @param up_to_message_id [String, nil] Truncate the fork at this message UUID
378
504
  # @param title [String, nil] Custom title for the fork
505
+ # @param session_store [SessionStore, nil] Fork within this store instead of on local disk
379
506
  # @return [ForkSessionResult] Result containing the new session ID
380
- def self.fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
507
+ # @raise [ArgumentError] if session_id/up_to_message_id is invalid or there are no messages
508
+ # @raise [Errno::ENOENT] if the source session is not found
509
+ def self.fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil, session_store: nil)
510
+ unless session_store.nil?
511
+ return SessionMutations.fork_session_via_store(session_store: session_store, session_id: session_id,
512
+ directory: directory, up_to_message_id: up_to_message_id,
513
+ title: title)
514
+ end
515
+
381
516
  SessionMutations.fork_session(session_id: session_id, directory: directory,
382
517
  up_to_message_id: up_to_message_id, title: title)
383
518
  end
@@ -402,81 +537,83 @@ module ClaudeAgentSDK
402
537
  SessionSummary.fold_session_summary(prev, key, entries)
403
538
  end
404
539
 
405
- # List sessions from a SessionStore (store-backed counterpart to list_sessions).
406
- # @param session_store [SessionStore] the store to read from
540
+ # ---- Deprecated store twins (removed in 1.0) ----
541
+ #
542
+ # Each forwards to the same implementation as before — not to the merged
543
+ # function, so a nil session_store keeps failing as it always did instead
544
+ # of silently reading local disk — after one warning per method per process.
545
+
546
+ # @deprecated Use {.list_sessions} with +session_store:+. Removed in 1.0.
407
547
  # @return [Array<SDKSessionInfo>] sorted by last_modified descending
408
548
  def self.list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: 0)
549
+ Deprecation.warn_once(:list_sessions_from_store, 'list_sessions(session_store: store)')
409
550
  Sessions.list_sessions_from_store(session_store: session_store, directory: directory, limit: limit, offset: offset)
410
551
  end
411
552
 
412
- # Read metadata for a single session from a SessionStore.
553
+ # @deprecated Use {.get_session_info} with +session_store:+. Removed in 1.0.
413
554
  # @return [SDKSessionInfo, nil]
414
555
  def self.get_session_info_from_store(session_store:, session_id:, directory: nil)
556
+ Deprecation.warn_once(:get_session_info_from_store, 'get_session_info(session_store: store, ...)')
415
557
  Sessions.get_session_info_from_store(session_store: session_store, session_id: session_id, directory: directory)
416
558
  end
417
559
 
418
- # Read a session's conversation messages from a SessionStore.
560
+ # @deprecated Use {.get_session_messages} with +session_store:+. Removed in 1.0.
419
561
  # @return [Array<SessionMessage>]
420
562
  def self.get_session_messages_from_store(session_store:, session_id:, directory: nil, limit: nil, offset: 0)
563
+ Deprecation.warn_once(:get_session_messages_from_store, 'get_session_messages(session_store: store, ...)')
421
564
  Sessions.get_session_messages_from_store(session_store: session_store, session_id: session_id,
422
565
  directory: directory, limit: limit, offset: offset)
423
566
  end
424
567
 
425
- # List subagent IDs for a session from a SessionStore (requires list_subkeys).
568
+ # @deprecated Use {.list_subagents} with +session_store:+. Removed in 1.0.
426
569
  # @return [Array<String>]
427
570
  def self.list_subagents_from_store(session_store:, session_id:, directory: nil)
571
+ Deprecation.warn_once(:list_subagents_from_store, 'list_subagents(session_store: store, ...)')
428
572
  Sessions.list_subagents_from_store(session_store: session_store, session_id: session_id, directory: directory)
429
573
  end
430
574
 
431
- # Read the latest subagent metadata from a SessionStore, without its synthetic type marker.
575
+ # @deprecated Use {.get_subagent_metadata} with +session_store:+. Removed in 1.0.
432
576
  # @return [Hash{String => Object}, nil]
433
577
  def self.get_subagent_metadata_from_store(session_store:, session_id:, agent_id:, directory: nil)
578
+ Deprecation.warn_once(:get_subagent_metadata_from_store, 'get_subagent_metadata(session_store: store, ...)')
434
579
  Sessions.get_subagent_metadata_from_store(session_store: session_store, session_id: session_id,
435
580
  agent_id: agent_id, directory: directory)
436
581
  end
437
582
 
438
- # Read a subagent's conversation messages from a SessionStore.
583
+ # @deprecated Use {.get_subagent_messages} with +session_store:+. Removed in 1.0.
439
584
  # @return [Array<SessionMessage>]
440
585
  def self.get_subagent_messages_from_store(session_store:, session_id:, agent_id:, directory: nil, limit: nil,
441
586
  offset: 0)
587
+ Deprecation.warn_once(:get_subagent_messages_from_store, 'get_subagent_messages(session_store: store, ...)')
442
588
  Sessions.get_subagent_messages_from_store(session_store: session_store, session_id: session_id,
443
589
  agent_id: agent_id, directory: directory, limit: limit, offset: offset)
444
590
  end
445
591
 
446
- # Rename a session in a SessionStore (store-backed counterpart to
447
- # rename_session). Appends a custom-title entry carrying a fresh uuid +
448
- # timestamp via SessionStore#append.
449
- # @raise [ArgumentError] if session_id is invalid or title is empty
450
- # @raise [Errno::ENOENT] if the session is not found in the store (nothing is appended)
592
+ # @deprecated Use {.rename_session} with +session_store:+. Removed in 1.0.
451
593
  def self.rename_session_via_store(session_store:, session_id:, title:, directory: nil)
594
+ Deprecation.warn_once(:rename_session_via_store, 'rename_session(session_store: store, ...)')
452
595
  SessionMutations.rename_session_via_store(session_store: session_store, session_id: session_id,
453
596
  title: title, directory: directory)
454
597
  end
455
598
 
456
- # Tag a session in a SessionStore (store-backed counterpart to tag_session).
457
- # Pass nil to clear the tag.
458
- # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
459
- # @raise [Errno::ENOENT] if the session is not found in the store (nothing is appended)
599
+ # @deprecated Use {.tag_session} with +session_store:+. Removed in 1.0.
460
600
  def self.tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
601
+ Deprecation.warn_once(:tag_session_via_store, 'tag_session(session_store: store, ...)')
461
602
  SessionMutations.tag_session_via_store(session_store: session_store, session_id: session_id,
462
603
  tag: tag, directory: directory)
463
604
  end
464
605
 
465
- # Delete a session from a SessionStore (store-backed counterpart to
466
- # delete_session). No-op when the store does not implement #delete
467
- # (WORM/append-only backends).
468
- # @raise [ArgumentError] if session_id is invalid
606
+ # @deprecated Use {.delete_session} with +session_store:+. Removed in 1.0.
469
607
  def self.delete_session_via_store(session_store:, session_id:, directory: nil)
608
+ Deprecation.warn_once(:delete_session_via_store, 'delete_session(session_store: store, ...)')
470
609
  SessionMutations.delete_session_via_store(session_store: session_store, session_id: session_id,
471
610
  directory: directory)
472
611
  end
473
612
 
474
- # Fork a session in a SessionStore into a new branch with fresh UUIDs
475
- # (store-backed counterpart to fork_session).
476
- # @return [ForkSessionResult] result containing the new session ID
477
- # @raise [ArgumentError] if session_id/up_to_message_id is invalid or there are no messages
478
- # @raise [Errno::ENOENT] if the source session is not found in the store
613
+ # @deprecated Use {.fork_session} with +session_store:+. Removed in 1.0.
614
+ # @return [ForkSessionResult]
479
615
  def self.fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
616
+ Deprecation.warn_once(:fork_session_via_store, 'fork_session(session_store: store, ...)')
480
617
  SessionMutations.fork_session_via_store(session_store: session_store, session_id: session_id,
481
618
  directory: directory, up_to_message_id: up_to_message_id, title: title)
482
619
  end
@@ -698,6 +835,51 @@ module ClaudeAgentSDK
698
835
  end).wait
699
836
  end
700
837
 
838
+ # Run a query to completion and return its final ResultMessage.
839
+ #
840
+ # The one-call form of {.query} for when you want the answer rather than
841
+ # the stream: +ask(prompt).result+ is the final text, and the returned
842
+ # ResultMessage also carries cost, usage, duration, session_id and
843
+ # structured_output. It is {.query} underneath — same prompt types, same
844
+ # options, same errors — and it consumes the whole stream before
845
+ # returning. With an Enumerable prompt that produces several turns, the
846
+ # last ResultMessage is returned.
847
+ #
848
+ # An error result is returned like any other (check #is_error / #subtype);
849
+ # when the CLI then exits non-zero, {.query} raises ResultError, which
850
+ # propagates from here unchanged.
851
+ #
852
+ # @param prompt [String, Enumerable] The prompt, as for {.query}
853
+ # @param options [ClaudeAgentOptions, nil] Optional configuration
854
+ # @param transport [Transport, nil] Optional transport, as for {.query}
855
+ # @yield [Message] Optionally, every message as it arrives (including the
856
+ # final ResultMessage), so you can stream progress and still get the
857
+ # result back. Runs where {.query}'s block runs. The block observes the
858
+ # stream; it cannot end it early — use {.query} for that.
859
+ # @return [ResultMessage]
860
+ # @raise [CLIConnectionError] if the stream ends without a ResultMessage
861
+ #
862
+ # @example
863
+ # puts ClaudeAgentSDK.ask('What is 2 + 2?').result
864
+ #
865
+ # @example Stream progress, keep the result
866
+ # result = ClaudeAgentSDK.ask('Refactor lib/foo.rb', options: options) do |message|
867
+ # puts message.text if message.is_a?(ClaudeAgentSDK::AssistantMessage)
868
+ # end
869
+ # puts result # => [result: success, 3 turns, 12.4s, $0.0421]
870
+ def self.ask(prompt, options: nil, transport: nil, &block)
871
+ result = nil
872
+ query(prompt: prompt, options: options, transport: transport) do |message|
873
+ result = message if message.is_a?(ResultMessage)
874
+ block&.call(message)
875
+ end
876
+ # The same class Query raises to a caller still waiting on the stream
877
+ # when it ends ("Control stream ended").
878
+ raise CLIConnectionError, 'Claude Code ended the conversation without a result message' unless result
879
+
880
+ result
881
+ end
882
+
701
883
  # Client for bidirectional, interactive conversations with Claude Code
702
884
  #
703
885
  # This client provides full control over the conversation flow with support
@@ -967,6 +1149,12 @@ module ClaudeAgentSDK
967
1149
  @query_handler.set_permission_mode(mode)
968
1150
  end
969
1151
 
1152
+ # Ruby-style spelling of #set_permission_mode: `client.permission_mode = 'plan'`.
1153
+ # Delegates (rather than aliasing) so an override of #set_permission_mode applies to both.
1154
+ def permission_mode=(mode)
1155
+ set_permission_mode(mode)
1156
+ end
1157
+
970
1158
  # Change the AI model during conversation
971
1159
  # @param model [String, nil] Model name or nil for default
972
1160
  def set_model(model)
@@ -974,6 +1162,11 @@ module ClaudeAgentSDK
974
1162
  @query_handler.set_model(model)
975
1163
  end
976
1164
 
1165
+ # Ruby-style spelling of #set_model: `client.model = 'claude-opus-5'`.
1166
+ def model=(model)
1167
+ set_model(model)
1168
+ end
1169
+
977
1170
  # Reconnect a failed MCP server
978
1171
  # @param server_name [String] Name of the MCP server to reconnect
979
1172
  def reconnect_mcp_server(server_name)
@@ -1045,6 +1238,12 @@ module ClaudeAgentSDK
1045
1238
  @query_handler.get_context_usage
1046
1239
  end
1047
1240
 
1241
+ # Ruby-style spelling of #get_context_usage.
1242
+ # @return [Hash] Context usage response
1243
+ def context_usage
1244
+ get_context_usage
1245
+ end
1246
+
1048
1247
  # Get current MCP server connection status (only works with streaming mode)
1049
1248
  # @return [Hash] MCP status information, including mcpServers list
1050
1249
  def get_mcp_status
@@ -1052,6 +1251,12 @@ module ClaudeAgentSDK
1052
1251
  @query_handler.get_mcp_status
1053
1252
  end
1054
1253
 
1254
+ # Ruby-style spelling of #get_mcp_status.
1255
+ # @return [Hash] MCP status information, including mcpServers list
1256
+ def mcp_status
1257
+ get_mcp_status
1258
+ end
1259
+
1055
1260
  # Get server initialization info including available commands and output styles
1056
1261
  # @return [Hash] Server info
1057
1262
  def get_server_info
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ClaudeAgentSDK
6
+ module Generators
7
+ # `bin/rails generate claude_agent_sdk:install` — writes the initializer,
8
+ # ignores the vendored CLI, and prints the next steps. Lives under
9
+ # lib/generators/ so Rails' generator lookup finds it by namespace.
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ # Explicit: Thor derives the namespace by snake-casing the class path,
12
+ # which turns ClaudeAgentSDK into "claude_agent_s_d_k".
13
+ namespace 'claude_agent_sdk:install'
14
+ source_root File.expand_path('templates', __dir__)
15
+
16
+ desc 'Creates config/initializers/claude_agent_sdk.rb and git-ignores the vendored Claude Code CLI.'
17
+
18
+ GITIGNORE_ENTRY = '/vendor/claude/'
19
+ # Spellings that already ignore the vendored CLI directory.
20
+ GITIGNORE_PATTERN = %r{\A/?vendor/claude/?\z}
21
+
22
+ def create_initializer
23
+ template 'claude_agent_sdk.rb.tt', 'config/initializers/claude_agent_sdk.rb'
24
+ end
25
+
26
+ def ignore_vendored_cli
27
+ path = File.join(destination_root, '.gitignore')
28
+ entry = "# Claude Code CLI vendored by `bin/rails claude_agent_sdk:install_cli`\n#{GITIGNORE_ENTRY}\n"
29
+ return create_file('.gitignore', entry) unless File.exist?(path)
30
+
31
+ content = File.read(path)
32
+ if content.each_line.any? { |line| line.strip.match?(GITIGNORE_PATTERN) }
33
+ say_status :identical, '.gitignore (already ignores vendor/claude)', :blue
34
+ else
35
+ append_to_file '.gitignore', gitignore_separator(content) + entry
36
+ end
37
+ end
38
+
39
+ def show_next_steps
40
+ say <<~MSG
41
+
42
+ Next steps:
43
+ 1. Install the Claude Code CLI this gem is tested with into vendor/claude
44
+ (run it in your Dockerfile / bin/setup as well):
45
+ bin/rails claude_agent_sdk:install_cli
46
+ 2. Provide credentials to the CLI, e.g. ANTHROPIC_API_KEY in the environment.
47
+ 3. Review config/initializers/claude_agent_sdk.rb.
48
+
49
+ Rails guide: https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md
50
+ MSG
51
+ end
52
+
53
+ private
54
+
55
+ # Start the appended block on its own line, after a blank one.
56
+ def gitignore_separator(content)
57
+ return '' if content.empty?
58
+
59
+ content.end_with?("\n") ? "\n" : "\n\n"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Claude Agent SDK defaults, merged into every ClaudeAgentSDK.query and
4
+ # ClaudeAgentSDK::Client session; per-call ClaudeAgentOptions override them.
5
+ # Guide: https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md
6
+
7
+ # Uncomment together with `observers:` below (needs the opentelemetry-sdk gem).
8
+ # require 'claude_agent_sdk/instrumentation'
9
+
10
+ ClaudeAgentSDK.configure do |config|
11
+ config.default_options = {
12
+ # Model: full ID or alias ('opus', 'sonnet', 'haiku'). Unset, the CLI picks.
13
+ # model: 'claude-sonnet-5',
14
+ # model: 'claude-opus-5',
15
+ # model: 'claude-haiku-4-5',
16
+
17
+ # Tool permissions: 'default', 'acceptEdits', 'plan', 'dontAsk',
18
+ # 'bypassPermissions' (unattended jobs in a sandbox only), or 'auto'.
19
+ # permission_mode: 'default',
20
+
21
+ # The CLI is found in Rails.root/vendor/claude (bin/rails claude_agent_sdk:install_cli),
22
+ # whatever the process cwd. To run a binary from somewhere else instead:
23
+ # cli_path: '/usr/local/bin/claude',
24
+
25
+ # OpenTelemetry tracing (Langfuse, Honeycomb, ...). A factory lambda gives
26
+ # every query/session its own observer — safe under Puma and job threads.
27
+ # observers: [-> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }],
28
+
29
+ # Wraps every SDK callback (tool handlers, hooks, permission callbacks,
30
+ # message blocks, observers) so ActiveRecord connections go back to the
31
+ # pool. Not a bare `Rails.application.executor.wrap`: that deadlocks with
32
+ # development code reloading. See docs/rails.md ("callback_wrapper").
33
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
34
+ }
35
+ end