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    /// Override a config key (`-c key=value`).
101    ///
102    /// May be called multiple times to set several keys.
103    #[must_use]
104    pub fn config(mut self, key_value: impl Into<String>) -> Self {
105        self.config_overrides.push(key_value.into());
106        self
107    }
108
109    /// Enable an optional feature flag (`--enable <feature>`).
110    ///
111    /// May be called multiple times.
112    #[must_use]
113    pub fn enable(mut self, feature: impl Into<String>) -> Self {
114        self.enabled_features.push(feature.into());
115        self
116    }
117
118    /// Disable an optional feature flag (`--disable <feature>`).
119    ///
120    /// May be called multiple times.
121    #[must_use]
122    pub fn disable(mut self, feature: impl Into<String>) -> Self {
123        self.disabled_features.push(feature.into());
124        self
125    }
126
127    /// Attach an image to the prompt (`--image <path>`).
128    ///
129    /// May be called multiple times to attach several images.
130    #[must_use]
131    pub fn image(mut self, path: impl Into<String>) -> Self {
132        self.images.push(path.into());
133        self
134    }
135
136    /// Set the model to use (`--model <model>`).
137    ///
138    /// Panics if `model` is an empty string.
139    #[must_use]
140    pub fn model(mut self, model: impl Into<String>) -> Self {
141        let model = model.into();
142        assert!(!model.is_empty(), "model name must not be empty");
143        self.model = Some(model);
144        self
145    }
146
147    /// Use the OSS model tier (`--oss`).
148    #[must_use]
149    pub fn oss(mut self) -> Self {
150        self.oss = true;
151        self
152    }
153
154    /// Use a local model provider (`--local-provider <provider>`).
155    #[must_use]
156    pub fn local_provider(mut self, provider: impl Into<String>) -> Self {
157        self.local_provider = Some(provider.into());
158        self
159    }
160
161    /// Set the sandbox policy (`--sandbox <mode>`).
162    #[must_use]
163    pub fn sandbox(mut self, sandbox: SandboxMode) -> Self {
164        self.sandbox = Some(sandbox);
165        self
166    }
167
168    /// Set the approval policy (`--ask-for-approval <policy>`).
169    #[must_use]
170    pub fn approval_policy(mut self, policy: ApprovalPolicy) -> Self {
171        self.approval_policy = Some(policy);
172        self
173    }
174
175    /// Select a named configuration profile (`--profile <name>`).
176    #[must_use]
177    pub fn profile(mut self, profile: impl Into<String>) -> Self {
178        self.profile = Some(profile.into());
179        self
180    }
181
182    /// Run in full-auto mode — no approval prompts (`--full-auto`).
183    #[must_use]
184    pub fn full_auto(mut self) -> Self {
185        self.full_auto = true;
186        self
187    }
188
189    /// Bypass all approval prompts and sandbox restrictions.
190    ///
191    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
192    #[must_use]
193    pub fn dangerously_bypass_approvals_and_sandbox(mut self) -> Self {
194        self.dangerously_bypass_approvals_and_sandbox = true;
195        self
196    }
197
198    /// Change the working directory before running (`--cd <dir>`).
199    #[must_use]
200    pub fn cd(mut self, dir: impl Into<String>) -> Self {
201        self.cd = Some(dir.into());
202        self
203    }
204
205    /// Skip the git repository check (`--skip-git-repo-check`).
206    #[must_use]
207    pub fn skip_git_repo_check(mut self) -> Self {
208        self.skip_git_repo_check = true;
209        self
210    }
211
212    /// Add an extra directory to the context (`--add-dir <dir>`).
213    ///
214    /// May be called multiple times.
215    #[must_use]
216    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
217        self.add_dirs.push(dir.into());
218        self
219    }
220
221    /// Enable live web search (`--search`).
222    #[must_use]
223    pub fn search(mut self) -> Self {
224        self.search = true;
225        self
226    }
227
228    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
229    #[must_use]
230    pub fn ephemeral(mut self) -> Self {
231        self.ephemeral = true;
232        self
233    }
234
235    /// Require output to conform to a JSON schema (`--output-schema <path>`).
236    #[must_use]
237    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
238        self.output_schema = Some(path.into());
239        self
240    }
241
242    /// Control terminal color output (`--color <mode>`).
243    #[must_use]
244    pub fn color(mut self, color: Color) -> Self {
245        self.color = Some(color);
246        self
247    }
248
249    /// Show a progress cursor while the command runs (`--progress-cursor`).
250    #[must_use]
251    pub fn progress_cursor(mut self) -> Self {
252        self.progress_cursor = true;
253        self
254    }
255
256    /// Emit JSON Lines output (`--json`).
257    ///
258    /// When set, stdout will contain one JSON object per line. Use
259    /// [`execute_json_lines`](ExecCommand::execute_json_lines) to parse the
260    /// events automatically (requires the `json` feature).
261    #[must_use]
262    pub fn json(mut self) -> Self {
263        self.json = true;
264        self
265    }
266
267    /// Write the last assistant message to a file (`--output-last-message <path>`).
268    #[must_use]
269    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
270        self.output_last_message = Some(path.into());
271        self
272    }
273
274    /// Override the retry policy for this command.
275    ///
276    /// Takes precedence over the client-level policy set on [`Codex`].
277    #[must_use]
278    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
279        self.retry_policy = Some(policy);
280        self
281    }
282
283    /// Stream JSONL events from the command, invoking `handler` for each
284    /// parsed [`JsonLineEvent`] as it arrives.
285    ///
286    /// Automatically appends `--json` if not already set. Requires the `json`
287    /// feature.
288    ///
289    /// # Example
290    ///
291    /// ```no_run
292    /// use codex_wrapper::{Codex, ExecCommand, JsonLineEvent};
293    ///
294    /// # async fn example() -> codex_wrapper::Result<()> {
295    /// let codex = Codex::builder().build()?;
296    /// ExecCommand::new("what is 2+2?")
297    ///     .ephemeral()
298    ///     .stream(&codex, |event: JsonLineEvent| {
299    ///         println!("{}: {:?}", event.event_type, event.extra);
300    ///     })
301    ///     .await?;
302    /// # Ok(())
303    /// # }
304    /// ```
305    #[cfg(feature = "json")]
306    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
307    where
308        F: FnMut(JsonLineEvent),
309    {
310        crate::streaming::stream_exec(codex, self, handler).await
311    }
312
313    /// Execute the command and parse the output as JSON Lines events.
314    ///
315    /// Automatically appends `--json` if not already set. Requires the `json`
316    /// feature.
317    #[cfg(feature = "json")]
318    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
319        let mut args = self.args();
320        if !self.json {
321            args.push("--json".into());
322        }
323
324        let output = exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?;
325        parse_json_lines(&output.stdout)
326    }
327}
328
329impl CodexCommand for ExecCommand {
330    type Output = CommandOutput;
331
332    fn args(&self) -> Vec<String> {
333        let mut args = vec!["exec".to_string()];
334
335        push_repeat(&mut args, "-c", &self.config_overrides);
336        push_repeat(&mut args, "--enable", &self.enabled_features);
337        push_repeat(&mut args, "--disable", &self.disabled_features);
338        push_repeat(&mut args, "--image", &self.images);
339
340        if let Some(model) = &self.model {
341            args.push("--model".into());
342            args.push(model.clone());
343        }
344        if self.oss {
345            args.push("--oss".into());
346        }
347        if let Some(local_provider) = &self.local_provider {
348            args.push("--local-provider".into());
349            args.push(local_provider.clone());
350        }
351        if let Some(sandbox) = self.sandbox {
352            args.push("--sandbox".into());
353            args.push(sandbox.as_arg().into());
354        }
355        if let Some(policy) = self.approval_policy {
356            args.push("--ask-for-approval".into());
357            args.push(policy.as_arg().into());
358        }
359        if let Some(profile) = &self.profile {
360            args.push("--profile".into());
361            args.push(profile.clone());
362        }
363        if self.full_auto {
364            args.push("--full-auto".into());
365        }
366        if self.dangerously_bypass_approvals_and_sandbox {
367            args.push("--dangerously-bypass-approvals-and-sandbox".into());
368        }
369        if let Some(cd) = &self.cd {
370            args.push("--cd".into());
371            args.push(cd.clone());
372        }
373        if self.skip_git_repo_check {
374            args.push("--skip-git-repo-check".into());
375        }
376        push_repeat(&mut args, "--add-dir", &self.add_dirs);
377        if self.search {
378            args.push("--search".into());
379        }
380        if self.ephemeral {
381            args.push("--ephemeral".into());
382        }
383        if let Some(output_schema) = &self.output_schema {
384            args.push("--output-schema".into());
385            args.push(output_schema.clone());
386        }
387        if let Some(color) = self.color {
388            args.push("--color".into());
389            args.push(color.as_arg().into());
390        }
391        if self.progress_cursor {
392            args.push("--progress-cursor".into());
393        }
394        if self.json {
395            args.push("--json".into());
396        }
397        if let Some(path) = &self.output_last_message {
398            args.push("--output-last-message".into());
399            args.push(path.clone());
400        }
401        if let Some(prompt) = &self.prompt {
402            args.push(prompt.clone());
403        }
404
405        args
406    }
407
408    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
409        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
410    }
411}
412
413/// Resume a previous non-interactive session (`codex exec resume`).
414///
415/// Use [`session_id`](ExecResumeCommand::session_id) to target a specific
416/// session, or [`last`](ExecResumeCommand::last) to pick the most recent.
417#[derive(Debug, Clone)]
418pub struct ExecResumeCommand {
419    session_id: Option<String>,
420    prompt: Option<String>,
421    last: bool,
422    all: bool,
423    config_overrides: Vec<String>,
424    enabled_features: Vec<String>,
425    disabled_features: Vec<String>,
426    images: Vec<String>,
427    model: Option<String>,
428    full_auto: bool,
429    dangerously_bypass_approvals_and_sandbox: bool,
430    skip_git_repo_check: bool,
431    ephemeral: bool,
432    json: bool,
433    output_last_message: Option<String>,
434    retry_policy: Option<crate::retry::RetryPolicy>,
435}
436
437impl ExecResumeCommand {
438    /// Create a new resume command with no options set.
439    #[must_use]
440    pub fn new() -> Self {
441        Self {
442            session_id: None,
443            prompt: None,
444            last: false,
445            all: false,
446            config_overrides: Vec::new(),
447            enabled_features: Vec::new(),
448            disabled_features: Vec::new(),
449            images: Vec::new(),
450            model: None,
451            full_auto: false,
452            dangerously_bypass_approvals_and_sandbox: false,
453            skip_git_repo_check: false,
454            ephemeral: false,
455            json: false,
456            output_last_message: None,
457            retry_policy: None,
458        }
459    }
460
461    /// Resume a specific session by its ID.
462    #[must_use]
463    pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
464        self.session_id = Some(session_id.into());
465        self
466    }
467
468    /// Append an additional prompt to the resumed session.
469    #[must_use]
470    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
471        self.prompt = Some(prompt.into());
472        self
473    }
474
475    /// Resume the most recent session (`--last`).
476    #[must_use]
477    pub fn last(mut self) -> Self {
478        self.last = true;
479        self
480    }
481
482    /// Resume all sessions (`--all`).
483    #[must_use]
484    pub fn all(mut self) -> Self {
485        self.all = true;
486        self
487    }
488
489    /// Set the model to use (`--model <model>`).
490    ///
491    /// Panics if `model` is an empty string.
492    #[must_use]
493    pub fn model(mut self, model: impl Into<String>) -> Self {
494        let model = model.into();
495        assert!(!model.is_empty(), "model name must not be empty");
496        self.model = Some(model);
497        self
498    }
499
500    /// Attach an image to the prompt (`--image <path>`).
501    ///
502    /// May be called multiple times to attach several images.
503    #[must_use]
504    pub fn image(mut self, path: impl Into<String>) -> Self {
505        self.images.push(path.into());
506        self
507    }
508
509    /// Emit JSON Lines output (`--json`).
510    #[must_use]
511    pub fn json(mut self) -> Self {
512        self.json = true;
513        self
514    }
515
516    /// Write the last assistant message to a file (`--output-last-message <path>`).
517    #[must_use]
518    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
519        self.output_last_message = Some(path.into());
520        self
521    }
522
523    /// Override a config key (`-c key=value`).
524    ///
525    /// May be called multiple times to set several keys.
526    #[must_use]
527    pub fn config(mut self, key_value: impl Into<String>) -> Self {
528        self.config_overrides.push(key_value.into());
529        self
530    }
531
532    /// Enable an optional feature flag (`--enable <feature>`).
533    ///
534    /// May be called multiple times.
535    #[must_use]
536    pub fn enable(mut self, feature: impl Into<String>) -> Self {
537        self.enabled_features.push(feature.into());
538        self
539    }
540
541    /// Disable an optional feature flag (`--disable <feature>`).
542    ///
543    /// May be called multiple times.
544    #[must_use]
545    pub fn disable(mut self, feature: impl Into<String>) -> Self {
546        self.disabled_features.push(feature.into());
547        self
548    }
549
550    /// Run in full-auto mode — no approval prompts (`--full-auto`).
551    #[must_use]
552    pub fn full_auto(mut self) -> Self {
553        self.full_auto = true;
554        self
555    }
556
557    /// Bypass all approval prompts and sandbox restrictions.
558    ///
559    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
560    #[must_use]
561    pub fn dangerously_bypass_approvals_and_sandbox(mut self) -> Self {
562        self.dangerously_bypass_approvals_and_sandbox = true;
563        self
564    }
565
566    /// Skip the git repository check (`--skip-git-repo-check`).
567    #[must_use]
568    pub fn skip_git_repo_check(mut self) -> Self {
569        self.skip_git_repo_check = true;
570        self
571    }
572
573    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
574    #[must_use]
575    pub fn ephemeral(mut self) -> Self {
576        self.ephemeral = true;
577        self
578    }
579
580    /// Override the retry policy for this command.
581    ///
582    /// Takes precedence over the client-level policy set on [`Codex`].
583    #[must_use]
584    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
585        self.retry_policy = Some(policy);
586        self
587    }
588
589    /// Execute the command and parse the output as JSON Lines events.
590    ///
591    /// Automatically appends `--json` if not already set. Requires the `json`
592    /// feature.
593    #[cfg(feature = "json")]
594    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
595        let mut args = self.args();
596        if !self.json {
597            args.push("--json".into());
598        }
599
600        let output = exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?;
601        parse_json_lines(&output.stdout)
602    }
603
604    /// Stream JSONL events from the resume command, invoking `handler` for
605    /// each parsed [`JsonLineEvent`] as it arrives.
606    ///
607    /// Automatically appends `--json` if not already set. Requires the `json`
608    /// feature.
609    #[cfg(feature = "json")]
610    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
611    where
612        F: FnMut(JsonLineEvent),
613    {
614        crate::streaming::stream_exec_resume(codex, self, handler).await
615    }
616}
617
618impl Default for ExecResumeCommand {
619    fn default() -> Self {
620        Self::new()
621    }
622}
623
624impl CodexCommand for ExecResumeCommand {
625    type Output = CommandOutput;
626
627    fn args(&self) -> Vec<String> {
628        let mut args = vec!["exec".into(), "resume".into()];
629        push_repeat(&mut args, "-c", &self.config_overrides);
630        push_repeat(&mut args, "--enable", &self.enabled_features);
631        push_repeat(&mut args, "--disable", &self.disabled_features);
632        if self.last {
633            args.push("--last".into());
634        }
635        if self.all {
636            args.push("--all".into());
637        }
638        push_repeat(&mut args, "--image", &self.images);
639        if let Some(model) = &self.model {
640            args.push("--model".into());
641            args.push(model.clone());
642        }
643        if self.full_auto {
644            args.push("--full-auto".into());
645        }
646        if self.dangerously_bypass_approvals_and_sandbox {
647            args.push("--dangerously-bypass-approvals-and-sandbox".into());
648        }
649        if self.skip_git_repo_check {
650            args.push("--skip-git-repo-check".into());
651        }
652        if self.ephemeral {
653            args.push("--ephemeral".into());
654        }
655        if self.json {
656            args.push("--json".into());
657        }
658        if let Some(path) = &self.output_last_message {
659            args.push("--output-last-message".into());
660            args.push(path.clone());
661        }
662        if let Some(session_id) = &self.session_id {
663            args.push(session_id.clone());
664        }
665        if let Some(prompt) = &self.prompt {
666            args.push(prompt.clone());
667        }
668        args
669    }
670
671    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
672        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
673    }
674}
675
676fn push_repeat(args: &mut Vec<String>, flag: &str, values: &[String]) {
677    for value in values {
678        args.push(flag.into());
679        args.push(value.clone());
680    }
681}
682
683#[cfg(feature = "json")]
684fn parse_json_lines(stdout: &str) -> Result<Vec<JsonLineEvent>> {
685    stdout
686        .lines()
687        .filter(|line| line.trim_start().starts_with('{'))
688        .map(|line| {
689            serde_json::from_str(line).map_err(|source| Error::Json {
690                message: format!("failed to parse JSONL event: {line}"),
691                source,
692            })
693        })
694        .collect()
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn exec_args() {
703        let args = ExecCommand::new("fix the test")
704            .model("gpt-5")
705            .sandbox(SandboxMode::WorkspaceWrite)
706            .approval_policy(ApprovalPolicy::OnRequest)
707            .skip_git_repo_check()
708            .ephemeral()
709            .json()
710            .args();
711
712        assert_eq!(
713            args,
714            vec![
715                "exec",
716                "--model",
717                "gpt-5",
718                "--sandbox",
719                "workspace-write",
720                "--ask-for-approval",
721                "on-request",
722                "--skip-git-repo-check",
723                "--ephemeral",
724                "--json",
725                "fix the test",
726            ]
727        );
728    }
729
730    #[test]
731    #[should_panic(expected = "model name must not be empty")]
732    fn exec_model_empty_panics() {
733        let _ = ExecCommand::new("prompt").model("");
734    }
735
736    #[test]
737    #[should_panic(expected = "model name must not be empty")]
738    fn exec_resume_model_empty_panics() {
739        let _ = ExecResumeCommand::new().model("");
740    }
741
742    #[test]
743    fn exec_resume_args() {
744        let args = ExecResumeCommand::new()
745            .last()
746            .model("gpt-5")
747            .json()
748            .prompt("continue")
749            .args();
750
751        assert_eq!(
752            args,
753            vec![
754                "exec", "resume", "--last", "--model", "gpt-5", "--json", "continue",
755            ]
756        );
757    }
758}