codex-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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +145 -0
- data/lib/codex_sdk/agent_thread.rb +102 -0
- data/lib/codex_sdk/client.rb +27 -0
- data/lib/codex_sdk/config_serializer.rb +61 -0
- data/lib/codex_sdk/events.rb +50 -0
- data/lib/codex_sdk/exec.rb +175 -0
- data/lib/codex_sdk/items.rb +90 -0
- data/lib/codex_sdk/options.rb +72 -0
- data/lib/codex_sdk/version.rb +5 -0
- data/lib/codex_sdk.rb +34 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 03d9e3beac47546980e8b63c3051c2d5100a3f1db197cf85a43c8d7b9eaf8f56
|
|
4
|
+
data.tar.gz: 879f569d1dfadc2451c7bf2ccb6a6cc36e897d3c1644e327bc8bb3d477014e72
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c353524c5d986caea98150081c3efacaff18a918a643e2bf75af34f06e1b7d2c91627b433dc463d90c6f406cc20c68e8603430f2063ed6a1299ec881e19528bd
|
|
7
|
+
data.tar.gz: ad57c8fd2dd7de943f0b541930125219342fffab87ce42890e643d6590abc09deb4d03ed7876f29802d05cb73bb66fc91f0a692ec2cf902a9a1bd29264d2c36b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-04-10)
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Client with `start_thread` and `resume_thread`
|
|
7
|
+
- Blocking and streaming execution modes
|
|
8
|
+
- JSONL event parsing (thread, turn, item, error events)
|
|
9
|
+
- Item types: agent message, reasoning, command execution, file change, MCP tool call, web search, todo list
|
|
10
|
+
- Config serialization to TOML CLI flags
|
|
11
|
+
- Subprocess lifecycle management with graceful shutdown
|
|
12
|
+
- API key redaction in inspect output
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anton Kopylov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# codex-ruby
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the [Codex CLI](https://github.com/openai/codex). Provides subprocess management, JSONL event parsing, and a clean API for building AI-powered applications.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
[Codex CLI](https://github.com/openai/codex) must be installed and available in your PATH.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @openai/codex
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Supported platforms: macOS, Linux.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "codex-ruby"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Ruby 3.2+.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "codex_sdk"
|
|
29
|
+
|
|
30
|
+
client = CodexSDK::Client.new(
|
|
31
|
+
api_key: "your-api-key", # or set CODEX_API_KEY env var
|
|
32
|
+
base_url: "https://api.openai.com/v1" # optional
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Start a new thread
|
|
36
|
+
thread = client.start_thread(
|
|
37
|
+
model: "o4-mini",
|
|
38
|
+
sandbox_mode: "read-only",
|
|
39
|
+
working_directory: "/path/to/project"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Blocking run - returns a Turn with all items
|
|
43
|
+
turn = thread.run("Explain this codebase")
|
|
44
|
+
puts turn.final_response
|
|
45
|
+
puts "Tokens used: #{turn.usage.input_tokens} in, #{turn.usage.output_tokens} out"
|
|
46
|
+
|
|
47
|
+
# Streaming run - yields events as they arrive
|
|
48
|
+
thread.run_streamed("Fix the failing tests") do |event|
|
|
49
|
+
case event
|
|
50
|
+
when CodexSDK::Events::ItemCompleted
|
|
51
|
+
case event.item
|
|
52
|
+
when CodexSDK::Items::AgentMessage
|
|
53
|
+
puts event.item.text
|
|
54
|
+
when CodexSDK::Items::CommandExecution
|
|
55
|
+
puts "Ran: #{event.item.command} (exit #{event.item.exit_code})"
|
|
56
|
+
when CodexSDK::Items::FileChange
|
|
57
|
+
event.item.changes.each { |c| puts "#{c[:kind]}: #{c[:path]}" }
|
|
58
|
+
end
|
|
59
|
+
when CodexSDK::Events::TurnCompleted
|
|
60
|
+
puts "Done! Used #{event.usage.output_tokens} output tokens"
|
|
61
|
+
when CodexSDK::Events::TurnFailed
|
|
62
|
+
puts "Error: #{event.error_message}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Resume a thread
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
thread = client.resume_thread("thread_abc123", model: "o4-mini")
|
|
71
|
+
turn = thread.run("Now add tests for the changes")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Interrupt
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# From another Ruby thread
|
|
78
|
+
thread.interrupt
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Thread options
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
client.start_thread(
|
|
85
|
+
model: "o4-mini",
|
|
86
|
+
sandbox_mode: "read-only", # or "read-write"
|
|
87
|
+
working_directory: "/path",
|
|
88
|
+
approval_policy: "unless-allow-listed",
|
|
89
|
+
reasoning_effort: "high",
|
|
90
|
+
network_access: true,
|
|
91
|
+
web_search: true,
|
|
92
|
+
additional_directories: ["/other/path"],
|
|
93
|
+
skip_git_repo_check: false
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Config overrides
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
client = CodexSDK::Client.new(
|
|
101
|
+
api_key: "key",
|
|
102
|
+
config: {
|
|
103
|
+
mcp_servers: {
|
|
104
|
+
my_server: { url: "http://localhost:3000/mcp" }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Event types
|
|
111
|
+
|
|
112
|
+
| Event | Description |
|
|
113
|
+
|-------|-------------|
|
|
114
|
+
| `Events::ThreadStarted` | Thread created, provides `thread_id` |
|
|
115
|
+
| `Events::TurnStarted` | Turn began processing |
|
|
116
|
+
| `Events::TurnCompleted` | Turn finished, provides `usage` |
|
|
117
|
+
| `Events::TurnFailed` | Turn failed, provides `error_message` |
|
|
118
|
+
| `Events::ItemStarted` | Item processing started |
|
|
119
|
+
| `Events::ItemUpdated` | Item updated with partial data |
|
|
120
|
+
| `Events::ItemCompleted` | Item finished, provides typed `item` |
|
|
121
|
+
| `Events::Error` | Stream-level error, provides `message` |
|
|
122
|
+
|
|
123
|
+
## Item types
|
|
124
|
+
|
|
125
|
+
| Item | Fields |
|
|
126
|
+
|------|--------|
|
|
127
|
+
| `Items::AgentMessage` | `id`, `text` |
|
|
128
|
+
| `Items::Reasoning` | `id`, `text` |
|
|
129
|
+
| `Items::CommandExecution` | `id`, `command`, `aggregated_output`, `exit_code`, `status` |
|
|
130
|
+
| `Items::FileChange` | `id`, `changes` (array of `{path:, kind:}`), `status` |
|
|
131
|
+
| `Items::McpToolCall` | `id`, `server`, `tool`, `arguments`, `result`, `error`, `status` |
|
|
132
|
+
| `Items::WebSearch` | `id`, `query` |
|
|
133
|
+
| `Items::TodoList` | `id`, `items` (array of `{text:, completed:}`) |
|
|
134
|
+
| `Items::Error` | `id`, `message` |
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
bundle install
|
|
140
|
+
bundle exec rspec
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module CodexSDK
|
|
6
|
+
class AgentThread
|
|
7
|
+
attr_reader :id
|
|
8
|
+
|
|
9
|
+
def initialize(options, thread_options:, resume_id: nil)
|
|
10
|
+
@options = options
|
|
11
|
+
@thread_options = thread_options
|
|
12
|
+
@id = resume_id
|
|
13
|
+
@exec = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Blocking run: sends prompt, collects all events, returns a Turn.
|
|
17
|
+
def run(input, turn_options: TurnOptions.new)
|
|
18
|
+
items = []
|
|
19
|
+
final_response = ""
|
|
20
|
+
usage = nil
|
|
21
|
+
|
|
22
|
+
run_streamed(input, turn_options: turn_options) do |event|
|
|
23
|
+
case event
|
|
24
|
+
when Events::ItemCompleted
|
|
25
|
+
items << event.item
|
|
26
|
+
final_response = event.item.text if event.item.is_a?(Items::AgentMessage)
|
|
27
|
+
when Events::TurnCompleted
|
|
28
|
+
usage = event.usage
|
|
29
|
+
when Events::TurnFailed
|
|
30
|
+
raise Error, event.error_message
|
|
31
|
+
when Events::Error
|
|
32
|
+
items << Items::Error.new(id: nil, message: event.message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Turn.new(items: items, final_response: final_response, usage: usage)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Streaming run: yields each event to the block as it arrives.
|
|
40
|
+
def run_streamed(input, turn_options: TurnOptions.new, &block)
|
|
41
|
+
prompt = normalize_input(input)
|
|
42
|
+
|
|
43
|
+
output_schema_path = nil
|
|
44
|
+
if turn_options.output_schema
|
|
45
|
+
output_schema_path = write_output_schema(turn_options.output_schema)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@exec = Exec.new(
|
|
49
|
+
@options,
|
|
50
|
+
thread_options: @thread_options
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@exec.run(
|
|
54
|
+
prompt,
|
|
55
|
+
resume_thread_id: @id,
|
|
56
|
+
output_schema_path: output_schema_path
|
|
57
|
+
) do |event|
|
|
58
|
+
# Capture thread ID from first event
|
|
59
|
+
@id = event.thread_id if event.is_a?(Events::ThreadStarted)
|
|
60
|
+
|
|
61
|
+
block.call(event)
|
|
62
|
+
end
|
|
63
|
+
ensure
|
|
64
|
+
cleanup_output_schema(output_schema_path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Interrupt the running subprocess.
|
|
68
|
+
def interrupt
|
|
69
|
+
@exec&.interrupt
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize_input(input)
|
|
75
|
+
case input
|
|
76
|
+
when String
|
|
77
|
+
input
|
|
78
|
+
when Array
|
|
79
|
+
input.filter_map { |entry|
|
|
80
|
+
entry[:text] if entry[:type] == "text"
|
|
81
|
+
}.join("\n\n")
|
|
82
|
+
else
|
|
83
|
+
input.to_s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_output_schema(schema)
|
|
88
|
+
dir = Dir.mktmpdir("codex-output-schema")
|
|
89
|
+
path = File.join(dir, "schema.json")
|
|
90
|
+
File.write(path, JSON.generate(schema))
|
|
91
|
+
path
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cleanup_output_schema(path)
|
|
95
|
+
return unless path
|
|
96
|
+
dir = File.dirname(path)
|
|
97
|
+
FileUtils.rm_rf(dir)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
# best effort
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CodexSDK
|
|
4
|
+
class Client
|
|
5
|
+
def initialize(codex_path: nil, base_url: nil, api_key: nil, config: {}, env: nil)
|
|
6
|
+
@options = Options.new(
|
|
7
|
+
codex_path: codex_path,
|
|
8
|
+
base_url: base_url,
|
|
9
|
+
api_key: api_key,
|
|
10
|
+
config: config,
|
|
11
|
+
env: env
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Start a new thread with the given options.
|
|
16
|
+
def start_thread(**kwargs)
|
|
17
|
+
thread_options = ThreadOptions.new(**kwargs)
|
|
18
|
+
AgentThread.new(@options, thread_options: thread_options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Resume an existing thread by ID.
|
|
22
|
+
def resume_thread(thread_id, **kwargs)
|
|
23
|
+
thread_options = ThreadOptions.new(**kwargs)
|
|
24
|
+
AgentThread.new(@options, thread_options: thread_options, resume_id: thread_id)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CodexSDK
|
|
4
|
+
# Serializes Ruby hashes into --config CLI flags using TOML value syntax.
|
|
5
|
+
# Mirrors the TypeScript SDK's toTomlValue() and flattenConfig() logic.
|
|
6
|
+
module ConfigSerializer
|
|
7
|
+
BARE_KEY_PATTERN = /\A[A-Za-z0-9_-]+\z/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Converts a nested hash into an array of ["--config", "key=value"] pairs.
|
|
12
|
+
#
|
|
13
|
+
# to_flags({ sandbox_workspace_write: { network_access: true } })
|
|
14
|
+
# # => ["--config", "sandbox_workspace_write.network_access=true"]
|
|
15
|
+
def to_flags(config)
|
|
16
|
+
flatten(config).flat_map { |key, value| ["--config", "#{key}=#{to_toml_value(value)}"] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Flattens a nested hash into dotted key paths.
|
|
20
|
+
#
|
|
21
|
+
# flatten({ a: { b: 1, c: { d: 2 } } })
|
|
22
|
+
# # => { "a.b" => 1, "a.c.d" => 2 }
|
|
23
|
+
def flatten(hash, prefix: nil)
|
|
24
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
25
|
+
full_key = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
26
|
+
if value.is_a?(Hash)
|
|
27
|
+
result.merge!(flatten(value, prefix: full_key))
|
|
28
|
+
else
|
|
29
|
+
result[full_key] = value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Converts a Ruby value to a TOML literal string.
|
|
35
|
+
def to_toml_value(value)
|
|
36
|
+
case value
|
|
37
|
+
when String
|
|
38
|
+
value.to_json
|
|
39
|
+
when Integer, Float
|
|
40
|
+
raise ArgumentError, "cannot serialize non-finite number" unless value.to_f.finite?
|
|
41
|
+
value.to_s
|
|
42
|
+
when true, false
|
|
43
|
+
value.to_s
|
|
44
|
+
when Array
|
|
45
|
+
"[#{value.map { |v| to_toml_value(v) }.join(", ")}]"
|
|
46
|
+
when Hash
|
|
47
|
+
inner = value.map { |k, v| "#{format_key(k)} = #{to_toml_value(v)}" }.join(", ")
|
|
48
|
+
"{#{inner}}"
|
|
49
|
+
when nil
|
|
50
|
+
raise ArgumentError, "cannot serialize nil to TOML"
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError, "unsupported type: #{value.class}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_key(key)
|
|
57
|
+
str = key.to_s
|
|
58
|
+
str.match?(BARE_KEY_PATTERN) ? str : str.to_json
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CodexSDK
|
|
4
|
+
module Events
|
|
5
|
+
# Parse a JSON hash into a typed event.
|
|
6
|
+
def self.parse(data)
|
|
7
|
+
case data["type"]
|
|
8
|
+
when "thread.started" then ThreadStarted.new(thread_id: data["thread_id"])
|
|
9
|
+
when "turn.started" then TurnStarted.new
|
|
10
|
+
when "turn.completed" then TurnCompleted.from_json(data)
|
|
11
|
+
when "turn.failed" then TurnFailed.new(error_message: data.dig("error", "message").to_s)
|
|
12
|
+
when "item.started" then ItemStarted.new(item: Items.parse(data["item"]))
|
|
13
|
+
when "item.updated" then ItemUpdated.new(item: Items.parse(data["item"]))
|
|
14
|
+
when "item.completed" then ItemCompleted.new(item: Items.parse(data["item"]))
|
|
15
|
+
when "error" then Error.new(message: data["message"].to_s)
|
|
16
|
+
else Unknown.new(type: data["type"], data: data)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ThreadStarted = Data.define(:thread_id)
|
|
21
|
+
|
|
22
|
+
TurnStarted = Data.define do
|
|
23
|
+
def initialize; super(); end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
TurnCompleted = Data.define(:usage) do
|
|
27
|
+
def self.from_json(data)
|
|
28
|
+
usage_data = data["usage"]
|
|
29
|
+
return new(usage: nil) unless usage_data.is_a?(Hash) && usage_data.any?
|
|
30
|
+
|
|
31
|
+
usage = ::CodexSDK::Usage.new(
|
|
32
|
+
input_tokens: usage_data["input_tokens"].to_i,
|
|
33
|
+
cached_input_tokens: usage_data["cached_input_tokens"].to_i,
|
|
34
|
+
output_tokens: usage_data["output_tokens"].to_i
|
|
35
|
+
)
|
|
36
|
+
new(usage: usage)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
TurnFailed = Data.define(:error_message)
|
|
41
|
+
|
|
42
|
+
ItemStarted = Data.define(:item)
|
|
43
|
+
ItemUpdated = Data.define(:item)
|
|
44
|
+
ItemCompleted = Data.define(:item)
|
|
45
|
+
|
|
46
|
+
Error = Data.define(:message)
|
|
47
|
+
|
|
48
|
+
Unknown = Data.define(:type, :data)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module CodexSDK
|
|
7
|
+
# Internal: manages the codex CLI subprocess.
|
|
8
|
+
# Spawns `codex exec --experimental-json`, writes prompt to stdin,
|
|
9
|
+
# reads JSONL events from stdout.
|
|
10
|
+
class Exec
|
|
11
|
+
SHUTDOWN_TIMEOUT = 10 # seconds to wait after SIGTERM before SIGKILL
|
|
12
|
+
|
|
13
|
+
attr_reader :pid
|
|
14
|
+
|
|
15
|
+
def initialize(options, thread_options: ThreadOptions.new)
|
|
16
|
+
@options = options
|
|
17
|
+
@thread_options = thread_options
|
|
18
|
+
@stdin = nil
|
|
19
|
+
@stdout = nil
|
|
20
|
+
@stderr = nil
|
|
21
|
+
@wait_thread = nil
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Spawns the subprocess, writes the prompt, reads JSONL events.
|
|
26
|
+
# Yields each parsed event hash to the block.
|
|
27
|
+
def run(prompt, resume_thread_id: nil, images: [], output_schema_path: nil, &block)
|
|
28
|
+
args = build_args(resume_thread_id: resume_thread_id, images: images, output_schema_path: output_schema_path)
|
|
29
|
+
env = build_env
|
|
30
|
+
|
|
31
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(env, *args)
|
|
32
|
+
|
|
33
|
+
# Write prompt and close stdin (one-shot, matching TypeScript SDK)
|
|
34
|
+
@stdin.write(prompt.to_s)
|
|
35
|
+
@stdin.close
|
|
36
|
+
|
|
37
|
+
# Read stderr in background thread
|
|
38
|
+
stderr_reader = ::Thread.new { @stderr.read rescue "" }
|
|
39
|
+
|
|
40
|
+
# Read JSONL from stdout line by line
|
|
41
|
+
@stdout.each_line do |line|
|
|
42
|
+
line = line.strip
|
|
43
|
+
next if line.empty?
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
data = JSON.parse(line)
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
raise ParseError.new("Failed to parse event: #{e.message}", line: line)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
event = Events.parse(data)
|
|
52
|
+
block.call(event)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
stderr_buf = stderr_reader.value.to_s
|
|
56
|
+
status = @wait_thread.value
|
|
57
|
+
|
|
58
|
+
unless status.success?
|
|
59
|
+
code = status.exitstatus || status.termsig
|
|
60
|
+
truncated = stderr_buf.length > 500 ? "#{stderr_buf[0, 497]}..." : stderr_buf
|
|
61
|
+
raise ExecError.new(
|
|
62
|
+
"Codex exited with code #{code}: #{truncated}",
|
|
63
|
+
exit_code: code,
|
|
64
|
+
stderr: stderr_buf
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
ensure
|
|
68
|
+
cleanup
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Sends SIGTERM to the subprocess, waits, then SIGKILL if needed.
|
|
72
|
+
def interrupt
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
return unless @wait_thread&.alive?
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
Process.kill("TERM", @wait_thread.pid)
|
|
78
|
+
rescue Errno::ESRCH
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Wait for graceful shutdown
|
|
83
|
+
unless wait_for_exit(SHUTDOWN_TIMEOUT)
|
|
84
|
+
begin
|
|
85
|
+
Process.kill("KILL", @wait_thread.pid)
|
|
86
|
+
rescue Errno::ESRCH
|
|
87
|
+
# already gone
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def build_args(resume_thread_id: nil, images: [], output_schema_path: nil)
|
|
96
|
+
codex_path = @options.codex_path || find_codex_path
|
|
97
|
+
args = [codex_path, "exec", "--experimental-json"]
|
|
98
|
+
|
|
99
|
+
# Global config overrides
|
|
100
|
+
args.concat(ConfigSerializer.to_flags(@options.config)) if @options.config.any?
|
|
101
|
+
|
|
102
|
+
# Base URL
|
|
103
|
+
if @options.base_url
|
|
104
|
+
args.concat(["--config", "openai_base_url=#{ConfigSerializer.to_toml_value(@options.base_url)}"])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Thread options -> CLI flags
|
|
108
|
+
to = @thread_options
|
|
109
|
+
args.concat(["--model", to.model]) if to.model
|
|
110
|
+
args.concat(["--sandbox", to.sandbox_mode]) if to.sandbox_mode
|
|
111
|
+
args.concat(["--cd", to.working_directory]) if to.working_directory
|
|
112
|
+
args << "--dangerously-bypass-approvals-and-sandbox" if to.dangerously_bypass_approvals_and_sandbox
|
|
113
|
+
args << "--skip-git-repo-check" if to.skip_git_repo_check
|
|
114
|
+
|
|
115
|
+
to.additional_directories.each { |dir| args.concat(["--add-dir", dir]) }
|
|
116
|
+
|
|
117
|
+
if to.reasoning_effort
|
|
118
|
+
args.concat(["--config", "model_reasoning_effort=#{ConfigSerializer.to_toml_value(to.reasoning_effort)}"])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
unless to.network_access.nil?
|
|
122
|
+
args.concat(["--config", "sandbox_workspace_write.network_access=#{to.network_access}"])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if to.web_search
|
|
126
|
+
args.concat(["--config", "web_search=#{ConfigSerializer.to_toml_value(to.web_search)}"])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if to.approval_policy
|
|
130
|
+
args.concat(["--config", "approval_policy=#{ConfigSerializer.to_toml_value(to.approval_policy)}"])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Output schema
|
|
134
|
+
args.concat(["--output-schema", output_schema_path]) if output_schema_path
|
|
135
|
+
|
|
136
|
+
# Resume
|
|
137
|
+
args.concat(["resume", resume_thread_id]) if resume_thread_id
|
|
138
|
+
|
|
139
|
+
# Images (always last)
|
|
140
|
+
images.each { |path| args.concat(["--image", path]) }
|
|
141
|
+
|
|
142
|
+
args
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_env
|
|
146
|
+
base_env = @options.env || ENV.to_h
|
|
147
|
+
env = base_env.dup
|
|
148
|
+
env["CODEX_API_KEY"] = @options.api_key if @options.api_key
|
|
149
|
+
env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] ||= "codex_sdk_rb"
|
|
150
|
+
env
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def find_codex_path
|
|
154
|
+
path = `which codex 2>/dev/null`.strip
|
|
155
|
+
raise Error, "codex binary not found in PATH" if path.empty?
|
|
156
|
+
path
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def wait_for_exit(timeout)
|
|
160
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
161
|
+
loop do
|
|
162
|
+
return true unless @wait_thread&.alive?
|
|
163
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
164
|
+
return false if remaining <= 0
|
|
165
|
+
sleep([0.1, remaining].min)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def cleanup
|
|
170
|
+
@stdin&.close unless @stdin&.closed?
|
|
171
|
+
@stdout&.close unless @stdout&.closed?
|
|
172
|
+
@stderr&.close unless @stderr&.closed?
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CodexSDK
|
|
4
|
+
module Items
|
|
5
|
+
# Parse a JSON hash into a typed item.
|
|
6
|
+
def self.parse(data)
|
|
7
|
+
case data["type"]
|
|
8
|
+
when "agent_message" then AgentMessage.from_json(data)
|
|
9
|
+
when "reasoning" then Reasoning.from_json(data)
|
|
10
|
+
when "command_execution" then CommandExecution.from_json(data)
|
|
11
|
+
when "file_change" then FileChange.from_json(data)
|
|
12
|
+
when "mcp_tool_call" then McpToolCall.from_json(data)
|
|
13
|
+
when "web_search" then WebSearch.from_json(data)
|
|
14
|
+
when "todo_list" then TodoList.from_json(data)
|
|
15
|
+
when "error" then Error.from_json(data)
|
|
16
|
+
else Unknown.new(id: data["id"], type: data["type"], data: data)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
AgentMessage = Data.define(:id, :text) do
|
|
21
|
+
def self.from_json(data)
|
|
22
|
+
new(id: data["id"], text: data["text"].to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Reasoning = Data.define(:id, :text) do
|
|
27
|
+
def self.from_json(data)
|
|
28
|
+
new(id: data["id"], text: data["text"].to_s)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
CommandExecution = Data.define(:id, :command, :aggregated_output, :exit_code, :status) do
|
|
33
|
+
def self.from_json(data)
|
|
34
|
+
new(
|
|
35
|
+
id: data["id"],
|
|
36
|
+
command: data["command"].to_s,
|
|
37
|
+
aggregated_output: data["aggregated_output"].to_s,
|
|
38
|
+
exit_code: data["exit_code"],
|
|
39
|
+
status: data["status"].to_s
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
FileChange = Data.define(:id, :changes, :status) do
|
|
45
|
+
def self.from_json(data)
|
|
46
|
+
changes = (data["changes"] || []).map do |c|
|
|
47
|
+
{ path: c["path"], kind: c["kind"] }
|
|
48
|
+
end
|
|
49
|
+
new(id: data["id"], changes: changes, status: data["status"].to_s)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
McpToolCall = Data.define(:id, :server, :tool, :arguments, :result, :error, :status) do
|
|
54
|
+
def self.from_json(data)
|
|
55
|
+
new(
|
|
56
|
+
id: data["id"],
|
|
57
|
+
server: data["server"].to_s,
|
|
58
|
+
tool: data["tool"].to_s,
|
|
59
|
+
arguments: data["arguments"],
|
|
60
|
+
result: data["result"],
|
|
61
|
+
error: data["error"],
|
|
62
|
+
status: data["status"].to_s
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
WebSearch = Data.define(:id, :query) do
|
|
68
|
+
def self.from_json(data)
|
|
69
|
+
new(id: data["id"], query: data["query"].to_s)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
TodoList = Data.define(:id, :items) do
|
|
74
|
+
def self.from_json(data)
|
|
75
|
+
items = (data["items"] || []).map do |item|
|
|
76
|
+
{ text: item["text"], completed: item["completed"] }
|
|
77
|
+
end
|
|
78
|
+
new(id: data["id"], items: items)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Error = Data.define(:id, :message) do
|
|
83
|
+
def self.from_json(data)
|
|
84
|
+
new(id: data["id"], message: data["message"].to_s)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Unknown = Data.define(:id, :type, :data)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CodexSDK
|
|
4
|
+
# Constructor options for CodexSDK::Client.
|
|
5
|
+
Options = Data.define(
|
|
6
|
+
:codex_path, # Override path to codex binary
|
|
7
|
+
:base_url, # OpenAI API base URL
|
|
8
|
+
:api_key, # API key (set as CODEX_API_KEY env var)
|
|
9
|
+
:config, # Arbitrary --config key=value overrides (Hash)
|
|
10
|
+
:env # Full env replacement (no ENV inheritance when set)
|
|
11
|
+
) do
|
|
12
|
+
def initialize(codex_path: nil, base_url: nil, api_key: nil, config: {}, env: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def inspect
|
|
17
|
+
redacted_key = api_key ? "[REDACTED]" : "nil"
|
|
18
|
+
"#<#{self.class} codex_path=#{codex_path.inspect} base_url=#{base_url.inspect} " \
|
|
19
|
+
"api_key=#{redacted_key} config=#{config.inspect} env=#{env ? "[SET]" : "nil"}>"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Per-thread options controlling model, sandbox, and behavior.
|
|
24
|
+
ThreadOptions = Data.define(
|
|
25
|
+
:model,
|
|
26
|
+
:sandbox_mode,
|
|
27
|
+
:working_directory,
|
|
28
|
+
:approval_policy,
|
|
29
|
+
:dangerously_bypass_approvals_and_sandbox,
|
|
30
|
+
:reasoning_effort,
|
|
31
|
+
:network_access,
|
|
32
|
+
:web_search,
|
|
33
|
+
:additional_directories,
|
|
34
|
+
:skip_git_repo_check
|
|
35
|
+
) do
|
|
36
|
+
def initialize(
|
|
37
|
+
model: nil,
|
|
38
|
+
sandbox_mode: nil,
|
|
39
|
+
working_directory: nil,
|
|
40
|
+
approval_policy: nil,
|
|
41
|
+
dangerously_bypass_approvals_and_sandbox: false,
|
|
42
|
+
reasoning_effort: nil,
|
|
43
|
+
network_access: nil,
|
|
44
|
+
web_search: nil,
|
|
45
|
+
additional_directories: [],
|
|
46
|
+
skip_git_repo_check: false
|
|
47
|
+
)
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Per-turn options (output schema, abort).
|
|
53
|
+
TurnOptions = Data.define(:output_schema) do
|
|
54
|
+
def initialize(output_schema: nil)
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Token usage from a completed turn.
|
|
60
|
+
Usage = Data.define(:input_tokens, :cached_input_tokens, :output_tokens) do
|
|
61
|
+
def initialize(input_tokens: 0, cached_input_tokens: 0, output_tokens: 0)
|
|
62
|
+
super
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Result of a blocking Thread#run call.
|
|
67
|
+
Turn = Data.define(:items, :final_response, :usage) do
|
|
68
|
+
def initialize(items: [], final_response: "", usage: nil)
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/codex_sdk.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codex_sdk/version"
|
|
4
|
+
|
|
5
|
+
module CodexSDK
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
class ExecError < Error
|
|
9
|
+
attr_reader :exit_code, :stderr
|
|
10
|
+
|
|
11
|
+
def initialize(message, exit_code: nil, stderr: nil)
|
|
12
|
+
@exit_code = exit_code
|
|
13
|
+
@stderr = stderr
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ParseError < Error
|
|
19
|
+
attr_reader :line
|
|
20
|
+
|
|
21
|
+
def initialize(message, line: nil)
|
|
22
|
+
@line = line
|
|
23
|
+
super(message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
require_relative "codex_sdk/options"
|
|
29
|
+
require_relative "codex_sdk/config_serializer"
|
|
30
|
+
require_relative "codex_sdk/items"
|
|
31
|
+
require_relative "codex_sdk/events"
|
|
32
|
+
require_relative "codex_sdk/exec"
|
|
33
|
+
require_relative "codex_sdk/agent_thread"
|
|
34
|
+
require_relative "codex_sdk/client"
|
metadata
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: codex-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Anton Kopylov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rspec
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
description: A Ruby client for the Codex CLI, providing subprocess management, JSONL
|
|
41
|
+
event parsing, and a clean API for building AI-powered applications.
|
|
42
|
+
email:
|
|
43
|
+
- anton@tonic20.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- LICENSE.txt
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/codex_sdk.rb
|
|
52
|
+
- lib/codex_sdk/agent_thread.rb
|
|
53
|
+
- lib/codex_sdk/client.rb
|
|
54
|
+
- lib/codex_sdk/config_serializer.rb
|
|
55
|
+
- lib/codex_sdk/events.rb
|
|
56
|
+
- lib/codex_sdk/exec.rb
|
|
57
|
+
- lib/codex_sdk/items.rb
|
|
58
|
+
- lib/codex_sdk/options.rb
|
|
59
|
+
- lib/codex_sdk/version.rb
|
|
60
|
+
homepage: https://github.com/tonic20/codex-ruby
|
|
61
|
+
licenses:
|
|
62
|
+
- MIT
|
|
63
|
+
metadata:
|
|
64
|
+
homepage_uri: https://github.com/tonic20/codex-ruby
|
|
65
|
+
source_code_uri: https://github.com/tonic20/codex-ruby
|
|
66
|
+
changelog_uri: https://github.com/tonic20/codex-ruby/blob/main/CHANGELOG.md
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.2'
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.7.2
|
|
82
|
+
specification_version: 4
|
|
83
|
+
summary: Ruby SDK for the Codex CLI
|
|
84
|
+
test_files: []
|