Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 88 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,8 @@ impl Config {
///
/// Returns an error if the JSON is invalid.
pub fn from_json(json: &str) -> Result<Self> {
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)
}
Expand Down
75 changes: 70 additions & 5 deletions src/event.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Self> {
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)
}

Expand Down Expand Up @@ -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);
}
}
6 changes: 3 additions & 3 deletions src/handlers/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
72 changes: 44 additions & 28 deletions src/handlers/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,26 @@ impl Handler for EmailHandler {

async fn handle(&self, event: &Event, config: &HashMap<String, Value>) -> 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")
Expand All @@ -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,
Expand All @@ -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?;

Expand All @@ -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) {
Expand Down
Loading