Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1729,6 +1729,15 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}

#[test]
fn plugin_marketplace_remove_parses_under_plugin() {
let cli =
MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "remove", "debug"])
.expect("parse");

assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}

#[test]
fn marketplace_no_longer_parses_at_top_level() {
let add_result =
Expand All @@ -1738,6 +1747,10 @@ mod tests {
let upgrade_result =
MultitoolCli::try_parse_from(["codex", "marketplace", "upgrade", "debug"]);
assert!(upgrade_result.is_err());

let remove_result =
MultitoolCli::try_parse_from(["codex", "marketplace", "remove", "debug"]);
assert!(remove_result.is_err());
}

fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
Expand Down
36 changes: 36 additions & 0 deletions codex-rs/cli/src/marketplace_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ use clap::Parser;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core::plugins::MarketplaceAddRequest;
use codex_core::plugins::MarketplaceRemoveRequest;
use codex_core::plugins::PluginMarketplaceUpgradeOutcome;
use codex_core::plugins::PluginsManager;
use codex_core::plugins::add_marketplace;
use codex_core::plugins::remove_marketplace;
use codex_utils_cli::CliConfigOverrides;

#[derive(Debug, Parser)]
Expand All @@ -23,6 +25,7 @@ pub struct MarketplaceCli {
enum MarketplaceSubcommand {
Add(AddMarketplaceArgs),
Upgrade(UpgradeMarketplaceArgs),
Remove(RemoveMarketplaceArgs),
}

#[derive(Debug, Parser)]
Expand All @@ -47,6 +50,12 @@ struct UpgradeMarketplaceArgs {
marketplace_name: Option<String>,
}

#[derive(Debug, Parser)]
struct RemoveMarketplaceArgs {
/// Configured marketplace name to remove.
marketplace_name: String,
}

impl MarketplaceCli {
pub async fn run(self) -> Result<()> {
let MarketplaceCli {
Expand All @@ -61,6 +70,7 @@ impl MarketplaceCli {
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?,
MarketplaceSubcommand::Remove(args) => run_remove(args).await?,
}

Ok(())
Expand Down Expand Up @@ -120,6 +130,26 @@ async fn run_upgrade(
print_upgrade_outcome(&outcome, marketplace_name.as_deref())
}

async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> {
let RemoveMarketplaceArgs { marketplace_name } = args;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let outcome = remove_marketplace(
codex_home.to_path_buf(),
MarketplaceRemoveRequest { marketplace_name },
)
.await?;

println!("Removed marketplace `{}`.", outcome.marketplace_name);
if let Some(installed_root) = outcome.removed_installed_root {
println!(
"Removed installed marketplace root: {}",
installed_root.as_path().display()
);
}

Ok(())
}

fn print_upgrade_outcome(
outcome: &PluginMarketplaceUpgradeOutcome,
marketplace_name: Option<&str>,
Expand Down Expand Up @@ -201,4 +231,10 @@ mod tests {
let upgrade_one = UpgradeMarketplaceArgs::try_parse_from(["upgrade", "debug"]).unwrap();
assert_eq!(upgrade_one.marketplace_name.as_deref(), Some("debug"));
}

#[test]
fn remove_subcommand_parses_marketplace_name() {
let remove = RemoveMarketplaceArgs::try_parse_from(["remove", "debug"]).unwrap();
assert_eq!(remove.marketplace_name, "debug");
}
}
70 changes: 70 additions & 0 deletions codex-rs/cli/tests/marketplace_remove.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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<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)
}

fn configured_marketplace_update() -> MarketplaceConfigUpdate<'static> {
MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision: None,
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(["plugin", "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(["plugin", "marketplace", "remove", "debug"])
.assert()
.failure()
.stderr(contains(
"marketplace `debug` is not configured or installed",
));

Ok(())
}
3 changes: 3 additions & 0 deletions codex-rs/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ pub use diagnostics::format_config_error_with_source;
pub use diagnostics::io_error_from_config_error;
pub use fingerprint::version_for_toml;
pub use marketplace_edit::MarketplaceConfigUpdate;
pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
pub use marketplace_edit::record_user_marketplace;
pub use marketplace_edit::remove_user_marketplace;
pub use marketplace_edit::remove_user_marketplace_config;
pub use mcp_edit::ConfigEditsBuilder;
pub use mcp_edit::load_global_mcp_servers;
pub use mcp_types::AppToolApproval;
Expand Down
189 changes: 189 additions & 0 deletions codex-rs/config/src/marketplace_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ pub struct MarketplaceConfigUpdate<'a> {
pub sparse_paths: &'a [String],
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoveMarketplaceConfigOutcome {
Removed,
NotFound,
NameCaseMismatch { configured_name: String },
}

pub fn record_user_marketplace(
codex_home: &Path,
marketplace_name: &str,
Expand All @@ -31,6 +38,36 @@ 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<bool> {
let outcome = remove_user_marketplace_config(codex_home, marketplace_name)?;
Ok(outcome == RemoveMarketplaceConfigOutcome::Removed)
}

pub fn remove_user_marketplace_config(
codex_home: &Path,
marketplace_name: &str,
) -> std::io::Result<RemoveMarketplaceConfigOutcome> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let mut doc = match fs::read_to_string(&config_path) {
Ok(raw) => raw
.parse::<DocumentMut>()
.map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?,
Err(err) if err.kind() == ErrorKind::NotFound => {
return Ok(RemoveMarketplaceConfigOutcome::NotFound);
}
Err(err) => return Err(err),
};

let outcome = remove_marketplace(&mut doc, marketplace_name);
if outcome != RemoveMarketplaceConfigOutcome::Removed {
return Ok(outcome);
}

fs::create_dir_all(codex_home)?;
fs::write(config_path, doc.to_string())?;
Ok(RemoveMarketplaceConfigOutcome::Removed)
}

fn read_or_create_document(config_path: &Path) -> std::io::Result<DocumentMut> {
match fs::read_to_string(config_path) {
Ok(raw) => raw
Expand Down Expand Up @@ -80,8 +117,160 @@ fn upsert_marketplace(
marketplaces.insert(marketplace_name, TomlItem::Table(entry));
}

fn remove_marketplace(
doc: &mut DocumentMut,
marketplace_name: &str,
) -> RemoveMarketplaceConfigOutcome {
let root = doc.as_table_mut();
let Some(marketplaces_item) = root.get_mut("marketplaces") else {
return RemoveMarketplaceConfigOutcome::NotFound;
};

let mut remove_marketplaces = false;
let outcome = match marketplaces_item {
TomlItem::Table(marketplaces) => {
let outcome = if marketplaces.remove(marketplace_name).is_some() {
RemoveMarketplaceConfigOutcome::Removed
} else if let Some(configured_name) =
case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name)
{
RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name }
} else {
RemoveMarketplaceConfigOutcome::NotFound
};
remove_marketplaces = marketplaces.is_empty();
outcome
}
TomlItem::Value(value) => {
let Some(marketplaces) = value.as_inline_table_mut() else {
return RemoveMarketplaceConfigOutcome::NotFound;
};
let outcome = if marketplaces.remove(marketplace_name).is_some() {
RemoveMarketplaceConfigOutcome::Removed
} else if let Some(configured_name) =
case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name)
{
RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name }
} else {
RemoveMarketplaceConfigOutcome::NotFound
};
remove_marketplaces = marketplaces.is_empty();
outcome
}
_ => RemoveMarketplaceConfigOutcome::NotFound,
};

if outcome == RemoveMarketplaceConfigOutcome::Removed && remove_marketplaces {
root.remove("marketplaces");
}
outcome
}

fn case_mismatched_key<'a>(
mut keys: impl Iterator<Item = &'a str>,
requested_name: &str,
) -> Option<String> {
keys.find(|key| *key != requested_name && key.eq_ignore_ascii_case(requested_name))
.map(str::to_string)
}

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",
last_revision: None,
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);
}

#[test]
fn remove_user_marketplace_config_reports_case_mismatch() {
let codex_home = TempDir::new().unwrap();
let update = MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision: None,
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();

let outcome = remove_user_marketplace_config(codex_home.path(), "Debug").unwrap();

assert_eq!(
outcome,
RemoveMarketplaceConfigOutcome::NameCaseMismatch {
configured_name: "debug".to_string()
}
);
}

#[test]
fn remove_user_marketplace_config_removes_inline_table_entry() {
let codex_home = TempDir::new().unwrap();
fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
marketplaces = {
debug = { source_type = "git", source = "https://github.com/owner/repo.git" },
other = { source_type = "local", source = "/tmp/marketplace" },
}
"#,
)
.unwrap();

let outcome = remove_user_marketplace_config(codex_home.path(), "debug").unwrap();

assert_eq!(outcome, RemoveMarketplaceConfigOutcome::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"));
}
}
Loading
Loading