1
0
Fork 0
mirror of https://github.com/imjasonh/cnotes synced 2026-07-22 15:50:41 +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

106
CLAUDE.md Normal file
View file

@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build and Run Commands
```bash
# Build the binary
go build -o hooks
# Install hooks to Claude settings (project-level by default)
./hooks install
# Install to different scopes
./hooks install --global # ~/.claude/settings.json
./hooks install --local # ./.claude/settings.local.json
# Uninstall hooks
./hooks install --uninstall
# Test hook execution manually (for debugging)
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":"{\"command\":\"ls\"}"}' | ./hooks run
# Run with debug logging
./hooks run --debug
```
## Architecture Overview
This is a Claude Code hooks system that intercepts and processes all tool usage events. The architecture follows a registry pattern where individual hook handlers register themselves for specific events and tools.
### Core Components
**`internal/hooks/`** - Core hook engine
- `types.go` - Defines all hook input/output types and helper methods. Uses `json.RawMessage` for parameters to avoid type casting
- `registry.go` - Central registry for mapping events+tools to handler functions
- `runner.go` - Main execution engine that processes hook chains and handles JSON I/O
**`internal/handlers/`** - Hook implementations
- Each file implements handlers for specific events (pretooluse.go, posttooluse.go, etc.)
- Handlers auto-register in `init()` functions when package is imported
- All 7 Claude Code events are implemented: PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact
**`internal/config/`** - Claude settings management
- `settings.go` - Handles reading/writing Claude's settings.json files with support for global, local, and project-level configurations
**`cmd/`** - CLI interface using Cobra
- `run.go` - Handles hook execution (called by Claude Code)
- `install.go` - Manages hook installation/uninstallation
### Type Safety and JSON Handling
The codebase avoids string type casting entirely by:
1. Using `json.RawMessage` for all parameter storage
2. Providing typed structs (`BashToolInput`, `FileToolInput`) for tool-specific data
3. Using `input.GetBashInput()`, `input.GetFileInput()` helper methods that unmarshal JSON directly to typed structs
4. Never using `.(string)` type assertions anywhere in the codebase
### Hook Registration Pattern
```go
func init() {
hooks.RegisterHook(hooks.EventPreToolUse, "Bash", ValidateBashCommand)
hooks.RegisterHook(hooks.EventPreToolUse, "Write|Edit|MultiEdit", PreventSensitiveFileEdits)
}
```
Matchers support:
- Exact tool names: `"Bash"`
- Regex patterns: `"Write|Edit|MultiEdit"`
- Wildcard: `"*"` for all tools
### Hook Function Signature
All hook functions follow this signature:
```go
func HookName(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error)
```
Hook outputs can:
- Block execution: `Decision: "block"`
- Allow with modifications: `Decision: "approve"` + `ModifiedParameters`
- Add context to responses: `AdditionalContext`
- Modify user prompts: `ModifiedUserPrompt`
### Settings File Structure
The system uses Claude's native hook configuration format with proper event name mapping:
```json
{
"hooks": {
"PreToolUse": [{"matcher": ".*", "hooks": [{"type": "command", "command": "/path/to/hooks run"}]}],
"PostToolUse": [{"matcher": ".*", "hooks": [{"type": "command", "command": "/path/to/hooks run"}]}]
}
}
```
## Key Design Principles
- **No Type Casting**: All JSON handling uses proper unmarshaling to typed structs
- **Auto-Registration**: Hook handlers register themselves via `init()` functions
- **Comprehensive Coverage**: All 7 Claude Code events are implemented with placeholder functionality
- **Project-First**: Defaults to project-level `.claude/settings.json` installation
- **Extensible**: Easy to add new hook handlers by creating new files in `internal/handlers/`
When adding new hooks, create a new file in `internal/handlers/`, implement the hook function, and register it in an `init()` function. The handler will be automatically loaded when the package is imported.

View file

