sdks/node: mirror Elydora-Node-SDK 2.0.1
Rebuilt from the standalone 2101cb7 tree (durable transactions, Kiro IDE adapter, hook retry with a bounded chain lock, version 2.0.1) with the Open-Source API trims re-applied: no webhooks, members, admin events, deep health, token rotation, or agent integration updates. Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
1 parent
1656a94811
commit
98c61a0b6f
44 files changed
+6794
-1130
No files matched your search
@@ -38,6 +38,8 @@ const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
|
||||
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const MAX_CHAIN_ATTEMPTS = 5;
|
||||
const SUBMIT_BUDGET_MS = 4000;
|
||||
|
||||
${PROTECTED_RUNTIME_READER}
|
||||
|
||||
@@ -165,6 +167,81 @@ function writeChainState(chainHash) {
|
||||
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
||||
}
|
||||
|
||||
function lockOwnerAlive(lockPath) {
|
||||
let owner;
|
||||
try {
|
||||
owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10);
|
||||
} catch (error) {
|
||||
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
|
||||
return false;
|
||||
}
|
||||
if (!Number.isInteger(owner) || owner <= 0) return false;
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasRuntimeErrorCode(error, 'ESRCH')) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function withChainLock(update) {
|
||||
const lockPath = CHAIN_STATE_PATH + '.lock';
|
||||
const deadline = performance.now() + 1000;
|
||||
for (;;) {
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
||||
fs.writeSync(descriptor, String(process.pid));
|
||||
} catch (error) {
|
||||
if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error;
|
||||
if (lockOwnerAlive(lockPath)) {
|
||||
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||
continue;
|
||||
}
|
||||
let age = 0;
|
||||
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) {
|
||||
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||
}
|
||||
if (age > 5000) {
|
||||
const reclaimed = lockPath + '.' + process.pid + '.stale';
|
||||
try {
|
||||
fs.renameSync(lockPath, reclaimed);
|
||||
fs.unlinkSync(reclaimed);
|
||||
} catch (reclaimError) {
|
||||
if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return update();
|
||||
} finally {
|
||||
let current = null;
|
||||
try {
|
||||
current = fs.lstatSync(lockPath);
|
||||
} catch (statError) {
|
||||
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||
}
|
||||
const own = fs.fstatSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function advanceChainState(fromHash, toHash) {
|
||||
return withChainLock(() => {
|
||||
if (readChainState() !== fromHash) return false;
|
||||
writeChainState(toHash);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function readConfigAndKey() {
|
||||
const raw = readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
@@ -219,27 +296,43 @@ async function readHookInput() {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function submitOperation(hookData, runtime) {
|
||||
const toolEvent = 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 ||
|
||||
toolEvent.name || 'unknown';
|
||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
|
||||
hookData.parameters || toolEvent.input || {};
|
||||
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
||||
hookData.session || hookData.taskId || 'unknown';
|
||||
const previousHash = readChainState();
|
||||
async function postOperation(operation, runtime, deadline) {
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) throw new Error('Audit API retry budget exhausted');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining));
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
|
||||
const response = await fetch(runtime.baseUrl + '/v1/operations', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(operation),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) return { accepted: true };
|
||||
const responseBody = await response.text();
|
||||
if (response.status === 400 && responseBody) {
|
||||
let failure;
|
||||
try { failure = JSON.parse(responseBody); } catch (error) {
|
||||
throw new Error('Audit API returned invalid error JSON: ' + error.message);
|
||||
}
|
||||
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
|
||||
const match = String(failure.error.message || '').match(
|
||||
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
|
||||
);
|
||||
if (match) return { accepted: false, expected: match[1] };
|
||||
}
|
||||
}
|
||||
throw new Error('Audit API returned HTTP ' + response.status);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash) {
|
||||
const operationId = uuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const payload = NATIVE_PAYLOAD ? hookData : {
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
};
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
const chainHash = computeChainHash(previousHash, payloadHash, operationId, issuedAt);
|
||||
const unsigned = {
|
||||
op_version: '1.0',
|
||||
@@ -261,41 +354,42 @@ async function submitOperation(hookData, runtime) {
|
||||
runtime.privateKey,
|
||||
Buffer.from(jcsCanonicalise(unsigned), 'utf-8'),
|
||||
);
|
||||
const operation = { ...unsigned, chain_hash: chainHash, signature };
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
|
||||
const response = await fetch(runtime.baseUrl + '/v1/operations', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(operation),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
writeChainState(chainHash);
|
||||
return { operation: { ...unsigned, chain_hash: chainHash, signature }, chainHash };
|
||||
}
|
||||
|
||||
async function submitOperation(hookData, runtime) {
|
||||
const toolEvent = 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 ||
|
||||
toolEvent.name || 'unknown';
|
||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
|
||||
hookData.parameters || toolEvent.input || {};
|
||||
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
||||
hookData.session || hookData.taskId || 'unknown';
|
||||
const payload = NATIVE_PAYLOAD ? hookData : {
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
};
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
const deadline = performance.now() + SUBMIT_BUDGET_MS;
|
||||
let previousHash = readChainState();
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
const built = buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash);
|
||||
const result = await postOperation(built.operation, runtime, deadline);
|
||||
if (result.accepted) {
|
||||
advanceChainState(previousHash, built.chainHash);
|
||||
return;
|
||||
}
|
||||
const responseBody = await response.text();
|
||||
if (response.status === 400 && responseBody) {
|
||||
let failure;
|
||||
try { failure = JSON.parse(responseBody); } catch (error) {
|
||||
throw new Error('Audit API returned invalid error JSON: ' + error.message);
|
||||
}
|
||||
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
|
||||
const match = String(failure.error.message || '').match(
|
||||
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
|
||||
);
|
||||
if (match) {
|
||||
writeChainState(match[1]);
|
||||
logError(new Error('Chain hash resynced to server: ' + match[1]));
|
||||
}
|
||||
}
|
||||
advanceChainState(previousHash, result.expected);
|
||||
logError(new Error('Chain hash resynced to server: ' + result.expected));
|
||||
if (attempt >= MAX_CHAIN_ATTEMPTS) {
|
||||
throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
|
||||
}
|
||||
throw new Error('Audit API returned HTTP ' + response.status);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
previousHash = readChainState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildKiroIdeCommand,
|
||||
kiroIdeRuntimeReference,
|
||||
sameKiroIdeAgentId,
|
||||
sameKiroIdePath,
|
||||
type KiroIdeRuntimeReference,
|
||||
} from './kiroide-command.js';
|
||||
import {
|
||||
buildKiroIdeHook,
|
||||
kiroIdeRuntimeContracts,
|
||||
renderKiroIdeDocument,
|
||||
type KiroIdeDocument,
|
||||
type KiroIdeHook,
|
||||
type RenderedKiroIdeDocument,
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'kirocli';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const V2_AGENT_NAME = 'elydora-audit';
|
||||
export const V3_CONFIG_FILE = 'elydora-audit.json';
|
||||
export const HOOK_TIMEOUT_MILLISECONDS = 10_000;
|
||||
|
||||
const V2_DESCRIPTION = 'Kiro CLI with Elydora audit and freeze enforcement';
|
||||
const V3_GUARD_NAME = 'elydora-guard';
|
||||
const V3_AUDIT_NAME = 'elydora-audit';
|
||||
const V2_EVENTS = new Set([
|
||||
'agentSpawn',
|
||||
'userPromptSubmit',
|
||||
'preToolUse',
|
||||
'postToolUse',
|
||||
'stop',
|
||||
]);
|
||||
|
||||
export interface KiroCliPaths {
|
||||
readonly homeDirectory: string;
|
||||
readonly kiroDirectory: string;
|
||||
readonly agentsDirectory: string;
|
||||
readonly hooksDirectory: string;
|
||||
readonly v2Path: string;
|
||||
readonly v3Path: string;
|
||||
}
|
||||
|
||||
export interface KiroCliV2Hook extends JsonObject {
|
||||
readonly command: string;
|
||||
readonly matcher?: string;
|
||||
readonly timeout_ms?: number;
|
||||
readonly cache_ttl_seconds?: number;
|
||||
}
|
||||
|
||||
export interface KiroCliV2Document {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: JsonObject;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedKiroCliV2Document {
|
||||
readonly document: KiroCliV2Document;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export function resolveKiroCliPaths(homeDirectory = os.homedir()): KiroCliPaths {
|
||||
const resolvedHome = path.resolve(homeDirectory);
|
||||
const kiroDirectory = path.join(resolvedHome, '.kiro');
|
||||
const agentsDirectory = path.join(kiroDirectory, 'agents');
|
||||
const hooksDirectory = path.join(kiroDirectory, 'hooks');
|
||||
return {
|
||||
homeDirectory: resolvedHome,
|
||||
kiroDirectory,
|
||||
agentsDirectory,
|
||||
hooksDirectory,
|
||||
v2Path: path.join(agentsDirectory, `${V2_AGENT_NAME}.json`),
|
||||
v3Path: path.join(hooksDirectory, V3_CONFIG_FILE),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOptionalInteger(value: unknown, field: string, label: string): void {
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || (value as number) < 0)) {
|
||||
throw new Error(`${label} ${field} must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateV2Hook(value: unknown, event: string, index: number): KiroCliV2Hook {
|
||||
const label = `Kiro CLI v2 agent hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (typeof value.command !== 'string' || !value.command) {
|
||||
throw new Error(`${label} command must be a non-empty string`);
|
||||
}
|
||||
if (value.matcher !== undefined) {
|
||||
if (!['preToolUse', 'postToolUse'].includes(event)) {
|
||||
throw new Error(`${label} matcher is valid only for tool events`);
|
||||
}
|
||||
if (typeof value.matcher !== 'string' || !value.matcher) {
|
||||
throw new Error(`${label} matcher must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
validateOptionalInteger(value.timeout_ms, 'timeout_ms', label);
|
||||
validateOptionalInteger(value.cache_ttl_seconds, 'cache_ttl_seconds', label);
|
||||
return value as KiroCliV2Hook;
|
||||
}
|
||||
|
||||
function validateV2Hooks(value: unknown, filePath: string): JsonObject {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) {
|
||||
throw new Error(`Kiro CLI v2 agent field "hooks" must be an object: ${filePath}`);
|
||||
}
|
||||
const hooks: JsonObject = { ...value };
|
||||
for (const [event, entries] of Object.entries(value)) {
|
||||
if (!V2_EVENTS.has(event)) {
|
||||
throw new Error(`Kiro CLI v2 agent has unsupported hook event "${event}": ${filePath}`);
|
||||
}
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new Error(`Kiro CLI v2 agent hooks.${event} must be an array: ${filePath}`);
|
||||
}
|
||||
hooks[event] = entries.map((entry, index) => validateV2Hook(entry, event, index));
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
function validateOptionalV2Fields(root: JsonObject, filePath: string): void {
|
||||
for (const field of ['name', 'description', 'prompt', 'model', 'keyboardShortcut', 'welcomeMessage']) {
|
||||
if (root[field] !== undefined && typeof root[field] !== 'string') {
|
||||
throw new Error(`Kiro CLI v2 agent field "${field}" must be a string: ${filePath}`);
|
||||
}
|
||||
}
|
||||
for (const field of ['tools', 'allowedTools', 'resources']) {
|
||||
const value = root[field];
|
||||
if (value !== undefined
|
||||
&& (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string'))) {
|
||||
throw new Error(`Kiro CLI v2 agent field "${field}" must be an array of strings: ${filePath}`);
|
||||
}
|
||||
}
|
||||
if (root.includeMcpJson !== undefined && typeof root.includeMcpJson !== 'boolean') {
|
||||
throw new Error(`Kiro CLI v2 agent field "includeMcpJson" must be a boolean: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseKiroCliV2Document(filePath: string, raw: string): KiroCliV2Document {
|
||||
const root = parseStrictJsonObject(raw, `Kiro CLI v2 agent at ${filePath}`);
|
||||
validateOptionalV2Fields(root, filePath);
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
root,
|
||||
hooks: validateV2Hooks(root.hooks, filePath),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createKiroCliV2Document(filePath: string): KiroCliV2Document {
|
||||
return { exists: false, filePath, root: {}, hooks: {} };
|
||||
}
|
||||
|
||||
function eventHooks(document: KiroCliV2Document, event: string): readonly KiroCliV2Hook[] {
|
||||
return (document.hooks[event] as readonly KiroCliV2Hook[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
function exactKeys(value: JsonObject, keys: readonly string[]): boolean {
|
||||
return Object.keys(value).sort().join('|') === [...keys].sort().join('|');
|
||||
}
|
||||
|
||||
function legacyWindowsReference(
|
||||
command: unknown,
|
||||
scriptName: string,
|
||||
): KiroIdeRuntimeReference | undefined {
|
||||
if (typeof command !== 'string') return undefined;
|
||||
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
|
||||
if (!match
|
||||
|| !path.isAbsolute(match[1])
|
||||
|| !['node', 'node.exe'].includes(path.basename(match[1]).toLowerCase())
|
||||
|| !path.isAbsolute(match[2])
|
||||
|| path.basename(match[2]) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(match[2]);
|
||||
if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId ? { agentId, scriptPath: match[2] } : undefined;
|
||||
}
|
||||
|
||||
function ownedReference(command: unknown, scriptName: string): KiroIdeRuntimeReference | undefined {
|
||||
return typeof command === 'string'
|
||||
? kiroIdeRuntimeReference(command, scriptName) ?? legacyWindowsReference(command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function v2OwnedReference(hook: KiroCliV2Hook, event: string) {
|
||||
if (event === 'preToolUse') return ownedReference(hook.command, GUARD_SCRIPT);
|
||||
if (event === 'postToolUse') return ownedReference(hook.command, AUDIT_SCRIPT);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function v2ManagedReference(hook: KiroCliV2Hook, event: string) {
|
||||
const reference = v2OwnedReference(hook, event);
|
||||
if (!reference
|
||||
|| !exactKeys(hook, ['matcher', 'command', 'timeout_ms'])
|
||||
|| hook.matcher !== '*'
|
||||
|| hook.timeout_ms !== HOOK_TIMEOUT_MILLISECONDS) return undefined;
|
||||
return reference;
|
||||
}
|
||||
|
||||
function managedMetadata(root: JsonObject): boolean {
|
||||
return root.name === V2_AGENT_NAME
|
||||
&& root.description === V2_DESCRIPTION
|
||||
&& Array.isArray(root.tools)
|
||||
&& root.tools.length === 1
|
||||
&& root.tools[0] === '*'
|
||||
&& root.includeMcpJson === true;
|
||||
}
|
||||
|
||||
function hasOwnedV2Hooks(document: KiroCliV2Document, agentId?: string): boolean {
|
||||
return ['preToolUse', 'postToolUse'].some((event) => eventHooks(document, event).some((hook) => {
|
||||
const reference = v2OwnedReference(hook, event);
|
||||
return reference !== undefined
|
||||
&& (agentId === undefined || sameKiroIdeAgentId(reference.agentId, agentId));
|
||||
}));
|
||||
}
|
||||
|
||||
export function requireAvailableKiroCliV2Document(document: KiroCliV2Document): void {
|
||||
if (document.exists && !managedMetadata(document.root) && !hasOwnedV2Hooks(document)) {
|
||||
throw new Error(`Kiro CLI v2 agent path conflicts with the Elydora contract: ${document.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function withoutManagedKiroCliV2Hooks(
|
||||
document: KiroCliV2Document,
|
||||
agentId?: string,
|
||||
): JsonObject {
|
||||
const next = { ...document.hooks };
|
||||
for (const event of ['preToolUse', 'postToolUse']) {
|
||||
const remaining = eventHooks(document, event).filter((hook) => {
|
||||
const reference = v2OwnedReference(hook, event);
|
||||
return !reference || (agentId !== undefined && !sameKiroIdeAgentId(reference.agentId, agentId));
|
||||
});
|
||||
if (remaining.length > 0) next[event] = remaining;
|
||||
else delete next[event];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildV2Hook(scriptPath: string): KiroCliV2Hook {
|
||||
return {
|
||||
matcher: '*',
|
||||
command: buildKiroIdeCommand(scriptPath),
|
||||
timeout_ms: HOOK_TIMEOUT_MILLISECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKiroCliV2Hooks(
|
||||
document: KiroCliV2Document,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): JsonObject {
|
||||
const hooks = withoutManagedKiroCliV2Hooks(document);
|
||||
hooks.preToolUse = [...eventHooks({ ...document, hooks }, 'preToolUse'), buildV2Hook(guardPath)];
|
||||
hooks.postToolUse = [...eventHooks({ ...document, hooks }, 'postToolUse'), buildV2Hook(auditPath)];
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function renderKiroCliV2Installation(
|
||||
document: KiroCliV2Document,
|
||||
hooks: JsonObject,
|
||||
): RenderedKiroCliV2Document {
|
||||
const next = `${JSON.stringify({
|
||||
...document.root,
|
||||
name: V2_AGENT_NAME,
|
||||
description: V2_DESCRIPTION,
|
||||
tools: ['*'],
|
||||
includeMcpJson: true,
|
||||
hooks,
|
||||
}, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
function entirelyManagedV2Document(document: KiroCliV2Document, agentId?: string): boolean {
|
||||
if (!document.exists
|
||||
|| !managedMetadata(document.root)
|
||||
|| !exactKeys(document.root, ['name', 'description', 'tools', 'includeMcpJson', 'hooks'])
|
||||
|| !exactKeys(document.hooks, ['preToolUse', 'postToolUse'])) return false;
|
||||
for (const event of ['preToolUse', 'postToolUse']) {
|
||||
const hooks = eventHooks(document, event);
|
||||
if (hooks.length !== 1) return false;
|
||||
const reference = v2OwnedReference(hooks[0], event);
|
||||
if (!reference || (agentId !== undefined && !sameKiroIdeAgentId(reference.agentId, agentId))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function renderKiroCliV2Uninstall(
|
||||
document: KiroCliV2Document,
|
||||
hooks: JsonObject,
|
||||
agentId?: string,
|
||||
): RenderedKiroCliV2Document {
|
||||
if (!hasOwnedV2Hooks(document, agentId)) return { document, changed: false };
|
||||
if (entirelyManagedV2Document(document, agentId) && Object.keys(hooks).length === 0) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
const next = `${JSON.stringify({ ...document.root, hooks }, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
export function kiroCliV2RuntimeContracts(
|
||||
document: KiroCliV2Document,
|
||||
): KiroIdeRuntimeContract[] {
|
||||
for (const event of ['preToolUse', 'postToolUse']) {
|
||||
if (eventHooks(document, event).some((hook) => (
|
||||
v2OwnedReference(hook, event) !== undefined
|
||||
&& v2ManagedReference(hook, event) === undefined
|
||||
))) return [];
|
||||
}
|
||||
const guards = eventHooks(document, 'preToolUse')
|
||||
.map((hook) => v2ManagedReference(hook, 'preToolUse'))
|
||||
.filter((value): value is KiroIdeRuntimeReference => value !== undefined);
|
||||
const audits = eventHooks(document, 'postToolUse')
|
||||
.map((hook) => v2ManagedReference(hook, 'postToolUse'))
|
||||
.filter((value): value is KiroIdeRuntimeReference => value !== undefined);
|
||||
if (guards.length !== 1 || audits.length !== 1
|
||||
|| !sameKiroIdeAgentId(guards[0].agentId, audits[0].agentId)) return [];
|
||||
return [{ agentId: guards[0].agentId, guardPath: guards[0].scriptPath, auditPath: audits[0].scriptPath }];
|
||||
}
|
||||
|
||||
function v3Specification(name: string) {
|
||||
if (name === V3_GUARD_NAME) return { scriptName: GUARD_SCRIPT };
|
||||
if (name === V3_AUDIT_NAME) return { scriptName: AUDIT_SCRIPT };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function v3OwnedReference(hook: KiroIdeHook) {
|
||||
const specification = v3Specification(hook.name);
|
||||
return specification && hook.action.type === 'command'
|
||||
? ownedReference(hook.action.command, specification.scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function requireAvailableKiroCliV3Hooks(hooks: readonly KiroIdeHook[]): void {
|
||||
for (const hook of hooks) {
|
||||
if (v3Specification(hook.name) && !v3OwnedReference(hook)) {
|
||||
throw new Error(`Kiro CLI v3 hook name "${hook.name}" conflicts with the Elydora contract`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function withoutManagedKiroCliV3Hooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
agentId?: string,
|
||||
): KiroIdeHook[] {
|
||||
return hooks.filter((hook) => {
|
||||
const reference = v3OwnedReference(hook);
|
||||
return !reference || (agentId !== undefined && !sameKiroIdeAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
function hasOwnedV3Hooks(hooks: readonly KiroIdeHook[], agentId?: string): boolean {
|
||||
return hooks.some((hook) => {
|
||||
const reference = v3OwnedReference(hook);
|
||||
return reference !== undefined
|
||||
&& (agentId === undefined || sameKiroIdeAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
function entirelyManagedV3Document(document: KiroIdeDocument, agentId?: string): boolean {
|
||||
return document.exists
|
||||
&& exactKeys(document.root, ['version', 'hooks'])
|
||||
&& document.hooks.length > 0
|
||||
&& document.hooks.every((hook) => {
|
||||
const reference = v3OwnedReference(hook);
|
||||
return reference !== undefined
|
||||
&& (agentId === undefined || sameKiroIdeAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
export function renderKiroCliV3Uninstall(
|
||||
document: KiroIdeDocument,
|
||||
hooks: readonly KiroIdeHook[],
|
||||
agentId?: string,
|
||||
): RenderedKiroIdeDocument {
|
||||
if (!hasOwnedV3Hooks(document.hooks, agentId)) return { document, changed: false };
|
||||
if (hooks.length === 0 && entirelyManagedV3Document(document, agentId)) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
return renderKiroIdeDocument(document, hooks);
|
||||
}
|
||||
|
||||
export function buildKiroCliV3Hooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): KiroIdeHook[] {
|
||||
return [
|
||||
...withoutManagedKiroCliV3Hooks(hooks),
|
||||
buildKiroIdeHook(V3_GUARD_NAME, guardPath),
|
||||
buildKiroIdeHook(V3_AUDIT_NAME, auditPath),
|
||||
];
|
||||
}
|
||||
|
||||
export function commonKiroCliRuntimeContract(
|
||||
v2Document: KiroCliV2Document,
|
||||
v3Hooks: readonly KiroIdeHook[],
|
||||
): KiroIdeRuntimeContract | undefined {
|
||||
const v2 = kiroCliV2RuntimeContracts(v2Document);
|
||||
const v3 = kiroIdeRuntimeContracts(v3Hooks);
|
||||
if (v2.length !== 1 || v3.length !== 1
|
||||
|| !sameKiroIdeAgentId(v2[0].agentId, v3[0].agentId)
|
||||
|| !sameKiroIdePath(v2[0].guardPath, v3[0].guardPath)
|
||||
|| !sameKiroIdePath(v2[0].auditPath, v3[0].auditPath)) return undefined;
|
||||
return v2[0];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type RenderedKiroCliV2Document,
|
||||
} from './kirocli-contract.js';
|
||||
import type { KiroCliSources } from './kirocli-io.js';
|
||||
import type { RenderedKiroIdeDocument } from './kiroide-contract.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Kiro CLI';
|
||||
const V2_DIRECTORY_LABEL = 'Kiro CLI agents directory';
|
||||
const V3_DIRECTORY_LABEL = 'Kiro CLI global hooks directory';
|
||||
|
||||
export type KiroCliRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedKiroCliInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
function hookLocations(sources: KiroCliSources) {
|
||||
return [
|
||||
{ directoryLabel: V2_DIRECTORY_LABEL, filePath: sources.paths.v2Path },
|
||||
{ directoryLabel: V3_DIRECTORY_LABEL, filePath: sources.paths.v3Path },
|
||||
];
|
||||
}
|
||||
|
||||
export async function preflightKiroCliInstallation(
|
||||
config: InstallConfig,
|
||||
sources: KiroCliSources,
|
||||
): Promise<KiroCliRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocations(sources),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareKiroCliInstallation(
|
||||
config: InstallConfig,
|
||||
sources: KiroCliSources,
|
||||
v2: RenderedKiroCliV2Document,
|
||||
v3: RenderedKiroIdeDocument,
|
||||
): Promise<PreparedKiroCliInstallation> {
|
||||
if (v2.next === undefined || v3.next === undefined) {
|
||||
throw new Error('Kiro CLI installation requires both v2 and v3 hook documents');
|
||||
}
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [
|
||||
{
|
||||
directoryLabel: V2_DIRECTORY_LABEL,
|
||||
label: 'Kiro CLI v2 agent config',
|
||||
filePath: sources.paths.v2Path,
|
||||
expectedSource: sources.v2.raw,
|
||||
source: v2.next,
|
||||
},
|
||||
{
|
||||
directoryLabel: V3_DIRECTORY_LABEL,
|
||||
label: 'Kiro CLI v3 global hooks',
|
||||
filePath: sources.paths.v3Path,
|
||||
expectedSource: sources.v3.raw,
|
||||
source: v3.next,
|
||||
},
|
||||
],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitKiroCliInstallation(
|
||||
prepared: PreparedKiroCliInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareKiroCliUninstall(
|
||||
sources: KiroCliSources,
|
||||
v2: RenderedKiroCliV2Document,
|
||||
v3: RenderedKiroIdeDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const changes: ManagedFileChange[] = [];
|
||||
const directories = [];
|
||||
for (const item of [
|
||||
{ rendered: v2, label: 'Kiro CLI v2 agent config', directoryLabel: V2_DIRECTORY_LABEL },
|
||||
{ rendered: v3, label: 'Kiro CLI v3 global hooks', directoryLabel: V3_DIRECTORY_LABEL },
|
||||
]) {
|
||||
if (!item.rendered.changed) continue;
|
||||
const change = await prepareManagedFileChange({
|
||||
filePath: item.rendered.document.filePath,
|
||||
label: item.label,
|
||||
next: item.rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: item.rendered.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (!change) continue;
|
||||
changes.push(change);
|
||||
directories.push({
|
||||
path: path.dirname(item.rendered.document.filePath),
|
||||
label: item.directoryLabel,
|
||||
});
|
||||
}
|
||||
return { displayName: DISPLAY_NAME, operation: 'uninstall', directories, changes };
|
||||
}
|
||||
|
||||
export async function commitKiroCliUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
AGENT_KEY,
|
||||
createKiroCliV2Document,
|
||||
parseKiroCliV2Document,
|
||||
resolveKiroCliPaths,
|
||||
type KiroCliPaths,
|
||||
type KiroCliV2Document,
|
||||
} from './kirocli-contract.js';
|
||||
import {
|
||||
createKiroIdeDocument,
|
||||
parseKiroIdeDocument,
|
||||
type KiroIdeDocument,
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
const MAX_HOOK_BYTES = 512 * 1024;
|
||||
|
||||
export interface KiroCliSources {
|
||||
readonly paths: KiroCliPaths;
|
||||
readonly v2: KiroCliV2Document;
|
||||
readonly v3: KiroIdeDocument;
|
||||
}
|
||||
|
||||
async function inspectKiroCliDirectories(paths: KiroCliPaths): Promise<void> {
|
||||
await inspectPhysicalDirectory(paths.homeDirectory, 'Kiro CLI home directory');
|
||||
await inspectPhysicalDirectory(paths.kiroDirectory, 'Kiro CLI configuration directory');
|
||||
await Promise.all([
|
||||
inspectPhysicalDirectory(paths.agentsDirectory, 'Kiro CLI agents directory'),
|
||||
inspectPhysicalDirectory(paths.hooksDirectory, 'Kiro CLI hooks directory'),
|
||||
]);
|
||||
}
|
||||
|
||||
async function readV2Document(paths: KiroCliPaths): Promise<KiroCliV2Document> {
|
||||
const snapshot = await readPhysicalFile(
|
||||
paths.v2Path,
|
||||
'Kiro CLI v2 agent config',
|
||||
MAX_HOOK_BYTES,
|
||||
);
|
||||
return snapshot
|
||||
? parseKiroCliV2Document(paths.v2Path, snapshot.contents)
|
||||
: createKiroCliV2Document(paths.v2Path);
|
||||
}
|
||||
|
||||
async function readV3Document(paths: KiroCliPaths): Promise<KiroIdeDocument> {
|
||||
const snapshot = await readPhysicalFile(
|
||||
paths.v3Path,
|
||||
'Kiro CLI v3 global hooks',
|
||||
MAX_HOOK_BYTES,
|
||||
);
|
||||
return snapshot
|
||||
? parseKiroIdeDocument(paths.v3Path, snapshot.contents, 'Kiro CLI v3 global hooks')
|
||||
: createKiroIdeDocument(paths.v3Path);
|
||||
}
|
||||
|
||||
export async function readKiroCliSources(): Promise<KiroCliSources> {
|
||||
const paths = resolveKiroCliPaths();
|
||||
await inspectKiroCliDirectories(paths);
|
||||
const [v2, v3] = await Promise.all([readV2Document(paths), readV3Document(paths)]);
|
||||
return { paths, v2, v3 };
|
||||
}
|
||||
|
||||
export async function kiroCliRuntimeFilesExist(
|
||||
contract: KiroIdeRuntimeContract,
|
||||
): Promise<boolean> {
|
||||
return managedRuntimeFilesExist(contract, AGENT_KEY);
|
||||
}
|
||||
@@ -1,359 +1,97 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildKiroCliV2Hooks,
|
||||
buildKiroCliV3Hooks,
|
||||
commonKiroCliRuntimeContract,
|
||||
renderKiroCliV2Installation,
|
||||
renderKiroCliV2Uninstall,
|
||||
renderKiroCliV3Uninstall,
|
||||
requireAvailableKiroCliV2Document,
|
||||
requireAvailableKiroCliV3Hooks,
|
||||
withoutManagedKiroCliV2Hooks,
|
||||
withoutManagedKiroCliV3Hooks,
|
||||
} from './kirocli-contract.js';
|
||||
import {
|
||||
commitKiroCliInstallation,
|
||||
commitKiroCliUninstall,
|
||||
preflightKiroCliInstallation,
|
||||
prepareKiroCliInstallation,
|
||||
prepareKiroCliUninstall,
|
||||
} from './kirocli-installation.js';
|
||||
import { kiroCliRuntimeFilesExist, readKiroCliSources } from './kirocli-io.js';
|
||||
import { renderKiroIdeDocument } from './kiroide-contract.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'kirocli';
|
||||
const V2_AGENT_NAME = 'elydora-audit';
|
||||
const V2_DESCRIPTION = 'Kiro CLI with Elydora audit and freeze enforcement';
|
||||
const V3_GUARD_NAME = 'elydora-guard';
|
||||
const V3_AUDIT_NAME = 'elydora-audit';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
interface HookContract {
|
||||
readonly guard: string;
|
||||
readonly audit: string;
|
||||
readonly configPath: string;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveV2Path(): string {
|
||||
return path.join(os.homedir(), '.kiro', 'agents', `${V2_AGENT_NAME}.json`);
|
||||
}
|
||||
|
||||
function resolveV3Path(): string {
|
||||
const configDir = entry.configDir.replace(/^~/, os.homedir());
|
||||
return path.join(configDir, entry.configFile);
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quoteWindows(value: string): string {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
function buildCommand(scriptPath: string): string {
|
||||
const quote = process.platform === 'win32' ? quoteWindows : quotePosix;
|
||||
return `${quote(process.execPath)} ${quote(scriptPath)}`;
|
||||
}
|
||||
|
||||
async function readJsonFile(configPath: string, label: string): Promise<JsonObject | undefined> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fsp.readFile(configPath, 'utf-8');
|
||||
} catch (error) {
|
||||
if (isObject(error) && error.code === 'ENOENT') return undefined;
|
||||
throw new Error(`Read ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function writeJsonFile(configPath: string, value: JsonObject, label: string): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(configPath), { recursive: true });
|
||||
const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await fsp.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n', {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
await fsp.rename(tempPath, configPath);
|
||||
} catch (error) {
|
||||
await fsp.unlink(tempPath).catch(() => undefined);
|
||||
throw new Error(`Write ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFile(configPath: string, label: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch (error) {
|
||||
if (isObject(error) && error.code === 'ENOENT') return;
|
||||
throw new Error(`Remove ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hooksObject(settings: JsonObject, label: string): JsonObject {
|
||||
if (settings.hooks === undefined) return {};
|
||||
if (!isObject(settings.hooks)) throw new Error(`${label} field "hooks" must be an object`);
|
||||
return { ...settings.hooks };
|
||||
}
|
||||
|
||||
function hookEntries(hooks: JsonObject, event: string, label: string): JsonObject[] {
|
||||
const value = hooks[event];
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || !value.every(isObject)) {
|
||||
throw new Error(`${label} field "hooks.${event}" must be an array of objects`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isManagedCommand(command: unknown, scriptName: string, agentId?: string): command is string {
|
||||
if (typeof command !== 'string') return false;
|
||||
const normalized = command.toLowerCase();
|
||||
if (!normalized.includes('.elydora') || !normalized.includes(scriptName)) return false;
|
||||
return !agentId || command.includes(agentId);
|
||||
}
|
||||
|
||||
function withoutV2Hooks(entries: JsonObject[], agentId?: string): JsonObject[] {
|
||||
return entries.filter(
|
||||
(hook) => !isManagedCommand(hook.command, 'guard.js', agentId)
|
||||
&& !isManagedCommand(hook.command, 'hook.js', agentId),
|
||||
);
|
||||
}
|
||||
|
||||
function buildV2Hook(scriptPath: string): JsonObject {
|
||||
return {
|
||||
matcher: '*',
|
||||
command: buildCommand(scriptPath),
|
||||
timeout_ms: 5000,
|
||||
};
|
||||
}
|
||||
|
||||
function isOwnedV2Config(settings: JsonObject, hooks: JsonObject): boolean {
|
||||
const configKeys = new Set(['name', 'description', 'tools', 'includeMcpJson', 'hooks']);
|
||||
const hookKeys = new Set(['preToolUse', 'postToolUse']);
|
||||
return Object.keys(settings).every((key) => configKeys.has(key))
|
||||
&& settings.name === V2_AGENT_NAME
|
||||
&& settings.description === V2_DESCRIPTION
|
||||
&& Array.isArray(settings.tools)
|
||||
&& settings.tools.length === 1
|
||||
&& settings.tools[0] === '*'
|
||||
&& settings.includeMcpJson === true
|
||||
&& Object.keys(hooks).every((key) => hookKeys.has(key))
|
||||
&& Object.values(hooks).every((value) => Array.isArray(value) && value.length === 0);
|
||||
}
|
||||
|
||||
function v3Hooks(settings: JsonObject): JsonObject[] {
|
||||
if (settings.version !== undefined && settings.version !== 'v1') {
|
||||
throw new Error('Kiro CLI v3 hooks config field "version" must be "v1"');
|
||||
}
|
||||
if (settings.hooks === undefined) return [];
|
||||
if (!Array.isArray(settings.hooks) || !settings.hooks.every(isObject)) {
|
||||
throw new Error('Kiro CLI v3 hooks config field "hooks" must be an array of objects');
|
||||
}
|
||||
return settings.hooks;
|
||||
}
|
||||
|
||||
function v3ActionCommand(hook: JsonObject): unknown {
|
||||
return isObject(hook.action) ? hook.action.command : undefined;
|
||||
}
|
||||
|
||||
function isManagedV3Hook(hook: JsonObject, agentId?: string): boolean {
|
||||
if (hook.name === V3_GUARD_NAME) {
|
||||
return isManagedCommand(v3ActionCommand(hook), 'guard.js', agentId);
|
||||
}
|
||||
if (hook.name === V3_AUDIT_NAME) {
|
||||
return isManagedCommand(v3ActionCommand(hook), 'hook.js', agentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildV3Hook(
|
||||
name: string,
|
||||
description: string,
|
||||
trigger: 'PreToolUse' | 'PostToolUse',
|
||||
scriptPath: string,
|
||||
): JsonObject {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
trigger,
|
||||
matcher: '.*',
|
||||
action: { type: 'command', command: buildCommand(scriptPath) },
|
||||
timeout: 5,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function findV2Command(entries: JsonObject[], scriptName: string): string | undefined {
|
||||
return entries.find((hook) => isManagedCommand(hook.command, scriptName))?.command as string | undefined;
|
||||
}
|
||||
|
||||
function findV3Command(entries: JsonObject[], name: string, scriptName: string): string | undefined {
|
||||
const hook = entries.find(
|
||||
(candidate) => candidate.name === name && isManagedCommand(v3ActionCommand(candidate), scriptName),
|
||||
);
|
||||
return hook ? v3ActionCommand(hook) as string : undefined;
|
||||
}
|
||||
|
||||
async function runtimeScriptsExist(contracts: HookContract[]): Promise<boolean> {
|
||||
const root = path.join(os.homedir(), '.elydora');
|
||||
let entries: Array<{ isDirectory(): boolean; name: string }>;
|
||||
try {
|
||||
entries = await fsp.readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isObject(error) && error.code === 'ENOENT') return false;
|
||||
throw new Error(`Read Elydora runtime directory: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
for (const directory of entries) {
|
||||
if (!directory.isDirectory()) continue;
|
||||
const agentDir = path.join(root, directory.name);
|
||||
const guardPath = path.join(agentDir, 'guard.js');
|
||||
const hookPath = path.join(agentDir, 'hook.js');
|
||||
const referencesScripts = contracts.some(
|
||||
({ guard, audit }) => guard.includes(guardPath) && audit.includes(hookPath),
|
||||
);
|
||||
if (!referencesScripts) continue;
|
||||
|
||||
const configPath = path.join(agentDir, 'config.json');
|
||||
const config = await readJsonFile(configPath, `Elydora runtime config at ${configPath}`);
|
||||
if (!config || config.agent_name !== AGENT_KEY) continue;
|
||||
|
||||
for (const [scriptPath, label] of [
|
||||
[guardPath, 'Elydora guard runtime'],
|
||||
[hookPath, 'Elydora audit runtime'],
|
||||
] as const) {
|
||||
try {
|
||||
await fsp.access(scriptPath);
|
||||
} catch (error) {
|
||||
if (isObject(error) && error.code === 'ENOENT') return false;
|
||||
throw new Error(`${label} at ${scriptPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
function validateSources(
|
||||
sources: Awaited<ReturnType<typeof readKiroCliSources>>,
|
||||
): void {
|
||||
requireAvailableKiroCliV2Document(sources.v2);
|
||||
requireAvailableKiroCliV3Hooks(sources.v3.hooks);
|
||||
}
|
||||
|
||||
export const kirocliPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readKiroCliSources();
|
||||
validateSources(sources);
|
||||
await preflightKiroCliInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const v2Path = resolveV2Path();
|
||||
const v3Path = resolveV3Path();
|
||||
const v2Settings = (await readJsonFile(v2Path, 'Kiro CLI v2 agent config')) ?? {};
|
||||
const v3Settings = (await readJsonFile(v3Path, 'Kiro CLI v3 hooks config')) ?? {};
|
||||
const v2Hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
|
||||
const currentV3Hooks = v3Hooks(v3Settings);
|
||||
|
||||
v2Hooks.preToolUse = [
|
||||
...withoutV2Hooks(hookEntries(v2Hooks, 'preToolUse', 'Kiro CLI v2 agent config')),
|
||||
buildV2Hook(config.guardScriptPath),
|
||||
];
|
||||
v2Hooks.postToolUse = [
|
||||
...withoutV2Hooks(hookEntries(v2Hooks, 'postToolUse', 'Kiro CLI v2 agent config')),
|
||||
buildV2Hook(config.hookScriptPath),
|
||||
];
|
||||
|
||||
const nextV2Settings: JsonObject = {
|
||||
name: V2_AGENT_NAME,
|
||||
description: V2_DESCRIPTION,
|
||||
tools: ['*'],
|
||||
includeMcpJson: true,
|
||||
...v2Settings,
|
||||
hooks: v2Hooks,
|
||||
};
|
||||
const nextV3Settings: JsonObject = {
|
||||
...v3Settings,
|
||||
version: 'v1',
|
||||
hooks: [
|
||||
...currentV3Hooks.filter((hook) => !isManagedV3Hook(hook)),
|
||||
buildV3Hook(
|
||||
V3_GUARD_NAME,
|
||||
'Block tool use when the Elydora agent is frozen',
|
||||
'PreToolUse',
|
||||
config.guardScriptPath,
|
||||
),
|
||||
buildV3Hook(
|
||||
V3_AUDIT_NAME,
|
||||
'Record tool use in the Elydora audit trail',
|
||||
'PostToolUse',
|
||||
config.hookScriptPath,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
await writeJsonFile(v2Path, nextV2Settings, 'Kiro CLI v2 agent config');
|
||||
await writeJsonFile(v3Path, nextV3Settings, 'Kiro CLI v3 hooks config');
|
||||
console.log(' Kiro CLI v2: start with "kiro-cli --agent elydora-audit".');
|
||||
console.log(' Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.');
|
||||
const sources = await readKiroCliSources();
|
||||
validateSources(sources);
|
||||
const paths = await preflightKiroCliInstallation(config, sources);
|
||||
const v2 = renderKiroCliV2Installation(
|
||||
sources.v2,
|
||||
buildKiroCliV2Hooks(sources.v2, paths.guardPath, paths.auditPath),
|
||||
);
|
||||
const v3 = renderKiroIdeDocument(
|
||||
sources.v3,
|
||||
buildKiroCliV3Hooks(sources.v3.hooks, paths.guardPath, paths.auditPath),
|
||||
);
|
||||
const prepared = await prepareKiroCliInstallation(config, sources, v2, v3);
|
||||
await commitKiroCliInstallation(prepared);
|
||||
console.log(` Kiro CLI v2 agent: ${sources.paths.v2Path}`);
|
||||
console.log(' Kiro CLI v2 verification: run "kiro-cli agent validate --path <agent-file>".');
|
||||
console.log(' Kiro CLI v2 activation: start with "kiro-cli --agent elydora-audit".');
|
||||
console.log(` Kiro CLI v3 global hooks (2.13.0+): ${sources.paths.v3Path}`);
|
||||
console.log(' Kiro CLI v3 activation: start the TUI with "kiro-cli --v3".');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const v2Path = resolveV2Path();
|
||||
const v3Path = resolveV3Path();
|
||||
const v2Settings = await readJsonFile(v2Path, 'Kiro CLI v2 agent config');
|
||||
const v3Settings = await readJsonFile(v3Path, 'Kiro CLI v3 hooks config');
|
||||
|
||||
if (v2Settings) {
|
||||
const hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
|
||||
hooks.preToolUse = withoutV2Hooks(
|
||||
hookEntries(hooks, 'preToolUse', 'Kiro CLI v2 agent config'),
|
||||
agentId,
|
||||
);
|
||||
hooks.postToolUse = withoutV2Hooks(
|
||||
hookEntries(hooks, 'postToolUse', 'Kiro CLI v2 agent config'),
|
||||
agentId,
|
||||
);
|
||||
if (isOwnedV2Config(v2Settings, hooks)) {
|
||||
await removeFile(v2Path, 'Kiro CLI v2 agent config');
|
||||
} else {
|
||||
await writeJsonFile(v2Path, { ...v2Settings, hooks }, 'Kiro CLI v2 agent config');
|
||||
}
|
||||
}
|
||||
|
||||
if (v3Settings) {
|
||||
const hooks = v3Hooks(v3Settings).filter((hook) => !isManagedV3Hook(hook, agentId));
|
||||
const owned = Object.keys(v3Settings).every((key) => key === 'version' || key === 'hooks');
|
||||
if (owned && hooks.length === 0) {
|
||||
await removeFile(v3Path, 'Kiro CLI v3 hooks config');
|
||||
} else {
|
||||
await writeJsonFile(v3Path, { ...v3Settings, hooks }, 'Kiro CLI v3 hooks config');
|
||||
}
|
||||
}
|
||||
const sources = await readKiroCliSources();
|
||||
const v2 = renderKiroCliV2Uninstall(
|
||||
sources.v2,
|
||||
withoutManagedKiroCliV2Hooks(sources.v2, agentId),
|
||||
agentId,
|
||||
);
|
||||
const v3 = renderKiroCliV3Uninstall(
|
||||
sources.v3,
|
||||
withoutManagedKiroCliV3Hooks(sources.v3.hooks, agentId),
|
||||
agentId,
|
||||
);
|
||||
const prepared = await prepareKiroCliUninstall(sources, v2, v3);
|
||||
await commitKiroCliUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const v2Path = resolveV2Path();
|
||||
const v3Path = resolveV3Path();
|
||||
const v2Settings = await readJsonFile(v2Path, 'Kiro CLI v2 agent config');
|
||||
const v3Settings = await readJsonFile(v3Path, 'Kiro CLI v3 hooks config');
|
||||
const contracts: HookContract[] = [];
|
||||
|
||||
if (v2Settings) {
|
||||
const hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
|
||||
const guard = findV2Command(
|
||||
hookEntries(hooks, 'preToolUse', 'Kiro CLI v2 agent config'),
|
||||
'guard.js',
|
||||
);
|
||||
const audit = findV2Command(
|
||||
hookEntries(hooks, 'postToolUse', 'Kiro CLI v2 agent config'),
|
||||
'hook.js',
|
||||
);
|
||||
if (guard && audit) contracts.push({ guard, audit, configPath: v2Path });
|
||||
}
|
||||
|
||||
if (v3Settings) {
|
||||
const hooks = v3Hooks(v3Settings);
|
||||
const guard = findV3Command(hooks, V3_GUARD_NAME, 'guard.js');
|
||||
const audit = findV3Command(hooks, V3_AUDIT_NAME, 'hook.js');
|
||||
if (guard && audit) contracts.push({ guard, audit, configPath: v3Path });
|
||||
}
|
||||
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured ? await runtimeScriptsExist(contracts) : false;
|
||||
const sources = await readKiroCliSources();
|
||||
validateSources(sources);
|
||||
const contract = commonKiroCliRuntimeContract(sources.v2, sources.v3.hooks);
|
||||
const hookConfigured = contract !== undefined;
|
||||
const hookScriptExists = contract !== undefined
|
||||
&& await kiroCliRuntimeFilesExist(contract);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: contracts.at(-1)?.configPath ?? v3Path,
|
||||
configPath: sources.paths.v3Path,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export interface KiroIdeRuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
export function sameKiroIdePath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.resolve(left);
|
||||
const normalizedRight = path.resolve(right);
|
||||
return process.platform === 'win32'
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
export function sameKiroIdeAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function windowsPowerShellPath(): string {
|
||||
const configuredRoot = process.env.SystemRoot;
|
||||
const systemRoot = configuredRoot
|
||||
&& path.win32.isAbsolute(configuredRoot)
|
||||
&& !/["%\r\n]/.test(configuredRoot)
|
||||
? configuredRoot
|
||||
: 'C:\\Windows';
|
||||
return path.win32.join(
|
||||
systemRoot,
|
||||
'System32',
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe',
|
||||
);
|
||||
}
|
||||
|
||||
function windowsCommand(scriptPath: string): string {
|
||||
const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
|
||||
const encoded = Buffer.from(source, 'utf16le').toString('base64');
|
||||
return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
|
||||
}
|
||||
|
||||
export function buildKiroIdeCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Kiro IDE hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32'
|
||||
? windowsCommand(scriptPath)
|
||||
: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
}
|
||||
|
||||
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
const apostrophe = `'"'"'`;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(apostrophe, index)) {
|
||||
value += "'";
|
||||
index += apostrophe.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePosixCommand(command: string): readonly [string, string] | undefined {
|
||||
const executable = readPosixArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPosixArgument(command, executable.next + 1);
|
||||
return script && script.next === command.length ? [executable.value, script.value] : undefined;
|
||||
}
|
||||
|
||||
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] !== "'") {
|
||||
value += command[index];
|
||||
continue;
|
||||
}
|
||||
if (command[index + 1] === "'") {
|
||||
value += "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return { value, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePowerShellSource(source: string): readonly [string, string] | undefined {
|
||||
if (!source.startsWith('& ')) return undefined;
|
||||
const executable = readPowerShellArgument(source, 2);
|
||||
if (!executable || source[executable.next] !== ' ') return undefined;
|
||||
const script = readPowerShellArgument(source, executable.next + 1);
|
||||
return script && source.slice(script.next) === '; exit $LASTEXITCODE'
|
||||
? [executable.value, script.value]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseWindowsCommand(command: string): readonly [string, string] | undefined {
|
||||
const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command);
|
||||
if (!match
|
||||
|| !path.win32.isAbsolute(match[1])
|
||||
|| path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined;
|
||||
const buffer = Buffer.from(match[2], 'base64');
|
||||
if (buffer.length % 2 !== 0 || buffer.toString('base64') !== match[2]) return undefined;
|
||||
return parsePowerShellSource(buffer.toString('utf16le'));
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath).toLowerCase();
|
||||
return basename === 'node' || basename === 'node.exe';
|
||||
}
|
||||
|
||||
export function kiroIdeRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): KiroIdeRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parseWindowsCommand(command)
|
||||
: parsePosixCommand(command);
|
||||
if (!parsed
|
||||
|| !path.isAbsolute(parsed[0])
|
||||
|| !isNodeExecutable(parsed[0])
|
||||
|| !path.isAbsolute(parsed[1])
|
||||
|| path.basename(parsed[1]) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(parsed[1]);
|
||||
if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId && agentId !== '.' && agentId !== '..'
|
||||
? { agentId, scriptPath: parsed[1] }
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildKiroIdeCommand,
|
||||
kiroIdeRuntimeReference,
|
||||
sameKiroIdeAgentId,
|
||||
sameKiroIdePath,
|
||||
type KiroIdeRuntimeReference,
|
||||
} from './kiroide-command.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'kiroide';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const CONFIG_FILE = 'elydora-audit.json';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const LEGACY_CONFIG_FILE = 'elydora-audit.kiro.hook';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
|
||||
const GUARD_NAME = 'elydora-guard';
|
||||
const AUDIT_NAME = 'elydora-audit';
|
||||
const GUARD_DESCRIPTION = 'Block tool use when the Elydora agent is frozen';
|
||||
const AUDIT_DESCRIPTION = 'Record tool use in the Elydora audit trail';
|
||||
const KIRO_TRIGGERS = new Set([
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PreTaskExec',
|
||||
'PostTaskExec',
|
||||
'UserPromptSubmit',
|
||||
'PostFileCreate',
|
||||
'PostFileSave',
|
||||
'PostFileDelete',
|
||||
]);
|
||||
|
||||
export interface KiroIdeAction extends JsonObject {
|
||||
readonly type: 'command' | 'agent';
|
||||
readonly command?: string;
|
||||
readonly prompt?: string;
|
||||
}
|
||||
|
||||
export interface KiroIdeHook extends JsonObject {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly trigger: string;
|
||||
readonly matcher?: string;
|
||||
readonly action: KiroIdeAction;
|
||||
readonly timeout?: number;
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface KiroIdeDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: readonly KiroIdeHook[];
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedKiroIdeDocument {
|
||||
readonly document: KiroIdeDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface KiroIdeRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface KiroIdePaths {
|
||||
readonly workspaceRoot: string;
|
||||
readonly kiroDirectory: string;
|
||||
readonly hooksDirectory: string;
|
||||
readonly configPath: string;
|
||||
readonly legacyConfigPath: string;
|
||||
}
|
||||
|
||||
export function resolveKiroIdePaths(
|
||||
workspaceRoot = process.cwd(),
|
||||
homeDirectory = os.homedir(),
|
||||
): KiroIdePaths {
|
||||
const resolvedWorkspace = path.resolve(workspaceRoot);
|
||||
const kiroDirectory = path.join(resolvedWorkspace, '.kiro');
|
||||
const hooksDirectory = path.join(kiroDirectory, 'hooks');
|
||||
return {
|
||||
workspaceRoot: resolvedWorkspace,
|
||||
kiroDirectory,
|
||||
hooksDirectory,
|
||||
configPath: path.join(hooksDirectory, CONFIG_FILE),
|
||||
legacyConfigPath: path.join(homeDirectory, '.kiro', 'hooks', LEGACY_CONFIG_FILE),
|
||||
};
|
||||
}
|
||||
|
||||
function validateAction(value: unknown, label: string): KiroIdeAction {
|
||||
if (!isObject(value)) throw new Error(`${label} action must be an object`);
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || !value.command) {
|
||||
throw new Error(`${label} command action requires a non-empty command`);
|
||||
}
|
||||
return value as KiroIdeAction;
|
||||
}
|
||||
if (value.type === 'agent') {
|
||||
if (typeof value.prompt !== 'string' || !value.prompt) {
|
||||
throw new Error(`${label} agent action requires a non-empty prompt`);
|
||||
}
|
||||
return value as KiroIdeAction;
|
||||
}
|
||||
throw new Error(`${label} action has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
|
||||
function validateHook(value: unknown, index: number, documentLabel: string): KiroIdeHook {
|
||||
const label = `${documentLabel} hooks[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (typeof value.name !== 'string' || !value.name) throw new Error(`${label} requires a name`);
|
||||
if (typeof value.trigger !== 'string' || !KIRO_TRIGGERS.has(value.trigger)) {
|
||||
throw new Error(`${label} has unsupported trigger "${String(value.trigger)}"`);
|
||||
}
|
||||
if (value.description !== undefined && typeof value.description !== 'string') {
|
||||
throw new Error(`${label} description must be a string`);
|
||||
}
|
||||
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
if (value.timeout !== undefined
|
||||
&& (!Number.isSafeInteger(value.timeout) || (value.timeout as number) < 0)) {
|
||||
throw new Error(`${label} timeout must be a non-negative integer`);
|
||||
}
|
||||
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
||||
throw new Error(`${label} enabled must be a boolean`);
|
||||
}
|
||||
return { ...value, action: validateAction(value.action, label) } as KiroIdeHook;
|
||||
}
|
||||
|
||||
export function parseKiroIdeDocument(
|
||||
filePath: string,
|
||||
raw: string,
|
||||
documentLabel = 'Kiro IDE hooks',
|
||||
): KiroIdeDocument {
|
||||
const root = parseStrictJsonObject(raw, `${documentLabel} at ${filePath}`);
|
||||
if (root.version !== 'v1') throw new Error(`${documentLabel} version must be "v1": ${filePath}`);
|
||||
if (!Array.isArray(root.hooks)) throw new Error(`${documentLabel} field "hooks" must be an array: ${filePath}`);
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
root,
|
||||
hooks: root.hooks.map((hook, index) => validateHook(hook, index, documentLabel)),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createKiroIdeDocument(filePath: string): KiroIdeDocument {
|
||||
return { exists: false, filePath, root: {}, hooks: [] };
|
||||
}
|
||||
|
||||
function exactKeys(value: JsonObject, keys: readonly string[]): boolean {
|
||||
return Object.keys(value).sort().join('|') === [...keys].sort().join('|');
|
||||
}
|
||||
|
||||
function managedSpecification(name: string): {
|
||||
readonly description: string;
|
||||
readonly trigger: 'PreToolUse' | 'PostToolUse';
|
||||
readonly scriptName: string;
|
||||
} | undefined {
|
||||
if (name === GUARD_NAME) {
|
||||
return { description: GUARD_DESCRIPTION, trigger: 'PreToolUse', scriptName: GUARD_SCRIPT };
|
||||
}
|
||||
if (name === AUDIT_NAME) {
|
||||
return { description: AUDIT_DESCRIPTION, trigger: 'PostToolUse', scriptName: AUDIT_SCRIPT };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function managedReference(hook: KiroIdeHook) {
|
||||
const specification = managedSpecification(hook.name);
|
||||
const owned = ownedReference(hook);
|
||||
if (!specification
|
||||
|| !owned
|
||||
|| !exactKeys(hook, ['name', 'description', 'trigger', 'matcher', 'action', 'timeout', 'enabled'])
|
||||
|| hook.description !== specification.description
|
||||
|| hook.trigger !== specification.trigger
|
||||
|| hook.matcher !== '.*'
|
||||
|| hook.timeout !== HOOK_TIMEOUT_SECONDS
|
||||
|| hook.enabled !== true
|
||||
|| !exactKeys(hook.action, ['type', 'command'])) return undefined;
|
||||
return owned;
|
||||
}
|
||||
|
||||
function ownedReference(hook: KiroIdeHook) {
|
||||
const specification = managedSpecification(hook.name);
|
||||
if (!specification
|
||||
|| hook.action.type !== 'command'
|
||||
|| typeof hook.action.command !== 'string') return undefined;
|
||||
return kiroIdeRuntimeReference(hook.action.command, specification.scriptName);
|
||||
}
|
||||
|
||||
export function requireAvailableKiroIdeHooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
productLabel = 'Kiro IDE',
|
||||
): void {
|
||||
for (const hook of hooks) {
|
||||
if (managedSpecification(hook.name) && !ownedReference(hook)) {
|
||||
throw new Error(`${productLabel} hook name "${hook.name}" conflicts with the Elydora contract`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKiroIdeHook(
|
||||
name: typeof GUARD_NAME | typeof AUDIT_NAME,
|
||||
scriptPath: string,
|
||||
): KiroIdeHook {
|
||||
const specification = managedSpecification(name)!;
|
||||
return {
|
||||
name,
|
||||
description: specification.description,
|
||||
trigger: specification.trigger,
|
||||
matcher: '.*',
|
||||
action: { type: 'command', command: buildKiroIdeCommand(scriptPath) },
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function withoutManagedKiroIdeHooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
agentId?: string,
|
||||
): KiroIdeHook[] {
|
||||
return hooks.filter((hook) => {
|
||||
const reference = ownedReference(hook);
|
||||
return !reference || (agentId !== undefined && !sameKiroIdeAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
function entirelyManaged(document: KiroIdeDocument): boolean {
|
||||
return document.exists
|
||||
&& exactKeys(document.root, ['version', 'hooks'])
|
||||
&& document.hooks.length > 0
|
||||
&& document.hooks.every((hook) => managedReference(hook) !== undefined);
|
||||
}
|
||||
|
||||
export function renderKiroIdeDocument(
|
||||
document: KiroIdeDocument,
|
||||
hooks: readonly KiroIdeHook[],
|
||||
): RenderedKiroIdeDocument {
|
||||
if (!document.exists && hooks.length === 0) return { document, changed: false };
|
||||
if (document.exists
|
||||
&& hooks.length === document.hooks.length
|
||||
&& hooks.every((hook, index) => hook === document.hooks[index])) {
|
||||
return { document, changed: false };
|
||||
}
|
||||
if (hooks.length === 0 && entirelyManaged(document)) return { document, changed: true };
|
||||
const next = `${JSON.stringify({ ...document.root, version: 'v1', hooks }, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
export function kiroIdeRuntimeContracts(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
): KiroIdeRuntimeContract[] {
|
||||
const guards = new Map<string, KiroIdeRuntimeReference[]>();
|
||||
const audits = new Map<string, KiroIdeRuntimeReference[]>();
|
||||
for (const hook of hooks) {
|
||||
if (!managedSpecification(hook.name)) continue;
|
||||
const reference = managedReference(hook);
|
||||
if (!reference) return [];
|
||||
const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId;
|
||||
const target = hook.name === GUARD_NAME ? guards : audits;
|
||||
const entries = target.get(key) ?? [];
|
||||
entries.push(reference);
|
||||
target.set(key, entries);
|
||||
}
|
||||
const contracts: KiroIdeRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const audit = audits.get(key);
|
||||
if (guard.length !== 1 || audit?.length !== 1 || !guard[0] || !audit[0]) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: audit[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
|
||||
function legacyReference(command: unknown, scriptName: string) {
|
||||
if (typeof command !== 'string') return undefined;
|
||||
const match = /^node "([^"\r\n]+)"$/.exec(command);
|
||||
if (!match || !path.isAbsolute(match[1]) || path.basename(match[1]) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(match[1]);
|
||||
if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
return { agentId: path.basename(agentDirectory), scriptPath: match[1] };
|
||||
}
|
||||
|
||||
export function legacyKiroIdeRuntimeContract(raw: string, filePath: string): KiroIdeRuntimeContract | undefined {
|
||||
const root = parseStrictJsonObject(raw, `legacy Kiro IDE hook at ${filePath}`);
|
||||
if (!exactKeys(root, ['name', 'description', 'version', 'hooks'])
|
||||
|| root.name !== 'Elydora Audit'
|
||||
|| root.description !== 'Sends tool-use events to the Elydora tamper-evident audit platform'
|
||||
|| root.version !== '1.0.0'
|
||||
|| !isObject(root.hooks)
|
||||
|| !exactKeys(root.hooks, ['pre_tool_use', 'post_tool_use'])) return undefined;
|
||||
const guardHook = root.hooks.pre_tool_use;
|
||||
const auditHook = root.hooks.post_tool_use;
|
||||
if (!isObject(guardHook)
|
||||
|| !isObject(auditHook)
|
||||
|| !exactKeys(guardHook, ['command', 'timeout_ms'])
|
||||
|| !exactKeys(auditHook, ['command', 'timeout_ms'])
|
||||
|| guardHook.timeout_ms !== 5000
|
||||
|| auditHook.timeout_ms !== 5000) return undefined;
|
||||
const guard = legacyReference(guardHook.command, GUARD_SCRIPT);
|
||||
const audit = legacyReference(auditHook.command, AUDIT_SCRIPT);
|
||||
if (!guard || !audit || !sameKiroIdeAgentId(guard.agentId, audit.agentId)) return undefined;
|
||||
return { agentId: guard.agentId, guardPath: guard.scriptPath, auditPath: audit.scriptPath };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import { sameKiroIdeAgentId } from './kiroide-command.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type KiroIdePaths,
|
||||
type RenderedKiroIdeDocument,
|
||||
} from './kiroide-contract.js';
|
||||
import {
|
||||
type KiroIdeSources,
|
||||
type LegacyKiroIdeDocument,
|
||||
requirePhysicalLegacyDirectory,
|
||||
} from './kiroide-io.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Kiro IDE';
|
||||
const DIRECTORY_LABEL = 'Kiro IDE hooks directory';
|
||||
|
||||
export type KiroIdeRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedKiroIdeInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
function hookLocation(paths: KiroIdePaths) {
|
||||
return [{ directoryLabel: DIRECTORY_LABEL, filePath: paths.configPath }];
|
||||
}
|
||||
|
||||
function removesLegacy(legacy: LegacyKiroIdeDocument, agentId?: string): boolean {
|
||||
return legacy.contract !== undefined
|
||||
&& (agentId === undefined || sameKiroIdeAgentId(legacy.contract.agentId, agentId));
|
||||
}
|
||||
|
||||
async function prepareLegacyRemoval(
|
||||
legacy: LegacyKiroIdeDocument,
|
||||
agentId?: string,
|
||||
): Promise<ManagedFileChange | undefined> {
|
||||
if (!removesLegacy(legacy, agentId)) return undefined;
|
||||
if (legacy.raw === undefined) throw new Error(`Legacy Kiro IDE hook source is missing: ${legacy.filePath}`);
|
||||
await requirePhysicalLegacyDirectory(legacy);
|
||||
return prepareManagedFileChange({
|
||||
filePath: legacy.filePath,
|
||||
label: 'legacy Kiro IDE hook',
|
||||
next: undefined,
|
||||
mode: 0o600,
|
||||
expectedSource: legacy.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function preflightKiroIdeInstallation(
|
||||
config: InstallConfig,
|
||||
paths: KiroIdePaths,
|
||||
): Promise<KiroIdeRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocation(paths),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareKiroIdeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
): Promise<PreparedKiroIdeInstallation> {
|
||||
if (rendered.next === undefined) throw new Error('Kiro IDE installation requires a hook document');
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'Kiro IDE hooks',
|
||||
filePath: sources.paths.configPath,
|
||||
expectedSource: sources.document.raw,
|
||||
source: rendered.next,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
const legacyChange = await prepareLegacyRemoval(sources.legacy, config.agentId);
|
||||
if (!legacyChange) return prepared;
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
directories: [
|
||||
...prepared.transaction.directories,
|
||||
{ path: path.dirname(sources.legacy.filePath), label: 'legacy Kiro IDE hooks directory' },
|
||||
],
|
||||
changes: [...prepared.transaction.changes, legacyChange],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitKiroIdeInstallation(
|
||||
prepared: PreparedKiroIdeInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareKiroIdeUninstall(
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
agentId?: string,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const changes: ManagedFileChange[] = [];
|
||||
const directories = [];
|
||||
if (rendered.changed) {
|
||||
const configChange = await prepareManagedFileChange({
|
||||
filePath: sources.document.filePath,
|
||||
label: 'Kiro IDE hooks',
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: sources.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (configChange) {
|
||||
changes.push(configChange);
|
||||
directories.push({ path: sources.paths.hooksDirectory, label: DIRECTORY_LABEL });
|
||||
}
|
||||
}
|
||||
const legacyChange = await prepareLegacyRemoval(sources.legacy, agentId);
|
||||
if (legacyChange) {
|
||||
changes.push(legacyChange);
|
||||
directories.push({
|
||||
path: path.dirname(sources.legacy.filePath),
|
||||
label: 'legacy Kiro IDE hooks directory',
|
||||
});
|
||||
}
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitKiroIdeUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createKiroIdeDocument,
|
||||
legacyKiroIdeRuntimeContract,
|
||||
parseKiroIdeDocument,
|
||||
resolveKiroIdePaths,
|
||||
type KiroIdeDocument,
|
||||
type KiroIdePaths,
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
|
||||
export interface LegacyKiroIdeDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw?: string;
|
||||
readonly contract?: KiroIdeRuntimeContract;
|
||||
}
|
||||
|
||||
export interface KiroIdeSources {
|
||||
readonly paths: KiroIdePaths;
|
||||
readonly document: KiroIdeDocument;
|
||||
readonly legacy: LegacyKiroIdeDocument;
|
||||
}
|
||||
|
||||
async function inspectWorkspace(paths: KiroIdePaths): Promise<void> {
|
||||
if (!await inspectPhysicalDirectory(paths.workspaceRoot, 'Kiro IDE workspace')) {
|
||||
throw new Error(`Kiro IDE workspace is missing: ${paths.workspaceRoot}`);
|
||||
}
|
||||
await inspectPhysicalDirectory(paths.kiroDirectory, 'Kiro IDE configuration directory');
|
||||
await inspectPhysicalDirectory(paths.hooksDirectory, 'Kiro IDE hooks directory');
|
||||
}
|
||||
|
||||
async function readDocument(paths: KiroIdePaths): Promise<KiroIdeDocument> {
|
||||
await inspectWorkspace(paths);
|
||||
const snapshot = await readPhysicalFile(paths.configPath, 'Kiro IDE hooks');
|
||||
return snapshot
|
||||
? parseKiroIdeDocument(paths.configPath, snapshot.contents)
|
||||
: createKiroIdeDocument(paths.configPath);
|
||||
}
|
||||
|
||||
async function readLegacyDocument(paths: KiroIdePaths): Promise<LegacyKiroIdeDocument> {
|
||||
const snapshot = await readPhysicalFile(paths.legacyConfigPath, 'legacy Kiro IDE hook');
|
||||
if (!snapshot) return { exists: false, filePath: paths.legacyConfigPath };
|
||||
return {
|
||||
exists: true,
|
||||
filePath: paths.legacyConfigPath,
|
||||
raw: snapshot.contents,
|
||||
contract: legacyKiroIdeRuntimeContract(snapshot.contents, paths.legacyConfigPath),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readKiroIdeSources(): Promise<KiroIdeSources> {
|
||||
const paths = resolveKiroIdePaths();
|
||||
const [document, legacy] = await Promise.all([
|
||||
readDocument(paths),
|
||||
readLegacyDocument(paths),
|
||||
]);
|
||||
return { paths, document, legacy };
|
||||
}
|
||||
|
||||
export async function requirePhysicalLegacyDirectory(
|
||||
legacy: LegacyKiroIdeDocument,
|
||||
): Promise<void> {
|
||||
if (!legacy.exists) return;
|
||||
const hooksDirectory = path.dirname(legacy.filePath);
|
||||
const kiroDirectory = path.dirname(hooksDirectory);
|
||||
if (!await inspectPhysicalDirectory(kiroDirectory, 'legacy Kiro IDE configuration directory')) {
|
||||
throw new Error(`Legacy Kiro IDE configuration directory is missing: ${kiroDirectory}`);
|
||||
}
|
||||
if (!await inspectPhysicalDirectory(hooksDirectory, 'legacy Kiro IDE hooks directory')) {
|
||||
throw new Error(`Legacy Kiro IDE hooks directory is missing: ${hooksDirectory}`);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,76 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildKiroIdeHook,
|
||||
kiroIdeRuntimeContracts,
|
||||
renderKiroIdeDocument,
|
||||
requireAvailableKiroIdeHooks,
|
||||
withoutManagedKiroIdeHooks,
|
||||
} from './kiroide-contract.js';
|
||||
import {
|
||||
commitKiroIdeInstallation,
|
||||
commitKiroIdeUninstall,
|
||||
preflightKiroIdeInstallation,
|
||||
prepareKiroIdeInstallation,
|
||||
prepareKiroIdeUninstall,
|
||||
} from './kiroide-installation.js';
|
||||
import { readKiroIdeSources } from './kiroide-io.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'kiroide';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
// .kiro/hooks is project-relative, but for global install use home dir
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
export const kiroidePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
managesRuntime: true,
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
const hookConfig = {
|
||||
name: 'Elydora Audit',
|
||||
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
||||
version: '1.0.0',
|
||||
hooks: {
|
||||
pre_tool_use: {
|
||||
command: `node "${config.guardScriptPath}"`,
|
||||
timeout_ms: 5000,
|
||||
},
|
||||
post_tool_use: {
|
||||
command: `node "${config.hookScriptPath}"`,
|
||||
timeout_ms: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8');
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readKiroIdeSources();
|
||||
requireAvailableKiroIdeHooks(sources.document.hooks);
|
||||
await preflightKiroIdeInstallation(config, sources.paths);
|
||||
},
|
||||
|
||||
async uninstall(_agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const sources = await readKiroIdeSources();
|
||||
requireAvailableKiroIdeHooks(sources.document.hooks);
|
||||
const paths = await preflightKiroIdeInstallation(config, sources.paths);
|
||||
const hooks = withoutManagedKiroIdeHooks(sources.document.hooks);
|
||||
const rendered = renderKiroIdeDocument(sources.document, [
|
||||
...hooks,
|
||||
buildKiroIdeHook('elydora-guard', paths.guardPath),
|
||||
buildKiroIdeHook('elydora-audit', paths.auditPath),
|
||||
]);
|
||||
const prepared = await prepareKiroIdeInstallation(config, sources, rendered);
|
||||
await commitKiroIdeInstallation(prepared);
|
||||
console.log(` Kiro IDE workspace hooks: ${sources.paths.configPath}`);
|
||||
console.log(' Kiro IDE verification: confirm Elydora hooks in the Agent Hooks panel.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readKiroIdeSources();
|
||||
const rendered = renderKiroIdeDocument(
|
||||
sources.document,
|
||||
withoutManagedKiroIdeHooks(sources.document.hooks, agentId),
|
||||
);
|
||||
const prepared = await prepareKiroIdeUninstall(sources, rendered, agentId);
|
||||
await commitKiroIdeUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use);
|
||||
|
||||
// Extract hook script path from post_tool_use command
|
||||
if (hookConfigured && config.hooks.post_tool_use) {
|
||||
const cmd = config.hooks.post_tool_use.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readKiroIdeSources();
|
||||
requireAvailableKiroIdeHooks(sources.document.hooks);
|
||||
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
&& (await Promise.all(contracts.map((contract) => (
|
||||
managedRuntimeFilesExist(contract, AGENT_KEY)
|
||||
)))).every(Boolean);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.paths.configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export interface LettaRuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly executablePath?: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
export function sameLettaPath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.resolve(left);
|
||||
const normalizedRight = path.resolve(right);
|
||||
return process.platform === 'win32'
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
export function sameLettaAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32'
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function buildLettaCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Letta Code hook commands require absolute executable and script paths');
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
|
||||
}
|
||||
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
}
|
||||
|
||||
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
const apostrophe = `'"'"'`;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(apostrophe, index)) {
|
||||
value += "'";
|
||||
index += apostrophe.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePosixCommand(command: string): readonly [string, string] | undefined {
|
||||
const executable = readPosixArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPosixArgument(command, executable.next + 1);
|
||||
if (!script || script.next !== command.length) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] !== "'") {
|
||||
value += command[index];
|
||||
continue;
|
||||
}
|
||||
if (command[index + 1] === "'") {
|
||||
value += "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return { value, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePowerShellCommand(command: string): readonly [string, string] | undefined {
|
||||
if (!command.startsWith('& ')) return undefined;
|
||||
const executable = readPowerShellArgument(command, 2);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPowerShellArgument(command, executable.next + 1);
|
||||
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function legacyScriptPath(command: string): string | undefined {
|
||||
if (!command.startsWith('node "') || !command.endsWith('"')) return undefined;
|
||||
const scriptPath = command.slice(6, -1);
|
||||
return scriptPath.includes('"') ? undefined : scriptPath;
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath).toLowerCase();
|
||||
return basename === 'node' || basename === 'node.exe';
|
||||
}
|
||||
|
||||
function runtimeReference(
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
executablePath?: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!sameLettaPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
if (!agentId || agentId === '.' || agentId === '..') return undefined;
|
||||
return { agentId, executablePath, scriptPath };
|
||||
}
|
||||
|
||||
export function lettaRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellCommand(command)
|
||||
: parsePosixCommand(command);
|
||||
if (parsed
|
||||
&& path.isAbsolute(parsed[0])
|
||||
&& isNodeExecutable(parsed[0])) {
|
||||
return runtimeReference(parsed[1], scriptName, parsed[0]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function lettaLegacyRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const legacy = legacyScriptPath(command);
|
||||
return legacy ? runtimeReference(legacy, scriptName) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
applyEdits,
|
||||
modify,
|
||||
type FormattingOptions,
|
||||
type JSONPath,
|
||||
} from 'jsonc-parser';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import {
|
||||
MANAGED_EVENTS,
|
||||
managedLettaRemovals,
|
||||
readLettaHooks,
|
||||
type LettaGroup,
|
||||
type LettaHooks,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export type LettaDocumentKind = 'global' | 'project' | 'project-local';
|
||||
|
||||
export interface LettaDocument {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: LettaHooks;
|
||||
readonly hasHooksContainer: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedLettaDocument {
|
||||
readonly document: LettaDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export function lettaSourceLabel(kind: LettaDocumentKind): string {
|
||||
switch (kind) {
|
||||
case 'project': return 'Letta Code project settings';
|
||||
case 'project-local': return 'Letta Code project-local settings';
|
||||
default: return 'Letta Code global settings';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLettaDocument(options: DocumentOptions): LettaDocument {
|
||||
const label = `${lettaSourceLabel(options.kind)} at ${options.filePath}`;
|
||||
const root = parseStrictJsonObject(options.raw, label);
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readLettaHooks(root.hooks),
|
||||
hasHooksContainer: Object.hasOwn(root, 'hooks'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLettaDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind,
|
||||
exists: false,
|
||||
filePath,
|
||||
raw: '{}\n',
|
||||
});
|
||||
}
|
||||
|
||||
function formatting(raw: string): FormattingOptions {
|
||||
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
||||
const insertSpaces = !indentation?.includes('\t');
|
||||
return {
|
||||
eol: raw.includes('\r\n') ? '\r\n' : '\n',
|
||||
insertSpaces,
|
||||
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function change(
|
||||
raw: string,
|
||||
jsonPath: JSONPath,
|
||||
value: unknown,
|
||||
isArrayInsertion = false,
|
||||
): string {
|
||||
return applyEdits(raw, modify(raw, jsonPath, value, {
|
||||
formattingOptions: formatting(raw),
|
||||
isArrayInsertion,
|
||||
}));
|
||||
}
|
||||
|
||||
function currentDocument(document: LettaDocument, raw: string): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind: document.kind,
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
raw,
|
||||
snapshot: document.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function groupsForEvent(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const groups = hooks[event];
|
||||
return Array.isArray(groups) ? groups : [];
|
||||
}
|
||||
|
||||
function removeManagedEntries(
|
||||
document: LettaDocument,
|
||||
initial: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
let raw = initial;
|
||||
const removals = managedLettaRemovals(document.hooks, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = change(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||
(left, right) => right - left,
|
||||
)) {
|
||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if (groupsForEvent(current.hooks, event).length === 0) {
|
||||
raw = change(raw, ['hooks', event], undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||
raw = change(raw, ['hooks'], undefined);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: LettaDocument,
|
||||
raw: string,
|
||||
event: ManagedLettaEvent,
|
||||
group: LettaGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
const groups = groupsForEvent(current.hooks, event);
|
||||
if (groups.length > 0) {
|
||||
return change(raw, ['hooks', event, groups.length], group, true);
|
||||
}
|
||||
return change(raw, ['hooks', event], [group]);
|
||||
}
|
||||
|
||||
export function renderLettaDocument(
|
||||
document: LettaDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ManagedLettaEvent, LettaGroup>,
|
||||
): RenderedLettaDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (additions.size === 0 && Object.keys(current.root).length === 0) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
export function lettaDocumentLabel(document: LettaDocument): string {
|
||||
return lettaSourceLabel(document.kind);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
buildLettaCommand,
|
||||
lettaLegacyRuntimeReference,
|
||||
lettaRuntimeReference,
|
||||
sameLettaAgentId,
|
||||
sameLettaPath,
|
||||
type LettaRuntimeReference,
|
||||
} from './letta-command.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'letta';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_MS = 10_000;
|
||||
export const LETTA_AUDIT_OPTIONS = {
|
||||
nativePayload: true,
|
||||
failClosed: true,
|
||||
} as const;
|
||||
export const MANAGED_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
] as const;
|
||||
|
||||
const TOOL_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest',
|
||||
]);
|
||||
|
||||
const SIMPLE_EVENTS = new Set([
|
||||
'UserPromptSubmit',
|
||||
'Notification',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
]);
|
||||
|
||||
const KNOWN_EVENTS = new Set([...TOOL_EVENTS, ...SIMPLE_EVENTS]);
|
||||
|
||||
export type ManagedLettaEvent = (typeof MANAGED_EVENTS)[number];
|
||||
|
||||
export interface LettaHandler extends JsonObject {
|
||||
readonly type: 'command' | 'prompt';
|
||||
readonly command?: string;
|
||||
readonly prompt?: string;
|
||||
readonly model?: string;
|
||||
readonly timeout?: number;
|
||||
readonly quiet?: boolean;
|
||||
}
|
||||
|
||||
export interface LettaGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: LettaHandler[];
|
||||
}
|
||||
|
||||
export interface LettaHooks extends JsonObject {
|
||||
readonly disabled?: boolean;
|
||||
readonly [event: string]: LettaGroup[] | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface LettaRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedLettaRemoval {
|
||||
readonly event: ManagedLettaEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: readonly number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, label: string): LettaHandler {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.type !== 'command' && value.type !== 'prompt') {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
validateTimeout(value.timeout, label);
|
||||
if (value.quiet !== undefined && typeof value.quiet !== 'boolean') {
|
||||
throw new Error(`${label} quiet must be a boolean`);
|
||||
}
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
} else {
|
||||
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty prompt`);
|
||||
}
|
||||
if (value.model !== undefined && typeof value.model !== 'string') {
|
||||
throw new Error(`${label} model must be a string`);
|
||||
}
|
||||
}
|
||||
return value as LettaHandler;
|
||||
}
|
||||
|
||||
function validateGroup(
|
||||
value: unknown,
|
||||
event: string,
|
||||
index: number,
|
||||
): LettaGroup {
|
||||
const label = `Letta Code settings group hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (TOOL_EVENTS.has(event)) {
|
||||
if (typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
} else if (value.matcher !== undefined) {
|
||||
throw new Error(`${label} matcher is unsupported for ${event}`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
|
||||
)),
|
||||
} as LettaGroup;
|
||||
}
|
||||
|
||||
export function readLettaHooks(value: unknown): LettaHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Letta Code settings field "hooks" must be an object');
|
||||
if (value.disabled !== undefined && typeof value.disabled !== 'boolean') {
|
||||
throw new Error('Letta Code settings field "hooks.disabled" must be a boolean');
|
||||
}
|
||||
const hooks: JsonObject = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (event === 'disabled') {
|
||||
hooks.disabled = groups;
|
||||
continue;
|
||||
}
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
hooks[event] = groups;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Letta Code settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks as LettaHooks;
|
||||
}
|
||||
|
||||
export function buildLettaGroup(scriptPath: string): LettaGroup {
|
||||
return {
|
||||
matcher: '*',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: buildLettaCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactGroup(group: LettaGroup): boolean {
|
||||
return Object.keys(group).sort().join('|') === 'hooks|matcher'
|
||||
&& group.matcher === '*';
|
||||
}
|
||||
|
||||
function exactCurrentReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function exactLegacyReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|type'
|
||||
&& handler.type === 'command'
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaLegacyRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return exactCurrentReference(handler, scriptName)
|
||||
?? exactLegacyReference(handler, scriptName);
|
||||
}
|
||||
|
||||
const EVENT_CONTRACTS = [
|
||||
['PreToolUse', GUARD_SCRIPT],
|
||||
['PostToolUse', AUDIT_SCRIPT],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT],
|
||||
] as const;
|
||||
|
||||
function eventGroups(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const value = hooks[event];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function managedLettaRemovals(
|
||||
hooks: LettaHooks,
|
||||
agentId?: string,
|
||||
): ManagedLettaRemoval[] {
|
||||
const removals: ManagedLettaRemoval[] = [];
|
||||
for (const [event, scriptName] of EVENT_CONTRACTS) {
|
||||
eventGroups(hooks, event).forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const reference = managedReference(handler, scriptName);
|
||||
return reference && (agentId === undefined || sameLettaAgentId(reference.agentId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactGroup(group) && handlerIndexes.length === group.hooks.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly LettaGroup[],
|
||||
scriptName: string,
|
||||
): Map<string, LettaRuntimeReference[]> {
|
||||
const references = new Map<string, LettaRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = exactCurrentReference(handler, scriptName);
|
||||
if (!reference
|
||||
|| !reference.executablePath
|
||||
|| !sameLettaPath(reference.executablePath, process.execPath)) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function lettaRuntimeContracts(hooks: LettaHooks): LettaRuntimeContract[] {
|
||||
const guards = referencesForEvent(eventGroups(hooks, 'PreToolUse'), GUARD_SCRIPT);
|
||||
const posts = referencesForEvent(eventGroups(hooks, 'PostToolUse'), AUDIT_SCRIPT);
|
||||
const failures = referencesForEvent(
|
||||
eventGroups(hooks, 'PostToolUseFailure'),
|
||||
AUDIT_SCRIPT,
|
||||
);
|
||||
const contracts: LettaRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const post = posts.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
||||
if (!sameLettaPath(post[0].scriptPath, failure[0].scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: post[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
} from './letta-contract.js';
|
||||
import { sameLettaPath } from './letta-command.js';
|
||||
import {
|
||||
lettaDocumentLabel,
|
||||
type RenderedLettaDocument,
|
||||
} from './letta-config.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
import {
|
||||
requireLettaHooksEnabled,
|
||||
type LettaSources,
|
||||
} from './letta-sources.js';
|
||||
|
||||
const DISPLAY_NAME = 'Letta Code';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Letta Code global configuration directory';
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export type LettaRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedLettaInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
export async function preflightLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
): Promise<LettaRuntimePaths> {
|
||||
requireLettaHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
filePath: sources.global.filePath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function readOnlyPreconditions(sources: LettaSources, changedPath?: string) {
|
||||
return sources.preconditions.filter((condition) => (
|
||||
changedPath === undefined || !sameLettaPath(condition.filePath, changedPath)
|
||||
));
|
||||
}
|
||||
|
||||
export async function prepareLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedLettaInstallation> {
|
||||
await preflightLettaInstallation(config, sources);
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaInstallation(
|
||||
prepared: PreparedLettaInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareLettaUninstall(
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const change = rendered.changed
|
||||
? await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: SETTINGS_DIRECTORY_LABEL,
|
||||
}],
|
||||
changes: change ? [change] : [],
|
||||
preconditions: readOnlyPreconditions(
|
||||
sources,
|
||||
rendered.changed ? rendered.document.filePath : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createLettaDocument,
|
||||
lettaDocumentLabel,
|
||||
lettaSourceLabel,
|
||||
parseLettaDocument,
|
||||
type LettaDocument,
|
||||
type LettaDocumentKind,
|
||||
} from './letta-config.js';
|
||||
import {
|
||||
inspectPhysicalDirectory,
|
||||
readPhysicalFile,
|
||||
type FileSnapshot,
|
||||
} from './managed-files.js';
|
||||
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export interface LettaSourcePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface LettaDisableControl {
|
||||
readonly disabled: boolean;
|
||||
readonly source?: LettaDocument;
|
||||
}
|
||||
|
||||
export interface LettaSources {
|
||||
readonly homeDirectory: string;
|
||||
readonly global: LettaDocument;
|
||||
readonly project: LettaDocument;
|
||||
readonly projectLocal: LettaDocument;
|
||||
readonly projectActive: boolean;
|
||||
readonly disableControl: LettaDisableControl;
|
||||
readonly preconditions: readonly LettaSourcePrecondition[];
|
||||
}
|
||||
|
||||
function comparisonPath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
async function canonicalPath(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return path.resolve(filePath);
|
||||
throw new Error(`Resolve Letta Code path at ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHomeDirectory(): string {
|
||||
return path.resolve(process.env.HOME || os.homedir());
|
||||
}
|
||||
|
||||
async function readDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): Promise<LettaDocument> {
|
||||
const snapshot = await readPhysicalFile(filePath, lettaSourceLabel(kind), MAX_SOURCE_BYTES);
|
||||
return snapshot
|
||||
? parseLettaDocument({
|
||||
kind,
|
||||
exists: true,
|
||||
filePath,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
})
|
||||
: createLettaDocument(kind, filePath);
|
||||
}
|
||||
|
||||
function sourcePrecondition(document: LettaDocument): LettaSourcePrecondition {
|
||||
return {
|
||||
filePath: document.filePath,
|
||||
label: lettaDocumentLabel(document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: document.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function deduplicatePreconditions(
|
||||
values: readonly LettaSourcePrecondition[],
|
||||
): LettaSourcePrecondition[] {
|
||||
const result = new Map<string, LettaSourcePrecondition>();
|
||||
for (const value of values) {
|
||||
const key = comparisonPath(value.filePath);
|
||||
if (!result.has(key)) result.set(key, value);
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function effectiveDisable(
|
||||
global: LettaDocument,
|
||||
project: LettaDocument,
|
||||
projectLocal: LettaDocument,
|
||||
projectActive: boolean,
|
||||
): LettaDisableControl {
|
||||
if (global.hooks.disabled === false) return { disabled: false, source: global };
|
||||
if (global.hooks.disabled === true) return { disabled: true, source: global };
|
||||
if (projectActive && project.hooks.disabled === true) {
|
||||
return { disabled: true, source: project };
|
||||
}
|
||||
if (projectLocal.hooks.disabled === true) {
|
||||
return { disabled: true, source: projectLocal };
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
export async function readLettaSources(): Promise<LettaSources> {
|
||||
const homeDirectory = resolveHomeDirectory();
|
||||
const workspace = process.cwd();
|
||||
const globalDirectory = path.join(homeDirectory, '.letta');
|
||||
const projectDirectory = path.join(workspace, '.letta');
|
||||
await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory');
|
||||
await inspectPhysicalDirectory(globalDirectory, 'Letta Code global configuration directory');
|
||||
if (comparisonPath(projectDirectory) !== comparisonPath(globalDirectory)) {
|
||||
await inspectPhysicalDirectory(projectDirectory, 'Letta Code project configuration directory');
|
||||
}
|
||||
const globalPath = path.join(globalDirectory, 'settings.json');
|
||||
const projectPath = path.join(projectDirectory, 'settings.json');
|
||||
const projectLocalPath = path.join(projectDirectory, 'settings.local.json');
|
||||
const [canonicalWorkspace, canonicalHome] = await Promise.all([
|
||||
canonicalPath(workspace),
|
||||
canonicalPath(homeDirectory),
|
||||
]);
|
||||
const projectActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
|
||||
const global = await readDocument('global', globalPath);
|
||||
const project = projectActive
|
||||
? await readDocument('project', projectPath)
|
||||
: createLettaDocument('project', projectPath);
|
||||
const projectLocal = await readDocument('project-local', projectLocalPath);
|
||||
const preconditions = deduplicatePreconditions([
|
||||
sourcePrecondition(global),
|
||||
...(projectActive ? [sourcePrecondition(project)] : []),
|
||||
sourcePrecondition(projectLocal),
|
||||
]);
|
||||
return {
|
||||
homeDirectory,
|
||||
global,
|
||||
project,
|
||||
projectLocal,
|
||||
projectActive,
|
||||
disableControl: effectiveDisable(global, project, projectLocal, projectActive),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireLettaHooksEnabled(sources: LettaSources): void {
|
||||
if (!sources.disableControl.disabled) return;
|
||||
const source = sources.disableControl.source;
|
||||
const location = source
|
||||
? `${lettaDocumentLabel(source)} at ${source.filePath}`
|
||||
: 'effective settings';
|
||||
throw new Error(`Letta Code hooks are disabled by hooks.disabled in ${location}`);
|
||||
}
|
||||
+63
-146
@@ -1,173 +1,90 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
buildLettaGroup,
|
||||
lettaRuntimeContracts,
|
||||
type LettaGroup,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { renderLettaDocument } from './letta-config.js';
|
||||
import {
|
||||
commitLettaInstallation,
|
||||
commitLettaUninstall,
|
||||
preflightLettaInstallation,
|
||||
prepareLettaInstallation,
|
||||
prepareLettaUninstall,
|
||||
} from './letta-installation.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import { readLettaSources } from './letta-sources.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'letta';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||
ManagedLettaEvent,
|
||||
LettaGroup
|
||||
> {
|
||||
return new Map([
|
||||
['PreToolUse', buildLettaGroup(guardPath)],
|
||||
['PostToolUse', buildLettaGroup(auditPath)],
|
||||
['PostToolUseFailure', buildLettaGroup(auditPath)],
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
async function runtimeExists(
|
||||
contracts: ReturnType<typeof lettaRuntimeContracts>,
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
})) return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export const lettaPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readLettaSources();
|
||||
await preflightLettaInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const paths = await preflightLettaInstallation(config, sources);
|
||||
const rendered = renderLettaDocument(
|
||||
sources.global,
|
||||
undefined,
|
||||
installedGroups(paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitLettaInstallation(
|
||||
await prepareLettaInstallation(config, sources, rendered),
|
||||
);
|
||||
console.log(` Letta Code hooks: ${sources.global.filePath}`);
|
||||
console.log(' Letta Code verification: run /hooks and restart active sessions.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const rendered = renderLettaDocument(sources.global, agentId, new Map());
|
||||
if (!rendered.changed) return;
|
||||
await commitLettaUninstall(await prepareLettaUninstall(sources, rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readLettaSources();
|
||||
const contracts = lettaRuntimeContracts(sources.global.hooks);
|
||||
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured ? await runtimeExists(contracts) : false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.global.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
|
||||
import { generateHookScript, type HookScriptOptions } from './hook-template.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { samePath } from './managed-installation.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
const MAX_SECRET_BYTES = 64 * 1024;
|
||||
const MAX_CONFIG_BYTES = 512 * 1024;
|
||||
const GUARD_SCRIPT = 'guard.js';
|
||||
const AUDIT_SCRIPT = 'hook.js';
|
||||
|
||||
export interface ManagedRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRuntimeStatusOptions {
|
||||
readonly guardOptions?: GuardScriptOptions;
|
||||
readonly auditOptions?: HookScriptOptions;
|
||||
}
|
||||
|
||||
function sameAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string, configPath: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(
|
||||
config: JsonObject,
|
||||
contract: ManagedRuntimeContract,
|
||||
configPath: string,
|
||||
agentKey: string,
|
||||
): void {
|
||||
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
||||
const extra = Object.keys(config).find((key) => !supported.has(key));
|
||||
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
|
||||
requireString(config.org_id, 'org_id', configPath);
|
||||
requireString(config.kid, 'kid', configPath);
|
||||
const agentId = requireString(config.agent_id, 'agent_id', configPath);
|
||||
if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
|
||||
throw new Error(`Elydora runtime identity does not match ${agentKey} hooks: ${configPath}`);
|
||||
}
|
||||
if (config.token !== undefined) requireString(config.token, 'token', configPath);
|
||||
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(rawBaseUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (!['http:', 'https:'].includes(baseUrl.protocol)
|
||||
|| !baseUrl.hostname
|
||||
|| baseUrl.username
|
||||
|| baseUrl.password
|
||||
|| baseUrl.search
|
||||
|| baseUrl.hash) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePrivateKey(contents: string, keyPath: string): void {
|
||||
const bytes = Buffer.from(contents, 'base64url');
|
||||
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
|
||||
throw new Error(`Elydora private key is invalid: ${keyPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validContractPaths(contract: ManagedRuntimeContract): boolean {
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
return samePath(path.dirname(agentDirectory), runtimeRoot)
|
||||
&& sameAgentId(path.basename(agentDirectory), contract.agentId)
|
||||
&& samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|
||||
&& samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
|
||||
}
|
||||
|
||||
export async function managedRuntimeFilesExist(
|
||||
contract: ManagedRuntimeContract,
|
||||
agentKey: string,
|
||||
options: ManagedRuntimeStatusOptions = {},
|
||||
): Promise<boolean> {
|
||||
if (!validContractPaths(contract)) return false;
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
|
||||
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false;
|
||||
const configPath = path.join(agentDirectory, 'config.json');
|
||||
const keyPath = path.join(agentDirectory, 'private.key');
|
||||
const [config, key, guard, audit] = await Promise.all([
|
||||
readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
|
||||
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
|
||||
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
|
||||
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
|
||||
]);
|
||||
if (!config || !key || !guard || !audit) return false;
|
||||
validateRuntimeConfig(
|
||||
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
|
||||
contract,
|
||||
configPath,
|
||||
agentKey,
|
||||
);
|
||||
validatePrivateKey(key.contents, keyPath);
|
||||
return guard.contents === generateGuardScript(agentKey, contract.agentId, options.guardOptions)
|
||||
&& audit.contents === generateHookScript(
|
||||
agentKey,
|
||||
contract.agentId,
|
||||
options.auditOptions ?? { nativePayload: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { HookScriptOptions } from './hook-template.js';
|
||||
import { samePath } from './managed-installation.js';
|
||||
import { parseStrictJsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'opencode';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const PLUGIN_FILE = 'elydora-audit.js';
|
||||
export const LEGACY_PLUGIN_FILE = 'elydora-audit.mjs';
|
||||
export const OPENCODE_AUDIT_OPTIONS = Object.freeze({
|
||||
failClosed: true,
|
||||
nativePayload: true,
|
||||
}) satisfies HookScriptOptions;
|
||||
|
||||
const METADATA_MARKER = '// @elydora-opencode-plugin ';
|
||||
const METADATA_VERSION = 1;
|
||||
const MAX_RUNTIME_STDERR_BYTES = 64 * 1024;
|
||||
const GUARD_TIMEOUT_MILLISECONDS = 5_000;
|
||||
const AUDIT_TIMEOUT_MILLISECONDS = 7_000;
|
||||
|
||||
export interface OpenCodePaths {
|
||||
readonly homeDirectory: string;
|
||||
readonly configRoot: string;
|
||||
readonly configDirectory: string;
|
||||
readonly pluginsDirectory: string;
|
||||
readonly pluginPath: string;
|
||||
readonly legacyPluginPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly executablePath: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeLegacyContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
interface OpenCodePluginMetadata {
|
||||
readonly version: 1;
|
||||
readonly agentName: 'opencode';
|
||||
readonly agentId: string;
|
||||
readonly executablePath: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export function sameOpenCodeAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
export function resolveOpenCodePaths(
|
||||
homeDirectory = os.homedir(),
|
||||
xdgConfigHome = process.env.XDG_CONFIG_HOME,
|
||||
): OpenCodePaths {
|
||||
const resolvedHome = path.resolve(homeDirectory);
|
||||
const configRoot = xdgConfigHome || path.join(resolvedHome, '.config');
|
||||
if (!path.isAbsolute(configRoot)) {
|
||||
throw new Error('XDG_CONFIG_HOME must be an absolute path for OpenCode integration');
|
||||
}
|
||||
const configDirectory = path.join(configRoot, 'opencode');
|
||||
const pluginsDirectory = path.join(configDirectory, 'plugins');
|
||||
return {
|
||||
homeDirectory: resolvedHome,
|
||||
configRoot: path.normalize(configRoot),
|
||||
configDirectory,
|
||||
pluginsDirectory,
|
||||
pluginPath: path.join(pluginsDirectory, PLUGIN_FILE),
|
||||
legacyPluginPath: path.join(pluginsDirectory, LEGACY_PLUGIN_FILE),
|
||||
};
|
||||
}
|
||||
|
||||
function validAgentSegment(agentId: string): boolean {
|
||||
return Boolean(agentId)
|
||||
&& agentId !== '.'
|
||||
&& agentId !== '..'
|
||||
&& path.basename(agentId) === agentId;
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath).toLowerCase();
|
||||
return basename === 'node' || basename === 'node.exe';
|
||||
}
|
||||
|
||||
function validateRuntimePaths(metadata: OpenCodePluginMetadata): void {
|
||||
if (!validAgentSegment(metadata.agentId)) {
|
||||
throw new Error('OpenCode plugin metadata contains an invalid agentId');
|
||||
}
|
||||
if (!path.isAbsolute(metadata.executablePath) || !isNodeExecutable(metadata.executablePath)) {
|
||||
throw new Error('OpenCode plugin metadata executablePath must reference Node.js');
|
||||
}
|
||||
const agentDirectory = path.join(os.homedir(), '.elydora', metadata.agentId);
|
||||
if (!samePath(metadata.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|
||||
|| !samePath(metadata.auditPath, path.join(agentDirectory, AUDIT_SCRIPT))) {
|
||||
throw new Error('OpenCode plugin metadata references an unexpected runtime path');
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadata(value: unknown): OpenCodePluginMetadata {
|
||||
const object = value as Record<string, unknown>;
|
||||
if (!object || typeof object !== 'object' || Array.isArray(object)) {
|
||||
throw new Error('OpenCode plugin metadata must be an object');
|
||||
}
|
||||
if (Object.keys(object).sort().join(',') !== 'agentId,agentName,auditPath,executablePath,guardPath,version') {
|
||||
throw new Error('OpenCode plugin metadata contains an unexpected field set');
|
||||
}
|
||||
if (object.version !== METADATA_VERSION) throw new Error('OpenCode plugin metadata version must be 1');
|
||||
if (object.agentName !== AGENT_KEY) throw new Error('OpenCode plugin metadata agentName must be opencode');
|
||||
for (const field of ['agentId', 'executablePath', 'guardPath', 'auditPath']) {
|
||||
if (typeof object[field] !== 'string' || object[field].length === 0) {
|
||||
throw new Error(`OpenCode plugin metadata ${field} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
const metadata = object as unknown as OpenCodePluginMetadata;
|
||||
validateRuntimePaths(metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function encodeMetadata(metadata: OpenCodePluginMetadata): string {
|
||||
return Buffer.from(JSON.stringify(metadata), 'utf-8').toString('base64url');
|
||||
}
|
||||
|
||||
export function buildOpenCodeMetadata(
|
||||
agentId: string,
|
||||
executablePath: string,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): OpenCodeRuntimeContract {
|
||||
return validateMetadata({
|
||||
version: METADATA_VERSION,
|
||||
agentName: AGENT_KEY,
|
||||
agentId,
|
||||
executablePath,
|
||||
guardPath,
|
||||
auditPath,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGeneratedSource(source: string): string {
|
||||
return source.replaceAll('\r\n', '\n');
|
||||
}
|
||||
|
||||
export function buildOpenCodePlugin(contract: OpenCodeRuntimeContract): string {
|
||||
const metadata = validateMetadata({
|
||||
version: METADATA_VERSION,
|
||||
agentName: AGENT_KEY,
|
||||
...contract,
|
||||
});
|
||||
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
|
||||
${METADATA_MARKER}${encodeMetadata(metadata)}
|
||||
// Generated by @elydora/sdk. DO NOT EDIT.
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const RUNTIME_EXECUTABLE = ${JSON.stringify(metadata.executablePath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(metadata.guardPath)};
|
||||
const AUDIT_SCRIPT_PATH = ${JSON.stringify(metadata.auditPath)};
|
||||
const MAX_STDERR_BYTES = ${MAX_RUNTIME_STDERR_BYTES};
|
||||
const GUARD_TIMEOUT_MS = ${GUARD_TIMEOUT_MILLISECONDS};
|
||||
const AUDIT_TIMEOUT_MS = ${AUDIT_TIMEOUT_MILLISECONDS};
|
||||
|
||||
function eventPayload(eventName, input, output) {
|
||||
return JSON.stringify({
|
||||
hook_event_name: eventName,
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: eventName === 'tool.execute.before' ? output?.args : input?.args,
|
||||
session_id: input?.sessionID || 'unknown',
|
||||
call_id: input?.callID || 'unknown',
|
||||
input,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
function runGuard(input, output) {
|
||||
let payload;
|
||||
try {
|
||||
payload = eventPayload('tool.execute.before', input, output);
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard input serialization failed: ' + error.message);
|
||||
}
|
||||
const result = spawnSync(RUNTIME_EXECUTABLE, [GUARD_SCRIPT_PATH], {
|
||||
input: payload,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: MAX_STDERR_BYTES,
|
||||
timeout: GUARD_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
const stderr = String(result.stderr || '').trim();
|
||||
if (result.status !== 0) {
|
||||
const detail = stderr || (result.signal
|
||||
? 'Guard terminated by signal ' + result.signal
|
||||
: 'Guard exited with status ' + result.status);
|
||||
throw new Error((result.status === 2 ? '' : 'Elydora guard failed: ') + detail);
|
||||
}
|
||||
if (stderr) console.error(stderr);
|
||||
}
|
||||
|
||||
function runAudit(input, output) {
|
||||
let payload;
|
||||
try {
|
||||
payload = eventPayload('tool.execute.after', input, output);
|
||||
} catch (error) {
|
||||
console.error('[elydora] OpenCode audit input serialization failed:', error.message);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(RUNTIME_EXECUTABLE, [AUDIT_SCRIPT_PATH], {
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const stderr = [];
|
||||
let stderrBytes = 0;
|
||||
let truncated = false;
|
||||
let settled = false;
|
||||
let timer;
|
||||
|
||||
const report = (message) => {
|
||||
console.error('[elydora] OpenCode audit runtime failed:', message);
|
||||
};
|
||||
const complete = (failure) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (failure) report(failure);
|
||||
resolve();
|
||||
};
|
||||
const stderrText = () => {
|
||||
const detail = Buffer.concat(stderr).toString('utf-8').trim();
|
||||
return truncated ? detail + '\\n[stderr truncated]' : detail;
|
||||
};
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const value = Buffer.from(chunk);
|
||||
const remaining = MAX_STDERR_BYTES - stderrBytes;
|
||||
if (remaining <= 0) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
stderr.push(value.subarray(0, remaining));
|
||||
stderrBytes += Math.min(value.length, remaining);
|
||||
if (value.length > remaining) truncated = true;
|
||||
});
|
||||
child.once('error', (error) => complete(error.message));
|
||||
child.stdin.once('error', (error) => {
|
||||
child.kill();
|
||||
complete('failed to write hook input: ' + error.message);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
const detail = stderrText();
|
||||
if (code !== 0 || signal) {
|
||||
complete(detail || (signal
|
||||
? 'runtime terminated by signal ' + signal
|
||||
: 'runtime exited with code ' + code));
|
||||
return;
|
||||
}
|
||||
if (detail) report(detail);
|
||||
complete();
|
||||
});
|
||||
timer = setTimeout(() => {
|
||||
child.kill();
|
||||
complete('runtime timed out after ' + AUDIT_TIMEOUT_MS + 'ms');
|
||||
}, AUDIT_TIMEOUT_MS);
|
||||
try {
|
||||
child.stdin.end(payload);
|
||||
} catch (error) {
|
||||
child.kill();
|
||||
complete('failed to write hook input: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const ElydoraAuditPlugin = async () => ({
|
||||
'tool.execute.before': async (input, output) => {
|
||||
runGuard(input, output);
|
||||
},
|
||||
'tool.execute.after': async (input, output) => {
|
||||
await runAudit(input, output);
|
||||
},
|
||||
});
|
||||
`);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function parseOpenCodePlugin(
|
||||
filePath: string,
|
||||
source: string,
|
||||
): OpenCodeRuntimeContract | undefined {
|
||||
const markerLine = source.split(/\r?\n/, 3)[1];
|
||||
if (!markerLine?.startsWith(METADATA_MARKER)) return undefined;
|
||||
let metadata: OpenCodePluginMetadata;
|
||||
try {
|
||||
const encoded = markerLine.slice(METADATA_MARKER.length);
|
||||
const decoded = Buffer.from(encoded, 'base64url').toString('utf-8');
|
||||
if (Buffer.from(decoded, 'utf-8').toString('base64url') !== encoded) {
|
||||
throw new Error('metadata encoding is not canonical base64url');
|
||||
}
|
||||
metadata = validateMetadata(parseStrictJsonObject(decoded, 'OpenCode plugin metadata'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse Elydora OpenCode plugin metadata at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (source !== buildOpenCodePlugin(metadata)) {
|
||||
throw new Error(`Elydora OpenCode plugin at ${filePath} does not match the managed template`);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function buildLegacyOpenCodePlugin(auditPath: string, guardPath: string): string {
|
||||
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const HOOK_SCRIPT_PATH = ${JSON.stringify(auditPath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardPath)};
|
||||
|
||||
export const ElydoraAuditPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async () => {
|
||||
// Guard — blocks tool if agent is frozen
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard failed: ' + error.message);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
|
||||
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
|
||||
throw new Error(prefix + message);
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
const data = JSON.stringify({
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: input?.args || output?.args || {},
|
||||
session_id: input?.sessionID || ctx?.project?.name || 'unknown',
|
||||
});
|
||||
const child = spawn('node', [HOOK_SCRIPT_PATH], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
console.error('[elydora] audit hook failed:', error.message);
|
||||
});
|
||||
child.stdin.on('error', (error) => {
|
||||
console.error('[elydora] audit input failed:', error.message);
|
||||
});
|
||||
child.stdin.end(data);
|
||||
child.unref();
|
||||
},
|
||||
};
|
||||
};
|
||||
`);
|
||||
}
|
||||
|
||||
function legacyRuntimeContract(
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): OpenCodeLegacyContract | undefined {
|
||||
if (!path.isAbsolute(guardPath) || !path.isAbsolute(auditPath)) return undefined;
|
||||
const agentDirectory = path.dirname(guardPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
|
||||
|| !samePath(auditPath, path.join(agentDirectory, AUDIT_SCRIPT))
|
||||
|| !samePath(guardPath, path.join(agentDirectory, GUARD_SCRIPT))) return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return validAgentSegment(agentId) ? { agentId, guardPath, auditPath } : undefined;
|
||||
}
|
||||
|
||||
export function parseLegacyOpenCodePlugin(source: string): OpenCodeLegacyContract | undefined {
|
||||
const normalized = normalizeGeneratedSource(source);
|
||||
const auditMatch = normalized.match(/^const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
|
||||
const guardMatch = normalized.match(/^const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
|
||||
if (!auditMatch || !guardMatch) return undefined;
|
||||
let auditPath: unknown;
|
||||
let guardPath: unknown;
|
||||
try {
|
||||
auditPath = JSON.parse(auditMatch[1]);
|
||||
guardPath = JSON.parse(guardMatch[1]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof auditPath !== 'string' || typeof guardPath !== 'string'
|
||||
|| normalized !== buildLegacyOpenCodePlugin(auditPath, guardPath)) return undefined;
|
||||
return legacyRuntimeContract(guardPath, auditPath);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
OPENCODE_AUDIT_OPTIONS,
|
||||
buildOpenCodeMetadata,
|
||||
buildOpenCodePlugin,
|
||||
sameOpenCodeAgentId,
|
||||
} from './opencode-contract.js';
|
||||
import type { OpenCodePluginFile, OpenCodeSources } from './opencode-io.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedHookSource,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'OpenCode';
|
||||
const DIRECTORY_LABEL = 'OpenCode plugins directory';
|
||||
|
||||
export type OpenCodeRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedOpenCodeInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
export async function preflightOpenCodeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: OpenCodeSources,
|
||||
): Promise<OpenCodeRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
filePath: sources.paths.pluginPath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareOpenCodeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: OpenCodeSources,
|
||||
): Promise<PreparedOpenCodeInstallation> {
|
||||
const paths = await preflightOpenCodeInstallation(config, sources);
|
||||
const metadata = buildOpenCodeMetadata(
|
||||
config.agentId,
|
||||
process.execPath,
|
||||
paths.guardPath,
|
||||
paths.auditPath,
|
||||
);
|
||||
const hookSources: ManagedHookSource[] = [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'OpenCode plugin',
|
||||
filePath: sources.current.filePath,
|
||||
expectedSource: sources.current.source,
|
||||
expectedSnapshot: sources.current.snapshot,
|
||||
source: buildOpenCodePlugin(metadata),
|
||||
}];
|
||||
if (sources.legacy.contract) {
|
||||
hookSources.push({
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'legacy OpenCode plugin',
|
||||
filePath: sources.legacy.filePath,
|
||||
expectedSource: sources.legacy.source,
|
||||
expectedSnapshot: sources.legacy.snapshot,
|
||||
});
|
||||
}
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources,
|
||||
config,
|
||||
auditOptions: OPENCODE_AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitOpenCodeInstallation(
|
||||
prepared: PreparedOpenCodeInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
function ownedByAgent<TContract extends { readonly agentId: string }>(
|
||||
file: OpenCodePluginFile<TContract>,
|
||||
agentId?: string,
|
||||
): boolean {
|
||||
return file.contract !== undefined
|
||||
&& (agentId === undefined || sameOpenCodeAgentId(file.contract.agentId, agentId));
|
||||
}
|
||||
|
||||
export async function prepareOpenCodeUninstall(
|
||||
sources: OpenCodeSources,
|
||||
agentId?: string,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const owned = [sources.current, sources.legacy].filter((file) => ownedByAgent(file, agentId));
|
||||
const changes = await Promise.all(owned.map((file) => prepareManagedFileChange({
|
||||
filePath: file.filePath,
|
||||
label: file.filePath === sources.paths.pluginPath ? 'OpenCode plugin' : 'legacy OpenCode plugin',
|
||||
next: undefined,
|
||||
mode: 0o600,
|
||||
expectedSource: file.source,
|
||||
expectedSnapshot: file.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})));
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: owned.map((file) => ({
|
||||
path: path.dirname(file.filePath),
|
||||
label: DIRECTORY_LABEL,
|
||||
})),
|
||||
changes: changes.filter((change): change is ManagedFileChange => change !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitOpenCodeUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
OPENCODE_AUDIT_OPTIONS,
|
||||
parseLegacyOpenCodePlugin,
|
||||
parseOpenCodePlugin,
|
||||
resolveOpenCodePaths,
|
||||
type OpenCodeLegacyContract,
|
||||
type OpenCodePaths,
|
||||
type OpenCodeRuntimeContract,
|
||||
} from './opencode-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
const MAX_PLUGIN_BYTES = 512 * 1024;
|
||||
|
||||
export interface OpenCodePluginFile<TContract> {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly source?: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly contract?: TContract;
|
||||
}
|
||||
|
||||
export interface OpenCodeSources {
|
||||
readonly paths: OpenCodePaths;
|
||||
readonly current: OpenCodePluginFile<OpenCodeRuntimeContract>;
|
||||
readonly legacy: OpenCodePluginFile<OpenCodeLegacyContract>;
|
||||
}
|
||||
|
||||
async function inspectOpenCodeDirectories(paths: OpenCodePaths): Promise<void> {
|
||||
await inspectPhysicalDirectory(paths.homeDirectory, 'OpenCode home directory');
|
||||
await inspectPhysicalDirectory(paths.configRoot, 'OpenCode XDG config directory');
|
||||
await inspectPhysicalDirectory(paths.configDirectory, 'OpenCode config directory');
|
||||
await inspectPhysicalDirectory(paths.pluginsDirectory, 'OpenCode plugins directory');
|
||||
}
|
||||
|
||||
async function readPluginFile<TContract>(
|
||||
filePath: string,
|
||||
label: string,
|
||||
parse: (filePath: string, source: string) => TContract | undefined,
|
||||
): Promise<OpenCodePluginFile<TContract>> {
|
||||
const snapshot = await readPhysicalFile(filePath, label, MAX_PLUGIN_BYTES);
|
||||
if (!snapshot) return { exists: false, filePath };
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
source: snapshot.contents,
|
||||
snapshot,
|
||||
contract: parse(filePath, snapshot.contents),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readOpenCodeSources(): Promise<OpenCodeSources> {
|
||||
const paths = resolveOpenCodePaths();
|
||||
await inspectOpenCodeDirectories(paths);
|
||||
const [current, legacy] = await Promise.all([
|
||||
readPluginFile(paths.pluginPath, 'OpenCode plugin', parseOpenCodePlugin),
|
||||
readPluginFile(
|
||||
paths.legacyPluginPath,
|
||||
'legacy OpenCode plugin',
|
||||
(_filePath, source) => parseLegacyOpenCodePlugin(source),
|
||||
),
|
||||
]);
|
||||
return { paths, current, legacy };
|
||||
}
|
||||
|
||||
export function requireAvailableOpenCodePlugin(
|
||||
file: OpenCodePluginFile<OpenCodeRuntimeContract>,
|
||||
): void {
|
||||
if (file.exists && !file.contract) {
|
||||
throw new Error(`OpenCode plugin path is owned by another integration: ${file.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
async function executableExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fsp.stat(filePath)).isFile();
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
|
||||
throw new Error(`Inspect OpenCode Node.js runtime at ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
|
||||
export async function openCodeRuntimeFilesExist(
|
||||
contract: OpenCodeRuntimeContract,
|
||||
): Promise<boolean> {
|
||||
return await executableExists(contract.executablePath)
|
||||
&& await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
auditOptions: OPENCODE_AUDIT_OPTIONS,
|
||||
});
|
||||
}
|
||||
@@ -1,134 +1,63 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { AGENT_KEY } from './opencode-contract.js';
|
||||
import {
|
||||
commitOpenCodeInstallation,
|
||||
commitOpenCodeUninstall,
|
||||
preflightOpenCodeInstallation,
|
||||
prepareOpenCodeInstallation,
|
||||
prepareOpenCodeUninstall,
|
||||
} from './opencode-installation.js';
|
||||
import {
|
||||
openCodeRuntimeFilesExist,
|
||||
readOpenCodeSources,
|
||||
requireAvailableOpenCodePlugin,
|
||||
} from './opencode-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'opencode';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
|
||||
return `// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const HOOK_SCRIPT_PATH = ${JSON.stringify(hookScriptPath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardScriptPath)};
|
||||
|
||||
export const ElydoraAuditPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async () => {
|
||||
// Guard — blocks tool if agent is frozen
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard failed: ' + error.message);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
|
||||
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
|
||||
throw new Error(prefix + message);
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
const data = JSON.stringify({
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: input?.args || output?.args || {},
|
||||
session_id: input?.sessionID || ctx?.project?.name || 'unknown',
|
||||
});
|
||||
const child = spawn('node', [HOOK_SCRIPT_PATH], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
console.error('[elydora] audit hook failed:', error.message);
|
||||
});
|
||||
child.stdin.on('error', (error) => {
|
||||
console.error('[elydora] audit input failed:', error.message);
|
||||
});
|
||||
child.stdin.end(data);
|
||||
child.unref();
|
||||
},
|
||||
};
|
||||
};
|
||||
`;
|
||||
function validateSources(sources: Awaited<ReturnType<typeof readOpenCodeSources>>): void {
|
||||
requireAvailableOpenCodePlugin(sources.current);
|
||||
}
|
||||
|
||||
export const opencodePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
managesRuntime: true,
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
|
||||
|
||||
await fsp.writeFile(configPath, script, 'utf-8');
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
await preflightOpenCodeInstallation(config, sources);
|
||||
},
|
||||
|
||||
async uninstall(_agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
const prepared = await prepareOpenCodeInstallation(config, sources);
|
||||
await commitOpenCodeInstallation(prepared);
|
||||
console.log(` OpenCode global plugin: ${sources.paths.pluginPath}`);
|
||||
console.log(' OpenCode activation: restart active OpenCode sessions.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
const prepared = await prepareOpenCodeUninstall(sources, agentId);
|
||||
await commitOpenCodeUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
let guardScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const hookMatch = raw.match(/const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
|
||||
const guardMatch = raw.match(/const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
|
||||
if (hookMatch && guardMatch) {
|
||||
hookScriptPath = JSON.parse(hookMatch[1]);
|
||||
guardScriptPath = JSON.parse(guardMatch[1]);
|
||||
hookConfigured = true;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath && guardScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
await fsp.access(guardScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
const contract = sources.legacy.contract ? undefined : sources.current.contract;
|
||||
const hookConfigured = contract !== undefined;
|
||||
const hookScriptExists = contract !== undefined
|
||||
&& await openCodeRuntimeFilesExist(contract);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.paths.pluginPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -7,14 +7,14 @@ export interface AgentRegistryEntry {
|
||||
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
|
||||
['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }],
|
||||
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
|
||||
['cursor', { name: 'Cursor', configDir: '~/.cursor', configFile: 'hooks.json' }],
|
||||
['cursor', { name: 'Cursor CLI', configDir: '~/.cursor', configFile: 'hooks.json' }],
|
||||
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
|
||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.js' }],
|
||||
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
|
||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||
['codex', { name: 'Codex CLI', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||
['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }],
|
||||
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
|
||||
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
|
||||
|
||||
Reference in new issue
Block a user