mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Summary Support marketplace plugins whose source directory does not include a discoverable plugin manifest. Metadata-rich `marketplace.json` entries now act as fallback plugin manifests for listing, local detail reads, install, and non-curated cache refresh. The fallback preserves marketplace-entry plugin fields wholesale, then adds the small Codex-facing compatibility bridge for presentation metadata. A real source `plugin.json` always wins when present. ## Details - Capture flattened marketplace-entry fields into `MarketplacePluginManifestFallback`, preserving fields such as `version`, `description`, `skills`, `mcpServers`, `apps`, `hooks`, `agents`, `commands`, `strict`, `author`, and future manifest fields without a per-field translation list. - Bridge Claude-style top-level `displayName`, `author.name`, `homepage`, and marketplace `category` into Codex's nested `interface` fields only when the nested values are absent. - Treat fallback metadata as installable only when the marketplace entry contributes metadata beyond bare `name` and `source`; existing missing-manifest behavior remains for metadata-free entries. - Read local plugin details from the already parsed fallback manifest, including fallback-declared app and MCP paths, instead of rereading only an on-disk manifest. - Pass fallback contents into `PluginStore`, which validates them and injects `.codex-plugin/plugin.json` into Store's existing atomic copy. Local marketplace source directories are never mutated, and the fallback path no longer needs an additional staging directory. - Keep Git source materialization unchanged; Git clones still use the existing marketplace source staging area before Store installation.
984 lines
31 KiB
Rust
984 lines
31 KiB
Rust
use crate::manifest::PluginManifestInterface;
|
|
use crate::manifest::load_plugin_manifest;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginInstallPolicy;
|
|
use codex_git_utils::get_git_repo_root;
|
|
use codex_plugin::PluginId;
|
|
use codex_plugin::PluginIdError;
|
|
use codex_protocol::protocol::Product;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use serde::Deserialize;
|
|
use serde_json::Map as JsonMap;
|
|
use serde_json::Value as JsonValue;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::Component;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tracing::warn;
|
|
|
|
const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
|
|
".agents/plugins/marketplace.json",
|
|
".agents/plugins/api_marketplace.json",
|
|
".claude-plugin/marketplace.json",
|
|
];
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ResolvedMarketplacePlugin {
|
|
pub plugin_id: PluginId,
|
|
pub source: MarketplacePluginSource,
|
|
pub policy: MarketplacePluginPolicy,
|
|
pub interface: Option<PluginManifestInterface>,
|
|
pub manifest: Option<crate::manifest::PluginManifest>,
|
|
pub manifest_fallback: MarketplacePluginManifestFallback,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Marketplace {
|
|
pub name: String,
|
|
pub path: AbsolutePathBuf,
|
|
pub interface: Option<MarketplaceInterface>,
|
|
pub plugins: Vec<MarketplacePlugin>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MarketplaceListError {
|
|
pub path: AbsolutePathBuf,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct MarketplaceListOutcome {
|
|
pub marketplaces: Vec<Marketplace>,
|
|
pub errors: Vec<MarketplaceListError>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MarketplaceInterface {
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MarketplacePluginManifestFallback {
|
|
contents: String,
|
|
has_metadata: bool,
|
|
}
|
|
|
|
impl MarketplacePluginManifestFallback {
|
|
pub fn contents(&self) -> &str {
|
|
&self.contents
|
|
}
|
|
|
|
pub(crate) fn contents_if_has_metadata(&self) -> Option<&str> {
|
|
self.has_metadata.then_some(self.contents())
|
|
}
|
|
|
|
pub(crate) fn parse_for_plugin_root(
|
|
&self,
|
|
plugin_root: &Path,
|
|
) -> Option<crate::manifest::PluginManifest> {
|
|
crate::manifest::parse_plugin_manifest(
|
|
plugin_root,
|
|
&fallback_plugin_manifest_path(plugin_root),
|
|
&self.contents,
|
|
)
|
|
.ok()
|
|
}
|
|
|
|
pub(crate) fn parse_for_listing(&self) -> Option<crate::manifest::PluginManifest> {
|
|
// Git sources have no plugin root before install. Parse against a synthetic absolute root,
|
|
// then discard path-bearing fields so listings expose metadata only.
|
|
let mut manifest = crate::manifest::parse_plugin_manifest(
|
|
Path::new("/"),
|
|
Path::new("/.codex-plugin/plugin.json"),
|
|
&self.contents,
|
|
)
|
|
.ok()?;
|
|
manifest.paths = crate::manifest::PluginManifestPaths {
|
|
skills: Vec::new(),
|
|
mcp_servers: None,
|
|
apps: None,
|
|
hooks: None,
|
|
};
|
|
if let Some(interface) = manifest.interface.as_mut() {
|
|
interface.composer_icon = None;
|
|
interface.logo = None;
|
|
interface.screenshots.clear();
|
|
}
|
|
Some(manifest)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MarketplacePlugin {
|
|
pub name: String,
|
|
pub local_version: Option<String>,
|
|
pub source: MarketplacePluginSource,
|
|
pub policy: MarketplacePluginPolicy,
|
|
pub interface: Option<PluginManifestInterface>,
|
|
pub keywords: Vec<String>,
|
|
pub manifest_fallback: Option<MarketplacePluginManifestFallback>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum MarketplacePluginSource {
|
|
Local {
|
|
path: AbsolutePathBuf,
|
|
},
|
|
Git {
|
|
url: String,
|
|
path: Option<String>,
|
|
ref_name: Option<String>,
|
|
sha: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MarketplacePluginPolicy {
|
|
pub installation: MarketplacePluginInstallPolicy,
|
|
pub authentication: MarketplacePluginAuthPolicy,
|
|
// TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of
|
|
// only carrying it through core marketplace metadata.
|
|
pub products: Option<Vec<Product>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
|
pub enum MarketplacePluginInstallPolicy {
|
|
#[serde(rename = "NOT_AVAILABLE")]
|
|
NotAvailable,
|
|
#[default]
|
|
#[serde(rename = "AVAILABLE")]
|
|
Available,
|
|
#[serde(rename = "INSTALLED_BY_DEFAULT")]
|
|
InstalledByDefault,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
|
pub enum MarketplacePluginAuthPolicy {
|
|
#[default]
|
|
#[serde(rename = "ON_INSTALL")]
|
|
OnInstall,
|
|
#[serde(rename = "ON_USE")]
|
|
OnUse,
|
|
}
|
|
|
|
impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy {
|
|
fn from(value: MarketplacePluginInstallPolicy) -> Self {
|
|
match value {
|
|
MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable,
|
|
MarketplacePluginInstallPolicy::Available => Self::Available,
|
|
MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy {
|
|
fn from(value: MarketplacePluginAuthPolicy) -> Self {
|
|
match value {
|
|
MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall,
|
|
MarketplacePluginAuthPolicy::OnUse => Self::OnUse,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum MarketplaceError {
|
|
#[error("{context}: {source}")]
|
|
Io {
|
|
context: &'static str,
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
|
|
#[error("marketplace file `{path}` does not exist")]
|
|
MarketplaceNotFound { path: PathBuf },
|
|
|
|
#[error("invalid marketplace file `{path}`: {message}")]
|
|
InvalidMarketplaceFile { path: PathBuf, message: String },
|
|
|
|
#[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")]
|
|
PluginNotFound {
|
|
plugin_name: String,
|
|
marketplace_name: String,
|
|
},
|
|
|
|
#[error(
|
|
"plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`"
|
|
)]
|
|
PluginNotAvailable {
|
|
plugin_name: String,
|
|
marketplace_name: String,
|
|
},
|
|
|
|
#[error("plugins feature is disabled")]
|
|
PluginsDisabled,
|
|
|
|
#[error("{0}")]
|
|
InvalidPlugin(String),
|
|
}
|
|
|
|
impl MarketplaceError {
|
|
fn io(context: &'static str, source: io::Error) -> Self {
|
|
Self::Io { context, source }
|
|
}
|
|
}
|
|
|
|
pub fn find_marketplace_plugin(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
plugin_name: &str,
|
|
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
|
let marketplace = load_raw_marketplace_manifest(marketplace_path)?;
|
|
let marketplace_name = marketplace.name;
|
|
let marketplace_name_for_not_found = marketplace_name.clone();
|
|
for plugin in marketplace.plugins {
|
|
if plugin.name != plugin_name {
|
|
continue;
|
|
}
|
|
|
|
if let Some(plugin) =
|
|
resolve_marketplace_plugin_entry(marketplace_path, &marketplace_name, plugin)?
|
|
{
|
|
return Ok(plugin);
|
|
}
|
|
}
|
|
|
|
Err(MarketplaceError::PluginNotFound {
|
|
plugin_name: plugin_name.to_string(),
|
|
marketplace_name: marketplace_name_for_not_found,
|
|
})
|
|
}
|
|
|
|
pub fn find_installable_marketplace_plugin(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
plugin_name: &str,
|
|
restriction_product: Option<Product>,
|
|
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
|
let resolved = find_marketplace_plugin(marketplace_path, plugin_name)?;
|
|
let product_allowed = match resolved.policy.products.as_deref() {
|
|
None => true,
|
|
Some([]) => false,
|
|
Some(products) => {
|
|
restriction_product.is_some_and(|product| product.matches_product_restriction(products))
|
|
}
|
|
};
|
|
if resolved.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|
|
|| !product_allowed
|
|
{
|
|
return Err(MarketplaceError::PluginNotAvailable {
|
|
plugin_name: resolved.plugin_id.plugin_name,
|
|
marketplace_name: resolved.plugin_id.marketplace_name,
|
|
});
|
|
}
|
|
|
|
Ok(resolved)
|
|
}
|
|
|
|
pub fn list_marketplaces(
|
|
additional_roots: &[AbsolutePathBuf],
|
|
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
|
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
|
}
|
|
|
|
pub(crate) fn home_dir() -> Option<PathBuf> {
|
|
["HOME", "USERPROFILE"]
|
|
.into_iter()
|
|
.filter_map(std::env::var_os)
|
|
.filter(|value| !value.is_empty())
|
|
.map(PathBuf::from)
|
|
.find(|path| path.is_absolute())
|
|
.or_else(dirs::home_dir)
|
|
}
|
|
|
|
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
|
|
let Some(path) = find_marketplace_manifest_path(root) else {
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: root.to_path_buf(),
|
|
message: "marketplace root does not contain a supported manifest".to_string(),
|
|
});
|
|
};
|
|
let marketplace = load_marketplace(&path)?;
|
|
Ok(marketplace.name)
|
|
}
|
|
|
|
pub fn find_marketplace_manifest_path(root: &Path) -> Option<AbsolutePathBuf> {
|
|
MARKETPLACE_MANIFEST_RELATIVE_PATHS
|
|
.iter()
|
|
.find_map(|relative_path| {
|
|
let path = root.join(relative_path);
|
|
if !path.is_file() {
|
|
return None;
|
|
}
|
|
AbsolutePathBuf::try_from(path).ok()
|
|
})
|
|
}
|
|
|
|
fn supported_marketplace_manifest_path(path: &Path) -> Option<AbsolutePathBuf> {
|
|
if !path.is_file() {
|
|
return None;
|
|
}
|
|
if !MARKETPLACE_MANIFEST_RELATIVE_PATHS
|
|
.iter()
|
|
.any(|relative_path| marketplace_root_from_layout(path, relative_path).is_some())
|
|
{
|
|
return None;
|
|
}
|
|
AbsolutePathBuf::try_from(path.to_path_buf()).ok()
|
|
}
|
|
|
|
fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError {
|
|
MarketplaceError::InvalidMarketplaceFile {
|
|
path: path.to_path_buf(),
|
|
message: "marketplace file is not in a supported location".to_string(),
|
|
}
|
|
}
|
|
|
|
fn marketplace_root_from_layout(marketplace_path: &Path, relative_path: &str) -> Option<PathBuf> {
|
|
let mut current = marketplace_path;
|
|
for component in Path::new(relative_path).components().rev() {
|
|
let expected = match component {
|
|
Component::Normal(expected) => expected,
|
|
_ => return None,
|
|
};
|
|
if current.file_name() != Some(expected) {
|
|
return None;
|
|
}
|
|
current = current.parent()?;
|
|
}
|
|
Some(current.to_path_buf())
|
|
}
|
|
|
|
pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> {
|
|
let marketplace = load_raw_marketplace_manifest(path)?;
|
|
let mut plugins = Vec::new();
|
|
|
|
for plugin in marketplace.plugins {
|
|
let plugin = match resolve_marketplace_plugin_entry(path, &marketplace.name, plugin) {
|
|
Ok(Some(plugin)) => plugin,
|
|
Ok(None) => continue,
|
|
Err(MarketplaceError::InvalidPlugin(message)) => {
|
|
warn!(
|
|
path = %path.display(),
|
|
marketplace = %marketplace.name,
|
|
error = %message,
|
|
"skipping invalid marketplace plugin"
|
|
);
|
|
continue;
|
|
}
|
|
Err(err) => return Err(err),
|
|
};
|
|
|
|
let manifest_fallback = plugin
|
|
.manifest_fallback
|
|
.contents_if_has_metadata()
|
|
.map(|_| plugin.manifest_fallback.clone());
|
|
let local_version = plugin
|
|
.manifest
|
|
.as_ref()
|
|
.and_then(|manifest| manifest.version.clone());
|
|
let keywords = plugin
|
|
.manifest
|
|
.map(|manifest| manifest.keywords)
|
|
.unwrap_or_default();
|
|
|
|
plugins.push(MarketplacePlugin {
|
|
name: plugin.plugin_id.plugin_name,
|
|
local_version,
|
|
source: plugin.source,
|
|
policy: plugin.policy,
|
|
interface: plugin.interface,
|
|
keywords,
|
|
manifest_fallback,
|
|
});
|
|
}
|
|
|
|
Ok(Marketplace {
|
|
name: marketplace.name,
|
|
path: path.clone(),
|
|
interface: resolve_marketplace_interface(marketplace.interface),
|
|
plugins,
|
|
})
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn list_marketplaces_with_home(
|
|
additional_roots: &[AbsolutePathBuf],
|
|
home_dir: Option<&Path>,
|
|
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
|
let mut outcome = MarketplaceListOutcome::default();
|
|
|
|
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
|
match load_marketplace(&marketplace_path) {
|
|
Ok(marketplace) => outcome.marketplaces.push(marketplace),
|
|
Err(err) => {
|
|
warn!(
|
|
path = %marketplace_path.display(),
|
|
error = %err,
|
|
"skipping marketplace that failed to load"
|
|
);
|
|
outcome.errors.push(MarketplaceListError {
|
|
path: marketplace_path,
|
|
message: err.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(outcome)
|
|
}
|
|
|
|
fn discover_marketplace_paths_from_roots(
|
|
additional_roots: &[AbsolutePathBuf],
|
|
home_dir: Option<&Path>,
|
|
) -> Vec<AbsolutePathBuf> {
|
|
let mut paths = Vec::new();
|
|
|
|
if let Some(home) = home_dir
|
|
&& let Some(path) = find_marketplace_manifest_path(home)
|
|
{
|
|
paths.push(path);
|
|
}
|
|
|
|
for root in additional_roots {
|
|
if let Some(path) = supported_marketplace_manifest_path(root.as_path())
|
|
&& !paths.contains(&path)
|
|
{
|
|
paths.push(path);
|
|
continue;
|
|
}
|
|
// Curated marketplaces can now come from an HTTP-downloaded directory that is not a git
|
|
// checkout, so check the root directly before falling back to repo-root discovery.
|
|
if let Some(path) = find_marketplace_manifest_path(root.as_path())
|
|
&& !paths.contains(&path)
|
|
{
|
|
paths.push(path);
|
|
continue;
|
|
}
|
|
if let Some(repo_root) = get_git_repo_root(root.as_path())
|
|
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
|
|
&& let Some(path) = find_marketplace_manifest_path(repo_root.as_path())
|
|
&& !paths.contains(&path)
|
|
{
|
|
paths.push(path);
|
|
}
|
|
}
|
|
|
|
paths
|
|
}
|
|
|
|
fn load_raw_marketplace_manifest(
|
|
path: &AbsolutePathBuf,
|
|
) -> Result<RawMarketplaceManifest, MarketplaceError> {
|
|
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
|
|
if err.kind() == io::ErrorKind::NotFound {
|
|
MarketplaceError::MarketplaceNotFound {
|
|
path: path.to_path_buf(),
|
|
}
|
|
} else {
|
|
MarketplaceError::io("failed to read marketplace file", err)
|
|
}
|
|
})?;
|
|
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
|
|
path: path.to_path_buf(),
|
|
message: err.to_string(),
|
|
})
|
|
}
|
|
|
|
fn resolve_marketplace_plugin_entry(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
marketplace_name: &str,
|
|
plugin: RawMarketplaceManifestPlugin,
|
|
) -> Result<Option<ResolvedMarketplacePlugin>, MarketplaceError> {
|
|
let RawMarketplaceManifestPlugin {
|
|
name,
|
|
source,
|
|
policy,
|
|
category,
|
|
manifest_fields,
|
|
} = plugin;
|
|
let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else {
|
|
return Ok(None);
|
|
};
|
|
let manifest_fallback =
|
|
marketplace_plugin_manifest_fallback(&name, category.as_deref(), &manifest_fields);
|
|
|
|
let manifest = match &source {
|
|
MarketplacePluginSource::Local { path } => {
|
|
if codex_utils_plugins::find_plugin_manifest_path(path.as_path()).is_some() {
|
|
load_plugin_manifest(path.as_path())
|
|
} else if manifest_fallback.has_metadata {
|
|
manifest_fallback.parse_for_plugin_root(path.as_path())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
MarketplacePluginSource::Git { .. } if manifest_fallback.has_metadata => {
|
|
manifest_fallback.parse_for_listing()
|
|
}
|
|
MarketplacePluginSource::Git { .. } => None,
|
|
};
|
|
let interface = plugin_interface_with_marketplace_category(
|
|
manifest
|
|
.as_ref()
|
|
.and_then(|manifest| manifest.interface.clone()),
|
|
category,
|
|
);
|
|
|
|
Ok(Some(ResolvedMarketplacePlugin {
|
|
plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err {
|
|
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
|
})?,
|
|
source,
|
|
policy: MarketplacePluginPolicy {
|
|
installation: policy.installation,
|
|
authentication: policy.authentication,
|
|
products: policy.products,
|
|
},
|
|
interface,
|
|
manifest,
|
|
manifest_fallback,
|
|
}))
|
|
}
|
|
|
|
fn resolve_supported_plugin_source(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
plugin_name: &str,
|
|
source: RawMarketplaceManifestPluginSource,
|
|
) -> Option<MarketplacePluginSource> {
|
|
match source {
|
|
RawMarketplaceManifestPluginSource::Unsupported(_) => {
|
|
warn!(
|
|
path = %marketplace_path.display(),
|
|
plugin = plugin_name,
|
|
"skipping marketplace plugin with unsupported source"
|
|
);
|
|
None
|
|
}
|
|
source => match resolve_plugin_source(marketplace_path, source) {
|
|
Ok(source) => Some(source),
|
|
Err(err) => {
|
|
warn!(
|
|
path = %marketplace_path.display(),
|
|
plugin = plugin_name,
|
|
error = %err,
|
|
"skipping marketplace plugin that failed to resolve"
|
|
);
|
|
None
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
fn resolve_plugin_source(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
source: RawMarketplaceManifestPluginSource,
|
|
) -> Result<MarketplacePluginSource, MarketplaceError> {
|
|
match source {
|
|
RawMarketplaceManifestPluginSource::Path(path)
|
|
| RawMarketplaceManifestPluginSource::Object(
|
|
RawMarketplaceManifestPluginSourceObject::Local { path },
|
|
) => Ok(MarketplacePluginSource::Local {
|
|
path: resolve_local_plugin_source_path(marketplace_path, &path)?,
|
|
}),
|
|
RawMarketplaceManifestPluginSource::Object(
|
|
RawMarketplaceManifestPluginSourceObject::Url {
|
|
url,
|
|
path,
|
|
ref_name,
|
|
sha,
|
|
},
|
|
) => Ok(MarketplacePluginSource::Git {
|
|
url: normalize_git_plugin_source_url(marketplace_path, &url)?,
|
|
path: path
|
|
.as_deref()
|
|
.map(|path| normalize_remote_plugin_subdir(marketplace_path, path))
|
|
.transpose()?,
|
|
ref_name: normalize_optional_git_selector(&ref_name),
|
|
sha: normalize_optional_git_selector(&sha),
|
|
}),
|
|
RawMarketplaceManifestPluginSource::Object(
|
|
RawMarketplaceManifestPluginSourceObject::GitSubdir {
|
|
url,
|
|
path,
|
|
ref_name,
|
|
sha,
|
|
},
|
|
) => Ok(MarketplacePluginSource::Git {
|
|
url: normalize_git_plugin_source_url(marketplace_path, &url)?,
|
|
path: Some(normalize_remote_plugin_subdir(marketplace_path, &path)?),
|
|
ref_name: normalize_optional_git_selector(&ref_name),
|
|
sha: normalize_optional_git_selector(&sha),
|
|
}),
|
|
RawMarketplaceManifestPluginSource::Unsupported(_) => {
|
|
unreachable!("unsupported plugin sources should be filtered before resolution")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_local_plugin_source_path(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
path: &str,
|
|
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
|
match path {
|
|
"" => {
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: "local plugin source path must not be empty".to_string(),
|
|
});
|
|
}
|
|
"." | "./" => return marketplace_root_dir(marketplace_path),
|
|
_ => {}
|
|
}
|
|
|
|
// Non-root local sources must keep the explicit `./` prefix and remain normalized.
|
|
let Some(relative_path) = path.strip_prefix("./") else {
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: "local plugin source path must start with `./`".to_string(),
|
|
});
|
|
};
|
|
|
|
let relative_source_path = Path::new(relative_path);
|
|
if relative_source_path
|
|
.components()
|
|
.any(|component| !matches!(component, Component::Normal(_)))
|
|
{
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: "local plugin source path must stay within the marketplace root".to_string(),
|
|
});
|
|
}
|
|
|
|
// `marketplace.json` lives under a supported marketplace layout beneath `<root>`,
|
|
// but local plugin paths are resolved relative to `<root>`.
|
|
Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path))
|
|
}
|
|
|
|
fn normalize_remote_plugin_subdir(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
path: &str,
|
|
) -> Result<String, MarketplaceError> {
|
|
let path = path.trim();
|
|
let path = path.strip_prefix("./").unwrap_or(path);
|
|
if path.is_empty() {
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: "git plugin source path must not be empty".to_string(),
|
|
});
|
|
}
|
|
let relative_path = Path::new(path);
|
|
if relative_path
|
|
.components()
|
|
.any(|component| !matches!(component, Component::Normal(_)))
|
|
{
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: "git plugin source path must stay within the repository root".to_string(),
|
|
});
|
|
}
|
|
Ok(path.to_string())
|
|
}
|
|
|
|
fn normalize_git_plugin_source_url(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
url: &str,
|
|
) -> Result<String, MarketplaceError> {
|
|
let url = url.trim();
|
|
if url.is_empty() {
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: "git plugin source url must not be empty".to_string(),
|
|
});
|
|
}
|
|
if url.starts_with("http://") || url.starts_with("https://") {
|
|
return Ok(normalize_github_git_url(url));
|
|
}
|
|
if url.starts_with("./")
|
|
|| url.starts_with("../")
|
|
|| url.starts_with(".\\")
|
|
|| url.starts_with("..\\")
|
|
{
|
|
return normalize_relative_git_plugin_source_url(marketplace_path, url);
|
|
}
|
|
if url.starts_with("file://") || url.starts_with('/') {
|
|
return Ok(url.to_string());
|
|
}
|
|
if url.starts_with("ssh://") || url.starts_with("git@") && url.contains(':') {
|
|
return Ok(url.to_string());
|
|
}
|
|
if let Some(url) = normalize_github_shorthand_url(url) {
|
|
return Ok(url);
|
|
}
|
|
|
|
Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: format!("invalid git plugin source url: {url}"),
|
|
})
|
|
}
|
|
|
|
fn normalize_relative_git_plugin_source_url(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
url: &str,
|
|
) -> Result<String, MarketplaceError> {
|
|
let mut normalized = marketplace_root_dir(marketplace_path)?
|
|
.as_path()
|
|
.to_path_buf();
|
|
for segment in url.split(['/', '\\']) {
|
|
match segment {
|
|
"" | "." => {}
|
|
".." => {
|
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
|
path: marketplace_path.to_path_buf(),
|
|
message: "relative git plugin source url must stay within the marketplace root"
|
|
.to_string(),
|
|
});
|
|
}
|
|
segment => normalized.push(segment),
|
|
}
|
|
}
|
|
Ok(normalized.display().to_string())
|
|
}
|
|
|
|
fn normalize_optional_git_selector(value: &Option<String>) -> Option<String> {
|
|
value
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
fn normalize_github_git_url(url: &str) -> String {
|
|
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
|
|
format!("{url}.git")
|
|
} else {
|
|
url.to_string()
|
|
}
|
|
}
|
|
|
|
fn normalize_github_shorthand_url(source: &str) -> Option<String> {
|
|
if !looks_like_github_shorthand(source) {
|
|
return None;
|
|
}
|
|
let mut segments = source.split('/');
|
|
let owner = segments.next()?;
|
|
let repo = segments.next()?;
|
|
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
|
if repo.is_empty() {
|
|
return None;
|
|
}
|
|
Some(format!("https://github.com/{owner}/{repo}.git"))
|
|
}
|
|
|
|
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 fn plugin_interface_with_marketplace_category(
|
|
mut interface: Option<PluginManifestInterface>,
|
|
category: Option<String>,
|
|
) -> Option<PluginManifestInterface> {
|
|
if let Some(category) = category {
|
|
// Marketplace taxonomy wins when both sources provide a category.
|
|
interface
|
|
.get_or_insert_with(PluginManifestInterface::default)
|
|
.category = Some(category);
|
|
}
|
|
interface
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn marketplace_root_dir(
|
|
marketplace_path: &AbsolutePathBuf,
|
|
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
|
for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS {
|
|
if let Some(marketplace_root) =
|
|
marketplace_root_from_layout(marketplace_path.as_path(), relative_path)
|
|
{
|
|
return AbsolutePathBuf::try_from(marketplace_root)
|
|
.map_err(|_| invalid_marketplace_layout_error(marketplace_path));
|
|
}
|
|
}
|
|
|
|
Err(invalid_marketplace_layout_error(marketplace_path))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifest {
|
|
name: String,
|
|
#[serde(default)]
|
|
interface: Option<RawMarketplaceManifestInterface>,
|
|
plugins: Vec<RawMarketplaceManifestPlugin>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifestInterface {
|
|
#[serde(default)]
|
|
display_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifestPlugin {
|
|
name: String,
|
|
source: RawMarketplaceManifestPluginSource,
|
|
#[serde(default)]
|
|
policy: RawMarketplaceManifestPluginPolicy,
|
|
#[serde(default)]
|
|
category: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(flatten)]
|
|
manifest_fields: JsonMap<String, JsonValue>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifestPluginPolicy {
|
|
#[serde(default)]
|
|
installation: MarketplacePluginInstallPolicy,
|
|
#[serde(default)]
|
|
authentication: MarketplacePluginAuthPolicy,
|
|
products: Option<Vec<Product>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum RawMarketplaceManifestPluginSource {
|
|
Path(String),
|
|
Object(RawMarketplaceManifestPluginSourceObject),
|
|
#[allow(dead_code)]
|
|
Unsupported(JsonValue),
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(tag = "source", rename_all = "lowercase")]
|
|
enum RawMarketplaceManifestPluginSourceObject {
|
|
Local {
|
|
path: String,
|
|
},
|
|
Url {
|
|
url: String,
|
|
path: Option<String>,
|
|
#[serde(rename = "ref")]
|
|
ref_name: Option<String>,
|
|
sha: Option<String>,
|
|
},
|
|
#[serde(rename = "git-subdir")]
|
|
GitSubdir {
|
|
url: String,
|
|
path: String,
|
|
#[serde(rename = "ref")]
|
|
ref_name: Option<String>,
|
|
sha: Option<String>,
|
|
},
|
|
}
|
|
|
|
fn resolve_marketplace_interface(
|
|
interface: Option<RawMarketplaceManifestInterface>,
|
|
) -> Option<MarketplaceInterface> {
|
|
let interface = interface?;
|
|
if interface.display_name.is_some() {
|
|
Some(MarketplaceInterface {
|
|
display_name: interface.display_name,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn fallback_plugin_manifest_path(plugin_root: &Path) -> PathBuf {
|
|
plugin_root.join(".codex-plugin/plugin.json")
|
|
}
|
|
|
|
fn marketplace_plugin_manifest_fallback(
|
|
name: &str,
|
|
category: Option<&str>,
|
|
manifest_fields: &JsonMap<String, JsonValue>,
|
|
) -> MarketplacePluginManifestFallback {
|
|
let mut manifest = manifest_fields.clone();
|
|
manifest.insert("name".to_string(), JsonValue::String(name.to_string()));
|
|
if let Some(category) = category {
|
|
manifest.insert(
|
|
"category".to_string(),
|
|
JsonValue::String(category.to_string()),
|
|
);
|
|
}
|
|
if let Some(interface) = plugin_manifest_interface(manifest_fields, category) {
|
|
manifest.insert("interface".to_string(), interface);
|
|
}
|
|
|
|
let contents = serde_json::to_string_pretty(&JsonValue::Object(manifest))
|
|
.unwrap_or_else(|_| format!(r#"{{"name":"{name}"}}"#));
|
|
MarketplacePluginManifestFallback {
|
|
contents,
|
|
has_metadata: !manifest_fields.is_empty() || category.is_some(),
|
|
}
|
|
}
|
|
|
|
fn plugin_manifest_interface(
|
|
fields: &JsonMap<String, JsonValue>,
|
|
category: Option<&str>,
|
|
) -> Option<JsonValue> {
|
|
let mut interface = fields
|
|
.get("interface")
|
|
.and_then(JsonValue::as_object)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
if !interface.contains_key("displayName")
|
|
&& let Some(display_name) = fields.get("displayName").and_then(JsonValue::as_str)
|
|
{
|
|
interface.insert(
|
|
"displayName".to_string(),
|
|
JsonValue::String(display_name.to_string()),
|
|
);
|
|
}
|
|
if !interface.contains_key("developerName")
|
|
&& let Some(author_name) = fields
|
|
.get("author")
|
|
.and_then(|author| author.get("name"))
|
|
.and_then(JsonValue::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
interface.insert(
|
|
"developerName".to_string(),
|
|
JsonValue::String(author_name.to_string()),
|
|
);
|
|
}
|
|
if !interface.contains_key("websiteUrl")
|
|
&& !interface.contains_key("websiteURL")
|
|
&& let Some(homepage) = fields.get("homepage").and_then(JsonValue::as_str)
|
|
{
|
|
interface.insert(
|
|
"websiteUrl".to_string(),
|
|
JsonValue::String(homepage.to_string()),
|
|
);
|
|
}
|
|
if let Some(category) = category.map(str::trim).filter(|value| !value.is_empty()) {
|
|
interface.insert(
|
|
"category".to_string(),
|
|
JsonValue::String(category.to_string()),
|
|
);
|
|
}
|
|
|
|
(!interface.is_empty()).then_some(JsonValue::Object(interface))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "marketplace_tests.rs"]
|
|
mod tests;
|