Commit d74f29061a3c
Changed files (10)
pkgs
tools
claude-hooks
internal
paths
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