claude-agent-sdk 0.36.0 → 1.0.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +7 -3
  4. data/UPGRADING-1.0.md +151 -0
  5. data/docs/client.md +26 -1
  6. data/docs/errors.md +6 -0
  7. data/docs/hooks-and-permissions.md +5 -3
  8. data/docs/mcp-servers.md +1 -2
  9. data/docs/sessions.md +101 -3
  10. data/docs/types.md +109 -4
  11. data/lib/claude_agent_sdk/cli_installer.rb +30 -3
  12. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  13. data/lib/claude_agent_sdk/deprecation.rb +1 -1
  14. data/lib/claude_agent_sdk/errors.rb +10 -0
  15. data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
  16. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  17. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  18. data/lib/claude_agent_sdk/observer.rb +2 -1
  19. data/lib/claude_agent_sdk/option_warnings.rb +2 -0
  20. data/lib/claude_agent_sdk/query.rb +50 -43
  21. data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
  22. data/lib/claude_agent_sdk/session_mutations.rb +20 -8
  23. data/lib/claude_agent_sdk/session_resume.rb +31 -16
  24. data/lib/claude_agent_sdk/session_store.rb +7 -3
  25. data/lib/claude_agent_sdk/session_summary.rb +4 -2
  26. data/lib/claude_agent_sdk/sessions.rb +8 -6
  27. data/lib/claude_agent_sdk/streaming.rb +1 -1
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
  31. data/lib/claude_agent_sdk/types/attributes.rb +236 -0
  32. data/lib/claude_agent_sdk/types/base.rb +322 -0
  33. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  34. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  36. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  37. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  38. data/lib/claude_agent_sdk/types/options.rb +352 -0
  39. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  40. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  41. data/lib/claude_agent_sdk/types.rb +13 -2534
  42. data/lib/claude_agent_sdk/version.rb +1 -1
  43. data/lib/claude_agent_sdk.rb +62 -28
  44. data/sig/claude_agent_sdk/cancellation_signal.rbs +14 -0
  45. data/sig/claude_agent_sdk/configuration.rbs +14 -0
  46. data/sig/claude_agent_sdk/errors.rbs +86 -0
  47. data/sig/claude_agent_sdk/observer.rbs +42 -0
  48. data/sig/claude_agent_sdk/railtie.rbs +10 -0
  49. data/sig/claude_agent_sdk/sdk_mcp_server.rbs +76 -0
  50. data/sig/claude_agent_sdk/session_store.rbs +105 -0
  51. data/sig/claude_agent_sdk/streaming.rbs +15 -0
  52. data/sig/claude_agent_sdk/transport.rbs +98 -0
  53. data/sig/claude_agent_sdk/types/base.rbs +39 -0
  54. data/sig/claude_agent_sdk/types/content_blocks.rbs +79 -0
  55. data/sig/claude_agent_sdk/types/hooks.rbs +528 -0
  56. data/sig/claude_agent_sdk/types/mcp.rbs +216 -0
  57. data/sig/claude_agent_sdk/types/messages.rbs +586 -0
  58. data/sig/claude_agent_sdk/types/option_values.rbs +245 -0
  59. data/sig/claude_agent_sdk/types/options.rbs +288 -0
  60. data/sig/claude_agent_sdk/types/permissions.rbs +108 -0
  61. data/sig/claude_agent_sdk/types/sessions.rbs +66 -0
  62. data/sig/claude_agent_sdk.rbs +231 -0
  63. data/sig/manifest.yaml +5 -0
  64. metadata +32 -1
@@ -5,9 +5,11 @@ require_relative 'errors'
5
5
 
6
6
  module ClaudeAgentSDK
7
7
  # Parse message from CLI output into typed Message objects
8
+ #
9
+ # @api private
8
10
  class MessageParser
9
- def self.parse(data)
10
- raise MessageParseError.new("Invalid message data type", data: data) unless data.is_a?(Hash)
11
+ def self.parse(data) # rubocop:disable Metrics/CyclomaticComplexity -- flat dispatch over CLI message types
12
+ raise MessageParseError.new('Invalid message data type', data: data) unless data.is_a?(Hash)
11
13
 
