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