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	"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}