Skip to main content

codex_codes/
cli.rs

1//! Builder for launching the Codex app-server process.
2//!
3//! The [`AppServerBuilder`] configures and spawns `codex app-server --listen stdio://`,
4//! a long-lived process that speaks JSON-RPC over newline-delimited stdio.
5
6use log::debug;
7use std::ffi::{OsStr, OsString};
8use std::path::PathBuf;
9use std::process::Stdio;
10
11/// Builder for launching a Codex app-server process.
12///
13/// Produces commands of the form: `codex [-c k=v]... app-server --listen stdio:// [extra]...`
14///
15/// All model, sandbox, and approval configuration that isn't expressible as a
16/// CLI flag is done via JSON-RPC requests after connecting. For everything
17/// that *is* a CLI flag, see [`config_override`](Self::config_override) and
18/// [`extra_args`](Self::extra_args).
19#[derive(Debug, Clone)]
20pub struct AppServerBuilder {
21    command: PathBuf,
22    working_directory: Option<PathBuf>,
23    /// Environment variables set on the app-server child process.
24    environment: Vec<(OsString, OsString)>,
25    /// `-c key=value` overrides, in insertion order. Emitted *before* the
26    /// `app-server` subcommand because `-c` is a global `codex` flag.
27    config_overrides: Vec<(String, String)>,
28    /// Raw additional args appended *after* the `--listen stdio://` so they
29    /// land as subcommand args to `app-server`.
30    extra_args: Vec<String>,
31}
32
33impl Default for AppServerBuilder {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl AppServerBuilder {
40    /// Create a new builder with default settings.
41    pub fn new() -> Self {
42        Self {
43            command: PathBuf::from("codex"),
44            working_directory: None,
45            environment: Vec::new(),
46            config_overrides: Vec::new(),
47            extra_args: Vec::new(),
48        }
49    }
50
51    /// Set custom path to the codex binary.
52    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
53        self.command = path.into();
54        self
55    }
56
57    /// Set the working directory for the app-server process.
58    pub fn working_directory<P: Into<PathBuf>>(mut self, dir: P) -> Self {
59        self.working_directory = Some(dir.into());
60        self
61    }
62
63    /// Set an environment variable on the app-server child process.
64    ///
65    /// Calling this more than once with the same key follows
66    /// [`std::process::Command::env`] semantics: the last value wins.
67    ///
68    /// # Example
69    ///
70    /// ```
71    /// use codex_codes::AppServerBuilder;
72    ///
73    /// let builder = AppServerBuilder::new().env("CODEX_HOME", "/tmp/codex-home");
74    /// ```
75    pub fn env<K, V>(mut self, key: K, value: V) -> Self
76    where
77        K: AsRef<OsStr>,
78        V: AsRef<OsStr>,
79    {
80        self.environment
81            .push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
82        self
83    }
84
85    /// Set environment variables on the app-server child process.
86    ///
87    /// If the iterator contains duplicate keys, or a key was already set by
88    /// [`env`](Self::env), the last value wins.
89    pub fn envs<I, K, V>(mut self, variables: I) -> Self
90    where
91        I: IntoIterator<Item = (K, V)>,
92        K: AsRef<OsStr>,
93        V: AsRef<OsStr>,
94    {
95        self.environment.extend(
96            variables
97                .into_iter()
98                .map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string())),
99        );
100        self
101    }
102
103    /// Append a `-c key=value` global config override.
104    ///
105    /// Repeatable. Each call appends one override; order is preserved on the
106    /// command line. The `value` is passed to codex unparsed — codex tries
107    /// TOML, then falls back to the raw string. The caller is responsible for
108    /// any quoting / escaping the value itself needs (e.g. arrays:
109    /// `("sandbox_permissions", r#"["disk-full-read-access"]"#)`).
110    ///
111    /// `-c` flags are placed *before* the `app-server` subcommand because
112    /// they're parsed as global `codex` options, not subcommand args.
113    ///
114    /// # Example
115    ///
116    /// ```
117    /// use codex_codes::AppServerBuilder;
118    ///
119    /// let builder = AppServerBuilder::new()
120    ///     .config_override("sandbox_mode", "workspace-write")
121    ///     .config_override("approval_policy", "on-request");
122    /// ```
123    pub fn config_override<K, V>(mut self, key: K, value: V) -> Self
124    where
125        K: Into<String>,
126        V: Into<String>,
127    {
128        self.config_overrides.push((key.into(), value.into()));
129        self
130    }
131
132    /// Append raw arguments to the `app-server` subcommand invocation.
133    ///
134    /// Inserted *after* the hardcoded `--listen stdio://`, so they land as
135    /// subcommand args. Use this for flags the SDK doesn't model yet — e.g.
136    /// `--strict-config`, or `--session-source app-server` once that becomes
137    /// available on the multitool subcommand.
138    ///
139    /// For `-c key=value` global overrides use [`config_override`](Self::config_override)
140    /// instead; those need to be placed before the subcommand.
141    ///
142    /// # Example
143    ///
144    /// ```
145    /// use codex_codes::AppServerBuilder;
146    ///
147    /// let builder = AppServerBuilder::new()
148    ///     .extra_args(["--strict-config"]);
149    /// ```
150    pub fn extra_args<I, S>(mut self, args: I) -> Self
151    where
152        I: IntoIterator<Item = S>,
153        S: Into<String>,
154    {
155        self.extra_args.extend(args.into_iter().map(Into::into));
156        self
157    }
158
159    /// Resolve the command path, using `which` for non-absolute paths.
160    fn resolve_command(&self) -> crate::error::Result<PathBuf> {
161        if self.command.is_absolute() {
162            return Ok(self.command.clone());
163        }
164        which::which(&self.command).map_err(|_| crate::error::Error::BinaryNotFound {
165            name: self.command.display().to_string(),
166        })
167    }
168
169    /// Build the command arguments.
170    ///
171    /// Layout: `[-c k=v]... app-server --listen stdio:// [extra_args]...`
172    fn build_args(&self) -> Vec<String> {
173        let mut args =
174            Vec::with_capacity(self.config_overrides.len() * 2 + 3 + self.extra_args.len());
175        for (k, v) in &self.config_overrides {
176            args.push("-c".to_string());
177            args.push(format!("{k}={v}"));
178        }
179        args.push("app-server".to_string());
180        args.push("--listen".to_string());
181        args.push("stdio://".to_string());
182        args.extend(self.extra_args.iter().cloned());
183        args
184    }
185
186    /// Build a Tokio command without spawning it.
187    ///
188    /// This is the escape hatch for process configuration not modeled by the
189    /// builder. The command is fully configured for app-server stdio; callers
190    /// can make further adjustments before spawning it.
191    ///
192    /// Pair this with [`crate::AsyncClient::new`] to customize the command
193    /// while preserving the SDK's stdio setup.
194    #[cfg(feature = "async-client")]
195    pub fn build_command(self) -> crate::error::Result<tokio::process::Command> {
196        self.build_command_sync().map(tokio::process::Command::from)
197    }
198
199    /// Build a standard-library command without spawning it.
200    ///
201    /// This is the synchronous counterpart to [`build_command`](Self::build_command).
202    /// Pair it with [`crate::SyncClient::new`] to customize the command while
203    /// preserving the SDK's stdio setup.
204    pub fn build_command_sync(self) -> crate::error::Result<std::process::Command> {
205        let resolved = self.resolve_command()?;
206        let args = self.build_args();
207
208        debug!(
209            "[CLI] Building app-server command: {} {}",
210            resolved.display(),
211            args.join(" ")
212        );
213
214        let mut command = std::process::Command::new(resolved);
215        command
216            .args(args)
217            .envs(self.environment)
218            .stdin(Stdio::piped())
219            .stdout(Stdio::piped())
220            .stderr(Stdio::piped());
221
222        if let Some(dir) = self.working_directory {
223            command.current_dir(dir);
224        }
225
226        Ok(command)
227    }
228
229    /// Spawn the app-server process asynchronously.
230    #[cfg(feature = "async-client")]
231    pub async fn spawn(self) -> crate::error::Result<tokio::process::Child> {
232        self.build_command()?
233            .spawn()
234            .map_err(crate::error::Error::Io)
235    }
236
237    /// Spawn the app-server process synchronously.
238    pub fn spawn_sync(self) -> crate::error::Result<std::process::Child> {
239        self.build_command_sync()?
240            .spawn()
241            .map_err(crate::error::Error::Io)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_default_args() {
251        let builder = AppServerBuilder::new();
252        let args = builder.build_args();
253
254        assert_eq!(args, vec!["app-server", "--listen", "stdio://"]);
255    }
256
257    #[test]
258    fn test_custom_command() {
259        let builder = AppServerBuilder::new().command("/usr/local/bin/codex");
260        assert_eq!(builder.command, PathBuf::from("/usr/local/bin/codex"));
261    }
262
263    #[test]
264    fn test_working_directory() {
265        let builder = AppServerBuilder::new().working_directory("/tmp/work");
266        assert_eq!(builder.working_directory, Some(PathBuf::from("/tmp/work")));
267    }
268
269    #[test]
270    fn test_build_command_sync_applies_process_configuration() {
271        let executable = std::env::current_exe().unwrap();
272        let command = AppServerBuilder::new()
273            .command(&executable)
274            .working_directory("/tmp/work")
275            .env("CODEX_HOME", "/tmp/original")
276            .envs([("RUST_LOG", "debug"), ("CODEX_HOME", "/tmp/codex-home")])
277            .config_override("approval_policy", "never")
278            .extra_args(["--strict-config"])
279            .build_command_sync()
280            .unwrap();
281
282        assert_eq!(command.get_program(), executable);
283        assert_eq!(
284            command.get_args().collect::<Vec<_>>(),
285            [
286                "-c",
287                "approval_policy=never",
288                "app-server",
289                "--listen",
290                "stdio://",
291                "--strict-config",
292            ]
293        );
294        assert_eq!(
295            command.get_current_dir(),
296            Some(std::path::Path::new("/tmp/work"))
297        );
298        assert!(command.get_envs().any(|(key, value)| {
299            key == "CODEX_HOME" && value == Some(OsStr::new("/tmp/codex-home"))
300        }));
301        assert!(command
302            .get_envs()
303            .any(|(key, value)| key == "RUST_LOG" && value == Some(OsStr::new("debug"))));
304    }
305
306    #[test]
307    fn test_config_override_single() {
308        let args = AppServerBuilder::new()
309            .config_override("sandbox_mode", "workspace-write")
310            .build_args();
311        assert_eq!(
312            args,
313            vec![
314                "-c",
315                "sandbox_mode=workspace-write",
316                "app-server",
317                "--listen",
318                "stdio://"
319            ]
320        );
321    }
322
323    #[test]
324    fn test_config_override_multiple_preserves_order() {
325        let args = AppServerBuilder::new()
326            .config_override("sandbox_mode", "workspace-write")
327            .config_override("approval_policy", "on-request")
328            .build_args();
329        // Both `-c` pairs come BEFORE `app-server` since `-c` is a global
330        // codex flag.
331        assert_eq!(
332            args,
333            vec![
334                "-c",
335                "sandbox_mode=workspace-write",
336                "-c",
337                "approval_policy=on-request",
338                "app-server",
339                "--listen",
340                "stdio://"
341            ]
342        );
343    }
344
345    #[test]
346    fn test_extra_args_appended_after_listen() {
347        let args = AppServerBuilder::new()
348            .extra_args(["--strict-config"])
349            .build_args();
350        assert_eq!(
351            args,
352            vec!["app-server", "--listen", "stdio://", "--strict-config"]
353        );
354    }
355
356    #[test]
357    fn test_config_override_and_extra_args_combined() {
358        let args = AppServerBuilder::new()
359            .config_override("sandbox_mode", "workspace-write")
360            .extra_args(["--strict-config", "--something-else"])
361            .build_args();
362        assert_eq!(
363            args,
364            vec![
365                "-c",
366                "sandbox_mode=workspace-write",
367                "app-server",
368                "--listen",
369                "stdio://",
370                "--strict-config",
371                "--something-else",
372            ]
373        );
374    }
375
376    #[test]
377    fn test_config_override_value_with_special_chars_unchanged() {
378        // Caller is responsible for quoting the value half; we pass it
379        // through unchanged so codex's TOML parser sees it verbatim.
380        let args = AppServerBuilder::new()
381            .config_override("sandbox_permissions", r#"["disk-full-read-access"]"#)
382            .build_args();
383        assert_eq!(args[1], r#"sandbox_permissions=["disk-full-read-access"]"#);
384    }
385}