feat(hooks): Hook Configuration Schema and Types (#9074)

Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
This commit is contained in:
Edilmo Palencia
2025-11-02 11:49:16 -08:00
committed by GitHub
parent 02518d2927
commit c0495ce2f9
5 changed files with 217 additions and 2 deletions

View File

@@ -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 || {},
});
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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
});
});

View File

@@ -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 };