agcodex_exec/
lib.rs

1mod cli;
2mod event_processor;
3mod event_processor_with_human_output;
4mod event_processor_with_json_output;
5
6use std::io::IsTerminal;
7use std::io::Read;
8use std::path::PathBuf;
9
10use agcodex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
11use agcodex_core::ConversationManager;
12use agcodex_core::NewConversation;
13use agcodex_core::config::Config;
14use agcodex_core::config::ConfigOverrides;
15use agcodex_core::protocol::AskForApproval;
16use agcodex_core::protocol::Event;
17use agcodex_core::protocol::EventMsg;
18use agcodex_core::protocol::InputItem;
19use agcodex_core::protocol::Op;
20use agcodex_core::protocol::TaskCompleteEvent;
21use agcodex_core::util::is_inside_git_repo;
22use agcodex_ollama::DEFAULT_OSS_MODEL;
23use agcodex_protocol::config_types::SandboxMode;
24pub use cli::Cli;
25use event_processor_with_human_output::EventProcessorWithHumanOutput;
26use event_processor_with_json_output::EventProcessorWithJsonOutput;
27use tracing::debug;
28use tracing::error;
29use tracing::info;
30use tracing_subscriber::EnvFilter;
31
32use crate::event_processor::CodexStatus;
33use crate::event_processor::EventProcessor;
34
35pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
36    let Cli {
37        images,
38        model: model_cli_arg,
39        oss,
40        config_profile,
41        full_auto,
42        dangerously_bypass_approvals_and_sandbox,
43        cwd,
44        skip_git_repo_check,
45        color,
46        last_message_file,
47        json: json_mode,
48        sandbox_mode: sandbox_mode_cli_arg,
49        prompt,
50        config_overrides,
51    } = cli;
52
53    // Determine the prompt based on CLI arg and/or stdin.
54    let prompt = match prompt {
55        Some(p) if p != "-" => p,
56        // Either `-` was passed or no positional arg.
57        maybe_dash => {
58            // When no arg (None) **and** stdin is a TTY, bail out early – unless the
59            // user explicitly forced reading via `-`.
60            let force_stdin = matches!(maybe_dash.as_deref(), Some("-"));
61
62            if std::io::stdin().is_terminal() && !force_stdin {
63                eprintln!(
64                    "No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
65                );
66                std::process::exit(1);
67            }
68
69            // Ensure the user knows we are waiting on stdin, as they may
70            // have gotten into this state by mistake. If so, and they are not
71            // writing to stdin, Codex will hang indefinitely, so this should
72            // help them debug in that case.
73            if !force_stdin {
74                eprintln!("Reading prompt from stdin...");
75            }
76            let mut buffer = String::new();
77            if let Err(e) = std::io::stdin().read_to_string(&mut buffer) {
78                eprintln!("Failed to read prompt from stdin: {e}");
79                std::process::exit(1);
80            } else if buffer.trim().is_empty() {
81                eprintln!("No prompt provided via stdin.");
82                std::process::exit(1);
83            }
84            buffer
85        }
86    };
87
88    let (stdout_with_ansi, stderr_with_ansi) = match color {
89        cli::Color::Always => (true, true),
90        cli::Color::Never => (false, false),
91        cli::Color::Auto => (
92            std::io::stdout().is_terminal(),
93            std::io::stderr().is_terminal(),
94        ),
95    };
96
97    // TODO(mbolin): Take a more thoughtful approach to logging.
98    let default_level = "error";
99    let _ = tracing_subscriber::fmt()
100        // Fallback to the `default_level` log filter if the environment
101        // variable is not set _or_ contains an invalid value
102        .with_env_filter(
103            EnvFilter::try_from_default_env()
104                .or_else(|_| EnvFilter::try_new(default_level))
105                .unwrap_or_else(|_| EnvFilter::new(default_level)),
106        )
107        .with_ansi(stderr_with_ansi)
108        .with_writer(std::io::stderr)
109        .try_init();
110
111    let sandbox_mode = if full_auto {
112        Some(SandboxMode::WorkspaceWrite)
113    } else if dangerously_bypass_approvals_and_sandbox {
114        Some(SandboxMode::DangerFullAccess)
115    } else {
116        sandbox_mode_cli_arg.map(Into::<SandboxMode>::into)
117    };
118
119    // When using `--oss`, let the bootstrapper pick the model (defaulting to
120    // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
121    // `oss` model provider.
122    let model = if let Some(model) = model_cli_arg {
123        Some(model)
124    } else if oss {
125        Some(DEFAULT_OSS_MODEL.to_owned())
126    } else {
127        None // No model specified, will use the default.
128    };
129
130    let model_provider = if oss {
131        Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_string())
132    } else {
133        None // No specific model provider override.
134    };
135
136    // Load configuration and determine approval policy
137    let overrides = ConfigOverrides {
138        model,
139        config_profile,
140        // This CLI is intended to be headless and has no affordances for asking
141        // the user for approval.
142        approval_policy: Some(AskForApproval::Never),
143        sandbox_mode,
144        cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
145        model_provider,
146        codex_linux_sandbox_exe,
147        base_instructions: None,
148        include_plan_tool: None,
149        include_apply_patch_tool: None,
150        disable_response_storage: oss.then_some(true),
151        show_raw_agent_reasoning: oss.then_some(true),
152    };
153    // Parse `-c` overrides.
154    let cli_kv_overrides = match config_overrides.parse_overrides() {
155        Ok(v) => v,
156        Err(e) => {
157            eprintln!("Error parsing -c overrides: {e}");
158            std::process::exit(1);
159        }
160    };
161
162    let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
163    let mut event_processor: Box<dyn EventProcessor> = if json_mode {
164        Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone()))
165    } else {
166        Box::new(EventProcessorWithHumanOutput::create_with_ansi(
167            stdout_with_ansi,
168            &config,
169            last_message_file.clone(),
170        ))
171    };
172
173    if oss {
174        agcodex_ollama::ensure_oss_ready(&config)
175            .await
176            .map_err(|e| anyhow::anyhow!("OSS setup failed: {e}"))?;
177    }
178
179    // Print the effective configuration and prompt so users can see what Codex
180    // is using.
181    event_processor.print_config_summary(&config, &prompt);
182
183    if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) {
184        eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
185        std::process::exit(1);
186    }
187
188    let conversation_manager = ConversationManager::default();
189    let NewConversation {
190        conversation_id: _,
191        conversation,
192        session_configured,
193    } = conversation_manager.new_conversation(config).await?;
194    info!("Codex initialized with event: {session_configured:?}");
195
196    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
197    {
198        let conversation = conversation.clone();
199        tokio::spawn(async move {
200            loop {
201                tokio::select! {
202                    _ = tokio::signal::ctrl_c() => {
203                        tracing::debug!("Keyboard interrupt");
204                        // Immediately notify Codex to abort any in‑flight task.
205                        conversation.submit(Op::Interrupt).await.ok();
206
207                        // Exit the inner loop and return to the main input prompt. The codex
208                        // will emit a `TurnInterrupted` (Error) event which is drained later.
209                        break;
210                    }
211                    res = conversation.next_event() => match res {
212                        Ok(event) => {
213                            debug!("Received event: {event:?}");
214
215                            let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
216                            if let Err(e) = tx.send(event) {
217                                error!("Error sending event: {e:?}");
218                                break;
219                            }
220                            if is_shutdown_complete {
221                                info!("Received shutdown event, exiting event loop.");
222                                break;
223                            }
224                        },
225                        Err(e) => {
226                            error!("Error receiving event: {e:?}");
227                            break;
228                        }
229                    }
230                }
231            }
232        });
233    }
234
235    // Send images first, if any.
236    if !images.is_empty() {
237        let items: Vec<InputItem> = images
238            .into_iter()
239            .map(|path| InputItem::LocalImage { path })
240            .collect();
241        let initial_images_event_id = conversation.submit(Op::UserInput { items }).await?;
242        info!("Sent images with event ID: {initial_images_event_id}");
243        while let Ok(event) = conversation.next_event().await {
244            if event.id == initial_images_event_id
245                && matches!(
246                    event.msg,
247                    EventMsg::TaskComplete(TaskCompleteEvent {
248                        last_agent_message: _,
249                    })
250                )
251            {
252                break;
253            }
254        }
255    }
256
257    // Send the prompt.
258    let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
259    let initial_prompt_task_id = conversation.submit(Op::UserInput { items }).await?;
260    info!("Sent prompt with event ID: {initial_prompt_task_id}");
261
262    // Run the loop until the task is complete.
263    while let Some(event) = rx.recv().await {
264        let shutdown: CodexStatus = event_processor.process_event(event);
265        match shutdown {
266            CodexStatus::Running => continue,
267            CodexStatus::InitiateShutdown => {
268                conversation.submit(Op::Shutdown).await?;
269            }
270            CodexStatus::Shutdown => {
271                break;
272            }
273        }
274    }
275
276    Ok(())
277}