12
14
  message_type = data[:type]
13
15
  raise MessageParseError.new("Message missing 'type' field", data: data) unless message_type
@@ -47,13 +49,17 @@ module ClaudeAgentSDK
47
49
  uuid = data[:uuid] # UUID for rewind support
48
50
  tool_use_result = data[:tool_use_result]
49
51
  message_data = data[:message]
50
- raise MessageParseError.new("Missing message field in user message", data: data) unless message_data
52
+ raise MessageParseError.new('Missing message field in user message', data: data) unless message_data
51
53
  # A non-Hash message (malformed CLI output) raised a raw TypeError from
52
54
  # message_data[:content] instead of the documented MessageParseError.
53
- raise MessageParseError.new("Invalid message field in user message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
55
+ unless message_data.is_a?(Hash)
56
+ raise MessageParseError.new(
57
+ "Invalid message field in user message (expected Hash, got #{message_data.class})", data: data
58
+ )
59
+ end
54
60
 
55
61
  content = message_data[:content]
56
- raise MessageParseError.new("Missing content in user message", data: data) unless content
62
+ raise MessageParseError.new('Missing content in user message', data: data) unless content
57
63
 
58
64
  origin = parse_origin(data)
59
65
 
@@ -85,11 +91,17 @@ module ClaudeAgentSDK
85
91
  message_data = data[:message]
86
92
  # A non-Hash message (malformed CLI output) raised a raw TypeError from
87
93
  # dig instead of the documented MessageParseError.
88
- raise MessageParseError.new("Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
94
+ unless message_data.is_a?(Hash)
95
+ raise MessageParseError.new(
96
+ "Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data
97
+ )
98
+ end
89
99
 
90
100
  content = message_data[:content]
91
- raise MessageParseError.new("Missing content in assistant message", data: data) unless content
92
- raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data) unless content.is_a?(Array)
101
+ raise MessageParseError.new('Missing content in assistant message', data: data) unless content
102
+ unless content.is_a?(Array)
103
+ raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data)
104
+ end
93
105
 
94
106
  content_blocks = parse_content_blocks(content, data)
95
107
  AssistantMessage.new(
@@ -194,7 +206,9 @@ module ClaudeAgentSDK
194
206
  # opaque TypeError/NoMethodError from `block[:type]` deep in parsing.
195
207
  def self.parse_content_blocks(content, data)
196
208
  content.map do |block|
197
- raise MessageParseError.new("Invalid content block (expected Hash, got #{block.class})", data: data) unless block.is_a?(Hash)
209
+ unless block.is_a?(Hash)
210
+ raise MessageParseError.new("Invalid content block (expected Hash, got #{block.class})", data: data)
211
+ end
198
212
 
199
213
  parse_content_block(block)
200
214
  end
@@ -39,7 +39,8 @@ module ClaudeAgentSDK
39
39
  # Client#query/#receive_messages/#receive_response/#connect (after
40
40
  # argument/configuration validation — usage errors such as 'Not
41
41
  # connected' or invalid options do not notify) — including errors raised
42
- # by the user's own message block — before on_close where both fire. query() fires on_close even for connect-phase failures (its
42
+ # by the user's own message block — before on_close where both fire.
43
+ # query() fires on_close even for connect-phase failures (its
43
44
  # ensure always runs); a Client#connect failure before the handshake
44
45
  # completes fires on_error WITHOUT on_close (the session never opened).
45
46
  # Not notified (by design): errors raised by control-request methods
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ClaudeAgentSDK
4
4
  # Advisory warnings for option combinations that silently change behavior.
5
+ #
6
+ # @api private
5
7
  module OptionWarnings
6
8
  @emitted = Set.new
7
9
  @mutex = Mutex.new
@@ -19,9 +19,17 @@ module ClaudeAgentSDK
19
19
  # - Tool permission callbacks
20
20
  # - Message streaming
21
21
  # - Initialization handshake
22
- class Query
22
+ #
23
+ # @api private
24
+ class Query # rubocop:disable Metrics/ClassLength -- control-protocol hub: routing, hooks, permissions, MCP bridge
23
25
  attr_reader :transport, :is_streaming_mode, :sdk_mcp_servers
24
26
 
27
+ # The CLI's response to the initialize control request (nil before
28
+ # #initialize_protocol completes). Read by Client#server_info.
29
+ #
30
+ # @api private
31
+ attr_reader :initialization_result
32
+
25
33
  CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
26
34
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
27
35
 
@@ -63,7 +71,7 @@ module ClaudeAgentSDK
63
71
  end
64
72
  end
65
73
 
66
- def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
74
+ def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
67
75
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
68
76
  forward_subagent_text: false, agent_progress_summaries: nil,
69
77
  callback_scheduling: :thread, callback_wrapper: nil)
