Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ and project-level, with project level hooks taking precedence.
"hooks": {
"PreToolUse": [
{
"name": "no-rm-rf", // friendly name shown in the TUI
"matcher": "bash", // regex tested against the tool name
"command": "./hooks/my-hot-hook.sh", // the path to the hook
"timeout": 10, // in seconds; default 30
Expand Down Expand Up @@ -600,6 +601,10 @@ Each entry under a `hooks.<EventName>` array:

```jsonc
{
// string. Optional. Friendly display name shown in the TUI. Falls back to
// command when omitted.
"name": "no-rm-rf",

// string. Optional. Regex tested against the tool name. Omit to match all.
"matcher": "^bash$",

Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,8 @@ func (t ToolGrep) GetTimeout() time.Duration {
// is owned by hooks.Runner so a JSON round-trip, merge, or reload can't
// silently drop compiled state.
type HookConfig struct {
// Friendly display name shown in the TUI. Falls back to Command when empty.
Name string `json:"name,omitempty" jsonschema:"description=Friendly display name shown in the TUI for this hook"`
// Regex pattern tested against the tool name. Empty means match all.
Matcher string `json:"matcher,omitempty" jsonschema:"description=Regex pattern tested against the tool name. Empty means match all tools."`
// Shell command to execute.
Expand All @@ -553,6 +555,15 @@ type HookConfig struct {
Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for the hook command,default=30"`
}

// DisplayName returns the hook name for display purposes. It returns Name
// when set, otherwise falls back to Command.
func (h *HookConfig) DisplayName() string {
if h.Name != "" {
return h.Name
}
return h.Command
}

// TimeoutDuration returns the hook timeout as a time.Duration, defaulting
// to 30s.
func (h *HookConfig) TimeoutDuration() time.Duration {
Expand Down
31 changes: 31 additions & 0 deletions internal/hooks/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,37 @@ func TestValidateHooksNormalizesEventNames(t *testing.T) {
}
}

func TestRunnerHookNameUsesDisplayName(t *testing.T) {
t.Parallel()

t.Run("name field is used when set", func(t *testing.T) {
t.Parallel()
hookCfg := config.HookConfig{
Name: "my-hook",
Command: `echo '{"decision":"allow"}'`,
}
r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
require.NoError(t, err)
require.Equal(t, DecisionAllow, result.Decision)
require.Len(t, result.Hooks, 1)
require.Equal(t, "my-hook", result.Hooks[0].Name)
})

t.Run("command is used when name is empty", func(t *testing.T) {
t.Parallel()
hookCfg := config.HookConfig{
Command: `echo '{"decision":"allow"}'`,
}
r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
require.NoError(t, err)
require.Equal(t, DecisionAllow, result.Decision)
require.Len(t, result.Hooks, 1)
require.Equal(t, `echo '{"decision":"allow"}'`, result.Hooks[0].Name)
})
}

func TestRunnerParallelExecution(t *testing.T) {
t.Parallel()
// Two hooks: one allows, one denies. Deny should win.
Expand Down
2 changes: 1 addition & 1 deletion internal/hooks/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func (r *Runner) Run(ctx context.Context, eventName, sessionID, toolName, toolIn
agg.Hooks = make([]HookInfo, len(deduped))
for i, h := range deduped {
agg.Hooks[i] = HookInfo{
Name: h.Command,
Name: h.DisplayName(),
Matcher: h.Matcher,
Decision: results[i].Decision.String(),
Halt: results[i].Halt,
Expand Down
4 changes: 4 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@
},
"HookConfig": {
"properties": {
"name": {
"type": "string",
"description": "Friendly display name shown in the TUI for this hook"
},
"matcher": {
"type": "string",
"description": "Regex pattern tested against the tool name. Empty means match all tools."
Expand Down
Loading