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:
grip
2026-01-09 23:04:22 -08:00
committed by Steve Yegge
parent ecde3f2fd1
commit b4118e9f60
2 changed files with 106 additions and 2 deletions

View File

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