From 192afc0a93825d529dd8eded8e6e37a3b042b98e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 19 May 2026 07:43:46 +0000 Subject: [PATCH] COR-5907: Type install-config JSON with structs instead of map[string]any - Replace map[string]any literals in all five installX functions with strongly-typed structs: claudeHooksConfig (shared by Claude, Droid, Codex), cursorConfig, and cascadeConfig. - Add writeClaudeStyleConfig helper for read-merge-write pattern that preserves unrelated top-level keys (permissions, mcpServers, etc.). - Add writeJSONFile helper to eliminate marshal/mkdir/write boilerplate. - Collapse Codex tool matcher into codexToolMatcher constant shared by PreToolUse and PostToolUse. - Add golden-file tests pinning each install function's output. --- cmd/hookshot/install_test.go | 393 +++++++++++++++++++++++++++++++++++ cmd/hookshot/main.go | 361 ++++++++++++++++---------------- 2 files changed, 571 insertions(+), 183 deletions(-) create mode 100644 cmd/hookshot/install_test.go diff --git a/cmd/hookshot/install_test.go b/cmd/hookshot/install_test.go new file mode 100644 index 0000000..aa92de9 --- /dev/null +++ b/cmd/hookshot/install_test.go @@ -0,0 +1,393 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// normalizeJSON re-marshals JSON to normalize key order so comparisons +// are stable regardless of map iteration order. +func normalizeJSON(t *testing.T, data []byte) string { + t.Helper() + var v any + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("normalizeJSON: %v", err) + } + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("normalizeJSON marshal: %v", err) + } + return string(out) +} + +func TestInstallClaude(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := installClaude("/usr/local/bin/hooks"); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json")) + if err != nil { + t.Fatal(err) + } + + want := `{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks claude-stop" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks claude-pre-tool-use" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks claude-after-file-edit" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks claude-user-prompt-submit" + } + ] + } + ] + } +}` + + if normalizeJSON(t, got) != normalizeJSON(t, []byte(want)) { + t.Errorf("Claude config mismatch.\ngot:\n%s\nwant:\n%s", normalizeJSON(t, got), normalizeJSON(t, []byte(want))) + } +} + +func TestInstallClaude_PreservesExistingKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + claudeDir := filepath.Join(home, ".claude") + os.MkdirAll(claudeDir, 0755) + + existing := `{ + "permissions": { + "allow": ["Bash(*)"] + }, + "mcpServers": { + "my-server": {"command": "npx my-server"} + } +}` + os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(existing), 0644) + + if err := installClaude("/usr/local/bin/hooks"); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + if err != nil { + t.Fatal(err) + } + + var parsed map[string]any + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatal(err) + } + + if _, ok := parsed["permissions"]; !ok { + t.Error("existing 'permissions' key was not preserved") + } + if _, ok := parsed["mcpServers"]; !ok { + t.Error("existing 'mcpServers' key was not preserved") + } + if _, ok := parsed["hooks"]; !ok { + t.Error("'hooks' key was not written") + } +} + +func TestInstallCursor(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := installCursor("/usr/local/bin/hooks"); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(home, ".cursor", "hooks.json")) + if err != nil { + t.Fatal(err) + } + + want := `{ + "version": 1, + "hooks": { + "stop": [ + { + "command": "/usr/local/bin/hooks cursor-stop" + } + ], + "beforeShellExecution": [ + { + "command": "/usr/local/bin/hooks cursor-before-shell" + } + ], + "beforeMCPExecution": [ + { + "command": "/usr/local/bin/hooks cursor-before-mcp" + } + ], + "afterFileEdit": [ + { + "command": "/usr/local/bin/hooks cursor-after-file-edit" + } + ], + "beforeSubmitPrompt": [ + { + "command": "/usr/local/bin/hooks cursor-before-submit-prompt" + } + ] + } +}` + + if normalizeJSON(t, got) != normalizeJSON(t, []byte(want)) { + t.Errorf("Cursor config mismatch.\ngot:\n%s\nwant:\n%s", normalizeJSON(t, got), normalizeJSON(t, []byte(want))) + } +} + +func TestInstallDroid(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := installDroid("/usr/local/bin/hooks"); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(home, ".factory", "settings.json")) + if err != nil { + t.Fatal(err) + } + + want := `{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks droid-stop" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks droid-pre-tool-use" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks droid-after-file-edit" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks droid-user-prompt-submit" + } + ] + } + ] + } +}` + + if normalizeJSON(t, got) != normalizeJSON(t, []byte(want)) { + t.Errorf("Droid config mismatch.\ngot:\n%s\nwant:\n%s", normalizeJSON(t, got), normalizeJSON(t, []byte(want))) + } +} + +func TestInstallCodex(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := installCodex("/usr/local/bin/hooks"); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(home, ".codex", "hooks.json")) + if err != nil { + t.Fatal(err) + } + + want := `{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks codex-stop" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|apply_patch|mcp__.*", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks codex-pre-tool-use" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|apply_patch|mcp__.*", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks codex-post-tool-use" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/hooks codex-user-prompt-submit" + } + ] + } + ] + } +}` + + if normalizeJSON(t, got) != normalizeJSON(t, []byte(want)) { + t.Errorf("Codex config mismatch.\ngot:\n%s\nwant:\n%s", normalizeJSON(t, got), normalizeJSON(t, []byte(want))) + } +} + +func TestInstallCascade(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := installCascade("/usr/local/bin/hooks"); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(home, ".codeium", "windsurf", "hooks.json")) + if err != nil { + t.Fatal(err) + } + + want := `{ + "hooks": { + "pre_run_command": [ + { + "command": "/usr/local/bin/hooks cascade-pre-run-command" + } + ], + "pre_mcp_tool_use": [ + { + "command": "/usr/local/bin/hooks cascade-pre-mcp-tool-use" + } + ], + "pre_user_prompt": [ + { + "command": "/usr/local/bin/hooks cascade-pre-user-prompt" + } + ], + "post_write_code": [ + { + "command": "/usr/local/bin/hooks cascade-post-write-code" + } + ], + "post_cascade_response": [ + { + "command": "/usr/local/bin/hooks cascade-post-cascade-response" + } + ] + } +}` + + if normalizeJSON(t, got) != normalizeJSON(t, []byte(want)) { + t.Errorf("Cascade config mismatch.\ngot:\n%s\nwant:\n%s", normalizeJSON(t, got), normalizeJSON(t, []byte(want))) + } +} + +func TestInstallCodex_PreservesExistingKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + codexDir := filepath.Join(home, ".codex") + os.MkdirAll(codexDir, 0755) + + existing := `{ + "features": { + "hooks": true + } +}` + os.WriteFile(filepath.Join(codexDir, "hooks.json"), []byte(existing), 0644) + + if err := installCodex("/usr/local/bin/hooks"); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(codexDir, "hooks.json")) + if err != nil { + t.Fatal(err) + } + + var parsed map[string]any + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatal(err) + } + + if _, ok := parsed["features"]; !ok { + t.Error("existing 'features' key was not preserved") + } + if _, ok := parsed["hooks"]; !ok { + t.Error("'hooks' key was not written") + } +} diff --git a/cmd/hookshot/main.go b/cmd/hookshot/main.go index 418a197..c477656 100644 --- a/cmd/hookshot/main.go +++ b/cmd/hookshot/main.go @@ -301,18 +301,92 @@ Flags:`) fmt.Println("\nInstallation complete!") } -func installClaude(binaryPath string) error { - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("resolving home directory: %w", err) - } - configPath := filepath.Join(homeDir, ".claude", "settings.json") +// ============================================================================= +// Install config types — Claude-style (Claude Code, Factory Droid, Codex) +// ============================================================================= - fmt.Printf("Installing to Claude Code (%s)...\n", configPath) +// claudeHookCommand is a single hook command entry in a Claude Code settings file. +type claudeHookCommand struct { + Type string `json:"type"` + Command string `json:"command"` +} + +// claudeHookEntry groups an optional matcher with one or more hook commands. +type claudeHookEntry struct { + Matcher string `json:"matcher,omitempty"` + Hooks []claudeHookCommand `json:"hooks"` +} + +// claudeHooksConfig is the value of the top-level "hooks" key in +// ~/.claude/settings.json, ~/.factory/settings.json, and ~/.codex/hooks.json. +type claudeHooksConfig struct { + Stop []claudeHookEntry `json:"Stop,omitempty"` + PreToolUse []claudeHookEntry `json:"PreToolUse,omitempty"` + PostToolUse []claudeHookEntry `json:"PostToolUse,omitempty"` + UserPromptSubmit []claudeHookEntry `json:"UserPromptSubmit,omitempty"` +} - // Read existing config or create new +// ============================================================================= +// Install config types — Cursor +// ============================================================================= + +type cursorHookCommand struct { + Command string `json:"command"` +} + +type cursorHooksSection struct { + Stop []cursorHookCommand `json:"stop,omitempty"` + BeforeShellExecution []cursorHookCommand `json:"beforeShellExecution,omitempty"` + BeforeMCPExecution []cursorHookCommand `json:"beforeMCPExecution,omitempty"` + AfterFileEdit []cursorHookCommand `json:"afterFileEdit,omitempty"` + BeforeSubmitPrompt []cursorHookCommand `json:"beforeSubmitPrompt,omitempty"` +} + +type cursorConfig struct { + Version int `json:"version"` + Hooks cursorHooksSection `json:"hooks"` +} + +// ============================================================================= +// Install config types — Windsurf Cascade +// ============================================================================= + +type cascadeHookCommand struct { + Command string `json:"command"` +} + +type cascadeHooksSection struct { + PreRunCommand []cascadeHookCommand `json:"pre_run_command,omitempty"` + PreMCPToolUse []cascadeHookCommand `json:"pre_mcp_tool_use,omitempty"` + PreUserPrompt []cascadeHookCommand `json:"pre_user_prompt,omitempty"` + PostWriteCode []cascadeHookCommand `json:"post_write_code,omitempty"` + PostCascadeResponse []cascadeHookCommand `json:"post_cascade_response,omitempty"` +} + +type cascadeConfig struct { + Hooks cascadeHooksSection `json:"hooks"` +} + +// ============================================================================= +// Codex tool matcher +// ============================================================================= + +// codexToolMatcher covers Bash (heredoc file edits and greenfield writes that +// Codex 0.130.0+ routes through plain Bash), apply_patch, and MCP tool calls. +// "apply_patch" alone covers Codex file edits — Codex emits "Edit" and "Write" +// as matcher aliases for apply_patch, so they're redundant here. +const codexToolMatcher = "Bash|apply_patch|mcp__.*" + +// ============================================================================= +// Install helpers +// ============================================================================= + +// writeClaudeStyleConfig reads an existing JSON file at path (preserving +// unrelated top-level keys like "permissions" or "mcpServers"), replaces the +// "hooks" key with the typed value, and writes the result back. +func writeClaudeStyleConfig(path string, hooks claudeHooksConfig) error { var config map[string]any - data, err := os.ReadFile(configPath) + data, err := os.ReadFile(path) if err == nil { json.Unmarshal(data, &config) } @@ -320,48 +394,67 @@ func installClaude(binaryPath string) error { config = make(map[string]any) } - // Build hooks config - hooks := map[string]any{ - "Stop": []map[string]any{{ - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " claude-stop", - }}, - }}, - "PreToolUse": []map[string]any{{ - "matcher": "*", - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " claude-pre-tool-use", - }}, - }}, - "PostToolUse": []map[string]any{{ - "matcher": "Write|Edit", - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " claude-after-file-edit", - }}, - }}, - "UserPromptSubmit": []map[string]any{{ - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " claude-user-prompt-submit", - }}, - }}, + hooksJSON, err := json.Marshal(hooks) + if err != nil { + return fmt.Errorf("marshaling hooks: %w", err) } + var hooksMap any + if err := json.Unmarshal(hooksJSON, &hooksMap); err != nil { + return fmt.Errorf("round-tripping hooks to any: %w", err) + } + config["hooks"] = hooksMap + + return writeJSONFile(path, config) +} - config["hooks"] = hooks +// writeJSONFile marshals v as indented JSON, creates parent directories, and +// writes the file. Used for Cursor and Cascade configs that overwrite the +// whole file, and as the final write step for writeClaudeStyleConfig. +func writeJSONFile(path string, v any) error { + output, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshaling JSON: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + if err := os.WriteFile(path, output, 0644); err != nil { + return fmt.Errorf("writing file: %w", err) + } + return nil +} - // Ensure directory exists - os.MkdirAll(filepath.Dir(configPath), 0755) +// ============================================================================= +// Install functions +// ============================================================================= - // Write config - output, err := json.MarshalIndent(config, "", " ") +func installClaude(binaryPath string) error { + homeDir, err := os.UserHomeDir() if err != nil { - return err + return fmt.Errorf("resolving home directory: %w", err) } + configPath := filepath.Join(homeDir, ".claude", "settings.json") - if err := os.WriteFile(configPath, output, 0644); err != nil { + fmt.Printf("Installing to Claude Code (%s)...\n", configPath) + + hooks := claudeHooksConfig{ + Stop: []claudeHookEntry{{ + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " claude-stop"}}, + }}, + PreToolUse: []claudeHookEntry{{ + Matcher: "*", + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " claude-pre-tool-use"}}, + }}, + PostToolUse: []claudeHookEntry{{ + Matcher: "Write|Edit", + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " claude-after-file-edit"}}, + }}, + UserPromptSubmit: []claudeHookEntry{{ + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " claude-user-prompt-submit"}}, + }}, + } + + if err := writeClaudeStyleConfig(configPath, hooks); err != nil { return err } @@ -378,28 +471,18 @@ func installCursor(binaryPath string) error { fmt.Printf("Installing to Cursor (%s)...\n", configPath) - // Build hooks config - config := map[string]any{ - "version": 1, - "hooks": map[string]any{ - "stop": []map[string]any{{"command": binaryPath + " cursor-stop"}}, - "beforeShellExecution": []map[string]any{{"command": binaryPath + " cursor-before-shell"}}, - "beforeMCPExecution": []map[string]any{{"command": binaryPath + " cursor-before-mcp"}}, - "afterFileEdit": []map[string]any{{"command": binaryPath + " cursor-after-file-edit"}}, - "beforeSubmitPrompt": []map[string]any{{"command": binaryPath + " cursor-before-submit-prompt"}}, + config := cursorConfig{ + Version: 1, + Hooks: cursorHooksSection{ + Stop: []cursorHookCommand{{Command: binaryPath + " cursor-stop"}}, + BeforeShellExecution: []cursorHookCommand{{Command: binaryPath + " cursor-before-shell"}}, + BeforeMCPExecution: []cursorHookCommand{{Command: binaryPath + " cursor-before-mcp"}}, + AfterFileEdit: []cursorHookCommand{{Command: binaryPath + " cursor-after-file-edit"}}, + BeforeSubmitPrompt: []cursorHookCommand{{Command: binaryPath + " cursor-before-submit-prompt"}}, }, } - // Ensure directory exists - os.MkdirAll(filepath.Dir(configPath), 0755) - - // Write config - output, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(configPath, output, 0644); err != nil { + if err := writeJSONFile(configPath, config); err != nil { return err } @@ -416,58 +499,24 @@ func installDroid(binaryPath string) error { fmt.Printf("Installing to Factory Droid (%s)...\n", configPath) - // Read existing config or create new - var config map[string]any - data, err := os.ReadFile(configPath) - if err == nil { - json.Unmarshal(data, &config) - } - if config == nil { - config = make(map[string]any) - } - - // Build hooks config (same structure as Claude Code) - hooks := map[string]any{ - "Stop": []map[string]any{{ - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " droid-stop", - }}, + hooks := claudeHooksConfig{ + Stop: []claudeHookEntry{{ + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " droid-stop"}}, }}, - "PreToolUse": []map[string]any{{ - "matcher": "*", - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " droid-pre-tool-use", - }}, + PreToolUse: []claudeHookEntry{{ + Matcher: "*", + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " droid-pre-tool-use"}}, }}, - "PostToolUse": []map[string]any{{ - "matcher": "Write|Edit", - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " droid-after-file-edit", - }}, + PostToolUse: []claudeHookEntry{{ + Matcher: "Write|Edit", + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " droid-after-file-edit"}}, }}, - "UserPromptSubmit": []map[string]any{{ - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " droid-user-prompt-submit", - }}, + UserPromptSubmit: []claudeHookEntry{{ + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " droid-user-prompt-submit"}}, }}, } - config["hooks"] = hooks - - // Ensure directory exists - os.MkdirAll(filepath.Dir(configPath), 0755) - - // Write config - output, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(configPath, output, 0644); err != nil { + if err := writeClaudeStyleConfig(configPath, hooks); err != nil { return err } @@ -484,68 +533,24 @@ func installCodex(binaryPath string) error { fmt.Printf("Installing to OpenAI Codex (%s)...\n", configPath) - // Read existing config or create new - var config map[string]any - data, err := os.ReadFile(configPath) - if err == nil { - json.Unmarshal(data, &config) - } - if config == nil { - config = make(map[string]any) - } - - // Codex hook config follows the same JSON shape as Claude Code's - // settings but lives in ~/.codex/hooks.json. Matchers include - // "mcp__.*" so MCP tool calls reach the hook binary. "apply_patch" - // alone covers Codex file edits — Codex emits "Edit" and "Write" as - // matcher aliases for apply_patch, so they're redundant here. - hooks := map[string]any{ - "Stop": []map[string]any{{ - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " codex-stop", - }}, + hooks := claudeHooksConfig{ + Stop: []claudeHookEntry{{ + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " codex-stop"}}, }}, - "PreToolUse": []map[string]any{{ - "matcher": "Bash|apply_patch|mcp__.*", - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " codex-pre-tool-use", - }}, + PreToolUse: []claudeHookEntry{{ + Matcher: codexToolMatcher, + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " codex-pre-tool-use"}}, }}, - "PostToolUse": []map[string]any{{ - // Bash is required to catch the heredoc-style file edits - // (`apply_patch <<'PATCH' … PATCH`) and greenfield writes - // (`cat <<'EOF' > FILE … EOF`) Codex 0.130.0+ routes - // through plain Bash in addition to the apply_patch tool. The - // unified codex-post-tool-use bridge parses both shapes via - // codex.ParseApplyPatchFromBash / codex.ParseBashRedirectWrite - // — but only sees the events if the matcher itself lets - // them through. - "matcher": "Bash|apply_patch|mcp__.*", - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " codex-post-tool-use", - }}, + PostToolUse: []claudeHookEntry{{ + Matcher: codexToolMatcher, + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " codex-post-tool-use"}}, }}, - "UserPromptSubmit": []map[string]any{{ - "hooks": []map[string]any{{ - "type": "command", - "command": binaryPath + " codex-user-prompt-submit", - }}, + UserPromptSubmit: []claudeHookEntry{{ + Hooks: []claudeHookCommand{{Type: "command", Command: binaryPath + " codex-user-prompt-submit"}}, }}, } - config["hooks"] = hooks - - os.MkdirAll(filepath.Dir(configPath), 0755) - - output, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(configPath, output, 0644); err != nil { + if err := writeClaudeStyleConfig(configPath, hooks); err != nil { return err } @@ -562,27 +567,17 @@ func installCascade(binaryPath string) error { fmt.Printf("Installing to Windsurf Cascade (%s)...\n", configPath) - // Build hooks config - config := map[string]any{ - "hooks": map[string]any{ - "pre_run_command": []map[string]any{{"command": binaryPath + " cascade-pre-run-command"}}, - "pre_mcp_tool_use": []map[string]any{{"command": binaryPath + " cascade-pre-mcp-tool-use"}}, - "pre_user_prompt": []map[string]any{{"command": binaryPath + " cascade-pre-user-prompt"}}, - "post_write_code": []map[string]any{{"command": binaryPath + " cascade-post-write-code"}}, - "post_cascade_response": []map[string]any{{"command": binaryPath + " cascade-post-cascade-response"}}, + config := cascadeConfig{ + Hooks: cascadeHooksSection{ + PreRunCommand: []cascadeHookCommand{{Command: binaryPath + " cascade-pre-run-command"}}, + PreMCPToolUse: []cascadeHookCommand{{Command: binaryPath + " cascade-pre-mcp-tool-use"}}, + PreUserPrompt: []cascadeHookCommand{{Command: binaryPath + " cascade-pre-user-prompt"}}, + PostWriteCode: []cascadeHookCommand{{Command: binaryPath + " cascade-post-write-code"}}, + PostCascadeResponse: []cascadeHookCommand{{Command: binaryPath + " cascade-post-cascade-response"}}, }, } - // Ensure directory exists - os.MkdirAll(filepath.Dir(configPath), 0755) - - // Write config - output, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(configPath, output, 0644); err != nil { + if err := writeJSONFile(configPath, config); err != nil { return err }