executor.go 2.5 KB

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