Skip to main content

codex_utils_cli/
config_override.rs

1//! Support for `-c key=value` overrides shared across Codex CLI tools.
2//!
3//! This module provides a [`CliConfigOverrides`] struct that can be embedded
4//! into a `clap`-derived CLI struct using `#[clap(flatten)]`. Each occurrence
5//! of `-c key=value` (or `--config key=value`) will be collected as a raw
6//! string. Helper methods are provided to convert the raw strings into
7//! key/value pairs as well as to apply them onto a mutable
8//! `serde_json::Value` representing the configuration tree.
9
10use clap::ArgAction;
11use clap::Parser;
12use serde::de::Error as SerdeError;
13use toml::Value;
14
15/// CLI option that captures arbitrary configuration overrides specified as
16/// `-c key=value`. It intentionally keeps both halves **unparsed** so that the
17/// calling code can decide how to interpret the right-hand side.
18#[derive(Parser, Debug, Default, Clone)]
19pub struct CliConfigOverrides {
20    /// Override a configuration value that would otherwise be loaded from
21    /// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override
22    /// nested values. The `value` portion is parsed as TOML. If it fails to
23    /// parse as TOML, the raw string is used as a literal.
24    ///
25    /// Examples:
26    ///   - `-c model="o3"`
27    ///   - `-c 'sandbox_permissions=["disk-full-read-access"]'`
28    ///   - `-c shell_environment_policy.inherit=all`
29    #[arg(
30        short = 'c',
31        long = "config",
32        value_name = "key=value",
33        action = ArgAction::Append,
34        global = true,
35    )]
36    pub raw_overrides: Vec<String>,
37}
38
39impl CliConfigOverrides {
40    /// Prepend root-level config flags so they have lower precedence than
41    /// command-specific flags parsed after a subcommand.
42    pub fn prepend_root_overrides(&mut self, root_overrides: Self) {
43        self.raw_overrides
44            .splice(0..0, root_overrides.raw_overrides);
45    }
46
47    /// Parse the raw strings captured from the CLI into a list of `(path,
48    /// value)` tuples where `value` is a `serde_json::Value`.
49    pub fn parse_overrides(&self) -> Result<Vec<(String, Value)>, String> {
50        self.raw_overrides
51            .iter()
52            .map(|s| {
53                // Only split on the *first* '=' so values are free to contain
54                // the character.
55                let mut parts = s.splitn(2, '=');
56                let key = match parts.next() {
57                    Some(k) => k.trim(),
58                    None => return Err("Override missing key".to_string()),
59                };
60                let value_str = parts
61                    .next()
62                    .ok_or_else(|| format!("Invalid override (missing '='): {s}"))?
63                    .trim();
64
65                if key.is_empty() {
66                    return Err(format!("Empty key in override: {s}"));
67                }
68
69                // Attempt to parse as TOML. If that fails, treat it as a raw
70                // string. This allows convenient usage such as
71                // `-c model=o3` without the quotes.
72                let value: Value = match parse_toml_value(value_str) {
73                    Ok(v) => v,
74                    Err(_) => {
75                        // Strip leading/trailing quotes if present
76                        let trimmed = value_str.trim().trim_matches(|c| c == '"' || c == '\'');
77                        Value::String(trimmed.to_string())
78                    }
79                };
80
81                Ok((canonicalize_override_key(key), value))
82            })
83            .collect()
84    }
85}
86
87fn canonicalize_override_key(key: &str) -> String {
88    if key == "use_legacy_landlock" {
89        "features.use_legacy_landlock".to_string()
90    } else {
91        key.to_string()
92    }
93}
94
95fn parse_toml_value(raw: &str) -> Result<Value, toml::de::Error> {
96    let wrapped = format!("_x_ = {raw}");
97    let table: toml::Table = toml::from_str(&wrapped)?;
98    table
99        .get("_x_")
100        .cloned()
101        .ok_or_else(|| SerdeError::custom("missing sentinel key"))
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn parses_basic_scalar() {
110        let v = parse_toml_value("42").expect("parse");
111        assert_eq!(v.as_integer(), Some(42));
112    }
113
114    #[test]
115    fn parses_bool() {
116        let true_literal = parse_toml_value("true").expect("parse");
117        assert_eq!(true_literal.as_bool(), Some(true));
118
119        let false_literal = parse_toml_value("false").expect("parse");
120        assert_eq!(false_literal.as_bool(), Some(false));
121    }
122
123    #[test]
124    fn fails_on_unquoted_string() {
125        assert!(parse_toml_value("hello").is_err());
126    }
127
128    #[test]
129    fn parses_array() {
130        let v = parse_toml_value("[1, 2, 3]").expect("parse");
131        let arr = v.as_array().expect("array");
132        assert_eq!(arr.len(), 3);
133    }
134
135    #[test]
136    fn canonicalizes_use_legacy_landlock_alias() {
137        let overrides = CliConfigOverrides {
138            raw_overrides: vec!["use_legacy_landlock=true".to_string()],
139        };
140        let parsed = overrides.parse_overrides().expect("parse_overrides");
141        assert_eq!(parsed[0].0.as_str(), "features.use_legacy_landlock");
142        assert_eq!(parsed[0].1.as_bool(), Some(true));
143    }
144
145    #[test]
146    fn prepends_root_overrides() {
147        let mut subcommand_overrides = CliConfigOverrides {
148            raw_overrides: vec![r#"model="gpt-5.2""#.to_string()],
149        };
150        subcommand_overrides.prepend_root_overrides(CliConfigOverrides {
151            raw_overrides: vec![r#"model="gpt-5.1""#.to_string()],
152        });
153
154        assert_eq!(
155            subcommand_overrides.raw_overrides,
156            vec![
157                r#"model="gpt-5.1""#.to_string(),
158                r#"model="gpt-5.2""#.to_string(),
159            ]
160        );
161    }
162
163    #[test]
164    fn parses_inline_table() {
165        let v = parse_toml_value("{a = 1, b = 2}").expect("parse");
166        let tbl = v.as_table().expect("table");
167        assert_eq!(tbl.get("a").unwrap().as_integer(), Some(1));
168        assert_eq!(tbl.get("b").unwrap().as_integer(), Some(2));
169    }
170}