feat: hook core types — EventType, Action, CommandType, HookDef, Executor

This commit is contained in:
2026-04-07 00:36:18 +02:00
parent 451c79aaf6
commit ff9fd8c294
3 changed files with 345 additions and 0 deletions

126
internal/hook/event.go Normal file
View File

@@ -0,0 +1,126 @@
package hook
import "fmt"
// EventType identifies when a hook fires.
type EventType int
const (
PreToolUse EventType = iota + 1
PostToolUse
SessionStart
SessionEnd
PreCompact
Stop
)
func (e EventType) String() string {
switch e {
case PreToolUse:
return "pre_tool_use"
case PostToolUse:
return "post_tool_use"
case SessionStart:
return "session_start"
case SessionEnd:
return "session_end"
case PreCompact:
return "pre_compact"
case Stop:
return "stop"
default:
return fmt.Sprintf("unknown_event(%d)", int(e))
}
}
// ParseEventType parses a TOML event string into an EventType.
func ParseEventType(s string) (EventType, error) {
switch s {
case "pre_tool_use":
return PreToolUse, nil
case "post_tool_use":
return PostToolUse, nil
case "session_start":
return SessionStart, nil
case "session_end":
return SessionEnd, nil
case "pre_compact":
return PreCompact, nil
case "stop":
return Stop, nil
default:
return 0, fmt.Errorf("hook: unknown event type %q", s)
}
}
// CommandType is the mechanism a hook uses to evaluate.
type CommandType int
const (
CommandTypeShell CommandType = iota + 1 // run a shell command
CommandTypePrompt // send a prompt to an LLM
CommandTypeAgent // spawn an elf
)
func (c CommandType) String() string {
switch c {
case CommandTypeShell:
return "command"
case CommandTypePrompt:
return "prompt"
case CommandTypeAgent:
return "agent"
default:
return fmt.Sprintf("unknown_command(%d)", int(c))
}
}
// ParseCommandType parses a TOML type string into a CommandType.
func ParseCommandType(s string) (CommandType, error) {
switch s {
case "command":
return CommandTypeShell, nil
case "prompt":
return CommandTypePrompt, nil
case "agent":
return CommandTypeAgent, nil
default:
return 0, fmt.Errorf("hook: unknown command type %q", s)
}
}
// Action is the outcome of a hook execution.
type Action int
const (
Allow Action = iota + 1 // exit 0 — hook approves
Deny // exit 2 — hook rejects
Skip // exit 1 — hook abstains
)
func (a Action) String() string {
switch a {
case Allow:
return "allow"
case Deny:
return "deny"
case Skip:
return "skip"
default:
return fmt.Sprintf("unknown_action(%d)", int(a))
}
}
// ParseAction maps a shell exit code to an Action.
func ParseAction(exitCode int) (Action, error) {
switch exitCode {
case 0:
return Allow, nil
case 1:
return Skip, nil
case 2:
return Deny, nil
default:
return 0, fmt.Errorf("hook: unrecognised exit code %d", exitCode)
}
}

163
internal/hook/event_test.go Normal file
View File

