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

This commit is contained in:
Michael Bolin
2025-05-27 10:46:05 -07:00
parent 29d154cb13
commit 1c9594b18b
9 changed files with 289 additions and 2 deletions
+1
View File
@@ -9,6 +9,7 @@ workspace = true
[dependencies]
clap = { version = "4", features = ["derive", "wrap_help"], optional = true }
codex-core = { path = "../core" }
serde_json = { version = "1" }
[features]
# Separate feature so that `clap` is not a mandatory dependency.
+130
View File
@@ -0,0 +1,130 @@
//! 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-write-cwd\"]`
/// - `-c history.max-lines=0`
#[arg(
short = 'c',
long = "config",
value_name = "key=value",
action = ArgAction::Append,
)]
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;