Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 58 additions & 5 deletions cmd/bd/setup/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
"strings"

"github.com/steveyegge/beads/internal/templates/agents"
)
Expand Down Expand Up @@ -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, "", " ")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions cmd/bd/setup/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading