diff --git a/Cargo.lock b/Cargo.lock index a09ff06..e942af5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "boopifier" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "assert_cmd", diff --git a/README.md b/README.md index 9d60621..4d8dee3 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Boopifier -A universal notification handler for Claude Code events. +A universal notification handler for Claude Code and OpenCode events. -Boopifier reads JSON events from stdin (sent by Claude Code hooks) and dispatches them to various notification handlers. Play sounds when Claude responds, get desktop notifications for important events, send yourself Signal messages, and more. **Crucially, it supports project-specific notification configs in your global config file** - perfect for keeping work notification preferences out of work repos while still getting customized notifications for each project. +Boopifier reads JSON events from stdin (sent by Claude Code or OpenCode hooks) and dispatches them to various notification handlers. Play sounds when Claude responds, get desktop notifications for important events, send yourself Signal messages, and more. **Crucially, it supports project-specific notification configs in your global config file** - perfect for keeping work notification preferences out of work repos while still getting customized notifications for each project. ## Features - **Project-Specific Overrides**: Define different notification handlers for different projects (by path pattern) in your global config - keep personal notification preferences out of work repos -- **Cross-Platform Hook Support**: Full implementation of all Claude Code hook types (Stop, Notification, PermissionRequest, SessionStart/End, PreCompact, and more) +- **Cross-Platform Hook Support**: Full implementation of all Claude Code hook types (Stop, Notification, PermissionRequest, SessionStart/End, PreCompact, and more) and OpenCode hook types (tool.execute.before/after, session.idle, file.edited, and more) - **Multiple Notification Targets**: Desktop, Sound, Signal, Webhook, Email -- **Flexible Event Matching**: Route different Claude Code events to different handlers with regex support +- **Flexible Event Matching**: Route different hook events to different handlers with regex support - **Secrets Management**: Environment variables and file-based secrets - **Async Handler Execution**: Fast, concurrent notification delivery - **Extensible Plugin System**: Easy to add new notification handlers @@ -111,6 +111,85 @@ Example `.claude/boopifier.json`: Now boopifier will play a sound on Notification events and show a desktop notification when Claude stops responding! +### Setup with OpenCode + +Boopifier natively supports [OpenCode](https://github.com/anomalyco/opencode) hooks. OpenCode events are automatically detected and normalized, so the same match rules and handlers work with both systems. + +**Step 1: Configure OpenCode hooks** + +Add boopifier as a shell hook in your `opencode.json`: + +```json +{ + "hooks": { + "session_completed": [ + { + "command": ["boopifier"] + } + ], + "file_edited": [ + { + "command": ["boopifier"] + } + ] + } +} +``` + +For plugin-based hooks, you can pipe events to boopifier from an OpenCode plugin using `ctx.$`: + +```typescript +export const BoopifierPlugin: Plugin = async ({ $, client }) => ({ + event: async ({ event }) => { + await $`echo ${JSON.stringify(event)} | boopifier`; + }, +}); +``` + +**Step 2: Configure boopifier handlers** + +Create a config file. When running under OpenCode, boopifier looks for config in this order: +1. `$OPENCODE_PROJECT_DIR/.opencode/boopifier.json` (OpenCode project config) +2. `$OPENCODE_PROJECT_DIR/.claude/boopifier.json` (shared project config) +3. `~/.config/opencode/boopifier.json` (OpenCode global config) +4. `~/.claude/boopifier.json` (shared global fallback) + +The config format is identical. OpenCode events are normalized with a `hook_event_name` field, so existing match rules work: + +```json +{ + "handlers": [ + { + "name": "sound-on-session-idle", + "type": "sound", + "match_rules": {"hook_event_name": "Stop"}, + "config": { + "file": "/path/to/done.mp3" + } + } + ] +} +``` + +#### OpenCode Event Mapping + +OpenCode events are automatically mapped to internal hook names: + +| OpenCode Event | `hook_event_name` | Description | +|---------------------------|-------------------|----------------------------------| +| `tool.execute.before` | `PreToolUse` | Before tool execution | +| `tool.execute.after` | `PostToolUse` | After tool execution | +| `session.idle` | `Stop` | Session idle / agent stopped | +| `session.created` | `SessionStart` | New session started | +| `session.deleted` | `SessionEnd` | Session deleted | +| `session.completed` | `Stop` | Session completed | +| `session.compacted` | `PreCompact` | History was compacted | +| `session.compacting` | `PreCompact` | History is about to be compacted | +| `file.edited` | `FileEdited` | A file was edited (OpenCode-only)| +| `session.error` | `SessionError` | Session error (OpenCode-only) | + +This means you can write a single boopifier config that works with both Claude Code and OpenCode. + ### Project-Specific Overrides You can define project-specific handler configurations in your **global** config file using path patterns. This is useful for work projects where you don't want to commit personal notification settings to the repo. @@ -151,7 +230,7 @@ Add an `overrides` array to `~/.claude/boopifier.json`: - Glob patterns are supported (`*`, `**`, etc.) - When a pattern matches, override handlers **replace** base handlers completely - If multiple patterns match, the **last match wins** -- Project-specific `.claude/boopifier.json` files still take full precedence +- Project-specific `.claude/boopifier.json` and `.opencode/boopifier.json` files still take full precedence ## Available Handlers @@ -211,7 +290,7 @@ See [GETTING_STARTED.md](GETTING_STARTED.md) for comprehensive documentation. ## Event Matching -Boopifier receives all fields from Claude Code hook events and makes them available for both matching rules and template substitution in handler configs. See the [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks) for details on what fields are available for each hook type. +Boopifier receives all fields from hook events and makes them available for both matching rules and template substitution in handler configs. See the [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks) for details on what fields are available for each hook type. OpenCode events are normalized with a `hook_event_name` field (see [event mapping](#opencode-event-mapping)), so the same match rules work for both. Handlers can match on event fields. Use `null` to match all events. @@ -282,11 +361,11 @@ See [CLAUDE.md](CLAUDE.md) for detailed development documentation. ## Architecture ``` -stdin → Event Parser → Config Loader → Event Matcher → Handler Registry → Notifications +stdin -> Event Parser -> [OpenCode Normalizer] -> Config Loader -> Event Matcher -> Handler Registry -> Notifications ``` -- **Event**: Flexible JSON structure from Claude Code -- **Config**: `.claude/boopifier.json` with handler definitions +- **Event**: Flexible JSON structure from Claude Code or OpenCode (auto-normalized) +- **Config**: `.claude/boopifier.json` or `.opencode/boopifier.json` with handler definitions - **Matcher**: Pattern matching to filter events - **Handlers**: Pluggable notification targets diff --git a/src/config.rs b/src/config.rs index 37856ac..b81935b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -137,9 +137,8 @@ impl Config { /// /// Returns an error if the JSON is invalid. pub fn from_json(json: &str) -> Result { - let config: Config = serde_json::from_str(json).map_err(|e| { - NotificationError::InvalidConfig(format!("Invalid JSON: {}", e)) - })?; + let config: Config = serde_json::from_str(json) + .map_err(|e| NotificationError::InvalidConfig(format!("Invalid JSON: {}", e)))?; Ok(config) } diff --git a/src/event.rs b/src/event.rs index 52044a4..613492b 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,15 +1,22 @@ -//! Claude Code event types. +//! Event types for Claude Code and OpenCode hooks. //! -//! This module defines the event structure received from Claude Code hooks via stdin. +//! This module defines the event structure received from hooks via stdin. +//! OpenCode events are automatically normalized with a `hook_event_name` field +//! so that existing matchers work transparently. +use crate::hooks::opencode; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -/// A Claude Code event received from stdin. +/// A hook event received from stdin. /// /// Events are flexible JSON objects that can contain any fields. /// The event type and other metadata are extracted from the JSON. +/// +/// Both Claude Code and OpenCode events are supported. OpenCode events are +/// automatically normalized: a `hook_event_name` field is injected so that +/// existing match rules (e.g., `{"hook_event_name": "Stop"}`) work for both. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Event { /// The raw JSON value for flexible matching @@ -20,12 +27,29 @@ pub struct Event { impl Event { /// Creates a new event from a JSON string. /// + /// If the event is from OpenCode (detected by dotted event type fields), + /// a `hook_event_name` field is injected with the mapped internal name. + /// /// # Errors /// /// Returns an error if the JSON is invalid. pub fn from_json(json: &str) -> anyhow::Result { - let event = serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_str(json)) - .map_err(|e| anyhow::anyhow!("Failed to parse event JSON: {}", e))?; + let mut event: Event = + serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_str(json)) + .map_err(|e| anyhow::anyhow!("Failed to parse event JSON: {}", e))?; + + // Normalize OpenCode events: inject hook_event_name if missing + if !event.data.contains_key("hook_event_name") { + if let Some(oc_type) = opencode::detect_opencode_event_type(&event.data) { + if let Some(mapped) = opencode::map_opencode_event(&oc_type) { + event.data.insert( + "hook_event_name".to_string(), + Value::String(mapped.to_string()), + ); + } + } + } + Ok(event) } @@ -87,4 +111,45 @@ mod tests { let json = r#"{"invalid": }"#; assert!(Event::from_json(json).is_err()); } + + #[test] + fn test_opencode_event_normalization() { + // OpenCode event with "type" field should get hook_event_name injected + let json = r#"{"type": "tool.execute.before", "tool": "bash"}"#; + let event = Event::from_json(json).unwrap(); + assert_eq!(event.get_str("hook_event_name"), Some("PreToolUse")); + // Original fields are preserved + assert_eq!(event.get_str("tool"), Some("bash")); + } + + #[test] + fn test_opencode_session_idle_normalization() { + let json = r#"{"event": "session.idle", "sessionID": "abc123"}"#; + let event = Event::from_json(json).unwrap(); + assert_eq!(event.get_str("hook_event_name"), Some("Stop")); + assert_eq!(event.get_str("sessionID"), Some("abc123")); + } + + #[test] + fn test_opencode_file_edited_normalization() { + let json = r#"{"hook": "file.edited", "file": "src/main.rs"}"#; + let event = Event::from_json(json).unwrap(); + assert_eq!(event.get_str("hook_event_name"), Some("FileEdited")); + } + + #[test] + fn test_claude_code_event_not_renormalized() { + // Claude Code events with existing hook_event_name should not be modified + let json = r#"{"hook_event_name": "Stop", "type": "something"}"#; + let event = Event::from_json(json).unwrap(); + assert_eq!(event.get_str("hook_event_name"), Some("Stop")); + } + + #[test] + fn test_unknown_event_no_normalization() { + // Unknown events should not get hook_event_name injected + let json = r#"{"type": "unknown.thing", "data": "test"}"#; + let event = Event::from_json(json).unwrap(); + assert_eq!(event.get_str("hook_event_name"), None); + } } diff --git a/src/handlers/desktop.rs b/src/handlers/desktop.rs index 1541466..0e6ce80 100644 --- a/src/handlers/desktop.rs +++ b/src/handlers/desktop.rs @@ -55,9 +55,9 @@ impl Handler for DesktopHandler { // Set urgency (Linux only) apply_urgency(&mut notification, &urgency); - notification - .show() - .map_err(|e| NotificationError::SendFailed(format!("Failed to send desktop notification: {}", e)))?; + notification.show().map_err(|e| { + NotificationError::SendFailed(format!("Failed to send desktop notification: {}", e)) + })?; Ok(()) } diff --git a/src/handlers/email.rs b/src/handlers/email.rs index 9e065b6..657edb3 100644 --- a/src/handlers/email.rs +++ b/src/handlers/email.rs @@ -23,20 +23,26 @@ impl Handler for EmailHandler { async fn handle(&self, event: &Event, config: &HashMap) -> HandlerResult<()> { // Required config - let to = config - .get("to") - .and_then(|v| v.as_str()) - .ok_or_else(|| NotificationError::InvalidConfig("Email handler requires 'to' configuration".to_string()))?; - - let from = config - .get("from") - .and_then(|v| v.as_str()) - .ok_or_else(|| NotificationError::InvalidConfig("Email handler requires 'from' configuration".to_string()))?; + let to = config.get("to").and_then(|v| v.as_str()).ok_or_else(|| { + NotificationError::InvalidConfig( + "Email handler requires 'to' configuration".to_string(), + ) + })?; + + let from = config.get("from").and_then(|v| v.as_str()).ok_or_else(|| { + NotificationError::InvalidConfig( + "Email handler requires 'from' configuration".to_string(), + ) + })?; let smtp_server = config .get("smtp_server") .and_then(|v| v.as_str()) - .ok_or_else(|| NotificationError::InvalidConfig("Email handler requires 'smtp_server' configuration".to_string()))?; + .ok_or_else(|| { + NotificationError::InvalidConfig( + "Email handler requires 'smtp_server' configuration".to_string(), + ) + })?; let smtp_port = config .get("smtp_port") @@ -45,11 +51,7 @@ impl Handler for EmailHandler { .unwrap_or(25); // Optional config - let subject = render_template( - config.get("subject"), - event, - "Claude Code Notification", - ); + let subject = render_template(config.get("subject"), event, "Claude Code Notification"); let body = render_template( config.get("body"), event, @@ -62,7 +64,14 @@ impl Handler for EmailHandler { // Send email send_email( - from, to, &subject, &body, smtp_server, smtp_port, username, password, + from, + to, + &subject, + &body, + smtp_server, + smtp_port, + username, + password, ) .await?; @@ -82,24 +91,31 @@ async fn send_email( ) -> HandlerResult<()> { // Build the email let email = Message::builder() - .from(from.parse().map_err(|e| NotificationError::Email(format!("Invalid 'from' address: {}", e)))?) - .to(to.parse().map_err(|e| NotificationError::Email(format!("Invalid 'to' address: {}", e)))?) + .from( + from.parse() + .map_err(|e| NotificationError::Email(format!("Invalid 'from' address: {}", e)))?, + ) + .to(to + .parse() + .map_err(|e| NotificationError::Email(format!("Invalid 'to' address: {}", e)))?) .subject(subject) .header(ContentType::TEXT_PLAIN) .body(body.to_string()) .map_err(|e| NotificationError::Email(format!("Failed to build email: {}", e)))?; // Build SMTP transport - use builder_dangerous for local/test servers - let mut mailer = if smtp_port == 1025 || smtp_server == "localhost" || smtp_server == "127.0.0.1" { - // Local test server - no TLS - SmtpTransport::builder_dangerous(smtp_server) - .port(smtp_port) - } else { - // Production server - use relay with TLS - SmtpTransport::relay(smtp_server) - .map_err(|e| NotificationError::Email(format!("Failed to connect to SMTP server: {}", e)))? - .port(smtp_port) - }; + let mut mailer = + if smtp_port == 1025 || smtp_server == "localhost" || smtp_server == "127.0.0.1" { + // Local test server - no TLS + SmtpTransport::builder_dangerous(smtp_server).port(smtp_port) + } else { + // Production server - use relay with TLS + SmtpTransport::relay(smtp_server) + .map_err(|e| { + NotificationError::Email(format!("Failed to connect to SMTP server: {}", e)) + })? + .port(smtp_port) + }; // Add credentials if provided if let (Some(user), Some(pass)) = (username, password) { diff --git a/src/handlers/signal.rs b/src/handlers/signal.rs index 473f959..6d2c102 100644 --- a/src/handlers/signal.rs +++ b/src/handlers/signal.rs @@ -25,7 +25,9 @@ impl Handler for SignalHandler { .get("recipient") .and_then(|v| v.as_str()) .ok_or_else(|| { - NotificationError::InvalidConfig("Signal handler requires 'recipient' configuration".to_string()) + NotificationError::InvalidConfig( + "Signal handler requires 'recipient' configuration".to_string(), + ) })?; // Get message template or use default @@ -71,7 +73,10 @@ async fn send_signal_message( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(NotificationError::Handler(format!("signal-cli failed: {}", stderr))); + return Err(NotificationError::Handler(format!( + "signal-cli failed: {}", + stderr + ))); } Ok(()) @@ -127,6 +132,9 @@ mod tests { let result = handler.handle(&event, &config).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("requires 'recipient'")); + assert!(result + .unwrap_err() + .to_string() + .contains("requires 'recipient'")); } } diff --git a/src/handlers/sound.rs b/src/handlers/sound.rs index ad92e12..f58862b 100644 --- a/src/handlers/sound.rs +++ b/src/handlers/sound.rs @@ -7,8 +7,8 @@ use crate::event::Event; use crate::handlers::{Handler, HandlerResult}; use async_trait::async_trait; use rand::seq::SliceRandom; -use rodio::{Decoder, Sink}; use rodio::stream::OutputStreamBuilder; +use rodio::{Decoder, Sink}; use serde_json::Value; use std::collections::HashMap; use std::fs::File; @@ -60,10 +60,7 @@ impl Handler for SoundHandler { let expanded_path = shellexpand::tilde(&file_path).to_string(); // Get optional volume (0.0 to 1.0, default 1.0) - let volume = config - .get("volume") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) as f32; + let volume = config.get("volume").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32; // Play the sound in a blocking task to avoid blocking the async runtime tokio::task::spawn_blocking(move || { @@ -71,9 +68,9 @@ impl Handler for SoundHandler { suppress_alsa_errors_if_not_debug(); play_sound(&expanded_path, volume) }) - .await - .map_err(|e| NotificationError::Audio(format!("Sound playback task failed: {}", e)))? - .map_err(|e| NotificationError::Audio(format!("Sound playback failed: {}", e)))?; + .await + .map_err(|e| NotificationError::Audio(format!("Sound playback task failed: {}", e)))? + .map_err(|e| NotificationError::Audio(format!("Sound playback failed: {}", e)))?; Ok(()) } @@ -100,13 +97,15 @@ fn get_sound_file(config: &HashMap) -> HandlerResult { .collect(), _ => { return Err(NotificationError::InvalidConfig( - "Sound handler 'files' must be an array of strings".to_string() + "Sound handler 'files' must be an array of strings".to_string(), )) } }; if files.is_empty() { - return Err(NotificationError::InvalidConfig("Sound handler 'files' array is empty".to_string())); + return Err(NotificationError::InvalidConfig( + "Sound handler 'files' array is empty".to_string(), + )); } // Check if random selection is enabled @@ -118,32 +117,33 @@ fn get_sound_file(config: &HashMap) -> HandlerResult { if random { // Randomly select one file let mut rng = rand::thread_rng(); - files - .choose(&mut rng) - .cloned() - .ok_or_else(|| NotificationError::Audio("Failed to randomly select sound file".to_string())) + files.choose(&mut rng).cloned().ok_or_else(|| { + NotificationError::Audio("Failed to randomly select sound file".to_string()) + }) } else { // Use first file if random not enabled Ok(files[0].clone()) } } else { Err(NotificationError::InvalidConfig( - "Sound handler requires either 'file' or 'files' configuration".to_string() + "Sound handler requires either 'file' or 'files' configuration".to_string(), )) } } fn play_sound(file_path: &str, volume: f32) -> HandlerResult<()> { // Get output stream handle (rodio 0.21 API) - let stream_handle = OutputStreamBuilder::open_default_stream() - .map_err(|e| NotificationError::Audio(format!("Failed to get audio output stream: {}", e)))?; + let stream_handle = OutputStreamBuilder::open_default_stream().map_err(|e| { + NotificationError::Audio(format!("Failed to get audio output stream: {}", e)) + })?; // Create a sink for audio playback (rodio 0.21 API) let sink = Sink::connect_new(&stream_handle.mixer()); // Open the audio file - let file = File::open(file_path) - .map_err(|e| NotificationError::Audio(format!("Failed to open audio file '{}': {}", file_path, e)))?; + let file = File::open(file_path).map_err(|e| { + NotificationError::Audio(format!("Failed to open audio file '{}': {}", file_path, e)) + })?; // Decode the audio file let source = Decoder::new(BufReader::new(file)) @@ -192,7 +192,10 @@ mod tests { #[test] fn test_get_sound_file_single() { let mut config = HashMap::new(); - config.insert("file".to_string(), Value::String("/path/to/sound.wav".to_string())); + config.insert( + "file".to_string(), + Value::String("/path/to/sound.wav".to_string()), + ); let result = get_sound_file(&config).unwrap(); assert_eq!(result, "/path/to/sound.wav"); diff --git a/src/handlers/webhook.rs b/src/handlers/webhook.rs index 62f378b..a4f6b24 100644 --- a/src/handlers/webhook.rs +++ b/src/handlers/webhook.rs @@ -42,12 +42,11 @@ impl Handler for WebhookHandler { async fn handle(&self, event: &Event, config: &HashMap) -> HandlerResult<()> { // Get webhook URL - let url = config - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - NotificationError::InvalidConfig("Webhook handler requires 'url' configuration".to_string()) - })?; + let url = config.get("url").and_then(|v| v.as_str()).ok_or_else(|| { + NotificationError::InvalidConfig( + "Webhook handler requires 'url' configuration".to_string(), + ) + })?; // Get payload type (slack, discord, json, or custom) let payload_type = config @@ -150,9 +149,11 @@ fn render_payload_template(value: &Value, event: &Event) -> Value { } Value::Object(new_map) } - Value::Array(arr) => { - Value::Array(arr.iter().map(|v| render_payload_template(v, event)).collect()) - } + Value::Array(arr) => Value::Array( + arr.iter() + .map(|v| render_payload_template(v, event)) + .collect(), + ), other => other.clone(), } } diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 3c74c1f..d89b975 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -1,15 +1,19 @@ -//! Hook type system for Claude Code hooks. +//! Hook type system for Claude Code and OpenCode hooks. //! //! Each hook type (Stop, Notification, PreToolUse, etc.) has specific response requirements. //! This module provides a trait-based abstraction for handling different hook types. +//! +//! OpenCode events are automatically detected and mapped to internal hook types. +//! See the [`opencode`] module for the mapping table. -pub mod stop; +pub mod compact; pub mod notification; -pub mod tool_use; +pub mod opencode; pub mod permission; pub mod prompt; pub mod session; -pub mod compact; +pub mod stop; +pub mod tool_use; use crate::event::Event; use anyhow::{bail, Result}; @@ -42,7 +46,7 @@ pub enum PermissionDecision { Ask, } -/// Trait for Claude Code hook types. +/// Trait for hook types (Claude Code and OpenCode). /// /// Each hook type knows how to generate its own JSON response format. pub trait Hook: Send + Sync { @@ -53,12 +57,32 @@ pub trait Hook: Send + Sync { fn generate_response(&self, outcomes: &[HandlerOutcome]) -> Value; } -/// Create a Hook instance from an event by parsing the hook_event_name +/// Create a Hook instance from an event. +/// +/// Supports both Claude Code events (via `hook_event_name` field) and OpenCode events +/// (via `type`, `event`, or `hook` fields with dotted notation like `tool.execute.before`). +/// +/// OpenCode events are normalized: a `hook_event_name` field is injected into the event +/// data so that existing matchers work without modification. pub fn hook_from_event(event: &Event) -> Result> { - let hook_event_name = event - .get_str("hook_event_name") - .unwrap_or("unknown"); + // Try Claude Code format first + let hook_event_name = if let Some(name) = event.get_str("hook_event_name") { + name.to_string() + } else if let Some(oc_event) = opencode::detect_opencode_event_type(&event.data) { + // Map OpenCode event to internal hook name + match opencode::map_opencode_event(&oc_event) { + Some(mapped) => mapped.to_string(), + None => bail!("Unrecognized OpenCode event: {}", oc_event), + } + } else { + bail!("No hook_event_name or recognized OpenCode event type found") + }; + + hook_from_name(&hook_event_name, event) +} +/// Create a Hook from a resolved hook name and event data. +fn hook_from_name(hook_event_name: &str, event: &Event) -> Result> { match hook_event_name { "Stop" | "SubagentStop" => Ok(Box::new(stop::StopHook::new(hook_event_name))), "Notification" => Ok(Box::new(notification::NotificationHook)), @@ -69,6 +93,8 @@ pub fn hook_from_event(event: &Event) -> Result> { "SessionStart" => Ok(Box::new(session::SessionStartHook)), "SessionEnd" => Ok(Box::new(session::SessionEndHook)), "PreCompact" => Ok(Box::new(compact::PreCompactHook)), + "FileEdited" => Ok(Box::new(opencode::FileEditedHook)), + "SessionError" => Ok(Box::new(opencode::SessionErrorHook)), _ => bail!("Unknown hook type: {}", hook_event_name), } } diff --git a/src/hooks/opencode.rs b/src/hooks/opencode.rs new file mode 100644 index 0000000..e5f6852 --- /dev/null +++ b/src/hooks/opencode.rs @@ -0,0 +1,180 @@ +//! OpenCode event normalization and hook support. +//! +//! OpenCode uses a different event naming convention than Claude Code. +//! This module maps OpenCode event names to internal hook types so that +//! existing matchers and handlers work unchanged. +//! +//! ## Event Name Mapping +//! +//! | OpenCode Event | Internal Hook Type | +//! |-------------------------|--------------------| +//! | `tool.execute.before` | `PreToolUse` | +//! | `tool.execute.after` | `PostToolUse` | +//! | `session.idle` | `Stop` | +//! | `session.created` | `SessionStart` | +//! | `session.deleted` | `SessionEnd` | +//! | `session.completed` | `Stop` | +//! | `session.compacted` | `PreCompact` | +//! | `session.compacting` | `PreCompact` | +//! | `file.edited` | `FileEdited` | +//! | `session.error` | `SessionError` | + +use super::{HandlerOutcome, Hook}; +use serde_json::{json, Value}; + +/// Maps an OpenCode event type string to the equivalent internal hook name. +/// +/// Returns `None` if the event type is not a recognized OpenCode event. +pub fn map_opencode_event(event_type: &str) -> Option<&'static str> { + match event_type { + "tool.execute.before" => Some("PreToolUse"), + "tool.execute.after" => Some("PostToolUse"), + "session.idle" => Some("Stop"), + "session.created" => Some("SessionStart"), + "session.deleted" => Some("SessionEnd"), + "session.completed" => Some("Stop"), + "session.compacted" | "session.compacting" => Some("PreCompact"), + "file.edited" => Some("FileEdited"), + "session.error" => Some("SessionError"), + _ => None, + } +} + +/// Detects whether a JSON event is from OpenCode by checking for known fields. +/// +/// OpenCode events typically have a `type` or `event` field with dotted notation +/// (e.g., `tool.execute.before`), whereas Claude Code events use `hook_event_name`. +pub fn detect_opencode_event_type( + data: &std::collections::HashMap, +) -> Option { + // Check common OpenCode event type fields + for field in &["type", "event", "hook"] { + if let Some(Value::String(s)) = data.get(*field) { + if s.contains('.') && map_opencode_event(s).is_some() { + return Some(s.clone()); + } + } + } + None +} + +/// Handler for FileEdited hooks (OpenCode-only). +/// +/// Fires when OpenCode detects a file has been edited. +/// Returns an empty object for passive observation. +pub struct FileEditedHook; + +impl Hook for FileEditedHook { + fn hook_type(&self) -> &str { + "FileEdited" + } + + fn generate_response(&self, _outcomes: &[HandlerOutcome]) -> Value { + json!({}) + } +} + +/// Handler for SessionError hooks (OpenCode-only). +/// +/// Fires when an OpenCode session encounters an error. +/// Returns an empty object for passive observation. +pub struct SessionErrorHook; + +impl Hook for SessionErrorHook { + fn hook_type(&self) -> &str { + "SessionError" + } + + fn generate_response(&self, _outcomes: &[HandlerOutcome]) -> Value { + json!({}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_known_opencode_events() { + assert_eq!( + map_opencode_event("tool.execute.before"), + Some("PreToolUse") + ); + assert_eq!( + map_opencode_event("tool.execute.after"), + Some("PostToolUse") + ); + assert_eq!(map_opencode_event("session.idle"), Some("Stop")); + assert_eq!(map_opencode_event("session.created"), Some("SessionStart")); + assert_eq!(map_opencode_event("session.deleted"), Some("SessionEnd")); + assert_eq!(map_opencode_event("session.completed"), Some("Stop")); + assert_eq!(map_opencode_event("session.compacted"), Some("PreCompact")); + assert_eq!(map_opencode_event("session.compacting"), Some("PreCompact")); + assert_eq!(map_opencode_event("file.edited"), Some("FileEdited")); + assert_eq!(map_opencode_event("session.error"), Some("SessionError")); + } + + #[test] + fn test_map_unknown_event() { + assert_eq!(map_opencode_event("unknown.event"), None); + assert_eq!(map_opencode_event("Stop"), None); + } + + #[test] + fn test_detect_opencode_event_from_type_field() { + let mut data = std::collections::HashMap::new(); + data.insert("type".to_string(), json!("tool.execute.before")); + assert_eq!( + detect_opencode_event_type(&data), + Some("tool.execute.before".to_string()) + ); + } + + #[test] + fn test_detect_opencode_event_from_event_field() { + let mut data = std::collections::HashMap::new(); + data.insert("event".to_string(), json!("session.idle")); + assert_eq!( + detect_opencode_event_type(&data), + Some("session.idle".to_string()) + ); + } + + #[test] + fn test_detect_opencode_event_from_hook_field() { + let mut data = std::collections::HashMap::new(); + data.insert("hook".to_string(), json!("file.edited")); + assert_eq!( + detect_opencode_event_type(&data), + Some("file.edited".to_string()) + ); + } + + #[test] + fn test_detect_no_opencode_event_for_claude_code() { + let mut data = std::collections::HashMap::new(); + data.insert("hook_event_name".to_string(), json!("Stop")); + assert_eq!(detect_opencode_event_type(&data), None); + } + + #[test] + fn test_detect_no_opencode_event_for_unknown_dotted() { + let mut data = std::collections::HashMap::new(); + data.insert("type".to_string(), json!("unknown.thing")); + assert_eq!(detect_opencode_event_type(&data), None); + } + + #[test] + fn test_file_edited_hook_response() { + let hook = FileEditedHook; + assert_eq!(hook.hook_type(), "FileEdited"); + assert_eq!(hook.generate_response(&[]), json!({})); + } + + #[test] + fn test_session_error_hook_response() { + let hook = SessionErrorHook; + assert_eq!(hook.hook_type(), "SessionError"); + assert_eq!(hook.generate_response(&[]), json!({})); + } +} diff --git a/src/hooks/tool_use.rs b/src/hooks/tool_use.rs index f18c242..3e69561 100644 --- a/src/hooks/tool_use.rs +++ b/src/hooks/tool_use.rs @@ -17,10 +17,7 @@ pub struct PreToolUseHook { impl PreToolUseHook { pub fn from_event(event: &Event) -> Result { - let tool_name = event - .get_str("tool_name") - .unwrap_or("unknown") - .to_string(); + let tool_name = event.get_str("tool_name").unwrap_or("unknown").to_string(); Ok(Self { tool_name }) } @@ -94,8 +91,8 @@ mod tests { #[test] fn test_pre_tool_use_auto_approve() { - let event = Event::from_json(r#"{"hook_event_name": "PreToolUse", "tool_name": "Bash"}"#) - .unwrap(); + let event = + Event::from_json(r#"{"hook_event_name": "PreToolUse", "tool_name": "Bash"}"#).unwrap(); let hook = PreToolUseHook::from_event(&event).unwrap(); assert_eq!(hook.hook_type(), "PreToolUse"); @@ -116,8 +113,8 @@ mod tests { fn test_pre_tool_use_interactive_deny() { use crate::hooks::{InteractiveResponse, PermissionDecision}; - let event = Event::from_json(r#"{"hook_event_name": "PreToolUse", "tool_name": "Bash"}"#) - .unwrap(); + let event = + Event::from_json(r#"{"hook_event_name": "PreToolUse", "tool_name": "Bash"}"#).unwrap(); let hook = PreToolUseHook::from_event(&event).unwrap(); let interactive = InteractiveResponse { diff --git a/src/lib.rs b/src/lib.rs index 774b762..3fbbde5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,15 @@ -//! Boopifier - A universal notification receiver for Claude Code events. +//! Boopifier - A universal notification receiver for Claude Code and OpenCode events. //! -//! This library receives JSON events from Claude Code hooks via stdin and dispatches -//! them to various notification handlers based on configuration. +//! This library receives JSON events from Claude Code or OpenCode hooks via stdin +//! and dispatches them to various notification handlers based on configuration. +//! +//! OpenCode events are automatically detected and normalized to the internal format, +//! so existing match rules and handlers work transparently with both systems. //! //! # Architecture //! -//! - **Event**: JSON events from Claude Code -//! - **Config**: Configuration from `.claude/boopifier.json` +//! - **Event**: JSON events from Claude Code or OpenCode (auto-normalized) +//! - **Config**: Configuration from `.claude/boopifier.json` or `.opencode/boopifier.json` //! - **Matcher**: Pattern matching to filter events //! - **Handlers**: Pluggable notification targets (desktop, sound, signal, webhook, email) //! @@ -81,7 +84,11 @@ pub async fn process_event( for handler_config in &config.handlers { // Check if event matches the handler's rules - if !matches(&event, &handler_config.match_rules, &handler_config.match_type) { + if !matches( + &event, + &handler_config.match_rules, + &handler_config.match_type, + ) { continue; } diff --git a/src/main.rs b/src/main.rs index 6bd5aa9..f13c8c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ -//! Boopifier - Universal notification receiver for Claude Code events. +//! Boopifier - Universal notification receiver for Claude Code and OpenCode events. //! //! Reads JSON events from stdin and dispatches them to configured handlers. -use clap::Parser; use boopifier::{hook_from_event, process_event, Config, Event, HandlerOutcome, HandlerRegistry}; +use clap::Parser; use serde_json::json; use std::fs::OpenOptions; use std::io::{self, BufRead, Write}; @@ -13,7 +13,7 @@ use std::process; #[derive(Parser)] #[command(name = "boopifier")] #[command(author, version, about)] -#[command(about = "Universal notification handler for Claude Code events")] +#[command(about = "Universal notification handler for Claude Code and OpenCode events")] struct Cli { /// Path to the configuration file (overrides auto-detection) #[arg(short, long)] @@ -123,22 +123,34 @@ async fn main() { Ok(cfg) => cfg, Err(e) => { logger.log(&format!("Failed to load config: {}", e)); - output_hook_error(&format!("Failed to load config from {:?}: {}", config_path, e)); + output_hook_error(&format!( + "Failed to load config from {:?}: {}", + config_path, e + )); process::exit(0); // Exit 0 for hook compatibility } }; - // Apply project-specific overrides if using global config - if let Ok(project_dir) = std::env::var("CLAUDE_PROJECT_DIR") { + // Apply project-specific overrides if using global config. + // Checks CLAUDE_PROJECT_DIR (Claude Code) and OPENCODE_PROJECT_DIR (OpenCode). + let project_dir = std::env::var("CLAUDE_PROJECT_DIR") + .or_else(|_| std::env::var("OPENCODE_PROJECT_DIR")) + .ok(); + + if let Some(ref project_dir) = project_dir { // Only apply overrides if we're not using a project-specific config - let project_config_path = PathBuf::from(&project_dir).join(".claude/boopifier.json"); - if !project_config_path.exists() { + let project_config_path = PathBuf::from(project_dir).join(".claude/boopifier.json"); + let opencode_config_path = PathBuf::from(project_dir).join(".opencode/boopifier.json"); + if !project_config_path.exists() && !opencode_config_path.exists() { logger.log(&format!("Checking overrides for project: {}", project_dir)); - config.apply_overrides(&project_dir); + config.apply_overrides(project_dir); } } - logger.log(&format!("Loaded config with {} handlers", config.handlers.len())); + logger.log(&format!( + "Loaded config with {} handlers", + config.handlers.len() + )); // Create handler registry let registry = HandlerRegistry::new(); @@ -185,13 +197,25 @@ async fn main() { match process_event(&event_json, &config, ®istry).await { Ok(outcomes) => { // Log handler outcomes - let successes = outcomes.iter().filter(|o| matches!(o, HandlerOutcome::Success)).count(); - let errors = outcomes.iter().filter(|o| matches!(o, HandlerOutcome::Error(_))).count(); + let successes = outcomes + .iter() + .filter(|o| matches!(o, HandlerOutcome::Success)) + .count(); + let errors = outcomes + .iter() + .filter(|o| matches!(o, HandlerOutcome::Error(_))) + .count(); if errors == 0 { - logger.log(&format!("Event processed successfully ({} handlers)", successes)); + logger.log(&format!( + "Event processed successfully ({} handlers)", + successes + )); } else { - logger.log(&format!("Event processed: {} succeeded, {} failed", successes, errors)); + logger.log(&format!( + "Event processed: {} succeeded, {} failed", + successes, errors + )); for outcome in &outcomes { if let HandlerOutcome::Error(msg) = outcome { logger.log(&format!("Handler error: {}", msg)); @@ -224,25 +248,47 @@ async fn main() { process::exit(0); } -/// Resolve the config file path using Claude Code conventions. +/// Resolve the config file path using Claude Code and OpenCode conventions. /// /// Resolution order: -/// 1. $CLAUDE_PROJECT_DIR/.claude/boopifier.json (if CLAUDE_PROJECT_DIR is set and file exists) -/// 2. ~/.claude/boopifier.json (global fallback, may include path-based overrides) +/// 1. `$CLAUDE_PROJECT_DIR/.claude/boopifier.json` (Claude Code project config) +/// 2. `$OPENCODE_PROJECT_DIR/.opencode/boopifier.json` (OpenCode project config) +/// 3. `$OPENCODE_PROJECT_DIR/.claude/boopifier.json` (shared config in OpenCode project) +/// 4. `~/.config/opencode/boopifier.json` (OpenCode global config) +/// 5. `~/.claude/boopifier.json` (Claude Code global config / shared fallback) /// -/// Note: When using the global config, project-specific overrides will be applied -/// based on glob pattern matching against $CLAUDE_PROJECT_DIR. +/// Note: When using a global config, project-specific overrides will be applied +/// based on glob pattern matching against `$CLAUDE_PROJECT_DIR` or `$OPENCODE_PROJECT_DIR`. fn resolve_config_path() -> PathBuf { - // Try project-specific config if CLAUDE_PROJECT_DIR is set + // 1. Claude Code project config if let Ok(project_dir) = std::env::var("CLAUDE_PROJECT_DIR") { - let project_config = PathBuf::from(project_dir).join(".claude/boopifier.json"); + let project_config = PathBuf::from(&project_dir).join(".claude/boopifier.json"); if project_config.exists() { return project_config; } } - // Fall back to global config + // 2-3. OpenCode project config + if let Ok(project_dir) = std::env::var("OPENCODE_PROJECT_DIR") { + let opencode_config = PathBuf::from(&project_dir).join(".opencode/boopifier.json"); + if opencode_config.exists() { + return opencode_config; + } + let shared_config = PathBuf::from(&project_dir).join(".claude/boopifier.json"); + if shared_config.exists() { + return shared_config; + } + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + + // 4. OpenCode global config + let opencode_global = PathBuf::from(&home).join(".config/opencode/boopifier.json"); + if opencode_global.exists() { + return opencode_global; + } + + // 5. Claude Code global config (shared fallback) PathBuf::from(home).join(".claude/boopifier.json") } diff --git a/src/matcher.rs b/src/matcher.rs index 997785f..ab1d040 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -37,21 +37,39 @@ pub fn matches(event: &Event, rules: &Option, match_type: &MatchType Some(MatchRules::Simple(simple_rules)) => { // Check if this is actually a complex rule that was mis-deserialized // (happens because untagged enums try Simple first) - if simple_rules.contains_key("any") || simple_rules.contains_key("all") || simple_rules.contains_key("not") { + if simple_rules.contains_key("any") + || simple_rules.contains_key("all") + || simple_rules.contains_key("not") + { // Extract complex rule components - let all = simple_rules.get("all") + let all = simple_rules + .get("all") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_object().map(|o| { - o.iter().map(|(k, v)| (k.clone(), v.clone())).collect() - })).collect()); - - let any = simple_rules.get("any") + .map(|arr| { + arr.iter() + .filter_map(|v| { + v.as_object().map(|o| { + o.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + }) + }) + .collect() + }); + + let any = simple_rules + .get("any") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_object().map(|o| { - o.iter().map(|(k, v)| (k.clone(), v.clone())).collect() - })).collect()); - - let not = simple_rules.get("not") + .map(|arr| { + arr.iter() + .filter_map(|v| { + v.as_object().map(|o| { + o.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + }) + }) + .collect() + }); + + let not = simple_rules + .get("not") .and_then(|v| v.as_object()) .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()); @@ -70,7 +88,9 @@ fn matches_simple(event: &Event, rules: &HashMap, match_type: &Ma for (key, expected_value) in rules { // Support nested keys with dot notation (e.g., "tool.name") let actual_value = if key.contains('.') { - event.get_nested_str(key).map(|s| Value::String(s.to_string())) + event + .get_nested_str(key) + .map(|s| Value::String(s.to_string())) } else { event.data.get(key).cloned() }; @@ -151,9 +171,9 @@ fn values_match(actual: &Value, expected: &Value, match_type: &MatchType) -> boo (Value::Array(a), Value::Array(e)) => e.iter().all(|ev| a.iter().any(|av| av == ev)), // Object: recursive matching - (Value::Object(a), Value::Object(e)) => { - e.iter().all(|(k, ev)| a.get(k).is_some_and(|av| values_match(av, ev, match_type))) - } + (Value::Object(a), Value::Object(e)) => e + .iter() + .all(|(k, ev)| a.get(k).is_some_and(|av| values_match(av, ev, match_type))), // Type mismatch _ => false, @@ -171,7 +191,11 @@ mod tests { let mut rules = HashMap::new(); rules.insert("event_type".to_string(), json!("success")); - assert!(matches(&event, &Some(MatchRules::Simple(rules)), &MatchType::Exact)); + assert!(matches( + &event, + &Some(MatchRules::Simple(rules)), + &MatchType::Exact + )); } #[test] @@ -180,7 +204,11 @@ mod tests { let mut rules = HashMap::new(); rules.insert("event_type".to_string(), json!("success")); - assert!(!matches(&event, &Some(MatchRules::Simple(rules)), &MatchType::Exact)); + assert!(!matches( + &event, + &Some(MatchRules::Simple(rules)), + &MatchType::Exact + )); } #[test] @@ -189,7 +217,11 @@ mod tests { let mut rules = HashMap::new(); rules.insert("tool.name".to_string(), json!("bash")); - assert!(matches(&event, &Some(MatchRules::Simple(rules)), &MatchType::Exact)); + assert!(matches( + &event, + &Some(MatchRules::Simple(rules)), + &MatchType::Exact + )); } #[test] @@ -271,7 +303,10 @@ mod test_complex_any_hook_events { not: None, }; - assert!(matches(&event, &Some(rules), &MatchType::Exact), "Should match Notification in any rules"); + assert!( + matches(&event, &Some(rules), &MatchType::Exact), + "Should match Notification in any rules" + ); } } @@ -287,12 +322,19 @@ mod test_misdeserialized_complex_rules { let event = Event::from_json(r#"{"hook_event_name": "Notification"}"#).unwrap(); let mut simple_map = HashMap::new(); - simple_map.insert("any".to_string(), json!([ - {"hook_event_name": "Notification"}, - {"hook_event_name": "Stop"} - ])); - - assert!(matches(&event, &Some(MatchRules::Simple(simple_map)), &MatchType::Exact)); + simple_map.insert( + "any".to_string(), + json!([ + {"hook_event_name": "Notification"}, + {"hook_event_name": "Stop"} + ]), + ); + + assert!(matches( + &event, + &Some(MatchRules::Simple(simple_map)), + &MatchType::Exact + )); } #[test] @@ -300,12 +342,19 @@ mod test_misdeserialized_complex_rules { let event = Event::from_json(r#"{"hook_event_name": "PermissionRequest"}"#).unwrap(); let mut simple_map = HashMap::new(); - simple_map.insert("any".to_string(), json!([ - {"hook_event_name": "Notification"}, - {"hook_event_name": "Stop"} - ])); - - assert!(!matches(&event, &Some(MatchRules::Simple(simple_map)), &MatchType::Exact)); + simple_map.insert( + "any".to_string(), + json!([ + {"hook_event_name": "Notification"}, + {"hook_event_name": "Stop"} + ]), + ); + + assert!(!matches( + &event, + &Some(MatchRules::Simple(simple_map)), + &MatchType::Exact + )); } #[test] @@ -316,7 +365,11 @@ mod test_misdeserialized_complex_rules { let mut simple_map = HashMap::new(); simple_map.insert("hook_event_name".to_string(), json!("Notification")); - assert!(matches(&event, &Some(MatchRules::Simple(simple_map)), &MatchType::Exact)); + assert!(matches( + &event, + &Some(MatchRules::Simple(simple_map)), + &MatchType::Exact + )); } #[test] @@ -341,11 +394,19 @@ mod test_regex_matching { #[test] fn test_regex_simple_pattern() { - let event = Event::from_json(r#"{"message": "Claude needs your permission to use Write"}"#).unwrap(); + let event = Event::from_json(r#"{"message": "Claude needs your permission to use Write"}"#) + .unwrap(); let mut rules = HashMap::new(); - rules.insert("message".to_string(), json!("Claude needs your permission.*")); - assert!(matches(&event, &Some(MatchRules::Simple(rules)), &MatchType::Regex)); + rules.insert( + "message".to_string(), + json!("Claude needs your permission.*"), + ); + assert!(matches( + &event, + &Some(MatchRules::Simple(rules)), + &MatchType::Regex + )); } #[test] @@ -353,8 +414,15 @@ mod test_regex_matching { let event = Event::from_json(r#"{"message": "Something else"}"#).unwrap(); let mut rules = HashMap::new(); - rules.insert("message".to_string(), json!("Claude needs your permission.*")); - assert!(!matches(&event, &Some(MatchRules::Simple(rules)), &MatchType::Regex)); + rules.insert( + "message".to_string(), + json!("Claude needs your permission.*"), + ); + assert!(!matches( + &event, + &Some(MatchRules::Simple(rules)), + &MatchType::Regex + )); } #[test] @@ -382,6 +450,10 @@ mod test_regex_matching { let mut rules = HashMap::new(); rules.insert("status".to_string(), json!("success")); - assert!(matches(&event, &Some(MatchRules::Simple(rules)), &MatchType::Exact)); + assert!(matches( + &event, + &Some(MatchRules::Simple(rules)), + &MatchType::Exact + )); } }