Commit d74f29061a3c

Vincent Demeester <vincent@sbr.pm>
2025-12-03 21:54:40
feat: Add Go-based Claude Code hooks implementation
- Add claude-hooks Go package with three binaries: * claude-hooks-initialize-session (SessionStart hook) * claude-hooks-capture-tool-output (PostToolUse hook) * claude-hooks-validate-docs (documentation link validator) - Implement session initialization with terminal title and logging - Implement tool output capture to JSONL files - Add documentation validator for markdown internal links - Include Nix package definition with vendorHash - Add comprehensive README with setup and development instructions - Expose package in pkgs/default.nix Advantages over TypeScript/Bun: - Zero runtime dependencies (compiled native binaries) - Faster execution and startup time - Native Nix integration - Cross-compilation support Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 9905573
Changed files (10)
pkgs
tools
claude-hooks
cmd
capture-tool-output
initialize-session
validate-docs
internal
pkgs/default.nix
@@ -15,6 +15,7 @@ in
   vrsync = pkgs.callPackage ./my/vrsync { };
   vde-thinkpad = pkgs.callPackage ./my/vde-thinkpad { };
   battery-monitor = pkgs.callPackage ../tools/battery-monitor { };
+  claude-hooks = pkgs.callPackage ../tools/claude-hooks { };
   ape = pkgs.callPackage ./ape { };
   ram = pkgs.callPackage ./ram { };
   govanityurl = pkgs.callPackage ./govanityurl { };
