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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +432 -0
- data/lib/claude_agent_sdk/errors.rb +53 -0
- data/lib/claude_agent_sdk/message_parser.rb +110 -0
- data/lib/claude_agent_sdk/query.rb +442 -0
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +165 -0
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +365 -0
- data/lib/claude_agent_sdk/transport.rb +44 -0
- data/lib/claude_agent_sdk/types.rb +358 -0
- data/lib/claude_agent_sdk/version.rb +5 -0
- data/lib/claude_agent_sdk.rb +256 -0
- metadata +126 -0
| @@ -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
         |