Compare commits

...

7 Commits

Author SHA1 Message Date
xli-oai
d7d719f518 Add marketplace update command 2026-04-08 11:52:13 -07:00
xli-oai
614e2f4154 Revert "Materialize curated marketplace with known marketplaces"
This reverts commit ccfe948a7e.
2026-04-08 11:50:25 -07:00
xli-oai
ccfe948a7e Materialize curated marketplace with known marketplaces 2026-04-08 11:13:47 -07:00
xli-oai
faaff79b41 Record added marketplaces in local registry 2026-04-08 10:32:47 -07:00
xli-oai
b8a9e8870a Handle marketplace add edge cases 2026-04-08 09:38:55 -07:00
xli-oai
1e5bddeb93 Store added marketplaces under plugin temp dir 2026-04-08 02:14:27 -07:00
xli-oai
a3e9ac4fe2 Add marketplace add command 2026-04-07 18:28:43 -07:00
9 changed files with 1451 additions and 0 deletions

View File

@@ -38,10 +38,12 @@ use supports_color::Stream;
mod app_cmd;
#[cfg(target_os = "macos")]
mod desktop_app;
mod marketplace_cmd;
mod mcp_cmd;
#[cfg(not(windows))]
mod wsl_paths;
use crate::marketplace_cmd::MarketplaceCli;
use crate::mcp_cmd::McpCli;
use codex_core::config::Config;
@@ -105,6 +107,9 @@ enum Subcommand {
/// Manage external MCP servers for Codex.
Mcp(McpCli),
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
/// Start Codex as an MCP server (stdio).
McpServer,
@@ -691,6 +696,18 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::Marketplace(mut marketplace_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"marketplace",
)?;
prepend_config_flags(
&mut marketplace_cli.config_overrides,
root_config_overrides.clone(),
);
marketplace_cli.run().await?;
}
Some(Subcommand::AppServer(app_server_cli)) => {
let AppServerCommand {
subcommand,

View File

@@ -0,0 +1,625 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::find_codex_home;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core::plugins::marketplace_install_root;
use codex_core::plugins::record_installed_marketplace_root;
use codex_core::plugins::validate_marketplace_root;
use codex_utils_cli::CliConfigOverrides;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
mod metadata;
mod update;
#[derive(Debug, Parser)]
pub struct MarketplaceCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
subcommand: MarketplaceSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum MarketplaceSubcommand {
/// Add a marketplace repository or local marketplace directory.
Add(AddMarketplaceArgs),
/// Refresh one added marketplace, or every added marketplace when NAME is omitted.
Update(update::UpdateMarketplaceArgs),
}
#[derive(Debug, Parser)]
struct AddMarketplaceArgs {
/// Marketplace source. Supports owner/repo[@ref], git URLs, SSH URLs, or local directories.
source: String,
/// Git ref to check out. Overrides any @ref or #ref suffix in SOURCE.
#[arg(long = "ref", value_name = "REF")]
ref_name: Option<String>,
/// Sparse-checkout paths to use while cloning git sources.
#[arg(long = "sparse", value_name = "PATH", num_args = 1..)]
sparse_paths: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum MarketplaceSource {
LocalDirectory {
path: PathBuf,
source_id: String,
},
Git {
url: String,
ref_name: Option<String>,
source_id: String,
},
}
impl MarketplaceCli {
pub async fn run(self) -> Result<()> {
let MarketplaceCli {
config_overrides,
subcommand,
} = self;
// Validate overrides now. This command writes to CODEX_HOME only; marketplace discovery
// happens from that cache root after the next plugin/list or app-server start.
config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
MarketplaceSubcommand::Update(args) => update::run_update(args).await?,
}
Ok(())
}
}
async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
let AddMarketplaceArgs {
source,
ref_name,
sparse_paths,
} = args;
let source = parse_marketplace_source(&source, ref_name)?;
if !sparse_paths.is_empty() && !matches!(source, MarketplaceSource::Git { .. }) {
bail!("--sparse can only be used with git marketplace sources");
}
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let install_root = marketplace_install_root(&codex_home);
fs::create_dir_all(&install_root).with_context(|| {
format!(
"failed to create marketplace install directory {}",
install_root.display()
)
})?;
let install_metadata =
metadata::MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
if let Some(existing_root) =
metadata::installed_marketplace_root_for_source(&install_root, &install_metadata.source_id)?
{
let marketplace_name = validate_marketplace_root(&existing_root).with_context(|| {
format!(
"failed to validate installed marketplace at {}",
existing_root.display()
)
})?;
println!(
"Marketplace `{marketplace_name}` is already added from {}.",
source.display()
);
println!("Installed marketplace root: {}", existing_root.display());
return Ok(());
}
let staging_root = marketplace_staging_root(&install_root);
fs::create_dir_all(&staging_root).with_context(|| {
format!(
"failed to create marketplace staging directory {}",
staging_root.display()
)
})?;
let staged_dir = tempfile::Builder::new()
.prefix("marketplace-add-")
.tempdir_in(&staging_root)
.with_context(|| {
format!(
"failed to create temporary marketplace directory in {}",
staging_root.display()
)
})?;
let staged_root = staged_dir.path().to_path_buf();
match &source {
MarketplaceSource::LocalDirectory { path, .. } => {
copy_dir_recursive(path, &staged_root).with_context(|| {
format!(
"failed to copy marketplace source {} into {}",
path.display(),
staged_root.display()
)
})?;
}
MarketplaceSource::Git { url, ref_name, .. } => {
clone_git_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
}
}
let marketplace_name = validate_marketplace_root(&staged_root)
.with_context(|| format!("failed to validate marketplace from {}", source.display()))?;
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
bail!(
"marketplace `{OPENAI_CURATED_MARKETPLACE_NAME}` is reserved and cannot be added from {}",
source.display()
);
}
metadata::write_marketplace_source_metadata(&staged_root, &install_metadata)?;
let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);
ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;
replace_marketplace_root(&staged_root, &destination)
.with_context(|| format!("failed to install marketplace at {}", destination.display()))?;
record_installed_marketplace_root(&codex_home, &marketplace_name, &destination)
.with_context(|| format!("failed to record marketplace `{marketplace_name}`"))?;
println!(
"Added marketplace `{marketplace_name}` from {}.",
source.display()
);
println!("Installed marketplace root: {}", destination.display());
Ok(())
}
fn parse_marketplace_source(
source: &str,
explicit_ref: Option<String>,
) -> Result<MarketplaceSource> {
let source = source.trim();
if source.is_empty() {
bail!("marketplace source must not be empty");
}
let source = expand_home(source);
let path = PathBuf::from(&source);
let path_exists = path.try_exists().with_context(|| {
format!(
"failed to access local marketplace source {}",
path.display()
)
})?;
if path_exists || looks_like_local_path(&source) {
if !path_exists {
bail!(
"local marketplace source does not exist: {}",
path.display()
);
}
let metadata = path.metadata().with_context(|| {
format!("failed to read local marketplace source {}", path.display())
})?;
if metadata.is_file() {
if path
.extension()
.is_some_and(|extension| extension == "json")
{
bail!(
"local marketplace JSON files are not supported yet; pass the marketplace root directory containing .agents/plugins/marketplace.json: {}",
path.display()
);
}
bail!(
"local marketplace source file must be a JSON marketplace manifest or a directory containing .agents/plugins/marketplace.json: {}",
path.display()
);
}
if !metadata.is_dir() {
bail!(
"local marketplace source must be a file or directory: {}",
path.display()
);
}
let path = path
.canonicalize()
.with_context(|| format!("failed to resolve {}", path.display()))?;
return Ok(MarketplaceSource::LocalDirectory {
source_id: format!("directory:{}", path.display()),
path,
});
}
let (base_source, parsed_ref) = split_source_ref(&source);
let ref_name = explicit_ref.or(parsed_ref);
if is_ssh_git_url(&base_source) || is_http_git_url(&base_source) {
let url = normalize_git_url(&base_source);
return Ok(MarketplaceSource::Git {
source_id: git_source_id("git", &url, ref_name.as_deref()),
url,
ref_name,
});
}
if looks_like_github_shorthand(&base_source) {
let url = format!("https://github.com/{base_source}.git");
return Ok(MarketplaceSource::Git {
source_id: git_source_id("github", &base_source, ref_name.as_deref()),
url,
ref_name,
});
}
if base_source.starts_with("http://") || base_source.starts_with("https://") {
bail!(
"URL marketplace manifests are not supported yet; pass a git repository URL or a local marketplace directory"
);
}
bail!("invalid marketplace source format: {source}");
}
fn git_source_id(kind: &str, source: &str, ref_name: Option<&str>) -> String {
if let Some(ref_name) = ref_name {
format!("{kind}:{source}#{ref_name}")
} else {
format!("{kind}:{source}")
}
}
fn split_source_ref(source: &str) -> (String, Option<String>) {
if let Some((base, ref_name)) = source.rsplit_once('#') {
return (base.to_string(), non_empty_ref(ref_name));
}
if !source.contains("://")
&& !is_ssh_git_url(source)
&& let Some((base, ref_name)) = source.rsplit_once('@')
{
return (base.to_string(), non_empty_ref(ref_name));
}
(source.to_string(), None)
}
fn non_empty_ref(ref_name: &str) -> Option<String> {
let ref_name = ref_name.trim();
(!ref_name.is_empty()).then(|| ref_name.to_string())
}
fn normalize_git_url(url: &str) -> String {
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
format!("{url}.git")
} else {
url.to_string()
}
}
fn looks_like_local_path(source: &str) -> bool {
source.starts_with("./")
|| source.starts_with("../")
|| source.starts_with('/')
|| source.starts_with("~/")
|| source == "."
|| source == ".."
}
fn expand_home(source: &str) -> String {
let Some(rest) = source.strip_prefix("~/") else {
return source.to_string();
};
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest).display().to_string();
}
source.to_string()
}
fn is_ssh_git_url(source: &str) -> bool {
source.starts_with("git@") && source.contains(':')
}
fn is_http_git_url(source: &str) -> bool {
(source.starts_with("http://") || source.starts_with("https://"))
&& (source.ends_with(".git") || source.starts_with("https://github.com/"))
}
fn looks_like_github_shorthand(source: &str) -> bool {
let mut segments = source.split('/');
let owner = segments.next();
let repo = segments.next();
let extra = segments.next();
owner.is_some_and(is_github_shorthand_segment)
&& repo.is_some_and(is_github_shorthand_segment)
&& extra.is_none()
}
fn is_github_shorthand_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
}
pub(super) fn clone_git_source(
url: &str,
ref_name: Option<&str>,
sparse_paths: &[String],
destination: &Path,
) -> Result<()> {
let destination = destination.to_string_lossy().to_string();
if sparse_paths.is_empty() {
run_git(&["clone", url, destination.as_str()], None)?;
if let Some(ref_name) = ref_name {
run_git(&["checkout", ref_name], Some(Path::new(&destination)))?;
}
return Ok(());
}
run_git(
&[
"clone",
"--filter=blob:none",
"--no-checkout",
url,
destination.as_str(),
],
None,
)?;
let mut sparse_args = vec!["sparse-checkout", "set"];
sparse_args.extend(sparse_paths.iter().map(String::as_str));
let destination = Path::new(&destination);
run_git(&sparse_args, Some(destination))?;
run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?;
Ok(())
}
pub(super) fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<()> {
let mut command = Command::new("git");
command.args(args);
command.env("GIT_TERMINAL_PROMPT", "0");
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
output.status,
stdout.trim(),
stderr.trim()
);
}
pub(super) fn copy_dir_recursive(source: &Path, target: &Path) -> Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
if entry.file_name().to_str() == Some(".git") {
continue;
}
copy_dir_recursive(&source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path)?;
} else if file_type.is_symlink() {
copy_symlink_target(&source_path, &target_path)?;
}
}
Ok(())
}
#[cfg(unix)]
fn copy_symlink_target(source: &Path, target: &Path) -> Result<()> {
std::os::unix::fs::symlink(fs::read_link(source)?, target)?;
Ok(())
}
#[cfg(windows)]
fn copy_symlink_target(source: &Path, target: &Path) -> Result<()> {
let metadata = fs::metadata(source)?;
if metadata.is_dir() {
copy_dir_recursive(source, target)
} else {
fs::copy(source, target).map(|_| ()).map_err(Into::into)
}
}
pub(super) fn replace_marketplace_root(staged_root: &Path, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let backup = if destination.exists() {
let parent = destination
.parent()
.context("marketplace destination has no parent")?;
let staging_root = marketplace_staging_root(parent);
fs::create_dir_all(&staging_root)?;
let backup = tempfile::Builder::new()
.prefix("marketplace-backup-")
.tempdir_in(&staging_root)?;
let backup_root = backup.path().join("previous");
fs::rename(destination, &backup_root)?;
Some((backup, backup_root))
} else {
None
};
if let Err(err) = fs::rename(staged_root, destination) {
if let Some((_, backup_root)) = backup {
let _ = fs::rename(backup_root, destination);
}
return Err(err.into());
}
Ok(())
}
pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf {
install_root.join(".staging")
}
fn safe_marketplace_dir_name(marketplace_name: &str) -> Result<String> {
let safe = marketplace_name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'-'
}
})
.collect::<String>();
let safe = safe.trim_matches('.').to_string();
if safe.is_empty() || safe == ".." {
bail!("marketplace name `{marketplace_name}` cannot be used as an install directory");
}
Ok(safe)
}
fn ensure_marketplace_destination_is_inside_install_root(
install_root: &Path,
destination: &Path,
) -> Result<()> {
let install_root = install_root.canonicalize().with_context(|| {
format!(
"failed to resolve marketplace install root {}",
install_root.display()
)
})?;
let destination_parent = destination
.parent()
.context("marketplace destination has no parent")?
.canonicalize()
.with_context(|| {
format!(
"failed to resolve marketplace destination parent {}",
destination.display()
)
})?;
if !destination_parent.starts_with(&install_root) {
bail!(
"marketplace destination {} is outside install root {}",
destination.display(),
install_root.display()
);
}
Ok(())
}
impl MarketplaceSource {
fn source_id(&self) -> &str {
match self {
Self::LocalDirectory { source_id, .. } | Self::Git { source_id, .. } => source_id,
}
}
fn display(&self) -> String {
match self {
Self::LocalDirectory { path, .. } => path.display().to_string(),
Self::Git { url, ref_name, .. } => {
if let Some(ref_name) = ref_name {
format!("{url}#{ref_name}")
} else {
url.clone()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn github_shorthand_parses_ref_suffix() {
assert_eq!(
parse_marketplace_source("owner/repo@main", /* explicit_ref */ None).unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: Some("main".to_string()),
source_id: "github:owner/repo#main".to_string(),
}
);
}
#[test]
fn git_url_parses_fragment_ref() {
assert_eq!(
parse_marketplace_source(
"https://example.com/team/repo.git#v1",
/* explicit_ref */ None,
)
.unwrap(),
MarketplaceSource::Git {
url: "https://example.com/team/repo.git".to_string(),
ref_name: Some("v1".to_string()),
source_id: "git:https://example.com/team/repo.git#v1".to_string(),
}
);
}
#[test]
fn explicit_ref_overrides_source_ref() {
assert_eq!(
parse_marketplace_source(
"owner/repo@main",
/* explicit_ref */ Some("release".to_string()),
)
.unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: Some("release".to_string()),
source_id: "github:owner/repo#release".to_string(),
}
);
}
#[test]
fn github_shorthand_and_git_url_have_different_source_ids() {
let shorthand = parse_marketplace_source("owner/repo", /* explicit_ref */ None).unwrap();
let git_url = parse_marketplace_source(
"https://github.com/owner/repo.git",
/* explicit_ref */ None,
)
.unwrap();
assert_ne!(shorthand.source_id(), git_url.source_id());
assert_eq!(
shorthand,
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
source_id: "github:owner/repo".to_string(),
}
);
assert_eq!(
git_url,
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
source_id: "git:https://github.com/owner/repo.git".to_string(),
}
);
}
}

