claude_code 0.0.14

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,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'pathname'
6
+
7
+ module ClaudeCode
8
+ class Client
9
+ def initialize
10
+ # Client setup
11
+ end
12
+
13
+ def process_query(prompt: nil, messages: nil, options:, cli_path: nil, mcp_servers: {})
14
+ if messages
15
+ # Handle streaming JSON input
16
+ transport = SubprocessCLITransport.new(prompt: "", options: options, cli_path: cli_path)
17
+ transport.connect
18
+
19
+ # Send messages
20
+ transport.send_messages(messages)
21
+
22
+ # Return enumerator for responses
23
+ return Enumerator.new do |yielder|
24
+ begin
25
+ transport.receive_messages do |data|
26
+ message = parse_message(data)
27
+ yielder << message if message
28
+ end
29
+ ensure
30
+ transport.disconnect
31
+ end
32
+ end
33
+ end
34
+
35
+ transport = SubprocessCLITransport.new(prompt: prompt, options: options, cli_path: cli_path)
36
+
37
+ transport.connect
38
+
39
+ # Return lazy enumerator that streams messages as they arrive
40
+ Enumerator.new do |yielder|
41
+ begin
42
+ transport.receive_messages do |data|
43
+ message = parse_message(data)
44
+ yielder << message if message
45
+ end
46
+ ensure
47
+ transport.disconnect
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def build_environment
55
+ env = ENV.to_h
56
+ env['CLAUDE_CODE_ENTRYPOINT'] = 'sdk-ruby'
57
+ env['ANTHROPIC_API_KEY'] = ENV['ANTHROPIC_API_KEY'] if ENV['ANTHROPIC_API_KEY']
58
+ env['CLAUDE_CODE_USE_BEDROCK'] = ENV['CLAUDE_CODE_USE_BEDROCK'] if ENV['CLAUDE_CODE_USE_BEDROCK']
59
+ env['CLAUDE_CODE_USE_VERTEX'] = ENV['CLAUDE_CODE_USE_VERTEX'] if ENV['CLAUDE_CODE_USE_VERTEX']
60
+ env
61
+ end
62
+
63
+ def parse_message(data)
64
+ case data['type']
65
+ when 'user'
66
+ UserMessage.new(data.dig('message', 'content'))
67
+ when 'assistant'
68
+ content_blocks = []
69
+ message_content = data.dig('message', 'content') || []
70
+ message_content.each do |block|
71
+ case block['type']
72
+ when 'text'
73
+ content_blocks << TextBlock.new(block['text'])
74
+ when 'tool_use'
75
+ content_blocks << ToolUseBlock.new(
76
+ id: block['id'],
77
+ name: block['name'],
78
+ input: block['input']
79
+ )
80
+ when 'tool_result'
81
+ content_blocks << ToolResultBlock.new(
82
+ tool_use_id: block['tool_use_id'],
83
+ content: block['content'],
84
+ is_error: block['is_error']
85
+ )
86
+ end
87
+ end
88
+ AssistantMessage.new(content_blocks)
89
+ when 'system'
90
+ SystemMessage.new(
91
+ subtype: data['subtype'],
92
+ data: data
93
+ )
94
+ when 'result'
95
+ ResultMessage.new(
96
+ subtype: data['subtype'],
97
+ duration_ms: data['duration_ms'],
98
+ duration_api_ms: data['duration_api_ms'],
99
+ is_error: data['is_error'],
100
+ num_turns: data['num_turns'],
101
+ session_id: data['session_id'],
102
+ total_cost_usd: data['total_cost_usd'],
103
+ usage: data['usage'],
104
+ result: data['result']
105
+ )
106
+ else
107
+ nil
108
+ end
109
+ end
110
+ end
111
+
112
+ class SubprocessCLITransport
113
+ MAX_BUFFER_SIZE = 1024 * 1024 # 1MB
114
+
115
+ def initialize(prompt:, options:, cli_path: nil)
116
+ @prompt = prompt
117
+ @options = options
118
+ @cli_path = cli_path
119
+ @cwd = options.cwd&.to_s
120
+ @process = nil
121
+ @stdin = nil
122
+ @stdout = nil
123
+ @stderr = nil
124
+ end
125
+
126
+ def find_cli(cli_path = nil)
127
+ # Use provided CLI path if valid
128
+ if cli_path
129
+ return cli_path if File.executable?(cli_path)
130
+ raise CLINotFoundError.new("CLI not found at specified path: #{cli_path}", cli_path: cli_path)
131
+ end
132
+
133
+ # Try PATH first using cross-platform which
134
+ cli = which('claude')
135
+ return cli if cli
136
+
137
+ # Try common locations
138
+ locations = [
139
+ Pathname.new(File.expand_path('~/.claude/local/claude')),
140
+ Pathname.new(File.expand_path('~/.npm-global/bin/claude')),
141
+ Pathname.new('/usr/local/bin/claude'),
142
+ Pathname.new(File.expand_path('~/.local/bin/claude')),
143
+ Pathname.new(File.expand_path('~/node_modules/.bin/claude')),
144
+ Pathname.new(File.expand_path('~/.yarn/bin/claude'))
145
+ ]
146
+
147
+ locations.each do |path|
148
+ return path.to_s if path.exist? && path.file?
149
+ end
150
+
151
+ # Check if Node.js is installed using cross-platform which
152
+ node_installed = !which('node').nil?
153
+
154
+ unless node_installed
155
+ error_msg = <<~MSG
156
+ Claude Code requires Node.js, which is not installed.
157
+
158
+ 📦 Install Node.js from: https://nodejs.org/
159
+
160
+ After installing Node.js, install Claude Code:
161
+ npm install -g @anthropic-ai/claude-code
162
+
163
+ 💡 For more installation options, see:
164
+ https://docs.anthropic.com/en/docs/claude-code/quickstart
165
+ MSG
166
+ raise CLINotFoundError.new(error_msg)
167
+ end
168
+
169
+ # Node is installed but Claude Code isn't
170
+ error_msg = <<~MSG
171
+ Claude Code not found. Install with:
172
+ npm install -g @anthropic-ai/claude-code
173
+
174
+ 📍 If already installed locally, try:
175
+ export PATH="$HOME/node_modules/.bin:$PATH"
176
+
177
+ 🔧 Or specify the path when creating client:
178
+ ClaudeCode.query(..., cli_path: '/path/to/claude')
179
+
180
+ 💡 For more installation options, see:
181
+ https://docs.anthropic.com/en/docs/claude-code/quickstart
182
+ MSG
183
+ raise CLINotFoundError.new(error_msg)
184
+ end
185
+
186
+ # Cross-platform which command
187
+ def which(cmd)
188
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
189
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
190
+ exts.each do |ext|
191
+ exe = File.join(path, "#{cmd}#{ext}")
192
+ return exe if File.executable?(exe) && !File.directory?(exe)
193
+ end
194
+ end
195
+ nil
196
+ end
197
+
198
+ def build_environment
199
+ # Start with current environment
200
+ env = ENV.to_h
201
+
202
+ # Set SDK entrypoint identifier
203
+ env['CLAUDE_CODE_ENTRYPOINT'] = 'sdk-ruby'
204
+
205
+ # Ensure ANTHROPIC_API_KEY is available if set
206
+ # This allows the CLI to authenticate with Anthropic's API
207
+ if ENV['ANTHROPIC_API_KEY']
208
+ env['ANTHROPIC_API_KEY'] = ENV['ANTHROPIC_API_KEY']
209
+ end
210
+
211
+ # Support for other authentication methods
212
+ if ENV['CLAUDE_CODE_USE_BEDROCK']
213
+ env['CLAUDE_CODE_USE_BEDROCK'] = ENV['CLAUDE_CODE_USE_BEDROCK']
214
+ end
215
+
216
+ if ENV['CLAUDE_CODE_USE_VERTEX']
217
+ env['CLAUDE_CODE_USE_VERTEX'] = ENV['CLAUDE_CODE_USE_VERTEX']
218
+ end
219
+
220
+ env
221
+ end
222
+
223
+ def build_command
224
+ # Determine output format (default to stream-json for SDK)
225
+ output_format = @options.output_format || 'stream-json'
226
+ cmd = [@cli_path, '--output-format', output_format, '--verbose']
227
+
228
+ # Add input format if specified
229
+ cmd.concat(['--input-format', @options.input_format]) if @options.input_format
230
+
231
+ cmd.concat(['--system-prompt', @options.system_prompt]) if @options.system_prompt
232
+ cmd.concat(['--append-system-prompt', @options.append_system_prompt]) if @options.append_system_prompt
233
+ cmd.concat(['--allowedTools', @options.allowed_tools.join(',')]) unless @options.allowed_tools.empty?
234
+ cmd.concat(['--max-turns', @options.max_turns.to_s]) if @options.max_turns
235
+ cmd.concat(['--disallowedTools', @options.disallowed_tools.join(',')]) unless @options.disallowed_tools.empty?
236
+ cmd.concat(['--model', @options.model]) if @options.model
237
+ cmd.concat(['--permission-prompt-tool', @options.permission_prompt_tool_name]) if @options.permission_prompt_tool_name
238
+ cmd.concat(['--permission-mode', @options.permission_mode]) if @options.permission_mode
239
+ cmd << '--continue' if @options.continue_conversation
240
+ cmd.concat(['--resume', @options.resume]) if @options.resume
241
+
242
+ unless @options.mcp_servers.empty?
243
+ mcp_config = { 'mcpServers' => @options.mcp_servers.transform_values { |config|
244
+ config.respond_to?(:to_h) ? config.to_h : config
245
+ } }
246
+ cmd.concat(['--mcp-config', JSON.generate(mcp_config)])
247
+ end
248
+
249
+ # For streaming JSON input, we use --print mode and send JSON via stdin
250
+ # For regular input, we use --print with the prompt
251
+ if @options.input_format == 'stream-json'
252
+ cmd << '--print'
253
+ else
254
+ cmd.concat(['--print', @prompt])
255
+ end
256
+
257
+ cmd
258
+ end
259
+
260
+ def connect
261
+ return if @process
262
+
263
+ # Find CLI if not already set
264
+ @cli_path ||= find_cli
265
+
266
+ cmd = build_command
267
+ puts "Debug: Connecting with command: #{cmd.join(' ')}" if ENV['DEBUG_CLAUDE_SDK']
268
+
269
+ begin
270
+ env = build_environment
271
+
272
+ if @cwd
273
+ @stdin, @stdout, @stderr, @process = Open3.popen3(env, *cmd, chdir: @cwd)
274
+ else
275
+ @stdin, @stdout, @stderr, @process = Open3.popen3(env, *cmd)
276
+ end
277
+
278
+ # Handle different input modes
279
+ if @options.input_format == 'stream-json'
280
+ # Keep stdin open for streaming JSON input
281
+ puts "Debug: Keeping stdin open for streaming JSON input" if ENV['DEBUG_CLAUDE_SDK']
282
+ else
283
+ # Close stdin for regular prompt mode
284
+ @stdin.close
285
+ end
286
+
287
+ puts "Debug: Process started with PID #{@process.pid}" if ENV['DEBUG_CLAUDE_SDK']
288
+
289
+ rescue Errno::ENOENT => e
290
+ if @cwd && !Dir.exist?(@cwd)
291
+ raise CLIConnectionError.new("Working directory does not exist: #{@cwd}")
292
+ end
293
+ raise CLINotFoundError.new("Claude Code not found at: #{@cli_path}")
294
+ rescue => e
295
+ raise CLIConnectionError.new("Failed to start Claude Code: #{e.class} - #{e.message}")
296
+ end
297
+ end
298
+
299
+ def disconnect
300
+ return unless @process
301
+
302
+ begin
303
+ # Try to terminate gracefully
304
+ if @process.alive?
305
+ Process.kill('INT', @process.pid)
306
+
307
+ # Wait for process to exit with timeout
308
+ begin
309
+ require 'timeout'
310
+ Timeout.timeout(5) do
311
+ @process.join
312
+ end
313
+ rescue Timeout::Error
314
+ # Force kill if it doesn't exit gracefully
315
+ begin
316
+ Process.kill('KILL', @process.pid) if @process.alive?
317
+ @process.join rescue nil
318
+ rescue Errno::ESRCH
319
+ # Process already gone
320
+ end
321
+ end
322
+ end
323
+ rescue Errno::ESRCH, Errno::ECHILD
324
+ # Process already gone
325
+ ensure
326
+ @stdin&.close rescue nil
327
+ @stdout&.close rescue nil
328
+ @stderr&.close rescue nil
329
+ @stdin = nil
330
+ @stdout = nil
331
+ @stderr = nil
332
+ @process = nil
333
+ end
334
+ end
335
+
336
+ def receive_messages
337
+ raise CLIConnectionError.new("Not connected") unless @process && @stdout
338
+
339
+ json_buffer = ""
340
+
341
+ begin
342
+ @stdout.each_line do |line|
343
+ line = line.strip
344
+ next if line.empty?
345
+
346
+ json_lines = line.split("\n")
347
+
348
+ json_lines.each do |json_line|
349
+ json_line = json_line.strip
350
+ next if json_line.empty?
351
+
352
+ json_buffer += json_line
353
+
354
+ if json_buffer.length > MAX_BUFFER_SIZE
355
+ json_buffer = ""
356
+ raise CLIJSONDecodeError.new(
357
+ "JSON message exceeded maximum buffer size of #{MAX_BUFFER_SIZE} bytes",
358
+ StandardError.new("Buffer size #{json_buffer.length} exceeds limit #{MAX_BUFFER_SIZE}")
359
+ )
360
+ end
361
+
362
+ begin
363
+ data = JSON.parse(json_buffer)
364
+ json_buffer = ""
365
+ yield data
366
+ rescue JSON::ParserError => e
367
+ # For single-line JSON, if parsing fails, it's an error
368
+ if json_buffer.include?("\n") || json_buffer.length > 1000
369
+ raise CLIJSONDecodeError.new(json_buffer, e)
370
+ end
371
+ # Otherwise continue accumulating
372
+ next
373
+ end
374
+ end
375
+ end
376
+ rescue IOError
377
+ # Process has closed
378
+ end
379
+
380
+ # If there's still data in the buffer, it's invalid JSON
381
+ if json_buffer && !json_buffer.strip.empty?
382
+ # Try one more time to parse in case it's valid JSON
383
+ begin
384
+ data = JSON.parse(json_buffer)
385
+ yield data
386
+ rescue JSON::ParserError => e
387
+ raise CLIJSONDecodeError.new(json_buffer, StandardError.new("Incomplete JSON at end of stream"))
388
+ end
389
+ end
390
+
391
+ # Check for errors
392
+ exit_code = @process.value.exitstatus if @process
393
+ stderr_output = @stderr.read if @stderr
394
+
395
+ if exit_code && exit_code != 0
396
+ raise ProcessError.new(
397
+ "Command failed with exit code #{exit_code}",
398
+ exit_code: exit_code,
399
+ stderr: stderr_output
400
+ )
401
+ end
402
+ end
403
+
404
+ def connected?
405
+ @process && @process.alive?
406
+ end
407
+
408
+ # Send a JSON message via stdin for streaming input mode
409
+ def send_message(message)
410
+ raise CLIConnectionError.new("Not connected to CLI") unless @stdin
411
+
412
+ json_line = message.to_json + "\n"
413
+ puts "Debug: Sending JSON message: #{json_line.strip}" if ENV['DEBUG_CLAUDE_SDK']
414
+
415
+ begin
416
+ @stdin.write(json_line)
417
+ @stdin.flush
418
+ rescue Errno::EPIPE
419
+ # Pipe is broken, process has terminated
420
+ raise CLIConnectionError.new("CLI process terminated unexpectedly")
421
+ end
422
+ end
423
+
424
+ # Send multiple messages and close stdin to signal end of input
425
+ def send_messages(messages)
426
+ raise CLIConnectionError.new("Not connected to CLI") unless @stdin
427
+
428
+ messages.each do |message|
429
+ send_message(message)
430
+ end
431
+
432
+ # Close stdin to signal end of input stream
433
+ @stdin.close
434
+ @stdin = nil
435
+ end
436
+ end
437
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeCode
4
+ class ClaudeSDKError < StandardError
5
+ end
6
+
7
+ class CLIConnectionError < ClaudeSDKError
8
+ end
9
+
10
+ class CLINotFoundError < CLIConnectionError
11
+ attr_reader :cli_path
12
+
13
+ def initialize(message = "Claude Code not found", cli_path: nil)
14
+ @cli_path = cli_path
15
+ message = "#{message}: #{cli_path}" if cli_path
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ class ProcessError < ClaudeSDKError
21
+ attr_reader :exit_code, :stderr
22
+
23
+ def initialize(message = "Process failed", exit_code: nil, stderr: nil)
24
+ @exit_code = exit_code
25
+ @stderr = stderr
26
+
27
+ message = "#{message} (exit code: #{exit_code})" if exit_code
28
+ message = "#{message}\nError output: #{stderr}" if stderr
29
+
30
+ super(message)
31
+ end
32
+ end
33
+
34
+ class CLIJSONDecodeError < ClaudeSDKError
35
+ attr_reader :line, :original_error
36
+
37
+ def initialize(line = nil, original_error = nil)
38
+ @line = line
39
+ @original_error = original_error
40
+
41
+ msg = "Failed to decode JSON"
42
+ msg += ": #{line[0, 100]}..." if line && !line.empty?
43
+ msg += " (#{original_error.message})" if original_error
44
+
45
+ super(msg)
46
+ end
47
+ end
48
+ end