claude-agent-sdk 0.2.1 → 0.4.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.
@@ -32,6 +32,7 @@ module ClaudeAgentSDK
32
32
 
33
33
  def self.parse_user_message(data)
34
34
  parent_tool_use_id = data[:parent_tool_use_id]
35
+ uuid = data[:uuid] # UUID for rewind support
35
36
  message_data = data[:message]
36
37
  raise MessageParseError.new("Missing message field in user message", data: data) unless message_data
37
38
 
@@ -40,9 +41,9 @@ module ClaudeAgentSDK
40
41
 
41
42
  if content.is_a?(Array)
42
43
  content_blocks = content.map { |block| parse_content_block(block) }
43
- UserMessage.new(content: content_blocks, parent_tool_use_id: parent_tool_use_id)
44
+ UserMessage.new(content: content_blocks, uuid: uuid, parent_tool_use_id: parent_tool_use_id)
44
45
  else
45
- UserMessage.new(content: content, parent_tool_use_id: parent_tool_use_id)
46
+ UserMessage.new(content: content, uuid: uuid, parent_tool_use_id: parent_tool_use_id)
46
47
  end
47
48
  end
48
49
 
@@ -54,7 +55,8 @@ module ClaudeAgentSDK
54
55
  AssistantMessage.new(
55
56
  content: content_blocks,
56
57
  model: data.dig(:message, :model),
57
- parent_tool_use_id: data[:parent_tool_use_id]
58
+ parent_tool_use_id: data[:parent_tool_use_id],
59
+ error: data[:error] # authentication_failed, billing_error, rate_limit, invalid_request, server_error, unknown
58
60
  )
59
61
  end
60
62
 
@@ -75,7 +77,8 @@ module ClaudeAgentSDK
75
77
  session_id: data[:session_id],
76
78
  total_cost_usd: data[:total_cost_usd],
77
79
  usage: data[:usage],
78
- result: data[:result]
80
+ result: data[:result],
81
+ structured_output: data[:structured_output] # Structured output when output_format is specified
79
82
  )
80
83
  end
81
84
 
@@ -217,16 +217,73 @@ module ClaudeAgentSDK
217
217
  callback = @hook_callbacks[callback_id]
218
218
  raise "No hook callback found for ID: #{callback_id}" unless callback
219
219
 
220
+ # Parse input data into typed HookInput object
221
+ input_data = request_data[:input] || {}
222
+ hook_input = parse_hook_input(input_data)
223
+
224
+ # Create typed HookContext
225
+ context = HookContext.new(signal: nil)
226
+
220
227
  hook_output = callback.call(
221
- request_data[:input],
228
+ hook_input,
222
229
  request_data[:tool_use_id],
223
- { signal: nil }
230
+ context
224
231
  )
225
232
 
226
233
  # Convert Ruby-safe field names to CLI-expected names
227
234
  convert_hook_output_for_cli(hook_output)
228
235
  end
229
236
 
237
+ def parse_hook_input(input_data)
238
+ event_name = input_data[:hook_event_name] || input_data['hook_event_name']
239
+ base_args = {
240
+ session_id: input_data[:session_id],
241
+ transcript_path: input_data[:transcript_path],
242
+ cwd: input_data[:cwd],
243
+ permission_mode: input_data[:permission_mode]
244
+ }
245
+
246
+ case event_name
247
+ when 'PreToolUse'
248
+ PreToolUseHookInput.new(
249
+ tool_name: input_data[:tool_name],
250
+ tool_input: input_data[:tool_input],
251
+ **base_args
252
+ )
253
+ when 'PostToolUse'
254
+ PostToolUseHookInput.new(
255
+ tool_name: input_data[:tool_name],
256
+ tool_input: input_data[:tool_input],
257
+ tool_response: input_data[:tool_response],
258
+ **base_args
259
+ )
260
+ when 'UserPromptSubmit'
261
+ UserPromptSubmitHookInput.new(
262
+ prompt: input_data[:prompt],
263
+ **base_args
264
+ )
265
+ when 'Stop'
266
+ StopHookInput.new(
267
+ stop_hook_active: input_data[:stop_hook_active],
268
+ **base_args
269
+ )
270
+ when 'SubagentStop'
271
+ SubagentStopHookInput.new(
272
+ stop_hook_active: input_data[:stop_hook_active],
273
+ **base_args
274
+ )
275
+ when 'PreCompact'
276
+ PreCompactHookInput.new(
277
+ trigger: input_data[:trigger],
278
+ custom_instructions: input_data[:custom_instructions],
279
+ **base_args
280
+ )
281
+ else
282
+ # Return base input for unknown event types
283
+ BaseHookInput.new(**base_args)
284
+ end
285
+ end
286
+
230
287
  def handle_mcp_message(request_data)
