diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d1888ea190..c1a1c2df62 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 || {}, }); } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index dfd38b7e27..cfdbf7e44e 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -75,6 +75,7 @@ const MIGRATION_MAP: Record = { disableUpdateNag: 'general.disableUpdateNag', dnsResolutionOrder: 'advanced.dnsResolutionOrder', enableMessageBusIntegration: 'tools.enableMessageBusIntegration', + enableHooks: 'tools.enableHooks', enablePromptCompletion: 'general.enablePromptCompletion', enforcedAuthType: 'security.auth.enforcedType', excludeTools: 'tools.exclude', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ec23adb8f6..82ccdddd1f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index fed88f2af7..ae5ddaa573 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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 + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f721d74d9c..c610202bf8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 };