tools/claude-hooks/cmd/capture-tool-output/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/vdemeester/home/tools/claude-hooks/internal/paths"
+)
+
+// ToolUseData represents the input from PostToolUse hook
+type ToolUseData struct {
+	ToolName       string                 `json:"tool_name"`
+	ToolInput      map[string]interface{} `json:"tool_input"`
+	ToolResponse   map[string]interface{} `json:"tool_response"`
+	ConversationID string                 `json:"conversation_id"`
+	Timestamp      string                 `json:"timestamp"`
+}
+
+// CaptureEntry represents a log entry in JSONL format
+type CaptureEntry struct {
+	Timestamp string                 `json:"timestamp"`
+	Tool      string                 `json:"tool"`
+	Input     map[string]interface{} `json:"input"`
+	Output    map[string]interface{} `json:"output"`
+	Session   string                 `json:"session"`
+}
+
+// List of tools to capture
+var interestingTools = map[string]bool{
+	"Bash":         true,
+	"Edit":         true,
+	"Write":        true,
+	"Read":         true,
+	"Task":         true,
+	"NotebookEdit": true,
+	"Skill":        true,
+	"SlashCommand": true,
+}
+
+func main() {
+	// Read input from stdin
+	input, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[capture-tool-output] Error reading stdin: %v\n", err)
+		os.Exit(0) // Silent failure - don't disrupt workflow
+	}
+
+	if len(input) == 0 {
+		os.Exit(0)
+	}
+
+	var data ToolUseData
+	if err := json.Unmarshal(input, &data); err != nil {
+		fmt.Fprintf(os.Stderr, "[capture-tool-output] Error parsing JSON: %v\n", err)
+		os.Exit(0) // Silent failure
+	}
+
+	// Only capture interesting tools
+	if !interestingTools[data.ToolName] {
+		os.Exit(0)
+	}
+
+	// Get today's date for organization
+	now := time.Now()
+	today := now.Format("2006-01-02")
+	yearMonth := now.Format("2006-01")
+
+	// Ensure capture directory exists
+	dateDir := filepath.Join(paths.HistoryDir(), "tool-outputs", yearMonth)
+	if err := os.MkdirAll(dateDir, 0755); err != nil {
+		fmt.Fprintf(os.Stderr, "[capture-tool-output] Error creating directory: %v\n", err)
+		os.Exit(0) // Silent failure
+	}
+
+	// Format output as JSONL
+	captureFile := filepath.Join(dateDir, fmt.Sprintf("%s_tool-outputs.jsonl", today))
+
+	timestamp := data.Timestamp
+	if timestamp == "" {
+		timestamp = now.Format(time.RFC3339)
+	}
+
+	entry := CaptureEntry{
+		Timestamp: timestamp,
+		Tool:      data.ToolName,
+		Input:     data.ToolInput,
+		Output:    data.ToolResponse,
+		Session:   data.ConversationID,
+	}
+
+	jsonData, err := json.Marshal(entry)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[capture-tool-output] Error marshaling JSON: %v\n", err)
+		os.Exit(0) // Silent failure
+	}
+
+	// Append to daily log
+	f, err := os.OpenFile(captureFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[capture-tool-output] Error opening file: %v\n", err)
+		os.Exit(0) // Silent failure
+	}
+	defer f.Close()
+
+	if _, err := f.WriteString(string(jsonData) + "\n"); err != nil {
+		fmt.Fprintf(os.Stderr, "[capture-tool-output] Error writing to file: %v\n", err)
+		os.Exit(0) // Silent failure
+	}
+
+	os.Exit(0)
+}
tools/claude-hooks/cmd/initialize-session/main.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/vdemeester/home/tools/claude-hooks/internal/paths"
+)
+
+const (
+	debounceDuration = 2 * time.Second
+)
+
+func getLockfile() string {
+	return filepath.Join(os.TempDir(), "claude-session-start.lock")
+}
+
+// shouldDebounce checks if we're within the debounce window
+func shouldDebounce() bool {
+	lockfile := getLockfile()
+
+	data, err := os.ReadFile(lockfile)
+	if err == nil {
+		lockTime, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
+		if err == nil {
+			now := time.Now().UnixMilli()
+			if now-lockTime < debounceDuration.Milliseconds() {
+				return true
+			}
+		}
+	}
+
+	// Update lockfile with current timestamp
+	now := time.Now().UnixMilli()
+	if err := os.WriteFile(lockfile, []byte(fmt.Sprintf("%d", now)), 0644); err != nil {
+		// Ignore write errors
+	}
+
+	return false
+}
+
+// isSubagentSession checks if this is a subagent session
+func isSubagentSession() bool {
+	claudeProjectDir := os.Getenv("CLAUDE_PROJECT_DIR")
+	if strings.Contains(claudeProjectDir, "/.claude/agents/") {
+		return true
+	}
+	if os.Getenv("CLAUDE_AGENT_TYPE") != "" {
+		return true
+	}
+	return false
+}
+
+// setTerminalTitle sets the terminal tab title using ANSI escape codes
+func setTerminalTitle(title string) {
+	fmt.Fprintf(os.Stderr, "\x1b]0;%s\x07", title)
+	fmt.Fprintf(os.Stderr, "\x1b]2;%s\x07", title)
+	fmt.Fprintf(os.Stderr, "\x1b]30;%s\x07", title)
+}
+
+// logSessionStart logs the session start to history
+func logSessionStart() error {
+	timestamp := paths.GetTimestamp()
+	yearMonth := timestamp[:7] // YYYY-MM
+
+	logDir := filepath.Join(paths.HistoryDir(), "sessions", yearMonth)
+	if err := os.MkdirAll(logDir, 0755); err != nil {
+		return err
+	}
+
+	logEntry := fmt.Sprintf("%s - Session started\n", time.Now().Format(time.RFC3339))
+	logFile := filepath.Join(logDir, fmt.Sprintf("%s_session-log.txt", timestamp[:10]))
+
+	f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	_, err = f.WriteString(logEntry)
+	return err
+}
+
+func main() {
+	// Check if this is a subagent session
+	if isSubagentSession() {
+		fmt.Fprintln(os.Stderr, "๐Ÿค– Subagent session detected - skipping session initialization")
+		os.Exit(0)
+	}
+
+	// Check debounce to prevent duplicate notifications
+	if shouldDebounce() {
+		fmt.Fprintln(os.Stderr, "๐Ÿ”‡ Debouncing duplicate SessionStart event")
+		os.Exit(0)
+	}
+
+	// Set initial tab title
+	tabTitle := "Claude Ready"
+	setTerminalTitle(tabTitle)
+	fmt.Fprintf(os.Stderr, "๐Ÿ“ Session initialized: \"%s\"\n", tabTitle)
+
+	// Log session start to history (silent failure)
+	if err := logSessionStart(); err != nil {
+		// Don't break session start for logging issues
+		fmt.Fprintf(os.Stderr, "[initialize-session] Warning: Could not log session start: %v\n", err)
+	}
+
+	os.Exit(0)
+}
tools/claude-hooks/cmd/validate-docs/main.go
@@ -0,0 +1,169 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/bmatcuk/doublestar/v4"
+)
+
+// ANSI color codes
+const (
+	colorReset  = "\x1b[0m"
+	colorRed    = "\x1b[31m"
+	colorGreen  = "\x1b[32m"
+	colorYellow = "\x1b[33m"
+	colorCyan   = "\x1b[36m"
+)
+
+type brokenLink struct {
+	file   string
+	link   string
+	target string
+	line   int
+}
+
+// extractLinks extracts markdown links from content
+func extractLinks(content string) []struct {
+	link string
+	line int
+} {
+	var links []struct {
+		link string
+		line int
+	}
+
+	// Match [text](path) style links
+	linkRegex := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+
+	scanner := bufio.NewScanner(strings.NewReader(content))
+	lineNum := 0
+	for scanner.Scan() {
+		lineNum++
+		line := scanner.Text()
+
+		matches := linkRegex.FindAllStringSubmatch(line, -1)
+		for _, match := range matches {
+			if len(match) >= 3 {
+				link := match[2]
+
+				// Skip external URLs, anchors, and mailto links
+				if strings.HasPrefix(link, "http://") ||
+					strings.HasPrefix(link, "https://") ||
+					strings.HasPrefix(link, "#") ||
+					strings.HasPrefix(link, "mailto:") {
+					continue
+				}
+
+				links = append(links, struct {
+					link string
+					line int
+				}{link: link, line: lineNum})
+			}
+		}
+	}
+
+	return links
+}
+
+// resolveLink resolves a link path relative to the file
+func resolveLink(fromFile, linkPath, baseDir string) string {
+	// Remove anchor if present
+	parts := strings.Split(linkPath, "#")
+	pathWithoutAnchor := parts[0]
+
+	// If it starts with ~, expand to home directory
+	if strings.HasPrefix(pathWithoutAnchor, "~/") {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return pathWithoutAnchor
+		}
+		return filepath.Join(home, pathWithoutAnchor[2:])
+	}
+
+	// If it's absolute, use as-is
+	if filepath.IsAbs(pathWithoutAnchor) {
+		return pathWithoutAnchor
+	}
+
+	// Otherwise, resolve relative to the file's directory
+	fileDir := filepath.Dir(fromFile)
+	return filepath.Join(fileDir, pathWithoutAnchor)
+}
+
+// validateDocs validates markdown files in a directory
+func validateDocs(baseDir string) []brokenLink {
+	var broken []brokenLink
+
+	// Find all markdown files using doublestar glob
+	pattern := filepath.Join(baseDir, "**/*.md")
+	matches, err := doublestar.FilepathGlob(pattern)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "%sError globbing files: %v%s\n", colorYellow, err, colorReset)
+		return broken
+	}
+
+	for _, filePath := range matches {
+		// Skip node_modules and hidden directories
+		if strings.Contains(filePath, "node_modules") || strings.Contains(filePath, "/.") {
+			continue
+		}
+
+		content, err := os.ReadFile(filePath)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "%sWarning: Could not read %s%s\n", colorYellow, filePath, colorReset)
+			continue
+		}
+
+		links := extractLinks(string(content))
+		for _, linkInfo := range links {
+			targetPath := resolveLink(filePath, linkInfo.link, baseDir)
+
+			// Check if target exists
+			if _, err := os.Stat(targetPath); os.IsNotExist(err) {
+				// Get relative path for display
+				relPath, _ := filepath.Rel(baseDir, filePath)
+				broken = append(broken, brokenLink{
+					file:   relPath,
+					link:   linkInfo.link,
+					target: targetPath,
+					line:   linkInfo.line,
+				})
+			}
+		}
+	}
+
+	return broken
+}
+
+func main() {
+	baseDir, err := os.Getwd()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
+		os.Exit(1)
+	}
+
+	fmt.Printf("\n%s๐Ÿ” Documentation Link Validator%s\n", colorCyan, colorReset)
+	fmt.Printf("%s   Base directory: %s%s\n\n", colorCyan, baseDir, colorReset)
+
+	brokenLinks := validateDocs(baseDir)
+
+	if len(brokenLinks) > 0 {
+		fmt.Printf("\n%sโŒ Found %d broken link(s):%s\n\n", colorRed, len(brokenLinks), colorReset)
+
+		for _, broken := range brokenLinks {
+			fmt.Printf("  %s%s:%d%s\n", colorYellow, broken.file, broken.line, colorReset)
+			fmt.Printf("    โ†’ %s%s%s (not found)\n\n", colorRed, broken.link, colorReset)
+		}
+
+		fmt.Printf("\n%sDocumentation validation failed. Please fix the broken links.%s\n\n", colorRed, colorReset)
+		os.Exit(1)
+	}
+
+	fmt.Printf("%sโœ… All documentation links are valid%s\n\n", colorGreen, colorReset)
+	os.Exit(0)
+}
tools/claude-hooks/internal/paths/paths.go
@@ -0,0 +1,79 @@
+package paths
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// ClaudeDir returns the base Claude directory (~/.claude or CLAUDE_DIR env var)
+func ClaudeDir() string {
+	if dir := os.Getenv("CLAUDE_DIR"); dir != "" {
+		return dir
+	}
+	home, err := os.UserHomeDir()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
+		os.Exit(1)
+	}
+	return filepath.Join(home, ".claude")
+}
+
+// HooksDir returns the hooks directory
+func HooksDir() string {
+	return filepath.Join(ClaudeDir(), "hooks")
+}
+
+// SkillsDir returns the skills directory
+func SkillsDir() string {
+	return filepath.Join(ClaudeDir(), "skills")
+}
+
+// AgentsDir returns the agents directory
+func AgentsDir() string {
+	return filepath.Join(ClaudeDir(), "agents")
+}
+
+// HistoryDir returns the history directory
+func HistoryDir() string {
+	return filepath.Join(ClaudeDir(), "history")
+}
+
+// GetHistoryFilePath returns a history file path with year-month organization
+func GetHistoryFilePath(subdir, filename string) string {
+	now := time.Now()
+	yearMonth := now.Format("2006-01")
+	return filepath.Join(HistoryDir(), subdir, yearMonth, filename)
+}
+
+// GetTimestamp returns current timestamp in YYYY-MM-DD-HHMMSS format
+func GetTimestamp() string {
+	return time.Now().Format("2006-01-02-150405")
+}
+
+// GetDate returns current date in YYYY-MM-DD format
+func GetDate() string {
+	return time.Now().Format("2006-01-02")
+}
+
+// GetYearMonth returns current year-month in YYYY-MM format
+func GetYearMonth() string {
+	return time.Now().Format("2006-01")
+}
+
+// ValidateClaudeStructure validates that the Claude directory exists
+func ValidateClaudeStructure() error {
+	claudeDir := ClaudeDir()
+	if _, err := os.Stat(claudeDir); os.IsNotExist(err) {
+		return fmt.Errorf("CLAUDE_DIR does not exist: %s\nExpected ~/.claude or set CLAUDE_DIR environment variable", claudeDir)
+	}
+	return nil
+}
+
+// EnsureHistoryDir creates the history directory structure if needed
+func EnsureHistoryDir(subdir string) error {
+	yearMonth := GetYearMonth()
+	dir := filepath.Join(HistoryDir(), subdir, yearMonth)
+	return os.MkdirAll(dir, 0755)
+}
tools/claude-hooks/default.nix
@@ -0,0 +1,28 @@
+{ buildGoModule }:
+
+buildGoModule rec {
+  pname = "claude-hooks";
+  version = "0.1.0";
+  src = ./.;
+
+  vendorHash = "sha256-bdpAteulG3045jPdEpjcT4yGlnxLKDMlK7lk9WVRTKc=";
+
+  # Build all three binaries
+  subPackages = [
+    "cmd/capture-tool-output"
+    "cmd/initialize-session"
+    "cmd/validate-docs"
+  ];
+
+  # Rename binaries to have consistent prefix
+  postInstall = ''
+    mv $out/bin/capture-tool-output $out/bin/claude-hooks-capture-tool-output
+    mv $out/bin/initialize-session $out/bin/claude-hooks-initialize-session
+    mv $out/bin/validate-docs $out/bin/claude-hooks-validate-docs
+  '';
+
+  meta = {
+    description = "Claude Code hooks for session initialization, tool output capture, and documentation validation";
+    mainProgram = "claude-hooks-capture-tool-output";
+  };
+}
tools/claude-hooks/go.mod
@@ -0,0 +1,5 @@
+module github.com/vdemeester/home/tools/claude-hooks
+
+go 1.23
+
+require github.com/bmatcuk/doublestar/v4 v4.7.1
tools/claude-hooks/go.sum
@@ -0,0 +1,2 @@
+github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
+github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
tools/claude-hooks/README.md
@@ -0,0 +1,218 @@
+# Claude Code Hooks (Go Implementation)
+
+Claude Code hooks implemented in Go for better performance, zero runtime dependencies, and native integration with the Nix ecosystem.
+
+Migrated from TypeScript/Bun implementation in `dots/.claude/hooks/`.
+
+## Hooks Included
+
+### 1. `claude-hooks-initialize-session`
+**Event**: SessionStart
+
+**What it does**:
+- Detects and skips subagent sessions
+- Sets terminal tab title to "Claude Ready"
+- Logs session start to `~/.claude/history/sessions/YYYY-MM/YYYY-MM-DD_session-log.txt`
+- Implements debouncing to prevent duplicate triggers (2 second window)
+
+### 2. `claude-hooks-capture-tool-output`
+**Event**: PostToolUse
+
+**What it does**:
+- Captures outputs from interesting tools (Bash, Edit, Write, Read, Task, NotebookEdit, Skill, SlashCommand)
+- Logs to JSONL files: `~/.claude/history/tool-outputs/YYYY-MM/YYYY-MM-DD_tool-outputs.jsonl`
+- Silent failure - doesn't disrupt workflow if logging fails
+- Automatically creates directory structure
+
+### 3. `claude-hooks-validate-docs`
+**Event**: Manual/Pre-commit
+
+**What it does**:
+- Scans all markdown files for internal links
+- Checks if linked files exist
+- Reports broken links with file:line numbers
+- Exit code 0 if valid, 1 if broken links found
+- Skips external URLs, anchors, and mailto links
+
+## Architecture
+
+```
+tools/claude-hooks/
+โ”œโ”€โ”€ cmd/
+โ”‚   โ”œโ”€โ”€ capture-tool-output/main.go   # PostToolUse hook
+โ”‚   โ”œโ”€โ”€ initialize-session/main.go    # SessionStart hook
+โ”‚   โ””โ”€โ”€ validate-docs/main.go         # Documentation validator
+โ”œโ”€โ”€ internal/
+โ”‚   โ””โ”€โ”€ paths/paths.go                # Shared path utilities
+โ”œโ”€โ”€ default.nix                       # Nix package definition
+โ”œโ”€โ”€ go.mod                            # Go module definition
+โ”œโ”€โ”€ go.sum                            # Go dependencies
+โ”œโ”€โ”€ setup-hooks.sh                    # Configuration script
+โ””โ”€โ”€ README.md                         # This file
+```
+
+## Installation
+
+### Via Nix (Recommended)
+
+1. **Build the package**:
+   ```bash
+   nix build .#claude-hooks
+   ```
+
+2. **Install to your profile**:
+   ```bash
+   nix profile install .#claude-hooks
+   ```
+
+3. **Or add to home-manager** (in `home/common/dev/default.nix` or similar):
+   ```nix
+   home.packages = with pkgs; [
+     claude-hooks
+   ];
+   ```
+
+4. **Configure hooks** (run setup script):
+   ```bash
+   ./tools/claude-hooks/setup-hooks.sh
+   ```
+
+   Or manually update `~/.claude/settings.json`:
+   ```json
+   {
+     "hooks": {
+       "SessionStart": [
+         {
+           "hooks": [
+             {
+               "type": "command",
+               "command": "claude-hooks-initialize-session"
+             }
+           ]
+         }
+       ],
+       "PostToolUse": [
+         {
+           "hooks": [
+             {
+               "type": "command",
+               "command": "claude-hooks-capture-tool-output"
+             }
+           ]
+         }
+       ]
+     }
+   }
+   ```
+
+### Manual Build (without Nix)
+
+```bash
+cd tools/claude-hooks
+
+# Build all hooks
+go build -o bin/claude-hooks-capture-tool-output ./cmd/capture-tool-output
+go build -o bin/claude-hooks-initialize-session ./cmd/initialize-session
+go build -o bin/claude-hooks-validate-docs ./cmd/validate-docs
+
+# Copy to PATH
+sudo cp bin/* /usr/local/bin/
+
+# Then run setup script
+./setup-hooks.sh
+```
+
+## Development
+
+### Running hooks directly with `go run`
+
+```bash
+cd tools/claude-hooks
+
+# Test initialize-session
+go run ./cmd/initialize-session
+
+# Test capture-tool-output (requires JSON input)
+echo '{"tool_name":"Bash","tool_input":{},"tool_response":{},"conversation_id":"test"}' | \
+  go run ./cmd/capture-tool-output
+
+# Test validate-docs
+go run ./cmd/validate-docs
+```
+
+### Testing
+
+```bash
+# Run all tests
+go test ./...
+
+# Test with verbose output
+go test -v ./internal/paths
+
+# Test specific package
+go test ./cmd/validate-docs
+```
+
+### Updating vendorHash
+
+If you add/update Go dependencies:
+
+```bash
+cd tools/claude-hooks
+go mod tidy
+nix build .#claude-hooks 2>&1 | grep "got:" | awk '{print $2}'
+# Copy the hash to default.nix vendorHash field
+```
+
+## Advantages over TypeScript/Bun
+
+1. **Zero runtime dependencies** - Compiled to native binary, no Bun required
+2. **Faster execution** - Native code vs interpreted JavaScript
+3. **Easier distribution** - Single binary per hook
+4. **Cross-compilation** - Build for any architecture from any platform
+5. **Nix integration** - Proper package management and reproducible builds
+6. **Type safety** - Compile-time checks without runtime overhead
+7. **Standard library** - Excellent built-in support for JSON, file I/O, paths
+
+## Migration Notes
+
+The Go implementation is functionally equivalent to the TypeScript version with these improvements:
+
+- **Performance**: Faster startup and execution
+- **Reliability**: Compile-time type checking
+- **Portability**: Works on all systems where Go binaries run
+- **Integration**: Native Nix packaging for declarative installation
+
+The old TypeScript hooks in `dots/.claude/hooks/` can be removed after verifying the Go versions work correctly.
+
+## Troubleshooting
+
+**Hook not running:**
+- Check `~/.claude/settings.json` syntax
+- Verify binaries are in PATH: `which claude-hooks-initialize-session`
+- Check stderr output in terminal
+- Verify hooks are executable: `ls -la $(which claude-hooks-initialize-session)`
+
+**Permission errors:**
+- Ensure directories exist: `mkdir -p ~/.claude/history/{sessions,tool-outputs}`
+- Check write permissions on `~/.claude/history`
+
+**Debugging:**
+- Hooks write errors to stderr - check terminal output
+- Run hooks manually to test: `claude-hooks-initialize-session`
+- Check log files: `ls -la ~/.claude/history/`
+
+**Build errors:**
+- Run `go mod tidy` to update dependencies
+- Check Go version: `go version` (requires Go 1.23+)
+- Verify vendorHash in `default.nix` is correct
+
+## Environment Variables
+
+- `CLAUDE_DIR` - Override default `~/.claude` directory (optional)
+- `CLAUDE_PROJECT_DIR` - Set by Claude Code (used for subagent detection)
+- `CLAUDE_AGENT_TYPE` - Set by Claude Code for subagents (used for detection)
+
+## License
+
+Same as the rest of the home repository.
tools/claude-hooks/setup-hooks.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+#
+# setup-hooks.sh - Configure Claude Code hooks to use Nix-built binaries
+#
+# This script updates ~/.claude/settings.json to reference the correct
+# hook binaries from the Nix store (via PATH).
+#
+# Usage:
+#   ./setup-hooks.sh              # Update settings.json
+#   ./setup-hooks.sh --dry-run    # Show what would be changed
+
+set -euo pipefail
+
+SETTINGS_FILE="${HOME}/.claude/settings.json"
+SETTINGS_BACKUP="${HOME}/.claude/settings.json.backup"
+
+# Determine if this is a dry run
+DRY_RUN=false
+if [[ "${1:-}" == "--dry-run" ]]; then
+    DRY_RUN=true
+fi
+
+# Check if settings.json exists
+if [[ ! -f "$SETTINGS_FILE" ]]; then
+    echo "Error: $SETTINGS_FILE does not exist"
+    echo "Please create it first or run Claude Code to initialize it"
+    exit 1
+fi
+
+# Backup existing settings
+if [[ "$DRY_RUN" == false ]]; then
+    cp "$SETTINGS_FILE" "$SETTINGS_BACKUP"
+    echo "Backed up existing settings to: $SETTINGS_BACKUP"
+fi
+
+# Read current settings
+CURRENT_SETTINGS=$(cat "$SETTINGS_FILE")
+
+# Build new hooks configuration
+# Note: The binaries should be in PATH after installing via Nix
+NEW_HOOKS=$(cat <<'EOF'
+{
+  "hooks": [
+    {
+      "type": "command",
+      "command": "claude-hooks-initialize-session"
+    }
+  ]
+}
+EOF
+)
+
+POST_TOOL_HOOKS=$(cat <<'EOF'
+{
+  "hooks": [
+    {
+      "type": "command",
+      "command": "claude-hooks-capture-tool-output"
+    }
+  ]
+}
+EOF
+)
+
+# Use jq to update the settings.json with proper JSON merging
+UPDATED_SETTINGS=$(echo "$CURRENT_SETTINGS" | jq --argjson sessionStart "$NEW_HOOKS" \
+    --argjson postTool "$POST_TOOL_HOOKS" \
+    '.hooks.SessionStart = [$sessionStart] | .hooks.PostToolUse = [$postTool]')
+
+if [[ "$DRY_RUN" == true ]]; then
+    echo "=== DRY RUN - No changes made ==="
+    echo ""
+    echo "Would update $SETTINGS_FILE with:"
+    echo "$UPDATED_SETTINGS" | jq '.'
+    echo ""
+    echo "Run without --dry-run to apply these changes"
+else
+    echo "$UPDATED_SETTINGS" | jq '.' > "$SETTINGS_FILE"
+    echo "Successfully updated $SETTINGS_FILE"
+    echo ""
+    echo "Hook binaries configured:"
+    echo "  - SessionStart: claude-hooks-initialize-session"
+    echo "  - PostToolUse: claude-hooks-capture-tool-output"
+    echo ""
+    echo "Make sure claude-hooks is installed:"
+    echo "  nix profile install .#claude-hooks"
+    echo ""
+    echo "Or add to your home-manager configuration:"
+    echo "  home.packages = [ pkgs.claude-hooks ];"
+fi