diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 9ed2c1be6..c850556e4 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -20,6 +20,7 @@ use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; use serde::Serialize; +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -177,9 +178,14 @@ pub async fn run_plugin_list( .is_none_or(|name| marketplace.name == *name) }) .collect::>(); + let marketplace_sources = configured_marketplace_sources(&plugins_input); if args.json { - let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available); + let output = JsonPluginListOutput::from_marketplaces( + marketplaces, + args.available, + &marketplace_sources, + ); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } @@ -269,13 +275,19 @@ impl JsonPluginListOutput { fn from_marketplaces( marketplaces: Vec, include_available: bool, + marketplace_sources: &HashMap, ) -> Self { let mut installed = Vec::new(); let mut available = Vec::new(); for marketplace in marketplaces { + let marketplace_source = marketplace_sources.get(&marketplace.name).cloned(); for plugin in marketplace.plugins { - let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin); + let entry = JsonPluginListEntry::from_configured_plugin( + &marketplace.name, + marketplace_source.clone(), + plugin, + ); if entry.installed { installed.push(entry); } else if include_available { @@ -301,6 +313,8 @@ struct JsonPluginListEntry { installed: bool, enabled: bool, source: JsonPluginSource, + #[serde(skip_serializing_if = "Option::is_none")] + marketplace_source: Option, install_policy: &'static str, auth_policy: &'static str, } @@ -308,6 +322,7 @@ struct JsonPluginListEntry { impl JsonPluginListEntry { fn from_configured_plugin( marketplace_name: &str, + marketplace_source: Option, plugin: codex_core_plugins::ConfiguredMarketplacePlugin, ) -> Self { let version = plugin.installed_version.or(plugin.local_version); @@ -319,6 +334,7 @@ impl JsonPluginListEntry { installed: plugin.installed, enabled: plugin.enabled, source: JsonPluginSource::from_marketplace_source(plugin.source), + marketplace_source, install_policy: install_policy_label(plugin.policy.installation), auth_policy: auth_policy_label(plugin.policy.authentication), } @@ -375,6 +391,44 @@ impl JsonPluginSource { } } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceSource { + source_type: String, + source: String, +} + +fn configured_marketplace_sources( + plugins_input: &PluginsConfigInput, +) -> HashMap { + let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { + return HashMap::new(); + }; + let Some(marketplaces) = user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return HashMap::new(); + }; + + marketplaces + .iter() + .filter_map(|(marketplace_name, marketplace)| { + let source_type = marketplace + .get("source_type") + .and_then(toml::Value::as_str)?; + let source = marketplace.get("source").and_then(toml::Value::as_str)?; + Some(( + marketplace_name.clone(), + JsonMarketplaceSource { + source_type: source_type.to_string(), + source: source.to_string(), + }, + )) + }) + .collect() +} + fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { match policy { MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 4ce361721..2625e0b68 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -2,6 +2,7 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -495,6 +496,7 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { 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 source_path = source.path().to_string_lossy().into_owned(); let assert = codex_command(codex_home.path())? .args(["plugin", "list", "--available", "--json"]) @@ -519,6 +521,69 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_json_includes_configured_git_marketplace_source() -> Result<()> { + let codex_home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let plugin_path = marketplace_root.join("plugins").join("sample"); + let normalized_plugin_path = canonicalize_existing_preserving_symlinks(&plugin_path)?; + + 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": normalized_plugin_path.display().to_string(), + }, + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", }, @@ -533,6 +598,7 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() 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"); + let source_path = source.path().to_string_lossy().into_owned(); codex_command(codex_home.path())? .args(["plugin", "add", "sample@debug"]) @@ -561,6 +627,10 @@ async fn plugin_list_json_prints_installed_plugins() -> Result<()> { "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", },