mirror of
https://github.com/openai/codex.git
synced 2026-05-05 22:01:37 +03:00
support plugin/list. (#13540)
Introduce a plugin/list which reads from local marketplace.json. Also update the signature for plugin/install.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::marketplace::MarketplaceError;
|
||||
use super::marketplace::MarketplacePluginSourceSummary;
|
||||
use super::marketplace::list_marketplaces;
|
||||
use super::marketplace::resolve_marketplace_plugin;
|
||||
use super::plugin_manifest_name;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
@@ -26,6 +28,7 @@ use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -42,8 +45,21 @@ pub struct AppConnectorId(pub String);
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
pub cwd: PathBuf,
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredMarketplaceSummary {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub plugins: Vec<ConfiguredMarketplacePluginSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredMarketplacePluginSummary {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -234,11 +250,7 @@ impl PluginsManager {
|
||||
&self,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallResult, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&request.cwd,
|
||||
&request.plugin_name,
|
||||
&request.marketplace_name,
|
||||
)?;
|
||||
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
|
||||
let store = self.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
store.install(resolved.source_path.into_path_buf(), resolved.plugin_id)
|
||||
@@ -262,6 +274,56 @@ impl PluginsManager {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn list_marketplaces_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<ConfiguredMarketplaceSummary>, MarketplaceError> {
|
||||
let configured_plugins = self
|
||||
.plugins_for_config(config)
|
||||
.plugins()
|
||||
.iter()
|
||||
.map(|plugin| (plugin.config_name.clone(), plugin.enabled))
|
||||
.collect::<HashMap<String, bool>>();
|
||||
let marketplaces = list_marketplaces(additional_roots)?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
|
||||
Ok(marketplaces
|
||||
.into_iter()
|
||||
.filter_map(|marketplace| {
|
||||
let marketplace_name = marketplace.name.clone();
|
||||
let plugins = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter_map(|plugin| {
|
||||
let plugin_key = format!("{}@{marketplace_name}", plugin.name);
|
||||
if !seen_plugin_keys.insert(plugin_key.clone()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ConfiguredMarketplacePluginSummary {
|
||||
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
|
||||
// plugin entries from duplicate marketplace files intentionally
|
||||
// resolve to the first discovered source.
|
||||
enabled: configured_plugins
|
||||
.get(&plugin_key)
|
||||
.copied()
|
||||
.unwrap_or(false),
|
||||
name: plugin.name,
|
||||
source: plugin.source,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(!plugins.is_empty()).then_some(ConfiguredMarketplaceSummary {
|
||||
name: marketplace.name,
|
||||
path: marketplace.path,
|
||||
plugins,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -288,9 +350,9 @@ impl PluginInstallError {
|
||||
matches!(
|
||||
self,
|
||||
Self::Marketplace(
|
||||
MarketplaceError::InvalidMarketplaceFile { .. }
|
||||
MarketplaceError::MarketplaceNotFound { .. }
|
||||
| MarketplaceError::InvalidMarketplaceFile { .. }
|
||||
| MarketplaceError::PluginNotFound { .. }
|
||||
| MarketplaceError::DuplicatePlugin { .. }
|
||||
| MarketplaceError::InvalidPlugin(_)
|
||||
) | Self::Store(PluginStoreError::Invalid(_))
|
||||
)
|
||||
@@ -1086,8 +1148,10 @@ mod tests {
|
||||
let result = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
marketplace_name: "debug".to_string(),
|
||||
cwd: repo_root.clone(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1106,4 +1170,207 @@ mod tests {
|
||||
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_for_config_includes_enabled_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "enabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./enabled-plugin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./disabled-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."enabled-plugin@debug"]
|
||||
enabled = true
|
||||
|
||||
[plugins."disabled-plugin@debug"]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path == tmp.path().join("repo/.agents/plugins/marketplace.json")
|
||||
})
|
||||
.expect("expected repo marketplace entry");
|
||||
|
||||
assert_eq!(
|
||||
marketplace,
|
||||
ConfiguredMarketplaceSummary {
|
||||
name: "debug".to_string(),
|
||||
path: tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
||||
plugins: vec![
|
||||
ConfiguredMarketplacePluginSummary {
|
||||
name: "enabled-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo/.agents/plugins/enabled-plugin"),
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
ConfiguredMarketplacePluginSummary {
|
||||
name: "disabled-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo/.agents/plugins/disabled-plugin"),
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_for_config_uses_first_duplicate_plugin_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_a_root = tmp.path().join("repo-a");
|
||||
let repo_b_root = tmp.path().join("repo-b");
|
||||
fs::create_dir_all(repo_a_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_b_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_a_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "dup-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./from-a"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo_b_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "dup-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./from-b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "b-only-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./from-b-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."dup-plugin@debug"]
|
||||
enabled = true
|
||||
|
||||
[plugins."b-only-plugin@debug"]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[
|
||||
AbsolutePathBuf::try_from(repo_a_root).unwrap(),
|
||||
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let repo_a_marketplace = marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path == tmp.path().join("repo-a/.agents/plugins/marketplace.json")
|
||||
})
|
||||
.expect("repo-a marketplace should be listed");
|
||||
assert_eq!(
|
||||
repo_a_marketplace.plugins,
|
||||
vec![ConfiguredMarketplacePluginSummary {
|
||||
name: "dup-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo-a/.agents/plugins/from-a"),
|
||||
},
|
||||
enabled: true,
|
||||
}]
|
||||
);
|
||||
|
||||
let repo_b_marketplace = marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path == tmp.path().join("repo-b/.agents/plugins/marketplace.json")
|
||||
})
|
||||
.expect("repo-b marketplace should be listed");
|
||||
assert_eq!(
|
||||
repo_b_marketplace.plugins,
|
||||
vec![ConfiguredMarketplacePluginSummary {
|
||||
name: "b-only-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo-b/.agents/plugins/from-b-only"),
|
||||
},
|
||||
enabled: false,
|
||||
}]
|
||||
);
|
||||
|
||||
let duplicate_plugin_count = marketplaces
|
||||
.iter()
|
||||
.flat_map(|marketplace| marketplace.plugins.iter())
|
||||
.filter(|plugin| plugin.name == "dup-plugin")
|
||||
.count();
|
||||
assert_eq!(duplicate_plugin_count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,24 @@ pub struct ResolvedMarketplacePlugin {
|
||||
pub source_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceSummary {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub plugins: Vec<MarketplacePluginSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplacePluginSummary {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MarketplacePluginSourceSummary {
|
||||
Local { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MarketplaceError {
|
||||
#[error("{context}: {source}")]
|
||||
@@ -27,6 +45,9 @@ pub enum MarketplaceError {
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("marketplace file `{path}` does not exist")]
|
||||
MarketplaceNotFound { path: PathBuf },
|
||||
|
||||
#[error("invalid marketplace file `{path}`: {message}")]
|
||||
InvalidMarketplaceFile { path: PathBuf, message: String },
|
||||
|
||||
@@ -36,14 +57,6 @@ pub enum MarketplaceError {
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"multiple marketplace plugin entries matched `{plugin_name}` in marketplace `{marketplace_name}`"
|
||||
)]
|
||||
DuplicatePlugin {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPlugin(String),
|
||||
}
|
||||
@@ -54,77 +67,97 @@ impl MarketplaceError {
|
||||
}
|
||||
}
|
||||
|
||||
// For now, marketplace discovery always reads from disk so installs see the latest
|
||||
// marketplace.json contents without any in-memory cache invalidation.
|
||||
// Always read the specified marketplace file from disk so installs see the
|
||||
// latest marketplace.json contents without any in-memory cache invalidation.
|
||||
pub fn resolve_marketplace_plugin(
|
||||
cwd: &Path,
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
marketplace_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
resolve_marketplace_plugin_from_paths(
|
||||
&discover_marketplace_paths(cwd),
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
)
|
||||
}
|
||||
let marketplace = load_marketplace(marketplace_path.as_path())?;
|
||||
let marketplace_name = marketplace.name;
|
||||
let plugin = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.name == plugin_name);
|
||||
|
||||
fn resolve_marketplace_plugin_from_paths(
|
||||
marketplace_paths: &[PathBuf],
|
||||
plugin_name: &str,
|
||||
marketplace_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
for marketplace_path in marketplace_paths {
|
||||
let marketplace = load_marketplace(marketplace_path)?;
|
||||
let discovered_marketplace_name = marketplace.name;
|
||||
let mut matches = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter(|plugin| plugin.name == plugin_name)
|
||||
.collect::<Vec<_>>();
|
||||
let Some(plugin) = plugin else {
|
||||
return Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name,
|
||||
});
|
||||
};
|
||||
|
||||
if discovered_marketplace_name != marketplace_name || matches.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches.len() > 1 {
|
||||
return Err(MarketplaceError::DuplicatePlugin {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(plugin) = matches.pop() {
|
||||
let plugin_id = PluginId::new(plugin.name, marketplace_name.to_string()).map_err(
|
||||
|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
},
|
||||
)?;
|
||||
return Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
let plugin_id = PluginId::new(plugin.name, marketplace_name).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
})?;
|
||||
Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
|
||||
pub fn list_marketplaces(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
fn list_marketplaces_with_home(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
|
||||
let mut marketplaces = Vec::new();
|
||||
|
||||
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
||||
let marketplace = load_marketplace(marketplace_path.as_path())?;
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let source = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local {
|
||||
path: resolve_plugin_source_path(
|
||||
marketplace_path.as_path(),
|
||||
MarketplacePluginSource::Local { path },
|
||||
)?
|
||||
.into_path_buf(),
|
||||
},
|
||||
};
|
||||
|
||||
plugins.push(MarketplacePluginSummary {
|
||||
name: plugin.name,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
marketplaces.push(MarketplaceSummary {
|
||||
name: marketplace.name,
|
||||
path: marketplace_path,
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(marketplaces)
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths_from_roots(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Some(repo_root) = get_git_repo_root(cwd) {
|
||||
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
|
||||
if let Some(home) = home_dir {
|
||||
let path = home.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir() {
|
||||
let path = home.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() {
|
||||
paths.push(path);
|
||||
for root in additional_roots {
|
||||
if let Some(repo_root) = get_git_repo_root(root.as_path()) {
|
||||
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() && !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +165,15 @@ fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.map_err(|err| MarketplaceError::io("failed to read marketplace file", err))?;
|
||||
let contents = fs::read_to_string(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(),
|
||||
@@ -233,9 +273,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved =
|
||||
resolve_marketplace_plugin(&repo_root.join("nested"), "local-plugin", "codex-curated")
|
||||
.unwrap();
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
@@ -260,7 +302,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(&repo_root, "missing", "codex-curated").unwrap_err();
|
||||
let err = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"missing",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -269,7 +315,112 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_prefers_repo_over_home_for_same_plugin() {
|
||||
fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let home_root = tmp.path().join("home");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(home_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
home_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "home-only",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "repo-only",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: home_root.join(".agents/plugins/marketplace.json"),
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-shared"),
|
||||
},
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
name: "home-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-only"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_root.join(".agents/plugins/marketplace.json"),
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-shared"),
|
||||
},
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
name: "repo-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-only"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let home_root = tmp.path().join("home");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
@@ -313,23 +464,97 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin_from_paths(
|
||||
&[repo_marketplace, home_marketplace],
|
||||
"local-plugin",
|
||||
"codex-curated",
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/repo-plugin"),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
marketplaces,
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: home_marketplace,
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-plugin"),
|
||||
},
|
||||
}],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_marketplace.clone(),
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-plugin"),
|
||||
},
|
||||
}],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_marketplace).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let nested_root = repo_root.join("nested/project");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(&nested_root).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[
|
||||
AbsolutePathBuf::try_from(repo_root.clone()).unwrap(),
|
||||
AbsolutePathBuf::try_from(nested_root).unwrap(),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_root.join(".agents/plugins/marketplace.json"),
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/plugin"),
|
||||
},
|
||||
}],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,8 +581,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err =
|
||||
resolve_marketplace_plugin(&repo_root, "local-plugin", "codex-curated").unwrap_err();
|
||||
let err = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -367,4 +595,46 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./first"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./second"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ mod store;
|
||||
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplacePluginSummary;
|
||||
pub use manager::ConfiguredMarketplaceSummary;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::PluginCapabilitySummary;
|
||||
pub use manager::PluginInstallError;
|
||||
@@ -16,6 +18,8 @@ pub use manager::PluginsManager;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub(crate) use manifest::plugin_manifest_name;
|
||||
pub use marketplace::MarketplaceError;
|
||||
pub use marketplace::MarketplacePluginSourceSummary;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub use store::PluginId;
|
||||
|
||||
Reference in New Issue
Block a user