Skip to main content

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}