mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat: support hooks in plugins (#1127)
* feat(agent-core): support per-hook cwd and env in HookEngine * feat(agent-core): support hooks in plugin manifest and aggregate via PluginManager * feat(agent-core): merge plugin hooks into session hook engine * chore: add changeset for plugin hooks
This commit is contained in:
parent
28d358b526
commit
184acf5db5
11 changed files with 220 additions and 11 deletions
5
.changeset/plugin-hooks.md
Normal file
5
.changeset/plugin-hooks.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Plugins can now declare hooks in their manifest to run scripts on lifecycle events.
|
||||
|
|
@ -4,6 +4,7 @@ import path from 'node:path';
|
|||
|
||||
import type { McpServerConfig } from '../config/schema';
|
||||
import { discoverSkills, type SkillRoot } from '../skill';
|
||||
import type { HookDef } from '../session/hooks';
|
||||
import { downloadZip, extractZip } from './archive';
|
||||
import { resolveGithubSource } from './github-resolver';
|
||||
import { parseManifest, type ParsedManifestResult } from './manifest';
|
||||
|
|
@ -239,6 +240,21 @@ export class PluginManager {
|
|||
return out;
|
||||
}
|
||||
|
||||
enabledHooks(): readonly HookDef[] {
|
||||
const out: HookDef[] = [];
|
||||
for (const record of this.records.values()) {
|
||||
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
|
||||
for (const hook of record.manifest.hooks ?? []) {
|
||||
out.push({
|
||||
...hook,
|
||||
cwd: record.root,
|
||||
env: { KIMI_CODE_HOME: this.kimiHomeDir, KIMI_PLUGIN_ROOT: record.root },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
summaries(): readonly PluginSummary[] {
|
||||
return this.list().map((record) => recordToSummary(record));
|
||||
}
|
||||
|
|
@ -362,6 +378,7 @@ function recordToSummary(record: PluginRecord): PluginSummary {
|
|||
skillCount: record.skillCount,
|
||||
mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length,
|
||||
enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length,
|
||||
hookCount: record.manifest?.hooks?.length ?? 0,
|
||||
hasErrors: record.diagnostics.some((d) => d.severity === 'error'),
|
||||
source: record.source,
|
||||
originalSource: record.originalSource,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { realpath, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpServerConfigSchema, type McpServerConfig } from '../config/schema';
|
||||
import {
|
||||
HookDefSchema,
|
||||
McpServerConfigSchema,
|
||||
type HookDefConfig,
|
||||
type McpServerConfig,
|
||||
} from '../config/schema';
|
||||
import {
|
||||
PLUGIN_NAME_REGEX,
|
||||
type PluginDiagnostic,
|
||||
|
|
@ -19,7 +24,6 @@ const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json';
|
|||
const UNSUPPORTED_RUNTIME_FIELDS = [
|
||||
'tools',
|
||||
'commands',
|
||||
'hooks',
|
||||
'apps',
|
||||
'inject',
|
||||
'configFile',
|
||||
|
|
@ -121,6 +125,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
|
|||
skills,
|
||||
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
|
||||
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
|
||||
hooks: readHooks(raw['hooks'], diagnostics),
|
||||
interface: readInterface(raw['interface']),
|
||||
skillInstructions,
|
||||
};
|
||||
|
|
@ -284,6 +289,30 @@ async function readMcpServers(
|
|||
return Object.keys(out).length === 0 ? undefined : out;
|
||||
}
|
||||
|
||||
function readHooks(
|
||||
raw: unknown,
|
||||
diagnostics: PluginDiagnostic[],
|
||||
): readonly HookDefConfig[] | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (!Array.isArray(raw)) {
|
||||
diagnostics.push({ severity: 'warn', message: '"hooks" must be an array' });
|
||||
return undefined;
|
||||
}
|
||||
const out: HookDefConfig[] = [];
|
||||
raw.forEach((entry, i) => {
|
||||
const parsed = HookDefSchema.safeParse(entry);
|
||||
if (!parsed.success) {
|
||||
diagnostics.push({
|
||||
severity: 'warn',
|
||||
message: `Invalid hook at index ${i}: ${parsed.error.message}`,
|
||||
});
|
||||
} else {
|
||||
out.push(parsed.data);
|
||||
}
|
||||
});
|
||||
return out.length === 0 ? undefined : out;
|
||||
}
|
||||
|
||||
async function normalizePluginMcpServer(input: {
|
||||
readonly pluginRoot: string;
|
||||
readonly name: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { McpServerConfig } from '../config/schema';
|
||||
import type { HookDefConfig, McpServerConfig } from '../config/schema';
|
||||
|
||||
export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info';
|
||||
|
||||
|
|
@ -35,6 +35,7 @@ export interface PluginManifest {
|
|||
readonly skills?: readonly string[]; // resolved absolute paths
|
||||
readonly sessionStart?: PluginSessionStart;
|
||||
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
|
||||
readonly hooks?: readonly HookDefConfig[];
|
||||
readonly interface?: PluginInterface;
|
||||
readonly skillInstructions?: string;
|
||||
}
|
||||
|
|
@ -105,6 +106,7 @@ export interface PluginSummary {
|
|||
readonly skillCount: number;
|
||||
readonly mcpServerCount: number;
|
||||
readonly enabledMcpServerCount: number;
|
||||
readonly hookCount: number;
|
||||
readonly hasErrors: boolean;
|
||||
readonly source: PluginSource;
|
||||
readonly originalSource?: string;
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
|
||||
providerManager: this.resolveProviderManager(summary.id),
|
||||
background: config.background,
|
||||
hooks: config.hooks,
|
||||
hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()],
|
||||
permissionRules: config.permission?.rules,
|
||||
skills: this.resolveSessionSkillConfig(config),
|
||||
mcpConfig,
|
||||
|
|
@ -412,7 +412,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
|
||||
providerManager: this.resolveProviderManager(summary.id),
|
||||
background: config.background,
|
||||
hooks: config.hooks,
|
||||
hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()],
|
||||
permissionRules: config.permission?.rules,
|
||||
skills: this.resolveSessionSkillConfig(config),
|
||||
mcpConfig,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ export class HookEngine {
|
|||
matched.map((hook) =>
|
||||
runHook(hook.command, inputData, {
|
||||
timeout: hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS,
|
||||
cwd: this.options.cwd === '' ? undefined : this.options.cwd,
|
||||
cwd: hook.cwd ?? (this.options.cwd === '' ? undefined : this.options.cwd),
|
||||
env: hook.env,
|
||||
signal: args.signal,
|
||||
}),
|
||||
),
|
||||
|
|
@ -96,13 +97,14 @@ export class HookEngine {
|
|||
}
|
||||
|
||||
private matchingHooks(event: string, matcherValue: string): HookDef[] {
|
||||
const seenCommands = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const matched: HookDef[] = [];
|
||||
|
||||
for (const hook of this.byEvent.get(event) ?? []) {
|
||||
if (!matches(hook.matcher ?? '', matcherValue)) continue;
|
||||
if (seenCommands.has(hook.command)) continue;
|
||||
seenCommands.add(hook.command);
|
||||
const key = (hook.cwd ?? '') + '\0' + hook.command;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
matched.push(hook);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { HookResult } from './types';
|
|||
export interface RunHookOptions {
|
||||
readonly timeout: number;
|
||||
readonly cwd?: string;
|
||||
readonly env?: Readonly<Record<string, string>>;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ export async function runHook(
|
|||
cwd: options.cwd,
|
||||
stdio: 'pipe',
|
||||
detached: process.platform !== 'win32',
|
||||
env: options.env ? { ...process.env, ...options.env } : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return allowResult({ stderr: errorMessage(error) });
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export interface HookDef {
|
|||
readonly matcher?: string;
|
||||
readonly command: string;
|
||||
readonly timeout?: number;
|
||||
readonly cwd?: string;
|
||||
readonly env?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface HookResult {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { realpathSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ContentPart } from '@moonshot-ai/kosong';
|
||||
|
||||
|
|
@ -10,6 +13,8 @@ type HookDef = {
|
|||
matcher?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
interface HookResult {
|
||||
|
|
@ -349,4 +354,46 @@ describe('HookEngine', () => {
|
|||
spy?.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('runs a hook with HookDef.cwd as the working directory', async () => {
|
||||
const { HookEngine } = await importEngine();
|
||||
const pluginCwd = tmpdir();
|
||||
const engine = new HookEngine(
|
||||
[
|
||||
{
|
||||
event: 'PreToolUse',
|
||||
command: 'node -e "process.stdout.write(process.cwd())"',
|
||||
timeout: 5,
|
||||
cwd: pluginCwd,
|
||||
},
|
||||
],
|
||||
{ cwd: process.cwd() },
|
||||
);
|
||||
const results = await engine.trigger('PreToolUse', { inputData: {} });
|
||||
expect(results[0]?.stdout).toBe(realpathSync(pluginCwd));
|
||||
});
|
||||
|
||||
it('passes HookDef.env into the hook process environment', async () => {
|
||||
const { HookEngine } = await importEngine();
|
||||
const engine = new HookEngine([
|
||||
{
|
||||
event: 'PreToolUse',
|
||||
command: 'node -e "process.stdout.write(process.env.KIMI_PLUGIN_TEST ?? \'missing\')"',
|
||||
timeout: 5,
|
||||
env: { KIMI_PLUGIN_TEST: 'plugin-value' },
|
||||
},
|
||||
]);
|
||||
const results = await engine.trigger('PreToolUse', { inputData: {} });
|
||||
expect(results[0]?.stdout).toBe('plugin-value');
|
||||
});
|
||||
|
||||
it('does not dedupe hooks that share a command but have different cwd', async () => {
|
||||
const { HookEngine } = await importEngine();
|
||||
const engine = new HookEngine([
|
||||
{ event: 'Stop', command: 'echo same', timeout: 5, cwd: process.cwd() },
|
||||
{ event: 'Stop', command: 'echo same', timeout: 5, cwd: tmpdir() },
|
||||
]);
|
||||
const results = await engine.trigger('Stop', { inputData: {} });
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ async function makePlugin(
|
|||
version?: string;
|
||||
sessionStartSkill?: string;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
hooks?: readonly unknown[];
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), `plugin-${name}-`));
|
||||
|
|
@ -49,6 +50,9 @@ async function makePlugin(
|
|||
if (options.mcpServers !== undefined) {
|
||||
manifest['mcpServers'] = options.mcpServers;
|
||||
}
|
||||
if (options.hooks !== undefined) {
|
||||
manifest['hooks'] = options.hooks;
|
||||
}
|
||||
await writeFile(
|
||||
path.join(root, 'kimi.plugin.json'),
|
||||
JSON.stringify(manifest),
|
||||
|
|
@ -853,6 +857,52 @@ describe('PluginManager', () => {
|
|||
expect(updated.github?.ref).toEqual({ kind: 'tag', value: 'v5.1.0' });
|
||||
expect(manager.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('enabledHooks() returns hooks from enabled plugins with cwd and env injected', async () => {
|
||||
const home = await makeKimiHome();
|
||||
const root = await makePlugin('demo', {
|
||||
hooks: [{ event: 'PreToolUse', command: './hooks/guard.sh', timeout: 10 }],
|
||||
});
|
||||
const manager = new PluginManager({ kimiHomeDir: home });
|
||||
await manager.load();
|
||||
await manager.install(root);
|
||||
const installedRoot = await managedPluginRoot(home, 'demo');
|
||||
expect(manager.enabledHooks()).toEqual([
|
||||
{
|
||||
event: 'PreToolUse',
|
||||
command: './hooks/guard.sh',
|
||||
timeout: 10,
|
||||
cwd: installedRoot,
|
||||
env: { KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: installedRoot },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('enabledHooks() excludes disabled plugins', async () => {
|
||||
const home = await makeKimiHome();
|
||||
const root = await makePlugin('demo', {
|
||||
hooks: [{ event: 'PreToolUse', command: './x.sh' }],
|
||||
});
|
||||
const manager = new PluginManager({ kimiHomeDir: home });
|
||||
await manager.load();
|
||||
await manager.install(root);
|
||||
await manager.setEnabled('demo', false);
|
||||
expect(manager.enabledHooks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('summaries() include hookCount', async () => {
|
||||
const home = await makeKimiHome();
|
||||
const root = await makePlugin('demo', {
|
||||
hooks: [
|
||||
{ event: 'PreToolUse', command: './a.sh' },
|
||||
{ event: 'Stop', command: './b.sh' },
|
||||
],
|
||||
});
|
||||
const manager = new PluginManager({ kimiHomeDir: home });
|
||||
await manager.load();
|
||||
await manager.install(root);
|
||||
expect(manager.summaries()[0]?.hookCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
interface MockGithubFetchOptions {
|
||||
|
|
|
|||
|
|
@ -223,7 +223,6 @@ describe('parseManifest', () => {
|
|||
config_file: 'legacy-cfg.json',
|
||||
inject: { foo: 'bar' },
|
||||
bootstrap: { skill: 'using-demo' },
|
||||
hooks: { sessionStart: { skill: 'using-demo' } },
|
||||
apps: './apps',
|
||||
}),
|
||||
});
|
||||
|
|
@ -236,7 +235,6 @@ describe('parseManifest', () => {
|
|||
'config_file',
|
||||
'inject',
|
||||
'bootstrap',
|
||||
'hooks',
|
||||
'apps',
|
||||
]) {
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
|
|
@ -366,4 +364,59 @@ describe('parseManifest', () => {
|
|||
expect(result.manifest?.interface?.displayName).toBe('Demo');
|
||||
expect(result.manifest?.interface?.shortDescription).toBe('A demo.');
|
||||
});
|
||||
|
||||
it('parses a flat hooks array from the manifest', async () => {
|
||||
const root = await makePlugin({
|
||||
'kimi.plugin.json': JSON.stringify({
|
||||
name: 'demo',
|
||||
hooks: [
|
||||
{ event: 'PreToolUse', matcher: 'Bash', command: './hooks/guard.sh', timeout: 10 },
|
||||
{ event: 'UserPromptSubmit', command: 'node ./hooks/log.js' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const result = await parseManifest(root);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.manifest?.hooks).toEqual([
|
||||
{ event: 'PreToolUse', matcher: 'Bash', command: './hooks/guard.sh', timeout: 10 },
|
||||
{ event: 'UserPromptSubmit', command: 'node ./hooks/log.js' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns and skips a hook entry that is missing required fields', async () => {
|
||||
const root = await makePlugin({
|
||||
'kimi.plugin.json': JSON.stringify({
|
||||
name: 'demo',
|
||||
hooks: [{ event: 'PreToolUse' }],
|
||||
}),
|
||||
});
|
||||
const result = await parseManifest(root);
|
||||
expect(result.manifest?.hooks).toBeUndefined();
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ severity: 'warn', message: expect.stringContaining('index 0') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when hooks is not an array', async () => {
|
||||
const root = await makePlugin({
|
||||
'kimi.plugin.json': JSON.stringify({ name: 'demo', hooks: { event: 'Stop', command: 'x' } }),
|
||||
});
|
||||
const result = await parseManifest(root);
|
||||
expect(result.manifest?.hooks).toBeUndefined();
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ severity: 'warn', message: '"hooks" must be an array' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a hook entry that sets cwd/env (strict schema)', async () => {
|
||||
const root = await makePlugin({
|
||||
'kimi.plugin.json': JSON.stringify({
|
||||
name: 'demo',
|
||||
hooks: [{ event: 'PreToolUse', command: './x.sh', cwd: '/tmp' }],
|
||||
}),
|
||||
});
|
||||
const result = await parseManifest(root);
|
||||
expect(result.manifest?.hooks).toBeUndefined();
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ severity: 'warn' }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue