diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 6a898c9ad6..3c378f09ff 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -4,6 +4,7 @@ use anyhow::bail; use clap::Parser; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; +use codex_config::remove_user_marketplace; use codex_core::config::find_codex_home; use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::plugins::marketplace_install_root; @@ -31,6 +32,9 @@ pub struct MarketplaceCli { enum MarketplaceSubcommand { /// Add a remote marketplace repository. Add(AddMarketplaceArgs), + + /// Remove a configured marketplace. + Remove(RemoveMarketplaceArgs), } #[derive(Debug, Parser)] @@ -51,6 +55,12 @@ struct AddMarketplaceArgs { sparse_paths: Vec, } +#[derive(Debug, Parser)] +struct RemoveMarketplaceArgs { + /// Configured marketplace name to remove. + marketplace_name: String, +} + #[derive(Debug, PartialEq, Eq)] pub(super) enum MarketplaceSource { Git { @@ -74,6 +84,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, + MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } Ok(()) @@ -177,6 +188,38 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { Ok(()) } +async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { + let RemoveMarketplaceArgs { marketplace_name } = args; + validate_plugin_segment(&marketplace_name, "marketplace name").map_err(anyhow::Error::msg)?; + + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let install_root = marketplace_install_root(&codex_home); + let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?); + let removed_root = ops::remove_marketplace_root(&destination).with_context(|| { + format!( + "failed to remove installed marketplace root {}", + destination.display() + ) + })?; + let removed_config = + remove_user_marketplace(&codex_home, &marketplace_name).with_context(|| { + format!("failed to remove marketplace `{marketplace_name}` from user config.toml") + })?; + if !removed_root && !removed_config { + bail!("marketplace `{marketplace_name}` is not configured or installed"); + } + + println!("Removed marketplace `{marketplace_name}`."); + if removed_root { + println!( + "Removed installed marketplace root: {}", + destination.display() + ); + } + + Ok(()) +} + fn record_added_marketplace( codex_home: &Path, marketplace_name: &str, @@ -559,4 +602,10 @@ mod tests { vec!["plugins/foo", "skills/bar"] ); } + + #[test] + fn remove_subcommand_parses_marketplace_name() { + let remove = RemoveMarketplaceArgs::try_parse_from(["remove", "debug"]).unwrap(); + assert_eq!(remove.marketplace_name, "debug"); + } } diff --git a/codex-rs/cli/src/marketplace_cmd/ops.rs b/codex-rs/cli/src/marketplace_cmd/ops.rs index ffb777fdbd..ac5c487434 100644 --- a/codex-rs/cli/src/marketplace_cmd/ops.rs +++ b/codex-rs/cli/src/marketplace_cmd/ops.rs @@ -79,6 +79,15 @@ pub(super) fn replace_marketplace_root(staged_root: &Path, destination: &Path) - fs::rename(staged_root, destination).map_err(Into::into) } +pub(super) fn remove_marketplace_root(root: &Path) -> Result { + if !root.exists() { + return Ok(false); + } + + fs::remove_dir_all(root)?; + Ok(true) +} + pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf { install_root.join(".staging") } @@ -115,4 +124,13 @@ mod tests { "installed" ); } + + #[test] + fn remove_marketplace_root_returns_false_when_missing() { + let temp_dir = TempDir::new().unwrap(); + + let removed = remove_marketplace_root(&temp_dir.path().join("missing")).unwrap(); + + assert!(!removed); + } } diff --git a/codex-rs/cli/tests/marketplace_remove.rs b/codex-rs/cli/tests/marketplace_remove.rs new file mode 100644 index 0000000000..6a47a01008 --- /dev/null +++ b/codex-rs/cli/tests/marketplace_remove.rs @@ -0,0 +1,69 @@ +use anyhow::Result; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_core::plugins::marketplace_install_root; +use predicates::str::contains; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn configured_marketplace_update() -> MarketplaceConfigUpdate<'static> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + } +} + +fn write_installed_marketplace(codex_home: &Path, marketplace_name: &str) -> Result<()> { + let root = marketplace_install_root(codex_home).join(marketplace_name); + std::fs::create_dir_all(root.join(".agents/plugins"))?; + std::fs::write(root.join(".agents/plugins/marketplace.json"), "{}")?; + std::fs::write(root.join("marker.txt"), "installed")?; + Ok(()) +} + +#[tokio::test] +async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + + codex_command(codex_home.path())? + .args(["marketplace", "remove", "debug"]) + .assert() + .success() + .stdout(contains("Removed marketplace `debug`.")); + + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(config_path)?; + assert!(!config.contains("[marketplaces.debug]")); + assert!( + !marketplace_install_root(codex_home.path()) + .join("debug") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .args(["marketplace", "remove", "debug"]) + .assert() + .failure() + .stderr(contains( + "marketplace `debug` is not configured or installed", + )); + + Ok(()) +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index ffed561984..f26ed43565 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -61,6 +61,7 @@ pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; pub use marketplace_edit::MarketplaceConfigUpdate; pub use marketplace_edit::record_user_marketplace; +pub use marketplace_edit::remove_user_marketplace; pub use mcp_edit::ConfigEditsBuilder; pub use mcp_edit::load_global_mcp_servers; pub use mcp_types::AppToolApproval; diff --git a/codex-rs/config/src/marketplace_edit.rs b/codex-rs/config/src/marketplace_edit.rs index 33cdd8e163..76cdcd5e8e 100644 --- a/codex-rs/config/src/marketplace_edit.rs +++ b/codex-rs/config/src/marketplace_edit.rs @@ -30,6 +30,26 @@ pub fn record_user_marketplace( fs::write(config_path, doc.to_string()) } +pub fn remove_user_marketplace(codex_home: &Path, marketplace_name: &str) -> std::io::Result { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match fs::read_to_string(&config_path) { + Ok(raw) => raw + .parse::() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + + let removed = remove_marketplace(&mut doc, marketplace_name); + if !removed { + return Ok(false); + } + + fs::create_dir_all(codex_home)?; + fs::write(config_path, doc.to_string())?; + Ok(true) +} + fn read_or_create_document(config_path: &Path) -> std::io::Result { match fs::read_to_string(config_path) { Ok(raw) => raw @@ -76,8 +96,68 @@ fn upsert_marketplace( marketplaces.insert(marketplace_name, TomlItem::Table(entry)); } +fn remove_marketplace(doc: &mut DocumentMut, marketplace_name: &str) -> bool { + let root = doc.as_table_mut(); + let Some(marketplaces_item) = root.get_mut("marketplaces") else { + return false; + }; + let Some(marketplaces) = marketplaces_item.as_table_mut() else { + return false; + }; + if marketplaces.remove(marketplace_name).is_none() { + return false; + } + if marketplaces.is_empty() { + root.remove("marketplaces"); + } + true +} + fn new_implicit_table() -> TomlTable { let mut table = TomlTable::new(); table.set_implicit(true); table } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn remove_user_marketplace_removes_requested_entry() { + let codex_home = TempDir::new().unwrap(); + let update = MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update).unwrap(); + record_user_marketplace(codex_home.path(), "other", &update).unwrap(); + + let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap(); + + assert!(removed); + let config: toml::Value = + toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap()) + .unwrap(); + let marketplaces = config + .get("marketplaces") + .and_then(toml::Value::as_table) + .unwrap(); + assert_eq!(marketplaces.len(), 1); + assert!(marketplaces.contains_key("other")); + } + + #[test] + fn remove_user_marketplace_returns_false_when_missing() { + let codex_home = TempDir::new().unwrap(); + + let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap(); + + assert!(!removed); + } +}