From 74a1b46a0077b2da52df45120d57fb4c0bba319f Mon Sep 17 00:00:00 2001 From: Casey Chow Date: Thu, 14 May 2026 09:33:38 -0700 Subject: [PATCH] [codex] add plugin marketplace CLI commands (#21396) ## Why Plugin CLI installs should behave more like `apt-get install`: configured marketplaces are the only install sources, the local marketplace snapshot is the package index used at install time, and `plugins/cache` is only a cache of already-downloaded plugin bytes. That distinction matters once marketplaces and plugins have auth or availability state. A repo-local marketplace manifest or leftover cached plugin artifact should not silently become an install source unless the marketplace was explicitly configured and its readable snapshot still authorizes the plugin. ## What Changed - add CLI commands to list configured marketplaces and add, list, or remove marketplace plugins - accept stable `plugin@marketplace` ids for add/remove while preserving the explicit `--marketplace` form - restrict `codex plugin add` and `codex plugin list` to configured marketplaces instead of also discovering current-working-directory marketplace roots - fail `codex plugin add` and `codex plugin list` when a configured marketplace snapshot is missing or malformed instead of treating it as an empty source or a generic plugin miss - preserve marketplace snapshot semantics: a configured local/Git marketplace snapshot can authorize installs without consulting the original upstream source - allow `plugins/cache` reuse only after configured marketplace resolution succeeds - keep removal resilient after marketplace deletion or drift and ignore malformed marketplace config entries in listing ## Commands Added - `codex plugin add @` - `codex plugin add --marketplace ` - `codex plugin list` - `codex plugin list --marketplace ` - `codex plugin remove @` - `codex plugin remove --marketplace ` - `codex plugin marketplace add ` - `codex plugin marketplace add --ref ` - `codex plugin marketplace add --sparse ` - `codex plugin marketplace list` - `codex plugin marketplace upgrade` - `codex plugin marketplace upgrade ` - `codex plugin marketplace remove ` ## CLI Help Output
codex plugin --help ```text Manage Codex plugins Usage: codex plugin [OPTIONS] Commands: add Install a plugin from a configured marketplace snapshot list List plugins available from configured marketplace snapshots marketplace Add, list, upgrade, or remove configured plugin marketplaces remove Remove an installed plugin from local config and cache help Print this message or the help of the given subcommand(s) ```
codex plugin add --help ```text Install a plugin from a configured marketplace snapshot. Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with `--marketplace MARKETPLACE`. Usage: codex plugin add [OPTIONS] Arguments: Plugin selector to install: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace Options: -m, --marketplace Configured marketplace name to use when PLUGIN does not include @MARKETPLACE Examples: codex plugin add sample@debug codex plugin add sample --marketplace debug ```
codex plugin list --help ```text List plugins available from configured marketplace snapshots Usage: codex plugin list [OPTIONS] Options: -m, --marketplace Only list plugins from this configured marketplace name Examples: codex plugin list codex plugin list --marketplace debug ```
codex plugin remove --help ```text Remove an installed plugin from local config and cache. Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with `--marketplace MARKETPLACE`. Usage: codex plugin remove [OPTIONS] Arguments: Plugin selector to remove: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace Options: -m, --marketplace Marketplace name to use when PLUGIN does not include @MARKETPLACE Examples: codex plugin remove sample@debug codex plugin remove sample --marketplace debug ```
codex plugin marketplace --help ```text Add, list, upgrade, or remove configured plugin marketplaces Usage: codex plugin marketplace [OPTIONS] Commands: add Add a local or Git marketplace to the configured marketplace sources list List configured marketplace names and their local snapshot roots upgrade Refresh configured Git marketplace snapshots remove Remove a configured marketplace source by name ```
codex plugin marketplace add --help ```text Add a local or Git marketplace to the configured marketplace sources Usage: codex plugin marketplace add [OPTIONS] Arguments: Marketplace source: a local path, owner/repo[@ref], HTTPS Git URL, or SSH Git URL Options: --ref Git ref to fetch for Git marketplace sources --sparse Sparse checkout path for Git marketplace sources. Can be repeated Examples: codex plugin marketplace add ./path/to/marketplace codex plugin marketplace add owner/repo --ref main codex plugin marketplace add https://github.com/owner/repo --sparse plugins/foo ```
codex plugin marketplace list --help ```text List configured marketplace names and their local snapshot roots Usage: codex plugin marketplace list [OPTIONS] ```
codex plugin marketplace upgrade --help ```text Refresh configured Git marketplace snapshots. Omit MARKETPLACE_NAME to upgrade all configured Git marketplaces. Usage: codex plugin marketplace upgrade [OPTIONS] [MARKETPLACE_NAME] Arguments: [MARKETPLACE_NAME] Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces Examples: codex plugin marketplace upgrade codex plugin marketplace upgrade debug ```
codex plugin marketplace remove --help ```text Remove a configured marketplace source by name Usage: codex plugin marketplace remove [OPTIONS] Arguments: Configured marketplace name to remove Example: codex plugin marketplace remove debug ```
## Public Semantics - `codex plugin add @` succeeds only when `` is configured and its local marketplace snapshot contains `` - repo-local marketplaces are not install sources until the user runs `codex plugin marketplace add ...` - configured marketplace snapshots must be readable; missing or malformed snapshots fail the CLI operation rather than silently falling through to cache or empty results - cached plugin artifacts can satisfy reinstall only when the configured marketplace snapshot still authorizes that plugin - cached plugin artifacts alone never make a plugin installable ## Tests - `cargo test -p codex-cli --test plugin_cli` - `cargo clippy -p codex-cli --tests -- -D warnings` - `cargo test -p codex-cli` - `git diff --check` - `just bazel-lock-update` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 78 ++++-- codex-rs/cli/src/marketplace_cmd.rs | 80 +++++- codex-rs/cli/src/plugin_cmd.rs | 405 ++++++++++++++++++++++++++++ codex-rs/cli/tests/plugin_cli.rs | 327 ++++++++++++++++++++++ 6 files changed, 870 insertions(+), 22 deletions(-) create mode 100644 codex-rs/cli/src/plugin_cmd.rs create mode 100644 codex-rs/cli/tests/plugin_cli.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9ecc708371..20197567ab 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2242,6 +2242,7 @@ dependencies = [ "codex-memories-write", "codex-model-provider", "codex-models-manager", + "codex-plugin", "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 0831163934..627d5d16d1 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -44,6 +44,7 @@ codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } codex-model-provider = { workspace = true } codex-models-manager = { workspace = true } +codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 38b3ea0c58..6e1ccac867 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -50,11 +50,13 @@ mod desktop_app; mod doctor; mod marketplace_cmd; mod mcp_cmd; +mod plugin_cmd; #[cfg(not(windows))] mod wsl_paths; -use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; +use crate::plugin_cmd::PluginCli; +use crate::plugin_cmd::PluginSubcommand; use doctor::DoctorCommand; use codex_config::LoaderOverrides; @@ -189,22 +191,6 @@ enum Subcommand { Features(FeaturesCli), } -#[derive(Debug, Parser)] -#[command(bin_name = "codex plugin")] -struct PluginCli { - #[clap(flatten)] - pub config_overrides: CliConfigOverrides, - - #[command(subcommand)] - subcommand: PluginSubcommand, -} - -#[derive(Debug, clap::Subcommand)] -enum PluginSubcommand { - /// Manage plugin marketplaces for Codex. - Marketplace(MarketplaceCli), -} - #[derive(Debug, Parser)] struct CompletionCommand { /// Shell to generate completions for @@ -947,10 +933,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } = plugin_cli; prepend_config_flags(&mut config_overrides, root_config_overrides.clone()); match subcommand { + PluginSubcommand::Add(args) => { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + plugin_cmd::run_plugin_add(overrides, args).await?; + } + PluginSubcommand::List(args) => { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + plugin_cmd::run_plugin_list(overrides, args).await?; + } PluginSubcommand::Marketplace(mut marketplace_cli) => { prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides); marketplace_cli.run().await?; } + PluginSubcommand::Remove(args) => { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + plugin_cmd::run_plugin_remove(overrides, args).await?; + } } } Some(Subcommand::AppServer(app_server_cli)) => { @@ -2339,6 +2343,7 @@ mod tests { for (subcommand, usage) in [ ("add", "Usage: codex plugin marketplace add"), + ("list", "Usage: codex plugin marketplace list"), ("upgrade", "Usage: codex plugin marketplace upgrade"), ("remove", "Usage: codex plugin marketplace remove"), ] { @@ -2365,6 +2370,45 @@ mod tests { assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); } + #[test] + fn plugin_add_parses_under_plugin() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "plugin", + "add", + "sample", + "--marketplace", + "debug", + ]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } + + #[test] + fn plugin_list_parses_under_plugin() { + let cli = + MultitoolCli::try_parse_from(["codex", "plugin", "list", "--marketplace", "debug"]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } + + #[test] + fn plugin_remove_parses_under_plugin() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "plugin", + "remove", + "sample", + "--marketplace", + "debug", + ]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } + #[test] fn update_parses_as_update_subcommand() { let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse"); diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index fcd9049d59..b4132a0127 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -6,10 +6,13 @@ use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; +use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; #[derive(Debug, Parser)] @@ -24,21 +27,36 @@ pub struct MarketplaceCli { #[derive(Debug, clap::Subcommand)] enum MarketplaceSubcommand { + /// Add a local or Git marketplace to the configured marketplace sources. Add(AddMarketplaceArgs), + + /// List configured marketplace names and their local snapshot roots. + List, + + /// Refresh configured Git marketplace snapshots. + /// + /// Omit MARKETPLACE_NAME to upgrade all configured Git marketplaces. Upgrade(UpgradeMarketplaceArgs), + + /// Remove a configured marketplace source by name. Remove(RemoveMarketplaceArgs), } #[derive(Debug, Parser)] -#[command(bin_name = "codex plugin marketplace add")] +#[command( + bin_name = "codex plugin marketplace add", + after_help = "Examples:\n codex plugin marketplace add ./path/to/marketplace\n codex plugin marketplace add owner/repo --ref main\n codex plugin marketplace add https://github.com/owner/repo --sparse plugins/foo" +)] struct AddMarketplaceArgs { - /// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, SSH URLs, - /// or local marketplace root directories. + /// Marketplace source: a local path, owner/repo[@ref], HTTPS Git URL, or SSH Git URL. + #[arg(value_name = "SOURCE")] source: String, + /// Git ref to fetch for Git marketplace sources. #[arg(long = "ref", value_name = "REF")] ref_name: Option, + /// Sparse checkout path for Git marketplace sources. Can be repeated. #[arg( long = "sparse", value_name = "PATH", @@ -48,15 +66,24 @@ struct AddMarketplaceArgs { } #[derive(Debug, Parser)] -#[command(bin_name = "codex plugin marketplace upgrade")] +#[command( + bin_name = "codex plugin marketplace upgrade", + after_help = "Examples:\n codex plugin marketplace upgrade\n codex plugin marketplace upgrade debug" +)] struct UpgradeMarketplaceArgs { + /// Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces. + #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: Option, } #[derive(Debug, Parser)] -#[command(bin_name = "codex plugin marketplace remove")] +#[command( + bin_name = "codex plugin marketplace remove", + after_help = "Example:\n codex plugin marketplace remove debug" +)] struct RemoveMarketplaceArgs { /// Configured marketplace name to remove. + #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: String, } @@ -73,6 +100,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, + MarketplaceSubcommand::List => run_list(overrides).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } @@ -118,6 +146,48 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { Ok(()) } +async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; + let configured_marketplaces = config + .config_layer_stack + .get_user_layer() + .and_then(|layer| layer.config.get("marketplaces")) + .and_then(toml::Value::as_table); + let Some(configured_marketplaces) = configured_marketplaces else { + println!("No configured plugin marketplaces."); + return Ok(()); + }; + + if configured_marketplaces.is_empty() { + println!("No configured plugin marketplaces."); + return Ok(()); + } + + let default_install_root = marketplace_install_root(config.codex_home.as_path()); + for (marketplace_name, marketplace) in configured_marketplaces { + if !marketplace.is_table() { + eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table."); + continue; + } + if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") { + eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}."); + continue; + } + let root = resolve_configured_marketplace_root( + marketplace_name, + marketplace, + default_install_root.as_path(), + ) + .map(|root| root.display().to_string()) + .unwrap_or_else(|| "".to_string()); + println!("{marketplace_name}\t{root}"); + } + + Ok(()) +} + async fn run_upgrade( overrides: Vec<(String, toml::Value)>, args: UpgradeMarketplaceArgs, diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs new file mode 100644 index 0000000000..dba06313e5 --- /dev/null +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -0,0 +1,405 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use clap::Parser; +use codex_core::config::Config; +use codex_core::config::find_codex_home; +use codex_core_plugins::ConfiguredMarketplace; +use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginsConfigInput; +use codex_core_plugins::PluginsManager; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; +use codex_core_plugins::marketplace::MarketplaceListError; +use codex_core_plugins::marketplace::find_marketplace_manifest_path; +use codex_plugin::PluginId; +use codex_plugin::validate_plugin_segment; +use codex_utils_cli::CliConfigOverrides; +use std::path::PathBuf; + +use crate::marketplace_cmd::MarketplaceCli; + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin")] +pub struct PluginCli { + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, + + #[command(subcommand)] + pub subcommand: PluginSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +pub enum PluginSubcommand { + /// Install a plugin from a configured marketplace snapshot. + /// + /// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with + /// `--marketplace MARKETPLACE`. + Add(AddPluginArgs), + + /// List plugins available from configured marketplace snapshots. + List(ListPluginsArgs), + + /// Add, list, upgrade, or remove configured plugin marketplaces. + Marketplace(MarketplaceCli), + + /// Remove an installed plugin from local config and cache. + /// + /// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with + /// `--marketplace MARKETPLACE`. + Remove(RemovePluginArgs), +} + +#[derive(Debug, Parser)] +#[command( + bin_name = "codex plugin add", + after_help = "Examples:\n codex plugin add sample@debug\n codex plugin add sample --marketplace debug" +)] +pub struct AddPluginArgs { + /// Plugin selector to install: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace. + #[arg(value_name = "PLUGIN[@MARKETPLACE]")] + plugin: String, + + /// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE. + #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] + marketplace_name: Option, +} + +#[derive(Debug, Parser)] +#[command( + bin_name = "codex plugin list", + after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug" +)] +pub struct ListPluginsArgs { + /// Only list plugins from this configured marketplace name. + #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] + marketplace_name: Option, +} + +#[derive(Debug, Parser)] +#[command( + bin_name = "codex plugin remove", + after_help = "Examples:\n codex plugin remove sample@debug\n codex plugin remove sample --marketplace debug" +)] +pub struct RemovePluginArgs { + /// Plugin selector to remove: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace. + #[arg(value_name = "PLUGIN[@MARKETPLACE]")] + plugin: String, + + /// Marketplace name to use when PLUGIN does not include @MARKETPLACE. + #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] + marketplace_name: Option, +} + +pub async fn run_plugin_add( + overrides: Vec<(String, toml::Value)>, + args: AddPluginArgs, +) -> Result<()> { + let PluginCommandContext { + codex_home, + plugins_input, + manager, + } = load_plugin_command_context(overrides).await?; + let PluginSelection { + plugin_name, + marketplace_name, + .. + } = parse_plugin_selection(args.plugin, args.marketplace_name)?; + let marketplace = find_marketplace_for_plugin( + &manager, + codex_home.as_path(), + &plugins_input, + &marketplace_name, + &plugin_name, + )?; + let outcome = manager + .install_plugin(PluginInstallRequest { + plugin_name, + marketplace_path: marketplace.path, + }) + .await?; + + println!( + "Added plugin `{}` from marketplace `{}`.", + outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name + ); + println!( + "Installed plugin root: {}", + outcome.installed_path.as_path().display() + ); + + Ok(()) +} + +pub async fn run_plugin_list( + overrides: Vec<(String, toml::Value)>, + args: ListPluginsArgs, +) -> Result<()> { + let PluginCommandContext { + codex_home, + plugins_input, + manager, + .. + } = load_plugin_command_context(overrides).await?; + let outcome = manager + .list_marketplaces_for_config(&plugins_input, &[]) + .context("failed to list marketplace plugins")?; + ensure_configured_marketplace_snapshots_loaded( + codex_home.as_path(), + &plugins_input, + &outcome.errors, + /*marketplace_name*/ None, + )?; + + let marketplaces = outcome + .marketplaces + .into_iter() + .filter(|marketplace| { + args.marketplace_name + .as_ref() + .is_none_or(|name| marketplace.name == *name) + }) + .collect::>(); + + if marketplaces.is_empty() { + if let Some(marketplace_name) = args.marketplace_name { + println!("No plugins found in marketplace `{marketplace_name}`."); + } else { + println!("No marketplace plugins found."); + } + } else { + for marketplace in marketplaces { + println!("Marketplace `{}`", marketplace.name); + println!("Path: {}", marketplace.path.as_path().display()); + for plugin in &marketplace.plugins { + let state = if plugin.installed && plugin.enabled { + "installed, enabled" + } else if plugin.installed { + "installed, disabled" + } else { + "not installed" + }; + println!(" {} ({state})", plugin.id); + } + } + } + + Ok(()) +} + +pub async fn run_plugin_remove( + overrides: Vec<(String, toml::Value)>, + args: RemovePluginArgs, +) -> Result<()> { + let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?; + let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?; + + manager.uninstall_plugin(selection.plugin_key).await?; + println!( + "Removed plugin `{}` from marketplace `{}`.", + selection.plugin_name, selection.marketplace_name + ); + + Ok(()) +} + +struct PluginCommandContext { + codex_home: PathBuf, + plugins_input: PluginsConfigInput, + manager: PluginsManager, +} + +async fn load_plugin_command_context( + overrides: Vec<(String, toml::Value)>, +) -> Result { + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; + let plugins_input = config.plugins_config_input(); + let manager = PluginsManager::new(codex_home.to_path_buf()); + Ok(PluginCommandContext { + codex_home: codex_home.to_path_buf(), + plugins_input, + manager, + }) +} + +struct PluginSelection { + plugin_name: String, + marketplace_name: String, + plugin_key: String, +} + +impl PluginSelection { + fn from_plugin_id(plugin_id: PluginId) -> Self { + let plugin_key = plugin_id.as_key(); + Self { + plugin_name: plugin_id.plugin_name, + marketplace_name: plugin_id.marketplace_name, + plugin_key, + } + } +} + +fn parse_plugin_selection( + plugin: String, + marketplace_name: Option, +) -> Result { + match (PluginId::parse(&plugin), marketplace_name) { + (Ok(plugin_id), None) => Ok(PluginSelection::from_plugin_id(plugin_id)), + (Ok(plugin_id), Some(marketplace_name)) => { + if plugin_id.marketplace_name != marketplace_name { + bail!( + "plugin id `{}` belongs to marketplace `{}`, but --marketplace specified `{}`", + plugin, + plugin_id.marketplace_name, + marketplace_name + ); + } + Ok(PluginSelection::from_plugin_id(plugin_id)) + } + (Err(_), Some(marketplace_name)) => Ok(PluginSelection::from_plugin_id(PluginId::new( + plugin, + marketplace_name, + )?)), + (Err(_), None) => { + bail!("plugin requires --marketplace unless passed as @") + } + } +} + +fn find_marketplace_for_plugin( + manager: &PluginsManager, + codex_home: &std::path::Path, + plugins_input: &PluginsConfigInput, + marketplace_name: &str, + plugin_name: &str, +) -> Result { + let outcome = manager + .list_marketplaces_for_config(plugins_input, &[]) + .context("failed to list marketplace plugins")?; + ensure_configured_marketplace_snapshots_loaded( + codex_home, + plugins_input, + &outcome.errors, + Some(marketplace_name), + )?; + let matches = outcome + .marketplaces + .into_iter() + .filter(|marketplace| marketplace.name == marketplace_name) + .filter(|marketplace| { + marketplace + .plugins + .iter() + .any(|plugin| plugin.name == plugin_name) + }) + .collect::>(); + + match matches.as_slice() { + [] => bail!("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`"), + [marketplace] => Ok(marketplace.clone()), + _ => bail!( + "plugin `{plugin_name}` in marketplace `{marketplace_name}` matched multiple marketplace roots" + ), + } +} + +struct ConfiguredMarketplaceSnapshotIssue { + marketplace_name: String, + path: PathBuf, + message: String, +} + +fn ensure_configured_marketplace_snapshots_loaded( + codex_home: &std::path::Path, + plugins_input: &PluginsConfigInput, + load_errors: &[MarketplaceListError], + marketplace_name: Option<&str>, +) -> Result<()> { + let issues = configured_marketplace_snapshot_issues( + codex_home, + plugins_input, + load_errors, + marketplace_name, + ); + if issues.is_empty() { + return Ok(()); + } + + let issue_lines = issues + .iter() + .map(|issue| { + format!( + "- `{}` at {}: {}", + issue.marketplace_name, + issue.path.display(), + issue.message + ) + }) + .collect::>() + .join("\n"); + bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}"); +} + +fn configured_marketplace_snapshot_issues( + codex_home: &std::path::Path, + plugins_input: &PluginsConfigInput, + load_errors: &[MarketplaceListError], + marketplace_name: Option<&str>, +) -> Vec { + let Some(user_layer) = plugins_input.config_layer_stack.get_user_layer() else { + return Vec::new(); + }; + let Some(configured_marketplaces) = user_layer + .config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return Vec::new(); + }; + + let default_install_root = marketplace_install_root(codex_home); + let mut manifest_paths = Vec::new(); + let mut issues = Vec::new(); + for (configured_name, marketplace) in configured_marketplaces { + if marketplace_name.is_some_and(|name| configured_name != name) { + continue; + } + if !marketplace.is_table() + || validate_plugin_segment(configured_name, "marketplace name").is_err() + { + continue; + } + let Some(root) = resolve_configured_marketplace_root( + configured_name, + marketplace, + &default_install_root, + ) else { + continue; + }; + match find_marketplace_manifest_path(&root) { + Some(path) => manifest_paths.push((configured_name.clone(), path)), + None => issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: root, + message: "marketplace root does not contain a supported manifest".to_string(), + }), + } + } + + for error in load_errors { + if let Some((configured_name, _)) = manifest_paths + .iter() + .find(|(_, path)| path.as_path() == error.path.as_path()) + { + issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: error.path.to_path_buf(), + message: error.message.clone(), + }); + } + } + issues +} diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs new file mode 100644 index 0000000000..9829e4df07 --- /dev/null +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -0,0 +1,327 @@ +use anyhow::Result; +use codex_config::CONFIG_TOML_FILE; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use predicates::prelude::PredicateBooleanExt; +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 codex_command_in(codex_home: &Path, current_dir: &Path) -> Result { + let mut cmd = codex_command(codex_home)?; + cmd.current_dir(current_dir); + Ok(cmd) +} + +fn configured_local_marketplace(source: &str) -> MarketplaceConfigUpdate<'_> { + MarketplaceConfigUpdate { + last_updated: "2026-05-06T00:00:00Z", + last_revision: None, + source_type: "local", + source, + ref_name: None, + sparse_paths: &[], + } +} + +fn write_plugins_enabled_config(codex_home: &Path) -> Result<()> { + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + )?; + Ok(()) +} + +fn write_marketplace_source(source: &Path) -> Result<()> { + std::fs::create_dir_all(source.join(".agents/plugins"))?; + std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; + std::fs::write( + source.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + )?; + std::fs::write( + source.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample","description":"Sample plugin"}"#, + )?; + Ok(()) +} + +fn setup_local_marketplace() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(source.path())?; + let source_path = source.path().to_string_lossy().into_owned(); + record_user_marketplace( + codex_home.path(), + "debug", + &configured_local_marketplace(&source_path), + )?; + Ok((codex_home, source)) +} + +fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(source.path())?; + Ok((codex_home, source)) +} + +fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + let source_path = source.path().to_string_lossy().into_owned(); + record_user_marketplace( + codex_home.path(), + "debug", + &configured_local_marketplace(&source_path), + )?; + Ok((codex_home, source)) +} + +fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::create_dir_all(source.path().join(".agents/plugins"))?; + std::fs::write( + source.path().join(".agents/plugins/marketplace.json"), + "{not valid json", + )?; + let source_path = source.path().to_string_lossy().into_owned(); + record_user_marketplace( + codex_home.path(), + "debug", + &configured_local_marketplace(&source_path), + )?; + Ok((codex_home, source)) +} + +#[tokio::test] +async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list"]) + .assert() + .success() + .stdout(contains("debug")) + .stdout(contains(source.path().display().to_string())); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_shows_plugins_grouped_by_marketplace() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "list"]) + .assert() + .success() + .stdout(contains("Marketplace `debug`")) + .stdout(contains("sample@debug (not installed)")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<()> { + let (codex_home, source) = setup_unconfigured_local_marketplace()?; + + codex_command_in(codex_home.path(), source.path())? + .args(["plugin", "list"]) + .assert() + .success() + .stdout(contains("No marketplace plugins found.")) + .stdout(predicates::str::is_match("sample@debug").unwrap().not()); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> { + let (codex_home, source) = setup_configured_marketplace_without_manifest()?; + + codex_command(codex_home.path())? + .args(["plugin", "list"]) + .assert() + .failure() + .stderr(contains( + "failed to load configured marketplace snapshot(s):", + )) + .stderr(contains("`debug`")) + .stderr(contains(source.path().display().to_string())) + .stderr(contains( + "marketplace root does not contain a supported manifest", + )); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success() + .stdout(contains("Added plugin `sample` from marketplace `debug`.")); + + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + assert!(config.contains("[plugins.\"sample@debug\"]")); + + codex_command(codex_home.path())? + .args(["plugin", "remove", "sample", "--marketplace", "debug"]) + .assert() + .success() + .stdout(contains( + "Removed plugin `sample` from marketplace `debug`.", + )); + + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + assert!(!config.contains("[plugins.\"sample@debug\"]")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> { + let (codex_home, source) = setup_unconfigured_local_marketplace()?; + + codex_command_in(codex_home.path(), source.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .failure() + .stderr(contains( + "plugin `sample` was not found in marketplace `debug`", + )); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> { + let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .failure() + .stderr(contains( + "failed to load configured marketplace snapshot(s):", + )) + .stderr(contains("`debug`")) + .stderr(contains("invalid marketplace file")) + .stderr(contains("key must be a string")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_reinstalls_from_configured_marketplace_snapshot() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success() + .stdout(contains("Added plugin `sample` from marketplace `debug`.")); + + assert!( + codex_home + .path() + .join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json") + .is_file() + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_remove_works_after_marketplace_is_removed() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample", "--marketplace", "debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "remove", "sample@debug"]) + .assert() + .success() + .stdout(contains( + "Removed plugin `sample` from marketplace `debug`.", + )); + + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + assert!(!config.contains("[plugins.\"sample@debug\"]")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_rejects_cached_plugins_without_authorizing_marketplace_snapshot() -> Result<()> +{ + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .success(); + + assert!( + codex_home + .path() + .join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json") + .is_file() + ); + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .failure() + .stderr(contains( + "plugin `sample` was not found in marketplace `debug`", + )); + + Ok(()) +}