Class: ClaudeSDK::Internal::SubprocessCLI
- Defined in:
- lib/claude_sdk/internal/transport/subprocess_cli.rb
Overview
Subprocess transport implementation using Claude Code CLI
This transport launches the Claude Code CLI as a subprocess and communicates with it via JSON streaming on stdout/stderr.
Constant Summary collapse
- MAX_BUFFER_SIZE =
Maximum buffer size for JSON messages (1MB)
1024 * 1024
Instance Attribute Summary collapse
-
#logger ⇒ Logger
readonly
The logger instance.
Instance Method Summary collapse
-
#build_command ⇒ Array<String>
private
Build CLI command with arguments.
-
#connect ⇒ void
Connect to Claude Code CLI.
-
#connected? ⇒ Boolean
Check if subprocess is running.
-
#disconnect ⇒ void
Disconnect from Claude Code CLI.
-
#find_cli ⇒ String
private
Find Claude Code CLI binary.
-
#format_permission_mode(mode) ⇒ String
private
Convert permission mode from Ruby symbol format to CLI camelCase format.
-
#initialize(prompt:, options:, cli_path: nil) ⇒ SubprocessCLI
constructor
Initialize subprocess transport.
-
#receive_messages {|Hash| ... } ⇒ Enumerator<Hash>
Receive messages from CLI output.
-
#send_request(messages, options) ⇒ void
Send request (not used for CLI transport - args passed via command line).
-
#serialize_mcp_servers ⇒ Hash
private
Serialize MCP servers for CLI.
-
#which(cmd) ⇒ String?
private
Cross-platform which command.
Constructor Details
#initialize(prompt:, options:, cli_path: nil) ⇒ SubprocessCLI
Initialize subprocess transport
40 41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 40 def initialize(prompt:, options:, cli_path: nil) # rubocop:disable Lint/MissingSuper @prompt = prompt @options = @cli_path = cli_path ? cli_path.to_s : find_cli @cwd = .cwd&.to_s @pid = nil @stdin = nil @stdout = nil @stderr = nil @wait_thread = nil @logger = Logger.new($stderr) @logger.level = ENV["CLAUDE_SDK_DEBUG"] ? Logger::DEBUG : Logger::INFO end |
Instance Attribute Details
#logger ⇒ Logger (readonly)
Returns the logger instance.
33 34 35 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 33 def logger @logger end |
Instance Method Details
#build_command ⇒ Array<String> (private)
Build CLI command with arguments
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 314 def build_command cmd = [@cli_path, "--output-format", "stream-json", "--verbose"] cmd.push("--system-prompt", @options.system_prompt) if @options.system_prompt cmd.push("--append-system-prompt", @options.append_system_prompt) if @options.append_system_prompt if @options.allowed_tools && !@options.allowed_tools.empty? cmd.push("--allowedTools", @options.allowed_tools.join(",")) end cmd.push("--max-turns", @options.max_turns.to_s) if @options.max_turns if @options.disallowed_tools && !@options.disallowed_tools.empty? cmd.push("--disallowedTools", @options.disallowed_tools.join(",")) end cmd.push("--model", @options.model) if @options.model if @options. cmd.push("--permission-prompt-tool", @options.) end cmd.push("--permission-mode", (@options.)) if @options. cmd.push("--continue") if @options.continue_conversation cmd.push("--resume", @options.resume) if @options.resume cmd.push("--session-id", @options.session_id) if @options.session_id cmd.push("--settings", @options.settings.to_s) if @options.settings if @options.mcp_servers && !@options.mcp_servers.empty? mcp_config = { "mcpServers" => serialize_mcp_servers } cmd.push("--mcp-config", JSON.generate(mcp_config)) end cmd.push("--print", @prompt) cmd end |
#connect ⇒ void
This method returns an undefined value.
Connect to Claude Code CLI
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 58 def connect return if @pid cmd = build_command logger.debug("Executing command: #{cmd.join(" ")}") begin env = ENV.to_h.merge("CLAUDE_CODE_ENTRYPOINT" => "sdk-ruby") # Build spawn options = {} [:chdir] = @cwd if @cwd # Use Open3 to spawn process with pipes @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(env, *cmd, **) @pid = @wait_thread.pid # Close stdin since we don't need it @stdin.close rescue Errno::ENOENT # Check if error is from working directory or CLI raise CLIConnectionError, "Working directory does not exist: #{@cwd}" if @cwd && !File.directory?(@cwd) raise CLINotFoundError.new( message: "Claude Code not found", cli_path: @cli_path, ) rescue StandardError => e raise CLIConnectionError, "Failed to start Claude Code: #{e.}" end end |
#connected? ⇒ Boolean
Check if subprocess is running
236 237 238 239 240 241 242 243 244 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 236 def connected? return false unless @pid # Check if process is still running Process.kill(0, @pid) true rescue Errno::ESRCH, Errno::EPERM false end |
#disconnect ⇒ void
This method returns an undefined value.
Disconnect from Claude Code CLI
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 93 def disconnect return unless @pid begin # Try to terminate gracefully Process.kill("INT", @pid) # Wait for process to exit with timeout using the wait thread if @wait_thread&.alive? begin Timeout.timeout(5) do @wait_thread.join end rescue Timeout::Error # Force kill if it doesn't exit gracefully begin Process.kill("KILL", @pid) rescue StandardError nil end begin @wait_thread.join rescue StandardError nil end end end rescue Errno::ESRCH, Errno::ECHILD # Process already gone ensure @stdin&.close @stdout&.close @stderr&.close @stdin = nil @stdout = nil @stderr = nil @pid = nil @wait_thread = nil end end |
#find_cli ⇒ String (private)
Find Claude Code CLI binary
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 252 def find_cli # Check PATH first if (cli_path = which("claude")) return cli_path end # Check common locations locations = [ Pathname.new(File.("~/.npm-global/bin/claude")), Pathname.new("/usr/local/bin/claude"), Pathname.new(File.("~/.local/bin/claude")), Pathname.new(File.("~/node_modules/.bin/claude")), Pathname.new(File.("~/.yarn/bin/claude")), ] locations.each do |path| return path.to_s if path.exist? && path.file? end # Check if Node.js is installed node_installed = !which("node").nil? unless node_installed error_msg = "Claude Code requires Node.js, which is not installed.\n\n" error_msg += "Install Node.js from: https://nodejs.org/\n" error_msg += "\nAfter installing Node.js, install Claude Code:\n" error_msg += " npm install -g @anthropic-ai/claude-code" raise CLINotFoundError.new(message: error_msg, cli_path: @cli_path) end # Node is installed but Claude Code isn't error_msg = <<~MSG Claude Code not found. Install with: npm install -g @anthropic-ai/claude-code If already installed locally, try: export PATH="$HOME/node_modules/.bin:$PATH" Or specify the path when creating transport: SubprocessCLI.new(..., cli_path: '/path/to/claude') MSG raise CLINotFoundError.new(message: error_msg, cli_path: @cli_path) end |
#format_permission_mode(mode) ⇒ String (private)
Convert permission mode from Ruby symbol format to CLI camelCase format
369 370 371 372 373 374 375 376 377 378 379 380 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 369 def (mode) case mode when :default "default" when :accept_edits "acceptEdits" when :bypass_permissions "bypassPermissions" else mode.to_s end end |
#receive_messages {|Hash| ... } ⇒ Enumerator<Hash>
Receive messages from CLI output
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 149 def return enum_for(:receive_messages) unless block_given? raise CLIConnectionError, "Not connected" unless @pid json_buffer = "" stderr_lines = [] # Read stdout directly (not in async task to avoid conflicts) logger.debug("Starting to read stdout...") if @stdout begin @stdout.each_line do |line| logger.debug("Read line: #{line.inspect}") line_str = line.strip next if line_str.empty? # Split by newlines in case multiple JSON objects are on one line json_lines = line_str.split("\n") json_lines.each do |json_line| json_line.strip! next if json_line.empty? # Keep accumulating partial JSON until we can parse it json_buffer += json_line if json_buffer.length > MAX_BUFFER_SIZE json_buffer = "" raise CLIJSONDecodeError.new( line: "Buffer exceeded #{MAX_BUFFER_SIZE} bytes", original_error: StandardError.new("Buffer overflow"), ) end begin data = JSON.parse(json_buffer) json_buffer = "" logger.debug("Parsed JSON: #{data["type"]}") yield data rescue JSON::ParserError # Keep accumulating end end end rescue IOError, Errno::EBADF # Stream was closed, that's ok end end # Collect stderr if @stderr begin @stderr.each_line do |line| stderr_lines << line.strip end rescue IOError, Errno::EBADF # Stream was closed, that's ok end end # Wait for process completion exit_status = nil if @wait_thread logger.debug("Waiting for process to complete...") @wait_thread.join exit_status = @wait_thread.value.exitstatus logger.debug("Process completed with status: #{exit_status}") end stderr_output = stderr_lines.join("\n") # Check exit status if exit_status && exit_status != 0 raise ProcessError.new( "Command failed with exit code #{exit_status}", exit_code: exit_status, stderr: stderr_output, ) elsif !stderr_output.empty? logger.debug("Process stderr: #{stderr_output}") end end |
#send_request(messages, options) ⇒ void
This method returns an undefined value.
Send request (not used for CLI transport - args passed via command line)
139 140 141 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 139 def send_request(, ) # Not used - all arguments passed via command line end |
#serialize_mcp_servers ⇒ Hash (private)
Serialize MCP servers for CLI
359 360 361 362 363 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 359 def serialize_mcp_servers @options.mcp_servers.transform_values do |server| server.respond_to?(:to_h) ? server.to_h : server end end |
#which(cmd) ⇒ String? (private)
Cross-platform which command
300 301 302 303 304 305 306 307 308 309 |
# File 'lib/claude_sdk/internal/transport/subprocess_cli.rb', line 300 def which(cmd) exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""] ENV["PATH"].split(File::PATH_SEPARATOR).each do |path| exts.each do |ext| exe = File.join(path, "#{cmd}#{ext}") return exe if File.executable?(exe) && !File.directory?(exe) end end nil end |