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