231
288
  server_name = request_data[:server_name]
232
289
  mcp_message = request_data[:message]
@@ -238,6 +295,13 @@ module ClaudeAgentSDK
238
295
  end
239
296
 
240
297
  def convert_hook_output_for_cli(hook_output)
298
+ # Handle typed output objects
299
+ if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
300
+ return hook_output.to_h
301
+ end
302
+
303
+ return {} unless hook_output.is_a?(Hash)
304
+
241
305
  # Convert Ruby hash with symbol keys to CLI format
242
306
  # Handle special keywords that might be Ruby-safe versions
243
307
  converted = {}
@@ -245,9 +309,21 @@ module ClaudeAgentSDK
245
309
  converted_key = case key
246
310
  when :async_, 'async_' then 'async'
247
311
  when :continue_, 'continue_' then 'continue'
248
- else key.to_s.gsub('_', '')
312
+ when :hook_specific_output then 'hookSpecificOutput'
313
+ when :suppress_output then 'suppressOutput'
314
+ when :stop_reason then 'stopReason'
315
+ when :system_message then 'systemMessage'
316
+ when :async_timeout then 'asyncTimeout'
317
+ else key.to_s
249
318
  end
250
- converted[converted_key] = value
319
+
320
+ # Recursively convert nested objects
321
+ converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash)
322
+ value.to_h
323
+ else
324
+ value
325
+ end
326
+ converted[converted_key] = converted_value
251
327
  end
252
328
  converted
253
329
  end
@@ -475,6 +551,17 @@ module ClaudeAgentSDK
475
551
  })
476
552
  end
477
553
 
554
+ # Rewind files to a previous checkpoint (v0.1.15+)
555
+ # Restores file state to what it was at the given user message
556
+ # Requires enable_file_checkpointing to be true in options
557
+ # @param user_message_uuid [String] The UUID of the UserMessage to rewind to
558
+ def rewind_files(user_message_uuid)
559
+ send_control_request({
560
+ subtype: 'rewind_files',
561
+ userMessageUuid: user_message_uuid
562
+ })
563
+ end
564
+
478
565
  # Stream input messages to transport
479
566
  def stream_input(stream)
480
567
  stream.each do |message|
@@ -75,11 +75,97 @@ module ClaudeAgentSDK
75
75
  cmd.concat(['--max-turns', @options.max_turns.to_s]) if @options.max_turns
76
76
  cmd.concat(['--disallowedTools', @options.disallowed_tools.join(',')]) unless @options.disallowed_tools.empty?
77
77
  cmd.concat(['--model', @options.model]) if @options.model
78
+ cmd.concat(['--fallback-model', @options.fallback_model]) if @options.fallback_model
78
79
  cmd.concat(['--permission-prompt-tool', @options.permission_prompt_tool_name]) if @options.permission_prompt_tool_name
79
80
  cmd.concat(['--permission-mode', @options.permission_mode]) if @options.permission_mode
80
81
  cmd << '--continue' if @options.continue_conversation
81
82
  cmd.concat(['--resume', @options.resume]) if @options.resume
