From 1fdd76accfd4751ac503b3d7dbe719a2886a6891 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Fri, 10 Apr 2026 13:16:13 -0700 Subject: [PATCH] Add marketplace list command --- codex-rs/cli/src/marketplace_cmd.rs | 4 + codex-rs/cli/src/marketplace_cmd/list.rs | 77 +++++++++++++++++ codex-rs/cli/src/marketplace_cmd/metadata.rs | 74 +++++++++++++++++ codex-rs/cli/tests/marketplace_add.rs | 87 ++++++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 codex-rs/cli/src/marketplace_cmd/list.rs diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index f5df805e4b..8477100dca 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -16,6 +16,7 @@ use std::path::PathBuf; use std::time::SystemTime; use std::time::UNIX_EPOCH; +mod list; mod metadata; mod ops; @@ -32,6 +33,8 @@ pub struct MarketplaceCli { enum MarketplaceSubcommand { /// Add a marketplace repository or local marketplace directory. Add(AddMarketplaceArgs), + /// List configured marketplaces. + List(list::ListMarketplaceArgs), } #[derive(Debug, Parser)] @@ -78,6 +81,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, + MarketplaceSubcommand::List(args) => list::run_list(args).await?, } Ok(()) diff --git a/codex-rs/cli/src/marketplace_cmd/list.rs b/codex-rs/cli/src/marketplace_cmd/list.rs new file mode 100644 index 0000000000..fd961bf68f --- /dev/null +++ b/codex-rs/cli/src/marketplace_cmd/list.rs @@ -0,0 +1,77 @@ +use super::metadata; +use anyhow::Context; +use anyhow::Result; +use clap::Parser; +use codex_core::config::find_codex_home; +use codex_core::plugins::marketplace_install_root; + +#[derive(Debug, Parser)] +pub(super) struct ListMarketplaceArgs { + /// Print the configured marketplaces as JSON. + #[arg(long)] + json: bool, +} + +pub(super) async fn run_list(args: ListMarketplaceArgs) -> Result<()> { + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let install_root = marketplace_install_root(&codex_home); + let marketplaces = metadata::configured_marketplaces(&codex_home, &install_root)?; + + if args.json { + let json = marketplaces + .into_iter() + .map(|marketplace| { + let install_metadata = marketplace.install_metadata; + let mut json = serde_json::Map::new(); + json.insert( + "name".to_string(), + serde_json::Value::String(marketplace.name), + ); + json.insert( + "sourceType".to_string(), + serde_json::Value::String(install_metadata.config_source_type().to_string()), + ); + match install_metadata.config_source_type() { + "directory" => { + json.insert( + "path".to_string(), + serde_json::Value::String(install_metadata.config_source()), + ); + } + "git" => { + json.insert( + "url".to_string(), + serde_json::Value::String(install_metadata.config_source()), + ); + } + _ => {} + } + json.insert( + "installLocation".to_string(), + serde_json::Value::String(marketplace.install_root.display().to_string()), + ); + serde_json::Value::Object(json) + }) + .collect::>(); + let output = serde_json::to_string_pretty(&json)?; + println!("{output}"); + return Ok(()); + } + + if marketplaces.is_empty() { + println!("No marketplaces configured."); + return Ok(()); + } + + for marketplace in marketplaces { + println!("{}", marketplace.name); + println!( + " Source: {} {}", + marketplace.install_metadata.config_source_type(), + marketplace.install_metadata.source_display() + ); + println!(" Install root: {}", marketplace.install_root.display()); + } + + Ok(()) +} diff --git a/codex-rs/cli/src/marketplace_cmd/metadata.rs b/codex-rs/cli/src/marketplace_cmd/metadata.rs index 6a6b3c0c49..306ada8685 100644 --- a/codex-rs/cli/src/marketplace_cmd/metadata.rs +++ b/codex-rs/cli/src/marketplace_cmd/metadata.rs @@ -1,11 +1,22 @@ use super::MarketplaceSource; use anyhow::Context; use anyhow::Result; +use anyhow::bail; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::ConfigToml; +use codex_config::types::MarketplaceConfig; +use codex_config::types::MarketplaceSourceType; use codex_core::plugins::validate_marketplace_root; use std::path::Path; use std::path::PathBuf; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ConfiguredMarketplace { + pub(super) name: String, + pub(super) install_root: PathBuf, + pub(super) install_metadata: MarketplaceInstallMetadata, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct MarketplaceInstallMetadata { source: InstalledMarketplaceSource, @@ -51,6 +62,28 @@ pub(super) fn installed_marketplace_root_for_source( Ok(None) } +pub(super) fn configured_marketplaces( + codex_home: &Path, + install_root: &Path, +) -> Result> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let config = read_user_config(&config_path)?; + let mut marketplaces = config + .marketplaces + .into_iter() + .map(|(name, config)| { + let install_metadata = MarketplaceInstallMetadata::from_config(&name, &config)?; + Ok(ConfiguredMarketplace { + install_root: install_root.join(&name), + name, + install_metadata, + }) + }) + .collect::>>()?; + marketplaces.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + Ok(marketplaces) +} + impl MarketplaceInstallMetadata { pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self { let source = match source { @@ -66,6 +99,27 @@ impl MarketplaceInstallMetadata { Self { source } } + fn from_config(marketplace_name: &str, config: &MarketplaceConfig) -> Result { + let Some(source_type) = config.source_type else { + bail!("marketplace `{marketplace_name}` is missing source_type in user config.toml"); + }; + let Some(source) = config.source.as_ref() else { + bail!("marketplace `{marketplace_name}` is missing source in user config.toml"); + }; + + let source = match source_type { + MarketplaceSourceType::Directory => InstalledMarketplaceSource::LocalDirectory { + path: PathBuf::from(source), + }, + MarketplaceSourceType::Git => InstalledMarketplaceSource::Git { + url: source.clone(), + ref_name: config.ref_name.clone(), + sparse_paths: config.sparse_paths.clone().unwrap_or_default(), + }, + }; + Ok(Self { source }) + } + pub(super) fn config_source_type(&self) -> &'static str { match &self.source { InstalledMarketplaceSource::LocalDirectory { .. } => "directory", @@ -94,6 +148,16 @@ impl MarketplaceInstallMetadata { } } + pub(super) fn source_display(&self) -> String { + match &self.source { + InstalledMarketplaceSource::LocalDirectory { path } => path.display().to_string(), + InstalledMarketplaceSource::Git { url, ref_name, .. } => match ref_name { + Some(ref_name) => format!("{url}#{ref_name}"), + None => url.clone(), + }, + } + } + fn matches_config(&self, marketplace: &toml::Value) -> bool { marketplace.get("source_type").and_then(toml::Value::as_str) == Some(self.config_source_type()) @@ -117,3 +181,13 @@ fn config_sparse_paths(marketplace: &toml::Value) -> Vec { }) .unwrap_or_default() } + +fn read_user_config(config_path: &Path) -> Result { + match std::fs::read_to_string(config_path) { + Ok(raw) => toml::from_str::(&raw) + .with_context(|| format!("failed to parse user config {}", config_path.display())), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ConfigToml::default()), + Err(err) => Err(err) + .with_context(|| format!("failed to read user config {}", config_path.display())), + } +} diff --git a/codex-rs/cli/tests/marketplace_add.rs b/codex-rs/cli/tests/marketplace_add.rs index 46dbe663e9..7ae4ca38a1 100644 --- a/codex-rs/cli/tests/marketplace_add.rs +++ b/codex-rs/cli/tests/marketplace_add.rs @@ -4,6 +4,7 @@ use codex_core::plugins::marketplace_install_root; use codex_core::plugins::validate_marketplace_root; use predicates::str::contains; use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; use std::path::Path; use tempfile::TempDir; @@ -284,3 +285,89 @@ async fn marketplace_add_rejects_ref_for_local_directory() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn marketplace_list_prints_configured_marketplaces() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "first install")?; + + codex_command(codex_home.path())? + .args(["marketplace", "add", source.path().to_str().unwrap()]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["marketplace", "list"]) + .assert() + .success() + .stdout(contains("debug")) + .stdout(contains("Source: directory")) + .stdout(contains( + source.path().canonicalize()?.display().to_string(), + )); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_json_includes_source_and_install_location() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "first install")?; + + codex_command(codex_home.path())? + .args(["marketplace", "add", source.path().to_str().unwrap()]) + .assert() + .success(); + + let output = codex_command(codex_home.path())? + .args(["marketplace", "list", "--json"]) + .output()?; + assert!(output.status.success()); + + let json: JsonValue = serde_json::from_slice(&output.stdout)?; + let entries = json + .as_array() + .context("list --json should return an array")?; + let expected_source = source.path().canonicalize()?.display().to_string(); + let expected_install_root = marketplace_install_root(codex_home.path()) + .join("debug") + .canonicalize()? + .display() + .to_string(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].get("name").and_then(JsonValue::as_str), + Some("debug") + ); + assert_eq!( + entries[0].get("sourceType").and_then(JsonValue::as_str), + Some("directory") + ); + assert_eq!( + entries[0].get("path").and_then(JsonValue::as_str), + Some(expected_source.as_str()) + ); + assert_eq!( + entries[0] + .get("installLocation") + .and_then(JsonValue::as_str), + Some(expected_install_root.as_str()) + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_without_marketplaces_prints_empty_message() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .args(["marketplace", "list"]) + .assert() + .success() + .stdout(contains("No marketplaces configured.")); + + Ok(()) +}