From e16657ca4545100676c6dd5902dc8179067ed55b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 17 Jul 2025 15:10:15 -0700 Subject: [PATCH] feat: add --json flag to `codex exec` (#1603) This is designed to facilitate programmatic use of Codex in a more lightweight way than using `codex mcp`. Passing `--json` to `codex exec` will print each event as a line of JSON to stdout. Note that it does not print the individual tokens as they are streamed, only full messages, as this is aimed at programmatic use rather than to power UI. image I changed the existing `EventProcessor` into a trait and moved the implementation to `EventProcessorWithHumanOutput`. Then I introduced an alternative implementation, `EventProcessorWithJsonOutput`. The `--json` flag determines which implementation to use. --- codex-rs/exec/src/cli.rs | 4 + codex-rs/exec/src/event_processor.rs | 552 +----------------- .../src/event_processor_with_human_output.rs | 520 +++++++++++++++++ .../src/event_processor_with_json_output.rs | 48 ++ codex-rs/exec/src/lib.rs | 18 +- 5 files changed, 613 insertions(+), 529 deletions(-) create mode 100644 codex-rs/exec/src/event_processor_with_human_output.rs create mode 100644 codex-rs/exec/src/event_processor_with_json_output.rs diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 613fedf0..53af25c7 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -51,6 +51,10 @@ pub struct Cli { #[arg(long = "color", value_enum, default_value_t = Color::Auto)] pub color: Color, + /// Print events to stdout as JSONL. + #[arg(long = "json", default_value_t = false)] + pub json: bool, + /// Specifies file where the last message from the agent should be written. #[arg(long = "output-last-message")] pub last_message_file: Option, diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 5ab09994..56db651a 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,539 +1,37 @@ -use codex_common::elapsed::format_elapsed; use codex_common::summarize_sandbox_policy; use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol::TokenUsage; -use owo_colors::OwoColorize; -use owo_colors::Style; -use shlex::try_join; -use std::collections::HashMap; -use std::io::Write; -use std::time::Instant; -/// This should be configurable. When used in CI, users may not want to impose -/// a limit so they can see the full transcript. -const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20; +pub(crate) trait EventProcessor { + /// Print summary of effective configuration and user prompt. + fn print_config_summary(&mut self, config: &Config, prompt: &str); -pub(crate) struct EventProcessor { - call_id_to_command: HashMap, - call_id_to_patch: HashMap, - - /// Tracks in-flight MCP tool calls so we can calculate duration and print - /// a concise summary when the corresponding `McpToolCallEnd` event is - /// received. - call_id_to_tool_call: HashMap, - - // To ensure that --color=never is respected, ANSI escapes _must_ be added - // using .style() with one of these fields. If you need a new style, add a - // new field here. - bold: Style, - italic: Style, - dimmed: Style, - - magenta: Style, - red: Style, - green: Style, - cyan: Style, - - /// Whether to include `AgentReasoning` events in the output. - show_agent_reasoning: bool, - answer_started: bool, - reasoning_started: bool, + /// Handle a single event emitted by the agent. + fn process_event(&mut self, event: Event); } -impl EventProcessor { - pub(crate) fn create_with_ansi(with_ansi: bool, config: &Config) -> Self { - let call_id_to_command = HashMap::new(); - let call_id_to_patch = HashMap::new(); - let call_id_to_tool_call = HashMap::new(); - - if with_ansi { - Self { - call_id_to_command, - call_id_to_patch, - bold: Style::new().bold(), - italic: Style::new().italic(), - dimmed: Style::new().dimmed(), - magenta: Style::new().magenta(), - red: Style::new().red(), - green: Style::new().green(), - cyan: Style::new().cyan(), - call_id_to_tool_call, - show_agent_reasoning: !config.hide_agent_reasoning, - answer_started: false, - reasoning_started: false, - } - } else { - Self { - call_id_to_command, - call_id_to_patch, - bold: Style::new(), - italic: Style::new(), - dimmed: Style::new(), - magenta: Style::new(), - red: Style::new(), - green: Style::new(), - cyan: Style::new(), - call_id_to_tool_call, - show_agent_reasoning: !config.hide_agent_reasoning, - answer_started: false, - reasoning_started: false, - } - } - } -} - -struct ExecCommandBegin { - command: Vec, - start_time: Instant, -} - -/// Metadata captured when an `McpToolCallBegin` event is received. -struct McpToolCallBegin { - /// Formatted invocation string, e.g. `server.tool({"city":"sf"})`. - invocation: String, - /// Timestamp when the call started so we can compute duration later. - start_time: Instant, -} - -struct PatchApplyBegin { - start_time: Instant, - auto_approved: bool, -} - -// Timestamped println helper. The timestamp is styled with self.dimmed. -#[macro_export] -macro_rules! ts_println { - ($self:ident, $($arg:tt)*) => {{ - let now = chrono::Utc::now(); - let formatted = now.format("[%Y-%m-%dT%H:%M:%S]"); - print!("{} ", formatted.style($self.dimmed)); - println!($($arg)*); - }}; -} - -impl EventProcessor { - /// Print a concise summary of the effective configuration that will be used - /// for the session. This mirrors the information shown in the TUI welcome - /// screen. - pub(crate) fn print_config_summary(&mut self, config: &Config, prompt: &str) { - const VERSION: &str = env!("CARGO_PKG_VERSION"); - ts_println!( - self, - "OpenAI Codex v{} (research preview)\n--------", - VERSION - ); - - let mut entries = vec![ - ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), - ("provider", config.model_provider_id.clone()), - ("approval", format!("{:?}", config.approval_policy)), - ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), - ]; - if config.model_provider.wire_api == WireApi::Responses - && model_supports_reasoning_summaries(config) - { - entries.push(( - "reasoning effort", - config.model_reasoning_effort.to_string(), - )); - entries.push(( - "reasoning summaries", - config.model_reasoning_summary.to_string(), - )); - } - - for (key, value) in entries { - println!("{} {}", format!("{key}:").style(self.bold), value); - } - - println!("--------"); - - // Echo the prompt that will be sent to the agent so it is visible in the - // transcript/logs before any events come in. Note the prompt may have been - // read from stdin, so it may not be visible in the terminal otherwise. - ts_println!( - self, - "{}\n{}", - "User instructions:".style(self.bold).style(self.cyan), - prompt - ); +pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { + let mut entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", config.model.clone()), + ("provider", config.model_provider_id.clone()), + ("approval", format!("{:?}", config.approval_policy)), + ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), + ]; + if config.model_provider.wire_api == WireApi::Responses + && model_supports_reasoning_summaries(config) + { + entries.push(( + "reasoning effort", + config.model_reasoning_effort.to_string(), + )); + entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); } - pub(crate) fn process_event(&mut self, event: Event) { - let Event { id: _, msg } = event; - match msg { - EventMsg::Error(ErrorEvent { message }) => { - let prefix = "ERROR:".style(self.red); - ts_println!(self, "{prefix} {message}"); - } - EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { - ts_println!(self, "{}", message.style(self.dimmed)); - } - EventMsg::TaskStarted | EventMsg::TaskComplete(_) => { - // Ignore. - } - EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { - ts_println!(self, "tokens used: {total_tokens}"); - } - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - if !self.answer_started { - ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta)); - self.answer_started = true; - } - print!("{delta}"); - #[allow(clippy::expect_used)] - std::io::stdout().flush().expect("could not flush stdout"); - } - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { - if !self.show_agent_reasoning { - return; - } - if !self.reasoning_started { - ts_println!( - self, - "{}\n", - "thinking".style(self.italic).style(self.magenta), - ); - self.reasoning_started = true; - } - print!("{delta}"); - #[allow(clippy::expect_used)] - std::io::stdout().flush().expect("could not flush stdout"); - } - EventMsg::AgentMessage(AgentMessageEvent { message }) => { - // if answer_started is false, this means we haven't received any - // delta. Thus, we need to print the message as a new answer. - if !self.answer_started { - ts_println!( - self, - "{}\n{}", - "codex".style(self.italic).style(self.magenta), - message, - ); - } else { - println!(); - self.answer_started = false; - } - } - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id, - command, - cwd, - }) => { - self.call_id_to_command.insert( - call_id.clone(), - ExecCommandBegin { - command: command.clone(), - start_time: Instant::now(), - }, - ); - ts_println!( - self, - "{} {} in {}", - "exec".style(self.magenta), - escape_command(&command).style(self.bold), - cwd.to_string_lossy(), - ); - } - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, - stdout, - stderr, - exit_code, - }) => { - let exec_command = self.call_id_to_command.remove(&call_id); - let (duration, call) = if let Some(ExecCommandBegin { - command, - start_time, - }) = exec_command - { - ( - format!(" in {}", format_elapsed(start_time)), - format!("{}", escape_command(&command).style(self.bold)), - ) - } else { - ("".to_string(), format!("exec('{call_id}')")) - }; - - let output = if exit_code == 0 { stdout } else { stderr }; - let truncated_output = output - .lines() - .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) - .collect::>() - .join("\n"); - match exit_code { - 0 => { - let title = format!("{call} succeeded{duration}:"); - ts_println!(self, "{}", title.style(self.green)); - } - _ => { - let title = format!("{call} exited {exit_code}{duration}:"); - ts_println!(self, "{}", title.style(self.red)); - } - } - println!("{}", truncated_output.style(self.dimmed)); - } - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id, - server, - tool, - arguments, - }) => { - // Build fully-qualified tool name: server.tool - let fq_tool_name = format!("{server}.{tool}"); - - // Format arguments as compact JSON so they fit on one line. - let args_str = arguments - .as_ref() - .map(|v: &serde_json::Value| { - serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) - }) - .unwrap_or_default(); - - let invocation = if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - }; - - self.call_id_to_tool_call.insert( - call_id.clone(), - McpToolCallBegin { - invocation: invocation.clone(), - start_time: Instant::now(), - }, - ); - - ts_println!( - self, - "{} {}", - "tool".style(self.magenta), - invocation.style(self.bold), - ); - } - EventMsg::McpToolCallEnd(tool_call_end_event) => { - let is_success = tool_call_end_event.is_success(); - let McpToolCallEndEvent { call_id, result } = tool_call_end_event; - // Retrieve start time and invocation for duration calculation and labeling. - let info = self.call_id_to_tool_call.remove(&call_id); - - let (duration, invocation) = if let Some(McpToolCallBegin { - invocation, - start_time, - .. - }) = info - { - (format!(" in {}", format_elapsed(start_time)), invocation) - } else { - (String::new(), format!("tool('{call_id}')")) - }; - - let status_str = if is_success { "success" } else { "failed" }; - let title_style = if is_success { self.green } else { self.red }; - let title = format!("{invocation} {status_str}{duration}:"); - - ts_println!(self, "{}", title.style(title_style)); - - if let Ok(res) = result { - let val: serde_json::Value = res.into(); - let pretty = - serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); - - for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { - println!("{}", line.style(self.dimmed)); - } - } - } - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved, - changes, - }) => { - // Store metadata so we can calculate duration later when we - // receive the corresponding PatchApplyEnd event. - self.call_id_to_patch.insert( - call_id.clone(), - PatchApplyBegin { - start_time: Instant::now(), - auto_approved, - }, - ); - - ts_println!( - self, - "{} auto_approved={}:", - "apply_patch".style(self.magenta), - auto_approved, - ); - - // Pretty-print the patch summary with colored diff markers so - // it's easy to scan in the terminal output. - for (path, change) in changes.iter() { - match change { - FileChange::Add { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - println!("{}", header.style(self.magenta)); - for line in content.lines() { - println!("{}", line.style(self.green)); - } - } - FileChange::Delete => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - println!("{}", header.style(self.magenta)); - } - FileChange::Update { - unified_diff, - move_path, - } => { - let header = if let Some(dest) = move_path { - format!( - "{} {} -> {}", - format_file_change(change), - path.to_string_lossy(), - dest.to_string_lossy() - ) - } else { - format!("{} {}", format_file_change(change), path.to_string_lossy()) - }; - println!("{}", header.style(self.magenta)); - - // Colorize diff lines. We keep file header lines - // (--- / +++) without extra coloring so they are - // still readable. - for diff_line in unified_diff.lines() { - if diff_line.starts_with('+') && !diff_line.starts_with("+++") { - println!("{}", diff_line.style(self.green)); - } else if diff_line.starts_with('-') - && !diff_line.starts_with("---") - { - println!("{}", diff_line.style(self.red)); - } else { - println!("{diff_line}"); - } - } - } - } - } - } - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id, - stdout, - stderr, - success, - }) => { - let patch_begin = self.call_id_to_patch.remove(&call_id); - - // Compute duration and summary label similar to exec commands. - let (duration, label) = if let Some(PatchApplyBegin { - start_time, - auto_approved, - }) = patch_begin - { - ( - format!(" in {}", format_elapsed(start_time)), - format!("apply_patch(auto_approved={auto_approved})"), - ) - } else { - (String::new(), format!("apply_patch('{call_id}')")) - }; - - let (exit_code, output, title_style) = if success { - (0, stdout, self.green) - } else { - (1, stderr, self.red) - }; - - let title = format!("{label} exited {exit_code}{duration}:"); - ts_println!(self, "{}", title.style(title_style)); - for line in output.lines() { - println!("{}", line.style(self.dimmed)); - } - } - EventMsg::ExecApprovalRequest(_) => { - // Should we exit? - } - EventMsg::ApplyPatchApprovalRequest(_) => { - // Should we exit? - } - EventMsg::AgentReasoning(agent_reasoning_event) => { - if self.show_agent_reasoning { - if !self.reasoning_started { - ts_println!( - self, - "{}\n{}", - "codex".style(self.italic).style(self.magenta), - agent_reasoning_event.text, - ); - } else { - println!(); - self.reasoning_started = false; - } - } - } - EventMsg::SessionConfigured(session_configured_event) => { - let SessionConfiguredEvent { - session_id, - model, - history_log_id: _, - history_entry_count: _, - } = session_configured_event; - - ts_println!( - self, - "{} {}", - "codex session".style(self.magenta).style(self.bold), - session_id.to_string().style(self.dimmed) - ); - - ts_println!(self, "model: {}", model); - println!(); - } - EventMsg::GetHistoryEntryResponse(_) => { - // Currently ignored in exec output. - } - } - } -} - -fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")) -} - -fn format_file_change(change: &FileChange) -> &'static str { - match change { - FileChange::Add { .. } => "A", - FileChange::Delete => "D", - FileChange::Update { - move_path: Some(_), .. - } => "R", - FileChange::Update { - move_path: None, .. - } => "M", - } + entries } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs new file mode 100644 index 00000000..7b390711 --- /dev/null +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -0,0 +1,520 @@ +use codex_common::elapsed::format_elapsed; +use codex_core::config::Config; +use codex_core::protocol::AgentMessageDeltaEvent; +use codex_core::protocol::AgentMessageEvent; +use codex_core::protocol::AgentReasoningDeltaEvent; +use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::FileChange; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; +use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::TokenUsage; +use owo_colors::OwoColorize; +use owo_colors::Style; +use shlex::try_join; +use std::collections::HashMap; +use std::io::Write; +use std::time::Instant; + +use crate::event_processor::EventProcessor; +use crate::event_processor::create_config_summary_entries; + +/// This should be configurable. When used in CI, users may not want to impose +/// a limit so they can see the full transcript. +const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20; +pub(crate) struct EventProcessorWithHumanOutput { + call_id_to_command: HashMap, + call_id_to_patch: HashMap, + + /// Tracks in-flight MCP tool calls so we can calculate duration and print + /// a concise summary when the corresponding `McpToolCallEnd` event is + /// received. + call_id_to_tool_call: HashMap, + + // To ensure that --color=never is respected, ANSI escapes _must_ be added + // using .style() with one of these fields. If you need a new style, add a + // new field here. + bold: Style, + italic: Style, + dimmed: Style, + + magenta: Style, + red: Style, + green: Style, + cyan: Style, + + /// Whether to include `AgentReasoning` events in the output. + show_agent_reasoning: bool, + answer_started: bool, + reasoning_started: bool, +} + +impl EventProcessorWithHumanOutput { + pub(crate) fn create_with_ansi(with_ansi: bool, config: &Config) -> Self { + let call_id_to_command = HashMap::new(); + let call_id_to_patch = HashMap::new(); + let call_id_to_tool_call = HashMap::new(); + + if with_ansi { + Self { + call_id_to_command, + call_id_to_patch, + bold: Style::new().bold(), + italic: Style::new().italic(), + dimmed: Style::new().dimmed(), + magenta: Style::new().magenta(), + red: Style::new().red(), + green: Style::new().green(), + cyan: Style::new().cyan(), + call_id_to_tool_call, + show_agent_reasoning: !config.hide_agent_reasoning, + answer_started: false, + reasoning_started: false, + } + } else { + Self { + call_id_to_command, + call_id_to_patch, + bold: Style::new(), + italic: Style::new(), + dimmed: Style::new(), + magenta: Style::new(), + red: Style::new(), + green: Style::new(), + cyan: Style::new(), + call_id_to_tool_call, + show_agent_reasoning: !config.hide_agent_reasoning, + answer_started: false, + reasoning_started: false, + } + } + } +} + +struct ExecCommandBegin { + command: Vec, + start_time: Instant, +} + +/// Metadata captured when an `McpToolCallBegin` event is received. +struct McpToolCallBegin { + /// Formatted invocation string, e.g. `server.tool({"city":"sf"})`. + invocation: String, + /// Timestamp when the call started so we can compute duration later. + start_time: Instant, +} + +struct PatchApplyBegin { + start_time: Instant, + auto_approved: bool, +} + +// Timestamped println helper. The timestamp is styled with self.dimmed. +#[macro_export] +macro_rules! ts_println { + ($self:ident, $($arg:tt)*) => {{ + let now = chrono::Utc::now(); + let formatted = now.format("[%Y-%m-%dT%H:%M:%S]"); + print!("{} ", formatted.style($self.dimmed)); + println!($($arg)*); + }}; +} + +impl EventProcessor for EventProcessorWithHumanOutput { + /// Print a concise summary of the effective configuration that will be used + /// for the session. This mirrors the information shown in the TUI welcome + /// screen. + fn print_config_summary(&mut self, config: &Config, prompt: &str) { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + ts_println!( + self, + "OpenAI Codex v{} (research preview)\n--------", + VERSION + ); + + let entries = create_config_summary_entries(config); + + for (key, value) in entries { + println!("{} {}", format!("{key}:").style(self.bold), value); + } + + println!("--------"); + + // Echo the prompt that will be sent to the agent so it is visible in the + // transcript/logs before any events come in. Note the prompt may have been + // read from stdin, so it may not be visible in the terminal otherwise. + ts_println!( + self, + "{}\n{}", + "User instructions:".style(self.bold).style(self.cyan), + prompt + ); + } + + fn process_event(&mut self, event: Event) { + let Event { id: _, msg } = event; + match msg { + EventMsg::Error(ErrorEvent { message }) => { + let prefix = "ERROR:".style(self.red); + ts_println!(self, "{prefix} {message}"); + } + EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { + ts_println!(self, "{}", message.style(self.dimmed)); + } + EventMsg::TaskStarted | EventMsg::TaskComplete(_) => { + // Ignore. + } + EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { + ts_println!(self, "tokens used: {total_tokens}"); + } + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { + if !self.answer_started { + ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta)); + self.answer_started = true; + } + print!("{delta}"); + #[allow(clippy::expect_used)] + std::io::stdout().flush().expect("could not flush stdout"); + } + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { + if !self.show_agent_reasoning { + return; + } + if !self.reasoning_started { + ts_println!( + self, + "{}\n", + "thinking".style(self.italic).style(self.magenta), + ); + self.reasoning_started = true; + } + print!("{delta}"); + #[allow(clippy::expect_used)] + std::io::stdout().flush().expect("could not flush stdout"); + } + EventMsg::AgentMessage(AgentMessageEvent { message }) => { + // if answer_started is false, this means we haven't received any + // delta. Thus, we need to print the message as a new answer. + if !self.answer_started { + ts_println!( + self, + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + message, + ); + } else { + println!(); + self.answer_started = false; + } + } + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id, + command, + cwd, + }) => { + self.call_id_to_command.insert( + call_id.clone(), + ExecCommandBegin { + command: command.clone(), + start_time: Instant::now(), + }, + ); + ts_println!( + self, + "{} {} in {}", + "exec".style(self.magenta), + escape_command(&command).style(self.bold), + cwd.to_string_lossy(), + ); + } + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + stdout, + stderr, + exit_code, + }) => { + let exec_command = self.call_id_to_command.remove(&call_id); + let (duration, call) = if let Some(ExecCommandBegin { + command, + start_time, + }) = exec_command + { + ( + format!(" in {}", format_elapsed(start_time)), + format!("{}", escape_command(&command).style(self.bold)), + ) + } else { + ("".to_string(), format!("exec('{call_id}')")) + }; + + let output = if exit_code == 0 { stdout } else { stderr }; + let truncated_output = output + .lines() + .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) + .collect::>() + .join("\n"); + match exit_code { + 0 => { + let title = format!("{call} succeeded{duration}:"); + ts_println!(self, "{}", title.style(self.green)); + } + _ => { + let title = format!("{call} exited {exit_code}{duration}:"); + ts_println!(self, "{}", title.style(self.red)); + } + } + println!("{}", truncated_output.style(self.dimmed)); + } + EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id, + server, + tool, + arguments, + }) => { + // Build fully-qualified tool name: server.tool + let fq_tool_name = format!("{server}.{tool}"); + + // Format arguments as compact JSON so they fit on one line. + let args_str = arguments + .as_ref() + .map(|v: &serde_json::Value| { + serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) + }) + .unwrap_or_default(); + + let invocation = if args_str.is_empty() { + format!("{fq_tool_name}()") + } else { + format!("{fq_tool_name}({args_str})") + }; + + self.call_id_to_tool_call.insert( + call_id.clone(), + McpToolCallBegin { + invocation: invocation.clone(), + start_time: Instant::now(), + }, + ); + + ts_println!( + self, + "{} {}", + "tool".style(self.magenta), + invocation.style(self.bold), + ); + } + EventMsg::McpToolCallEnd(tool_call_end_event) => { + let is_success = tool_call_end_event.is_success(); + let McpToolCallEndEvent { call_id, result } = tool_call_end_event; + // Retrieve start time and invocation for duration calculation and labeling. + let info = self.call_id_to_tool_call.remove(&call_id); + + let (duration, invocation) = if let Some(McpToolCallBegin { + invocation, + start_time, + .. + }) = info + { + (format!(" in {}", format_elapsed(start_time)), invocation) + } else { + (String::new(), format!("tool('{call_id}')")) + }; + + let status_str = if is_success { "success" } else { "failed" }; + let title_style = if is_success { self.green } else { self.red }; + let title = format!("{invocation} {status_str}{duration}:"); + + ts_println!(self, "{}", title.style(title_style)); + + if let Ok(res) = result { + let val: serde_json::Value = res.into(); + let pretty = + serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); + + for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { + println!("{}", line.style(self.dimmed)); + } + } + } + EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id, + auto_approved, + changes, + }) => { + // Store metadata so we can calculate duration later when we + // receive the corresponding PatchApplyEnd event. + self.call_id_to_patch.insert( + call_id.clone(), + PatchApplyBegin { + start_time: Instant::now(), + auto_approved, + }, + ); + + ts_println!( + self, + "{} auto_approved={}:", + "apply_patch".style(self.magenta), + auto_approved, + ); + + // Pretty-print the patch summary with colored diff markers so + // it's easy to scan in the terminal output. + for (path, change) in changes.iter() { + match change { + FileChange::Add { content } => { + let header = format!( + "{} {}", + format_file_change(change), + path.to_string_lossy() + ); + println!("{}", header.style(self.magenta)); + for line in content.lines() { + println!("{}", line.style(self.green)); + } + } + FileChange::Delete => { + let header = format!( + "{} {}", + format_file_change(change), + path.to_string_lossy() + ); + println!("{}", header.style(self.magenta)); + } + FileChange::Update { + unified_diff, + move_path, + } => { + let header = if let Some(dest) = move_path { + format!( + "{} {} -> {}", + format_file_change(change), + path.to_string_lossy(), + dest.to_string_lossy() + ) + } else { + format!("{} {}", format_file_change(change), path.to_string_lossy()) + }; + println!("{}", header.style(self.magenta)); + + // Colorize diff lines. We keep file header lines + // (--- / +++) without extra coloring so they are + // still readable. + for diff_line in unified_diff.lines() { + if diff_line.starts_with('+') && !diff_line.starts_with("+++") { + println!("{}", diff_line.style(self.green)); + } else if diff_line.starts_with('-') + && !diff_line.starts_with("---") + { + println!("{}", diff_line.style(self.red)); + } else { + println!("{diff_line}"); + } + } + } + } + } + } + EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id, + stdout, + stderr, + success, + }) => { + let patch_begin = self.call_id_to_patch.remove(&call_id); + + // Compute duration and summary label similar to exec commands. + let (duration, label) = if let Some(PatchApplyBegin { + start_time, + auto_approved, + }) = patch_begin + { + ( + format!(" in {}", format_elapsed(start_time)), + format!("apply_patch(auto_approved={auto_approved})"), + ) + } else { + (String::new(), format!("apply_patch('{call_id}')")) + }; + + let (exit_code, output, title_style) = if success { + (0, stdout, self.green) + } else { + (1, stderr, self.red) + }; + + let title = format!("{label} exited {exit_code}{duration}:"); + ts_println!(self, "{}", title.style(title_style)); + for line in output.lines() { + println!("{}", line.style(self.dimmed)); + } + } + EventMsg::ExecApprovalRequest(_) => { + // Should we exit? + } + EventMsg::ApplyPatchApprovalRequest(_) => { + // Should we exit? + } + EventMsg::AgentReasoning(agent_reasoning_event) => { + if self.show_agent_reasoning { + if !self.reasoning_started { + ts_println!( + self, + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + agent_reasoning_event.text, + ); + } else { + println!(); + self.reasoning_started = false; + } + } + } + EventMsg::SessionConfigured(session_configured_event) => { + let SessionConfiguredEvent { + session_id, + model, + history_log_id: _, + history_entry_count: _, + } = session_configured_event; + + ts_println!( + self, + "{} {}", + "codex session".style(self.magenta).style(self.bold), + session_id.to_string().style(self.dimmed) + ); + + ts_println!(self, "model: {}", model); + println!(); + } + EventMsg::GetHistoryEntryResponse(_) => { + // Currently ignored in exec output. + } + } + } +} + +fn escape_command(command: &[String]) -> String { + try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")) +} + +fn format_file_change(change: &FileChange) -> &'static str { + match change { + FileChange::Add { .. } => "A", + FileChange::Delete => "D", + FileChange::Update { + move_path: Some(_), .. + } => "R", + FileChange::Update { + move_path: None, .. + } => "M", + } +} diff --git a/codex-rs/exec/src/event_processor_with_json_output.rs b/codex-rs/exec/src/event_processor_with_json_output.rs new file mode 100644 index 00000000..699460bb --- /dev/null +++ b/codex-rs/exec/src/event_processor_with_json_output.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +use codex_core::config::Config; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use serde_json::json; + +use crate::event_processor::EventProcessor; +use crate::event_processor::create_config_summary_entries; + +pub(crate) struct EventProcessorWithJsonOutput; + +impl EventProcessorWithJsonOutput { + pub fn new() -> Self { + Self {} + } +} + +impl EventProcessor for EventProcessorWithJsonOutput { + fn print_config_summary(&mut self, config: &Config, prompt: &str) { + let entries = create_config_summary_entries(config) + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect::>(); + #[allow(clippy::expect_used)] + let config_json = + serde_json::to_string(&entries).expect("Failed to serialize config summary to JSON"); + println!("{config_json}"); + + let prompt_json = json!({ + "prompt": prompt, + }); + println!("{prompt_json}"); + } + + fn process_event(&mut self, event: Event) { + match event.msg { + EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => { + // Suppress streaming events in JSON mode. + } + _ => { + if let Ok(line) = serde_json::to_string(&event) { + println!("{line}"); + } + } + } + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index afefed1a..b557c893 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1,5 +1,7 @@ mod cli; mod event_processor; +mod event_processor_with_human_output; +mod event_processor_with_json_output; use std::io::IsTerminal; use std::io::Read; @@ -19,12 +21,15 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::TaskCompleteEvent; use codex_core::util::is_inside_git_repo; -use event_processor::EventProcessor; +use event_processor_with_human_output::EventProcessorWithHumanOutput; +use event_processor_with_json_output::EventProcessorWithJsonOutput; use tracing::debug; use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; +use crate::event_processor::EventProcessor; + pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let Cli { images, @@ -36,6 +41,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any skip_git_repo_check, color, last_message_file, + json: json_mode, sandbox_mode: sandbox_mode_cli_arg, prompt, config_overrides, @@ -115,7 +121,15 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any }; let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?; - let mut event_processor = EventProcessor::create_with_ansi(stdout_with_ansi, &config); + let mut event_processor: Box = if json_mode { + Box::new(EventProcessorWithJsonOutput::new()) + } else { + Box::new(EventProcessorWithHumanOutput::create_with_ansi( + stdout_with_ansi, + &config, + )) + }; + // Print the effective configuration and prompt so users can see what Codex // is using. event_processor.print_config_summary(&config, &prompt);