claude-agent-sdk 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4b8c3f8f0eefdfe38747d264f5d89148e1e589f12c200796dd2c37e821b3f61
4
- data.tar.gz: 60252ef9c5f8679526a1f76b1be4216138e8bc7d91deb7bc1235b94d04a8481d
3
+ metadata.gz: db31632430bb28dc8f07903bfc8634a8c437da9dc988f2237606948ecc71f7e0
4
+ data.tar.gz: e7574810137063aef37a0a78fb9d855645a0a479ba0b909b63a491f0ea7060f6
5
5
  SHA512:
6
- metadata.gz: a95797df469fa0acede9c2187d0e8dd9420582e856853552dcc4b0c09b7e4564ca7371478624a904090366d06880a2e7457f71910b88e815fbbe12d8f210f255
7
- data.tar.gz: 77354e6852d2f86b060ac0e4948006ed1c28311f7481e4efa7cdc2f57864448ab0d656244559da43094cde1ac409ae25fd4aad13228640001b0e453d7d9fc753
6
+ metadata.gz: e394102a18bace179f7745b2b10cc721393ff25e8dcbc75f9492e6822aa6a80afa1455eb168b6567857cbf10a7d1d2e96e7293e43b043ad69ac2b41f95a3eb99
7
+ data.tar.gz: 03b94423b5d2369eb974e9cf82b2523e36aea4a09d05181e86528da11b3432d8685256705989210941b644333b5f9d3173b2efa0fd3b5f1b74e5e5aea3705bc6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.2] - 2026-02-07
9
+
10
+ ### Fixed
11
+ - **MCP response fidelity:** Non-text content (images, binary data) is now preserved in SDK MCP tool responses instead of being silently dropped
12
+ - **MCP error key:** Tool error flag is now sent as `isError` (camelCase) matching the JSON-RPC spec, instead of `is_error` which the CLI ignored
13
+ - **MCP structured content:** `structuredContent` is now passed through in tool responses
14
+ - **ENV pollution:** `query()` and `Client.connect` no longer mutate the global `ENV`; entrypoint is passed via transport options
15
+ - **Symbol key env:** Fixed symbol keys in `env` option causing spawn failures (PR #7)
16
+
17
+ ### Added
18
+ - `ClaudeAgentSDK.flexible_fetch` helper for tolerant hash key lookup (symbol/string, camelCase/snake_case)
19
+ - Gated real CLI integration tests (`RUN_REAL_INTEGRATION=1`) with budget cap
20
+ - `CLAUDE.md` architecture guide for contributors
21
+
22
+ ## [0.4.1] - 2026-02-05
23
+
24
+ ### Added
25
+
26
+ #### Hook Parity
27
+ - Added hook event support for `PostToolUseFailure`, `Notification`, `SubagentStart`, and `PermissionRequest`
28
+ - Expanded `SubagentStop` hook inputs with `agent_id`, `agent_transcript_path`, and `agent_type`
29
+ - Added hook-specific outputs for new hook events
30
+ - Added `updatedMCPToolOutput` support to `PostToolUse` hook outputs
31
+
32
+ #### MCP Status APIs
33
+ - `get_mcp_status` on `Query` and `Client` for live MCP connection status (streaming mode)
34
+ - `get_server_info` on `Client` as a parity alias for server initialization info
35
+
36
+ ### Fixed
37
+ - Hook input parsing now supports both symbol and string keys
38
+ - Hook callback timeouts are enforced and control request cancellation is handled cleanly
39
+
8
40
  ## [0.4.0] - 2026-01-06
9
41
 
10
42
  ### Added
@@ -54,7 +86,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
86
  ### Improved
55
87
  - Better long-term maintenance by leveraging official SDK updates
56
88
  - Aligned with Python SDK implementation pattern (using official MCP library)
57
- - All 86 tests passing with full backward compatibility maintained
89
+ - All tests passing with full backward compatibility maintained
58
90
 
59
91
  ### Technical Details
60
92
  - Creates dynamic `MCP::Tool`, `MCP::Resource`, and `MCP::Prompt` classes from block-based definitions
@@ -75,7 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
75
107
  - **Critical:** Replaced `Async::Process` with Ruby's built-in `Open3` for subprocess management
76
108
  - Fixed "uninitialized constant Async::Process" error that prevented the gem from working
77
109
  - Process management now uses standard Ruby threads instead of async tasks
78
- - All 86 tests passing
110
+ - All tests passing
79
111
 
80
112
  ## [0.1.1] - 2025-10-14
81
113
 
@@ -84,7 +116,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
84
116
  - Fixed issue where SDK couldn't find Claude Code when accessed via shell alias
85
117
 
86
118
  ### Added
87
- - Comprehensive test suite with 86 passing tests
119
+ - Comprehensive test suite (RSpec)
88
120
  - Test documentation in spec/README.md
89
121
 
90
122
  ### Changed
data/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
  - [Tools Configuration](#tools-configuration)
23
23
  - [Sandbox Settings](#sandbox-settings)
24
24
  - [File Checkpointing & Rewind](#file-checkpointing--rewind)
25
+ - [Rails Integration](#rails-integration)
25
26
  - [Types](#types)
26
27
  - [Error Handling](#error-handling)
27
28
  - [Examples](#examples)
@@ -33,6 +34,10 @@
33
34
  Add this line to your application's Gemfile:
34
35
 
35
36
  ```ruby
37
+ # Recommended: Use the latest from GitHub for newest features
38
+ gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
39
+
40
+ # Or use a stable version from RubyGems
36
41
  gem 'claude-agent-sdk', '~> 0.4.0'
37
42
  ```
38
43
 
@@ -42,7 +47,7 @@ And then execute:
42
47
  bundle install
43
48
  ```
44
49
 
45
- Or install it yourself as:
50
+ Or install directly from RubyGems:
46
51
 
47
52
  ```bash
48
53
  gem install claude-agent-sdk
@@ -53,6 +58,16 @@ gem install claude-agent-sdk
53
58
  - Node.js
54
59
  - Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code`
55
60
 
61
+ ### Agentic Coding Skill
62
+
63
+ If you're using [Claude Code](https://claude.ai/claude-code) or another agentic coding tool that supports [skills](https://skills.sh), you can install the SDK skill:
64
+
65
+ ```bash
66
+ npx skills add https://github.com/ya-luotao/claude-agent-sdk-ruby --skill claude-agent-sdk-ruby
67
+ ```
68
+
69
+ This skill teaches your AI coding assistant about the SDK's APIs, patterns, and best practices, making it easier to get help writing code that uses this SDK.
70
+
56
71
  ## Quick Start
57
72
 
58
73
  ```ruby
@@ -204,10 +219,18 @@ Async do
204
219
  # Change AI model during conversation
205
220
  client.set_model('claude-sonnet-4-5')
206
221
 
222
+ # Get MCP server connection status
223
+ status = client.get_mcp_status
224
+ puts "MCP status: #{status}"
225
+
207
226
  # Get server initialization info
208
227
  info = client.server_info
209
228
  puts "Available commands: #{info}"
210
229
 
230
+ # (Parity alias) Get server initialization info
231
+ info = client.get_server_info
232
+ puts "Available commands: #{info}"
233
+
211
234
  client.disconnect
212
235
  end.wait
213
236
  ```
@@ -365,6 +388,21 @@ For complete examples, see [examples/mcp_calculator.rb](examples/mcp_calculator.
365
388
 
366
389
  A **hook** is a Ruby proc/lambda that the Claude Code *application* (*not* Claude) invokes at specific points of the Claude agent loop. Hooks can provide deterministic processing and automated feedback for Claude. Read more in [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks).
367
390
 
391
+ ### Supported Events
392
+
393
+ All hook input objects include common fields like `session_id`, `transcript_path`, `cwd`, and `permission_mode`.
394
+
395
+ - `PreToolUse` → `PreToolUseHookInput` (`tool_name`, `tool_input`)
396
+ - `PostToolUse` → `PostToolUseHookInput` (`tool_name`, `tool_input`, `tool_response`)
397
+ - `PostToolUseFailure` → `PostToolUseFailureHookInput` (`tool_name`, `tool_input`, `tool_use_id`, `error`, `is_interrupt`)
398
+ - `UserPromptSubmit` → `UserPromptSubmitHookInput` (`prompt`)
399
+ - `Stop` → `StopHookInput` (`stop_hook_active`)
400
+ - `SubagentStop` → `SubagentStopHookInput` (`stop_hook_active`, `agent_id`, `agent_transcript_path`, `agent_type`)
401
+ - `PreCompact` → `PreCompactHookInput` (`trigger`, `custom_instructions`)
402
+ - `Notification` → `NotificationHookInput` (`message`, `title`, `notification_type`)
403
+ - `SubagentStart` → `SubagentStartHookInput` (`agent_id`, `agent_type`)
404
+ - `PermissionRequest` → `PermissionRequestHookInput` (`tool_name`, `tool_input`, `permission_suggestions`)
405
+
368
406
  ### Example
369
407
 
370
408
  ```ruby
@@ -373,13 +411,12 @@ require 'async'
373
411
 
374
412
  Async do
375
413
  # Define a hook that blocks dangerous bash commands
376
- bash_hook = lambda do |input, tool_use_id, context|
377
- tool_name = input[:tool_name]
378
- tool_input = input[:tool_input]
379
-
380
- return {} unless tool_name == 'Bash'
414
+ bash_hook = lambda do |input, _tool_use_id, _context|
415
+ # Hook inputs are typed objects (e.g., PreToolUseHookInput) with Ruby-style accessors
416
+ return {} unless input.respond_to?(:tool_name) && input.tool_name == 'Bash'
381
417
 
382
- command = tool_input[:command] || ''
418
+ tool_input = input.tool_input || {}
419
+ command = tool_input[:command] || tool_input['command'] || ''
383
420
  block_patterns = ['rm -rf', 'foo.sh']
384
421
 
385
422
  block_patterns.each do |pattern|
@@ -677,11 +714,164 @@ Async do
677
714
  end
678
715
 
679
716
  client.disconnect
680
- end
717
+ end.wait
681
718
  ```
682
719
 
683
720
  > **Note:** The `uuid` field on `UserMessage` is populated by the CLI and represents checkpoint identifiers. Rewinding to a UUID restores file state to what it was at that point in the conversation.
684
721
 
722
+ ## Rails Integration
723
+
724
+ The SDK integrates well with Rails applications. Here are common patterns:
725
+
726
+ ### ActionCable Streaming
727
+
728
+ Stream Claude responses to the frontend in real-time:
729
+
730
+ ```ruby
731
+ # app/jobs/chat_agent_job.rb
732
+ class ChatAgentJob < ApplicationJob
733
+ queue_as :claude_agents
734
+
735
+ def perform(chat_id, message_content)
736
+ Async do
737
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
738
+ system_prompt: { type: 'preset', preset: 'claude_code' },
739
+ permission_mode: 'bypassPermissions'
740
+ )
741
+
742
+ client = ClaudeAgentSDK::Client.new(options: options)
743
+
744
+ begin
745
+ client.connect
746
+ client.query(message_content)
747
+
748
+ client.receive_response do |message|
749
+ case message
750
+ when ClaudeAgentSDK::AssistantMessage
751
+ text = extract_text(message)
752
+ ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: text })
753
+
754
+ when ClaudeAgentSDK::ResultMessage
755
+ ChatChannel.broadcast_to(chat_id, {
756
+ type: 'complete',
757
+ content: message.result,
758
+ cost: message.total_cost_usd
759
+ })
760
+ end
761
+ end
762
+ ensure
763
+ client.disconnect
764
+ end
765
+ end.wait
766
+ end
767
+
768
+ private
769
+
770
+ def extract_text(message)
771
+ message.content
772
+ .select { |b| b.is_a?(ClaudeAgentSDK::TextBlock) }
773
+ .map(&:text)
774
+ .join("\n\n")
775
+ end
776
+ end
777
+ ```
778
+
779
+ ### Session Resumption
780
+
781
+ Persist Claude sessions for multi-turn conversations:
782
+
783
+ ```ruby
784
+ # app/models/chat_session.rb
785
+ class ChatSession < ApplicationRecord
786
+ # Columns: id, claude_session_id, user_id, created_at, updated_at
787
+
788
+ def send_message(content)
789
+ options = build_options
790
+ client = ClaudeAgentSDK::Client.new(options: options)
791
+
792
+ Async do
793
+ client.connect
794
+ client.query(content, session_id: claude_session_id ? nil : generate_session_id)
795
+
796
+ client.receive_response do |message|
797
+ if message.is_a?(ClaudeAgentSDK::ResultMessage)
798
+ # Save session ID for next message
799
+ update!(claude_session_id: message.session_id)
800
+ end
801
+ end
802
+ ensure
803
+ client.disconnect
804
+ end.wait
805
+ end
806
+
807
+ private
808
+
809
+ def build_options
810
+ opts = {
811
+ permission_mode: 'bypassPermissions',
812
+ setting_sources: []
813
+ }
814
+ opts[:resume] = claude_session_id if claude_session_id.present?
815
+ ClaudeAgentSDK::ClaudeAgentOptions.new(**opts)
816
+ end
817
+
818
+ def generate_session_id
819
+ "chat_#{id}_#{Time.current.to_i}"
820
+ end
821
+ end
822
+ ```
823
+
824
+ ### Background Jobs with Error Handling
825
+
826
+ ```ruby
827
+ class ClaudeAgentJob < ApplicationJob
828
+ queue_as :claude_agents
829
+ retry_on ClaudeAgentSDK::ProcessError, wait: :polynomially_longer, attempts: 3
830
+
831
+ def perform(task_id)
832
+ task = Task.find(task_id)
833
+
834
+ Async do
835
+ execute_agent(task)
836
+ end.wait
837
+
838
+ rescue ClaudeAgentSDK::CLINotFoundError => e
839
+ task.update!(status: 'failed', error: 'Claude CLI not installed')
840
+ raise
841
+ end
842
+
843
+ private
844
+
845
+ def execute_agent(task)
846
+ # ... agent execution
847
+ end
848
+ end
849
+ ```
850
+
851
+ ### HTTP MCP Servers
852
+
853
+ Connect to remote tool services:
854
+
855
+ ```ruby
856
+ mcp_servers = {
857
+ 'api_tools' => ClaudeAgentSDK::McpHttpServerConfig.new(
858
+ url: ENV['MCP_SERVER_URL'],
859
+ headers: { 'Authorization' => "Bearer #{ENV['MCP_TOKEN']}" }
860
+ ).to_h
861
+ }
862
+
863
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
864
+ mcp_servers: mcp_servers,
865
+ permission_mode: 'bypassPermissions'
866
+ )
867
+ ```
868
+
869
+ For complete examples, see:
870
+ - [examples/rails_actioncable_example.rb](examples/rails_actioncable_example.rb)
871
+ - [examples/rails_background_job_example.rb](examples/rails_background_job_example.rb)
872
+ - [examples/session_resumption_example.rb](examples/session_resumption_example.rb)
873
+ - [examples/http_mcp_server_example.rb](examples/http_mcp_server_example.rb)
874
+
685
875
  ## Types
686
876
 
687
877
  See [lib/claude_agent_sdk/types.rb](lib/claude_agent_sdk/types.rb) for complete type definitions.
@@ -926,22 +1116,48 @@ See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-co
926
1116
 
927
1117
  ## Examples
928
1118
 
1119
+ ### Core Examples
1120
+
929
1121
  | Example | Description |
930
1122
  |---------|-------------|
931
1123
  | [examples/quick_start.rb](examples/quick_start.rb) | Basic `query()` usage with options |
932
1124
  | [examples/client_example.rb](examples/client_example.rb) | Interactive Client usage |
933
1125
  | [examples/streaming_input_example.rb](examples/streaming_input_example.rb) | Streaming input for multi-turn conversations |
1126
+ | [examples/session_resumption_example.rb](examples/session_resumption_example.rb) | Multi-turn conversations with session persistence |
1127
+ | [examples/structured_output_example.rb](examples/structured_output_example.rb) | JSON schema structured output |
1128
+ | [examples/error_handling_example.rb](examples/error_handling_example.rb) | Error handling with `AssistantMessage.error` |
1129
+
1130
+ ### MCP Server Examples
1131
+
1132
+ | Example | Description |
1133
+ |---------|-------------|
934
1134
  | [examples/mcp_calculator.rb](examples/mcp_calculator.rb) | Custom tools with SDK MCP servers |
935
1135
  | [examples/mcp_resources_prompts_example.rb](examples/mcp_resources_prompts_example.rb) | MCP resources and prompts |
1136
+ | [examples/http_mcp_server_example.rb](examples/http_mcp_server_example.rb) | HTTP/SSE MCP server configuration |
1137
+
1138
+ ### Hooks & Permissions
1139
+
1140
+ | Example | Description |
1141
+ |---------|-------------|
936
1142
  | [examples/hooks_example.rb](examples/hooks_example.rb) | Using hooks to control tool execution |
1143
+ | [examples/advanced_hooks_example.rb](examples/advanced_hooks_example.rb) | Typed hook inputs/outputs |
937
1144
  | [examples/permission_callback_example.rb](examples/permission_callback_example.rb) | Dynamic tool permission control |
938
- | [examples/structured_output_example.rb](examples/structured_output_example.rb) | JSON schema structured output |
1145
+
1146
+ ### Advanced Features
1147
+
1148
+ | Example | Description |
1149
+ |---------|-------------|
939
1150
  | [examples/budget_control_example.rb](examples/budget_control_example.rb) | Budget control with `max_budget_usd` |
940
1151
  | [examples/fallback_model_example.rb](examples/fallback_model_example.rb) | Fallback model configuration |
941
- | [examples/advanced_hooks_example.rb](examples/advanced_hooks_example.rb) | Typed hook inputs/outputs |
942
- | [examples/error_handling_example.rb](examples/error_handling_example.rb) | Error handling with `AssistantMessage.error` |
943
1152
  | [examples/extended_thinking_example.rb](examples/extended_thinking_example.rb) | Extended thinking (API parity) |
944
1153
 
1154
+ ### Rails Integration
1155
+
1156
+ | Example | Description |
1157
+ |---------|-------------|
1158
+ | [examples/rails_actioncable_example.rb](examples/rails_actioncable_example.rb) | ActionCable streaming to frontend |
1159
+ | [examples/rails_background_job_example.rb](examples/rails_background_job_example.rb) | Background jobs with session resumption |
1160
+
945
1161
  ## Development
946
1162
 
947
1163
  After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
@@ -30,8 +30,10 @@ module ClaudeAgentSDK
30
30
  @pending_control_responses = {}
31
31
  @pending_control_results = {}
32
32
  @hook_callbacks = {}
33
+ @hook_callback_timeouts = {}
33
34
  @next_callback_id = 0
34
35
  @request_counter = 0
36
+ @inflight_control_request_tasks = {}
35
37
 
36
38
  # Message stream
37
39
  @message_queue = Async::Queue.new
@@ -59,6 +61,7 @@ module ClaudeAgentSDK
59
61
  callback_id = "hook_#{@next_callback_id}"
60
62
  @next_callback_id += 1
61
63
  @hook_callbacks[callback_id] = callback
64
+ @hook_callback_timeouts[callback_id] = matcher[:timeout] if matcher[:timeout]
62
65
  callback_ids << callback_id
63
66
  end
64
67
  hooks_config[event] << {
@@ -103,9 +106,19 @@ module ClaudeAgentSDK
103
106
  when 'control_response'
104
107
  handle_control_response(message)
105
108
  when 'control_request'
106
- Async { handle_control_request(message) }
109
+ request_id = message[:request_id]
110
+ task = Async do
111
+ begin
112
+ handle_control_request(message)
113
+ ensure
114
+ @inflight_control_request_tasks.delete(request_id) if request_id
115
+ end
116
+ end
117
+ @inflight_control_request_tasks[request_id] = task if request_id
107
118
  when 'control_cancel_request'
108
- # TODO: Implement cancellation support
119
+ request_id = message[:request_id] || message[:requestId]
120
+ task = request_id ? @inflight_control_request_tasks[request_id] : nil
121
+ task&.stop
109
122
  next
110
123
  else
111
124
  # Regular SDK messages go to the queue
@@ -163,6 +176,17 @@ module ClaudeAgentSDK
163
176
  }
164
177
  }
165
178
  @transport.write(JSON.generate(success_response) + "\n")
179
+ rescue Async::Stop
180
+ # Cancellation requested; respond with an error so the CLI can unblock.
181
+ cancelled_response = {
182
+ type: 'control_response',
183
+ response: {
184
+ subtype: 'error',
185
+ request_id: request_id,
186
+ error: 'Cancelled'
187
+ }
188
+ }
189
+ @transport.write(JSON.generate(cancelled_response) + "\n")
166
190
  rescue StandardError => e
167
191
  # Send error response
168
192
  error_response = {
@@ -228,7 +252,17 @@ module ClaudeAgentSDK
228
252
  hook_input,
229
253
  request_data[:tool_use_id],
230
254
  context
231
- )
255
+ ) unless @hook_callback_timeouts[callback_id]
256
+
257
+ if (timeout = @hook_callback_timeouts[callback_id])
258
+ hook_output = Async::Task.current.with_timeout(timeout) do
259
+ callback.call(
260
+ hook_input,
261
+ request_data[:tool_use_id],
262
+ context
263
+ )
264
+ end
265
+ end
232
266
 
233
267
  # Convert Ruby-safe field names to CLI-expected names
234
268
  convert_hook_output_for_cli(hook_output)
@@ -236,46 +270,79 @@ module ClaudeAgentSDK
236
270
 
237
271
  def parse_hook_input(input_data)
238
272
  event_name = input_data[:hook_event_name] || input_data['hook_event_name']
273
+ fetch = ->(key) { input_data[key] || input_data[key.to_s] }
239
274
  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]
275
+ session_id: fetch.call(:session_id),
276
+ transcript_path: fetch.call(:transcript_path),
277
+ cwd: fetch.call(:cwd),
278
+ permission_mode: fetch.call(:permission_mode)
244
279
  }
245
280
 
246
281
  case event_name
247
282
  when 'PreToolUse'
248
283
  PreToolUseHookInput.new(
249
- tool_name: input_data[:tool_name],
250
- tool_input: input_data[:tool_input],
284
+ tool_name: fetch.call(:tool_name),
285
+ tool_input: fetch.call(:tool_input),
251
286
  **base_args
252
287
  )
253
288
  when 'PostToolUse'
254
289
  PostToolUseHookInput.new(
255
- tool_name: input_data[:tool_name],
256
- tool_input: input_data[:tool_input],
257
- tool_response: input_data[:tool_response],
290
+ tool_name: fetch.call(:tool_name),
291
+ tool_input: fetch.call(:tool_input),
292
+ tool_response: fetch.call(:tool_response),
293
+ **base_args
294
+ )
295
+ when 'PostToolUseFailure'
296
+ PostToolUseFailureHookInput.new(
297
+ tool_name: fetch.call(:tool_name),
298
+ tool_input: fetch.call(:tool_input),
299
+ tool_use_id: fetch.call(:tool_use_id),
300
+ error: fetch.call(:error),
301
+ is_interrupt: fetch.call(:is_interrupt),
258
302
  **base_args
259
303
  )
260
304
  when 'UserPromptSubmit'
261
305
  UserPromptSubmitHookInput.new(
262
- prompt: input_data[:prompt],
306
+ prompt: fetch.call(:prompt),
263
307
  **base_args
264
308
  )
265
309
  when 'Stop'
266
310
  StopHookInput.new(
267
- stop_hook_active: input_data[:stop_hook_active],
311
+ stop_hook_active: fetch.call(:stop_hook_active),
268
312
  **base_args
269
313
  )
270
314
  when 'SubagentStop'
271
315
  SubagentStopHookInput.new(
272
- stop_hook_active: input_data[:stop_hook_active],
316
+ stop_hook_active: fetch.call(:stop_hook_active),
317
+ agent_id: fetch.call(:agent_id),
318
+ agent_transcript_path: fetch.call(:agent_transcript_path),
319
+ agent_type: fetch.call(:agent_type),
320
+ **base_args
321
+ )
322
+ when 'Notification'
323
+ NotificationHookInput.new(
324
+ message: fetch.call(:message),
325
+ title: fetch.call(:title),
326
+ notification_type: fetch.call(:notification_type),
327
+ **base_args
328
+ )
329
+ when 'SubagentStart'
330
+ SubagentStartHookInput.new(
331
+ agent_id: fetch.call(:agent_id),
332
+ agent_type: fetch.call(:agent_type),
333
+ **base_args
334
+ )
335
+ when 'PermissionRequest'
336
+ PermissionRequestHookInput.new(
337
+ tool_name: fetch.call(:tool_name),
338
+ tool_input: fetch.call(:tool_input),
339
+ permission_suggestions: fetch.call(:permission_suggestions),
273
340
  **base_args
274
341
  )
275
342
  when 'PreCompact'
276
343
  PreCompactHookInput.new(
277
- trigger: input_data[:trigger],
278
- custom_instructions: input_data[:custom_instructions],
344
+ trigger: fetch.call(:trigger),
345
+ custom_instructions: fetch.call(:custom_instructions),
279
346
  **base_args
280
347
  )
281
348
  else
@@ -455,19 +522,14 @@ module ClaudeAgentSDK
455
522
 
456
523
  # Call the tool
457
524
  result = server.call_tool(tool_name, arguments)
525
+ content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') || []
526
+ response_data = { content: content }
458
527
 
459
- # Format response
460
- content = []
461
- if result[:content]
462
- result[:content].each do |item|
463
- if item[:type] == 'text'
464
- content << { type: 'text', text: item[:text] }
465
- end
466
- end
467
- end
528
+ is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error')
529
+ response_data[:isError] = !!is_error unless is_error.nil?
468
530
 
469
- response_data = { content: content }
470
- response_data[:is_error] = true if result[:is_error]
531
+ structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content')
532
+ response_data[:structuredContent] = structured_content unless structured_content.nil?
471
533
 
472
534
  {
473
535
  jsonrpc: '2.0',
@@ -530,6 +592,12 @@ module ClaudeAgentSDK
530
592
 
531
593
  public
532
594
 
595
+ # Get current MCP server connection status (only works with streaming mode)
596
+ # @return [Hash] MCP status information, including mcpServers list
597
+ def get_mcp_status
598
+ send_control_request({ subtype: 'mcp_status' })
599
+ end
600
+
533
601
  # Send interrupt control request
534
602
  def interrupt
535
603
  send_control_request({ subtype: 'interrupt' })
@@ -172,15 +172,19 @@ module ClaudeAgentSDK
172
172
  # Filter out server_context and pass remaining args to handler
173
173
  result = @tool_def.handler.call(args)
174
174
 
175
- # Convert result to MCP::Tool::Response format
176
- content = result[:content].map do |item|
177
- {
178
- type: item[:type],
179
- text: item[:text]
180
- }
175
+ content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
176
+ unless result.is_a?(Hash) && content
177
+ raise "Tool '#{@tool_def.name}' must return a hash with :content key"
181
178
  end
182
179
 
183
- MCP::Tool::Response.new(content)
180
+ is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error')
181
+ structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content')
182
+
183
+ MCP::Tool::Response.new(
184
+ content,
185
+ error: !!is_error,
186
+ structured_content: structured_content
187
+ )
184
188
  end
185
189
 
186
190
  private
@@ -64,10 +64,17 @@ module ClaudeAgentSDK
64
64
  if @options.system_prompt
65
65
  if @options.system_prompt.is_a?(String)
66
66
  cmd.concat(['--system-prompt', @options.system_prompt])
67
- elsif @options.system_prompt.is_a?(Hash) &&
68
- @options.system_prompt[:type] == 'preset' &&
69
- @options.system_prompt[:append]
70
- cmd.concat(['--append-system-prompt', @options.system_prompt[:append]])
67
+ elsif @options.system_prompt.is_a?(SystemPromptPreset)
68
+ cmd.concat(['--system-prompt-preset', @options.system_prompt.preset]) if @options.system_prompt.preset
69
+ cmd.concat(['--append-system-prompt', @options.system_prompt.append]) if @options.system_prompt.append
70
+ elsif @options.system_prompt.is_a?(Hash)
71
+ prompt_type = @options.system_prompt[:type] || @options.system_prompt['type']
72
+ if prompt_type == 'preset'
73
+ preset = @options.system_prompt[:preset] || @options.system_prompt['preset']
74
+ append = @options.system_prompt[:append] || @options.system_prompt['append']
75
+ cmd.concat(['--system-prompt-preset', preset]) if preset
76
+ cmd.concat(['--append-system-prompt', append]) if append
77
+ end
71
78
  end
72
79
  end
73
80
 
@@ -246,10 +253,10 @@ module ClaudeAgentSDK
246
253
  cmd = build_command
247
254
 
248
255
  # Build environment
249
- process_env = ENV.to_h.merge(@options.env).merge(
250
- 'CLAUDE_CODE_ENTRYPOINT' => 'sdk-rb',
251
- 'CLAUDE_AGENT_SDK_VERSION' => VERSION
252
- )
256
+ # Convert symbol keys to strings for spawn compatibility
257
+ custom_env = @options.env.transform_keys { |k| k.to_s }
258
+ process_env = ENV.to_h.merge('CLAUDE_AGENT_SDK_VERSION' => VERSION).merge(custom_env)
259
+ process_env['CLAUDE_CODE_ENTRYPOINT'] ||= 'sdk-rb'
253
260
  process_env['PWD'] = @cwd.to_s if @cwd
254
261
 
255
262
  # Determine stderr handling
@@ -446,7 +453,8 @@ module ClaudeAgentSDK
446
453
 
447
454
  def check_claude_version
448
455
  begin
449
- output = `#{@cli_path} -v 2>&1`.strip
456
+ stdout, stderr, = Open3.capture3(@cli_path.to_s, '-v')
457
+ output = (stdout.to_s + stderr.to_s).strip
450
458
  if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
451
459
  version = match[1]
452
460
  version_parts = version.split('.').map(&:to_i)
@@ -14,7 +14,18 @@ module ClaudeAgentSDK
14
14
  PERMISSION_BEHAVIORS = %w[allow deny ask].freeze
15
15
 
16
16
  # Type constants for hook events
17
- HOOK_EVENTS = %w[PreToolUse PostToolUse UserPromptSubmit Stop SubagentStop PreCompact].freeze
17
+ HOOK_EVENTS = %w[
18
+ PreToolUse
19
+ PostToolUse
20
+ PostToolUseFailure
21
+ UserPromptSubmit
22
+ Stop
23
+ SubagentStop
24
+ PreCompact
25
+ Notification
26
+ SubagentStart
27
+ PermissionRequest
28
+ ].freeze
18
29
 
19
30
  # Type constants for assistant message errors
20
31
  ASSISTANT_MESSAGE_ERRORS = %w[authentication_failed billing_error rate_limit invalid_request server_error unknown].freeze
@@ -305,12 +316,71 @@ module ClaudeAgentSDK
305
316
 
306
317
  # SubagentStop hook input
307
318
  class SubagentStopHookInput < BaseHookInput
308
- attr_accessor :hook_event_name, :stop_hook_active
319
+ attr_accessor :hook_event_name, :stop_hook_active, :agent_id, :agent_transcript_path, :agent_type
309
320
 
310
- def initialize(hook_event_name: 'SubagentStop', stop_hook_active: false, **base_args)
321
+ def initialize(hook_event_name: 'SubagentStop', stop_hook_active: false, agent_id: nil,
322
+ agent_transcript_path: nil, agent_type: nil, **base_args)
311
323
  super(**base_args)
312
324
  @hook_event_name = hook_event_name
313
325
  @stop_hook_active = stop_hook_active
326
+ @agent_id = agent_id
327
+ @agent_transcript_path = agent_transcript_path
328
+ @agent_type = agent_type
329
+ end
330
+ end
331
+
332
+ # PostToolUseFailure hook input
333
+ class PostToolUseFailureHookInput < BaseHookInput
334
+ attr_accessor :hook_event_name, :tool_name, :tool_input, :tool_use_id, :error, :is_interrupt
335
+
336
+ def initialize(hook_event_name: 'PostToolUseFailure', tool_name: nil, tool_input: nil, tool_use_id: nil,
337
+ error: nil, is_interrupt: nil, **base_args)
338
+ super(**base_args)
339
+ @hook_event_name = hook_event_name
340
+ @tool_name = tool_name
341
+ @tool_input = tool_input
342
+ @tool_use_id = tool_use_id
343
+ @error = error
344
+ @is_interrupt = is_interrupt
345
+ end
346
+ end
347
+
348
+ # Notification hook input
349
+ class NotificationHookInput < BaseHookInput
350
+ attr_accessor :hook_event_name, :message, :title, :notification_type
351
+
352
+ def initialize(hook_event_name: 'Notification', message: nil, title: nil, notification_type: nil, **base_args)
353
+ super(**base_args)
354
+ @hook_event_name = hook_event_name
355
+ @message = message
356
+ @title = title
357
+ @notification_type = notification_type
358
+ end
359
+ end
360
+
361
+ # SubagentStart hook input
362
+ class SubagentStartHookInput < BaseHookInput
363
+ attr_accessor :hook_event_name, :agent_id, :agent_type
364
+
365
+ def initialize(hook_event_name: 'SubagentStart', agent_id: nil, agent_type: nil, **base_args)
366
+ super(**base_args)
367
+ @hook_event_name = hook_event_name
368
+ @agent_id = agent_id
369
+ @agent_type = agent_type
370
+ end
371
+ end
372
+
373
+ # PermissionRequest hook input
374
+ class PermissionRequestHookInput < BaseHookInput
375
+ attr_accessor :hook_event_name, :tool_name, :tool_input, :permission_suggestions
376
+
377
+ def initialize(hook_event_name: 'PermissionRequest', tool_name: nil, tool_input: nil, permission_suggestions: nil,
378
+ **base_args)
379
+ super(**base_args)
380
+ @hook_event_name = hook_event_name
381
+ @tool_name = tool_name
382
+ @tool_input = tool_input
383
+ @permission_suggestions = permission_suggestions
314
384
  end
315
385
  end
316
386
 
@@ -348,10 +418,28 @@ module ClaudeAgentSDK
348
418
 
349
419
  # PostToolUse hook specific output
350
420
  class PostToolUseHookSpecificOutput
421
+ attr_accessor :hook_event_name, :additional_context, :updated_mcp_tool_output
422
+
423
+ def initialize(additional_context: nil, updated_mcp_tool_output: nil)
424
+ @hook_event_name = 'PostToolUse'
425
+ @additional_context = additional_context
426
+ @updated_mcp_tool_output = updated_mcp_tool_output
427
+ end
428
+
429
+ def to_h
430
+ result = { hookEventName: @hook_event_name }
431
+ result[:additionalContext] = @additional_context if @additional_context
432
+ result[:updatedMCPToolOutput] = @updated_mcp_tool_output if @updated_mcp_tool_output
433
+ result
434
+ end
435
+ end
436
+
437
+ # PostToolUseFailure hook specific output
438
+ class PostToolUseFailureHookSpecificOutput
351
439
  attr_accessor :hook_event_name, :additional_context
352
440
 
353
441
  def initialize(additional_context: nil)
354
- @hook_event_name = 'PostToolUse'
442
+ @hook_event_name = 'PostToolUseFailure'
355
443
  @additional_context = additional_context
356
444
  end
357
445
 
@@ -378,6 +466,54 @@ module ClaudeAgentSDK
378
466
  end
379
467
  end
380
468
 
469
+ # Notification hook specific output
470
+ class NotificationHookSpecificOutput
471
+ attr_accessor :hook_event_name, :additional_context
472
+
473
+ def initialize(additional_context: nil)
474
+ @hook_event_name = 'Notification'
475
+ @additional_context = additional_context
476
+ end
477
+
478
+ def to_h
479
+ result = { hookEventName: @hook_event_name }
480
+ result[:additionalContext] = @additional_context if @additional_context
481
+ result
482
+ end
483
+ end
484
+
485
+ # SubagentStart hook specific output
486
+ class SubagentStartHookSpecificOutput
487
+ attr_accessor :hook_event_name, :additional_context
488
+
489
+ def initialize(additional_context: nil)
490
+ @hook_event_name = 'SubagentStart'
491
+ @additional_context = additional_context
492
+ end
493
+
494
+ def to_h
495
+ result = { hookEventName: @hook_event_name }
496
+ result[:additionalContext] = @additional_context if @additional_context
497
+ result
498
+ end
499
+ end
500
+
501
+ # PermissionRequest hook specific output
502
+ class PermissionRequestHookSpecificOutput
503
+ attr_accessor :hook_event_name, :decision
504
+
505
+ def initialize(decision: nil)
506
+ @hook_event_name = 'PermissionRequest'
507
+ @decision = decision
508
+ end
509
+
510
+ def to_h
511
+ result = { hookEventName: @hook_event_name }
512
+ result[:decision] = @decision if @decision
513
+ result
514
+ end
515
+ end
516
+
381
517
  # SessionStart hook specific output
382
518
  class SessionStartHookSpecificOutput
383
519
  attr_accessor :hook_event_name, :additional_context
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.2'
5
5
  end
@@ -14,6 +14,16 @@ require 'securerandom'
14
14
 
15
15
  # Claude Agent SDK for Ruby
16
16
  module ClaudeAgentSDK
17
+ # Look up a value in a hash that may use symbol or string keys in camelCase or snake_case.
18
+ # Returns the first non-nil value found, preserving false as a meaningful value.
19
+ def self.flexible_fetch(hash, camel_key, snake_key)
20
+ val = hash[camel_key.to_sym]
21
+ val = hash[camel_key.to_s] if val.nil?
22
+ val = hash[snake_key.to_sym] if val.nil?
23
+ val = hash[snake_key.to_s] if val.nil?
24
+ val
25
+ end
26
+
17
27
  # Query Claude Code for one-shot or unidirectional streaming interactions
18
28
  #
19
29
  # This function is ideal for simple, stateless queries where you don't need
@@ -51,7 +61,7 @@ module ClaudeAgentSDK
51
61
  return enum_for(:query, prompt: prompt, options: options) unless block
52
62
 
53
63
  options ||= ClaudeAgentOptions.new
54
- ENV['CLAUDE_CODE_ENTRYPOINT'] = 'sdk-rb'
64
+ options = options.dup_with(env: (options.env || {}).merge('CLAUDE_CODE_ENTRYPOINT' => 'sdk-rb'))
55
65
 
56
66
  Async do
57
67
  transport = SubprocessCLITransport.new(prompt, options)
@@ -126,36 +136,37 @@ module ClaudeAgentSDK
126
136
  @transport = nil
127
137
  @query_handler = nil
128
138
  @connected = false
129
- ENV['CLAUDE_CODE_ENTRYPOINT'] = 'sdk-rb-client'
130
139
  end
131
140
 
132
- # Connect to Claude with optional initial prompt
141
+ # Connect to Claude with optional initial prompt.
142
+ #
143
+ # Client always uses streaming mode for bidirectional communication. If you
144
+ # pass a String, it will be sent as an initial user message after the
145
+ # connection is established. If you pass an Enumerator, it should yield
146
+ # JSONL messages (e.g., from ClaudeAgentSDK::Streaming.user_message).
147
+ #
133
148
  # @param prompt [String, Enumerator, nil] Initial prompt or message stream
134
149
  def connect(prompt = nil)
135
150
  return if @connected
136
151
 
152
+ raise ArgumentError, "prompt must be a String, an Enumerator, or nil (got #{prompt.class})" unless prompt.nil? || prompt.is_a?(String) || prompt.respond_to?(:each)
153
+
137
154
  # Validate and configure permission settings
138
155
  configured_options = @options
139
156
  if @options.can_use_tool
140
- # can_use_tool requires streaming mode
141
- if prompt.is_a?(String)
142
- raise ArgumentError, 'can_use_tool callback requires streaming mode'
143
- end
144
-
145
157
  # can_use_tool and permission_prompt_tool_name are mutually exclusive
146
- if @options.permission_prompt_tool_name
147
- raise ArgumentError, 'can_use_tool callback cannot be used with permission_prompt_tool_name'
148
- end
158
+ raise ArgumentError, 'can_use_tool callback cannot be used with permission_prompt_tool_name' if @options.permission_prompt_tool_name
149
159
 
150
160
  # Set permission_prompt_tool_name to stdio for control protocol
151
161
  configured_options = @options.dup_with(permission_prompt_tool_name: 'stdio')
152
162
  end
153
163
 
154
- # Auto-connect with empty enumerator if no prompt is provided
155
- # This matches the Python SDK pattern where ClaudeSDKClient always uses streaming mode
156
- # An empty enumerator keeps stdin open for bidirectional communication
157
- actual_prompt = prompt || [].to_enum
158
- @transport = SubprocessCLITransport.new(actual_prompt, configured_options)
164
+ configured_options = configured_options.dup_with(
165
+ env: (configured_options.env || {}).merge('CLAUDE_CODE_ENTRYPOINT' => 'sdk-rb-client')
166
+ )
167
+
168
+ # Client always uses streaming mode; keep stdin open for bidirectional communication.
169
+ @transport = SubprocessCLITransport.new([].to_enum, configured_options)
159
170
  @transport.connect
160
171
 
161
172
  # Extract SDK MCP servers
@@ -183,6 +194,20 @@ module ClaudeAgentSDK
183
194
  @query_handler.initialize_protocol
184
195
 
185
196
  @connected = true
197
+
198
+ # Optionally send initial prompt/messages after connection is ready.
199
+ case prompt
200
+ when nil
201
+ nil
202
+ when String
203
+ query(prompt)
204
+ else
205
+ prompt.each do |message_json|
206
+ message_json = message_json.to_s
207
+ message_json += "\n" unless message_json.end_with?("\n")
208
+ @transport.write(message_json)
209
+ end
210
+ end
186
211
  end
187
212
 
188
213
  # Send a query to Claude
@@ -259,6 +284,20 @@ module ClaudeAgentSDK
259
284
  @query_handler&.instance_variable_get(:@initialization_result)
260
285
  end
261
286
 
287
+ # Get current MCP server connection status (only works with streaming mode)
288
+ # @return [Hash] MCP status information, including mcpServers list
289
+ def get_mcp_status
290
+ raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
291
+ @query_handler.get_mcp_status
292
+ end
293
+
294
+ # Get server initialization info including available commands and output styles
295
+ # @return [Hash] Server info
296
+ def get_server_info
297
+ raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
298
+ server_info
299
+ end
300
+
262
301
  # Disconnect from Claude
263
302
  def disconnect
264
303
  return unless @connected
@@ -278,10 +317,12 @@ module ClaudeAgentSDK
278
317
  hooks.each do |event, matchers|
279
318
  internal_hooks[event.to_s] = []
280
319
  matchers.each do |matcher|
281
- internal_hooks[event.to_s] << {
320
+ config = {
282
321
  matcher: matcher.matcher,
283
322
  hooks: matcher.hooks
284
323
  }
324
+ config[:timeout] = matcher.timeout if matcher.timeout
325
+ internal_hooks[event.to_s] << config
285
326
  end
286
327
  end
287
328
  internal_hooks
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-01-05 00:00:00.000000000 Z
10
+ date: 2026-02-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: async