356 lines
11 KiB
Go
356 lines
11 KiB
Go
package kiro
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
const (
|
|
// Kiro reads hooks from a workspace-local agent configuration file at
|
|
// .kiro/agents/<name>.json. AO installs its hooks into a dedicated agent
|
|
// file so it never clobbers a user's own agents.
|
|
// See https://kiro.dev/docs/cli/hooks/ and
|
|
// https://kiro.dev/docs/cli/custom-agents/configuration-reference#hooks-field
|
|
kiroHooksDirName = ".kiro"
|
|
kiroAgentsDirName = "agents"
|
|
kiroAgentFileName = "ao.json"
|
|
|
|
// kiroHookCommandPrefix identifies the hook commands AO owns, so install
|
|
// skips duplicates and uninstall recognizes AO entries by prefix without an
|
|
// embedded template to diff against.
|
|
kiroHookCommandPrefix = "ao hooks kiro "
|
|
|
|
kiroAgentName = "ao"
|
|
kiroAgentDescription = "Agent Orchestrator session instructions"
|
|
)
|
|
|
|
// kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by
|
|
// tests to decode the written file. Kiro hooks are a map of camelCase event
|
|
// name to a flat array of {matcher?, command} entries.
|
|
type kiroHookFile struct {
|
|
Name string `json:"name"`
|
|
Prompt *string `json:"prompt"`
|
|
Hooks map[string][]kiroHookEntry `json:"hooks"`
|
|
}
|
|
|
|
type kiroHookEntry struct {
|
|
Matcher string `json:"matcher,omitempty"`
|
|
Command string `json:"command"`
|
|
}
|
|
|
|
// kiroHookSpec describes one hook AO installs, defined in code rather than read
|
|
// from an embedded hooks file.
|
|
type kiroHookSpec struct {
|
|
// Event is the native Kiro hook event name (camelCase).
|
|
Event string
|
|
// Command is the AO hook command line.
|
|
Command string
|
|
}
|
|
|
|
// kiroManagedHooks is the source of truth for the hooks AO installs. The native
|
|
// Kiro events are mapped onto AO hook sub-command names (the trailing word) so
|
|
// the CLI hook dispatcher routes them to DeriveActivityState:
|
|
//
|
|
// agentSpawn -> session-start (ActivityActive)
|
|
// userPromptSubmit -> user-prompt-submit (ActivityActive)
|
|
// preToolUse -> permission-request (ActivityWaitingInput)
|
|
// stop -> stop (ActivityIdle)
|
|
var kiroManagedHooks = []kiroHookSpec{
|
|
{Event: "agentSpawn", Command: kiroHookCommandPrefix + "session-start"},
|
|
{Event: "userPromptSubmit", Command: kiroHookCommandPrefix + "user-prompt-submit"},
|
|
{Event: "preToolUse", Command: kiroHookCommandPrefix + "permission-request"},
|
|
{Event: "stop", Command: kiroHookCommandPrefix + "stop"},
|
|
}
|
|
|
|
// GetAgentHooks installs AO's Kiro hooks into the worktree-local
|
|
// .kiro/agents/ao.json file. Existing hook entries are preserved and duplicate
|
|
// AO commands are not appended.
|
|
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(cfg.WorkspacePath) == "" {
|
|
return errors.New("kiro.GetAgentHooks: WorkspacePath is required")
|
|
}
|
|
|
|
hooksPath := kiroAgentPath(cfg.WorkspacePath)
|
|
topLevel, rawHooks, err := readKiroHooks(hooksPath)
|
|
if err != nil {
|
|
return fmt.Errorf("kiro.GetAgentHooks: %w", err)
|
|
}
|
|
|
|
for event, specs := range groupKiroHooksByEvent() {
|
|
var existing []kiroHookEntry
|
|
if err := parseKiroHookEvent(rawHooks, event, &existing); err != nil {
|
|
return fmt.Errorf("kiro.GetAgentHooks: %w", err)
|
|
}
|
|
for _, spec := range specs {
|
|
if !kiroHookCommandExists(existing, spec.Command) {
|
|
existing = append(existing, kiroHookEntry{Command: spec.Command})
|
|
}
|
|
}
|
|
if err := marshalKiroHookEvent(rawHooks, event, existing); err != nil {
|
|
return fmt.Errorf("kiro.GetAgentHooks: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, cfg.SystemPrompt, cfg.Config); err != nil {
|
|
return fmt.Errorf("kiro.GetAgentHooks: %w", err)
|
|
}
|
|
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), kiroAgentFileName); err != nil {
|
|
return fmt.Errorf("kiro.GetAgentHooks: gitignore: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UninstallHooks removes AO's Kiro hooks from the workspace-local
|
|
// .kiro/agents/ao.json file, leaving user-defined hooks untouched. A missing
|
|
// file is a no-op.
|
|
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(workspacePath) == "" {
|
|
return errors.New("kiro.UninstallHooks: workspacePath is required")
|
|
}
|
|
|
|
hooksPath := kiroAgentPath(workspacePath)
|
|
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
topLevel, rawHooks, err := readKiroHooks(hooksPath)
|
|
if err != nil {
|
|
return fmt.Errorf("kiro.UninstallHooks: %w", err)
|
|
}
|
|
|
|
for _, event := range kiroManagedEvents() {
|
|
var entries []kiroHookEntry
|
|
if err := parseKiroHookEvent(rawHooks, event, &entries); err != nil {
|
|
return fmt.Errorf("kiro.UninstallHooks: %w", err)
|
|
}
|
|
entries = removeKiroManagedHooks(entries)
|
|
if err := marshalKiroHookEvent(rawHooks, event, entries); err != nil {
|
|
return fmt.Errorf("kiro.UninstallHooks: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, "", ports.AgentConfig{}); err != nil {
|
|
return fmt.Errorf("kiro.UninstallHooks: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AreHooksInstalled reports whether any AO Kiro hook is present in the
|
|
// workspace-local agent file. A missing file means none are installed.
|
|
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
if strings.TrimSpace(workspacePath) == "" {
|
|
return false, errors.New("kiro.AreHooksInstalled: workspacePath is required")
|
|
}
|
|
|
|
hooksPath := kiroAgentPath(workspacePath)
|
|
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
_, rawHooks, err := readKiroHooks(hooksPath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("kiro.AreHooksInstalled: %w", err)
|
|
}
|
|
|
|
for _, event := range kiroManagedEvents() {
|
|
var entries []kiroHookEntry
|
|
if err := parseKiroHookEvent(rawHooks, event, &entries); err != nil {
|
|
return false, fmt.Errorf("kiro.AreHooksInstalled: %w", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if isKiroManagedHook(entry.Command) {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func kiroAgentPath(workspacePath string) string {
|
|
return filepath.Join(workspacePath, kiroHooksDirName, kiroAgentsDirName, kiroAgentFileName)
|
|
}
|
|
|
|
// readKiroHooks loads the agent file into a top-level raw map plus the decoded
|
|
// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty file
|
|
// yields empty maps.
|
|
func readKiroHooks(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
|
|
}
|
|
|
|
// writeKiroHooks folds rawHooks back into topLevel and writes the file. An
|
|
// empty hooks map drops the "hooks" key entirely.
|
|
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
|
|
if err := setKiroAgentDefaults(topLevel, systemPrompt, agentConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
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 setKiroAgentDefaults(topLevel map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
|
|
defaults := map[string]any{
|
|
"name": kiroAgentName,
|
|
"description": kiroAgentDescription,
|
|
"prompt": nil,
|
|
"mcpServers": map[string]any{},
|
|
"tools": []string{"*"},
|
|
"toolAliases": map[string]any{},
|
|
"allowedTools": []any{},
|
|
"resources": []any{},
|
|
"toolsSettings": map[string]any{},
|
|
"includeMcpJson": true,
|
|
}
|
|
if systemPrompt != "" {
|
|
defaults["prompt"] = systemPrompt
|
|
}
|
|
if model := strings.TrimSpace(agentConfig.Model); model != "" {
|
|
defaults["model"] = model
|
|
} else {
|
|
delete(topLevel, "model")
|
|
}
|
|
|
|
for key, value := range defaults {
|
|
managedKey := key == "name" || key == "prompt" || key == "model"
|
|
if !managedKey {
|
|
if _, ok := topLevel[key]; ok {
|
|
continue
|
|
}
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Errorf("encode agent %s: %w", key, err)
|
|
}
|
|
topLevel[key] = data
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// groupKiroHooksByEvent groups the managed hook specs by their Kiro event so
|
|
// each event's array is rewritten once.
|
|
func groupKiroHooksByEvent() map[string][]kiroHookSpec {
|
|
byEvent := map[string][]kiroHookSpec{}
|
|
for _, spec := range kiroManagedHooks {
|
|
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
|
}
|
|
return byEvent
|
|
}
|
|
|
|
// kiroManagedEvents returns the distinct Kiro events AO manages, in the order
|
|
// they first appear in kiroManagedHooks.
|
|
func kiroManagedEvents() []string {
|
|
seen := map[string]bool{}
|
|
events := make([]string, 0, len(kiroManagedHooks))
|
|
for _, spec := range kiroManagedHooks {
|
|
if !seen[spec.Event] {
|
|
seen[spec.Event] = true
|
|
events = append(events, spec.Event)
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
func isKiroManagedHook(command string) bool {
|
|
return strings.HasPrefix(command, kiroHookCommandPrefix)
|
|
}
|
|
|
|
// removeKiroManagedHooks strips AO hook entries from an event's array.
|
|
func removeKiroManagedHooks(entries []kiroHookEntry) []kiroHookEntry {
|
|
kept := make([]kiroHookEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if !isKiroManagedHook(entry.Command) {
|
|
kept = append(kept, entry)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
func parseKiroHookEvent(rawHooks map[string]json.RawMessage, event string, target *[]kiroHookEntry) 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 marshalKiroHookEvent(rawHooks map[string]json.RawMessage, event string, entries []kiroHookEntry) error {
|
|
if len(entries) == 0 {
|
|
delete(rawHooks, event)
|
|
return nil
|
|
}
|
|
data, err := json.Marshal(entries)
|
|
if err != nil {
|
|
return fmt.Errorf("encode %s hooks: %w", event, err)
|
|
}
|
|
rawHooks[event] = data
|
|
return nil
|
|
}
|
|
|
|
func kiroHookCommandExists(entries []kiroHookEntry, command string) bool {
|
|
for _, entry := range entries {
|
|
if entry.Command == command {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|