Skip to main content

codex_wrapper/command/
fork.rs

1/// Fork a previous interactive session.
2///
3/// Wraps `codex fork [session-id] [prompt]`.
4use crate::Codex;
5use crate::command::CodexCommand;
6use crate::command::exec::effective_sandbox;
7use crate::error::Result;
8use crate::exec::{self, CommandOutput};
9use crate::types::{ApprovalPolicy, SandboxMode};
10
11/// Fork a previous interactive Codex session, creating a new branch of conversation.
12#[derive(Debug, Clone)]
13pub struct ForkCommand {
14    approve_for_me: bool,
15    session_id: Option<String>,
16    prompt: Option<String>,
17    last: bool,
18    all: bool,
19    config_overrides: Vec<String>,
20    enabled_features: Vec<String>,
21    disabled_features: Vec<String>,
22    images: Vec<String>,
23    model: Option<String>,
24    oss: bool,
25    local_provider: Option<String>,
26    profile: Option<String>,
27    sandbox: Option<SandboxMode>,
28    approval_policy: Option<ApprovalPolicy>,
29    full_auto: bool,
30    dangerously_bypass_approvals_and_sandbox: bool,
31    dangerously_bypass_hook_trust: bool,
32    strict_config: bool,
33    no_alt_screen: bool,
34    remote: Option<String>,
35    remote_auth_token_env: Option<String>,
36    cd: Option<String>,
37    search: bool,
38    add_dirs: Vec<String>,
39}
40
41impl ForkCommand {
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            approve_for_me: false,
46            session_id: None,
47            prompt: None,
48            last: false,
49            all: false,
50            config_overrides: Vec::new(),
51            enabled_features: Vec::new(),
52            disabled_features: Vec::new(),
53            images: Vec::new(),
54            model: None,
55            oss: false,
56            local_provider: None,
57            profile: None,
58            sandbox: None,
59            approval_policy: None,
60            full_auto: false,
61            dangerously_bypass_approvals_and_sandbox: false,
62            dangerously_bypass_hook_trust: false,
63            strict_config: false,
64            no_alt_screen: false,
65            remote: None,
66            remote_auth_token_env: None,
67            cd: None,
68            search: false,
69            add_dirs: Vec::new(),
70        }
71    }
72
73    /// Session ID (UUID) to fork.
74    #[must_use]
75    pub fn session_id(mut self, id: impl Into<String>) -> Self {
76        self.session_id = Some(id.into());
77        self
78    }
79
80    /// Optional prompt to start the forked session with.
81    #[must_use]
82    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
83        self.prompt = Some(prompt.into());
84        self
85    }
86
87    /// Fork the most recent session without showing the picker.
88    #[must_use]
89    pub fn last(mut self) -> Self {
90        self.last = true;
91        self
92    }
93
94    /// Show all sessions (disables cwd filtering).
95    #[must_use]
96    pub fn all(mut self) -> Self {
97        self.all = true;
98        self
99    }
100
101    #[must_use]
102    pub fn config(mut self, key_value: impl Into<String>) -> Self {
103        self.config_overrides.push(key_value.into());
104        self
105    }
106
107    #[must_use]
108    pub fn enable(mut self, feature: impl Into<String>) -> Self {
109        self.enabled_features.push(feature.into());
110        self
111    }
112
113    #[must_use]
114    pub fn disable(mut self, feature: impl Into<String>) -> Self {
115        self.disabled_features.push(feature.into());
116        self
117    }
118
119    #[must_use]
120    pub fn image(mut self, path: impl Into<String>) -> Self {
121        self.images.push(path.into());
122        self
123    }
124
125    #[must_use]
126    pub fn model(mut self, model: impl Into<String>) -> Self {
127        self.model = Some(model.into());
128        self
129    }
130
131    #[must_use]
132    pub fn oss(mut self) -> Self {
133        self.oss = true;
134        self
135    }
136
137    #[must_use]
138    pub fn local_provider(mut self, provider: impl Into<String>) -> Self {
139        self.local_provider = Some(provider.into());
140        self
141    }
142
143    #[must_use]
144    pub fn profile(mut self, profile: impl Into<String>) -> Self {
145        self.profile = Some(profile.into());
146        self
147    }
148
149    #[must_use]
150    pub fn sandbox(mut self, sandbox: SandboxMode) -> Self {
151        self.sandbox = Some(sandbox);
152        self
153    }
154
155    #[must_use]
156    pub fn approval_policy(mut self, policy: ApprovalPolicy) -> Self {
157        self.approval_policy = Some(policy);
158        self
159    }
160
161    /// Run in full-auto mode, emitted as `--sandbox workspace-write`.
162    ///
163    /// `codex fork` rejects `--full-auto` outright in `codex-cli` 0.145.0
164    /// ("unexpected argument"), so this emits the replacement the CLI names
165    /// for the exec family. An explicit [`sandbox`](Self::sandbox) call is
166    /// more specific and wins over it.
167    #[must_use]
168    pub fn full_auto(mut self) -> Self {
169        self.full_auto = true;
170        self
171    }
172
173    /// Route approval requests through automatic review, using the
174    /// workspace-write sandbox (`--approve-for-me`).
175    ///
176    /// Added in `codex-cli` 0.147.0. Older releases reject it as an unexpected
177    /// argument, so this is the one builder method with a floor above the
178    /// wrapper's tested minimum. `codex exec review` and `codex exec resume`
179    /// do not accept it.
180    #[must_use]
181    pub fn approve_for_me(mut self) -> Self {
182        self.approve_for_me = true;
183        self
184    }
185
186    #[must_use]
187    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
188        self.dangerously_bypass_approvals_and_sandbox = true;
189        self
190    }
191
192    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
193    #[must_use]
194    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
195        self.dangerously_bypass_hook_trust = true;
196        self
197    }
198
199    /// Error on unrecognized config keys (`--strict-config`).
200    #[must_use]
201    pub fn strict_config(mut self) -> Self {
202        self.strict_config = true;
203        self
204    }
205
206    /// Do not use the terminal alternate screen (`--no-alt-screen`).
207    #[must_use]
208    pub fn no_alt_screen(mut self) -> Self {
209        self.no_alt_screen = true;
210        self
211    }
212
213    /// Connect the TUI to a remote app server endpoint (`--remote <ADDR>`).
214    #[must_use]
215    pub fn remote(mut self, addr: impl Into<String>) -> Self {
216        self.remote = Some(addr.into());
217        self
218    }
219
220    /// Env var holding the bearer token for the remote app server
221    /// (`--remote-auth-token-env <ENV_VAR>`).
222    #[must_use]
223    pub fn remote_auth_token_env(mut self, env_var: impl Into<String>) -> Self {
224        self.remote_auth_token_env = Some(env_var.into());
225        self
226    }
227
228    #[must_use]
229    pub fn cd(mut self, dir: impl Into<String>) -> Self {
230        self.cd = Some(dir.into());
231        self
232    }
233
234    /// Enable live web search.
235    #[must_use]
236    pub fn search(mut self) -> Self {
237        self.search = true;
238        self
239    }
240
241    #[must_use]
242    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
243        self.add_dirs.push(dir.into());
244        self
245    }
246}
247
248impl Default for ForkCommand {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl CodexCommand for ForkCommand {
255    type Output = CommandOutput;
256
257    fn args(&self) -> Vec<String> {
258        let mut args = vec!["fork".into()];
259
260        for v in &self.config_overrides {
261            args.push("-c".into());
262            args.push(v.clone());
263        }
264        for v in &self.enabled_features {
265            args.push("--enable".into());
266            args.push(v.clone());
267        }
268        for v in &self.disabled_features {
269            args.push("--disable".into());
270            args.push(v.clone());
271        }
272        if self.last {
273            args.push("--last".into());
274        }
275        if self.all {
276            args.push("--all".into());
277        }
278        for v in &self.images {
279            args.push("--image".into());
280            args.push(v.clone());
281        }
282        if let Some(model) = &self.model {
283            args.push("--model".into());
284            args.push(model.clone());
285        }
286        if self.oss {
287            args.push("--oss".into());
288        }
289        if let Some(provider) = &self.local_provider {
290            args.push("--local-provider".into());
291            args.push(provider.clone());
292        }
293        if let Some(profile) = &self.profile {
294            args.push("--profile".into());
295            args.push(profile.clone());
296        }
297        if let Some(sandbox) = effective_sandbox(self.sandbox, self.full_auto) {
298            args.push("--sandbox".into());
299            args.push(sandbox.as_arg().into());
300        }
301        if let Some(policy) = self.approval_policy {
302            args.push("--ask-for-approval".into());
303            args.push(policy.as_arg().into());
304        }
305        if self.approve_for_me {
306            args.push("--approve-for-me".into());
307        }
308        if self.dangerously_bypass_approvals_and_sandbox {
309            args.push("--dangerously-bypass-approvals-and-sandbox".into());
310        }
311        if self.dangerously_bypass_hook_trust {
312            args.push("--dangerously-bypass-hook-trust".into());
313        }
314        if self.strict_config {
315            args.push("--strict-config".into());
316        }
317        if self.no_alt_screen {
318            args.push("--no-alt-screen".into());
319        }
320        if let Some(remote) = &self.remote {
321            args.push("--remote".into());
322            args.push(remote.clone());
323        }
324        if let Some(env_var) = &self.remote_auth_token_env {
325            args.push("--remote-auth-token-env".into());
326            args.push(env_var.clone());
327        }
328        if let Some(cd) = &self.cd {
329            args.push("--cd".into());
330            args.push(cd.clone());
331        }
332        if self.search {
333            args.push("--search".into());
334        }
335        for v in &self.add_dirs {
336            args.push("--add-dir".into());
337            args.push(v.clone());
338        }
339        if let Some(id) = &self.session_id {
340            args.push(id.clone());
341        }
342        if let Some(prompt) = &self.prompt {
343            args.push(prompt.clone());
344        }
345        args
346    }
347
348    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
349        exec::run_codex(codex, self.args()).await
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn fork_last_args() {
359        let args = ForkCommand::new()
360            .last()
361            .model("gpt-5")
362            .prompt("take a different approach")
363            .args();
364        assert_eq!(
365            args,
366            vec![
367                "fork",
368                "--last",
369                "--model",
370                "gpt-5",
371                "take a different approach"
372            ]
373        );
374    }
375
376    #[test]
377    fn fork_session_id_args() {
378        let args = ForkCommand::new()
379            .session_id("abc-123")
380            .full_auto()
381            .search()
382            .args();
383        assert_eq!(
384            args,
385            vec![
386                "fork",
387                "--sandbox",
388                "workspace-write",
389                "--search",
390                "abc-123"
391            ]
392        );
393    }
394
395    /// `codex fork` rejects `--full-auto` outright, so it must never be
396    /// emitted. See #55.
397    #[test]
398    fn fork_full_auto_never_emits_the_flag() {
399        let args = ForkCommand::new().full_auto().args();
400        assert!(!args.iter().any(|a| a == "--full-auto"));
401    }
402
403    #[test]
404    fn fork_explicit_sandbox_wins_over_full_auto() {
405        let args = ForkCommand::new()
406            .full_auto()
407            .sandbox(SandboxMode::ReadOnly)
408            .args();
409        assert_eq!(args, vec!["fork", "--sandbox", "read-only"]);
410    }
411
412    #[test]
413    fn fork_new_flags_args() {
414        let args = ForkCommand::new()
415            .last()
416            .strict_config()
417            .set_bypass_hook_trust()
418            .no_alt_screen()
419            .remote("ws://host:9000")
420            .remote_auth_token_env("CODEX_TOKEN")
421            .args();
422        assert_eq!(
423            args,
424            vec![
425                "fork",
426                "--last",
427                "--dangerously-bypass-hook-trust",
428                "--strict-config",
429                "--no-alt-screen",
430                "--remote",
431                "ws://host:9000",
432                "--remote-auth-token-env",
433                "CODEX_TOKEN",
434            ]
435        );
436    }
437}