@@ -0,0 +1,163 @@
package hook
import (
"testing"
)
func TestEventType_String(t *testing.T) {
tests := []struct {
event EventType
want string
}{
{PreToolUse, "pre_tool_use"},
{PostToolUse, "post_tool_use"},
{SessionStart, "session_start"},
{SessionEnd, "session_end"},
{PreCompact, "pre_compact"},
{Stop, "stop"},
}
for _, tt := range tests {
if got := tt.event.String(); got != tt.want {
t.Errorf("EventType(%d).String() = %q, want %q", tt.event, got, tt.want)
}
}
}
func TestParseEventType(t *testing.T) {
tests := []struct {
input string
want EventType
wantErr bool
}{
{"pre_tool_use", PreToolUse, false},
{"post_tool_use", PostToolUse, false},
{"session_start", SessionStart, false},
{"session_end", SessionEnd, false},
{"pre_compact", PreCompact, false},
{"stop", Stop, false},
{"unknown", 0, true},
{"", 0, true},
{"PRE_TOOL_USE", 0, true}, // case-sensitive
}
for _, tt := range tests {
got, err := ParseEventType(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseEventType(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
continue
}
if !tt.wantErr && got != tt.want {
t.Errorf("ParseEventType(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestCommandType_String(t *testing.T) {
tests := []struct {
ct CommandType
want string
}{
{CommandTypeShell, "command"},
{CommandTypePrompt, "prompt"},
{CommandTypeAgent, "agent"},
}
for _, tt := range tests {
if got := tt.ct.String(); got != tt.want {
t.Errorf("CommandType(%d).String() = %q, want %q", tt.ct, got, tt.want)
}
}
}
func TestParseCommandType(t *testing.T) {
tests := []struct {
input string
want CommandType
wantErr bool
}{
{"command", CommandTypeShell, false},
{"prompt", CommandTypePrompt, false},
{"agent", CommandTypeAgent, false},
{"shell", 0, true},
{"", 0, true},
}
for _, tt := range tests {
got, err := ParseCommandType(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseCommandType(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
continue
}
if !tt.wantErr && got != tt.want {
t.Errorf("ParseCommandType(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestAction_String(t *testing.T) {
tests := []struct {
action Action
want string
}{
{Allow, "allow"},
{Deny, "deny"},
{Skip, "skip"},
}
for _, tt := range tests {
if got := tt.action.String(); got != tt.want {
t.Errorf("Action(%d).String() = %q, want %q", tt.action, got, tt.want)
}
}
}
func TestParseAction(t *testing.T) {
tests := []struct {
exitCode int
want Action
wantErr bool
}{
{0, Allow, false},
{1, Skip, false},
{2, Deny, false},
{3, 0, true},
{-1, 0, true},
{127, 0, true},
}
for _, tt := range tests {
got, err := ParseAction(tt.exitCode)
if (err != nil) != tt.wantErr {
t.Errorf("ParseAction(%d) error = %v, wantErr %v", tt.exitCode, err, tt.wantErr)
continue
}
if !tt.wantErr && got != tt.want {
t.Errorf("ParseAction(%d) = %v, want %v", tt.exitCode, got, tt.want)
}
}
}
func TestHookDef_Validate(t *testing.T) {
valid := HookDef{
Name: "test-hook",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: "echo hello",
}
tests := []struct {
name string
def HookDef
wantErr bool
}{
{"valid", valid, false},
{"empty name", withName(valid, ""), true},
{"empty exec", withExec(valid, ""), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.def.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("HookDef.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func withName(d HookDef, name string) HookDef { d.Name = name; return d }
func withExec(d HookDef, exec string) HookDef { d.Exec = exec; return d }

56
internal/hook/hook.go Normal file
View File

@@ -0,0 +1,56 @@
package hook
import (
"context"
"fmt"
"time"
)
// HookDef is a parsed hook definition from config.
type HookDef struct {
Name string
Event EventType
Command CommandType
Exec string
Timeout time.Duration // default 30s if zero
FailOpen bool // true = allow on error/timeout; false = deny
ToolPattern string // glob for tool name filtering (PreToolUse/PostToolUse only)
}
// Validate reports an error if the definition is unusable.
func (d HookDef) Validate() error {
if d.Name == "" {
return fmt.Errorf("hook: name is required")
}
if d.Exec == "" {
return fmt.Errorf("hook %q: exec is required", d.Name)
}
return nil
}
// timeout returns the effective timeout, defaulting to 30s.
func (d HookDef) timeout() time.Duration {
if d.Timeout > 0 {
return d.Timeout
}
return 30 * time.Second
}
// HookResult is returned by an Executor after running a hook.
type HookResult struct {
Action Action
Output []byte // transformed payload (nil = no transform)
Error error
Duration time.Duration
}
// Executor runs a single hook.
type Executor interface {
Execute(ctx context.Context, payload []byte) (HookResult, error)
}
// Handler pairs a definition with its executor.
type Handler struct {
def HookDef
executor Executor
}