1
0
Fork 0
mirror of https://github.com/imjasonh/cnotes synced 2026-07-11 10:21:44 +00:00

Scaffold all 7 hook events and eliminate type casting

- Add missing hook handlers: Stop, SubagentStop, PreCompact
- Replace all string type casting with proper JSON unmarshaling
- Change ToolUseRequest.Parameters from map[string]interface{} to json.RawMessage
- Update helper methods to use json.Unmarshal directly to typed structs
- Replace speech notifications with simple logging notifications
- Update documentation to reflect all hook events and remove speech references
- Add comprehensive CLAUDE.md with architecture overview and development guidance

All hooks now use type-safe JSON handling without .(string) assertions.
Complete coverage of Claude Code events: PreToolUse, PostToolUse,
UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jason Hall 2025-07-21 12:01:55 -04:00
parent c61b579243
commit 45ef980233
Failed to extract signature
10 changed files with 351 additions and 108 deletions

View file

@ -1,3 +1,16 @@
// Package handlers provides hook implementations for all Claude Code events.
//
// The following hook events are implemented:
// - PreToolUse: Command validation and sensitive file protection (pretooluse.go)
// - PostToolUse: Tool usage logging and goimports automation (posttooluse.go, goimports.go)
// - UserPromptSubmit: Project context injection (userprompt.go)
// - Notification: System notification logging (notification.go)
// - Stop: Session completion logging (stop.go)
// - SubagentStop: Subagent completion logging (subagentstop.go)
// - PreCompact: Context compaction handling (precompact.go)
//
// Each handler file registers its hooks in init() functions that are automatically
// called when the package is imported.
package handlers
import (
@ -5,10 +18,13 @@ import (
)
func init() {
// PreToolUse hooks - validation and security
hooks.RegisterHook(hooks.EventPreToolUse, "Bash", ValidateBashCommand)
hooks.RegisterHook(hooks.EventPreToolUse, "Write|Edit|MultiEdit", PreventSensitiveFileEdits)
// PostToolUse hooks - logging and automation
hooks.RegisterHook(hooks.EventPostToolUse, "*", LogToolUsage)
// UserPromptSubmit hooks - context enhancement
hooks.RegisterHook(hooks.EventUserPromptSubmit, "*", AddProjectContext)
}
}

View file

@ -2,78 +2,54 @@ package handlers
import (
"context"
"fmt"
"log/slog"
"os/exec"
"runtime"
"strings"
"time"
"github.com/imjasonh/hooks/internal/hooks"
)
func init() {
hooks.RegisterHook(hooks.EventNotification, "*", SpeakNotification)
hooks.RegisterHook(hooks.EventNotification, "*", LogNotification)
}
func SpeakNotification(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
// Only run on macOS
if runtime.GOOS != "darwin" {
return hooks.HookOutput{Decision: "approve"}, nil
}
// Build the notification content for speech
// LogNotification logs notification events for debugging and monitoring.
// This hook is triggered when Claude needs permission to use a tool or when
// prompt input has been idle for 60+ seconds.
//
// Common use cases:
// - Debug notification flow
// - Monitor permission requests
// - Track user idle states
// - Audit notification patterns
func LogNotification(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
var message string
var notificationType string
// Check if we have the direct message format (newer)
if input.Message != "" {
message = input.Message
notificationType = "direct"
} else if input.Notification.Permission != "" {
// Permission request (older format)
message = fmt.Sprintf("Requesting permission to %s", input.Notification.Message)
message = input.Notification.Message
notificationType = "permission"
} else if input.Notification.Message != "" {
// Regular notification (older format)
message = input.Notification.Message
notificationType = "message"
} else {
// No valid notification content
slog.Debug("notification hook triggered with no message content", "session_id", input.SessionID)
return hooks.HookOutput{Decision: "approve"}, nil
}
// Speak the notification using macOS say command
if _, err := exec.LookPath("say"); err == nil {
// Build more informative spoken message
var spokenMessage string
if input.Notification.Permission != "" {
spokenMessage = fmt.Sprintf("Claude is requesting permission for %s: %s", input.Notification.Tool, message)
} else if input.Notification.Tool != "" {
spokenMessage = fmt.Sprintf("Claude notification from %s: %s", input.Notification.Tool, message)
} else {
spokenMessage = fmt.Sprintf("Claude notification: %s", 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)
}
}()
slog.Info("spoke notification",
"tool", input.Notification.Tool,
"permission", input.Notification.Permission != "")
}
}
slog.Info("notification received",
"type", notificationType,
"message", message,
"tool", input.Notification.Tool,
"permission", input.Notification.Permission != "",
"session_id", input.SessionID,
"timestamp", time.Now().Unix())
return hooks.HookOutput{Decision: "approve"}, nil
}

