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};
7use crate::types::{ApprovalPolicyConfig, Color, SandboxMode, WebSearchMode};
8#[cfg(feature = "json")]
9use crate::types::{JsonLineEvent, QueryResult};
10
11/// Push the typed config-key overrides shared by the exec-family builders.
12///
13/// `codex-cli` 0.145.0 removed `--ask-for-approval` and `--search` from the
14/// exec family; both settings moved to `-c` config keys. These are pushed
15/// before any caller-supplied [`config`](ExecCommand::config) strings because
16/// `-c` is last-wins, so a raw override still beats the typed setter.
17pub(crate) fn push_typed_config(
18    args: &mut Vec<String>,
19    approval_policy: Option<ApprovalPolicyConfig>,
20    web_search: Option<WebSearchMode>,
21) {
22    if let Some(policy) = approval_policy {
23        args.push("-c".into());
24        args.push(format!("approval_policy=\"{}\"", policy.as_config_value()));
25    }
26    if let Some(mode) = web_search {
27        args.push("-c".into());
28        args.push(format!("web_search=\"{}\"", mode.as_config_value()));
29    }
30}
31
32/// Resolve the sandbox mode, folding in the deprecated `full_auto` shim.
33///
34/// `--full-auto` is hidden on the exec family (and rejected outright by `fork`
35/// and `resume`); the CLI's own advice is `--sandbox workspace-write`. An
36/// explicit `sandbox()` call is more specific and wins.
37pub(crate) fn effective_sandbox(
38    sandbox: Option<SandboxMode>,
39    full_auto: bool,
40) -> Option<SandboxMode> {
41    sandbox.or(full_auto.then_some(SandboxMode::WorkspaceWrite))
42}
43
44/// Run Codex non-interactively (`codex exec <prompt>`).
45///
46/// This is the primary command for programmatic use. It supports the full
47/// range of exec flags: model selection, sandbox policy, images, config
48/// overrides, feature flags, JSON output, and more.
49///
50/// # Example
51///
52/// ```no_run
53/// use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};
54///
55/// # async fn example() -> codex_wrapper::Result<()> {
56/// let codex = Codex::builder().build()?;
57/// let output = ExecCommand::new("fix the failing test")
58///     .model("o3")
59///     .sandbox(SandboxMode::WorkspaceWrite)
60///     .ephemeral()
61///     .execute(&codex)
62///     .await?;
63/// println!("{}", output.stdout);
64/// # Ok(())
65/// # }
66/// ```
67#[derive(Debug, Clone)]
68pub struct ExecCommand {
69    approve_for_me: bool,
70    prompt: Option<String>,
71    prompt_via_stdin: bool,
72    approval_policy: Option<ApprovalPolicyConfig>,
73    web_search: Option<WebSearchMode>,
74    config_overrides: Vec<String>,
75    enabled_features: Vec<String>,
76    disabled_features: Vec<String>,
77    images: Vec<String>,
78    model: Option<String>,
79    oss: bool,
80    local_provider: Option<String>,
81    sandbox: Option<SandboxMode>,
82    strict_config: bool,
83    dangerously_bypass_hook_trust: bool,
84    ignore_user_config: bool,
85    ignore_rules: bool,
86    profile: Option<String>,
87    full_auto: bool,
88    dangerously_bypass_approvals_and_sandbox: bool,
89    cd: Option<String>,
90    skip_git_repo_check: bool,
91    add_dirs: Vec<String>,
92    ephemeral: bool,
93    output_schema: Option<String>,
94    color: Option<Color>,
95    json: bool,
96    output_last_message: Option<String>,
97    retry_policy: Option<crate::retry::RetryPolicy>,
98}
99
100impl ExecCommand {
101    /// Create a new exec command with the given prompt.
102    #[must_use]
103    pub fn new(prompt: impl Into<String>) -> Self {
104        Self {
105            approve_for_me: false,
106            prompt: Some(prompt.into()),
107            prompt_via_stdin: false,
108            approval_policy: None,
109            web_search: None,
110            config_overrides: Vec::new(),
111            enabled_features: Vec::new(),
112            disabled_features: Vec::new(),
113            images: Vec::new(),
114            model: None,
115            oss: false,
116            local_provider: None,
117            sandbox: None,
118            strict_config: false,
119            dangerously_bypass_hook_trust: false,
120            ignore_user_config: false,
121            ignore_rules: false,
122            profile: None,
123            full_auto: false,
124            dangerously_bypass_approvals_and_sandbox: false,
125            cd: None,
126            skip_git_repo_check: false,
127            add_dirs: Vec::new(),
128            ephemeral: false,
129            output_schema: None,
130            color: None,
131            json: false,
132            output_last_message: None,
133            retry_policy: None,
134        }
135    }
136
137    /// Send the prompt on stdin instead of as an argument (`codex exec -`).
138    ///
139    /// Shorthand for [`new`](Self::new) followed by
140    /// [`prompt_via_stdin`](Self::prompt_via_stdin). Use it for prompts that
141    /// are large or awkward to pass through argv.
142    ///
143    /// ```no_run
144    /// use codex_wrapper::{Codex, CodexCommand, ExecCommand};
145    ///
146    /// # async fn example() -> codex_wrapper::Result<()> {
147    /// let codex = Codex::builder().build()?;
148    /// let diff = std::fs::read_to_string("huge.patch")?;
149    /// let output = ExecCommand::from_stdin(format!("Review this patch:\n{diff}"))
150    ///     .execute(&codex)
151    ///     .await?;
152    /// # let _ = output;
153    /// # Ok(())
154    /// # }
155    /// ```
156    ///
157    /// Before 0.3 this took no argument and set the prompt to the literal
158    /// `-`, which could not work: nothing wrote to the child's stdin, so the
159    /// CLI saw an immediate EOF and an empty prompt (#81).
160    #[must_use]
161    pub fn from_stdin(prompt: impl Into<String>) -> Self {
162        Self::new(prompt).prompt_via_stdin()
163    }
164
165    /// Deliver this command's prompt on stdin rather than in argv.
166    ///
167    /// The prompt is replaced by `-` in the argument list and written to the
168    /// child's stdin instead.
169    ///
170    /// Retry does not apply to a stdin prompt, and any policy set on the
171    /// command or the client is ignored for it. A second attempt would need to
172    /// write the prompt again, into a pipe the first attempt has already
173    /// consumed, and retrying with an empty stdin would be worse than not
174    /// retrying.
175    #[must_use]
176    pub fn prompt_via_stdin(mut self) -> Self {
177        self.prompt_via_stdin = true;
178        self
179    }
180
181    /// The prompt to write to the child's stdin, if this command sends it
182    /// there. `None` when the prompt travels in argv.
183    ///
184    /// Only the streaming path needs this, and that path is `json`-gated.
185    #[cfg(feature = "json")]
186    pub(crate) fn stdin_prompt(&self) -> Option<&str> {
187        self.prompt_via_stdin
188            .then(|| self.prompt.as_deref().unwrap_or_default())
189    }
190
191    /// Override a config key (`-c key=value`).
192    ///
193    /// May be called multiple times to set several keys. Because `-c` is
194    /// last-wins, a key set here overrides the same key set by
195    /// [`approval_policy`](Self::approval_policy) or
196    /// [`search_mode`](Self::search_mode).
197    #[must_use]
198    pub fn config(mut self, key_value: impl Into<String>) -> Self {
199        self.config_overrides.push(key_value.into());
200        self
201    }
202
203    /// Set when the model asks for approval (`-c approval_policy="<value>"`).
204    ///
205    /// `codex-cli` 0.145.0 removed `--ask-for-approval` from `codex exec`; the
206    /// config key is the supported equivalent. Accepts an
207    /// [`ApprovalPolicy`](crate::ApprovalPolicy) directly, or an
208    /// [`ApprovalPolicyConfig`] for the two values the flag never took.
209    ///
210    /// ```
211    /// use codex_wrapper::{ApprovalPolicyConfig, CodexCommand, ExecCommand};
212    ///
213    /// let args = ExecCommand::new("hi")
214    ///     .approval_policy(ApprovalPolicyConfig::Never)
215    ///     .args();
216    /// assert!(args.windows(2).any(|w| w == ["-c", "approval_policy=\"never\""]));
217    /// ```
218    #[must_use]
219    pub fn approval_policy(mut self, policy: impl Into<ApprovalPolicyConfig>) -> Self {
220        self.approval_policy = Some(policy.into());
221        self
222    }
223
224    /// Enable live web search.
225    ///
226    /// Shorthand for `search_mode(WebSearchMode::Live)`, which is what the
227    /// removed `--search` flag meant.
228    #[must_use]
229    pub fn search(self) -> Self {
230        self.search_mode(WebSearchMode::Live)
231    }
232
233    /// Set the web search mode (`-c web_search="<value>"`).
234    ///
235    /// `codex-cli` 0.145.0 removed `--search` from `codex exec`; the config
236    /// key is the supported equivalent, and it is an enum rather than the
237    /// flag's boolean.
238    #[must_use]
239    pub fn search_mode(mut self, mode: WebSearchMode) -> Self {
240        self.web_search = Some(mode);
241        self
242    }
243
244    /// Enable an optional feature flag (`--enable <feature>`).
245    ///
246    /// May be called multiple times.
247    #[must_use]
248    pub fn enable(mut self, feature: impl Into<String>) -> Self {
249        self.enabled_features.push(feature.into());
250        self
251    }
252
253    /// Disable an optional feature flag (`--disable <feature>`).
254    ///
255    /// May be called multiple times.
256    #[must_use]
257    pub fn disable(mut self, feature: impl Into<String>) -> Self {
258        self.disabled_features.push(feature.into());
259        self
260    }
261
262    /// Attach an image to the prompt (`--image <path>`).
263    ///
264    /// May be called multiple times to attach several images.
265    #[must_use]
266    pub fn image(mut self, path: impl Into<String>) -> Self {
267        self.images.push(path.into());
268        self
269    }
270
271    /// Set the model to use (`--model <model>`).
272    ///
273    /// Panics if `model` is an empty string.
274    #[must_use]
275    pub fn model(mut self, model: impl Into<String>) -> Self {
276        let model = model.into();
277        assert!(!model.is_empty(), "model name must not be empty");
278        self.model = Some(model);
279        self
280    }
281
282    /// Use the OSS model tier (`--oss`).
283    #[must_use]
284    pub fn oss(mut self) -> Self {
285        self.oss = true;
286        self
287    }
288
289    /// Use a local model provider (`--local-provider <provider>`).
290    #[must_use]
291    pub fn local_provider(mut self, provider: impl Into<String>) -> Self {
292        self.local_provider = Some(provider.into());
293        self
294    }
295
296    /// Set the sandbox policy (`--sandbox <mode>`).
297    #[must_use]
298    pub fn sandbox(mut self, sandbox: SandboxMode) -> Self {
299        self.sandbox = Some(sandbox);
300        self
301    }
302
303    /// Error on unrecognized config keys (`--strict-config`).
304    #[must_use]
305    pub fn strict_config(mut self) -> Self {
306        self.strict_config = true;
307        self
308    }
309
310    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
311    ///
312    /// Allows configured hooks to run without confirmation. Use with caution.
313    #[must_use]
314    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
315        self.dangerously_bypass_hook_trust = true;
316        self
317    }
318
319    /// Ignore the user-level config file (`--ignore-user-config`).
320    #[must_use]
321    pub fn ignore_user_config(mut self) -> Self {
322        self.ignore_user_config = true;
323        self
324    }
325
326    /// Ignore project rules files (`--ignore-rules`).
327    #[must_use]
328    pub fn ignore_rules(mut self) -> Self {
329        self.ignore_rules = true;
330        self
331    }
332
333    /// Select a named configuration profile (`--profile <name>`).
334    #[must_use]
335    pub fn profile(mut self, profile: impl Into<String>) -> Self {
336        self.profile = Some(profile.into());
337        self
338    }
339
340    /// Run in full-auto mode, emitted as `--sandbox workspace-write`.
341    ///
342    /// `--full-auto` is deprecated upstream. `codex-cli` 0.145.0 hides it from
343    /// `codex exec --help` and warns when it is used:
344    ///
345    /// ```text
346    /// warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.
347    /// ```
348    ///
349    /// This method emits the replacement the CLI names. An explicit
350    /// [`sandbox`](Self::sandbox) call is more specific and wins over it.
351    #[must_use]
352    pub fn full_auto(mut self) -> Self {
353        self.full_auto = true;
354        self
355    }
356
357    /// Route approval requests through automatic review, using the
358    /// workspace-write sandbox (`--approve-for-me`).
359    ///
360    /// Added in `codex-cli` 0.147.0. Older releases reject it as an unexpected
361    /// argument, so this is the one builder method with a floor above the
362    /// wrapper's tested minimum. `codex exec review` and `codex exec resume`
363    /// do not accept it.
364    #[must_use]
365    pub fn approve_for_me(mut self) -> Self {
366        self.approve_for_me = true;
367        self
368    }
369
370    /// Bypass all approval prompts and sandbox restrictions.
371    ///
372    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
373    #[must_use]
374    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
375        self.dangerously_bypass_approvals_and_sandbox = true;
376        self
377    }
378
379    /// Change the working directory before running (`--cd <dir>`).
380    #[must_use]
381    pub fn cd(mut self, dir: impl Into<String>) -> Self {
382        self.cd = Some(dir.into());
383        self
384    }
385
386    /// Skip the git repository check (`--skip-git-repo-check`).
387    #[must_use]
388    pub fn skip_git_repo_check(mut self) -> Self {
389        self.skip_git_repo_check = true;
390        self
391    }
392
393    /// Add an extra directory to the context (`--add-dir <dir>`).
394    ///
395    /// May be called multiple times.
396    #[must_use]
397    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
398        self.add_dirs.push(dir.into());
399        self
400    }
401
402    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
403    #[must_use]
404    pub fn ephemeral(mut self) -> Self {
405        self.ephemeral = true;
406        self
407    }
408
409    /// Require output to conform to a JSON schema (`--output-schema <path>`).
410    #[must_use]
411    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
412        self.output_schema = Some(path.into());
413        self
414    }
415
416    /// Control terminal color output (`--color <mode>`).
417    #[must_use]
418    pub fn color(mut self, color: Color) -> Self {
419        self.color = Some(color);
420        self
421    }
422
423    /// Emit JSON Lines output (`--json`).
424    ///
425    /// When set, stdout will contain one JSON object per line. Use
426    /// [`execute_json_lines`](ExecCommand::execute_json_lines) to parse the
427    /// events automatically (requires the `json` feature).
428    #[must_use]
429    pub fn json(mut self) -> Self {
430        self.json = true;
431        self
432    }
433
434    /// Write the last assistant message to a file (`--output-last-message <path>`).
435    #[must_use]
436    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
437        self.output_last_message = Some(path.into());
438        self
439    }
440
441    /// Override the retry policy for this command.
442    ///
443    /// Takes precedence over the client-level policy set on [`Codex`].
444    #[must_use]
445    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
446        self.retry_policy = Some(policy);
447        self
448    }
449
450    /// Stream JSONL events from the command, invoking `handler` for each
451    /// parsed [`JsonLineEvent`] as it arrives.
452    ///
453    /// Automatically appends `--json` if not already set. Requires the `json`
454    /// feature.
455    ///
456    /// # Example
457    ///
458    /// ```no_run
459    /// use codex_wrapper::{Codex, ExecCommand, JsonLineEvent};
460    ///
461    /// # async fn example() -> codex_wrapper::Result<()> {
462    /// let codex = Codex::builder().build()?;
463    /// ExecCommand::new("what is 2+2?")
464    ///     .ephemeral()
465    ///     .stream(&codex, |event: JsonLineEvent| {
466    ///         println!("{}: {:?}", event.event_type, event.extra);
467    ///     })
468    ///     .await?;
469    /// # Ok(())
470    /// # }
471    /// ```
472    #[cfg(feature = "json")]
473    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
474    where
475        F: FnMut(JsonLineEvent),
476    {
477        crate::streaming::stream_exec(codex, self, handler).await
478    }
479
480    /// Execute the command and parse the output as JSON Lines events.
481    ///
482    /// Automatically appends `--json` if not already set. Requires the `json`
483    /// feature.
484    #[cfg(feature = "json")]
485    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
486        let mut args = self.args();
487        if !self.json {
488            args.push("--json".into());
489        }
490
491        let output = if self.prompt_via_stdin {
492            let prompt = self.prompt.as_deref().unwrap_or_default();
493            exec::run_codex_with_stdin_prompt(codex, args, prompt).await?
494        } else {
495            exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?
496        };
497        parse_json_lines(&output.stdout)
498    }
499
500    /// Execute the command and return a typed [`QueryResult`].
501    ///
502    /// Assembles the final result text, ids, and token usage from the JSONL
503    /// event stream. Use [`execute_json_lines`](ExecCommand::execute_json_lines) for
504    /// the raw event stream. Requires the `json` feature.
505    #[cfg(feature = "json")]
506    pub async fn execute_json(&self, codex: &Codex) -> Result<QueryResult> {
507        let events = self.execute_json_lines(codex).await?;
508        Ok(QueryResult::from_events(events))
509    }
510}
511
512impl CodexCommand for ExecCommand {
513    type Output = CommandOutput;
514
515    fn args(&self) -> Vec<String> {
516        let mut args = vec!["exec".to_string()];
517
518        push_typed_config(&mut args, self.approval_policy, self.web_search);
519        push_repeat(&mut args, "-c", &self.config_overrides);
520        push_repeat(&mut args, "--enable", &self.enabled_features);
521        push_repeat(&mut args, "--disable", &self.disabled_features);
522        push_repeat(&mut args, "--image", &self.images);
523
524        if let Some(model) = &self.model {
525            args.push("--model".into());
526            args.push(model.clone());
527        }
528        if self.oss {
529            args.push("--oss".into());
530        }
531        if let Some(local_provider) = &self.local_provider {
532            args.push("--local-provider".into());
533            args.push(local_provider.clone());
534        }
535        if let Some(sandbox) = effective_sandbox(self.sandbox, self.full_auto) {
536            args.push("--sandbox".into());
537            args.push(sandbox.as_arg().into());
538        }
539        if self.strict_config {
540            args.push("--strict-config".into());
541        }
542        if let Some(profile) = &self.profile {
543            args.push("--profile".into());
544            args.push(profile.clone());
545        }
546        if self.approve_for_me {
547            args.push("--approve-for-me".into());
548        }
549        if self.dangerously_bypass_approvals_and_sandbox {
550            args.push("--dangerously-bypass-approvals-and-sandbox".into());
551        }
552        if self.dangerously_bypass_hook_trust {
553            args.push("--dangerously-bypass-hook-trust".into());
554        }
555        if let Some(cd) = &self.cd {
556            args.push("--cd".into());
557            args.push(cd.clone());
558        }
559        if self.skip_git_repo_check {
560            args.push("--skip-git-repo-check".into());
561        }
562        push_repeat(&mut args, "--add-dir", &self.add_dirs);
563        if self.ephemeral {
564            args.push("--ephemeral".into());
565        }
566        if self.ignore_user_config {
567            args.push("--ignore-user-config".into());
568        }
569        if self.ignore_rules {
570            args.push("--ignore-rules".into());
571        }
572        if let Some(output_schema) = &self.output_schema {
573            args.push("--output-schema".into());
574            args.push(output_schema.clone());
575        }
576        if let Some(color) = self.color {
577            args.push("--color".into());
578            args.push(color.as_arg().into());
579        }
580        if self.json {
581            args.push("--json".into());
582        }
583        if let Some(path) = &self.output_last_message {
584            args.push("--output-last-message".into());
585            args.push(path.clone());
586        }
587        if self.prompt_via_stdin {
588            // The prompt travels on stdin; `-` is how the CLI is told to read
589            // it from there.
590            args.push("-".into());
591        } else if let Some(prompt) = &self.prompt {
592            args.push(prompt.clone());
593        }
594
595        args
596    }
597
598    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
599        if self.prompt_via_stdin {
600            let prompt = self.prompt.as_deref().unwrap_or_default();
601            return exec::run_codex_with_stdin_prompt(codex, self.args(), prompt).await;
602        }
603        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
604    }
605}
606
607/// Resume a previous non-interactive session (`codex exec resume`).
608///
609/// Use [`session_id`](ExecResumeCommand::session_id) to target a specific
610/// session, or [`last`](ExecResumeCommand::last) to pick the most recent.
611#[derive(Debug, Clone)]
612pub struct ExecResumeCommand {
613    session_id: Option<String>,
614    prompt: Option<String>,
615    last: bool,
616    all: bool,
617    approval_policy: Option<ApprovalPolicyConfig>,
618    web_search: Option<WebSearchMode>,
619    config_overrides: Vec<String>,
620    enabled_features: Vec<String>,
621    disabled_features: Vec<String>,
622    images: Vec<String>,
623    model: Option<String>,
624    strict_config: bool,
625    dangerously_bypass_hook_trust: bool,
626    ignore_user_config: bool,
627    ignore_rules: bool,
628    output_schema: Option<String>,
629    full_auto: bool,
630    dangerously_bypass_approvals_and_sandbox: bool,
631    skip_git_repo_check: bool,
632    ephemeral: bool,
633    json: bool,
634    output_last_message: Option<String>,
635    retry_policy: Option<crate::retry::RetryPolicy>,
636}
637
638impl ExecResumeCommand {
639    /// Create a new resume command with no options set.
640    #[must_use]
641    pub fn new() -> Self {
642        Self {
643            session_id: None,
644            prompt: None,
645            last: false,
646            all: false,
647            approval_policy: None,
648            web_search: None,
649            config_overrides: Vec::new(),
650            enabled_features: Vec::new(),
651            disabled_features: Vec::new(),
652            images: Vec::new(),
653            model: None,
654            strict_config: false,
655            dangerously_bypass_hook_trust: false,
656            ignore_user_config: false,
657            ignore_rules: false,
658            output_schema: None,
659            full_auto: false,
660            dangerously_bypass_approvals_and_sandbox: false,
661            skip_git_repo_check: false,
662            ephemeral: false,
663            json: false,
664            output_last_message: None,
665            retry_policy: None,
666        }
667    }
668
669    /// Resume a specific session by its ID.
670    #[must_use]
671    pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
672        self.session_id = Some(session_id.into());
673        self
674    }
675
676    /// Append an additional prompt to the resumed session.
677    #[must_use]
678    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
679        self.prompt = Some(prompt.into());
680        self
681    }
682
683    /// Resume the most recent session (`--last`).
684    #[must_use]
685    pub fn last(mut self) -> Self {
686        self.last = true;
687        self
688    }
689
690    /// Resume all sessions (`--all`).
691    #[must_use]
692    pub fn all(mut self) -> Self {
693        self.all = true;
694        self
695    }
696
697    /// Set the model to use (`--model <model>`).
698    ///
699    /// Panics if `model` is an empty string.
700    #[must_use]
701    pub fn model(mut self, model: impl Into<String>) -> Self {
702        let model = model.into();
703        assert!(!model.is_empty(), "model name must not be empty");
704        self.model = Some(model);
705        self
706    }
707
708    /// Attach an image to the prompt (`--image <path>`).
709    ///
710    /// May be called multiple times to attach several images.
711    #[must_use]
712    pub fn image(mut self, path: impl Into<String>) -> Self {
713        self.images.push(path.into());
714        self
715    }
716
717    /// Emit JSON Lines output (`--json`).
718    #[must_use]
719    pub fn json(mut self) -> Self {
720        self.json = true;
721        self
722    }
723
724    /// Write the last assistant message to a file (`--output-last-message <path>`).
725    #[must_use]
726    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
727        self.output_last_message = Some(path.into());
728        self
729    }
730
731    /// Override a config key (`-c key=value`).
732    ///
733    /// May be called multiple times to set several keys. Because `-c` is
734    /// last-wins, a key set here overrides the same key set by
735    /// [`approval_policy`](Self::approval_policy),
736    /// [`search_mode`](Self::search_mode), or [`full_auto`](Self::full_auto).
737    #[must_use]
738    pub fn config(mut self, key_value: impl Into<String>) -> Self {
739        self.config_overrides.push(key_value.into());
740        self
741    }
742
743    /// Set when the model asks for approval (`-c approval_policy="<value>"`).
744    ///
745    /// `codex-cli` 0.145.0 removed `--ask-for-approval` from the exec family;
746    /// the config key is the supported equivalent. Accepts an
747    /// [`ApprovalPolicy`](crate::ApprovalPolicy) directly, or an
748    /// [`ApprovalPolicyConfig`] for the two values the flag never took.
749    #[must_use]
750    pub fn approval_policy(mut self, policy: impl Into<ApprovalPolicyConfig>) -> Self {
751        self.approval_policy = Some(policy.into());
752        self
753    }
754
755    /// Enable live web search.
756    ///
757    /// Shorthand for `search_mode(WebSearchMode::Live)`, which is what the
758    /// removed `--search` flag meant.
759    #[must_use]
760    pub fn search(self) -> Self {
761        self.search_mode(WebSearchMode::Live)
762    }
763
764    /// Set the web search mode (`-c web_search="<value>"`).
765    ///
766    /// `codex-cli` 0.145.0 removed `--search` from the exec family; the config
767    /// key is the supported equivalent, and it is an enum rather than the
768    /// flag's boolean.
769    #[must_use]
770    pub fn search_mode(mut self, mode: WebSearchMode) -> Self {
771        self.web_search = Some(mode);
772        self
773    }
774
775    /// Enable an optional feature flag (`--enable <feature>`).
776    ///
777    /// May be called multiple times.
778    #[must_use]
779    pub fn enable(mut self, feature: impl Into<String>) -> Self {
780        self.enabled_features.push(feature.into());
781        self
782    }
783
784    /// Disable an optional feature flag (`--disable <feature>`).
785    ///
786    /// May be called multiple times.
787    #[must_use]
788    pub fn disable(mut self, feature: impl Into<String>) -> Self {
789        self.disabled_features.push(feature.into());
790        self
791    }
792
793    /// Error on unrecognized config keys (`--strict-config`).
794    #[must_use]
795    pub fn strict_config(mut self) -> Self {
796        self.strict_config = true;
797        self
798    }
799
800    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
801    ///
802    /// Allows configured hooks to run without confirmation. Use with caution.
803    #[must_use]
804    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
805        self.dangerously_bypass_hook_trust = true;
806        self
807    }
808
809    /// Ignore the user-level config file (`--ignore-user-config`).
810    #[must_use]
811    pub fn ignore_user_config(mut self) -> Self {
812        self.ignore_user_config = true;
813        self
814    }
815
816    /// Ignore project rules files (`--ignore-rules`).
817    #[must_use]
818    pub fn ignore_rules(mut self) -> Self {
819        self.ignore_rules = true;
820        self
821    }
822
823    /// Require output to conform to a JSON schema (`--output-schema <path>`).
824    #[must_use]
825    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
826        self.output_schema = Some(path.into());
827        self
828    }
829
830    /// Run in full-auto mode, emitted as `-c sandbox_mode="workspace-write"`.
831    ///
832    /// `--full-auto` is deprecated upstream; `codex-cli` 0.145.0 hides it and
833    /// warns to use `--sandbox workspace-write` instead. `codex exec resume`
834    /// has no `--sandbox` flag, so this sets the equivalent config key.
835    #[must_use]
836    pub fn full_auto(mut self) -> Self {
837        self.full_auto = true;
838        self
839    }
840
841    /// Bypass all approval prompts and sandbox restrictions.
842    ///
843    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
844    #[must_use]
845    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
846        self.dangerously_bypass_approvals_and_sandbox = true;
847        self
848    }
849
850    /// Skip the git repository check (`--skip-git-repo-check`).
851    #[must_use]
852    pub fn skip_git_repo_check(mut self) -> Self {
853        self.skip_git_repo_check = true;
854        self
855    }
856
857    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
858    #[must_use]
859    pub fn ephemeral(mut self) -> Self {
860        self.ephemeral = true;
861        self
862    }
863
864    /// Override the retry policy for this command.
865    ///
866    /// Takes precedence over the client-level policy set on [`Codex`].
867    #[must_use]
868    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
869        self.retry_policy = Some(policy);
870        self
871    }
872
873    /// Execute the command and parse the output as JSON Lines events.
874    ///
875    /// Automatically appends `--json` if not already set. Requires the `json`
876    /// feature.
877    #[cfg(feature = "json")]
878    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
879        let mut args = self.args();
880        if !self.json {
881            args.push("--json".into());
882        }
883
884        let output = exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?;
885        parse_json_lines(&output.stdout)
886    }
887
888    /// Execute the resume command and return a typed [`QueryResult`].
889    ///
890    /// Assembles the final result text, ids, and token usage from the JSONL
891    /// event stream. Requires the `json` feature.
892    #[cfg(feature = "json")]
893    pub async fn execute_json(&self, codex: &Codex) -> Result<QueryResult> {
894        let events = self.execute_json_lines(codex).await?;
895        Ok(QueryResult::from_events(events))
896    }
897
898    /// Stream JSONL events from the resume command, invoking `handler` for
899    /// each parsed [`JsonLineEvent`] as it arrives.
900    ///
901    /// Automatically appends `--json` if not already set. Requires the `json`
902    /// feature.
903    #[cfg(feature = "json")]
904    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
905    where
906        F: FnMut(JsonLineEvent),
907    {
908        crate::streaming::stream_exec_resume(codex, self, handler).await
909    }
910}
911
912impl Default for ExecResumeCommand {
913    fn default() -> Self {
914        Self::new()
915    }
916}
917
918impl CodexCommand for ExecResumeCommand {
919    type Output = CommandOutput;
920
921    fn args(&self) -> Vec<String> {
922        let mut args = vec!["exec".into(), "resume".into()];
923        push_typed_config(&mut args, self.approval_policy, self.web_search);
924        // `exec resume` has no `--sandbox` flag, so the `--full-auto`
925        // replacement has to go through the config key.
926        if self.full_auto {
927            args.push("-c".into());
928            args.push(format!(
929                "sandbox_mode=\"{}\"",
930                SandboxMode::WorkspaceWrite.as_arg()
931            ));
932        }
933        push_repeat(&mut args, "-c", &self.config_overrides);
934        push_repeat(&mut args, "--enable", &self.enabled_features);
935        push_repeat(&mut args, "--disable", &self.disabled_features);
936        if self.last {
937            args.push("--last".into());
938        }
939        if self.all {
940            args.push("--all".into());
941        }
942        push_repeat(&mut args, "--image", &self.images);
943        if let Some(model) = &self.model {
944            args.push("--model".into());
945            args.push(model.clone());
946        }
947        if self.strict_config {
948            args.push("--strict-config".into());
949        }
950        if self.dangerously_bypass_approvals_and_sandbox {
951            args.push("--dangerously-bypass-approvals-and-sandbox".into());
952        }
953        if self.dangerously_bypass_hook_trust {
954            args.push("--dangerously-bypass-hook-trust".into());
955        }
956        if self.skip_git_repo_check {
957            args.push("--skip-git-repo-check".into());
958        }
959        if self.ephemeral {
960            args.push("--ephemeral".into());
961        }
962        if self.ignore_user_config {
963            args.push("--ignore-user-config".into());
964        }
965        if self.ignore_rules {
966            args.push("--ignore-rules".into());
967        }
968        if let Some(output_schema) = &self.output_schema {
969            args.push("--output-schema".into());
970            args.push(output_schema.clone());
971        }
972        if self.json {
973            args.push("--json".into());
974        }
975        if let Some(path) = &self.output_last_message {
976            args.push("--output-last-message".into());
977            args.push(path.clone());
978        }
979        if let Some(session_id) = &self.session_id {
980            args.push(session_id.clone());
981        }
982        if let Some(prompt) = &self.prompt {
983            args.push(prompt.clone());
984        }
985        args
986    }
987
988    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
989        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
990    }
991}
992
993fn push_repeat(args: &mut Vec<String>, flag: &str, values: &[String]) {
994    for value in values {
995        args.push(flag.into());
996        args.push(value.clone());
997    }
998}
999
1000#[cfg(feature = "json")]
1001fn parse_json_lines(stdout: &str) -> Result<Vec<JsonLineEvent>> {
1002    stdout
1003        .lines()
1004        .filter(|line| line.trim_start().starts_with('{'))
1005        .map(|line| {
1006            serde_json::from_str(line).map_err(|source| Error::Json {
1007                message: format!("failed to parse JSONL event: {line}"),
1008                source,
1009            })
1010        })
1011        .collect()
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use crate::types::ApprovalPolicy;
1018
1019    #[test]
1020    fn exec_args() {
1021        let args = ExecCommand::new("fix the test")
1022            .model("gpt-5")
1023            .sandbox(SandboxMode::WorkspaceWrite)
1024            .strict_config()
1025            .skip_git_repo_check()
1026            .ephemeral()
1027            .ignore_user_config()
1028            .ignore_rules()
1029            .json()
1030            .args();
1031
1032        assert_eq!(
1033            args,
1034            vec![
1035                "exec",
1036                "--model",
1037                "gpt-5",
1038                "--sandbox",
1039                "workspace-write",
1040                "--strict-config",
1041                "--skip-git-repo-check",
1042                "--ephemeral",
1043                "--ignore-user-config",
1044                "--ignore-rules",
1045                "--json",
1046                "fix the test",
1047            ]
1048        );
1049    }
1050
1051    #[test]
1052    fn exec_args_hook_trust() {
1053        let args = ExecCommand::new("go")
1054            .set_bypass_approvals_and_sandbox()
1055            .set_bypass_hook_trust()
1056            .args();
1057
1058        assert_eq!(
1059            args,
1060            vec![
1061                "exec",
1062                "--dangerously-bypass-approvals-and-sandbox",
1063                "--dangerously-bypass-hook-trust",
1064                "go",
1065            ]
1066        );
1067    }
1068
1069    #[test]
1070    #[should_panic(expected = "model name must not be empty")]
1071    fn exec_model_empty_panics() {
1072        let _ = ExecCommand::new("prompt").model("");
1073    }
1074
1075    #[test]
1076    #[should_panic(expected = "model name must not be empty")]
1077    fn exec_resume_model_empty_panics() {
1078        let _ = ExecResumeCommand::new().model("");
1079    }
1080
1081    #[test]
1082    fn exec_resume_args() {
1083        let args = ExecResumeCommand::new()
1084            .last()
1085            .model("gpt-5")
1086            .json()
1087            .prompt("continue")
1088            .args();
1089
1090        assert_eq!(
1091            args,
1092            vec![
1093                "exec", "resume", "--last", "--model", "gpt-5", "--json", "continue",
1094            ]
1095        );
1096    }
1097
1098    #[test]
1099    fn exec_resume_new_flags() {
1100        let args = ExecResumeCommand::new()
1101            .last()
1102            .strict_config()
1103            .set_bypass_hook_trust()
1104            .args();
1105
1106        assert_eq!(
1107            args,
1108            vec![
1109                "exec",
1110                "resume",
1111                "--last",
1112                "--strict-config",
1113                "--dangerously-bypass-hook-trust",
1114            ]
1115        );
1116    }
1117
1118    /// #53: `--ask-for-approval` and `--search` were removed from `codex exec`
1119    /// in codex-cli 0.145.0; the settings live on as config keys.
1120    #[test]
1121    fn exec_approval_and_search_emit_config_keys() {
1122        let args = ExecCommand::new("hi")
1123            .approval_policy(ApprovalPolicy::Never)
1124            .search()
1125            .args();
1126        assert_eq!(
1127            args,
1128            vec![
1129                "exec",
1130                "-c",
1131                "approval_policy=\"never\"",
1132                "-c",
1133                "web_search=\"live\"",
1134                "hi"
1135            ]
1136        );
1137        assert!(
1138            !args
1139                .iter()
1140                .any(|a| a == "--ask-for-approval" || a == "--search")
1141        );
1142    }
1143
1144    /// `granular` and `on-failure` are accepted by the config key but not by
1145    /// the flag, which is why `ApprovalPolicyConfig` exists.
1146    #[test]
1147    fn exec_approval_accepts_config_only_values() {
1148        let args = ExecCommand::new("hi")
1149            .approval_policy(ApprovalPolicyConfig::Granular)
1150            .args();
1151        assert_eq!(
1152            args,
1153            vec!["exec", "-c", "approval_policy=\"granular\"", "hi"]
1154        );
1155    }
1156
1157    #[test]
1158    fn exec_search_mode_variants() {
1159        for (mode, expected) in [
1160            (WebSearchMode::Disabled, "disabled"),
1161            (WebSearchMode::Cached, "cached"),
1162            (WebSearchMode::Indexed, "indexed"),
1163            (WebSearchMode::Live, "live"),
1164        ] {
1165            let args = ExecCommand::new("hi").search_mode(mode).args();
1166            assert_eq!(args[2], format!("web_search=\"{expected}\""));
1167        }
1168    }
1169
1170    /// `-c` is last-wins, so a raw override has to be emitted after the typed
1171    /// setters for it to take effect.
1172    #[test]
1173    fn exec_raw_config_is_emitted_after_typed_config() {
1174        let args = ExecCommand::new("hi")
1175            .approval_policy(ApprovalPolicy::Never)
1176            .config("approval_policy=\"untrusted\"")
1177            .args();
1178        let typed = args
1179            .iter()
1180            .position(|a| a == "approval_policy=\"never\"")
1181            .unwrap();
1182        let raw = args
1183            .iter()
1184            .position(|a| a == "approval_policy=\"untrusted\"")
1185            .unwrap();
1186        assert!(typed < raw, "raw override must win: {args:?}");
1187    }
1188
1189    /// #55: `--full-auto` is hidden and deprecated on the exec family.
1190    #[test]
1191    fn exec_full_auto_emits_sandbox_workspace_write() {
1192        let args = ExecCommand::new("hi").full_auto().args();
1193        assert_eq!(args, vec!["exec", "--sandbox", "workspace-write", "hi"]);
1194        assert!(!args.iter().any(|a| a == "--full-auto"));
1195    }
1196
1197    #[test]
1198    fn exec_explicit_sandbox_wins_over_full_auto() {
1199        let args = ExecCommand::new("hi")
1200            .full_auto()
1201            .sandbox(SandboxMode::ReadOnly)
1202            .args();
1203        assert_eq!(args, vec!["exec", "--sandbox", "read-only", "hi"]);
1204    }
1205
1206    /// `codex exec resume` has no `--sandbox` flag, so the replacement goes
1207    /// through the config key instead.
1208    #[test]
1209    fn exec_resume_full_auto_emits_sandbox_config_key() {
1210        let args = ExecResumeCommand::new().last().full_auto().args();
1211        assert_eq!(
1212            args,
1213            vec![
1214                "exec",
1215                "resume",
1216                "-c",
1217                "sandbox_mode=\"workspace-write\"",
1218                "--last"
1219            ]
1220        );
1221        assert!(!args.iter().any(|a| a == "--full-auto"));
1222    }
1223
1224    #[test]
1225    fn exec_resume_approval_and_search_emit_config_keys() {
1226        let args = ExecResumeCommand::new()
1227            .last()
1228            .approval_policy(ApprovalPolicyConfig::OnFailure)
1229            .search_mode(WebSearchMode::Cached)
1230            .args();
1231        assert_eq!(
1232            args,
1233            vec![
1234                "exec",
1235                "resume",
1236                "-c",
1237                "approval_policy=\"on-failure\"",
1238                "-c",
1239                "web_search=\"cached\"",
1240                "--last"
1241            ]
1242        );
1243    }
1244
1245    /// #65: these three were listed in #41 P1 but never landed on
1246    /// `ExecResumeCommand`.
1247    #[test]
1248    fn exec_resume_ignore_and_output_schema_args() {
1249        let args = ExecResumeCommand::new()
1250            .last()
1251            .ignore_user_config()
1252            .ignore_rules()
1253            .output_schema("/tmp/schema.json")
1254            .args();
1255        assert_eq!(
1256            args,
1257            vec![
1258                "exec",
1259                "resume",
1260                "--last",
1261                "--ignore-user-config",
1262                "--ignore-rules",
1263                "--output-schema",
1264                "/tmp/schema.json"
1265            ]
1266        );
1267    }
1268
1269    /// #81: the builder emitted `codex exec -` while every spawn path closed
1270    /// the child's stdin, so the prompt was never delivered. This drives a
1271    /// fake codex that echoes back what it read, which is the only way to see
1272    /// the difference: the argv is identical either way.
1273    #[cfg(all(unix, feature = "json"))]
1274    #[tokio::test]
1275    async fn stdin_prompt_reaches_the_child() {
1276        let codex = echoing_stdin_codex();
1277        let prompt = "a prompt too awkward for argv\nwith a second line";
1278
1279        let result = ExecCommand::from_stdin(prompt)
1280            .execute_json(&codex)
1281            .await
1282            .unwrap();
1283
1284        assert_eq!(result.result, prompt);
1285    }
1286
1287    /// The same delivery, on the streaming path, which pipes stdin separately.
1288    #[cfg(all(unix, feature = "json"))]
1289    #[tokio::test]
1290    async fn stdin_prompt_reaches_the_child_when_streaming() {
1291        let codex = echoing_stdin_codex();
1292        let prompt = "streamed stdin prompt";
1293        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1294        let sink = std::sync::Arc::clone(&seen);
1295
1296        ExecCommand::from_stdin(prompt)
1297            .stream(&codex, move |event| {
1298                if let Some(text) = event.agent_message_text() {
1299                    sink.lock().unwrap().push(text);
1300                }
1301            })
1302            .await
1303            .unwrap();
1304
1305        assert_eq!(seen.lock().unwrap().as_slice(), &[prompt.to_string()]);
1306    }
1307
1308    /// A prompt larger than a pipe buffer must not deadlock: the write and the
1309    /// output drain have to run concurrently.
1310    #[cfg(all(unix, feature = "json"))]
1311    #[tokio::test]
1312    async fn a_prompt_larger_than_the_pipe_buffer_still_completes() {
1313        let codex = echoing_stdin_codex();
1314        // Well past the usual 64 KiB pipe capacity.
1315        let prompt = "x".repeat(512 * 1024);
1316
1317        let result = ExecCommand::from_stdin(&prompt)
1318            .execute_json(&codex)
1319            .await
1320            .unwrap();
1321
1322        assert_eq!(result.result.len(), prompt.len());
1323    }
1324
1325    #[cfg(all(unix, feature = "json"))]
1326    fn echoing_stdin_codex() -> Codex {
1327        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1328            .join("tests")
1329            .join("fake-codex-echo-stdin.sh");
1330        Codex::builder()
1331            .binary("/bin/bash")
1332            .arg(script.to_str().unwrap())
1333            .build()
1334            .expect("bash must exist")
1335    }
1336
1337    #[test]
1338    fn from_stdin_emits_the_dash_positional_not_the_prompt() {
1339        let args = ExecCommand::from_stdin("secret prompt").ephemeral().args();
1340        assert_eq!(args, vec!["exec", "--ephemeral", "-"]);
1341        // The prompt must not leak into argv, which is the whole point of
1342        // sending it on stdin.
1343        assert!(!args.iter().any(|a| a.contains("secret")));
1344    }
1345
1346    #[test]
1347    fn prompt_via_stdin_converts_an_existing_prompt() {
1348        let args = ExecCommand::new("hello").prompt_via_stdin().args();
1349        assert_eq!(args, vec!["exec", "-"]);
1350    }
1351}