diff --git a/cmd/bd/setup/claude.go b/cmd/bd/setup/claude.go index cbf370794f..9378d6af0d 100644 --- a/cmd/bd/setup/claude.go +++ b/cmd/bd/setup/claude.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/steveyegge/beads/internal/templates/agents" ) @@ -141,11 +142,19 @@ func installClaude(env claudeEnv, global bool, stealth bool) error { command = "bd prime --stealth" } - if addHookCommand(hooks, "SessionStart", command) { - _, _ = fmt.Fprintln(env.stdout, "✓ Registered SessionStart hook") - } - if addHookCommand(hooks, "PreCompact", command) { - _, _ = fmt.Fprintln(env.stdout, "✓ Registered PreCompact hook") + // GH#3192: Skip writing hooks if the beads plugin is already providing them. + // The plugin declares identical SessionStart/PreCompact hooks in plugin.json, + // so project-level hooks would fire bd prime twice per session. + pluginManaged := hasBeadsPlugin(env) + if pluginManaged { + _, _ = fmt.Fprintln(env.stdout, "✓ Beads plugin detected — hooks are plugin-managed, skipping") + } else { + if addHookCommand(hooks, "SessionStart", command) { + _, _ = fmt.Fprintln(env.stdout, "✓ Registered SessionStart hook") + } + if addHookCommand(hooks, "PreCompact", command) { + _, _ = fmt.Fprintln(env.stdout, "✓ Registered PreCompact hook") + } } data, err := json.MarshalIndent(settings, "", " ") @@ -221,6 +230,9 @@ func checkClaude(env claudeEnv) error { case hasBeadsHooks(legacySettings): _, _ = fmt.Fprintf(env.stdout, "✓ Project hooks installed (legacy): %s\n", legacySettings) _, _ = fmt.Fprintf(env.stdout, " Consider running 'bd setup claude' to migrate to .claude/settings.json\n") + case hasBeadsPlugin(env): + // GH#3192: Plugin provides hooks via plugin.json — no project-level hooks needed + _, _ = fmt.Fprintln(env.stdout, "✓ Hooks provided by beads plugin (plugin-managed)") default: _, _ = fmt.Fprintln(env.stdout, "✗ No hooks installed") _, _ = fmt.Fprintln(env.stdout, " Run: bd setup claude") @@ -411,6 +423,47 @@ func removeHookCommand(hooks map[string]interface{}, event, command string) { } } +// hasBeadsPlugin checks if the beads Claude Code plugin is enabled in any +// settings file. The plugin declares its own SessionStart/PreCompact hooks +// in plugin.json, so project-level hooks from bd setup claude would duplicate them. +func hasBeadsPlugin(env claudeEnv) bool { + paths := []string{ + projectSettingsPath(env.projectDir), + globalSettingsPath(env.homeDir), + legacyProjectSettingsPath(env.projectDir), + } + for _, p := range paths { + if checkBeadsPluginInFile(env.readFile, p) { + return true + } + } + return false +} + +// checkBeadsPluginInFile checks if the beads plugin is enabled in a single settings file. +func checkBeadsPluginInFile(readFile func(string) ([]byte, error), path string) bool { + data, err := readFile(path) + if err != nil { + return false + } + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + return false + } + enabledPlugins, ok := settings["enabledPlugins"].(map[string]interface{}) + if !ok { + return false + } + for key, value := range enabledPlugins { + if strings.Contains(strings.ToLower(key), "beads") { + if enabled, ok := value.(bool); ok && enabled { + return true + } + } + } + return false +} + // hasBeadsHooks checks if a settings file has bd prime hooks func hasBeadsHooks(settingsPath string) bool { data, err := os.ReadFile(settingsPath) // #nosec G304 -- settingsPath is constructed from known safe locations (user home/.claude), not user input diff --git a/cmd/bd/setup/claude_test.go b/cmd/bd/setup/claude_test.go index 8fdd29b207..7a2277ffa4 100644 --- a/cmd/bd/setup/claude_test.go +++ b/cmd/bd/setup/claude_test.go @@ -847,3 +847,131 @@ func TestClaudeWrappersExit(t *testing.T) { } }) } + +// settingsWithPlugin returns settings data with the beads plugin enabled. +func settingsWithPlugin() map[string]interface{} { + return map[string]interface{}{ + "enabledPlugins": map[string]interface{}{ + "beads@beads-marketplace": true, + }, + } +} + +func TestHasBeadsPlugin(t *testing.T) { + t.Run("plugin in project settings", func(t *testing.T) { + env, _, _ := newClaudeTestEnv(t) + writeSettings(t, projectSettingsPath(env.projectDir), settingsWithPlugin()) + if !hasBeadsPlugin(env) { + t.Error("expected plugin to be detected in project settings") + } + }) + + t.Run("plugin in global settings", func(t *testing.T) { + env, _, _ := newClaudeTestEnv(t) + writeSettings(t, globalSettingsPath(env.homeDir), settingsWithPlugin()) + if !hasBeadsPlugin(env) { + t.Error("expected plugin to be detected in global settings") + } + }) + + t.Run("plugin disabled", func(t *testing.T) { + env, _, _ := newClaudeTestEnv(t) + writeSettings(t, projectSettingsPath(env.projectDir), map[string]interface{}{ + "enabledPlugins": map[string]interface{}{ + "beads@beads-marketplace": false, + }, + }) + if hasBeadsPlugin(env) { + t.Error("disabled plugin should not be detected") + } + }) + + t.Run("no plugin", func(t *testing.T) { + env, _, _ := newClaudeTestEnv(t) + if hasBeadsPlugin(env) { + t.Error("expected no plugin detected") + } + }) +} + +func TestInstallClaudeSkipsHooksWhenPluginPresent(t *testing.T) { + env, stdout, _ := newClaudeTestEnv(t) + + // Pre-populate project settings with the plugin enabled + writeSettings(t, projectSettingsPath(env.projectDir), settingsWithPlugin()) + + if err := installClaude(env, false, false); err != nil { + t.Fatalf("installClaude: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "plugin-managed") { + t.Error("expected plugin-managed message in output") + } + if strings.Contains(out, "Registered SessionStart hook") { + t.Error("should NOT register hooks when plugin is present") + } + + // Verify settings file has no hooks written + data, err := env.readFile(projectSettingsPath(env.projectDir)) + if err != nil { + t.Fatalf("read settings: %v", err) + } + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("parse settings: %v", err) + } + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks != nil { + if _, hasSession := hooks["SessionStart"]; hasSession { + t.Error("SessionStart hooks should not be written when plugin is present") + } + if _, hasCompact := hooks["PreCompact"]; hasCompact { + t.Error("PreCompact hooks should not be written when plugin is present") + } + } + + // CLAUDE.md should still be installed + instructionsPath := filepath.Join(env.projectDir, claudeInstructionsFile) + if _, err := os.Stat(instructionsPath); err != nil { + t.Errorf("CLAUDE.md should still be installed even with plugin: %v", err) + } +} + +func TestInstallClaudeWritesHooksWithoutPlugin(t *testing.T) { + env, stdout, _ := newClaudeTestEnv(t) + + if err := installClaude(env, false, false); err != nil { + t.Fatalf("installClaude: %v", err) + } + + out := stdout.String() + if strings.Contains(out, "plugin-managed") { + t.Error("should NOT show plugin-managed when no plugin") + } + if !strings.Contains(out, "Registered SessionStart hook") { + t.Error("expected hooks to be registered without plugin") + } +} + +func TestCheckClaudePluginManaged(t *testing.T) { + env, stdout, _ := newClaudeTestEnv(t) + + // Plugin enabled but no hooks in settings files + writeSettings(t, globalSettingsPath(env.homeDir), settingsWithPlugin()) + + // checkClaude needs CLAUDE.md to exist for the agents check + instructionsPath := filepath.Join(env.projectDir, claudeInstructionsFile) + if err := os.WriteFile(instructionsPath, []byte(agents.RenderSection(agents.ProfileMinimal)), 0o644); err != nil { + t.Fatalf("write CLAUDE.md: %v", err) + } + + if err := checkClaude(env); err != nil { + t.Fatalf("checkClaude: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "plugin-managed") { + t.Errorf("expected plugin-managed message, got: %s", out) + } +}