ask-coding-providers 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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Ask
7
+ module CodingProviders
8
+ module Claude
9
+ # Adapter for Claude Code CLI.
10
+ #
11
+ # Uses `claude -p --output-format=stream-json` for non-interactive prompts.
12
+ # Streams JSON events via stdout for real-time response display.
13
+ #
14
+ # @example
15
+ # adapter = Ask::CodingProviders::Claude::Adapter.new(cli_path: "claude")
16
+ # adapter.start
17
+ # sid = adapter.create_session("/tmp")
18
+ # adapter.send_and_stream(sid, "Hello") { |ev| puts ev }
19
+ class Adapter < Ask::CodingProviders::Adapter
20
+ def initialize(cli_path: "claude", cwd: ".", request_timeout: 120.0)
21
+ @cli_path = cli_path
22
+ @cwd = cwd
23
+ @request_timeout = request_timeout
24
+ @started = false
25
+ @sessions = {}
26
+ end
27
+
28
+ def start
29
+ @started = true
30
+ end
31
+
32
+ def stop
33
+ @started = false
34
+ @sessions = {}
35
+ end
36
+
37
+ def running?
38
+ @started
39
+ end
40
+
41
+ def create_session(workspace_path, mode: nil)
42
+ ensure_started
43
+ sid = "sess_#{SecureRandom.uuid}"
44
+ @sessions[sid] = { workspace: workspace_path, mode: mode, created_at: Time.now }
45
+ sid
46
+ end
47
+
48
+ def resume_session(session_id)
49
+ @sessions&.dig(session_id) || {}
50
+ end
51
+
52
+ def subscribe(session_id, after_seq: 0)
53
+ { "eventSeq" => 0 }
54
+ end
55
+
56
+ def send_message(session_id, content)
57
+ ensure_started
58
+ run_claude(content)
59
+ end
60
+
61
+ def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
62
+ return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout) unless block
63
+ ensure_started
64
+
65
+ block.call({ type: "turn.started", seq: 1, payload: { "sessionId" => session_id } })
66
+
67
+ deadline = Time.now + (turn_timeout || @request_timeout)
68
+ accumulated = ""
69
+
70
+ begin
71
+ stdin, stdout, stderr, wait_thr = Open3.popen3(
72
+ @cli_path, "-p", content.to_s,
73
+ "--verbose", "--output-format=stream-json",
74
+ chdir: @cwd
75
+ )
76
+ stdin.close
77
+
78
+ # Read JSON events from stdout
79
+ stdout.each_line do |line|
80
+ break if Time.now > deadline
81
+ line = line.strip
82
+ next if line.empty?
83
+
84
+ begin
85
+ event = JSON.parse(line)
86
+ rescue JSON::ParserError
87
+ next
88
+ end
89
+
90
+ case event["type"]
91
+ when "assistant"
92
+ msg = event["message"] || {}
93
+ (msg["content"] || []).each do |block|
94
+ if block["type"] == "text" && block["text"]
95
+ delta = block["text"]
96
+ accumulated += delta
97
+ block.call({
98
+ type: "model.streaming", seq: 2,
99
+ payload: { "delta" => delta, "sessionId" => session_id }
100
+ })
101
+ end
102
+ end
103
+ when "result"
104
+ is_error = event["is_error"]
105
+ if is_error
106
+ err = event["result"] || "Claude error"
107
+ block.call({
108
+ type: "turn.failed", seq: 3,
109
+ payload: { "error" => { "message" => err }, "sessionId" => session_id }
110
+ })
111
+ else
112
+ block.call({
113
+ type: "turn.completed", seq: 3,
114
+ payload: { "response" => accumulated, "sessionId" => session_id }
115
+ })
116
+ end
117
+ when "error"
118
+ err_msg = event["message"] || event["error"] || "Claude error"
119
+ block.call({
120
+ type: "turn.failed", seq: 3,
121
+ payload: { "error" => { "message" => err_msg }, "sessionId" => session_id }
122
+ })
123
+ end
124
+ end
125
+
126
+ stdout.close rescue nil
127
+ stderr.close rescue nil
128
+ wait_thr&.join(5)
129
+
130
+ rescue => e
131
+ block.call({
132
+ type: "turn.failed", seq: 3,
133
+ payload: { "error" => { "message" => e.message }, "sessionId" => session_id }
134
+ })
135
+ end
136
+ end
137
+
138
+ def get_events(session_id, after_seq:, limit: nil)
139
+ {}
140
+ end
141
+
142
+ def respond(request_id, result)
143
+ end
144
+
145
+ def get_workspace_state(workspace_path)
146
+ {}
147
+ end
148
+
149
+ def self.from_config(workspace_path: Dir.pwd, cli_path: nil, request_timeout: 120, **)
150
+ new(cli_path: cli_path || "claude", cwd: workspace_path, request_timeout: request_timeout)
151
+ end
152
+
153
+ private
154
+
155
+ def ensure_started
156
+ raise Error, "Claude adapter not started" unless @started
157
+ end
158
+
159
+ def run_claude(content)
160
+ stdout, stderr, wait_thr = Open3.capture3(
161
+ @cli_path, "-p", content.to_s,
162
+ "--verbose", "--output-format=stream-json",
163
+ chdir: @cwd
164
+ )
165
+ events = stdout.split("\n").filter_map { |l| JSON.parse(l) rescue nil }
166
+ result = events.find { |e| e["type"] == "result" }
167
+ if result && result["is_error"]
168
+ { "error" => result["result"] || "Claude error" }
169
+ else
170
+ text = events
171
+ .select { |e| e["type"] == "assistant" }
172
+ .flat_map { |e| (e.dig("message", "content") || []).map { |c| c["text"] } }
173
+ .join
174
+ { "response" => text }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ Ask::CodingProviders.register_adapter(:claude, Ask::CodingProviders::Claude::Adapter)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "claude/adapter"
4
+
5
+ module Ask
6
+ module CodingProviders
7
+ module Claude
8
+ class Error < Ask::CodingProviders::Error; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module CodingProviders
5
+ module Codex
6
+ # Adapter implementation for the Codex app-server.
7
+ #
8
+ # Communicates with `codex app-server` over stdio JSON-RPC 2.0.
9
+ # Supports thread (session) lifecycle and streaming turns.
10
+ #
11
+ # Note: Codex does not speak ACP. Use this adapter for Codex.
12
+ # For agents that support ACP (Claude, OpenCode, Gemini CLI), use
13
+ # {Ask::CodingProviders::ACP::Adapter} instead.
14
+ class Adapter < Ask::CodingProviders::Adapter
15
+ def initialize(cwd: ".", cli_path: nil, request_timeout: 60.0, model: nil, model_provider: nil)
16
+ @cwd = cwd
17
+ @cli_path = cli_path
18
+ @request_timeout = request_timeout
19
+ @model = model || ENV["CODEX_MODEL"]
20
+ @model_provider = model_provider || ENV["CODEX_MODEL_PROVIDER"]
21
+ @client = nil
22
+ @codex_db = CodexDB.new
23
+ end
24
+
25
+ def start
26
+ return if @client
27
+ @client = AppServerClient.new(cwd: @cwd, cli_path: @cli_path, request_timeout: @request_timeout,
28
+ model: @model, model_provider: @model_provider)
29
+ @client.on_notification { |method, params, request_id| handle_notification(method, params, request_id) }
30
+ @client.start
31
+ # Perform the initialize handshake
32
+ @client.initialize!
33
+ @client.send_initialized
34
+ end
35
+
36
+ def stop
37
+ @client&.stop
38
+ @client = nil
39
+ end
40
+
41
+ def running?
42
+ @client&.running? || false
43
+ end
44
+
45
+ def create_session(workspace_path, mode: nil)
46
+ ensure_running
47
+ thread = @client.thread_start(cwd: workspace_path)
48
+ tid = thread["id"] || thread[:id]
49
+ raise Error, "thread/start did not return thread id" unless tid
50
+ tid
51
+ end
52
+
53
+ def resume_session(session_id)
54
+ ensure_running
55
+ @client.thread_resume(session_id)
56
+ rescue => e
57
+ {}
58
+ end
59
+
60
+ def subscribe(session_id, after_seq: 0)
61
+ ensure_running
62
+ { "eventSeq" => 0 }
63
+ end
64
+
65
+ def send_message(session_id, content)
66
+ ensure_running
67
+ @client.turn_start(session_id, content)
68
+ end
69
+
70
+ def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
71
+ return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout) unless block
72
+ raise "send_and_stream requires app-server mode" unless @client
73
+
74
+ ev_queue = Queue.new
75
+ my_turn_id = nil
76
+ done = false
77
+
78
+ handler = ->(method, params, request_id) do
79
+ case method
80
+ when "turn/started"
81
+ my_turn_id = params["turnId"] || params[:turnId]
82
+ ev_queue << {
83
+ type: "turn.started", seq: params["seq"] || params[:seq] || 0,
84
+ payload: { "sessionId" => session_id }
85
+ }
86
+ when "item/agentMessage/delta"
87
+ tid = params["turnId"] || params[:turnId]
88
+ if my_turn_id.nil? || tid == my_turn_id
89
+ ev_queue << {
90
+ type: "model.streaming", seq: params["seq"] || params[:seq] || 0,
91
+ payload: { "delta" => params["content"] || params[:content] || "", "sessionId" => session_id }
92
+ }
93
+ end
94
+ when "turn/completed"
95
+ tid = params["turnId"] || params[:turnId]
96
+ if my_turn_id.nil? || tid == my_turn_id
97
+ response = params.dig("response", "message") || params.dig("turn", "response") || ""
98
+ ev_queue << {
99
+ type: "turn.completed", seq: params["seq"] || params[:seq] || 0,
100
+ payload: { "response" => response, "sessionId" => session_id,
101
+ "tokenCount" => params["tokenCount"] || params[:tokenCount] || 0 }
102
+ }
103
+ done = true
104
+ end
105
+ when "turn/failed"
106
+ error_msg = params.dig("error", "message") || "Turn failed"
107
+ ev_queue << {
108
+ type: "turn.failed", seq: params["seq"] || params[:seq] || 0,
109
+ payload: { "error" => { "message" => error_msg }, "sessionId" => session_id }
110
+ }
111
+ done = true
112
+ end
113
+ end
114
+
115
+ @client.on_notification(&handler)
116
+ begin
117
+ @client.turn_start(session_id, content)
118
+ deadline = Time.now + turn_timeout
119
+ until done
120
+ remaining = deadline - Time.now
121
+ break if remaining <= 0
122
+ begin
123
+ block.call(ev_queue.pop(timeout: remaining))
124
+ rescue ThreadError
125
+ break
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ def get_events(session_id, after_seq:, limit: nil)
132
+ ensure_running
133
+ {}
134
+ end
135
+
136
+ def respond(request_id, result)
137
+ @client&.respond(request_id, result)
138
+ end
139
+
140
+ def get_workspace_state(workspace_path)
141
+ ensure_running
142
+ @client.read_workspace_state
143
+ rescue => e
144
+ {}
145
+ end
146
+
147
+ # Build a Codex adapter from config.
148
+ def self.from_config(workspace_path: Dir.pwd, cli_path: nil, request_timeout: 600, model: nil, model_provider: nil, **)
149
+ new(cwd: workspace_path, cli_path: cli_path, request_timeout: request_timeout,
150
+ model: model, model_provider: model_provider)
151
+ end
152
+
153
+ # ── Session/project queries (delegated to CodexDB) ──
154
+
155
+ def list_projects
156
+ @codex_db.list_projects
157
+ end
158
+
159
+ def find_sessions(directory:, limit: 20)
160
+ @codex_db.find_sessions(directory: directory, limit: limit)
161
+ end
162
+
163
+ def find_recent_session
164
+ @codex_db.find_recent_session
165
+ end
166
+
167
+ def find_recent_tui_session(workspace_path)
168
+ @codex_db.find_recent_tui_session(workspace_path)
169
+ end
170
+
171
+ def session_directory(session_id)
172
+ @codex_db.session_directory(session_id)
173
+ end
174
+
175
+ def session_history(session_id, limit: 100)
176
+ @codex_db.session_history(session_id, limit: limit)
177
+ end
178
+
179
+ def recent_sessions
180
+ @codex_db.recent_sessions
181
+ end
182
+
183
+ private
184
+
185
+ def ensure_running
186
+ raise Error, "Codex app-server is not running. Call #start first." unless @client&.running?
187
+ end
188
+
189
+ def handle_notification(method, params, request_id)
190
+ # Auto-respond to user input requests
191
+ return unless request_id
192
+ return unless method == "ToolRequestUserInput"
193
+ @client&.respond(request_id, { "response" => "" })
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+
200
+ Ask::CodingProviders.register_adapter(:codex, Ask::CodingProviders::Codex::Adapter)
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Ask
7
+ module CodingProviders
8
+ module Codex
9
+ # JSON-RPC 2.0 client for the Codex app-server (stdio transport).
10
+ #
11
+ # Protocol: https://learn.chatgpt.com/docs/app-server
12
+ #
13
+ # Spawns `codex app-server` as a subprocess and communicates over stdio
14
+ # using newline-delimited JSON.
15
+ class AppServerClient
16
+ CLI_PATHS = [
17
+ -> { ENV["CODEX_CLI_PATH"] },
18
+ -> { find_in_path("codex") },
19
+ ].freeze
20
+
21
+ attr_reader :cli_path
22
+
23
+ def initialize(cwd: ".", cli_path: nil, request_timeout: 30.0, model: nil, model_provider: nil)
24
+ @cwd = cwd
25
+ @cli_path = cli_path || self.class.resolve_cli_path
26
+ @request_timeout = request_timeout
27
+ @model = model
28
+ @model_provider = model_provider
29
+ @extra_args = build_extra_args
30
+ @mutex = Mutex.new
31
+ @stdin = nil
32
+ @event_handlers = []
33
+ @pending = {}
34
+ @next_id = 0
35
+ @started = false
36
+ @stdout_queue = Queue.new
37
+ @initialized = false
38
+ end
39
+
40
+ def start
41
+ @mutex.synchronize do
42
+ return if @started
43
+ @stdin, stdout, stderr, @wait_thr = Open3.popen3(@cli_path, "app-server", "--stdio", *@extra_args, chdir: @cwd)
44
+
45
+ @stdout_thread = Thread.new(stdout) do |io|
46
+ io.each_line do |line|
47
+ line = line.strip
48
+ @stdout_queue << line unless line.empty?
49
+ end
50
+ @stdout_queue << nil
51
+ end
52
+
53
+ # Drain stderr to avoid deadlocks
54
+ Thread.new(stderr) { |io| io.each_line { |_| } }
55
+
56
+ @dispatcher = Thread.new do
57
+ while (line = @stdout_queue.pop)
58
+ begin
59
+ handle_message(JSON.parse(line))
60
+ rescue JSON::ParserError
61
+ end
62
+ end
63
+ rescue => e
64
+ end
65
+
66
+ @started = true
67
+ end
68
+ end
69
+
70
+ def stop
71
+ @mutex.synchronize do
72
+ return unless @started
73
+ @started = false
74
+ @stdin&.close rescue nil
75
+ @stdout_thread&.join(3) rescue nil
76
+ Process.kill("TERM", @wait_thr.pid) rescue nil
77
+ @wait_thr&.join(5) rescue nil
78
+ @stdin = nil
79
+ @pending.each_value { |f| f[:error] = Error.new("process exited"); f[:done] = true; f[:condition].signal }
80
+ @pending.clear
81
+ end
82
+ end
83
+
84
+ def running?
85
+ @started && @wait_thr&.alive?
86
+ end
87
+
88
+ def on_notification(&handler)
89
+ @mutex.synchronize { @event_handlers << handler }
90
+ end
91
+
92
+ # Perform the JSON-RPC initialize handshake.
93
+ # Must be called before any other request.
94
+ def initialize!
95
+ result = request("initialize", {
96
+ protocolVersion: "0.1.0",
97
+ clientInfo: { name: "askoda", version: "0.1.0" },
98
+ capabilities: {}
99
+ })
100
+ @initialized = true
101
+ result
102
+ end
103
+
104
+ def send_initialized
105
+ return unless @initialized
106
+ # The 'initialized' notification is a JSON-RPC 2.0 notification (no id)
107
+ @mutex.synchronize do
108
+ write_line({ jsonrpc: "2.0", method: "initialized", params: {} })
109
+ end
110
+ end
111
+
112
+ # -- High-level API --
113
+
114
+ def thread_start(cwd: nil)
115
+ ensure_initialized
116
+ params = {
117
+ cwd: cwd || @cwd,
118
+ approvalPolicy: "never",
119
+ sandbox: "read-only"
120
+ }
121
+ result = request("thread/start", params)
122
+ result&.dig("thread") || result&.dig(:thread) || {}
123
+ end
124
+
125
+ def thread_resume(thread_id)
126
+ ensure_initialized
127
+ request("thread/resume", { threadId: thread_id })
128
+ end
129
+
130
+ def turn_start(thread_id, input)
131
+ ensure_initialized
132
+ input_items = input.is_a?(Array) ? input : [{ type: "text", text: input.to_s }]
133
+ request("turn/start", {
134
+ threadId: thread_id,
135
+ input: input_items,
136
+ cwd: @cwd
137
+ })
138
+ end
139
+
140
+ def thread_list(limit: 20)
141
+ ensure_initialized
142
+ request("thread/list", { limit: limit })
143
+ end
144
+
145
+ def read_workspace_state
146
+ ensure_initialized
147
+ request("config/read", {})
148
+ end
149
+
150
+ def model_list
151
+ ensure_initialized
152
+ request("model/list", {})
153
+ end
154
+
155
+ def respond(request_id, result)
156
+ ensure_initialized
157
+ @mutex.synchronize { write_line({ id: request_id, result: result }) }
158
+ end
159
+
160
+ def request(method, params = nil, timeout: nil)
161
+ ensure_running
162
+ future = { done: false, result: nil, error: nil, condition: ConditionVariable.new }
163
+
164
+ @mutex.synchronize do
165
+ @next_id += 1
166
+ @pending[@next_id] = future
167
+ write_line({ jsonrpc: "2.0", id: @next_id, method: method, params: params }.compact)
168
+ end
169
+
170
+ timeout ||= @request_timeout
171
+ deadline = Time.now + timeout
172
+
173
+ @mutex.synchronize do
174
+ until future[:done]
175
+ remaining = deadline - Time.now
176
+ raise TimeoutError, "Request timed out after #{timeout}s" if remaining <= 0
177
+ future[:condition].wait(@mutex, remaining)
178
+ end
179
+ end
180
+
181
+ raise future[:error] if future[:error]
182
+ future[:result]
183
+ end
184
+
185
+ def self.resolve_cli_path
186
+ CLI_PATHS.each do |resolver|
187
+ path = resolver.call
188
+ return path if path && File.exist?(path)
189
+ end
190
+ # Check the npm global install path
191
+ npm_path = File.expand_path("~/.asdf/installs/nodejs/*/lib/node_modules/@openai/codex/bin/codex.js")
192
+ Dir[npm_path].each { |p| return p if File.exist?(p) }
193
+ raise Error, "Cannot find Codex CLI. Set CODEX_CLI_PATH or install Codex."
194
+ end
195
+
196
+ private
197
+
198
+ def build_extra_args
199
+ args = []
200
+ args += ["-c", "model=#{@model}"] if @model
201
+ args += ["-c", "model_provider=#{@model_provider}"] if @model_provider
202
+ args
203
+ end
204
+
205
+ def ensure_running
206
+ raise Error, "app-server is not running. Call #start first." unless running?
207
+ end
208
+
209
+ def ensure_initialized
210
+ ensure_running
211
+ raise Error, "Not initialized. Call #initialize! first." unless @initialized
212
+ end
213
+
214
+ def write_line(msg)
215
+ @stdin&.puts(JSON.generate(msg))
216
+ @stdin&.flush
217
+ end
218
+
219
+ def handle_message(msg)
220
+ if msg.key?("id")
221
+ rid = msg["id"]
222
+ future = nil
223
+ @mutex.synchronize { future = @pending.delete(rid) }
224
+
225
+ if future
226
+ @mutex.synchronize do
227
+ if msg.key?("error")
228
+ future[:error] = Error.new("[#{msg["error"]["code"]}] #{msg["error"]["message"]}")
229
+ elsif msg.key?("result")
230
+ future[:result] = msg["result"]
231
+ else
232
+ future[:error] = Error.new("No result or error")
233
+ end
234
+ future[:done] = true
235
+ future[:condition].signal
236
+ end
237
+ return
238
+ end
239
+
240
+ dispatch_notification(msg["method"], msg["params"] || {}, msg["id"]) if msg.key?("method")
241
+ return
242
+ end
243
+
244
+ dispatch_notification(msg["method"], msg["params"] || {}, nil) if msg.key?("method")
245
+ end
246
+
247
+ def dispatch_notification(method, params, request_id)
248
+ @event_handlers.dup.each { |h| h.call(method, params, request_id) rescue nil }
249
+ end
250
+
251
+ def self.find_in_path(executable)
252
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
253
+ path = File.join(dir, executable)
254
+ return path if File.exist?(path)
255
+ end
256
+ nil
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end