mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
feat(hooks): Hook Configuration Schema and Types (#9074)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
This commit is contained in:
@@ -711,6 +711,9 @@ export async function loadCliConfig(
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors ?? false,
|
||||
ptyInfo: ptyInfo?.name,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks: settings.tools?.enableHooks ?? false,
|
||||
hooks: settings.hooks || {},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
disableUpdateNag: 'general.disableUpdateNag',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enableMessageBusIntegration: 'tools.enableMessageBusIntegration',
|
||||
enableHooks: 'tools.enableHooks',
|
||||
enablePromptCompletion: 'general.enablePromptCompletion',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
BugCommandSettings,
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
HookDefinition,
|
||||
HookEventName,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
@@ -878,6 +880,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable policy-based tool confirmation via message bus integration. When enabled, tools will automatically respect policy engine decisions (ALLOW/DENY/ASK_USER) without requiring individual tool implementations.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableHooks: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks System',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1199,6 +1211,18 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
type: 'object',
|
||||
label: 'Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {} as { [K in HookEventName]?: HookDefinition[] },
|
||||
description:
|
||||
'Hook configurations for intercepting and customizing agent behavior.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
} as const satisfies SettingsSchema;
|
||||
|
||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||
|
||||
@@ -6,11 +6,17 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { ConfigParameters, SandboxConfig } from './config.js';
|
||||
import type {
|
||||
ConfigParameters,
|
||||
SandboxConfig,
|
||||
HookDefinition,
|
||||
} from './config.js';
|
||||
import {
|
||||
Config,
|
||||
ApprovalMode,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
HookType,
|
||||
HookEventName,
|
||||
} from './config.js';
|
||||
import * as path from 'node:path';
|
||||
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
@@ -1133,3 +1139,111 @@ describe('BaseLlmClient Lifecycle', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config getHooks', () => {
|
||||
const baseParams: ConfigParameters = {
|
||||
cwd: '/tmp',
|
||||
targetDir: '/path/to/target',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session-id',
|
||||
model: 'gemini-pro',
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
it('should return undefined when no hooks are provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.getHooks()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty object when empty hooks are provided', () => {
|
||||
const configWithEmptyHooks = new Config({
|
||||
...baseParams,
|
||||
hooks: {},
|
||||
});
|
||||
expect(configWithEmptyHooks.getHooks()).toEqual({});
|
||||
});
|
||||
|
||||
it('should return the hooks configuration when provided', () => {
|
||||
const mockHooks: { [K in HookEventName]?: HookDefinition[] } = {
|
||||
[HookEventName.BeforeTool]: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo "test hook"',
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[HookEventName.AfterTool]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: './hooks/after-tool.sh',
|
||||
timeout: 10000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
hooks: mockHooks,
|
||||
});
|
||||
|
||||
const retrievedHooks = config.getHooks();
|
||||
expect(retrievedHooks).toEqual(mockHooks);
|
||||
expect(retrievedHooks).toBe(mockHooks); // Should return the same reference
|
||||
});
|
||||
|
||||
it('should return hooks with all supported event types', () => {
|
||||
const allEventHooks: { [K in HookEventName]?: HookDefinition[] } = {
|
||||
[HookEventName.BeforeAgent]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test1' }] },
|
||||
],
|
||||
[HookEventName.AfterAgent]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test2' }] },
|
||||
],
|
||||
[HookEventName.BeforeTool]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test3' }] },
|
||||
],
|
||||
[HookEventName.AfterTool]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test4' }] },
|
||||
],
|
||||
[HookEventName.BeforeModel]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test5' }] },
|
||||
],
|
||||
[HookEventName.AfterModel]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test6' }] },
|
||||
],
|
||||
[HookEventName.BeforeToolSelection]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test7' }] },
|
||||
],
|
||||
[HookEventName.Notification]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test8' }] },
|
||||
],
|
||||
[HookEventName.SessionStart]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test9' }] },
|
||||
],
|
||||
[HookEventName.SessionEnd]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test10' }] },
|
||||
],
|
||||
[HookEventName.PreCompress]: [
|
||||
{ hooks: [{ type: HookType.Command, command: 'test11' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
hooks: allEventHooks,
|
||||
});
|
||||
|
||||
const retrievedHooks = config.getHooks();
|
||||
expect(retrievedHooks).toEqual(allEventHooks);
|
||||
expect(Object.keys(retrievedHooks!)).toHaveLength(11); // All hook event types
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface GeminiCLIExtension {
|
||||
contextFiles: string[];
|
||||
excludeTools?: string[];
|
||||
id: string;
|
||||
hooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
@@ -209,6 +210,50 @@ export interface SandboxConfig {
|
||||
image: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names for the hook system
|
||||
*/
|
||||
export enum HookEventName {
|
||||
BeforeTool = 'BeforeTool',
|
||||
AfterTool = 'AfterTool',
|
||||
BeforeAgent = 'BeforeAgent',
|
||||
Notification = 'Notification',
|
||||
AfterAgent = 'AfterAgent',
|
||||
SessionStart = 'SessionStart',
|
||||
SessionEnd = 'SessionEnd',
|
||||
PreCompress = 'PreCompress',
|
||||
BeforeModel = 'BeforeModel',
|
||||
AfterModel = 'AfterModel',
|
||||
BeforeToolSelection = 'BeforeToolSelection',
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook configuration entry
|
||||
*/
|
||||
export interface CommandHookConfig {
|
||||
type: HookType.Command;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export type HookConfig = CommandHookConfig;
|
||||
|
||||
/**
|
||||
* Hook definition with matcher
|
||||
*/
|
||||
export interface HookDefinition {
|
||||
matcher?: string;
|
||||
sequential?: boolean;
|
||||
hooks: HookConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook implementation types
|
||||
*/
|
||||
export enum HookType {
|
||||
Command = 'command',
|
||||
}
|
||||
|
||||
export interface ConfigParameters {
|
||||
sessionId: string;
|
||||
embeddingModel?: string;
|
||||
@@ -286,6 +331,10 @@ export interface ConfigParameters {
|
||||
recordResponses?: string;
|
||||
ptyInfo?: string;
|
||||
disableYoloMode?: boolean;
|
||||
enableHooks?: boolean;
|
||||
hooks?: {
|
||||
[K in HookEventName]?: HookDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -388,6 +437,10 @@ export class Config {
|
||||
readonly fakeResponses?: string;
|
||||
readonly recordResponses?: string;
|
||||
private readonly disableYoloMode: boolean;
|
||||
private readonly enableHooks: boolean;
|
||||
private readonly hooks:
|
||||
| { [K in HookEventName]?: HookDefinition[] }
|
||||
| undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
@@ -482,8 +535,16 @@ export class Config {
|
||||
this.initialUseModelRouter = params.useModelRouter ?? false;
|
||||
this.useModelRouter = this.initialUseModelRouter;
|
||||
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [];
|
||||
this.enableHooks = params.enableHooks ?? false;
|
||||
|
||||
// Enable MessageBus integration if:
|
||||
// 1. Explicitly enabled via setting, OR
|
||||
// 2. Hooks are enabled and hooks are configured
|
||||
const hasHooks = params.hooks && Object.keys(params.hooks).length > 0;
|
||||
const hooksNeedMessageBus = this.enableHooks && hasHooks;
|
||||
this.enableMessageBusIntegration =
|
||||
params.enableMessageBusIntegration ?? false;
|
||||
params.enableMessageBusIntegration ??
|
||||
(hooksNeedMessageBus ? true : false);
|
||||
this.codebaseInvestigatorSettings = {
|
||||
enabled: params.codebaseInvestigatorSettings?.enabled ?? false,
|
||||
maxNumTurns: params.codebaseInvestigatorSettings?.maxNumTurns ?? 15,
|
||||
@@ -511,6 +572,7 @@ export class Config {
|
||||
};
|
||||
this.retryFetchErrors = params.retryFetchErrors ?? false;
|
||||
this.disableYoloMode = params.disableYoloMode ?? false;
|
||||
this.hooks = params.hooks;
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
@@ -1118,6 +1180,10 @@ export class Config {
|
||||
return this.enableMessageBusIntegration;
|
||||
}
|
||||
|
||||
getEnableHooks(): boolean {
|
||||
return this.enableHooks;
|
||||
}
|
||||
|
||||
getCodebaseInvestigatorSettings(): CodebaseInvestigatorSettings {
|
||||
return this.codebaseInvestigatorSettings;
|
||||
}
|
||||
@@ -1247,6 +1313,13 @@ export class Config {
|
||||
await registry.discoverAllTools();
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hooks configuration
|
||||
*/
|
||||
getHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
|
||||
return this.hooks;
|
||||
}
|
||||
}
|
||||
// Export model constants for use in CLI
|
||||
export { DEFAULT_GEMINI_FLASH_MODEL };
|
||||
|
||||
Reference in New Issue
Block a user