diff --git a/README.md b/README.md index 3a78f965739..295733fb113 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [**Getting started**](./docs/getting-started.md) - [CLI usage](./docs/getting-started.md#cli-usage) + - [Managing multiple authentication profiles](./docs/getting-started.md#managing-multiple-authentication-profiles) - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input) - [Example prompts](./docs/getting-started.md#example-prompts) - [Custom prompts](./docs/prompts.md) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0ecada0fb3c..e97f5d27c17 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -971,6 +971,8 @@ dependencies = [ "codex-stdio-to-uds", "codex-tui", "ctor 0.5.0", + "dirs", + "libc", "owo-colors", "predicates", "pretty_assertions", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index d66678687b3..e44ea7e6fc9 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,6 +36,7 @@ codex-rmcp-client = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } +dirs = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 39935034121..5a4cfcf07f0 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,3 +1,6 @@ +use anyhow::Context; +use anyhow::anyhow; +use anyhow::bail; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; @@ -19,8 +22,10 @@ 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 dirs::home_dir; use codex_tui::UpdateAction; use owo_colors::OwoColorize; +use std::fs; use std::path::PathBuf; use supports_color::Stream; @@ -30,6 +35,10 @@ use crate::mcp_cmd::McpCli; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; +const AUTH_PROFILE_ENV_VAR: &str = "CODEX_ACTIVE_AUTH_PROFILE"; +const AUTH_PROFILE_DIR: &str = "profiles"; + /// Codex CLI /// /// If no subcommand is specified, options will be forwarded to the interactive CLI. @@ -49,6 +58,11 @@ struct MultitoolCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, + /// Authentication profile name. When provided, Codex keeps login state + /// isolated under `~/.codex/profiles/` (or the equivalent CODEX_HOME). + #[arg(long = "auth-profile", value_name = "PROFILE", global = true)] + auth_profile: Option, + #[clap(flatten)] pub feature_toggles: FeatureToggles, @@ -210,6 +224,97 @@ struct GenerateTsCommand { prettier: Option, } +fn apply_auth_profile(raw_profile: Option<&str>) -> anyhow::Result> { + let Some(profile) = raw_profile else { + // SAFETY: Removing an environment variable is safe; no strings are involved. + unsafe { + std::env::remove_var(AUTH_PROFILE_ENV_VAR); + } + return Ok(None); + }; + + let trimmed = profile.trim(); + if trimmed.is_empty() { + bail!("Authentication profile name cannot be empty"); + } + + let base = resolve_codex_home_base()?; + let slug = profile_slug(trimmed)?; + let profile_dir = base.join(AUTH_PROFILE_DIR).join(&slug); + + fs::create_dir_all(&profile_dir).with_context(|| { + format!( + "Failed to create auth profile directory at {}", + profile_dir.display() + ) + })?; + + let canonical_dir = profile_dir.canonicalize().unwrap_or(profile_dir.clone()); + + // SAFETY: `canonical_dir` is derived from a valid `PathBuf` and `trimmed` + // originates from CLI input which cannot contain interior NUL bytes. + unsafe { + std::env::set_var(CODEX_HOME_ENV_VAR, &canonical_dir); + std::env::set_var(AUTH_PROFILE_ENV_VAR, trimmed); + } + + Ok(Some(trimmed.to_string())) +} + +fn resolve_codex_home_base() -> anyhow::Result { + if let Some(existing) = std::env::var_os(CODEX_HOME_ENV_VAR) + && !existing.is_empty() + { + return Ok(PathBuf::from(existing)); + } + + let mut home = home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?; + home.push(".codex"); + Ok(home) +} + +fn profile_slug(input: &str) -> anyhow::Result { + let mut slug = String::new(); + let mut last_was_dash = true; + + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if !last_was_dash && !slug.is_empty() { + slug.push('-'); + last_was_dash = true; + } + } + + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + bail!("Authentication profile must include at least one alphanumeric character"); + } + Ok(slug) +} + +fn shell_quote(arg: &str) -> String { + if arg.is_empty() { + return "''".to_string(); + } + + if arg + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'-' | b'_')) + { + return arg.to_string(); + } + + let escaped = arg.replace('\'', "'\\''"); + format!("'{escaped}'") +} + +fn format_exit_messages( + exit_info: AppExitInfo, + color_enabled: bool, + auth_profile: Option<&str>, +) -> Vec { #[derive(Debug, Parser)] struct StdioToUdsCommand { /// Path to the Unix domain socket to connect to. @@ -234,7 +339,10 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec) { /// Handle the app exit and print the results. Optionally run the update action. fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { let update_action = exit_info.update_action; let color_enabled = supports_color::on(Stream::Stdout).is_some(); - for line in format_exit_messages(exit_info, color_enabled) { + for line in format_exit_messages(exit_info, color_enabled, auth_profile) { println!("{line}"); } if let Some(action) = update_action { @@ -339,11 +448,13 @@ fn main() -> anyhow::Result<()> { async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let MultitoolCli { config_overrides: mut root_config_overrides, + auth_profile, feature_toggles, mut interactive, subcommand, } = MultitoolCli::parse(); + let active_auth_profile = apply_auth_profile(auth_profile.as_deref())?; // Fold --enable/--disable into config overrides so they flow to all subcommands. root_config_overrides .raw_overrides @@ -356,6 +467,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + print_exit_messages(exit_info, active_auth_profile.as_deref()); handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { @@ -611,6 +723,7 @@ mod tests { let MultitoolCli { interactive, config_overrides: root_overrides, + auth_profile: _, subcommand, feature_toggles: _, } = cli; @@ -649,14 +762,14 @@ mod tests { conversation_id: None, update_action: None, }; - let lines = format_exit_messages(exit_info, false); + let lines = format_exit_messages(exit_info, false, None); 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); + let lines = format_exit_messages(exit_info, false, None); assert_eq!( lines, vec![ @@ -670,11 +783,44 @@ mod tests { #[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); + let lines = format_exit_messages(exit_info, true, None); assert_eq!(lines.len(), 2); assert!(lines[1].contains("\u{1b}[36m")); } + #[test] + fn format_exit_messages_includes_auth_profile_hint() { + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let lines = format_exit_messages(exit_info, false, Some("Personal")); + assert_eq!( + lines[1], + "To continue this session, run codex --auth-profile Personal resume 123e4567-e89b-12d3-a456-426614174000.".to_string() + ); + } + + #[test] + fn format_exit_messages_quotes_auth_profile_when_needed() { + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let lines = format_exit_messages(exit_info, false, Some("Personal Account")); + assert_eq!( + lines[1], + "To continue this session, run codex --auth-profile 'Personal Account' resume 123e4567-e89b-12d3-a456-426614174000.".to_string() + ); + } + + #[test] + fn profile_slug_normalizes_name() { + assert_eq!(profile_slug("Primary Account").unwrap(), "primary-account"); + assert_eq!(profile_slug("Admin--Team").unwrap(), "admin-team"); + assert_eq!(profile_slug("Data_Sandbox").unwrap(), "data-sandbox"); + } + + #[test] + fn profile_slug_rejects_invalid_input() { + assert!(profile_slug("!!!").is_err()); + assert!(profile_slug(" ").is_err()); + } + #[test] fn resume_model_flag_applies_when_no_root_flags() { let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5-test"].as_ref()); diff --git a/docs/getting-started.md b/docs/getting-started.md index 3473cf53193..20d70c3973f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -35,6 +35,48 @@ codex resume --last codex resume 7f9f9a2e-1b3c-4c7a-9b0e-123456789abc ``` +### Managing multiple authentication profiles + +If you log into Codex with more than one account, add `--auth-profile ` +to any `codex` invocation to isolate credentials and history per profile. The +CLI maps each profile to `~/.codex/profiles/`, where the slug is a +lowercase, hyphenated form of the name (for example, `"Work Account"` +becomes `work-account`). + +Using Codex without the flag keeps the legacy behaviour: state is stored in +`~/.codex` and shared across all runs. + +Examples: + +```sh +# Launch the TUI with a personal profile (stored at ~/.codex/profiles/personal) +codex --auth-profile Personal + +# Log in with a separate work account +codex --auth-profile "Work Account" login --api-key sk-... + +# Run non-interactive commands inside that profile +codex --auth-profile "Work Account" exec "npm test" + +# Resume a session saved under the same profile +codex --auth-profile "Work Account" resume --last +``` + +Exit messages now include the profile flag when one is active so you can +copy/paste the suggested command, for example: + +``` +To continue this session, run codex --auth-profile 'Work Account' resume . +``` + +When `--auth-profile` is set, Codex exports two environment variables before +doing any work: + +- `CODEX_HOME` points to the profile directory +- `CODEX_ACTIVE_AUTH_PROFILE` matches the name you passed on the command line + +Both variables revert to their defaults when the flag is omitted. + ### Running with a prompt as input You can also run Codex CLI with a prompt as input: