executor.go

  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}