feat(hooks): add hook registry and install command (bd-qj9nc)
- Add hooks_registry.go: LoadRegistry(), HookRegistry/HookDefinition types - Add hooks_install.go: gt hooks install command with --role and --all-rigs flags - gt hooks list now reads from ~/gt/hooks/registry.toml - Supports dry-run, deduplication, and creates .claude dirs as needed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
beads/crew/emma
parent
6616a4726c
commit
d0e49a216a
@@ -0,0 +1,165 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// HookRegistry represents the hooks/registry.toml structure.
|
||||
type HookRegistry struct {
|
||||
Hooks map[string]HookDefinition `toml:"hooks"`
|
||||
}
|
||||
|
||||
// HookDefinition represents a single hook definition in the registry.
|
||||
type HookDefinition struct {
|
||||
Description string `toml:"description"`
|
||||
Event string `toml:"event"`
|
||||
Matchers []string `toml:"matchers"`
|
||||
Command string `toml:"command"`
|
||||
Roles []string `toml:"roles"`
|
||||
Scope string `toml:"scope"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
}
|
||||
|
||||
var (
|
||||
hooksListAll bool
|
||||
)
|
||||
|
||||
var hooksListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available hooks from the registry",
|
||||
Long: `List all hooks defined in the hook registry.
|
||||
|
||||
The registry is at ~/gt/hooks/registry.toml and defines hooks that can be
|
||||
installed for different roles (crew, polecat, witness, etc.).
|
||||
|
||||
Examples:
|
||||
gt hooks list # Show enabled hooks
|
||||
gt hooks list --all # Show all hooks including disabled`,
|
||||
RunE: runHooksList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
hooksCmd.AddCommand(hooksListCmd)
|
||||
hooksListCmd.Flags().BoolVarP(&hooksListAll, "all", "a", false, "Show all hooks including disabled")
|
||||
hooksListCmd.Flags().BoolVarP(&hooksVerbose, "verbose", "v", false, "Show hook commands and matchers")
|
||||
}
|
||||
|
||||
// LoadRegistry loads the hook registry from the town's hooks directory.
|
||||
func LoadRegistry(townRoot string) (*HookRegistry, error) {
|
||||
registryPath := filepath.Join(townRoot, "hooks", "registry.toml")
|
||||
|
||||
data, err := os.ReadFile(registryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("hook registry not found at %s", registryPath)
|
||||
}
|
||||
return nil, fmt.Errorf("reading registry: %w", err)
|
||||
}
|
||||
|
||||
var registry HookRegistry
|
||||
if _, err := toml.Decode(string(data), ®istry); err != nil {
|
||||
return nil, fmt.Errorf("parsing registry: %w", err)
|
||||
}
|
||||
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func runHooksList(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
registry, err := LoadRegistry(townRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(registry.Hooks) == 0 {
|
||||
fmt.Println(style.Dim.Render("No hooks defined in registry"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Hook Registry\n", style.Bold.Render("📋"))
|
||||
fmt.Printf("Source: %s\n\n", style.Dim.Render(filepath.Join(townRoot, "hooks", "registry.toml")))
|
||||
|
||||
// Group by event type
|
||||
byEvent := make(map[string][]struct {
|
||||
name string
|
||||
def HookDefinition
|
||||
})
|
||||
eventOrder := []string{"PreToolUse", "PostToolUse", "SessionStart", "PreCompact", "UserPromptSubmit", "Stop"}
|
||||
|
||||
for name, def := range registry.Hooks {
|
||||
if !hooksListAll && !def.Enabled {
|
||||
continue
|
||||
}
|
||||
byEvent[def.Event] = append(byEvent[def.Event], struct {
|
||||
name string
|
||||
def HookDefinition
|
||||
}{name, def})
|
||||
}
|
||||
|
||||
// Add any events not in the predefined order
|
||||
for event := range byEvent {
|
||||
found := false
|
||||
for _, o := range eventOrder {
|
||||
if event == o {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
eventOrder = append(eventOrder, event)
|
||||
}
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, event := range eventOrder {
|
||||
hooks := byEvent[event]
|
||||
if len(hooks) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("▸"), event)
|
||||
|
||||
for _, h := range hooks {
|
||||
count++
|
||||
statusIcon := "●"
|
||||
statusColor := style.Success
|
||||
if !h.def.Enabled {
|
||||
statusIcon = "○"
|
||||
statusColor = style.Dim
|
||||
}
|
||||
|
||||
rolesStr := strings.Join(h.def.Roles, ", ")
|
||||
scopeStr := h.def.Scope
|
||||
|
||||
fmt.Printf(" %s %s\n", statusColor.Render(statusIcon), style.Bold.Render(h.name))
|
||||
fmt.Printf(" %s\n", h.def.Description)
|
||||
fmt.Printf(" %s %s %s %s\n",
|
||||
style.Dim.Render("roles:"), rolesStr,
|
||||
style.Dim.Render("scope:"), scopeStr)
|
||||
|
||||
if hooksVerbose {
|
||||
fmt.Printf(" %s %s\n", style.Dim.Render("command:"), h.def.Command)
|
||||
for _, m := range h.def.Matchers {
|
||||
fmt.Printf(" %s %s\n", style.Dim.Render("matcher:"), m)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("%s %d hooks in registry\n", style.Dim.Render("Total:"), count)
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user