View File

@@ -0,0 +1,200 @@
use super::MarketplaceSource;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_core::plugins::validate_marketplace_root;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
const MARKETPLACE_ADD_SOURCE_FILE: &str = ".codex-marketplace-source";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct MarketplaceInstallMetadata {
pub(super) source_id: String,
pub(super) source: InstalledMarketplaceSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum InstalledMarketplaceSource {
LocalDirectory {
path: PathBuf,
},
Git {
url: String,
ref_name: Option<String>,
sparse_paths: Vec<String>,
},
}
pub(super) fn installed_marketplace_root_for_source(
install_root: &Path,
source_id: &str,
) -> Result<Option<PathBuf>> {
let entries = fs::read_dir(install_root).with_context(|| {
format!(
"failed to read marketplace install directory {}",
install_root.display()
)
})?;
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let root = entry.path();
let metadata_path = root.join(MARKETPLACE_ADD_SOURCE_FILE);
if !metadata_path.is_file() {
continue;
}
let metadata = read_marketplace_source_metadata(&root)?;
if metadata
.as_ref()
.is_some_and(|metadata| metadata.source_id == source_id)
&& validate_marketplace_root(&root).is_ok()
{
return Ok(Some(root));
}
}
Ok(None)
}
pub(super) fn write_marketplace_source_metadata(
root: &Path,
metadata: &MarketplaceInstallMetadata,
) -> Result<()> {
let source = match &metadata.source {
InstalledMarketplaceSource::LocalDirectory { path } => serde_json::json!({
"kind": "directory",
"path": path,
}),
InstalledMarketplaceSource::Git {
url,
ref_name,
sparse_paths,
} => serde_json::json!({
"kind": "git",
"url": url,
"ref": ref_name,
"sparsePaths": sparse_paths,
}),
};
let content = serde_json::to_string_pretty(&serde_json::json!({
"version": 1,
"sourceId": metadata.source_id,
"source": source,
}))?;
fs::write(root.join(MARKETPLACE_ADD_SOURCE_FILE), content).with_context(|| {
format!(
"failed to write marketplace source metadata in {}",
root.display()
)
})
}
pub(super) fn read_marketplace_source_metadata(
root: &Path,
) -> Result<Option<MarketplaceInstallMetadata>> {
let path = root.join(MARKETPLACE_ADD_SOURCE_FILE);
if !path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&path).with_context(|| {
format!(
"failed to read marketplace source metadata {}",
path.display()
)
})?;
if !content.trim_start().starts_with('{') {
return Ok(Some(MarketplaceInstallMetadata {
source_id: content.trim().to_string(),
source: InstalledMarketplaceSource::LocalDirectory {
path: root.to_path_buf(),
},
}));
}
let json: serde_json::Value = serde_json::from_str(&content).with_context(|| {
format!(
"failed to parse marketplace source metadata {}",
path.display()
)
})?;
let source_id = json
.get("sourceId")
.and_then(serde_json::Value::as_str)
.context("marketplace source metadata is missing sourceId")?
.to_string();
let source = json
.get("source")
.context("marketplace source metadata is missing source")?;
let kind = source
.get("kind")
.and_then(serde_json::Value::as_str)
.context("marketplace source metadata is missing source.kind")?;
let source = match kind {
"directory" => {
let path = source
.get("path")
.and_then(serde_json::Value::as_str)
.context("marketplace directory metadata is missing path")?;
InstalledMarketplaceSource::LocalDirectory {
path: PathBuf::from(path),
}
}
"git" => {
let url = source
.get("url")
.and_then(serde_json::Value::as_str)
.context("marketplace git metadata is missing url")?
.to_string();
let ref_name = source
.get("ref")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let sparse_paths = source
.get("sparsePaths")
.and_then(serde_json::Value::as_array)
.map(|paths| {
paths
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
InstalledMarketplaceSource::Git {
url,
ref_name,
sparse_paths,
}
}
other => bail!("unsupported marketplace source metadata kind `{other}`"),
};
Ok(Some(MarketplaceInstallMetadata { source_id, source }))
}
impl MarketplaceInstallMetadata {
pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self {
let source_id = if sparse_paths.is_empty() {
source.source_id().to_string()
} else {
format!(
"{}?sparse={}",
source.source_id(),
serde_json::to_string(sparse_paths).unwrap_or_else(|_| "[]".to_string())
)
};
let source = match source {
MarketplaceSource::LocalDirectory { path, .. } => {
InstalledMarketplaceSource::LocalDirectory { path: path.clone() }
}
MarketplaceSource::Git { url, ref_name, .. } => InstalledMarketplaceSource::Git {
url: url.clone(),
ref_name: ref_name.clone(),
sparse_paths: sparse_paths.to_vec(),
},
};
Self { source_id, source }
}
}

View File

@@ -0,0 +1,250 @@
use super::clone_git_source;
use super::copy_dir_recursive;
use super::marketplace_staging_root;
use super::metadata::InstalledMarketplaceSource;
use super::metadata::MarketplaceInstallMetadata;
use super::metadata::read_marketplace_source_metadata;
use super::metadata::write_marketplace_source_metadata;
use super::replace_marketplace_root;
use super::run_git;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::find_codex_home;
use codex_core::plugins::marketplace_install_root;
use codex_core::plugins::validate_marketplace_root;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Parser)]
pub(super) struct UpdateMarketplaceArgs {
/// Marketplace name to update. If omitted, updates all added marketplaces.
pub(super) name: Option<String>,
}
#[derive(Debug)]
struct InstalledMarketplace {
name: String,
root: PathBuf,
metadata: Option<MarketplaceInstallMetadata>,
}
pub(super) async fn run_update(args: UpdateMarketplaceArgs) -> Result<()> {
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let install_root = marketplace_install_root(&codex_home);
let marketplaces = installed_marketplaces(&install_root)?;
if let Some(name) = args.name {
let available_names = marketplaces
.iter()
.map(|marketplace| marketplace.name.clone())
.collect::<Vec<_>>();
let available_names = if available_names.is_empty() {
"<none>".to_string()
} else {
available_names.join(", ")
};
let marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == name)
.with_context(|| {
format!(
"marketplace `{name}` is not added. Available marketplaces: {available_names}"
)
})?;
println!("Updating marketplace: {name}...");
refresh_installed_marketplace(&marketplace, |message| println!("{message}"))?;
println!("Successfully updated marketplace: {name}");
return Ok(());
}
let marketplaces = marketplaces
.into_iter()
.filter(|marketplace| marketplace.metadata.is_some())
.collect::<Vec<_>>();
if marketplaces.is_empty() {
println!("No marketplaces configured.");
return Ok(());
}
println!("Updating {} marketplace(s)...", marketplaces.len());
for marketplace in &marketplaces {
if let Err(err) = refresh_installed_marketplace(marketplace, |message| {
println!("{}: {message}", marketplace.name)
}) {
eprintln!(
"Failed to update marketplace `{}`: {err:#}",
marketplace.name
);
}
}
println!(
"Successfully updated {} marketplace(s).",
marketplaces.len()
);
Ok(())
}
fn refresh_installed_marketplace(
marketplace: &InstalledMarketplace,
on_progress: impl Fn(&str),
) -> Result<()> {
let metadata = marketplace.metadata.as_ref().with_context(|| {
format!(
"marketplace `{}` was added without source metadata; remove and add it again before updating",
marketplace.name
)
})?;
match &metadata.source {
InstalledMarketplaceSource::LocalDirectory { path } => {
on_progress("Validating local marketplace...");
let source_marketplace_name = validate_marketplace_root(path).with_context(|| {
format!(
"failed to validate local marketplace source {}",
path.display()
)
})?;
ensure_refreshed_marketplace_name_is_stable(
&marketplace.name,
&source_marketplace_name,
)?;
on_progress("Refreshing marketplace cache from local directory...");
let install_root = marketplace
.root
.parent()
.context("marketplace root has no parent")?;
let staging_root = marketplace_staging_root(install_root);
fs::create_dir_all(&staging_root)?;
let staged_dir = tempfile::Builder::new()
.prefix("marketplace-update-")
.tempdir_in(&staging_root)?;
let staged_root = staged_dir.path().to_path_buf();
copy_dir_recursive(path, &staged_root)?;
let refreshed_name = validate_marketplace_root(&staged_root)?;
ensure_refreshed_marketplace_name_is_stable(&marketplace.name, &refreshed_name)?;
write_marketplace_source_metadata(&staged_root, metadata)?;
replace_marketplace_root(&staged_root, &marketplace.root)?;
}
InstalledMarketplaceSource::Git {
url,
ref_name,
sparse_paths,
} => {
on_progress("Refreshing marketplace cache...");
refresh_git_marketplace(&marketplace.root, url, ref_name.as_deref(), sparse_paths)
.with_context(|| {
format!("failed to refresh git marketplace `{}`", marketplace.name)
})?;
let refreshed_name = validate_marketplace_root(&marketplace.root).with_context(|| {
format!(
"marketplace `{}` was refreshed but no longer has a valid .agents/plugins/marketplace.json; remove and add it again if the repository moved or stopped being a marketplace",
marketplace.name
)
})?;
ensure_refreshed_marketplace_name_is_stable(&marketplace.name, &refreshed_name)?;
write_marketplace_source_metadata(&marketplace.root, metadata)?;
}
}
Ok(())
}
fn refresh_git_marketplace(
destination: &Path,
url: &str,
ref_name: Option<&str>,
sparse_paths: &[String],
) -> Result<()> {
match pull_git_source(destination, ref_name, sparse_paths) {
Ok(()) => return Ok(()),
Err(pull_err) => {
if destination.exists() {
fs::remove_dir_all(destination).with_context(|| {
format!(
"git pull failed ({pull_err:#}) and failed to remove stale marketplace directory {}; remove it manually and retry",
destination.display()
)
})?;
}
clone_git_source(url, ref_name, sparse_paths, destination).with_context(|| {
format!("git pull failed ({pull_err:#}) and fallback clone from {url} failed")
})?;
}
}
Ok(())
}
fn pull_git_source(
destination: &Path,
ref_name: Option<&str>,
sparse_paths: &[String],
) -> Result<()> {
if !destination.join(".git").is_dir() {
bail!(
"marketplace cache {} is not a git repository",
destination.display()
);
}
if !sparse_paths.is_empty() {
let mut sparse_args = vec!["sparse-checkout", "set"];
sparse_args.extend(sparse_paths.iter().map(String::as_str));
run_git(&sparse_args, Some(destination))?;
}
if let Some(ref_name) = ref_name {
run_git(&["fetch", "origin", ref_name], Some(destination))?;
run_git(&["checkout", ref_name], Some(destination))?;
run_git(&["pull", "origin", ref_name], Some(destination))?;
} else {
run_git(&["pull", "origin", "HEAD"], Some(destination))?;
}
if sparse_paths.is_empty()
&& let Err(err) = run_git(
&["submodule", "update", "--init", "--recursive"],
Some(destination),
)
{
eprintln!("Warning: failed to update marketplace submodules: {err:#}");
}
Ok(())
}
fn installed_marketplaces(install_root: &Path) -> Result<Vec<InstalledMarketplace>> {
let Ok(entries) = fs::read_dir(install_root) else {
return Ok(Vec::new());
};
let mut marketplaces = Vec::new();
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let root = entry.path();
let Ok(name) = validate_marketplace_root(&root) else {
continue;
};
let metadata = read_marketplace_source_metadata(&root)?;
marketplaces.push(InstalledMarketplace {
name,
root,
metadata,
});
}
marketplaces.sort_unstable_by(|left, right| left.name.cmp(&right.name));
Ok(marketplaces)
}
fn ensure_refreshed_marketplace_name_is_stable(
original_name: &str,
refreshed_name: &str,
) -> Result<()> {
if original_name != refreshed_name {
bail!(
"marketplace `{original_name}` refreshed successfully but now declares name `{refreshed_name}`; remove and add it again to accept a marketplace rename"
);
}
Ok(())
}

