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:
parent
c61b579243
commit
45ef980233
10 changed files with 351 additions and 108 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
internal/handlers/precompact.go
Normal file
69
internal/handlers/precompact.go
Normal 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
41
internal/handlers/stop.go
Normal 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
|
||||
}
|
||||
42
internal/handlers/subagentstop.go
Normal file
42
internal/handlers/subagentstop.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue