mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-02 06:14:27 +02:00
feat: Hooks (#9093)
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 +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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user