Add features enable/disable subcommands (#10180)

## Summary
- add `codex features enable <feature>` and `codex features disable
<feature>`
- persist feature flag changes to `config.toml` (respecting profile)
- print the under-development feature warning when enabling prerelease
features
- keep `features list` behavior unchanged and add unit/integration tests

## Testing
- cargo test -p codex-cli
This commit is contained in:
Josh McKinney
2026-01-29 12:35:03 -08:00
committed by GitHub
parent 48f203120d
commit 03aee7140f
2 changed files with 154 additions and 0 deletions

View File

@@ -39,6 +39,9 @@ use crate::mcp_cmd::McpCli;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_core::features::Stage;
use codex_core::features::is_known_feature_key;
use codex_core::terminal::TerminalName;
@@ -448,6 +451,16 @@ struct FeaturesCli {
enum FeaturesSubcommand {
/// List known features with their stage and effective state.
List,
/// Enable a feature in config.toml.
Enable(FeatureSetArgs),
/// Disable a feature in config.toml.
Disable(FeatureSetArgs),
}
#[derive(Debug, Parser)]
struct FeatureSetArgs {
/// Feature key to update (for example: unified_exec).
feature: String,
}
fn stage_str(stage: codex_core::features::Stage) -> &'static str {
@@ -711,12 +724,69 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
println!("{name:<name_width$} {stage:<stage_width$} {enabled}");
}
}
FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => {
enable_feature_in_config(&interactive, &feature).await?;
}
FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => {
disable_feature_in_config(&interactive, &feature).await?;
}
},
}
Ok(())
}
async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let codex_home = find_codex_home()?;
ConfigEditsBuilder::new(&codex_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, true)
.apply()
.await?;
println!("Enabled feature `{feature}` in config.toml.");
maybe_print_under_development_feature_warning(&codex_home, interactive, feature);
Ok(())
}
async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let codex_home = find_codex_home()?;
ConfigEditsBuilder::new(&codex_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, false)
.apply()
.await?;
println!("Disabled feature `{feature}` in config.toml.");
Ok(())
}
fn maybe_print_under_development_feature_warning(
codex_home: &std::path::Path,
interactive: &TuiCli,
feature: &str,
) {
if interactive.config_profile.is_some() {
return;
}
let Some(spec) = codex_core::features::FEATURES
.iter()
.find(|spec| spec.key == feature)
else {
return;
};
if !matches!(spec.stage, Stage::UnderDevelopment) {
return;
}
let config_path = codex_home.join(codex_core::config::CONFIG_TOML_FILE);
eprintln!(
"Under-development features enabled: {feature}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {}.",
config_path.display()
);
}
/// Prepend root-level overrides so they have lower precedence than
/// CLI-specific ones specified after the subcommand (if any).
fn prepend_config_flags(
@@ -1171,6 +1241,32 @@ mod tests {
assert!(app_server.analytics_default_enabled);
}
#[test]
fn features_enable_parses_feature_name() {
let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"])
.expect("parse should succeed");
let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
panic!("expected features subcommand");
};
let FeaturesSubcommand::Enable(FeatureSetArgs { feature }) = sub else {
panic!("expected features enable");
};
assert_eq!(feature, "unified_exec");
}
#[test]
fn features_disable_parses_feature_name() {
let cli = MultitoolCli::try_parse_from(["codex", "features", "disable", "shell_tool"])
.expect("parse should succeed");
let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
panic!("expected features subcommand");
};
let FeaturesSubcommand::Disable(FeatureSetArgs { feature }) = sub else {
panic!("expected features disable");
};
assert_eq!(feature, "shell_tool");
}
#[test]
fn feature_toggles_known_features_generate_overrides() {
let toggles = FeatureToggles {

View File

@@ -0,0 +1,58 @@
use std::path::Path;
use anyhow::Result;
use predicates::str::contains;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
#[tokio::test]
async fn features_enable_writes_feature_flag_to_config() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["features", "enable", "unified_exec"])
.assert()
.success()
.stdout(contains("Enabled feature `unified_exec` in config.toml."));
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains("[features]"));
assert!(config.contains("unified_exec = true"));
Ok(())
}
#[tokio::test]
async fn features_disable_writes_feature_flag_to_config() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["features", "disable", "shell_tool"])
.assert()
.success()
.stdout(contains("Disabled feature `shell_tool` in config.toml."));
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains("[features]"));
assert!(config.contains("shell_tool = false"));
Ok(())
}
#[tokio::test]
async fn features_enable_under_development_feature_prints_warning() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["features", "enable", "sqlite"])
.assert()
.success()
.stderr(contains("Under-development features enabled: sqlite."));
Ok(())
}