diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 4deb36ae..ca179d16 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -500,6 +500,28 @@ impl CliSession { /// Start an interactive session, optionally with an initial message pub async fn interactive(&mut self, prompt: Option) -> 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) -> 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(()) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index f2ffddff..d6824237 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -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>, } @@ -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, @@ -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(); diff --git a/crates/goose/src/hooks/mod.rs b/crates/goose/src/hooks/mod.rs new file mode 100644 index 00000000..ea7f2266 --- /dev/null +++ b/crates/goose/src/hooks/mod.rs @@ -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 `/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 { + 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>, +} + +/// One rule within a `hooks.json` event entry. +#[derive(Debug, Deserialize)] +struct RawHookRule { + #[serde(default)] + matcher: Option, + #[serde(default)] + hooks: Vec, +} + +/// 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, + #[serde(default)] + command: Option, + #[serde(default)] + timeout: Option, +} + +/// A loaded, plugin-bound hook rule ready to execute. +#[derive(Debug, Clone)] +struct LoadedRule { + plugin_name: String, + plugin_root: PathBuf, + matcher: Option, + actions: Vec, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_dir: Option, +} + +impl HookContext { + pub fn new(event: HookEvent, session_id: impl Into) -> 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, tool_input: Option) -> 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) -> 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) -> Self { + self.working_dir = Some(dir.into()); + self + } +} + +/// Loads and executes plugin hooks. +#[derive(Debug, Default, Clone)] +pub struct HookManager { + rules: HashMap>, +} + +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) -> Self { + let mut rules: HashMap> = 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::>(), + "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>> { + 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> = 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) -> 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()); + } +} diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 98e6c051..273ac4d4 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -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; diff --git a/crates/goose/src/plugins/discovery.rs b/crates/goose/src/plugins/discovery.rs new file mode 100644 index 00000000..38b83424 --- /dev/null +++ b/crates/goose/src/plugins/discovery.rs @@ -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. +//! `/plugins//`). +//! 2. **User-placed**: plugins dropped into `~/.agents/plugins//` or +//! `/.agents/plugins//` per the Open Plugins spec. +//! +//! Settings files (`/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 . +#[derive(Debug, Default, Deserialize)] +struct PluginSettings { + #[serde(default, rename = "enabledPlugins")] + enabled: Vec, + #[serde(default, rename = "disabledPlugins")] + disabled: Vec, +} + +/// 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 { + let settings = load_all_settings(project_root); + + let mut found: HashMap = 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 = 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> { + 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::>() + ); + } +} diff --git a/crates/goose/src/plugins/formats/mod.rs b/crates/goose/src/plugins/formats/mod.rs index e816fe51..faf54092 100644 --- a/crates/goose/src/plugins/formats/mod.rs +++ b/crates/goose/src/plugins/formats/mod.rs @@ -1 +1,2 @@ pub(super) mod gemini; +pub(super) mod open_plugins; diff --git a/crates/goose/src/plugins/formats/open_plugins.rs b/crates/goose/src/plugins/formats/open_plugins.rs new file mode 100644 index 00000000..e4de3293 --- /dev/null +++ b/crates/goose/src/plugins/formats/open_plugins.rs @@ -0,0 +1,159 @@ +//! Open Plugins format adapter (). +//! +//! 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, + #[serde(default)] + version: Option, +} + +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>, +) -> Result { + 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 { + 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 = 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 +} diff --git a/crates/goose/src/plugins/mod.rs b/crates/goose/src/plugins/mod.rs index cbec1831..e474612c 100644 --- a/crates/goose/src/plugins/mod.rs +++ b/crates/goose/src/plugins/mod.rs @@ -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>, ) -> Result { + 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::() => {} + Err(err) => return Err(err), + } + match formats::gemini::try_install_from_manifest_at_root( source, checkout_dir, diff --git a/examples/plugins/hello-hooks/README.md b/examples/plugins/hello-hooks/README.md new file mode 100644 index 00000000..8d4aca50 --- /dev/null +++ b/examples/plugins/hello-hooks/README.md @@ -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//` (user scope) and +`/.agents/plugins//` (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"] } +``` diff --git a/examples/plugins/hello-hooks/hooks/hooks.json b/examples/plugins/hello-hooks/hooks/hooks.json new file mode 100644 index 00000000..c19b034b --- /dev/null +++ b/examples/plugins/hello-hooks/hooks/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/examples/plugins/hello-hooks/plugin.json b/examples/plugins/hello-hooks/plugin.json new file mode 100644 index 00000000..f004c9c3 --- /dev/null +++ b/examples/plugins/hello-hooks/plugin.json @@ -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." +} diff --git a/examples/plugins/hello-hooks/scripts/announce.sh b/examples/plugins/hello-hooks/scripts/announce.sh new file mode 100755 index 00000000..d0c2ed7b --- /dev/null +++ b/examples/plugins/hello-hooks/scripts/announce.sh @@ -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"