feat: add native Cline hook integration

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 10:19:29 -04:00
1 parent 9d03a68477
commit 2eff1ae119
10 files changed
+1316 -5

No files matched your search

+360
View File
@@ -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
}