diff --git a/README.md b/README.md index a3451eb41..432fa4029 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 deba2d194..b80fb4087 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -10,6 +10,7 @@ package agent import ( "context" _ "embed" + "encoding/json" "errors" "fmt" "log/slog" @@ -29,6 +30,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" @@ -83,6 +85,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] @@ -98,6 +101,7 @@ type SessionAgentOptions struct { Sessions session.Service Messages message.Service Tools []fantasy.AgentTool + Hooks *hooks.Executor } func NewSessionAgent( @@ -113,6 +117,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](), } @@ -175,6 +180,19 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return nil, err } + // Execute UserPromptSubmit hook + if a.hooks != nil { + if err := 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, + }); err != nil { + slog.Debug("user_prompt_submit hook execution failed", "error", err) + } + } + // Add the session to the context. ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID) @@ -307,6 +325,25 @@ 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 { + slog.Warn("Failed to unmarshal tool input for PreToolUse hook", "error", err, "tool", tc.ToolName) + } + 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, @@ -335,6 +372,36 @@ 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 { + if err := json.Unmarshal([]byte(tc.Input), &toolInput); err != nil { + slog.Debug("Failed to unmarshal tool input for PostToolUse hook", "error", err, "tool", result.ToolName) + } + break + } + } + + if err := 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, + }); err != nil { + slog.Debug("post_tool_use hook execution failed", "error", err) + } + } + toolResult := message.ToolResult{ ToolCallID: result.ToolCallID, Name: result.ToolName, @@ -476,6 +543,27 @@ 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 + } + + if err := 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, + }); err != nil { + slog.Debug("stop hook execution failed", "error", err) + } + } + if shouldSummarize { a.activeRequests.Del(call.SessionID) if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil { @@ -525,6 +613,18 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan return nil } + // Execute PreCompact hook + if a.hooks != nil { + if err := a.hooks.Execute(ctx, hooks.HookContext{ + EventType: config.PreCompact, + SessionID: sessionID, + Provider: a.largeModel.ModelCfg.Provider, + Model: a.largeModel.ModelCfg.Model, + }); err != nil { + slog.Debug("pre_compact hook execution failed", "error", err) + } + } + aiMsgs, _ := a.preparePrompt(msgs) genCtx, cancel := context.WithCancel(ctx) diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 3e9318132..6628bc39d 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -6,12 +6,14 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "charm.land/fantasy" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/hooks" ) //go:embed templates/agent_tool.md @@ -104,6 +106,21 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) if err != nil { return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) } + + // Execute SubagentStop hook + if c.hooks != nil { + if err := c.hooks.Execute(ctx, hooks.HookContext{ + EventType: config.SubagentStop, + SessionID: sessionID, + ToolName: AgentToolName, + MessageID: agentMessageID, + Provider: model.ModelCfg.Provider, + Model: model.ModelCfg.Model, + }); err != nil { + slog.Debug("subagent_stop hook execution failed", "error", err) + } + } + return fantasy.NewTextResponse(result.Response.Content.Text()), nil }), nil } diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index b668b5dba..65c52686a 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -115,7 +115,7 @@ func testEnv(t *testing.T) fakeEnv { sessions := session.NewService(q) messages := message.NewService(q) - permissions := permission.NewPermissionService(workingDir, true, []string{}) + permissions := permission.NewPermissionService(workingDir, true, []string{}, nil) history := history.NewService(q, conn) lspClients := csync.NewMap[string, *lsp.Client]() @@ -149,7 +149,18 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro 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 fa0a9b90d..60691efc4 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" @@ -61,6 +62,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 @@ -76,6 +78,7 @@ func NewCoordinator( permissions permission.Service, history history.Service, lspClients *csync.Map[string, *lsp.Client], + hooksExecutor *hooks.Executor, ) (Coordinator, error) { c := &coordinator{ cfg: cfg, @@ -84,6 +87,7 @@ func NewCoordinator( permissions: permissions, history: history, lspClients: lspClients, + hooks: hooksExecutor, agents: make(map[string]SessionAgent), } @@ -287,15 +291,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, }) c.readyWg.Go(func() error { tools, err := c.buildTools(ctx, agent) diff --git a/internal/app/app.go b/internal/app/app.go index dc0d26a83..57314003e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -22,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/format" "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" @@ -47,6 +48,7 @@ type App struct { LSPClients *csync.Map[string, *lsp.Client] config *config.Config + hooks *hooks.Executor serviceEventsWG *sync.WaitGroup eventsCtx context.Context @@ -70,16 +72,20 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { allowedTools = cfg.Permissions.AllowedTools } + // Initialize hooks executor + hooksExecutor := hooks.NewExecutor(cfg.Hooks, cfg.WorkingDir()) + app := &App{ Sessions: sessions, Messages: messages, History: files, - Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), + Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools, hooksExecutor), LSPClients: csync.NewMap[string, *lsp.Client](), globalCtx: ctx, config: cfg, + hooks: hooksExecutor, events: make(chan tea.Msg, 100), serviceEventsWG: &sync.WaitGroup{}, @@ -313,6 +319,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { if coderAgentCfg.ID == "" { return fmt.Errorf("coder agent configuration is missing") } + var err error app.AgentCoordinator, err = agent.NewCoordinator( ctx, @@ -322,6 +329,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.Permissions, app.History, app.LSPClients, + app.hooks, ) if err != nil { slog.Error("Failed to create coder agent", "err", err) diff --git a/internal/config/config.go b/internal/config/config.go index 02c0b468d..b67de27e3 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 000000000..1a61786c2 --- /dev/null +++ b/internal/config/hooks.go @@ -0,0 +1,46 @@ +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 = "pre_tool_use" + // PostToolUse runs after tool calls complete. + PostToolUse HookEventType = "post_tool_use" + // UserPromptSubmit runs when the user submits a prompt, before processing. + UserPromptSubmit HookEventType = "user_prompt_submit" + // Stop runs when Crush finishes responding. + Stop HookEventType = "stop" + // SubagentStop runs when subagent tasks complete. + SubagentStop HookEventType = "subagent_stop" + // PreCompact runs before running a compact operation. + PreCompact HookEventType = "pre_compact" + // PermissionRequested runs when a permission is requested from the user. + PermissionRequested HookEventType = "permission_requested" +) + +// 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. + // WARNING: Hook commands execute with Crush's full permissions. Only use trusted commands. + Command string `json:"command" jsonschema:"required,description=Shell command to execute for this hook (executes with Crush's permissions),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. + // Supports pipe-separated tool names like "edit|write|multiedit". + Matcher string `json:"matcher,omitempty" jsonschema:"description=Tool name or pattern to match (e.g. 'bash' 'edit|write' for multiple or '*' for all),example=bash,example=edit|write|multiedit,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 000000000..18414f34d --- /dev/null +++ b/internal/hooks/HOOKS.md @@ -0,0 +1,393 @@ +# 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 +- **`pre_tool_use`**: Runs before tool calls. If a hook fails (non-zero exit code), the tool execution is blocked. +- **`post_tool_use`**: Runs after tool calls complete, can be used to process results or trigger actions. + +### Session Events +- **`user_prompt_submit`**: Runs when the user submits a prompt, before processing +- **`stop`**: Runs when Crush finishes responding to a prompt +- **`subagent_stop`**: Runs when subagent tasks complete (e.g., fetch tool, agent tool) + +### Other Events +- **`pre_compact`**: Runs before running a compact operation +- **`permission_requested`**: Runs when a permission is requested from the user + +## Configuration Format + +Hooks are configured in your Crush configuration file. Configuration files are searched in the following order: + +1. `.crush.json` (project-specific, hidden) +2. `crush.json` (project-specific) +3. `$HOME/.config/crush/crush.json` (global, Linux/macOS) + or `%LOCALAPPDATA%\crush\crush.json` (global, Windows) + +Example configuration: + +```json +{ + "hooks": { + "pre_tool_use": [ + { + "matcher": "bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_name + \": \" + .tool_input.command' >> ~/crush-commands.log", + "timeout": 5 + } + ] + } + ], + "post_tool_use": [ + { + "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": "pre_tool_use", + "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: + +- **pre_tool_use**: `event_type`, `session_id`, `tool_name`, `tool_input`, `message_id`, `provider`, `model` +- **post_tool_use**: `event_type`, `session_id`, `tool_name`, `tool_input`, `tool_result`, `tool_error`, `message_id`, `provider`, `model` +- **user_prompt_submit**: `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 (`pre_tool_use`, `post_tool_use`), you can specify matchers to target specific tools: + +- `"bash"` - Only matches the bash tool +- `"edit"` - Only matches the edit tool +- `"edit|write|multiedit"` - Matches any of the specified tools (pipe-separated) +- `"*"` or `""` - Matches all tools + +For non-tool events, leave the matcher empty or use `"*"`. + +### Hook Command + +Each hook has these properties: +- `type`: Currently only `"command"` is supported +- `command`: Shell command to execute. Receives JSON context via stdin +- `timeout`: (optional) Maximum execution time in seconds (default: 30, max: 300) + +**Important**: When processing JSON with `jq`, be aware that `tool_result` fields can contain large content or special characters that may cause parse errors. For reliability: +- Use `cat` instead of `jq` to output raw JSON: `cat >> hooks.log` +- Extract only specific fields: `jq -r '.tool_name, .session_id'` +- For `post_tool_use` hooks, tool results can be very large (e.g., entire file contents) + +## Examples + +### Log All Bash Commands + +```json +{ + "hooks": { + "pre_tool_use": [ + { + "matcher": "bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.timestamp + \" - \" + .tool_input.command' >> ~/crush-bash.log" + } + ] + } + ] + } +} +``` + +### Auto-format Files After Editing + +```json +{ + "hooks": { + "post_tool_use": [ + { + "matcher": "edit|write|multiedit", + "hooks": [ + { + "type": "command", + "command": "jq -r .tool_input.file_path | xargs prettier --write" + } + ] + } + ] + } +} +``` + +### Notify on Completion + +Note: Examples use macOS-specific tools. For cross-platform alternatives, use `notify-send` (Linux) or custom scripts. + +```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-tokens.log" + } + ] + } + ] + } +} +``` + +### Validate Tool Usage + +```json +{ + "hooks": { + "pre_tool_use": [ + { + "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": { + "post_tool_use": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "jq -r .tool_name >> ~/crush-tools.log" + }, + { + "type": "command", + "command": "if jq -e .tool_error > /dev/null; then echo 'Error in tool' | pbcopy; fi" + } + ] + } + ] + } +} +``` + +### Debug: Log All Hook Events + +For debugging or monitoring, log complete JSON for all events: + +```json +{ + "hooks": { + "pre_tool_use": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "(echo \"[$(date '+%Y-%m-%d %H:%M:%S')] pre_tool_use:\" && cat && echo \"\") >> hooks.log" + } + ] + } + ], + "post_tool_use": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "(echo \"[$(date '+%Y-%m-%d %H:%M:%S')] post_tool_use:\" && cat && echo \"\") >> hooks.log" + } + ] + } + ] + } +} +``` + +Note: Using `cat` avoids potential jq parsing errors with large or complex tool results. + +### Track Subagent Completion + +```json +{ + "hooks": { + "subagent_stop": [ + { + "hooks": [ + { + "type": "command", + "command": "echo \"Subagent task completed: $(jq -r .tool_name)\" | tee -a ~/crush-subagent.log" + } + ] + } + ] + } +} +``` + +### Pre-Compact Notification + +```json +{ + "hooks": { + "pre_compact": [ + { + "hooks": [ + { + "type": "command", + "command": "osascript -e 'display notification \"Compacting conversation...\" with title \"Crush\"'" + } + ] + } + ] + } +} +``` + +### Permission Requested Notification + +```json +{ + "hooks": { + "permission_requested": [ + { + "hooks": [ + { + "type": "command", + "command": "jq -r '\"Permission requested: \\(.tool_name) \\(.permission_action) \\(.permission_path)\"' | tee -a ~/crush-permissions.log" + } + ] + } + ] + } +} +``` + +## 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":"pre_tool_use","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 pre_tool_use) +- Interactive hooks are not supported diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go new file mode 100644 index 000000000..2011d3e31 --- /dev/null +++ b/internal/hooks/hooks.go @@ -0,0 +1,186 @@ +package hooks + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "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"` + PermissionAction string `json:"permission_action,omitempty"` + PermissionPath string `json:"permission_path,omitempty"` + PermissionParams any `json:"permission_params,omitempty"` + PermissionToolCall string `json:"permission_tool_call,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 matchesToolName(matcher.Matcher, ctx.ToolName) + } + + // For non-tool events, only empty or wildcard matchers apply + return matcher.Matcher == "" || matcher.Matcher == "*" +} + +// matchesToolName supports pipe-separated patterns like "edit|write|multiedit". +func matchesToolName(pattern, toolName string) bool { + if pattern == "" || pattern == "*" { + return true + } + + // Check for exact match first + if pattern == toolName { + return true + } + + // Check if pattern contains pipes (multiple tool names) + if !strings.Contains(pattern, "|") { + return false + } + + // Split by pipe and check each tool name + for tool := range strings.SplitSeq(pattern, "|") { + tool = strings.TrimSpace(tool) + if tool == toolName { + return true + } + } + + return false +} + +// 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, + ) + + stdout, stderr, err := e.shell.ExecWithStdin(execCtx, hook.Command, string(contextJSON)) + 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 000000000..badff2856 --- /dev/null +++ b/internal/hooks/hooks_test.go @@ -0,0 +1,494 @@ +package hooks + +import ( + "context" + "errors" + "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: "pipe-separated matcher matches first tool", + matcher: config.HookMatcher{ + Matcher: "edit|write|multiedit", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "edit", + }, + want: true, + }, + { + name: "pipe-separated matcher matches middle tool", + matcher: config.HookMatcher{ + Matcher: "edit|write|multiedit", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "write", + }, + want: true, + }, + { + name: "pipe-separated matcher matches last tool", + matcher: config.HookMatcher{ + Matcher: "edit|write|multiedit", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "multiedit", + }, + want: true, + }, + { + name: "pipe-separated matcher doesn't match different tool", + matcher: config.HookMatcher{ + Matcher: "edit|write|multiedit", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "bash", + }, + want: false, + }, + { + name: "pipe-separated matcher with spaces", + matcher: config.HookMatcher{ + Matcher: "edit | write | multiedit", + }, + ctx: HookContext{ + EventType: config.PreToolUse, + ToolName: "write", + }, + want: true, + }, + { + 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", strings.TrimSpace(lines[0])) + require.Equal(t, "hook2", strings.TrimSpace(lines[1])) + require.Equal(t, "hook3", strings.TrimSpace(lines[2])) +} + +func TestHookExecutor_PipeSeparatedMatcher(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + logFile := filepath.Join(tempDir, "pipe-matcher-log.txt") + + hookConfig := config.HookConfig{ + config.PostToolUse: []config.HookMatcher{ + { + Matcher: "edit|write|multiedit", + Hooks: []config.Hook{ + { + Type: "command", + Command: `jq -r '.tool_name' >> ` + logFile, + }, + }, + }, + }, + } + + executor := NewExecutor(hookConfig, tempDir) + ctx := context.Background() + + // Test that edit triggers the hook + err := executor.Execute(ctx, HookContext{ + EventType: config.PostToolUse, + ToolName: "edit", + }) + require.NoError(t, err) + + // Test that write triggers the hook + err = executor.Execute(ctx, HookContext{ + EventType: config.PostToolUse, + ToolName: "write", + }) + require.NoError(t, err) + + // Test that multiedit triggers the hook + err = executor.Execute(ctx, HookContext{ + EventType: config.PostToolUse, + ToolName: "multiedit", + }) + require.NoError(t, err) + + // Test that bash does NOT trigger the hook + err = executor.Execute(ctx, HookContext{ + EventType: config.PostToolUse, + ToolName: "bash", + }) + require.NoError(t, err) + + // Verify only the matching tools were logged + 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, "edit", strings.TrimSpace(lines[0])) + require.Equal(t, "write", strings.TrimSpace(lines[1])) + require.Equal(t, "multiedit", strings.TrimSpace(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) + // On context cancellation, the shell may return either context.Canceled or an exit status + // depending on timing. Check for both. + if !errors.Is(err, context.Canceled) { + // If not context.Canceled, we should still have an error from the cancelled context + require.NotNil(t, ctx.Err()) + } +} + +func ptrInt(i int) *int { + return &i +} diff --git a/internal/permission/permission.go b/internal/permission/permission.go index e7bc3f65f..0d1ef1215 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -3,12 +3,15 @@ package permission import ( "context" "errors" + "log/slog" "os" "path/filepath" "slices" "sync" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/hooks" "github.com/charmbracelet/crush/internal/pubsub" "github.com/google/uuid" ) @@ -66,6 +69,7 @@ type permissionService struct { autoApproveSessionsMu sync.RWMutex skip bool allowedTools []string + hooks *hooks.Executor // used to make sure we only process one request at a time requestMu sync.Mutex @@ -181,16 +185,24 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { } s.sessionPermissionsMu.RUnlock() - s.sessionPermissionsMu.RLock() - for _, p := range s.sessionPermissions { - if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path { - s.sessionPermissionsMu.RUnlock() - return true + s.activeRequest = &permission + + // Execute PermissionRequested hook. + // Uses context.Background() since Request() is called synchronously and hooks should + // run even if the calling operation is cancelled. Hooks have their own timeout. + if s.hooks != nil { + if err := s.hooks.Execute(context.Background(), hooks.HookContext{ + EventType: config.PermissionRequested, + SessionID: permission.SessionID, + ToolName: permission.ToolName, + PermissionAction: permission.Action, + PermissionPath: permission.Path, + PermissionParams: permission.Params, + PermissionToolCall: permission.ToolCallID, + }); err != nil { + slog.Debug("permission_requested hook execution failed", "error", err) } } - s.sessionPermissionsMu.RUnlock() - - s.activeRequest = &permission respCh := make(chan bool, 1) s.pendingRequests.Set(permission.ID, respCh) @@ -220,7 +232,7 @@ func (s *permissionService) SkipRequests() bool { return s.skip } -func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service { +func NewPermissionService(workingDir string, skip bool, allowedTools []string, hooksExecutor *hooks.Executor) Service { return &permissionService{ Broker: pubsub.NewBroker[PermissionRequest](), notificationBroker: pubsub.NewBroker[PermissionNotification](), @@ -230,5 +242,6 @@ func NewPermissionService(workingDir string, skip bool, allowedTools []string) S skip: skip, allowedTools: allowedTools, pendingRequests: csync.NewMap[string, chan bool](), + hooks: hooksExecutor, } } diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index d1ccd2868..c977f4ffe 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -54,7 +54,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - service := NewPermissionService("/tmp", false, tt.allowedTools) + service := NewPermissionService("/tmp", false, tt.allowedTools, nil) // Create a channel to capture the permission request // Since we're testing the allowlist logic, we need to simulate the request @@ -79,7 +79,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) { } func TestPermissionService_SkipMode(t *testing.T) { - service := NewPermissionService("/tmp", true, []string{}) + service := NewPermissionService("/tmp", true, []string{}, nil) result := service.Request(CreatePermissionRequest{ SessionID: "test-session", @@ -96,7 +96,7 @@ func TestPermissionService_SkipMode(t *testing.T) { func TestPermissionService_SequentialProperties(t *testing.T) { t.Run("Sequential permission requests with persistent grants", func(t *testing.T) { - service := NewPermissionService("/tmp", false, []string{}) + service := NewPermissionService("/tmp", false, []string{}, nil) req1 := CreatePermissionRequest{ SessionID: "session1", @@ -140,7 +140,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { assert.True(t, result2, "Second request should be auto-approved") }) t.Run("Sequential requests with temporary grants", func(t *testing.T) { - service := NewPermissionService("/tmp", false, []string{}) + service := NewPermissionService("/tmp", false, []string{}, nil) req := CreatePermissionRequest{ SessionID: "session2", @@ -180,7 +180,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { assert.False(t, result2, "Second request should be denied") }) t.Run("Concurrent requests with different outcomes", func(t *testing.T) { - service := NewPermissionService("/tmp", false, []string{}) + service := NewPermissionService("/tmp", false, []string{}, nil) events := service.Subscribe(t.Context()) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 7a4b19b86..f6a960bff 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -15,6 +15,7 @@ import ( "context" "errors" "fmt" + "io" "os" "slices" "strings" @@ -100,7 +101,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 the given stdin +func (s *Shell) ExecWithStdin(ctx context.Context, command string, stdin string) (string, string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return s.exec(ctx, command, strings.NewReader(stdin)) } // GetWorkingDir returns the current working directory @@ -229,15 +238,19 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand } // 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 *strings.Reader) (string, string, error) { line, err := syntax.NewParser().Parse(strings.NewReader(command), "") if err != nil { return "", "", fmt.Errorf("could not parse command: %w", err) } var stdout, stderr bytes.Buffer + var stdinReader io.Reader + if stdin != nil { + stdinReader = stdin + } runner, err := interp.New( - interp.StdIO(nil, &stdout, &stderr), + interp.StdIO(stdinReader, &stdout, &stderr), interp.Interactive(false), interp.Env(expand.ListEnviron(s.env...)), interp.Dir(s.cwd), diff --git a/schema.json b/schema.json index 093012bcd..ddfd500eb 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' 'edit|write' for multiple or '*' for all)", + "examples": [ + "bash", + "edit|write|multiedit", + "*" + ] + }, + "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": {