| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140 |
- package hooks
- import (
- "encoding/json"
- "fmt"
- "os"
- "strings"
- "github.com/tidwall/gjson"
- )
- // Payload is the JSON structure piped to hook commands via stdin.
- // ToolInput is emitted as a parsed JSON object for compatibility with
- // Claude Code hooks (which expect tool_input to be an object, not a
- // string).
- type Payload struct {
- Event string `json:"event"`
- SessionID string `json:"session_id"`
- CWD string `json:"cwd"`
- ToolName string `json:"tool_name"`
- ToolInput json.RawMessage `json:"tool_input"`
- }
- // BuildPayload constructs the JSON stdin payload for a hook command.
- func BuildPayload(eventName, sessionID, cwd, toolName, toolInputJSON string) []byte {
- toolInput := json.RawMessage(toolInputJSON)
- if !json.Valid(toolInput) {
- toolInput = json.RawMessage("{}")
- }
- p := Payload{
- Event: eventName,
- SessionID: sessionID,
- CWD: cwd,
- ToolName: toolName,
- ToolInput: toolInput,
- }
- data, err := json.Marshal(p)
- if err != nil {
- return []byte("{}")
- }
- return data
- }
- // BuildEnv constructs the environment variable slice for a hook command.
- // It includes all current process env vars plus hook-specific ones.
- func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
- env := os.Environ()
- env = append(env,
- fmt.Sprintf("CRUSH_EVENT=%s", eventName),
- fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
- fmt.Sprintf("CRUSH_SESSION_ID=%s", sessionID),
- fmt.Sprintf("CRUSH_CWD=%s", cwd),
- fmt.Sprintf("CRUSH_PROJECT_DIR=%s", projectDir),
- )
- // Extract tool-specific env vars from the JSON input.
- if toolInputJSON != "" {
- if cmd := gjson.Get(toolInputJSON, "command"); cmd.Exists() {
- env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_COMMAND=%s", cmd.String()))
- }
- if fp := gjson.Get(toolInputJSON, "file_path"); fp.Exists() {
- env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_FILE_PATH=%s", fp.String()))
- }
- }
- return env
- }
- // parseStdout parses the JSON output from a hook command's stdout.
- // Supports both Crush format and Claude Code format (hookSpecificOutput).
- func parseStdout(stdout string) HookResult {
- stdout = strings.TrimSpace(stdout)
- if stdout == "" {
- return HookResult{Decision: DecisionNone}
- }
- var raw map[string]json.RawMessage
- if err := json.Unmarshal([]byte(stdout), &raw); err != nil {
- return HookResult{Decision: DecisionNone}
- }
- // Claude Code compat: if hookSpecificOutput is present, parse that.
- if hso, ok := raw["hookSpecificOutput"]; ok {
- return parseClaudeCodeOutput(hso)
- }
- var parsed struct {
- Decision string `json:"decision"`
- Reason string `json:"reason"`
- Context string `json:"context"`
- UpdatedInput string `json:"updated_input"`
- }
- if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
- return HookResult{Decision: DecisionNone}
- }
- result := HookResult{
- Reason: parsed.Reason,
- Context: parsed.Context,
- UpdatedInput: parsed.UpdatedInput,
- }
- result.Decision = parseDecision(parsed.Decision)
- return result
- }
- // parseClaudeCodeOutput handles the Claude Code hook output format:
- // {"hookSpecificOutput": {"permissionDecision": "allow", ...}}
- func parseClaudeCodeOutput(data json.RawMessage) HookResult {
- var hso struct {
- PermissionDecision string `json:"permissionDecision"`
- PermissionDecisionReason string `json:"permissionDecisionReason"`
- UpdatedInput json.RawMessage `json:"updatedInput"`
- }
- if err := json.Unmarshal(data, &hso); err != nil {
- return HookResult{Decision: DecisionNone}
- }
- result := HookResult{
- Decision: parseDecision(hso.PermissionDecision),
- Reason: hso.PermissionDecisionReason,
- }
- // Marshal updatedInput back to a string for our opaque format.
- if len(hso.UpdatedInput) > 0 && string(hso.UpdatedInput) != "null" {
- result.UpdatedInput = string(hso.UpdatedInput)
- }
- return result
- }
- func parseDecision(s string) Decision {
- switch strings.ToLower(s) {
- case "allow":
- return DecisionAllow
- case "deny":
- return DecisionDeny
- default:
- return DecisionNone
- }
- }
|