agent-orchestrator/backend/internal/adapters/agent/cline/hooks.go

180 lines
6.8 KiB
Go

package cline
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// Cline's hook system is git-style: each lifecycle hook is an executable script
// placed in the workspace-local `.clinerules/hooks/` directory, named exactly
// after the hook event (no extension), reading a JSON payload on stdin and
// writing a JSON result on stdout (see docs.cline.bot hooks reference).
//
// AO installs one wrapper script per managed event. Each script forwards the
// hook payload to `ao hooks cline <subcommand>` and emits the no-op
// continuation result Cline expects. Scripts carry a marker line so install is
// idempotent and uninstall recognizes AO-owned scripts without an embedded
// template to diff against; user-authored hooks (lacking the marker) are never
// touched.
const (
clineHooksDirName = ".clinerules"
clineHooksSubDir = "hooks"
// clineHookCommandPrefix identifies the hook commands AO owns. The CLI hook
// dispatcher routes "ao hooks cline <subcommand>" to DeriveActivityState.
clineHookCommandPrefix = "ao hooks cline "
// clineHookMarker tags AO-generated hook scripts so install/uninstall can
// distinguish them from user-authored Cline hooks in the same directory.
clineHookMarker = "# ao-managed-cline-hook"
)
// clineHookSpec describes one hook AO installs: the native Cline hook event
// (used as the script's filename) and the AO sub-command its wrapper forwards
// to (used by DeriveActivityState).
type clineHookSpec struct {
// Event is the native Cline hook name, which is also the script filename.
Event string
// Subcommand is the fixed AO hook sub-command name the wrapper invokes.
Subcommand string
}
// clineManagedHooks is the source of truth for the hooks AO installs. The
// native Cline events are mapped onto AO's fixed sub-command names so activity
// derivation stays uniform across adapters:
// - TaskStart -> session-start (a new task begins: active)
// - UserPromptSubmit -> user-prompt-submit (user message submitted: active)
// - PreToolUse -> permission-request (about to act: approval point)
// - TaskCancel -> stop (task cancelled/aborted: idle)
var clineManagedHooks = []clineHookSpec{
{Event: "TaskStart", Subcommand: "session-start"},
{Event: "UserPromptSubmit", Subcommand: "user-prompt-submit"},
{Event: "PreToolUse", Subcommand: "permission-request"},
{Event: "TaskCancel", Subcommand: "stop"},
}
// GetAgentHooks installs AO's Cline hook scripts into the worktree-local
// `.clinerules/hooks/` directory. Existing user-authored hook scripts are
// preserved, and re-running install simply rewrites AO-owned scripts in place.
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("cline.GetAgentHooks: WorkspacePath is required")
}
hooksDir := clineHooksDir(cfg.WorkspacePath)
if err := os.MkdirAll(hooksDir, 0o750); err != nil {
return fmt.Errorf("cline.GetAgentHooks: create hook dir: %w", err)
}
// Only scripts AO actually wrote go into the workspace .gitignore: a
// user-authored script at one of these paths must keep counting as dirt so
// workspace teardown preserves it.
written := make([]string, 0, len(clineManagedHooks))
for _, spec := range clineManagedHooks {
scriptPath := filepath.Join(hooksDir, spec.Event)
// Never clobber a user-authored hook with the same event name.
if hookutil.FileExists(scriptPath) && !isManagedClineHook(scriptPath) {
continue
}
script := renderClineHookScript(spec.Subcommand)
if err := hookutil.AtomicWriteFile(scriptPath, []byte(script), 0o700); err != nil {
return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err)
}
written = append(written, spec.Event)
}
if err := hookutil.EnsureWorkspaceGitignore(hooksDir, written...); err != nil {
return fmt.Errorf("cline.GetAgentHooks: gitignore: %w", err)
}
return nil
}
// UninstallHooks removes AO's Cline hook scripts from the workspace-local
// `.clinerules/hooks/` directory, leaving user-authored hooks untouched. A
// missing directory 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("cline.UninstallHooks: workspacePath is required")
}
hooksDir := clineHooksDir(workspacePath)
if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) {
return nil
}
for _, spec := range clineManagedHooks {
scriptPath := filepath.Join(hooksDir, spec.Event)
if !hookutil.FileExists(scriptPath) || !isManagedClineHook(scriptPath) {
continue
}
if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cline.UninstallHooks: remove %s: %w", spec.Event, err)
}
}
return nil
}
// AreHooksInstalled reports whether any AO Cline hook script is present in the
// workspace-local hooks directory. A missing directory means none.
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("cline.AreHooksInstalled: workspacePath is required")
}
hooksDir := clineHooksDir(workspacePath)
if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) {
return false, nil
}
for _, spec := range clineManagedHooks {
scriptPath := filepath.Join(hooksDir, spec.Event)
if hookutil.FileExists(scriptPath) && isManagedClineHook(scriptPath) {
return true, nil
}
}
return false, nil
}
func clineHooksDir(workspacePath string) string {
return filepath.Join(workspacePath, clineHooksDirName, clineHooksSubDir)
}
// renderClineHookScript builds an executable wrapper that forwards the Cline
// hook payload (JSON on stdin) to the AO CLI hook dispatcher and prints the
// no-op continuation result Cline expects ({"cancel": false}). The marker line
// identifies it as AO-owned.
func renderClineHookScript(subcommand string) string {
var b strings.Builder
b.WriteString("#!/usr/bin/env bash\n")
b.WriteString(clineHookMarker + "\n")
// Forward stdin to the AO dispatcher; ignore its exit code so a missing/old
// `ao` binary can never block Cline's own execution.
b.WriteString(clineHookCommandPrefix + subcommand + " || true\n")
// Cline requires a JSON result on stdout; never block the agent.
b.WriteString(`echo '{"cancel": false}'` + "\n")
return b.String()
}
func isManagedClineHook(scriptPath string) bool {
data, err := os.ReadFile(scriptPath) //nolint:gosec // path built from caller-owned workspace dir
if err != nil {
return false
}
return strings.Contains(string(data), clineHookMarker)
}