feat: add support for -c/--config to override individual config items

This commit is contained in:
Michael Bolin
2025-05-27 12:55:17 -07:00
parent 29d154cb13
commit b9302c46d3
15 changed files with 362 additions and 44 deletions
+1
View File
@@ -506,6 +506,7 @@ version = "0.0.0"
dependencies = [
"clap",
"codex-core",
"serde_json",
]
[[package]]
+24 -3
View File
@@ -2,6 +2,7 @@ use clap::Parser;
use codex_cli::LandlockCommand;
use codex_cli::SeatbeltCommand;
use codex_cli::proto;
use codex_common::CliConfigOverrides;
use codex_exec::Cli as ExecCli;
use codex_tui::Cli as TuiCli;
use std::path::PathBuf;
@@ -19,6 +20,9 @@ use crate::proto::ProtoCli;
subcommand_negates_reqs = true
)]
struct MultitoolCli {
#[clap(flatten)]
config_overrides: CliConfigOverrides,
#[clap(flatten)]
interactive: TuiCli,
@@ -73,15 +77,32 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
match cli.subcommand {
None => {
codex_tui::run_main(cli.interactive, codex_linux_sandbox_exe)?;
let mut interactive = cli.interactive;
// Prepend root-level overrides so they have lower precedence than
// CLI-specific ones specified after the subcommand (if any).
interactive
.config_overrides
.raw_overrides
.splice(0..0, cli.config_overrides.raw_overrides.into_iter());
codex_tui::run_main(interactive, codex_linux_sandbox_exe)?;
}
Some(Subcommand::Exec(exec_cli)) => {
Some(Subcommand::Exec(mut exec_cli)) => {
exec_cli
.config_overrides
.raw_overrides
.splice(0..0, cli.config_overrides.raw_overrides.into_iter());
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::Mcp) => {
codex_mcp_server::run_main(codex_linux_sandbox_exe).await?;
}
Some(Subcommand::Proto(proto_cli)) => {
Some(Subcommand::Proto(mut proto_cli)) => {
proto_cli
.config_overrides
.raw_overrides
.splice(0..0, cli.config_overrides.raw_overrides.into_iter());
proto::run_main(proto_cli).await?;
}
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
+12 -3
View File
@@ -2,6 +2,7 @@ use std::io::IsTerminal;
use std::sync::Arc;
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_core::Codex;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
@@ -13,9 +14,12 @@ use tracing::error;
use tracing::info;
#[derive(Debug, Parser)]
pub struct ProtoCli {}
pub struct ProtoCli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> {
pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
if std::io::stdin().is_terminal() {
anyhow::bail!("Protocol mode expects stdin to be a pipe, not a terminal");
}
@@ -24,7 +28,12 @@ pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> {
.with_writer(std::io::stderr)
.init();
let config = Config::load_with_overrides(ConfigOverrides::default())?;
let ProtoCli { config_overrides } = opts;
let overrides_vec = config_overrides
.parse_overrides()
.map_err(|e| anyhow::anyhow!("error parsing --config overrides: {e}"))?;
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
let ctrl_c = notify_on_sigint();
let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await?;
let codex = Arc::new(codex);
+2 -1
View File
@@ -9,8 +9,9 @@ workspace = true
[dependencies]
clap = { version = "4", features = ["derive", "wrap_help"], optional = true }
codex-core = { path = "../core" }
serde_json = { version = "1", optional = true }
[features]
# Separate feature so that `clap` is not a mandatory dependency.
cli = ["clap"]
cli = ["clap", "serde_json"]
elapsed = []
+131
View File
@@ -0,0 +1,131 @@
//! Support for `-c key=value` overrides shared across Codex CLI tools.
//!
//! This module provides a [`CliConfigOverrides`] struct that can be embedded
//! into a `clap`-derived CLI struct using `#[clap(flatten)]`. Each occurrence
//! of `-c key=value` (or `--config key=value`) will be collected as a raw
//! string. Helper methods are provided to convert the raw strings into
//! key/value pairs as well as to apply them onto a mutable
//! `serde_json::Value` representing the configuration tree.
use clap::ArgAction;
use clap::Parser;
use serde_json::Value;
/// CLI option that captures arbitrary configuration overrides specified as
/// `-c key=value`. It intentionally keeps both halves **unparsed** so that the
/// calling code can decide how to interpret the right-hand side.
#[derive(Parser, Debug, Default, Clone)]
pub struct CliConfigOverrides {
/// Override a configuration value that would otherwise be loaded from
/// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override
/// nested values. The `value` portion is parsed as JSON. If it fails to
/// parse as JSON, the raw string is used as a literal.
///
/// Examples:
/// - `-c model="o4-mini"`
/// - `-c 'sandbox_permissions=["disk-full-read-access"]'`
/// - `-c shell_environment_policy.inherit=all`
#[arg(
short = 'c',
long = "config",
value_name = "key=value",
action = ArgAction::Append,
global = true,
)]
pub raw_overrides: Vec<String>,
}
impl CliConfigOverrides {
/// Parse the raw strings captured from the CLI into a list of `(path,
/// value)` tuples where `value` is a `serde_json::Value`.
pub fn parse_overrides(&self) -> Result<Vec<(String, Value)>, String> {
self.raw_overrides
.iter()
.map(|s| {
// Only split on the *first* '=' so values are free to contain
// the character.
let mut parts = s.splitn(2, '=');
let key = match parts.next() {
Some(k) => k.trim(),
None => return Err("Override missing key".to_string()),
};
let value_str = parts
.next()
.ok_or_else(|| format!("Invalid override (missing '='): {s}"))?
.trim();
if key.is_empty() {
return Err(format!("Empty key in override: {s}"));
}
// Attempt to parse as JSON. If that fails, treat it as a raw
// string. This allows convenient usage such as
// `-c model=o4-mini` without the quotes.
let value: Value = match serde_json::from_str(value_str) {
Ok(v) => v,
Err(_) => Value::String(value_str.to_string()),
};
Ok((key.to_string(), value))
})
.collect()
}
/// Apply all parsed overrides onto `target`. Intermediate objects will be
/// created as necessary. Values located at the destination path will be
/// replaced.
pub fn apply_on_value(&self, target: &mut Value) -> Result<(), String> {
let overrides = self.parse_overrides()?;
for (path, value) in overrides {
apply_single_override(target, &path, value);
}
Ok(())
}
}
/// Apply a single override onto `root`, creating intermediate objects as
/// necessary.
fn apply_single_override(root: &mut Value, path: &str, value: Value) {
use serde_json::Map;
let parts: Vec<&str> = path.split('.').collect();
let mut current = root;
for (i, part) in parts.iter().enumerate() {
let is_last = i == parts.len() - 1;
if is_last {
// Replace value at leaf.
if let Value::Object(obj) = current {
obj.insert(part.to_string(), value);
} else {
// Replace non-object with object containing the leaf.
*current = Value::Object({
let mut m = Map::new();
m.insert(part.to_string(), value);
m
});
}
return;
}
// Traverse or create intermediate object.
match current {
Value::Object(obj) => {
current = obj
.entry(part.to_string())
.or_insert_with(|| Value::Object(Map::new()));
}
_ => {
// Non-object encountered, replace with object so we can
// continue traversal.
*current = Value::Object(Map::new());
if let Value::Object(obj) = current {
current = obj
.entry((*part).to_string())
.or_insert_with(|| Value::Object(Map::new()));
}
}
}
}
}
+6
View File
@@ -8,3 +8,9 @@ pub mod elapsed;
pub use approval_mode_cli_arg::ApprovalModeCliArg;
#[cfg(feature = "cli")]
pub use approval_mode_cli_arg::SandboxPermissionOption;
#[cfg(feature = "cli")]
mod config_override;
#[cfg(feature = "cli")]
pub use config_override::CliConfigOverrides;
+121 -4
View File
@@ -13,6 +13,7 @@ use crate::protocol::SandboxPermission;
use crate::protocol::SandboxPolicy;
use dirs::home_dir;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -108,6 +109,124 @@ pub struct Config {
pub codex_linux_sandbox_exe: Option<PathBuf>,
}
impl Config {
/// Load configuration with *generic* CLI overrides (`-c key=value`) applied
/// **in between** the values parsed from `config.toml` and the
/// strongly-typed overrides specified via [`ConfigOverrides`].
///
/// The precedence order is therefore: `config.toml` < `-c` overrides <
/// `ConfigOverrides`.
///
/// Most CLI binaries should call this method – `load_with_overrides()` is
/// still available for non-CLI callers (tests, servers) which do not accept
/// the `-c` flag.
pub fn load_with_cli_overrides(
cli_overrides: Vec<(String, JsonValue)>,
overrides: ConfigOverrides,
) -> std::io::Result<Self> {
// Resolve CODEX_HOME first; needed by sandbox deserializer later.
let codex_home = find_codex_home()?;
// Step 1: parse `config.toml` into a generic JSON value.
let mut root_value = load_config_as_json(&codex_home)?;
// Step 2: apply the `-c` overrides.
for (path, value) in cli_overrides.into_iter() {
apply_json_override(&mut root_value, &path, value);
}
// Step 3: deserialize into `ConfigToml` so that Serde can enforce the
// correct types.
let cfg: ConfigToml = serde_json::from_value(root_value).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
// Step 4: merge with the strongly-typed overrides.
Self::load_from_base_config_with_overrides(cfg, overrides, codex_home)
}
}
/// Read `~/.codex/config.toml` (or the resolved CODEX_HOME location) and
/// return it as a generic JSON value. Returns an empty JSON object when the
/// file does not exist.
fn load_config_as_json(codex_home: &Path) -> std::io::Result<JsonValue> {
let config_path = codex_home.join("config.toml");
match std::fs::read_to_string(&config_path) {
Ok(contents) => {
// Parse as TOML first, then convert to JSON for easier mutation.
match toml::from_str::<toml::Value>(&contents) {
Ok(toml_val) => {
let json_val = serde_json::to_value(toml_val).map_err(|e| {
tracing::error!("Failed to convert TOML config to JSON value: {e}");
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
Ok(json_val)
}
Err(e) => {
tracing::error!("Failed to parse config.toml: {e}");
Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("config.toml not found, using defaults");
Ok(JsonValue::Object(Default::default()))
}
Err(e) => {
tracing::error!("Failed to read config.toml: {e}");
Err(e)
}
}
}
/// Apply a single dotted-path override onto a JSON value.
fn apply_json_override(root: &mut JsonValue, path: &str, value: JsonValue) {
use serde_json::Map;
let segments: Vec<&str> = path.split('.').collect();
let mut current = root;
for (idx, segment) in segments.iter().enumerate() {
let is_last = idx == segments.len() - 1;
if is_last {
match current {
JsonValue::Object(map) => {
map.insert(segment.to_string(), value);
}
_ => {
*current = JsonValue::Object({
let mut m = Map::new();
m.insert(segment.to_string(), value);
m
});
}
}
return;
}
// Traverse or create intermediate object.
match current {
JsonValue::Object(map) => {
current = map
.entry(segment.to_string())
.or_insert_with(|| JsonValue::Object(Map::new()));
}
_ => {
*current = JsonValue::Object(Map::new());
if let JsonValue::Object(map) = current {
// Safe unwrap: we just replaced current with an empty
// object and immediately inserted the key.
current = map
.entry(segment.to_string())
.or_insert_with(|| JsonValue::Object(Map::new()));
}
}
}
}
}
/// Base config deserialized from ~/.codex/config.toml.
#[derive(Deserialize, Debug, Clone, Default)]
pub struct ConfigToml {
@@ -227,7 +346,6 @@ pub struct ConfigOverrides {
pub cwd: Option<PathBuf>,
pub approval_policy: Option<AskForApproval>,
pub sandbox_policy: Option<SandboxPolicy>,
pub disable_response_storage: Option<bool>,
pub model_provider: Option<String>,
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
@@ -264,7 +382,6 @@ impl Config {
cwd,
approval_policy,
sandbox_policy,
disable_response_storage,
model_provider,
config_profile: config_profile_key,
codex_linux_sandbox_exe,
@@ -356,8 +473,8 @@ impl Config {
.unwrap_or_else(AskForApproval::default),
sandbox_policy,
shell_environment_policy,
disable_response_storage: disable_response_storage
.or(config_profile.disable_response_storage)
disable_response_storage: config_profile
.disable_response_storage
.or(cfg.disable_response_storage)
.unwrap_or(false),
notify: cfg.notify,
+1 -1
View File
@@ -89,7 +89,7 @@ pub struct Tui {
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
/// "Core" environment variables for the platform. On UNIX, this would
/// include HOME, LOGNAME, PATH, SHELL, and USER, among others.
+3 -3
View File
@@ -1,5 +1,6 @@
use clap::Parser;
use clap::ValueEnum;
use codex_common::CliConfigOverrides;
use codex_common::SandboxPermissionOption;
use std::path::PathBuf;
@@ -33,9 +34,8 @@ pub struct Cli {
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,
/// Disable server‑side response storage (sends the full conversation context with every request)
#[arg(long = "disable-response-storage", default_value_t = false)]
pub disable_response_storage: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
/// Specifies color settings for use in the output.
#[arg(long = "color", value_enum, default_value_t = Color::Auto)]
+11 -7
View File
@@ -34,10 +34,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
sandbox,
cwd,
skip_git_repo_check,
disable_response_storage,
color,
last_message_file,
prompt,
config_overrides,
} = cli;
let (stdout_with_ansi, stderr_with_ansi) = match color {
@@ -63,16 +63,20 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
// the user for approval.
approval_policy: Some(AskForApproval::Never),
sandbox_policy,
disable_response_storage: if disable_response_storage {
Some(true)
} else {
None
},
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
model_provider: None,
codex_linux_sandbox_exe,
};
let config = Config::load_with_overrides(overrides)?;
// Parse `-c` overrides.
let cli_kv_overrides = match config_overrides.parse_overrides() {
Ok(v) => v,
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
// Print the effective configuration so users can see what Codex is using.
print_config_summary(&config, stdout_with_ansi);
+19 -2
View File
@@ -10,13 +10,30 @@
//! This allows us to ship a completely separate set of functionality as part
//! of the `codex-exec` binary.
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_exec::Cli;
use codex_exec::run_main;
#[derive(Parser, Debug)]
struct TopCli {
#[clap(flatten)]
config_overrides: CliConfigOverrides,
#[clap(flatten)]
inner: Cli,
}
fn main() -> anyhow::Result<()> {
codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move {
let cli = Cli::parse();
run_main(cli, codex_linux_sandbox_exe).await?;
let top_cli = TopCli::parse();
// Merge root-level overrides into inner CLI struct so downstream logic remains unchanged.
let mut inner = top_cli.inner;
inner
.config_overrides
.raw_overrides
.splice(0..0, top_cli.config_overrides.raw_overrides.into_iter());
run_main(inner, codex_linux_sandbox_exe).await?;
Ok(())
})
}
@@ -40,13 +40,6 @@ pub(crate) struct CodexToolCallParam {
/// (e.g. "disk-write-cwd", "network-full-access").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_permissions: Option<Vec<CodexToolCallSandboxPermission>>,
/// Disable server-side response storage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_response_storage: Option<bool>,
// Custom system instructions.
// #[serde(default, skip_serializing_if = "Option::is_none")]
// pub instructions: Option<String>,
}
// Create custom enums for use with `CodexToolCallApprovalPolicy` where we
@@ -155,7 +148,6 @@ impl CodexToolCallParam {
cwd,
approval_policy,
sandbox_permissions,
disable_response_storage,
} = self;
let sandbox_policy = sandbox_permissions.map(|perms| {
SandboxPolicy::from(perms.into_iter().map(Into::into).collect::<Vec<_>>())
@@ -168,7 +160,6 @@ impl CodexToolCallParam {
cwd: cwd.map(PathBuf::from),
approval_policy: approval_policy.map(Into::into),
sandbox_policy,
disable_response_storage,
model_provider: None,
codex_linux_sandbox_exe,
};
+3 -3
View File
@@ -1,5 +1,6 @@
use clap::Parser;
use codex_common::ApprovalModeCliArg;
use codex_common::CliConfigOverrides;
use codex_common::SandboxPermissionOption;
use std::path::PathBuf;
@@ -40,7 +41,6 @@ pub struct Cli {
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,
/// Disable server‑side response storage (sends the full conversation context with every request)
#[arg(long = "disable-response-storage", default_value_t = false)]
pub disable_response_storage: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
+11 -6
View File
@@ -54,18 +54,23 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::
model: cli.model.clone(),
approval_policy,
sandbox_policy,
disable_response_storage: if cli.disable_response_storage {
Some(true)
} else {
None
},
cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
model_provider: None,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
#[allow(clippy::print_stderr)]
match Config::load_with_overrides(overrides) {
match Config::load_with_cli_overrides(cli_kv_overrides, overrides) {
Ok(config) => config,
Err(err) => {
eprintln!("Error loading configuration: {err}");
+17 -2
View File
@@ -1,11 +1,26 @@
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_tui::Cli;
use codex_tui::run_main;
#[derive(Parser, Debug)]
struct TopCli {
#[clap(flatten)]
config_overrides: CliConfigOverrides,
#[clap(flatten)]
inner: Cli,
}
fn main() -> anyhow::Result<()> {
codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move {
let cli = Cli::parse();
run_main(cli, codex_linux_sandbox_exe)?;
let top_cli = TopCli::parse();
let mut inner = top_cli.inner;
inner
.config_overrides
.raw_overrides
.splice(0..0, top_cli.config_overrides.raw_overrides.into_iter());
run_main(inner, codex_linux_sandbox_exe)?;
Ok(())
})
}