feat: open-plugins generalization + skills (#9112)

Co-authored-by: Jack Amadeo <jackamadeo@block.xyz>
This commit is contained in:
Alex Hancock
2026-05-19 17:02:34 -04:00
committed by GitHub
parent 904d4d3ea7
commit 20a832afe1
4 changed files with 314 additions and 258 deletions
+12 -6
View File
@@ -58,6 +58,8 @@ pub enum HookEvent {
BeforeShellExecution,
AfterShellExecution,
Stop,
SubagentStart,
SubagentStop,
}
impl HookEvent {
@@ -74,6 +76,8 @@ impl HookEvent {
HookEvent::BeforeShellExecution => "BeforeShellExecution",
HookEvent::AfterShellExecution => "AfterShellExecution",
HookEvent::Stop => "Stop",
HookEvent::SubagentStart => "SubagentStart",
HookEvent::SubagentStop => "SubagentStop",
}
}
@@ -90,6 +94,8 @@ impl HookEvent {
"BeforeShellExecution" => HookEvent::BeforeShellExecution,
"AfterShellExecution" => HookEvent::AfterShellExecution,
"Stop" => HookEvent::Stop,
"SubagentStart" => HookEvent::SubagentStart,
"SubagentStop" => HookEvent::SubagentStop,
_ => return None,
})
}
@@ -527,7 +533,7 @@ fn expand_plugin_root(command: &str, plugin_root: &Path) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::discovery::{DiscoveredPlugin, PluginSource};
use crate::plugins::discovery::{DiscoveredPlugin, PluginScope};
fn write_plugin(root: &Path, name: &str, hooks_json: &str) -> PathBuf {
let plugin = root.join(name);
@@ -551,7 +557,7 @@ mod tests {
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
scope: PluginScope::User,
}]);
assert!(!mgr.has_hooks(HookEvent::PreToolUse));
}
@@ -567,7 +573,7 @@ mod tests {
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
scope: PluginScope::User,
}]);
assert!(mgr.has_hooks(HookEvent::PostToolUse));
}
@@ -583,7 +589,7 @@ mod tests {
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
scope: PluginScope::User,
}]);
assert!(!mgr.has_hooks(HookEvent::PostToolUse));
}
@@ -601,7 +607,7 @@ mod tests {
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root: root.clone(),
source: PluginSource::UserPlaced,
scope: PluginScope::User,
}]);
mgr.emit(
@@ -626,7 +632,7 @@ mod tests {
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
scope: PluginScope::User,
}]);
// Non-matching tool: marker not created.
+145 -100
View File
@@ -1,27 +1,8 @@
//! Discovery of installed and user-placed plugins, honoring the Open Plugins
//! `settings.json` enabled/disabled lists.
//!
//! Two sources of plugins are supported:
//!
//! 1. **Installed**: plugins installed via [`crate::plugins::install_plugin`]
//! live under [`crate::plugins::plugin_install_dir`] (i.e.
//! `<data>/plugins/<name>/`).
//! 2. **User-placed**: plugins dropped into `~/.agents/plugins/<name>/` or
//! `<project>/.agents/plugins/<name>/` per the Open Plugins spec.
//!
//! Settings files (`<config>/settings.json`) declare which plugins are
//! enabled. The default is **enabled** for every discovered plugin — to
//! disable one, list it under `disabledPlugins`. (We deliberately diverge
//! from the spec's strict "enabled list only" model so users who drop a
//! plugin into `.agents/plugins/` see it work without also editing a
//! settings file.)
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::config::paths::Paths;
use crate::plugins::plugin_install_dir;
/// A plugin found on disk and not disabled by any settings file.
@@ -29,15 +10,13 @@ use crate::plugins::plugin_install_dir;
pub struct DiscoveredPlugin {
pub name: String,
pub root: PathBuf,
pub source: PluginSource,
pub scope: PluginScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginSource {
/// Installed via `goose plugins install` into the data dir cache.
Installed,
/// User-placed under `~/.agents/plugins/` or `.agents/plugins/`.
UserPlaced,
pub enum PluginScope {
User,
Project,
}
/// Settings file format from <https://open-plugins.com/plugin-builders/installation>.
@@ -49,9 +28,8 @@ struct PluginSettings {
disabled: Vec<String>,
}
/// Scope of a settings file, in precedence order (highest first).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Scope {
enum SettingsScope {
Local,
Project,
User,
@@ -62,70 +40,61 @@ enum Scope {
/// `project_root`, when supplied, enables project + local scope settings and
/// project-scope `.agents/plugins/` lookups.
pub fn discover_enabled_plugins(project_root: Option<&Path>) -> Vec<DiscoveredPlugin> {
let settings = load_all_settings(project_root);
let scoped_settings = load_all_settings(project_root);
let mut found: HashMap<String, DiscoveredPlugin> = HashMap::new();
// Installed plugins (from `goose plugins install`).
for (name, root) in list_installed_plugins() {
found.entry(name.clone()).or_insert(DiscoveredPlugin {
name,
root,
source: PluginSource::Installed,
});
}
// User-placed plugins. Project scope wins over user scope.
let mut placed_roots: Vec<PathBuf> = Vec::new();
if let Some(root) = project_root {
placed_roots.push(root.join(".agents").join("plugins"));
}
if let Some(home) = dirs::home_dir() {
placed_roots.push(home.join(".agents").join("plugins"));
}
for dir in placed_roots {
for (name, root) in list_dir_children(&dir) {
for (name, root) in list_dir_children(&project_plugin_dir(root)) {
found.entry(name.clone()).or_insert(DiscoveredPlugin {
name,
root,
source: PluginSource::UserPlaced,
scope: PluginScope::Project,
});
}
}
// Apply settings: a plugin disabled at any scope is dropped. (Strictly
// the spec says higher precedence wins, but a "disabled" mark anywhere
// is the safer default and matches what users intuitively expect.)
let disabled: HashSet<&str> = settings
.iter()
.flat_map(|(_, s)| s.disabled.iter().map(String::as_str))
.collect();
let explicit_enabled: HashSet<&str> = settings
.iter()
.flat_map(|(_, s)| s.enabled.iter().map(String::as_str))
.collect();
for (name, root) in list_dir_children(&plugin_install_dir()) {
found.entry(name.clone()).or_insert(DiscoveredPlugin {
name,
root,
scope: PluginScope::User,
});
}
found
.into_values()
.filter(|p| !disabled.contains(p.name.as_str()))
// If the user has explicitly listed plugins as enabled, treat the
// list as a filter for installed plugins (project teams pinning what
// teammates run). User-placed plugins remain available unconditionally
// unless explicitly disabled, so demos drop in and just work.
.filter(|p| {
if explicit_enabled.is_empty() {
return true;
}
match p.source {
PluginSource::Installed => explicit_enabled.contains(p.name.as_str()),
PluginSource::UserPlaced => true,
}
})
.filter(|plugin| is_enabled(&plugin.name, &scoped_settings))
.collect()
}
fn list_installed_plugins() -> Vec<(String, PathBuf)> {
list_dir_children(&plugin_install_dir())
fn is_enabled(plugin_name: &str, scoped_settings: &[(SettingsScope, PluginSettings)]) -> bool {
for scope in [
SettingsScope::Local,
SettingsScope::Project,
SettingsScope::User,
] {
let Some(settings) = scoped_settings
.iter()
.find_map(|(s, settings)| (*s == scope).then_some(settings))
else {
continue;
};
let listed_disabled = settings.disabled.iter().any(|n| n == plugin_name);
let listed_enabled = settings.enabled.iter().any(|n| n == plugin_name);
if listed_disabled {
return false;
}
if listed_enabled {
return true;
}
}
true
}
fn project_plugin_dir(project_root: &Path) -> PathBuf {
project_root.join(".agents").join("plugins")
}
fn list_dir_children(dir: &Path) -> Vec<(String, PathBuf)> {
@@ -146,11 +115,14 @@ fn list_dir_children(dir: &Path) -> Vec<(String, PathBuf)> {
.collect()
}
fn load_all_settings(project_root: Option<&Path>) -> Vec<(Scope, PluginSettings)> {
let mut paths: Vec<(Scope, PathBuf)> = vec![(Scope::User, user_settings_path())];
fn load_all_settings(project_root: Option<&Path>) -> Vec<(SettingsScope, PluginSettings)> {
let mut paths: Vec<(SettingsScope, PathBuf)> = Vec::new();
if let Some(path) = user_settings_path() {
paths.push((SettingsScope::User, path));
}
if let Some(root) = project_root {
paths.push((Scope::Project, project_settings_path(root, false)));
paths.push((Scope::Local, project_settings_path(root, true)));
paths.push((SettingsScope::Project, project_settings_path(root, false)));
paths.push((SettingsScope::Local, project_settings_path(root, true)));
}
paths
@@ -166,8 +138,21 @@ fn load_all_settings(project_root: Option<&Path>) -> Vec<(Scope, PluginSettings)
.collect()
}
fn user_settings_path() -> PathBuf {
Paths::in_config_dir("settings.json")
fn user_settings_path() -> Option<PathBuf> {
if let Ok(test_root) = std::env::var("GOOSE_PATH_ROOT") {
return Some(
PathBuf::from(test_root)
.join(".config")
.join("goose")
.join("settings.json"),
);
}
Some(
dirs::home_dir()?
.join(".config")
.join("goose")
.join("settings.json"),
)
}
fn project_settings_path(project_root: &Path, local: bool) -> PathBuf {
@@ -202,8 +187,18 @@ mod tests {
.unwrap();
}
fn write_settings(dir: &Path, contents: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("settings.json"), contents).unwrap();
}
fn write_local_settings(dir: &Path, contents: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("settings.local.json"), contents).unwrap();
}
#[test]
fn finds_user_placed_plugin_under_project_root() {
fn finds_project_scope_plugin() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
write_plugin_dir(&project.join(".agents").join("plugins"), "demo");
@@ -211,44 +206,94 @@ mod tests {
let found = discover_enabled_plugins(Some(project));
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"demo"), "got: {names:?}");
let demo = found.iter().find(|p| p.name == "demo").unwrap();
assert_eq!(demo.scope, PluginScope::Project);
}
#[test]
fn disabled_plugin_is_filtered_out() {
fn disabled_in_project_settings_drops_plugin() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
write_plugin_dir(&project.join(".agents").join("plugins"), "demo");
let settings_dir = project.join(".config").join("goose");
std::fs::create_dir_all(&settings_dir).unwrap();
std::fs::write(
settings_dir.join("settings.json"),
write_settings(
&project.join(".config").join("goose"),
r#"{"disabledPlugins":["demo"]}"#,
)
.unwrap();
);
let found = discover_enabled_plugins(Some(project));
assert!(found.iter().all(|p| p.name != "demo"));
}
#[test]
fn explicit_enabled_does_not_block_user_placed() {
fn explicit_enabled_filters_out_unlisted_plugins() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
write_plugin_dir(&project.join(".agents").join("plugins"), "demo");
write_plugin_dir(&project.join(".agents").join("plugins"), "other");
write_settings(
&project.join(".config").join("goose"),
r#"{"enabledPlugins":["demo"]}"#,
);
let found = discover_enabled_plugins(Some(project));
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"demo"), "got: {names:?}");
assert!(names.contains(&"other"), "got: {names:?}");
}
#[test]
fn local_scope_overrides_project_scope() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
write_plugin_dir(&project.join(".agents").join("plugins"), "demo");
let settings_dir = project.join(".config").join("goose");
std::fs::create_dir_all(&settings_dir).unwrap();
std::fs::write(
settings_dir.join("settings.json"),
r#"{"enabledPlugins":["something-else"]}"#,
)
.unwrap();
write_settings(
&project.join(".config").join("goose"),
r#"{"disabledPlugins":["demo"]}"#,
);
write_local_settings(
&project.join(".config").join("goose"),
r#"{"enabledPlugins":["demo"]}"#,
);
let found = discover_enabled_plugins(Some(project));
assert!(
found.iter().any(|p| p.name == "demo"),
"user-placed plugin should remain available; got: {:?}",
"local scope should win; got: {:?}",
found.iter().map(|p| &p.name).collect::<Vec<_>>()
);
}
#[test]
fn project_scope_overrides_user_scope() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
write_plugin_dir(&project.join(".agents").join("plugins"), "demo");
let fake_home = tempfile::tempdir().unwrap();
write_settings(
&fake_home.path().join(".config").join("goose"),
r#"{"disabledPlugins":["demo"]}"#,
);
write_settings(
&project.join(".config").join("goose"),
r#"{"enabledPlugins":["demo"]}"#,
);
let prev = std::env::var("GOOSE_PATH_ROOT").ok();
unsafe { std::env::set_var("GOOSE_PATH_ROOT", fake_home.path()) };
let found = discover_enabled_plugins(Some(project));
match prev {
Some(v) => unsafe { std::env::set_var("GOOSE_PATH_ROOT", v) },
None => unsafe { std::env::remove_var("GOOSE_PATH_ROOT") },
}
assert!(
found.iter().any(|p| p.name == "demo"),
"project scope should win over user; got: {:?}",
found.iter().map(|p| &p.name).collect::<Vec<_>>()
);
}
+149 -147
View File
@@ -1,3 +1,5 @@
//! Open Plugins format adapter (<https://open-plugins.com>).
use crate::plugins::{
copy_dir_all, write_install_metadata, FormatNotSupported, ImportedSkill, PluginFormat,
PluginInstall, PluginInstallOptions,
@@ -10,16 +12,18 @@ use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
const MANIFESTS: [&str; 3] = [
"plugin.json",
".goose-plugin/plugin.json",
".plugin/plugin.json",
"plugin.json",
];
const HOOKS_FILE: &str = "hooks/hooks.json";
const FORMAT: &str = "open-plugins";
const COMPONENT_MARKERS: &[&str] = &["hooks/hooks.json", "commands", "agents"];
#[derive(Debug, Deserialize)]
struct OpenPluginsManifest {
name: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
@@ -39,6 +43,17 @@ pub(in crate::plugins) fn try_install_from_manifest_at_root(
options: &PluginInstallOptions,
last_update_check: Option<DateTime<Utc>>,
) -> Result<PluginInstall> {
let has_manifest = manifest_path(checkout_dir).is_some();
let has_component = has_component_marker(checkout_dir);
if !has_manifest && !has_component {
return Err(FormatNotSupported.into());
}
if !has_manifest && checkout_dir.join(super::gemini::MANIFEST).is_file() {
return Err(FormatNotSupported.into());
}
install_from_manifest(
source,
checkout_dir,
@@ -55,61 +70,30 @@ fn install_from_manifest(
options: &PluginInstallOptions,
last_update_check: Option<DateTime<Utc>>,
) -> Result<PluginInstall> {
let has_manifest = manifest_path(checkout_dir).is_some();
let has_hooks = checkout_dir.join(HOOKS_FILE).is_file();
if !has_manifest && !has_hooks {
return Err(FormatNotSupported.into());
}
let manifest = read_manifest(source, checkout_dir)?;
validate_plugin_name(&manifest.name)?;
let skills = find_agent_skills(checkout_dir, manifest.skills.as_ref())?;
if skills.is_empty() && !has_hooks {
bail!(
"Plugin '{}' does not contain any Open Plugins skills",
manifest.name
);
}
do_install(
source,
checkout_dir,
install_root,
options,
last_update_check,
manifest,
skills,
)
}
fn do_install(
source: &str,
checkout_dir: &Path,
install_root: &Path,
options: &PluginInstallOptions,
last_update_check: Option<DateTime<Utc>>,
manifest: OpenPluginsManifest,
skills: Vec<SkillCandidate>,
) -> Result<PluginInstall> {
validate_plugin_name(&manifest.name)?;
let manifest = read_manifest(checkout_dir, source)?;
let plugin_name = manifest
.name
.clone()
.expect("read_manifest always sets a name");
validate_plugin_name(&plugin_name)?;
fs::create_dir_all(install_root)?;
let destination = install_root.join(&manifest.name);
let destination = install_root.join(&plugin_name);
if destination.exists() {
bail!(
"Plugin '{}' is already installed at {}",
manifest.name,
plugin_name,
destination.display()
);
}
let skills = find_agent_skills(checkout_dir, manifest.skills.as_ref())?;
copy_dir_all(checkout_dir, &destination)?;
let mut imported_skills = Vec::new();
for skill in skills {
let namespaced_name = namespaced_component_name(&manifest.name, &skill.name);
let namespaced_name = namespaced_component_name(&plugin_name, &skill.name);
let installed_skill_dir = destination.join(&skill.relative_directory);
rewrite_skill_name(&installed_skill_dir.join("SKILL.md"), &namespaced_name)?;
imported_skills.push(ImportedSkill {
@@ -127,8 +111,9 @@ fn do_install(
)?;
imported_skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(PluginInstall {
name: manifest.name,
name: plugin_name,
version: manifest.version.unwrap_or_else(|| "unknown".to_string()),
format: PluginFormat::OpenPlugins,
source: source.to_string(),
@@ -138,7 +123,7 @@ fn do_install(
}
pub(in crate::plugins) fn installed_skill_dirs(plugin_dir: &Path) -> Vec<PathBuf> {
let manifest = match read_manifest("", plugin_dir) {
let manifest = match read_manifest(plugin_dir, "") {
Ok(manifest) => manifest,
Err(_) => return Vec::new(),
};
@@ -161,30 +146,24 @@ pub(in crate::plugins) fn installed_skill_dirs(plugin_dir: &Path) -> Vec<PathBuf
dedupe_paths(dirs)
}
fn read_manifest(source: &str, plugin_dir: &Path) -> Result<OpenPluginsManifest> {
match manifest_path(plugin_dir) {
Some(manifest_path) => serde_json::from_str(&fs::read_to_string(&manifest_path)?)
.with_context(|| format!("Failed to parse {}", manifest_path.display())),
None => Ok(OpenPluginsManifest {
name: infer_name_from_source(source, plugin_dir),
fn read_manifest(plugin_dir: &Path, source: &str) -> Result<OpenPluginsManifest> {
let mut manifest = match manifest_path(plugin_dir) {
Some(manifest_path) => {
serde_json::from_str::<OpenPluginsManifest>(&fs::read_to_string(&manifest_path)?)
.with_context(|| format!("Failed to parse {}", manifest_path.display()))?
}
None => OpenPluginsManifest {
name: None,
version: None,
skills: None,
}),
}
}
},
};
fn infer_name_from_source(source: &str, plugin_dir: &Path) -> String {
if !source.is_empty() {
let trimmed = source.trim_end_matches('/').trim_end_matches(".git");
if let Some(name) = trimmed.rsplit('/').find(|s| !s.is_empty()) {
return name.to_string();
}
if manifest.name.as_deref().map(str::is_empty).unwrap_or(true) {
manifest.name = Some(infer_name(plugin_dir, source));
}
plugin_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("plugin")
.to_string()
Ok(manifest)
}
fn manifest_path(plugin_dir: &Path) -> Option<PathBuf> {
@@ -194,6 +173,26 @@ fn manifest_path(plugin_dir: &Path) -> Option<PathBuf> {
.find(|path| path.is_file())
}
fn has_component_marker(plugin_dir: &Path) -> bool {
COMPONENT_MARKERS.iter().any(|marker| {
let path = plugin_dir.join(marker);
path.is_file() || path.is_dir()
})
}
fn infer_name(plugin_dir: &Path, source: &str) -> String {
let trimmed = source.trim_end_matches('/').trim_end_matches(".git");
let from_source = trimmed.rsplit('/').find(|s| !s.is_empty());
if let Some(name) = from_source {
return name.to_string();
}
plugin_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("plugin")
.to_string()
}
fn validate_plugin_name(name: &str) -> Result<()> {
if name.is_empty() || name.len() > 64 {
bail!(
@@ -523,24 +522,23 @@ mod tests {
}
#[test]
fn installs_open_plugins_with_root_manifest() {
fn installs_open_plugins_with_only_hooks() {
let install_root = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
fs::write(
repo.path().join("plugin.json"),
r#"{"name":"root-plugin","version":"2.0.0"}"#,
r#"{"name":"hello-hooks","version":"0.1.0"}"#,
)
.unwrap();
let skill_dir = repo.path().join("skills").join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::create_dir_all(repo.path().join("hooks")).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\ndescription: Deploy code\n---\nDo a deploy.",
repo.path().join("hooks/hooks.json"),
r#"{"hooks":{"SessionStart":[{"hooks":[]}]}}"#,
)
.unwrap();
let installed = install_from_manifest(
"https://example.invalid/repo.git",
"https://example.invalid/hello-hooks.git",
repo.path(),
install_root.path(),
&PluginInstallOptions::default(),
@@ -548,68 +546,16 @@ mod tests {
)
.unwrap();
assert_eq!(installed.name, "root-plugin");
assert_eq!(installed.version, "2.0.0");
assert_eq!(installed.skills.len(), 1);
assert_eq!(installed.skills[0].name, "root-plugin:deploy");
}
#[test]
fn installs_hooks_only_plugin() {
let install_root = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
let hooks_dir = repo.path().join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
fs::write(hooks_dir.join("hooks.json"), r#"{"hooks":[]}"#).unwrap();
fs::write(
repo.path().join("plugin.json"),
r#"{"name":"hooks-plugin","version":"1.0.0"}"#,
)
.unwrap();
let installed = install_from_manifest(
"https://example.invalid/repo.git",
repo.path(),
install_root.path(),
&PluginInstallOptions::default(),
None,
)
.unwrap();
assert_eq!(installed.name, "hooks-plugin");
assert_eq!(installed.name, "hello-hooks");
assert_eq!(installed.version, "0.1.0");
assert_eq!(installed.format, PluginFormat::OpenPlugins);
assert!(installed.skills.is_empty());
assert!(installed.directory.join("hooks/hooks.json").is_file());
assert!(installed.directory.join("plugin.json").is_file());
}
#[test]
fn rejects_open_plugins_without_skills() {
let install_root = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
fs::create_dir_all(repo.path().join(".plugin")).unwrap();
fs::write(
repo.path().join(".plugin/plugin.json"),
r#"{"name":"test-plugin","version":"1.0.0"}"#,
)
.unwrap();
let commands_dir = repo.path().join("commands");
fs::create_dir_all(&commands_dir).unwrap();
fs::write(commands_dir.join("deploy.md"), "Deploy to staging.").unwrap();
let err = install_from_manifest(
"https://example.invalid/repo.git",
repo.path(),
install_root.path(),
&PluginInstallOptions::default(),
None,
)
.unwrap_err();
assert!(err
.to_string()
.contains("does not contain any Open Plugins skills"));
}
#[test]
fn rejects_repo_without_manifest_or_hooks() {
fn bare_skills_directory_is_not_claimed_as_open_plugin() {
let install_root = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
let skill_dir = repo.path().join("skills").join("audit");
@@ -620,8 +566,8 @@ mod tests {
)
.unwrap();
let err = install_from_manifest(
"https://example.invalid/my-plugin.git",
let err = try_install_from_manifest_at_root(
"https://example.invalid/repo.git",
repo.path(),
install_root.path(),
&PluginInstallOptions::default(),
@@ -629,7 +575,50 @@ mod tests {
)
.unwrap_err();
assert!(err.is::<FormatNotSupported>());
assert!(err.is::<FormatNotSupported>(), "got: {err}");
}
#[test]
fn installs_manifestless_open_plugins_with_only_hooks() {
let install_root = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
fs::create_dir_all(repo.path().join("hooks")).unwrap();
fs::write(
repo.path().join("hooks/hooks.json"),
r#"{"hooks":{"SessionStart":[{"hooks":[]}]}}"#,
)
.unwrap();
let installed = try_install_from_manifest_at_root(
"https://example.invalid/hello-hooks.git",
repo.path(),
install_root.path(),
&PluginInstallOptions::default(),
None,
)
.unwrap();
assert_eq!(installed.name, "hello-hooks");
assert!(installed.skills.is_empty());
assert!(installed.directory.join("hooks/hooks.json").is_file());
}
#[test]
fn rejects_repo_with_no_manifest_or_components() {
let install_root = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
fs::write(repo.path().join("README.md"), "Hi").unwrap();
let err = try_install_from_manifest_at_root(
"https://example.invalid/repo.git",
repo.path(),
install_root.path(),
&PluginInstallOptions::default(),
None,
)
.unwrap_err();
assert!(err.is::<FormatNotSupported>(), "got: {err}");
}
#[test]
@@ -679,18 +668,31 @@ mod tests {
}
#[test]
fn infers_name_from_git_url() {
assert_eq!(
infer_name_from_source("https://github.com/org/my-plugin.git", Path::new("/tmp/x")),
"my-plugin"
);
assert_eq!(
infer_name_from_source("https://github.com/org/my-plugin/", Path::new("/tmp/x")),
"my-plugin"
);
assert_eq!(
infer_name_from_source("", Path::new("/tmp/fallback")),
"fallback"
fn defers_to_gemini_when_gemini_manifest_present_without_open_plugin_manifest() {
let install_root = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
fs::write(
repo.path().join(super::super::gemini::MANIFEST),
r#"{"name":"gemini-ext","version":"1.0.0"}"#,
)
.unwrap();
let commands_dir = repo.path().join("commands");
fs::create_dir_all(&commands_dir).unwrap();
fs::write(commands_dir.join("deploy.md"), "Deploy to staging.").unwrap();
let err = try_install_from_manifest_at_root(
"https://example.invalid/Gemini-Ext.git",
repo.path(),
install_root.path(),
&PluginInstallOptions::default(),
None,
)
.unwrap_err();
assert!(
err.is::<FormatNotSupported>(),
"expected FormatNotSupported so Gemini installer can take over, got: {err}"
);
}
}
+8 -5
View File
@@ -30,11 +30,14 @@ impl std::fmt::Display for PluginFormat {
}
}
/// Directory where plugins installed via `install_plugin` live.
pub fn plugin_install_dir() -> PathBuf {
Paths::plugins_dir()
}
pub fn project_plugin_install_dir(project_root: &Path) -> PathBuf {
project_root.join(".agents").join("plugins")
}
#[derive(Debug, Clone)]
pub struct PluginInstall {
pub name: String,
@@ -79,7 +82,7 @@ struct InstallMetadata {
}
pub fn installed_plugin_skill_dirs() -> Vec<PathBuf> {
let plugins_dir = Paths::plugins_dir();
let plugins_dir = plugin_install_dir();
for update in auto_update_plugins_at_root(Utc::now(), &plugins_dir) {
if let Err(err) = update.result {
warn!(
@@ -119,7 +122,7 @@ pub fn install_plugin_with_options(
source: &str,
options: PluginInstallOptions,
) -> Result<PluginInstall> {
install_plugin_with_options_at_root(source, options, &Paths::plugins_dir())
install_plugin_with_options_at_root(source, options, &plugin_install_dir())
}
fn install_plugin_with_options_at_root(
@@ -145,11 +148,11 @@ fn install_plugin_with_options_at_root(
}
pub fn update_plugin(name: &str) -> Result<PluginInstall> {
update_plugin_at_root(Utc::now(), &Paths::plugins_dir(), name)
update_plugin_at_root(Utc::now(), &plugin_install_dir(), name)
}
pub fn auto_update_plugins() -> Vec<PluginAutoUpdateResult> {
auto_update_plugins_at_root(Utc::now(), &Paths::plugins_dir())
auto_update_plugins_at_root(Utc::now(), &plugin_install_dir())
}
fn auto_update_plugins_at_root(