mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[codex] Support marketplace plugin manifest fallback (#28789)
## 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.
This commit is contained in:
1 parent
dce673905a
commit
772c5c5195
7 files changed
+897
-47
No files matched your search
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -29,6 +30,7 @@ pub struct ResolvedMarketplacePlugin {
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
pub manifest: Option<crate::manifest::PluginManifest>,
|
||||
pub manifest_fallback: MarketplacePluginManifestFallback,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -56,6 +58,57 @@ 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,
|
||||
@@ -64,6 +117,7 @@ pub struct MarketplacePlugin {
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
pub keywords: Vec<String>,
|
||||
pub manifest_fallback: Option<MarketplacePluginManifestFallback>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -313,6 +367,10 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
|
||||
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()
|
||||
@@ -329,6 +387,7 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
|
||||
policy: plugin.policy,
|
||||
interface: plugin.interface,
|
||||
keywords,
|
||||
manifest_fallback,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,13 +493,27 @@ fn resolve_marketplace_plugin_entry(
|
||||
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 } => load_plugin_manifest(path.as_path()),
|
||||
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(
|
||||
@@ -462,6 +535,7 @@ fn resolve_marketplace_plugin_entry(
|
||||
},
|
||||
interface,
|
||||
manifest,
|
||||
manifest_fallback,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -765,6 +839,9 @@ struct RawMarketplaceManifestPlugin {
|
||||
policy: RawMarketplaceManifestPluginPolicy,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(flatten)]
|
||||
manifest_fields: JsonMap<String, JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
@@ -822,6 +899,85 @@ fn resolve_marketplace_interface(
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in new issue
Block a user