From 12e0caf90ee3a146a1e408e270d9a90fa9c78970 Mon Sep 17 00:00:00 2001 From: James MacAulay Date: Wed, 14 Jan 2026 23:51:16 -0500 Subject: [PATCH] fix(doctor): detect plugin and hooks in project-level settings (#1091) * fix(doctor): detect beads plugin in project-level settings isBeadsPluginInstalled() now checks project-level settings files (.claude/settings.json and .claude/settings.local.json) in addition to user-level settings (~/.claude/settings.json). This fixes the contradictory bd doctor output where the plugin check passes but the integration check warns "Not configured" when the plugin is enabled at project scope. Fixes #1090 Co-Authored-By: Claude Opus 4.5 * fix(doctor): detect Claude hooks in project-level settings.json hasClaudeHooks() was missing .claude/settings.json - it only checked .claude/settings.local.json for project-level hooks. Now checks all three locations: - ~/.claude/settings.json (user-level) - .claude/settings.json (project-level) - .claude/settings.local.json (project-level, gitignored) Also uses filepath.Join consistently for cross-platform compatibility. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- cmd/bd/doctor/claude.go | 37 ++++++++-- cmd/bd/doctor/claude_test.go | 127 +++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 6 deletions(-) diff --git a/cmd/bd/doctor/claude.go b/cmd/bd/doctor/claude.go index 5327162a..f3e47d22 100644 --- a/cmd/bd/doctor/claude.go +++ b/cmd/bd/doctor/claude.go @@ -81,15 +81,39 @@ func CheckClaude() DoctorCheck { } } -// isBeadsPluginInstalled checks if beads plugin is enabled in Claude Code +// isBeadsPluginInstalled checks if beads plugin is enabled in Claude Code. +// It checks user-level (~/.claude/settings.json) and project-level settings +// (.claude/settings.json and .claude/settings.local.json). func isBeadsPluginInstalled() bool { home, err := os.UserHomeDir() if err != nil { return false } - settingsPath := filepath.Join(home, ".claude/settings.json") - data, err := os.ReadFile(settingsPath) // #nosec G304 -- settingsPath is constructed from user home dir, not user input + // Check user-level settings + userSettings := filepath.Join(home, ".claude", "settings.json") + if checkPluginInSettings(userSettings) { + return true + } + + // Check project-level settings + projectSettings := filepath.Join(".claude", "settings.json") + if checkPluginInSettings(projectSettings) { + return true + } + + // Check project-level local settings (gitignored) + projectLocalSettings := filepath.Join(".claude", "settings.local.json") + if checkPluginInSettings(projectLocalSettings) { + return true + } + + return false +} + +// checkPluginInSettings checks if beads plugin is enabled in a settings file +func checkPluginInSettings(settingsPath string) bool { + data, err := os.ReadFile(settingsPath) // #nosec G304 -- settingsPath is constructed from known safe locations, not user input if err != nil { return false } @@ -159,10 +183,11 @@ func hasClaudeHooks() bool { return false } - globalSettings := filepath.Join(home, ".claude/settings.json") - projectSettings := ".claude/settings.local.json" + globalSettings := filepath.Join(home, ".claude", "settings.json") + projectSettings := filepath.Join(".claude", "settings.json") + projectLocalSettings := filepath.Join(".claude", "settings.local.json") - return hasBeadsHooks(globalSettings) || hasBeadsHooks(projectSettings) + return hasBeadsHooks(globalSettings) || hasBeadsHooks(projectSettings) || hasBeadsHooks(projectLocalSettings) } // hasBeadsHooks checks if a settings file has bd prime hooks diff --git a/cmd/bd/doctor/claude_test.go b/cmd/bd/doctor/claude_test.go index 582152f1..2ef0967e 100644 --- a/cmd/bd/doctor/claude_test.go +++ b/cmd/bd/doctor/claude_test.go @@ -179,6 +179,63 @@ func TestIsBeadsPluginInstalled(t *testing.T) { } } +func TestIsBeadsPluginInstalledProjectLevel(t *testing.T) { + // Test that plugin is detected in each project-level settings file + for _, filename := range []string{"settings.json", "settings.local.json"} { + t.Run(filename, func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatal(err) + } + content := `{"enabledPlugins":{"beads@beads-marketplace":true}}` + if err := os.WriteFile(filepath.Join(".claude", filename), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if !isBeadsPluginInstalled() { + t.Errorf("expected to detect plugin in .claude/%s", filename) + } + }) + } + + // Test negative cases - plugin should NOT be detected + t.Run("plugin disabled", func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatal(err) + } + content := `{"enabledPlugins":{"beads@beads-marketplace":false}}` + if err := os.WriteFile(filepath.Join(".claude", "settings.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if isBeadsPluginInstalled() { + t.Error("expected NOT to detect plugin when explicitly disabled") + } + }) + + t.Run("no plugin section", func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatal(err) + } + content := `{"hooks":{}}` + if err := os.WriteFile(filepath.Join(".claude", "settings.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if isBeadsPluginInstalled() { + t.Error("expected NOT to detect plugin when enabledPlugins section missing") + } + }) +} + func TestHasClaudeHooks(t *testing.T) { // Sanity check for hooks detection result := hasClaudeHooks() @@ -189,6 +246,76 @@ func TestHasClaudeHooks(t *testing.T) { } } +func TestHasClaudeHooksProjectLevel(t *testing.T) { + hooksContent := `{ + "hooks": { + "SessionStart": [ + {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]} + ] + } + }` + + // Test that hooks are detected in each project-level settings file + for _, filename := range []string{"settings.json", "settings.local.json"} { + t.Run(filename, func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".claude", filename), []byte(hooksContent), 0o644); err != nil { + t.Fatal(err) + } + + if !hasClaudeHooks() { + t.Errorf("expected to detect hooks in .claude/%s", filename) + } + }) + } + + // Test negative cases + t.Run("no hooks section", func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatal(err) + } + content := `{"enabledPlugins":{}}` + if err := os.WriteFile(filepath.Join(".claude", "settings.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if hasClaudeHooks() { + t.Error("expected NOT to detect hooks when hooks section missing") + } + }) + + t.Run("hooks but not bd prime", func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatal(err) + } + content := `{ + "hooks": { + "SessionStart": [ + {"matcher": "", "hooks": [{"type": "command", "command": "echo hello"}]} + ] + } + }` + if err := os.WriteFile(filepath.Join(".claude", "settings.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if hasClaudeHooks() { + t.Error("expected NOT to detect hooks when bd prime not present") + } + }) +} + func TestCheckClaude(t *testing.T) { // Verify CheckClaude returns a valid DoctorCheck check := CheckClaude()