feat: support Kiro CLI v2 and v3 hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 04:28:53 -04:00
1 parent 51ed21a115
commit 2214c00b94
5 files changed
+675 -124

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
# Elydora Node SDK Engineering Contract
## Scope
This repository owns the published `@elydora/sdk` package, its `elydora` CLI, local signing behavior, API client, generated hook runtime, and Node-specific agent adapters.
## Integration Sources
- Verify every agent hook contract against current official provider documentation before changing an adapter.
- Treat `../Elydora-Open-Source/integrations/catalog.json` as the cross-product provider inventory.
- Keep adapter delivery claims aligned with executable tests in this repository.
- Synchronize completed provider behavior into the Python SDK, Go SDK, Open Source distribution, Console, Docs, and landing page through separate reviewed commits.
## Hook Adapter Invariants
- Preserve unrelated user configuration and remove only Elydora-owned entries.
- Parse every affected user configuration before the first write.
- Surface malformed or unreadable configuration with contextual errors and leave the original file intact.
- Write configuration through a same-directory temporary file followed by an atomic rename.
- Quote the current Node executable and generated script paths for the host shell.
- Forward official hook JSON from STDIN without reshaping provider fields.
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
- Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist.
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
## Code Quality
- Keep production source files at or below 500 lines.
- Keep functions focused on one ownership boundary.
- Propagate unexpected errors to the CLI boundary.
- Use documented defaults only for genuinely optional configuration.
- Avoid compatibility shims without a named public or user configuration contract.
## Verification
Run the focused adapter test during development, then execute all release gates before commit:
```powershell
npm run build
node --test test/<provider>-plugin.test.mjs
npm test
npm audit --omit=dev --audit-level=high
npm pack --dry-run --json
git diff --check
```
Provider adapter tests must cover installation, idempotency, official event forwarding, blocking behavior, status, missing runtime files, uninstall ownership, and malformed configuration preservation.
Commit and push one root issue before starting the next one.
+2
View File
@@ -77,6 +77,8 @@ npx elydora install \
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
## API Reference ## API Reference
### Configuration ### Configuration
+309 -123
View File
@@ -1,173 +1,359 @@
import fsp from 'node:fs/promises'; import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { SUPPORTED_AGENTS } from './registry.js'; import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'kirocli'; 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)!; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string { type JsonObject = Record<string, unknown>;
return entry.configDir.replace(/^~/, os.homedir());
interface HookContract {
readonly guard: string;
readonly audit: string;
readonly configPath: string;
} }
function resolveConfigPath(): string { function isObject(value: unknown): value is JsonObject {
return path.join(resolveConfigDir(), entry.configFile); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
function buildHookCommand(scriptPath: string): string { function resolveV2Path(): string {
return `node "${scriptPath}"`; return path.join(os.homedir(), '.kiro', 'agents', `${V2_AGENT_NAME}.json`);
} }
function isElydoraCommand(cmd: string, agentId?: string): boolean { function resolveV3Path(): string {
if (!cmd.includes('elydora')) return false; const configDir = entry.configDir.replace(/^~/, os.homedir());
if (agentId) { return path.join(configDir, entry.configFile);
return cmd.includes(agentId); }
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)}`);
} }
return true;
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;
} }
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> { async function writeJsonFile(configPath: string, value: JsonObject, label: string): Promise<void> {
return arr.filter((entry) => { await fsp.mkdir(path.dirname(configPath), { recursive: true });
if (Array.isArray(entry.hooks)) { const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
const cmds = entry.hooks as Array<Record<string, unknown>>; try {
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId)); await fsp.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n', {
} encoding: 'utf-8',
if (typeof entry.command === 'string') { mode: 0o600,
return !isElydoraCommand(entry.command, agentId); });
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 true;
}); }
return false;
} }
export const kirocliPlugin: AgentPlugin = { export const kirocliPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> { async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir(); const v2Path = resolveV2Path();
await fsp.mkdir(configDir, { recursive: true }); 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);
const configPath = resolveConfigPath(); v2Hooks.preToolUse = [
let settings: Record<string, unknown> = {}; ...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),
];
try { const nextV2Settings: JsonObject = {
const raw = await fsp.readFile(configPath, 'utf-8'); name: V2_AGENT_NAME,
settings = JSON.parse(raw); description: V2_DESCRIPTION,
} catch { tools: ['*'],
// File doesn't exist or isn't valid JSON — start fresh 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,
),
],
};
if (!settings.hooks || typeof settings.hooks !== 'object') { await writeJsonFile(v2Path, nextV2Settings, 'Kiro CLI v2 agent config');
settings.hooks = {}; await writeJsonFile(v3Path, nextV3Settings, 'Kiro CLI v3 hooks config');
} console.log(' Kiro CLI v2: start with "kiro-cli --agent elydora-audit".');
const hooks = settings.hooks as Record<string, unknown>; console.log(' Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.');
// --- 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), timeout_ms: 5000 }],
});
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), timeout_ms: 5000 }],
});
hooks.PostToolUse = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
}, },
async uninstall(agentId?: string): Promise<void> { async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath(); 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');
let settings: Record<string, unknown>; if (v2Settings) {
try { const hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
const raw = await fsp.readFile(configPath, 'utf-8'); hooks.preToolUse = withoutV2Hooks(
settings = JSON.parse(raw); hookEntries(hooks, 'preToolUse', 'Kiro CLI v2 agent config'),
} catch { agentId,
return; // Nothing to uninstall );
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');
}
} }
const hooks = settings.hooks as Record<string, unknown> | undefined; if (v3Settings) {
if (!hooks) return; const hooks = v3Hooks(v3Settings).filter((hook) => !isManagedV3Hook(hook, agentId));
const owned = Object.keys(v3Settings).every((key) => key === 'version' || key === 'hooks');
if (Array.isArray(hooks.PreToolUse)) { if (owned && hooks.length === 0) {
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId); await removeFile(v3Path, 'Kiro CLI v3 hooks config');
} else {
await writeJsonFile(v3Path, { ...v3Settings, hooks }, 'Kiro CLI v3 hooks config');
}
} }
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');
}, },
async status(): Promise<PluginStatus> { async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath(); 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[] = [];
let hookConfigured = false; if (v2Settings) {
let hookScriptPath = ''; const hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
try { const guard = findV2Command(
const raw = await fsp.readFile(configPath, 'utf-8'); hookEntries(hooks, 'preToolUse', 'Kiro CLI v2 agent config'),
const settings = JSON.parse(raw); 'guard.js',
const hooks = settings.hooks as Record<string, unknown> | undefined; );
if (hooks) { const audit = findV2Command(
const checkArr = (arr: unknown) => { hookEntries(hooks, 'postToolUse', 'Kiro CLI v2 agent config'),
if (!Array.isArray(arr)) return false; 'hook.js',
return (arr as Array<Record<string, unknown>>).some((entry) => { );
if (Array.isArray(entry.hooks)) { if (guard && audit) contracts.push({ guard, audit, configPath: v2Path });
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 (v3Settings) {
if (hookScriptPath) { const hooks = v3Hooks(v3Settings);
try { const guard = findV3Command(hooks, V3_GUARD_NAME, 'guard.js');
await fsp.access(hookScriptPath); const audit = findV3Command(hooks, V3_AUDIT_NAME, 'hook.js');
hookScriptExists = true; if (guard && audit) contracts.push({ guard, audit, configPath: v3Path });
} catch {
// File doesn't exist
}
} }
const hookConfigured = contracts.length > 0;
const hookScriptExists = hookConfigured ? await runtimeScriptsExist(contracts) : false;
return { return {
installed: hookConfigured && hookScriptExists, installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY, agentName: AGENT_KEY,
displayName: entry.name, displayName: entry.name,
hookConfigured, hookConfigured,
hookScriptExists, hookScriptExists,
configPath, configPath: contracts.at(-1)?.configPath ?? v3Path,
}; };
}, },
}; };
+1 -1
View File
@@ -8,7 +8,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }], ['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }], ['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }],
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }], ['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro', 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' }], ['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }], ['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }], ['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }],
+314
View File
@@ -0,0 +1,314 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kirocli.js')).href;
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
function runNode(args, env, input = '') {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
env: { ...process.env, ...env },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}
function runCommand(command, input = '') {
return new Promise((resolve, reject) => {
const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}
async function runPlugin(homeDir, method, argument) {
const script = `
import { kirocliPlugin } from ${JSON.stringify(pluginModuleUrl)};
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
const result = await kirocliPlugin[process.env.ELYDORA_TEST_METHOD](argument);
if (result !== undefined) console.log(JSON.stringify(result));
`;
return runNode(
['--input-type=module', '--eval', script],
{
HOME: homeDir,
USERPROFILE: homeDir,
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
ELYDORA_TEST_METHOD: method,
},
);
}
async function writeJsonOrText(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
}
async function createFixture({ existingV2, existingV3, guardSource, hookSource } = {}) {
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kirocli-'));
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
const v2Path = path.join(homeDir, '.kiro', 'agents', 'elydora-audit.json');
const v3Path = path.join(homeDir, '.kiro', 'hooks', 'elydora-audit.json');
await mkdir(agentDir, { recursive: true });
const guardScriptPath = path.join(agentDir, 'guard.js');
const hookScriptPath = path.join(agentDir, 'hook.js');
const runtimeConfigPath = path.join(agentDir, 'config.json');
await writeFile(
guardScriptPath,
guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
);
await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n');
await writeFile(runtimeConfigPath, JSON.stringify({
agent_id: 'agent-1',
agent_name: 'kirocli',
}));
if (existingV2 !== undefined) await writeJsonOrText(v2Path, existingV2);
if (existingV3 !== undefined) await writeJsonOrText(v3Path, existingV3);
const config = {
agentName: 'kirocli',
agentId: 'agent-1',
baseUrl: 'https://api.elydora.com',
guardScriptPath,
hookScriptPath,
};
const installResult = await runPlugin(homeDir, 'install', config);
return {
config,
guardScriptPath,
homeDir,
hookScriptPath,
installResult,
runtimeConfigPath,
v2Path,
v3Path,
async close() {
await rm(homeDir, { recursive: true, force: true });
},
};
}
function findV3Hook(config, name) {
return config.hooks.find((hook) => hook.name === name);
}
test('Kiro CLI registry points at the v3 global hook contract', async () => {
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
assert.deepEqual(SUPPORTED_AGENTS.get('kirocli'), {
name: 'Kiro CLI',
configDir: '~/.kiro/hooks',
configFile: 'elydora-audit.json',
});
});
test('Kiro CLI install preserves user hooks and writes idempotent v2 and v3 contracts', async () => {
const fixture = await createFixture({
existingV2: {
description: 'User Kiro agent',
tools: ['read'],
hooks: {
agentSpawn: [{ command: 'existing-spawn' }],
preToolUse: [{ matcher: 'read', command: 'existing-v2' }],
},
},
existingV3: {
version: 'v1',
hooks: [{
name: 'existing-v3',
trigger: 'SessionStart',
action: { type: 'command', command: 'existing-command' },
}],
},
});
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
assert.match(fixture.installResult.stdout, /--agent elydora-audit/);
assert.match(fixture.installResult.stdout, /--v3/);
const secondInstall = await runPlugin(fixture.homeDir, 'install', fixture.config);
assert.equal(secondInstall.code, 0, secondInstall.stderr);
const v2 = JSON.parse(await readFile(fixture.v2Path, 'utf-8'));
assert.equal(v2.description, 'User Kiro agent');
assert.deepEqual(v2.tools, ['read']);
assert.equal(v2.hooks.agentSpawn[0].command, 'existing-spawn');
assert.equal(v2.hooks.preToolUse.length, 2);
assert.equal(v2.hooks.postToolUse.length, 1);
assert.equal(v2.hooks.preToolUse[1].matcher, '*');
assert.equal(typeof v2.hooks.preToolUse[1].command, 'string');
const v3 = JSON.parse(await readFile(fixture.v3Path, 'utf-8'));
assert.equal(v3.version, 'v1');
assert.equal(v3.hooks.length, 3);
assert.equal(v3.hooks[0].name, 'existing-v3');
assert.deepEqual(findV3Hook(v3, 'elydora-guard'), {
name: 'elydora-guard',
description: 'Block tool use when the Elydora agent is frozen',
trigger: 'PreToolUse',
matcher: '.*',
action: { type: 'command', command: findV3Hook(v3, 'elydora-guard').action.command },
timeout: 5,
enabled: true,
});
assert.equal(findV3Hook(v3, 'elydora-audit').trigger, 'PostToolUse');
} finally {
await fixture.close();
}
});
test('Kiro CLI hook commands enforce freezes and forward the official event payload', async () => {
const capturePath = path.join(os.tmpdir(), `elydora-kiro-event-${process.pid}-${Date.now()}.json`);
const fixture = await createFixture({
hookSource: `
const fs = require('node:fs');
const chunks = [];
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
`,
});
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
const v3 = JSON.parse(await readFile(fixture.v3Path, 'utf-8'));
const payload = {
hook_event_name: 'PreToolUse',
cwd: fixture.homeDir,
session_id: 'session-1',
tool_name: 'execute_bash',
tool_input: { command: 'echo test' },
};
const guardResult = await runCommand(findV3Hook(v3, 'elydora-guard').action.command, JSON.stringify(payload));
assert.equal(guardResult.code, 2);
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
payload.hook_event_name = 'PostToolUse';
payload.tool_response = { success: true, result: 'test' };
const auditResult = await runCommand(findV3Hook(v3, 'elydora-audit').action.command, JSON.stringify(payload));
assert.equal(auditResult.code, 0, auditResult.stderr);
assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf-8')), payload);
} finally {
await fixture.close();
await rm(capturePath, { force: true });
}
});
test('Kiro CLI status accepts either contract and requires both runtime scripts', async () => {
const fixture = await createFixture();
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
const status = await runPlugin(fixture.homeDir, 'status', null);
assert.equal(status.code, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).installed, true);
await rm(fixture.v3Path);
const v2Only = await runPlugin(fixture.homeDir, 'status', null);
const v2Status = JSON.parse(v2Only.stdout);
assert.equal(v2Status.installed, true);
assert.equal(v2Status.configPath, fixture.v2Path);
const reinstall = await runPlugin(fixture.homeDir, 'install', fixture.config);
assert.equal(reinstall.code, 0, reinstall.stderr);
await rm(fixture.v2Path);
const v3Only = await runPlugin(fixture.homeDir, 'status', null);
const v3Status = JSON.parse(v3Only.stdout);
assert.equal(v3Status.installed, true);
assert.equal(v3Status.configPath, fixture.v3Path);
await rm(fixture.guardScriptPath);
const degraded = await runPlugin(fixture.homeDir, 'status', null);
assert.equal(JSON.parse(degraded.stdout).installed, false);
} finally {
await fixture.close();
}
});
test('Kiro CLI status surfaces malformed referenced runtime metadata', async () => {
const fixture = await createFixture();
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
await writeFile(fixture.runtimeConfigPath, '{ malformed');
const status = await runPlugin(fixture.homeDir, 'status', null);
assert.equal(status.code, 1);
assert.match(status.stderr, /parse Elydora runtime config/i);
} finally {
await fixture.close();
}
});
test('Kiro CLI uninstall preserves unrelated v2 and v3 hooks', async () => {
const fixture = await createFixture({
existingV2: { hooks: { preToolUse: [{ matcher: 'read', command: 'existing-v2' }] } },
existingV3: {
version: 'v1',
hooks: [{
name: 'existing-v3',
trigger: 'SessionStart',
action: { type: 'command', command: 'existing-command' },
}],
},
});
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
const result = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1');
assert.equal(result.code, 0, result.stderr);
const v2 = JSON.parse(await readFile(fixture.v2Path, 'utf-8'));
assert.deepEqual(v2.hooks.preToolUse, [{ matcher: 'read', command: 'existing-v2' }]);
assert.deepEqual(v2.hooks.postToolUse, []);
const v3 = JSON.parse(await readFile(fixture.v3Path, 'utf-8'));
assert.equal(v3.hooks.length, 1);
assert.equal(v3.hooks[0].name, 'existing-v3');
} finally {
await fixture.close();
}
});
test('Kiro CLI uninstall removes configs owned entirely by Elydora', async () => {
const fixture = await createFixture();
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
const result = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1');
assert.equal(result.code, 0, result.stderr);
await assert.rejects(readFile(fixture.v2Path), { code: 'ENOENT' });
await assert.rejects(readFile(fixture.v3Path), { code: 'ENOENT' });
} finally {
await fixture.close();
}
});
test('Kiro CLI install preserves malformed configs for recovery', async () => {
const fixture = await createFixture({ existingV3: '{ malformed' });
try {
assert.equal(fixture.installResult.code, 1);
assert.match(fixture.installResult.stderr, /parse Kiro CLI v3 hooks config/i);
assert.equal(await readFile(fixture.v3Path, 'utf-8'), '{ malformed');
} finally {
await fixture.close();
}
const v2Fixture = await createFixture({ existingV2: '{ malformed' });
try {
assert.equal(v2Fixture.installResult.code, 1);
assert.match(v2Fixture.installResult.stderr, /parse Kiro CLI v2 agent config/i);
assert.equal(await readFile(v2Fixture.v2Path, 'utf-8'), '{ malformed');
} finally {
await v2Fixture.close();
}
});