@@ -132,7 +140,7 @@ module ClaudeAgentSDK
132
140
 
133
141
  # Initialize control protocol if in streaming mode
134
142
  # @return [Hash, nil] Initialize response with supported commands, or nil if not streaming
135
- def initialize_protocol
143
+ def initialize_protocol # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds the initialize request from every optional option
136
144
  return nil unless @is_streaming_mode
137
145
 
138
146
  # Build hooks configuration for initialization
@@ -237,7 +245,10 @@ module ClaudeAgentSDK
237
245
  return if @task
238
246
 
239
247
  parent = Async::Task.current?
240
- raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
248
+ unless parent
249
+ raise CLIConnectionError,
250
+ 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})'
251
+ end
241
252
 
242
253
  @owning_scheduler = Fiber.scheduler
243
254
  # Async child fibers do not inherit OTel's fiber-local current context.
@@ -322,8 +333,8 @@ module ClaudeAgentSDK
322
333
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS
323
334
  end
324
335
 
325
- def read_messages
326
- @transport.read_messages do |message|
336
+ def read_messages # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- concurrency-sensitive read loop; kept whole on purpose
337
+ @transport.read_messages do |message| # rubocop:disable Metrics/BlockLength -- see read_messages
327
338
  break if @closed
328
339
 
329
340
  msg_type = message[:type]
@@ -338,14 +349,12 @@ module ClaudeAgentSDK
338
349
  # nothing keeps running after close; bare Async do may root at the
339
350
  # reactor and leak past shutdown.
340
351
  handler_task = Async::Task.current.async(&FiberBoundary.capture_otel_context do
341
- begin
342
- handle_control_request(message)
343
- ensure
344
- # Identity-guarded: if the CLI ever reused an in-flight request
345
- # id, the later handler owns the slot and must stay cancellable.
346
- if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
347
- @inflight_control_request_tasks.delete(request_id)
348
- end
352
+ handle_control_request(message)
353
+ ensure
354
+ # Identity-guarded: if the CLI ever reused an in-flight request
355
+ # id, the later handler owns the slot and must stay cancellable.
356
+ if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
357
+ @inflight_control_request_tasks.delete(request_id)
349
358
  end
350
359
  end)
351
360
  # A handler that never suspends (MCP metadata, unsupported-subtype
@@ -383,11 +392,7 @@ module ClaudeAgentSDK
383
392
  @first_result_received = true
384
393
  @first_result_condition.signal
385
394
  end
386
- if message[:is_error]
387
- @last_error_result = message
388
- else
389
- @last_error_result = nil
390
- end
395
+ @last_error_result = message[:is_error] ? message : nil
391
396
  elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
392
397
  # Anything other than the post-turn session_state_changed marker
393
398
  # means the conversation moved on; a ProcessError now is a fresh
@@ -533,11 +538,12 @@ module ClaudeAgentSDK
533
538
  waiter = @pending_control_responses[request_id]
534
539
  return unless waiter
535
540
 
536
- if response[:subtype] == 'error'
537
- @pending_control_results[request_id] = StandardError.new(response[:error] || 'Unknown error')
538
- else
539
- @pending_control_results[request_id] = response
540
- end
541
+ @pending_control_results[request_id] =
542
+ if response[:subtype] == 'error'
543
+ StandardError.new(response[:error] || 'Unknown error')
544
+ else
545
+ response
546
+ end
541
547
 
