diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d15a1062d9..772cd041bb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2223,6 +2223,7 @@ dependencies = [ "codex-mcp-server", "codex-memories-write", "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 120d2e497a..7362e1a2f1 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -41,6 +41,7 @@ codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { 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 d04b7092dd..6cf8fbca05 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -48,11 +48,13 @@ mod app_cmd; mod desktop_app; 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 codex_core::build_models_manager; use codex_core::config::Config; @@ -181,22 +183,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 @@ -886,10 +872,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)) => { @@ -2040,6 +2044,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"), ] { @@ -2066,6 +2071,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..56fc83c4b9 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)] @@ -25,6 +28,7 @@ pub struct MarketplaceCli { #[derive(Debug, clap::Subcommand)] enum MarketplaceSubcommand { Add(AddMarketplaceArgs), + List, Upgrade(UpgradeMarketplaceArgs), Remove(RemoveMarketplaceArgs), } @@ -73,6 +77,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 +123,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..68cb6dfdb1 --- /dev/null +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -0,0 +1,280 @@ +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_plugin::PluginId; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; + +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 marketplace. + Add(AddPluginArgs), + + /// List marketplace plugins. + List(ListPluginsArgs), + + /// Manage plugin marketplaces for Codex. + Marketplace(MarketplaceCli), + + /// Remove an installed plugin. + Remove(RemovePluginArgs), +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin add")] +pub struct AddPluginArgs { + /// Plugin to install. Accepts with --marketplace or @. + plugin: String, + + /// Marketplace name containing the plugin. + #[arg(long = "marketplace", short = 'm')] + marketplace_name: Option, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin list")] +pub struct ListPluginsArgs { + /// Only list plugins in this marketplace. + #[arg(long = "marketplace", short = 'm')] + marketplace_name: Option, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin remove")] +pub struct RemovePluginArgs { + /// Plugin to remove. Accepts with --marketplace or @. + plugin: String, + + /// Marketplace name containing the plugin. + #[arg(long = "marketplace", short = 'm')] + marketplace_name: Option, +} + +pub async fn run_plugin_add( + overrides: Vec<(String, toml::Value)>, + args: AddPluginArgs, +) -> Result<()> { + let PluginCommandContext { + 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, &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 { + plugins_input, + manager, + .. + } = load_plugin_command_context(overrides).await?; + let current_dir = AbsolutePathBuf::try_from(std::env::current_dir()?) + .context("failed to resolve current directory")?; + let outcome = manager + .list_marketplaces_for_config(&plugins_input, &[current_dir]) + .context("failed to list marketplace plugins")?; + + 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); + } + } + } + + for error in outcome.errors { + eprintln!( + "Failed to load marketplace {}: {}", + error.path.as_path().display(), + error.message + ); + } + + 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 { + 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 { + plugins_input, + manager, + }) +} + +struct PluginSelection { + plugin_name: String, + marketplace_name: String, + plugin_key: String, +} + +fn parse_plugin_selection( + plugin: String, + marketplace_name: Option, +) -> Result { + match (PluginId::parse(&plugin), marketplace_name) { + (Ok(plugin_id), None) => { + let plugin_key = plugin_id.as_key(); + Ok(PluginSelection { + plugin_name: plugin_id.plugin_name, + marketplace_name: plugin_id.marketplace_name, + plugin_key, + }) + } + (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 + ); + } + let plugin_key = plugin_id.as_key(); + Ok(PluginSelection { + plugin_name: plugin_id.plugin_name, + marketplace_name: plugin_id.marketplace_name, + plugin_key, + }) + } + (Err(_), Some(marketplace_name)) => { + let plugin_id = PluginId::new(plugin, marketplace_name)?; + let plugin_key = plugin_id.as_key(); + Ok(PluginSelection { + plugin_name: plugin_id.plugin_name, + marketplace_name: plugin_id.marketplace_name, + plugin_key, + }) + } + (Err(_), None) => { + bail!("plugin requires --marketplace unless passed as @") + } + } +} + +fn find_marketplace_for_plugin( + manager: &PluginsManager, + plugins_input: &PluginsConfigInput, + marketplace_name: &str, + plugin_name: &str, +) -> Result { + let current_dir = AbsolutePathBuf::try_from(std::env::current_dir()?) + .context("failed to resolve current directory")?; + let matches = manager + .list_marketplaces_for_config(plugins_input, &[current_dir]) + .context("failed to list marketplace plugins")? + .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" + ), + } +} diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs new file mode 100644 index 0000000000..f33f2f3743 --- /dev/null +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -0,0 +1,178 @@ +use anyhow::Result; +use codex_config::CONFIG_TOML_FILE; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +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_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(()) +} + +#[tokio::test] +async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { + 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), + )?; + + 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 = 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), + )?; + + 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_add_and_remove_updates_installed_plugin_config() -> Result<()> { + 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), + )?; + + 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_remove_works_after_marketplace_is_removed() -> Result<()> { + 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), + )?; + + 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(()) +}