1use crate::error::Result;
2use crate::{CmdOutput, CmdRequest, CmdStdin, CmdTool};
3use std::path::PathBuf;
4
5pub struct CodexTool;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SandboxMode {
9 ReadOnly,
10 WorkspaceWrite,
11 DangerFullAccess,
12}
13
14impl SandboxMode {
15 fn as_str(self) -> &'static str {
16 match self {
17 SandboxMode::ReadOnly => "read-only",
18 SandboxMode::WorkspaceWrite => "workspace-write",
19 SandboxMode::DangerFullAccess => "danger-full-access",
20 }
21 }
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum OssProvider {
26 LmStudio,
27 Ollama,
28}
29
30impl OssProvider {
31 fn as_str(self) -> &'static str {
32 match self {
33 OssProvider::LmStudio => "lmstudio",
34 OssProvider::Ollama => "ollama",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ColorMode {
41 Always,
42 Never,
43 Auto,
44}
45
46impl ColorMode {
47 fn as_str(self) -> &'static str {
48 match self {
49 ColorMode::Always => "always",
50 ColorMode::Never => "never",
51 ColorMode::Auto => "auto",
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct CodexRequest {
58 pub task: Option<String>,
59 pub stdin: Option<CmdStdin>,
60 pub timeout_ms: Option<u64>,
61 pub fail_on_non_zero: bool,
62 pub background: bool,
63 pub dangerously_bypass_approvals_and_sandbox: bool,
64 pub options: CodexOptions,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct CodexOptions {
69 pub search: bool,
70 pub config: Vec<String>,
71 pub enable: Vec<String>,
72 pub disable: Vec<String>,
73 pub images: Vec<PathBuf>,
74 pub model: Option<String>,
75 pub oss: bool,
76 pub local_provider: Option<OssProvider>,
77 pub sandbox: Option<SandboxMode>,
78 pub profile: Option<String>,
79 pub full_auto: bool,
80 pub dangerously_bypass_approvals_and_sandbox: bool,
81 pub cd: Option<String>,
82 pub skip_git_repo_check: bool,
83 pub add_dirs: Vec<String>,
84 pub ephemeral: bool,
85 pub output_schema: Option<String>,
86 pub color: Option<ColorMode>,
87 pub progress_cursor: bool,
88 pub json: bool,
89 pub output_last_message: Option<String>,
90}
91
92impl CodexRequest {
93 pub fn new(task: impl Into<String>) -> Self {
94 Self {
95 task: Some(task.into()),
96 stdin: None,
97 timeout_ms: None,
98 fail_on_non_zero: true,
99 background: false,
100 dangerously_bypass_approvals_and_sandbox: false,
101 options: CodexOptions::default(),
102 }
103 }
104
105 pub fn from_stdin(stdin: CmdStdin) -> Self {
106 Self {
107 task: None,
108 stdin: Some(stdin),
109 timeout_ms: None,
110 fail_on_non_zero: true,
111 background: false,
112 dangerously_bypass_approvals_and_sandbox: false,
113 options: CodexOptions::default(),
114 }
115 }
116}
117
118impl CodexTool {
119 pub fn exec(req: CodexRequest) -> Result<CmdOutput> {
120 Self::run_args(
121 build_exec_args(&req),
122 req.stdin.clone(),
123 req.timeout_ms,
124 req.fail_on_non_zero,
125 req.background,
126 )
127 }
128
129 fn run_args(
130 args: Vec<String>,
131 stdin: Option<CmdStdin>,
132 timeout_ms: Option<u64>,
133 fail_on_non_zero: bool,
134 background: bool,
135 ) -> Result<CmdOutput> {
136 CmdTool::run(CmdRequest {
137 program: "codex".to_string(),
138 args,
139 cwd: None,
140 env: None,
141 timeout_ms,
142 fail_on_non_zero,
143 stdin,
144 background,
145 })
146 }
147}
148
149fn build_exec_args(req: &CodexRequest) -> Vec<String> {
150 let mut args = Vec::new();
151 push_common_args(
152 &mut args,
153 req.options.search,
154 &req.options.config,
155 &req.options.enable,
156 &req.options.disable,
157 &req.options.images,
158 req.options.model.as_deref(),
159 req.options.oss,
160 req.options.local_provider,
161 req.options.sandbox,
162 req.options.profile.as_deref(),
163 req.options.full_auto,
164 req.dangerously_bypass_approvals_and_sandbox
165 || req.options.dangerously_bypass_approvals_and_sandbox,
166 req.options.cd.as_deref(),
167 &req.options.add_dirs,
168 );
169 args.push("exec".to_string());
170
171 if req.options.skip_git_repo_check {
172 args.push("--skip-git-repo-check".to_string());
173 }
174 if req.options.ephemeral {
175 args.push("--ephemeral".to_string());
176 }
177 if let Some(output_schema) = &req.options.output_schema {
178 args.push("--output-schema".to_string());
179 args.push(output_schema.clone());
180 }
181 if let Some(color) = req.options.color {
182 args.push("--color".to_string());
183 args.push(color.as_str().to_string());
184 }
185 if req.options.progress_cursor {
186 args.push("--progress-cursor".to_string());
187 }
188 if req.options.json {
189 args.push("--json".to_string());
190 }
191 if let Some(output_last_message) = &req.options.output_last_message {
192 args.push("--output-last-message".to_string());
193 args.push(output_last_message.clone());
194 }
195
196 push_prompt_arg(&mut args, req.task.as_deref(), req.stdin.as_ref());
197 args
198}
199
200#[allow(clippy::too_many_arguments)]
201fn push_common_args(
202 args: &mut Vec<String>,
203 search: bool,
204 config: &[String],
205 enable: &[String],
206 disable: &[String],
207 images: &[PathBuf],
208 model: Option<&str>,
209 oss: bool,
210 local_provider: Option<OssProvider>,
211 sandbox: Option<SandboxMode>,
212 profile: Option<&str>,
213 full_auto: bool,
214 dangerously_bypass_approvals_and_sandbox: bool,
215 cd: Option<&str>,
216 add_dirs: &[String],
217) {
218 if search {
219 args.push("--search".to_string());
220 }
221
222 for entry in config {
223 args.push("-c".to_string());
224 args.push(entry.clone());
225 }
226 for feature in enable {
227 args.push("--enable".to_string());
228 args.push(feature.clone());
229 }
230 for feature in disable {
231 args.push("--disable".to_string());
232 args.push(feature.clone());
233 }
234 for image in images {
235 args.push("--image".to_string());
236 args.push(image.display().to_string());
237 }
238 if let Some(model) = model {
239 args.push("--model".to_string());
240 args.push(model.to_string());
241 }
242 if oss {
243 args.push("--oss".to_string());
244 }
245 if let Some(local_provider) = local_provider {
246 args.push("--local-provider".to_string());
247 args.push(local_provider.as_str().to_string());
248 }
249 if let Some(sandbox) = sandbox {
250 args.push("--sandbox".to_string());
251 args.push(sandbox.as_str().to_string());
252 }
253 if let Some(profile) = profile {
254 args.push("--profile".to_string());
255 args.push(profile.to_string());
256 }
257 if full_auto {
258 args.push("--full-auto".to_string());
259 }
260 if dangerously_bypass_approvals_and_sandbox {
261 args.push("--dangerously-bypass-approvals-and-sandbox".to_string());
262 }
263 if let Some(cd) = cd {
264 args.push("--cd".to_string());
265 args.push(cd.to_string());
266 }
267 for dir in add_dirs {
268 args.push("--add-dir".to_string());
269 args.push(dir.clone());
270 }
271}
272
273fn push_prompt_arg(args: &mut Vec<String>, prompt: Option<&str>, stdin: Option<&CmdStdin>) {
274 if let Some(prompt) = prompt {
275 args.push(prompt.to_string());
276 return;
277 }
278
279 if stdin.is_some() {
280 args.push("-".to_string());
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn test_build_exec_args_with_structured_options() {
290 let req = CodexRequest {
291 task: Some("run tests".to_string()),
292 stdin: None,
293 timeout_ms: Some(10_000),
294 fail_on_non_zero: true,
295 background: false,
296 dangerously_bypass_approvals_and_sandbox: true,
297 options: CodexOptions {
298 search: true,
299 config: vec!["model=\"gpt-5\"".to_string()],
300 enable: vec!["fast_mode".to_string()],
301 disable: vec!["slow_mode".to_string()],
302 images: vec![PathBuf::from("/tmp/a.png")],
303 model: Some("gpt-5".to_string()),
304 oss: true,
305 local_provider: Some(OssProvider::Ollama),
306 sandbox: Some(SandboxMode::WorkspaceWrite),
307 profile: Some("default".to_string()),
308 full_auto: true,
309 dangerously_bypass_approvals_and_sandbox: false,
310 cd: Some("/tmp/work".to_string()),
311 skip_git_repo_check: true,
312 add_dirs: vec!["/tmp/extra".to_string()],
313 ephemeral: true,
314 output_schema: Some("/tmp/schema.json".to_string()),
315 color: Some(ColorMode::Never),
316 progress_cursor: true,
317 json: true,
318 output_last_message: Some("/tmp/out.txt".to_string()),
319 },
320 };
321
322 let args = build_exec_args(&req);
323 assert_eq!(args[0], "--search");
324 assert!(args.iter().any(|x| x == "exec"));
325 assert!(args.contains(&"--skip-git-repo-check".to_string()));
326 assert!(args.contains(&"--ephemeral".to_string()));
327 assert!(args.contains(&"--json".to_string()));
328 assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
329 assert!(args.contains(&"run tests".to_string()));
330 }
331
332 #[test]
333 fn test_build_exec_args_uses_stdin_marker_without_prompt() {
334 let req = CodexRequest {
335 stdin: Some(CmdStdin::Text("hello".to_string())),
336 ..CodexRequest::default()
337 };
338
339 let args = build_exec_args(&req);
340 assert_eq!(args, vec!["exec".to_string(), "-".to_string()]);
341 }
342
343 #[test]
344 fn test_new_request_defaults_to_simple_task() {
345 let req = CodexRequest::new("fix tests");
346 let args = build_exec_args(&req);
347
348 assert_eq!(args, vec!["exec".to_string(), "fix tests".to_string()]);
349 assert!(req.fail_on_non_zero);
350 assert!(!req.background);
351 assert!(!req.options.search);
352 }
353
354 #[test]
355 fn test_top_level_dangerous_flag_is_applied() {
356 let mut req = CodexRequest::new("do work");
357 req.dangerously_bypass_approvals_and_sandbox = true;
358
359 let args = build_exec_args(&req);
360 assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
361 }
362}