542
548
  # Signal that response is ready. INVARIANT: the result slot above
543
549
  # MUST be written before this signal — senders check the slot before
@@ -545,7 +551,7 @@ module ClaudeAgentSDK
545
551
  waiter.signal
546
552
  end
547
553
 
548
- def handle_control_request(request)
554
+ def handle_control_request(request) # rubocop:disable Metrics/MethodLength -- subtype dispatch plus the shared error response
549
555
  request_id = request[:request_id] || request[:requestId]
550
556
  request_data = request[:request]
551
557
  subtype = request_data[:subtype]
@@ -636,7 +642,7 @@ module ClaudeAgentSDK
636
642
  nil
637
643
  end
638
644
 
639
- def handle_permission_request(request_data, request_id: nil)
645
+ def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion
640
646
  raise 'canUseTool callback is not provided' unless @can_use_tool
641
647
 
642
648
  signal = CancellationSignal.new
@@ -644,13 +650,17 @@ module ClaudeAgentSDK
644
650
  original_input = request_data[:input]
645
651
 
646
652
  # Field order mirrors Python _internal/query.py's can_use_tool branch.
647
- # Suggestions are hydrated into PermissionUpdate (Python #920); a
648
- # malformed entry raises here, on the reactor, and becomes an error
649
- # control_response — same observable behavior as Python.
653
+ # Suggestions are hydrated into PermissionUpdate (Python #920) through
654
+ # the lenient .wrap, so fields a newer CLI adds never trip the
655
+ # strict-attribute warning meant for user-built updates. A nil (or
656
+ # false) entry becomes an empty PermissionUpdate, as PermissionUpdate.new
657
+ # made it before; any other non-Hash entry raises here, on the reactor,
658
+ # and becomes an error control_response — same observable behavior as
659
+ # Python.
650
660
  context = ToolPermissionContext.new(
651
661
  signal: signal,
652
662
  request_id: request_id,
653
- suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.new(s) },
663
+ suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.wrap(s || {}) },
654
664
  tool_use_id: request_data[:tool_use_id],
655
665
  agent_id: request_data[:agent_id],
656
666
  blocked_path: request_data[:blocked_path],
@@ -681,9 +691,7 @@ module ClaudeAgentSDK
681
691
  behavior: 'allow',
682
692
  updatedInput: response.updated_input || original_input
683
693
  }
684
- if response.updated_permissions
685
- result[:updatedPermissions] = response.updated_permissions.map(&:to_h)
686
- end
694
+ result[:updatedPermissions] = response.updated_permissions.map(&:to_h) if response.updated_permissions
687
695
  result
688
696
  when PermissionResultDeny
689
697
  result = { behavior: 'deny', message: response.message }
@@ -699,7 +707,7 @@ module ClaudeAgentSDK
699
707
  untrack_callback_signal(request_id, signal)
700
708
  end
701
709
 
