Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 144 additions & 1 deletion codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use anyhow::Context;
use anyhow::bail;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
Expand Down Expand Up @@ -29,6 +31,8 @@ mod mcp_cmd;
use crate::mcp_cmd::McpCli;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;

/// Codex CLI
///
Expand Down Expand Up @@ -111,6 +115,9 @@ enum Subcommand {

/// Inspect feature flags.
Features(FeaturesCli),

/// Inspect configuration profiles.
Profiles(ProfilesCli),
}

#[derive(Debug, Parser)]
Expand Down Expand Up @@ -310,6 +317,18 @@ enum FeaturesSubcommand {
List,
}

#[derive(Debug, Parser)]
struct ProfilesCli {
#[command(subcommand)]
sub: ProfilesSubcommand,
}

#[derive(Debug, Parser)]
enum ProfilesSubcommand {
/// List configuration profiles defined in config.toml.
List,
}

fn stage_str(stage: codex_core::features::Stage) -> &'static str {
use codex_core::features::Stage;
match stage {
Expand Down Expand Up @@ -482,6 +501,31 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::GenerateTs(gen_cli)) => {
codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?;
}
Some(Subcommand::Profiles(ProfilesCli { sub })) => match sub {
ProfilesSubcommand::List => {
let cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(|e| anyhow::anyhow!(e))?;

let codex_home =
find_codex_home().context("failed to resolve CODEX_HOME directory")?;
let cfg =
load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await?;

if let Some(selected) = interactive.config_profile.as_deref() {
if !cfg.profiles.contains_key(selected) {
bail!("config profile `{selected}` not found");
}
}

let mut profiles: Vec<_> = cfg.profiles.keys().cloned().collect();
profiles.sort();

for name in profiles {
println!("{name}");
}
}
},
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
FeaturesSubcommand::List => {
// Respect root-level `-c` overrides plus top-level flags like `--profile`.
Expand Down Expand Up @@ -596,7 +640,106 @@ fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) {
fn print_completion(cmd: CompletionCommand) {
let mut app = MultitoolCli::command();
let name = "codex";
generate(cmd.shell, &mut app, name, &mut std::io::stdout());
match cmd.shell {
Shell::Fish => {
let mut buffer = Vec::new();
generate(cmd.shell, &mut app, name, &mut buffer);
let mut script = String::from_utf8(buffer)
.expect("clap should only emit UTF-8 output for completion scripts");

script = script.replace(" p/profile=", " 'p/profile=?'");
script = script.replace(
" -s p -l profile -d 'Configuration profile from config.toml to specify default options' -r",
" -s p -l profile -d 'Configuration profile from config.toml to specify default options' -r -f -a \"(__fish_codex_profile_list)\"",
);
script.push_str(
"\nfunction __fish_codex_profile_list\n\tcommand codex profiles list 2>/dev/null\nend\n",
);

print!("{script}");
}
Shell::Bash => {
let mut buffer = Vec::new();
generate(cmd.shell, &mut app, name, &mut buffer);
let mut script = String::from_utf8(buffer)
.expect("clap should only emit UTF-8 output for completion scripts");

let helper = r#"__codex_bash_complete_profiles() {
local cur_word="$1"
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(codex profiles list 2>/dev/null)" -- "${cur_word}"))
}

"#;
script = format!("{helper}{script}");

// clap emits a combined handler for `--profile`/`-p` when they share semantics.
// Rewrite only that exact block so other short `-p` flags keep their original behavior.
let profile_pair_block = r#" --profile)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-p)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
"#;
let profile_pair_replacement = r#" --profile)
__codex_bash_complete_profiles "${cur}"
return 0
;;
-p)
__codex_bash_complete_profiles "${cur}"
return 0
;;
"#;
let updated = script.replace(profile_pair_block, profile_pair_replacement);
if updated == script {
// If the paired block is absent (future clap change), fall back to replacing
// only the long-form clause so we still upgrade `--profile` completions.
script = script.replace(
" --profile)\n COMPREPLY=($(compgen -f \"${cur}\"))\n return 0\n ;;\n",
" --profile)\n __codex_bash_complete_profiles \"${cur}\"\n return 0\n ;;\n",
);
} else {
script = updated;
}

print!("{script}");
}
Shell::Zsh => {
let mut buffer = Vec::new();
generate(cmd.shell, &mut app, name, &mut buffer);
let mut script = String::from_utf8(buffer)
.expect("clap should only emit UTF-8 output for completion scripts");

script = script.replace(
":CONFIG_PROFILE:_default",
":CONFIG_PROFILE:__codex_zsh_complete_profiles",
);

let helper = r#"(( $+functions[__codex_zsh_complete_profiles] )) ||
__codex_zsh_complete_profiles() {
local -a profiles
profiles=(${(f)"$(codex profiles list 2>/dev/null)"})
compadd -a profiles
}

"#;

if let Some(stripped) = script.strip_prefix("#compdef codex\n\n") {
script = format!("#compdef codex\n\n{helper}{stripped}");
} else {
script = format!("{helper}{script}");
}

print!("{script}");
}
_ => {
let mut stdout = std::io::stdout();
generate(cmd.shell, &mut app, name, &mut stdout);
}
}
}

