1package hooks
2
3import (
4 "context"
5 _ "embed"
6 "encoding/json"
7 "fmt"
8 "os"
9 "strings"
10
11 "github.com/charmbracelet/crush/internal/shell"
12 "mvdan.cc/sh/v3/interp"
13)
14
15//go:embed helpers.sh
16var helpersScript string
17
18// Executor executes individual hook scripts.
19type Executor struct {
20 workingDir string
21}
22
23// NewExecutor creates a new hook executor.
24func NewExecutor(workingDir string) *Executor {
25 return &Executor{workingDir: workingDir}
26}
27
28// Execute runs a single hook script and returns the result.
29func (e *Executor) Execute(ctx context.Context, hookPath string, context HookContext) (*HookResult, error) {
30 hookScript, err := os.ReadFile(hookPath)
31 if err != nil {
32 return nil, fmt.Errorf("failed to read hook: %w", err)
33 }
34
35 contextJSON, err := json.Marshal(context.Data)
36 if err != nil {
37 return nil, fmt.Errorf("failed to marshal context: %w", err)
38 }
39
40 // Wrap user hook in a function and prepend helper functions
41 // Read stdin before calling the function, then export it
42 fullScript := fmt.Sprintf(`%s
43
44# Save stdin to variable before entering function
45_CRUSH_STDIN=$(cat)
46export _CRUSH_STDIN
47
48_crush_hook_main() {
49%s
50}
51
52_crush_hook_main
53`, helpersScript, string(hookScript))
54
55 env := append(os.Environ(),
56 "CRUSH_HOOK_TYPE="+string(context.HookType),
57 "CRUSH_SESSION_ID="+context.SessionID,
58 "CRUSH_WORKING_DIR="+context.WorkingDir,
59 )
60
61 if context.ToolName != "" {
62 env = append(env,
63 "CRUSH_TOOL_NAME="+context.ToolName,
64 "CRUSH_TOOL_CALL_ID="+context.ToolCallID,
65 )
66 }
67
68 for k, v := range context.Environment {
69 env = append(env, k+"="+v)
70 }
71
72 hookShell := shell.NewShell(&shell.Options{
73 WorkingDir: context.WorkingDir,
74 Env: env,
75 ExecHandlers: []func(interp.ExecHandlerFunc) interp.ExecHandlerFunc{RegisterBuiltins},
76 })
77
78 // Pass JSON context via stdin instead of heredoc
79 stdin := strings.NewReader(string(contextJSON))
80 stdout, stderr, err := hookShell.ExecWithStdin(ctx, fullScript, stdin)
81
82 result := parseShellEnv(hookShell.GetEnv())
83 exitCode := shell.ExitCode(err)
84 switch exitCode {
85 case 2:
86 result.Continue = false
87 case 1:
88 return nil, fmt.Errorf("hook failed with exit code 1: %w\nstderr: %s", err, stderr)
89 }
90
91 if trimmed := strings.TrimSpace(stdout); len(trimmed) > 0 && trimmed[0] == '{' {
92 if jsonResult, parseErr := parseJSONResult([]byte(trimmed)); parseErr == nil {
93 mergeJSONResult(result, jsonResult)
94 }
95 }
96
97 return result, nil
98}
99
100// GetHelpersScript returns the embedded helper script for display.
101func GetHelpersScript() string {
102 return helpersScript
103}