1package hooks
2
3import (
4 "encoding/json"
5 "fmt"
6 "log/slog"
7 "os"
8 "strings"
9
10 "github.com/charmbracelet/crush/internal/shell"
11 "github.com/tidwall/gjson"
12)
13
14// SupportedOutputVersion is the highest envelope version this build
15// understands. Hooks may omit `version` entirely (treated as 1) or pin
16// an older version. Unknown higher versions are still parsed but logged.
17const SupportedOutputVersion = 1
18
19// Payload is the JSON structure piped to hook commands via stdin.
20// ToolInput is emitted as a parsed JSON object for compatibility with
21// Claude Code hooks (which expect tool_input to be an object, not a
22// string).
23type Payload struct {
24 Event string `json:"event"`
25 SessionID string `json:"session_id"`
26 CWD string `json:"cwd"`
27 ToolName string `json:"tool_name"`
28 ToolInput json.RawMessage `json:"tool_input"`
29}
30
31// BuildPayload constructs the JSON stdin payload for a hook command.
32func BuildPayload(eventName, sessionID, cwd, toolName, toolInputJSON string) []byte {
33 toolInput := json.RawMessage(toolInputJSON)
34 if !json.Valid(toolInput) {
35 toolInput = json.RawMessage("{}")
36 }
37 p := Payload{
38 Event: eventName,
39 SessionID: sessionID,
40 CWD: cwd,
41 ToolName: toolName,
42 ToolInput: toolInput,
43 }
44 data, err := json.Marshal(p)
45 if err != nil {
46 return []byte("{}")
47 }
48 return data
49}
50
51// BuildEnv constructs the environment variable slice for a hook command.
52// It includes all current process env vars plus hook-specific ones.
53func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
54 env := os.Environ()
55 env = append(env, shell.CrushEnvMarkers()...)
56 env = append(
57 env,
58 fmt.Sprintf("CRUSH_EVENT=%s", eventName),
59 fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
60 fmt.Sprintf("CRUSH_SESSION_ID=%s", sessionID),
61 fmt.Sprintf("CRUSH_CWD=%s", cwd),
62 fmt.Sprintf("CRUSH_PROJECT_DIR=%s", projectDir),
63 )
64
65 // Extract tool-specific env vars from the JSON input.
66 if toolInputJSON != "" {
67 if cmd := gjson.Get(toolInputJSON, "command"); cmd.Exists() {
68 env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_COMMAND=%s", cmd.String()))
69 }
70 if fp := gjson.Get(toolInputJSON, "file_path"); fp.Exists() {
71 env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_FILE_PATH=%s", fp.String()))
72 }
73 }
74
75 return env
76}
77
78// parseStdout parses the JSON output from a hook command's stdout.
79// Supports both Crush format and Claude Code format (hookSpecificOutput).
80func parseStdout(stdout string) HookResult {
81 stdout = strings.TrimSpace(stdout)
82 if stdout == "" {
83 return HookResult{Decision: DecisionNone}
84 }
85
86 var raw map[string]json.RawMessage
87 if err := json.Unmarshal([]byte(stdout), &raw); err != nil {
88 return HookResult{Decision: DecisionNone}
89 }
90
91 // Claude Code compat: if hookSpecificOutput is present, parse that.
92 if hso, ok := raw["hookSpecificOutput"]; ok {
93 return parseClaudeCodeOutput(hso)
94 }
95
96 var parsed struct {
97 Version int `json:"version"`
98 Decision string `json:"decision"`
99 Halt bool `json:"halt"`
100 Reason string `json:"reason"`
101 Context json.RawMessage `json:"context"`
102 UpdatedInput json.RawMessage `json:"updated_input"`
103 }
104 if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
105 return HookResult{Decision: DecisionNone}
106 }
107
108 if parsed.Version > SupportedOutputVersion {
109 slog.Debug(
110 "Hook output declared a newer envelope version than this build supports",
111 "version", parsed.Version,
112 "supported", SupportedOutputVersion,
113 )
114 }
115
116 result := HookResult{
117 Halt: parsed.Halt,
118 Reason: parsed.Reason,
119 Context: parseContext(parsed.Context),
120 }
121 result.Decision = parseDecision(parsed.Decision)
122 result.UpdatedInput = rawToString(parsed.UpdatedInput)
123 return result
124}
125
126// parseContext accepts either a single string or an array of strings and
127// returns a newline-joined value with empty entries dropped.
128func parseContext(raw json.RawMessage) string {
129 if len(raw) == 0 || string(raw) == "null" {
130 return ""
131 }
132 // String form.
133 if raw[0] == '"' {
134 var s string
135 if err := json.Unmarshal(raw, &s); err == nil {
136 return s
137 }
138 return ""
139 }
140 // Array form.
141 if raw[0] == '[' {
142 var items []string
143 if err := json.Unmarshal(raw, &items); err != nil {
144 return ""
145 }
146 out := items[:0]
147 for _, s := range items {
148 if s != "" {
149 out = append(out, s)
150 }
151 }
152 return strings.Join(out, "\n")
153 }
154 return ""
155}
156
157// parseClaudeCodeOutput handles the Claude Code hook output format:
158// {"hookSpecificOutput": {"permissionDecision": "allow", ...}}
159func parseClaudeCodeOutput(data json.RawMessage) HookResult {
160 var hso struct {
161 PermissionDecision string `json:"permissionDecision"`
162 PermissionDecisionReason string `json:"permissionDecisionReason"`
163 UpdatedInput json.RawMessage `json:"updatedInput"`
164 }
165 if err := json.Unmarshal(data, &hso); err != nil {
166 return HookResult{Decision: DecisionNone}
167 }
168
169 result := HookResult{
170 Decision: parseDecision(hso.PermissionDecision),
171 Reason: hso.PermissionDecisionReason,
172 }
173
174 // Marshal updatedInput back to a string for our opaque format.
175 if len(hso.UpdatedInput) > 0 && string(hso.UpdatedInput) != "null" {
176 result.UpdatedInput = string(hso.UpdatedInput)
177 }
178
179 return result
180}
181
182// rawToString converts a json.RawMessage to a string suitable for use
183// as opaque tool input. It accepts both a JSON object (nested) and a
184// JSON string (stringified, for backward compatibility).
185func rawToString(raw json.RawMessage) string {
186 if len(raw) == 0 || string(raw) == "null" {
187 return ""
188 }
189 // If it's a JSON string, unwrap it.
190 if raw[0] == '"' {
191 var s string
192 if err := json.Unmarshal(raw, &s); err == nil {
193 return s
194 }
195 }
196 // Otherwise it's an object/array — use as-is.
197 return string(raw)
198}
199
200func parseDecision(s string) Decision {
201 switch strings.ToLower(s) {
202 case "allow":
203 return DecisionAllow
204 case "deny":
205 return DecisionDeny
206 default:
207 return DecisionNone
208 }
209}