feat: sync Node Kimi hook integration
This commit is contained in:
1 parent
aeedbebd02
commit
cb9f33c255
9 files changed
+976
-4
No files matched your search
@@ -22,6 +22,7 @@ This repository publishes the open-source server, console, integration catalog,
|
|||||||
- Use each provider's documented blocking mechanism and failure mode.
|
- Use each provider's documented blocking mechanism and failure mode.
|
||||||
- Report installation as healthy only when a complete contract references all required runtime files.
|
- 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.
|
- Keep stable, legacy, and early-access contracts explicit, including their activation commands.
|
||||||
|
- Select Kimi Code and legacy kimi-cli config targets from existing runtime evidence; a fresh installation targets current Kimi Code and keeps migration markers isolated to active runtimes.
|
||||||
|
|
||||||
## Mirror Workflow
|
## Mirror Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -289,11 +289,11 @@
|
|||||||
"failure_mode": "fail_open"
|
"failure_mode": "fail_open"
|
||||||
},
|
},
|
||||||
"adapters": {
|
"adapters": {
|
||||||
"node": false,
|
"node": true,
|
||||||
"python": false,
|
"python": false,
|
||||||
"go": false
|
"go": false
|
||||||
},
|
},
|
||||||
"delivery_state": "planned",
|
"delivery_state": "partial",
|
||||||
"source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks",
|
"source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks",
|
||||||
"contract_variants": [
|
"contract_variants": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,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.
|
||||||
|
|
||||||
|
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`.
|
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
|
||||||
@@ -71,6 +73,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera
|
|||||||
|-------|-----|
|
|-------|-----|
|
||||||
| Claude Code | `claudecode` |
|
| Claude Code | `claudecode` |
|
||||||
| OpenAI Codex | `codex` |
|
| OpenAI Codex | `codex` |
|
||||||
|
| Kimi Code | `kimi` |
|
||||||
| Cursor | `cursor` |
|
| Cursor | `cursor` |
|
||||||
| Gemini CLI | `gemini` |
|
| Gemini CLI | `gemini` |
|
||||||
| Kiro CLI | `kirocli` |
|
| Kiro CLI | `kirocli` |
|
||||||
|
|||||||
Generated
+12
@@ -8,6 +8,9 @@
|
|||||||
"name": "@elydora/sdk",
|
"name": "@elydora/sdk",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@decimalturn/toml-patch": "^2.0.0"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"elydora": "dist/cli.js"
|
"elydora": "dist/cli.js"
|
||||||
},
|
},
|
||||||
@@ -19,6 +22,15 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.13",
|
"version": "22.19.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
|
||||||
|
|||||||
@@ -21,7 +21,10 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import { opencodePlugin } from './plugins/opencode.js';
|
|||||||
import { copilotPlugin } from './plugins/copilot.js';
|
import { copilotPlugin } from './plugins/copilot.js';
|
||||||
import { lettaPlugin } from './plugins/letta.js';
|
import { lettaPlugin } from './plugins/letta.js';
|
||||||
import { codexPlugin } from './plugins/codex.js';
|
import { codexPlugin } from './plugins/codex.js';
|
||||||
|
import { kimiPlugin } from './plugins/kimi.js';
|
||||||
|
|
||||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
|||||||
['copilot', copilotPlugin],
|
['copilot', copilotPlugin],
|
||||||
['letta', lettaPlugin],
|
['letta', lettaPlugin],
|
||||||
['codex', codexPlugin],
|
['codex', codexPlugin],
|
||||||
|
['kimi', kimiPlugin],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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<string>([
|
||||||
|
...SHARED_EVENTS,
|
||||||
|
'PermissionRequest',
|
||||||
|
'PermissionResult',
|
||||||
|
'Interrupt',
|
||||||
|
]);
|
||||||
|
const LEGACY_EVENTS = new Set<string>(SHARED_EVENTS);
|
||||||
|
|
||||||
|
type TomlObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<KimiContract[]> {
|
||||||
|
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<KimiConfigDocument> {
|
||||||
|
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<KimiConfigDocument[]> {
|
||||||
|
const documents: KimiConfigDocument[] = [];
|
||||||
|
for (const contract of await resolveContracts()) documents.push(await readConfig(contract));
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHooks(document: KimiConfigDocument, hooks: KimiHook[]): Promise<string> {
|
||||||
|
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<never> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<TomlObject | undefined> {
|
||||||
|
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<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 (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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<PluginStatus> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -14,4 +14,5 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
|
|||||||
['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }],
|
['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }],
|
||||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||||
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||||
|
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
|
||||||
]);
|
]);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user