fix(setup): avoid null values in Claude settings hooks (GH#955)
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>
This commit is contained in:
@@ -96,6 +96,14 @@ func installClaude(env claudeEnv, project bool, stealth bool) error {
|
||||
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"
|
||||
@@ -272,7 +280,8 @@ func removeHookCommand(hooks map[string]interface{}, event, command string) {
|
||||
}
|
||||
|
||||
// Filter out bd prime hooks
|
||||
var filtered []interface{}
|
||||
// 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 {
|
||||
@@ -304,7 +313,14 @@ func removeHookCommand(hooks map[string]interface{}, event, command string) {
|
||||
}
|
||||
}
|
||||
|
||||
hooks[event] = filtered
|
||||
// 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
|
||||
|
||||
@@ -281,6 +281,94 @@ func TestRemoveHookCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveHookCommandNoNull verifies that removing all hooks deletes the key
|
||||
// instead of setting it to null. GH#955: null values in hooks cause Claude Code to fail.
|
||||
func TestRemoveHookCommandNoNull(t *testing.T) {
|
||||
hooks := map[string]interface{}{
|
||||
"SessionStart": []interface{}{
|
||||
map[string]interface{}{
|
||||
"matcher": "",
|
||||
"hooks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "command",
|
||||
"command": "bd prime",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
removeHookCommand(hooks, "SessionStart", "bd prime")
|
||||
|
||||
// Key should be deleted, not set to null or empty array
|
||||
if _, exists := hooks["SessionStart"]; exists {
|
||||
t.Error("Expected SessionStart key to be deleted after removing all hooks")
|
||||
}
|
||||
|
||||
// Verify JSON serialization doesn't produce null
|
||||
data, err := json.Marshal(hooks)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "null") {
|
||||
t.Errorf("JSON contains null: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallClaudeCleanupNullHooks verifies that install cleans up existing null values.
|
||||
// GH#955: null values left by previous buggy removal cause Claude Code to fail.
|
||||
func TestInstallClaudeCleanupNullHooks(t *testing.T) {
|
||||
env, stdout, _ := newClaudeTestEnv(t)
|
||||
|
||||
// Create settings file with null hooks (simulating the bug)
|
||||
settingsPath := globalSettingsPath(env.homeDir)
|
||||
writeSettings(t, settingsPath, map[string]interface{}{
|
||||
"hooks": map[string]interface{}{
|
||||
"SessionStart": nil,
|
||||
"PreCompact": nil,
|
||||
},
|
||||
})
|
||||
|
||||
// Install should clean up null values and add proper hooks
|
||||
err := installClaude(env, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify hooks were properly added
|
||||
if !strings.Contains(stdout.String(), "Registered SessionStart hook") {
|
||||
t.Error("Expected SessionStart hook to be registered")
|
||||
}
|
||||
|
||||
// Read back the file and verify no null values
|
||||
data, err := env.readFile(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "null") {
|
||||
t.Errorf("Settings file still contains null: %s", data)
|
||||
}
|
||||
|
||||
// Verify it parses as valid Claude settings
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
t.Fatalf("parse settings: %v", err)
|
||||
}
|
||||
hooks, ok := settings["hooks"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("hooks section missing")
|
||||
}
|
||||
for _, event := range []string{"SessionStart", "PreCompact"} {
|
||||
eventHooks, ok := hooks[event].([]interface{})
|
||||
if !ok {
|
||||
t.Errorf("%s should be an array, not nil or missing", event)
|
||||
}
|
||||
if len(eventHooks) == 0 {
|
||||
t.Errorf("%s should have hooks", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasBeadsHooks(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user