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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user