feat: initial hooks implementation

Kujtim Hoxha created

Change summary

README.md                     |   1 
internal/agent/agent.go       |  79 +++++++
internal/agent/common_test.go |  13 +
internal/agent/coordinator.go |  25 +
internal/config/config.go     |   2 
internal/config/hooks.go      |  48 ++++
internal/hooks/HOOKS.md       | 287 ++++++++++++++++++++++++++++
internal/hooks/hooks.go       | 155 +++++++++++++++
internal/hooks/hooks_test.go  | 371 +++++++++++++++++++++++++++++++++++++
schema.json                   |  70 ++++++
10 files changed, 1,041 insertions(+), 10 deletions(-)

Detailed changes

README.md 🔗

@@ -18,6 +18,7 @@
 - **Session-Based:** maintain multiple work sessions and contexts per project
 - **LSP-Enhanced:** Crush uses LSPs for additional context, just like you do
 - **Extensible:** add capabilities via MCPs (`http`, `stdio`, and `sse`)
+- **[Hooks](./internal/hooks/HOOKS.md):** execute custom shell commands at lifecycle events
 - **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), FreeBSD, OpenBSD, and NetBSD
 
 ## Installation

internal/agent/agent.go 🔗

@@ -3,6 +3,7 @@ package agent
 import (
 	"context"
 	_ "embed"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -22,6 +23,7 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/hooks"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
@@ -76,6 +78,7 @@ type sessionAgent struct {
 	messages             message.Service
 	disableAutoSummarize bool
 	isYolo               bool
+	hooks                *hooks.Executor
 
 	messageQueue   *csync.Map[string, []SessionAgentCall]
 	activeRequests *csync.Map[string, context.CancelFunc]
@@ -91,6 +94,7 @@ type SessionAgentOptions struct {
 	Sessions             session.Service
 	Messages             message.Service
 	Tools                []fantasy.AgentTool
+	Hooks                *hooks.Executor
 }
 
 func NewSessionAgent(
@@ -106,6 +110,7 @@ func NewSessionAgent(
 		disableAutoSummarize: opts.DisableAutoSummarize,
 		tools:                opts.Tools,
 		isYolo:               opts.IsYolo,
+		hooks:                opts.Hooks,
 		messageQueue:         csync.NewMap[string, []SessionAgentCall](),
 		activeRequests:       csync.NewMap[string, context.CancelFunc](),
 	}
@@ -168,6 +173,17 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		return nil, err
 	}
 
+	// Execute UserPromptSubmit hook
+	if a.hooks != nil {
+		_ = a.hooks.Execute(ctx, hooks.HookContext{
+			EventType:  config.UserPromptSubmit,
+			SessionID:  call.SessionID,
+			UserPrompt: call.Prompt,
+			Provider:   a.largeModel.ModelCfg.Provider,
+			Model:      a.largeModel.ModelCfg.Model,
+		})
+	}
+
 	// add the session to the context
 	ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
 
@@ -293,6 +309,24 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			// TODO: implement
 		},
 		OnToolCall: func(tc fantasy.ToolCallContent) error {
+			// Execute PreToolUse hook - blocks tool execution on error
+			if a.hooks != nil {
+				toolInput := make(map[string]any)
+				if err := json.Unmarshal([]byte(tc.Input), &toolInput); err == nil {
+					if err := a.hooks.Execute(genCtx, hooks.HookContext{
+						EventType: config.PreToolUse,
+						SessionID: call.SessionID,
+						ToolName:  tc.ToolName,
+						ToolInput: toolInput,
+						MessageID: currentAssistant.ID,
+						Provider:  a.largeModel.ModelCfg.Provider,
+						Model:     a.largeModel.ModelCfg.Model,
+					}); err != nil {
+						return fmt.Errorf("PreToolUse hook blocked tool execution: %w", err)
+					}
+				}
+			}
+
 			toolCall := message.ToolCall{
 				ID:               tc.ToolCallID,
 				Name:             tc.ToolName,
@@ -321,6 +355,32 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			case fantasy.ToolResultContentTypeMedia:
 				// TODO: handle this message type
 			}
+
+			// Execute PostToolUse hook
+			if a.hooks != nil {
+				toolInput := make(map[string]any)
+				// Try to get tool input from the assistant message
+				toolCalls := currentAssistant.ToolCalls()
+				for _, tc := range toolCalls {
+					if tc.ID == result.ToolCallID {
+						_ = json.Unmarshal([]byte(tc.Input), &toolInput)
+						break
+					}
+				}
+
+				_ = a.hooks.Execute(genCtx, hooks.HookContext{
+					EventType:  config.PostToolUse,
+					SessionID:  call.SessionID,
+					ToolName:   result.ToolName,
+					ToolInput:  toolInput,
+					ToolResult: resultContent,
+					ToolError:  isError,
+					MessageID:  currentAssistant.ID,
+					Provider:   a.largeModel.ModelCfg.Provider,
+					Model:      a.largeModel.ModelCfg.Model,
+				})
+			}
+
 			toolResult := message.ToolResult{
 				ToolCallID: result.ToolCallID,
 				Name:       result.ToolName,
@@ -461,6 +521,25 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	}
 	wg.Wait()
 
+	// Execute Stop hook
+	if a.hooks != nil && result != nil {
+		var totalTokens, inputTokens int64
+		for _, step := range result.Steps {
+			totalTokens += step.Usage.TotalTokens
+			inputTokens += step.Usage.InputTokens
+		}
+
+		_ = a.hooks.Execute(ctx, hooks.HookContext{
+			EventType:   config.Stop,
+			SessionID:   call.SessionID,
+			MessageID:   currentAssistant.ID,
+			Provider:    a.largeModel.ModelCfg.Provider,
+			Model:       a.largeModel.ModelCfg.Model,
+			TokensUsed:  totalTokens,
+			TokensInput: inputTokens,
+		})
+	}
+
 	if shouldSummarize {
 		a.activeRequests.Del(call.SessionID)
 		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {

internal/agent/common_test.go 🔗

@@ -148,7 +148,18 @@ func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt
 			DefaultMaxTokens: 10000,
 		},
 	}
-	agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools})
+	agent := NewSessionAgent(SessionAgentOptions{
+		LargeModel:           largeModel,
+		SmallModel:           smallModel,
+		SystemPromptPrefix:   "",
+		SystemPrompt:         systemPrompt,
+		DisableAutoSummarize: false,
+		IsYolo:               true,
+		Sessions:             env.sessions,
+		Messages:             env.messages,
+		Tools:                tools,
+		Hooks:                nil,
+	})
 	return agent
 }
 

