on main help sign in
feat(hooks): add name field to hooks
commit021e3773a63366a1970563543e694c36c6bb6e38 authorBruno Krugel <brunokrugel@gmail.com> dateSat 2 May 2026, 01:03 (6 months ago) parent4abf206
4 files changed+47−1
internal/config/config.go+11 internal/hooks/hooks_test.go+31 internal/hooks/runner.go+1−1 schema.json+4
internal/config/config.go+11
@@ -545,6 +545,8 @@ func (t ToolGrep) GetTimeout() time.Duration {
545545// is owned by hooks.Runner so a JSON round-trip, merge, or reload can't
546546// silently drop compiled state.
547547type HookConfig struct {
548+ // Friendly display name shown in the TUI. Falls back to Command when empty.
549+ Name string `json:"name,omitempty" jsonschema:"description=Friendly display name shown in the TUI for this hook"`
548550 // Regex pattern tested against the tool name. Empty means match all.
549551 Matcher string `json:"matcher,omitempty" jsonschema:"description=Regex pattern tested against the tool name. Empty means match all tools."`
550552 // Shell command to execute.
@@ -553,6 +555,15 @@ type HookConfig struct {
553555 Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for the hook command,default=30"`
554556}
555557
558+// DisplayName returns the hook name for display purposes. It returns Name
559+// when set, otherwise falls back to Command.
560+func (h *HookConfig) DisplayName() string {
561+ if h.Name != "" {
562+ return h.Name
563+ }
564+ return h.Command
565+}
566+
556567// TimeoutDuration returns the hook timeout as a time.Duration, defaulting
557568// to 30s.
558569func (h *HookConfig) TimeoutDuration() time.Duration {
internal/hooks/hooks_test.go+31
@@ -476,6 +476,37 @@ func TestValidateHooksNormalizesEventNames(t *testing.T) {
476476 }
477477}
478478
479+func TestRunnerHookNameUsesDisplayName(t *testing.T) {
480+ t.Parallel()
481+
482+ t.Run("name field is used when set", func(t *testing.T) {
483+ t.Parallel()
484+ hookCfg := config.HookConfig{
485+ Name: "my-hook",
486+ Command: `echo '{"decision":"allow"}'`,
487+ }
488+ r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
489+ result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
490+ require.NoError(t, err)
491+ require.Equal(t, DecisionAllow, result.Decision)
492+ require.Len(t, result.Hooks, 1)
493+ require.Equal(t, "my-hook", result.Hooks[0].Name)
494+ })
495+
496+ t.Run("command is used when name is empty", func(t *testing.T) {
497+ t.Parallel()
498+ hookCfg := config.HookConfig{
499+ Command: `echo '{"decision":"allow"}'`,
500+ }
501+ r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
502+ result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
503+ require.NoError(t, err)
504+ require.Equal(t, DecisionAllow, result.Decision)
505+ require.Len(t, result.Hooks, 1)
506+ require.Equal(t, `echo '{"decision":"allow"}'`, result.Hooks[0].Name)
507+ })
508+}
509+
479510func TestRunnerParallelExecution(t *testing.T) {
480511 t.Parallel()
481512 // Two hooks: one allows, one denies. Deny should win.
internal/hooks/runner.go+1−1
@@ -123,7 +123,7 @@ func (r *Runner) Run(ctx context.Context, eventName, sessionID, toolName, toolIn
123123 agg.Hooks = make([]HookInfo, len(deduped))
124124 for i, h := range deduped {
125125 agg.Hooks[i] = HookInfo{
126− Name: h.Command,
126+ Name: h.DisplayName(),
127127 Matcher: h.Matcher,
128128 Decision: results[i].Decision.String(),
129129 Halt: results[i].Halt,
schema.json+4
@@ -106,6 +106,10 @@
106106 },
107107 "HookConfig": {
108108 "properties": {
109+ "name": {
110+ "type": "string",
111+ "description": "Friendly display name shown in the TUI for this hook"
112+ },
109113 "matcher": {
110114 "type": "string",
111115 "description": "Regex pattern tested against the tool name. Empty means match all tools."