Skip to content
Open
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
29 changes: 29 additions & 0 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/domain/frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
},
}

Expand Down
24 changes: 19 additions & 5 deletions internal/domain/frontmatter_slashnext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:*)",
Expand Down Expand Up @@ -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),
)
}
}
Expand All @@ -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",
}
Expand Down
3 changes: 3 additions & 0 deletions internal/domain/frontmatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func TestGetBaseFrontmatter(t *testing.T) {
"description",
"allowed-tools",
"subtask",
"hooks",
},
},
{
Expand All @@ -27,6 +28,7 @@ func TestGetBaseFrontmatter(t *testing.T) {
"description",
"allowed-tools",
"subtask",
"hooks",
},
},
{
Expand All @@ -36,6 +38,7 @@ func TestGetBaseFrontmatter(t *testing.T) {
"description",
"allowed-tools",
"subtask",
"hooks",
},
},
}
Expand Down
28 changes: 28 additions & 0 deletions internal/domain/hooks_frontmatter.go
Original file line number Diff line number Diff line change
@@ -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
}
164 changes: 164 additions & 0 deletions internal/domain/hooks_frontmatter_test.go
Original file line number Diff line number Diff line change
@@ -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"],
)
}
}
})
}
}
Loading
Loading