From b0eede713dba28ac43c6fd7e415b95400c9571ab Mon Sep 17 00:00:00 2001 From: Agustin Rodriguez Date: Mon, 13 Apr 2026 10:17:04 -0600 Subject: [PATCH 1/4] fix: detect and resolve competing Bash PreToolUse hook conflicts Claude Code silently drops updatedInput when multiple PreToolUse hooks match the same tool (anthropics/claude-code#15897), causing chop compression to be invisibly disabled. - chop doctor now detects competing Bash PreToolUse hooks from both settings.json and plugin hooks.json files, with a clear error and bug reference - chop init warns immediately on install if competing hooks exist - chop fix-hooks generates ~/.claude/hooks/chop-wrapper.sh combining all hooks into one (user chooses standard or wrapper approach) - Normalize git -C in tracking history so commands display as plain "git status" regardless of how Claude constructs the invocation --- hooks/install.go | 356 +++++++++++++++++++++++++++++++++++++++++- hooks/install_test.go | 148 ++++++++++++++++++ main.go | 139 ++++++++++++++++- 3 files changed, 637 insertions(+), 6 deletions(-) diff --git a/hooks/install.go b/hooks/install.go index e721349..26ebefb 100644 --- a/hooks/install.go +++ b/hooks/install.go @@ -3,6 +3,7 @@ package hooks import ( "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -29,6 +30,27 @@ func Install(version string) { _ = config.WriteDiscoveryInfo(version) fmt.Printf("chop hook installed in %s\n", settingsPath) + // Warn if competing Bash PreToolUse hooks exist — they will silently disable compression. + if conflicts, err := FindConflictingBashHooks(); err == nil && conflicts.HasConflict() { + fmt.Println() + fmt.Println("WARNING: competing Bash PreToolUse hooks detected.") + fmt.Println("Claude Code will silently drop chop's output due to a known bug:") + fmt.Println(" https://github.com/anthropics/claude-code/issues/15897") + if len(conflicts.SettingsConflicts) > 0 { + fmt.Println("Conflicts in ~/.claude/settings.json:") + for _, cmd := range conflicts.SettingsConflicts { + fmt.Printf(" - %s\n", cmd) + } + } + if len(conflicts.PluginConflicts) > 0 { + fmt.Println("Conflicts from plugins:") + for _, path := range conflicts.PluginConflicts { + fmt.Printf(" - %s\n", path) + } + } + fmt.Println("Run `chop fix-hooks` to generate a combined wrapper script automatically.") + } + binPath, _ := chopBinaryPath() fmt.Printf("\nInstallation complete! Please tell your Claude Code: 'Remember that chop is installed at %s and use it for CLI compression.' This will prevent the agent from searching for it in the future.\n", binPath) } @@ -218,15 +240,347 @@ func writeSettings(path string, settings map[string]interface{}) error { return os.WriteFile(path, data, 0o600) } +// isChopHook returns true for direct chop binary invocations: `"/chop" hook`. +// Used by IsInstalled, GetHookCommand, install, and uninstall — must stay strict. func isChopHook(hookObj map[string]interface{}) bool { cmd, ok := hookObj["command"].(string) if !ok { return false } - // Match commands that reference "chop" and end with " hook" return strings.Contains(cmd, chopBinaryName) && strings.HasSuffix(cmd, " hook") } +// isChopAwareHook returns true if the hook is either a direct chop invocation or a +// wrapper script whose filename contains "chop" (e.g. chop-verify.sh). +// Used only by conflict detection to avoid false positives on user-created wrappers. +func isChopAwareHook(hookObj map[string]interface{}) bool { + if isChopHook(hookObj) { + return true + } + cmd, ok := hookObj["command"].(string) + if !ok { + return false + } + fields := strings.Fields(cmd) + if len(fields) >= 1 { + script := filepath.Base(fields[len(fields)-1]) + return strings.Contains(strings.ToLower(script), chopBinaryName) + } + return false +} + +// ConflictingBashHooks describes hooks that compete with chop's updatedInput output. +// When multiple Bash PreToolUse hooks are active, Claude Code silently drops updatedInput +// from all of them (https://github.com/anthropics/claude-code/issues/15897). +type ConflictingBashHooks struct { + // SettingsConflicts are non-chop hook commands found in the Bash PreToolUse + // section of ~/.claude/settings.json. + SettingsConflicts []string + // PluginConflicts are plugin hooks.json file paths that declare a Bash + // PreToolUse hook, adding a competing hook matcher at the Claude Code level. + PluginConflicts []string +} + +// HasConflict returns true if any conflicting hooks were found. +func (c ConflictingBashHooks) HasConflict() bool { + return len(c.SettingsConflicts) > 0 || len(c.PluginConflicts) > 0 +} + +// HasChopAwareHook returns true if a Bash PreToolUse hook that is either a direct +// chop invocation or a chop wrapper script is registered in settings.json. +// Unlike IsInstalled, this does not require the strict `"" hook` form. +func HasChopAwareHook() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + settingsPath := filepath.Join(home, ".claude", "settings.json") + settings, err := readSettings(settingsPath) + if err != nil { + return false + } + hooksRaw, ok := settings["hooks"] + if !ok { + return false + } + hooksMap, ok := hooksRaw.(map[string]interface{}) + if !ok { + return false + } + ptuRaw, ok := hooksMap["PreToolUse"] + if !ok { + return false + } + ptu, ok := ptuRaw.([]interface{}) + if !ok { + return false + } + for _, entry := range ptu { + m, ok := entry.(map[string]interface{}) + if !ok { + continue + } + if matcher, _ := m["matcher"].(string); matcher != "Bash" { + continue + } + hooksArrayRaw, ok := m["hooks"] + if !ok { + continue + } + hooksArray, ok := hooksArrayRaw.([]interface{}) + if !ok { + continue + } + for _, h := range hooksArray { + hMap, ok := h.(map[string]interface{}) + if !ok { + continue + } + if isChopAwareHook(hMap) { + return true + } + } + } + return false +} + +// FindConflictingBashHooks scans settings.json and plugin hooks.json files for +// Bash PreToolUse hooks that would compete with chop's updatedInput output. +func FindConflictingBashHooks() (ConflictingBashHooks, error) { + home, err := os.UserHomeDir() + if err != nil { + return ConflictingBashHooks{}, fmt.Errorf("failed to get home directory: %w", err) + } + return findConflictingBashHooksIn(home) +} + +// findConflictingBashHooksIn is the testable core of FindConflictingBashHooks. +func findConflictingBashHooksIn(home string) (ConflictingBashHooks, error) { + result := ConflictingBashHooks{} + + // --- scan settings.json for non-chop Bash PreToolUse hooks --- + settingsPath := filepath.Join(home, ".claude", "settings.json") + settings, err := readSettings(settingsPath) + if err == nil { + if hooksRaw, ok := settings["hooks"]; ok { + if hooksMap, ok := hooksRaw.(map[string]interface{}); ok { + if ptuRaw, ok := hooksMap["PreToolUse"]; ok { + if ptu, ok := ptuRaw.([]interface{}); ok { + for _, entry := range ptu { + m, ok := entry.(map[string]interface{}) + if !ok { + continue + } + if matcher, _ := m["matcher"].(string); matcher != "Bash" { + continue + } + hooksArrayRaw, ok := m["hooks"] + if !ok { + continue + } + hooksArray, ok := hooksArrayRaw.([]interface{}) + if !ok { + continue + } + for _, h := range hooksArray { + hMap, ok := h.(map[string]interface{}) + if !ok { + continue + } + if isChopAwareHook(hMap) { + continue // chop or a chop wrapper — not a conflict + } + if cmd, ok := hMap["command"].(string); ok && cmd != "" { + result.SettingsConflicts = append(result.SettingsConflicts, cmd) + } + } + } + } + } + } + } + } + + // --- scan plugin hooks.json files for Bash PreToolUse entries --- + pluginsDir := filepath.Join(home, ".claude", "plugins") + if _, err := os.Stat(pluginsDir); err == nil { + _ = filepath.WalkDir(pluginsDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + // Only care about hooks/hooks.json files inside the plugins tree + if d.Name() != "hooks.json" { + return nil + } + if filepath.Base(filepath.Dir(path)) != "hooks" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var pluginHooks struct { + Hooks struct { + PreToolUse []struct { + Matcher string `json:"matcher"` + Hooks []struct { + Command string `json:"command"` + } `json:"hooks"` + } `json:"PreToolUse"` + } `json:"hooks"` + } + if json.Unmarshal(data, &pluginHooks) != nil { + return nil + } + for _, entry := range pluginHooks.Hooks.PreToolUse { + if entry.Matcher != "Bash" { + continue + } + for _, h := range entry.Hooks { + if h.Command != "" { + result.PluginConflicts = append(result.PluginConflicts, path) + return nil // one report per file is enough + } + } + } + return nil + }) + } + + return result, nil +} + +// WrapperScriptPath returns the canonical path for the chop-generated wrapper script. +func WrapperScriptPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".claude", "hooks", "chop-wrapper.sh"), nil +} + +// GenerateConflictFixScript writes a combined Bash PreToolUse wrapper script to +// ~/.claude/hooks/chop-wrapper.sh. The script runs each competing hook first (forwarding +// any denial), then invokes chop for command rewriting. Only settings.json conflicts can +// be auto-fixed; plugin conflicts require manual intervention. +func GenerateConflictFixScript(conflicts ConflictingBashHooks, chopBinPath string) (string, error) { + scriptPath, err := WrapperScriptPath() + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o700); err != nil { + return "", fmt.Errorf("failed to create hooks directory: %w", err) + } + + var sb strings.Builder + sb.WriteString("#!/usr/bin/env bash\n") + sb.WriteString("# Combined Bash PreToolUse hook — generated by chop fix-hooks\n") + sb.WriteString("# Workaround for: https://github.com/anthropics/claude-code/issues/15897\n") + sb.WriteString("INPUT=$(cat)\n\n") + sb.WriteString("_run_hook() {\n") + sb.WriteString(" local out\n") + sb.WriteString(" out=$(printf '%s' \"$INPUT\" | eval \"$1\" 2>/dev/null) || return 0\n") + sb.WriteString(" if printf '%s' \"$out\" | grep -q '\"permissionDecision\":\"deny\"'; then\n") + sb.WriteString(" printf '%s\\n' \"$out\"\n") + sb.WriteString(" exit 0\n") + sb.WriteString(" fi\n") + sb.WriteString("}\n\n") + + if len(conflicts.SettingsConflicts) > 0 { + sb.WriteString("# Competing hooks (run before chop)\n") + for _, cmd := range conflicts.SettingsConflicts { + sb.WriteString(fmt.Sprintf("_run_hook %s\n", shellQuote(cmd))) + } + sb.WriteString("\n") + } + + sb.WriteString("# chop: rewrite supported commands for compression\n") + sb.WriteString(fmt.Sprintf("printf '%%s' \"$INPUT\" | %s hook\n", shellQuote(chopBinPath))) + + if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil { + return "", fmt.Errorf("failed to write wrapper script: %w", err) + } + return scriptPath, nil +} + +// ApplyConflictFix replaces all Bash PreToolUse hooks in settings.json with a single +// wrapper hook pointing to wrapperPath. +func ApplyConflictFix(wrapperPath string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + settingsPath := filepath.Join(home, ".claude", "settings.json") + settings, err := readSettings(settingsPath) + if err != nil { + return err + } + + hooksRaw, ok := settings["hooks"] + if !ok { + hooksRaw = make(map[string]interface{}) + settings["hooks"] = hooksRaw + } + hooksMap, ok := hooksRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("hooks field is not an object in %s", settingsPath) + } + + ptuRaw, ok := hooksMap["PreToolUse"] + if !ok { + ptuRaw = []interface{}{} + } + ptu, ok := ptuRaw.([]interface{}) + if !ok { + return fmt.Errorf("hooks.PreToolUse is not an array in %s", settingsPath) + } + + // Convert path to forward slashes for shell compatibility + wrapperCmd := "bash " + strings.ReplaceAll(wrapperPath, "\\", "/") + + // Replace all Bash matcher entries with a single wrapper entry + newPTU := make([]interface{}, 0, len(ptu)) + bashReplaced := false + for _, entry := range ptu { + m, ok := entry.(map[string]interface{}) + if !ok { + newPTU = append(newPTU, entry) + continue + } + if matcher, _ := m["matcher"].(string); matcher != "Bash" { + newPTU = append(newPTU, entry) + continue + } + if !bashReplaced { + newPTU = append(newPTU, map[string]interface{}{ + "matcher": "Bash", + "hooks": []interface{}{ + map[string]interface{}{"type": "command", "command": wrapperCmd}, + }, + }) + bashReplaced = true + } + // Additional Bash matcher entries are dropped (merged into wrapper) + } + if !bashReplaced { + newPTU = append(newPTU, map[string]interface{}{ + "matcher": "Bash", + "hooks": []interface{}{ + map[string]interface{}{"type": "command", "command": wrapperCmd}, + }, + }) + } + + hooksMap["PreToolUse"] = newPTU + settings["hooks"] = hooksMap + return writeSettings(settingsPath, settings) +} + +// shellQuote wraps a string in single quotes, escaping any single quotes within. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + func installTo(settingsPath string) error { hookCmd, err := buildHookCommand() if err != nil { diff --git a/hooks/install_test.go b/hooks/install_test.go index 4a8c774..25e2e6c 100644 --- a/hooks/install_test.go +++ b/hooks/install_test.go @@ -8,6 +8,19 @@ import ( "testing" ) +// writePluginHooksJSON creates a plugin hooks.json file at the given path with a +// Bash PreToolUse entry using the supplied command string. +func writePluginHooksJSON(t *testing.T, path string, bashCmd string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("failed to create plugin hooks dir: %v", err) + } + content := `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"` + bashCmd + `"}]}]}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write plugin hooks.json: %v", err) + } +} + const testHookCmd = `"/usr/local/bin/chop" hook` func tempSettingsPath(t *testing.T) string { @@ -386,3 +399,138 @@ func TestBuildHookCommandFormat(t *testing.T) { t.Errorf("hook command should not contain backslashes, got %q", cmd) } } + +func TestFindConflictingBashHooks_NoConflicts(t *testing.T) { + home := t.TempDir() + // Install only the chop hook — no conflicts expected. + settingsPath := filepath.Join(home, ".claude", "settings.json") + if err := installWithCommand(settingsPath, testHookCmd); err != nil { + t.Fatalf("installWithCommand failed: %v", err) + } + + conflicts, err := findConflictingBashHooksIn(home) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conflicts.HasConflict() { + t.Errorf("expected no conflicts, got settings=%v plugins=%v", + conflicts.SettingsConflicts, conflicts.PluginConflicts) + } +} + +func TestFindConflictingBashHooks_WrapperScriptNoConflict(t *testing.T) { + home := t.TempDir() + settingsPath := filepath.Join(home, ".claude", "settings.json") + + // User-created chop wrapper (e.g. chop-verify.sh) — should not be reported as a conflict. + settings := map[string]interface{}{ + "hooks": map[string]interface{}{ + "PreToolUse": []interface{}{ + map[string]interface{}{ + "matcher": "Bash", + "hooks": []interface{}{ + map[string]interface{}{"type": "command", "command": "bash ~/.claude/hooks/chop-verify.sh"}, + }, + }, + }, + }, + } + writeJSON(t, settingsPath, settings) + + conflicts, err := findConflictingBashHooksIn(home) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conflicts.HasConflict() { + t.Errorf("expected no conflicts for chop wrapper script, got %+v", conflicts) + } +} + +func TestFindConflictingBashHooks_SettingsConflict(t *testing.T) { + home := t.TempDir() + settingsPath := filepath.Join(home, ".claude", "settings.json") + + // Write settings.json with chop AND a second competing hook in the same Bash matcher. + settings := map[string]interface{}{ + "hooks": map[string]interface{}{ + "PreToolUse": []interface{}{ + map[string]interface{}{ + "matcher": "Bash", + "hooks": []interface{}{ + map[string]interface{}{"type": "command", "command": testHookCmd}, + map[string]interface{}{"type": "command", "command": "bash ~/.claude/hooks/verify-branch.sh"}, + }, + }, + }, + }, + } + writeJSON(t, settingsPath, settings) + + conflicts, err := findConflictingBashHooksIn(home) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conflicts.SettingsConflicts) != 1 { + t.Fatalf("expected 1 settings conflict, got %d: %v", len(conflicts.SettingsConflicts), conflicts.SettingsConflicts) + } + if conflicts.SettingsConflicts[0] != "bash ~/.claude/hooks/verify-branch.sh" { + t.Errorf("unexpected conflict command: %q", conflicts.SettingsConflicts[0]) + } + if len(conflicts.PluginConflicts) != 0 { + t.Errorf("expected no plugin conflicts, got %v", conflicts.PluginConflicts) + } +} + +func TestFindConflictingBashHooks_PluginConflict(t *testing.T) { + home := t.TempDir() + settingsPath := filepath.Join(home, ".claude", "settings.json") + if err := installWithCommand(settingsPath, testHookCmd); err != nil { + t.Fatalf("installWithCommand failed: %v", err) + } + + // Simulate a plugin with a Bash PreToolUse hook. + pluginHooksPath := filepath.Join(home, ".claude", "plugins", "marketplaces", + "dx-claude-code", "plugins", "dx-foundations", "hooks", "hooks.json") + writePluginHooksJSON(t, pluginHooksPath, "/path/to/verify-branch.sh") + + conflicts, err := findConflictingBashHooksIn(home) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conflicts.PluginConflicts) != 1 { + t.Fatalf("expected 1 plugin conflict, got %d: %v", len(conflicts.PluginConflicts), conflicts.PluginConflicts) + } + if !strings.HasSuffix(conflicts.PluginConflicts[0], "hooks.json") { + t.Errorf("expected plugin conflict to be a hooks.json path, got %q", conflicts.PluginConflicts[0]) + } + if len(conflicts.SettingsConflicts) != 0 { + t.Errorf("expected no settings conflicts, got %v", conflicts.SettingsConflicts) + } +} + +func TestFindConflictingBashHooks_EmptyPluginPreToolUse(t *testing.T) { + home := t.TempDir() + settingsPath := filepath.Join(home, ".claude", "settings.json") + if err := installWithCommand(settingsPath, testHookCmd); err != nil { + t.Fatalf("installWithCommand failed: %v", err) + } + + // Plugin with PreToolUse:[] — the fixed state; should not be reported as a conflict. + pluginHooksPath := filepath.Join(home, ".claude", "plugins", "cache", + "dx-claude-code", "dx-foundations", "abc123", "hooks", "hooks.json") + if err := os.MkdirAll(filepath.Dir(pluginHooksPath), 0o700); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(pluginHooksPath, + []byte(`{"hooks":{"PreToolUse":[]}}`), 0o600); err != nil { + t.Fatalf("failed to write plugin hooks.json: %v", err) + } + + conflicts, err := findConflictingBashHooksIn(home) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conflicts.HasConflict() { + t.Errorf("expected no conflicts for empty PreToolUse, got %+v", conflicts) + } +} diff --git a/main.go b/main.go index 6a68bc0..42f553e 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,9 @@ func main() { case "doctor": runDoctor() return + case "fix-hooks": + runFixHooks() + return case "filter": runFilter(os.Args[2:]) return @@ -331,7 +334,26 @@ func main() { func trackSilent(command, raw, filtered string) { rawTokens := tracking.CountTokens(raw) filteredTokens := tracking.CountTokens(filtered) - _ = tracking.Track(command, rawTokens, filteredTokens) + _ = tracking.Track(normalizeCommandForTracking(command), rawTokens, filteredTokens) +} + +// normalizeCommandForTracking strips flags that carry no semantic meaning for +// tracking purposes. Currently handles git's -C flag, which only sets +// the working directory and is transparent to what the command actually does. +func normalizeCommandForTracking(command string) string { + fields := strings.Fields(command) + if len(fields) < 2 || fields[0] != "git" { + return command + } + out := fields[:1:1] // keep "git" + for i := 1; i < len(fields); i++ { + if fields[i] == "-C" { + i++ // skip the path argument too + continue + } + out = append(out, fields[i]) + } + return strings.Join(out, " ") } func runCapture(args []string) { @@ -2010,9 +2032,15 @@ func runDoctor() { // 1. Check if hook is installed installed, _ := hooks.IsInstalled() if !installed { - fmt.Println("[!] hook is not installed") - fmt.Println(" fix: chop init --global") - issues++ + if hooks.HasChopAwareHook() { + // User has a wrapper script (e.g. chop-verify.sh) that invokes chop internally. + // This is a valid non-standard install — do not report as an error. + fmt.Println("[ok] chop wrapper script detected (non-standard install)") + } else { + fmt.Println("[!] hook is not installed") + fmt.Println(" fix: chop init --global") + issues++ + } } else { // 2. Check if hook path matches current binary hookCmd := hooks.GetHookCommand() @@ -2029,7 +2057,31 @@ func runDoctor() { } } - // 3. Check if binary is in legacy ~/bin + // 3. Check for competing Bash PreToolUse hooks (Claude Code bug #15897) + conflicts, err := hooks.FindConflictingBashHooks() + if err == nil && conflicts.HasConflict() { + fmt.Println("[!] competing Bash PreToolUse hooks detected — chop compression will be silently disabled") + fmt.Println(" cause: https://github.com/anthropics/claude-code/issues/15897") + fmt.Println(" when multiple Bash PreToolUse hooks are active, Claude Code drops updatedInput from all of them") + if len(conflicts.SettingsConflicts) > 0 { + fmt.Println(" competing hooks in ~/.claude/settings.json:") + for _, cmd := range conflicts.SettingsConflicts { + fmt.Printf(" - %s\n", cmd) + } + } + if len(conflicts.PluginConflicts) > 0 { + fmt.Println(" competing hooks from plugins:") + for _, path := range conflicts.PluginConflicts { + fmt.Printf(" - %s\n", path) + } + } + fmt.Println(" fix: run `chop fix-hooks` to generate a combined wrapper script automatically") + issues++ + } else if err == nil { + fmt.Println("[ok] no competing Bash PreToolUse hooks") + } + + // 4. Check if binary is in legacy ~/bin exe, err := os.Executable() if err == nil { exe, _ = filepath.EvalSymlinks(exe) @@ -2100,6 +2152,83 @@ func runDoctor() { } } +func runFixHooks() { + conflicts, err := hooks.FindConflictingBashHooks() + if err != nil { + fmt.Fprintf(os.Stderr, "chop: failed to check for conflicts: %v\n", err) + os.Exit(1) + } + + if !conflicts.HasConflict() { + fmt.Println("no hook conflicts detected — nothing to fix") + return + } + + fmt.Println("Competing Bash PreToolUse hooks detected:") + fmt.Println(" cause: https://github.com/anthropics/claude-code/issues/15897") + for _, cmd := range conflicts.SettingsConflicts { + fmt.Printf(" [settings.json] %s\n", cmd) + } + for _, path := range conflicts.PluginConflicts { + fmt.Printf(" [plugin] %s\n", path) + } + fmt.Println() + + if len(conflicts.PluginConflicts) > 0 && len(conflicts.SettingsConflicts) == 0 { + fmt.Println("Plugin conflicts cannot be auto-fixed — chop cannot modify plugin files.") + fmt.Println("Options:") + fmt.Println(" [1] Disable the plugin's Bash PreToolUse hook manually (set PreToolUse: [] in the plugin hooks.json)") + fmt.Println(" [2] Use a wrapper script: merge the plugin's hook logic into ~/.claude/hooks/chop-wrapper.sh manually") + return + } + + fmt.Println("How would you like to fix this?") + fmt.Println(" [1] Standard — install chop directly and let you merge hooks manually") + fmt.Println(" [2] Wrapper — generate ~/.claude/hooks/chop-wrapper.sh combining all hooks (recommended)") + if len(conflicts.PluginConflicts) > 0 { + fmt.Println(" Note: plugin conflicts must still be fixed manually (disable PreToolUse in plugin hooks.json)") + } + fmt.Print("\nEnter choice [1/2]: ") + + var choice string + fmt.Scan(&choice) //nolint:errcheck + + switch strings.TrimSpace(choice) { + case "1": + fmt.Println() + fmt.Println("Standard install selected.") + fmt.Println("chop is already installed. Remove or merge the competing hooks manually, then run `chop doctor` to verify.") + case "2": + chopBin, err := buildExpectedHookCmd() + if err != nil { + fmt.Fprintf(os.Stderr, "chop: failed to determine binary path: %v\n", err) + os.Exit(1) + } + // Extract just the binary path from `"" hook` + chopBinPath := strings.TrimSuffix(strings.Trim(chopBin, `"`), `" hook`) + chopBinPath = strings.TrimSuffix(chopBin, " hook") + chopBinPath = strings.Trim(chopBinPath, `"`) + + scriptPath, err := hooks.GenerateConflictFixScript(conflicts, chopBinPath) + if err != nil { + fmt.Fprintf(os.Stderr, "chop: failed to generate wrapper: %v\n", err) + os.Exit(1) + } + fmt.Printf("generated: %s\n", scriptPath) + + if err := hooks.ApplyConflictFix(scriptPath); err != nil { + fmt.Fprintf(os.Stderr, "chop: failed to update settings.json: %v\n", err) + os.Exit(1) + } + fmt.Println("updated: ~/.claude/settings.json") + fmt.Println() + fmt.Println("Done. Run `chop doctor` to verify the fix.") + default: + fmt.Println("Invalid choice. Run `chop fix-hooks` again.") + os.Exit(1) + } +} + func runCompletion(args []string) { if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: chop completion ") From 09635fd9802381a90635a9d4a1fb7656d1539362 Mon Sep 17 00:00:00 2001 From: Agustin Rodriguez Date: Mon, 13 Apr 2026 10:19:38 -0600 Subject: [PATCH 2/4] fix: add 15 missing commands to hook interception list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit golangci-lint, turbo, poetry, conda, pipenv, snyk, trivy, kustomize, argocd, flux, stern, vault, psql, mysql, sqlite3, glab, and mvnw were registered in the filter registry but absent from supportedCommands — the hook never intercepted them so their filters never ran. --- hooks/hook.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hooks/hook.go b/hooks/hook.go index cb272ac..3f08fb1 100644 --- a/hooks/hook.go +++ b/hooks/hook.go @@ -28,6 +28,11 @@ var supportedCommands = map[string]bool{ "cat": true, "tail": true, "less": true, "more": true, "find": true, "node": true, "node16": true, "node18": true, "node20": true, "node22": true, "acli": true, + // filters added in v1.38.2 — wired to registry but missing from hook interception + "golangci-lint": true, "turbo": true, "poetry": true, "conda": true, "pipenv": true, + "snyk": true, "trivy": true, "kustomize": true, "argocd": true, "flux": true, + "stern": true, "vault": true, "psql": true, "mysql": true, "sqlite3": true, + "glab": true, "mvnw": true, } // shellBuiltins are commands that should never be wrapped. From 8db61d2b3604ba0d9fdddf970b3ad566dcdc19f1 Mon Sep 17 00:00:00 2001 From: Agustin Rodriguez Date: Mon, 13 Apr 2026 10:45:54 -0600 Subject: [PATCH 3/4] feat: add chop unwrap-hooks to reverse fix-hooks When Anthropic resolves claude-code#15897, unwrap-hooks restores the standard direct chop hook and removes chop-wrapper.sh. It parses the wrapper's _run_hook lines and tells the user which plugin hooks to re-enable manually. --- hooks/install.go | 120 +++++++++++++++++++++++++++++++++++++++++++++++ main.go | 31 ++++++++++++ 2 files changed, 151 insertions(+) diff --git a/hooks/install.go b/hooks/install.go index 26ebefb..2940ef8 100644 --- a/hooks/install.go +++ b/hooks/install.go @@ -581,6 +581,126 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } +// ExtractWrappedHooks parses a chop-generated wrapper script and returns the +// competing hook commands that were embedded in it via _run_hook lines. +// Returns nil if the file does not exist or contains no _run_hook lines. +func ExtractWrappedHooks(scriptPath string) ([]string, error) { + data, err := os.ReadFile(scriptPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var cmds []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "_run_hook ") { + continue + } + arg := strings.TrimPrefix(line, "_run_hook ") + // Unquote single-quoted argument (reverse of shellQuote) + if len(arg) >= 2 && arg[0] == '\'' && arg[len(arg)-1] == '\'' { + arg = arg[1 : len(arg)-1] + arg = strings.ReplaceAll(arg, "'\\''", "'") + } + cmds = append(cmds, arg) + } + return cmds, nil +} + +// UnwrapHooks reverses fix-hooks: reinstalls the direct chop hook in settings.json +// and removes chop-wrapper.sh. Returns the list of commands that were in the wrapper +// so the caller can inform the user what to re-enable elsewhere. +func UnwrapHooks(version string) ([]string, error) { + wrapperPath, err := WrapperScriptPath() + if err != nil { + return nil, err + } + + wrapped, err := ExtractWrappedHooks(wrapperPath) + if err != nil { + return nil, fmt.Errorf("failed to read wrapper script: %w", err) + } + + // Remove all chop-aware hooks from settings.json before reinstalling the direct hook. + // installWithCommand only recognises strict chop hooks, so wrapper entries linger + // alongside the new entry unless we remove them first. + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + settingsPath := filepath.Join(home, ".claude", "settings.json") + if err := removeChopAwareHooks(settingsPath); err != nil { + return nil, fmt.Errorf("failed to remove wrapper hook: %w", err) + } + + // Reinstall direct chop hook + Install(version) + + // Remove the wrapper script if it exists + if _, err := os.Stat(wrapperPath); err == nil { + _ = os.Remove(wrapperPath) + } + + return wrapped, nil +} + +// removeChopAwareHooks strips all chop-aware entries (direct or wrapper) from the +// Bash PreToolUse matcher in settings.json. Called before reinstalling the direct hook. +func removeChopAwareHooks(settingsPath string) error { + settings, err := readSettings(settingsPath) + if err != nil { + return err + } + hooksRaw, ok := settings["hooks"] + if !ok { + return nil + } + hooksMap, ok := hooksRaw.(map[string]interface{}) + if !ok { + return nil + } + ptuRaw, ok := hooksMap["PreToolUse"] + if !ok { + return nil + } + ptu, ok := ptuRaw.([]interface{}) + if !ok { + return nil + } + + newPTU := make([]interface{}, 0, len(ptu)) + for _, entry := range ptu { + m, ok := entry.(map[string]interface{}) + if !ok { + newPTU = append(newPTU, entry) + continue + } + if matcher, _ := m["matcher"].(string); matcher != "Bash" { + newPTU = append(newPTU, entry) + continue + } + hooksArrayRaw, _ := m["hooks"].([]interface{}) + newHooks := make([]interface{}, 0, len(hooksArrayRaw)) + for _, h := range hooksArrayRaw { + hMap, ok := h.(map[string]interface{}) + if !ok || isChopAwareHook(hMap) { + continue + } + newHooks = append(newHooks, h) + } + if len(newHooks) > 0 { + m["hooks"] = newHooks + newPTU = append(newPTU, m) + } + } + + hooksMap["PreToolUse"] = newPTU + settings["hooks"] = hooksMap + return writeSettings(settingsPath, settings) +} + func installTo(settingsPath string) error { hookCmd, err := buildHookCommand() if err != nil { diff --git a/main.go b/main.go index 42f553e..30af65f 100644 --- a/main.go +++ b/main.go @@ -137,6 +137,9 @@ func main() { case "fix-hooks": runFixHooks() return + case "unwrap-hooks": + runUnwrapHooks() + return case "filter": runFilter(os.Args[2:]) return @@ -2229,6 +2232,34 @@ func runFixHooks() { } } +func runUnwrapHooks() { + installed, _ := hooks.IsInstalled() + if !installed && !hooks.HasChopAwareHook() { + fmt.Println("no chop hook detected — nothing to unwrap") + return + } + + wrapped, err := hooks.UnwrapHooks(version) + if err != nil { + fmt.Fprintf(os.Stderr, "chop: %v\n", err) + os.Exit(1) + } + + fmt.Println("chop hook restored to standard install") + fmt.Println("chop-wrapper.sh removed") + + if len(wrapped) > 0 { + fmt.Println() + fmt.Println("The following hooks were inside the wrapper — re-enable them manually in their plugin hooks.json:") + for _, cmd := range wrapped { + fmt.Printf(" - %s\n", cmd) + } + } + + fmt.Println() + fmt.Println("Run `chop doctor` to verify.") +} + func runCompletion(args []string) { if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: chop completion ") From 6112d3fa61da8d99b8435ddff516f146338a6f6a Mon Sep 17 00:00:00 2001 From: Agustin Rodriguez Date: Mon, 13 Apr 2026 10:49:29 -0600 Subject: [PATCH 4/4] fix: remove internal plugin names from test fixtures --- hooks/install_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/install_test.go b/hooks/install_test.go index 25e2e6c..499f350 100644 --- a/hooks/install_test.go +++ b/hooks/install_test.go @@ -490,8 +490,8 @@ func TestFindConflictingBashHooks_PluginConflict(t *testing.T) { // Simulate a plugin with a Bash PreToolUse hook. pluginHooksPath := filepath.Join(home, ".claude", "plugins", "marketplaces", - "dx-claude-code", "plugins", "dx-foundations", "hooks", "hooks.json") - writePluginHooksJSON(t, pluginHooksPath, "/path/to/verify-branch.sh") + "some-marketplace", "plugins", "some-plugin", "hooks", "hooks.json") + writePluginHooksJSON(t, pluginHooksPath, "/path/to/competing-hook.sh") conflicts, err := findConflictingBashHooksIn(home) if err != nil { @@ -517,7 +517,7 @@ func TestFindConflictingBashHooks_EmptyPluginPreToolUse(t *testing.T) { // Plugin with PreToolUse:[] — the fixed state; should not be reported as a conflict. pluginHooksPath := filepath.Join(home, ".claude", "plugins", "cache", - "dx-claude-code", "dx-foundations", "abc123", "hooks", "hooks.json") + "some-marketplace", "some-plugin", "hash1234", "hooks", "hooks.json") if err := os.MkdirAll(filepath.Dir(pluginHooksPath), 0o700); err != nil { t.Fatalf("failed to create dir: %v", err) }