claude-agent-sdk 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,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require_relative 'transport'
6
+ require_relative 'errors'
7
+ require_relative 'version'
8
+
9
+ module ClaudeAgentSDK
10
+ # Subprocess transport using Claude Code CLI
11
+ class SubprocessCLITransport < Transport
12
+ DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
13
+ MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
14
+
15
+ def initialize(prompt, options)
16
+ @prompt = prompt
17
+ @is_streaming = !prompt.is_a?(String)
18
+ @options = options
19
+ @cli_path = options.cli_path || find_cli
20
+ @cwd = options.cwd
21
+ @process = nil
22
+ @stdin = nil
23
+ @stdout = nil
24
+ @stderr = nil
25
+ @ready = false
26
+ @exit_error = nil
27
+ @max_buffer_size = options.max_buffer_size || DEFAULT_MAX_BUFFER_SIZE
28
+ @stderr_task = nil
29
+ end
30
+
31
+ def find_cli
32
+ # Try which command first
33
+ cli = `which claude 2>/dev/null`.strip
34
+ return cli unless cli.empty?
35
+
36
+ # Try common locations
37
+ locations = [
38
+ File.join(Dir.home, '.npm-global/bin/claude'),
39
+ '/usr/local/bin/claude',
40
+ File.join(Dir.home, '.local/bin/claude'),
41
+ File.join(Dir.home, 'node_modules/.bin/claude'),
42
+ File.join(Dir.home, '.yarn/bin/claude')
43
+ ]
44
+
45
+ locations.each do |path|
46
+ return path if File.exist?(path) && File.file?(path)
47
+ end
48
+
49
+ raise CLINotFoundError.new(
50
+ "Claude Code not found. Install with:\n" \
51
+ " npm install -g @anthropic-ai/claude-code\n" \
52
+ "\nIf already installed locally, try:\n" \
53
+ ' export PATH="$HOME/node_modules/.bin:$PATH"' \
54
+ "\n\nOr provide the path via ClaudeAgentOptions:\n" \
55
+ " ClaudeAgentOptions.new(cli_path: '/path/to/claude')"
56
+ )
57
+ end
58
+
59
+ def build_command
60
+ cmd = [@cli_path, '--output-format', 'stream-json', '--verbose']
61
+
62
+ # System prompt handling
63
+ if @options.system_prompt
64
+ if @options.system_prompt.is_a?(String)
65
+ cmd.concat(['--system-prompt', @options.system_prompt])
66
+ elsif @options.system_prompt.is_a?(Hash) &&
67
+ @options.system_prompt[:type] == 'preset' &&
68
+ @options.system_prompt[:append]
69
+ cmd.concat(['--append-system-prompt', @options.system_prompt[:append]])
70
+ end
71
+ end
72
+
73
+ cmd.concat(['--allowedTools', @options.allowed_tools.join(',')]) unless @options.allowed_tools.empty?
74
+ cmd.concat(['--max-turns', @options.max_turns.to_s]) if @options.max_turns
75
+ cmd.concat(['--disallowedTools', @options.disallowed_tools.join(',')]) unless @options.disallowed_tools.empty?
76
+ cmd.concat(['--model', @options.model]) if @options.model
77
+ cmd.concat(['--permission-prompt-tool', @options.permission_prompt_tool_name]) if @options.permission_prompt_tool_name
78
+ cmd.concat(['--permission-mode', @options.permission_mode]) if @options.permission_mode
79
+ cmd << '--continue' if @options.continue_conversation
80
+ cmd.concat(['--resume', @options.resume]) if @options.resume
81
+ cmd.concat(['--settings', @options.settings]) if @options.settings
82
+
83
+ # Add directories
84
+ @options.add_dirs.each do |dir|
85
+ cmd.concat(['--add-dir', dir.to_s])
86
+ end
87
+
88
+ # MCP servers
89
+ if @options.mcp_servers && !@options.mcp_servers.empty?
90
+ if @options.mcp_servers.is_a?(Hash)
91
+ servers_for_cli = {}
92
+ @options.mcp_servers.each do |name, config|
93
+ if config.is_a?(Hash) && config[:type] == 'sdk'
94
+ # For SDK servers, exclude instance field
95
+ sdk_config = config.reject { |k, _| k == :instance }
96
+ servers_for_cli[name] = sdk_config
97
+ else
98
+ servers_for_cli[name] = config
99
+ end
100
+ end
101
+ cmd.concat(['--mcp-config', JSON.generate({ mcpServers: servers_for_cli })]) unless servers_for_cli.empty?
102
+ else
103
+ cmd.concat(['--mcp-config', @options.mcp_servers.to_s])
104
+ end
105
+ end
106
+
107
+ cmd << '--include-partial-messages' if @options.include_partial_messages
108
+ cmd << '--fork-session' if @options.fork_session
109
+
110
+ # Agents
111
+ if @options.agents
112
+ agents_dict = @options.agents.transform_values do |agent_def|
113
+ {
114
+ description: agent_def.description,
115
+ prompt: agent_def.prompt,
116
+ tools: agent_def.tools,
117
+ model: agent_def.model
118
+ }.compact
119
+ end
120
+ cmd.concat(['--agents', JSON.generate(agents_dict)])
121
+ end
122
+
123
+ # Setting sources
124
+ sources_value = @options.setting_sources ? @options.setting_sources.join(',') : ''
125
+ cmd.concat(['--setting-sources', sources_value])
126
+
127
+ # Extra args
128
+ @options.extra_args.each do |flag, value|
129
+ if value.nil?
130
+ cmd << "--#{flag}"
131
+ else
132
+ cmd.concat(["--#{flag}", value.to_s])
133
+ end
134
+ end
135
+
136
+ # Prompt handling
137
+ if @is_streaming
138
+ cmd.concat(['--input-format', 'stream-json'])
139
+ else
140
+ cmd.concat(['--print', '--', @prompt.to_s])
141
+ end
142
+
143
+ cmd
144
+ end
145
+
146
+ def connect
147
+ return if @process
148
+
149
+ check_claude_version
150
+
151
+ cmd = build_command
152
+
153
+ # Build environment
154
+ process_env = ENV.to_h.merge(@options.env).merge(
155
+ 'CLAUDE_CODE_ENTRYPOINT' => 'sdk-rb',
156
+ 'CLAUDE_AGENT_SDK_VERSION' => VERSION
157
+ )
158
+ process_env['PWD'] = @cwd.to_s if @cwd
159
+
160
+ # Determine stderr handling
161
+ should_pipe_stderr = @options.stderr || @options.extra_args.key?('debug-to-stderr')
162
+
163
+ begin
164
+ # Start process
165
+ @process = Async::Process::Child.new(
166
+ *cmd,
167
+ stdin: :pipe,
168
+ stdout: :pipe,
169
+ stderr: should_pipe_stderr ? :pipe : nil,
170
+ chdir: @cwd&.to_s,
171
+ env: process_env
172
+ )
173
+
174
+ @stdout = @process.stdout
175
+ @stdin = @process.stdin if @is_streaming
176
+
177
+ # Handle stderr if piped
178
+ if should_pipe_stderr && @process.stderr
179
+ @stderr = @process.stderr
180
+ @stderr_task = Async do
181
+ handle_stderr
182
+ rescue StandardError
183
+ # Ignore errors during stderr reading
184
+ end
185
+ end
186
+
187
+ # Close stdin for non-streaming mode
188
+ @process.stdin.close unless @is_streaming
189
+
190
+ @ready = true
191
+ rescue Errno::ENOENT => e
192
+ # Check if error is from cwd or CLI
193
+ if @cwd && !File.directory?(@cwd.to_s)
194
+ error = CLIConnectionError.new("Working directory does not exist: #{@cwd}")
195
+ @exit_error = error
196
+ raise error
197
+ end
198
+ error = CLINotFoundError.new("Claude Code not found at: #{@cli_path}")
199
+ @exit_error = error
200
+ raise error
201
+ rescue StandardError => e
202
+ error = CLIConnectionError.new("Failed to start Claude Code: #{e}")
203
+ @exit_error = error
204
+ raise error
205
+ end
206
+ end
207
+
208
+ def handle_stderr
209
+ return unless @stderr
210
+
211
+ @stderr.each_line do |line|
212
+ line_str = line.chomp
213
+ next if line_str.empty?
214
+
215
+ @options.stderr&.call(line_str)
216
+ end
217
+ rescue StandardError
218
+ # Ignore errors during stderr reading
219
+ end
220
+
221
+ def close
222
+ @ready = false
223
+ return unless @process
224
+
225
+ # Cancel stderr task
226
+ @stderr_task&.stop
227
+
228
+ # Close streams
229
+ begin
230
+ @stdin&.close
231
+ rescue StandardError
232
+ # Ignore
233
+ end
234
+ begin
235
+ @stderr&.close
236
+ rescue StandardError
237
+ # Ignore
238
+ end
239
+
240
+ # Terminate process
241
+ begin
242
+ @process.terminate
243
+ @process.wait
244
+ rescue StandardError
245
+ # Ignore
246
+ end
247
+
248
+ @process = nil
249
+ @stdout = nil
250
+ @stdin = nil
251
+ @stderr = nil
252
+ @exit_error = nil
253
+ end
254
+
255
+ def write(data)
256
+ raise CLIConnectionError, 'ProcessTransport is not ready for writing' unless @ready && @stdin
257
+ raise CLIConnectionError, "Cannot write to terminated process" if @process && @process.status
258
+
259
+ raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
260
+
261
+ begin
262
+ @stdin.write(data)
263
+ @stdin.flush
264
+ rescue StandardError => e
265
+ @ready = false
266
+ @exit_error = CLIConnectionError.new("Failed to write to process stdin: #{e}")
267
+ raise @exit_error
268
+ end
269
+ end
270
+
271
+ def end_input
272
+ return unless @stdin
273
+
274
+ begin
275
+ @stdin.close
276
+ rescue StandardError
277
+ # Ignore
278
+ end
279
+ @stdin = nil
280
+ end
281
+
282
+ def read_messages(&block)
283
+ return enum_for(:read_messages) unless block_given?
284
+
285
+ raise CLIConnectionError, 'Not connected' unless @process && @stdout
286
+
287
+ json_buffer = ''
288
+
289
+ begin
290
+ @stdout.each_line do |line|
291
+ line_str = line.strip
292
+ next if line_str.empty?
293
+
294
+ json_lines = line_str.split("\n")
295
+
296
+ json_lines.each do |json_line|
297
+ json_line = json_line.strip
298
+ next if json_line.empty?
299
+
300
+ json_buffer += json_line
301
+
302
+ if json_buffer.bytesize > @max_buffer_size
303
+ buffer_length = json_buffer.bytesize
304
+ json_buffer = ''
305
+ raise CLIJSONDecodeError.new(
306
+ "JSON message exceeded maximum buffer size",
307
+ StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
308
+ )
309
+ end
310
+
311
+ begin
312
+ data = JSON.parse(json_buffer, symbolize_names: true)
313
+ json_buffer = ''
314
+ yield data
315
+ rescue JSON::ParserError
316
+ # Continue accumulating
317
+ next
318
+ end
319
+ end
320
+ end
321
+ rescue IOError
322
+ # Stream closed
323
+ rescue StopIteration
324
+ # Client disconnected
325
+ end
326
+
327
+ # Check process completion
328
+ status = @process.wait
329
+ returncode = status.exitstatus
330
+
331
+ if returncode && returncode != 0
332
+ @exit_error = ProcessError.new(
333
+ "Command failed with exit code #{returncode}",
334
+ exit_code: returncode,
335
+ stderr: 'Check stderr output for details'
336
+ )
337
+ raise @exit_error
338
+ end
339
+ end
340
+
341
+ def check_claude_version
342
+ begin
343
+ output = `#{@cli_path} -v 2>&1`.strip
344
+ if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
345
+ version = match[1]
346
+ version_parts = version.split('.').map(&:to_i)
347
+ min_parts = MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
348
+
349
+ if version_parts < min_parts
350
+ warning = "Warning: Claude Code version #{version} is unsupported in the Agent SDK. " \
351
+ "Minimum required version is #{MINIMUM_CLAUDE_CODE_VERSION}. " \
352
+ "Some features may not work correctly."
353
+ warn warning
354
+ end
355
+ end
356
+ rescue StandardError
357
+ # Ignore version check errors
358
+ end
359
+ end
360
+
361
+ def ready?
362
+ @ready
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Abstract transport for Claude communication
5
+ #
6
+ # WARNING: This internal API is exposed for custom transport implementations
7
+ # (e.g., remote Claude Code connections). The Claude Code team may change or
8
+ # remove this abstract class in any future release. Custom implementations
9
+ # must be updated to match interface changes.
10
+ class Transport
11
+ # Connect the transport and prepare for communication
12
+ def connect
13
+ raise NotImplementedError, 'Subclasses must implement #connect'
14
+ end
15
+
16
+ # Write raw data to the transport
17
+ # @param data [String] Raw string data to write (typically JSON + newline)
18
+ def write(data)
19
+ raise NotImplementedError, 'Subclasses must implement #write'
20
+ end
21
+
22
+ # Read and parse messages from the transport
23
+ # @return [Enumerator] Async enumerator of parsed JSON messages
24
+ def read_messages
25
+ raise NotImplementedError, 'Subclasses must implement #read_messages'
26
+ end
27
+
28
+ # Close the transport connection and clean up resources
29
+ def close
30
+ raise NotImplementedError, 'Subclasses must implement #close'
31
+ end
32
+
33
+ # Check if transport is ready for communication
34
+ # @return [Boolean] True if transport is ready to send/receive messages
35
+ def ready?
36
+ raise NotImplementedError, 'Subclasses must implement #ready?'
37
+ end
38
+
39
+ # End the input stream (close stdin for process transports)
40
+ def end_input
41
+ raise NotImplementedError, 'Subclasses must implement #end_input'
42
+ end
43
+ end
44
+ end