316 lines
8.0 KiB
Go
316 lines
8.0 KiB
Go
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",
|
|
GroupID: GroupConfig,
|
|
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
|
|
// NOTE: Mayor settings are at ~/gt/mayor/.claude/, NOT ~/gt/.claude/
|
|
// Settings at town root would pollute all child workspaces.
|
|
locations := []struct {
|
|
path string
|
|
agent string
|
|
}{
|
|
{filepath.Join(townRoot, "mayor", ".claude", "settings.json"), "mayor/"},
|
|
{filepath.Join(townRoot, "deacon", ".claude", "settings.json"), "deacon/"},
|
|
}
|
|
|
|
// 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() && !strings.HasPrefix(p.Name(), ".") {
|
|
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
|
|
}
|