@ -43,14 +43,18 @@ func init() {
```go ```go
func BlockRmRf(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { func BlockRmRf(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
cmd := input.ToolUseRequest.Parameters["command"].(string) bashInput, err := input.GetBashInput()
if strings.Contains(cmd, "rm -rf /") { if err != nil {
return hooks.HookOutput{Decision: "approve"}, nil
}
if strings.Contains(bashInput.Command, "rm -rf /") {
return hooks.HookOutput{ return hooks.HookOutput{
Decision: "block", Decision: "block",
Reason: "Cannot execute rm -rf on root directory", Reason: "Cannot execute rm -rf on root directory",
}, nil }, nil
} }
return hooks.HookOutput{Decision: "continue"}, nil return hooks.HookOutput{Decision: "approve"}, nil
} }
``` ```
@ -70,25 +74,29 @@ func AddGitInfo(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, e
```go ```go
func ForceNonInteractiveSudo(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { func ForceNonInteractiveSudo(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
cmd := input.ToolUseRequest.Parameters["command"].(string) bashInput, err := input.GetBashInput()
if strings.Contains(cmd, "sudo") && !strings.Contains(cmd, "sudo -n") { if err != nil {
return hooks.HookOutput{Decision: "approve"}, nil
}
if strings.Contains(bashInput.Command, "sudo") && !strings.Contains(bashInput.Command, "sudo -n") {
return hooks.HookOutput{ return hooks.HookOutput{
Decision: "continue", Decision: "approve",
ModifiedParameters: map[string]interface{}{ ModifiedParameters: map[string]interface{}{
"command": strings.ReplaceAll(cmd, "sudo", "sudo -n"), "command": strings.ReplaceAll(bashInput.Command, "sudo", "sudo -n"),
}, },
}, nil }, nil
} }
return hooks.HookOutput{Decision: "continue"}, nil return hooks.HookOutput{Decision: "approve"}, nil
} }
``` ```
## Built-in Hooks ## Built-in Hooks
This package includes several example hooks: This package includes comprehensive hook implementations for all Claude Code events:
### Pre Tool Use ### Pre Tool Use
- **Bash Command Validator**: Blocks dangerous commands like `rm -rf /` - **Bash Command Validator**: Blocks dangerous commands using regex patterns
- **Sensitive File Protection**: Prevents editing `.env`, `.aws/credentials`, etc. - **Sensitive File Protection**: Prevents editing `.env`, `.aws/credentials`, etc.
### Post Tool Use ### Post Tool Use
@ -99,7 +107,16 @@ This package includes several example hooks:
- **Project Context**: Adds git branch and project type information - **Project Context**: Adds git branch and project type information
### Notification ### Notification
- **Speech Notifications**: Uses macOS 'say' command to speak notifications aloud - **System Notifications**: Logs notification events for debugging and monitoring
### Stop
- **Session Completion**: Logs when main Claude agent finishes responding
### SubagentStop
- **Subagent Completion**: Logs when Task tool subagents finish responding
### PreCompact
- **Context Compaction**: Handles before context window compaction (manual/auto)
## Testing Hooks ## Testing Hooks

View file

@ -87,7 +87,10 @@ func runInstall(cmd *cobra.Command, args []string) error {
fmt.Println(" • pre_tool_use: Validates bash commands and prevents sensitive file edits") 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(" • 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(" • user_prompt_submit: Adds project context to prompts")
fmt.Println(" • notification: Speaks notifications aloud (macOS)") fmt.Println(" • notification: Logs notification events")
fmt.Println(" • stop: Logs session completion")
fmt.Println(" • subagent_stop: Logs subagent completion")
fmt.Println(" • pre_compact: Handles context compaction events")
fmt.Println("\nTo test: echo '{\"event\":\"pre_tool_use\",\"tool\":\"Bash\",\"tool_use_request\":{\"tool\":\"Bash\",\"parameters\":{\"command\":\"ls\"}}}' | hooks run") fmt.Println("\nTo test: echo '{\"event\":\"pre_tool_use\",\"tool\":\"Bash\",\"tool_use_request\":{\"tool\":\"Bash\",\"parameters\":{\"command\":\"ls\"}}}' | hooks run")
return nil return nil

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 package handlers
import ( import (
@ -5,10 +18,13 @@ import (
) )
func init() { func init() {
// PreToolUse hooks - validation and security
hooks.RegisterHook(hooks.EventPreToolUse, "Bash", ValidateBashCommand) hooks.RegisterHook(hooks.EventPreToolUse, "Bash", ValidateBashCommand)
hooks.RegisterHook(hooks.EventPreToolUse, "Write|Edit|MultiEdit", PreventSensitiveFileEdits) hooks.RegisterHook(hooks.EventPreToolUse, "Write|Edit|MultiEdit", PreventSensitiveFileEdits)
// PostToolUse hooks - logging and automation
hooks.RegisterHook(hooks.EventPostToolUse, "*", LogToolUsage) hooks.RegisterHook(hooks.EventPostToolUse, "*", LogToolUsage)
// UserPromptSubmit hooks - context enhancement
hooks.RegisterHook(hooks.EventUserPromptSubmit, "*", AddProjectContext) hooks.RegisterHook(hooks.EventUserPromptSubmit, "*", AddProjectContext)
} }

View file

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

View file

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

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" "fmt"
"log/slog" "log/slog"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"github.com/imjasonh/hooks/internal/hooks" "github.com/imjasonh/hooks/internal/hooks"
) )
func AddProjectContext(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { 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 var contexts []string
if gitRoot := findGitRoot(input.CWD); gitRoot != "" { if gitRoot := findGitRoot(input.CWD); gitRoot != "" {