Add marketplace list command

This commit is contained in:
xli-oai
2026-04-10 13:16:13 -07:00
parent 674015acdf
commit 1fdd76accf
4 changed files with 242 additions and 0 deletions
+4
View File
@@ -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(())
+77
View File
@@ -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::<Vec<_>>();
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(())
}
@@ -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<Vec<ConfiguredMarketplace>> {
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::<Result<Vec<_>>>()?;
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<Self> {
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<String> {
})
.unwrap_or_default()
}
fn read_user_config(config_path: &Path) -> Result<ConfigToml> {
match std::fs::read_to_string(config_path) {
Ok(raw) => toml::from_str::<ConfigToml>(&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())),
}
}
+87
View File
@@ -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(())
}