Files
codex/codex-rs/cli/src/main.rs
Ahmed Ibrahim e352f37940 fix
2025-09-30 15:09:26 -07:00

746 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use clap::CommandFactory;
use clap::Parser;
use clap::error::Error as ClapError;
use clap::error::ErrorKind as ClapErrorKind;
use clap_complete::Shell;
use clap_complete::generate;
use codex_arg0::arg0_dispatch_or_else;
use codex_chatgpt::apply_command::ApplyCommand;
use codex_chatgpt::apply_command::run_apply_command;
use codex_cli::LandlockCommand;
use codex_cli::SeatbeltCommand;
use codex_cli::login::run_login_status;
use codex_cli::login::run_login_with_api_key;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_login_with_device_code;
use codex_cli::login::run_logout;
use codex_cli::proto;
use codex_cloud_tasks::Cli as CloudTasksCli;
use codex_common::CliConfigOverrides;
use codex_exec::Cli as ExecCli;
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
use owo_colors::OwoColorize;
use std::path::PathBuf;
use supports_color::Stream;
use uuid::Uuid;
mod mcp_cmd;
use crate::mcp_cmd::McpCli;
use crate::proto::ProtoCli;
/// Codex CLI
///
/// If no subcommand is specified, options will be forwarded to the interactive CLI.
#[derive(Debug, Parser)]
#[clap(
author,
version,
// If a subcommand is given, ignore requirements of the default args.
subcommand_negates_reqs = true,
// The executable is sometimes invoked via a platformspecific name like
// `codex-x86_64-unknown-linux-musl`, but the help output should always use
// the generic `codex` command name that users run.
bin_name = "codex"
)]
struct MultitoolCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[clap(flatten)]
interactive: TuiCli,
#[clap(subcommand)]
subcommand: Option<Subcommand>,
}
#[derive(Debug, clap::Subcommand)]
enum Subcommand {
/// Run Codex non-interactively.
#[clap(visible_alias = "e")]
Exec(ExecCli),
/// Manage login.
Login(LoginCommand),
/// Remove stored authentication credentials.
Logout(LogoutCommand),
/// [experimental] Run Codex as an MCP server and manage MCP servers.
Mcp(McpCli),
/// [experimental] Run the Codex MCP server (stdio transport).
McpServer,
/// [experimental] Run the app server.
AppServer,
/// Run the Protocol stream via stdin/stdout
#[clap(visible_alias = "p")]
Proto(ProtoCli),
/// Generate shell completion scripts.
Completion(CompletionCommand),
/// Internal debugging commands.
Debug(DebugArgs),
/// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree.
#[clap(visible_alias = "a")]
Apply(ApplyCommand),
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
Resume(ResumeCommand),
/// Internal: generate TypeScript protocol bindings.
#[clap(hide = true)]
GenerateTs(GenerateTsCommand),
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
#[clap(name = "cloud", alias = "cloud-tasks")]
Cloud(CloudTasksCli),
/// Internal: run the responses API proxy.
#[clap(hide = true)]
ResponsesApiProxy(ResponsesApiProxyArgs),
}
#[derive(Debug, Parser)]
struct CompletionCommand {
/// Shell to generate completions for
#[clap(value_enum, default_value_t = Shell::Bash)]
shell: Shell,
}
#[derive(Debug, Parser)]
struct ResumeCommand {
#[clap(flatten)]
config_overrides: TuiCli,
/// Continue the most recent session without showing the picker.
#[arg(long = "last", default_value_t = false)]
last: bool,
/// Conversation/session id (UUID). When provided, resumes this session.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID", index = 2)]
session_id: Option<String>,
}
#[derive(Debug, Parser)]
struct DebugArgs {
#[command(subcommand)]
cmd: DebugCommand,
}
#[derive(Debug, clap::Subcommand)]
enum DebugCommand {
/// Run a command under Seatbelt (macOS only).
Seatbelt(SeatbeltCommand),
/// Run a command under Landlock+seccomp (Linux only).
Landlock(LandlockCommand),
}
#[derive(Debug, Parser)]
struct LoginCommand {
#[clap(skip)]
config_overrides: CliConfigOverrides,
#[arg(long = "api-key", value_name = "API_KEY")]
api_key: Option<String>,
/// EXPERIMENTAL: Use device code flow (not yet supported)
/// This feature is experimental and may changed in future releases.
#[arg(long = "experimental_use-device-code", hide = true)]
use_device_code: bool,
/// EXPERIMENTAL: Use custom OAuth issuer base URL (advanced)
/// Override the OAuth issuer base URL (advanced)
#[arg(long = "experimental_issuer", value_name = "URL", hide = true)]
issuer_base_url: Option<String>,
/// EXPERIMENTAL: Use custom OAuth client ID (advanced)
#[arg(long = "experimental_client-id", value_name = "CLIENT_ID", hide = true)]
client_id: Option<String>,
#[command(subcommand)]
action: Option<LoginSubcommand>,
}
#[derive(Debug, clap::Subcommand)]
enum LoginSubcommand {
/// Show login status.
Status,
}
#[derive(Debug, Parser)]
struct LogoutCommand {
#[clap(skip)]
config_overrides: CliConfigOverrides,
}
#[derive(Debug, Parser)]
struct GenerateTsCommand {
/// Output directory where .ts files will be written
#[arg(short = 'o', long = "out", value_name = "DIR")]
out_dir: PathBuf,
/// Optional path to the Prettier executable to format generated files
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
prettier: Option<PathBuf>,
}
fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<String> {
let AppExitInfo {
token_usage,
conversation_id,
} = exit_info;
if token_usage.is_zero() {
return Vec::new();
}
let mut lines = vec![format!(
"{}",
codex_core::protocol::FinalOutput::from(token_usage)
)];
if let Some(session_id) = conversation_id {
let resume_cmd = format!("codex resume {session_id}");
let command = if color_enabled {
resume_cmd.cyan().to_string()
} else {
resume_cmd
};
lines.push(format!("To continue this session, run {command}"));
}
lines
}
fn print_exit_messages(exit_info: AppExitInfo) {
let color_enabled = supports_color::on(Stream::Stdout).is_some();
for line in format_exit_messages(exit_info, color_enabled) {
println!("{line}");
}
}
pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE";
/// As early as possible in the process lifecycle, apply hardening measures
/// if the CODEX_SECURE_MODE environment variable is set to "1".
#[ctor::ctor]
fn pre_main_hardening() {
let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) {
Ok(value) => value,
Err(_) => return,
};
if secure_mode == "1" {
codex_process_hardening::pre_main_hardening();
}
// Always clear this env var so child processes don't inherit it.
unsafe {
std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR);
}
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
cli_main(codex_linux_sandbox_exe).await?;
Ok(())
})
}
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
let MultitoolCli {
config_overrides: root_config_overrides,
mut interactive,
subcommand,
} = MultitoolCli::parse();
match subcommand {
None => {
prepend_config_flags(
&mut interactive.config_overrides,
root_config_overrides.clone(),
);
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
print_exit_messages(exit_info);
}
Some(Subcommand::Exec(mut exec_cli)) => {
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
);
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::McpServer) => {
codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
}
Some(Subcommand::Mcp(mut mcp_cli)) => {
// Propagate any root-level config overrides (e.g. `-c key=value`).
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::AppServer) => {
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
}
Some(Subcommand::Resume(mut resume_cmd)) => {
if let Err(err) = resume_cmd.normalize() {
err.exit();
}
let ResumeCommand {
config_overrides,
last,
session_id,
} = resume_cmd;
interactive = finalize_resume_interactive(
interactive,
root_config_overrides.clone(),
session_id,
last,
config_overrides,
);
codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::Login(mut login_cli)) => {
prepend_config_flags(
&mut login_cli.config_overrides,
root_config_overrides.clone(),
);
match login_cli.action {
Some(LoginSubcommand::Status) => {
run_login_status(login_cli.config_overrides).await;
}
None => {
if login_cli.use_device_code {
run_login_with_device_code(
login_cli.config_overrides,
login_cli.issuer_base_url,
login_cli.client_id,
)
.await;
} else if let Some(api_key) = login_cli.api_key {
run_login_with_api_key(login_cli.config_overrides, api_key).await;
} else {
run_login_with_chatgpt(login_cli.config_overrides).await;
}
}
}
}
Some(Subcommand::Logout(mut logout_cli)) => {
prepend_config_flags(
&mut logout_cli.config_overrides,
root_config_overrides.clone(),
);
run_logout(logout_cli.config_overrides).await;
}
Some(Subcommand::Proto(mut proto_cli)) => {
prepend_config_flags(
&mut proto_cli.config_overrides,
root_config_overrides.clone(),
);
proto::run_main(proto_cli).await?;
}
Some(Subcommand::Completion(completion_cli)) => {
print_completion(completion_cli);
}
Some(Subcommand::Cloud(mut cloud_cli)) => {
prepend_config_flags(
&mut cloud_cli.config_overrides,
root_config_overrides.clone(),
);
codex_cloud_tasks::run_main(cloud_cli, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
DebugCommand::Seatbelt(mut seatbelt_cli) => {
prepend_config_flags(
&mut seatbelt_cli.config_overrides,
root_config_overrides.clone(),
);
codex_cli::debug_sandbox::run_command_under_seatbelt(
seatbelt_cli,
codex_linux_sandbox_exe,
)
.await?;
}
DebugCommand::Landlock(mut landlock_cli) => {
prepend_config_flags(
&mut landlock_cli.config_overrides,
root_config_overrides.clone(),
);
codex_cli::debug_sandbox::run_command_under_landlock(
landlock_cli,
codex_linux_sandbox_exe,
)
.await?;
}
},
Some(Subcommand::Apply(mut apply_cli)) => {
prepend_config_flags(
&mut apply_cli.config_overrides,
root_config_overrides.clone(),
);
run_apply_command(apply_cli, None).await?;
}
Some(Subcommand::ResponsesApiProxy(args)) => {
tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args))
.await??;
}
Some(Subcommand::GenerateTs(gen_cli)) => {
codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?;
}
}
Ok(())
}
/// Prepend root-level overrides so they have lower precedence than
/// CLI-specific ones specified after the subcommand (if any).
fn prepend_config_flags(
subcommand_config_overrides: &mut CliConfigOverrides,
cli_config_overrides: CliConfigOverrides,
) {
subcommand_config_overrides
.raw_overrides
.splice(0..0, cli_config_overrides.raw_overrides);
}
/// Build the final `TuiCli` for a `codex resume` invocation.
fn finalize_resume_interactive(
mut interactive: TuiCli,
root_config_overrides: CliConfigOverrides,
session_id: Option<String>,
last: bool,
resume_cli: TuiCli,
) -> TuiCli {
// Start with the parsed interactive CLI so resume shares the same
// configuration surface area as `codex` without additional flags.
let resume_session_id = session_id;
interactive.resume_picker = resume_session_id.is_none() && !last;
interactive.resume_last = last;
interactive.resume_session_id = resume_session_id;
// Merge resume-scoped flags and overrides with highest precedence.
merge_resume_cli_flags(&mut interactive, resume_cli);
// Propagate any root-level config overrides (e.g. `-c key=value`).
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
interactive
}
/// Merge flags provided to `codex resume` so they take precedence over any
/// root-level flags. Only overrides fields explicitly set on the resume-scoped
/// CLI. Also appends `-c key=value` overrides with highest precedence.
fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) {
if let Some(model) = resume_cli.model {
interactive.model = Some(model);
}
if resume_cli.oss {
interactive.oss = true;
}
if let Some(profile) = resume_cli.config_profile {
interactive.config_profile = Some(profile);
}
if let Some(sandbox) = resume_cli.sandbox_mode {
interactive.sandbox_mode = Some(sandbox);
}
if let Some(approval) = resume_cli.approval_policy {
interactive.approval_policy = Some(approval);
}
if resume_cli.full_auto {
interactive.full_auto = true;
}
if resume_cli.dangerously_bypass_approvals_and_sandbox {
interactive.dangerously_bypass_approvals_and_sandbox = true;
}
if let Some(cwd) = resume_cli.cwd {
interactive.cwd = Some(cwd);
}
if resume_cli.web_search {
interactive.web_search = true;
}
if !resume_cli.images.is_empty() {
interactive.images = resume_cli.images;
}
if let Some(prompt) = resume_cli.prompt {
interactive.prompt = Some(prompt);
}
interactive
.config_overrides
.raw_overrides
.extend(resume_cli.config_overrides.raw_overrides);
}
fn print_completion(cmd: CompletionCommand) {
let mut app = MultitoolCli::command();
let name = "codex";
generate(cmd.shell, &mut app, name, &mut std::io::stdout());
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::protocol::TokenUsage;
use codex_protocol::mcp_protocol::ConversationId;
fn finalize_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
interactive,
config_overrides: root_overrides,
subcommand,
} = cli;
let mut resume_cmd = match subcommand.expect("resume present") {
Subcommand::Resume(cmd) => cmd,
_ => unreachable!(),
};
resume_cmd.normalize().expect("normalize resume args");
let ResumeCommand {
config_overrides: resume_cli,
last,
session_id,
} = resume_cmd;
finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli)
}
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
total_tokens: 2,
..Default::default()
};
AppExitInfo {
token_usage,
conversation_id: conversation
.map(ConversationId::from_string)
.map(Result::unwrap),
}
}
#[test]
fn format_exit_messages_skips_zero_usage() {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
};
let lines = format_exit_messages(exit_info, false);
assert!(lines.is_empty());
}
#[test]
fn format_exit_messages_includes_resume_hint_without_color() {
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
let lines = format_exit_messages(exit_info, false);
assert_eq!(
lines,
vec![
"Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000"
.to_string(),
]
);
}
#[test]
fn format_exit_messages_applies_color_when_enabled() {
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
let lines = format_exit_messages(exit_info, true);
assert_eq!(lines.len(), 2);
assert!(lines[1].contains("\u{1b}[36m"));
}
#[test]
fn resume_model_flag_applies_when_no_root_flags() {
let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5-test"].as_ref());
assert_eq!(interactive.model.as_deref(), Some("gpt-5-test"));
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_picker_logic_none_and_not_last() {
let interactive = finalize_from_args(["codex", "resume"].as_ref());
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_picker_logic_last() {
let interactive = finalize_from_args(["codex", "resume", "--last"].as_ref());
assert!(!interactive.resume_picker);
assert!(interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_last_accepts_follow_up_prompt() {
let interactive = finalize_from_args(["codex", "resume", "--last", "hi there"].as_ref());
assert!(interactive.resume_last);
assert_eq!(interactive.prompt.as_deref(), Some("hi there"));
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_prompt_before_session_id() {
let interactive = finalize_from_args(
[
"codex",
"resume",
"summarize progress",
"123e4567-e89b-12d3-a456-426614174000",
]
.as_ref(),
);
assert_eq!(interactive.prompt.as_deref(), Some("summarize progress"));
assert_eq!(
interactive.resume_session_id.as_deref(),
Some("123e4567-e89b-12d3-a456-426614174000"),
);
assert!(!interactive.resume_last);
assert!(!interactive.resume_picker);
}
#[test]
fn resume_picker_logic_with_session_id() {
let interactive = finalize_from_args(
["codex", "resume", "123e4567-e89b-12d3-a456-426614174000"].as_ref(),
);
assert!(!interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(
interactive.resume_session_id.as_deref(),
Some("123e4567-e89b-12d3-a456-426614174000")
);
}
#[test]
fn resume_merges_option_flags_and_full_auto() {
let interactive = finalize_from_args(
[
"codex",
"resume",
"123e4567-e89b-12d3-a456-426614174000",
"--oss",
"--full-auto",
"--search",
"--sandbox",
"workspace-write",
"--ask-for-approval",
"on-request",
"-m",
"gpt-5-test",
"-p",
"my-profile",
"-C",
"/tmp",
"-i",
"/tmp/a.png,/tmp/b.png",
]
.as_ref(),
);
assert_eq!(interactive.model.as_deref(), Some("gpt-5-test"));
assert!(interactive.oss);
assert_eq!(interactive.config_profile.as_deref(), Some("my-profile"));
assert!(matches!(
interactive.sandbox_mode,
Some(codex_common::SandboxModeCliArg::WorkspaceWrite)
));
assert!(matches!(
interactive.approval_policy,
Some(codex_common::ApprovalModeCliArg::OnRequest)
));
assert!(interactive.full_auto);
assert_eq!(
interactive.cwd.as_deref(),
Some(std::path::Path::new("/tmp"))
);
assert!(interactive.web_search);
let has_a = interactive
.images
.iter()
.any(|p| p == std::path::Path::new("/tmp/a.png"));
let has_b = interactive
.images
.iter()
.any(|p| p == std::path::Path::new("/tmp/b.png"));
assert!(has_a && has_b);
assert!(!interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(
interactive.resume_session_id.as_deref(),
Some("123e4567-e89b-12d3-a456-426614174000")
);
}
#[test]
fn resume_merges_dangerously_bypass_flag() {
let interactive = finalize_from_args(
[
"codex",
"resume",
"--dangerously-bypass-approvals-and-sandbox",
]
.as_ref(),
);
assert!(interactive.dangerously_bypass_approvals_and_sandbox);
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
}
}
impl ResumeCommand {
fn normalize(&mut self) -> Result<(), ClapError> {
if self.last {
if let Some(value) = self.session_id.take() {
if Self::looks_like_session_id(&value) {
return Err(ClapError::raw(
ClapErrorKind::ArgumentConflict,
"The argument '--last' cannot be used with '[SESSION_ID]'",
));
}
if let Some(existing) = &mut self.config_overrides.prompt {
if !existing.is_empty() {
existing.push(' ');
}
existing.push_str(&value);
} else {
self.config_overrides.prompt = Some(value);
}
}
return Ok(());
}
if self.session_id.is_some() {
return Ok(());
}
if let Some(prompt) = self.config_overrides.prompt.take() {
if Self::looks_like_session_id(&prompt) {
self.session_id = Some(prompt);
} else {
self.config_overrides.prompt = Some(prompt);
}
}
Ok(())
}
fn looks_like_session_id(value: &str) -> bool {
Uuid::parse_str(value).is_ok()
}
}