Files
beads/cmd/bd/doctor/claude.go
Steve Yegge 6789e4fd9a feat: add hooks to plugin.json, eliminating need for global install
The beads plugin now provides SessionStart and PreCompact hooks directly
via plugin.json. Users no longer need to run 'bd setup claude' when using
the plugin - hooks are automatically available.

Changes:
- Add hooks section to .claude-plugin/plugin.json with SessionStart and
  PreCompact hooks that run 'bd prime'
- Update doctor/claude.go to recognize plugin-provided hooks as valid
- Update tips.go to check for plugin installation when determining if
  Claude integration is complete
- Update messaging to recommend plugin installation as primary option

The 'bd setup claude' command remains available for CLI-only users who
do not want to install the plugin.

Closes #462

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 22:44:42 -08:00

349 lines
9.9 KiB
Go

package doctor
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
)
// DoctorCheck represents a single diagnostic check result
type DoctorCheck struct {
Name string `json:"name"`
Status string `json:"status"` // "ok", "warning", or "error"
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
Fix string `json:"fix,omitempty"`
}
// CheckClaude returns Claude integration verification as a DoctorCheck
func CheckClaude() DoctorCheck {
// Check what's installed
hasPlugin := isBeadsPluginInstalled()
hasMCP := isMCPServerInstalled()
hasHooks := hasClaudeHooks()
// Plugin now provides hooks directly via plugin.json, so if plugin is installed
// we consider hooks to be available (plugin hooks + any user-configured hooks)
if hasPlugin {
return DoctorCheck{
Name: "Claude Integration",
Status: "ok",
Message: "Plugin installed",
Detail: "Slash commands and workflow hooks enabled via plugin",
}
} else if hasMCP && hasHooks {
return DoctorCheck{
Name: "Claude Integration",
Status: "ok",
Message: "MCP server and hooks installed",
Detail: "Workflow reminders enabled (legacy MCP mode)",
}
} else if !hasMCP && !hasPlugin && hasHooks {
return DoctorCheck{
Name: "Claude Integration",
Status: "ok",
Message: "Hooks installed (CLI mode)",
Detail: "Plugin not detected - install for slash commands",
}
} else if hasMCP && !hasHooks {
return DoctorCheck{
Name: "Claude Integration",
Status: "warning",
Message: "MCP server installed but hooks missing",
Detail: "MCP-only mode: relies on tools for every query (~10.5k tokens)\n" +
" bd prime hooks provide much better token efficiency",
Fix: "Add bd prime hooks for better token efficiency:\n" +
" 1. Run 'bd setup claude' to add SessionStart/PreCompact hooks\n" +
"\n" +
"Benefits:\n" +
" • MCP mode: ~50 tokens vs ~10.5k for full tool scan (99% reduction)\n" +
" • Automatic context refresh on session start and compaction\n" +
" • Works alongside MCP tools for when you need them\n" +
"\n" +
"See: bd setup claude --help",
}
} else {
return DoctorCheck{
Name: "Claude Integration",
Status: "warning",
Message: "Not configured",
Detail: "Claude can use bd more effectively with the beads plugin",
Fix: "Set up Claude integration:\n" +
" Option 1: Install the beads plugin (recommended)\n" +
" • Provides hooks, slash commands, and MCP tools automatically\n" +
" • See: https://github.com/steveyegge/beads#claude-code-plugin\n" +
"\n" +
" Option 2: CLI-only mode\n" +
" • Run 'bd setup claude' to add SessionStart/PreCompact hooks\n" +
" • No slash commands, but hooks provide workflow context\n" +
"\n" +
"Benefits:\n" +
" • Auto-inject workflow context on session start (~50-2k tokens)\n" +
" • Automatic context recovery before compaction",
}
}
}
// isBeadsPluginInstalled checks if beads plugin is enabled in Claude Code
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
if err != nil {
return false
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return false
}
// Check enabledPlugins section for beads
enabledPlugins, ok := settings["enabledPlugins"].(map[string]interface{})
if !ok {
return false
}
// Look for beads@beads-marketplace plugin
for key, value := range enabledPlugins {
if strings.Contains(strings.ToLower(key), "beads") {
// Check if it's enabled (value should be true)
if enabled, ok := value.(bool); ok && enabled {
return true
}
}
}
return false
}
// isMCPServerInstalled checks if MCP server is configured
func isMCPServerInstalled() 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
if err != nil {
return false
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return false
}
// Check mcpServers section for beads
mcpServers, ok := settings["mcpServers"].(map[string]interface{})
if !ok {
return false
}
// Look for beads server (any key containing "beads")
for key := range mcpServers {
if strings.Contains(strings.ToLower(key), "beads") {
return true
}
}
return false
}
// hasClaudeHooks checks if Claude hooks are installed
func hasClaudeHooks() bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
globalSettings := filepath.Join(home, ".claude/settings.json")
projectSettings := ".claude/settings.local.json"
return hasBeadsHooks(globalSettings) || hasBeadsHooks(projectSettings)
}
// 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
if err != nil {
return false
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return false
}
hooks, ok := settings["hooks"].(map[string]interface{})
if !ok {
return false
}
// Check SessionStart and PreCompact for "bd prime"
for _, event := range []string{"SessionStart", "PreCompact"} {
eventHooks, ok := hooks[event].([]interface{})
if !ok {
continue
}
for _, hook := range eventHooks {
hookMap, ok := hook.(map[string]interface{})
if !ok {
continue
}
commands, ok := hookMap["hooks"].([]interface{})
if !ok {
continue
}
for _, cmd := range commands {
cmdMap, ok := cmd.(map[string]interface{})
if !ok {
continue
}
if cmdMap["command"] == "bd prime" {
return true
}
}
}
}
return false
}
// verifyPrimeOutput checks if bd prime command works and adapts correctly
// Returns a check result
func VerifyPrimeOutput() DoctorCheck {
cmd := exec.Command("bd", "prime")
output, err := cmd.CombinedOutput()
if err != nil {
return DoctorCheck{
Name: "bd prime Command",
Status: "error",
Message: "Command failed to execute",
Fix: "Ensure bd is installed and in PATH",
}
}
if len(output) == 0 {
return DoctorCheck{
Name: "bd prime Command",
Status: "error",
Message: "No output produced",
Detail: "Expected workflow context markdown",
}
}
// Check if output adapts to MCP mode
hasMCP := isMCPServerInstalled()
outputStr := string(output)
if hasMCP && strings.Contains(outputStr, "mcp__plugin_beads_beads__") {
return DoctorCheck{
Name: "bd prime Output",
Status: "ok",
Message: "MCP mode detected",
Detail: "Outputting workflow reminders",
}
} else if !hasMCP && strings.Contains(outputStr, "bd ready") {
return DoctorCheck{
Name: "bd prime Output",
Status: "ok",
Message: "CLI mode detected",
Detail: "Outputting full command reference",
}
} else {
return DoctorCheck{
Name: "bd prime Output",
Status: "warning",
Message: "Output may not be adapting to environment",
}
}
}
// CheckBdInPath verifies that 'bd' command is available in PATH.
// This is important because Claude hooks rely on executing 'bd prime'.
func CheckBdInPath() DoctorCheck {
_, err := exec.LookPath("bd")
if err != nil {
return DoctorCheck{
Name: "bd in PATH",
Status: "warning",
Message: "'bd' command not found in PATH",
Detail: "Claude hooks execute 'bd prime' and won't work without bd in PATH",
Fix: "Install bd globally:\n" +
" • Homebrew: brew install steveyegge/tap/bd\n" +
" • Script: curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash\n" +
" • Or add bd to your PATH",
}
}
return DoctorCheck{
Name: "bd in PATH",
Status: "ok",
Message: "'bd' command available",
}
}
// CheckDocumentationBdPrimeReference checks if AGENTS.md or CLAUDE.md reference 'bd prime'
// and verifies the command exists. This helps catch version mismatches where docs
// reference features not available in the installed version.
func CheckDocumentationBdPrimeReference(repoPath string) DoctorCheck {
docFiles := []string{
filepath.Join(repoPath, "AGENTS.md"),
filepath.Join(repoPath, "CLAUDE.md"),
filepath.Join(repoPath, ".claude", "CLAUDE.md"),
}
var filesWithBdPrime []string
for _, docFile := range docFiles {
content, err := os.ReadFile(docFile) // #nosec G304 - controlled paths from repoPath
if err != nil {
continue
}
if strings.Contains(string(content), "bd prime") {
filesWithBdPrime = append(filesWithBdPrime, filepath.Base(docFile))
}
}
// If no docs reference bd prime, that's fine - not everyone uses it
if len(filesWithBdPrime) == 0 {
return DoctorCheck{
Name: "Documentation bd prime",
Status: "ok",
Message: "No bd prime references in documentation",
}
}
// Docs reference bd prime - verify the command works
cmd := exec.Command("bd", "prime", "--help")
if err := cmd.Run(); err != nil {
return DoctorCheck{
Name: "Documentation bd prime",
Status: "warning",
Message: "Documentation references 'bd prime' but command not found",
Detail: "Files: " + strings.Join(filesWithBdPrime, ", "),
Fix: "Upgrade bd to get the 'bd prime' command:\n" +
" • Homebrew: brew upgrade bd\n" +
" • Script: curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash\n" +
" Or remove 'bd prime' references from documentation if using older version",
}
}
return DoctorCheck{
Name: "Documentation bd prime",
Status: "ok",
Message: "Documentation references match installed features",
Detail: "Files: " + strings.Join(filesWithBdPrime, ", "),
}
}