82
- cmd.concat(['--settings', @options.settings]) if @options.settings
83
+
84
+ # Settings handling with sandbox merge
85
+ # Sandbox settings are merged into the main settings JSON
86
+ if @options.settings || @options.sandbox
87
+ settings_hash = {}
88
+ settings_is_path = false
89
+
90
+ # Parse existing settings if provided
91
+ if @options.settings
92
+ if @options.settings.is_a?(String)
93
+ begin
94
+ settings_hash = JSON.parse(@options.settings)
95
+ rescue JSON::ParserError
96
+ # If not valid JSON, treat as file path and pass as-is
97
+ settings_is_path = true
98
+ cmd.concat(['--settings', @options.settings])
99
+ if @options.sandbox
100
+ warn "Warning: Cannot merge sandbox settings when settings is a file path. " \
101
+ "Sandbox settings will be ignored. Use a Hash or JSON string for settings " \
102
+ "to enable sandbox merging."
103
+ end
104
+ end
105
+ elsif @options.settings.is_a?(Hash)
106
+ settings_hash = @options.settings.dup
107
+ end
108
+ end
109
+
110
+ # Merge sandbox settings if provided (only when settings is not a file path)
111
+ if !settings_is_path && @options.sandbox
112
+ sandbox_hash = if @options.sandbox.is_a?(SandboxSettings)
113
+ @options.sandbox.to_h
114
+ else
115
+ @options.sandbox
116
+ end
117
+ settings_hash[:sandbox] = sandbox_hash unless sandbox_hash.empty?
118
+ end
119
+
120
+ # Output merged settings (only when settings is not a file path)
121
+ if !settings_is_path && !settings_hash.empty?
122
+ cmd.concat(['--settings', JSON.generate(settings_hash)])
123
+ end
124
+ end
125
+
126
+ # Budget limit option
127
+ cmd.concat(['--max-budget-usd', @options.max_budget_usd.to_s]) if @options.max_budget_usd
128
+ # Note: max_thinking_tokens is stored in options but not yet supported by Claude CLI
129
+
130
+ # Betas option for enabling experimental features
131
+ if @options.betas && !@options.betas.empty?
132
+ cmd.concat(['--betas', @options.betas.join(',')])
133
+ end
134
+
135
+ # Tools option for base tools selection
136
+ if @options.tools
137
+ if @options.tools.is_a?(Array)
138
+ cmd.concat(['--tools', @options.tools.join(',')])
139
+ elsif @options.tools.is_a?(ToolsPreset)
140
+ cmd.concat(['--tools', JSON.generate(@options.tools.to_h)])
141
+ elsif @options.tools.is_a?(Hash)
142
+ cmd.concat(['--tools', JSON.generate(@options.tools)])
143
+ end
144
+ end
145
+
146
+ # Append allowed tools option
147
+ if @options.append_allowed_tools && !@options.append_allowed_tools.empty?
148
+ cmd.concat(['--append-allowed-tools', @options.append_allowed_tools.join(',')])
149
+ end
150
+
151
+ # File checkpointing for rewind support
152
+ cmd << '--enable-file-checkpointing' if @options.enable_file_checkpointing
153
+
154
+ # JSON schema for structured output
155
+ # Accepts either:
156
+ # 1. Direct schema: { type: 'object', properties: {...} }
157
+ # 2. Wrapped format: { type: 'json_schema', schema: {...} }
158
+ if @options.output_format
159
+ schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_schema'
160
+ @options.output_format[:schema]
161
+ elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema'
162
+ @options.output_format['schema']
163
+ else
164
+ @options.output_format
165
+ end
166
+ schema_json = schema.is_a?(String) ? schema : JSON.generate(schema)
167
+ cmd.concat(['--json-schema', schema_json])
168
+ end
83
169
 
84
170
  # Add directories
85
171
  @options.add_dirs.each do |dir|
@@ -121,6 +207,14 @@ module ClaudeAgentSDK
121
207
  cmd.concat(['--agents', JSON.generate(agents_dict)])
122
208
  end
123
209
 
210
+ # Plugins
211
+ if @options.plugins && !@options.plugins.empty?
212
+ plugins_config = @options.plugins.map do |plugin|
213
+ plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin
214
+ end
215
+ cmd.concat(['--plugins', JSON.generate(plugins_config)])
216
+ end
217
+
124
218
  # Setting sources
125
219
  sources_value = @options.setting_sources ? @options.setting_sources.join(',') : ''
126
220
  cmd.concat(['--setting-sources', sources_value])
@@ -159,7 +253,7 @@ module ClaudeAgentSDK
159
253
  process_env['PWD'] = @cwd.to_s if @cwd
160
254
 
161
255
  # Determine stderr handling
162
- should_pipe_stderr = @options.stderr || @options.extra_args.key?('debug-to-stderr')
256
+ should_pipe_stderr = @options.stderr || @options.debug_stderr || @options.extra_args.key?('debug-to-stderr')
163
257
 
164
258
  begin
165
259
  # Start process using Open3
@@ -205,7 +299,17 @@ module ClaudeAgentSDK
205
299
  line_str = line.chomp
206
300
  next if line_str.empty?
207
301
 
302
+ # Call stderr callback if provided
208
303
  @options.stderr&.call(line_str)
304
+
305
+ # Write to debug_stderr file/IO if provided
306
+ if @options.debug_stderr
307
+ if @options.debug_stderr.respond_to?(:puts)
308
+ @options.debug_stderr.puts(line_str)
309
+ elsif @options.debug_stderr.is_a?(String)
310
+ File.open(@options.debug_stderr, 'a') { |f| f.puts(line_str) }
311
+ end
312
+ end
209
313
  end
210
314
  rescue StandardError
211
315
  # Ignore errors during stderr reading