180 lines
6.8 KiB
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)
|
|
}
|