View file

@ -9,31 +9,29 @@ import (
)
func LogToolUsage(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
params := input.ToolUseRequest.Parameters
switch input.Tool {
case "Bash":
if cmd, ok := params["command"].(string); ok {
if bashInput, err := input.GetBashInput(); err == nil {
slog.Info("bash command executed",
"command", cmd,
"command", bashInput.Command,
"session_id", input.SessionID,
"cwd", input.CWD,
"timestamp", time.Now().Unix())
}
case "Write", "Edit", "MultiEdit":
if path, ok := params["file_path"].(string); ok {
slog.Info("file modified",
"tool", input.Tool,
"file", path,
"session_id", input.SessionID,
"timestamp", time.Now().Unix())
}
case "Read":
if path, ok := params["file_path"].(string); ok {
slog.Info("file read",
"file", path,
"session_id", input.SessionID,
"timestamp", time.Now().Unix())
case "Write", "Edit", "MultiEdit", "Read":
if fileInput, err := input.GetFileInput(); err == nil {
if input.Tool == "Read" {
slog.Info("file read",
"file", fileInput.FilePath,
"session_id", input.SessionID,
"timestamp", time.Now().Unix())
} else {
slog.Info("file modified",
"tool", input.Tool,
"file", fileInput.FilePath,
"session_id", input.SessionID,
"timestamp", time.Now().Unix())
}
}
default:
slog.Info("tool used",
@ -43,4 +41,4 @@ func LogToolUsage(ctx context.Context, input hooks.HookInput) (hooks.HookOutput,
}
return hooks.HookOutput{Decision: "approve"}, nil
}
}

View file

@ -0,0 +1,69 @@
package handlers
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/imjasonh/hooks/internal/hooks"
)
func init() {
hooks.RegisterHook(hooks.EventPreCompact, "*", HandlePreCompact)
}
// HandlePreCompact is triggered before Claude Code runs a compact operation to
// reduce context window size. This can happen manually via /compact command
// or automatically when the context window fills up.
//
// The matcher indicates the compaction trigger:
// - "manual": User ran /compact command
// - "auto": Automatic compaction due to context window limit
//
// Common use cases:
// - Save conversation state before compaction
// - Warn user about potential data loss
// - Export important context to external files
// - Create conversation backups
// - Log compaction events for analysis
func HandlePreCompact(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
// Determine compaction type from the tool name/matcher
compactionType := "unknown"
if input.Tool == "manual" || input.ToolName == "manual" {
compactionType = "manual"
} else if input.Tool == "auto" || input.ToolName == "auto" {
compactionType = "auto"
}
slog.Info("pre-compaction hook triggered",
"session_id", input.SessionID,
"compaction_type", compactionType,
"cwd", input.CWD,
"transcript_path", input.TranscriptPath,
"timestamp", time.Now().Unix())
// Add context warning for the user
var warningContext string
if compactionType == "manual" {
warningContext = "Manual compaction requested - some conversation history will be summarized"
} else if compactionType == "auto" {
warningContext = "Automatic compaction triggered - context window limit reached"
} else {
warningContext = "Context compaction about to occur"
}
// TODO: Future enhancements could include:
// - Export full conversation before compaction
// - Save important code snippets or commands
// - Create conversation timeline backup
// - Allow user to specify what to preserve
// - Integration with external note-taking systems
// - Automatic git commit of current work
// - Context importance scoring and preservation
return hooks.HookOutput{
Decision: "approve",
AdditionalContext: fmt.Sprintf("🔄 %s", warningContext),
}, nil
}

41
internal/handlers/stop.go Normal file
View file

@ -0,0 +1,41 @@
package handlers
import (
"context"
"log/slog"
"time"
"github.com/imjasonh/hooks/internal/hooks"
)
func init() {
hooks.RegisterHook(hooks.EventStop, "*", LogSessionCompletion)
}
// LogSessionCompletion is triggered when the main Claude Code agent finishes responding.
// This hook runs after Claude has completed its response but does not run if the session
// was interrupted by the user.
//
// Common use cases:
// - Session cleanup and logging
// - Performance metrics collection
// - Workflow completion notifications
// - Resource cleanup
// - Export session summaries
func LogSessionCompletion(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
slog.Info("Claude session completed",
"session_id", input.SessionID,
"cwd", input.CWD,
"transcript_path", input.TranscriptPath,
"timestamp", time.Now().Unix())
// TODO: Future enhancements could include:
// - Calculate session duration
// - Count tools used in session
// - Export conversation summary
// - Send completion notifications
// - Clean up temporary files
// - Archive transcript
return hooks.HookOutput{Decision: "approve"}, nil
}

View file

@ -0,0 +1,42 @@
package handlers
import (
"context"
"log/slog"
"time"
"github.com/imjasonh/hooks/internal/hooks"
)
func init() {
hooks.RegisterHook(hooks.EventSubagentStop, "*", LogSubagentCompletion)
}
// LogSubagentCompletion is triggered when a Claude Code subagent (Task tool call)
// finishes responding. Subagents are created when Claude uses the Task tool to
// delegate work to another Claude instance.
//
// Common use cases:
// - Track nested workflow completion
// - Monitor subagent performance
// - Resource cleanup after Task operations
// - Debug complex multi-agent workflows
// - Collect metrics on task delegation
func LogSubagentCompletion(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
slog.Info("Claude subagent completed",
"session_id", input.SessionID,
"cwd", input.CWD,
"transcript_path", input.TranscriptPath,
"timestamp", time.Now().Unix())
// TODO: Future enhancements could include:
// - Track subagent hierarchy depth
// - Measure subagent execution time
// - Collect subagent success/failure rates
// - Clean up subagent-specific resources
// - Aggregate results from multiple subagents
// - Send notifications for long-running subagents
// - Export subagent conversation logs
return hooks.HookOutput{Decision: "approve"}, nil
}

View file

@ -5,38 +5,13 @@ import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/imjasonh/hooks/internal/hooks"
)
func AddProjectContext(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
// Speak notification for hook event on macOS
if runtime.GOOS == "darwin" {
if _, err := exec.LookPath("say"); err == nil {
// Extract first few words from prompt for context
promptPreview := input.Prompt
if len(promptPreview) > 50 {
promptPreview = promptPreview[:47] + "..."
}
// Remove newlines for speech
promptPreview = strings.ReplaceAll(promptPreview, "\n", " ")
spokenMessage := fmt.Sprintf("User prompt received: %s", promptPreview)
cmd := exec.CommandContext(ctx, "say", "-v", "Samantha", spokenMessage)
if err := cmd.Start(); err == nil {
go func() {
if err := cmd.Wait(); err != nil {
slog.Debug("say command failed", "error", err)
}
}()
}
}
}
var contexts []string
if gitRoot := findGitRoot(input.CWD); gitRoot != "" {