feat(hooks): add name field to hooks

Bruno Krugel created

Change summary

internal/config/config.go    | 11 +++++++++++
internal/hooks/hooks_test.go | 31 +++++++++++++++++++++++++++++++
internal/hooks/runner.go     |  2 +-
schema.json                  |  4 ++++
4 files changed, 47 insertions(+), 1 deletion(-)

Detailed changes

internal/config/config.go 🔗

@@ -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.
@@ -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 {

internal/hooks/hooks_test.go 🔗

@@ -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.

internal/hooks/runner.go 🔗

@@ -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,

schema.json 🔗

@@ -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."