feat: open-plugins generalization + skills (#9112)
Co-authored-by: Jack Amadeo <jackamadeo@block.xyz>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user