From 2eff1ae119a3c009b41c8b0a0d62d1b03f9a480c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 10:19:29 -0400 Subject: [PATCH] feat: add native Cline hook integration --- AGENTS.md | 1 + README.md | 3 + cmd/elydora/plugins/cline.go | 135 +++++++ cmd/elydora/plugins/cline_contract.go | 360 ++++++++++++++++++ cmd/elydora/plugins/cline_io.go | 211 ++++++++++ cmd/elydora/plugins/cline_recovery_test.go | 206 ++++++++++ cmd/elydora/plugins/cline_test.go | 178 +++++++++ .../plugins/cline_test_helpers_test.go | 205 ++++++++++ cmd/elydora/plugins/hooktemplate.go | 19 +- cmd/elydora/plugins/registry.go | 3 + 10 files changed, 1316 insertions(+), 5 deletions(-) create mode 100644 cmd/elydora/plugins/cline.go create mode 100644 cmd/elydora/plugins/cline_contract.go create mode 100644 cmd/elydora/plugins/cline_io.go create mode 100644 cmd/elydora/plugins/cline_recovery_test.go create mode 100644 cmd/elydora/plugins/cline_test.go create mode 100644 cmd/elydora/plugins/cline_test_helpers_test.go diff --git a/AGENTS.md b/AGENTS.md index 3a04b5e..a66dc73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. +- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control. ## Code Quality diff --git a/README.md b/README.md index 8a220c3..dfc4f4e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ elydora install \ Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. +Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control. + Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default. Legacy `kimi-cli` uses `~/.kimi/config.toml`; Elydora updates both contracts only when both runtimes are present. @@ -100,6 +102,7 @@ Auggie installation writes user-level hooks to `~/.augment/settings.json` and cr | Augment Code CLI | `augment` | | Claude Code | `claudecode` | | OpenAI Codex | `codex` | +| Cline | `cline` | | Copilot CLI | `copilot` | | Cursor | `cursor` | | Gemini CLI | `gemini` | diff --git a/cmd/elydora/plugins/cline.go b/cmd/elydora/plugins/cline.go new file mode 100644 index 0000000..7691bff --- /dev/null +++ b/cmd/elydora/plugins/cline.go @@ -0,0 +1,135 @@ +package plugins + +import "fmt" + +// ClinePlugin manages Cline's native global file hooks. +type ClinePlugin struct{} + +func (p *ClinePlugin) Install(config InstallConfig) error { + if config.AgentID == "" { + return fmt.Errorf("agent ID is required") + } + paths, err := resolveClineHookFiles() + if err != nil { + return err + } + guardState, err := readClineHookFile(paths.guardPath) + if err != nil { + return err + } + auditState, err := readClineHookFile(paths.auditPath) + if err != nil { + return err + } + if err := requireAvailableClineHook(guardState); err != nil { + return err + } + if err := requireAvailableClineHook(auditState); err != nil { + return err + } + if config.GuardScriptPath == "" { + return fmt.Errorf("guard script path is required") + } + guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") + if err != nil { + return err + } + if !guardExists { + return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) + } + hookPath, err := hookScriptPath(config.AgentID) + if err != nil { + return err + } + if config.HookScript != "" { + hookPath = config.HookScript + } + guardMetadata, err := buildClineMetadata("guard", config.AgentID, config.GuardScriptPath) + if err != nil { + return fmt.Errorf("build Cline guard metadata: %w", err) + } + auditMetadata, err := buildClineMetadata("audit", config.AgentID, hookPath) + if err != nil { + return fmt.Errorf("build Cline audit metadata: %w", err) + } + guardSource, err := buildClineWrapper(guardMetadata) + if err != nil { + return fmt.Errorf("build Cline guard wrapper: %w", err) + } + auditSource, err := buildClineWrapper(auditMetadata) + if err != nil { + return fmt.Errorf("build Cline audit wrapper: %w", err) + } + if _, err := clineContractForFiles( + clineHookFile{ + exists: true, filePath: paths.guardPath, + source: guardSource, metadata: &guardMetadata, + }, + clineHookFile{ + exists: true, filePath: paths.auditPath, + source: auditSource, metadata: &auditMetadata, + }, + ); err != nil { + return err + } + runtimeConfig := config + runtimeConfig.AgentName = clineAgentKey + if err := GenerateHookScript(hookPath, runtimeConfig); err != nil { + return fmt.Errorf("generate hook script: %w", err) + } + if err := writeClineHookPair( + clinePendingWrite{state: guardState, source: guardSource}, + clinePendingWrite{state: auditState, source: auditSource}, + ); err != nil { + return err + } + fmt.Println("Cline: user-level PreToolUse and PostToolUse hooks installed.") + return nil +} + +func (p *ClinePlugin) Uninstall(agentID string) error { + paths, err := resolveClineHookFiles() + if err != nil { + return err + } + guardState, err := readClineHookFile(paths.guardPath) + if err != nil { + return err + } + auditState, err := readClineHookFile(paths.auditPath) + if err != nil { + return err + } + return removeOwnedClineHooks([]clineHookFile{guardState, auditState}, agentID) +} + +func (p *ClinePlugin) Status() (PluginStatus, error) { + paths, err := resolveClineHookFiles() + status := PluginStatus{ + AgentName: clineAgentKey, + DisplayName: "Cline", + ConfigPath: paths.hooksDirectory, + } + if err != nil { + return status, err + } + guardState, err := readClineHookFile(paths.guardPath) + if err != nil { + return status, err + } + auditState, err := readClineHookFile(paths.auditPath) + if err != nil { + return status, err + } + contract, err := clineContractForFiles(guardState, auditState) + if err != nil || contract == nil { + return status, err + } + status.HookConfigured = true + status.HookScriptExists, err = clineRuntimeFilesExist(contract) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists + return status, nil +} diff --git a/cmd/elydora/plugins/cline_contract.go b/cmd/elydora/plugins/cline_contract.go new file mode 100644 index 0000000..668ebd6 --- /dev/null +++ b/cmd/elydora/plugins/cline_contract.go @@ -0,0 +1,360 @@ +package plugins + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +const ( + clineAgentKey = "cline" + clineGuardFileName = "PreToolUse.mjs" + clineAuditFileName = "PostToolUse.mjs" + clineGuardScript = "guard.js" + clineAuditScript = "hook.js" + clineMetadataMarker = "// @elydora-cline-hook " + clineMetadataVersion = 1 +) + +type clineHookMetadata struct { + Version int `json:"version"` + Kind string `json:"kind"` + AgentID string `json:"agentId"` + RuntimePath string `json:"runtimePath"` +} + +type clineHookFile struct { + exists bool + filePath string + source string + metadata *clineHookMetadata +} + +type clineHookPaths struct { + hooksDirectory string + guardPath string + auditPath string +} + +type clineRuntimeContract struct { + agentID string + agentDirectory string + guardPath string + auditPath string +} + +func clineHomeDirectory() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return home, nil +} + +func clineElydoraRoot() (string, error) { + home, err := clineHomeDirectory() + if err != nil { + return "", err + } + return filepath.Join(home, ".elydora"), nil +} + +func resolveClineHookFiles() (clineHookPaths, error) { + clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR")) + if clineDirectory == "" { + home, err := clineHomeDirectory() + if err != nil { + return clineHookPaths{}, err + } + clineDirectory = filepath.Join(home, ".cline") + } else { + absolute, err := filepath.Abs(clineDirectory) + if err != nil { + return clineHookPaths{}, fmt.Errorf("resolve CLINE_DIR path: %w", err) + } + clineDirectory = absolute + } + hooksDirectory := filepath.Join(clineDirectory, "hooks") + return clineHookPaths{ + hooksDirectory: hooksDirectory, + guardPath: filepath.Join(hooksDirectory, clineGuardFileName), + auditPath: filepath.Join(hooksDirectory, clineAuditFileName), + }, nil +} + +func sameClineAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func normalizeClinePath(value string) (string, error) { + absolute, err := filepath.Abs(value) + if err != nil { + return "", err + } + normalized := filepath.Clean(absolute) + if runtime.GOOS == "windows" { + normalized = strings.ToLower(normalized) + } + return normalized, nil +} + +func sameClinePath(left, right string) (bool, error) { + normalizedLeft, err := normalizeClinePath(left) + if err != nil { + return false, err + } + normalizedRight, err := normalizeClinePath(right) + if err != nil { + return false, err + } + return normalizedLeft == normalizedRight, nil +} + +func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) { + metadata := clineHookMetadata{ + Version: clineMetadataVersion, + Kind: kind, + AgentID: agentID, + RuntimePath: runtimePath, + } + if err := validateClineMetadata(metadata); err != nil { + return clineHookMetadata{}, err + } + return metadata, nil +} + +func validateClineMetadata(metadata clineHookMetadata) error { + if metadata.Version != clineMetadataVersion { + return fmt.Errorf("metadata version must be 1") + } + if metadata.Kind != "guard" && metadata.Kind != "audit" { + return fmt.Errorf(`metadata kind must be "guard" or "audit"`) + } + if metadata.AgentID == "" { + return fmt.Errorf("metadata agentId must be a non-empty string") + } + if !filepath.IsAbs(metadata.RuntimePath) { + return fmt.Errorf("metadata runtimePath must be an absolute path") + } + return nil +} + +func encodeClineMetadata(metadata clineHookMetadata) (string, error) { + encoded, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("encode Cline hook metadata: %w", err) + } + return base64.RawURLEncoding.EncodeToString(encoded), nil +} + +func clineJSONString(value string) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func buildClineWrapper(metadata clineHookMetadata) (string, error) { + if err := validateClineMetadata(metadata); err != nil { + return "", err + } + encodedMetadata, err := encodeClineMetadata(metadata) + if err != nil { + return "", err + } + hookKind, err := clineJSONString(metadata.Kind) + if err != nil { + return "", fmt.Errorf("encode Cline hook kind: %w", err) + } + runtimePath, err := clineJSONString(metadata.RuntimePath) + if err != nil { + return "", fmt.Errorf("encode Cline runtime path: %w", err) + } + const template = `#!/usr/bin/env node +__METADATA_LINE__ +import { spawn } from 'node:child_process'; + +const hookKind = __HOOK_KIND__; +const runtimePath = __RUNTIME_PATH__; + +function runRuntime(input) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [runtimePath], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const stdout = []; + const stderr = []; + let stdinError; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.stdin.on('error', (error) => { + if (error?.code !== 'EPIPE') stdinError = error; + }); + child.once('error', reject); + child.once('close', (code, signal) => { + if (stdinError) { + reject(stdinError); + return; + } + resolve({ + code, + signal, + stdout: Buffer.concat(stdout).toString('utf-8'), + stderr: Buffer.concat(stderr).toString('utf-8'), + }); + }); + child.stdin.end(input); + }); +} + +async function main() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const result = await runRuntime(Buffer.concat(chunks)); + if (result.stdout) process.stderr.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + + if (hookKind === 'guard' && result.code === 2) { + const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.'; + process.stdout.write('HOOK_CONTROL\t' + JSON.stringify({ cancel: true, errorMessage }) + '\n'); + return; + } + if (result.signal) throw new Error('runtime terminated by signal ' + result.signal); + if (result.code !== 0) throw new Error('runtime exited with code ' + result.code); +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + process.stderr.write('Elydora Cline ' + hookKind + ' wrapper failed: ' + message + '\n'); + process.exitCode = 1; +}); +` + return strings.NewReplacer( + "__METADATA_LINE__", clineMetadataMarker+encodedMetadata, + "__HOOK_KIND__", hookKind, + "__RUNTIME_PATH__", runtimePath, + ).Replace(template), nil +} + +func decodeClineMetadata(encoded string) (clineHookMetadata, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err) + } + expected := []string{"version", "kind", "agentId", "runtimePath"} + if len(fields) != len(expected) { + return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set") + } + for _, field := range expected { + if _, exists := fields[field]; !exists { + return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set") + } + } + var metadata clineHookMetadata + if err := json.Unmarshal(raw, &metadata); err != nil { + return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err) + } + if err := validateClineMetadata(metadata); err != nil { + return clineHookMetadata{}, err + } + return metadata, nil +} + +func parseClineMetadata(filePath, source string) (*clineHookMetadata, error) { + lines := strings.SplitN(source, "\n", 3) + if len(lines) < 2 { + return nil, nil + } + markerLine := strings.TrimSuffix(lines[1], "\r") + if !strings.HasPrefix(markerLine, clineMetadataMarker) { + return nil, nil + } + metadata, err := decodeClineMetadata(strings.TrimPrefix(markerLine, clineMetadataMarker)) + if err != nil { + return nil, fmt.Errorf("parse Elydora Cline hook metadata at %s: %w", filePath, err) + } + return &metadata, nil +} + +func assertClineWrapperIntegrity(file clineHookFile) error { + if file.metadata == nil { + return nil + } + expected, err := buildClineWrapper(*file.metadata) + if err != nil { + return err + } + if file.source != expected { + return fmt.Errorf("Elydora Cline hook at %s does not match the managed template", file.filePath) + } + return nil +} + +func validateClineAgentSegment(agentID string) error { + if agentID == "." || agentID == ".." || filepath.Base(agentID) != agentID { + return fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId") + } + return nil +} + +func clineContractForFiles( + guardFile, auditFile clineHookFile, +) (*clineRuntimeContract, error) { + if guardFile.metadata == nil || auditFile.metadata == nil { + return nil, nil + } + if err := assertClineWrapperIntegrity(guardFile); err != nil { + return nil, err + } + if err := assertClineWrapperIntegrity(auditFile); err != nil { + return nil, err + } + guard := guardFile.metadata + audit := auditFile.metadata + if guard.Kind != "guard" || audit.Kind != "audit" { + return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata") + } + if !sameClineAgentID(guard.AgentID, audit.AgentID) { + return nil, fmt.Errorf("Elydora Cline hook files reference different agents") + } + if err := validateClineAgentSegment(guard.AgentID); err != nil { + return nil, err + } + runtimeRoot, err := clineElydoraRoot() + if err != nil { + return nil, err + } + agentDirectory := filepath.Join(runtimeRoot, guard.AgentID) + expectedGuard := filepath.Join(agentDirectory, clineGuardScript) + expectedAudit := filepath.Join(agentDirectory, clineAuditScript) + guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard) + if err != nil { + return nil, fmt.Errorf("compare Cline guard runtime path: %w", err) + } + auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit) + if err != nil { + return nil, fmt.Errorf("compare Cline audit runtime path: %w", err) + } + if !guardMatches || !auditMatches { + return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path") + } + return &clineRuntimeContract{ + agentID: guard.AgentID, + agentDirectory: agentDirectory, + guardPath: expectedGuard, + auditPath: expectedAudit, + }, nil +} diff --git a/cmd/elydora/plugins/cline_io.go b/cmd/elydora/plugins/cline_io.go new file mode 100644 index 0000000..06eb801 --- /dev/null +++ b/cmd/elydora/plugins/cline_io.go @@ -0,0 +1,211 @@ +package plugins + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +type clinePendingWrite struct { + state clineHookFile + source string +} + +type clineStagedFile struct { + state clineHookFile + temporaryPath string +} + +type clineRenameFunc func(source, destination string) error + +func readClineHookFile(filePath string) (clineHookFile, error) { + // #nosec G304 -- filePath is resolved from Cline's native hook directory. + raw, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return clineHookFile{filePath: filePath}, nil + } + return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err) + } + source := string(raw) + metadata, err := parseClineMetadata(filePath, source) + if err != nil { + return clineHookFile{}, err + } + return clineHookFile{ + exists: true, + filePath: filePath, + source: source, + metadata: metadata, + }, nil +} + +func requireAvailableClineHook(file clineHookFile) error { + if file.exists && file.metadata == nil { + return fmt.Errorf( + "Cline hook at %s already exists and is owned by another integration", + file.filePath, + ) + } + return nil +} + +func removeClineTemporary(path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err) + } + return nil +} + +func failClineStage(temporary *os.File, path string, cause error) error { + closeErr := temporary.Close() + removeErr := removeClineTemporary(path) + return errors.Join(cause, closeErr, removeErr) +} + +func stageClineHook(write clinePendingWrite) (clineStagedFile, error) { + directory := filepath.Dir(write.state.filePath) + if err := os.MkdirAll(directory, 0700); err != nil { + return clineStagedFile{}, fmt.Errorf("create Cline hooks directory at %s: %w", directory, err) + } + temporary, err := os.CreateTemp(directory, "."+filepath.Base(write.state.filePath)+".*.tmp") + if err != nil { + return clineStagedFile{}, fmt.Errorf("stage Cline hook at %s: %w", write.state.filePath, err) + } + temporaryPath := temporary.Name() + if err := temporary.Chmod(0700); err != nil { + return clineStagedFile{}, failClineStage( + temporary, + temporaryPath, + fmt.Errorf("set permissions for %s: %w", temporaryPath, err), + ) + } + written, err := temporary.WriteString(write.source) + if err != nil { + return clineStagedFile{}, failClineStage( + temporary, + temporaryPath, + fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, err), + ) + } + if written != len(write.source) { + return clineStagedFile{}, failClineStage( + temporary, + temporaryPath, + fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, io.ErrShortWrite), + ) + } + if err := temporary.Sync(); err != nil { + return clineStagedFile{}, failClineStage( + temporary, + temporaryPath, + fmt.Errorf("sync temporary Cline hook for %s: %w", write.state.filePath, err), + ) + } + if err := temporary.Close(); err != nil { + return clineStagedFile{}, errors.Join( + fmt.Errorf("close temporary Cline hook for %s: %w", write.state.filePath, err), + removeClineTemporary(temporaryPath), + ) + } + return clineStagedFile{state: write.state, temporaryPath: temporaryPath}, nil +} + +func rollbackClineHook(state clineHookFile) error { + if state.exists { + return writeHookFileAtomic(state.filePath, []byte(state.source), 0700) + } + return removeHookFile(state.filePath, "Cline hook rollback") +} + +func writeClineHookPair(guard, audit clinePendingWrite) error { + return writeClineHookPairWithRename(guard, audit, os.Rename) +} + +func writeClineHookPairWithRename( + guard, audit clinePendingWrite, + rename clineRenameFunc, +) error { + writes := []clinePendingWrite{guard, audit} + staged := make([]clineStagedFile, 0, len(writes)) + for _, write := range writes { + file, err := stageClineHook(write) + if err != nil { + failures := []error{fmt.Errorf("stage Cline hook pair: %w", err)} + for _, item := range staged { + if cleanupErr := removeClineTemporary(item.temporaryPath); cleanupErr != nil { + failures = append(failures, cleanupErr) + } + } + return errors.Join(failures...) + } + staged = append(staged, file) + } + + committed := 0 + for index, item := range staged { + if err := rename(item.temporaryPath, item.state.filePath); err != nil { + failures := []error{ + fmt.Errorf("write Cline hook pair at %s: %w", item.state.filePath, err), + } + for rollbackIndex := committed - 1; rollbackIndex >= 0; rollbackIndex-- { + if rollbackErr := rollbackClineHook(staged[rollbackIndex].state); rollbackErr != nil { + failures = append(failures, fmt.Errorf( + "restore Cline hook at %s: %w", + staged[rollbackIndex].state.filePath, + rollbackErr, + )) + } + } + for cleanupIndex := index; cleanupIndex < len(staged); cleanupIndex++ { + if cleanupErr := removeClineTemporary(staged[cleanupIndex].temporaryPath); cleanupErr != nil { + failures = append(failures, cleanupErr) + } + } + return errors.Join(failures...) + } + committed++ + } + return nil +} + +func removeOwnedClineHooks(files []clineHookFile, agentID string) error { + for _, file := range files { + if file.metadata == nil { + continue + } + if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) { + continue + } + if err := removeHookFile(file.filePath, "Cline hook"); err != nil { + return err + } + } + return nil +} + +func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) { + configPath := filepath.Join(contract.agentDirectory, "config.json") + config, exists, err := readHookJSONObject(configPath, "Elydora runtime config") + if err != nil || !exists { + return false, err + } + agentID, ok := config["agent_id"].(string) + if config["agent_name"] != clineAgentKey || !ok || !sameClineAgentID(agentID, contract.agentID) { + return false, nil + } + guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime") + if err != nil { + return false, err + } + auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime") + if err != nil { + return false, err + } + return guardExists && auditExists, nil +} diff --git a/cmd/elydora/plugins/cline_recovery_test.go b/cmd/elydora/plugins/cline_recovery_test.go new file mode 100644 index 0000000..d6450f5 --- /dev/null +++ b/cmd/elydora/plugins/cline_recovery_test.go @@ -0,0 +1,206 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestClineStatusRequiresIntactHooksAndRuntimes(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { + t.Fatalf("installed status = %#v, %v", status, err) + } + if status.AgentName != "cline" || status.DisplayName != "Cline" || status.ConfigPath != fixture.hooksDir { + t.Fatalf("status metadata = %#v", status) + } + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove guard runtime: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { + t.Fatalf("degraded status = %#v, %v", status, err) + } + if err := os.WriteFile(fixture.guardPath, []byte("process.exit(0);\n"), 0700); err != nil { + t.Fatalf("restore guard runtime: %v", err) + } + if err := os.Remove(fixture.auditWrapper); err != nil { + t.Fatalf("remove audit wrapper: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("partial status = %#v, %v", status, err) + } +} + +func TestClineStatusSurfacesCorruptHooksAndRuntimeMetadata(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + guard := readClineTestFile(t, fixture.guardWrapper) + if err := os.WriteFile(fixture.guardWrapper, []byte(guard+"\n// tampered\n"), 0700); err != nil { + t.Fatalf("tamper guard wrapper: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "managed template") { + t.Fatalf("tampered status error = %v", err) + } + installClineFixture(t, fixture) + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") { + t.Fatalf("runtime status error = %v", err) + } +} + +func TestClineInstallRejectsUserFilenameCollisionsBeforeWrites(t *testing.T) { + for _, collision := range []string{"guard", "audit"} { + t.Run(collision, func(t *testing.T) { + options := clineFixtureOptions{} + if collision == "guard" { + options.ExistingGuard = clineString("// user PreToolUse hook\n") + } else { + options.ExistingAudit = clineString("// user PostToolUse hook\n") + } + fixture := prepareClineFixture(t, options) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "owned by another integration") { + t.Fatalf("install error = %v", err) + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingClineTestFile(t, path) + } + if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard { + t.Fatalf("user guard wrapper changed") + } + if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit { + t.Fatalf("user audit wrapper changed") + } + }) + } +} + +func TestClineInstallPreservesCorruptOwnedMetadataForRecovery(t *testing.T) { + corrupt := "#!/usr/bin/env node\n// @elydora-cline-hook invalid\n" + fixture := prepareClineFixture(t, clineFixtureOptions{ExistingGuard: &corrupt}) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "parse Elydora Cline hook metadata") { + t.Fatalf("install error = %v", err) + } + if readClineTestFile(t, fixture.guardWrapper) != corrupt { + t.Fatalf("corrupt hook changed") + } + requireMissingClineTestFile(t, fixture.auditWrapper) + requireMissingClineTestFile(t, fixture.hookPath) +} + +func TestClineInstallRejectsMissingGuardBeforeWrites(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{SkipGuard: true}) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") { + t.Fatalf("install error = %v", err) + } + for _, path := range []string{ + fixture.guardWrapper, + fixture.auditWrapper, + fixture.hookPath, + fixture.runtimeConfig, + fixture.privateKey, + } { + requireMissingClineTestFile(t, path) + } +} + +func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py") + if err := os.WriteFile(userHook, []byte("# user hook\n"), 0600); err != nil { + t.Fatalf("write user hook: %v", err) + } + if err := fixture.plugin.Uninstall("agent-10"); err != nil { + t.Fatalf("uninstall other agent: %v", err) + } + readClineTestFile(t, fixture.guardWrapper) + readClineTestFile(t, fixture.auditWrapper) + if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil { + t.Fatalf("uninstall Cline hooks: %v", err) + } + requireMissingClineTestFile(t, fixture.guardWrapper) + requireMissingClineTestFile(t, fixture.auditWrapper) + if readClineTestFile(t, userHook) != "# user hook\n" { + t.Fatalf("user hook changed") + } +} + +func TestClineHookPairRollsBackFirstCommitWhenSecondCommitFails(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + originalGuard := readClineTestFile(t, fixture.guardWrapper) + originalAudit := readClineTestFile(t, fixture.auditWrapper) + guardState, err := readClineHookFile(fixture.guardWrapper) + if err != nil { + t.Fatalf("read guard state: %v", err) + } + auditState, err := readClineHookFile(fixture.auditWrapper) + if err != nil { + t.Fatalf("read audit state: %v", err) + } + guardMetadata, err := buildClineMetadata("guard", "agent-2", fixture.guardPath) + if err != nil { + t.Fatalf("build replacement guard metadata: %v", err) + } + auditMetadata, err := buildClineMetadata("audit", "agent-2", fixture.hookPath) + if err != nil { + t.Fatalf("build replacement audit metadata: %v", err) + } + replacementGuard, err := buildClineWrapper(guardMetadata) + if err != nil { + t.Fatalf("build replacement guard: %v", err) + } + replacementAudit, err := buildClineWrapper(auditMetadata) + if err != nil { + t.Fatalf("build replacement audit: %v", err) + } + failureInjected := false + rename := func(source, destination string) error { + if !failureInjected && sameClineTestPath(destination, fixture.auditWrapper) { + failureInjected = true + return errors.New("simulated audit commit failure") + } + return os.Rename(source, destination) + } + err = writeClineHookPairWithRename( + clinePendingWrite{state: guardState, source: replacementGuard}, + clinePendingWrite{state: auditState, source: replacementAudit}, + rename, + ) + if err == nil || !strings.Contains(err.Error(), "write Cline hook pair") { + t.Fatalf("write pair error = %v", err) + } + if !failureInjected { + t.Fatalf("audit commit failure was not injected") + } + if readClineTestFile(t, fixture.guardWrapper) != originalGuard || + readClineTestFile(t, fixture.auditWrapper) != originalAudit { + t.Fatalf("hook pair was not restored") + } + entries, err := os.ReadDir(fixture.hooksDir) + if err != nil { + t.Fatalf("read hook directory: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("temporary hook remains: %s", entry.Name()) + } + } +} + +func sameClineTestPath(left, right string) bool { + leftPath, leftErr := filepath.Abs(left) + rightPath, rightErr := filepath.Abs(right) + return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath) +} diff --git a/cmd/elydora/plugins/cline_test.go b/cmd/elydora/plugins/cline_test.go new file mode 100644 index 0000000..68a003c --- /dev/null +++ b/cmd/elydora/plugins/cline_test.go @@ -0,0 +1,178 @@ +package plugins + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestClineRegistryUsesNativeGlobalHookDirectory(t *testing.T) { + entry := SupportedAgents["cline"] + if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" { + t.Fatalf("Cline registry entry = %#v", entry) + } + if _, ok := NewPlugin("cline").(*ClinePlugin); !ok { + t.Fatalf("Cline plugin is not registered") + } +} + +func TestClineInstallWritesOnlyNativeGlobalHooksAndIsIdempotent(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + originalGuard := readClineTestFile(t, fixture.guardWrapper) + originalAudit := readClineTestFile(t, fixture.auditWrapper) + + installClineFixture(t, fixture) + + if readClineTestFile(t, fixture.guardWrapper) != originalGuard { + t.Fatalf("guard wrapper changed during idempotent install") + } + if readClineTestFile(t, fixture.auditWrapper) != originalAudit { + t.Fatalf("audit wrapper changed during idempotent install") + } + if !strings.HasPrefix(originalGuard, "#!/usr/bin/env node\n// @elydora-cline-hook ") { + t.Fatalf("guard wrapper has unexpected header: %q", originalGuard) + } + entries, err := os.ReadDir(fixture.hooksDir) + if err != nil { + t.Fatalf("read Cline hook directory: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("temporary hook file remains: %s", entry.Name()) + } + } + requireMissingClineTestFile(t, filepath.Join(fixture.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs")) + requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs")) + requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "hooks", "PreToolUse.mjs")) +} + +func TestClineInstallUsesOfficialDefaultWhenClineDirIsEmpty(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + t.Setenv("CLINE_DIR", "") + installClineFixture(t, fixture) + + defaultHooks := filepath.Join(fixture.homeDir, ".cline", "hooks") + readClineTestFile(t, filepath.Join(defaultHooks, "PreToolUse.mjs")) + readClineTestFile(t, filepath.Join(defaultHooks, "PostToolUse.mjs")) + requireMissingClineTestFile(t, fixture.guardWrapper) +} + +func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + capturePath := filepath.Join(t.TempDir(), "captured-event.json") + captureJSON, err := json.Marshal(capturePath) + if err != nil { + t.Fatalf("marshal capture path: %v", err) + } + captureScript := "const fs = require('node:fs'); const chunks = []; " + + "process.stdin.on('data', chunk => chunks.push(chunk)); " + + "process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", JSON.stringify({" + + "cwd: process.cwd(), input: Buffer.concat(chunks).toString('utf-8')})));\n" + if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { + t.Fatalf("write capture hook: %v", err) + } + + prePayload := []byte(`{"clineVersion":"3.0.46","hookName":"tool_call","taskId":"task-1","tool_call":{"id":"call-1","name":"read_file","input":{"path":"README.md"}}}`) + guard := runClineWrapper(t, fixture, fixture.guardWrapper, prePayload) + if guard.exitCode != 0 || !strings.Contains(guard.stderr, "Agent is frozen by Elydora") { + t.Fatalf("guard result = %#v", guard) + } + control := decodeClineControl(t, guard.stdout) + if control["cancel"] != true || control["errorMessage"] != "Agent is frozen by Elydora." { + t.Fatalf("guard control = %#v", control) + } + + postPayload := []byte("{\n \"clineVersion\": \"3.0.46\",\n \"hookName\": \"tool_result\",\n \"taskId\": \"task-1\",\n \"tool_result\": {\"name\": \"read_file\", \"input\": {\"path\": \"README.md\"}}\n}") + audit := runClineWrapper(t, fixture, fixture.auditWrapper, postPayload) + if audit.exitCode != 0 || audit.stdout != "" { + t.Fatalf("audit result = %#v", audit) + } + var captured map[string]any + if err := json.Unmarshal([]byte(readClineTestFile(t, capturePath)), &captured); err != nil { + t.Fatalf("decode captured payload: %v", err) + } + if captured["cwd"] != fixture.workspaceDir || captured["input"] != string(postPayload) { + t.Fatalf("captured payload = %#v", captured) + } +} + +func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) { + operations := make(chan map[string]any, 1) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + defer request.Body.Close() + var operation map[string]any + if err := json.NewDecoder(request.Body).Decode(&operation); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + operations <- operation + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{}`)) + })) + defer server.Close() + + fixture := prepareClineFixture(t, clineFixtureOptions{}) + fixture.config.BaseURL = server.URL + installClineFixture(t, fixture) + payload := []byte(`{"hookName":"tool_result","taskId":"task-1","tool_result":{"name":"read_file","input":{"path":"README.md"},"output":"ok"}}`) + result := runClineWrapper(t, fixture, fixture.auditWrapper, payload) + if result.exitCode != 0 { + t.Fatalf("audit wrapper result = %#v", result) + } + + var operation map[string]any + select { + case operation = <-operations: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for Elydora operation") + } + wantPayload := map[string]any{ + "tool_name": "read_file", + "tool_input": map[string]any{"path": "README.md"}, + "session_id": "task-1", + } + if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), wantPayload) { + t.Fatalf("operation payload = %#v", operation["payload"]) + } + if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) { + t.Fatalf("operation action = %#v", operation["action"]) + } + if !reflect.DeepEqual(requireClineTestObject(t, operation["subject"]), map[string]any{"session_id": "task-1"}) { + t.Fatalf("operation subject = %#v", operation["subject"]) + } +} + +func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) { + passing := prepareClineFixture(t, clineFixtureOptions{ + GuardSource: clineString("process.stdin.resume();\n"), + }) + installClineFixture(t, passing) + passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`)) + if passResult.exitCode != 0 || passResult.stdout != "" { + t.Fatalf("passing guard result = %#v", passResult) + } + + failing := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, failing) + if err := os.WriteFile( + failing.hookPath, + []byte("process.stderr.write('audit failed\\n'); process.exit(7);\n"), + 0700, + ); err != nil { + t.Fatalf("write failing audit runtime: %v", err) + } + failResult := runClineWrapper(t, failing, failing.auditWrapper, []byte(`{}`)) + if failResult.exitCode != 1 || !strings.Contains(failResult.stderr, "audit failed") || + !strings.Contains(failResult.stderr, "exited with code 7") { + t.Fatalf("failing audit result = %#v", failResult) + } +} diff --git a/cmd/elydora/plugins/cline_test_helpers_test.go b/cmd/elydora/plugins/cline_test_helpers_test.go new file mode 100644 index 0000000..d6cc590 --- /dev/null +++ b/cmd/elydora/plugins/cline_test_helpers_test.go @@ -0,0 +1,205 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +const clineTestAgentID = "agent-1" + +type clineFixtureOptions struct { + ExistingAudit *string + ExistingGuard *string + GuardSource *string + SkipGuard bool +} + +type clineFixture struct { + plugin *ClinePlugin + config InstallConfig + homeDir string + workspaceDir string + clineDir string + hooksDir string + agentDir string + guardPath string + hookPath string + guardWrapper string + auditWrapper string + runtimeConfig string + privateKey string +} + +type clineCommandResult struct { + exitCode int + stdout string + stderr string +} + +func clineString(value string) *string { + return &value +} + +func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixture { + t.Helper() + rootDir := t.TempDir() + homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + workspaceDir := filepath.Join(rootDir, "workspace") + clineDir := filepath.Join(rootDir, "custom-cline-home") + hooksDir := filepath.Join(clineDir, "hooks") + agentDir := filepath.Join(homeDir, ".elydora", clineTestAgentID) + guardPath := filepath.Join(agentDir, "guard.js") + hookPath := filepath.Join(agentDir, "hook.js") + guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs") + auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs") + + for _, directory := range []string{workspaceDir, agentDir} { + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatalf("create fixture directory %s: %v", directory, err) + } + } + if !options.SkipGuard { + guardSource := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n" + if options.GuardSource != nil { + guardSource = *options.GuardSource + } + if err := os.WriteFile(guardPath, []byte(guardSource), 0700); err != nil { + t.Fatalf("write guard runtime: %v", err) + } + } + writeOptionalClineTestFile(t, guardWrapper, options.ExistingGuard) + writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit) + + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + t.Setenv("CLINE_DIR", clineDir) + return &clineFixture{ + plugin: &ClinePlugin{}, + homeDir: homeDir, + workspaceDir: workspaceDir, + clineDir: clineDir, + hooksDir: hooksDir, + agentDir: agentDir, + guardPath: guardPath, + hookPath: hookPath, + guardWrapper: guardWrapper, + auditWrapper: auditWrapper, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + config: InstallConfig{ + AgentName: "cline", + OrgID: "org-1", + AgentID: clineTestAgentID, + PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", + KID: "kid-1", + BaseURL: "https://api.elydora.test", + GuardScriptPath: guardPath, + }, + } +} + +func writeOptionalClineTestFile(t *testing.T, path string, contents *string) { + t.Helper() + if contents == nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(*contents), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func installClineFixture(t *testing.T, fixture *clineFixture) { + t.Helper() + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Cline hooks: %v", err) + } +} + +func runClineWrapper( + t *testing.T, + fixture *clineFixture, + wrapperPath string, + payload []byte, +) clineCommandResult { + t.Helper() + nodePath, err := exec.LookPath("node") + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // #nosec G204 -- nodePath is resolved with exec.LookPath and the wrapper path is test-owned. + command := exec.CommandContext(ctx, nodePath, wrapperPath) + command.Dir = fixture.workspaceDir + command.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + ) + command.Stdin = bytes.NewReader(payload) + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + runErr := command.Run() + if ctx.Err() != nil { + t.Fatalf("Cline wrapper timed out: %v", ctx.Err()) + } + exitCode := 0 + if runErr != nil { + var exitError *exec.ExitError + if !errors.As(runErr, &exitError) { + t.Fatalf("run Cline wrapper: %v", runErr) + } + exitCode = exitError.ExitCode() + } + return clineCommandResult{exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String()} +} + +func readClineTestFile(t *testing.T, path string) string { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(contents) +} + +func requireMissingClineTestFile(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be missing, got %v", path, err) + } +} + +func requireClineTestObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("value is not an object: %#v", value) + } + return object +} + +func decodeClineControl(t *testing.T, stdout string) map[string]any { + t.Helper() + const marker = "HOOK_CONTROL\t" + if len(stdout) < len(marker) || stdout[:len(marker)] != marker { + t.Fatalf("Cline control output = %q", stdout) + } + var control map[string]any + if err := json.Unmarshal([]byte(stdout[len(marker):]), &control); err != nil { + t.Fatalf("decode Cline control output: %v", err) + } + return control +} diff --git a/cmd/elydora/plugins/hooktemplate.go b/cmd/elydora/plugins/hooktemplate.go index 2118efc..bf3be2d 100644 --- a/cmd/elydora/plugins/hooktemplate.go +++ b/cmd/elydora/plugins/hooktemplate.go @@ -354,11 +354,20 @@ async function main() { return; } - // Extract tool_name and tool_input from hook data - // Different agents may pass data in different shapes - const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown'; - const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {}; - const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown'; + // Extract provider-native tool fields without reshaping the hook input. + const clineToolEvent = + hookData.tool_result && typeof hookData.tool_result === 'object' + ? hookData.tool_result + : hookData.tool_call && typeof hookData.tool_call === 'object' + ? hookData.tool_call + : {}; + const toolName = + hookData.tool_name || hookData.toolName || hookData.name || clineToolEvent.name || 'unknown'; + const toolInput = + hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || + clineToolEvent.input || {}; + const sessionId = + hookData.session_id || hookData.sessionId || hookData.session || hookData.taskId || 'unknown'; // Read agent config and private key let config; diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go index 41962ba..228ea23 100644 --- a/cmd/elydora/plugins/registry.go +++ b/cmd/elydora/plugins/registry.go @@ -12,6 +12,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"}, "claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"}, "codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"}, + "cline": {Name: "Cline", ConfigDir: "~/.cline/hooks", ConfigFile: "PreToolUse.mjs"}, "cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}, "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, "grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"}, @@ -33,6 +34,8 @@ func NewPlugin(agentName string) AgentPlugin { return &ClaudeCodePlugin{} case "codex": return &CodexPlugin{} + case "cline": + return &ClinePlugin{} case "cursor": return &CursorPlugin{} case "gemini":