diff --git a/README.md b/README.md index 7a30653..063b166 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ npx elydora install \ |-------|-----| | Claude Code | `claudecode` | | OpenAI Codex | `codex` | +| Kimi Code | `kimi` | | Cursor | `cursor` | | Gemini CLI | `gemini` | | Kiro CLI | `kirocli` | @@ -77,6 +78,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. +Kimi installation writes the strict hook contract to each detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the migrating Python CLI's `~/.kimi/config.toml`. A fresh installation targets current Kimi Code, avoiding cross-runtime migration markers. Both runtimes load the hooks globally; run `/hooks` to inspect them. + 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 diff --git a/package-lock.json b/package-lock.json index 7daf8c4..4bac06f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@elydora/sdk", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@elydora/sdk", - "version": "1.2.0", + "version": "1.2.1", "license": "MIT", + "dependencies": { + "@decimalturn/toml-patch": "^2.0.0" + }, "bin": { "elydora": "dist/cli.js" }, @@ -19,6 +22,15 @@ "node": ">=18.0.0" } }, + "node_modules/@decimalturn/toml-patch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@decimalturn/toml-patch/-/toml-patch-2.0.0.tgz", + "integrity": "sha512-nVU9bcndpePHaDalbqA3Rbx/46onD9fcKgUI5dUsvbIpFMYXp/78ScUwdFUCXjcjN9DD+pNitTfDiwJJTPb4ig==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@types/node": { "version": "22.19.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", diff --git a/package.json b/package.json index b2f42be..caae0f4 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ }, "license": "MIT", "devDependencies": { - "typescript": "^5.7.0", - "@types/node": "^22.0.0" + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "dependencies": { + "@decimalturn/toml-patch": "^2.0.0" } } diff --git a/src/cli.ts b/src/cli.ts index f8abb58..502253a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { opencodePlugin } from './plugins/opencode.js'; import { copilotPlugin } from './plugins/copilot.js'; import { lettaPlugin } from './plugins/letta.js'; import { codexPlugin } from './plugins/codex.js'; +import { kimiPlugin } from './plugins/kimi.js'; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); @@ -28,6 +29,7 @@ const PLUGINS: ReadonlyMap = new Map([ ['copilot', copilotPlugin], ['letta', lettaPlugin], ['codex', codexPlugin], + ['kimi', kimiPlugin], ]); // --------------------------------------------------------------------------- diff --git a/src/plugins/kimi.ts b/src/plugins/kimi.ts new file mode 100644 index 0000000..10b1488 --- /dev/null +++ b/src/plugins/kimi.ts @@ -0,0 +1,491 @@ +import { randomUUID } from 'node:crypto'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { SUPPORTED_AGENTS } from './registry.js'; + +const AGENT_KEY = 'kimi'; +const GUARD_SCRIPT = 'guard.js'; +const AUDIT_SCRIPT = 'hook.js'; +const HOOK_TIMEOUT_SECONDS = 10; +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +const SHARED_EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'UserPromptSubmit', + 'Stop', + 'StopFailure', + 'SessionStart', + 'SessionEnd', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'Notification', +] as const; + +const MODERN_EVENTS = new Set([ + ...SHARED_EVENTS, + 'PermissionRequest', + 'PermissionResult', + 'Interrupt', +]); +const LEGACY_EVENTS = new Set(SHARED_EVENTS); + +type TomlObject = Record; + +interface KimiHook extends TomlObject { + readonly event: string; + readonly command: string; + readonly matcher?: string; + readonly timeout?: number; +} + +interface KimiContract { + readonly runtimeName: 'Kimi Code' | 'kimi-cli'; + readonly label: string; + readonly configPath: string; + readonly events: ReadonlySet; +} + +interface KimiConfigDocument { + readonly contract: KimiContract; + readonly exists: boolean; + readonly raw: string; + readonly root: TomlObject; + readonly hooks: KimiHook[]; + readonly usesHookTables: boolean; +} + +interface RuntimeContract { + readonly guard: string; + readonly audit: string; + readonly configPath: string; +} + +type ConfigMutation = + | { readonly kind: 'none' } + | { readonly kind: 'remove'; readonly document: KimiConfigDocument } + | { readonly kind: 'write'; readonly document: KimiConfigDocument; readonly raw: string }; + +function isObject(value: unknown): value is TomlObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOwn(value: TomlObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isObject(error) && error.code === code; +} + +async function pathExists(filePath: string, label: string): Promise { + try { + await fsp.stat(filePath); + return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; + throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) }); + } +} + +async function legacyCliOnPath(): Promise { + const pathValue = process.env.PATH; + if (!pathValue) return false; + const names = process.platform === 'win32' + ? ['kimi-cli.exe', 'kimi-cli.cmd', 'kimi-cli.bat', 'kimi-cli.com', 'kimi-cli'] + : ['kimi-cli']; + for (const value of pathValue.split(path.delimiter)) { + const directory = value.replace(/^"(.*)"$/, '$1'); + if (!directory) continue; + for (const name of names) { + const candidate = path.join(directory, name); + try { + const stats = await fsp.stat(candidate); + if (stats.isFile() && (process.platform === 'win32' || (stats.mode & 0o111) !== 0)) return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) continue; + throw new Error(`Inspect kimi-cli executable at ${candidate}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + } + } + return false; +} + +async function resolveContracts(): Promise { + const home = os.homedir(); + const explicitKimiHome = process.env.KIMI_CODE_HOME || undefined; + const kimiHome = explicitKimiHome ?? path.join(home, '.kimi-code'); + const modern: KimiContract = { + runtimeName: 'Kimi Code', + label: 'Kimi Code hooks config', + configPath: path.join(kimiHome, 'config.toml'), + events: MODERN_EVENTS, + }; + const legacy: KimiContract = { + runtimeName: 'kimi-cli', + label: 'kimi-cli legacy hooks config', + configPath: path.join(home, '.kimi', 'config.toml'), + events: LEGACY_EVENTS, + }; + const modernDetected = explicitKimiHome !== undefined || await pathExists(kimiHome, 'Kimi Code home'); + const legacyDetected = await pathExists(legacy.configPath, legacy.label) || await legacyCliOnPath(); + if (legacyDetected && !modernDetected) return [legacy]; + return legacyDetected ? [modern, legacy] : [modern]; +} + +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)}`; +} + +function buildHook(event: 'PreToolUse' | 'PostToolUse', scriptPath: string): KimiHook { + return { event, command: buildCommand(scriptPath), timeout: HOOK_TIMEOUT_SECONDS }; +} + +function validateHook(value: unknown, contract: KimiContract, index: number): KimiHook { + if (!isObject(value)) { + throw new Error(`${contract.label} hook ${index + 1} must be a table`); + } + const supportedFields = new Set(['event', 'matcher', 'command', 'timeout']); + const unsupportedField = Object.keys(value).find((key) => !supportedFields.has(key)); + if (unsupportedField) { + throw new Error(`${contract.label} hook ${index + 1} has unsupported field "${unsupportedField}"`); + } + if (typeof value.event !== 'string' || !contract.events.has(value.event)) { + throw new Error(`${contract.label} hook ${index + 1} has unsupported event "${String(value.event)}"`); + } + if (typeof value.command !== 'string' || value.command.length === 0) { + throw new Error(`${contract.label} hook ${index + 1} requires a non-empty command`); + } + if (value.matcher !== undefined && typeof value.matcher !== 'string') { + throw new Error(`${contract.label} hook ${index + 1} matcher must be a string`); + } + if ( + value.timeout !== undefined + && (!Number.isInteger(value.timeout) || (value.timeout as number) < 1 || (value.timeout as number) > 600) + ) { + throw new Error(`${contract.label} hook ${index + 1} timeout must be an integer from 1 to 600`); + } + return value as KimiHook; +} + +function readHooks(root: TomlObject, contract: KimiContract): KimiHook[] { + if (root.hooks === undefined) return []; + if (!Array.isArray(root.hooks)) throw new Error(`${contract.label} field "hooks" must be an array`); + return root.hooks.map((hook, index) => validateHook(hook, contract, index)); +} + +function documentUsesHookTables(nodes: readonly unknown[]): boolean { + return nodes.some((node) => { + if (!isObject(node) || node.type !== 'TableArray' || !isObject(node.key)) return false; + if (!isObject(node.key.item) || !Array.isArray(node.key.item.value)) return false; + return node.key.item.value.length === 1 && node.key.item.value[0] === 'hooks'; + }); +} + +async function readConfig(contract: KimiContract): Promise { + let raw: string; + try { + raw = await fsp.readFile(contract.configPath, 'utf-8'); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return { contract, exists: false, raw: '', root: {}, hooks: [], usesHookTables: false }; + } + throw new Error(`Read ${contract.label} at ${contract.configPath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + + let document: { readonly toJsObject: unknown; readonly cst: readonly unknown[] }; + try { + const { TomlDocument } = await import('@decimalturn/toml-patch'); + document = new TomlDocument(raw); + } catch (error) { + throw new Error(`Failed to parse ${contract.label} at ${contract.configPath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + const root: unknown = document.toJsObject; + if (!isObject(root)) throw new Error(`${contract.label} at ${contract.configPath} must contain a TOML table`); + return { + contract, + exists: true, + raw, + root, + hooks: readHooks(root, contract), + usesHookTables: documentUsesHookTables(document.cst), + }; +} + +async function readAllConfigs(): Promise { + const documents: KimiConfigDocument[] = []; + for (const contract of await resolveContracts()) documents.push(await readConfig(contract)); + return documents; +} + +async function renderHooks(document: KimiConfigDocument, hooks: KimiHook[]): Promise { + const { + patch: patchToml, + stringify: stringifyToml, + TomlFormat, + } = await import('@decimalturn/toml-patch'); + const nextRoot: TomlObject = { ...document.root, hooks }; + if (document.usesHookTables) return patchToml(document.raw, nextRoot); + + let base = document.raw; + if (hasOwn(document.root, 'hooks')) { + const rootWithoutHooks = { ...document.root }; + delete rootWithoutHooks.hooks; + base = patchToml(document.raw, rootWithoutHooks); + } + const format = document.raw.length > 0 + ? TomlFormat.autoDetectFormat(document.raw) + : TomlFormat.default(); + const hookTables = stringifyToml({ hooks }, format); + if (base.length === 0) return hookTables; + const newline = format.newLine; + const separator = base.endsWith(newline + newline) ? '' : base.endsWith(newline) ? newline : newline + newline; + return base + separator + hookTables; +} + +function normalizeForComparison(value: string): string { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +function commandReferencesPath(command: string, filePath: string): boolean { + return normalizeForComparison(command).includes(normalizeForComparison(filePath)); +} + +function isManagedHook( + hook: KimiHook, + event: 'PreToolUse' | 'PostToolUse', + scriptName: string, + agentId?: string, +): boolean { + if (hook.event !== event) return false; + if (agentId) { + const scriptPath = path.join(os.homedir(), '.elydora', agentId, scriptName); + return commandReferencesPath(hook.command, scriptPath); + } + const command = normalizeForComparison(hook.command); + const runtimeMarker = normalizeForComparison(`${path.sep}.elydora${path.sep}`); + const scriptMarker = normalizeForComparison(`${path.sep}${scriptName}`); + return command.includes(runtimeMarker) && command.includes(scriptMarker); +} + +function withoutManagedHooks(hooks: KimiHook[], agentId?: string): KimiHook[] { + return hooks.filter((hook) => !isManagedHook(hook, 'PreToolUse', GUARD_SCRIPT, agentId) + && !isManagedHook(hook, 'PostToolUse', AUDIT_SCRIPT, agentId)); +} + +async function failWrite( + handle: FileHandle | undefined, + tempPath: string, + document: KimiConfigDocument, + cause: unknown, +): Promise { + const errors = [asError(cause)]; + if (handle) { + try { + await handle.close(); + } catch (error) { + errors.push(asError(error)); + } + } + try { + await fsp.unlink(tempPath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error)); + } + const message = `Write ${document.contract.label} at ${document.contract.configPath}: ${errorMessage(cause)}`; + if (errors.length > 1) throw new AggregateError(errors, message); + throw new Error(message, { cause: errors[0] }); +} + +async function writeConfig(document: KimiConfigDocument, raw: string): Promise { + const configPath = document.contract.configPath; + const directory = path.dirname(configPath); + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + const tempPath = path.join(directory, `.${path.basename(configPath)}.${randomUUID()}.tmp`); + let handle: FileHandle | undefined; + try { + handle = await fsp.open(tempPath, 'wx', 0o600); + await handle.writeFile(raw, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await fsp.rename(tempPath, configPath); + } catch (error) { + await failWrite(handle, tempPath, document, error); + } +} + +async function removeConfig(document: KimiConfigDocument): Promise { + try { + await fsp.unlink(document.contract.configPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return; + throw new Error( + `Remove ${document.contract.label} at ${document.contract.configPath}: ${errorMessage(error)}`, + { cause: asError(error) }, + ); + } +} + +async function regularFileExists(filePath: string, label: string): Promise { + try { + return (await fsp.stat(filePath)).isFile(); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return false; + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) }); + } +} + +async function requireRuntime(filePath: string, label: string): Promise { + if (!filePath) throw new Error(`${label} path is required`); + if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); +} + +async function readJsonObject(filePath: string, label: string): Promise { + let raw: string; + try { + raw = await fsp.readFile(filePath, 'utf-8'); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return undefined; + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) }); + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`); + return value; +} + +function findRuntimeContract(document: KimiConfigDocument): RuntimeContract | undefined { + const guard = document.hooks.find((hook) => isManagedHook(hook, 'PreToolUse', GUARD_SCRIPT)); + const audit = document.hooks.find((hook) => isManagedHook(hook, 'PostToolUse', AUDIT_SCRIPT)); + return guard && audit + ? { guard: guard.command, audit: audit.command, configPath: document.contract.configPath } + : undefined; +} + +async function runtimeScriptsExist(contracts: RuntimeContract[]): Promise { + 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 (hasErrorCode(error, 'ENOENT')) return false; + throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + + for (const directory of entries) { + if (!directory.isDirectory()) continue; + const agentDir = path.join(root, directory.name); + const guardPath = path.join(agentDir, GUARD_SCRIPT); + const auditPath = path.join(agentDir, AUDIT_SCRIPT); + if (!contracts.some(({ guard, audit }) => commandReferencesPath(guard, guardPath) + && commandReferencesPath(audit, auditPath))) continue; + + const runtimeConfigPath = path.join(agentDir, 'config.json'); + const runtimeConfig = await readJsonObject(runtimeConfigPath, 'Elydora runtime config'); + if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue; + const [guardExists, auditExists] = await Promise.all([ + regularFileExists(guardPath, 'Elydora guard runtime'), + regularFileExists(auditPath, 'Elydora audit runtime'), + ]); + return guardExists && auditExists; + } + return false; +} + +async function applyMutation(mutation: ConfigMutation): Promise { + if (mutation.kind === 'write') await writeConfig(mutation.document, mutation.raw); + if (mutation.kind === 'remove') await removeConfig(mutation.document); +} + +export const kimiPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + if (!config.agentId) throw new Error('agentId is required'); + const documents = await readAllConfigs(); + await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); + await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); + const mutations: ConfigMutation[] = []; + for (const document of documents) { + const hooks = [ + ...withoutManagedHooks(document.hooks), + buildHook('PreToolUse', config.guardScriptPath), + buildHook('PostToolUse', config.hookScriptPath), + ]; + mutations.push({ kind: 'write', document, raw: await renderHooks(document, hooks) }); + } + for (const mutation of mutations) await applyMutation(mutation); + const runtimes = documents.map(({ contract }) => contract.runtimeName).join(' and '); + console.log(` ${runtimes}: global PreToolUse and PostToolUse hooks installed.`); + }, + + async uninstall(agentId?: string): Promise { + const documents = await readAllConfigs(); + const mutations: ConfigMutation[] = []; + for (const document of documents) { + const hooks = withoutManagedHooks(document.hooks, agentId); + if (hooks.length === document.hooks.length) { + mutations.push({ kind: 'none' }); + continue; + } + const raw = await renderHooks(document, hooks); + mutations.push(raw.trim().length === 0 + ? { kind: 'remove', document } + : { kind: 'write', document, raw }); + } + for (const mutation of mutations) await applyMutation(mutation); + }, + + async status(): Promise { + const documents = await readAllConfigs(); + const contracts = documents + .map(findRuntimeContract) + .filter((contract): contract is RuntimeContract => contract !== undefined); + const hookConfigured = contracts.length > 0; + const hookScriptExists = hookConfigured ? await runtimeScriptsExist(contracts) : false; + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath: contracts.at(-1)?.configPath ?? documents[0]!.contract.configPath, + }; + }, +}; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b42866b..e969362 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -14,4 +14,5 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }], ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], ['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }], + ['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }], ]); diff --git a/test/kimi-plugin.test.mjs b/test/kimi-plugin.test.mjs new file mode 100644 index 0000000..8df9ec0 --- /dev/null +++ b/test/kimi-plugin.test.mjs @@ -0,0 +1,459 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, mkdir, readFile, readdir, 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'; +import { parse as parseToml } from '@decimalturn/toml-patch'; + +const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi.js')).href; +const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +const cliPath = path.resolve('dist/cli.js'); + +function runNode(args, env, input = '', unset = []) { + return new Promise((resolve, reject) => { + const childEnv = { ...process.env, ...env }; + for (const key of unset) delete childEnv[key]; + const child = spawn(process.execPath, args, { + env: childEnv, + 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(fixture, method, argument) { + const script = ` + import { kimiPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await kimiPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + const env = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + PATH: fixture.binDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }; + if (fixture.explicitKimiHome) env.KIMI_CODE_HOME = fixture.kimiHome; + return runNode( + ['--input-type=module', '--eval', script], + env, + '', + fixture.explicitKimiHome ? [] : ['KIMI_CODE_HOME'], + ); +} + +async function writeOptional(filePath, contents) { + if (contents === undefined) return; + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents, 'utf-8'); +} + +async function createFixture({ + guardSource, + hookSource, + modernConfig, + legacyConfig, + legacyInstalled = true, + explicitKimiHome = true, +} = {}) { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-')); + const binDir = path.join(homeDir, 'bin'); + const kimiHome = explicitKimiHome + ? path.join(homeDir, 'custom-kimi-code') + : path.join(homeDir, '.kimi-code'); + const modernPath = path.join(kimiHome, 'config.toml'); + const legacyPath = path.join(homeDir, '.kimi', 'config.toml'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(agentDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + if (legacyInstalled) { + const executable = path.join(binDir, process.platform === 'win32' ? 'kimi-cli.cmd' : 'kimi-cli'); + await writeFile(executable, ''); + if (process.platform !== 'win32') await chmod(executable, 0o700); + } + 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(path.join(agentDir, 'config.json'), JSON.stringify({ + agent_id: 'agent-1', + agent_name: 'kimi', + })); + await writeOptional(modernPath, modernConfig); + await writeOptional(legacyPath, legacyConfig); + + const fixture = { + agentDir, + binDir, + guardScriptPath, + homeDir, + hookScriptPath, + kimiHome, + explicitKimiHome, + legacyPath, + modernPath, + async close() { + await rm(homeDir, { recursive: true, force: true }); + }, + }; + fixture.installResult = await runPlugin(fixture, 'install', { + agentName: 'kimi', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath, + hookScriptPath, + }); + return fixture; +} + +function managedHook(config, event, scriptName) { + return config.hooks.find( + (hook) => hook.event === event && hook.command.includes(scriptName), + ); +} + +function assertStrictHook(hook) { + assert.deepEqual(Object.keys(hook).sort(), ['command', 'event', 'timeout']); + assert.equal(hook.timeout, 10); +} + +test('Kimi is registered in the SDK and CLI', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('kimi'), { + name: 'Kimi Code', + configDir: '~/.kimi-code', + configFile: 'config.toml', + }); + + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-cli-')); + try { + const result = await runNode(['--no-warnings', cliPath, 'status'], { + HOME: homeDir, + USERPROFILE: homeDir, + KIMI_CODE_HOME: path.join(homeDir, '.kimi-code'), + }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Kimi Code \(kimi\)/); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Kimi install preserves both user configs and is idempotent', async () => { + const modernConfig = [ + '# modern user config', + 'default_model = "kimi-code/k3"', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "existing-modern"', + 'timeout = 30 # keep modern hook', + '', + ].join('\n'); + const legacyConfig = [ + '# legacy user config', + 'telemetry = false', + '', + '[[hooks]]', + 'event = "SessionEnd"', + 'command = "existing-legacy"', + '', + ].join('\n'); + const fixture = await createFixture({ modernConfig, legacyConfig }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.match(fixture.installResult.stdout, /Kimi Code and kimi-cli/i); + const secondInstall = await runPlugin(fixture, 'install', { + agentName: 'kimi', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(secondInstall.code, 0, secondInstall.stderr); + + for (const [configPath, comment, existingCommand] of [ + [fixture.modernPath, '# modern user config', 'existing-modern'], + [fixture.legacyPath, '# legacy user config', 'existing-legacy'], + ]) { + const raw = await readFile(configPath, 'utf-8'); + const config = parseToml(raw); + assert.match(raw, new RegExp(comment)); + assert(config.hooks.some((hook) => hook.command === existingCommand)); + assert.equal(config.hooks.length, 3); + assertStrictHook(managedHook(config, 'PreToolUse', 'guard.js')); + assertStrictHook(managedHook(config, 'PostToolUse', 'hook.js')); + } + await assert.rejects( + readFile(path.join(fixture.homeDir, '.kimi-code', 'config.toml')), + { code: 'ENOENT' }, + ); + } finally { + await fixture.close(); + } +}); + +test('Kimi Code install does not create a false legacy migration source', async () => { + const fixture = await createFixture({ legacyInstalled: false }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.equal(parseToml(await readFile(fixture.modernPath, 'utf-8')).hooks.length, 2); + await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Kimi Code treats an empty home override as the official default', async () => { + const fixture = await createFixture({ explicitKimiHome: false, legacyInstalled: false }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + fixture.explicitKimiHome = true; + fixture.kimiHome = ''; + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).installed, true); + } finally { + await fixture.close(); + } +}); + +test('Legacy kimi-cli install does not create a premature migration target', async () => { + const fixture = await createFixture({ explicitKimiHome: false }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.equal(parseToml(await readFile(fixture.legacyPath, 'utf-8')).hooks.length, 2); + await assert.rejects(readFile(fixture.modernPath), { code: 'ENOENT' }); + await rm(fixture.binDir, { recursive: true }); + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).installed, true); + } finally { + await fixture.close(); + } +}); + +test('Kimi commands block freezes and forward the official payload unchanged', async () => { + const capturePath = path.join(os.tmpdir(), `elydora-kimi-event-${process.pid}-${Date.now()}.json`); + const 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))); + `; + const fixture = await createFixture({ hookSource }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const config = parseToml(await readFile(fixture.modernPath, 'utf-8')); + const guard = managedHook(config, 'PreToolUse', 'guard.js'); + const audit = managedHook(config, 'PostToolUse', 'hook.js'); + const prePayload = { + hook_event_name: 'PreToolUse', + session_id: 'session-1', + cwd: fixture.homeDir, + tool_name: 'Bash', + tool_input: { command: 'echo test' }, + tool_call_id: 'call-1', + }; + const guardResult = await runCommand(guard.command, JSON.stringify(prePayload)); + assert.equal(guardResult.code, 2); + assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + + const postPayload = { + ...prePayload, + hook_event_name: 'PostToolUse', + tool_output: { output: 'test' }, + }; + const auditResult = await runCommand(audit.command, JSON.stringify(postPayload)); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf-8')), postPayload); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test('Kimi status accepts either runtime contract and requires both scripts', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + await rm(fixture.modernPath); + let status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).installed, true); + + const reinstall = await runPlugin(fixture, 'install', { + agentName: 'kimi', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(reinstall.code, 0, reinstall.stderr); + await rm(fixture.legacyPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, true); + + await rm(fixture.guardScriptPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + } finally { + await fixture.close(); + } +}); + +test('Kimi uninstall preserves user hooks in both contracts', async () => { + const userHook = [ + '# user hook', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "existing-command"', + 'timeout = 30 # keep timeout', + '', + ].join('\n'); + const fixture = await createFixture({ modernConfig: userHook, legacyConfig: userHook }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + for (const configPath of [fixture.modernPath, fixture.legacyPath]) { + const raw = await readFile(configPath, 'utf-8'); + const config = parseToml(raw); + assert.match(raw, /# user hook/); + assert.match(raw, /# keep timeout/); + assert.deepEqual(config.hooks.map((hook) => ({ ...hook })), [{ + event: 'SessionStart', + command: 'existing-command', + timeout: 30, + }]); + } + } finally { + await fixture.close(); + } +}); + +test('Kimi uninstall removes configs created entirely by Elydora', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + await assert.rejects(readFile(fixture.modernPath), { code: 'ENOENT' }); + await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Kimi parses every config before writing either contract', async () => { + const modernConfig = '# untouched modern\ndefault_model = "kimi-code/k3"\n'; + const legacyConfig = '[malformed'; + const fixture = await createFixture({ modernConfig, legacyConfig }); + try { + assert.equal(fixture.installResult.code, 1); + assert.match(fixture.installResult.stderr, /parse kimi-cli legacy hooks config/i); + assert.equal(await readFile(fixture.modernPath, 'utf-8'), modernConfig); + assert.equal(await readFile(fixture.legacyPath, 'utf-8'), legacyConfig); + } finally { + await fixture.close(); + } +}); + +test('Kimi rejects invalid hook shapes without rewriting user config', async () => { + const modernConfig = [ + '[[hooks]]', + 'event = "PreToolUse"', + 'command = "existing-command"', + 'cwd = "/tmp"', + '', + ].join('\n'); + const fixture = await createFixture({ modernConfig }); + try { + assert.equal(fixture.installResult.code, 1); + assert.match(fixture.installResult.stderr, /unsupported field "cwd"/i); + assert.equal(await readFile(fixture.modernPath, 'utf-8'), modernConfig); + await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Kimi install rejects missing runtimes before creating configs', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-missing-runtime-')); + const fixture = { + binDir: path.join(homeDir, 'bin'), + explicitKimiHome: true, + homeDir, + kimiHome: path.join(homeDir, 'custom-kimi-code'), + modernPath: path.join(homeDir, 'custom-kimi-code', 'config.toml'), + legacyPath: path.join(homeDir, '.kimi', 'config.toml'), + }; + try { + const result = await runPlugin(fixture, 'install', { + agentName: 'kimi', + agentId: 'agent-1', + guardScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'guard.js'), + hookScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'hook.js'), + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /runtime is missing/i); + await assert.rejects(readFile(fixture.modernPath), { code: 'ENOENT' }); + await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Kimi status surfaces malformed referenced runtime metadata', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); + +test('Kimi atomic writes leave no temporary files behind', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + for (const directory of [path.dirname(fixture.modernPath), path.dirname(fixture.legacyPath)]) { + assert.equal((await readdir(directory)).some((name) => name.endsWith('.tmp')), false); + } + } finally { + await fixture.close(); + } +});