internal/agent/coordinator.go 🔗

@@ -21,6 +21,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/hooks"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
@@ -60,6 +61,7 @@ type coordinator struct {
 	permissions permission.Service
 	history     history.Service
 	lspClients  *csync.Map[string, *lsp.Client]
+	hooks       *hooks.Executor
 
 	currentAgent SessionAgent
 	agents       map[string]SessionAgent
@@ -74,6 +76,9 @@ func NewCoordinator(
 	history history.Service,
 	lspClients *csync.Map[string, *lsp.Client],
 ) (Coordinator, error) {
+	// Initialize hooks executor
+	hooksExecutor := hooks.NewExecutor(cfg.Hooks, cfg.WorkingDir())
+
 	c := &coordinator{
 		cfg:         cfg,
 		sessions:    sessions,
@@ -81,6 +86,7 @@ func NewCoordinator(
 		permissions: permissions,
 		history:     history,
 		lspClients:  lspClients,
+		hooks:       hooksExecutor,
 		agents:      make(map[string]SessionAgent),
 	}
 
@@ -290,15 +296,16 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
 
 	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
 	result := NewSessionAgent(SessionAgentOptions{
-		large,
-		small,
-		largeProviderCfg.SystemPromptPrefix,
-		systemPrompt,
-		c.cfg.Options.DisableAutoSummarize,
-		c.permissions.SkipRequests(),
-		c.sessions,
-		c.messages,
-		nil,
+		LargeModel:           large,
+		SmallModel:           small,
+		SystemPromptPrefix:   largeProviderCfg.SystemPromptPrefix,
+		SystemPrompt:         systemPrompt,
+		DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
+		IsYolo:               c.permissions.SkipRequests(),
+		Sessions:             c.sessions,
+		Messages:             c.messages,
+		Tools:                nil,
+		Hooks:                c.hooks,
 	})
 	go func() {
 		tools, err := c.buildTools(ctx, agent)

internal/config/config.go 🔗

@@ -303,6 +303,8 @@ type Config struct {
 
 	Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
 
+	Hooks HookConfig `json:"hooks,omitempty" jsonschema:"description=Hook configurations for lifecycle events"`
+
 	Agents map[string]Agent `json:"-"`
 
 	// Internal

internal/config/hooks.go 🔗

@@ -0,0 +1,48 @@
+package config
+
+// HookEventType represents the lifecycle event when a hook should run.
+type HookEventType string
+
+const (
+	// PreToolUse runs before tool calls and can block them.
+	PreToolUse HookEventType = "PreToolUse"
+	// PostToolUse runs after tool calls complete.
+	PostToolUse HookEventType = "PostToolUse"
+	// UserPromptSubmit runs when the user submits a prompt, before processing.
+	UserPromptSubmit HookEventType = "UserPromptSubmit"
+	// Notification runs when Crush sends notifications.
+	Notification HookEventType = "Notification"
+	// Stop runs when Crush finishes responding.
+	Stop HookEventType = "Stop"
+	// SubagentStop runs when subagent tasks complete.
+	SubagentStop HookEventType = "SubagentStop"
+	// PreCompact runs before running a compact operation.
+	PreCompact HookEventType = "PreCompact"
+	// SessionStart runs when a session starts or resumes.
+	SessionStart HookEventType = "SessionStart"
+	// SessionEnd runs when a session ends.
+	SessionEnd HookEventType = "SessionEnd"
+)
+
+// Hook represents a single hook command configuration.
+type Hook struct {
+	// Type is the hook type, currently only "command" is supported.
+	Type string `json:"type" jsonschema:"description=Hook type,enum=command,default=command"`
+	// Command is the shell command to execute.
+	Command string `json:"command" jsonschema:"required,description=Shell command to execute for this hook,example=echo 'Hook executed'"`
+	// Timeout is the maximum time in seconds to wait for the hook to complete.
+	// Default is 30 seconds.
+	Timeout *int `json:"timeout,omitempty" jsonschema:"description=Maximum time in seconds to wait for hook completion,default=30,minimum=1,maximum=300"`
+}
+
+// HookMatcher represents a matcher for a specific event type.
+type HookMatcher struct {
+	// Matcher is the tool name or pattern to match (for tool events).
+	// For non-tool events, this can be empty or "*" to match all.
+	Matcher string `json:"matcher,omitempty" jsonschema:"description=Tool name or pattern to match (e.g. 'bash' or '*' for all),example=bash,example=edit,example=*"`
+	// Hooks is the list of hooks to execute when the matcher matches.
+	Hooks []Hook `json:"hooks" jsonschema:"required,description=List of hooks to execute when matcher matches"`
+}
+
+// HookConfig holds the complete hook configuration.
+type HookConfig map[HookEventType][]HookMatcher

internal/hooks/HOOKS.md 🔗

@@ -0,0 +1,287 @@
+# Hooks Guide
+
+⚠️ **Security Warning**: Hooks run automatically with your user's permissions and have full access to your filesystem and environment. Only configure hooks from trusted sources and review all commands before adding them.
+
+Hooks are user-defined shell commands that execute at various points in Crush's lifecycle. They provide deterministic control over Crush's behavior, ensuring certain actions always occur rather than relying on the LLM to choose to run them.
+
+## Hook Events
+
+Crush provides several lifecycle events where hooks can run:
+
+### Tool Events
+- **`PreToolUse`**: Runs before tool calls. If a hook fails (non-zero exit code), the tool execution is blocked.
+- **`PostToolUse`**: Runs after tool calls complete, can be used to process results or trigger actions.
+
+### Session Events
+- **`UserPromptSubmit`**: Runs when the user submits a prompt, before processing
+- **`Stop`**: Runs when Crush finishes responding to a prompt
+- **`SubagentStop`**: Runs when subagent tasks complete (e.g., fetch tool, agent tool)
+- **`SessionStart`**: Runs when a session starts or resumes
+- **`SessionEnd`**: Runs when a session ends
+
+### Other Events
+- **`Notification`**: Runs when Crush sends notifications
+- **`PreCompact`**: Runs before running a compact operation
+
+## Configuration Format
+
+Hooks are configured in your Crush configuration file (e.g., `crush.json` or `~/.crush/crush.json`):
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "matcher": "bash",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "jq -r '.tool_name + \": \" + .tool_input.command' >> ~/crush-commands.log",
+            "timeout": 5
+          }
+        ]
+      }
+    ],
+    "PostToolUse": [
+      {
+        "matcher": "*",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "echo \"Tool $(jq -r .tool_name) completed\" | notify-send \"Crush Hook\""
+          }
+        ]
+      }
+    ],
+    "Stop": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "echo \"Prompt completed. Tokens used: $(jq -r .tokens_used)\""
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+## Hook Context
+
+Each hook receives a JSON context object via stdin containing information about the event:
+
+```json
+{
+  "event_type": "PreToolUse",
+  "session_id": "abc123",
+  "tool_name": "bash",
+  "tool_input": {
+    "command": "echo hello",
+    "description": "Print hello"
+  },
+  "tool_result": "",
+  "tool_error": false,
+  "user_prompt": "",
+  "timestamp": "2025-10-30T12:00:00Z",
+  "working_dir": "/path/to/project",
+  "message_id": "msg123",
+  "provider": "anthropic",
+  "model": "claude-3-5-sonnet-20241022",
+  "tokens_used": 1000,
+  "tokens_input": 500
+}
+```
+
+### Context Fields by Event Type
+
+Different events include different fields:
+
+- **PreToolUse**: `event_type`, `session_id`, `tool_name`, `tool_input`, `message_id`, `provider`, `model`
+- **PostToolUse**: `event_type`, `session_id`, `tool_name`, `tool_input`, `tool_result`, `tool_error`, `message_id`, `provider`, `model`
+- **UserPromptSubmit**: `event_type`, `session_id`, `user_prompt`, `provider`, `model`
+- **Stop**: `event_type`, `session_id`, `message_id`, `provider`, `model`, `tokens_used`, `tokens_input`
+
+All events include: `event_type`, `timestamp`, `working_dir`
+
+## Environment Variables
+
+Hooks also receive environment variables:
+
+- `CRUSH_HOOK_CONTEXT`: Full JSON context as a string
+- `CRUSH_HOOK_EVENT`: The event type (e.g., "PreToolUse")
+- `CRUSH_SESSION_ID`: The session ID (if applicable)
+- `CRUSH_TOOL_NAME`: The tool name (for tool events)
+
+## Hook Configuration
+
+### Matchers
+
+For tool events (`PreToolUse`, `PostToolUse`), you can specify matchers to target specific tools:
+
+- `"bash"` - Only matches the bash tool
+- `"edit"` - Only matches the edit tool
+- `"*"` or `""` - Matches all tools
+
+For non-tool events, leave the matcher empty or use `"*"`.
+
+### Hook Properties
+
+- `type`: Currently only `"command"` is supported
+- `command`: The shell command to execute
+- `timeout`: (optional) Maximum execution time in seconds (default: 30, max: 300)
+
+## Examples
+
+### Log All Bash Commands
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "matcher": "bash",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "jq -r '.timestamp + \" - \" + .tool_input.command' >> ~/.crush/bash-log.txt"
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+### Auto-format Files After Editing
+
+```json
+{
+  "hooks": {
+    "PostToolUse": [
+      {
+        "matcher": "edit",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "jq -r .tool_input.file_path | xargs prettier --write"
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+### Notify on Completion
+
+```json
+{
+  "hooks": {
+    "Stop": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "osascript -e 'display notification \"Crush completed\" with title \"Crush\"'"
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+### Track Token Usage
+
+```json
+{
+  "hooks": {
+    "Stop": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "jq -r '\"\\(.timestamp): \\(.tokens_used) tokens\"' >> ~/.crush/token-usage.log"
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+### Validate Tool Usage
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "matcher": "bash",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "if jq -e '.tool_input.command | contains(\"rm -rf\")' > /dev/null; then echo \"Dangerous command detected\" >&2; exit 1; fi"
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+### Multiple Hooks
+
+You can execute multiple hooks for the same event:
+
+```json
+{
+  "hooks": {
+    "PostToolUse": [
+      {
+        "matcher": "*",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "jq -r .tool_name >> ~/.crush/tool-usage.log"
+          },
+          {
+            "type": "command",
+            "command": "if jq -e .tool_error > /dev/null; then echo 'Error in tool' | pbcopy; fi"
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+## Best Practices
+
+1. **Keep hooks fast**: Hooks run synchronously and can slow down Crush if they take too long.
+2. **Set appropriate timeouts**: Use shorter timeouts (1-5 seconds) for quick operations.
+3. **Handle errors gracefully**: Hooks should not crash or hang.
+4. **Use jq for JSON processing**: The context is piped to stdin as JSON.
+5. **Test hooks independently**: Run your shell commands manually with test data before configuring them.
+6. **Use absolute paths**: Hooks run in the project directory, but absolute paths are more reliable.
+7. **Consider privacy**: Don't log sensitive information like API keys or passwords.
+
+## Debugging Hooks
+
+Hooks log errors and warnings to Crush's log output. To see hook execution:
+
+1. Run Crush with debug logging enabled: `crush --debug`
+2. Check the logs for hook-related messages
+3. Test hook shell commands manually:
+   ```bash
+   echo '{"event_type":"PreToolUse","tool_name":"bash"}' | jq -r '.tool_name'
+   ```
+
+## Limitations
+
+- Hooks must complete within their timeout (default 30 seconds)
+- Hooks run in a shell environment and require shell utilities (bash, jq, etc.)
+- Hooks cannot modify Crush's internal state
+- Hook errors are logged but don't stop Crush execution (except for PreToolUse)
+- Interactive hooks are not supported

internal/hooks/hooks.go 🔗

@@ -0,0 +1,155 @@
+package hooks
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/shell"
+)
+
+const DefaultHookTimeout = 30 * time.Second
+
+// HookContext contains context information passed to hooks.
+type HookContext struct {
+	EventType   config.HookEventType `json:"event_type"`
+	SessionID   string               `json:"session_id,omitempty"`
+	ToolName    string               `json:"tool_name,omitempty"`
+	ToolInput   map[string]any       `json:"tool_input,omitempty"`
+	ToolResult  string               `json:"tool_result,omitempty"`
+	ToolError   bool                 `json:"tool_error,omitempty"`
+	UserPrompt  string               `json:"user_prompt,omitempty"`
+	Timestamp   time.Time            `json:"timestamp"`
+	WorkingDir  string               `json:"working_dir,omitempty"`
+	MessageID   string               `json:"message_id,omitempty"`
+	Provider    string               `json:"provider,omitempty"`
+	Model       string               `json:"model,omitempty"`
+	TokensUsed  int64                `json:"tokens_used,omitempty"`
+	TokensInput int64                `json:"tokens_input,omitempty"`
+}
+
+// Executor executes hooks based on configuration.
+type Executor struct {
+	config     config.HookConfig
+	workingDir string
+	shell      *shell.Shell
+}
+
+// NewExecutor creates a new hook executor.
+func NewExecutor(hookConfig config.HookConfig, workingDir string) *Executor {
+	shellInst := shell.NewShell(&shell.Options{
+		WorkingDir: workingDir,
+	})
+	return &Executor{
+		config:     hookConfig,
+		workingDir: workingDir,
+		shell:      shellInst,
+	}
+}
+
+// Execute runs all hooks matching the given event type and context.
+// Returns the first error encountered, causing subsequent hooks to be skipped.
+func (e *Executor) Execute(ctx context.Context, hookCtx HookContext) error {
+	if e.config == nil || e.shell == nil {
+		return nil
+	}
+
+	hookCtx.Timestamp = time.Now()
+	hookCtx.WorkingDir = e.workingDir
+
+	matchers, ok := e.config[hookCtx.EventType]
+	if !ok || len(matchers) == 0 {
+		return nil
+	}
+
+	for _, matcher := range matchers {
+		if ctx.Err() != nil {
+			return ctx.Err()
+		}
+
+		if !e.matcherApplies(matcher, hookCtx) {
+			continue
+		}
+
+		for _, hook := range matcher.Hooks {
+			if err := e.executeHook(ctx, hook, hookCtx); err != nil {
+				slog.Warn("Hook execution failed",
+					"event", hookCtx.EventType,
+					"matcher", matcher.Matcher,
+					"error", err,
+				)
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+// matcherApplies checks if a matcher applies to the given context.
+func (e *Executor) matcherApplies(matcher config.HookMatcher, ctx HookContext) bool {
+	if matcher.Matcher == "" || matcher.Matcher == "*" {
+		return true
+	}
+
+	if ctx.EventType == config.PreToolUse || ctx.EventType == config.PostToolUse {
+		return matcher.Matcher == ctx.ToolName
+	}
+
+	return matcher.Matcher == "" || matcher.Matcher == "*"
+}
+
+// executeHook executes a single hook command.
+func (e *Executor) executeHook(ctx context.Context, hook config.Hook, hookCtx HookContext) error {
+	if hook.Type != "command" {
+		return fmt.Errorf("unsupported hook type: %s", hook.Type)
+	}
+
+	timeout := DefaultHookTimeout
+	if hook.Timeout != nil {
+		timeout = time.Duration(*hook.Timeout) * time.Second
+	}
+
+	execCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	contextJSON, err := json.Marshal(hookCtx)
+	if err != nil {
+		return fmt.Errorf("failed to marshal hook context: %w", err)
+	}
+
+	e.shell.SetEnv("CRUSH_HOOK_EVENT", string(hookCtx.EventType))
+	e.shell.SetEnv("CRUSH_HOOK_CONTEXT", string(contextJSON))
+	if hookCtx.SessionID != "" {
+		e.shell.SetEnv("CRUSH_SESSION_ID", hookCtx.SessionID)
+	}
+	if hookCtx.ToolName != "" {
+		e.shell.SetEnv("CRUSH_TOOL_NAME", hookCtx.ToolName)
+	}
+
+	slog.Debug("Executing hook",
+		"event", hookCtx.EventType,
+		"command", hook.Command,
+		"timeout", timeout,
+	)
+
+	fullCommand := fmt.Sprintf("%s <<'CRUSH_HOOK_EOF'\n%s\nCRUSH_HOOK_EOF\n", hook.Command, string(contextJSON))
+
+	stdout, stderr, err := e.shell.Exec(execCtx, fullCommand)
+	if err != nil {
+		return fmt.Errorf("hook command failed: %w: stdout=%s stderr=%s", err, stdout, stderr)
+	}
+
+	if stdout != "" || stderr != "" {
+		slog.Debug("Hook output",
+			"event", hookCtx.EventType,
+			"stdout", stdout,
+			"stderr", stderr,
+		)
+	}
+
+	return nil
+}

internal/hooks/hooks_test.go 🔗

@@ -0,0 +1,371 @@
+package hooks
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/stretchr/testify/require"
+)
+
+func TestHookExecutor_Execute(t *testing.T) {
+	t.Parallel()
+
+	tempDir := t.TempDir()
+
+	tests := []struct {
+		name    string
+		config  config.HookConfig
+		hookCtx HookContext
+		wantErr bool
+	}{
+		{
+			name: "simple command hook",
+			config: config.HookConfig{
+				config.PreToolUse: []config.HookMatcher{
+					{
+						Matcher: "bash",
+						Hooks: []config.Hook{
+							{
+								Type:    "command",
+								Command: "echo 'hook executed'",
+							},
+						},
+					},
+				},
+			},
+			hookCtx: HookContext{
+				EventType: config.PreToolUse,
+				ToolName:  "bash",
+			},
+		},
+		{
+			name: "hook with jq processing",
+			config: config.HookConfig{
+				config.PreToolUse: []config.HookMatcher{
+					{
+						Matcher: "bash",
+						Hooks: []config.Hook{
+							{
+								Type:    "command",
+								Command: `jq -r '.tool_name'`,
+							},
+						},
+					},
+				},
+			},
+			hookCtx: HookContext{
+				EventType: config.PreToolUse,
+				ToolName:  "bash",
+			},
+		},
+		{
+			name: "hook that writes to file",
+			config: config.HookConfig{
+				config.PostToolUse: []config.HookMatcher{
+					{
+						Matcher: "*",
+						Hooks: []config.Hook{
+							{
+								Type:    "command",
+								Command: `jq -r '"\(.tool_name): \(.tool_result)"' >> ` + filepath.Join(tempDir, "hook-log.txt"),
+							},
+						},
+					},
+				},
+			},
+			hookCtx: HookContext{
+				EventType:  config.PostToolUse,
+				ToolName:   "edit",
+				ToolResult: "file edited successfully",
+			},
+		},
+		{
+			name: "hook with timeout",
+			config: config.HookConfig{
+				config.Stop: []config.HookMatcher{
+					{
+						Hooks: []config.Hook{
+							{
+								Type:    "command",
+								Command: "sleep 0.1 && echo 'done'",
+								Timeout: ptrInt(1),
+							},
+						},
+					},
+				},
+			},
+			hookCtx: HookContext{
+				EventType: config.Stop,
+			},
+		},
+		{
+			name: "failed hook command",
+			config: config.HookConfig{
+				config.PreToolUse: []config.HookMatcher{
+					{
+						Matcher: "bash",
+						Hooks: []config.Hook{
+							{
+								Type:    "command",
+								Command: "exit 1",
+							},
+						},
+					},
+				},
+			},
+			hookCtx: HookContext{
+				EventType: config.PreToolUse,
+				ToolName:  "bash",
+			},
+			wantErr: true,
+		},
+		{
+			name: "hook with single quote in JSON",
+			config: config.HookConfig{
+				config.PostToolUse: []config.HookMatcher{
+					{
+						Matcher: "edit",
+						Hooks: []config.Hook{
+							{
+								Type:    "command",
+								Command: `jq -r '.tool_result'`,
+							},
+						},
+					},
+				},
+			},
+			hookCtx: HookContext{
+				EventType:  config.PostToolUse,
+				ToolName:   "edit",
+				ToolResult: "it's a test with 'quotes'",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			executor := NewExecutor(tt.config, tempDir)
+			require.NotNil(t, executor)
+
+			ctx := context.Background()
+			err := executor.Execute(ctx, tt.hookCtx)
+
+			if tt.wantErr {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHookExecutor_MatcherApplies(t *testing.T) {
+	t.Parallel()
+
+	tempDir := t.TempDir()
+	executor := NewExecutor(config.HookConfig{}, tempDir)
+
+	tests := []struct {
+		name    string
+		matcher config.HookMatcher
+		ctx     HookContext
+		want    bool
+	}{
+		{
+			name: "empty matcher matches all",
+			matcher: config.HookMatcher{
+				Matcher: "",
+			},
+			ctx: HookContext{
+				EventType: config.PreToolUse,
+				ToolName:  "bash",
+			},
+			want: true,
+		},
+		{
+			name: "wildcard matcher matches all",
+			matcher: config.HookMatcher{
+				Matcher: "*",
+			},
+			ctx: HookContext{
+				EventType: config.PreToolUse,
+				ToolName:  "edit",
+			},
+			want: true,
+		},
+		{
+			name: "specific tool matcher matches",
+			matcher: config.HookMatcher{
+				Matcher: "bash",
+			},
+			ctx: HookContext{
+				EventType: config.PreToolUse,
+				ToolName:  "bash",
+			},
+			want: true,
+		},
+		{
+			name: "specific tool matcher doesn't match different tool",
+			matcher: config.HookMatcher{
+				Matcher: "bash",
+			},
+			ctx: HookContext{
+				EventType: config.PreToolUse,
+				ToolName:  "edit",
+			},
+			want: false,
+		},
+		{
+			name: "non-tool event matches empty matcher",
+			matcher: config.HookMatcher{
+				Matcher: "",
+			},
+			ctx: HookContext{
+				EventType: config.Stop,
+			},
+			want: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := executor.matcherApplies(tt.matcher, tt.ctx)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestHookExecutor_Timeout(t *testing.T) {
+	t.Parallel()
+
+	tempDir := t.TempDir()
+	shortTimeout := 1
+
+	hookConfig := config.HookConfig{
+		config.Stop: []config.HookMatcher{
+			{
+				Hooks: []config.Hook{
+					{
+						Type:    "command",
+						Command: "sleep 10",
+						Timeout: &shortTimeout,
+					},
+				},
+			},
+		},
+	}
+
+	executor := NewExecutor(hookConfig, tempDir)
+	ctx := context.Background()
+
+	start := time.Now()
+	err := executor.Execute(ctx, HookContext{
+		EventType: config.Stop,
+	})
+	duration := time.Since(start)
+
+	require.Error(t, err)
+	require.Less(t, duration, 2*time.Second)
+}
+
+func TestHookExecutor_MultipleHooks(t *testing.T) {
+	t.Parallel()
+
+	tempDir := t.TempDir()
+	logFile := filepath.Join(tempDir, "multi-hook-log.txt")
+
+	hookConfig := config.HookConfig{
+		config.PreToolUse: []config.HookMatcher{
+			{
+				Matcher: "bash",
+				Hooks: []config.Hook{
+					{
+						Type:    "command",
+						Command: "echo 'hook1' >> " + logFile,
+					},
+					{
+						Type:    "command",
+						Command: "echo 'hook2' >> " + logFile,
+					},
+					{
+						Type:    "command",
+						Command: "echo 'hook3' >> " + logFile,
+					},
+				},
+			},
+		},
+	}
+
+	executor := NewExecutor(hookConfig, tempDir)
+	ctx := context.Background()
+
+	err := executor.Execute(ctx, HookContext{
+		EventType: config.PreToolUse,
+		ToolName:  "bash",
+	})
+
+	require.NoError(t, err)
+
+	content, err := os.ReadFile(logFile)
+	require.NoError(t, err)
+
+	lines := strings.Split(strings.TrimSpace(string(content)), "\n")
+	require.Len(t, lines, 3)
+	require.Equal(t, "hook1", lines[0])
+	require.Equal(t, "hook2", lines[1])
+	require.Equal(t, "hook3", lines[2])
+}
+
+func TestHookExecutor_ContextCancellation(t *testing.T) {
+	t.Parallel()
+
+	tempDir := t.TempDir()
+	logFile := filepath.Join(tempDir, "cancel-log.txt")
+
+	hookConfig := config.HookConfig{
+		config.PreToolUse: []config.HookMatcher{
+			{
+				Matcher: "bash",
+				Hooks: []config.Hook{
+					{
+						Type:    "command",
+						Command: "echo 'hook1' >> " + logFile,
+					},
+					{
+						Type:    "command",
+						Command: "sleep 10 && echo 'hook2' >> " + logFile,
+					},
+				},
+			},
+		},
+	}
+
+	executor := NewExecutor(hookConfig, tempDir)
+	ctx, cancel := context.WithCancel(context.Background())
+
+	go func() {
+		time.Sleep(100 * time.Millisecond)
+		cancel()
+	}()
+
+	err := executor.Execute(ctx, HookContext{
+		EventType: config.PreToolUse,
+		ToolName:  "bash",
+	})
+
+	require.Error(t, err)
+	require.ErrorIs(t, err, context.Canceled)
+}
+
+func ptrInt(i int) *int {
+	return &i
+}

schema.json 🔗

@@ -79,6 +79,10 @@
         "tools": {
           "$ref": "#/$defs/Tools",
           "description": "Tool configurations"
+        },
+        "hooks": {
+          "$ref": "#/$defs/HookConfig",
+          "description": "Hook configurations for lifecycle events"
         }
       },
       "additionalProperties": false,
@@ -87,6 +91,72 @@
         "tools"
       ]
     },
+    "Hook": {
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "command"
+          ],
+          "description": "Hook type",
+          "default": "command"
+        },
+        "command": {
+          "type": "string",
+          "description": "Shell command to execute for this hook",
+          "examples": [
+            "echo 'Hook executed'"
+          ]
+        },
+        "timeout": {
+          "type": "integer",
+          "maximum": 300,
+          "minimum": 1,
+          "description": "Maximum time in seconds to wait for hook completion",
+          "default": 30
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "type",
+        "command"
+      ]
+    },
+    "HookConfig": {
+      "additionalProperties": {
+        "items": {
+          "$ref": "#/$defs/HookMatcher"
+        },
+        "type": "array"
+      },
+      "type": "object"
+    },
+    "HookMatcher": {
+      "properties": {
+        "matcher": {
+          "type": "string",
+          "description": "Tool name or pattern to match (e.g. 'bash' or '*' for all)",
+          "examples": [
+            "bash",
+            "edit",
+            "*"
+          ]
+        },
+        "hooks": {
+          "items": {
+            "$ref": "#/$defs/Hook"
+          },
+          "type": "array",
+          "description": "List of hooks to execute when matcher matches"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "hooks"
+      ]
+    },
     "LSPConfig": {
       "properties": {
         "disabled": {