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(env,
 57		fmt.Sprintf("CRUSH_EVENT=%s", eventName),
 58		fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
 59		fmt.Sprintf("CRUSH_SESSION_ID=%s", sessionID),
 60		fmt.Sprintf("CRUSH_CWD=%s", cwd),
 61		fmt.Sprintf("CRUSH_PROJECT_DIR=%s", projectDir),
 62	)
 63
 64	// Extract tool-specific env vars from the JSON input.
 65	if toolInputJSON != "" {
 66		if cmd := gjson.Get(toolInputJSON, "command"); cmd.Exists() {
 67			env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_COMMAND=%s", cmd.String()))
 68		}
 69		if fp := gjson.Get(toolInputJSON, "file_path"); fp.Exists() {
 70			env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_FILE_PATH=%s", fp.String()))
 71		}
 72	}
 73
 74	return env
 75}
 76
 77// parseStdout parses the JSON output from a hook command's stdout.
 78// Supports both Crush format and Claude Code format (hookSpecificOutput).
 79func parseStdout(stdout string) HookResult {
 80	stdout = strings.TrimSpace(stdout)
 81	if stdout == "" {
 82		return HookResult{Decision: DecisionNone}
 83	}
 84
 85	var raw map[string]json.RawMessage
 86	if err := json.Unmarshal([]byte(stdout), &raw); err != nil {
 87		return HookResult{Decision: DecisionNone}
 88	}
 89
 90	// Claude Code compat: if hookSpecificOutput is present, parse that.
 91	if hso, ok := raw["hookSpecificOutput"]; ok {
 92		return parseClaudeCodeOutput(hso)
 93	}
 94
 95	var parsed struct {
 96		Version      int             `json:"version"`
 97		Decision     string          `json:"decision"`
 98		Halt         bool            `json:"halt"`
 99		Reason       string          `json:"reason"`
100		Context      json.RawMessage `json:"context"`
101		UpdatedInput json.RawMessage `json:"updated_input"`
102	}
103	if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
104		return HookResult{Decision: DecisionNone}
105	}
106
107	if parsed.Version > SupportedOutputVersion {
108		slog.Debug("Hook output declared a newer envelope version than this build supports",
109			"version", parsed.Version,
110			"supported", SupportedOutputVersion,
111		)
112	}
113
114	result := HookResult{
115		Halt:    parsed.Halt,
116		Reason:  parsed.Reason,
117		Context: parseContext(parsed.Context),
118	}
119	result.Decision = parseDecision(parsed.Decision)
120	result.UpdatedInput = rawToString(parsed.UpdatedInput)
121	return result
122}
123
124// parseContext accepts either a single string or an array of strings and
125// returns a newline-joined value with empty entries dropped.
126func parseContext(raw json.RawMessage) string {
127	if len(raw) == 0 || string(raw) == "null" {
128		return ""
129	}
130	// String form.
131	if raw[0] == '"' {
132		var s string
133		if err := json.Unmarshal(raw, &s); err == nil {
134			return s
135		}
136		return ""
137	}
138	// Array form.
139	if raw[0] == '[' {
140		var items []string
141		if err := json.Unmarshal(raw, &items); err != nil {
142			return ""
143		}
144		out := items[:0]
145		for _, s := range items {
146			if s != "" {
147				out = append(out, s)
148			}
149		}
150		return strings.Join(out, "\n")
151	}
152	return ""
153}
154
155// parseClaudeCodeOutput handles the Claude Code hook output format:
156// {"hookSpecificOutput": {"permissionDecision": "allow", ...}}
157func parseClaudeCodeOutput(data json.RawMessage) HookResult {
158	var hso struct {
159		PermissionDecision       string          `json:"permissionDecision"`
160		PermissionDecisionReason string          `json:"permissionDecisionReason"`
161		UpdatedInput             json.RawMessage `json:"updatedInput"`
162	}
163	if err := json.Unmarshal(data, &hso); err != nil {
164		return HookResult{Decision: DecisionNone}
165	}
166
167	result := HookResult{
168		Decision: parseDecision(hso.PermissionDecision),
169		Reason:   hso.PermissionDecisionReason,
170	}
171
172	// Marshal updatedInput back to a string for our opaque format.
173	if len(hso.UpdatedInput) > 0 && string(hso.UpdatedInput) != "null" {
174		result.UpdatedInput = string(hso.UpdatedInput)
175	}
176
177	return result
178}
179
180// rawToString converts a json.RawMessage to a string suitable for use
181// as opaque tool input. It accepts both a JSON object (nested) and a
182// JSON string (stringified, for backward compatibility).
183func rawToString(raw json.RawMessage) string {
184	if len(raw) == 0 || string(raw) == "null" {
185		return ""
186	}
187	// If it's a JSON string, unwrap it.
188	if raw[0] == '"' {
189		var s string
190		if err := json.Unmarshal(raw, &s); err == nil {
191			return s
192		}
193	}
194	// Otherwise it's an object/array — use as-is.
195	return string(raw)
196}
197
198func parseDecision(s string) Decision {
199	switch strings.ToLower(s) {
200	case "allow":
201		return DecisionAllow
202	case "deny":
203		return DecisionDeny
204	default:
205		return DecisionNone
206	}
207}