diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index f8c559e50..9ed2c1be6 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -12,10 +12,14 @@ 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::MarketplacePluginAuthPolicy; +use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; +use codex_core_plugins::marketplace::MarketplacePluginSource; 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 serde::Serialize; use std::path::Path; use std::path::PathBuf; @@ -73,12 +77,20 @@ pub struct AddPluginArgs { #[derive(Debug, Parser)] #[command( bin_name = "codex plugin list", - after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug" + after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug\n codex plugin list --json\n codex plugin list --available --json" )] pub struct ListPluginsArgs { /// Only list plugins from this configured marketplace name. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output plugin list as JSON. + #[arg(long = "json")] + json: bool, + + /// Include uninstalled marketplace plugins in the JSON output. + #[arg(long = "available", requires = "json")] + available: bool, } #[derive(Debug, Parser)] @@ -166,6 +178,12 @@ pub async fn run_plugin_list( }) .collect::>(); + if args.json { + let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if marketplaces.is_empty() { if let Some(marketplace_name) = args.marketplace_name { println!("No plugins found in marketplace `{marketplace_name}`."); @@ -240,6 +258,138 @@ pub async fn run_plugin_list( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginListOutput { + installed: Vec, + available: Vec, +} + +impl JsonPluginListOutput { + fn from_marketplaces( + marketplaces: Vec, + include_available: bool, + ) -> Self { + let mut installed = Vec::new(); + let mut available = Vec::new(); + + for marketplace in marketplaces { + for plugin in marketplace.plugins { + let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin); + if entry.installed { + installed.push(entry); + } else if include_available { + available.push(entry); + } + } + } + + Self { + installed, + available, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginListEntry { + plugin_id: String, + name: String, + marketplace_name: String, + version: Option, + installed: bool, + enabled: bool, + source: JsonPluginSource, + install_policy: &'static str, + auth_policy: &'static str, +} + +impl JsonPluginListEntry { + fn from_configured_plugin( + marketplace_name: &str, + plugin: codex_core_plugins::ConfiguredMarketplacePlugin, + ) -> Self { + let version = plugin.installed_version.or(plugin.local_version); + Self { + plugin_id: plugin.id, + name: plugin.name, + marketplace_name: marketplace_name.to_string(), + version, + installed: plugin.installed, + enabled: plugin.enabled, + source: JsonPluginSource::from_marketplace_source(plugin.source), + install_policy: install_policy_label(plugin.policy.installation), + auth_policy: auth_policy_label(plugin.policy.authentication), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "source", rename_all = "kebab-case")] +enum JsonPluginSource { + Local { + path: String, + }, + Git { + url: String, + #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] + ref_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sha: Option, + }, + GitSubdir { + url: String, + path: String, + #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] + ref_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sha: Option, + }, +} + +impl JsonPluginSource { + fn from_marketplace_source(source: MarketplacePluginSource) -> Self { + match source { + MarketplacePluginSource::Local { path } => Self::Local { + path: path.as_path().display().to_string(), + }, + MarketplacePluginSource::Git { + url, + path: Some(path), + ref_name, + sha, + } => Self::GitSubdir { + url, + path, + ref_name, + sha, + }, + MarketplacePluginSource::Git { + url, + path: None, + ref_name, + sha, + } => Self::Git { url, ref_name, sha }, + } + } +} + +fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { + match policy { + MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", + MarketplacePluginInstallPolicy::Available => "AVAILABLE", + MarketplacePluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT", + } +} + +fn auth_policy_label(policy: MarketplacePluginAuthPolicy) -> &'static str { + match policy { + MarketplacePluginAuthPolicy::OnInstall => "ON_INSTALL", + MarketplacePluginAuthPolicy::OnUse => "ON_USE", + } +} + pub async fn run_plugin_remove( overrides: Vec<(String, toml::Value)>, args: RemovePluginArgs, diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 5719b8f10..4ce361721 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -4,6 +4,8 @@ use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -489,6 +491,103 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + let plugin_path = source.path().join("plugins").join("sample"); + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--available", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [], + "available": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": false, + "enabled": false, + "source": { + "source": "local", + "path": plugin_path.display().to_string(), + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_json_prints_installed_plugins() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + let plugin_path = source.path().join("plugins").join("sample"); + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": true, + "enabled": true, + "source": { + "source": "local", + "path": plugin_path.display().to_string(), + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + "available": [], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_available_requires_json() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "list", "--available"]) + .assert() + .failure() + .stderr(contains( + "the following required arguments were not provided", + )) + .stderr(contains("--json")); + + Ok(()) +} + #[tokio::test] async fn plugin_list_shows_installed_version_when_plugin_is_installed() -> Result<()> { let (codex_home, _source) = setup_local_marketplace()?;