When removing all hooks from an event, the key was being set to null instead of being deleted. Claude Code expects arrays, not null values, causing startup failures with 'Expected array, but received null'. Changes: - removeHookCommand now deletes the event key when no hooks remain - installClaude cleans up any existing null values from buggy removal - Added tests for null value prevention and cleanup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
375 lines
9.3 KiB
Go
375 lines
9.3 KiB
Go
package setup
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
var (
|
|
claudeEnvProvider = defaultClaudeEnv
|
|
errClaudeHooksMissing = errors.New("claude hooks not installed")
|
|
)
|
|
|
|
type claudeEnv struct {
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
homeDir string
|
|
projectDir string
|
|
ensureDir func(string, os.FileMode) error
|
|
readFile func(string) ([]byte, error)
|
|
writeFile func(string, []byte) error
|
|
}
|
|
|
|
func defaultClaudeEnv() (claudeEnv, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return claudeEnv{}, fmt.Errorf("home directory: %w", err)
|
|
}
|
|
workDir, err := os.Getwd()
|
|
if err != nil {
|
|
return claudeEnv{}, fmt.Errorf("working directory: %w", err)
|
|
}
|
|
return claudeEnv{
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
homeDir: home,
|
|
projectDir: workDir,
|
|
ensureDir: EnsureDir,
|
|
readFile: os.ReadFile,
|
|
writeFile: func(path string, data []byte) error {
|
|
return atomicWriteFile(path, data)
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func projectSettingsPath(base string) string {
|
|
return filepath.Join(base, ".claude", "settings.local.json")
|
|
}
|
|
|
|
func globalSettingsPath(home string) string {
|
|
return filepath.Join(home, ".claude", "settings.json")
|
|
}
|
|
|
|
// InstallClaude installs Claude Code hooks
|
|
func InstallClaude(project bool, stealth bool) {
|
|
env, err := claudeEnvProvider()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
setupExit(1)
|
|
return
|
|
}
|
|
if err := installClaude(env, project, stealth); err != nil {
|
|
setupExit(1)
|
|
}
|
|
}
|
|
|
|
func installClaude(env claudeEnv, project bool, stealth bool) error {
|
|
var settingsPath string
|
|
if project {
|
|
settingsPath = projectSettingsPath(env.projectDir)
|
|
_, _ = fmt.Fprintln(env.stdout, "Installing Claude hooks for this project...")
|
|
} else {
|
|
settingsPath = globalSettingsPath(env.homeDir)
|
|
_, _ = fmt.Fprintln(env.stdout, "Installing Claude hooks globally...")
|
|
}
|
|
|
|
if err := env.ensureDir(filepath.Dir(settingsPath), 0o755); err != nil {
|
|
_, _ = fmt.Fprintf(env.stderr, "Error: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
settings := make(map[string]interface{})
|
|
if data, err := env.readFile(settingsPath); err == nil {
|
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
_, _ = fmt.Fprintf(env.stderr, "Error: failed to parse settings.json: %v\n", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
hooks, ok := settings["hooks"].(map[string]interface{})
|
|
if !ok {
|
|
hooks = make(map[string]interface{})
|
|
settings["hooks"] = hooks
|
|
}
|
|
|
|
// GH#955: Clean up any null values left by previous buggy removal
|
|
// Claude Code expects arrays, not null values
|
|
for key, val := range hooks {
|
|
if val == nil {
|
|
delete(hooks, key)
|
|
}
|
|
}
|
|
|
|
command := "bd prime"
|
|
if stealth {
|
|
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")
|
|
}
|
|
|
|
data, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(env.stderr, "Error: marshal settings: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
if err := env.writeFile(settingsPath, data); err != nil {
|
|
_, _ = fmt.Fprintf(env.stderr, "Error: write settings: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(env.stdout, "\n✓ Claude Code integration installed")
|
|
_, _ = fmt.Fprintf(env.stdout, " Settings: %s\n", settingsPath)
|
|
_, _ = fmt.Fprintln(env.stdout, "\nRestart Claude Code for changes to take effect.")
|
|
return nil
|
|
}
|
|
|
|
// CheckClaude checks if Claude integration is installed
|
|
func CheckClaude() {
|
|
env, err := claudeEnvProvider()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
setupExit(1)
|
|
return
|
|
}
|
|
if err := checkClaude(env); err != nil {
|
|
setupExit(1)
|
|
}
|
|
}
|
|
|
|
func checkClaude(env claudeEnv) error {
|
|
globalSettings := globalSettingsPath(env.homeDir)
|
|
projectSettings := projectSettingsPath(env.projectDir)
|
|
|
|
switch {
|
|
case hasBeadsHooks(globalSettings):
|
|
_, _ = fmt.Fprintf(env.stdout, "✓ Global hooks installed: %s\n", globalSettings)
|
|
return nil
|
|
case hasBeadsHooks(projectSettings):
|
|
_, _ = fmt.Fprintf(env.stdout, "✓ Project hooks installed: %s\n", projectSettings)
|
|
return nil
|
|
default:
|
|
_, _ = fmt.Fprintln(env.stdout, "✗ No hooks installed")
|
|
_, _ = fmt.Fprintln(env.stdout, " Run: bd setup claude")
|
|
return errClaudeHooksMissing
|
|
}
|
|
}
|
|
|
|
// RemoveClaude removes Claude Code hooks
|
|
func RemoveClaude(project bool) {
|
|
env, err := claudeEnvProvider()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
setupExit(1)
|
|
return
|
|
}
|
|
if err := removeClaude(env, project); err != nil {
|
|
setupExit(1)
|
|
}
|
|
}
|
|
|
|
func removeClaude(env claudeEnv, project bool) error {
|
|
var settingsPath string
|
|
if project {
|
|
settingsPath = projectSettingsPath(env.projectDir)
|
|
_, _ = fmt.Fprintln(env.stdout, "Removing Claude hooks from project...")
|
|
} else {
|
|
settingsPath = globalSettingsPath(env.homeDir)
|
|
_, _ = fmt.Fprintln(env.stdout, "Removing Claude hooks globally...")
|
|
}
|
|
|
|
data, err := env.readFile(settingsPath)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(env.stdout, "No settings file found")
|
|
return nil
|
|
}
|
|
|
|
var settings map[string]interface{}
|
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
_, _ = fmt.Fprintf(env.stderr, "Error: failed to parse settings.json: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
hooks, ok := settings["hooks"].(map[string]interface{})
|
|
if !ok {
|
|
_, _ = fmt.Fprintln(env.stdout, "No hooks found")
|
|
return nil
|
|
}
|
|
|
|
removeHookCommand(hooks, "SessionStart", "bd prime")
|
|
removeHookCommand(hooks, "PreCompact", "bd prime")
|
|
removeHookCommand(hooks, "SessionStart", "bd prime --stealth")
|
|
removeHookCommand(hooks, "PreCompact", "bd prime --stealth")
|
|
|
|
data, err = json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(env.stderr, "Error: marshal settings: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
if err := env.writeFile(settingsPath, data); err != nil {
|
|
_, _ = fmt.Fprintf(env.stderr, "Error: write settings: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(env.stdout, "✓ Claude hooks removed")
|
|
return nil
|
|
}
|
|
|
|
// addHookCommand adds a hook command to an event if not already present
|
|
// Returns true if hook was added, false if already exists
|
|
func addHookCommand(hooks map[string]interface{}, event, command string) bool {
|
|
// Get or create event array
|
|
eventHooks, ok := hooks[event].([]interface{})
|
|
if !ok {
|
|
eventHooks = []interface{}{}
|
|
}
|
|
|
|
// Check if bd hook already registered
|
|
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"] == command {
|
|
fmt.Printf("✓ Hook already registered: %s\n", event)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add bd hook to array
|
|
newHook := map[string]interface{}{
|
|
"matcher": "",
|
|
"hooks": []interface{}{
|
|
map[string]interface{}{
|
|
"type": "command",
|
|
"command": command,
|
|
},
|
|
},
|
|
}
|
|
|
|
eventHooks = append(eventHooks, newHook)
|
|
hooks[event] = eventHooks
|
|
return true
|
|
}
|
|
|
|
// removeHookCommand removes a hook command from an event
|
|
func removeHookCommand(hooks map[string]interface{}, event, command string) {
|
|
eventHooks, ok := hooks[event].([]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Filter out bd prime hooks
|
|
// Initialize as empty slice (not nil) to avoid JSON null serialization
|
|
filtered := make([]interface{}, 0, len(eventHooks))
|
|
for _, hook := range eventHooks {
|
|
hookMap, ok := hook.(map[string]interface{})
|
|
if !ok {
|
|
filtered = append(filtered, hook)
|
|
continue
|
|
}
|
|
|
|
commands, ok := hookMap["hooks"].([]interface{})
|
|
if !ok {
|
|
filtered = append(filtered, hook)
|
|
continue
|
|
}
|
|
|
|
keepHook := true
|
|
for _, cmd := range commands {
|
|
cmdMap, ok := cmd.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cmdMap["command"] == command {
|
|
keepHook = false
|
|
fmt.Printf("✓ Removed %s hook\n", event)
|
|
break
|
|
}
|
|
}
|
|
|
|
if keepHook {
|
|
filtered = append(filtered, hook)
|
|
}
|
|
}
|
|
|
|
// GH#955: Delete the key entirely if no hooks remain, rather than
|
|
// leaving an empty array. This is cleaner and avoids potential
|
|
// issues with empty arrays in settings.
|
|
if len(filtered) == 0 {
|
|
delete(hooks, event)
|
|
} else {
|
|
hooks[event] = filtered
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Check for either variant
|
|
cmd := cmdMap["command"]
|
|
if cmd == "bd prime" || cmd == "bd prime --stealth" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|