From 31ff9629465497b5a44e5a251f478fa4094b2cc2 Mon Sep 17 00:00:00 2001 From: connerohnesorge Date: Sun, 8 Feb 2026 14:43:10 -0600 Subject: [PATCH] impl 1 for hooks in command frontmatters --- cmd/hooks.go | 29 +++ cmd/root.go | 1 + internal/domain/frontmatter.go | 4 + internal/domain/frontmatter_slashnext_test.go | 24 +- internal/domain/frontmatter_test.go | 3 + internal/domain/hooks_frontmatter.go | 28 +++ internal/domain/hooks_frontmatter_test.go | 164 ++++++++++++++ internal/domain/hooktype.go | 71 ++++++ internal/domain/hooktype_test.go | 102 +++++++++ internal/hooks/handler.go | 57 +++++ internal/hooks/pretooluse.go | 65 ++++++ internal/hooks/pretooluse_test.go | 210 ++++++++++++++++++ internal/hooks/types.go | 24 ++ 13 files changed, 777 insertions(+), 5 deletions(-) create mode 100644 cmd/hooks.go create mode 100644 internal/domain/hooks_frontmatter.go create mode 100644 internal/domain/hooks_frontmatter_test.go create mode 100644 internal/domain/hooktype.go create mode 100644 internal/domain/hooktype_test.go create mode 100644 internal/hooks/handler.go create mode 100644 internal/hooks/pretooluse.go create mode 100644 internal/hooks/pretooluse_test.go create mode 100644 internal/hooks/types.go diff --git a/cmd/hooks.go b/cmd/hooks.go new file mode 100644 index 00000000..14d01de1 --- /dev/null +++ b/cmd/hooks.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/connerohnesorge/spectr/internal/domain" + "github.com/connerohnesorge/spectr/internal/hooks" +) + +// HooksCmd represents the hooks command for processing Claude Code hook events. +type HooksCmd struct { + HookType string `arg:"" help:"Hook event type (PreToolUse, Stop, etc.)"` //nolint:lll,revive // Kong struct tag + Command string `name:"command" short:"c" help:"Slash command context" required:""` //nolint:lll,revive // Kong struct tag +} + +// Run executes the hooks command by parsing the hook type and delegating +// to the hooks package for processing. +func (c *HooksCmd) Run() error { + ht, ok := domain.ParseHookType(c.HookType) + if !ok { + return fmt.Errorf( + "unknown hook type: %s", + c.HookType, + ) + } + + return hooks.Handle(ht, c.Command, os.Stdin, os.Stdout) +} diff --git a/cmd/root.go b/cmd/root.go index 549f8644..6c9040d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ type CLI struct { Accept AcceptCmd `cmd:"" help:"Accept tasks.md"` //nolint:lll,revive // Kong struct tag with alignment Archive archive.ArchiveCmd `cmd:"" help:"Archive a change"` //nolint:lll,revive // Kong struct tag with alignment Graph GraphCmd `cmd:"" help:"Show dependency graph"` //nolint:lll,revive // Kong struct tag with alignment + Hooks HooksCmd `cmd:"" help:"Process hook events"` //nolint:lll,revive // Kong struct tag with alignment PR PRCmd `cmd:"" help:"Create pull requests"` //nolint:lll,revive // Kong struct tag with alignment View ViewCmd `cmd:"" help:"Display dashboard"` //nolint:lll,revive // Kong struct tag with alignment Version VersionCmd `cmd:"" help:"Show version info"` //nolint:lll,revive // Kong struct tag with alignment diff --git a/internal/domain/frontmatter.go b/internal/domain/frontmatter.go index 705fadb5..240376a0 100644 --- a/internal/domain/frontmatter.go +++ b/internal/domain/frontmatter.go @@ -30,6 +30,7 @@ var ValidFrontmatterKeys = map[string]bool{ "description": true, "allowed-tools": true, "subtask": true, + "hooks": true, // Hook definitions for Claude Code slash commands "context": false, // Claude Code: "fork" runs in forked sub-agent context "agent": true, // Agent routing (e.g., "plan" for planning subagent) } @@ -79,16 +80,19 @@ var BaseSlashCommandFrontmatter = map[SlashCommand]map[string]any{ "description": "Proposal Creation Guide (project)", "allowed-tools": "Read, Glob, Grep, Write, Edit, Bash(spectr:*)", "subtask": false, + "hooks": BuildHooksFrontmatter(SlashProposal), }, SlashApply: { "description": "Change Proposal Application/Acceptance Process (project)", "allowed-tools": "Read, Glob, Grep, Write, Edit, Bash(spectr:*)", "subtask": false, + "hooks": BuildHooksFrontmatter(SlashApply), }, SlashNext: { "description": "Spectr: Next Task Execution", "allowed-tools": "Read, Glob, Grep, Write, Edit, Bash(spectr:*)", "subtask": false, + "hooks": BuildHooksFrontmatter(SlashNext), }, } diff --git a/internal/domain/frontmatter_slashnext_test.go b/internal/domain/frontmatter_slashnext_test.go index 6e9b04b6..a92dc1b6 100644 --- a/internal/domain/frontmatter_slashnext_test.go +++ b/internal/domain/frontmatter_slashnext_test.go @@ -9,7 +9,7 @@ func TestSlashNextFrontmatter(t *testing.T) { // Get the base frontmatter for SlashNext fm := GetBaseFrontmatter(SlashNext) - // Verify expected field values + // Verify expected scalar field values expectedValues := map[string]any{ "description": "Spectr: Next Task Execution", "allowed-tools": "Read, Glob, Grep, Write, Edit, Bash(spectr:*)", @@ -37,12 +37,25 @@ func TestSlashNextFrontmatter(t *testing.T) { } } - // Verify no unexpected fields - if len(fm) != len(expectedValues) { + // Verify hooks field exists and is a map + hooksVal, hasHooks := fm["hooks"] + if !hasHooks { + t.Error("SlashNext frontmatter missing field \"hooks\"") + } else if hooksMap, ok := hooksVal.(map[string]any); !ok { + t.Error("SlashNext frontmatter \"hooks\" is not map[string]any") + } else if len(hooksMap) != len(AllHookTypes()) { t.Errorf( - "SlashNext frontmatter has %d fields, want %d", + "SlashNext frontmatter hooks has %d entries, want %d", + len(hooksMap), + len(AllHookTypes()), + ) + } + + // Verify total field count (3 scalar + 1 hooks = 4) + if len(fm) != 4 { + t.Errorf( + "SlashNext frontmatter has %d fields, want 4", len(fm), - len(expectedValues), ) } } @@ -67,6 +80,7 @@ func TestSlashNextRenderedFrontmatter(t *testing.T) { "---", "allowed-tools: Read, Glob, Grep, Write, Edit, Bash(spectr:*)", "description: 'Spectr: Next Task Execution'", + "hooks:", "subtask: false", "# Spectr: Next Task Execution", } diff --git a/internal/domain/frontmatter_test.go b/internal/domain/frontmatter_test.go index 8e160ef8..d16ef6ec 100644 --- a/internal/domain/frontmatter_test.go +++ b/internal/domain/frontmatter_test.go @@ -18,6 +18,7 @@ func TestGetBaseFrontmatter(t *testing.T) { "description", "allowed-tools", "subtask", + "hooks", }, }, { @@ -27,6 +28,7 @@ func TestGetBaseFrontmatter(t *testing.T) { "description", "allowed-tools", "subtask", + "hooks", }, }, { @@ -36,6 +38,7 @@ func TestGetBaseFrontmatter(t *testing.T) { "description", "allowed-tools", "subtask", + "hooks", }, }, } diff --git a/internal/domain/hooks_frontmatter.go b/internal/domain/hooks_frontmatter.go new file mode 100644 index 00000000..9dacd3f1 --- /dev/null +++ b/internal/domain/hooks_frontmatter.go @@ -0,0 +1,28 @@ +package domain + +// hookTimeout is the default timeout in seconds for hook commands. +const hookTimeout = 600 + +// BuildHooksFrontmatter constructs the hooks map for a slash command's +// YAML frontmatter. Each hook type gets an entry with a command that +// invokes spectr hooks. +func BuildHooksFrontmatter(cmd SlashCommand) map[string]any { + hooks := make(map[string]any, len(AllHookTypes())) + + for _, ht := range AllHookTypes() { + hooks[ht.String()] = []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": "spectr hooks " + ht.String() + " --command " + cmd.String(), + "timeout": hookTimeout, + }, + }, + }, + } + } + + return hooks +} diff --git a/internal/domain/hooks_frontmatter_test.go b/internal/domain/hooks_frontmatter_test.go new file mode 100644 index 00000000..d2ffbdc8 --- /dev/null +++ b/internal/domain/hooks_frontmatter_test.go @@ -0,0 +1,164 @@ +package domain + +import ( + "strings" + "testing" +) + +func TestBuildHooksFrontmatter(t *testing.T) { + tests := []struct { + name string + cmd SlashCommand + }{ + {"proposal", SlashProposal}, + {"apply", SlashApply}, + {"next", SlashNext}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hooks := BuildHooksFrontmatter(tt.cmd) + + // Should have one entry per hook type + if len(hooks) != len(AllHookTypes()) { + t.Errorf( + "BuildHooksFrontmatter(%v) returned %d entries, want %d", + tt.cmd, + len(hooks), + len(AllHookTypes()), + ) + } + + // Verify each hook type is present with correct structure + for _, ht := range AllHookTypes() { + entry, ok := hooks[ht.String()] + if !ok { + t.Errorf( + "BuildHooksFrontmatter(%v) missing hook type %q", + tt.cmd, + ht.String(), + ) + + continue + } + + // Verify entry is []any with one element + arr, ok := entry.([]any) + if !ok || len(arr) != 1 { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] expected []any with 1 element", + tt.cmd, + ht.String(), + ) + + continue + } + + // Verify the matcher/hooks structure + hookEntry, ok := arr[0].(map[string]any) + if !ok { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q][0] expected map[string]any", + tt.cmd, + ht.String(), + ) + + continue + } + + // Check matcher + if matcher, ok := hookEntry["matcher"]; !ok || matcher != "" { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] matcher = %v, want empty string", + tt.cmd, + ht.String(), + matcher, + ) + } + + // Check hooks array + innerHooks, ok := hookEntry["hooks"].([]any) + if !ok || len(innerHooks) != 1 { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] hooks expected []any with 1 element", + tt.cmd, + ht.String(), + ) + + continue + } + + // Check command structure + hookCmd, ok := innerHooks[0].(map[string]any) + if !ok { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] hook command expected map[string]any", + tt.cmd, + ht.String(), + ) + + continue + } + + if hookCmd["type"] != "command" { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] type = %v, want \"command\"", + tt.cmd, + ht.String(), + hookCmd["type"], + ) + } + + // Verify command string contains hook type and command name + cmdStr, ok := hookCmd["command"].(string) + if !ok { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] command is not a string", + tt.cmd, + ht.String(), + ) + + continue + } + + if !strings.Contains(cmdStr, ht.String()) { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] command %q does not contain hook type", + tt.cmd, + ht.String(), + cmdStr, + ) + } + + if !strings.Contains(cmdStr, tt.cmd.String()) { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] command %q does not contain command name", + tt.cmd, + ht.String(), + cmdStr, + ) + } + + expectedCmd := "spectr hooks " + ht.String() + " --command " + tt.cmd.String() + if cmdStr != expectedCmd { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] command = %q, want %q", + tt.cmd, + ht.String(), + cmdStr, + expectedCmd, + ) + } + + if hookCmd["timeout"] != 600 { + t.Errorf( + "BuildHooksFrontmatter(%v)[%q] timeout = %v, want 600", + tt.cmd, + ht.String(), + hookCmd["timeout"], + ) + } + } + }) + } +} diff --git a/internal/domain/hooktype.go b/internal/domain/hooktype.go new file mode 100644 index 00000000..4cb0184c --- /dev/null +++ b/internal/domain/hooktype.go @@ -0,0 +1,71 @@ +package domain + +// HookType represents a type-safe hook event type identifier. +type HookType int + +const ( + HookPreToolUse HookType = iota + HookPostToolUse + HookUserPromptSubmit + HookStop + HookSubagentStart + HookSubagentStop + HookPreCompact + HookSessionStart + HookSessionEnd + HookNotification + HookPermissionRequest +) + +const unknownHookType = "unknown" + +// String returns the PascalCase name for the hook type. +func (h HookType) String() string { + names := []string{ + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "SessionStart", + "SessionEnd", + "Notification", + "PermissionRequest", + } + if int(h) < len(names) { + return names[h] + } + + return unknownHookType +} + +// AllHookTypes returns all defined hook types. +func AllHookTypes() []HookType { + return []HookType{ + HookPreToolUse, + HookPostToolUse, + HookUserPromptSubmit, + HookStop, + HookSubagentStart, + HookSubagentStop, + HookPreCompact, + HookSessionStart, + HookSessionEnd, + HookNotification, + HookPermissionRequest, + } +} + +// ParseHookType parses a string into a HookType. +// Returns the HookType and true if found, or zero value and false if not. +func ParseHookType(s string) (HookType, bool) { + for _, ht := range AllHookTypes() { + if ht.String() == s { + return ht, true + } + } + + return 0, false +} diff --git a/internal/domain/hooktype_test.go b/internal/domain/hooktype_test.go new file mode 100644 index 00000000..0982813d --- /dev/null +++ b/internal/domain/hooktype_test.go @@ -0,0 +1,102 @@ +package domain + +import ( + "testing" +) + +func TestHookTypeString(t *testing.T) { + tests := []struct { + hookType HookType + want string + }{ + {HookPreToolUse, "PreToolUse"}, + {HookPostToolUse, "PostToolUse"}, + {HookUserPromptSubmit, "UserPromptSubmit"}, + {HookStop, "Stop"}, + {HookSubagentStart, "SubagentStart"}, + {HookSubagentStop, "SubagentStop"}, + {HookPreCompact, "PreCompact"}, + {HookSessionStart, "SessionStart"}, + {HookSessionEnd, "SessionEnd"}, + {HookNotification, "Notification"}, + {HookPermissionRequest, "PermissionRequest"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.hookType.String(); got != tt.want { + t.Errorf( + "HookType.String() = %q, want %q", + got, + tt.want, + ) + } + }) + } +} + +func TestHookTypeString_Unknown(t *testing.T) { + unknown := HookType(999) + if got := unknown.String(); got != unknownHookType { + t.Errorf( + "HookType(999).String() = %q, want %q", + got, + unknownHookType, + ) + } +} + +func TestAllHookTypes(t *testing.T) { + types := AllHookTypes() + if len(types) != 11 { + t.Errorf( + "AllHookTypes() returned %d types, want 11", + len(types), + ) + } +} + +func TestParseHookType(t *testing.T) { + tests := []struct { + input string + want HookType + ok bool + }{ + {"PreToolUse", HookPreToolUse, true}, + {"PostToolUse", HookPostToolUse, true}, + {"UserPromptSubmit", HookUserPromptSubmit, true}, + {"Stop", HookStop, true}, + {"SubagentStart", HookSubagentStart, true}, + {"SubagentStop", HookSubagentStop, true}, + {"PreCompact", HookPreCompact, true}, + {"SessionStart", HookSessionStart, true}, + {"SessionEnd", HookSessionEnd, true}, + {"Notification", HookNotification, true}, + {"PermissionRequest", HookPermissionRequest, true}, + {"invalid", 0, false}, + {"pretooluse", 0, false}, + {"", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, ok := ParseHookType(tt.input) + if ok != tt.ok { + t.Errorf( + "ParseHookType(%q) ok = %v, want %v", + tt.input, + ok, + tt.ok, + ) + } + if got != tt.want { + t.Errorf( + "ParseHookType(%q) = %v, want %v", + tt.input, + got, + tt.want, + ) + } + }) + } +} diff --git a/internal/hooks/handler.go b/internal/hooks/handler.go new file mode 100644 index 00000000..b12f7db4 --- /dev/null +++ b/internal/hooks/handler.go @@ -0,0 +1,57 @@ +package hooks + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/connerohnesorge/spectr/internal/domain" +) + +// Handle reads hook input from stdin, dispatches to the appropriate handler, +// and writes the hook output to stdout. +func Handle( + hookType domain.HookType, + command string, + stdin io.Reader, + stdout io.Writer, +) error { + var input HookInput + if err := json.NewDecoder(stdin).Decode(&input); err != nil { + return fmt.Errorf("failed to decode hook input: %w", err) + } + + output := dispatch(hookType, command, &input) + + if err := json.NewEncoder(stdout).Encode(output); err != nil { + return fmt.Errorf("failed to encode hook output: %w", err) + } + + return nil +} + +// dispatch routes hook events to their handlers. +// Most hook types are no-ops that return an empty (non-blocking) response. +func dispatch( + hookType domain.HookType, + command string, + input *HookInput, +) *HookOutput { + switch hookType { + case domain.HookPreToolUse: + return handlePreToolUse(command, input) + case domain.HookPostToolUse, + domain.HookUserPromptSubmit, + domain.HookStop, + domain.HookSubagentStart, + domain.HookSubagentStop, + domain.HookPreCompact, + domain.HookSessionStart, + domain.HookSessionEnd, + domain.HookNotification, + domain.HookPermissionRequest: + return &HookOutput{} + } + + return &HookOutput{} +} diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go new file mode 100644 index 00000000..0f1801cc --- /dev/null +++ b/internal/hooks/pretooluse.go @@ -0,0 +1,65 @@ +package hooks + +import ( + "encoding/json" + "regexp" + "strings" +) + +// spectrChangesPattern matches file paths under spectr/changes//. +var spectrChangesPattern = regexp.MustCompile( + `(?:^|/)spectr/changes/[^/]+/`, +) + +// fileToolNames lists the tool names that perform file write operations. +var fileToolNames = map[string]bool{ + "Edit": true, + "Write": true, +} + +// handlePreToolUse checks if a file write operation targets protected +// files under spectr/changes/. For the "apply" command, only tasks.jsonc +// is allowed to be modified. +func handlePreToolUse( + command string, + input *HookInput, +) *HookOutput { + // Only guard file writes during apply + if command != "apply" { + return &HookOutput{} + } + + // Only check file-writing tools + if !fileToolNames[input.ToolName] { + return &HookOutput{} + } + + // Parse tool_input to get file_path + var ti toolInput + if err := json.Unmarshal(input.ToolInput, &ti); err != nil { + return &HookOutput{} + } + + if ti.FilePath == "" { + return &HookOutput{} + } + + // Check if the file is under spectr/changes// + if !spectrChangesPattern.MatchString(ti.FilePath) { + return &HookOutput{} + } + + // Allow tasks.jsonc modifications + if strings.HasSuffix(ti.FilePath, "/tasks.jsonc") { + return &HookOutput{} + } + + // Block all other modifications under spectr/changes/ + msg := "Blocked: cannot modify " + ti.FilePath + + " during apply. Only tasks.jsonc may be modified under spectr/changes/." + + return &HookOutput{ + Blocked: true, + Message: &msg, + } +} diff --git a/internal/hooks/pretooluse_test.go b/internal/hooks/pretooluse_test.go new file mode 100644 index 00000000..8b20825f --- /dev/null +++ b/internal/hooks/pretooluse_test.go @@ -0,0 +1,210 @@ +package hooks + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/connerohnesorge/spectr/internal/domain" +) + +func TestHandlePreToolUse_BlocksProposalModification( + t *testing.T, +) { + input := HookInput{ + SessionID: "test-session", + HookEventName: "PreToolUse", + ToolName: "Edit", + ToolInput: json.RawMessage( + `{"file_path": "spectr/changes/foo/proposal.md", "old_string": "a", "new_string": "b"}`, + ), + } + + output := handlePreToolUse("apply", &input) + + if !output.Blocked { + t.Error("expected Edit on proposal.md to be blocked") + } + if output.Message == nil { + t.Fatal("expected message when blocked") + } + if !strings.Contains(*output.Message, "proposal.md") { + t.Errorf( + "message should mention the file, got: %s", + *output.Message, + ) + } +} + +func TestHandlePreToolUse_AllowsTasksJsonc( + t *testing.T, +) { + input := HookInput{ + SessionID: "test-session", + HookEventName: "PreToolUse", + ToolName: "Write", + ToolInput: json.RawMessage( + `{"file_path": "spectr/changes/foo/tasks.jsonc", "content": "{}"}`, + ), + } + + output := handlePreToolUse("apply", &input) + + if output.Blocked { + t.Error("expected Write on tasks.jsonc to be allowed") + } +} + +func TestHandlePreToolUse_AllowsNonApplyCommand( + t *testing.T, +) { + input := HookInput{ + SessionID: "test-session", + HookEventName: "PreToolUse", + ToolName: "Edit", + ToolInput: json.RawMessage( + `{"file_path": "spectr/changes/foo/proposal.md", "old_string": "a", "new_string": "b"}`, + ), + } + + output := handlePreToolUse("proposal", &input) + + if output.Blocked { + t.Error("expected non-apply command to not block") + } +} + +func TestHandlePreToolUse_AllowsNonFileTools( + t *testing.T, +) { + input := HookInput{ + SessionID: "test-session", + HookEventName: "PreToolUse", + ToolName: "Read", + ToolInput: json.RawMessage( + `{"file_path": "spectr/changes/foo/proposal.md"}`, + ), + } + + output := handlePreToolUse("apply", &input) + + if output.Blocked { + t.Error("expected Read tool to not be blocked") + } +} + +func TestHandlePreToolUse_AllowsFilesOutsideChanges( + t *testing.T, +) { + input := HookInput{ + SessionID: "test-session", + HookEventName: "PreToolUse", + ToolName: "Edit", + ToolInput: json.RawMessage( + `{"file_path": "src/main.go", "old_string": "a", "new_string": "b"}`, + ), + } + + output := handlePreToolUse("apply", &input) + + if output.Blocked { + t.Error("expected files outside spectr/changes/ to not be blocked") + } +} + +func TestHandlePreToolUse_BlocksWriteTool( + t *testing.T, +) { + input := HookInput{ + SessionID: "test-session", + HookEventName: "PreToolUse", + ToolName: "Write", + ToolInput: json.RawMessage( + `{"file_path": "spectr/changes/bar/specs/auth/spec.md", "content": "new content"}`, + ), + } + + output := handlePreToolUse("apply", &input) + + if !output.Blocked { + t.Error("expected Write on spec.md under changes to be blocked") + } +} + +func TestHandlePreToolUse_AbsolutePath( + t *testing.T, +) { + input := HookInput{ + SessionID: "test-session", + HookEventName: "PreToolUse", + ToolName: "Edit", + ToolInput: json.RawMessage( + `{"file_path": "/home/user/project/spectr/changes/foo/proposal.md", "old_string": "a", "new_string": "b"}`, + ), + } + + output := handlePreToolUse("apply", &input) + + if !output.Blocked { + t.Error("expected absolute path under spectr/changes/ to be blocked") + } +} + +func TestHandle_FullRoundTrip(t *testing.T) { + inputJSON := `{ + "session_id": "test", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": {"file_path": "spectr/changes/foo/proposal.md", "old_string": "a", "new_string": "b"} + }` + + var buf bytes.Buffer + err := Handle( + domain.HookPreToolUse, + "apply", + strings.NewReader(inputJSON), + &buf, + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + + var output HookOutput + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + if !output.Blocked { + t.Error("expected blocked output from full round trip") + } +} + +func TestHandle_NoopHookType(t *testing.T) { + inputJSON := `{ + "session_id": "test", + "hook_event_name": "Stop", + "tool_name": "", + "tool_input": {} + }` + + var buf bytes.Buffer + err := Handle( + domain.HookStop, + "apply", + strings.NewReader(inputJSON), + &buf, + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + + var output HookOutput + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + if output.Blocked { + t.Error("expected non-blocking output for Stop hook type") + } +} diff --git a/internal/hooks/types.go b/internal/hooks/types.go new file mode 100644 index 00000000..106233b9 --- /dev/null +++ b/internal/hooks/types.go @@ -0,0 +1,24 @@ +// Package hooks implements hook handling for Claude Code slash commands. +package hooks + +import "encoding/json" + +// HookInput represents the JSON payload received from Claude Code via stdin. +type HookInput struct { + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` +} + +// HookOutput represents the JSON response sent to Claude Code via stdout. +type HookOutput struct { + Blocked bool `json:"blocked"` + Message *string `json:"message"` + SystemPrompt *string `json:"system_prompt"` +} + +// toolInput represents parsed tool_input fields relevant to file operations. +type toolInput struct { + FilePath string `json:"file_path"` +}