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,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "timeout"
6
+
7
+ module ClaudeAgent
8
+ module Transport
9
+ # Subprocess transport that communicates with Claude Code CLI
10
+ #
11
+ # This transport spawns the Claude Code CLI as a subprocess and
12
+ # communicates via stdin/stdout using JSON Lines protocol.
13
+ #
14
+ # @example Basic usage
15
+ # transport = ClaudeAgent::Transport::Subprocess.new(options)
16
+ # transport.connect
17
+ # transport.write('{"type":"user","message":{"role":"user","content":"Hello"}}')
18
+ # transport.read_messages { |msg| puts msg }
19
+ # transport.close
20
+ #
21
+ class Subprocess < Base
22
+ MINIMUM_CLI_VERSION = "2.0.0"
23
+ DEFAULT_BUFFER_SIZE = 1_048_576 # 1MB
24
+ VERSION_CHECK_TIMEOUT = 2
25
+
26
+ attr_reader :options, :cli_path, :process
27
+
28
+ # @param options [ClaudeAgent::Options, nil] Configuration options
29
+ # @param cli_path [String, nil] Override path to Claude CLI
30
+ def initialize(options: nil, cli_path: nil)
31
+ super()
32
+ @options = options || Options.new
33
+ @cli_path = cli_path || @options.cli_path || find_cli_path
34
+ @stdin = nil
35
+ @stdout = nil
36
+ @stderr = nil
37
+ @wait_thread = nil
38
+ @process = nil # Custom spawned process (SpawnedProcess)
39
+ @connected = false
40
+ @killed = false
41
+ @buffer = +""
42
+ @max_buffer_size = @options.max_buffer_size || DEFAULT_BUFFER_SIZE
43
+ @mutex = Mutex.new
44
+ end
45
+
46
+ # Start the CLI subprocess
47
+ # @param streaming [Boolean] Whether to use streaming mode
48
+ # @param prompt [String, nil] Initial prompt for non-streaming mode
49
+ # @return [void]
50
+ def connect(streaming: true, prompt: nil)
51
+ raise CLIConnectionError, "Already connected" if @connected
52
+
53
+ check_cli_version! unless skip_version_check?
54
+
55
+ cmd = build_command(streaming: streaming, prompt: prompt)
56
+ env = @options.to_env
57
+
58
+ # Build spawn options for custom spawn function support
59
+ spawn_options = SpawnOptions.new(
60
+ command: @cli_path,
61
+ args: cmd.drop(1), # Remove command itself since it's in :command
62
+ cwd: working_directory,
63
+ env: env,
64
+ abort_signal: @options.abort_signal
65
+ )
66
+
67
+ # Use custom spawn function if provided, otherwise use default
68
+ spawn_func = @options.spawn_claude_code_process || DEFAULT_SPAWN
69
+
70
+ if spawn_func
71
+ @process = spawn_func.call(spawn_options)
72
+ # Extract streams from process for compatibility
73
+ if @process.respond_to?(:stdin)
74
+ @stdin = @process.stdin
75
+ @stdout = @process.stdout
76
+ @stderr = @process.stderr
77
+ @wait_thread = @process.wait_thread if @process.respond_to?(:wait_thread)
78
+ else
79
+ # Custom process - use wrapper methods
80
+ @stdin = ProcessStdinWrapper.new(@process)
81
+ @stdout = ProcessStdoutWrapper.new(@process)
82
+ @stderr = nil # Custom processes handle stderr internally
83
+ @wait_thread = nil
84
+ end
85
+ else
86
+ # Fallback to direct Open3 spawn
87
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(env, *cmd, chdir: working_directory)
88
+ end
89
+
90
+ @connected = true
91
+
92
+ # Always start stderr reader to prevent pipe buffer from filling up
93
+ start_stderr_reader if @stderr
94
+
95
+ # For non-streaming mode with --print, close stdin immediately
96
+ # The prompt is passed as command-line argument, not via stdin
97
+ unless streaming
98
+ if @process&.respond_to?(:close_stdin)
99
+ @process.close_stdin
100
+ elsif @stdin && !@stdin.closed?
101
+ @stdin.close
102
+ end
103
+ end
104
+ rescue Errno::ENOENT => e
105
+ raise CLINotFoundError, "Claude CLI not found at '#{@cli_path}': #{e.message}"
106
+ rescue => e
107
+ close
108
+ raise CLIConnectionError, "Failed to start CLI: #{e.message}"
109
+ end
110
+
111
+ # Write a JSON message to stdin
112
+ # @param data [String] JSON string to write
113
+ # @return [void]
114
+ def write(data)
115
+ raise CLIConnectionError, "Not connected" unless @connected
116
+ raise CLIConnectionError, "stdin closed" unless @stdin && !@stdin.closed?
117
+
118
+ @mutex.synchronize do
119
+ @stdin.write(data)
120
+ @stdin.write("\n") unless data.end_with?("\n")
121
+ @stdin.flush
122
+ end
123
+ rescue Errno::EPIPE
124
+ raise CLIConnectionError, "Broken pipe - CLI process may have terminated"
125
+ end
126
+
127
+ # Read and parse JSON messages from stdout
128
+ # @yield [Hash] Parsed JSON messages
129
+ # @return [Enumerator] If no block given
130
+ def read_messages
131
+ return enum_for(:read_messages) unless block_given?
132
+
133
+ raise CLIConnectionError, "Not connected" unless @connected
134
+ raise CLIConnectionError, "stdout closed" unless @stdout && !@stdout.closed?
135
+
136
+ @stdout.each_line do |line|
137
+ line = line.strip
138
+ next if line.empty?
139
+
140
+ begin
141
+ message = JSON.parse(line)
142
+ yield message
143
+ rescue JSON::ParserError
144
+ # Buffer partial JSON (in case of split lines)
145
+ @buffer << line
146
+ if @buffer.bytesize > @max_buffer_size
147
+ raise JSONDecodeError.new("Buffer overflow while parsing JSON", raw_content: @buffer[0..500])
148
+ end
149
+
150
+ # Try to parse buffer
151
+ begin
152
+ message = JSON.parse(@buffer)
153
+ @buffer = +""
154
+ yield message
155
+ rescue JSON::ParserError
156
+ # Keep buffering
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ # Close stdin to signal end of input
163
+ # @return [void]
164
+ def end_input
165
+ return unless @stdin && !@stdin.closed?
166
+
167
+ @mutex.synchronize do
168
+ @stdin.close
169
+ end
170
+ end
171
+
172
+ # Close all streams and wait for process to exit
173
+ # @return [Integer, nil] Exit status
174
+ def close
175
+ # Use custom process close if available
176
+ if @process&.respond_to?(:close)
177
+ @process.close
178
+ exit_status = @process.exit_status if @process.respond_to?(:exit_status)
179
+ @connected = false
180
+ @process = nil
181
+ @stdin = @stdout = @stderr = @wait_thread = nil
182
+ return exit_status
183
+ end
184
+
185
+ @mutex.synchronize do
186
+ @stdin&.close unless @stdin&.closed?
187
+ @stdout&.close unless @stdout&.closed?
188
+ @stderr&.close unless @stderr&.closed?
189
+ end
190
+
191
+ exit_status = @wait_thread&.value&.exitstatus
192
+ @connected = false
193
+ @stdin = @stdout = @stderr = @wait_thread = nil
194
+
195
+ exit_status
196
+ end
197
+
198
+ # Check if transport is ready
199
+ # @return [Boolean]
200
+ def ready?
201
+ @connected && @stdin && !@stdin.closed? && @stdout && !@stdout.closed?
202
+ end
203
+
204
+ # Check if transport is connected
205
+ # @return [Boolean]
206
+ def connected?
207
+ @connected
208
+ end
209
+
210
+ # Get the exit status of the CLI process
211
+ # @return [Integer, nil]
212
+ def exit_status
213
+ return @process.exit_status if @process&.respond_to?(:exit_status)
214
+
215
+ @wait_thread&.value&.exitstatus
216
+ end
217
+
218
+ # Check if the CLI process is still running
219
+ # @return [Boolean]
220
+ def running?
221
+ return @process.running? if @process&.respond_to?(:running?)
222
+
223
+ @wait_thread&.alive? || false
224
+ end
225
+
226
+ # Check if the CLI process was killed externally
227
+ # @return [Boolean]
228
+ def killed?
229
+ @killed || (@wait_thread && !@wait_thread.alive? && !@connected)
230
+ end
231
+
232
+ # Terminate the CLI process gracefully (SIGTERM)
233
+ # @param timeout [Numeric] Seconds to wait before force kill
234
+ # @return [void]
235
+ def terminate(timeout: 5)
236
+ # Use custom process terminate if available
237
+ if @process&.respond_to?(:terminate)
238
+ @process.terminate(timeout: timeout)
239
+ return
240
+ end
241
+
242
+ return unless @wait_thread&.alive?
243
+
244
+ pid = nil
245
+ @mutex.synchronize do
246
+ pid = @wait_thread&.pid
247
+ end
248
+
249
+ return unless pid
250
+
251
+ begin
252
+ Process.kill("TERM", pid)
253
+ rescue Errno::ESRCH, Errno::EPERM
254
+ # Process already dead or no permission
255
+ return
256
+ end
257
+
258
+ # Wait for graceful shutdown
259
+ unless @wait_thread.join(timeout)
260
+ kill
261
+ end
262
+ end
263
+
264
+ # Force kill the CLI process (SIGKILL)
265
+ # @return [void]
266
+ def kill
267
+ # Use custom process kill if available
268
+ if @process&.respond_to?(:kill)
269
+ @mutex.synchronize { @killed = true }
270
+ @process.kill
271
+ return
272
+ end
273
+
274
+ return unless @wait_thread&.alive?
275
+
276
+ pid = nil
277
+ @mutex.synchronize do
278
+ pid = @wait_thread&.pid
279
+ @killed = true
280
+ end
281
+
282
+ return unless pid
283
+
284
+ begin
285
+ Process.kill("KILL", pid)
286
+ rescue Errno::ESRCH, Errno::EPERM
287
+ # Process already dead or no permission
288
+ end
289
+ end
290
+
291
+ private
292
+
293
+ def find_cli_path
294
+ # Check common locations
295
+ paths = [
296
+ `which claude 2>/dev/null`.strip,
297
+ "/usr/local/bin/claude",
298
+ "/opt/homebrew/bin/claude",
299
+ File.expand_path("~/.local/bin/claude")
300
+ ]
301
+
302
+ paths.find { |p| !p.empty? && File.executable?(p) } || "claude"
303
+ end
304
+
305
+ def check_cli_version!
306
+ version_output = `#{@cli_path} -v 2>&1`.strip
307
+ # Parse version like "claude 2.1.0" or just "2.1.0"
308
+ version_match = version_output.match(/(\d+\.\d+\.\d+)/)
309
+
310
+ unless version_match
311
+ raise CLIVersionError.new(nil)
312
+ end
313
+
314
+ found_version = version_match[1]
315
+ unless version_satisfies?(found_version, MINIMUM_CLI_VERSION)
316
+ raise CLIVersionError.new(found_version)
317
+ end
318
+ rescue Errno::ENOENT
319
+ raise CLINotFoundError
320
+ rescue Errno::ETIMEDOUT, Timeout::Error
321
+ # Skip version check on timeout
322
+ end
323
+
324
+ def version_satisfies?(found, minimum)
325
+ found_parts = found.split(".").map(&:to_i)
326
+ minimum_parts = minimum.split(".").map(&:to_i)
327
+
328
+ found_parts.zip(minimum_parts).each do |f, m|
329
+ f ||= 0
330
+ m ||= 0
331
+ return true if f > m
332
+ return false if f < m
333
+ end
334
+
335
+ true
336
+ end
337
+
338
+ def skip_version_check?
339
+ ENV["CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"] == "true"
340
+ end
341
+
342
+ def build_command(streaming:, prompt: nil)
343
+ cmd = [ @cli_path ]
344
+
345
+ # Add options-based arguments
346
+ cmd.concat(@options.to_cli_args)
347
+
348
+ # Output is always stream-json
349
+ cmd.push("--output-format", "stream-json")
350
+
351
+ # Input format only for streaming mode
352
+ cmd.push("--input-format", "stream-json") if streaming
353
+
354
+ # Always add verbose for better debugging (must be before -- separator)
355
+ cmd.push("--verbose")
356
+
357
+ # For non-streaming mode, add prompt as positional argument after --
358
+ if !streaming && prompt
359
+ cmd.push("--print")
360
+ cmd.push("--")
361
+ cmd.push(prompt)
362
+ end
363
+
364
+ cmd
365
+ end
366
+
367
+ def working_directory
368
+ dir = @options.cwd&.to_s
369
+ (dir && Dir.exist?(dir)) ? dir : Dir.pwd
370
+ end
371
+
372
+ def start_stderr_reader
373
+ Thread.new do
374
+ @stderr.each_line do |line|
375
+ # Call callback if provided, otherwise just drain
376
+ @options.stderr_callback&.call(line.chomp)
377
+ rescue
378
+ # Ignore callback errors
379
+ end
380
+ rescue IOError
381
+ # Stream closed, exit thread
382
+ end
383
+ end
384
+ end
385
+
386
+ # Wrapper for custom process stdin to match IO interface
387
+ # @api private
388
+ class ProcessStdinWrapper
389
+ def initialize(process)
390
+ @process = process
391
+ @closed = false
392
+ end
393
+
394
+ def write(data)
395
+ @process.write(data)
396
+ end
397
+
398
+ def flush
399
+ # Custom processes handle their own flushing
400
+ end
401
+
402
+ def close
403
+ @process.close_stdin
404
+ @closed = true
405
+ end
406
+
407
+ def closed?
408
+ @closed
409
+ end
410
+ end
411
+
412
+ # Wrapper for custom process stdout to match IO interface
413
+ # @api private
414
+ class ProcessStdoutWrapper
415
+ def initialize(process)
416
+ @process = process
417
+ end
418
+
419
+ def each_line(&block)
420
+ @process.read_stdout(&block)
421
+ end
422
+
423
+ def close
424
+ # Custom processes handle their own closing
425
+ end
426
+
427
+ def closed?
428
+ !@process.running?
429
+ end
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Assistant message error types (TypeScript SDK parity)
5
+ # Used to categorize errors returned by the assistant
6
+ ASSISTANT_MESSAGE_ERROR_TYPES = %w[
7
+ authentication_failed
8
+ billing_error
9
+ rate_limit
10
+ invalid_request
11
+ server_error
12
+ unknown
13
+ ].freeze
14
+
15
+ # API key source types (TypeScript SDK parity)
16
+ # Indicates where the API key was sourced from
17
+ API_KEY_SOURCES = %w[user project org temporary].freeze
18
+
19
+ # Tools preset configuration (TypeScript SDK parity)
20
+ #
21
+ # @example
22
+ # preset = ToolsPreset.new(preset: "claude_code")
23
+ # options = ClaudeAgent::Options.new(tools: preset)
24
+ #
25
+ ToolsPreset = Data.define(:type, :preset) do
26
+ def initialize(type: "preset", preset: "claude_code")
27
+ super
28
+ end
29
+
30
+ def to_h
31
+ { type: type, preset: preset }
32
+ end
33
+ end
34
+ # Return type for supported_commands() (TypeScript SDK parity)
35
+ #
36
+ # @example
37
+ # cmd = SlashCommand.new(name: "commit", description: "Create a commit", argument_hint: "[message]")
38
+ # cmd.name # => "commit"
39
+ # cmd.description # => "Create a commit"
40
+ #
41
+ SlashCommand = Data.define(:name, :description, :argument_hint) do
42
+ def initialize(name:, description: nil, argument_hint: nil)
43
+ super
44
+ end
45
+ end
46
+
47
+ # Return type for supported_models() (TypeScript SDK parity)
48
+ #
49
+ # @example
50
+ # model = ModelInfo.new(value: "claude-3-opus", display_name: "Claude 3 Opus", description: "Most capable")
51
+ # model.value # => "claude-3-opus"
52
+ # model.display_name # => "Claude 3 Opus"
53
+ #
54
+ ModelInfo = Data.define(:value, :display_name, :description) do
55
+ def initialize(value:, display_name: nil, description: nil)
56
+ super
57
+ end
58
+ end
59
+
60
+ # Return type for mcp_server_status() (TypeScript SDK parity)
61
+ # Status values: "connected", "failed", "needs-auth", "pending"
62
+ #
63
+ # @example
64
+ # status = McpServerStatus.new(name: "filesystem", status: "connected", server_info: {name: "fs", version: "1.0"})
65
+ #
66
+ McpServerStatus = Data.define(:name, :status, :server_info) do
67
+ def initialize(name:, status:, server_info: nil)
68
+ super
69
+ end
70
+ end
71
+
72
+ # Return type for account_info() (TypeScript SDK parity)
73
+ #
74
+ # @example
75
+ # info = AccountInfo.new(email: "user@example.com", organization: "Acme Corp")
76
+ #
77
+ AccountInfo = Data.define(:email, :organization, :subscription_type, :token_source, :api_key_source) do
78
+ def initialize(email: nil, organization: nil, subscription_type: nil, token_source: nil, api_key_source: nil)
79
+ super
80
+ end
81
+ end
82
+
83
+ # Per-model usage statistics returned in result messages (TypeScript SDK parity)
84
+ #
85
+ # @example
86
+ # usage = ModelUsage.new(input_tokens: 100, output_tokens: 50, cost_usd: 0.01)
87
+ #
88
+ ModelUsage = Data.define(
89
+ :input_tokens,
90
+ :output_tokens,
91
+ :cache_read_input_tokens,
92
+ :cache_creation_input_tokens,
93
+ :web_search_requests,
94
+ :cost_usd,
95
+ :context_window
96
+ ) do
97
+ def initialize(
98
+ input_tokens: 0,
99
+ output_tokens: 0,
100
+ cache_read_input_tokens: 0,
101
+ cache_creation_input_tokens: 0,
102
+ web_search_requests: 0,
103
+ cost_usd: 0.0,
104
+ context_window: nil
105
+ )
106
+ super
107
+ end
108
+ end
109
+
110
+ # Permission denial information in result messages (TypeScript SDK parity)
111
+ #
112
+ SDKPermissionDenial = Data.define(:tool_name, :tool_use_id, :tool_input) do
113
+ def initialize(tool_name:, tool_use_id:, tool_input:)
114
+ super
115
+ end
116
+ end
117
+
118
+ # Result of set_mcp_servers() control method (TypeScript SDK parity)
119
+ #
120
+ # @example
121
+ # result = McpSetServersResult.new(
122
+ # added: ["server1"],
123
+ # removed: ["old-server"],
124
+ # errors: {"server2" => "Connection failed"}
125
+ # )
126
+ #
127
+ McpSetServersResult = Data.define(:added, :removed, :errors) do
128
+ def initialize(added: [], removed: [], errors: {})
129
+ super
130
+ end
131
+ end
132
+
133
+ # Result of rewind_files() control method (TypeScript SDK parity)
134
+ #
135
+ # @example
136
+ # result = RewindFilesResult.new(
137
+ # can_rewind: true,
138
+ # files_changed: ["src/foo.rb", "src/bar.rb"],
139
+ # insertions: 10,
140
+ # deletions: 5
141
+ # )
142
+ #
143
+ RewindFilesResult = Data.define(:can_rewind, :error, :files_changed, :insertions, :deletions) do
144
+ def initialize(can_rewind:, error: nil, files_changed: nil, insertions: nil, deletions: nil)
145
+ super
146
+ end
147
+ end
148
+
149
+ # Agent definition for custom subagents (TypeScript SDK parity)
150
+ #
151
+ # @example
152
+ # agent = AgentDefinition.new(
153
+ # description: "Runs tests and reports results",
154
+ # prompt: "You are a test runner...",
155
+ # tools: ["Read", "Grep", "Glob", "Bash"],
156
+ # model: "haiku"
157
+ # )
158
+ #
159
+ AgentDefinition = Data.define(
160
+ :description,
161
+ :prompt,
162
+ :tools,
163
+ :disallowed_tools,
164
+ :model,
165
+ :mcp_servers,
166
+ :critical_system_reminder
167
+ ) do
168
+ def initialize(
169
+ description:,
170
+ prompt:,
171
+ tools: nil,
172
+ disallowed_tools: nil,
173
+ model: nil,
174
+ mcp_servers: nil,
175
+ critical_system_reminder: nil
176
+ )
177
+ super
178
+ end
179
+
180
+ def to_h
181
+ result = {
182
+ description: description,
183
+ prompt: prompt
184
+ }
185
+ result[:tools] = tools if tools
186
+ result[:disallowedTools] = disallowed_tools if disallowed_tools
187
+ result[:model] = model if model
188
+ result[:mcpServers] = mcp_servers if mcp_servers
189
+ result[:criticalSystemReminder_EXPERIMENTAL] = critical_system_reminder if critical_system_reminder
190
+ result
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "active_support/core_ext/hash/keys"
5
+
6
+ require_relative "claude_agent/version"
7
+ require_relative "claude_agent/errors"
8
+ require_relative "claude_agent/types" # TypeScript SDK parity types
9
+ require_relative "claude_agent/sandbox_settings" # Sandbox configuration types
10
+ require_relative "claude_agent/abort_controller" # Abort/cancel support (TypeScript SDK parity)
11
+ require_relative "claude_agent/spawn" # Custom spawn support (TypeScript SDK parity)
12
+ require_relative "claude_agent/options"
13
+ require_relative "claude_agent/content_blocks"
14
+ require_relative "claude_agent/messages"
15
+ require_relative "claude_agent/message_parser"
16
+ require_relative "claude_agent/hooks"
17
+ require_relative "claude_agent/permissions"
18
+ require_relative "claude_agent/control_protocol"
19
+ require_relative "claude_agent/transport/base"
20
+ require_relative "claude_agent/transport/subprocess"
21
+ require_relative "claude_agent/mcp/tool"
22
+ require_relative "claude_agent/mcp/server"
23
+ require_relative "claude_agent/query"
24
+ require_relative "claude_agent/client"
25
+
26
+ module ClaudeAgent
27
+ # Re-export key classes at module level for convenience
28
+ end