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
+890
-40
No files matched your search
@@ -7,12 +7,14 @@ use crate::manifest::PluginManifestMcpServers;
|
|||||||
use crate::manifest::PluginManifestPaths;
|
use crate::manifest::PluginManifestPaths;
|
||||||
use crate::manifest::load_plugin_manifest;
|
use crate::manifest::load_plugin_manifest;
|
||||||
use crate::marketplace::MarketplacePluginSource;
|
use crate::marketplace::MarketplacePluginSource;
|
||||||
|
use crate::marketplace::find_marketplace_plugin;
|
||||||
use crate::marketplace::list_marketplaces;
|
use crate::marketplace::list_marketplaces;
|
||||||
use crate::marketplace::load_marketplace;
|
use crate::marketplace::load_marketplace;
|
||||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||||
use crate::remote::RemoteInstalledPlugin;
|
use crate::remote::RemoteInstalledPlugin;
|
||||||
use crate::store::PluginStore;
|
use crate::store::PluginStore;
|
||||||
use crate::store::plugin_version_for_source;
|
use crate::store::plugin_version_for_source;
|
||||||
|
use crate::store::plugin_version_for_source_with_fallback_manifest;
|
||||||
use codex_app_server_protocol::AuthMode;
|
use codex_app_server_protocol::AuthMode;
|
||||||
use codex_config::ConfigLayerStack;
|
use codex_config::ConfigLayerStack;
|
||||||
use codex_config::HooksFile;
|
use codex_config::HooksFile;
|
||||||
@@ -460,7 +462,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
|
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
|
||||||
let marketplace_outcome = list_marketplaces(additional_roots)
|
let marketplace_outcome = list_marketplaces(additional_roots)
|
||||||
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
|
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
|
||||||
let mut plugin_sources = HashMap::<String, MarketplacePluginSource>::new();
|
let mut plugin_sources = HashMap::<String, (MarketplacePluginSource, Option<String>)>::new();
|
||||||
|
|
||||||
for marketplace in marketplace_outcome.marketplaces {
|
for marketplace in marketplace_outcome.marketplaces {
|
||||||
if is_openai_curated_marketplace_name(&marketplace.name) {
|
if is_openai_curated_marketplace_name(&marketplace.name) {
|
||||||
@@ -489,14 +491,31 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin_sources.insert(plugin_key, plugin.source);
|
let manifest_fallback = find_marketplace_plugin(&marketplace.path, &plugin.name)
|
||||||
|
.map(|resolved| {
|
||||||
|
resolved
|
||||||
|
.manifest_fallback
|
||||||
|
.contents_if_has_metadata()
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|err| {
|
||||||
|
warn!(
|
||||||
|
plugin = plugin.name,
|
||||||
|
marketplace = marketplace.name,
|
||||||
|
error = %err,
|
||||||
|
"failed to resolve marketplace plugin manifest fallback during cache refresh"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
});
|
||||||
|
plugin_sources.insert(plugin_key, (plugin.source, manifest_fallback));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cache_refreshed = false;
|
let mut cache_refreshed = false;
|
||||||
for plugin_id in configured_non_curated_plugin_ids {
|
for plugin_id in configured_non_curated_plugin_ids {
|
||||||
let plugin_key = plugin_id.as_key();
|
let plugin_key = plugin_id.as_key();
|
||||||
let Some(source) = plugin_sources.get(&plugin_key).cloned() else {
|
let Some((source, manifest_fallback_contents)) = plugin_sources.get(&plugin_key).cloned()
|
||||||
|
else {
|
||||||
warn!(
|
warn!(
|
||||||
plugin = plugin_id.plugin_name,
|
plugin = plugin_id.plugin_name,
|
||||||
marketplace = plugin_id.marketplace_name,
|
marketplace = plugin_id.marketplace_name,
|
||||||
@@ -509,7 +528,13 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
format!("failed to materialize plugin source for {plugin_key}: {err}")
|
format!("failed to materialize plugin source for {plugin_key}: {err}")
|
||||||
})?;
|
})?;
|
||||||
let source_path = materialized.path.clone();
|
let source_path = materialized.path.clone();
|
||||||
let plugin_version = plugin_version_for_source(source_path.as_path())
|
let plugin_version = match manifest_fallback_contents.as_deref() {
|
||||||
|
Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest(
|
||||||
|
source_path.as_path(),
|
||||||
|
manifest_contents,
|
||||||
|
),
|
||||||
|
None => plugin_version_for_source(source_path.as_path()),
|
||||||
|
}
|
||||||
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
|
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
|
||||||
|
|
||||||
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
|
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
|
||||||
@@ -518,8 +543,15 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
store
|
match manifest_fallback_contents.as_deref() {
|
||||||
.install_with_version(source_path, plugin_id.clone(), plugin_version)
|
Some(manifest_contents) => store.install_with_version_and_fallback_manifest(
|
||||||
|
source_path,
|
||||||
|
plugin_id.clone(),
|
||||||
|
plugin_version,
|
||||||
|
manifest_contents,
|
||||||
|
),
|
||||||
|
None => store.install_with_version(source_path, plugin_id.clone(), plugin_version),
|
||||||
|
}
|
||||||
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
|
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
|
||||||
cache_refreshed = true;
|
cache_refreshed = true;
|
||||||
}
|
}
|
||||||
@@ -901,15 +933,22 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
|||||||
|
|
||||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppDeclaration> {
|
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppDeclaration> {
|
||||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||||
return load_apps_from_paths(
|
return load_plugin_apps_from_manifest(plugin_root, &manifest.paths).await;
|
||||||
plugin_root,
|
|
||||||
plugin_app_config_paths(plugin_root, &manifest.paths),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_plugin_apps_from_manifest(
|
||||||
|
plugin_root: &Path,
|
||||||
|
manifest_paths: &PluginManifestPaths,
|
||||||
|
) -> Vec<AppDeclaration> {
|
||||||
|
load_apps_from_paths(
|
||||||
|
plugin_root,
|
||||||
|
plugin_app_config_paths(plugin_root, manifest_paths),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn plugin_app_declarations_from_value(value: &JsonValue) -> Vec<AppDeclaration> {
|
pub fn plugin_app_declarations_from_value(value: &JsonValue) -> Vec<AppDeclaration> {
|
||||||
let Ok(parsed) = serde_json::from_value::<PluginAppFile>(value.clone()) else {
|
let Ok(parsed) = serde_json::from_value::<PluginAppFile>(value.clone()) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -1181,7 +1220,7 @@ async fn load_declared_plugin_mcp_servers(plugin_root: &Path) -> HashMap<String,
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_plugin_mcp_servers_from_manifest(
|
pub(crate) async fn load_plugin_mcp_servers_from_manifest(
|
||||||
plugin_root: &Path,
|
plugin_root: &Path,
|
||||||
manifest_paths: &PluginManifestPaths,
|
manifest_paths: &PluginManifestPaths,
|
||||||
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
|
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use crate::loader::PluginHookLoadOutcome;
|
|||||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||||
use crate::loader::curated_plugin_cache_version;
|
use crate::loader::curated_plugin_cache_version;
|
||||||
use crate::loader::installed_plugin_telemetry_metadata;
|
use crate::loader::installed_plugin_telemetry_metadata;
|
||||||
use crate::loader::load_plugin_apps;
|
use crate::loader::load_plugin_apps_from_manifest;
|
||||||
use crate::loader::load_plugin_hooks;
|
use crate::loader::load_plugin_hooks;
|
||||||
use crate::loader::load_plugin_hooks_from_layer_stack;
|
use crate::loader::load_plugin_hooks_from_layer_stack;
|
||||||
use crate::loader::load_plugin_mcp_servers;
|
use crate::loader::load_plugin_mcp_servers_from_manifest;
|
||||||
use crate::loader::load_plugin_skills;
|
use crate::loader::load_plugin_skills;
|
||||||
use crate::loader::load_plugins_from_layer_stack;
|
use crate::loader::load_plugins_from_layer_stack;
|
||||||
use crate::loader::log_plugin_load_errors;
|
use crate::loader::log_plugin_load_errors;
|
||||||
@@ -27,6 +27,7 @@ use crate::marketplace::MarketplaceInterface;
|
|||||||
use crate::marketplace::MarketplaceListError;
|
use crate::marketplace::MarketplaceListError;
|
||||||
use crate::marketplace::MarketplaceListOutcome;
|
use crate::marketplace::MarketplaceListOutcome;
|
||||||
use crate::marketplace::MarketplacePluginAuthPolicy;
|
use crate::marketplace::MarketplacePluginAuthPolicy;
|
||||||
|
use crate::marketplace::MarketplacePluginManifestFallback;
|
||||||
use crate::marketplace::MarketplacePluginPolicy;
|
use crate::marketplace::MarketplacePluginPolicy;
|
||||||
use crate::marketplace::MarketplacePluginSource;
|
use crate::marketplace::MarketplacePluginSource;
|
||||||
use crate::marketplace::ResolvedMarketplacePlugin;
|
use crate::marketplace::ResolvedMarketplacePlugin;
|
||||||
@@ -315,6 +316,7 @@ pub struct ConfiguredMarketplacePlugin {
|
|||||||
pub policy: MarketplacePluginPolicy,
|
pub policy: MarketplacePluginPolicy,
|
||||||
pub interface: Option<PluginManifestInterface>,
|
pub interface: Option<PluginManifestInterface>,
|
||||||
pub keywords: Vec<String>,
|
pub keywords: Vec<String>,
|
||||||
|
pub manifest_fallback: Option<MarketplacePluginManifestFallback>,
|
||||||
pub installed: bool,
|
pub installed: bool,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
@@ -1256,15 +1258,32 @@ impl PluginsManager {
|
|||||||
};
|
};
|
||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
let codex_home = self.codex_home.clone();
|
let codex_home = self.codex_home.clone();
|
||||||
|
let manifest_fallback_contents = resolved
|
||||||
|
.manifest_fallback
|
||||||
|
.contents_if_has_metadata()
|
||||||
|
.map(str::to_string);
|
||||||
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
|
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
|
||||||
let materialized =
|
let materialized =
|
||||||
materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source)
|
materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source)
|
||||||
.map_err(PluginStoreError::Invalid)?;
|
.map_err(PluginStoreError::Invalid)?;
|
||||||
let source_path = materialized.path;
|
let source_path = materialized.path;
|
||||||
if let Some(plugin_version) = plugin_version {
|
match (plugin_version, manifest_fallback_contents.as_deref()) {
|
||||||
|
(Some(plugin_version), Some(manifest_contents)) => store
|
||||||
|
.install_with_version_and_fallback_manifest(
|
||||||
|
source_path,
|
||||||
|
resolved.plugin_id,
|
||||||
|
plugin_version,
|
||||||
|
manifest_contents,
|
||||||
|
),
|
||||||
|
(Some(plugin_version), None) => {
|
||||||
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
|
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
|
||||||
} else {
|
}
|
||||||
store.install(source_path, resolved.plugin_id)
|
(None, Some(manifest_contents)) => store.install_with_fallback_manifest(
|
||||||
|
source_path,
|
||||||
|
resolved.plugin_id,
|
||||||
|
manifest_contents,
|
||||||
|
),
|
||||||
|
(None, None) => store.install(source_path, resolved.plugin_id),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1394,6 +1413,7 @@ impl PluginsManager {
|
|||||||
let enabled = enabled_plugins.contains(&plugin_key);
|
let enabled = enabled_plugins.contains(&plugin_key);
|
||||||
let mut interface = plugin.interface;
|
let mut interface = plugin.interface;
|
||||||
let mut local_version = plugin.local_version;
|
let mut local_version = plugin.local_version;
|
||||||
|
let manifest_fallback = plugin.manifest_fallback.clone();
|
||||||
if installed
|
if installed
|
||||||
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
|
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
|
||||||
&& let Some(plugin_id) = plugin_id.as_ref()
|
&& let Some(plugin_id) = plugin_id.as_ref()
|
||||||
@@ -1424,6 +1444,7 @@ impl PluginsManager {
|
|||||||
policy: plugin.policy,
|
policy: plugin.policy,
|
||||||
keywords: plugin.keywords,
|
keywords: plugin.keywords,
|
||||||
interface,
|
interface,
|
||||||
|
manifest_fallback,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -1491,6 +1512,10 @@ impl PluginsManager {
|
|||||||
|
|
||||||
let marketplace_name = plugin.plugin_id.marketplace_name.clone();
|
let marketplace_name = plugin.plugin_id.marketplace_name.clone();
|
||||||
let plugin_key = plugin.plugin_id.as_key();
|
let plugin_key = plugin.plugin_id.as_key();
|
||||||
|
let manifest_fallback = plugin
|
||||||
|
.manifest_fallback
|
||||||
|
.contents_if_has_metadata()
|
||||||
|
.map(|_| plugin.manifest_fallback.clone());
|
||||||
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||||
let installed = installed_plugins.contains(&plugin_key);
|
let installed = installed_plugins.contains(&plugin_key);
|
||||||
let installed_version = if installed {
|
let installed_version = if installed {
|
||||||
@@ -1518,6 +1543,7 @@ impl PluginsManager {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|manifest| manifest.keywords.clone())
|
.map(|manifest| manifest.keywords.clone())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
manifest_fallback,
|
||||||
installed,
|
installed,
|
||||||
enabled: enabled_plugins.contains(&plugin_key),
|
enabled: enabled_plugins.contains(&plugin_key),
|
||||||
},
|
},
|
||||||
@@ -1604,7 +1630,16 @@ impl PluginsManager {
|
|||||||
"path does not exist or is not a directory".to_string(),
|
"path does not exist or is not a directory".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
|
let manifest =
|
||||||
|
if codex_utils_plugins::find_plugin_manifest_path(source_path.as_path()).is_some() {
|
||||||
|
load_plugin_manifest(source_path.as_path())
|
||||||
|
} else {
|
||||||
|
plugin
|
||||||
|
.manifest_fallback
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fallback| fallback.parse_for_plugin_root(source_path.as_path()))
|
||||||
|
}
|
||||||
|
.ok_or_else(|| {
|
||||||
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
|
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
|
||||||
})?;
|
})?;
|
||||||
let description = manifest.description.clone();
|
let description = manifest.description.clone();
|
||||||
@@ -1637,8 +1672,14 @@ impl PluginsManager {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let auth_mode = self.auth_mode();
|
let auth_mode = self.auth_mode();
|
||||||
let mut app_declarations = load_plugin_apps(source_path.as_path()).await;
|
let mut app_declarations =
|
||||||
let mut mcp_servers = load_plugin_mcp_servers(source_path.as_path(), auth_mode).await;
|
load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await;
|
||||||
|
let mut mcp_servers = load_plugin_mcp_servers_from_manifest(
|
||||||
|
source_path.as_path(),
|
||||||
|
&manifest.paths,
|
||||||
|
/*plugin_policy*/ None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if auth_mode.is_some() {
|
if auth_mode.is_some() {
|
||||||
apply_app_mcp_routing_policy(
|
apply_app_mcp_routing_policy(
|
||||||
&mut app_declarations,
|
&mut app_declarations,
|
||||||
|
|||||||
@@ -2241,6 +2241,98 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin_json() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let repo_root = tmp.path().join("repo");
|
||||||
|
let plugin_root = repo_root.join("plugins/quality-review");
|
||||||
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||||
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||||
|
fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
plugin_root.join("skills/thermo-nuclear-code-quality-review/SKILL.md"),
|
||||||
|
"review skill",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "debug",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "quality-review",
|
||||||
|
"description": "Strict code quality review focused on maintainability.",
|
||||||
|
"source": "./plugins/quality-review",
|
||||||
|
"author": {
|
||||||
|
"name": "Byron Grogan"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills/thermo-nuclear-code-quality-review"
|
||||||
|
],
|
||||||
|
"category": "code-review"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = PluginsManager::new(tmp.path().to_path_buf())
|
||||||
|
.install_plugin(PluginInstallRequest {
|
||||||
|
plugin_name: "quality-review".to_string(),
|
||||||
|
marketplace_path: AbsolutePathBuf::try_from(
|
||||||
|
repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let installed_path = tmp.path().join("plugins/cache/debug/quality-review/local");
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
PluginInstallOutcome {
|
||||||
|
plugin_id: PluginId::new("quality-review".to_string(), "debug".to_string()).unwrap(),
|
||||||
|
plugin_version: "local".to_string(),
|
||||||
|
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||||
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!plugin_root.join(".codex-plugin/plugin.json").exists());
|
||||||
|
assert!(
|
||||||
|
!tmp.path()
|
||||||
|
.join("plugins/.marketplace-plugin-source-staging")
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
|
||||||
|
let manifest = crate::manifest::load_plugin_manifest(&installed_path).unwrap();
|
||||||
|
assert_eq!(manifest.name, "quality-review");
|
||||||
|
assert_eq!(
|
||||||
|
manifest.description.as_deref(),
|
||||||
|
Some("Strict code quality review focused on maintainability.")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.paths.skills,
|
||||||
|
vec![
|
||||||
|
AbsolutePathBuf::try_from(
|
||||||
|
installed_path.join("skills/thermo-nuclear-code-quality-review")
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let interface = manifest.interface.expect("fallback interface");
|
||||||
|
assert_eq!(interface.developer_name.as_deref(), Some("Byron Grogan"));
|
||||||
|
assert_eq!(interface.category.as_deref(), Some("code-review"));
|
||||||
|
let fallback_json: serde_json::Value = serde_json::from_str(
|
||||||
|
&fs::read_to_string(installed_path.join(".codex-plugin/plugin.json")).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["author"],
|
||||||
|
serde_json::json!({ "name": "Byron Grogan" })
|
||||||
|
);
|
||||||
|
assert_eq!(fallback_json["category"], "code-review");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn install_plugin_supports_git_subdir_marketplace_sources() {
|
async fn install_plugin_supports_git_subdir_marketplace_sources() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -2484,6 +2576,7 @@ enabled = false
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: true,
|
installed: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -2503,6 +2596,7 @@ enabled = false
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: true,
|
installed: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
@@ -2632,6 +2726,7 @@ plugins = true
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: false,
|
installed: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}]
|
}]
|
||||||
@@ -2768,6 +2863,142 @@ plugins = true
|
|||||||
assert!(api_key_outcome.plugin.apps.is_empty());
|
assert!(api_key_outcome.plugin.apps.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_plugin_for_config_uses_marketplace_manifest_fallback_paths_for_local_source() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let repo_root = tmp.path().join("repo");
|
||||||
|
let plugin_root = repo_root.join("sample-plugin");
|
||||||
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||||
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||||
|
write_file(
|
||||||
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "debug",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "sample-plugin",
|
||||||
|
"source": "./sample-plugin",
|
||||||
|
"apps": "./config/custom.app.json",
|
||||||
|
"mcpServers": {
|
||||||
|
"sample-mcp": {
|
||||||
|
"command": "sample-mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
write_file(
|
||||||
|
&plugin_root.join("config/custom.app.json"),
|
||||||
|
r#"{"apps":{"sample-app":{"id":"connector_sample"}}}"#,
|
||||||
|
);
|
||||||
|
write_file(
|
||||||
|
&tmp.path().join(CONFIG_TOML_FILE),
|
||||||
|
r#"[features]
|
||||||
|
plugins = true
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = load_config(tmp.path(), &repo_root).await;
|
||||||
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||||
|
let outcome = manager
|
||||||
|
.read_plugin_for_config(
|
||||||
|
&config,
|
||||||
|
&PluginReadRequest {
|
||||||
|
plugin_name: "sample-plugin".to_string(),
|
||||||
|
marketplace_path: AbsolutePathBuf::try_from(
|
||||||
|
repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
outcome.plugin.apps,
|
||||||
|
vec![AppConnectorId("connector_sample".to_string())]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.plugin.mcp_server_names,
|
||||||
|
vec!["sample-mcp".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let listed_plugin = manager
|
||||||
|
.list_marketplaces_for_config(
|
||||||
|
&config,
|
||||||
|
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||||
|
/*include_openai_curated*/ false,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.marketplaces
|
||||||
|
.into_iter()
|
||||||
|
.find(|marketplace| marketplace.name == "debug")
|
||||||
|
.unwrap()
|
||||||
|
.plugins
|
||||||
|
.into_iter()
|
||||||
|
.find(|plugin| plugin.name == "sample-plugin")
|
||||||
|
.unwrap();
|
||||||
|
let listed_detail = manager
|
||||||
|
.read_plugin_detail_for_marketplace_plugin(&config, "debug", listed_plugin)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
listed_detail.apps,
|
||||||
|
vec![AppConnectorId("connector_sample".to_string())]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
listed_detail.mcp_server_names,
|
||||||
|
vec!["sample-mcp".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let repo_root = tmp.path().join("repo");
|
||||||
|
let plugin_root = repo_root.join("sample-plugin");
|
||||||
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||||
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||||
|
write_file(
|
||||||
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "debug",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "sample-plugin",
|
||||||
|
"source": "./sample-plugin",
|
||||||
|
"description": "Fallback metadata"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
write_file(&plugin_root.join(".codex-plugin/plugin.json"), "{");
|
||||||
|
write_file(
|
||||||
|
&tmp.path().join(CONFIG_TOML_FILE),
|
||||||
|
r#"[features]
|
||||||
|
plugins = true
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = load_config(tmp.path(), &repo_root).await;
|
||||||
|
let err = PluginsManager::new(tmp.path().to_path_buf())
|
||||||
|
.read_plugin_for_config(
|
||||||
|
&config,
|
||||||
|
&PluginReadRequest {
|
||||||
|
plugin_name: "sample-plugin".to_string(),
|
||||||
|
marketplace_path: AbsolutePathBuf::try_from(
|
||||||
|
repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "missing or invalid plugin.json");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_plugin_for_config_uses_user_layer_skill_settings_only() {
|
async fn read_plugin_for_config_uses_user_layer_skill_settings_only() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -3150,8 +3381,11 @@ enabled = true
|
|||||||
.find(|marketplace| marketplace.name == "debug")
|
.find(|marketplace| marketplace.name == "debug")
|
||||||
.expect("debug marketplace should be listed");
|
.expect("debug marketplace should be listed");
|
||||||
|
|
||||||
|
let mut plugins = marketplace.plugins;
|
||||||
|
assert!(plugins[0].manifest_fallback.is_some());
|
||||||
|
plugins[0].manifest_fallback = None;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
marketplace.plugins,
|
plugins,
|
||||||
vec![ConfiguredMarketplacePlugin {
|
vec![ConfiguredMarketplacePlugin {
|
||||||
id: "toolkit@debug".to_string(),
|
id: "toolkit@debug".to_string(),
|
||||||
name: "toolkit".to_string(),
|
name: "toolkit".to_string(),
|
||||||
@@ -3186,6 +3420,7 @@ enabled = true
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: true,
|
installed: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}]
|
}]
|
||||||
@@ -3266,6 +3501,7 @@ plugins = true
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: false,
|
installed: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}],
|
}],
|
||||||
@@ -3388,6 +3624,7 @@ plugins = true
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: false,
|
installed: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}],
|
}],
|
||||||
@@ -3815,6 +4052,7 @@ enabled = false
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: false,
|
installed: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}]
|
}]
|
||||||
@@ -3847,6 +4085,7 @@ enabled = false
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: false,
|
installed: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}]
|
}]
|
||||||
@@ -3937,6 +4176,7 @@ enabled = true
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
installed: false,
|
installed: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use codex_plugin::PluginIdError;
|
|||||||
use codex_protocol::protocol::Product;
|
use codex_protocol::protocol::Product;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::Map as JsonMap;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -29,6 +30,7 @@ pub struct ResolvedMarketplacePlugin {
|
|||||||
pub policy: MarketplacePluginPolicy,
|
pub policy: MarketplacePluginPolicy,
|
||||||
pub interface: Option<PluginManifestInterface>,
|
pub interface: Option<PluginManifestInterface>,
|
||||||
pub manifest: Option<crate::manifest::PluginManifest>,
|
pub manifest: Option<crate::manifest::PluginManifest>,
|
||||||
|
pub manifest_fallback: MarketplacePluginManifestFallback,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -56,6 +58,57 @@ pub struct MarketplaceInterface {
|
|||||||
pub display_name: Option<String>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct MarketplacePlugin {
|
pub struct MarketplacePlugin {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -64,6 +117,7 @@ pub struct MarketplacePlugin {
|
|||||||
pub policy: MarketplacePluginPolicy,
|
pub policy: MarketplacePluginPolicy,
|
||||||
pub interface: Option<PluginManifestInterface>,
|
pub interface: Option<PluginManifestInterface>,
|
||||||
pub keywords: Vec<String>,
|
pub keywords: Vec<String>,
|
||||||
|
pub manifest_fallback: Option<MarketplacePluginManifestFallback>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
@@ -313,6 +367,10 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
|
|||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let manifest_fallback = plugin
|
||||||
|
.manifest_fallback
|
||||||
|
.contents_if_has_metadata()
|
||||||
|
.map(|_| plugin.manifest_fallback.clone());
|
||||||
let local_version = plugin
|
let local_version = plugin
|
||||||
.manifest
|
.manifest
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -329,6 +387,7 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
|
|||||||
policy: plugin.policy,
|
policy: plugin.policy,
|
||||||
interface: plugin.interface,
|
interface: plugin.interface,
|
||||||
keywords,
|
keywords,
|
||||||
|
manifest_fallback,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,13 +493,27 @@ fn resolve_marketplace_plugin_entry(
|
|||||||
source,
|
source,
|
||||||
policy,
|
policy,
|
||||||
category,
|
category,
|
||||||
|
manifest_fields,
|
||||||
} = plugin;
|
} = plugin;
|
||||||
let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else {
|
let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
let manifest_fallback =
|
||||||
|
marketplace_plugin_manifest_fallback(&name, category.as_deref(), &manifest_fields);
|
||||||
|
|
||||||
let manifest = match &source {
|
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,
|
MarketplacePluginSource::Git { .. } => None,
|
||||||
};
|
};
|
||||||
let interface = plugin_interface_with_marketplace_category(
|
let interface = plugin_interface_with_marketplace_category(
|
||||||
@@ -462,6 +535,7 @@ fn resolve_marketplace_plugin_entry(
|
|||||||
},
|
},
|
||||||
interface,
|
interface,
|
||||||
manifest,
|
manifest,
|
||||||
|
manifest_fallback,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,6 +839,9 @@ struct RawMarketplaceManifestPlugin {
|
|||||||
policy: RawMarketplaceManifestPluginPolicy,
|
policy: RawMarketplaceManifestPluginPolicy,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(flatten)]
|
||||||
|
manifest_fields: JsonMap<String, JsonValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
#[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)]
|
#[cfg(test)]
|
||||||
#[path = "marketplace_tests.rs"]
|
#[path = "marketplace_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -20,6 +20,17 @@ fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
|
|||||||
fs::write(manifest_path, contents).unwrap();
|
fs::write(manifest_path, contents).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn minimal_manifest_fallback(name: &str) -> MarketplacePluginManifestFallback {
|
||||||
|
MarketplacePluginManifestFallback {
|
||||||
|
contents: format!(
|
||||||
|
r#"{{
|
||||||
|
"name": "{name}"
|
||||||
|
}}"#
|
||||||
|
),
|
||||||
|
has_metadata: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_marketplace_plugin_finds_repo_marketplace_plugin() {
|
fn find_marketplace_plugin_finds_repo_marketplace_plugin() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
@@ -65,6 +76,7 @@ fn find_marketplace_plugin_finds_repo_marketplace_plugin() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
manifest: None,
|
manifest: None,
|
||||||
|
manifest_fallback: minimal_manifest_fallback("local-plugin"),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,6 +120,7 @@ fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
manifest: None,
|
manifest: None,
|
||||||
|
manifest_fallback: minimal_manifest_fallback("string-source-plugin"),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,10 +175,219 @@ fn find_marketplace_plugin_supports_git_subdir_sources() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
manifest: None,
|
manifest: None,
|
||||||
|
manifest_fallback: minimal_manifest_fallback("remote-plugin"),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_marketplace_plugin_builds_manifest_fallback_from_entry() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo_root = tmp.path().join("repo");
|
||||||
|
let plugin_root = repo_root.join("plugins/quality-review");
|
||||||
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||||
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||||
|
fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap();
|
||||||
|
fs::create_dir_all(plugin_root.join("skills/second-review")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
r##"{
|
||||||
|
"name": "team-marketplace",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "quality-review",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"description": "Strict code quality review focused on maintainability.",
|
||||||
|
"displayName": "Quality Review",
|
||||||
|
"source": "./plugins/quality-review",
|
||||||
|
"author": {
|
||||||
|
"name": "Byron Grogan"
|
||||||
|
},
|
||||||
|
"homepage": "https://example.com/quality",
|
||||||
|
"repository": "https://github.com/example/quality-review",
|
||||||
|
"license": "MIT",
|
||||||
|
"skills": [
|
||||||
|
"./skills/thermo-nuclear-code-quality-review",
|
||||||
|
"./skills/second-review"
|
||||||
|
],
|
||||||
|
"commands": ["./commands/review.md"],
|
||||||
|
"mcpServers": {
|
||||||
|
"review": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "review-mcp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps": "./apps/app.json",
|
||||||
|
"hooks": ["./hooks/session.json"],
|
||||||
|
"agents": [
|
||||||
|
"./agents/thermo-nuclear-code-quality-review.md"
|
||||||
|
],
|
||||||
|
"category": "code-review",
|
||||||
|
"keywords": ["quality", "review"],
|
||||||
|
"strict": false,
|
||||||
|
"interface": {
|
||||||
|
"shortDescription": "Interface short description.",
|
||||||
|
"longDescription": "Runs strict reviews focused on maintainability and boundaries.",
|
||||||
|
"category": "interface-category",
|
||||||
|
"capabilities": ["review", "quality"],
|
||||||
|
"privacyPolicyURL": "https://example.com/privacy",
|
||||||
|
"termsOfServiceUrl": "https://example.com/terms",
|
||||||
|
"defaultPrompt": [
|
||||||
|
"Review this change",
|
||||||
|
"Find structural issues"
|
||||||
|
],
|
||||||
|
"brandColor": "#00AAFF",
|
||||||
|
"composerIcon": "./assets/icon.svg",
|
||||||
|
"logo": "./assets/logo.png",
|
||||||
|
"screenshots": ["./assets/shot.png"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"##,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resolved = find_marketplace_plugin(
|
||||||
|
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||||
|
"quality-review",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let manifest = resolved.manifest.as_ref().expect("fallback manifest");
|
||||||
|
assert_eq!(manifest.name, "quality-review");
|
||||||
|
assert_eq!(manifest.version.as_deref(), Some("1.2.3"));
|
||||||
|
assert_eq!(
|
||||||
|
manifest.description.as_deref(),
|
||||||
|
Some("Strict code quality review focused on maintainability.")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.paths.skills,
|
||||||
|
vec![
|
||||||
|
AbsolutePathBuf::try_from(
|
||||||
|
plugin_root.join("skills/thermo-nuclear-code-quality-review")
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
AbsolutePathBuf::try_from(plugin_root.join("skills/second-review")).unwrap(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let Some(crate::manifest::PluginManifestMcpServers::Object(mcp_servers)) =
|
||||||
|
manifest.paths.mcp_servers.as_ref()
|
||||||
|
else {
|
||||||
|
panic!("fallback mcpServers should be inline");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<JsonValue>(mcp_servers).unwrap(),
|
||||||
|
serde_json::json!({
|
||||||
|
"review": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "review-mcp"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.paths.apps.as_ref(),
|
||||||
|
Some(&AbsolutePathBuf::try_from(plugin_root.join("apps/app.json")).unwrap())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.paths.hooks.as_ref(),
|
||||||
|
Some(&crate::manifest::PluginManifestHooks::Paths(vec![
|
||||||
|
AbsolutePathBuf::try_from(plugin_root.join("hooks/session.json")).unwrap()
|
||||||
|
]))
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.keywords, vec!["quality", "review"]);
|
||||||
|
let interface = manifest.interface.as_ref().expect("fallback interface");
|
||||||
|
assert_eq!(
|
||||||
|
interface,
|
||||||
|
&PluginManifestInterface {
|
||||||
|
display_name: Some("Quality Review".to_string()),
|
||||||
|
short_description: Some("Interface short description.".to_string()),
|
||||||
|
long_description: Some(
|
||||||
|
"Runs strict reviews focused on maintainability and boundaries.".to_string()
|
||||||
|
),
|
||||||
|
developer_name: Some("Byron Grogan".to_string()),
|
||||||
|
category: Some("code-review".to_string()),
|
||||||
|
capabilities: vec!["review".to_string(), "quality".to_string()],
|
||||||
|
website_url: Some("https://example.com/quality".to_string()),
|
||||||
|
privacy_policy_url: Some("https://example.com/privacy".to_string()),
|
||||||
|
terms_of_service_url: Some("https://example.com/terms".to_string()),
|
||||||
|
default_prompt: Some(vec![
|
||||||
|
"Review this change".to_string(),
|
||||||
|
"Find structural issues".to_string()
|
||||||
|
]),
|
||||||
|
brand_color: Some("#00AAFF".to_string()),
|
||||||
|
composer_icon: Some(
|
||||||
|
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.svg")).unwrap()
|
||||||
|
),
|
||||||
|
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
|
||||||
|
screenshots: vec![
|
||||||
|
AbsolutePathBuf::try_from(plugin_root.join("assets/shot.png")).unwrap()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let fallback_json: JsonValue =
|
||||||
|
serde_json::from_str(resolved.manifest_fallback.contents()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["skills"],
|
||||||
|
serde_json::json!([
|
||||||
|
"./skills/thermo-nuclear-code-quality-review",
|
||||||
|
"./skills/second-review"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["mcpServers"],
|
||||||
|
serde_json::json!({
|
||||||
|
"review": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "review-mcp"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["displayName"],
|
||||||
|
JsonValue::String("Quality Review".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["interface"]["websiteUrl"],
|
||||||
|
JsonValue::String("https://example.com/quality".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["interface"]["privacyPolicyURL"],
|
||||||
|
JsonValue::String("https://example.com/privacy".to_string())
|
||||||
|
);
|
||||||
|
assert!(fallback_json["interface"].get("privacyPolicyUrl").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["author"],
|
||||||
|
serde_json::json!({ "name": "Byron Grogan" })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["agents"],
|
||||||
|
serde_json::json!(["./agents/thermo-nuclear-code-quality-review.md"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["commands"],
|
||||||
|
serde_json::json!(["./commands/review.md"])
|
||||||
|
);
|
||||||
|
assert_eq!(fallback_json["strict"], JsonValue::Bool(false));
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["homepage"],
|
||||||
|
JsonValue::String("https://example.com/quality".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["repository"],
|
||||||
|
JsonValue::String("https://github.com/example/quality-review".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["license"],
|
||||||
|
JsonValue::String("MIT".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_json["category"],
|
||||||
|
JsonValue::String("code-review".to_string())
|
||||||
|
);
|
||||||
|
assert!(resolved.manifest_fallback.has_metadata);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_marketplace_plugin_normalizes_github_shorthand_with_dot_git_suffix() {
|
fn find_marketplace_plugin_normalizes_github_shorthand_with_dot_git_suffix() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
@@ -415,6 +637,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() {
|
|||||||
screenshots: Vec::new(),
|
screenshots: Vec::new(),
|
||||||
}),
|
}),
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
@@ -500,6 +723,7 @@ fn list_marketplaces_supports_repo_root_local_plugin_sources() {
|
|||||||
screenshots: Vec::new(),
|
screenshots: Vec::new(),
|
||||||
}),
|
}),
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
@@ -552,6 +776,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
@@ -659,6 +884,7 @@ fn list_marketplaces_supports_explicit_api_marketplace_manifest_path() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
@@ -750,6 +976,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
},
|
},
|
||||||
MarketplacePlugin {
|
MarketplacePlugin {
|
||||||
name: "home-only".to_string(),
|
name: "home-only".to_string(),
|
||||||
@@ -764,6 +991,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -787,6 +1015,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
},
|
},
|
||||||
MarketplacePlugin {
|
MarketplacePlugin {
|
||||||
name: "repo-only".to_string(),
|
name: "repo-only".to_string(),
|
||||||
@@ -801,6 +1030,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -880,6 +1110,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
Marketplace {
|
Marketplace {
|
||||||
@@ -899,6 +1130,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -974,6 +1206,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
@@ -1138,6 +1371,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
}],
|
}],
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
@@ -1220,6 +1454,9 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "git-subdir-plugin",
|
"name": "git-subdir-plugin",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"displayName": "Git Subdir Plugin",
|
||||||
|
"keywords": ["git", "remote"],
|
||||||
"source": {
|
"source": {
|
||||||
"source": "git-subdir",
|
"source": "git-subdir",
|
||||||
"url": "owner/repo",
|
"url": "owner/repo",
|
||||||
@@ -1240,8 +1477,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
|
|||||||
.marketplaces;
|
.marketplaces;
|
||||||
|
|
||||||
assert_eq!(marketplaces.len(), 1);
|
assert_eq!(marketplaces.len(), 1);
|
||||||
|
let mut plugins = marketplaces[0].plugins.clone();
|
||||||
|
assert!(plugins[2].manifest_fallback.is_some());
|
||||||
|
plugins[2].manifest_fallback = None;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
marketplaces[0].plugins,
|
plugins,
|
||||||
vec![
|
vec![
|
||||||
MarketplacePlugin {
|
MarketplacePlugin {
|
||||||
name: "local-plugin".to_string(),
|
name: "local-plugin".to_string(),
|
||||||
@@ -1257,6 +1497,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
},
|
},
|
||||||
MarketplacePlugin {
|
MarketplacePlugin {
|
||||||
name: "url-plugin".to_string(),
|
name: "url-plugin".to_string(),
|
||||||
@@ -1274,10 +1515,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
|
|||||||
},
|
},
|
||||||
interface: None,
|
interface: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
|
manifest_fallback: None,
|
||||||
},
|
},
|
||||||
MarketplacePlugin {
|
MarketplacePlugin {
|
||||||
name: "git-subdir-plugin".to_string(),
|
name: "git-subdir-plugin".to_string(),
|
||||||
local_version: None,
|
local_version: Some("1.2.3".to_string()),
|
||||||
source: MarketplacePluginSource::Git {
|
source: MarketplacePluginSource::Git {
|
||||||
url: "https://github.com/owner/repo.git".to_string(),
|
url: "https://github.com/owner/repo.git".to_string(),
|
||||||
path: Some("plugins/example".to_string()),
|
path: Some("plugins/example".to_string()),
|
||||||
@@ -1289,8 +1531,12 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
|
|||||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||||
products: None,
|
products: None,
|
||||||
},
|
},
|
||||||
interface: None,
|
interface: Some(PluginManifestInterface {
|
||||||
keywords: Vec::new(),
|
display_name: Some("Git Subdir Plugin".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
keywords: vec!["git".to_string(), "remote".to_string()],
|
||||||
|
manifest_fallback: None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::manifest::PluginManifest;
|
use crate::manifest::PluginManifest;
|
||||||
use crate::manifest::load_plugin_manifest;
|
use crate::manifest::load_plugin_manifest;
|
||||||
|
use crate::manifest::parse_plugin_manifest;
|
||||||
use codex_plugin::PluginId;
|
use codex_plugin::PluginId;
|
||||||
use codex_plugin::validate_plugin_segment;
|
use codex_plugin::validate_plugin_segment;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
@@ -30,6 +31,12 @@ pub struct PluginStore {
|
|||||||
data_root: AbsolutePathBuf,
|
data_root: AbsolutePathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum InstallManifest<'a> {
|
||||||
|
OnDisk,
|
||||||
|
Fallback(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
impl PluginStore {
|
impl PluginStore {
|
||||||
pub fn new(codex_home: PathBuf) -> Self {
|
pub fn new(codex_home: PathBuf) -> Self {
|
||||||
Self::try_new(codex_home)
|
Self::try_new(codex_home)
|
||||||
@@ -104,8 +111,20 @@ impl PluginStore {
|
|||||||
source_path: AbsolutePathBuf,
|
source_path: AbsolutePathBuf,
|
||||||
plugin_id: PluginId,
|
plugin_id: PluginId,
|
||||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||||
let plugin_version = plugin_version_for_source(source_path.as_path())?;
|
self.install_with_manifest(source_path, plugin_id, InstallManifest::OnDisk)
|
||||||
self.install_with_version(source_path, plugin_id, plugin_version)
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_with_fallback_manifest(
|
||||||
|
&self,
|
||||||
|
source_path: AbsolutePathBuf,
|
||||||
|
plugin_id: PluginId,
|
||||||
|
manifest_contents: &str,
|
||||||
|
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||||
|
self.install_with_manifest(
|
||||||
|
source_path,
|
||||||
|
plugin_id,
|
||||||
|
InstallManifest::Fallback(manifest_contents),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install_with_version(
|
pub fn install_with_version(
|
||||||
@@ -113,6 +132,47 @@ impl PluginStore {
|
|||||||
source_path: AbsolutePathBuf,
|
source_path: AbsolutePathBuf,
|
||||||
plugin_id: PluginId,
|
plugin_id: PluginId,
|
||||||
plugin_version: String,
|
plugin_version: String,
|
||||||
|
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||||
|
self.install_with_version_and_manifest(
|
||||||
|
source_path,
|
||||||
|
plugin_id,
|
||||||
|
plugin_version,
|
||||||
|
InstallManifest::OnDisk,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_with_version_and_fallback_manifest(
|
||||||
|
&self,
|
||||||
|
source_path: AbsolutePathBuf,
|
||||||
|
plugin_id: PluginId,
|
||||||
|
plugin_version: String,
|
||||||
|
manifest_contents: &str,
|
||||||
|
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||||
|
self.install_with_version_and_manifest(
|
||||||
|
source_path,
|
||||||
|
plugin_id,
|
||||||
|
plugin_version,
|
||||||
|
InstallManifest::Fallback(manifest_contents),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_with_manifest(
|
||||||
|
&self,
|
||||||
|
source_path: AbsolutePathBuf,
|
||||||
|
plugin_id: PluginId,
|
||||||
|
manifest: InstallManifest<'_>,
|
||||||
|
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||||
|
let manifest = resolve_install_manifest(source_path.as_path(), manifest);
|
||||||
|
let plugin_version = plugin_version_for_install_manifest(source_path.as_path(), manifest)?;
|
||||||
|
self.install_with_version_and_manifest(source_path, plugin_id, plugin_version, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_with_version_and_manifest(
|
||||||
|
&self,
|
||||||
|
source_path: AbsolutePathBuf,
|
||||||
|
plugin_id: PluginId,
|
||||||
|
plugin_version: String,
|
||||||
|
manifest: InstallManifest<'_>,
|
||||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||||
if !source_path.as_path().is_dir() {
|
if !source_path.as_path().is_dir() {
|
||||||
return Err(PluginStoreError::Invalid(format!(
|
return Err(PluginStoreError::Invalid(format!(
|
||||||
@@ -121,7 +181,8 @@ impl PluginStore {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let plugin_name = plugin_name_for_source(source_path.as_path())?;
|
let manifest = resolve_install_manifest(source_path.as_path(), manifest);
|
||||||
|
let plugin_name = plugin_name_for_source(source_path.as_path(), manifest)?;
|
||||||
if plugin_name != plugin_id.plugin_name {
|
if plugin_name != plugin_id.plugin_name {
|
||||||
return Err(PluginStoreError::Invalid(format!(
|
return Err(PluginStoreError::Invalid(format!(
|
||||||
"plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`",
|
"plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`",
|
||||||
@@ -134,6 +195,7 @@ impl PluginStore {
|
|||||||
source_path.as_path(),
|
source_path.as_path(),
|
||||||
self.plugin_base_root(&plugin_id).as_path(),
|
self.plugin_base_root(&plugin_id).as_path(),
|
||||||
&plugin_version,
|
&plugin_version,
|
||||||
|
manifest,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(PluginInstallResult {
|
Ok(PluginInstallResult {
|
||||||
@@ -168,7 +230,37 @@ impl PluginStoreError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
pub fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
||||||
let plugin_version = plugin_manifest_version_for_source(source_path)?
|
plugin_version_for_install_manifest(source_path, InstallManifest::OnDisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn plugin_version_for_source_with_fallback_manifest(
|
||||||
|
source_path: &Path,
|
||||||
|
manifest_contents: &str,
|
||||||
|
) -> Result<String, PluginStoreError> {
|
||||||
|
let manifest =
|
||||||
|
resolve_install_manifest(source_path, InstallManifest::Fallback(manifest_contents));
|
||||||
|
plugin_version_for_install_manifest(source_path, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_install_manifest<'a>(
|
||||||
|
source_path: &Path,
|
||||||
|
manifest: InstallManifest<'a>,
|
||||||
|
) -> InstallManifest<'a> {
|
||||||
|
// A real plugin manifest always wins. The fallback only fills the gap for marketplace
|
||||||
|
// sources that cannot be changed in place because they may be user-owned directories.
|
||||||
|
match manifest {
|
||||||
|
InstallManifest::Fallback(_) if find_plugin_manifest_path(source_path).is_some() => {
|
||||||
|
InstallManifest::OnDisk
|
||||||
|
}
|
||||||
|
manifest => manifest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_version_for_install_manifest(
|
||||||
|
source_path: &Path,
|
||||||
|
manifest: InstallManifest<'_>,
|
||||||
|
) -> Result<String, PluginStoreError> {
|
||||||
|
let plugin_version = plugin_manifest_version_for_source(source_path, manifest)?
|
||||||
.unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
|
.unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
|
||||||
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
|
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
|
||||||
Ok(plugin_version)
|
Ok(plugin_version)
|
||||||
@@ -193,9 +285,20 @@ pub fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError> {
|
fn plugin_manifest_for_source(
|
||||||
load_plugin_manifest(source_path)
|
source_path: &Path,
|
||||||
.ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string()))
|
manifest: InstallManifest<'_>,
|
||||||
|
) -> Result<PluginManifest, PluginStoreError> {
|
||||||
|
match manifest {
|
||||||
|
InstallManifest::OnDisk => load_plugin_manifest(source_path)
|
||||||
|
.ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string())),
|
||||||
|
InstallManifest::Fallback(contents) => parse_plugin_manifest(
|
||||||
|
source_path,
|
||||||
|
&source_path.join(".codex-plugin/plugin.json"),
|
||||||
|
contents,
|
||||||
|
)
|
||||||
|
.map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}"))),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -207,12 +310,17 @@ struct RawPluginManifestVersion {
|
|||||||
|
|
||||||
fn plugin_manifest_version_for_source(
|
fn plugin_manifest_version_for_source(
|
||||||
source_path: &Path,
|
source_path: &Path,
|
||||||
|
manifest: InstallManifest<'_>,
|
||||||
) -> Result<Option<String>, PluginStoreError> {
|
) -> Result<Option<String>, PluginStoreError> {
|
||||||
|
let contents = match manifest {
|
||||||
|
InstallManifest::OnDisk => {
|
||||||
let manifest_path = find_plugin_manifest_path(source_path)
|
let manifest_path = find_plugin_manifest_path(source_path)
|
||||||
.ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?;
|
.ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?;
|
||||||
|
fs::read_to_string(&manifest_path)
|
||||||
let contents = fs::read_to_string(&manifest_path)
|
.map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?
|
||||||
.map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?;
|
}
|
||||||
|
InstallManifest::Fallback(contents) => contents.to_string(),
|
||||||
|
};
|
||||||
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents)
|
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents)
|
||||||
.map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?;
|
.map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?;
|
||||||
let Some(version) = manifest.version else {
|
let Some(version) = manifest.version else {
|
||||||
@@ -232,8 +340,11 @@ fn plugin_manifest_version_for_source(
|
|||||||
Ok(Some(version.to_string()))
|
Ok(Some(version.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
fn plugin_name_for_source(
|
||||||
let manifest = plugin_manifest_for_source(source_path)?;
|
source_path: &Path,
|
||||||
|
manifest: InstallManifest<'_>,
|
||||||
|
) -> Result<String, PluginStoreError> {
|
||||||
|
let manifest = plugin_manifest_for_source(source_path, manifest)?;
|
||||||
|
|
||||||
let plugin_name = manifest.name;
|
let plugin_name = manifest.name;
|
||||||
validate_plugin_segment(&plugin_name, "plugin name")
|
validate_plugin_segment(&plugin_name, "plugin name")
|
||||||
@@ -261,6 +372,7 @@ fn replace_plugin_root_atomically(
|
|||||||
source: &Path,
|
source: &Path,
|
||||||
target_root: &Path,
|
target_root: &Path,
|
||||||
plugin_version: &str,
|
plugin_version: &str,
|
||||||
|
manifest: InstallManifest<'_>,
|
||||||
) -> Result<(), PluginStoreError> {
|
) -> Result<(), PluginStoreError> {
|
||||||
let Some(parent) = target_root.parent() else {
|
let Some(parent) = target_root.parent() else {
|
||||||
return Err(PluginStoreError::Invalid(format!(
|
return Err(PluginStoreError::Invalid(format!(
|
||||||
@@ -287,6 +399,21 @@ fn replace_plugin_root_atomically(
|
|||||||
let staged_root = staged_dir.path().join(plugin_dir_name);
|
let staged_root = staged_dir.path().join(plugin_dir_name);
|
||||||
let staged_version_root = staged_root.join(plugin_version);
|
let staged_version_root = staged_root.join(plugin_version);
|
||||||
copy_dir_recursive(source, &staged_version_root)?;
|
copy_dir_recursive(source, &staged_version_root)?;
|
||||||
|
if let InstallManifest::Fallback(contents) = manifest {
|
||||||
|
// Inject the generated manifest into Store's existing atomic copy so install does not
|
||||||
|
// mutate the original source or require a second staging directory.
|
||||||
|
let manifest_path = staged_version_root.join(".codex-plugin/plugin.json");
|
||||||
|
let Some(manifest_parent) = manifest_path.parent() else {
|
||||||
|
return Err(PluginStoreError::Invalid(
|
||||||
|
"plugin manifest path has no parent".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
fs::create_dir_all(manifest_parent).map_err(|err| {
|
||||||
|
PluginStoreError::io("failed to create plugin manifest directory", err)
|
||||||
|
})?;
|
||||||
|
fs::write(&manifest_path, contents)
|
||||||
|
.map_err(|err| PluginStoreError::io("failed to write fallback plugin manifest", err))?;
|
||||||
|
}
|
||||||
|
|
||||||
let target_version_root = target_root.join(plugin_version);
|
let target_version_root = target_root.join(plugin_version);
|
||||||
if target_root.exists() && !target_version_root.exists() {
|
if target_root.exists() && !target_version_root.exists() {
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ fn install_with_version_uses_requested_cache_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn install_uses_manifest_version_when_present() {
|
fn install_prefers_on_disk_manifest_version_over_fallback() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
write_plugin_with_version(
|
write_plugin_with_version(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
@@ -203,9 +203,10 @@ fn install_uses_manifest_version_when_present() {
|
|||||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||||
|
|
||||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||||
.install(
|
.install_with_fallback_manifest(
|
||||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||||
plugin_id.clone(),
|
plugin_id.clone(),
|
||||||
|
r#"{"name":"sample-plugin","version":"9.9.9"}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user