Skip to main content

codex_wrapper/
lib.rs

1//! A type-safe Codex CLI wrapper for Rust.
2//!
3//! `codex-wrapper` provides a builder-pattern interface for invoking the
4//! `codex` CLI programmatically. It follows the same design philosophy as
5//! [`claude-wrapper`](https://crates.io/crates/claude-wrapper) and
6//! [`docker-wrapper`](https://crates.io/crates/docker-wrapper):
7//! each CLI subcommand is a builder struct that produces typed output.
8//!
9//! # Quick Start
10//!
11//! ```no_run
12//! use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};
13//!
14//! # async fn example() -> codex_wrapper::Result<()> {
15//! let codex = Codex::builder().build()?;
16//!
17//! let output = ExecCommand::new("summarize this repository")
18//!     .sandbox(SandboxMode::WorkspaceWrite)
19//!     .ephemeral()
20//!     .execute(&codex)
21//!     .await?;
22//!
23//! println!("{}", output.stdout);
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! ## Defaults
29//!
30//! | Type | Default variant |
31//! |------|-----------------|
32//! | [`SandboxMode`] | [`SandboxMode::WorkspaceWrite`] |
33//! | [`ApprovalPolicy`] | [`ApprovalPolicy::OnRequest`] |
34//!
35//! # Two-Layer Builder
36//!
37//! The [`Codex`] client holds shared config (binary path, env vars, timeout,
38//! retry policy). Command builders hold per-invocation options and call
39//! `execute(&codex)`.
40//!
41//! ```no_run
42//! use codex_wrapper::{Codex, CodexCommand, ExecCommand, ApprovalPolicy, RetryPolicy};
43//! use std::time::Duration;
44//!
45//! # async fn example() -> codex_wrapper::Result<()> {
46//! // Configure once, reuse across commands
47//! let codex = Codex::builder()
48//!     .env("OPENAI_API_KEY", "sk-...")
49//!     .timeout_secs(300)
50//!     .retry(RetryPolicy::new().max_attempts(3).exponential())
51//!     .build()?;
52//!
53//! // Each command is a separate builder
54//! let output = ExecCommand::new("fix the failing tests")
55//!     .model("o3")
56//!     .approval_policy(ApprovalPolicy::Never)
57//!     .skip_git_repo_check()
58//!     .ephemeral()
59//!     .execute(&codex)
60//!     .await?;
61//! # Ok(())
62//! # }
63//! ```
64//!
65//! # JSONL Output Parsing
66//!
67//! Use `execute_json_lines()` to get structured events from `--json` mode:
68//!
69//! ```no_run
70//! use codex_wrapper::{Codex, ExecCommand};
71//!
72//! # async fn example() -> codex_wrapper::Result<()> {
73//! let codex = Codex::builder().build()?;
74//! let events = ExecCommand::new("what is 2+2?")
75//!     .ephemeral()
76//!     .execute_json_lines(&codex)
77//!     .await?;
78//!
79//! for event in &events {
80//!     println!("{}: {:?}", event.event_type, event.extra);
81//! }
82//! # Ok(())
83//! # }
84//! ```
85//!
86//! # Available Commands
87//!
88//! | Command | CLI equivalent |
89//! |---------|---------------|
90//! | [`ExecCommand`] | `codex exec <prompt>` |
91//! | [`ExecResumeCommand`] | `codex exec resume` |
92//! | [`ReviewCommand`] | `codex exec review` |
93//! | [`ResumeCommand`] | `codex resume` |
94//! | [`ForkCommand`] | `codex fork` |
95//! | [`LoginCommand`] | `codex login` |
96//! | [`LoginStatusCommand`] | `codex login status` |
97//! | [`LogoutCommand`] | `codex logout` |
98//! | [`McpListCommand`] | `codex mcp list` |
99//! | [`McpGetCommand`] | `codex mcp get` |
100//! | [`McpAddCommand`] | `codex mcp add` |
101//! | [`McpRemoveCommand`] | `codex mcp remove` |
102//! | [`McpLoginCommand`] | `codex mcp login` |
103//! | [`McpLogoutCommand`] | `codex mcp logout` |
104//! | [`McpServerCommand`] | `codex mcp-server` |
105//! | [`CompletionCommand`] | `codex completion` |
106//! | [`SandboxCommand`] | `codex sandbox` |
107//! | [`ApplyCommand`] | `codex apply` |
108//! | [`FeaturesListCommand`] | `codex features list` |
109//! | [`FeaturesEnableCommand`] | `codex features enable` |
110//! | [`FeaturesDisableCommand`] | `codex features disable` |
111//! | [`VersionCommand`] | `codex --version` |
112//! | [`RawCommand`] | Escape hatch for arbitrary args |
113//!
114//! # Error Handling
115//!
116//! All commands return [`Result<T>`], with typed errors via [`thiserror`]:
117//!
118//! ```no_run
119//! use codex_wrapper::{Codex, CodexCommand, ExecCommand, Error};
120//!
121//! # async fn example() -> codex_wrapper::Result<()> {
122//! let codex = Codex::builder().build()?;
123//! match ExecCommand::new("test").execute(&codex).await {
124//!     Ok(output) => println!("{}", output.stdout),
125//!     Err(Error::CommandFailed { stderr, exit_code, .. }) => {
126//!         eprintln!("failed (exit {}): {}", exit_code, stderr);
127//!     }
128//!     Err(Error::Timeout { .. }) => eprintln!("timed out"),
129//!     Err(e) => eprintln!("{e}"),
130//! }
131//! # Ok(())
132//! # }
133//! ```
134//!
135//! # Features
136//!
137//! - `json` *(enabled by default)* - JSONL output parsing via `serde_json`
138
139pub mod command;
140pub mod error;
141pub mod exec;
142pub mod retry;
143#[cfg(feature = "json")]
144pub mod session;
145#[cfg(feature = "json")]
146pub mod streaming;
147pub mod types;
148pub mod version;
149
150use std::collections::HashMap;
151use std::path::{Path, PathBuf};
152use std::time::Duration;
153
154pub use command::CodexCommand;
155pub use command::apply::ApplyCommand;
156pub use command::completion::{CompletionCommand, Shell};
157pub use command::exec::{ExecCommand, ExecResumeCommand};
158pub use command::features::{FeaturesDisableCommand, FeaturesEnableCommand, FeaturesListCommand};
159pub use command::fork::ForkCommand;
160pub use command::login::{LoginCommand, LoginStatusCommand, LogoutCommand};
161pub use command::mcp::{
162    McpAddCommand, McpGetCommand, McpListCommand, McpLoginCommand, McpLogoutCommand,
163    McpRemoveCommand,
164};
165pub use command::mcp_server::McpServerCommand;
166pub use command::raw::RawCommand;
167pub use command::resume::ResumeCommand;
168pub use command::review::ReviewCommand;
169pub use command::sandbox::{SandboxCommand, SandboxPlatform};
170pub use command::version::VersionCommand;
171pub use error::{Error, Result};
172pub use exec::CommandOutput;
173pub use retry::{BackoffStrategy, RetryPolicy};
174#[cfg(feature = "json")]
175pub use session::{Session, TurnRecord};
176pub use types::*;
177pub use version::{CliVersion, VersionParseError};
178
179/// Shared Codex CLI client configuration.
180///
181/// Holds the binary path, working directory, environment variables, global
182/// arguments, timeout, and retry policy. Cheap to [`Clone`]; intended to be
183/// created once and reused across many command invocations.
184///
185/// # Example
186///
187/// ```no_run
188/// # fn example() -> codex_wrapper::Result<()> {
189/// let codex = codex_wrapper::Codex::builder()
190///     .env("OPENAI_API_KEY", "sk-...")
191///     .timeout_secs(120)
192///     .build()?;
193/// # Ok(())
194/// # }
195/// ```
196#[derive(Debug, Clone)]
197pub struct Codex {
198    pub(crate) binary: PathBuf,
199    pub(crate) working_dir: Option<PathBuf>,
200    pub(crate) env: HashMap<String, String>,
201    pub(crate) global_args: Vec<String>,
202    pub(crate) timeout: Option<Duration>,
203    pub(crate) retry_policy: Option<RetryPolicy>,
204}
205
206impl Codex {
207    /// Create a new [`CodexBuilder`].
208    #[must_use]
209    pub fn builder() -> CodexBuilder {
210        CodexBuilder::default()
211    }
212
213    /// Path to the resolved `codex` binary.
214    #[must_use]
215    pub fn binary(&self) -> &Path {
216        &self.binary
217    }
218
219    /// Working directory for command execution, if set.
220    #[must_use]
221    pub fn working_dir(&self) -> Option<&Path> {
222        self.working_dir.as_deref()
223    }
224
225    /// Return a clone of this client with a different working directory.
226    #[must_use]
227    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
228        let mut clone = self.clone();
229        clone.working_dir = Some(dir.into());
230        clone
231    }
232
233    /// Query the installed Codex CLI version.
234    pub async fn cli_version(&self) -> Result<CliVersion> {
235        let output = VersionCommand::new().execute(self).await?;
236        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
237            message: format!("failed to parse CLI version: {e}"),
238            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
239            working_dir: None,
240        })
241    }
242
243    /// Verify the installed CLI meets a minimum version requirement.
244    ///
245    /// Returns [`Error::VersionMismatch`] if the installed version is too old.
246    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
247        let version = self.cli_version().await?;
248        if version.satisfies_minimum(minimum) {
249            Ok(version)
250        } else {
251            Err(Error::VersionMismatch {
252                found: version,
253                minimum: *minimum,
254            })
255        }
256    }
257}
258
259/// Builder for creating a [`Codex`] client.
260///
261/// All options are optional. By default the builder discovers the `codex`
262/// binary via `PATH`.
263#[derive(Debug, Default)]
264pub struct CodexBuilder {
265    binary: Option<PathBuf>,
266    working_dir: Option<PathBuf>,
267    env: HashMap<String, String>,
268    global_args: Vec<String>,
269    timeout: Option<Duration>,
270    retry_policy: Option<RetryPolicy>,
271}
272
273impl CodexBuilder {
274    /// Set an explicit path to the `codex` binary (skips `PATH` lookup).
275    #[must_use]
276    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
277        self.binary = Some(path.into());
278        self
279    }
280
281    /// Set the working directory for all commands.
282    #[must_use]
283    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
284        self.working_dir = Some(path.into());
285        self
286    }
287
288    /// Set a single environment variable for child processes.
289    #[must_use]
290    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
291        self.env.insert(key.into(), value.into());
292        self
293    }
294
295    /// Set multiple environment variables for child processes.
296    #[must_use]
297    pub fn envs(
298        mut self,
299        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
300    ) -> Self {
301        for (key, value) in vars {
302            self.env.insert(key.into(), value.into());
303        }
304        self
305    }
306
307    /// Set the command timeout in seconds.
308    #[must_use]
309    pub fn timeout_secs(mut self, seconds: u64) -> Self {
310        self.timeout = Some(Duration::from_secs(seconds));
311        self
312    }
313
314    /// Set the command timeout as a [`Duration`].
315    #[must_use]
316    pub fn timeout(mut self, duration: Duration) -> Self {
317        self.timeout = Some(duration);
318        self
319    }
320
321    /// Append a raw global argument passed before any subcommand.
322    #[must_use]
323    pub fn arg(mut self, arg: impl Into<String>) -> Self {
324        self.global_args.push(arg.into());
325        self
326    }
327
328    /// Add a global config override (`-c key=value`).
329    #[must_use]
330    pub fn config(mut self, key_value: impl Into<String>) -> Self {
331        self.global_args.push("-c".into());
332        self.global_args.push(key_value.into());
333        self
334    }
335
336    /// Enable a feature flag globally (`--enable <name>`).
337    #[must_use]
338    pub fn enable(mut self, feature: impl Into<String>) -> Self {
339        self.global_args.push("--enable".into());
340        self.global_args.push(feature.into());
341        self
342    }
343
344    /// Disable a feature flag globally (`--disable <name>`).
345    #[must_use]
346    pub fn disable(mut self, feature: impl Into<String>) -> Self {
347        self.global_args.push("--disable".into());
348        self.global_args.push(feature.into());
349        self
350    }
351
352    /// Set a default [`RetryPolicy`] for all commands.
353    #[must_use]
354    pub fn retry(mut self, policy: RetryPolicy) -> Self {
355        self.retry_policy = Some(policy);
356        self
357    }
358
359    /// Build the [`Codex`] client.
360    ///
361    /// Returns [`Error::NotFound`] if no binary path was set and `codex` is
362    /// not found in `PATH`.
363    pub fn build(self) -> Result<Codex> {
364        let binary = match self.binary {
365            Some(path) => path,
366            None => which::which("codex").map_err(|_| Error::NotFound)?,
367        };
368
369        Ok(Codex {
370            binary,
371            working_dir: self.working_dir,
372            env: self.env,
373            global_args: self.global_args,
374            timeout: self.timeout,
375            retry_policy: self.retry_policy,
376        })
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn builder_with_binary() {
386        let codex = Codex::builder()
387            .binary("/usr/local/bin/codex")
388            .env("FOO", "bar")
389            .timeout_secs(60)
390            .build()
391            .unwrap();
392
393        assert_eq!(codex.binary, PathBuf::from("/usr/local/bin/codex"));
394        assert_eq!(codex.env.get("FOO").unwrap(), "bar");
395        assert_eq!(codex.timeout, Some(Duration::from_secs(60)));
396    }
397
398    #[test]
399    fn builder_global_args() {
400        let codex = Codex::builder()
401            .binary("/usr/local/bin/codex")
402            .config("model=\"gpt-5\"")
403            .enable("foo")
404            .disable("bar")
405            .build()
406            .unwrap();
407
408        assert_eq!(
409            codex.global_args,
410            vec![
411                "-c",
412                "model=\"gpt-5\"",
413                "--enable",
414                "foo",
415                "--disable",
416                "bar"
417            ]
418        );
419    }
420}