View File

@@ -0,0 +1,142 @@
use anyhow::Result;
use codex_core::plugins::marketplace_install_root;
use codex_core::plugins::validate_marketplace_root;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
fn write_marketplace_source(source: &Path, marker: &str) -> Result<()> {
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(source.join("plugins/sample/marker.txt"), marker)?;
Ok(())
}
#[tokio::test]
async fn marketplace_add_local_directory_installs_valid_marketplace_root() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "first install")?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args(["marketplace", "add", source.path().to_str().unwrap()])
.assert()
.success()
.stdout(contains("Added marketplace `debug`"));
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
assert_eq!(validate_marketplace_root(&installed_root)?, "debug");
let registry = std::fs::read_to_string(codex_home.path().join(".tmp/known_marketplaces.json"))?;
assert!(registry.contains(r#""name": "debug""#));
assert!(registry.contains(r#""installLocation""#));
assert!(
installed_root
.join("plugins/sample/.codex-plugin/plugin.json")
.is_file()
);
Ok(())
}
#[tokio::test]
async fn marketplace_add_same_source_is_idempotent() -> 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()
.stdout(contains("Added marketplace `debug`"));
std::fs::write(
source.path().join("plugins/sample/marker.txt"),
"source changed after add",
)?;
codex_command(codex_home.path())?
.args(["marketplace", "add", source.path().to_str().unwrap()])
.assert()
.success()
.stdout(contains("Marketplace `debug` is already added"));
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
assert_eq!(
std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt"))?,
"first install"
);
Ok(())
}
#[tokio::test]
async fn marketplace_update_refreshes_installed_marketplace_from_source() -> 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();
std::fs::write(
source.path().join("plugins/sample/marker.txt"),
"updated source",
)?;
codex_command(codex_home.path())?
.args(["marketplace", "update", "debug"])
.assert()
.success()
.stdout(contains("Successfully updated marketplace: debug"));
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
assert_eq!(
std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt"))?,
"updated source"
);
Ok(())
}
#[tokio::test]
async fn marketplace_update_without_marketplaces_is_successful_noop() -> Result<()> {
let codex_home = TempDir::new()?;
codex_command(codex_home.path())?
.args(["marketplace", "update"])
.assert()
.success()
.stdout(contains("No marketplaces configured."));
Ok(())
}

View File

@@ -58,6 +58,7 @@ use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use serde_json::json;
@@ -79,6 +80,8 @@ use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
const KNOWN_MARKETPLACES_FILE: &str = "known_marketplaces.json";
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
@@ -1222,6 +1225,7 @@ impl PluginsManager {
// Treat the curated catalog as an extra marketplace root so plugin listing can surface it
// without requiring every caller to know where it is stored.
let mut roots = additional_roots.to_vec();
roots.extend(installed_marketplace_roots(self.codex_home.as_path()));
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
if curated_repo_root.is_dir()
&& let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root)
@@ -1234,6 +1238,140 @@ impl PluginsManager {
}
}
pub fn marketplace_install_root(codex_home: &Path) -> PathBuf {
codex_home.join(INSTALLED_MARKETPLACES_DIR)
}
pub fn record_installed_marketplace_root(
codex_home: &Path,
marketplace_name: &str,
install_location: &Path,
) -> std::io::Result<()> {
let registry_path = marketplace_registry_path(codex_home);
let mut registry = if registry_path.is_file() {
read_marketplace_registry(&registry_path)?
} else {
KnownMarketplacesRegistry::default()
};
registry
.marketplaces
.retain(|marketplace| marketplace.name != marketplace_name);
registry.marketplaces.push(KnownMarketplaceRegistryEntry {
name: marketplace_name.to_string(),
install_location: install_location.to_path_buf(),
});
registry
.marketplaces
.sort_unstable_by(|left, right| left.name.cmp(&right.name));
write_marketplace_registry(&registry_path, &registry)
}
fn installed_marketplace_roots(codex_home: &Path) -> Vec<AbsolutePathBuf> {
let registry_path = marketplace_registry_path(codex_home);
if registry_path.is_file() {
return installed_marketplace_roots_from_registry(&registry_path);
}
let install_root = marketplace_install_root(codex_home);
let Ok(entries) = fs::read_dir(&install_root) else {
return Vec::new();
};
let mut roots = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
let file_type = entry.file_type().ok()?;
(file_type.is_dir() && path.join(".agents/plugins/marketplace.json").is_file())
.then_some(path)
})
.filter_map(|path| AbsolutePathBuf::try_from(path).ok())
.collect::<Vec<_>>();
roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
roots
}
fn marketplace_registry_path(codex_home: &Path) -> PathBuf {
codex_home.join(".tmp").join(KNOWN_MARKETPLACES_FILE)
}
fn installed_marketplace_roots_from_registry(registry_path: &Path) -> Vec<AbsolutePathBuf> {
let registry = match read_marketplace_registry(registry_path) {
Ok(registry) => registry,
Err(err) => {
warn!(
path = %registry_path.display(),
error = %err,
"failed to read installed marketplace registry"
);
return Vec::new();
}
};
let mut roots = registry
.marketplaces
.into_iter()
.filter_map(|marketplace| {
let path = marketplace.install_location;
if path.join(".agents/plugins/marketplace.json").is_file() {
AbsolutePathBuf::try_from(path).ok()
} else {
None
}
})
.collect::<Vec<_>>();
roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
roots
}
fn read_marketplace_registry(path: &Path) -> std::io::Result<KnownMarketplacesRegistry> {
let contents = fs::read_to_string(path)?;
serde_json::from_str(&contents).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"failed to parse marketplace registry {}: {err}",
path.display()
),
)
})
}
fn write_marketplace_registry(
path: &Path,
registry: &KnownMarketplacesRegistry,
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_json::to_vec_pretty(registry).map_err(std::io::Error::other)?;
fs::write(path, contents)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct KnownMarketplacesRegistry {
version: u32,
marketplaces: Vec<KnownMarketplaceRegistryEntry>,
}
impl Default for KnownMarketplacesRegistry {
fn default() -> Self {
Self {
version: 1,
marketplaces: Vec::new(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct KnownMarketplaceRegistryEntry {
name: String,
install_location: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum PluginInstallError {
#[error("{0}")]

View File

@@ -1504,6 +1504,70 @@ plugins = true
);
}
#[tokio::test]
async fn list_marketplaces_includes_installed_marketplace_roots() {
let tmp = tempfile::tempdir().unwrap();
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
let plugin_root = marketplace_root.join("plugins/sample");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
record_installed_marketplace_root(tmp.path(), "debug", &marketplace_root)
.expect("record installed marketplace");
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.expect("installed marketplace should be listed");
assert_eq!(
marketplace.path,
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
.unwrap()
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins[0].id, "sample@debug");
assert_eq!(
marketplace.plugins[0].source,
MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
}
);
}
#[tokio::test]
async fn list_marketplaces_uses_first_duplicate_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -211,6 +211,17 @@ pub fn list_marketplaces(
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
}
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
let path = AbsolutePathBuf::try_from(root.join(MARKETPLACE_RELATIVE_PATH)).map_err(|err| {
MarketplaceError::InvalidMarketplaceFile {
path: root.join(MARKETPLACE_RELATIVE_PATH),
message: format!("marketplace path must resolve to an absolute path: {err}"),
}
})?;
let marketplace = load_marketplace(&path)?;
Ok(marketplace.name)
}
pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> {
let marketplace = load_raw_marketplace_manifest(path)?;
let mut plugins = Vec::new();

View File

@@ -29,6 +29,7 @@ pub(crate) use injection::build_plugin_injections;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
pub use manager::INSTALLED_MARKETPLACES_DIR;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginDetail;
pub use manager::PluginInstallError;
@@ -43,7 +44,9 @@ pub use manager::RemotePluginSyncResult;
pub use manager::installed_plugin_telemetry_metadata;
pub use manager::load_plugin_apps;
pub use manager::load_plugin_mcp_servers;
pub use manager::marketplace_install_root;
pub use manager::plugin_telemetry_metadata_from_root;
pub use manager::record_installed_marketplace_root;
pub use manifest::PluginManifestInterface;
pub(crate) use manifest::PluginManifestPaths;
pub(crate) use manifest::load_plugin_manifest;
@@ -53,6 +56,7 @@ pub use marketplace::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginPolicy;
pub use marketplace::MarketplacePluginSource;
pub use marketplace::validate_marketplace_root;
pub use remote::RemotePluginFetchError;
pub use remote::fetch_remote_featured_plugin_ids;
pub(crate) use render::render_explicit_plugin_instructions;