claude_agent 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,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module ClaudeAgent
6
+ # Options passed to a spawn function for creating a Claude Code process (TypeScript SDK parity)
7
+ #
8
+ # This allows custom process creation for VMs, containers, remote execution, etc.
9
+ #
10
+ # @example
11
+ # options = SpawnOptions.new(
12
+ # command: "/usr/local/bin/claude",
13
+ # args: ["--output-format", "stream-json"],
14
+ # cwd: "/my/project",
15
+ # env: { "CLAUDE_CODE_ENTRYPOINT" => "sdk-rb" }
16
+ # )
17
+ #
18
+ SpawnOptions = Data.define(:command, :args, :cwd, :env, :abort_signal) do
19
+ def initialize(command:, args: [], cwd: nil, env: {}, abort_signal: nil)
20
+ super
21
+ end
22
+
23
+ # Get the full command line as an array
24
+ # @return [Array<String>]
25
+ def to_command_array
26
+ [ command, *args ]
27
+ end
28
+ end
29
+
30
+ # Interface for spawned process (TypeScript SDK parity)
31
+ #
32
+ # Custom spawn functions must return an object that responds to these methods.
33
+ # This allows wrapping SSH connections, Docker exec, VM instances, etc.
34
+ #
35
+ # @abstract Implement all methods for custom process types
36
+ #
37
+ module SpawnedProcess
38
+ # Write data to process stdin
39
+ # @param data [String] Data to write
40
+ # @return [void]
41
+ def write(data)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ # Read from process stdout
46
+ # @yield [String] Lines from stdout
47
+ # @return [void]
48
+ def read_stdout
49
+ raise NotImplementedError
50
+ end
51
+
52
+ # Read from process stderr
53
+ # @yield [String] Lines from stderr
54
+ # @return [void]
55
+ def read_stderr
56
+ raise NotImplementedError
57
+ end
58
+
59
+ # Close stdin to signal end of input
60
+ # @return [void]
61
+ def close_stdin
62
+ raise NotImplementedError
63
+ end
64
+
65
+ # Terminate the process gracefully (SIGTERM equivalent)
66
+ # @param timeout [Numeric] Seconds to wait before force kill
67
+ # @return [void]
68
+ def terminate(timeout: 5)
69
+ raise NotImplementedError
70
+ end
71
+
72
+ # Force kill the process (SIGKILL equivalent)
73
+ # @return [void]
74
+ def kill
75
+ raise NotImplementedError
76
+ end
77
+
78
+ # Check if process is still running
79
+ # @return [Boolean]
80
+ def running?
81
+ raise NotImplementedError
82
+ end
83
+
84
+ # Get process exit status
85
+ # @return [Integer, nil]
86
+ def exit_status
87
+ raise NotImplementedError
88
+ end
89
+
90
+ # Close all streams
91
+ # @return [void]
92
+ def close
93
+ raise NotImplementedError
94
+ end
95
+ end
96
+
97
+ # Local spawned process wrapping Open3.popen3 (TypeScript SDK parity)
98
+ #
99
+ # This is the default implementation used when no custom spawn function is provided.
100
+ #
101
+ # @example
102
+ # process = LocalSpawnedProcess.spawn(options)
103
+ # process.write('{"type":"user"}\n')
104
+ # process.read_stdout { |line| puts line }
105
+ # process.close
106
+ #
107
+ class LocalSpawnedProcess
108
+ include SpawnedProcess
109
+
110
+ attr_reader :pid, :stdin, :stdout, :stderr, :wait_thread
111
+
112
+ # Spawn a new local process
113
+ # @param spawn_options [SpawnOptions] Options for spawning
114
+ # @return [LocalSpawnedProcess]
115
+ def self.spawn(spawn_options)
116
+ cmd = spawn_options.to_command_array
117
+ env = spawn_options.env || {}
118
+ cwd = spawn_options.cwd
119
+
120
+ opts = {}
121
+ opts[:chdir] = cwd if cwd && Dir.exist?(cwd)
122
+
123
+ stdin, stdout, stderr, wait_thread = Open3.popen3(env, *cmd, **opts)
124
+
125
+ new(stdin: stdin, stdout: stdout, stderr: stderr, wait_thread: wait_thread)
126
+ end
127
+
128
+ def initialize(stdin:, stdout:, stderr:, wait_thread:)
129
+ @stdin = stdin
130
+ @stdout = stdout
131
+ @stderr = stderr
132
+ @wait_thread = wait_thread
133
+ @killed = false
134
+ @mutex = Mutex.new
135
+ end
136
+
137
+ def write(data)
138
+ @mutex.synchronize do
139
+ return if @stdin.closed?
140
+
141
+ @stdin.write(data)
142
+ @stdin.write("\n") unless data.end_with?("\n")
143
+ @stdin.flush
144
+ end
145
+ rescue Errno::EPIPE
146
+ # Process terminated
147
+ end
148
+
149
+ def read_stdout(&block)
150
+ return enum_for(:read_stdout) unless block_given?
151
+
152
+ @stdout.each_line(&block)
153
+ rescue IOError
154
+ # Stream closed
155
+ end
156
+
157
+ def read_stderr(&block)
158
+ return enum_for(:read_stderr) unless block_given?
159
+
160
+ @stderr.each_line(&block)
161
+ rescue IOError
162
+ # Stream closed
163
+ end
164
+
165
+ def close_stdin
166
+ @mutex.synchronize do
167
+ @stdin.close unless @stdin.closed?
168
+ end
169
+ end
170
+
171
+ def terminate(timeout: 5)
172
+ return unless running?
173
+
174
+ pid = @wait_thread.pid
175
+ begin
176
+ Process.kill("TERM", pid)
177
+ rescue Errno::ESRCH, Errno::EPERM
178
+ return
179
+ end
180
+
181
+ unless @wait_thread.join(timeout)
182
+ kill
183
+ end
184
+ end
185
+
186
+ def kill
187
+ return unless running?
188
+
189
+ @mutex.synchronize { @killed = true }
190
+ pid = @wait_thread.pid
191
+ begin
192
+ Process.kill("KILL", pid)
193
+ rescue Errno::ESRCH, Errno::EPERM
194
+ # Already dead
195
+ end
196
+ end
197
+
198
+ def running?
199
+ @wait_thread.alive?
200
+ end
201
+
202
+ def exit_status
203
+ @wait_thread.value&.exitstatus
204
+ end
205
+
206
+ def killed?
207
+ @killed
208
+ end
209
+
210
+ def close
211
+ @mutex.synchronize do
212
+ @stdin.close unless @stdin.closed?
213
+ @stdout.close unless @stdout.closed?
214
+ @stderr.close unless @stderr.closed?
215
+ end
216
+ @wait_thread.value
217
+ end
218
+ end
219
+
220
+ # Default spawn function for local subprocess execution
221
+ #
222
+ # This lambda is used when no custom spawn_claude_code_process is provided.
223
+ # It creates a LocalSpawnedProcess using Open3.popen3.
224
+ #
225
+ # @example Custom spawn for Docker
226
+ # custom_spawn = ->(opts) {
227
+ # docker_cmd = ["docker", "exec", "-i", "my-container", opts.command, *opts.args]
228
+ # DockerProcess.new(docker_cmd, env: opts.env)
229
+ # }
230
+ # options = ClaudeAgent::Options.new(spawn_claude_code_process: custom_spawn)
231
+ #
232
+ DEFAULT_SPAWN = ->(spawn_options) {
233
+ LocalSpawnedProcess.spawn(spawn_options)
234
+ }.freeze
235
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ module Transport
5
+ # Abstract base class for transport implementations
6
+ #
7
+ # Transports handle the low-level communication with Claude Code CLI
8
+ # or other backends. They are responsible for:
9
+ # - Starting/stopping the connection
10
+ # - Writing messages (JSON Lines format)
11
+ # - Reading and parsing responses
12
+ #
13
+ # @abstract Subclass and implement all abstract methods
14
+ #
15
+ class Base
16
+ # Establish the connection
17
+ # @return [void]
18
+ def connect
19
+ raise NotImplementedError, "#{self.class} must implement #connect"
20
+ end
21
+
22
+ # Write data to the transport
23
+ # @param data [String] JSON string to write (newline will be added)
24
+ # @return [void]
25
+ def write(data)
26
+ raise NotImplementedError, "#{self.class} must implement #write"
27
+ end
28
+
29
+ # Read messages from the transport
30
+ # @yield [Hash] Parsed JSON messages
31
+ # @return [Enumerator] If no block given
32
+ def read_messages(&block)
33
+ raise NotImplementedError, "#{self.class} must implement #read_messages"
34
+ end
35
+
36
+ # Signal end of input (close stdin for subprocess)
37
+ # @return [void]
38
+ def end_input
39
+ raise NotImplementedError, "#{self.class} must implement #end_input"
40
+ end
41
+
42
+ # Close the transport and cleanup resources
43
+ # @return [void]
44
+ def close
45
+ raise NotImplementedError, "#{self.class} must implement #close"
46
+ end
47
+
48
+ # Check if the transport is ready for communication
49
+ # @return [Boolean]
50
+ def ready?
51
+ raise NotImplementedError, "#{self.class} must implement #ready?"
52
+ end
53
+
54
+ # Check if the transport is connected
55
+ # @return [Boolean]
56
+ def connected?
57
+ raise NotImplementedError, "#{self.class} must implement #connected?"
58
+ end
59
+ end
60
+ end
61
+ end