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
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Boopifier

A universal notification handler for Claude Code events.
A universal notification handler for Claude Code and Cursor 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 Cursor 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), plus automatic Cursor event normalization
- **Multiple Notification Targets**: Desktop, Sound, Signal, Webhook, Email
- **Flexible Event Matching**: Route different Claude Code events to different handlers with regex support
- **Secrets Management**: Environment variables and file-based secrets
Expand Down Expand Up @@ -74,6 +74,42 @@ See the [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks)

This pipes hook events directly to boopifier.

### Setup with Cursor

Boopifier also works with [Cursor](https://cursor.com) hooks. Cursor events (camelCase names like `beforeShellExecution`, `stop`) are automatically normalized to the same internal format as Claude Code events, so your existing handler configs work for both.

**Step 1: Configure Cursor hooks**

Create `.cursor/hooks.json` in your project (or globally):

```json
{
"version": 1,
"hooks": {
"preToolUse": [{ "command": "boopifier" }],
"postToolUse": [{ "command": "boopifier" }],
"beforeShellExecution": [{ "command": "boopifier" }],
"afterShellExecution": [{ "command": "boopifier" }],
"afterFileEdit": [{ "command": "boopifier" }],
"stop": [{ "command": "boopifier" }],
"sessionStart": [{ "command": "boopifier" }],
"sessionEnd": [{ "command": "boopifier" }]
}
}
```

Cursor events are mapped to their Claude Code equivalents automatically:

| Cursor Event | Mapped To |
|---|---|
| `preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `beforeReadFile` | `PreToolUse` |
| `postToolUse`, `afterShellExecution`, `afterMCPExecution`, `afterFileEdit` | `PostToolUse` |
| `stop`, `afterAgentResponse` | `Stop` |
| `sessionStart` / `sessionEnd` | `SessionStart` / `SessionEnd` |
| `subagentStart` / `subagentStop` | `SubagentStart` / `SubagentStop` |

Fields are also normalized: `conversation_id` → `session_id`, and `tool_name` is synthesized for Cursor-specific hooks (`beforeShellExecution` → `Bash`, `beforeReadFile` → `Read`, `afterFileEdit` → `Edit`).

**Step 2: Configure boopifier handlers**

Create a config file. Boopifier automatically finds it using this priority:
Expand Down
90 changes: 85 additions & 5 deletions src/event.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
//! Claude Code event types.
//! Event types for Claude Code and Cursor 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.
//! Cursor events are automatically normalized with mapped `hook_event_name`
//! and synthesized fields so that existing matchers work transparently.

use crate::hooks::cursor;
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 Cursor events are supported. Cursor events are
/// automatically normalized: `hook_event_name` is remapped from camelCase
/// to PascalCase, and fields like `tool_name` and `session_id` are
/// synthesized from Cursor-specific equivalents.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
/// The raw JSON value for flexible matching
Expand All @@ -20,12 +28,29 @@ pub struct Event {
impl Event {
/// Creates a new event from a JSON string.
///
/// If the event is from Cursor (detected by camelCase hook_event_name),
/// the event name is remapped to PascalCase and Cursor-specific fields
/// are normalized.
///
/// # 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 Cursor events: remap hook_event_name and synthesize fields
if let Some(cursor_event) = cursor::detect_cursor_event(&event.data) {
if let Some(mapped) = cursor::map_cursor_event(&cursor_event) {
event.data.insert(
"hook_event_name".to_string(),
Value::String(mapped.to_string()),
);
cursor::normalize_cursor_fields(&mut event.data, &cursor_event);
}
}

Ok(event)
}

Expand Down Expand Up @@ -87,4 +112,59 @@ mod tests {
let json = r#"{"invalid": }"#;
assert!(Event::from_json(json).is_err());
}

#[test]
fn test_cursor_shell_execution_normalized() {
let json = r#"{"hook_event_name": "beforeShellExecution", "command": "ls -la", "conversation_id": "conv-abc"}"#;
let event = Event::from_json(json).unwrap();
assert_eq!(event.get_str("hook_event_name"), Some("PreToolUse"));
assert_eq!(event.get_str("tool_name"), Some("Bash"));
assert_eq!(event.get_str("session_id"), Some("conv-abc"));
// Original fields preserved
assert_eq!(event.get_str("command"), Some("ls -la"));
}

#[test]
fn test_cursor_file_edit_normalized() {
let json = r#"{"hook_event_name": "afterFileEdit", "file_path": "src/main.rs"}"#;
let event = Event::from_json(json).unwrap();
assert_eq!(event.get_str("hook_event_name"), Some("PostToolUse"));
assert_eq!(event.get_str("tool_name"), Some("Edit"));
}

#[test]
fn test_cursor_stop_normalized() {
let json = r#"{"hook_event_name": "stop", "status": "completed"}"#;
let event = Event::from_json(json).unwrap();
assert_eq!(event.get_str("hook_event_name"), Some("Stop"));
}

#[test]
fn test_cursor_pretooluse_preserves_tool_name() {
let json = r#"{"hook_event_name": "preToolUse", "tool_name": "WebSearch", "conversation_id": "conv-xyz"}"#;
let event = Event::from_json(json).unwrap();
assert_eq!(event.get_str("hook_event_name"), Some("PreToolUse"));
assert_eq!(event.get_str("tool_name"), Some("WebSearch"));
assert_eq!(event.get_str("session_id"), Some("conv-xyz"));
}

#[test]
fn test_claude_code_event_not_remapped() {
let json = r#"{"hook_event_name": "Stop", "session_id": "sess-123"}"#;
let event = Event::from_json(json).unwrap();
assert_eq!(event.get_str("hook_event_name"), Some("Stop"));
assert_eq!(event.get_str("session_id"), Some("sess-123"));
}

#[test]
fn test_cursor_session_events_normalized() {
let start = r#"{"hook_event_name": "sessionStart", "conversation_id": "c1"}"#;
let event = Event::from_json(start).unwrap();
assert_eq!(event.get_str("hook_event_name"), Some("SessionStart"));
assert_eq!(event.get_str("session_id"), Some("c1"));

let end = r#"{"hook_event_name": "sessionEnd", "conversation_id": "c2"}"#;
let event = Event::from_json(end).unwrap();
assert_eq!(event.get_str("hook_event_name"), Some("SessionEnd"));
}
}
Loading