feat: add gt hooks command to list Claude Code hooks

Add a command to list all hooks in the workspace. Scans for .claude/settings.json
files and displays hooks by type (SessionStart, PreCompact, UserPromptSubmit, etc).

Features:
- Scans town root, rigs, polecats, crew, witness, and refinery directories
- Shows hook type, location, status, and agent that owns it
- Supports --verbose flag to show hook commands
- Supports --json flag for machine-readable output

(gt-h6eq.5)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 11:33:43 -08:00
parent 26a7e54205
commit 949e1228a6
2 changed files with 447 additions and 0 deletions

312
internal/cmd/hooks.go Normal file
View File

@@ -0,0 +1,312 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
hooksJSON bool
hooksVerbose bool
)
var hooksCmd = &cobra.Command{
Use: "hooks",
Short: "List all Claude Code hooks in the workspace",
Long: `List all Claude Code hooks configured in the workspace.
Scans for .claude/settings.json files and displays hooks by type.
Hook types:
SessionStart - Runs when Claude session starts
PreCompact - Runs before context compaction
UserPromptSubmit - Runs before user prompt is submitted
PreToolUse - Runs before tool execution
PostToolUse - Runs after tool execution
Stop - Runs when Claude session stops
Examples:
gt hooks # List all hooks in workspace
gt hooks --verbose # Show hook commands
gt hooks --json # Output as JSON`,
RunE: runHooks,
}
func init() {
rootCmd.AddCommand(hooksCmd)
hooksCmd.Flags().BoolVar(&hooksJSON, "json", false, "Output as JSON")
hooksCmd.Flags().BoolVarP(&hooksVerbose, "verbose", "v", false, "Show hook commands")
}
// ClaudeSettings represents the Claude Code settings.json structure.
type ClaudeSettings struct {
EnabledPlugins map[string]bool `json:"enabledPlugins,omitempty"`
Hooks map[string][]ClaudeHookMatcher `json:"hooks,omitempty"`
}
// ClaudeHookMatcher represents a hook matcher entry.
type ClaudeHookMatcher struct {
Matcher string `json:"matcher"`
Hooks []ClaudeHook `json:"hooks"`
}
// ClaudeHook represents an individual hook.
type ClaudeHook struct {
Type string `json:"type"`
Command string `json:"command,omitempty"`
}
// HookInfo contains information about a discovered hook.
type HookInfo struct {
Type string `json:"type"` // Hook type (SessionStart, etc.)
Location string `json:"location"` // Path to the settings file
Agent string `json:"agent"` // Agent that owns this hook (e.g., "polecat/nux")
Matcher string `json:"matcher"` // Pattern matcher (empty = all)
Commands []string `json:"commands"` // Hook commands
Status string `json:"status"` // "active" or "disabled"
}
// HooksOutput is the JSON output structure.
type HooksOutput struct {
TownRoot string `json:"town_root"`
Hooks []HookInfo `json:"hooks"`
Count int `json:"count"`
}
func runHooks(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Find all .claude/settings.json files
hooks, err := discoverHooks(townRoot)
if err != nil {
return fmt.Errorf("discovering hooks: %w", err)
}
if hooksJSON {
return outputHooksJSON(townRoot, hooks)
}
return outputHooksHuman(townRoot, hooks)
}
// discoverHooks finds all Claude Code hooks in the workspace.
func discoverHooks(townRoot string) ([]HookInfo, error) {
var hooks []HookInfo
// Scan known locations for .claude/settings.json
locations := []struct {
path string
agent string
}{
{filepath.Join(townRoot, "mayor", ".claude", "settings.json"), "mayor/"},
{filepath.Join(townRoot, ".claude", "settings.json"), "town-root"},
}
// Scan rigs
entries, err := os.ReadDir(townRoot)
if err != nil {
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == "mayor" || entry.Name() == ".beads" || strings.HasPrefix(entry.Name(), ".") {
continue
}
rigName := entry.Name()
rigPath := filepath.Join(townRoot, rigName)
// Rig-level hooks
locations = append(locations, struct {
path string
agent string
}{filepath.Join(rigPath, ".claude", "settings.json"), fmt.Sprintf("%s/rig", rigName)})
// Polecats
polecatsDir := filepath.Join(rigPath, "polecats")
if polecats, err := os.ReadDir(polecatsDir); err == nil {
for _, p := range polecats {
if p.IsDir() {
locations = append(locations, struct {
path string
agent string
}{filepath.Join(polecatsDir, p.Name(), ".claude", "settings.json"), fmt.Sprintf("%s/%s", rigName, p.Name())})
}
}
}
// Crew members
crewDir := filepath.Join(rigPath, "crew")
if crew, err := os.ReadDir(crewDir); err == nil {
for _, c := range crew {
if c.IsDir() {
locations = append(locations, struct {
path string
agent string
}{filepath.Join(crewDir, c.Name(), ".claude", "settings.json"), fmt.Sprintf("%s/crew/%s", rigName, c.Name())})
}
}
}
// Witness
witnessPath := filepath.Join(rigPath, "witness", ".claude", "settings.json")
locations = append(locations, struct {
path string
agent string
}{witnessPath, fmt.Sprintf("%s/witness", rigName)})
// Refinery
refineryPath := filepath.Join(rigPath, "refinery", ".claude", "settings.json")
locations = append(locations, struct {
path string
agent string
}{refineryPath, fmt.Sprintf("%s/refinery", rigName)})
}
// Process each location
for _, loc := range locations {
if _, err := os.Stat(loc.path); os.IsNotExist(err) {
continue
}
found, err := parseHooksFile(loc.path, loc.agent)
if err != nil {
// Skip files that can't be parsed
continue
}
hooks = append(hooks, found...)
}
return hooks, nil
}
// parseHooksFile parses a .claude/settings.json file and extracts hooks.
func parseHooksFile(path, agent string) ([]HookInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var settings ClaudeSettings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
var hooks []HookInfo
for hookType, matchers := range settings.Hooks {
for _, matcher := range matchers {
var commands []string
for _, h := range matcher.Hooks {
if h.Command != "" {
commands = append(commands, h.Command)
}
}
if len(commands) > 0 {
hooks = append(hooks, HookInfo{
Type: hookType,
Location: path,
Agent: agent,
Matcher: matcher.Matcher,
Commands: commands,
Status: "active",
})
}
}
}
return hooks, nil
}
func outputHooksJSON(townRoot string, hooks []HookInfo) error {
output := HooksOutput{
TownRoot: townRoot,
Hooks: hooks,
Count: len(hooks),
}
data, err := json.MarshalIndent(output, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
func outputHooksHuman(townRoot string, hooks []HookInfo) error {
if len(hooks) == 0 {
fmt.Println(style.Dim.Render("No Claude Code hooks found in workspace"))
return nil
}
fmt.Printf("\n%s Claude Code Hooks\n", style.Bold.Render("🪝"))
fmt.Printf("Town root: %s\n\n", style.Dim.Render(townRoot))
// Group by hook type
byType := make(map[string][]HookInfo)
typeOrder := []string{"SessionStart", "PreCompact", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"}
for _, h := range hooks {
byType[h.Type] = append(byType[h.Type], h)
}
// Add any types not in the predefined order
for t := range byType {
found := false
for _, o := range typeOrder {
if t == o {
found = true
break
}
}
if !found {
typeOrder = append(typeOrder, t)
}
}
for _, hookType := range typeOrder {
typeHooks := byType[hookType]
if len(typeHooks) == 0 {
continue
}
fmt.Printf("%s %s\n", style.Bold.Render("▸"), hookType)
for _, h := range typeHooks {
statusIcon := "●"
if h.Status != "active" {
statusIcon = "○"
}
matcherStr := ""
if h.Matcher != "" {
matcherStr = fmt.Sprintf(" [%s]", h.Matcher)
}
fmt.Printf(" %s %-25s%s\n", statusIcon, h.Agent, style.Dim.Render(matcherStr))
if hooksVerbose {
for _, cmd := range h.Commands {
fmt.Printf(" %s %s\n", style.Dim.Render("→"), cmd)
}
}
}
fmt.Println()
}
fmt.Printf("%s %d hooks found\n", style.Dim.Render("Total:"), len(hooks))
return nil
}

135
internal/cmd/hooks_test.go Normal file
View File

@@ -0,0 +1,135 @@
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestParseHooksFile(t *testing.T) {
// Create a temp directory with a test settings file
tmpDir := t.TempDir()
claudeDir := filepath.Join(tmpDir, ".claude")
if err := os.MkdirAll(claudeDir, 0755); err != nil {
t.Fatalf("failed to create .claude dir: %v", err)
}
settings := ClaudeSettings{
Hooks: map[string][]ClaudeHookMatcher{
"SessionStart": {
{
Matcher: "",
Hooks: []ClaudeHook{
{Type: "command", Command: "gt prime"},
},
},
},
"UserPromptSubmit": {
{
Matcher: "*.go",
Hooks: []ClaudeHook{
{Type: "command", Command: "go fmt"},
{Type: "command", Command: "go vet"},
},
},
},
},
}
data, err := json.Marshal(settings)
if err != nil {
t.Fatalf("failed to marshal settings: %v", err)
}
settingsPath := filepath.Join(claudeDir, "settings.json")
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
t.Fatalf("failed to write settings: %v", err)
}
// Parse the file
hooks, err := parseHooksFile(settingsPath, "test/agent")
if err != nil {
t.Fatalf("parseHooksFile failed: %v", err)
}
// Verify results
if len(hooks) != 2 {
t.Errorf("expected 2 hooks, got %d", len(hooks))
}
// Find the SessionStart hook
var sessionStart, userPrompt *HookInfo
for i := range hooks {
switch hooks[i].Type {
case "SessionStart":
sessionStart = &hooks[i]
case "UserPromptSubmit":
userPrompt = &hooks[i]
}
}
if sessionStart == nil {
t.Fatal("expected SessionStart hook")
}
if sessionStart.Agent != "test/agent" {
t.Errorf("expected agent 'test/agent', got %q", sessionStart.Agent)
}
if len(sessionStart.Commands) != 1 || sessionStart.Commands[0] != "gt prime" {
t.Errorf("unexpected SessionStart commands: %v", sessionStart.Commands)
}
if userPrompt == nil {
t.Fatal("expected UserPromptSubmit hook")
}
if userPrompt.Matcher != "*.go" {
t.Errorf("expected matcher '*.go', got %q", userPrompt.Matcher)
}
if len(userPrompt.Commands) != 2 {
t.Errorf("expected 2 commands, got %d", len(userPrompt.Commands))
}
}
func TestParseHooksFileMissing(t *testing.T) {
_, err := parseHooksFile("/nonexistent/settings.json", "test")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestParseHooksFileInvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
if err := os.WriteFile(settingsPath, []byte("not json"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
_, err := parseHooksFile(settingsPath, "test")
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestParseHooksFileEmptyHooks(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
settings := ClaudeSettings{
Hooks: map[string][]ClaudeHookMatcher{},
}
data, _ := json.Marshal(settings)
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
hooks, err := parseHooksFile(settingsPath, "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(hooks) != 0 {
t.Errorf("expected 0 hooks, got %d", len(hooks))
}
}