Detailed changes
@@ -0,0 +1,567 @@
+# Hooks Package
+
+A Git-like hooks system for Crush that allows users to intercept and modify behavior at key points in the application lifecycle.
+
+## Overview
+
+The hooks package provides a flexible, shell-based system for customizing Crush behavior through executable scripts. Hooks can:
+
+- Add context to LLM requests
+- Control tool execution permissions
+- Modify prompts and tool parameters
+- Audit and log activity
+- Execute cleanup on shutdown
+
+### Cross-Platform Support
+
+The hooks system works on **Windows, macOS, and Linux**:
+
+- **Hook Files**: All hooks must be `.sh` files (shell scripts)
+- **Shell Execution**: Uses Crush's internal POSIX shell emulator (`mvdan.cc/sh`) on all platforms
+- **Hook Discovery**:
+ - **Unix/macOS**: `.sh` files must have execute permission (`chmod +x hook.sh`)
+ - **Windows**: `.sh` files are automatically recognized (no permission needed)
+- **Path Separators**: Use forward slashes (`/`) in hook scripts for cross-platform compatibility
+
+**Example**:
+```bash
+# Works on Windows, macOS, and Linux
+.crush/hooks/pre-tool-use/01-check.sh
+```
+
+## Quick Start
+
+### Creating a Hook
+
+1. Create an executable script in `.crush/hooks/{hook-type}/`:
+
+```bash
+#!/bin/bash
+# .crush/hooks/pre-tool-use/01-block-dangerous.sh
+
+if [ "$CRUSH_TOOL_NAME" = "bash" ]; then
+ COMMAND=$(crush_get_tool_input command)
+ if [[ "$COMMAND" =~ "rm -rf /" ]]; then
+ crush_deny "Blocked dangerous command"
+ fi
+fi
+```
+
+2. Make it executable:
+
+```bash
+chmod +x .crush/hooks/pre-tool-use/01-block-dangerous.sh
+```
+
+3. The hook will automatically execute when the event occurs.
+
+## Hook Types
+
+### 1. UserPromptSubmit
+
+**When**: After user submits prompt, before sending to LLM
+**Use cases**: Add context, modify prompts, validate input
+**Location**: `.crush/hooks/user-prompt-submit/`
+
+**Available data** (via stdin JSON):
+- `prompt` - User's prompt text
+- `attachments` - List of attached files
+- `model` - Model name
+- `is_first_message` - Boolean indicating if this is the first message in the conversation
+
+**Example**:
+```bash
+#!/bin/bash
+# Add git context to every prompt, and README only for first message
+
+BRANCH=$(git branch --show-current 2>/dev/null)
+if [ -n "$BRANCH" ]; then
+ crush_add_context "Current branch: $BRANCH"
+fi
+
+# Only add README context for the first message to avoid repetition
+IS_FIRST=$(crush_get_input is_first_message)
+if [ "$IS_FIRST" = "true" ] && [ -f "README.md" ]; then
+ crush_add_context_file "README.md"
+fi
+```
+
+### 2. PreToolUse
+
+**When**: After LLM requests tool use, before permission check & execution
+**Use cases**: Auto-approve, deny dangerous commands, audit requests
+**Location**: `.crush/hooks/pre-tool-use/`
+
+**Available data** (via stdin JSON):
+- `tool_input` - Tool parameters (object)
+
+**Environment variables**:
+- `$CRUSH_TOOL_NAME` - Name of the tool being called
+- `$CRUSH_TOOL_CALL_ID` - Unique ID for this tool call
+
+**Example**:
+```bash
+#!/bin/bash
+# Auto-approve read-only tools and modify parameters
+
+case "$CRUSH_TOOL_NAME" in
+ view|ls|grep|glob)
+ crush_approve "Auto-approved read-only tool"
+ ;;
+ bash)
+ COMMAND=$(crush_get_tool_input command)
+ if [[ "$COMMAND" =~ ^(ls|cat|grep) ]]; then
+ crush_approve "Auto-approved safe bash command"
+ fi
+ ;;
+ view)
+ # Limit file reads to 1000 lines max for performance
+ crush_modify_input "limit" "1000"
+ ;;
+esac
+```
+
+### 3. PostToolUse
+
+**When**: After tool executes, before result sent to LLM
+**Use cases**: Filter output, redact secrets, log results
+**Location**: `.crush/hooks/post-tool-use/`
+
+**Available data** (via stdin JSON):
+- `tool_input` - Tool parameters (object)
+- `tool_output` - Tool result (object with `success`, `content`)
+- `execution_time_ms` - How long the tool took
+
+**Environment variables**:
+- `$CRUSH_TOOL_NAME` - Name of the tool
+- `$CRUSH_TOOL_CALL_ID` - Unique ID for this tool call
+
+**Example**:
+```bash
+#!/bin/bash
+# Redact sensitive information from tool output
+
+# Get tool output using helper (stdin is automatically available)
+OUTPUT_CONTENT=$(crush_get_input tool_output | jq -r '.content // empty')
+
+# Check if output contains sensitive patterns
+if echo "$OUTPUT_CONTENT" | grep -qE '(password|api[_-]?key|secret|token)'; then
+ # Redact sensitive data
+ REDACTED=$(echo "$OUTPUT_CONTENT" | sed -E 's/(password|api[_-]?key|secret|token)[[:space:]]*[:=][[:space:]]*[^[:space:]]+/\1=\[REDACTED\]/gi')
+ crush_modify_output "content" "$REDACTED"
+ crush_log "Redacted sensitive information from $CRUSH_TOOL_NAME output"
+fi
+```
+
+### 4. Stop
+
+**When**: When agent conversation loop stops or is cancelled
+**Use cases**: Save conversation state, cleanup session resources, archive logs
+**Location**: `.crush/hooks/stop/`
+
+**Available data** (via stdin JSON):
+- `reason` - Why the loop stopped (e.g., "completed", "cancelled", "error")
+- `session_id` - The session ID that stopped
+
+**Example**:
+```bash
+#!/bin/bash
+# Save conversation summary when agent loop stops
+
+REASON=$(crush_get_input reason)
+SESSION_ID=$(crush_get_input session_id)
+
+# Archive session logs
+if [ -f ".crush/session-$SESSION_ID.log" ]; then
+ ARCHIVE="logs/session-$SESSION_ID-$(date +%Y%m%d-%H%M%S).log"
+ mkdir -p logs
+ mv ".crush/session-$SESSION_ID.log" "$ARCHIVE"
+ gzip "$ARCHIVE"
+ crush_log "Archived session logs: $ARCHIVE.gz (reason: $REASON)"
+fi
+```
+
+## Catch-All Hooks
+
+Place hooks at the **root level** (`.crush/hooks/*.sh`) to run for **ALL hook types**:
+
+```bash
+#!/bin/bash
+# .crush/hooks/00-global-log.sh
+# This runs for every hook type
+
+echo "[$CRUSH_HOOK_TYPE] Session: $CRUSH_SESSION_ID" >> global.log
+```
+
+**Execution order**:
+1. Catch-all hooks (alphabetically sorted)
+2. Type-specific hooks (alphabetically sorted)
+
+Use `$CRUSH_HOOK_TYPE` to determine which event triggered the hook.
+
+## Helper Functions
+
+All hooks have access to these built-in functions (no sourcing required):
+
+### Permission Helpers
+
+#### `crush_approve [message]`
+Approve the current tool call (PreToolUse only).
+
+```bash
+crush_approve "Auto-approved read-only command"
+```
+
+#### `crush_deny [message]`
+Deny the current tool call and stop execution (PreToolUse only).
+
+```bash
+crush_deny "Blocked dangerous operation"
+# Script exits immediately with code 2
+```
+
+#### `crush_ask [message]`
+Ask user for permission (default behavior).
+
+```bash
+crush_ask "This command modifies files, please review"
+```
+
+### Context Helpers
+
+#### `crush_add_context "content"`
+Add raw text content to LLM context.
+
+```bash
+crush_add_context "Project uses React 18 with TypeScript"
+```
+
+#### `crush_add_context_file "path"`
+Load a file and add its content to LLM context.
+
+```bash
+crush_add_context_file "docs/ARCHITECTURE.md"
+crush_add_context_file "package.json"
+```
+
+### Modification Helpers
+
+#### `crush_modify_prompt "new_prompt"`
+Replace the user's prompt (UserPromptSubmit only).
+
+```bash
+PROMPT=$(crush_get_prompt)
+MODIFIED="$PROMPT\n\nNote: Always use TypeScript."
+crush_modify_prompt "$MODIFIED"
+```
+
+#### `crush_modify_input "param_name" "value"`
+Modify tool input parameters (PreToolUse only).
+
+Values are parsed as JSON when valid, supporting all JSON types (strings, numbers, booleans, arrays, objects).
+
+```bash
+# Strings (no quotes needed for simple strings)
+crush_modify_input "command" "ls -la"
+crush_modify_input "working_dir" "/tmp"
+
+# Numbers (parsed as JSON)
+crush_modify_input "offset" "100"
+crush_modify_input "limit" "50"
+
+# Booleans (parsed as JSON)
+crush_modify_input "run_in_background" "true"
+crush_modify_input "replace_all" "false"
+
+# Arrays (JSON format)
+crush_modify_input "ignore" '["*.log","*.tmp"]'
+
+# Quoted strings (for strings with spaces or special chars)
+crush_modify_input "message" '"hello world"'
+```
+
+#### `crush_modify_output "field_name" "value"`
+Modify tool output before sending to LLM (PostToolUse only).
+
+```bash
+# Redact sensitive information from tool output content
+crush_modify_output "content" "[REDACTED - sensitive data removed]"
+
+# Can also modify other fields in the tool_output object
+crush_modify_output "success" "false"
+```
+
+#### `crush_stop [message]`
+Stop execution immediately.
+
+```bash
+if [ "$(date +%H)" -lt 9 ]; then
+ crush_stop "Crush is only available during business hours"
+fi
+```
+
+### Input Parsing Helpers
+
+Hooks receive JSON context via stdin, which is automatically saved and available to all helper functions. You can call multiple helpers without manually reading stdin first.
+
+#### `crush_get_input "field_name"`
+Get a top-level field from the hook context.
+
+```bash
+# Can call multiple times without saving stdin
+PROMPT=$(crush_get_input prompt)
+MODEL=$(crush_get_input model)
+```
+
+#### `crush_get_tool_input "parameter"`
+Get a tool parameter (PreToolUse/PostToolUse only).
+
+```bash
+# Can call multiple times without saving stdin
+COMMAND=$(crush_get_tool_input command)
+FILE_PATH=$(crush_get_tool_input file_path)
+```
+
+#### `crush_get_prompt`
+Get the user's prompt (UserPromptSubmit only).
+
+```bash
+PROMPT=$(crush_get_prompt)
+if [[ "$PROMPT" =~ "password" ]]; then
+ crush_stop "Never include passwords in prompts"
+fi
+```
+
+### Logging Helper
+
+#### `crush_log "message"`
+Write to Crush's log (stderr).
+
+```bash
+crush_log "Processing hook for tool: $CRUSH_TOOL_NAME"
+```
+
+## Environment Variables
+
+All hooks have access to these environment variables:
+
+### Always Available
+- `$CRUSH_HOOK_TYPE` - Type of hook: `user-prompt-submit`, `pre-tool-use`, `post-tool-use`, `stop`
+- `$CRUSH_SESSION_ID` - Current session ID
+- `$CRUSH_WORKING_DIR` - Working directory
+
+### Tool Hooks (PreToolUse, PostToolUse)
+- `$CRUSH_TOOL_NAME` - Name of the tool being called
+- `$CRUSH_TOOL_CALL_ID` - Unique ID for this tool call
+
+## Result Communication
+
+Hooks communicate results back to Crush in two ways:
+
+### 1. Environment Variables (Simple)
+
+Export variables to set hook results:
+
+```bash
+export CRUSH_PERMISSION=approve
+export CRUSH_MESSAGE="Auto-approved"
+export CRUSH_CONTINUE=false
+export CRUSH_CONTEXT_CONTENT="Additional context"
+export CRUSH_CONTEXT_FILES="/path/to/file1.md:/path/to/file2.md"
+```
+
+**Available variables**:
+- `CRUSH_PERMISSION` - `approve`, `ask`, or `deny`
+- `CRUSH_MESSAGE` - User-facing message
+- `CRUSH_CONTINUE` - `true` or `false` (stop execution)
+- `CRUSH_MODIFIED_PROMPT` - New prompt text
+- `CRUSH_MODIFIED_INPUT` - Modified tool input (format: `key=value:key2=value2`, values parsed as JSON)
+- `CRUSH_MODIFIED_OUTPUT` - Modified tool output (format: `key=value:key2=value2`, values parsed as JSON)
+- `CRUSH_CONTEXT_CONTENT` - Text to add to LLM context
+- `CRUSH_CONTEXT_FILES` - Colon-separated file paths
+
+**Note**: `CRUSH_MODIFIED_INPUT` and `CRUSH_MODIFIED_OUTPUT` use `:` as delimiter between pairs. For complex values with multiple fields or nested structures, use JSON output instead (see below).
+
+### 2. JSON Output (Complex)
+
+Echo JSON to stdout for complex modifications:
+
+```bash
+echo '{
+ "permission": "approve",
+ "message": "Modified command",
+ "modified_input": {
+ "command": "ls -la --color=auto"
+ },
+ "context_content": "Added context"
+}'
+```
+
+**JSON fields**:
+- `continue` (bool) - Continue execution
+- `permission` (string) - `approve`, `ask`, `deny`
+- `message` (string) - User-facing message
+- `modified_prompt` (string) - New prompt
+- `modified_input` (object) - Modified tool parameters
+- `modified_output` (object) - Modified tool results
+- `context_content` (string) - Context to add
+- `context_files` (array) - File paths to load
+
+**Note**: Environment variables and JSON output are merged automatically.
+
+## Exit Codes
+
+- **0** - Success, continue execution
+- **1** - Error (PreToolUse: denies permission, others: logs and continues)
+- **2** - Deny/stop execution (sets `Continue=false`)
+
+```bash
+# Example: Check rate limit
+COUNT=$(grep -c "$(date +%Y-%m-%d)" usage.log)
+if [ "$COUNT" -gt 100 ]; then
+ echo "Rate limit exceeded" >&2
+ exit 2 # Stops execution
+fi
+```
+
+## Hook Ordering
+
+Hooks execute **sequentially** in alphabetical order. Use numeric prefixes to control order:
+
+```
+.crush/hooks/
+ 00-global-log.sh # Catch-all: runs first for all types
+ pre-tool-use/
+ 01-rate-limit.sh # Runs first
+ 02-auto-approve.sh # Runs second
+ 99-audit.sh # Runs last
+```
+
+## Result Merging
+
+When multiple hooks execute, their results are merged:
+
+### Permission (Most Restrictive Wins)
+- `deny` > `ask` > `approve`
+- If any hook denies, the final result is deny
+
+### Continue (AND Logic)
+- All hooks must set `Continue=true` (or not set it)
+- If any hook sets `Continue=false`, execution stops
+
+### Context (Append)
+- Context content from all hooks is concatenated
+- Context files from all hooks are combined
+
+### Messages (Append)
+- Messages are joined with `; ` separator
+
+### Modified Fields (Last Wins)
+- Modified prompt: last hook's value wins
+- Modified input/output: maps are merged, last value wins for conflicts
+
+## Configuration
+
+Configure hooks in `crush.json`:
+
+```json
+{
+ "hooks": {
+ "enabled": true,
+ "timeout_seconds": 30,
+ "directories": [
+ "/path/to/custom/hooks",
+ ".crush/hooks"
+ ],
+ "disabled": [
+ "pre-tool-use/slow-check.sh",
+ "user-prompt-submit/verbose.sh"
+ ],
+ "environment": {
+ "CUSTOM_VAR": "value"
+ },
+ "inline": {
+ "pre-tool-use": [{
+ "name": "rate-limit",
+ "script": "#!/bin/bash\n# Inline hook script here..."
+ }]
+ }
+ }
+}
+```
+
+### Configuration Options
+
+- **enabled** (bool) - Enable/disable the entire hooks system (default: `true`)
+- **timeout_seconds** (int) - Maximum execution time per hook (default: `30`)
+- **directories** ([]string) - Additional directories to search for hooks
+- **disabled** ([]string) - List of hook paths to skip (relative to hooks directory)
+- **environment** (map) - Environment variables to pass to all hooks
+- **inline** (map) - Hooks defined directly in config (by hook type)
+
+## Best Practices
+
+### 1. Keep Hooks Fast
+Hooks run synchronously. Keep them under 1 second to avoid slowing down the UI.
+
+```bash
+# Bad: Slow network call
+curl -X POST https://api.example.com/log
+
+# Good: Log locally, sync in background
+echo "$LOG_ENTRY" >> audit.log
+```
+
+### 2. Handle Errors Gracefully
+Don't let hooks crash. Use error handling:
+
+```bash
+BRANCH=$(git branch --show-current 2>/dev/null)
+if [ -n "$BRANCH" ]; then
+ crush_add_context "Branch: $BRANCH"
+fi
+```
+
+### 3. Use Descriptive Names
+Use numeric prefixes and descriptive names:
+
+```bash
+01-security-check.sh # Good
+99-audit-log.sh # Good
+hook.sh # Bad
+```
+
+### 4. Test Hooks Independently
+Run hooks manually to test:
+
+```bash
+export CRUSH_HOOK_TYPE=pre-tool-use
+export CRUSH_TOOL_NAME=bash
+echo '{"tool_input":{"command":"rm -rf /"}}' | .crush/hooks/pre-tool-use/01-block-dangerous.sh
+echo "Exit code: $?"
+```
+
+### 5. Log for Debugging
+Use `crush_log` to debug hook execution:
+
+```bash
+crush_log "Checking command: $COMMAND"
+if [[ "$COMMAND" =~ "dangerous" ]]; then
+ crush_log "Blocking dangerous command"
+ crush_deny "Command blocked"
+fi
+```
+
+### 6. Don't Block on I/O
+Avoid blocking operations:
+
+```bash
+# Bad: Waits for user input
+read -p "Continue? " answer
+
+# Bad: Long-running process
+./expensive-analysis.sh
+
+# Good: Quick checks
+[ -f ".allowed" ] && crush_approve
+```
@@ -0,0 +1,35 @@
+package hooks
+
+// Config defines hook system configuration.
+type Config struct {
+ // Enabled controls whether hooks are executed.
+ Enabled bool
+
+ // TimeoutSeconds is the maximum time a hook can run.
+ TimeoutSeconds int
+
+ // Directories are additional directories to search for hooks.
+ // Defaults to [".crush/hooks"] if empty.
+ Directories []string
+
+ // Inline hooks defined directly in configuration.
+ // Map key is the hook type (e.g., "pre-tool-use").
+ Inline map[string][]InlineHook
+
+ // Disabled is a list of hook paths to skip.
+ // Paths are relative to the hooks directory.
+ // Example: ["pre-tool-use/02-slow-check.sh"]
+ Disabled []string
+
+ // Environment variables to pass to hooks.
+ Environment map[string]string
+}
+
+// InlineHook is a hook defined inline in the config.
+type InlineHook struct {
+ // Name is the name of the hook (used as filename).
+ Name string
+
+ // Script is the bash script content.
+ Script string
+}
@@ -0,0 +1,578 @@
+package hooks
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestReadmeExamples tests that all examples from the README work as documented.
+func TestReadmeExamples(t *testing.T) {
+ t.Parallel()
+
+ t.Run("block dangerous commands", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ hookScript := `#!/bin/bash
+if [ "$CRUSH_TOOL_NAME" = "bash" ]; then
+ COMMAND=$(crush_get_tool_input command)
+ if [[ "$COMMAND" =~ "rm -rf /" ]]; then
+ crush_deny "Blocked dangerous command"
+ fi
+fi
+`
+ hookPath := filepath.Join(hooksDir, "01-block-dangerous.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ // Test: Should block "rm -rf /"
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ ToolName: "bash",
+ ToolCallID: "call-1",
+ Data: map[string]any{
+ "tool_input": map[string]any{
+ "command": "rm -rf /",
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue, "Should stop execution for dangerous command")
+ assert.Equal(t, "deny", result.Permission)
+ assert.Contains(t, result.Message, "Blocked dangerous command")
+
+ // Test: Should allow safe commands
+ result2, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ ToolName: "bash",
+ ToolCallID: "call-2",
+ Data: map[string]any{
+ "tool_input": map[string]any{
+ "command": "ls -la",
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result2.Continue, "Should allow safe commands")
+ })
+
+ t.Run("auto-approve read-only tools", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ hookScript := `#!/bin/bash
+case "$CRUSH_TOOL_NAME" in
+ view|ls|grep|glob)
+ crush_approve "Auto-approved read-only tool"
+ ;;
+ bash)
+ COMMAND=$(crush_get_tool_input command)
+ if [[ "$COMMAND" =~ ^(ls|cat|grep) ]]; then
+ crush_approve "Auto-approved safe bash command"
+ fi
+ ;;
+esac
+`
+ hookPath := filepath.Join(hooksDir, "01-auto-approve.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ // Test: Should auto-approve view tool
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ ToolName: "view",
+ ToolCallID: "call-1",
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Contains(t, result.Message, "Auto-approved read-only tool")
+
+ // Test: Should auto-approve safe bash commands
+ result2, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ ToolName: "bash",
+ ToolCallID: "call-2",
+ Data: map[string]any{
+ "tool_input": map[string]any{
+ "command": "ls -la",
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result2.Continue)
+ assert.Equal(t, "approve", result2.Permission)
+ assert.Contains(t, result2.Message, "Auto-approved safe bash command")
+ })
+
+ t.Run("add git context", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Initialize git repo with a branch
+ gitDir := filepath.Join(tempDir, ".git")
+ require.NoError(t, os.MkdirAll(gitDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
+
+ hookScript := `#!/bin/bash
+BRANCH=$(git branch --show-current 2>/dev/null)
+if [ -n "$BRANCH" ]; then
+ crush_add_context "Current branch: $BRANCH"
+fi
+
+if [ -f "README.md" ]; then
+ crush_add_context_file "README.md"
+fi
+`
+ hookPath := filepath.Join(hooksDir, "01-add-context.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ // Create README.md
+ readmePath := filepath.Join(tempDir, "README.md")
+ require.NoError(t, os.WriteFile(readmePath, []byte("# Test Project\n"), 0o644))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{
+ "prompt": "help me",
+ },
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ // Should add context file (using relative path)
+ require.Len(t, result.ContextFiles, 1)
+ assert.Equal(t, "README.md", result.ContextFiles[0])
+ })
+
+ t.Run("audit logging", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "post-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ auditFile := filepath.Join(tempDir, "audit.log")
+ hookScript := `#!/bin/bash
+AUDIT_FILE="` + auditFile + `"
+TIMESTAMP=$(date -Iseconds)
+echo "$TIMESTAMP|$CRUSH_TOOL_NAME|$CRUSH_TOOL_CALL_ID" >> "$AUDIT_FILE"
+`
+ hookPath := filepath.Join(hooksDir, "01-audit.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookPostToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ ToolName: "bash",
+ ToolCallID: "call-123",
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+
+ // Verify audit log was written
+ content, err := os.ReadFile(auditFile)
+ require.NoError(t, err)
+ assert.Contains(t, string(content), "bash|call-123")
+ })
+
+ t.Run("catch-all hook", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ logFile := filepath.Join(tempDir, "global.log")
+ hookScript := `#!/bin/bash
+echo "Hook: $CRUSH_HOOK_TYPE" >> "` + logFile + `"
+`
+ hookPath := filepath.Join(hooksDir, "00-global-log.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ // Test with different hook types
+ _, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+ require.NoError(t, err)
+
+ _, err = manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+ require.NoError(t, err)
+
+ // Verify both hook types were logged
+ content, err := os.ReadFile(logFile)
+ require.NoError(t, err)
+ assert.Contains(t, string(content), "Hook: pre-tool-use")
+ assert.Contains(t, string(content), "Hook: user-prompt-submit")
+ })
+
+ t.Run("rate limiting", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ usageLog := filepath.Join(tempDir, "usage.log")
+ // Pre-populate with entries
+ today := "2024-01-15" // Fixed date for testing
+ for i := 0; i < 5; i++ {
+ f, err := os.OpenFile(usageLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ require.NoError(t, err)
+ _, err = f.WriteString(today + "\n")
+ require.NoError(t, err)
+ f.Close()
+ }
+
+ hookScript := `#!/bin/bash
+COUNT=$(grep -c "2024-01-15" "` + usageLog + `" 2>/dev/null || echo "0")
+if [ "$COUNT" -ge 3 ]; then
+ export CRUSH_CONTINUE=false
+ export CRUSH_MESSAGE="Rate limit exceeded"
+fi
+`
+ hookPath := filepath.Join(hooksDir, "01-rate-limit.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue, "Should stop execution when rate limit exceeded")
+ assert.Contains(t, result.Message, "Rate limit exceeded")
+ })
+
+ t.Run("conditional context", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Create package.json
+ packageJSON := filepath.Join(tempDir, "package.json")
+ require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name": "test"}`), 0o644))
+
+ hookScript := `#!/bin/bash
+if [ -f "package.json" ]; then
+ crush_add_context_file "package.json"
+fi
+`
+ hookPath := filepath.Join(hooksDir, "01-conditional.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ require.Len(t, result.ContextFiles, 1)
+ assert.Equal(t, "package.json", result.ContextFiles[0])
+ })
+
+ t.Run("JSON output example", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ hookScript := `#!/bin/bash
+COMMAND=$(crush_get_tool_input command)
+SAFE_CMD=$(echo "$COMMAND" | sed 's/--force//')
+echo "{\"modified_input\": {\"command\": \"$SAFE_CMD\"}}"
+`
+ hookPath := filepath.Join(hooksDir, "01-modify.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ ToolName: "bash",
+ ToolCallID: "call-1",
+ Data: map[string]any{
+ "tool_input": map[string]any{
+ "command": "rm --force file.txt",
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ require.NotNil(t, result.ModifiedInput)
+ assert.Equal(t, "rm file.txt", result.ModifiedInput["command"])
+ })
+
+ t.Run("environment variables example", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ hookScript := `#!/bin/bash
+export CRUSH_PERMISSION=approve
+export CRUSH_MESSAGE="Auto-approved"
+`
+ hookPath := filepath.Join(hooksDir, "01-env-vars.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Equal(t, "Auto-approved", result.Message)
+ })
+
+ t.Run("exit codes example", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ usageLog := filepath.Join(tempDir, "usage.log")
+ // Create usage log with entries
+ for i := 0; i < 150; i++ {
+ f, err := os.OpenFile(usageLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ require.NoError(t, err)
+ _, err = f.WriteString("2024-01-15\n")
+ require.NoError(t, err)
+ f.Close()
+ }
+
+ hookScript := `#!/bin/bash
+COUNT=$(grep -c "2024-01-15" "` + usageLog + `")
+if [ "$COUNT" -gt 100 ]; then
+ echo "Rate limit exceeded" >&2
+ exit 2 # Stops execution
+fi
+`
+ hookPath := filepath.Join(hooksDir, "01-exit-code.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue, "Exit code 2 should stop execution")
+ })
+
+ t.Run("helper functions comprehensive test", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Test all helper functions in one hook
+ hookScript := `#!/bin/bash
+# Read stdin once into variable
+CONTEXT=$(cat)
+
+# Test input parsing
+PROMPT=$(echo "$CONTEXT" | crush_get_prompt)
+MODEL=$(echo "$CONTEXT" | crush_get_input model)
+
+# Test context helpers
+crush_add_context "Using model: $MODEL"
+
+# Test logging
+crush_log "Processing prompt"
+
+# Test modification
+export CRUSH_MODIFIED_PROMPT="Enhanced: $PROMPT"
+`
+ hookPath := filepath.Join(hooksDir, "01-helpers.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{
+ "prompt": "original prompt",
+ "model": "gpt-4",
+ },
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ assert.Contains(t, result.ContextContent, "Using model: gpt-4")
+ require.NotNil(t, result.ModifiedPrompt)
+ // Trim any trailing whitespace/CRLF for cross-platform compatibility
+ assert.Equal(t, "Enhanced: original prompt", strings.TrimSpace(*result.ModifiedPrompt))
+ })
+
+ t.Run("is_first_message flag", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Hook that adds README only on first message
+ hookScript := `#!/bin/bash
+IS_FIRST=$(crush_get_input is_first_message)
+if [ "$IS_FIRST" = "true" ]; then
+ crush_add_context "This is the first message"
+else
+ crush_add_context "This is a follow-up message"
+fi
+`
+ hookPath := filepath.Join(hooksDir, "01-first-msg.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ // Test: First message
+ result1, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{
+ "prompt": "first prompt",
+ "is_first_message": true,
+ },
+ })
+ require.NoError(t, err)
+ assert.Contains(t, result1.ContextContent, "This is the first message")
+
+ // Test: Follow-up message
+ result2, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{
+ "prompt": "follow-up prompt",
+ "is_first_message": false,
+ },
+ })
+ require.NoError(t, err)
+ assert.Contains(t, result2.ContextContent, "This is a follow-up message")
+ })
+}
+
+// TestReadmeQuickExamples tests the quick examples from the quick reference.
+func TestReadmeQuickExamples(t *testing.T) {
+ t.Parallel()
+
+ t.Run("hook ordering", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Create hooks with specific order
+ hook1 := `#!/bin/bash
+export CRUSH_MESSAGE="first"
+`
+ hook2 := `#!/bin/bash
+export CRUSH_MESSAGE="${CRUSH_MESSAGE:-}; second"
+`
+ hook3 := `#!/bin/bash
+export CRUSH_MESSAGE="${CRUSH_MESSAGE:-}; third"
+`
+
+ require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "01-first.sh"), []byte(hook1), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "02-second.sh"), []byte(hook2), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "99-third.sh"), []byte(hook3), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ // Messages should be merged in order
+ assert.Contains(t, result.Message, "first")
+ assert.Contains(t, result.Message, "second")
+ assert.Contains(t, result.Message, "third")
+ })
+
+ t.Run("mixed env vars and JSON", func(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ hookScript := `#!/bin/bash
+# Set via environment variable
+export CRUSH_PERMISSION=approve
+
+# Output via JSON
+echo '{"message": "Combined output", "modified_input": {"key": "value"}}'
+`
+ hookPath := filepath.Join(hooksDir, "01-mixed.sh")
+ require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
+
+ manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
+
+ result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Equal(t, "Combined output", result.Message)
+ assert.Equal(t, "value", result.ModifiedInput["key"])
+ })
+}
@@ -0,0 +1,101 @@
+package hooks
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/charmbracelet/crush/internal/shell"
+)
+
+//go:embed helpers.sh
+var helpersScript string
+
+// Executor executes individual hook scripts.
+type Executor struct {
+ workingDir string
+}
+
+// NewExecutor creates a new hook executor.
+func NewExecutor(workingDir string) *Executor {
+ return &Executor{workingDir: workingDir}
+}
+
+// Execute runs a single hook script and returns the result.
+func (e *Executor) Execute(ctx context.Context, hookPath string, context HookContext) (*HookResult, error) {
+ hookScript, err := os.ReadFile(hookPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read hook: %w", err)
+ }
+
+ contextJSON, err := json.Marshal(context.Data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal context: %w", err)
+ }
+
+ // Wrap user hook in a function and prepend helper functions
+ // Read stdin before calling the function, then export it
+ fullScript := fmt.Sprintf(`%s
+
+# Save stdin to variable before entering function
+_CRUSH_STDIN=$(cat)
+export _CRUSH_STDIN
+
+_crush_hook_main() {
+%s
+}
+
+_crush_hook_main
+`, helpersScript, string(hookScript))
+
+ env := append(os.Environ(),
+ "CRUSH_HOOK_TYPE="+string(context.HookType),
+ "CRUSH_SESSION_ID="+context.SessionID,
+ "CRUSH_WORKING_DIR="+context.WorkingDir,
+ )
+
+ if context.ToolName != "" {
+ env = append(env,
+ "CRUSH_TOOL_NAME="+context.ToolName,
+ "CRUSH_TOOL_CALL_ID="+context.ToolCallID,
+ )
+ }
+
+ for k, v := range context.Environment {
+ env = append(env, k+"="+v)
+ }
+
+ hookShell := shell.NewShell(&shell.Options{
+ WorkingDir: context.WorkingDir,
+ Env: env,
+ })
+
+ // Pass JSON context via stdin instead of heredoc
+ stdin := strings.NewReader(string(contextJSON))
+ stdout, stderr, err := hookShell.ExecWithStdin(ctx, fullScript, stdin)
+
+ result := parseShellEnv(hookShell.GetEnv())
+ exitCode := shell.ExitCode(err)
+ switch exitCode {
+ case 2:
+ result.Continue = false
+ case 1:
+ return nil, fmt.Errorf("hook failed with exit code 1: %w\nstderr: %s", err, stderr)
+ }
+
+ if trimmed := strings.TrimSpace(stdout); len(trimmed) > 0 && trimmed[0] == '{' {
+ if jsonResult, parseErr := parseJSONResult([]byte(trimmed)); parseErr == nil {
+ mergeJSONResult(result, jsonResult)
+ }
+ }
+
+ return result, nil
+}
+
+// GetHelpersScript returns the embedded helper script for display.
+func GetHelpersScript() string {
+ return helpersScript
+}
@@ -0,0 +1,395 @@
+package hooks
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExecutor(t *testing.T) {
+ // Create temp directory for test hooks.
+ tempDir := t.TempDir()
+
+ t.Run("executes simple hook with env vars", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "test-hook.sh")
+ hookScript := `#!/bin/bash
+export CRUSH_PERMISSION=approve
+export CRUSH_MESSAGE="test message"
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{
+ "tool_input": map[string]any{
+ "command": "ls",
+ },
+ },
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Equal(t, "test message", result.Message)
+ })
+
+ t.Run("helper functions are available", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "helper-test.sh")
+ hookScript := `#!/bin/bash
+crush_approve "auto approved"
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Equal(t, "auto approved", result.Message)
+ })
+
+ t.Run("crush_deny sets continue=false and exits", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "deny-test.sh")
+ hookScript := `#!/bin/bash
+crush_deny "blocked"
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue)
+ assert.Equal(t, "deny", result.Permission)
+ assert.Equal(t, "blocked", result.Message)
+ })
+
+ t.Run("reads JSON from stdin", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "stdin-test.sh")
+ hookScript := `#!/bin/bash
+COMMAND=$(crush_get_tool_input command)
+if [ "$COMMAND" = "dangerous" ]; then
+ crush_deny "dangerous command"
+fi
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{
+ "tool_input": map[string]any{
+ "command": "dangerous",
+ },
+ },
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue)
+ assert.Equal(t, "deny", result.Permission)
+ })
+
+ t.Run("env variables are set correctly", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "env-test.sh")
+ hookScript := `#!/bin/bash
+if [ "$CRUSH_HOOK_TYPE" = "pre-tool-use" ] && \
+ [ "$CRUSH_SESSION_ID" = "test-123" ] && \
+ [ "$CRUSH_TOOL_NAME" = "bash" ]; then
+ export CRUSH_MESSAGE="env vars correct"
+fi
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-123",
+ WorkingDir: tempDir,
+ ToolName: "bash",
+ ToolCallID: "call-123",
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, "env vars correct", result.Message)
+ })
+
+ t.Run("supports JSON output for complex mutations", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "json-test.sh")
+ hookScript := `#!/bin/bash
+cat <<EOF
+{
+ "permission": "approve",
+ "modified_input": {
+ "command": "ls -la",
+ "safe": true
+ }
+}
+EOF
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Equal(t, "ls -la", result.ModifiedInput["command"])
+ assert.Equal(t, true, result.ModifiedInput["safe"])
+ })
+
+ t.Run("handles exit code 1 as error", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "error-test.sh")
+ hookScript := `#!/bin/bash
+echo "error occurred" >&2
+exit 1
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ _, err = executor.Execute(ctx, hookPath, hookCtx)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "hook failed with exit code 1")
+ })
+
+ t.Run("context files helper", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "files-test.sh")
+ hookScript := `#!/bin/bash
+crush_add_context_file "file1.md"
+crush_add_context_file "file2.txt"
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookUserPromptSubmit,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{"file1.md", "file2.txt"}, result.ContextFiles)
+ })
+
+ t.Run("context content helper", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "content-test.sh")
+ hookScript := `#!/bin/bash
+crush_add_context "This is additional context"
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookUserPromptSubmit,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, "This is additional context", result.ContextContent)
+ })
+
+ t.Run("returns error if hook file doesn't exist", func(t *testing.T) {
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ _, err := executor.Execute(ctx, "/nonexistent/hook.sh", hookCtx)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to read hook")
+ })
+
+ t.Run("passes custom environment variables", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "custom-env-test.sh")
+ hookScript := `#!/bin/bash
+if [ "$CUSTOM_API_KEY" = "secret123" ] && [ "$CUSTOM_REGION" = "us-west-2" ]; then
+ export CRUSH_MESSAGE="custom env vars set correctly"
+fi
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ Environment: map[string]string{
+ "CUSTOM_API_KEY": "secret123",
+ "CUSTOM_REGION": "us-west-2",
+ },
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, "custom env vars set correctly", result.Message)
+ })
+
+ t.Run("modify input helper function", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "modify-input-test.sh")
+ hookScript := `#!/bin/bash
+crush_modify_input "command" "ls -la"
+crush_modify_input "working_dir" "/tmp"
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ require.NotNil(t, result.ModifiedInput)
+ assert.Equal(t, "ls -la", result.ModifiedInput["command"])
+ assert.Equal(t, "/tmp", result.ModifiedInput["working_dir"])
+ })
+
+ t.Run("modify output helper function", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "modify-output-test.sh")
+ hookScript := `#!/bin/bash
+crush_modify_output "status" "redacted"
+crush_modify_output "data" "[REDACTED]"
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPostToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ require.NotNil(t, result.ModifiedOutput)
+ assert.Equal(t, "redacted", result.ModifiedOutput["status"])
+ assert.Equal(t, "[REDACTED]", result.ModifiedOutput["data"])
+ })
+
+ t.Run("modify input with JSON types", func(t *testing.T) {
+ hookPath := filepath.Join(tempDir, "modify-input-json-test.sh")
+ hookScript := `#!/bin/bash
+crush_modify_input "offset" "100"
+crush_modify_input "limit" "50"
+crush_modify_input "run_in_background" "true"
+crush_modify_input "ignore" '["*.log","*.tmp"]'
+`
+ err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
+ require.NoError(t, err)
+
+ executor := NewExecutor(tempDir)
+ ctx := context.Background()
+ hookCtx := HookContext{
+ HookType: HookPreToolUse,
+ SessionID: "test-session",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ }
+
+ result, err := executor.Execute(ctx, hookPath, hookCtx)
+
+ require.NoError(t, err)
+ require.NotNil(t, result.ModifiedInput)
+ assert.Equal(t, float64(100), result.ModifiedInput["offset"])
+ assert.Equal(t, float64(50), result.ModifiedInput["limit"])
+ assert.Equal(t, true, result.ModifiedInput["run_in_background"])
+ assert.Equal(t, []any{"*.log", "*.tmp"}, result.ModifiedInput["ignore"])
+ })
+}
+
+func TestGetHelpersScript(t *testing.T) {
+ script := GetHelpersScript()
+
+ assert.NotEmpty(t, script)
+ assert.Contains(t, script, "crush_approve")
+ assert.Contains(t, script, "crush_deny")
+ assert.Contains(t, script, "crush_add_context")
+}
@@ -0,0 +1,121 @@
+#!/bin/bash
+# Crush Hook Helper Functions
+# These functions are automatically available in all hooks.
+# No need to source this file - it's prepended automatically.
+
+# Permission helpers
+
+# Approve the current tool call.
+# Usage: crush_approve ["message"]
+crush_approve() {
+ export CRUSH_PERMISSION=approve
+ [ -n "$1" ] && export CRUSH_MESSAGE="$1"
+}
+
+# Deny the current tool call.
+# Usage: crush_deny ["message"]
+crush_deny() {
+ export CRUSH_PERMISSION=deny
+ export CRUSH_CONTINUE=false
+ [ -n "$1" ] && export CRUSH_MESSAGE="$1"
+ exit 2
+}
+
+# Ask user for permission (default behavior).
+# Usage: crush_ask ["message"]
+crush_ask() {
+ export CRUSH_PERMISSION=ask
+ [ -n "$1" ] && export CRUSH_MESSAGE="$1"
+}
+
+# Context helpers
+
+# Add raw text content to LLM context.
+# Usage: crush_add_context "content"
+crush_add_context() {
+ export CRUSH_CONTEXT_CONTENT="$1"
+}
+
+# Add a file to be loaded into LLM context.
+# Usage: crush_add_context_file "/path/to/file.md"
+crush_add_context_file() {
+ if [ -z "$CRUSH_CONTEXT_FILES" ]; then
+ export CRUSH_CONTEXT_FILES="$1"
+ else
+ export CRUSH_CONTEXT_FILES="$CRUSH_CONTEXT_FILES:$1"
+ fi
+}
+
+# Modification helpers
+
+# Modify the user prompt (UserPromptSubmit hooks only).
+# Usage: crush_modify_prompt "new prompt text"
+crush_modify_prompt() {
+ export CRUSH_MODIFIED_PROMPT="$1"
+}
+
+# Modify tool input parameters (PreToolUse hooks only).
+# Values are parsed as JSON when valid, supporting strings, numbers, booleans, arrays, objects.
+# Usage: crush_modify_input "param_name" "value"
+# Examples:
+# crush_modify_input "command" "ls -la"
+# crush_modify_input "offset" "100"
+# crush_modify_input "run_in_background" "true"
+# crush_modify_input "ignore" '["*.log","*.tmp"]'
+crush_modify_input() {
+ local key="$1"
+ local value="$2"
+ if [ -z "$CRUSH_MODIFIED_INPUT" ]; then
+ export CRUSH_MODIFIED_INPUT="$key=$value"
+ else
+ export CRUSH_MODIFIED_INPUT="$CRUSH_MODIFIED_INPUT:$key=$value"
+ fi
+}
+
+# Modify tool output (PostToolUse hooks only).
+# Usage: crush_modify_output "field_name" "value"
+crush_modify_output() {
+ local key="$1"
+ local value="$2"
+ if [ -z "$CRUSH_MODIFIED_OUTPUT" ]; then
+ export CRUSH_MODIFIED_OUTPUT="$key=$value"
+ else
+ export CRUSH_MODIFIED_OUTPUT="$CRUSH_MODIFIED_OUTPUT:$key=$value"
+ fi
+}
+
+# Stop execution.
+# Usage: crush_stop ["message"]
+crush_stop() {
+ export CRUSH_CONTINUE=false
+ [ -n "$1" ] && export CRUSH_MESSAGE="$1"
+ exit 1
+}
+
+# Input parsing helpers
+# These read from the JSON context saved in _CRUSH_STDIN
+
+# Get a field from the hook context.
+# Usage: VALUE=$(crush_get_input "field_name")
+crush_get_input() {
+ echo "$_CRUSH_STDIN" | jq -r ".$1 // empty"
+}
+
+# Get a tool input parameter.
+# Usage: COMMAND=$(crush_get_tool_input "command")
+crush_get_tool_input() {
+ echo "$_CRUSH_STDIN" | jq -r ".tool_input.$1 // empty"
+}
+
+# Get the user prompt.
+# Usage: PROMPT=$(crush_get_prompt)
+crush_get_prompt() {
+ echo "$_CRUSH_STDIN" | jq -r '.prompt // empty'
+}
+
+# Logging helper.
+# Writes to stderr which is captured by Crush.
+# Usage: crush_log "debug message"
+crush_log() {
+ echo "[CRUSH HOOK] $*" >&2
+}
@@ -0,0 +1,285 @@
+package hooks
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "maps"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/csync"
+)
+
+type manager struct {
+ workingDir string
+ dataDir string
+ config *Config
+ executor *Executor
+ hooks *csync.Map[HookType, []string]
+}
+
+// NewManager creates a new hook manager.
+func NewManager(workingDir, dataDir string, cfg *Config) Manager {
+ if cfg == nil {
+ cfg = &Config{
+ Enabled: true,
+ TimeoutSeconds: 30,
+ Directories: []string{filepath.Join(dataDir, "hooks")},
+ }
+ }
+
+ // Ensure default directory if not specified.
+ if len(cfg.Directories) == 0 {
+ cfg.Directories = []string{filepath.Join(dataDir, "hooks")}
+ }
+
+ return &manager{
+ workingDir: workingDir,
+ dataDir: dataDir,
+ config: cfg,
+ executor: NewExecutor(workingDir),
+ hooks: csync.NewMap[HookType, []string](),
+ }
+}
+
+// isExecutable checks if a file is executable.
+// On Unix: checks execute permission bits for .sh files.
+// On Windows: only recognizes .sh extension (as we use POSIX shell emulator).
+func isExecutable(info os.FileInfo) bool {
+ name := strings.ToLower(info.Name())
+ if !strings.HasSuffix(name, ".sh") {
+ return false
+ }
+
+ if runtime.GOOS == "windows" {
+ return true
+ }
+ return info.Mode()&0o111 != 0
+}
+
+// ExecuteHooks implements Manager.
+func (m *manager) ExecuteHooks(ctx context.Context, hookType HookType, hookContext HookContext) (HookResult, error) {
+ if !m.config.Enabled {
+ return HookResult{Continue: true}, nil
+ }
+
+ hookContext.HookType = hookType
+ hookContext.Environment = m.config.Environment
+
+ hooks := m.discoverHooks(hookType)
+ if len(hooks) == 0 {
+ return HookResult{Continue: true}, nil
+ }
+
+ slog.Debug("Executing hooks", "type", hookType, "count", len(hooks))
+
+ accumulated := HookResult{Continue: true}
+ for _, hookPath := range hooks {
+ if m.isDisabled(hookPath) {
+ slog.Debug("Skipping disabled hook", "path", hookPath)
+ continue
+ }
+
+ hookCtx, cancel := context.WithTimeout(ctx, time.Duration(m.config.TimeoutSeconds)*time.Second)
+
+ result, err := m.executor.Execute(hookCtx, hookPath, hookContext)
+ cancel()
+
+ if err != nil {
+ slog.Error("Hook execution failed", "path", hookPath, "error", err)
+ if hookType == HookPreToolUse {
+ accumulated.Continue = false
+ accumulated.Permission = "deny"
+ accumulated.Message = fmt.Sprintf("Hook failed: %v", err)
+ return accumulated, nil
+ }
+ continue
+ }
+
+ if result.Message != "" {
+ slog.Info("Hook message", "path", hookPath, "message", result.Message)
+ }
+
+ m.mergeResults(&accumulated, result)
+
+ if !result.Continue {
+ slog.Info("Hook stopped execution", "path", hookPath)
+ break
+ }
+ }
+
+ return accumulated, nil
+}
+
+// discoverHooks finds all executable hooks for the given type.
+func (m *manager) discoverHooks(hookType HookType) []string {
+ if cached, ok := m.hooks.Get(hookType); ok {
+ return cached
+ }
+
+ var hooks []string
+
+ for _, dir := range m.config.Directories {
+ if _, err := os.Stat(dir); err == nil {
+ entries, err := os.ReadDir(dir)
+ if err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ hookPath := filepath.Join(dir, entry.Name())
+
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+
+ if !isExecutable(info) {
+ continue
+ }
+
+ hooks = append(hooks, hookPath)
+ slog.Debug("Discovered catch-all hook", "path", hookPath, "type", hookType)
+ }
+ }
+ }
+
+ hookDir := filepath.Join(dir, string(hookType))
+ if _, err := os.Stat(hookDir); os.IsNotExist(err) {
+ continue
+ }
+
+ entries, err := os.ReadDir(hookDir)
+ if err != nil {
+ slog.Error("Failed to read hooks directory", "dir", hookDir, "error", err)
+ continue
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ hookPath := filepath.Join(hookDir, entry.Name())
+
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+
+ if !isExecutable(info) {
+ slog.Debug("Skipping non-executable hook", "path", hookPath)
+ continue
+ }
+
+ hooks = append(hooks, hookPath)
+ }
+ }
+
+ if inlineHooks, ok := m.config.Inline[string(hookType)]; ok {
+ for _, inline := range inlineHooks {
+ hookPath, err := m.writeInlineHook(hookType, inline)
+ if err != nil {
+ slog.Error("Failed to write inline hook", "name", inline.Name, "error", err)
+ continue
+ }
+ hooks = append(hooks, hookPath)
+ }
+ }
+
+ sort.Strings(hooks)
+ m.hooks.Set(hookType, hooks)
+ return hooks
+}
+
+// writeInlineHook writes an inline hook script to a temp file.
+func (m *manager) writeInlineHook(hookType HookType, inline InlineHook) (string, error) {
+ tempDir := filepath.Join(m.dataDir, "hooks", ".inline", string(hookType))
+ if err := os.MkdirAll(tempDir, 0o755); err != nil {
+ return "", err
+ }
+
+ hookPath := filepath.Join(tempDir, inline.Name)
+ if err := os.WriteFile(hookPath, []byte(inline.Script), 0o755); err != nil {
+ return "", err
+ }
+
+ return hookPath, nil
+}
+
+// isDisabled checks if a hook is in the disabled list.
+func (m *manager) isDisabled(hookPath string) bool {
+ for _, dir := range m.config.Directories {
+ if rel, err := filepath.Rel(dir, hookPath); err == nil {
+ // Normalize to forward slashes for cross-platform comparison
+ rel = filepath.ToSlash(rel)
+ if slices.Contains(m.config.Disabled, rel) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// mergeResults merges a new result into the accumulated result.
+func (m *manager) mergeResults(accumulated *HookResult, new *HookResult) {
+ accumulated.Continue = accumulated.Continue && new.Continue
+
+ if new.Permission != "" {
+ if new.Permission == "deny" {
+ accumulated.Permission = "deny"
+ } else if new.Permission == "ask" && accumulated.Permission != "deny" {
+ accumulated.Permission = "ask"
+ } else if new.Permission == "approve" && accumulated.Permission == "" {
+ accumulated.Permission = "approve"
+ }
+ }
+
+ if new.ModifiedPrompt != nil {
+ accumulated.ModifiedPrompt = new.ModifiedPrompt
+ }
+
+ if len(new.ModifiedInput) > 0 {
+ if accumulated.ModifiedInput == nil {
+ accumulated.ModifiedInput = make(map[string]any)
+ }
+ maps.Copy(accumulated.ModifiedInput, new.ModifiedInput)
+ }
+
+ if len(new.ModifiedOutput) > 0 {
+ if accumulated.ModifiedOutput == nil {
+ accumulated.ModifiedOutput = make(map[string]any)
+ }
+ maps.Copy(accumulated.ModifiedOutput, new.ModifiedOutput)
+ }
+
+ if new.ContextContent != "" {
+ if accumulated.ContextContent == "" {
+ accumulated.ContextContent = new.ContextContent
+ } else {
+ accumulated.ContextContent += "\n\n" + new.ContextContent
+ }
+ }
+
+ accumulated.ContextFiles = append(accumulated.ContextFiles, new.ContextFiles...)
+
+ if new.Message != "" {
+ if accumulated.Message == "" {
+ accumulated.Message = new.Message
+ } else {
+ accumulated.Message += "; " + new.Message
+ }
+ }
+}
+
+// ListHooks implements Manager.
+func (m *manager) ListHooks(hookType HookType) []string {
+ return m.discoverHooks(hookType)
+}
@@ -0,0 +1,524 @@
+package hooks
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestManager(t *testing.T) {
+ t.Run("discovers hooks in order", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Create hooks with numeric prefixes.
+ hooks := []string{"02-second.sh", "01-first.sh", "03-third.sh"}
+ for _, name := range hooks {
+ path := filepath.Join(hooksDir, name)
+ err := os.WriteFile(path, []byte("#!/bin/bash\necho test"), 0o755)
+ require.NoError(t, err)
+ }
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ discovered := mgr.ListHooks(HookPreToolUse)
+
+ assert.Len(t, discovered, 3)
+ // Should be sorted alphabetically.
+ assert.Contains(t, discovered[0], "01-first.sh")
+ assert.Contains(t, discovered[1], "02-second.sh")
+ assert.Contains(t, discovered[2], "03-third.sh")
+ })
+
+ t.Run("skips non-executable files", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Create non-executable file.
+ path := filepath.Join(hooksDir, "non-executable.sh")
+ err := os.WriteFile(path, []byte("#!/bin/bash\necho test"), 0o644)
+ require.NoError(t, err)
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ discovered := mgr.ListHooks(HookPreToolUse)
+
+ // On Windows, .sh files are always considered executable
+ // On Unix, non-executable files (0o644) should be skipped
+ if runtime.GOOS == "windows" {
+ assert.Len(t, discovered, 1, "On Windows, .sh files are executable regardless of permissions")
+ } else {
+ assert.Len(t, discovered, 0, "On Unix, non-executable files should be skipped")
+ }
+ })
+
+ t.Run("discovers hooks by extension on all platforms", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Only .sh files are recognized as hooks.
+ // On Unix, they need execute permission. On Windows, extension is enough.
+ validHook := filepath.Join(hooksDir, "valid-hook.sh")
+ err := os.WriteFile(validHook, []byte("#!/bin/bash\necho test"), 0o755)
+ require.NoError(t, err)
+
+ // These should NOT be discovered (wrong extensions).
+ invalidFiles := []string{"hook.bat", "hook.cmd", "hook.ps1", "hook.txt"}
+ for _, name := range invalidFiles {
+ path := filepath.Join(hooksDir, name)
+ err := os.WriteFile(path, []byte("echo test"), 0o755)
+ require.NoError(t, err)
+ }
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ discovered := mgr.ListHooks(HookPreToolUse)
+
+ // Only the .sh file should be discovered.
+ assert.Len(t, discovered, 1)
+ assert.Contains(t, discovered[0], "valid-hook.sh")
+ })
+
+ t.Run("executes multiple hooks and merges results", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "user-prompt-submit")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Hook 1: Adds context.
+ hook1 := filepath.Join(hooksDir, "01-add-context.sh")
+ err := os.WriteFile(hook1, []byte(`#!/bin/bash
+crush_add_context "Context from hook 1"
+`), 0o755)
+ require.NoError(t, err)
+
+ // Hook 2: Adds more context.
+ hook2 := filepath.Join(hooksDir, "02-add-more.sh")
+ err = os.WriteFile(hook2, []byte(`#!/bin/bash
+crush_add_context "Context from hook 2"
+`), 0o755)
+ require.NoError(t, err)
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{
+ "prompt": "test prompt",
+ },
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ // Contexts should be merged with \n\n separator.
+ assert.Equal(t, "Context from hook 1\n\nContext from hook 2", result.ContextContent)
+ })
+
+ t.Run("stops on first hook that sets continue=false", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Hook 1: Denies.
+ hook1 := filepath.Join(hooksDir, "01-deny.sh")
+ err := os.WriteFile(hook1, []byte(`#!/bin/bash
+crush_deny "blocked"
+`), 0o755)
+ require.NoError(t, err)
+
+ // Hook 2: Should not execute.
+ hook2 := filepath.Join(hooksDir, "02-never-runs.sh")
+ err = os.WriteFile(hook2, []byte(`#!/bin/bash
+export CRUSH_MESSAGE="should not see this"
+`), 0o755)
+ require.NoError(t, err)
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue)
+ assert.Equal(t, "deny", result.Permission)
+ assert.Equal(t, "blocked", result.Message)
+ assert.NotContains(t, result.Message, "should not see this")
+ })
+
+ t.Run("merges permissions with deny winning", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Hook 1: Approves.
+ hook1 := filepath.Join(hooksDir, "01-approve.sh")
+ err := os.WriteFile(hook1, []byte(`#!/bin/bash
+export CRUSH_PERMISSION=approve
+`), 0o755)
+ require.NoError(t, err)
+
+ // Hook 2: Denies (should win).
+ hook2 := filepath.Join(hooksDir, "02-deny.sh")
+ err = os.WriteFile(hook2, []byte(`#!/bin/bash
+export CRUSH_PERMISSION=deny
+`), 0o755)
+ require.NoError(t, err)
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "deny", result.Permission)
+ })
+
+ t.Run("disabled hooks are skipped", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Hook 1: Should run.
+ hook1 := filepath.Join(hooksDir, "01-enabled.sh")
+ err := os.WriteFile(hook1, []byte(`#!/bin/bash
+export CRUSH_MESSAGE="enabled"
+`), 0o755)
+ require.NoError(t, err)
+
+ // Hook 2: Disabled.
+ hook2 := filepath.Join(hooksDir, "02-disabled.sh")
+ err = os.WriteFile(hook2, []byte(`#!/bin/bash
+export CRUSH_MESSAGE="disabled"
+`), 0o755)
+ require.NoError(t, err)
+
+ cfg := &Config{
+ Enabled: true,
+ TimeoutSeconds: 30,
+ Directories: []string{filepath.Join(dataDir, "hooks")},
+ Disabled: []string{"pre-tool-use/02-disabled.sh"},
+ }
+
+ mgr := NewManager(tempDir, dataDir, cfg)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "enabled", result.Message)
+ })
+
+ t.Run("inline hooks are executed", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+
+ cfg := &Config{
+ Enabled: true,
+ TimeoutSeconds: 30,
+ Directories: []string{filepath.Join(dataDir, "hooks")},
+ Inline: map[string][]InlineHook{
+ "user-prompt-submit": {
+ {
+ Name: "inline-test.sh",
+ Script: `#!/bin/bash
+export CRUSH_MESSAGE="inline hook executed"
+`,
+ },
+ },
+ },
+ }
+
+ mgr := NewManager(tempDir, dataDir, cfg)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "inline hook executed", result.Message)
+ })
+
+ t.Run("returns empty result when hooks disabled", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+
+ cfg := &Config{
+ Enabled: false,
+ }
+
+ mgr := NewManager(tempDir, dataDir, cfg)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ assert.Empty(t, result.Message)
+ })
+
+ t.Run("returns empty result when no hooks found", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ })
+
+ t.Run("handles hook failure on PreToolUse by denying", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Hook that fails with exit 1.
+ hook := filepath.Join(hooksDir, "01-fail.sh")
+ err := os.WriteFile(hook, []byte(`#!/bin/bash
+exit 1
+`), 0o755)
+ require.NoError(t, err)
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue)
+ assert.Equal(t, "deny", result.Permission)
+ assert.Contains(t, result.Message, "Hook failed")
+ })
+
+ t.Run("caches discovered hooks", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ hook := filepath.Join(hooksDir, "01-test.sh")
+ err := os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755)
+ require.NoError(t, err)
+
+ mgr := NewManager(tempDir, dataDir, nil)
+
+ // First call - discovers.
+ hooks1 := mgr.ListHooks(HookPreToolUse)
+ assert.Len(t, hooks1, 1)
+
+ // Second call - should use cache.
+ hooks2 := mgr.ListHooks(HookPreToolUse)
+ assert.Equal(t, hooks1, hooks2)
+ })
+
+ t.Run("catch-all hooks at root execute for all types", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Create catch-all hook at root level.
+ catchAllHook := filepath.Join(hooksDir, "00-catch-all.sh")
+ err := os.WriteFile(catchAllHook, []byte(`#!/bin/bash
+export CRUSH_MESSAGE="catch-all: $CRUSH_HOOK_TYPE"
+`), 0o755)
+ require.NoError(t, err)
+
+ // Create specific hook for pre-tool-use.
+ specificDir := filepath.Join(hooksDir, "pre-tool-use")
+ require.NoError(t, os.MkdirAll(specificDir, 0o755))
+ specificHook := filepath.Join(specificDir, "01-specific.sh")
+ err = os.WriteFile(specificHook, []byte(`#!/bin/bash
+export CRUSH_MESSAGE="$CRUSH_MESSAGE; specific hook"
+`), 0o755)
+ require.NoError(t, err)
+
+ mgr := NewManager(tempDir, dataDir, nil)
+
+ // Test PreToolUse - should execute both catch-all and specific.
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.Contains(t, result.Message, "catch-all: pre-tool-use")
+ assert.Contains(t, result.Message, "specific hook")
+
+ // Test UserPromptSubmit - should only execute catch-all.
+ result2, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "catch-all: user-prompt-submit", result2.Message)
+ assert.NotContains(t, result2.Message, "specific hook")
+ })
+
+ t.Run("passes environment variables from config to hooks", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Hook that checks for custom environment variables.
+ hook := filepath.Join(hooksDir, "01-check-env.sh")
+ err := os.WriteFile(hook, []byte(`#!/bin/bash
+if [ "$CUSTOM_API_KEY" = "test-key-123" ] && [ "$CUSTOM_ENV" = "production" ]; then
+ export CRUSH_MESSAGE="config environment variables received"
+else
+ export CRUSH_MESSAGE="environment variables missing"
+fi
+`), 0o755)
+ require.NoError(t, err)
+
+ cfg := &Config{
+ Enabled: true,
+ TimeoutSeconds: 30,
+ Directories: []string{filepath.Join(dataDir, "hooks")},
+ Environment: map[string]string{
+ "CUSTOM_API_KEY": "test-key-123",
+ "CUSTOM_ENV": "production",
+ },
+ }
+
+ mgr := NewManager(tempDir, dataDir, cfg)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "config environment variables received", result.Message)
+ })
+
+ t.Run("handles inline hook write failure gracefully", func(t *testing.T) {
+ tempDir := t.TempDir()
+ // Use a read-only directory as dataDir to force write failure.
+ readOnlyDir := filepath.Join(tempDir, "readonly")
+ require.NoError(t, os.MkdirAll(readOnlyDir, 0o555)) // Read-only
+
+ cfg := &Config{
+ Enabled: true,
+ TimeoutSeconds: 30,
+ Directories: []string{filepath.Join(readOnlyDir, "hooks")},
+ Inline: map[string][]InlineHook{
+ "pre-tool-use": {
+ {
+ Name: "inline-fail.sh",
+ Script: "#!/bin/bash\necho test",
+ },
+ },
+ },
+ }
+
+ mgr := NewManager(tempDir, readOnlyDir, cfg)
+ ctx := context.Background()
+
+ // Should not error even though inline hook write fails.
+ // The hook will be skipped and logged.
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue) // Should continue despite write failure
+ })
+
+ t.Run("handles hooks directory read failure gracefully", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Create a hook file.
+ hook := filepath.Join(hooksDir, "01-test.sh")
+ require.NoError(t, os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755))
+
+ mgr := NewManager(tempDir, dataDir, nil)
+
+ // Make directory unreadable after discovery to test error path.
+ // Note: This is tricky to test reliably cross-platform.
+ // On some systems, we can't make a directory unreadable if we own it.
+ // We'll test that hooks are cached and re-discovery works.
+ hooks1 := mgr.ListHooks(HookPreToolUse)
+ assert.Len(t, hooks1, 1)
+
+ // Add another hook.
+ hook2 := filepath.Join(hooksDir, "02-test.sh")
+ require.NoError(t, os.WriteFile(hook2, []byte("#!/bin/bash\necho test2"), 0o755))
+
+ // Should still return cached hooks (won't see new one).
+ hooks2 := mgr.ListHooks(HookPreToolUse)
+ assert.Len(t, hooks2, 1, "hooks are cached, new hook not seen")
+ })
+
+ t.Run("approve permission is set when accumulated is empty", func(t *testing.T) {
+ tempDir := t.TempDir()
+ dataDir := filepath.Join(tempDir, ".crush")
+ hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
+ require.NoError(t, os.MkdirAll(hooksDir, 0o755))
+
+ // Single hook that approves.
+ hook := filepath.Join(hooksDir, "01-approve.sh")
+ require.NoError(t, os.WriteFile(hook, []byte(`#!/bin/bash
+export CRUSH_PERMISSION=approve
+export CRUSH_MESSAGE="auto-approved"
+`), 0o755))
+
+ mgr := NewManager(tempDir, dataDir, nil)
+ ctx := context.Background()
+ result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
+ SessionID: "test",
+ WorkingDir: tempDir,
+ Data: map[string]any{},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Equal(t, "auto-approved", result.Message)
+ })
+}
@@ -0,0 +1,183 @@
+package hooks
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+)
+
+// parseShellEnv parses hook results from environment variables.
+func parseShellEnv(env []string) *HookResult {
+ result := &HookResult{Continue: true}
+
+ for _, line := range env {
+ if !strings.HasPrefix(line, "CRUSH_") {
+ continue
+ }
+
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+
+ switch key {
+ case "CRUSH_CONTINUE":
+ result.Continue = value == "true"
+
+ case "CRUSH_PERMISSION":
+ result.Permission = value
+
+ case "CRUSH_MESSAGE":
+ result.Message = value
+
+ case "CRUSH_MODIFIED_PROMPT":
+ result.ModifiedPrompt = &value
+
+ case "CRUSH_CONTEXT_CONTENT":
+ if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
+ result.ContextContent = string(decoded)
+ } else {
+ result.ContextContent = value
+ }
+
+ case "CRUSH_CONTEXT_FILES":
+ if value != "" {
+ result.ContextFiles = strings.Split(value, ":")
+ }
+
+ case "CRUSH_MODIFIED_INPUT":
+ if value != "" {
+ result.ModifiedInput = parseKeyValuePairs(value)
+ }
+
+ case "CRUSH_MODIFIED_OUTPUT":
+ if value != "" {
+ result.ModifiedOutput = parseKeyValuePairs(value)
+ }
+ }
+ }
+
+ return result
+}
+
+// parseJSONResult parses hook results from JSON output.
+func parseJSONResult(data []byte) (*HookResult, error) {
+ result := &HookResult{Continue: true}
+
+ var raw map[string]any
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return nil, err
+ }
+
+ if v, ok := raw["continue"].(bool); ok {
+ result.Continue = v
+ }
+
+ if v, ok := raw["permission"].(string); ok {
+ result.Permission = v
+ }
+
+ if v, ok := raw["message"].(string); ok {
+ result.Message = v
+ }
+
+ if v, ok := raw["modified_prompt"].(string); ok {
+ result.ModifiedPrompt = &v
+ }
+
+ if v, ok := raw["modified_input"].(map[string]any); ok {
+ result.ModifiedInput = v
+ }
+
+ if v, ok := raw["modified_output"].(map[string]any); ok {
+ result.ModifiedOutput = v
+ }
+
+ if v, ok := raw["context_content"].(string); ok {
+ result.ContextContent = v
+ }
+
+ if v, ok := raw["context_files"].([]any); ok {
+ for _, file := range v {
+ if s, ok := file.(string); ok {
+ result.ContextFiles = append(result.ContextFiles, s)
+ }
+ }
+ }
+
+ return result, nil
+}
+
+// mergeJSONResult merges JSON-parsed result into env-parsed result.
+func mergeJSONResult(base *HookResult, jsonResult *HookResult) {
+ if !jsonResult.Continue {
+ base.Continue = false
+ }
+
+ if jsonResult.Permission != "" {
+ base.Permission = jsonResult.Permission
+ }
+
+ if jsonResult.Message != "" {
+ if base.Message == "" {
+ base.Message = jsonResult.Message
+ } else {
+ base.Message += "; " + jsonResult.Message
+ }
+ }
+
+ if jsonResult.ModifiedPrompt != nil {
+ base.ModifiedPrompt = jsonResult.ModifiedPrompt
+ }
+
+ if len(jsonResult.ModifiedInput) > 0 {
+ if base.ModifiedInput == nil {
+ base.ModifiedInput = make(map[string]any)
+ }
+ for k, v := range jsonResult.ModifiedInput {
+ base.ModifiedInput[k] = v
+ }
+ }
+
+ if len(jsonResult.ModifiedOutput) > 0 {
+ if base.ModifiedOutput == nil {
+ base.ModifiedOutput = make(map[string]any)
+ }
+ for k, v := range jsonResult.ModifiedOutput {
+ base.ModifiedOutput[k] = v
+ }
+ }
+
+ if jsonResult.ContextContent != "" {
+ if base.ContextContent == "" {
+ base.ContextContent = jsonResult.ContextContent
+ } else {
+ base.ContextContent += "\n\n" + jsonResult.ContextContent
+ }
+ }
+
+ base.ContextFiles = append(base.ContextFiles, jsonResult.ContextFiles...)
+}
+
+// parseKeyValuePairs parses "key=value:key2=value2" format into a map.
+// Values are parsed as JSON when possible, otherwise treated as strings.
+func parseKeyValuePairs(encoded string) map[string]any {
+ result := make(map[string]any)
+ pairs := strings.Split(encoded, ":")
+ for _, pair := range pairs {
+ key, value, ok := strings.Cut(pair, "=")
+ if !ok {
+ continue
+ }
+
+ // Try to parse value as JSON to support numbers, booleans, arrays, objects
+ var jsonValue any
+ if err := json.Unmarshal([]byte(value), &jsonValue); err == nil {
+ result[key] = jsonValue
+ } else {
+ // Fall back to string if not valid JSON
+ result[key] = value
+ }
+ }
+ return result
+}
@@ -0,0 +1,416 @@
+package hooks
+
+import (
+ "encoding/base64"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseShellEnv(t *testing.T) {
+ t.Run("parses basic fields", func(t *testing.T) {
+ env := []string{
+ "PATH=/usr/bin",
+ "CRUSH_CONTINUE=false",
+ "CRUSH_PERMISSION=approve",
+ "CRUSH_MESSAGE=test message",
+ "HOME=/home/user",
+ }
+
+ result := parseShellEnv(env)
+
+ assert.False(t, result.Continue)
+ assert.Equal(t, "approve", result.Permission)
+ assert.Equal(t, "test message", result.Message)
+ })
+
+ t.Run("parses modified prompt", func(t *testing.T) {
+ env := []string{
+ "CRUSH_MODIFIED_PROMPT=new prompt text",
+ }
+
+ result := parseShellEnv(env)
+
+ require.NotNil(t, result.ModifiedPrompt)
+ assert.Equal(t, "new prompt text", *result.ModifiedPrompt)
+ })
+
+ t.Run("parses context content", func(t *testing.T) {
+ env := []string{
+ "CRUSH_CONTEXT_CONTENT=some context",
+ }
+
+ result := parseShellEnv(env)
+
+ assert.Equal(t, "some context", result.ContextContent)
+ })
+
+ t.Run("parses base64 context content", func(t *testing.T) {
+ text := "multiline\ncontext\nhere"
+ encoded := base64.StdEncoding.EncodeToString([]byte(text))
+
+ env := []string{
+ "CRUSH_CONTEXT_CONTENT=" + encoded,
+ }
+
+ result := parseShellEnv(env)
+
+ assert.Equal(t, text, result.ContextContent)
+ })
+
+ t.Run("parses context files", func(t *testing.T) {
+ env := []string{
+ "CRUSH_CONTEXT_FILES=file1.md:file2.txt:file3.go",
+ }
+
+ result := parseShellEnv(env)
+
+ assert.Equal(t, []string{"file1.md", "file2.txt", "file3.go"}, result.ContextFiles)
+ })
+
+ t.Run("defaults to continue=true", func(t *testing.T) {
+ env := []string{}
+
+ result := parseShellEnv(env)
+
+ assert.True(t, result.Continue)
+ })
+
+ t.Run("ignores non-CRUSH env vars", func(t *testing.T) {
+ env := []string{
+ "PATH=/usr/bin",
+ "HOME=/home/user",
+ "CRUSH_MESSAGE=test",
+ }
+
+ result := parseShellEnv(env)
+
+ assert.Equal(t, "test", result.Message)
+ })
+
+ t.Run("falls back to raw value for invalid base64", func(t *testing.T) {
+ // Invalid base64 string should be used as-is.
+ env := []string{
+ "CRUSH_CONTEXT_CONTENT=this is not base64!@#$",
+ }
+
+ result := parseShellEnv(env)
+
+ assert.Equal(t, "this is not base64!@#$", result.ContextContent)
+ })
+
+ t.Run("parses modified input", func(t *testing.T) {
+ env := []string{
+ "CRUSH_MODIFIED_INPUT=command=ls -la:working_dir=/tmp",
+ }
+
+ result := parseShellEnv(env)
+
+ require.NotNil(t, result.ModifiedInput)
+ assert.Equal(t, "ls -la", result.ModifiedInput["command"])
+ assert.Equal(t, "/tmp", result.ModifiedInput["working_dir"])
+ })
+
+ t.Run("parses modified output", func(t *testing.T) {
+ env := []string{
+ "CRUSH_MODIFIED_OUTPUT=status=redacted:data=[REDACTED]",
+ }
+
+ result := parseShellEnv(env)
+
+ require.NotNil(t, result.ModifiedOutput)
+ assert.Equal(t, "redacted", result.ModifiedOutput["status"])
+ assert.Equal(t, "[REDACTED]", result.ModifiedOutput["data"])
+ })
+
+ t.Run("parses modified input with JSON types", func(t *testing.T) {
+ env := []string{
+ `CRUSH_MODIFIED_INPUT=offset=100:limit=50:run_in_background=true:ignore=["*.log","*.tmp"]`,
+ }
+
+ result := parseShellEnv(env)
+
+ require.NotNil(t, result.ModifiedInput)
+ assert.Equal(t, float64(100), result.ModifiedInput["offset"]) // JSON numbers are float64
+ assert.Equal(t, float64(50), result.ModifiedInput["limit"])
+ assert.Equal(t, true, result.ModifiedInput["run_in_background"])
+ assert.Equal(t, []any{"*.log", "*.tmp"}, result.ModifiedInput["ignore"])
+ })
+
+ t.Run("parses modified input with strings containing colons", func(t *testing.T) {
+ // Colons in file paths should work if the value doesn't contain '='
+ env := []string{
+ `CRUSH_MODIFIED_INPUT=path=/usr/local/bin:name=test`,
+ }
+
+ result := parseShellEnv(env)
+
+ require.NotNil(t, result.ModifiedInput)
+ // First pair: path=/usr/local/bin
+ // Second pair: name=test
+ // Note: This splits on first '=' in each pair
+ assert.Equal(t, "/usr/local/bin", result.ModifiedInput["path"])
+ assert.Equal(t, "test", result.ModifiedInput["name"])
+ })
+}
+
+func TestParseJSONResult(t *testing.T) {
+ t.Run("parses basic fields", func(t *testing.T) {
+ json := []byte(`{
+ "continue": false,
+ "permission": "deny",
+ "message": "blocked"
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ assert.False(t, result.Continue)
+ assert.Equal(t, "deny", result.Permission)
+ assert.Equal(t, "blocked", result.Message)
+ })
+
+ t.Run("parses modified_input", func(t *testing.T) {
+ json := []byte(`{
+ "modified_input": {
+ "command": "ls -la",
+ "working_dir": "/tmp"
+ }
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{
+ "command": "ls -la",
+ "working_dir": "/tmp",
+ }, result.ModifiedInput)
+ })
+
+ t.Run("parses modified_output", func(t *testing.T) {
+ json := []byte(`{
+ "modified_output": {
+ "content": "filtered output"
+ }
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{
+ "content": "filtered output",
+ }, result.ModifiedOutput)
+ })
+
+ t.Run("parses context_files array", func(t *testing.T) {
+ json := []byte(`{
+ "context_files": ["file1.md", "file2.txt"]
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{"file1.md", "file2.txt"}, result.ContextFiles)
+ })
+
+ t.Run("returns error on invalid JSON", func(t *testing.T) {
+ json := []byte(`{invalid}`)
+
+ _, err := parseJSONResult(json)
+
+ assert.Error(t, err)
+ })
+
+ t.Run("defaults to continue=true", func(t *testing.T) {
+ json := []byte(`{"message": "test"}`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ assert.True(t, result.Continue)
+ })
+
+ t.Run("handles wrong type for modified_input", func(t *testing.T) {
+ // modified_input should be a map, but here it's a string.
+ json := []byte(`{
+ "modified_input": "not a map"
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ // Should be nil/empty since type assertion failed.
+ assert.Nil(t, result.ModifiedInput)
+ })
+
+ t.Run("handles wrong type for modified_output", func(t *testing.T) {
+ // modified_output should be a map, but here it's an array.
+ json := []byte(`{
+ "modified_output": ["not", "a", "map"]
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ assert.Nil(t, result.ModifiedOutput)
+ })
+
+ t.Run("handles non-string elements in context_files", func(t *testing.T) {
+ // context_files should be array of strings, but has numbers.
+ json := []byte(`{
+ "context_files": ["file1.md", 123, "file2.md", null]
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ // Should only include valid strings.
+ assert.Equal(t, []string{"file1.md", "file2.md"}, result.ContextFiles)
+ })
+
+ t.Run("handles wrong type for context_files", func(t *testing.T) {
+ // context_files should be an array, but here it's a string.
+ json := []byte(`{
+ "context_files": "not an array"
+ }`)
+
+ result, err := parseJSONResult(json)
+
+ require.NoError(t, err)
+ // Should be empty since type assertion failed.
+ assert.Empty(t, result.ContextFiles)
+ })
+}
+
+func TestMergeJSONResult(t *testing.T) {
+ t.Run("merges continue flag", func(t *testing.T) {
+ base := &HookResult{Continue: true}
+ json := &HookResult{Continue: false}
+
+ mergeJSONResult(base, json)
+
+ assert.False(t, base.Continue)
+ })
+
+ t.Run("merges permission", func(t *testing.T) {
+ base := &HookResult{}
+ json := &HookResult{Permission: "approve"}
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, "approve", base.Permission)
+ })
+
+ t.Run("appends messages", func(t *testing.T) {
+ base := &HookResult{Message: "first"}
+ json := &HookResult{Message: "second"}
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, "first; second", base.Message)
+ })
+
+ t.Run("merges modified_input maps", func(t *testing.T) {
+ base := &HookResult{
+ ModifiedInput: map[string]any{
+ "field1": "value1",
+ },
+ }
+ json := &HookResult{
+ ModifiedInput: map[string]any{
+ "field2": "value2",
+ },
+ }
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, map[string]any{
+ "field1": "value1",
+ "field2": "value2",
+ }, base.ModifiedInput)
+ })
+
+ t.Run("overwrites conflicting modified_input fields", func(t *testing.T) {
+ base := &HookResult{
+ ModifiedInput: map[string]any{
+ "field": "old",
+ },
+ }
+ json := &HookResult{
+ ModifiedInput: map[string]any{
+ "field": "new",
+ },
+ }
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, "new", base.ModifiedInput["field"])
+ })
+
+ t.Run("appends context content", func(t *testing.T) {
+ base := &HookResult{ContextContent: "first"}
+ json := &HookResult{ContextContent: "second"}
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, "first\n\nsecond", base.ContextContent)
+ })
+
+ t.Run("appends context files", func(t *testing.T) {
+ base := &HookResult{ContextFiles: []string{"file1.md"}}
+ json := &HookResult{ContextFiles: []string{"file2.md", "file3.md"}}
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, []string{"file1.md", "file2.md", "file3.md"}, base.ContextFiles)
+ })
+
+ t.Run("initializes ModifiedInput when nil", func(t *testing.T) {
+ // Base has nil ModifiedInput.
+ base := &HookResult{}
+ json := &HookResult{
+ ModifiedInput: map[string]any{
+ "field": "value",
+ },
+ }
+
+ mergeJSONResult(base, json)
+
+ require.NotNil(t, base.ModifiedInput)
+ assert.Equal(t, "value", base.ModifiedInput["field"])
+ })
+
+ t.Run("initializes ModifiedOutput when nil", func(t *testing.T) {
+ // Base has nil ModifiedOutput.
+ base := &HookResult{}
+ json := &HookResult{
+ ModifiedOutput: map[string]any{
+ "filtered": true,
+ },
+ }
+
+ mergeJSONResult(base, json)
+
+ require.NotNil(t, base.ModifiedOutput)
+ assert.Equal(t, true, base.ModifiedOutput["filtered"])
+ })
+
+ t.Run("sets context content when base is empty", func(t *testing.T) {
+ base := &HookResult{}
+ json := &HookResult{ContextContent: "new content"}
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, "new content", base.ContextContent)
+ })
+
+ t.Run("sets message when base is empty", func(t *testing.T) {
+ base := &HookResult{}
+ json := &HookResult{Message: "new message"}
+
+ mergeJSONResult(base, json)
+
+ assert.Equal(t, "new message", base.Message)
+ })
+}
@@ -0,0 +1,93 @@
+// Package hooks provides a Git-like hooks system for Crush.
+//
+// Hooks are executable scripts that run at specific points in the application
+// lifecycle. They can modify behavior, add context, control permissions, and
+// audit activity.
+package hooks
+
+import "context"
+
+// HookType represents the type of hook.
+type HookType string
+
+const (
+ // HookUserPromptSubmit executes after user submits prompt, before sending to LLM.
+ HookUserPromptSubmit HookType = "user-prompt-submit"
+
+ // HookPreToolUse executes after LLM requests tool use, before permission check & execution.
+ HookPreToolUse HookType = "pre-tool-use"
+
+ // HookPostToolUse executes after tool executes, before result sent to LLM.
+ HookPostToolUse HookType = "post-tool-use"
+
+ // HookStop executes when agent conversation loop stops or is cancelled.
+ HookStop HookType = "stop"
+)
+
+// HookContext contains the data passed to hooks.
+type HookContext struct {
+ // HookType is the type of hook being executed.
+ HookType HookType
+
+ // SessionID is the current session ID.
+ SessionID string
+
+ // WorkingDir is the working directory.
+ WorkingDir string
+
+ // Data is hook-specific data marshaled to JSON and passed via stdin.
+ // For UserPromptSubmit: prompt, attachments, model, is_first_message
+ // For PreToolUse: tool_name, tool_call_id, tool_input
+ // For PostToolUse: tool_name, tool_call_id, tool_input, tool_output, execution_time_ms
+ // For Stop: reason
+ Data map[string]any
+
+ // ToolName is the tool name (for tool hooks only).
+ ToolName string
+
+ // ToolCallID is the tool call ID (for tool hooks only).
+ ToolCallID string
+
+ // Environment contains additional environment variables to pass to the hook.
+ Environment map[string]string
+}
+
+// HookResult contains the result of hook execution.
+type HookResult struct {
+ // Continue indicates whether to continue execution.
+ // If false, execution stops.
+ Continue bool
+
+ // Permission decision (for PreToolUse hooks only).
+ // Values: "ask" (default), "approve", "deny"
+ Permission string
+
+ // ModifiedPrompt is the modified user prompt (for UserPromptSubmit).
+ ModifiedPrompt *string
+
+ // ModifiedInput is the modified tool input parameters (for PreToolUse).
+ // This is a map that can be merged with the original tool input.
+ ModifiedInput map[string]any
+
+ // ModifiedOutput is the modified tool output (for PostToolUse).
+ ModifiedOutput map[string]any
+
+ // ContextContent is raw text content to add to LLM context.
+ ContextContent string
+
+ // ContextFiles is a list of file paths to load and add to LLM context.
+ ContextFiles []string
+
+ // Message is a user-facing message (logged and potentially displayed).
+ Message string
+}
+
+// Manager coordinates hook discovery and execution.
+type Manager interface {
+ // ExecuteHooks executes all hooks for the given type in order.
+ // Returns accumulated results from all hooks.
+ ExecuteHooks(ctx context.Context, hookType HookType, context HookContext) (HookResult, error)
+
+ // ListHooks returns all discovered hooks for a given type.
+ ListHooks(hookType HookType) []string
+}
@@ -100,7 +100,15 @@ func (s *Shell) Exec(ctx context.Context, command string) (string, string, error
s.mu.Lock()
defer s.mu.Unlock()
- return s.exec(ctx, command)
+ return s.exec(ctx, command, nil)
+}
+
+// ExecWithStdin executes a command in the shell with provided stdin
+func (s *Shell) ExecWithStdin(ctx context.Context, command string, stdin io.Reader) (string, string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return s.exec(ctx, command, stdin)
}
// ExecStream executes a command in the shell with streaming output to provided writers
@@ -237,9 +245,9 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand
}
// newInterp creates a new interpreter with the current shell state
-func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
+func (s *Shell) newInterp(stdin io.Reader, stdout, stderr io.Writer) (*interp.Runner, error) {
return interp.New(
- interp.StdIO(nil, stdout, stderr),
+ interp.StdIO(stdin, stdout, stderr),
interp.Interactive(false),
interp.Env(expand.ListEnviron(s.env...)),
interp.Dir(s.cwd),
@@ -257,13 +265,13 @@ func (s *Shell) updateShellFromRunner(runner *interp.Runner) {
}
// execCommon is the shared implementation for executing commands
-func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr io.Writer) error {
+func (s *Shell) execCommon(ctx context.Context, command string, stdin io.Reader, stdout, stderr io.Writer) error {
line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil {
return fmt.Errorf("could not parse command: %w", err)
}
- runner, err := s.newInterp(stdout, stderr)
+ runner, err := s.newInterp(stdin, stdout, stderr)
if err != nil {
return fmt.Errorf("could not run command: %w", err)
}
@@ -275,15 +283,15 @@ func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr i
}
// exec executes commands using a cross-platform shell interpreter.
-func (s *Shell) exec(ctx context.Context, command string) (string, string, error) {
+func (s *Shell) exec(ctx context.Context, command string, stdin io.Reader) (string, string, error) {
var stdout, stderr bytes.Buffer
- err := s.execCommon(ctx, command, &stdout, &stderr)
+ err := s.execCommon(ctx, command, stdin, &stdout, &stderr)
return stdout.String(), stderr.String(), err
}
// execStream executes commands using POSIX shell emulation with streaming output
func (s *Shell) execStream(ctx context.Context, command string, stdout, stderr io.Writer) error {
- return s.execCommon(ctx, command, stdout, stderr)
+ return s.execCommon(ctx, command, nil, stdout, stderr)
}
func (s *Shell) execHandlers() []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {