feat: Hooks (#9093)

This commit is contained in:
Alex Hancock
2026-05-11 12:02:44 -04:00
committed by GitHub
parent f5eda3527c
commit 40d4b118bb
12 changed files with 1258 additions and 18 deletions
+255
View File
@@ -0,0 +1,255 @@
//! 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::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.
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
pub name: String,
pub root: PathBuf,
pub source: PluginSource,
}
#[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,
}
/// Settings file format from <https://open-plugins.com/plugin-builders/installation>.
#[derive(Debug, Default, Deserialize)]
struct PluginSettings {
#[serde(default, rename = "enabledPlugins")]
enabled: Vec<String>,
#[serde(default, rename = "disabledPlugins")]
disabled: Vec<String>,
}
/// Scope of a settings file, in precedence order (highest first).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Scope {
Local,
Project,
User,
}
/// Discover all plugins that should be considered active.
///
/// `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 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) {
found.entry(name.clone()).or_insert(DiscoveredPlugin {
name,
root,
source: PluginSource::UserPlaced,
});
}
}
// 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();
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,
}
})
.collect()
}
fn list_installed_plugins() -> Vec<(String, PathBuf)> {
list_dir_children(&plugin_install_dir())
}
fn list_dir_children(dir: &Path) -> Vec<(String, PathBuf)> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if !path.is_dir() {
return None;
}
let name = path.file_name()?.to_str()?.to_string();
Some((name, path))
})
.collect()
}
fn load_all_settings(project_root: Option<&Path>) -> Vec<(Scope, PluginSettings)> {
let mut paths: Vec<(Scope, PathBuf)> = vec![(Scope::User, user_settings_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
.into_iter()
.filter_map(|(scope, path)| match read_settings(&path) {
Ok(Some(s)) => Some((scope, s)),
Ok(None) => None,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to read plugin settings");
None
}
})
.collect()
}
fn user_settings_path() -> PathBuf {
Paths::in_config_dir("settings.json")
}
fn project_settings_path(project_root: &Path, local: bool) -> PathBuf {
let file = if local {
"settings.local.json"
} else {
"settings.json"
};
project_root.join(".config").join("goose").join(file)
}
fn read_settings(path: &Path) -> anyhow::Result<Option<PluginSettings>> {
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(path)?;
let parsed: PluginSettings = serde_json::from_str(&text)?;
Ok(Some(parsed))
}
#[cfg(test)]
mod tests {
use super::*;
fn write_plugin_dir(root: &Path, name: &str) {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("hooks")).unwrap();
std::fs::write(
dir.join("hooks").join("hooks.json"),
r#"{"hooks":{"SessionStart":[{"hooks":[]}]}}"#,
)
.unwrap();
}
#[test]
fn finds_user_placed_plugin_under_project_root() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
write_plugin_dir(&project.join(".agents").join("plugins"), "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:?}");
}
#[test]
fn disabled_plugin_is_filtered_out() {
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#"{"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() {
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();
let found = discover_enabled_plugins(Some(project));
assert!(
found.iter().any(|p| p.name == "demo"),
"user-placed plugin should remain available; got: {:?}",
found.iter().map(|p| &p.name).collect::<Vec<_>>()
);
}
}
+1
View File
@@ -1 +1,2 @@
pub(super) mod gemini;
pub(super) mod open_plugins;
@@ -0,0 +1,159 @@
//! Open Plugins format adapter (<https://open-plugins.com>).
//!
//! Plugins are recognized as Open Plugins if they contain either:
//! - `plugin.json` (the spec's optional manifest), or
//! - `hooks/hooks.json`
//!
//! A bare `skills/` directory alone is not enough — it falls through to the
//! Gemini adapter, which requires its own manifest. We probe Open Plugins
//! first so a plugin that ships both a `plugin.json` and a
//! `gemini-extension.json` is treated as Open Plugins.
use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use fs_err as fs;
use serde::Deserialize;
use crate::plugins::{
copy_dir_all, write_install_metadata, FormatNotSupported, ImportedSkill, PluginFormat,
PluginInstall, PluginInstallOptions,
};
const MANIFEST: &str = "plugin.json";
const HOOKS_FILE: &str = "hooks/hooks.json";
const SKILLS_DIR: &str = "skills";
/// Optional `plugin.json` shape. We only need name + version; everything
/// else is forwarded as-is when the plugin is loaded at runtime.
#[derive(Debug, Default, Deserialize)]
struct OpenPluginsManifest {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
}
pub(in crate::plugins) fn try_install_from_manifest_at_root(
source: &str,
checkout_dir: &Path,
install_root: &Path,
options: &PluginInstallOptions,
last_update_check: Option<DateTime<Utc>>,
) -> Result<PluginInstall> {
let manifest_path = checkout_dir.join(MANIFEST);
let hooks_path = checkout_dir.join(HOOKS_FILE);
let skills_path = checkout_dir.join(SKILLS_DIR);
let has_manifest = manifest_path.is_file();
let has_hooks = hooks_path.is_file();
let has_skills = skills_path.is_dir();
if !has_manifest && !has_hooks {
return Err(FormatNotSupported.into());
}
let manifest: OpenPluginsManifest = if has_manifest {
serde_json::from_str(&fs::read_to_string(&manifest_path)?)
.with_context(|| format!("Failed to parse {}", manifest_path.display()))?
} else {
OpenPluginsManifest::default()
};
let name = manifest
.name
.unwrap_or_else(|| infer_name_from_source(source));
validate_plugin_name(&name)?;
let version = manifest.version.unwrap_or_else(|| "0.0.0".to_string());
fs::create_dir_all(install_root)?;
let destination = install_root.join(&name);
if destination.exists() {
bail!(
"Plugin '{}' is already installed at {}",
name,
destination.display()
);
}
copy_dir_all(checkout_dir, &destination)?;
write_install_metadata(
&destination,
source,
"open-plugins",
options.auto_update,
last_update_check,
)?;
let skills = if has_skills {
find_skills(&destination)
} else {
Vec::new()
};
Ok(PluginInstall {
name,
version,
format: PluginFormat::OpenPlugins,
source: source.to_string(),
directory: destination,
skills,
})
}
fn infer_name_from_source(source: &str) -> String {
let trimmed = source.trim_end_matches('/').trim_end_matches(".git");
trimmed
.rsplit('/')
.find(|s| !s.is_empty())
.unwrap_or("plugin")
.to_string()
}
fn validate_plugin_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Plugin name must not be empty");
}
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
bail!(
"Invalid plugin name '{}'. Names may only contain letters, numbers, dashes, and underscores",
name
);
}
Ok(())
}
fn find_skills(plugin_dir: &Path) -> Vec<ImportedSkill> {
let skills_dir = plugin_dir.join(SKILLS_DIR);
let entries = match fs::read_dir(&skills_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut skills: Vec<ImportedSkill> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if !path.join("SKILL.md").is_file() {
continue;
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unnamed")
.to_string();
skills.push(ImportedSkill {
name,
directory: path,
});
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
skills
}
+20
View File
@@ -1,3 +1,4 @@
pub mod discovery;
pub mod formats;
use crate::config::paths::Paths;
@@ -16,16 +17,23 @@ const AUTO_UPDATE_INTERVAL_HOURS: i64 = 24;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginFormat {
Gemini,
OpenPlugins,
}
impl std::fmt::Display for PluginFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginFormat::Gemini => write!(f, "gemini"),
PluginFormat::OpenPlugins => write!(f, "open-plugins"),
}
}
}
/// Directory where plugins installed via `install_plugin` live.
pub fn plugin_install_dir() -> PathBuf {
Paths::plugins_dir()
}
#[derive(Debug, Clone)]
pub struct PluginInstall {
pub name: String,
@@ -241,6 +249,18 @@ fn install_from_checkout_at_root(
options: &PluginInstallOptions,
last_update_check: Option<DateTime<Utc>>,
) -> Result<PluginInstall> {
match formats::open_plugins::try_install_from_manifest_at_root(
source,
checkout_dir,
install_root,
options,
last_update_check,
) {
Ok(install) => return Ok(install),
Err(err) if err.is::<FormatNotSupported>() => {}
Err(err) => return Err(err),
}
match formats::gemini::try_install_from_manifest_at_root(
source,
checkout_dir,