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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James MacAulay
2026-01-14 23:51:16 -05:00
committed by GitHub
parent 31239495f1
commit 12e0caf90e
2 changed files with 158 additions and 6 deletions

View File

@@ -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

View File

@@ -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()