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
+32 -8
View File
@@ -500,6 +500,28 @@ impl CliSession {
/// Start an interactive session, optionally with an initial message
pub async fn interactive(&mut self, prompt: Option<String>) -> Result<()> {
self.agent
.emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id)
.await;
let result = self.run_interactive(prompt).await;
self.agent
.emit_hook(goose::hooks::HookEvent::SessionEnd, &self.session_id)
.await;
if result.is_ok() {
println!(
"\n {} {}",
console::style("").red(),
console::style(format!("session closed · {}", &self.session_id)).dim()
);
}
result
}
async fn run_interactive(&mut self, prompt: Option<String>) -> Result<()> {
if let Some(prompt) = prompt {
let msg = Message::user().with_text(&prompt);
self.process_message(msg, CancellationToken::default(), true)
@@ -536,12 +558,6 @@ impl CliSession {
.await?;
}
println!(
"\n {} {}",
console::style("").red(),
console::style(format!("session closed · {}", &self.session_id)).dim()
);
Ok(())
}
@@ -1044,9 +1060,17 @@ impl CliSession {
/// Process a single message and exit
pub async fn headless(&mut self, prompt: String) -> Result<()> {
self.agent
.emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id)
.await;
let message = Message::user().with_text(&prompt);
self.process_message(message, CancellationToken::default(), false)
.await?;
let result = self
.process_message(message, CancellationToken::default(), false)
.await;
self.agent
.emit_hook(goose::hooks::HookEvent::SessionEnd, &self.session_id)
.await;
result?;
Ok(())
}
+101 -10
View File
@@ -171,6 +171,7 @@ pub struct Agent {
pub(super) retry_manager: RetryManager,
pub(super) tool_inspection_manager: ToolInspectionManager,
pub(super) hook_manager: crate::hooks::HookManager,
container: Mutex<Option<Container>>,
}
@@ -282,10 +283,63 @@ impl Agent {
permission_manager,
provider.clone(),
),
hook_manager: crate::hooks::HookManager::load(std::env::current_dir().ok().as_deref()),
container: Mutex::new(None),
}
}
/// Emit a lifecycle hook event with no extra context. Useful for events
/// that have no matcher (e.g. `SessionStart`, `SessionEnd`).
pub async fn emit_hook(&self, event: crate::hooks::HookEvent, session_id: &str) {
if !self.hook_manager.has_hooks(event) {
return;
}
self.hook_manager
.emit(event, crate::hooks::HookContext::new(event, session_id))
.await;
}
fn with_post_tool_hook(
&self,
result: ToolCallResult,
tool_call: &CallToolRequestParams,
session: &Session,
) -> ToolCallResult {
let hook_manager = self.hook_manager.clone();
let session_id = session.id.clone();
let working_dir = session.working_dir.to_string_lossy().to_string();
let tool_name = tool_call.name.to_string();
let tool_input = tool_call
.arguments
.as_ref()
.map(|a| serde_json::Value::Object(a.clone()));
let fut = async move {
let processed_result =
super::large_response_handler::process_tool_response(result.result.await);
let event = match &processed_result {
Ok(call_result) if call_result.is_error != Some(true) => {
crate::hooks::HookEvent::PostToolUse
}
_ => crate::hooks::HookEvent::PostToolUseFailure,
};
if hook_manager.has_hooks(event) {
let ctx = crate::hooks::HookContext::new(event, &session_id)
.with_tool(tool_name, tool_input)
.with_working_dir(working_dir);
hook_manager.emit(event, ctx).await;
}
processed_result
};
ToolCallResult {
notification_stream: result.notification_stream,
result: Box::new(fut.boxed()),
}
}
/// Create a tool inspection manager with default inspectors
fn create_tool_inspection_manager(
permission_manager: Arc<PermissionManager>,
@@ -613,22 +667,52 @@ impl Agent {
.await
.record_tool_arguments(&tool_call.arguments, &session.working_dir);
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::PreToolUse)
{
let ctx =
crate::hooks::HookContext::new(crate::hooks::HookEvent::PreToolUse, &session.id)
.with_tool(
tool_call.name.to_string(),
tool_call
.arguments
.as_ref()
.map(|a| serde_json::Value::Object(a.clone())),
)
.with_working_dir(session.working_dir.to_string_lossy().to_string());
self.hook_manager
.emit(crate::hooks::HookEvent::PreToolUse, ctx)
.await;
}
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
let arguments = tool_call
.arguments
.clone()
.map(Value::Object)
.unwrap_or(Value::Object(serde_json::Map::new()));
let result = self
.handle_schedule_management(arguments, request_id.clone())
.await;
let wrapped_result = result.map(CallToolResult::success);
return (request_id, Ok(ToolCallResult::from(wrapped_result)));
return (
request_id,
Ok(self.with_post_tool_hook(
ToolCallResult::from(wrapped_result),
&tool_call,
session,
)),
);
}
if tool_call.name == FINAL_OUTPUT_TOOL_NAME {
return if let Some(final_output_tool) = self.final_output_tool.lock().await.as_mut() {
let result = final_output_tool.execute_tool_call(tool_call.clone()).await;
(request_id, Ok(result))
(
request_id,
Ok(self.with_post_tool_hook(result, &tool_call, session)),
)
} else {
(
request_id,
@@ -680,14 +764,7 @@ impl Agent {
(
request_id,
Ok(ToolCallResult {
notification_stream: result.notification_stream,
result: Box::new(
result
.result
.map(super::large_response_handler::process_tool_response),
),
}),
Ok(self.with_post_tool_hook(result, &tool_call, session)),
)
}
@@ -1086,6 +1163,20 @@ impl Agent {
let message_text = user_message.as_concat_text();
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::UserPromptSubmit)
{
let ctx = crate::hooks::HookContext::new(
crate::hooks::HookEvent::UserPromptSubmit,
&session_config.id,
)
.with_message(message_text.clone());
self.hook_manager
.emit(crate::hooks::HookEvent::UserPromptSubmit, ctx)
.await;
}
// Track custom slash command usage (don't track command name for privacy)
if message_text.trim().starts_with('/') {
let command = message_text.split_whitespace().next();
+555
View File
@@ -0,0 +1,555 @@
//! Lifecycle hooks support, modelled after the Open Plugins
//! [hooks specification](https://open-plugins.com/agent-builders/components/hooks).
//!
//! Hooks live in `<plugin-root>/hooks/hooks.json` of any plugin discovered by
//! [`crate::plugins::discovery::discover_enabled_plugins`]. The schema is:
//!
//! ```json
//! {
//! "hooks": {
//! "PostToolUse": [
//! {
//! "matcher": "developer__shell|developer__text_editor",
//! "hooks": [
//! { "type": "command", "command": "${PLUGIN_ROOT}/scripts/log.sh" }
//! ]
//! }
//! ]
//! }
//! }
//! ```
//!
//! Goose currently supports `type: "command"` actions. Unknown event names and
//! action types are ignored per the spec. Hook scripts receive the JSON event
//! context on stdin and SHOULD exit 0 on success.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tracing::{debug, info, warn};
use crate::plugins::discovery::{discover_enabled_plugins, DiscoveredPlugin};
/// Default per-hook timeout when the plugin does not specify one.
const DEFAULT_HOOK_TIMEOUT_SECS: u64 = 30;
/// Lifecycle events a hook can subscribe to.
///
/// The variant names match the event names used in `hooks.json`. Unknown
/// events in user config are ignored at load time, per the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
SessionStart,
SessionEnd,
UserPromptSubmit,
BeforeReadFile,
AfterFileEdit,
BeforeShellExecution,
AfterShellExecution,
Stop,
}
impl HookEvent {
fn name(&self) -> &'static str {
match self {
HookEvent::PreToolUse => "PreToolUse",
HookEvent::PostToolUse => "PostToolUse",
HookEvent::PostToolUseFailure => "PostToolUseFailure",
HookEvent::SessionStart => "SessionStart",
HookEvent::SessionEnd => "SessionEnd",
HookEvent::UserPromptSubmit => "UserPromptSubmit",
HookEvent::BeforeReadFile => "BeforeReadFile",
HookEvent::AfterFileEdit => "AfterFileEdit",
HookEvent::BeforeShellExecution => "BeforeShellExecution",
HookEvent::AfterShellExecution => "AfterShellExecution",
HookEvent::Stop => "Stop",
}
}
fn from_name(name: &str) -> Option<Self> {
Some(match name {
"PreToolUse" => HookEvent::PreToolUse,
"PostToolUse" => HookEvent::PostToolUse,
"PostToolUseFailure" => HookEvent::PostToolUseFailure,
"SessionStart" => HookEvent::SessionStart,
"SessionEnd" => HookEvent::SessionEnd,
"UserPromptSubmit" => HookEvent::UserPromptSubmit,
"BeforeReadFile" => HookEvent::BeforeReadFile,
"AfterFileEdit" => HookEvent::AfterFileEdit,
"BeforeShellExecution" => HookEvent::BeforeShellExecution,
"AfterShellExecution" => HookEvent::AfterShellExecution,
"Stop" => HookEvent::Stop,
_ => return None,
})
}
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
/// Top-level `hooks.json` shape.
#[derive(Debug, Default, Deserialize)]
struct HooksFile {
#[serde(default)]
hooks: HashMap<String, Vec<RawHookRule>>,
}
/// One rule within a `hooks.json` event entry.
#[derive(Debug, Deserialize)]
struct RawHookRule {
#[serde(default)]
matcher: Option<String>,
#[serde(default)]
hooks: Vec<RawHookAction>,
}
/// One action entry under a rule's `hooks` array. We only run `command`
/// today, but we deserialize the others so that loading a plugin which uses
/// them does not fail.
#[derive(Debug, Deserialize)]
struct RawHookAction {
#[serde(default, rename = "type")]
action_type: Option<String>,
#[serde(default)]
command: Option<String>,
#[serde(default)]
timeout: Option<u64>,
}
/// A loaded, plugin-bound hook rule ready to execute.
#[derive(Debug, Clone)]
struct LoadedRule {
plugin_name: String,
plugin_root: PathBuf,
matcher: Option<Regex>,
actions: Vec<LoadedAction>,
}
#[derive(Debug, Clone)]
enum LoadedAction {
Command { command: String, timeout: Duration },
}
/// Context passed to a hook as JSON on stdin.
///
/// The `matcher_context` is the string the rule's `matcher` regex is tested
/// against — tool name for tool events, file path for file events, command
/// string for shell events. Other fields carry the same value plus the
/// raw JSON payload of the underlying event so scripts can do richer things
/// without needing to parse a hook-specific schema.
#[derive(Debug, Clone, Serialize)]
pub struct HookContext {
pub event: String,
pub session_id: String,
pub matcher_context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_input: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_output: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
}
impl HookContext {
pub fn new(event: HookEvent, session_id: impl Into<String>) -> Self {
Self {
event: event.to_string(),
session_id: session_id.into(),
matcher_context: None,
tool_name: None,
tool_input: None,
tool_output: None,
message: None,
working_dir: None,
}
}
pub fn with_tool(mut self, tool_name: impl Into<String>, tool_input: Option<Value>) -> Self {
let name = tool_name.into();
self.matcher_context = Some(name.clone());
self.tool_name = Some(name);
self.tool_input = tool_input;
self
}
pub fn with_tool_output(mut self, output: Value) -> Self {
self.tool_output = Some(output);
self
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
let msg = message.into();
self.matcher_context.get_or_insert_with(|| msg.clone());
self.message = Some(msg);
self
}
pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
self.working_dir = Some(dir.into());
self
}
}
/// Loads and executes plugin hooks.
#[derive(Debug, Default, Clone)]
pub struct HookManager {
rules: HashMap<HookEvent, Vec<LoadedRule>>,
}
impl HookManager {
/// Build a manager by scanning all enabled plugins for `hooks/hooks.json`.
pub fn load(project_root: Option<&Path>) -> Self {
let plugins = discover_enabled_plugins(project_root);
Self::from_plugins(plugins)
}
fn from_plugins(plugins: Vec<DiscoveredPlugin>) -> Self {
let mut rules: HashMap<HookEvent, Vec<LoadedRule>> = HashMap::new();
let mut total = 0usize;
for plugin in plugins {
let hooks_path = plugin.root.join("hooks").join("hooks.json");
if !hooks_path.is_file() {
continue;
}
match load_hooks_file(&hooks_path, &plugin.name, &plugin.root) {
Ok(loaded) => {
for (event, plugin_rules) in loaded {
total += plugin_rules.len();
rules.entry(event).or_default().extend(plugin_rules);
}
}
Err(err) => warn!(
plugin = %plugin.name,
path = %hooks_path.display(),
error = %err,
"Failed to load plugin hooks; skipping",
),
}
}
if total > 0 {
info!(
rule_count = total,
events = ?rules.keys().map(|e| e.name()).collect::<Vec<_>>(),
"Loaded plugin hooks",
);
}
Self { rules }
}
/// Returns true if any rule is registered for `event`.
pub fn has_hooks(&self, event: HookEvent) -> bool {
self.rules.get(&event).is_some_and(|r| !r.is_empty())
}
/// Fire all rules whose matcher matches the event context. Errors from
/// individual hooks are logged but never propagated — a misbehaving hook
/// MUST NOT crash the host tool.
pub async fn emit(&self, event: HookEvent, ctx: HookContext) {
let Some(rules) = self.rules.get(&event) else {
return;
};
if rules.is_empty() {
return;
}
let payload = match serde_json::to_string(&ctx) {
Ok(s) => s,
Err(err) => {
warn!(event = %event, error = %err, "Failed to serialize hook context");
return;
}
};
for rule in rules {
if let Some(matcher) = &rule.matcher {
let target = ctx.matcher_context.as_deref().unwrap_or("");
if !matcher.is_match(target) {
continue;
}
}
for action in &rule.actions {
let LoadedAction::Command { command, timeout } = action;
debug!(
plugin = %rule.plugin_name,
event = %event,
command = %command,
"Running plugin hook",
);
if let Err(err) =
run_command_hook(command, &rule.plugin_root, &payload, *timeout).await
{
warn!(
plugin = %rule.plugin_name,
event = %event,
command = %command,
error = %err,
"Plugin hook failed",
);
}
}
}
}
}
fn load_hooks_file(
path: &Path,
plugin_name: &str,
plugin_root: &Path,
) -> Result<HashMap<HookEvent, Vec<LoadedRule>>> {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let parsed: HooksFile =
serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
let mut out: HashMap<HookEvent, Vec<LoadedRule>> = HashMap::new();
for (event_name, raw_rules) in parsed.hooks {
let Some(event) = HookEvent::from_name(&event_name) else {
debug!(plugin = plugin_name, event = %event_name, "Ignoring unknown hook event");
continue;
};
for raw in raw_rules {
let matcher = match raw.matcher.as_deref().filter(|s| !s.is_empty()) {
Some(pattern) => match Regex::new(pattern) {
Ok(re) => Some(re),
Err(err) => {
warn!(
plugin = plugin_name,
pattern,
error = %err,
"Invalid hook matcher regex; skipping rule",
);
continue;
}
},
None => None,
};
let mut actions = Vec::new();
for raw_action in raw.hooks {
match raw_action.action_type.as_deref().unwrap_or("command") {
"command" => {
if let Some(cmd) = raw_action.command {
let timeout = Duration::from_secs(
raw_action.timeout.unwrap_or(DEFAULT_HOOK_TIMEOUT_SECS),
);
actions.push(LoadedAction::Command {
command: cmd,
timeout,
});
}
}
other => {
debug!(
plugin = plugin_name,
action_type = other,
"Ignoring unsupported hook action type",
);
}
}
}
if actions.is_empty() {
continue;
}
out.entry(event).or_default().push(LoadedRule {
plugin_name: plugin_name.to_string(),
plugin_root: plugin_root.to_path_buf(),
matcher,
actions,
});
}
}
Ok(out)
}
async fn run_command_hook(
raw_command: &str,
plugin_root: &Path,
payload: &str,
timeout: Duration,
) -> Result<()> {
let command = expand_plugin_root(raw_command, plugin_root);
let mut child = Command::new("sh")
.arg("-c")
.arg(&command)
.env("PLUGIN_ROOT", plugin_root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("spawning hook `{command}`"))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(payload.as_bytes()).await;
let _ = stdin.shutdown().await;
}
let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(res) => res.with_context(|| format!("waiting on hook `{command}`"))?,
Err(_) => anyhow::bail!("hook `{command}` timed out after {:?}", timeout),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"hook `{command}` exited with {:?}: {}",
output.status.code(),
stderr.trim()
);
}
Ok(())
}
fn expand_plugin_root(command: &str, plugin_root: &Path) -> String {
command.replace("${PLUGIN_ROOT}", &plugin_root.to_string_lossy())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::discovery::{DiscoveredPlugin, PluginSource};
fn write_plugin(root: &Path, name: &str, hooks_json: &str) -> PathBuf {
let plugin = root.join(name);
std::fs::create_dir_all(plugin.join("hooks")).unwrap();
std::fs::write(plugin.join("hooks").join("hooks.json"), hooks_json).unwrap();
plugin
}
fn make_manager(plugins: Vec<DiscoveredPlugin>) -> HookManager {
HookManager::from_plugins(plugins)
}
#[test]
fn ignores_unknown_events() {
let tmp = tempfile::tempdir().unwrap();
let root = write_plugin(
tmp.path(),
"p",
r#"{"hooks":{"NotARealEvent":[{"hooks":[{"type":"command","command":"echo"}]}]}}"#,
);
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
}]);
assert!(!mgr.has_hooks(HookEvent::PreToolUse));
}
#[test]
fn loads_matcher_and_command() {
let tmp = tempfile::tempdir().unwrap();
let root = write_plugin(
tmp.path(),
"p",
r#"{"hooks":{"PostToolUse":[{"matcher":"developer__.*","hooks":[{"type":"command","command":"echo hi"}]}]}}"#,
);
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
}]);
assert!(mgr.has_hooks(HookEvent::PostToolUse));
}
#[test]
fn invalid_matcher_skipped_without_panic() {
let tmp = tempfile::tempdir().unwrap();
let root = write_plugin(
tmp.path(),
"p",
r#"{"hooks":{"PostToolUse":[{"matcher":"[invalid","hooks":[{"type":"command","command":"echo"}]}]}}"#,
);
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
}]);
assert!(!mgr.has_hooks(HookEvent::PostToolUse));
}
#[tokio::test]
async fn emit_runs_command_with_plugin_root_substitution() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("ran.txt");
let marker_path = marker.to_string_lossy().into_owned();
let hooks = format!(
r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"sh -c 'echo $PLUGIN_ROOT > {marker}'"}}]}}]}}}}"#,
marker = marker_path,
);
let root = write_plugin(tmp.path(), "p", &hooks);
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root: root.clone(),
source: PluginSource::UserPlaced,
}]);
mgr.emit(
HookEvent::SessionStart,
HookContext::new(HookEvent::SessionStart, "session-1"),
)
.await;
let written = std::fs::read_to_string(&marker).unwrap();
assert_eq!(written.trim(), root.to_string_lossy());
}
#[tokio::test]
async fn matcher_filters_by_tool_name() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("ran.txt");
let hooks = format!(
r#"{{"hooks":{{"PreToolUse":[{{"matcher":"developer__shell","hooks":[{{"type":"command","command":"touch {}"}}]}}]}}}}"#,
marker.to_string_lossy(),
);
let root = write_plugin(tmp.path(), "p", &hooks);
let mgr = make_manager(vec![DiscoveredPlugin {
name: "p".into(),
root,
source: PluginSource::UserPlaced,
}]);
// Non-matching tool: marker not created.
mgr.emit(
HookEvent::PreToolUse,
HookContext::new(HookEvent::PreToolUse, "s").with_tool("other__tool", None),
)
.await;
assert!(!marker.exists());
// Matching tool: marker created.
mgr.emit(
HookEvent::PreToolUse,
HookContext::new(HookEvent::PreToolUse, "s").with_tool("developer__shell", None),
)
.await;
assert!(marker.exists());
}
}
+1
View File
@@ -19,6 +19,7 @@ pub mod execution;
pub mod gateway;
pub mod goose_apps;
pub mod hints;
pub mod hooks;
pub mod instance_id;
pub mod logging;
pub mod mcp_utils;
+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,
+48
View File
@@ -0,0 +1,48 @@
# hello-hooks
A tiny [Open Plugins](https://open-plugins.com) plugin that demonstrates goose's
hook system. It registers four event handlers — `SessionStart`,
`UserPromptSubmit`, `PreToolUse`, and `PostToolUse` (matched on
`developer__shell|developer__text_editor`) — that each shell out to
`scripts/announce.sh` to print a noticeable line to stderr and append the full
event payload to `last-event.log` next to the plugin.
## Layout
```
hello-hooks/
├── plugin.json
├── hooks/
│ └── hooks.json
└── scripts/
└── announce.sh
```
## Try it
Goose discovers plugins under `~/.agents/plugins/<name>/` (user scope) and
`<project-root>/.agents/plugins/<name>/` (project scope) per the Open Plugins
[installation spec](https://open-plugins.com/plugin-builders/installation#recommended-storage-paths).
```bash
mkdir -p ~/.agents/plugins
cp -R examples/plugins/hello-hooks ~/.agents/plugins/hello-hooks
chmod +x ~/.agents/plugins/hello-hooks/scripts/announce.sh
# Then run goose normally; you should see lines like
# 🚀 [hello-hooks] SessionStart
# 💬 [hello-hooks] UserPromptSubmit
# ⚡ [hello-hooks] PreToolUse tool=developer__shell
# ✅ [hello-hooks] PostToolUse tool=developer__shell
goose session
# Inspect the full payloads goose passed to the hook:
tail ~/.agents/plugins/hello-hooks/last-event.log
```
To turn the plugin off, add it to `disabledPlugins` in
`~/.config/goose/settings.json`:
```json
{ "disabledPlugins": ["hello-hooks"] }
```
@@ -0,0 +1,45 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/announce.sh start"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/announce.sh prompt"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/announce.sh pre-tool"
}
]
}
],
"PostToolUse": [
{
"matcher": "developer__shell|developer__text_editor",
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/announce.sh post-tool"
}
]
}
]
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "hello-hooks",
"version": "0.1.0",
"description": "A tiny demo plugin that prints messages so you can see goose's hook system firing in real time."
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Tiny hook handler that loudly announces every event goose fires at it.
#
# Goose pipes the event payload as JSON on stdin and sets PLUGIN_ROOT in the
# environment. We tee a copy of the payload into a log file inside the plugin
# directory so you can see the full structure later.
set -euo pipefail
label="${1:-event}"
log="${PLUGIN_ROOT:-.}/last-event.log"
payload="$(cat)"
event_name="$(printf '%s' "$payload" | sed -n 's/.*"event":"\([^"]*\)".*/\1/p')"
tool_name="$(printf '%s' "$payload" | sed -n 's/.*"tool_name":"\([^"]*\)".*/\1/p')"
emoji() {
case "$1" in
start) printf '\xf0\x9f\x9a\x80' ;; # rocket
prompt) printf '\xf0\x9f\x92\xac' ;; # speech balloon
pre-tool) printf '\xe2\x9a\xa1' ;; # zap
post-tool) printf '\xe2\x9c\x85' ;; # check
*) printf '\xf0\x9f\x94\x94' ;; # bell
esac
}
icon="$(emoji "$label")"
suffix=""
[ -n "$tool_name" ] && suffix=" tool=$tool_name"
# Both stderr (so it shows up alongside goose tracing) and a log file.
printf '%s [hello-hooks] %s%s\n' "$icon" "${event_name:-$label}" "$suffix" 1>&2
{
printf -- '---- %s @ %s ----\n' "${event_name:-$label}" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '%s\n' "$payload"
} >> "$log"