fix: sync Node Kiro CLI hook contracts

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 05:05:10 -04:00
1 parent d33b06b25b
commit ea903cfac5
5 files changed
+707 -122

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# Elydora Open Source Engineering Contract
## Scope
This repository publishes the open-source server, console, integration catalog, and distributable Node.js, Python, and Go SDK mirrors.
## Sources of Truth
- `integrations/catalog.json` owns provider IDs, official hook contracts, blocking semantics, release variants, and adapter delivery state.
- Official provider documentation owns every external hook API fact.
- The standalone SDK repositories own released package behavior. Mirror a reviewed standalone commit here without semantic drift.
- Backend schemas own persisted data contracts. Shared types and Console consumers must follow those schemas.
## Integration Invariants
- Verify high-drift provider contracts against current official documentation before changing the catalog or an adapter.
- 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.
- Use atomic same-directory writes for provider configuration.
- Forward official hook JSON from STDIN without reshaping provider fields.
- Use each provider's documented blocking mechanism and failure mode.
- Report installation as healthy only when a complete contract references all required runtime files.
- Keep stable, legacy, and early-access contracts explicit, including their activation commands.
## Mirror Workflow
1. Complete and push the standalone SDK change with focused and full release gates.
2. Confirm the pre-change mirror blob matches the standalone parent commit.
3. Mirror source, registry, README guidance, and executable regression tests.
4. Confirm the mirrored source and tests match the standalone commit where repository-specific differences are absent.
5. Run the SDK's focused and full gates plus `npm run validate:integrations` at the repository root.
6. Commit and push one root issue before starting the next mirror or product surface.
## Code Quality
- Keep production source files at or below 500 lines.
- Keep functions focused on one ownership boundary.
- Propagate unexpected errors to the CLI or request boundary.
- Keep technical claims executable through tests or generated validation.
- Avoid compatibility shims without a named public contract.
- Preserve the minimum runtime versions declared by each package.
## Verification
Node SDK:
```powershell
cd sdks/node
npm run build
node --test test/<provider>-plugin.test.mjs
npm test
npm audit --omit=dev --audit-level=high
npm pack --dry-run --json
```
Python SDK:
```powershell
cd sdks/python
py -3 -m pytest tests/test_<provider>_plugin.py -q
py -3 -m pytest -q
py -3 -m mypy elydora
py -3 -m pip check
```
Go SDK:
```powershell
cd sdks/go
go test ./cmd/elydora/plugins -run <Provider> -count=1
go test ./...
go test -race ./...
go vet ./...
govulncheck ./...
```
Repository catalog:
```powershell
npm run validate:integrations
git diff --check
```
+2
View File
@@ -52,6 +52,8 @@ npx elydora install \
--kid agent-456-key-v1 --kid agent-456-key-v1
``` ```
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`.
### Commands ### Commands
| Command | Description | | Command | Description |
+307 -121
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);
}
return true;
} }
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> { function quotePosix(value: string): string {
return arr.filter((entry) => { return `'${value.replaceAll("'", `'"'"'`)}'`;
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); function quoteWindows(value: string): string {
return `"${value.replaceAll('"', '\\"')}"`;
} }
return true;
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;
} }
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 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'),
); );
} const audit = findV2Command(
return typeof entry.command === 'string' && entry.command.includes('elydora'); hookEntries(hooks, 'postToolUse', 'Kiro CLI v2 agent config'),
}); 'hook.js',
}; );
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse); if (guard && audit) contracts.push({ guard, audit, configPath: v2Path });
// 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();
}
});