claude_agent 0.1.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.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Controller for aborting operations (TypeScript SDK parity)
5
+ #
6
+ # Provides a Ruby-idiomatic way to cancel ongoing SDK operations.
7
+ # Similar to JavaScript's AbortController pattern.
8
+ #
9
+ # @example Basic usage
10
+ # controller = AbortController.new
11
+ #
12
+ # Thread.new { sleep(5); controller.abort("Timeout") }
13
+ #
14
+ # ClaudeAgent.query(
15
+ # prompt: "Long running task",
16
+ # options: Options.new(abort_controller: controller)
17
+ # )
18
+ #
19
+ # @example With abort reason
20
+ # controller.abort("User cancelled")
21
+ # controller.signal.aborted? # => true
22
+ # controller.signal.reason # => "User cancelled"
23
+ #
24
+ class AbortController
25
+ attr_reader :signal
26
+
27
+ def initialize
28
+ @signal = AbortSignal.new
29
+ end
30
+
31
+ # Abort the operation
32
+ # @param reason [String, nil] Reason for aborting
33
+ # @return [void]
34
+ def abort(reason = nil)
35
+ @signal.abort!(reason)
36
+ end
37
+ end
38
+
39
+ # Signal object that tracks abort state (TypeScript SDK parity)
40
+ #
41
+ # Thread-safe signal that can be checked by multiple consumers
42
+ # and triggers callbacks when aborted.
43
+ #
44
+ class AbortSignal
45
+ def initialize
46
+ @aborted = false
47
+ @reason = nil
48
+ @mutex = Mutex.new
49
+ @condition = ConditionVariable.new
50
+ @callbacks = []
51
+ end
52
+
53
+ # Check if signal has been aborted
54
+ # @return [Boolean]
55
+ def aborted?
56
+ @mutex.synchronize { @aborted }
57
+ end
58
+
59
+ # Get the abort reason
60
+ # @return [String, nil]
61
+ def reason
62
+ @mutex.synchronize { @reason }
63
+ end
64
+
65
+ # Register a callback for when abort is triggered
66
+ # @yield [reason] Called when abort occurs
67
+ # @return [void]
68
+ def on_abort(&block)
69
+ @mutex.synchronize do
70
+ if @aborted
71
+ block.call(@reason)
72
+ else
73
+ @callbacks << block
74
+ end
75
+ end
76
+ end
77
+
78
+ # Wait until aborted (with optional timeout)
79
+ # @param timeout [Numeric, nil] Timeout in seconds
80
+ # @return [Boolean] True if aborted, false if timed out
81
+ def wait(timeout: nil)
82
+ @mutex.synchronize do
83
+ return true if @aborted
84
+
85
+ @condition.wait(@mutex, timeout)
86
+ @aborted
87
+ end
88
+ end
89
+
90
+ # Raise AbortError if aborted (for checking in loops)
91
+ # @raise [AbortError] If signal has been aborted
92
+ def check!
93
+ raise AbortError, reason if aborted?
94
+ end
95
+
96
+ # @api private
97
+ # Trigger the abort
98
+ # @param reason [String, nil] Reason for aborting
99
+ def abort!(reason = nil)
100
+ callbacks_to_call = []
101
+ @mutex.synchronize do
102
+ return if @aborted
103
+
104
+ @aborted = true
105
+ @reason = reason || "Operation was aborted"
106
+ callbacks_to_call = @callbacks.dup
107
+ @callbacks.clear
108
+ @condition.broadcast
109
+ end
110
+ callbacks_to_call.each { |cb| cb.call(@reason) }
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Interactive, bidirectional client for Claude Code CLI
5
+ #
6
+ # Unlike {ClaudeAgent.query}, the Client provides:
7
+ # - Multiple conversation turns
8
+ # - Streaming responses
9
+ # - Ability to interrupt operations
10
+ # - Dynamic permission and model changes
11
+ # - File checkpointing and rewind
12
+ #
13
+ # @example Basic usage
14
+ # client = ClaudeAgent::Client.new
15
+ # client.connect
16
+ # client.send_message("Hello!")
17
+ # client.receive_response.each { |msg| puts msg }
18
+ # client.disconnect
19
+ #
20
+ # @example With block (auto-disconnect)
21
+ # ClaudeAgent::Client.open do |client|
22
+ # client.send_message("Help me write a function")
23
+ # client.receive_response.each { |msg| puts msg }
24
+ #
25
+ # client.send_message("Now add tests")
26
+ # client.receive_response.each { |msg| puts msg }
27
+ # end
28
+ #
29
+ # @example With initial prompt
30
+ # ClaudeAgent::Client.open(prompt: "You are a helpful coding assistant") do |client|
31
+ # client.receive_response.each { |msg| puts msg }
32
+ # end
33
+ #
34
+ class Client
35
+ attr_reader :options, :transport, :server_info
36
+
37
+ # Open a client with automatic cleanup
38
+ #
39
+ # @param options [Options, nil] Configuration options
40
+ # @param transport [Transport::Base, nil] Custom transport
41
+ # @param prompt [String, nil] Initial prompt
42
+ # @yield [Client] Connected client
43
+ # @return [Object] Result of block
44
+ def self.open(options: nil, transport: nil, prompt: nil)
45
+ client = new(options: options, transport: transport)
46
+ begin
47
+ client.connect(prompt: prompt)
48
+ yield client
49
+ ensure
50
+ client.disconnect
51
+ end
52
+ end
53
+
54
+ # Create a new client
55
+ #
56
+ # @param options [Options, nil] Configuration options
57
+ # @param transport [Transport::Base, nil] Custom transport (default: Subprocess)
58
+ def initialize(options: nil, transport: nil)
59
+ @options = options || Options.new
60
+ @transport = transport || Transport::Subprocess.new(options: @options)
61
+ @protocol = nil
62
+ @server_info = nil
63
+ @connected = false
64
+ end
65
+
66
+ # Connect to the CLI
67
+ #
68
+ # @param prompt [String, nil] Initial prompt to send
69
+ # @return [void]
70
+ def connect(prompt: nil)
71
+ raise CLIConnectionError, "Already connected" if @connected
72
+
73
+ ENV["CLAUDE_CODE_ENTRYPOINT"] = "sdk-rb-client"
74
+
75
+ @protocol = ControlProtocol.new(transport: @transport, options: @options)
76
+ @server_info = @protocol.start(streaming: true)
77
+ @connected = true
78
+
79
+ send_message(prompt) if prompt
80
+ end
81
+
82
+ # Disconnect from the CLI
83
+ #
84
+ # @return [void]
85
+ def disconnect
86
+ return unless @connected
87
+
88
+ @protocol&.stop
89
+ @protocol = nil
90
+ @connected = false
91
+ end
92
+
93
+ # Check if client is connected
94
+ #
95
+ # @return [Boolean]
96
+ def connected?
97
+ @connected
98
+ end
99
+
100
+ # Send a message to Claude
101
+ #
102
+ # @param content [String, Array] Message content
103
+ # @param session_id [String] Session ID (for multi-session support)
104
+ # @param uuid [String, nil] Message UUID for file checkpointing
105
+ # @return [void]
106
+ def send_message(content, session_id: "default", uuid: nil)
107
+ require_connection!
108
+ @protocol.send_user_message(content, session_id: session_id, uuid: uuid)
109
+ end
110
+
111
+ # Alias for send_message
112
+ alias_method :query, :send_message
113
+
114
+ # Receive all messages (blocks until connection closes)
115
+ #
116
+ # @yield [Message] Received messages
117
+ # @return [Enumerator<Message>] If no block given
118
+ def receive_messages(&block)
119
+ require_connection!
120
+
121
+ @protocol.each_message(&block)
122
+ end
123
+
124
+ # Receive messages until a ResultMessage is received
125
+ #
126
+ # @yield [Message] Received messages
127
+ # @return [Enumerator<Message>] If no block given
128
+ def receive_response(&block)
129
+ require_connection!
130
+
131
+ @protocol.receive_response(&block)
132
+ end
133
+
134
+ # Stream user input from an enumerable (TypeScript SDK parity)
135
+ #
136
+ # Sends each message from the input stream to Claude. When a block is given,
137
+ # messages are sent in a background thread while responses are yielded.
138
+ #
139
+ # @param stream [Enumerable] Input stream of messages (strings, hashes, or UserMessage)
140
+ # @param session_id [String] Default session ID for messages
141
+ # @yield [Message] Received messages (if block given)
142
+ # @return [void]
143
+ # @raise [CLIConnectionError] If not connected
144
+ # @raise [AbortError] If abort signal is triggered
145
+ #
146
+ # @example Without block (just send messages)
147
+ # client.stream_input(["Hello", "How are you?"])
148
+ # client.receive_response.each { |msg| puts msg }
149
+ #
150
+ # @example With block (concurrent send/receive)
151
+ # client.stream_input(["Hello", "Follow up"]) do |msg|
152
+ # case msg
153
+ # when ClaudeAgent::AssistantMessage
154
+ # puts msg.text
155
+ # when ClaudeAgent::ResultMessage
156
+ # puts "Done!"
157
+ # end
158
+ # end
159
+ #
160
+ def stream_input(stream, session_id: "default", &block)
161
+ require_connection!
162
+
163
+ if block_given?
164
+ @protocol.stream_conversation(stream, session_id: session_id, &block)
165
+ else
166
+ @protocol.stream_input(stream, session_id: session_id)
167
+ end
168
+ end
169
+
170
+ # Interrupt the current operation
171
+ #
172
+ # @return [void]
173
+ def interrupt
174
+ require_connection!
175
+
176
+ @protocol.interrupt
177
+ end
178
+
179
+ # Abort all pending operations (TypeScript SDK parity)
180
+ #
181
+ # This method:
182
+ # 1. Triggers the abort controller (if configured)
183
+ # 2. Aborts the protocol and terminates the transport
184
+ #
185
+ # @param reason [String, nil] Reason for aborting
186
+ # @return [void]
187
+ def abort!(reason = nil)
188
+ return unless @connected
189
+
190
+ @options.abort_controller&.abort(reason)
191
+ @protocol&.abort!
192
+ end
193
+
194
+ # Change the permission mode
195
+ #
196
+ # @param mode [String] New permission mode
197
+ # @return [Hash] Response
198
+ def set_permission_mode(mode)
199
+ require_connection!
200
+
201
+ @protocol.set_permission_mode(mode)
202
+ end
203
+
204
+ # Change the model
205
+ #
206
+ # @param model [String, nil] New model name (nil to use default)
207
+ # @return [Hash] Response
208
+ def set_model(model)
209
+ require_connection!
210
+
211
+ @protocol.set_model(model)
212
+ end
213
+
214
+ # Rewind files to the state at a specific user message
215
+ #
216
+ # @param user_message_id [String] UUID of the user message to rewind to
217
+ # @param dry_run [Boolean] If true, preview changes without modifying files
218
+ # @return [RewindFilesResult] Result with rewind information
219
+ def rewind_files(user_message_id, dry_run: false)
220
+ require_connection!
221
+
222
+ @protocol.rewind_files(user_message_id, dry_run: dry_run)
223
+ end
224
+
225
+ # Set maximum thinking tokens (TypeScript SDK parity)
226
+ #
227
+ # @param tokens [Integer, nil] Max thinking tokens (nil to reset)
228
+ # @return [Hash] Response
229
+ def set_max_thinking_tokens(tokens)
230
+ require_connection!
231
+
232
+ @protocol.set_max_thinking_tokens(tokens)
233
+ end
234
+
235
+ # Get available slash commands (TypeScript SDK parity)
236
+ #
237
+ # @return [Array<SlashCommand>]
238
+ def supported_commands
239
+ require_connection!
240
+
241
+ @protocol.supported_commands
242
+ end
243
+
244
+ # Get available models (TypeScript SDK parity)
245
+ #
246
+ # @return [Array<ModelInfo>]
247
+ def supported_models
248
+ require_connection!
249
+
250
+ @protocol.supported_models
251
+ end
252
+
253
+ # Get MCP server status (TypeScript SDK parity)
254
+ #
255
+ # @return [Array<McpServerStatus>]
256
+ def mcp_server_status
257
+ require_connection!
258
+
259
+ @protocol.mcp_server_status
260
+ end
261
+
262
+ # Get account information (TypeScript SDK parity)
263
+ #
264
+ # @return [AccountInfo]
265
+ def account_info
266
+ require_connection!
267
+
268
+ @protocol.account_info
269
+ end
270
+
271
+ # Dynamically set MCP servers for this session (TypeScript SDK parity)
272
+ #
273
+ # This replaces the current set of dynamically-added MCP servers.
274
+ # Servers that are removed will be disconnected, and new servers will be connected.
275
+ #
276
+ # @param servers [Hash] Map of server name to configuration
277
+ # @return [McpSetServersResult] Result with added, removed, and errors
278
+ #
279
+ # @example
280
+ # result = client.set_mcp_servers({
281
+ # "my-server" => { type: "stdio", command: "node", args: ["server.js"] }
282
+ # })
283
+ # puts "Added: #{result.added}"
284
+ # puts "Removed: #{result.removed}"
285
+ #
286
+ def set_mcp_servers(servers)
287
+ require_connection!
288
+
289
+ @protocol.set_mcp_servers(servers)
290
+ end
291
+
292
+ private
293
+
294
+ def require_connection!
295
+ raise CLIConnectionError, "Not connected" unless @connected
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Text content block
5
+ #
6
+ # @example
7
+ # block = TextBlock.new(text: "Hello, world!")
8
+ # block.text # => "Hello, world!"
9
+ #
10
+ TextBlock = Data.define(:text) do
11
+ def type
12
+ :text
13
+ end
14
+
15
+ def to_h
16
+ { type: "text", text: text }
17
+ end
18
+ end
19
+
20
+ # Extended thinking content block
21
+ #
22
+ # @example
23
+ # block = ThinkingBlock.new(thinking: "Let me consider...", signature: "abc123")
24
+ # block.thinking # => "Let me consider..."
25
+ #
26
+ ThinkingBlock = Data.define(:thinking, :signature) do
27
+ def type
28
+ :thinking
29
+ end
30
+
31
+ def to_h
32
+ { type: "thinking", thinking: thinking, signature: signature }
33
+ end
34
+ end
35
+
36
+ # Tool use request block
37
+ #
38
+ # @example
39
+ # block = ToolUseBlock.new(id: "tool_123", name: "Read", input: {file_path: "/tmp/file"})
40
+ # block.name # => "Read"
41
+ #
42
+ ToolUseBlock = Data.define(:id, :name, :input) do
43
+ def type
44
+ :tool_use
45
+ end
46
+
47
+ def to_h
48
+ { type: "tool_use", id: id, name: name, input: input }
49
+ end
50
+ end
51
+
52
+ # Tool result block
53
+ #
54
+ # @example
55
+ # block = ToolResultBlock.new(tool_use_id: "tool_123", content: "file contents", is_error: false)
56
+ #
57
+ ToolResultBlock = Data.define(:tool_use_id, :content, :is_error) do
58
+ def initialize(tool_use_id:, content: nil, is_error: nil)
59
+ super
60
+ end
61
+
62
+ def type
63
+ :tool_result
64
+ end
65
+
66
+ def to_h
67
+ h = { type: "tool_result", tool_use_id: tool_use_id }
68
+ h[:content] = content unless content.nil?
69
+ h[:is_error] = is_error unless is_error.nil?
70
+ h
71
+ end
72
+ end
73
+
74
+ # Server tool use block (for MCP servers)
75
+ #
76
+ ServerToolUseBlock = Data.define(:id, :name, :input, :server_name) do
77
+ def type
78
+ :server_tool_use
79
+ end
80
+
81
+ def to_h
82
+ { type: "server_tool_use", id: id, name: name, input: input, server_name: server_name }
83
+ end
84
+ end
85
+
86
+ # Server tool result block
87
+ #
88
+ ServerToolResultBlock = Data.define(:tool_use_id, :content, :is_error, :server_name) do
89
+ def initialize(tool_use_id:, server_name:, content: nil, is_error: nil)
90
+ super
91
+ end
92
+
93
+ def type
94
+ :server_tool_result
95
+ end
96
+
97
+ def to_h
98
+ h = { type: "server_tool_result", tool_use_id: tool_use_id, server_name: server_name }
99
+ h[:content] = content unless content.nil?
100
+ h[:is_error] = is_error unless is_error.nil?
101
+ h
102
+ end
103
+ end
104
+
105
+ # Image content block (TypeScript SDK parity)
106
+ #
107
+ # Supports both base64-encoded image data and URL sources.
108
+ #
109
+ # @example Base64 image
110
+ # block = ImageContentBlock.new(
111
+ # source: { type: "base64", media_type: "image/png", data: "..." }
112
+ # )
113
+ #
114
+ # @example URL image
115
+ # block = ImageContentBlock.new(
116
+ # source: { type: "url", url: "https://example.com/image.png" }
117
+ # )
118
+ #
119
+ ImageContentBlock = Data.define(:source) do
120
+ def type
121
+ :image
122
+ end
123
+
124
+ # Get the media type if available
125
+ # @return [String, nil]
126
+ def media_type
127
+ source.is_a?(Hash) ? (source[:media_type] || source["media_type"]) : nil
128
+ end
129
+
130
+ # Get the base64 data if available
131
+ # @return [String, nil]
132
+ def data
133
+ source.is_a?(Hash) ? (source[:data] || source["data"]) : nil
134
+ end
135
+
136
+ # Get the URL if this is a URL-sourced image
137
+ # @return [String, nil]
138
+ def url
139
+ source.is_a?(Hash) ? (source[:url] || source["url"]) : nil
140
+ end
141
+
142
+ # Get the source type (base64 or url)
143
+ # @return [String, nil]
144
+ def source_type
145
+ source.is_a?(Hash) ? (source[:type] || source["type"]) : nil
146
+ end
147
+
148
+ def to_h
149
+ { type: "image", source: source }
150
+ end
151
+ end
152
+
153
+ # All content block types
154
+ CONTENT_BLOCK_TYPES = [
155
+ TextBlock,
156
+ ThinkingBlock,
157
+ ToolUseBlock,
158
+ ToolResultBlock,
159
+ ServerToolUseBlock,
160
+ ServerToolResultBlock,
161
+ ImageContentBlock
162
+ ].freeze
163
+ end