302 lines
10 KiB
Go
302 lines
10 KiB
Go
package copilot
|
|
|
|
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 (
|
|
// copilotHooksDir is the repository-scope hooks directory Copilot CLI reads
|
|
// (.github/hooks/*.json). AO writes a single dedicated file there so it never
|
|
// disturbs other hook files the user or repo may ship.
|
|
copilotHooksDir = ".github/hooks"
|
|
copilotHooksFileName = "ao.json"
|
|
|
|
// copilotHooksVersion is the schema version of the hooks file (Copilot uses 1).
|
|
copilotHooksVersion = 1
|
|
|
|
// copilotHookCommandPrefix identifies the hook commands AO owns, so install
|
|
// skips duplicates and uninstall recognizes AO entries by prefix without an
|
|
// embedded template to diff against. The CLI dispatcher routes
|
|
// `ao hooks copilot <event>` to DeriveActivityState.
|
|
copilotHookCommandPrefix = "ao hooks copilot "
|
|
copilotHookTimeoutSec = 30
|
|
)
|
|
|
|
// copilotHookFile is the on-disk shape of .github/hooks/ao.json. AO owns this
|
|
// dedicated file outright, so it only models the keys it manages (version,
|
|
// disableAllHooks, hooks); user-defined hooks live in their own .github/hooks/*
|
|
// files and are never touched.
|
|
type copilotHookFile struct {
|
|
Version int `json:"version"`
|
|
DisableAllHooks *bool `json:"disableAllHooks,omitempty"`
|
|
Hooks map[string][]copilotHookEntry `json:"hooks"`
|
|
}
|
|
|
|
// copilotHookEntry is one hook command. Copilot entries carry separate bash and
|
|
// powershell command strings (both required for cross-platform), a type, an
|
|
// optional working dir, and a timeout in seconds.
|
|
type copilotHookEntry struct {
|
|
Type string `json:"type"`
|
|
Bash string `json:"bash,omitempty"`
|
|
Powershell string `json:"powershell,omitempty"`
|
|
Cwd string `json:"cwd,omitempty"`
|
|
TimeoutSec int `json:"timeoutSec,omitempty"`
|
|
}
|
|
|
|
// copilotHookSpec describes one hook AO installs, defined in code rather than
|
|
// read from an embedded settings file.
|
|
type copilotHookSpec struct {
|
|
// Event is the native Copilot camelCase event name (sessionStart, ...).
|
|
Event string
|
|
// Command is the AO sub-command suffix (session-start, ...). It is appended
|
|
// to copilotHookCommandPrefix to form both the bash and powershell command,
|
|
// and is the value DeriveActivityState switches on.
|
|
Command string
|
|
}
|
|
|
|
// copilotManagedHooks is the source of truth for the hooks AO installs. The AO
|
|
// sub-command names (session-start, user-prompt-submit, permission-request,
|
|
// stop) are exactly what DeriveActivityState in activity.go switches on.
|
|
//
|
|
// Native event names use Copilot's camelCase form, taken verbatim from
|
|
// https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks
|
|
// (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse,
|
|
// errorOccurred, agentStop). Copilot does not document a "permissionRequest"
|
|
// event — the closest signal that AO's permission-request sub-command can
|
|
// piggyback on is preToolUse, which fires before any tool invocation, including
|
|
// the ones that would otherwise prompt the user for approval. This is a
|
|
// many-to-one collapse: every preToolUse currently produces ActivityWaitingInput
|
|
// via the permission-request sub-command. agentStop is the per-turn completion
|
|
// signal and maps to the "stop" sub-command (turn end → idle).
|
|
var copilotManagedHooks = []copilotHookSpec{
|
|
{Event: "sessionStart", Command: "session-start"},
|
|
{Event: "userPromptSubmitted", Command: "user-prompt-submit"},
|
|
{Event: "preToolUse", Command: "permission-request"},
|
|
{Event: "agentStop", Command: "stop"},
|
|
}
|
|
|
|
// GetAgentHooks installs AO's Copilot hooks into the worktree-local
|
|
// .github/hooks/ao.json file (the repository-scope hooks config Copilot CLI
|
|
// reads). The hooks report normalized activity-state signals back into AO's
|
|
// store. Existing AO entries are not duplicated and any unrelated keys are
|
|
// preserved, so the install is idempotent.
|
|
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("copilot.GetAgentHooks: WorkspacePath is required")
|
|
}
|
|
|
|
hooksPath := copilotHooksPath(cfg.WorkspacePath)
|
|
file, err := readCopilotHooks(hooksPath)
|
|
if err != nil {
|
|
return fmt.Errorf("copilot.GetAgentHooks: %w", err)
|
|
}
|
|
|
|
if file.Hooks == nil {
|
|
file.Hooks = map[string][]copilotHookEntry{}
|
|
}
|
|
for _, spec := range copilotManagedHooks {
|
|
command := copilotHookCommandPrefix + spec.Command
|
|
if copilotHookCommandExists(file.Hooks[spec.Event], command) {
|
|
continue
|
|
}
|
|
file.Hooks[spec.Event] = append(file.Hooks[spec.Event], copilotHookEntry{
|
|
Type: "command",
|
|
Bash: command,
|
|
Powershell: command,
|
|
TimeoutSec: copilotHookTimeoutSec,
|
|
})
|
|
}
|
|
|
|
if err := writeCopilotHooks(hooksPath, file); err != nil {
|
|
return fmt.Errorf("copilot.GetAgentHooks: %w", err)
|
|
}
|
|
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), copilotHooksFileName); err != nil {
|
|
return fmt.Errorf("copilot.GetAgentHooks: gitignore: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UninstallHooks removes AO's Copilot hooks from the workspace-local
|
|
// .github/hooks/ao.json file, leaving user-defined hooks and unrelated keys
|
|
// 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("copilot.UninstallHooks: workspacePath is required")
|
|
}
|
|
|
|
hooksPath := copilotHooksPath(workspacePath)
|
|
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
file, err := readCopilotHooks(hooksPath)
|
|
if err != nil {
|
|
return fmt.Errorf("copilot.UninstallHooks: %w", err)
|
|
}
|
|
|
|
for event, entries := range file.Hooks {
|
|
kept := removeCopilotManagedHooks(entries)
|
|
if len(kept) == 0 {
|
|
delete(file.Hooks, event)
|
|
continue
|
|
}
|
|
file.Hooks[event] = kept
|
|
}
|
|
|
|
if err := writeCopilotHooks(hooksPath, file); err != nil {
|
|
return fmt.Errorf("copilot.UninstallHooks: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AreHooksInstalled reports whether any AO Copilot hook is present in the
|
|
// workspace-local hooks 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("copilot.AreHooksInstalled: workspacePath is required")
|
|
}
|
|
|
|
hooksPath := copilotHooksPath(workspacePath)
|
|
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
file, err := readCopilotHooks(hooksPath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("copilot.AreHooksInstalled: %w", err)
|
|
}
|
|
|
|
for _, entries := range file.Hooks {
|
|
for _, entry := range entries {
|
|
if isCopilotManagedHook(entry) {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func copilotHooksPath(workspacePath string) string {
|
|
return filepath.Join(workspacePath, filepath.FromSlash(copilotHooksDir), copilotHooksFileName)
|
|
}
|
|
|
|
// readCopilotHooks loads the hooks file. A missing or empty file yields an empty
|
|
// file struct with a nil hooks map (and the AO schema version, used on write).
|
|
func readCopilotHooks(hooksPath string) (copilotHookFile, error) {
|
|
file := copilotHookFile{Version: copilotHooksVersion}
|
|
|
|
data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return file, nil
|
|
}
|
|
if err != nil {
|
|
return copilotHookFile{}, fmt.Errorf("read %s: %w", hooksPath, err)
|
|
}
|
|
if strings.TrimSpace(string(data)) == "" {
|
|
return file, nil
|
|
}
|
|
if err := json.Unmarshal(data, &file); err != nil {
|
|
return copilotHookFile{}, fmt.Errorf("parse %s: %w", hooksPath, err)
|
|
}
|
|
if file.Version == 0 {
|
|
file.Version = copilotHooksVersion
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
// writeCopilotHooks writes the file. An empty hooks map still writes a valid
|
|
// (versioned) file so AreHooksInstalled and re-install see a consistent shape.
|
|
func writeCopilotHooks(hooksPath string, file copilotHookFile) error {
|
|
if file.Version == 0 {
|
|
file.Version = copilotHooksVersion
|
|
}
|
|
if file.Hooks == nil {
|
|
file.Hooks = map[string][]copilotHookEntry{}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
|
|
return fmt.Errorf("create hooks dir: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(file, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
|
}
|
|
data = append(data, '\n')
|
|
if err := atomicWriteFile(hooksPath, data, 0o600); err != nil {
|
|
return fmt.Errorf("write %s: %w", hooksPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// atomicWriteFile writes data to path via a temp file in the same directory
|
|
// followed by a rename, so a crash or signal mid-write can't leave a truncated
|
|
// or empty file that Copilot then fails to parse (silently disabling hooks).
|
|
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Chmod(perm); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpName, path)
|
|
}
|
|
|
|
// isCopilotManagedHook reports whether an entry is one AO owns, recognized by the
|
|
// command prefix on either the bash or powershell command.
|
|
func isCopilotManagedHook(entry copilotHookEntry) bool {
|
|
return strings.HasPrefix(entry.Bash, copilotHookCommandPrefix) ||
|
|
strings.HasPrefix(entry.Powershell, copilotHookCommandPrefix)
|
|
}
|
|
|
|
func copilotHookCommandExists(entries []copilotHookEntry, command string) bool {
|
|
for _, entry := range entries {
|
|
if entry.Bash == command || entry.Powershell == command {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// removeCopilotManagedHooks strips AO hook entries from a slice, preserving
|
|
// user-defined entries in order.
|
|
func removeCopilotManagedHooks(entries []copilotHookEntry) []copilotHookEntry {
|
|
kept := make([]copilotHookEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if !isCopilotManagedHook(entry) {
|
|
kept = append(kept, entry)
|
|
}
|
|
}
|
|
return kept
|
|
}
|