claude-code-sdk-ruby 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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSDK
4
+ # Base exception for all Claude SDK errors
5
+ #
6
+ # @abstract All Claude SDK errors inherit from this class
7
+ class Error < StandardError; end
8
+
9
+ # Raised when unable to connect to Claude Code
10
+ #
11
+ # @see CLINotFoundError
12
+ class CLIConnectionError < Error; end
13
+
14
+ # Raised when Claude Code is not found or not installed
15
+ #
16
+ # @example
17
+ # raise CLINotFoundError.new("Claude Code not found", cli_path: "/usr/local/bin/claude")
18
+ class CLINotFoundError < CLIConnectionError
19
+ # @return [String, nil] the path where Claude Code was expected
20
+ attr_reader :cli_path
21
+
22
+ # @param message [String] the error message
23
+ # @param cli_path [String, nil] the path where Claude Code was expected
24
+ def initialize(message: "Claude Code not found", cli_path: nil)
25
+ @cli_path = cli_path
26
+ message = "#{message}: #{cli_path}" if cli_path
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ # Raised when the CLI process fails
32
+ #
33
+ # @example
34
+ # raise ProcessError.new("Command failed", exit_code: 1, stderr: "Error output")
35
+ class ProcessError < Error
36
+ # @return [Integer, nil] the process exit code
37
+ attr_reader :exit_code
38
+
39
+ # @return [String, nil] the stderr output from the process
40
+ attr_reader :stderr
41
+
42
+ # @param message [String] the error message
43
+ # @param exit_code [Integer, nil] the process exit code
44
+ # @param stderr [String, nil] the stderr output
45
+ def initialize(message, exit_code: nil, stderr: nil)
46
+ @exit_code = exit_code
47
+ @stderr = stderr
48
+
49
+ message = "#{message} (exit code: #{exit_code})" if exit_code
50
+ message = "#{message}\nError output: #{stderr}" if stderr
51
+
52
+ super(message)
53
+ end
54
+ end
55
+
56
+ # Raised when unable to decode JSON from CLI output
57
+ #
58
+ # @example
59
+ # raise CLIJSONDecodeError.new("invalid json", original_error: JSON::ParserError.new)
60
+ class CLIJSONDecodeError < Error
61
+ # @return [String] the line that failed to decode
62
+ attr_reader :line
63
+
64
+ # @return [Exception] the original JSON parsing error
65
+ attr_reader :original_error
66
+
67
+ # @param line [String] the line that failed to decode
68
+ # @param original_error [Exception] the original JSON parsing error
69
+ def initialize(line:, original_error:)
70
+ @line = line
71
+ @original_error = original_error
72
+ super("Failed to decode JSON: #{line[0..99]}...")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require_relative "transport/subprocess_cli"
5
+
6
+ module ClaudeSDK
7
+ module Internal
8
+ # Internal client implementation for processing queries through transport
9
+ #
10
+ # This client handles the communication with Claude Code CLI via the
11
+ # transport layer and parses the received messages into Ruby objects.
12
+ #
13
+ # @example
14
+ # client = InternalClient.new
15
+ # Async do
16
+ # client.process_query("Hello Claude", options) do |message|
17
+ # puts message
18
+ # end
19
+ # end
20
+ class InternalClient
21
+ # Initialize the internal client
22
+ def initialize
23
+ # Currently no initialization needed
24
+ end
25
+
26
+ # Process a query through transport
27
+ #
28
+ # @param prompt [String] the prompt to send to Claude
29
+ # @param options [ClaudeCodeOptions] configuration options
30
+ # @yield [Messages::User, Messages::Assistant, Messages::System, Messages::Result] parsed message
31
+ # @return [Enumerator] if no block given
32
+ def process_query(prompt:, options:)
33
+ return enum_for(:process_query, prompt: prompt, options: options) unless block_given?
34
+
35
+ transport = SubprocessCLI.new(
36
+ prompt: prompt,
37
+ options: options,
38
+ )
39
+
40
+ begin
41
+ transport.connect
42
+
43
+ transport.receive_messages do |data|
44
+ message = parse_message(data)
45
+ yield message if message
46
+ end
47
+ ensure
48
+ transport.disconnect
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # Parse message from CLI output
55
+ #
56
+ # @param data [Hash] raw message data from CLI
57
+ # @return [Messages::User, Messages::Assistant, Messages::System, Messages::Result, nil] parsed message
58
+ def parse_message(data)
59
+ case data["type"]
60
+ when "user"
61
+ Messages::User.new(
62
+ content: data.dig("message", "content"),
63
+ )
64
+
65
+ when "assistant"
66
+ content_blocks = parse_content_blocks(data.dig("message", "content") || [])
67
+ Messages::Assistant.new(content: content_blocks)
68
+
69
+ when "system"
70
+ Messages::System.new(
71
+ subtype: data["subtype"],
72
+ data: data,
73
+ )
74
+
75
+ when "result"
76
+ Messages::Result.new(
77
+ subtype: data["subtype"],
78
+ duration_ms: data["duration_ms"],
79
+ duration_api_ms: data["duration_api_ms"],
80
+ is_error: data["is_error"],
81
+ num_turns: data["num_turns"],
82
+ session_id: data["session_id"],
83
+ total_cost_usd: data["total_cost_usd"],
84
+ usage: data["usage"],
85
+ result: data["result"],
86
+ )
87
+
88
+ end
89
+ end
90
+
91
+ # Parse content blocks from assistant message
92
+ #
93
+ # @param blocks [Array<Hash>] raw content blocks
94
+ # @return [Array<ContentBlock::Text, ContentBlock::ToolUse, ContentBlock::ToolResult>] parsed blocks
95
+ def parse_content_blocks(blocks)
96
+ blocks.map do |block|
97
+ case block["type"]
98
+ when "text"
99
+ ContentBlock::Text.new(
100
+ text: block["text"],
101
+ )
102
+
103
+ when "tool_use"
104
+ ContentBlock::ToolUse.new(
105
+ id: block["id"],
106
+ name: block["name"],
107
+ input: block["input"],
108
+ )
109
+
110
+ when "tool_result"
111
+ ContentBlock::ToolResult.new(
112
+ tool_use_id: block["tool_use_id"],
113
+ content: block["content"],
114
+ is_error: block["is_error"],
115
+ )
116
+
117
+ end
118
+ end.compact
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "open3"
5
+ require "json"
6
+ require "logger"
7
+ require "pathname"
8
+ require "timeout"
9
+ require_relative "../transport"
10
+
11
+ module ClaudeSDK
12
+ module Internal
13
+ # Subprocess transport implementation using Claude Code CLI
14
+ #
15
+ # This transport launches the Claude Code CLI as a subprocess and
16
+ # communicates with it via JSON streaming on stdout/stderr.
17
+ #
18
+ # @example
19
+ # transport = SubprocessCLI.new(
20
+ # prompt: "Hello Claude",
21
+ # options: ClaudeCodeOptions.new
22
+ # )
23
+ # transport.connect
24
+ # transport.receive_messages do |message|
25
+ # puts message
26
+ # end
27
+ # transport.disconnect
28
+ class SubprocessCLI < Transport
29
+ # Maximum buffer size for JSON messages (1MB)
30
+ MAX_BUFFER_SIZE = 1024 * 1024
31
+
32
+ # @return [Logger] the logger instance
33
+ attr_reader :logger
34
+
35
+ # Initialize subprocess transport
36
+ #
37
+ # @param prompt [String] the prompt to send to Claude
38
+ # @param options [ClaudeCodeOptions] configuration options
39
+ # @param cli_path [String, Pathname, nil] path to Claude CLI binary
40
+ def initialize(prompt:, options:, cli_path: nil) # rubocop:disable Lint/MissingSuper
41
+ @prompt = prompt
42
+ @options = options
43
+ @cli_path = cli_path ? cli_path.to_s : find_cli
44
+ @cwd = options.cwd&.to_s
45
+ @pid = nil
46
+ @stdin = nil
47
+ @stdout = nil
48
+ @stderr = nil
49
+ @wait_thread = nil
50
+ @logger = Logger.new($stderr)
51
+ @logger.level = ENV["CLAUDE_SDK_DEBUG"] ? Logger::DEBUG : Logger::INFO
52
+ end
53
+
54
+ # Connect to Claude Code CLI
55
+ #
56
+ # @return [void]
57
+ # @raise [CLIConnectionError] if unable to start the CLI
58
+ def connect
59
+ return if @pid
60
+
61
+ cmd = build_command
62
+ logger.debug("Executing command: #{cmd.join(" ")}")
63
+
64
+ begin
65
+ env = ENV.to_h.merge("CLAUDE_CODE_ENTRYPOINT" => "sdk-ruby")
66
+
67
+ # Build spawn options
68
+ spawn_options = {}
69
+ spawn_options[:chdir] = @cwd if @cwd
70
+
71
+ # Use Open3 to spawn process with pipes
72
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(env, *cmd, **spawn_options)
73
+ @pid = @wait_thread.pid
74
+
75
+ # Close stdin since we don't need it
76
+ @stdin.close
77
+ rescue Errno::ENOENT
78
+ # Check if error is from working directory or CLI
79
+ raise CLIConnectionError, "Working directory does not exist: #{@cwd}" if @cwd && !File.directory?(@cwd)
80
+
81
+ raise CLINotFoundError.new(
82
+ message: "Claude Code not found",
83
+ cli_path: @cli_path,
84
+ )
85
+ rescue StandardError => e
86
+ raise CLIConnectionError, "Failed to start Claude Code: #{e.message}"
87
+ end
88
+ end
89
+
90
+ # Disconnect from Claude Code CLI
91
+ #
92
+ # @return [void]
93
+ def disconnect
94
+ return unless @pid
95
+
96
+ begin
97
+ # Try to terminate gracefully
98
+ Process.kill("INT", @pid)
99
+
100
+ # Wait for process to exit with timeout using the wait thread
101
+ if @wait_thread&.alive?
102
+ begin
103
+ Timeout.timeout(5) do
104
+ @wait_thread.join
105
+ end
106
+ rescue Timeout::Error
107
+ # Force kill if it doesn't exit gracefully
108
+ begin
109
+ Process.kill("KILL", @pid)
110
+ rescue StandardError
111
+ nil
112
+ end
113
+ begin
114
+ @wait_thread.join
115
+ rescue StandardError
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ rescue Errno::ESRCH, Errno::ECHILD
121
+ # Process already gone
122
+ ensure
123
+ @stdin&.close
124
+ @stdout&.close
125
+ @stderr&.close
126
+ @stdin = nil
127
+ @stdout = nil
128
+ @stderr = nil
129
+ @pid = nil
130
+ @wait_thread = nil
131
+ end
132
+ end
133
+
134
+ # Send request (not used for CLI transport - args passed via command line)
135
+ #
136
+ # @param messages [Array<Hash>] messages (ignored)
137
+ # @param options [Hash] options (ignored)
138
+ # @return [void]
139
+ def send_request(messages, options)
140
+ # Not used - all arguments passed via command line
141
+ end
142
+
143
+ # Receive messages from CLI output
144
+ #
145
+ # @yield [Hash] parsed JSON message from CLI
146
+ # @return [Enumerator<Hash>] if no block given
147
+ # @raise [ProcessError] if CLI exits with non-zero status
148
+ # @raise [CLIJSONDecodeError] if unable to parse JSON
149
+ def receive_messages
150
+ return enum_for(:receive_messages) unless block_given?
151
+
152
+ raise CLIConnectionError, "Not connected" unless @pid
153
+
154
+ json_buffer = ""
155
+ stderr_lines = []
156
+
157
+ # Read stdout directly (not in async task to avoid conflicts)
158
+ logger.debug("Starting to read stdout...")
159
+ if @stdout
160
+ begin
161
+ @stdout.each_line do |line|
162
+ logger.debug("Read line: #{line.inspect}")
163
+ line_str = line.strip
164
+ next if line_str.empty?
165
+
166
+ # Split by newlines in case multiple JSON objects are on one line
167
+ json_lines = line_str.split("\n")
168
+
169
+ json_lines.each do |json_line|
170
+ json_line.strip!
171
+ next if json_line.empty?
172
+
173
+ # Keep accumulating partial JSON until we can parse it
174
+ json_buffer += json_line
175
+
176
+ if json_buffer.length > MAX_BUFFER_SIZE
177
+ json_buffer = ""
178
+ raise CLIJSONDecodeError.new(
179
+ line: "Buffer exceeded #{MAX_BUFFER_SIZE} bytes",
180
+ original_error: StandardError.new("Buffer overflow"),
181
+ )
182
+ end
183
+
184
+ begin
185
+ data = JSON.parse(json_buffer)
186
+ json_buffer = ""
187
+ logger.debug("Parsed JSON: #{data["type"]}")
188
+ yield data
189
+ rescue JSON::ParserError
190
+ # Keep accumulating
191
+ end
192
+ end
193
+ end
194
+ rescue IOError, Errno::EBADF
195
+ # Stream was closed, that's ok
196
+ end
197
+ end
198
+
199
+ # Collect stderr
200
+ if @stderr
201
+ begin
202
+ @stderr.each_line do |line|
203
+ stderr_lines << line.strip
204
+ end
205
+ rescue IOError, Errno::EBADF
206
+ # Stream was closed, that's ok
207
+ end
208
+ end
209
+
210
+ # Wait for process completion
211
+ exit_status = nil
212
+ if @wait_thread
213
+ logger.debug("Waiting for process to complete...")
214
+ @wait_thread.join
215
+ exit_status = @wait_thread.value.exitstatus
216
+ logger.debug("Process completed with status: #{exit_status}")
217
+ end
218
+
219
+ stderr_output = stderr_lines.join("\n")
220
+
221
+ # Check exit status
222
+ if exit_status && exit_status != 0
223
+ raise ProcessError.new(
224
+ "Command failed with exit code #{exit_status}",
225
+ exit_code: exit_status,
226
+ stderr: stderr_output,
227
+ )
228
+ elsif !stderr_output.empty?
229
+ logger.debug("Process stderr: #{stderr_output}")
230
+ end
231
+ end
232
+
233
+ # Check if subprocess is running
234
+ #
235
+ # @return [Boolean] true if connected and running
236
+ def connected?
237
+ return false unless @pid
238
+
239
+ # Check if process is still running
240
+ Process.kill(0, @pid)
241
+ true
242
+ rescue Errno::ESRCH, Errno::EPERM
243
+ false
244
+ end
245
+
246
+ private
247
+
248
+ # Find Claude Code CLI binary
249
+ #
250
+ # @return [String] path to CLI binary
251
+ # @raise [CLINotFoundError] if CLI not found
252
+ def find_cli
253
+ # Check PATH first
254
+ if (cli_path = which("claude"))
255
+ return cli_path
256
+ end
257
+
258
+ # Check common locations
259
+ locations = [
260
+ Pathname.new(File.expand_path("~/.npm-global/bin/claude")),
261
+ Pathname.new("/usr/local/bin/claude"),
262
+ Pathname.new(File.expand_path("~/.local/bin/claude")),
263
+ Pathname.new(File.expand_path("~/node_modules/.bin/claude")),
264
+ Pathname.new(File.expand_path("~/.yarn/bin/claude")),
265
+ ]
266
+
267
+ locations.each do |path|
268
+ return path.to_s if path.exist? && path.file?
269
+ end
270
+
271
+ # Check if Node.js is installed
272
+ node_installed = !which("node").nil?
273
+
274
+ unless node_installed
275
+ error_msg = "Claude Code requires Node.js, which is not installed.\n\n"
276
+ error_msg += "Install Node.js from: https://nodejs.org/\n"
277
+ error_msg += "\nAfter installing Node.js, install Claude Code:\n"
278
+ error_msg += " npm install -g @anthropic-ai/claude-code"
279
+ raise CLINotFoundError.new(message: error_msg, cli_path: @cli_path)
280
+ end
281
+
282
+ # Node is installed but Claude Code isn't
283
+ error_msg = <<~MSG
284
+ Claude Code not found. Install with:
285
+ npm install -g @anthropic-ai/claude-code
286
+
287
+ If already installed locally, try:
288
+ export PATH="$HOME/node_modules/.bin:$PATH"
289
+
290
+ Or specify the path when creating transport:
291
+ SubprocessCLI.new(..., cli_path: '/path/to/claude')
292
+ MSG
293
+ raise CLINotFoundError.new(message: error_msg, cli_path: @cli_path)
294
+ end
295
+
296
+ # Cross-platform which command
297
+ #
298
+ # @param cmd [String] command to find
299
+ # @return [String, nil] path to command or nil
300
+ def which(cmd)
301
+ exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
302
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
303
+ exts.each do |ext|
304
+ exe = File.join(path, "#{cmd}#{ext}")
305
+ return exe if File.executable?(exe) && !File.directory?(exe)
306
+ end
307
+ end
308
+ nil
309
+ end
310
+
311
+ # Build CLI command with arguments
312
+ #
313
+ # @return [Array<String>] command array
314
+ def build_command
315
+ cmd = [@cli_path, "--output-format", "stream-json", "--verbose"]
316
+
317
+ cmd.push("--system-prompt", @options.system_prompt) if @options.system_prompt
318
+
319
+ cmd.push("--append-system-prompt", @options.append_system_prompt) if @options.append_system_prompt
320
+
321
+ if @options.allowed_tools && !@options.allowed_tools.empty?
322
+ cmd.push("--allowedTools", @options.allowed_tools.join(","))
323
+ end
324
+
325
+ cmd.push("--max-turns", @options.max_turns.to_s) if @options.max_turns
326
+
327
+ if @options.disallowed_tools && !@options.disallowed_tools.empty?
328
+ cmd.push("--disallowedTools", @options.disallowed_tools.join(","))
329
+ end
330
+
331
+ cmd.push("--model", @options.model) if @options.model
332
+
333
+ if @options.permission_prompt_tool_name
334
+ cmd.push("--permission-prompt-tool", @options.permission_prompt_tool_name)
335
+ end
336
+
337
+ cmd.push("--permission-mode", @options.permission_mode.to_s) if @options.permission_mode
338
+
339
+ cmd.push("--continue") if @options.continue_conversation
340
+
341
+ cmd.push("--resume", @options.resume) if @options.resume
342
+
343
+ if @options.mcp_servers && !@options.mcp_servers.empty?
344
+ mcp_config = { "mcpServers" => serialize_mcp_servers }
345
+ cmd.push("--mcp-config", JSON.generate(mcp_config))
346
+ end
347
+
348
+ cmd.push("--print", @prompt)
349
+ cmd
350
+ end
351
+
352
+ # Serialize MCP servers for CLI
353
+ #
354
+ # @return [Hash] serialized MCP servers
355
+ def serialize_mcp_servers
356
+ @options.mcp_servers.transform_values do |server|
357
+ server.respond_to?(:to_h) ? server.to_h : server
358
+ end
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSDK
4
+ module Internal
5
+ # Abstract transport interface for Claude communication
6
+ #
7
+ # @abstract Subclass and override {#connect}, {#disconnect}, {#send_request},
8
+ # {#receive_messages}, and {#connected?} to implement a transport
9
+ class Transport
10
+ # Initialize connection
11
+ #
12
+ # @abstract
13
+ # @return [void]
14
+ def connect
15
+ raise NotImplementedError, "#{self.class}#connect not implemented"
16
+ end
17
+
18
+ # Close connection
19
+ #
20
+ # @abstract
21
+ # @return [void]
22
+ def disconnect
23
+ raise NotImplementedError, "#{self.class}#disconnect not implemented"
24
+ end
25
+
26
+ # Send request to Claude
27
+ #
28
+ # @abstract
29
+ # @param messages [Array<Hash>] the messages to send
30
+ # @param options [Hash] additional options
31
+ # @return [void]
32
+ def send_request(messages, options)
33
+ raise NotImplementedError, "#{self.class}#send_request not implemented"
34
+ end
35
+
36
+ # Receive messages from Claude
37
+ #
38
+ # @abstract
39
+ # @return [Enumerator<Hash>] yields message hashes
40
+ def receive_messages
41
+ raise NotImplementedError, "#{self.class}#receive_messages not implemented"
42
+ end
43
+
44
+ # Check if transport is connected
45
+ #
46
+ # @abstract
47
+ # @return [Boolean] true if connected
48
+ def connected?
49
+ raise NotImplementedError, "#{self.class}#connected? not implemented"
50
+ end
51
+ end
52
+ end
53
+ end