From d80f1c1a5845a53703150e45bc2dfccddc8f5178 Mon Sep 17 00:00:00 2001 From: Darren Cheng Date: Sat, 7 Mar 2026 22:58:52 -0800 Subject: [PATCH] Add Claude Code hooks with install-time path resolution Add hooks infrastructure to dots: protect-files (blocks .env, certs, SSH keys), auto-format (gofmt, prettier, rubocop, ruff), and compact-context (re-injects context after compaction). The installer resolves $DOTS to absolute paths at install time so hooks work regardless of shell environment. Hooks are merged into ~/.claude/settings.json with a warning when replacing existing hooks. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 5 +- README.md | 20 +++- agents/hooks/hooks.json | 39 ++++++ agents/hooks/scripts/auto-format.sh | 39 ++++++ agents/hooks/scripts/compact-context.sh | 25 ++++ agents/hooks/scripts/protect-files.sh | 35 ++++++ cli/commands/install/agents.go | 73 ++++++++++++ cli/commands/install/agents_test.go | 150 ++++++++++++++++++++++++ 8 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 agents/hooks/hooks.json create mode 100755 agents/hooks/scripts/auto-format.sh create mode 100755 agents/hooks/scripts/compact-context.sh create mode 100755 agents/hooks/scripts/protect-files.sh create mode 100644 cli/commands/install/agents_test.go diff --git a/AGENTS.md b/AGENTS.md index b5529a0f..ed18cb52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,8 @@ dots doctor # Run diagnostics │ └── install/ # Component installers ├── agents/ │ ├── skills/ # Cross-agent skills (SKILL.md per skill) -│ └── custom/ # Custom agent types (.md per agent → ~/.claude/agents/) +│ ├── custom/ # Custom agent types (.md per agent → ~/.claude/agents/) +│ └── hooks/ # Claude Code hooks (merged into ~/.claude/settings.json) ├── cmd/ # 22 standalone utilities └── pkg/ # Shared utilities (log, run, cache, path) ``` @@ -93,7 +94,7 @@ dots doctor # Run diagnostics | vim | Vim configuration | | hammerspoon | Window management | | osx | macOS defaults | -| agents | Agent skills and custom agents (symlinks agents/skills → ~/.claude/skills + ~/.agents/skills, agents/custom → ~/.claude/agents) | +| agents | Agent skills, custom agents, and hooks (symlinks skills/custom, merges hooks into ~/.claude/settings.json) | ## Writing Skills / Slash Commands diff --git a/README.md b/README.md index 93306b25..885dad80 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ dots docker stop-all # Stop all Docker containers | Component | What it installs | |-----------|------------------| -| `agents` | Agent skills and custom agents (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`) | +| `agents` | Agent skills, custom agents, and hooks (symlinks skills/custom, merges `agents/hooks/hooks.json` into `~/.claude/settings.json`) | | `bin` | Custom shell scripts and Go utilities to `~/bin` | | `git` | `.gitconfig`, `.gitignore_global`, git extensions | | `home` | Dotfiles symlinked to `~/` (`.zshrc`, `.vimrc`, `.tmux.conf`, `.gitconfig`, etc.) | @@ -124,6 +124,18 @@ Dots also ships 3 reusable custom agent definitions in `agents/custom/`. These a Run `dots install agents` to symlink them to `~/.claude/agents/`. +### Hooks + +Claude Code hooks in `agents/hooks/` provide deterministic automation that fires at specific lifecycle events. Unlike skills (which Claude chooses to use), hooks always execute. + +| Hook | Event | Purpose | +|------|-------|---------| +| `protect-files.sh` | PreToolUse (Edit/Write) | Blocks writes to `.env`, keys, credentials | +| `auto-format.sh` | PostToolUse (Edit/Write) | Auto-formats files (gofmt, prettier, rubocop, ruff) | +| `compact-context.sh` | SessionStart (compact) | Re-injects context from `.claude/compact-context.md` after compaction | + +Run `dots install agents` to merge hooks into `~/.claude/settings.json`. + ## Project Structure ``` @@ -177,8 +189,10 @@ Run `dots install agents` to symlink them to `~/.claude/agents/`. │ ├── agents/ # Agent configuration │ ├── skills/ # 39 reusable skills (SKILL.md per skill) -│ └── custom/ # 3 custom agent types (.md per agent) -│ └── tests/ # Skill test suite +│ ├── custom/ # 3 custom agent types (.md per agent) +│ │ └── tests/ # Skill test suite +│ └── hooks/ # Claude Code hooks (merged into settings.json) +│ └── scripts/ # Hook shell scripts │ └── openspec/ # Change proposal system ``` diff --git a/agents/hooks/hooks.json b/agents/hooks/hooks.json new file mode 100644 index 00000000..b64f8905 --- /dev/null +++ b/agents/hooks/hooks.json @@ -0,0 +1,39 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "$DOTS/agents/hooks/scripts/protect-files.sh", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "$DOTS/agents/hooks/scripts/auto-format.sh", + "timeout": 15 + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "$DOTS/agents/hooks/scripts/compact-context.sh" + } + ] + } + ] + } +} diff --git a/agents/hooks/scripts/auto-format.sh b/agents/hooks/scripts/auto-format.sh new file mode 100755 index 00000000..494cc53b --- /dev/null +++ b/agents/hooks/scripts/auto-format.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# PostToolUse hook: Auto-format files after Write/Edit +# Runs the appropriate formatter based on file extension + +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then + exit 0 +fi + +case "$FILE_PATH" in + *.go) + command -v gofmt >/dev/null 2>&1 && gofmt -w "$FILE_PATH" 2>/dev/null + ;; + *.js|*.jsx|*.ts|*.tsx|*.css|*.scss|*.json|*.yaml|*.yml) + # Only run prettier if installed locally in the project + PROJECT_DIR=$(git -C "$(dirname "$FILE_PATH")" rev-parse --show-toplevel 2>/dev/null) + if [ -n "$PROJECT_DIR" ] && [ -f "$PROJECT_DIR/node_modules/.bin/prettier" ]; then + "$PROJECT_DIR/node_modules/.bin/prettier" --write "$FILE_PATH" 2>/dev/null + fi + ;; + *.rb) + # Only run rubocop if configured in the project (safe autocorrect only) + PROJECT_DIR=$(git -C "$(dirname "$FILE_PATH")" rev-parse --show-toplevel 2>/dev/null) + if [ -n "$PROJECT_DIR" ] && [ -f "$PROJECT_DIR/.rubocop.yml" ]; then + command -v rubocop >/dev/null 2>&1 && rubocop -a --force-exclusion "$FILE_PATH" 2>/dev/null + fi + ;; + *.py) + command -v ruff >/dev/null 2>&1 && ruff format "$FILE_PATH" 2>/dev/null + ;; +esac + +exit 0 diff --git a/agents/hooks/scripts/compact-context.sh b/agents/hooks/scripts/compact-context.sh new file mode 100755 index 00000000..cb6cd964 --- /dev/null +++ b/agents/hooks/scripts/compact-context.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# SessionStart hook (compact): Re-inject critical context after compaction +# Stdout from this hook is added as system context + +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +# Check for project-level context file +CWD=$(cat | jq -r '.cwd // empty') +if [ -n "$CWD" ]; then + CONTEXT_FILE="$CWD/.claude/compact-context.md" + if [ -f "$CONTEXT_FILE" ]; then + cat "$CONTEXT_FILE" + exit 0 + fi +fi + +# Fallback: inject user-level context +USER_CONTEXT="$HOME/.claude/compact-context.md" +if [ -f "$USER_CONTEXT" ]; then + cat "$USER_CONTEXT" +fi + +exit 0 diff --git a/agents/hooks/scripts/protect-files.sh b/agents/hooks/scripts/protect-files.sh new file mode 100755 index 00000000..6979b5f1 --- /dev/null +++ b/agents/hooks/scripts/protect-files.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# PreToolUse hook: Block writes to sensitive files +# Exit 2 = block with feedback to Claude + +if ! command -v jq >/dev/null 2>&1; then + echo "Hook error: jq is required but not installed" >&2 + exit 2 +fi + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +BASENAME=$(basename "$FILE_PATH") + +# Protected file patterns +case "$BASENAME" in + .env|.env.*) + echo "Blocked: cannot write to environment file '$FILE_PATH'" >&2 + exit 2 + ;; + *.pem|*.p12|*.pfx) + echo "Blocked: cannot write to certificate file '$FILE_PATH'" >&2 + exit 2 + ;; + id_rsa*|id_ed25519*|id_ecdsa*) + echo "Blocked: cannot write to SSH key file '$FILE_PATH'" >&2 + exit 2 + ;; +esac + +exit 0 diff --git a/cli/commands/install/agents.go b/cli/commands/install/agents.go index e601fc69..08cc7c9c 100644 --- a/cli/commands/install/agents.go +++ b/cli/commands/install/agents.go @@ -1,7 +1,9 @@ package install import ( + "encoding/json" "os" + "strings" "github.com/drn/dots/cli/link" "github.com/drn/dots/pkg/log" @@ -26,6 +28,9 @@ func (i Install) Agents() { link.Soft(skillsSource, path.FromHome(".claude/skills")) link.Soft(customSource, path.FromHome(".claude/agents")) + // Claude Code: merge hooks into ~/.claude/settings.json + installHooks() + // Codex: ensure ~/.agents exists and symlink skills agentsDir := path.FromHome(".agents") if _, err := os.Stat(agentsDir); os.IsNotExist(err) { @@ -36,3 +41,71 @@ func (i Install) Agents() { } link.Soft(skillsSource, path.FromHome(".agents/skills")) } + +// installHooks merges hooks from agents/hooks/hooks.json into ~/.claude/settings.json. +// Hook commands containing $DOTS are resolved to absolute paths at install time. +// Any existing hooks in settings.json are replaced (dots owns the hooks key). +func installHooks() { + mergeHooksIntoSettings( + path.FromDots("agents/hooks/hooks.json"), + path.FromHome(".claude/settings.json"), + path.Dots(), + ) +} + +// mergeHooksIntoSettings reads hooks from hooksPath, resolves $DOTS references +// to dotsDir, and writes them into settingsPath preserving other settings keys. +func mergeHooksIntoSettings(hooksPath, settingsPath, dotsDir string) { + // Read hooks config + hooksData, err := os.ReadFile(hooksPath) + if err != nil { + log.Warning("No hooks config found at %s", path.Pretty(hooksPath)) + return + } + + // Resolve $DOTS to absolute path so hooks work regardless of shell env + resolved := strings.ReplaceAll(string(hooksData), "$DOTS", dotsDir) + + var hooksConfig map[string]interface{} + if err := json.Unmarshal([]byte(resolved), &hooksConfig); err != nil { + log.Error("Failed to parse hooks config: %s", err.Error()) + return + } + + hooks, ok := hooksConfig["hooks"] + if !ok { + log.Warning("No 'hooks' key found in hooks config") + return + } + + // Read existing settings + settings := map[string]interface{}{} + if data, err := os.ReadFile(settingsPath); err == nil { + if err := json.Unmarshal(data, &settings); err != nil { + log.Error("Failed to parse existing settings: %s", err.Error()) + return + } + } + + // Warn if existing hooks will be replaced + if _, exists := settings["hooks"]; exists { + log.Warning("Replacing existing hooks in %s", path.Pretty(settingsPath)) + } + + // Replace hooks key (dots owns this key) + settings["hooks"] = hooks + + // Write back + output, err := json.MarshalIndent(settings, "", " ") + if err != nil { + log.Error("Failed to marshal settings: %s", err.Error()) + return + } + + if err := os.WriteFile(settingsPath, append(output, '\n'), 0644); err != nil { + log.Error("Failed to write settings: %s", err.Error()) + return + } + + log.Info("Merged hooks into %s", path.Pretty(settingsPath)) +} diff --git a/cli/commands/install/agents_test.go b/cli/commands/install/agents_test.go new file mode 100644 index 00000000..9ca6a245 --- /dev/null +++ b/cli/commands/install/agents_test.go @@ -0,0 +1,150 @@ +package install + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestMergeHooksIntoSettings_MergesAndPreservesExisting(t *testing.T) { + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + settingsPath := filepath.Join(tmpDir, "settings.json") + + os.WriteFile(hooksPath, []byte(`{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"$DOTS/test.sh"}]}]}}`), 0644) + os.WriteFile(settingsPath, []byte(`{"env":{"FOO":"bar"},"mcpServers":{}}`), 0644) + + mergeHooksIntoSettings(hooksPath, settingsPath, "/resolved/dots") + + result, _ := os.ReadFile(settingsPath) + var merged map[string]interface{} + json.Unmarshal(result, &merged) + + // Verify existing settings preserved + env, ok := merged["env"].(map[string]interface{}) + if !ok { + t.Fatal("env key missing after merge") + } + if env["FOO"] != "bar" { + t.Errorf("expected FOO=bar, got %v", env["FOO"]) + } + + // Verify hooks present + hooks, ok := merged["hooks"].(map[string]interface{}) + if !ok { + t.Fatal("hooks key missing after merge") + } + if _, ok := hooks["PreToolUse"]; !ok { + t.Error("PreToolUse hook missing after merge") + } +} + +func TestMergeHooksIntoSettings_ResolvesDOTS(t *testing.T) { + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + settingsPath := filepath.Join(tmpDir, "settings.json") + + os.WriteFile(hooksPath, []byte(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"$DOTS/scripts/hook.sh"}]}]}}`), 0644) + + mergeHooksIntoSettings(hooksPath, settingsPath, "/home/user/.dots") + + result, _ := os.ReadFile(settingsPath) + var merged map[string]interface{} + json.Unmarshal(result, &merged) + + // Navigate to the command string and verify $DOTS was resolved + hooks := merged["hooks"].(map[string]interface{}) + pre := hooks["PreToolUse"].([]interface{}) + entry := pre[0].(map[string]interface{}) + hookList := entry["hooks"].([]interface{}) + hook := hookList[0].(map[string]interface{}) + cmd := hook["command"].(string) + + if cmd != "/home/user/.dots/scripts/hook.sh" { + t.Errorf("expected $DOTS resolved to absolute path, got %s", cmd) + } +} + +func TestMergeHooksIntoSettings_CreatesSettingsIfMissing(t *testing.T) { + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + settingsPath := filepath.Join(tmpDir, "settings.json") + + os.WriteFile(hooksPath, []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"prompt","prompt":"check"}]}]}}`), 0644) + + mergeHooksIntoSettings(hooksPath, settingsPath, "/dots") + + result, _ := os.ReadFile(settingsPath) + var merged map[string]interface{} + json.Unmarshal(result, &merged) + + hooks, ok := merged["hooks"].(map[string]interface{}) + if !ok { + t.Fatal("hooks key missing") + } + if _, ok := hooks["Stop"]; !ok { + t.Error("Stop hook missing") + } +} + +func TestMergeHooksIntoSettings_MissingHooksFile(t *testing.T) { + tmpDir := t.TempDir() + + // Should not panic or error when hooks file doesn't exist + mergeHooksIntoSettings( + filepath.Join(tmpDir, "nonexistent.json"), + filepath.Join(tmpDir, "settings.json"), + "/dots", + ) + + // settings.json should not be created + if _, err := os.Stat(filepath.Join(tmpDir, "settings.json")); err == nil { + t.Error("settings.json should not be created when hooks file is missing") + } +} + +func TestMergeHooksIntoSettings_MalformedSettingsJSON(t *testing.T) { + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + settingsPath := filepath.Join(tmpDir, "settings.json") + + os.WriteFile(hooksPath, []byte(`{"hooks":{"Stop":[]}}`), 0644) + os.WriteFile(settingsPath, []byte(`{invalid json`), 0644) + + // Should not panic; should log error and return without modifying + mergeHooksIntoSettings(hooksPath, settingsPath, "/dots") + + // Original malformed file should be unchanged + result, _ := os.ReadFile(settingsPath) + if string(result) != "{invalid json" { + t.Error("malformed settings.json should be left unchanged") + } +} + +func TestMergeHooksIntoSettings_ReplacesExistingHooks(t *testing.T) { + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + settingsPath := filepath.Join(tmpDir, "settings.json") + + os.WriteFile(hooksPath, []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"prompt","prompt":"new"}]}]}}`), 0644) + os.WriteFile(settingsPath, []byte(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"old.sh"}]}]}}`), 0644) + + mergeHooksIntoSettings(hooksPath, settingsPath, "/dots") + + result, _ := os.ReadFile(settingsPath) + var merged map[string]interface{} + json.Unmarshal(result, &merged) + + hooks := merged["hooks"].(map[string]interface{}) + + // New hooks should be present + if _, ok := hooks["Stop"]; !ok { + t.Error("Stop hook should be present after replacement") + } + + // Old hooks should be gone (full replacement) + if _, ok := hooks["PreToolUse"]; ok { + t.Error("PreToolUse hook should be replaced (dots owns hooks key)") + } +}