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(()) +}