diff --git a/README.md b/README.md index 6a70443..876a183 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,11 @@ This package includes several example hooks: - **Project Context**: Adds git branch and project type information ### Notification +- **Visual Notifications**: Shows native macOS notifications via `terminal-notifier` - **Speech Notifications**: Uses macOS 'say' command to speak notifications aloud +Note: Install `terminal-notifier` with `brew install terminal-notifier` for visual notifications. + ## Testing Hooks Test individual hooks by piping JSON: diff --git a/cmd/install.go b/cmd/install.go index ee9ed8c..3220bd9 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -65,7 +65,9 @@ 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(" • notification: Shows notifications and speaks them aloud (macOS)") + fmt.Println("\nOptional: Install terminal-notifier for visual notifications:") + fmt.Println(" brew install terminal-notifier") 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/go.mod b/go.mod index 2ece09d..f2446f7 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,9 @@ module github.com/imjasonh/hooks go 1.24.4 +require github.com/spf13/cobra v1.9.1 + require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect ) diff --git a/internal/handlers/notification.go b/internal/handlers/notification.go index 72d1080..0439cc9 100644 --- a/internal/handlers/notification.go +++ b/internal/handlers/notification.go @@ -21,52 +21,73 @@ func SpeakNotification(ctx context.Context, input hooks.HookInput) (hooks.HookOu 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 + // Build the notification content + var title, message, subtitle string if input.Notification.Permission != "" { // Permission request - message = fmt.Sprintf("Claude is requesting permission to use %s. %s", - input.Notification.Tool, - input.Notification.Message) + title = "Claude Permission Request" + subtitle = fmt.Sprintf("Tool: %s", input.Notification.Tool) + message = fmt.Sprintf("Requesting permission to %s", input.Notification.Message) } else { // Regular notification - message = fmt.Sprintf("Notification from %s: %s", - input.Notification.Tool, - input.Notification.Message) + title = "Claude Notification" + subtitle = fmt.Sprintf("Tool: %s", input.Notification.Tool) + message = 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) + // Show notification using terminal-notifier if available + if _, err := exec.LookPath("terminal-notifier"); err == nil { + args := []string{ + "-title", title, + "-subtitle", subtitle, + "-message", message, + "-sound", "default", + "-group", "claude-hooks", } - }() + + cmd := exec.CommandContext(ctx, "terminal-notifier", args...) + if err := cmd.Start(); err != nil { + slog.Error("failed to show notification", "error", err) + } else { + // Don't wait for completion + go func() { + if err := cmd.Wait(); err != nil { + slog.Debug("terminal-notifier failed", "error", err) + } + }() + slog.Info("showed notification", + "tool", input.Notification.Tool, + "permission", input.Notification.Permission != "") + } + } else { + slog.Debug("terminal-notifier not found, install with: brew install terminal-notifier") + } - slog.Info("speaking notification", - "tool", input.Notification.Tool, - "permission", input.Notification.Permission != "", - "message_length", len(message)) + // Also speak the notification if say is available + if _, err := exec.LookPath("say"); err == nil { + spokenMessage := fmt.Sprintf("%s. %s", subtitle, message) + + // Sanitize for speech + spokenMessage = strings.ReplaceAll(spokenMessage, "\n", " ") + spokenMessage = strings.ReplaceAll(spokenMessage, "\"", "'") + + // Truncate if too long + if len(spokenMessage) > 200 { + spokenMessage = spokenMessage[:197] + "..." + } + + cmd := exec.CommandContext(ctx, "say", "-v", "Samantha", spokenMessage) + if err := cmd.Start(); err != nil { + slog.Debug("failed to start say command", "error", err) + } else { + // Don't wait for completion to avoid blocking + go func() { + if err := cmd.Wait(); err != nil { + slog.Debug("say command failed", "error", err) + } + }() + } + } return hooks.HookOutput{Decision: "continue"}, nil } \ No newline at end of file