Skip to content
Closed
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
156 changes: 151 additions & 5 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use anyhow::Context;
use anyhow::anyhow;
use anyhow::bail;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
Expand All @@ -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;

Expand All @@ -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.
Expand All @@ -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/<profile>` (or the equivalent CODEX_HOME).
#[arg(long = "auth-profile", value_name = "PROFILE", global = true)]
auth_profile: Option<String>,

#[clap(flatten)]
pub feature_toggles: FeatureToggles,

Expand Down Expand Up @@ -210,6 +224,97 @@ struct GenerateTsCommand {
prettier: Option<PathBuf>,
}

fn apply_auth_profile(raw_profile: Option<&str>) -> anyhow::Result<Option<String>> {
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<PathBuf> {
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<String> {
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<String> {
#[derive(Debug, Parser)]
struct StdioToUdsCommand {
/// Path to the Unix domain socket to connect to.
Expand All @@ -234,7 +339,10 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
)];

if let Some(session_id) = conversation_id {
let resume_cmd = format!("codex resume {session_id}");
let profile_segment = auth_profile
.map(|profile| format!(" --auth-profile {}", shell_quote(profile)))
.unwrap_or_default();
let resume_cmd = format!("codex{profile_segment} resume {session_id}");
let command = if color_enabled {
resume_cmd.cyan().to_string()
} else {
Expand All @@ -246,11 +354,12 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
lines
}

fn print_exit_messages(exit_info: AppExitInfo, auth_profile: Option<&str>) {
/// 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 {
Expand Down Expand Up @@ -339,11 +448,13 @@ fn main() -> anyhow::Result<()> {
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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
Expand All @@ -356,6 +467,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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)) => {
Expand Down Expand Up @@ -611,6 +723,7 @@ mod tests {
let MultitoolCli {
interactive,
config_overrides: root_overrides,
auth_profile: _,
subcommand,
feature_toggles: _,
} = cli;
Expand Down Expand Up @@ -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![
Expand All @@ -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());
Expand Down
42 changes: 42 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NAME>`
to any `codex` invocation to isolate credentials and history per profile. The
CLI maps each profile to `~/.codex/profiles/<slug>`, 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 <SESSION_ID>.
```

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:
Expand Down