feat: add native Cline hook integration
This commit is contained in:
1 parent
9d03a68477
commit
2eff1ae119
10 files changed
+1316
-5
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user