From dc5edf14d642d50b758ae964746d418048b83bd7 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 30 Oct 2025 10:42:33 +0100 Subject: [PATCH] feat: initial hooks implementation --- README.md | 1 + internal/agent/agent.go | 79 ++++++++ internal/agent/common_test.go | 13 +- internal/agent/coordinator.go | 25 ++- internal/config/config.go | 2 + internal/config/hooks.go | 48 +++++ internal/hooks/HOOKS.md | 287 ++++++++++++++++++++++++++ internal/hooks/hooks.go | 155 ++++++++++++++ internal/hooks/hooks_test.go | 371 ++++++++++++++++++++++++++++++++++ schema.json | 70 +++++++ 10 files changed, 1041 insertions(+), 10 deletions(-) create mode 100644 internal/config/hooks.go create mode 100644 internal/hooks/HOOKS.md create mode 100644 internal/hooks/hooks.go create mode 100644 internal/hooks/hooks_test.go diff --git a/README.md b/README.md index 0b82aff4cc2e87ca181d8dfe1b11966cf658644b..f00c9dc70b9f84d2299cf72435111aecc8b9dd33 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - **Session-Based:** maintain multiple work sessions and contexts per project - **LSP-Enhanced:** Crush uses LSPs for additional context, just like you do - **Extensible:** add capabilities via MCPs (`http`, `stdio`, and `sse`) +- **[Hooks](./internal/hooks/HOOKS.md):** execute custom shell commands at lifecycle events - **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), FreeBSD, OpenBSD, and NetBSD ## Installation diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5a70195ce8e3bd1cbb06af3ce8be50e6b3a2c58e..76503631fa9b80c32b8a95d9c2a6a54d8910e758 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -3,6 +3,7 @@ package agent import ( "context" _ "embed" + "encoding/json" "errors" "fmt" "log/slog" @@ -22,6 +23,7 @@ import ( "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/hooks" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" @@ -76,6 +78,7 @@ type sessionAgent struct { messages message.Service disableAutoSummarize bool isYolo bool + hooks *hooks.Executor messageQueue *csync.Map[string, []SessionAgentCall] activeRequests *csync.Map[string, context.CancelFunc] @@ -91,6 +94,7 @@ type SessionAgentOptions struct { Sessions session.Service Messages message.Service Tools []fantasy.AgentTool + Hooks *hooks.Executor } func NewSessionAgent( @@ -106,6 +110,7 @@ func NewSessionAgent( disableAutoSummarize: opts.DisableAutoSummarize, tools: opts.Tools, isYolo: opts.IsYolo, + hooks: opts.Hooks, messageQueue: csync.NewMap[string, []SessionAgentCall](), activeRequests: csync.NewMap[string, context.CancelFunc](), } @@ -168,6 +173,17 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return nil, err } + // Execute UserPromptSubmit hook + if a.hooks != nil { + _ = a.hooks.Execute(ctx, hooks.HookContext{ + EventType: config.UserPromptSubmit, + SessionID: call.SessionID, + UserPrompt: call.Prompt, + Provider: a.largeModel.ModelCfg.Provider, + Model: a.largeModel.ModelCfg.Model, + }) + } + // add the session to the context ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID) @@ -293,6 +309,24 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy // TODO: implement }, OnToolCall: func(tc fantasy.ToolCallContent) error { + // Execute PreToolUse hook - blocks tool execution on error + if a.hooks != nil { + toolInput := make(map[string]any) + if err := json.Unmarshal([]byte(tc.Input), &toolInput); err == nil { + if err := a.hooks.Execute(genCtx, hooks.HookContext{ + EventType: config.PreToolUse, + SessionID: call.SessionID, + ToolName: tc.ToolName, + ToolInput: toolInput, + MessageID: currentAssistant.ID, + Provider: a.largeModel.ModelCfg.Provider, + Model: a.largeModel.ModelCfg.Model, + }); err != nil { + return fmt.Errorf("PreToolUse hook blocked tool execution: %w", err) + } + } + } + toolCall := message.ToolCall{ ID: tc.ToolCallID, Name: tc.ToolName, @@ -321,6 +355,32 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy case fantasy.ToolResultContentTypeMedia: // TODO: handle this message type } + + // Execute PostToolUse hook + if a.hooks != nil { + toolInput := make(map[string]any) + // Try to get tool input from the assistant message + toolCalls := currentAssistant.ToolCalls() + for _, tc := range toolCalls { + if tc.ID == result.ToolCallID { + _ = json.Unmarshal([]byte(tc.Input), &toolInput) + break + } + } + + _ = a.hooks.Execute(genCtx, hooks.HookContext{ + EventType: config.PostToolUse, + SessionID: call.SessionID, + ToolName: result.ToolName, + ToolInput: toolInput, + ToolResult: resultContent, + ToolError: isError, + MessageID: currentAssistant.ID, + Provider: a.largeModel.ModelCfg.Provider, + Model: a.largeModel.ModelCfg.Model, + }) + } + toolResult := message.ToolResult{ ToolCallID: result.ToolCallID, Name: result.ToolName, @@ -461,6 +521,25 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy } wg.Wait() + // Execute Stop hook + if a.hooks != nil && result != nil { + var totalTokens, inputTokens int64 + for _, step := range result.Steps { + totalTokens += step.Usage.TotalTokens + inputTokens += step.Usage.InputTokens + } + + _ = a.hooks.Execute(ctx, hooks.HookContext{ + EventType: config.Stop, + SessionID: call.SessionID, + MessageID: currentAssistant.ID, + Provider: a.largeModel.ModelCfg.Provider, + Model: a.largeModel.ModelCfg.Model, + TokensUsed: totalTokens, + TokensInput: inputTokens, + }) + } + if shouldSummarize { a.activeRequests.Del(call.SessionID) if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil { diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index f6f564109a32c278f6e127809b9b2ef550c239bd..8495ca7a42a257753b6c4fafb33996a740074fc6 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -148,7 +148,18 @@ func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt DefaultMaxTokens: 10000, }, } - agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools}) + agent := NewSessionAgent(SessionAgentOptions{ + LargeModel: largeModel, + SmallModel: smallModel, + SystemPromptPrefix: "", + SystemPrompt: systemPrompt, + DisableAutoSummarize: false, + IsYolo: true, + Sessions: env.sessions, + Messages: env.messages, + Tools: tools, + Hooks: nil, + }) return agent } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index e6cdc70d9294dc2ef60ae31582490f4220f52620..f7cbaf5ea16bc60d4e9d33d3c7408773b505344b 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -21,6 +21,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/hooks" "github.com/charmbracelet/crush/internal/log" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/message" @@ -60,6 +61,7 @@ type coordinator struct { permissions permission.Service history history.Service lspClients *csync.Map[string, *lsp.Client] + hooks *hooks.Executor currentAgent SessionAgent agents map[string]SessionAgent @@ -74,6 +76,9 @@ func NewCoordinator( history history.Service, lspClients *csync.Map[string, *lsp.Client], ) (Coordinator, error) { + // Initialize hooks executor + hooksExecutor := hooks.NewExecutor(cfg.Hooks, cfg.WorkingDir()) + c := &coordinator{ cfg: cfg, sessions: sessions, @@ -81,6 +86,7 @@ func NewCoordinator( permissions: permissions, history: history, lspClients: lspClients, + hooks: hooksExecutor, agents: make(map[string]SessionAgent), } @@ -290,15 +296,16 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider) result := NewSessionAgent(SessionAgentOptions{ - large, - small, - largeProviderCfg.SystemPromptPrefix, - systemPrompt, - c.cfg.Options.DisableAutoSummarize, - c.permissions.SkipRequests(), - c.sessions, - c.messages, - nil, + LargeModel: large, + SmallModel: small, + SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix, + SystemPrompt: systemPrompt, + DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize, + IsYolo: c.permissions.SkipRequests(), + Sessions: c.sessions, + Messages: c.messages, + Tools: nil, + Hooks: c.hooks, }) go func() { tools, err := c.buildTools(ctx, agent) diff --git a/internal/config/config.go b/internal/config/config.go index 02c0b468d9dd30208f4ec6efd376306a34f504e4..b67de27e315ce72335871790c6b8477c405ef5c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -303,6 +303,8 @@ type Config struct { Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"` + Hooks HookConfig `json:"hooks,omitempty" jsonschema:"description=Hook configurations for lifecycle events"` + Agents map[string]Agent `json:"-"` // Internal diff --git a/internal/config/hooks.go b/internal/config/hooks.go new file mode 100644 index 0000000000000000000000000000000000000000..46939149b56c2107e6bd6a6b6efb064c41ba354d --- /dev/null +++ b/internal/config/hooks.go @@ -0,0 +1,48 @@ +package config + +// HookEventType represents the lifecycle event when a hook should run. +type HookEventType string + +const ( + // PreToolUse runs before tool calls and can block them. + PreToolUse HookEventType = "PreToolUse" + // PostToolUse runs after tool calls complete. + PostToolUse HookEventType = "PostToolUse" + // UserPromptSubmit runs when the user submits a prompt, before processing. + UserPromptSubmit HookEventType = "UserPromptSubmit" + // Notification runs when Crush sends notifications. + Notification HookEventType = "Notification" + // Stop runs when Crush finishes responding. + Stop HookEventType = "Stop" + // SubagentStop runs when subagent tasks complete. + SubagentStop HookEventType = "SubagentStop" + // PreCompact runs before running a compact operation. + PreCompact HookEventType = "PreCompact" + // SessionStart runs when a session starts or resumes. + SessionStart HookEventType = "SessionStart" + // SessionEnd runs when a session ends. + SessionEnd HookEventType = "SessionEnd" +) + +// Hook represents a single hook command configuration. +type Hook struct { + // Type is the hook type, currently only "command" is supported. + Type string `json:"type" jsonschema:"description=Hook type,enum=command,default=command"` + // Command is the shell command to execute. + Command string `json:"command" jsonschema:"required,description=Shell command to execute for this hook,example=echo 'Hook executed'"` + // Timeout is the maximum time in seconds to wait for the hook to complete. + // Default is 30 seconds. + Timeout *int `json:"timeout,omitempty" jsonschema:"description=Maximum time in seconds to wait for hook completion,default=30,minimum=1,maximum=300"` +} + +// HookMatcher represents a matcher for a specific event type. +type HookMatcher struct { + // Matcher is the tool name or pattern to match (for tool events). + // For non-tool events, this can be empty or "*" to match all. + Matcher string `json:"matcher,omitempty" jsonschema:"description=Tool name or pattern to match (e.g. 'bash' or '*' for all),example=bash,example=edit,example=*"` + // Hooks is the list of hooks to execute when the matcher matches. + Hooks []Hook `json:"hooks" jsonschema:"required,description=List of hooks to execute when matcher matches"` +} + +// HookConfig holds the complete hook configuration. +type HookConfig map[HookEventType][]HookMatcher diff --git a/internal/hooks/HOOKS.md b/internal/hooks/HOOKS.md new file mode 100644 index 0000000000000000000000000000000000000000..e58e3c8432b5433989394011f6c4303d04033da3 --- /dev/null +++ b/internal/hooks/HOOKS.md @@ -0,0 +1,287 @@ +# Hooks Guide + +⚠️ **Security Warning**: Hooks run automatically with your user's permissions and have full access to your filesystem and environment. Only configure hooks from trusted sources and review all commands before adding them. + +Hooks are user-defined shell commands that execute at various points in Crush's lifecycle. They provide deterministic control over Crush's behavior, ensuring certain actions always occur rather than relying on the LLM to choose to run them. + +## Hook Events + +Crush provides several lifecycle events where hooks can run: + +### Tool Events +- **`PreToolUse`**: Runs before tool calls. If a hook fails (non-zero exit code), the tool execution is blocked. +- **`PostToolUse`**: Runs after tool calls complete, can be used to process results or trigger actions. + +### Session Events +- **`UserPromptSubmit`**: Runs when the user submits a prompt, before processing +- **`Stop`**: Runs when Crush finishes responding to a prompt +- **`SubagentStop`**: Runs when subagent tasks complete (e.g., fetch tool, agent tool) +- **`SessionStart`**: Runs when a session starts or resumes +- **`SessionEnd`**: Runs when a session ends + +### Other Events +- **`Notification`**: Runs when Crush sends notifications +- **`PreCompact`**: Runs before running a compact operation + +## Configuration Format + +Hooks are configured in your Crush configuration file (e.g., `crush.json` or `~/.crush/crush.json`): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_name + \": \" + .tool_input.command' >> ~/crush-commands.log", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo \"Tool $(jq -r .tool_name) completed\" | notify-send \"Crush Hook\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "echo \"Prompt completed. Tokens used: $(jq -r .tokens_used)\"" + } + ] + } + ] + } +} +``` + +## Hook Context + +Each hook receives a JSON context object via stdin containing information about the event: + +```json +{ + "event_type": "PreToolUse", + "session_id": "abc123", + "tool_name": "bash", + "tool_input": { + "command": "echo hello", + "description": "Print hello" + }, + "tool_result": "", + "tool_error": false, + "user_prompt": "", + "timestamp": "2025-10-30T12:00:00Z", + "working_dir": "/path/to/project", + "message_id": "msg123", + "provider": "anthropic", + "model": "claude-3-5-sonnet-20241022", + "tokens_used": 1000, + "tokens_input": 500 +} +``` + +### Context Fields by Event Type + +Different events include different fields: + +- **PreToolUse**: `event_type`, `session_id`, `tool_name`, `tool_input`, `message_id`, `provider`, `model` +- **PostToolUse**: `event_type`, `session_id`, `tool_name`, `tool_input`, `tool_result`, `tool_error`, `message_id`, `provider`, `model` +- **UserPromptSubmit**: `event_type`, `session_id`, `user_prompt`, `provider`, `model` +- **Stop**: `event_type`, `session_id`, `message_id`, `provider`, `model`, `tokens_used`, `tokens_input` + +All events include: `event_type`, `timestamp`, `working_dir` + +## Environment Variables + +Hooks also receive environment variables: + +- `CRUSH_HOOK_CONTEXT`: Full JSON context as a string +- `CRUSH_HOOK_EVENT`: The event type (e.g., "PreToolUse") +- `CRUSH_SESSION_ID`: The session ID (if applicable) +- `CRUSH_TOOL_NAME`: The tool name (for tool events) + +## Hook Configuration + +### Matchers + +For tool events (`PreToolUse`, `PostToolUse`), you can specify matchers to target specific tools: + +- `"bash"` - Only matches the bash tool +- `"edit"` - Only matches the edit tool +- `"*"` or `""` - Matches all tools + +For non-tool events, leave the matcher empty or use `"*"`. + +### Hook Properties + +- `type`: Currently only `"command"` is supported +- `command`: The shell command to execute +- `timeout`: (optional) Maximum execution time in seconds (default: 30, max: 300) + +## Examples + +### Log All Bash Commands + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.timestamp + \" - \" + .tool_input.command' >> ~/.crush/bash-log.txt" + } + ] + } + ] + } +} +``` + +### Auto-format Files After Editing + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "edit", + "hooks": [ + { + "type": "command", + "command": "jq -r .tool_input.file_path | xargs prettier --write" + } + ] + } + ] + } +} +``` + +### Notify on Completion + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "osascript -e 'display notification \"Crush completed\" with title \"Crush\"'" + } + ] + } + ] + } +} +``` + +### Track Token Usage + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "jq -r '\"\\(.timestamp): \\(.tokens_used) tokens\"' >> ~/.crush/token-usage.log" + } + ] + } + ] + } +} +``` + +### Validate Tool Usage + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash", + "hooks": [ + { + "type": "command", + "command": "if jq -e '.tool_input.command | contains(\"rm -rf\")' > /dev/null; then echo \"Dangerous command detected\" >&2; exit 1; fi" + } + ] + } + ] + } +} +``` + +### Multiple Hooks + +You can execute multiple hooks for the same event: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "jq -r .tool_name >> ~/.crush/tool-usage.log" + }, + { + "type": "command", + "command": "if jq -e .tool_error > /dev/null; then echo 'Error in tool' | pbcopy; fi" + } + ] + } + ] + } +} +``` + +## Best Practices + +1. **Keep hooks fast**: Hooks run synchronously and can slow down Crush if they take too long. +2. **Set appropriate timeouts**: Use shorter timeouts (1-5 seconds) for quick operations. +3. **Handle errors gracefully**: Hooks should not crash or hang. +4. **Use jq for JSON processing**: The context is piped to stdin as JSON. +5. **Test hooks independently**: Run your shell commands manually with test data before configuring them. +6. **Use absolute paths**: Hooks run in the project directory, but absolute paths are more reliable. +7. **Consider privacy**: Don't log sensitive information like API keys or passwords. + +## Debugging Hooks + +Hooks log errors and warnings to Crush's log output. To see hook execution: + +1. Run Crush with debug logging enabled: `crush --debug` +2. Check the logs for hook-related messages +3. Test hook shell commands manually: + ```bash + echo '{"event_type":"PreToolUse","tool_name":"bash"}' | jq -r '.tool_name' + ``` + +## Limitations + +- Hooks must complete within their timeout (default 30 seconds) +- Hooks run in a shell environment and require shell utilities (bash, jq, etc.) +- Hooks cannot modify Crush's internal state +- Hook errors are logged but don't stop Crush execution (except for PreToolUse) +- Interactive hooks are not supported diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go new file mode 100644 index 0000000000000000000000000000000000000000..6c2d4fb9460c80acca0d5ae1fb6ef1174645e1b3 --- /dev/null +++ b/internal/hooks/hooks.go @@ -0,0 +1,155 @@ +package hooks + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/shell" +) + +const DefaultHookTimeout = 30 * time.Second + +// HookContext contains context information passed to hooks. +type HookContext struct { + EventType config.HookEventType `json:"event_type"` + SessionID string `json:"session_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolInput map[string]any `json:"tool_input,omitempty"` + ToolResult string `json:"tool_result,omitempty"` + ToolError bool `json:"tool_error,omitempty"` + UserPrompt string `json:"user_prompt,omitempty"` + Timestamp time.Time `json:"timestamp"` + WorkingDir string `json:"working_dir,omitempty"` + MessageID string `json:"message_id,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + TokensUsed int64 `json:"tokens_used,omitempty"` + TokensInput int64 `json:"tokens_input,omitempty"` +} + +// Executor executes hooks based on configuration. +type Executor struct { + config config.HookConfig + workingDir string + shell *shell.Shell +} + +// NewExecutor creates a new hook executor. +func NewExecutor(hookConfig config.HookConfig, workingDir string) *Executor { + shellInst := shell.NewShell(&shell.Options{ + WorkingDir: workingDir, + }) + return &Executor{ + config: hookConfig, + workingDir: workingDir, + shell: shellInst, + } +} + +// Execute runs all hooks matching the given event type and context. +// Returns the first error encountered, causing subsequent hooks to be skipped. +func (e *Executor) Execute(ctx context.Context, hookCtx HookContext) error { + if e.config == nil || e.shell == nil { + return nil + } + + hookCtx.Timestamp = time.Now() + hookCtx.WorkingDir = e.workingDir + + matchers, ok := e.config[hookCtx.EventType] + if !ok || len(matchers) == 0 { + return nil + } + + for _, matcher := range matchers { + if ctx.Err() != nil { + return ctx.Err() + } + + if !e.matcherApplies(matcher, hookCtx) { + continue + } + + for _, hook := range matcher.Hooks { + if err := e.executeHook(ctx, hook, hookCtx); err != nil { + slog.Warn("Hook execution failed", + "event", hookCtx.EventType, + "matcher", matcher.Matcher, + "error", err, + ) + return err + } + } + } + + return nil +} + +// matcherApplies checks if a matcher applies to the given context. +func (e *Executor) matcherApplies(matcher config.HookMatcher, ctx HookContext) bool { + if matcher.Matcher == "" || matcher.Matcher == "*" { + return true + } + + if ctx.EventType == config.PreToolUse || ctx.EventType == config.PostToolUse { + return matcher.Matcher == ctx.ToolName + } + + return matcher.Matcher == "" || matcher.Matcher == "*" +} + +// executeHook executes a single hook command. +func (e *Executor) executeHook(ctx context.Context, hook config.Hook, hookCtx HookContext) error { + if hook.Type != "command" { + return fmt.Errorf("unsupported hook type: %s", hook.Type) + } + + timeout := DefaultHookTimeout + if hook.Timeout != nil { + timeout = time.Duration(*hook.Timeout) * time.Second + } + + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + contextJSON, err := json.Marshal(hookCtx) + if err != nil { + return fmt.Errorf("failed to marshal hook context: %w", err) + } + + e.shell.SetEnv("CRUSH_HOOK_EVENT", string(hookCtx.EventType)) + e.shell.SetEnv("CRUSH_HOOK_CONTEXT", string(contextJSON)) + if hookCtx.SessionID != "" { + e.shell.SetEnv("CRUSH_SESSION_ID", hookCtx.SessionID) + } + if hookCtx.ToolName != "" { + e.shell.SetEnv("CRUSH_TOOL_NAME", hookCtx.ToolName) + } + + slog.Debug("Executing hook", + "event", hookCtx.EventType, + "command", hook.Command, + "timeout", timeout, + ) + + fullCommand := fmt.Sprintf("%s <<'CRUSH_HOOK_EOF'\n%s\nCRUSH_HOOK_EOF\n", hook.Command, string(contextJSON)) + + stdout, stderr, err := e.shell.Exec(execCtx, fullCommand) + if err != nil { + return fmt.Errorf("hook command failed: %w: stdout=%s stderr=%s", err, stdout, stderr) + } + + if stdout != "" || stderr != "" { + slog.Debug("Hook output", + "event", hookCtx.EventType, + "stdout", stdout, + "stderr", stderr, + ) + } + + return nil +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2855cfe9e3f63d0181e2a920cf7956e3ef5d3c17 --- /dev/null +++ b/internal/hooks/hooks_test.go @@ -0,0 +1,371 @@ +package hooks + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/charmbracelet/crush/internal/config" + "github.com/stretchr/testify/require" +) + +func TestHookExecutor_Execute(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + tests := []struct { + name string + config config.HookConfig + hookCtx HookContext + wantErr bool + }{ + { + name: "simple command hook", + config: config.HookConfig{ + config.PreToolUse: []config.HookMatcher{ + { + Matcher: "bash", + Hooks: []config.Hook{ + { + Type: "command", + Command: "echo 'hook executed'", + }, + }, + }, + }, + }, + hookCtx: HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }, + }, + { + name: "hook with jq processing", + config: config.HookConfig{ + config.PreToolUse: []config.HookMatcher{ + { + Matcher: "bash", + Hooks: []config.Hook{ + { + Type: "command", + Command: `jq -r '.tool_name'`, + }, + }, + }, + }, + }, + hookCtx: HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }, + }, + { + name: "hook that writes to file", + config: config.HookConfig{ + config.PostToolUse: []config.HookMatcher{ + { + Matcher: "*", + Hooks: []config.Hook{ + { + Type: "command", + Command: `jq -r '"\(.tool_name): \(.tool_result)"' >> ` + filepath.Join(tempDir, "hook-log.txt"), + }, + }, + }, + }, + }, + hookCtx: HookContext{ + EventType: config.PostToolUse, + ToolName: "edit", + ToolResult: "file edited successfully", + }, + }, + { + name: "hook with timeout", + config: config.HookConfig{ + config.Stop: []config.HookMatcher{ + { + Hooks: []config.Hook{ + { + Type: "command", + Command: "sleep 0.1 && echo 'done'", + Timeout: ptrInt(1), + }, + }, + }, + }, + }, + hookCtx: HookContext{ + EventType: config.Stop, + }, + }, + { + name: "failed hook command", + config: config.HookConfig{ + config.PreToolUse: []config.HookMatcher{ + { + Matcher: "bash", + Hooks: []config.Hook{ + { + Type: "command", + Command: "exit 1", + }, + }, + }, + }, + }, + hookCtx: HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }, + wantErr: true, + }, + { + name: "hook with single quote in JSON", + config: config.HookConfig{ + config.PostToolUse: []config.HookMatcher{ + { + Matcher: "edit", + Hooks: []config.Hook{ + { + Type: "command", + Command: `jq -r '.tool_result'`, + }, + }, + }, + }, + }, + hookCtx: HookContext{ + EventType: config.PostToolUse, + ToolName: "edit", + ToolResult: "it's a test with 'quotes'", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor := NewExecutor(tt.config, tempDir) + require.NotNil(t, executor) + + ctx := context.Background() + err := executor.Execute(ctx, tt.hookCtx) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestHookExecutor_MatcherApplies(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + executor := NewExecutor(config.HookConfig{}, tempDir) + + tests := []struct { + name string + matcher config.HookMatcher + ctx HookContext + want bool + }{ + { + name: "empty matcher matches all", + matcher: config.HookMatcher{ + Matcher: "", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }, + want: true, + }, + { + name: "wildcard matcher matches all", + matcher: config.HookMatcher{ + Matcher: "*", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "edit", + }, + want: true, + }, + { + name: "specific tool matcher matches", + matcher: config.HookMatcher{ + Matcher: "bash", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }, + want: true, + }, + { + name: "specific tool matcher doesn't match different tool", + matcher: config.HookMatcher{ + Matcher: "bash", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "edit", + }, + want: false, + }, + { + name: "non-tool event matches empty matcher", + matcher: config.HookMatcher{ + Matcher: "", + }, + ctx: HookContext{ + EventType: config.Stop, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := executor.matcherApplies(tt.matcher, tt.ctx) + require.Equal(t, tt.want, got) + }) + } +} + +func TestHookExecutor_Timeout(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + shortTimeout := 1 + + hookConfig := config.HookConfig{ + config.Stop: []config.HookMatcher{ + { + Hooks: []config.Hook{ + { + Type: "command", + Command: "sleep 10", + Timeout: &shortTimeout, + }, + }, + }, + }, + } + + executor := NewExecutor(hookConfig, tempDir) + ctx := context.Background() + + start := time.Now() + err := executor.Execute(ctx, HookContext{ + EventType: config.Stop, + }) + duration := time.Since(start) + + require.Error(t, err) + require.Less(t, duration, 2*time.Second) +} + +func TestHookExecutor_MultipleHooks(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + logFile := filepath.Join(tempDir, "multi-hook-log.txt") + + hookConfig := config.HookConfig{ + config.PreToolUse: []config.HookMatcher{ + { + Matcher: "bash", + Hooks: []config.Hook{ + { + Type: "command", + Command: "echo 'hook1' >> " + logFile, + }, + { + Type: "command", + Command: "echo 'hook2' >> " + logFile, + }, + { + Type: "command", + Command: "echo 'hook3' >> " + logFile, + }, + }, + }, + }, + } + + executor := NewExecutor(hookConfig, tempDir) + ctx := context.Background() + + err := executor.Execute(ctx, HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }) + + require.NoError(t, err) + + content, err := os.ReadFile(logFile) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + require.Len(t, lines, 3) + require.Equal(t, "hook1", lines[0]) + require.Equal(t, "hook2", lines[1]) + require.Equal(t, "hook3", lines[2]) +} + +func TestHookExecutor_ContextCancellation(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + logFile := filepath.Join(tempDir, "cancel-log.txt") + + hookConfig := config.HookConfig{ + config.PreToolUse: []config.HookMatcher{ + { + Matcher: "bash", + Hooks: []config.Hook{ + { + Type: "command", + Command: "echo 'hook1' >> " + logFile, + }, + { + Type: "command", + Command: "sleep 10 && echo 'hook2' >> " + logFile, + }, + }, + }, + }, + } + + executor := NewExecutor(hookConfig, tempDir) + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + err := executor.Execute(ctx, HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }) + + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) +} + +func ptrInt(i int) *int { + return &i +} diff --git a/schema.json b/schema.json index 093012bcd5dcd811fb512203d10f2d8db1780255..b5292ea955945e4638b739816180da3e7783e0d5 100644 --- a/schema.json +++ b/schema.json @@ -79,6 +79,10 @@ "tools": { "$ref": "#/$defs/Tools", "description": "Tool configurations" + }, + "hooks": { + "$ref": "#/$defs/HookConfig", + "description": "Hook configurations for lifecycle events" } }, "additionalProperties": false, @@ -87,6 +91,72 @@ "tools" ] }, + "Hook": { + "properties": { + "type": { + "type": "string", + "enum": [ + "command" + ], + "description": "Hook type", + "default": "command" + }, + "command": { + "type": "string", + "description": "Shell command to execute for this hook", + "examples": [ + "echo 'Hook executed'" + ] + }, + "timeout": { + "type": "integer", + "maximum": 300, + "minimum": 1, + "description": "Maximum time in seconds to wait for hook completion", + "default": 30 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type", + "command" + ] + }, + "HookConfig": { + "additionalProperties": { + "items": { + "$ref": "#/$defs/HookMatcher" + }, + "type": "array" + }, + "type": "object" + }, + "HookMatcher": { + "properties": { + "matcher": { + "type": "string", + "description": "Tool name or pattern to match (e.g. 'bash' or '*' for all)", + "examples": [ + "bash", + "edit", + "*" + ] + }, + "hooks": { + "items": { + "$ref": "#/$defs/Hook" + }, + "type": "array", + "description": "List of hooks to execute when matcher matches" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "hooks" + ] + }, "LSPConfig": { "properties": { "disabled": {