702
- def handle_hook_callback(request_data, request_id: nil)
710
+ def handle_hook_callback(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- hook round-trip: timeout, callback, output conversion
703
711
  callback_id = request_data[:callback_id]
704
712
  callback = @hook_callbacks[callback_id]
705
713
  raise "No hook callback found for ID: #{callback_id}" unless callback
@@ -784,7 +792,7 @@ module ClaudeAgentSDK
784
792
  @callback_request_signals.delete(request_id)
785
793
  end
786
794
 
787
- def parse_hook_input(input_data)
795
+ def parse_hook_input(input_data) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- one branch per hook event type
788
796
  event_name = input_data[:hook_event_name] || input_data['hook_event_name']
789
797
  fetch = lambda do |key|
790
798
  if input_data.key?(key)
@@ -1017,11 +1025,9 @@ module ClaudeAgentSDK
1017
1025
  { mcp_response: mcp_response }
1018
1026
  end
1019
1027
 
1020
- def convert_hook_output_for_cli(hook_output)
1028
+ def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key
1021
1029
  # Handle typed output objects
1022
- if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
1023
- return hook_output.to_h
1024
- end
1030
+ return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
1025
1031
 
1026
1032
  return {} unless hook_output.is_a?(Hash)
1027
1033
 
@@ -1135,7 +1141,7 @@ module ClaudeAgentSDK
1135
1141
  end
1136
1142
  end
1137
1143
 
1138
- def handle_sdk_mcp_request(server_name, message)
1144
+ def handle_sdk_mcp_request(server_name, message) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength -- JSON-RPC method dispatch for SDK MCP servers
1139
1145
  # Carry this session's scheduling mode and callback wrapper across the
1140
1146
  # dispatch into the (possibly session-shared) SdkMcpServer via fiber
1141
1147
  # storage — set on the dispatching fiber, read back by the server's
@@ -1162,7 +1168,7 @@ module ClaudeAgentSDK
1162
1168
  jsonrpc: '2.0',
1163
1169
  id: message[:id],
1164
1170
  error: {
1165
- code: -32601,
1171
+ code: -32_601,
1166
1172
  message: "Server '#{server_name}' not found"
1167
1173
  }
1168
1174
  }
@@ -1193,14 +1199,14 @@ module ClaudeAgentSDK
1193
1199
  {
1194
1200
  jsonrpc: '2.0',
1195
1201
  id: message[:id],
1196
- error: { code: -32601, message: "Method '#{method}' not found" }
1202
+ error: { code: -32_601, message: "Method '#{method}' not found" }
1197
1203
  }
1198
1204
  end
1199
1205
  rescue StandardError => e
1200
1206
  {
1201
1207
  jsonrpc: '2.0',
1202
1208
  id: message[:id],
1203
- error: { code: -32603, message: e.message }
1209
+ error: { code: -32_603, message: e.message }
1204
1210
  }
1205
1211
  ensure
1206
1212
  dispatch_scope&.close
@@ -1429,6 +1435,7 @@ module ClaudeAgentSDK
1429
1435
  wrote_message = false
1430
1436
  stream.each do |message|
1431
1437
  break if @closed
1438
+
1432
1439
  serialized = message.is_a?(Hash) ? JSON.generate(message) : message.to_s
1433
1440
  writeln(serialized)
1434
1441
  wrote_message = true
@@ -67,13 +67,14 @@ module ClaudeAgentSDK
67
67
  # @api private
68
68
  def self.ruby_type_to_json_schema(type)
69
69
  # Class#=== matches instances, not the class object used in { id: Integer }.
70
- type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
70
+ type = { String => :string, Integer => :integer, Float => :float,
71
+ TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
71
72
  case type
72
73
  when :string, String then { type: 'string' }
73
74
  when :integer, Integer then { type: 'integer' }
74
75
  when :float, Float, :number then { type: 'number' }
75
76
  when :boolean, TrueClass, FalseClass then { type: 'boolean' }
76
- else { type: 'string' } # Default fallback
77
+ else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit
77
78
  end
78
79
  end
79
80
 
@@ -96,14 +97,17 @@ module ClaudeAgentSDK
96
97
  #
97
98
  # This class wraps the official MCP Ruby SDK and provides a simpler block-based
98
99
  # API for defining tools, resources, and prompts.
99
- class SdkMcpServer
100
+ class SdkMcpServer # rubocop:disable Metrics/ClassLength -- one facade over MCP::Server tools, resources and prompts
100
101
  # The gem validates arguments before injecting its server_context keyword.
101
102
  # Guard actual keys here, independent of schema composition/$ref support,
102
103
  # and retain this guard even when schema validation falls back to permissive.
104
+ #
105
+ # @api private
103
106
  class ToolInputSchema < MCP::Tool::InputSchema
104
107
  def validate_arguments(arguments)
105
108
  if arguments.is_a?(Hash) && (arguments.key?(:server_context) || arguments.key?('server_context'))
106
- raise ValidationError, "Tool argument 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
109
+ raise ValidationError,
110
+ "Tool argument 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
107
111
  end
108
112
 
109
113
  super
@@ -150,7 +154,9 @@ module ClaudeAgentSDK
150
154
  # Validated at set time so a non-callable fails here, not later as a
151
155
  # NoMethodError inside a tool dispatch.
152
156
  def callback_wrapper=(value)
153
- raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
157
+ unless value.nil? || value.respond_to?(:call)
158
+ raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})"
159
+ end
154
160
 
155
161
  @callback_wrapper = value
156
162
  end
@@ -224,6 +230,7 @@ module ClaudeAgentSDK
224
230
  # Handle a JSON-RPC request
225
231
  # @param json_string [String] JSON-RPC request
226
232
  # @return [String] JSON-RPC response
233
+ # @api private
227
234
  def handle_json(json_string)
228
235
  @mcp_server.handle_json(json_string)
229
236
  end
@@ -245,6 +252,7 @@ module ClaudeAgentSDK
245
252
  # Responses are built from per-call locals (safe), but the gem's
246
253
  # instrumentation_callback attribution (@instrumentation_data ivar) can
247
254
  # cross-contaminate under concurrency — harmless with the default no-op.
255
+ # @api private
248
256
  def handle_message(message)
249
257
  original_id = message[:id]
250
258
  response = @mcp_server.handle(message.merge(jsonrpc: '2.0', id: 0))
@@ -294,7 +302,7 @@ module ClaudeAgentSDK
294
302
  end
295
303
 
296
304
  # Guard before flexible_fetch: it raises on non-Hash inputs.
297
- content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
305
+ content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil
298
306
  return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content
299
307
 
300
308
  result
@@ -333,7 +341,7 @@ module ClaudeAgentSDK
333
341
 
334
342
  # Ensure content has the expected format (symbol or string keys; guard
335
343
  # before flexible_fetch — it raises on non-Hash inputs)
336
- contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content, "contents", "contents") : nil
344
+ contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content, 'contents', 'contents') : nil
337
345
  raise "Resource '#{uri}' must return a hash with :contents key" if contents.nil?
338
346
 
339
347
  content
@@ -367,7 +375,7 @@ module ClaudeAgentSDK
367
375
  end
368
376
 
369
377
  # Ensure result has the expected format (symbol or string keys)
370
- messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
378
+ messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'messages', 'messages') : nil
371
379
  raise "Prompt '#{name}' must return a hash with :messages key" if messages.nil?
372
380
 
373
381
  result
@@ -379,7 +387,7 @@ module ClaudeAgentSDK
379
387
  # in content with isError: true, returned as a *successful* JSON-RPC
380
388
  # result.
381
389
  def error_tool_result(text)
382
- { content: [{ type: "text", text: text }], isError: true }
390
+ { content: [{ type: 'text', text: text }], isError: true }
383
391
  end
384
392
 
385
393
  # The mcp gem's tools/call error behavior swung across 0.x releases:
@@ -404,18 +412,19 @@ module ClaudeAgentSDK
404
412
  end
405
413
 
406
414
  # Create dynamic Tool classes from tool definitions
407
- def create_tool_classes(tools)
415
+ def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline
408
416
  # Captured so the dynamic class can resolve the effective scheduling
409
417
  # mode at call time — same pattern as prompt classes.
410
418
  sdk_server = self
411
- tools.map do |tool_def|
419
+ tools.map do |tool_def| # rubocop:disable Metrics/BlockLength -- see create_tool_classes
412
420
  # The gem injects server_context AFTER expanding the tool arguments,
413
421
  # overwriting a user value before our call method can recover it.
414
422
  # Check at registration (including raw SdkMcpTool definitions), not in
415
423
  # input_schema_value's permissive schema-error fallback.
416
424
  schema = ClaudeAgentSDK.normalize_tool_schema(tool_def.input_schema)
417
425
  if schema[:properties]&.key?(:server_context)
418
- raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
426
+ raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK; " \
427
+ "rename it (e.g. 'request_context')"
419
428
  end
420
429
 
