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
|
||||
}
|
||||
Reference in New Issue
Block a user