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;
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",
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 // EOF — process exited.
147 self.finished = true;
148 return Ok(None);
149 };
150
151 // Try to parse as a typed Event.
152 let event: Event = match serde_json::from_value(value.clone()) {
153 Ok(e) => e,
154 Err(e) => {
155 // If we can't parse it, log and skip (forward compatibility).
156 tracing::warn!(
157 error = %e,
158 line = %value,
159 "skipping unrecognized event from codex stdout"
160 );
161 continue;
162 }
163 };
164
165 // Track thread_id from the first ThreadStarted event.
166 if let Event::ThreadStarted { ref thread_id } = event {
167 self.thread_id = Some(thread_id.clone());
168 }
169
170 return Ok(Some(event));
171 }
172 }
173
174 /// Get the thread ID assigned by codex, if a `thread.started` event has
175 /// been received.
176 pub fn thread_id(&self) -> Option<&str> {
177 self.thread_id.as_deref()
178 }
179
180 /// Returns `true` if the execution has finished (subprocess exited or EOF).
181 pub fn is_finished(&self) -> bool {
182 self.finished
183 }
184
185 /// Send an interrupt signal to the subprocess (SIGINT).
186 ///
187 /// This tells codex to stop its current operation.
188 ///
189 /// # Errors
190 ///
191 /// Returns an error if the signal cannot be sent.
192 pub fn interrupt(&self) -> Result<()> {
193 self.transport.interrupt()
194 }
195
196 /// Kill the subprocess immediately.
197 pub async fn kill(mut self) -> Result<()> {
198 self.transport.kill().await
199 }
200
201 /// Wait for the subprocess to exit and return the exit code and stderr.
202 pub async fn wait(mut self) -> Result<(Option<i32>, Option<String>)> {
203 self.transport.wait_with_stderr().await
204 }
205}