mirror of
https://github.com/openai/codex.git
synced 2026-04-18 11:44:46 +00:00
Compare commits
7 Commits
dev/ningyi
...
xli-codex/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d719f518 | ||
|
|
614e2f4154 | ||
|
|
ccfe948a7e | ||
|
|
faaff79b41 | ||
|
|
b8a9e8870a | ||
|
|
1e5bddeb93 | ||
|
|
a3e9ac4fe2 |
@@ -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,
|
||||
|
||||
625
codex-rs/cli/src/marketplace_cmd.rs
Normal file
625
codex-rs/cli/src/marketplace_cmd.rs
Normal 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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
200
codex-rs/cli/src/marketplace_cmd/metadata.rs
Normal file
200
codex-rs/cli/src/marketplace_cmd/metadata.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
250
codex-rs/cli/src/marketplace_cmd/update.rs
Normal file
250
codex-rs/cli/src/marketplace_cmd/update.rs
Normal 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(())
|
||||
}
|
||||
142
codex-rs/cli/tests/marketplace_add.rs
Normal file
142
codex-rs/cli/tests/marketplace_add.rs
Normal 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(())
|
||||
}
|
||||
@@ -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(®istry_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(®istry_path, ®istry)
|
||||
}
|
||||
|
||||
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(®istry_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}")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user