333 lines
11 KiB
Go
333 lines
11 KiB
Go
// Package hooksjson implements the matcher-group hooks file that several agents
|
|
// (claude-code, goose, qwen, agy, droid) share byte-for-byte in shape. Each such
|
|
// file is a JSON object with a "hooks" sub-map keyed by native event name, whose
|
|
// values are matcher groups ({matcher?, hooks:[{type,command,timeout}]}). The
|
|
// adapters differed only in the file path, the AO command prefix, the per-hook
|
|
// timeout, and which events they install, so they describe those with a Manager
|
|
// and share the install/uninstall/detect logic here.
|
|
//
|
|
// The read/write path preserves every top-level key and every user-defined hook
|
|
// AO does not own, and writes atomically, so installing AO's hooks never clobbers
|
|
// unrelated settings.
|
|
package hooksjson
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
|
)
|
|
|
|
// HookEntry is one command hook inside a matcher group.
|
|
type HookEntry struct {
|
|
Type string `json:"type"`
|
|
Command string `json:"command"`
|
|
Timeout int `json:"timeout,omitempty"`
|
|
}
|
|
|
|
// MatcherGroup is a set of hooks sharing one matcher. Matcher is a pointer so it
|
|
// round-trips exactly: events that require a matcher (e.g. claude SessionStart's
|
|
// "startup") carry one; events that omit it serialize without the key.
|
|
type MatcherGroup struct {
|
|
Matcher *string `json:"matcher,omitempty"`
|
|
Hooks []HookEntry `json:"hooks"`
|
|
}
|
|
|
|
// HookSpec describes one hook AO installs: the native event it attaches to, its
|
|
// optional matcher, and the command to run. Adapters define these in code rather
|
|
// than reading an embedded template.
|
|
type HookSpec struct {
|
|
Event string
|
|
Matcher *string
|
|
Command string
|
|
}
|
|
|
|
// Manager installs, removes, and detects AO's hooks in one agent's matcher-group
|
|
// hooks file. Construct one per adapter with its file path, command prefix,
|
|
// per-hook timeout, and managed hook set.
|
|
type Manager struct {
|
|
// Label prefixes error messages, e.g. "claude-code" or "goose", so the
|
|
// wrapped error reads "<label>.GetAgentHooks: ...".
|
|
Label string
|
|
// CommandPrefix identifies AO-owned hook commands, e.g. "ao hooks goose ".
|
|
// Install skips commands already present and uninstall/detect match on it.
|
|
CommandPrefix string
|
|
// Timeout is written into each installed hook entry.
|
|
Timeout int
|
|
// Path returns the hooks file path for a workspace.
|
|
Path func(workspacePath string) string
|
|
// Managed is the set of hooks AO installs.
|
|
Managed []HookSpec
|
|
}
|
|
|
|
// Install merges AO's managed hooks into the workspace's hooks file, preserving
|
|
// user-defined hooks and unrelated settings, and is idempotent (a command
|
|
// already present is not appended). It also writes a self-ignoring .gitignore
|
|
// covering the hooks file so it does not block worktree teardown.
|
|
func (m Manager) Install(ctx context.Context, workspacePath string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(workspacePath) == "" {
|
|
return fmt.Errorf("%s.GetAgentHooks: WorkspacePath is required", m.Label)
|
|
}
|
|
|
|
hooksPath := m.Path(workspacePath)
|
|
topLevel, rawHooks, err := readHooksFile(hooksPath)
|
|
if err != nil {
|
|
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
|
}
|
|
|
|
for event, specs := range m.groupByEvent() {
|
|
var groups []MatcherGroup
|
|
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
|
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
|
}
|
|
for _, spec := range specs {
|
|
if !commandExists(groups, spec.Command) {
|
|
entry := HookEntry{Type: "command", Command: spec.Command, Timeout: m.Timeout}
|
|
groups = addHook(groups, entry, spec.Matcher)
|
|
}
|
|
}
|
|
if err := marshalEvent(rawHooks, event, groups); err != nil {
|
|
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
|
}
|
|
}
|
|
|
|
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
|
|
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
|
}
|
|
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), filepath.Base(hooksPath)); err != nil {
|
|
return fmt.Errorf("%s.GetAgentHooks: gitignore: %w", m.Label, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Uninstall removes AO's hooks from the workspace's hooks file, leaving
|
|
// user-defined hooks and unrelated settings untouched. A missing file is a no-op.
|
|
func (m Manager) Uninstall(ctx context.Context, workspacePath string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(workspacePath) == "" {
|
|
return fmt.Errorf("%s.UninstallHooks: workspacePath is required", m.Label)
|
|
}
|
|
|
|
hooksPath := m.Path(workspacePath)
|
|
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
topLevel, rawHooks, err := readHooksFile(hooksPath)
|
|
if err != nil {
|
|
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
|
}
|
|
|
|
for _, event := range m.managedEvents() {
|
|
var groups []MatcherGroup
|
|
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
|
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
|
}
|
|
groups = removeManaged(groups, m.CommandPrefix)
|
|
if err := marshalEvent(rawHooks, event, groups); err != nil {
|
|
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
|
}
|
|
}
|
|
|
|
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
|
|
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AreInstalled reports whether any AO hook is present in the workspace's hooks
|
|
// file. A missing file means none are installed.
|
|
func (m Manager) AreInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
if strings.TrimSpace(workspacePath) == "" {
|
|
return false, fmt.Errorf("%s.AreHooksInstalled: workspacePath is required", m.Label)
|
|
}
|
|
|
|
hooksPath := m.Path(workspacePath)
|
|
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
_, rawHooks, err := readHooksFile(hooksPath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
|
|
}
|
|
|
|
for _, event := range m.managedEvents() {
|
|
var groups []MatcherGroup
|
|
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
|
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
|
|
}
|
|
for _, group := range groups {
|
|
for _, hook := range group.Hooks {
|
|
if strings.HasPrefix(hook.Command, m.CommandPrefix) {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// groupByEvent groups the managed specs by event so each event array is
|
|
// rewritten once.
|
|
func (m Manager) groupByEvent() map[string][]HookSpec {
|
|
byEvent := map[string][]HookSpec{}
|
|
for _, spec := range m.Managed {
|
|
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
|
}
|
|
return byEvent
|
|
}
|
|
|
|
// managedEvents returns the distinct managed events, in first-seen order.
|
|
func (m Manager) managedEvents() []string {
|
|
seen := map[string]bool{}
|
|
events := make([]string, 0, len(m.Managed))
|
|
for _, spec := range m.Managed {
|
|
if !seen[spec.Event] {
|
|
seen[spec.Event] = true
|
|
events = append(events, spec.Event)
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
// readHooksFile loads the file into a top-level raw map plus the decoded "hooks"
|
|
// sub-map, preserving every key AO doesn't manage. A missing or empty file
|
|
// yields empty maps.
|
|
func readHooksFile(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) {
|
|
topLevel = map[string]json.RawMessage{}
|
|
rawHooks = map[string]json.RawMessage{}
|
|
|
|
data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return topLevel, rawHooks, nil
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err)
|
|
}
|
|
if strings.TrimSpace(string(data)) == "" {
|
|
return topLevel, rawHooks, nil
|
|
}
|
|
if err := json.Unmarshal(data, &topLevel); err != nil {
|
|
return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err)
|
|
}
|
|
if hooksRaw, ok := topLevel["hooks"]; ok {
|
|
if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil {
|
|
return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err)
|
|
}
|
|
}
|
|
return topLevel, rawHooks, nil
|
|
}
|
|
|
|
// writeHooksFile folds rawHooks back into topLevel and writes the file
|
|
// atomically. An empty hooks map drops the "hooks" key entirely.
|
|
func writeHooksFile(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
|
|
if len(rawHooks) == 0 {
|
|
delete(topLevel, "hooks")
|
|
} else {
|
|
hooksJSON, err := json.Marshal(rawHooks)
|
|
if err != nil {
|
|
return fmt.Errorf("encode hooks: %w", err)
|
|
}
|
|
topLevel["hooks"] = hooksJSON
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
|
|
return fmt.Errorf("create hook dir: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(topLevel, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
|
}
|
|
data = append(data, '\n')
|
|
if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
|
|
return fmt.Errorf("write %s: %w", hooksPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseEvent(rawHooks map[string]json.RawMessage, event string, target *[]MatcherGroup) error {
|
|
data, ok := rawHooks[event]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(data, target); err != nil {
|
|
return fmt.Errorf("parse %s hooks: %w", event, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func marshalEvent(rawHooks map[string]json.RawMessage, event string, groups []MatcherGroup) error {
|
|
if len(groups) == 0 {
|
|
delete(rawHooks, event)
|
|
return nil
|
|
}
|
|
data, err := json.Marshal(groups)
|
|
if err != nil {
|
|
return fmt.Errorf("encode %s hooks: %w", event, err)
|
|
}
|
|
rawHooks[event] = data
|
|
return nil
|
|
}
|
|
|
|
func commandExists(groups []MatcherGroup, command string) bool {
|
|
for _, group := range groups {
|
|
for _, hook := range group.Hooks {
|
|
if hook.Command == command {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// addHook appends hook to the group with a matching matcher, creating that group
|
|
// if none matches.
|
|
func addHook(groups []MatcherGroup, hook HookEntry, matcher *string) []MatcherGroup {
|
|
for i, group := range groups {
|
|
if matchersEqual(group.Matcher, matcher) {
|
|
groups[i].Hooks = append(groups[i].Hooks, hook)
|
|
return groups
|
|
}
|
|
}
|
|
return append(groups, MatcherGroup{Matcher: matcher, Hooks: []HookEntry{hook}})
|
|
}
|
|
|
|
// removeManaged strips AO hook entries (matched by command prefix) from every
|
|
// group, dropping any group left without hooks so the event array doesn't
|
|
// accumulate empty matcher objects.
|
|
func removeManaged(groups []MatcherGroup, prefix string) []MatcherGroup {
|
|
result := make([]MatcherGroup, 0, len(groups))
|
|
for _, group := range groups {
|
|
kept := make([]HookEntry, 0, len(group.Hooks))
|
|
for _, hook := range group.Hooks {
|
|
if !strings.HasPrefix(hook.Command, prefix) {
|
|
kept = append(kept, hook)
|
|
}
|
|
}
|
|
if len(kept) > 0 {
|
|
group.Hooks = kept
|
|
result = append(result, group)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func matchersEqual(a, b *string) bool {
|
|
if a == nil || b == nil {
|
|
return a == nil && b == nil
|
|
}
|
|
return *a == *b
|
|
}
|