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:
312
internal/cmd/hooks.go
Normal file
312
internal/cmd/hooks.go
Normal 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
135
internal/cmd/hooks_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user