diff --git a/internal/cmd/hooks.go b/internal/cmd/hooks.go new file mode 100644 index 00000000..53b44e5b --- /dev/null +++ b/internal/cmd/hooks.go @@ -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 +} diff --git a/internal/cmd/hooks_test.go b/internal/cmd/hooks_test.go new file mode 100644 index 00000000..b14bb3a3 --- /dev/null +++ b/internal/cmd/hooks_test.go @@ -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)) + } +}