#[cfg(test)]
Expand Down
97 changes: 97 additions & 0 deletions codex-rs/cli/tests/profiles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use std::fs;
use std::path::Path;

use anyhow::Result;
use assert_cmd::Command;
use predicates::str::contains;
use tempfile::TempDir;

fn codex_command(codex_home: &Path) -> Result<Command> {
let mut cmd = Command::cargo_bin("codex")?;
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}

#[test]
fn profiles_list_outputs_sorted_names() -> Result<()> {
let codex_home = TempDir::new()?;
fs::write(
codex_home.path().join("config.toml"),
r#"
[profiles.zeta]
model = "gpt-5"

[profiles.alpha]
model = "gpt-5"

[profiles.mid]
model = "gpt-5"
"#,
)?;

let mut cmd = codex_command(codex_home.path())?;
let output = cmd.args(["profiles", "list"]).output()?;
assert!(output.status.success());

let stdout = String::from_utf8(output.stdout)?;
let lines: Vec<_> = stdout.lines().collect();
assert_eq!(lines, vec!["alpha", "mid", "zeta"]);

Ok(())
}

#[test]
fn profiles_list_respects_invalid_flag() -> Result<()> {
let codex_home = TempDir::new()?;
fs::write(
codex_home.path().join("config.toml"),
r#"
[profiles.alpha]
model = "gpt-5"
"#,
)?;

let mut cmd = codex_command(codex_home.path())?;
cmd.args(["--profile", "missing", "profiles", "list"])
.assert()
.failure()
.stderr(contains("config profile `missing` not found"));

Ok(())
}

#[test]
fn fish_completion_uses_profiles_helper() -> Result<()> {
let output = Command::cargo_bin("codex")?
.args(["completion", "fish"])
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout)?;
assert!(stdout.contains("function __fish_codex_profile_list"));
assert!(stdout.contains("codex profiles list"));
Ok(())
}

#[test]
fn bash_completion_uses_profiles_helper() -> Result<()> {
let output = Command::cargo_bin("codex")?
.args(["completion", "bash"])
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout)?;
assert!(stdout.contains("__codex_bash_complete_profiles()"));
assert!(stdout.contains("__codex_bash_complete_profiles \"${cur}\""));
Ok(())
}

#[test]
fn zsh_completion_uses_profiles_helper() -> Result<()> {
let output = Command::cargo_bin("codex")?
.args(["completion", "zsh"])
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout)?;
assert!(stdout.contains("__codex_zsh_complete_profiles()"));
assert!(stdout.contains(":CONFIG_PROFILE:__codex_zsh_complete_profiles"));
Ok(())
}
6 changes: 6 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,12 @@ Users can specify config values at multiple levels. Order of precedence is as fo
3. as an entry in `config.toml`, e.g., `model = "o3"`
4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5-codex`)

To see which profiles are available in your current configuration, run:

```shell
codex profiles list
```

### history

By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner.
Expand Down