From f36813078c24aea122d6badfcb3efa769fbec265 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Mon, 21 Jul 2025 10:17:58 -0400 Subject: [PATCH] Add notification hook with macOS speech synthesis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Speaks Claude notifications using the 'say' command - Handles both permission requests and regular notifications - Only runs on macOS systems - Non-blocking execution to avoid delays 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 3 ++ cmd/install.go | 1 + internal/handlers/notification.go | 72 +++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 internal/handlers/notification.go diff --git a/README.md b/README.md index f231015..6a70443 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ This package includes several example hooks: ### User Prompt Submit - **Project Context**: Adds git branch and project type information +### Notification +- **Speech Notifications**: Uses macOS 'say' command to speak notifications aloud + ## Testing Hooks Test individual hooks by piping JSON: diff --git a/cmd/install.go b/cmd/install.go index 9334479..ee9ed8c 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -65,6 +65,7 @@ func runInstall(cmd *cobra.Command, args []string) error { fmt.Println(" • pre_tool_use: Validates bash commands and prevents sensitive file edits") fmt.Println(" • post_tool_use: Logs tool usage and runs goimports on modified Go files") fmt.Println(" • user_prompt_submit: Adds project context to prompts") + fmt.Println(" • notification: Speaks notifications aloud using macOS 'say' command") fmt.Println("\nTo test: echo '{\"event\":\"pre_tool_use\",\"tool\":\"Bash\",\"tool_use_request\":{\"tool\":\"Bash\",\"parameters\":{\"command\":\"ls\"}}}' | hooks run") return nil diff --git a/internal/handlers/notification.go b/internal/handlers/notification.go new file mode 100644 index 0000000..72d1080 --- /dev/null +++ b/internal/handlers/notification.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "runtime" + "strings" + + "github.com/imjasonh/hooks/internal/hooks" +) + +func init() { + hooks.RegisterHook(hooks.EventNotification, "*", SpeakNotification) +} + +func SpeakNotification(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + // Only run on macOS + if runtime.GOOS != "darwin" { + return hooks.HookOutput{Decision: "continue"}, nil + } + + // Check if say command is available + if _, err := exec.LookPath("say"); err != nil { + slog.Debug("say command not found", "error", err) + return hooks.HookOutput{Decision: "continue"}, nil + } + + // Build the message to speak + var message string + if input.Notification.Permission != "" { + // Permission request + message = fmt.Sprintf("Claude is requesting permission to use %s. %s", + input.Notification.Tool, + input.Notification.Message) + } else { + // Regular notification + message = fmt.Sprintf("Notification from %s: %s", + input.Notification.Tool, + input.Notification.Message) + } + + // Sanitize the message for speech + message = strings.ReplaceAll(message, "\n", " ") + message = strings.ReplaceAll(message, "\"", "'") + + // Run say command with a shorter message if it's too long + if len(message) > 200 { + message = message[:197] + "..." + } + + cmd := exec.CommandContext(ctx, "say", "-v", "Samantha", message) + if err := cmd.Start(); err != nil { + slog.Error("failed to start say command", "error", err) + return hooks.HookOutput{Decision: "continue"}, nil + } + + // Don't wait for completion to avoid blocking + go func() { + if err := cmd.Wait(); err != nil { + slog.Debug("say command failed", "error", err) + } + }() + + slog.Info("speaking notification", + "tool", input.Notification.Tool, + "permission", input.Notification.Permission != "", + "message_length", len(message)) + + return hooks.HookOutput{Decision: "continue"}, nil +} \ No newline at end of file