421
430
  # Create a new class that extends MCP::Tool
@@ -470,7 +479,7 @@ module ClaudeAgentSDK
470
479
  @tool_def.meta
471
480
  end
472
481
 
473
- def call(server_context: nil, **args)
482
+ def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgument -- declared to strip it from args
474
483
  # Filter out server_context and pass remaining args to handler.
475
484
  # Hop to a plain thread (default) so user handlers don't see
476
485
  # the Fiber scheduler; :inline runs in place on the reactor.
@@ -13,7 +13,9 @@ module ClaudeAgentSDK
13
13
  # Ported from Python SDK's _internal/session_mutations.py.
14
14
  # Appends typed metadata entries to the session's JSONL file,
15
15
  # matching the CLI pattern. Safe to call from any SDK host process.
16
- module SessionMutations # rubocop:disable Metrics/ModuleLength
16
+ #
17
+ # @api private
18
+ module SessionMutations # rubocop:disable Metrics/ModuleLength -- rename/tag/delete/fork share transcript helpers
17
19
  module_function
18
20
 
19
21
  # Transcript entry types kept in fork output. Mirrors Python's
@@ -82,7 +84,9 @@ module ClaudeAgentSDK
82
84
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
83
85
 
84
86
  result = find_session_file_with_dir(session_id, directory)
85
- raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result
87
+ unless result
88
+ raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}"
89
+ end
86
90
 
87
91
  path = result[0]
88
92
 
@@ -117,10 +121,14 @@ module ClaudeAgentSDK
117
121
  def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
118
122
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
119
123
 
120
- raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
124
+ if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
125
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}"
126
+ end
121
127
 
122
128
  result = find_session_file_with_dir(session_id, directory)
123
- raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result
129
+ unless result
130
+ raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}"
131
+ end
124
132
 
125
133
  file_path, project_dir = result
126
134
  file_size = File.size(file_path)
@@ -232,7 +240,9 @@ module ClaudeAgentSDK
232
240
  # @raise [Errno::ENOENT] if the source session is not found in the store
233
241
  def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
234
242
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
235
- raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
243
+ if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
244
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}"
245
+ end
236
246
 
237
247
  project_key = Sessions.project_key_for_directory(directory)
238
248
  raw = session_store.load('project_key' => project_key, 'session_id' => session_id)
@@ -382,7 +392,7 @@ module ClaudeAgentSDK
382
392
  # +derive_title+ is a callable invoked ONLY when no explicit +title+ is
383
393
  # given, so the disk path's head/tail byte scan and the store path's
384
394
  # entry-object scan each run only when needed.
385
- def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/MethodLength
395
+ def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- single fork rewrite pass: UUID remap, truncation, title
386
396
  transcript = transcript.reject { |e| e['isSidechain'] }
387
397
  raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
388
398
 
@@ -520,7 +530,7 @@ module ClaudeAgentSDK
520
530
  end
521
531
 
522
532
  # Build a single forked entry with remapped UUIDs.
523
- def build_forked_entry(original, index, total, uuid_mapping, by_uuid,
533
+ def build_forked_entry(original, index, total, uuid_mapping, by_uuid, # rubocop:disable Metrics/ParameterLists -- per-entry step of build_fork_lines; its state is threaded explicitly
524
534
  forked_session_id, source_session_id, now)
525
535
  new_uuid = uuid_mapping[original['uuid']]
526
536
 
@@ -603,7 +613,9 @@ module ClaudeAgentSDK
603
613
 
604
614
  def append_to_session_global(session_id, data, file_name)
605
615
  projects_dir = File.join(Sessions.config_dir, 'projects')
606
- raise Errno::ENOENT, "Session #{session_id} not found (no projects directory)" unless File.directory?(projects_dir)
616
+ unless File.directory?(projects_dir)
617
+ raise Errno::ENOENT, "Session #{session_id} not found (no projects directory)"
618
+ end
607
619
 
608
620
  found = Dir.children(projects_dir).any? do |child|
609
621
  candidate = File.join(projects_dir, child, file_name)