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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rspec_status +114 -0
- data/.rubocop.yml +51 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +288 -0
- data/Rakefile +32 -0
- data/USAGE_EXAMPLES.md +428 -0
- data/claude_code.gemspec +47 -0
- data/docs/README.md +254 -0
- data/docs/mcp_integration.md +404 -0
- data/docs/streaming.md +316 -0
- data/examples/authentication_examples.rb +133 -0
- data/examples/basic_usage.rb +73 -0
- data/examples/conversation_resuming.rb +96 -0
- data/examples/irb_helpers.rb +168 -0
- data/examples/jsonl_cli_equivalent.rb +108 -0
- data/examples/mcp_examples.rb +166 -0
- data/examples/model_examples.rb +111 -0
- data/examples/quick_start.rb +75 -0
- data/examples/rails_sidekiq_example.rb +336 -0
- data/examples/streaming_examples.rb +195 -0
- data/examples/streaming_json_input.rb +171 -0
- data/hello.txt +1 -0
- data/lib/claude_code/client.rb +437 -0
- data/lib/claude_code/errors.rb +48 -0
- data/lib/claude_code/types.rb +237 -0
- data/lib/claude_code/version.rb +5 -0
- data/lib/claude_code.rb +265 -0
- metadata +219 -0
@@ -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
|