apiari_codex_sdk/client.rs
1//! High-level client for spawning and streaming codex executions.
2//!
3//! [`CodexClient`] is the main entry point. Configure it once, then call
4//! [`exec`](CodexClient::exec) to start an [`Execution`] that reads JSONL
5//! events from the codex subprocess.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! # use apiari_codex_sdk::{CodexClient, ExecOptions, Event, Item};
11//! # async fn example() -> apiari_codex_sdk::error::Result<()> {
12//! let client = CodexClient::new();
13//! let mut execution = client.exec("List files in the current directory", ExecOptions {
14//! model: Some("o4-mini".into()),
15//! full_auto: true,
16//! ..Default::default()
17//! }).await?;
18//!
19//! while let Some(event) = execution.next_event().await? {
20//! if let Event::ItemCompleted { item: Item::AgentMessage { text, .. } } = &event {
21//! if let Some(text) = text {
22//! println!("{text}");
23//! }
24//! }
25//! }
26//! # Ok(())
27//! # }
28//! ```
29
30use crate::error::{Result, SdkError};
31use crate::options::{ExecOptions, ResumeOptions};
32use crate::transport::ReadOnlyTransport;
33use crate::types::Event;
34
35/// Builder / factory for codex executions.
36///
37/// Holds configuration that applies to every execution, such as the path
38/// to the `codex` binary.
39#[derive(Debug, Clone)]
40pub struct CodexClient {
41 /// Path to the `codex` CLI binary.
42 pub cli_path: String,
43}
44
45impl Default for CodexClient {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl CodexClient {
52 /// Create a new client that will look for `codex` on `$PATH`.
53 pub fn new() -> Self {
54 Self {
55 cli_path: "codex".to_owned(),
56 }
57 }
58
59 /// Create a new client with a custom path to the codex CLI binary.
60 pub fn with_cli_path(path: impl Into<String>) -> Self {
61 Self {
62 cli_path: path.into(),
63 }
64 }
65
66 /// Start a new codex execution with the given prompt and options.
67 ///
68 /// This spawns the `codex exec --json` subprocess and returns an
69 /// [`Execution`] handle for reading events.
70 ///
71 /// # Errors
72 ///
73 /// Returns [`SdkError::ProcessSpawn`](crate::error::SdkError::ProcessSpawn)
74 /// if the `codex` binary cannot be found or started.
75 pub async fn exec(&self, prompt: &str, opts: ExecOptions) -> Result<Execution> {
76 let args = opts.to_cli_args();
77 let transport = ReadOnlyTransport::spawn(
78 &self.cli_path,
79 &["exec"],
80 &args,
81 Some(prompt),
82 opts.working_dir.as_deref(),
83 &opts.env_vars,
84 )?;
85
86 Ok(Execution {
87 transport,
88 thread_id: None,
89 finished: false,
90 })
91 }
92
93 /// Resume a previous codex execution.
94 ///
95 /// # Errors
96 ///
97 /// Returns [`SdkError::ProcessSpawn`](crate::error::SdkError::ProcessSpawn)
98 /// if the `codex` binary cannot be found or started.
99 pub async fn exec_resume(&self, prompt: &str, opts: ResumeOptions) -> Result<Execution> {
100 let args = opts.to_cli_args();
101 let transport = ReadOnlyTransport::spawn(
102 &self.cli_path,
103 &["exec", "resume"],
104 &args,
105 Some(prompt),
106 opts.working_dir.as_deref(),
107 &opts.env_vars,
108 )?;
109
110 Ok(Execution {
111 transport,
112 thread_id: None,
113 finished: false,
114 })
115 }
116}
117
118/// A live execution of a `codex exec --json` subprocess.
119///
120/// Provides a read-only event stream. The codex process handles tool execution
121/// internally — there is no stdin interaction.
122pub struct Execution {
123 transport: ReadOnlyTransport,
124 thread_id: Option<String>,
125 finished: bool,
126}
127
128impl Execution {
129 /// Get the next event from the execution.
130 ///
131 /// Returns `Ok(None)` when the execution is complete (subprocess exited).
132 ///
133 /// # Errors
134 ///
135 /// Returns an error on I/O failure, JSON parse failure, or if the
136 /// subprocess dies unexpectedly.
137 pub async fn next_event(&mut self) -> Result<Option<Event>> {
138 if self.finished {
139 return Ok(None);
140 }
141
142 loop {
143 let value = self.transport.recv().await?;
144
145 let Some(value) = value else {
146 self.finished = true;
147 let (exit_code, stderr) = self.transport.wait_with_stderr().await?;
148 if exit_code.unwrap_or(0) != 0 {
149 return Err(SdkError::ProcessDied {
150 exit_code,
151 stderr: stderr.unwrap_or_default(),
152 });
153 }
154 return Ok(None);
155 };
156
157 // Try to parse as a typed Event.
158 let event: Event = match serde_json::from_value(value.clone()) {
159 Ok(e) => e,
160 Err(e) => {
161 // If we can't parse it, log and skip (forward compatibility).
162 tracing::warn!(
163 error = %e,
164 line = %value,
165 "skipping unrecognized event from codex stdout"
166 );
167 continue;
168 }
169 };
170
171 // Track thread_id from the first ThreadStarted event.
172 if let Event::ThreadStarted { ref thread_id } = event {
173 self.thread_id = Some(thread_id.clone());
174 }
175
176 return Ok(Some(event));
177 }
178 }
179
180 /// Get the thread ID assigned by codex, if a `thread.started` event has
181 /// been received.
182 pub fn thread_id(&self) -> Option<&str> {
183 self.thread_id.as_deref()
184 }
185
186 /// Returns `true` if the execution has finished (subprocess exited or EOF).
187 pub fn is_finished(&self) -> bool {
188 self.finished
189 }
190
191 /// Send an interrupt signal to the subprocess (SIGINT).
192 ///
193 /// This tells codex to stop its current operation.
194 ///
195 /// # Errors
196 ///
197 /// Returns an error if the signal cannot be sent.
198 pub fn interrupt(&self) -> Result<()> {
199 self.transport.interrupt()
200 }
201
202 /// Kill the subprocess immediately.
203 pub async fn kill(mut self) -> Result<()> {
204 self.transport.kill().await
205 }
206
207 /// Wait for the subprocess to exit and return the exit code and stderr.
208 pub async fn wait(mut self) -> Result<(Option<i32>, Option<String>)> {
209 self.transport.wait_with_stderr().await
210 }
211}