input.go 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. package hooks
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "os"
  6. "strings"
  7. "github.com/tidwall/gjson"
  8. )
  9. // Payload is the JSON structure piped to hook commands via stdin.
  10. // ToolInput is emitted as a parsed JSON object for compatibility with
  11. // Claude Code hooks (which expect tool_input to be an object, not a
  12. // string).
  13. type Payload struct {
  14. Event string `json:"event"`
  15. SessionID string `json:"session_id"`
  16. CWD string `json:"cwd"`
  17. ToolName string `json:"tool_name"`
  18. ToolInput json.RawMessage `json:"tool_input"`
  19. }
  20. // BuildPayload constructs the JSON stdin payload for a hook command.
  21. func BuildPayload(eventName, sessionID, cwd, toolName, toolInputJSON string) []byte {
  22. toolInput := json.RawMessage(toolInputJSON)
  23. if !json.Valid(toolInput) {
  24. toolInput = json.RawMessage("{}")
  25. }
  26. p := Payload{
  27. Event: eventName,
  28. SessionID: sessionID,
  29. CWD: cwd,
  30. ToolName: toolName,
  31. ToolInput: toolInput,
  32. }
  33. data, err := json.Marshal(p)
  34. if err != nil {
  35. return []byte("{}")
  36. }
  37. return data
  38. }
  39. // BuildEnv constructs the environment variable slice for a hook command.
  40. // It includes all current process env vars plus hook-specific ones.
  41. func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
  42. env := os.Environ()
  43. env = append(env,
  44. fmt.Sprintf("CRUSH_EVENT=%s", eventName),
  45. fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
  46. fmt.Sprintf("CRUSH_SESSION_ID=%s", sessionID),
  47. fmt.Sprintf("CRUSH_CWD=%s", cwd),
  48. fmt.Sprintf("CRUSH_PROJECT_DIR=%s", projectDir),
  49. )
  50. // Extract tool-specific env vars from the JSON input.
  51. if toolInputJSON != "" {
  52. if cmd := gjson.Get(toolInputJSON, "command"); cmd.Exists() {
  53. env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_COMMAND=%s", cmd.String()))
  54. }
  55. if fp := gjson.Get(toolInputJSON, "file_path"); fp.Exists() {
  56. env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_FILE_PATH=%s", fp.String()))
  57. }
  58. }
  59. return env
  60. }
  61. // parseStdout parses the JSON output from a hook command's stdout.
  62. // Supports both Crush format and Claude Code format (hookSpecificOutput).
  63. func parseStdout(stdout string) HookResult {
  64. stdout = strings.TrimSpace(stdout)
  65. if stdout == "" {
  66. return HookResult{Decision: DecisionNone}
  67. }
  68. var raw map[string]json.RawMessage
  69. if err := json.Unmarshal([]byte(stdout), &raw); err != nil {
  70. return HookResult{Decision: DecisionNone}
  71. }
  72. // Claude Code compat: if hookSpecificOutput is present, parse that.
  73. if hso, ok := raw["hookSpecificOutput"]; ok {
  74. return parseClaudeCodeOutput(hso)
  75. }
  76. var parsed struct {
  77. Decision string `json:"decision"`
  78. Reason string `json:"reason"`
  79. Context string `json:"context"`
  80. UpdatedInput string `json:"updated_input"`
  81. }
  82. if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
  83. return HookResult{Decision: DecisionNone}
  84. }
  85. result := HookResult{
  86. Reason: parsed.Reason,
  87. Context: parsed.Context,
  88. UpdatedInput: parsed.UpdatedInput,
  89. }
  90. result.Decision = parseDecision(parsed.Decision)
  91. return result
  92. }
  93. // parseClaudeCodeOutput handles the Claude Code hook output format:
  94. // {"hookSpecificOutput": {"permissionDecision": "allow", ...}}
  95. func parseClaudeCodeOutput(data json.RawMessage) HookResult {
  96. var hso struct {
  97. PermissionDecision string `json:"permissionDecision"`
  98. PermissionDecisionReason string `json:"permissionDecisionReason"`
  99. UpdatedInput json.RawMessage `json:"updatedInput"`
  100. }
  101. if err := json.Unmarshal(data, &hso); err != nil {
  102. return HookResult{Decision: DecisionNone}
  103. }
  104. result := HookResult{
  105. Decision: parseDecision(hso.PermissionDecision),
  106. Reason: hso.PermissionDecisionReason,
  107. }
  108. // Marshal updatedInput back to a string for our opaque format.
  109. if len(hso.UpdatedInput) > 0 && string(hso.UpdatedInput) != "null" {
  110. result.UpdatedInput = string(hso.UpdatedInput)
  111. }
  112. return result
  113. }
  114. func parseDecision(s string) Decision {
  115. switch strings.ToLower(s) {
  116. case "allow":
  117. return DecisionAllow
  118. case "deny":
  119. return DecisionDeny
  120. default:
  121. return DecisionNone
  122. }
  123. }