Skip to main content

codex_wrapper/command/
exec.rs

1use crate::Codex;
2use crate::command::CodexCommand;
3#[cfg(feature = "json")]
4use crate::error::Error;
5use crate::error::Result;
6use crate::exec::{self, CommandOutput};
7#[cfg(feature = "json")]
8use crate::types::JsonLineEvent;
9use crate::types::{ApprovalPolicy, Color, SandboxMode};
10
11/// Run Codex non-interactively (`codex exec <prompt>`).
12///
13/// This is the primary command for programmatic use. It supports the full
14/// range of exec flags: model selection, sandbox policy, approval policy,
15/// images, config overrides, feature flags, JSON output, and more.
16///
17/// # Example
18///
19/// ```no_run
20/// use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};
21///
22/// # async fn example() -> codex_wrapper::Result<()> {
23/// let codex = Codex::builder().build()?;
24/// let output = ExecCommand::new("fix the failing test")
25///     .model("o3")
26///     .sandbox(SandboxMode::WorkspaceWrite)
27///     .ephemeral()
28///     .execute(&codex)
29///     .await?;
30/// println!("{}", output.stdout);
31/// # Ok(())
32/// # }
33/// ```
34#[derive(Debug, Clone)]
35pub struct ExecCommand {
36    prompt: Option<String>,
37    config_overrides: Vec<String>,
38    enabled_features: Vec<String>,
39    disabled_features: Vec<String>,
40    images: Vec<String>,
41    model: Option<String>,
42    oss: bool,
43    local_provider: Option<String>,
44    sandbox: Option<SandboxMode>,
45    approval_policy: Option<ApprovalPolicy>,
46    profile: Option<String>,
47    full_auto: bool,
48    dangerously_bypass_approvals_and_sandbox: bool,
49    cd: Option<String>,
50    skip_git_repo_check: bool,
51    add_dirs: Vec<String>,
52    search: bool,
53    ephemeral: bool,
54    output_schema: Option<String>,
55    color: Option<Color>,
56    progress_cursor: bool,
57    json: bool,
58    output_last_message: Option<String>,
59    retry_policy: Option<crate::retry::RetryPolicy>,
60}
61
62impl ExecCommand {
63    /// Create a new exec command with the given prompt.
64    #[must_use]
65    pub fn new(prompt: impl Into<String>) -> Self {
66        Self {
67            prompt: Some(prompt.into()),
68            config_overrides: Vec::new(),
69            enabled_features: Vec::new(),
70            disabled_features: Vec::new(),
71            images: Vec::new(),
72            model: None,
73            oss: false,
74            local_provider: None,
75            sandbox: None,
76            approval_policy: None,
77            profile: None,
78            full_auto: false,
79            dangerously_bypass_approvals_and_sandbox: false,
80            cd: None,
81            skip_git_repo_check: false,
82            add_dirs: Vec::new(),
83            search: false,
84            ephemeral: false,
85            output_schema: None,
86            color: None,
87            progress_cursor: false,
88            json: false,
89            output_last_message: None,
90            retry_policy: None,
91        }
92    }
93
94    /// Read the prompt from stdin (`-`).
95    #[must_use]
96    pub fn from_stdin() -> Self {
97        Self::new("-")
98    }
99
100    #[must_use]
101    pub fn config(mut self, key_value: impl Into<String>) -> Self {
102        self.config_overrides.push(key_value.into());
103        self
104    }
105
106    #[must_use]
107    pub fn enable(mut self, feature: impl Into<String>) -> Self {
108        self.enabled_features.push(feature.into());
109        self
110    }
111
112    #[must_use]
113    pub fn disable(mut self, feature: impl Into<String>) -> Self {
114        self.disabled_features.push(feature.into());
115        self
116    }
117
118    #[must_use]
119    pub fn image(mut self, path: impl Into<String>) -> Self {
120        self.images.push(path.into());
121        self
122    }
123
124    #[must_use]
125    pub fn model(mut self, model: impl Into<String>) -> Self {
126        let model = model.into();
127        assert!(!model.is_empty(), "model name must not be empty");
128        self.model = Some(model);
129        self
130    }
131
132    #[must_use]
133    pub fn oss(mut self) -> Self {
134        self.oss = true;
135        self
136    }
137
138    #[must_use]
139    pub fn local_provider(mut self, provider: impl Into<String>) -> Self {
140        self.local_provider = Some(provider.into());
141        self
142    }
143
144    #[must_use]
145    pub fn sandbox(mut self, sandbox: SandboxMode) -> Self {
146        self.sandbox = Some(sandbox);
147        self
148    }
149
150    #[must_use]
151    pub fn approval_policy(mut self, policy: ApprovalPolicy) -> Self {
152        self.approval_policy = Some(policy);
153        self
154    }
155
156    #[must_use]
157    pub fn profile(mut self, profile: impl Into<String>) -> Self {
158        self.profile = Some(profile.into());
159        self
160    }
161
162    #[must_use]
163    pub fn full_auto(mut self) -> Self {
164        self.full_auto = true;
165        self
166    }
167
168    #[must_use]
169    pub fn dangerously_bypass_approvals_and_sandbox(mut self) -> Self {
170        self.dangerously_bypass_approvals_and_sandbox = true;
171        self
172    }
173
174    #[must_use]
175    pub fn cd(mut self, dir: impl Into<String>) -> Self {
176        self.cd = Some(dir.into());
177        self
178    }
179
180    #[must_use]
181    pub fn skip_git_repo_check(mut self) -> Self {
182        self.skip_git_repo_check = true;
183        self
184    }
185
186    #[must_use]
187    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
188        self.add_dirs.push(dir.into());
189        self
190    }
191
192    /// Enable live web search.
193    #[must_use]
194    pub fn search(mut self) -> Self {
195        self.search = true;
196        self
197    }
198
199    #[must_use]
200    pub fn ephemeral(mut self) -> Self {
201        self.ephemeral = true;
202        self
203    }
204
205    #[must_use]
206    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
207        self.output_schema = Some(path.into());
208        self
209    }
210
211    #[must_use]
212    pub fn color(mut self, color: Color) -> Self {
213        self.color = Some(color);
214        self
215    }
216
217    #[must_use]
218    pub fn progress_cursor(mut self) -> Self {
219        self.progress_cursor = true;
220        self
221    }
222
223    #[must_use]
224    pub fn json(mut self) -> Self {
225        self.json = true;
226        self
227    }
228
229    #[must_use]
230    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
231        self.output_last_message = Some(path.into());
232        self
233    }
234
235    #[must_use]
236    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
237        self.retry_policy = Some(policy);
238        self
239    }
240
241    #[cfg(feature = "json")]
242    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
243        let mut args = self.args();
244        if !self.json {
245            args.push("--json".into());
246        }
247
248        let output = exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?;
249        parse_json_lines(&output.stdout)
250    }
251}
252
253impl CodexCommand for ExecCommand {
254    type Output = CommandOutput;
255
256    fn args(&self) -> Vec<String> {
257        let mut args = vec!["exec".to_string()];
258
259        push_repeat(&mut args, "-c", &self.config_overrides);
260        push_repeat(&mut args, "--enable", &self.enabled_features);
261        push_repeat(&mut args, "--disable", &self.disabled_features);
262        push_repeat(&mut args, "--image", &self.images);
263
264        if let Some(model) = &self.model {
265            args.push("--model".into());
266            args.push(model.clone());
267        }
268        if self.oss {
269            args.push("--oss".into());
270        }
271        if let Some(local_provider) = &self.local_provider {
272            args.push("--local-provider".into());
273            args.push(local_provider.clone());
274        }
275        if let Some(sandbox) = self.sandbox {
276            args.push("--sandbox".into());
277            args.push(sandbox.as_arg().into());
278        }
279        if let Some(policy) = self.approval_policy {
280            args.push("--ask-for-approval".into());
281            args.push(policy.as_arg().into());
282        }
283        if let Some(profile) = &self.profile {
284            args.push("--profile".into());
285            args.push(profile.clone());
286        }
287        if self.full_auto {
288            args.push("--full-auto".into());
289        }
290        if self.dangerously_bypass_approvals_and_sandbox {
291            args.push("--dangerously-bypass-approvals-and-sandbox".into());
292        }
293        if let Some(cd) = &self.cd {
294            args.push("--cd".into());
295            args.push(cd.clone());
296        }
297        if self.skip_git_repo_check {
298            args.push("--skip-git-repo-check".into());
299        }
300        push_repeat(&mut args, "--add-dir", &self.add_dirs);
301        if self.search {
302            args.push("--search".into());
303        }
304        if self.ephemeral {
305            args.push("--ephemeral".into());
306        }
307        if let Some(output_schema) = &self.output_schema {
308            args.push("--output-schema".into());
309            args.push(output_schema.clone());
310        }
311        if let Some(color) = self.color {
312            args.push("--color".into());
313            args.push(color.as_arg().into());
314        }
315        if self.progress_cursor {
316            args.push("--progress-cursor".into());
317        }
318        if self.json {
319            args.push("--json".into());
320        }
321        if let Some(path) = &self.output_last_message {
322            args.push("--output-last-message".into());
323            args.push(path.clone());
324        }
325        if let Some(prompt) = &self.prompt {
326            args.push(prompt.clone());
327        }
328
329        args
330    }
331
332    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
333        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
334    }
335}
336
337/// Resume a previous non-interactive session (`codex exec resume`).
338///
339/// Use [`session_id`](ExecResumeCommand::session_id) to target a specific
340/// session, or [`last`](ExecResumeCommand::last) to pick the most recent.
341#[derive(Debug, Clone)]
342pub struct ExecResumeCommand {
343    session_id: Option<String>,
344    prompt: Option<String>,
345    last: bool,
346    all: bool,
347    config_overrides: Vec<String>,
348    enabled_features: Vec<String>,
349    disabled_features: Vec<String>,
350    images: Vec<String>,
351    model: Option<String>,
352    full_auto: bool,
353    dangerously_bypass_approvals_and_sandbox: bool,
354    skip_git_repo_check: bool,
355    ephemeral: bool,
356    json: bool,
357    output_last_message: Option<String>,
358    retry_policy: Option<crate::retry::RetryPolicy>,
359}
360
361impl ExecResumeCommand {
362    #[must_use]
363    pub fn new() -> Self {
364        Self {
365            session_id: None,
366            prompt: None,
367            last: false,
368            all: false,
369            config_overrides: Vec::new(),
370            enabled_features: Vec::new(),
371            disabled_features: Vec::new(),
372            images: Vec::new(),
373            model: None,
374            full_auto: false,
375            dangerously_bypass_approvals_and_sandbox: false,
376            skip_git_repo_check: false,
377            ephemeral: false,
378            json: false,
379            output_last_message: None,
380            retry_policy: None,
381        }
382    }
383
384    #[must_use]
385    pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
386        self.session_id = Some(session_id.into());
387        self
388    }
389
390    #[must_use]
391    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
392        self.prompt = Some(prompt.into());
393        self
394    }
395
396    #[must_use]
397    pub fn last(mut self) -> Self {
398        self.last = true;
399        self
400    }
401
402    #[must_use]
403    pub fn all(mut self) -> Self {
404        self.all = true;
405        self
406    }
407
408    #[must_use]
409    pub fn model(mut self, model: impl Into<String>) -> Self {
410        let model = model.into();
411        assert!(!model.is_empty(), "model name must not be empty");
412        self.model = Some(model);
413        self
414    }
415
416    #[must_use]
417    pub fn image(mut self, path: impl Into<String>) -> Self {
418        self.images.push(path.into());
419        self
420    }
421
422    #[must_use]
423    pub fn json(mut self) -> Self {
424        self.json = true;
425        self
426    }
427
428    #[must_use]
429    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
430        self.output_last_message = Some(path.into());
431        self
432    }
433
434    #[must_use]
435    pub fn config(mut self, key_value: impl Into<String>) -> Self {
436        self.config_overrides.push(key_value.into());
437        self
438    }
439
440    #[must_use]
441    pub fn enable(mut self, feature: impl Into<String>) -> Self {
442        self.enabled_features.push(feature.into());
443        self
444    }
445
446    #[must_use]
447    pub fn disable(mut self, feature: impl Into<String>) -> Self {
448        self.disabled_features.push(feature.into());
449        self
450    }
451
452    #[must_use]
453    pub fn full_auto(mut self) -> Self {
454        self.full_auto = true;
455        self
456    }
457
458    #[must_use]
459    pub fn dangerously_bypass_approvals_and_sandbox(mut self) -> Self {
460        self.dangerously_bypass_approvals_and_sandbox = true;
461        self
462    }
463
464    #[must_use]
465    pub fn skip_git_repo_check(mut self) -> Self {
466        self.skip_git_repo_check = true;
467        self
468    }
469
470    #[must_use]
471    pub fn ephemeral(mut self) -> Self {
472        self.ephemeral = true;
473        self
474    }
475
476    #[must_use]
477    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
478        self.retry_policy = Some(policy);
479        self
480    }
481}
482
483impl Default for ExecResumeCommand {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489impl CodexCommand for ExecResumeCommand {
490    type Output = CommandOutput;
491
492    fn args(&self) -> Vec<String> {
493        let mut args = vec!["exec".into(), "resume".into()];
494        push_repeat(&mut args, "-c", &self.config_overrides);
495        push_repeat(&mut args, "--enable", &self.enabled_features);
496        push_repeat(&mut args, "--disable", &self.disabled_features);
497        if self.last {
498            args.push("--last".into());
499        }
500        if self.all {
501            args.push("--all".into());
502        }
503        push_repeat(&mut args, "--image", &self.images);
504        if let Some(model) = &self.model {
505            args.push("--model".into());
506            args.push(model.clone());
507        }
508        if self.full_auto {
509            args.push("--full-auto".into());
510        }
511        if self.dangerously_bypass_approvals_and_sandbox {
512            args.push("--dangerously-bypass-approvals-and-sandbox".into());
513        }
514        if self.skip_git_repo_check {
515            args.push("--skip-git-repo-check".into());
516        }
517        if self.ephemeral {
518            args.push("--ephemeral".into());
519        }
520        if self.json {
521            args.push("--json".into());
522        }
523        if let Some(path) = &self.output_last_message {
524            args.push("--output-last-message".into());
525            args.push(path.clone());
526        }
527        if let Some(session_id) = &self.session_id {
528            args.push(session_id.clone());
529        }
530        if let Some(prompt) = &self.prompt {
531            args.push(prompt.clone());
532        }
533        args
534    }
535
536    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
537        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
538    }
539}
540
541fn push_repeat(args: &mut Vec<String>, flag: &str, values: &[String]) {
542    for value in values {
543        args.push(flag.into());
544        args.push(value.clone());
545    }
546}
547
548#[cfg(feature = "json")]
549fn parse_json_lines(stdout: &str) -> Result<Vec<JsonLineEvent>> {
550    stdout
551        .lines()
552        .filter(|line| line.trim_start().starts_with('{'))
553        .map(|line| {
554            serde_json::from_str(line).map_err(|source| Error::Json {
555                message: format!("failed to parse JSONL event: {line}"),
556                source,
557            })
558        })
559        .collect()
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn exec_args() {
568        let args = ExecCommand::new("fix the test")
569            .model("gpt-5")
570            .sandbox(SandboxMode::WorkspaceWrite)
571            .approval_policy(ApprovalPolicy::OnRequest)
572            .skip_git_repo_check()
573            .ephemeral()
574            .json()
575            .args();
576
577        assert_eq!(
578            args,
579            vec![
580                "exec",
581                "--model",
582                "gpt-5",
583                "--sandbox",
584                "workspace-write",
585                "--ask-for-approval",
586                "on-request",
587                "--skip-git-repo-check",
588                "--ephemeral",
589                "--json",
590                "fix the test",
591            ]
592        );
593    }
594
595    #[test]
596    #[should_panic(expected = "model name must not be empty")]
597    fn exec_model_empty_panics() {
598        let _ = ExecCommand::new("prompt").model("");
599    }
600
601    #[test]
602    #[should_panic(expected = "model name must not be empty")]
603    fn exec_resume_model_empty_panics() {
604        let _ = ExecResumeCommand::new().model("");
605    }
606
607    #[test]
608    fn exec_resume_args() {
609        let args = ExecResumeCommand::new()
610            .last()
611            .model("gpt-5")
612            .json()
613            .prompt("continue")
614            .args();
615
616        assert_eq!(
617            args,
618            vec![
619                "exec", "resume", "--last", "--model", "gpt-5", "--json", "continue",
620            ]
621        );
622    }
623}