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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodexSDK
4
+ VERSION = "0.1.0"
5
+ 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: []