diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 95dba06f17be..fc4d9eef6206 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -7,11 +7,14 @@ use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; use codex_core_plugins::marketplace::marketplace_root_dir; +use codex_core_plugins::marketplace_add::MarketplaceAddOutcome; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveOutcome; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; use std::collections::HashSet; use crate::plugin_cmd::configured_marketplace_snapshot_issues; @@ -32,7 +35,7 @@ enum MarketplaceSubcommand { Add(AddMarketplaceArgs), /// List plugin marketplaces Codex is currently considering and their roots. - List, + List(ListMarketplaceArgs), /// Refresh configured Git marketplace snapshots. /// @@ -64,6 +67,18 @@ struct AddMarketplaceArgs { action = clap::ArgAction::Append )] sparse_paths: Vec, + + /// Output add result as JSON. + #[arg(long = "json")] + json: bool, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace list")] +struct ListMarketplaceArgs { + /// Output marketplace list as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -75,6 +90,10 @@ struct UpgradeMarketplaceArgs { /// Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: Option, + + /// Output upgrade result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -86,6 +105,10 @@ struct RemoveMarketplaceArgs { /// Configured marketplace name to remove. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: String, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } impl MarketplaceCli { @@ -101,7 +124,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, - MarketplaceSubcommand::List => run_list(overrides).await?, + MarketplaceSubcommand::List(args) => run_list(overrides, args).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } @@ -115,6 +138,7 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { source, ref_name, sparse_paths, + json, } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; @@ -128,6 +152,12 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if outcome.already_added { println!( "Marketplace `{}` is already added from {}.", @@ -147,7 +177,25 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { Ok(()) } -async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceAddOutput { + marketplace_name: String, + installed_root: String, + already_added: bool, +} + +impl JsonMarketplaceAddOutput { + fn from_outcome(outcome: MarketplaceAddOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.installed_root.as_path().display().to_string(), + already_added: outcome.already_added, + } + } +} + +async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceArgs) -> Result<()> { let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -191,6 +239,12 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { bail!("failed to load marketplace(s):\n{issue_lines}"); } let marketplaces = marketplace_listing.marketplaces; + if args.json { + let output = JsonMarketplaceListOutput::from_marketplaces(marketplaces); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if marketplaces.is_empty() { println!("No plugin marketplaces in scope."); return Ok(()); @@ -227,11 +281,48 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListOutput { + marketplaces: Vec, +} + +impl JsonMarketplaceListOutput { + fn from_marketplaces(marketplaces: Vec) -> Self { + let mut seen_roots = HashSet::new(); + let marketplaces = marketplaces + .into_iter() + .filter_map(|marketplace| { + let root = marketplace_root_dir(&marketplace.path).ok()?; + if !seen_roots.insert(root.clone()) { + return None; + } + Some(JsonMarketplaceListEntry { + name: marketplace.name, + root: root.display().to_string(), + }) + }) + .collect(); + + Self { marketplaces } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListEntry { + name: String, + root: String, +} + async fn run_upgrade( overrides: Vec<(String, toml::Value)>, args: UpgradeMarketplaceArgs, ) -> Result<()> { - let UpgradeMarketplaceArgs { marketplace_name } = args; + let UpgradeMarketplaceArgs { + marketplace_name, + json, + } = args; let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -241,11 +332,18 @@ async fn run_upgrade( let outcome = manager .upgrade_configured_marketplaces_for_config(&plugins_input, marketplace_name.as_deref()) .map_err(anyhow::Error::msg)?; - print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + if json { + print_upgrade_outcome_json(&outcome) + } else { + print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + } } async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { - let RemoveMarketplaceArgs { marketplace_name } = args; + let RemoveMarketplaceArgs { + marketplace_name, + json, + } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let outcome = remove_marketplace( codex_home.to_path_buf(), @@ -253,6 +351,12 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceRemoveOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!("Removed marketplace `{}`.", outcome.marketplace_name); if let Some(installed_root) = outcome.removed_installed_root { println!( @@ -264,6 +368,76 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceRemoveOutput { + marketplace_name: String, + installed_root: Option, +} + +impl JsonMarketplaceRemoveOutput { + fn from_outcome(outcome: MarketplaceRemoveOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome + .removed_installed_root + .map(|root| root.as_path().display().to_string()), + } + } +} + +fn print_upgrade_outcome_json(outcome: &PluginMarketplaceUpgradeOutcome) -> Result<()> { + for error in &outcome.errors { + eprintln!( + "Failed to upgrade marketplace `{}`: {}", + error.marketplace_name, error.message + ); + } + if !outcome.all_succeeded() { + bail!("{} upgrade failure(s) occurred.", outcome.errors.len()); + } + + let output = JsonMarketplaceUpgradeOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeOutput { + selected_marketplaces: Vec, + upgraded_roots: Vec, + errors: Vec, +} + +impl JsonMarketplaceUpgradeOutput { + fn from_outcome(outcome: &PluginMarketplaceUpgradeOutcome) -> Self { + Self { + selected_marketplaces: outcome.selected_marketplaces.clone(), + upgraded_roots: outcome + .upgraded_roots + .iter() + .map(|root| root.display().to_string()) + .collect(), + errors: outcome + .errors + .iter() + .map(|error| JsonMarketplaceUpgradeError { + marketplace_name: error.marketplace_name.clone(), + message: error.message.clone(), + }) + .collect(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeError { + marketplace_name: String, + message: String, +} + fn print_upgrade_outcome( outcome: &PluginMarketplaceUpgradeOutcome, marketplace_name: Option<&str>, diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index c850556e4d47..7def7283c0fd 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -6,6 +6,7 @@ use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::ConfiguredMarketplace; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; +use codex_core_plugins::PluginInstallOutcome; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; @@ -73,6 +74,10 @@ pub struct AddPluginArgs { /// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output install result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -107,6 +112,10 @@ pub struct RemovePluginArgs { /// Marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } pub async fn run_plugin_add( @@ -118,11 +127,16 @@ pub async fn run_plugin_add( plugins_input, manager, } = load_plugin_command_context(overrides).await?; + let AddPluginArgs { + plugin, + marketplace_name, + json, + } = args; let PluginSelection { plugin_name, marketplace_name, .. - } = parse_plugin_selection(args.plugin, args.marketplace_name)?; + } = parse_plugin_selection(plugin, marketplace_name)?; let marketplace = find_marketplace_for_plugin( &manager, codex_home.as_path(), @@ -137,6 +151,12 @@ pub async fn run_plugin_add( }) .await?; + if json { + let output = JsonPluginAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!( "Added plugin `{}` from marketplace `{}`.", outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name @@ -149,6 +169,30 @@ pub async fn run_plugin_add( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginAddOutput { + plugin_id: String, + name: String, + marketplace_name: String, + version: String, + installed_path: String, + auth_policy: &'static str, +} + +impl JsonPluginAddOutput { + fn from_outcome(outcome: PluginInstallOutcome) -> Self { + Self { + plugin_id: outcome.plugin_id.as_key(), + name: outcome.plugin_id.plugin_name, + marketplace_name: outcome.plugin_id.marketplace_name, + version: outcome.plugin_version, + installed_path: outcome.installed_path.as_path().display().to_string(), + auth_policy: auth_policy_label(outcome.auth_policy), + } + } +} + pub async fn run_plugin_list( overrides: Vec<(String, toml::Value)>, args: ListPluginsArgs, @@ -449,9 +493,22 @@ pub async fn run_plugin_remove( args: RemovePluginArgs, ) -> Result<()> { let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?; - let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?; + let RemovePluginArgs { + plugin, + marketplace_name, + json, + } = args; + let selection = parse_plugin_selection(plugin, marketplace_name)?; + + manager + .uninstall_plugin(selection.plugin_key.clone()) + .await?; + if json { + let output = JsonPluginRemoveOutput::from_selection(selection); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } - manager.uninstall_plugin(selection.plugin_key).await?; println!( "Removed plugin `{}` from marketplace `{}`.", selection.plugin_name, selection.marketplace_name @@ -460,6 +517,24 @@ pub async fn run_plugin_remove( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginRemoveOutput { + plugin_id: String, + name: String, + marketplace_name: String, +} + +impl JsonPluginRemoveOutput { + fn from_selection(selection: PluginSelection) -> Self { + Self { + plugin_id: selection.plugin_key, + name: selection.plugin_name, + marketplace_name: selection.marketplace_name, + } + } +} + struct PluginCommandContext { codex_home: PathBuf, plugins_input: PluginsConfigInput, diff --git a/codex-rs/cli/tests/marketplace_add.rs b/codex-rs/cli/tests/marketplace_add.rs index 5ab18e24c48f..0439846a30fc 100644 --- a/codex-rs/cli/tests/marketplace_add.rs +++ b/codex-rs/cli/tests/marketplace_add.rs @@ -1,8 +1,10 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::AbsolutePathBuf; use predicates::str::contains; use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -70,6 +72,41 @@ async fn marketplace_add_local_directory_source() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_add_json_prints_add_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "local ref")?; + let source_parent = source.path().parent().unwrap(); + let source_arg = format!("./{}", source.path().file_name().unwrap().to_string_lossy()); + + let assert = codex_command(codex_home.path())? + .current_dir(source_parent) + .args([ + "plugin", + "marketplace", + "add", + source_arg.as_str(), + "--json", + ]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let expected_installed_root = AbsolutePathBuf::try_from(source.path().canonicalize()?)?; + + assert_eq!( + actual, + json!({ + "marketplaceName": "debug", + "installedRoot": expected_installed_root.as_path().display().to_string(), + "alreadyAdded": false, + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_remove.rs b/codex-rs/cli/tests/marketplace_remove.rs index 5c8c7a1f9164..b124574106f6 100644 --- a/codex-rs/cli/tests/marketplace_remove.rs +++ b/codex-rs/cli/tests/marketplace_remove.rs @@ -2,7 +2,10 @@ use anyhow::Result; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -54,6 +57,32 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_remove_json_prints_remove_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + let normalized_installed_root = canonicalize_existing_preserving_symlinks(&installed_root)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug", "--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!({ + "marketplaceName": "debug", + "installedRoot": normalized_installed_root.display().to_string(), + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_upgrade.rs b/codex-rs/cli/tests/marketplace_upgrade.rs index 268d75358e92..a2cf11f4af39 100644 --- a/codex-rs/cli/tests/marketplace_upgrade.rs +++ b/codex-rs/cli/tests/marketplace_upgrade.rs @@ -1,5 +1,7 @@ use anyhow::Result; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -22,6 +24,29 @@ async fn marketplace_upgrade_runs_under_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_upgrade_json_prints_upgrade_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "upgrade", "--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!({ + "selectedMarketplaces": [], + "upgradedRoots": [], + "errors": [], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 2625e0b68b28..cda79cf9a180 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -338,6 +338,32 @@ async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "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!({ + "marketplaces": [ + { + "name": "debug", + "root": source.path().display().to_string(), + }, + ], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> { let codex_home = TempDir::new()?; @@ -802,6 +828,69 @@ async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_add_json_prints_install_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let installed_path = codex_home.path().join("plugins/cache/debug/sample/1.2.3"); + let normalized_installed_path = canonicalize_existing_preserving_symlinks(&installed_path)?; + + assert_eq!( + actual, + json!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installedPath": normalized_installed_path.display().to_string(), + "authPolicy": "ON_INSTALL", + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_remove_json_prints_remove_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + let assert = codex_command(codex_home.path())? + .args([ + "plugin", + "remove", + "sample", + "--marketplace", + "debug", + "--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!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + }) + ); + + Ok(()) +} + #[tokio::test] async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> { let (codex_home, source) = setup_unconfigured_local_marketplace()?;