input.go

  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}