feat: sync Go Cline hook integration
This commit is contained in:
1 parent
008999a0c1
commit
8da7dbb524
11 files changed
+1318
-8
No files matched your search
@@ -27,7 +27,7 @@ This repository publishes the open-source server, console, integration catalog,
|
|||||||
- Write Grok Build integrations to a native user hook file under non-empty `GROK_HOME` or `~/.grok`; keep Claude Code and Cursor compatibility sources read-only.
|
- Write Grok Build integrations to a native user hook file under non-empty `GROK_HOME` or `~/.grok`; keep Claude Code and Cursor compatibility sources read-only.
|
||||||
- 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 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.
|
||||||
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
|
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
|
||||||
- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open.
|
- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open.
|
||||||
|
|
||||||
## Mirror Workflow
|
## Mirror Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -85,8 +85,8 @@
|
|||||||
"mechanism": "json_stdout_cancel",
|
"mechanism": "json_stdout_cancel",
|
||||||
"failure_mode": "fail_open"
|
"failure_mode": "fail_open"
|
||||||
},
|
},
|
||||||
"adapters": { "node": true, "python": true, "go": false },
|
"adapters": { "node": true, "python": true, "go": true },
|
||||||
"delivery_state": "partial",
|
"delivery_state": "available",
|
||||||
"source_url": "https://github.com/cline/cline/blob/main/sdk/examples/hooks/README.md"
|
"source_url": "https://github.com/cline/cline/blob/main/sdk/examples/hooks/README.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,6 +73,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.
|
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`.
|
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.
|
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.
|
||||||
@@ -97,6 +99,7 @@ Auggie installation writes user-level hooks to `~/.augment/settings.json` and cr
|
|||||||
| Augment Code CLI | `augment` |
|
| Augment Code CLI | `augment` |
|
||||||
| Claude Code | `claudecode` |
|
| Claude Code | `claudecode` |
|
||||||
| OpenAI Codex | `codex` |
|
| OpenAI Codex | `codex` |
|
||||||
|
| Cline | `cline` |
|
||||||
| Copilot CLI | `copilot` |
|
| Copilot CLI | `copilot` |
|
||||||
| Cursor | `cursor` |
|
| Cursor | `cursor` |
|
||||||
| Gemini CLI | `gemini` |
|
| Gemini CLI | `gemini` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -354,11 +354,20 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract tool_name and tool_input from hook data
|
// Extract provider-native tool fields without reshaping the hook input.
|
||||||
// Different agents may pass data in different shapes
|
const clineToolEvent =
|
||||||
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
|
hookData.tool_result && typeof hookData.tool_result === 'object'
|
||||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
|
? hookData.tool_result
|
||||||
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
|
: 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
|
// Read agent config and private key
|
||||||
let config;
|
let config;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
|||||||
"augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"},
|
"augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"},
|
||||||
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
||||||
"codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.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"},
|
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
||||||
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
||||||
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
|
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
|
||||||
@@ -33,6 +34,8 @@ func NewPlugin(agentName string) AgentPlugin {
|
|||||||
return &ClaudeCodePlugin{}
|
return &ClaudeCodePlugin{}
|
||||||
case "codex":
|
case "codex":
|
||||||
return &CodexPlugin{}
|
return &CodexPlugin{}
|
||||||
|
case "cline":
|
||||||
|
return &ClinePlugin{}
|
||||||
case "cursor":
|
case "cursor":
|
||||||
return &CursorPlugin{}
|
return &CursorPlugin{}
|
||||||
case "gemini":
|
case "gemini":
|
||||||
|
|||||||
Reference in new issue
Block a user