From b8c038f41f820415cf530bbdc53e6e3a9c49acc6 Mon Sep 17 00:00:00 2001 From: Edilmo Palencia Date: Wed, 3 Dec 2025 10:01:57 -0800 Subject: [PATCH] feat(hooks): Hooks Commands Panel, Enable/Disable, and Migrate (#14225) --- docs/get-started/configuration.md | 63 +- ...ooks-system.disabled-via-command.responses | 4 + ...oks-system.disabled-via-settings.responses | 2 + integration-tests/hooks-system.test.ts | 182 ++++++ packages/cli/src/commands/hooks.tsx | 25 + .../cli/src/commands/hooks/migrate.test.ts | 518 ++++++++++++++++ packages/cli/src/commands/hooks/migrate.ts | 273 +++++++++ packages/cli/src/config/config.ts | 6 + packages/cli/src/config/settings.ts | 6 + packages/cli/src/config/settingsSchema.ts | 205 ++++++- .../src/services/BuiltinCommandLoader.test.ts | 4 + .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/hooksCommand.test.ts | 569 ++++++++++++++++++ packages/cli/src/ui/commands/hooksCommand.ts | 250 ++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 4 + .../cli/src/ui/components/views/HooksList.tsx | 89 +++ packages/cli/src/ui/types.ts | 16 +- packages/cli/src/utils/deepMerge.test.ts | 39 ++ packages/core/src/config/config.ts | 22 +- packages/core/src/hooks/hookRegistry.test.ts | 1 + packages/core/src/hooks/hookRegistry.ts | 11 +- packages/core/src/hooks/hookSystem.test.ts | 158 +++++ packages/core/src/hooks/index.ts | 5 +- schemas/settings.schema.json | 130 +++- 24 files changed, 2568 insertions(+), 16 deletions(-) create mode 100644 integration-tests/hooks-system.disabled-via-command.responses create mode 100644 integration-tests/hooks-system.disabled-via-settings.responses create mode 100644 packages/cli/src/commands/hooks.tsx create mode 100644 packages/cli/src/commands/hooks/migrate.test.ts create mode 100644 packages/cli/src/commands/hooks/migrate.ts create mode 100644 packages/cli/src/ui/commands/hooksCommand.test.ts create mode 100644 packages/cli/src/ui/commands/hooksCommand.ts create mode 100644 packages/cli/src/ui/components/views/HooksList.tsx diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 948b1be04f..53a8eddbf9 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -816,10 +816,65 @@ their corresponding top-level category object in your `settings.json` file. #### `hooks` -- **`hooks`** (object): - - **Description:** Hook configurations for intercepting and customizing agent - behavior. - - **Default:** `{}` +- **`hooks.disabled`** (array): + - **Description:** List of hook names (commands) that should be disabled. + Hooks in this list will not execute even if configured. + - **Default:** `[]` + +- **`hooks.BeforeTool`** (array): + - **Description:** Hooks that execute before tool execution. Can intercept, + validate, or modify tool calls. + - **Default:** `[]` + +- **`hooks.AfterTool`** (array): + - **Description:** Hooks that execute after tool execution. Can process + results, log outputs, or trigger follow-up actions. + - **Default:** `[]` + +- **`hooks.BeforeAgent`** (array): + - **Description:** Hooks that execute before agent loop starts. Can set up + context or initialize resources. + - **Default:** `[]` + +- **`hooks.AfterAgent`** (array): + - **Description:** Hooks that execute after agent loop completes. Can perform + cleanup or summarize results. + - **Default:** `[]` + +- **`hooks.Notification`** (array): + - **Description:** Hooks that execute on notification events (errors, + warnings, info). Can log or alert on specific conditions. + - **Default:** `[]` + +- **`hooks.SessionStart`** (array): + - **Description:** Hooks that execute when a session starts. Can initialize + session-specific resources or state. + - **Default:** `[]` + +- **`hooks.SessionEnd`** (array): + - **Description:** Hooks that execute when a session ends. Can perform cleanup + or persist session data. + - **Default:** `[]` + +- **`hooks.PreCompress`** (array): + - **Description:** Hooks that execute before chat history compression. Can + back up or analyze conversation before compression. + - **Default:** `[]` + +- **`hooks.BeforeModel`** (array): + - **Description:** Hooks that execute before LLM requests. Can modify prompts, + inject context, or control model parameters. + - **Default:** `[]` + +- **`hooks.AfterModel`** (array): + - **Description:** Hooks that execute after LLM responses. Can process + outputs, extract information, or log interactions. + - **Default:** `[]` + +- **`hooks.BeforeToolSelection`** (array): + - **Description:** Hooks that execute before tool selection. Can filter or + prioritize available tools dynamically. + - **Default:** `[]` #### `mcpServers` diff --git a/integration-tests/hooks-system.disabled-via-command.responses b/integration-tests/hooks-system.disabled-via-command.responses new file mode 100644 index 0000000000..bf77dfe1cb --- /dev/null +++ b/integration-tests/hooks-system.disabled-via-command.responses @@ -0,0 +1,4 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Creating the First Test File**\n\nI'll use the `write_file` tool to create `first-run.txt` with the content \"test1\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12824,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":45}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"test1","file_path":"first-run.txt"}},"thoughtSignature":"CiQBcsjafG+JDSqtKOK+ZvSjZQZmS91c1Gz0YyTiirI2u5+rhEIKZAFyyNp8DNXb+xHILTC+FVlEifqEHdrmfFNBLKojci1UIBhcZpQ4UXCMkxUXYKO34IjTlyLgSsjVbbXWEFXatb/z/RtTDcf51uc3YOEwlDScGempkJxfFgcPfIiD7bhuHBqdQfUKfAFyyNp8wZ71h+QjdfVw12PwDXWgGZ0Xed1GuyJXuqAwpWnwxDIvsDaPwDFYyLR1XDiIZZk4AvFCGt6HGMSLRuPh4K3i9CVnDc5hcjyvMIde0idAFMrgs2Mq5SARfCPrWkqyq2f0Q0WonUl2n7yr/sDQ78rx2E6qXyUJ8XMKfAFyyNp8DdTYLttyI0jknqAeZDxdFmHtpJUI8UKP5YHzpQc8Qn80OJcwhZSRH4HRKCqoC7Sukq/A5vJ5T468WqgjOoLlPLq02bYRTf/q6LC1ogEhdLHrcFv2jDeCdXJJ8NHv3O4DZAUAk1W5Gd0428zMFOxH3AgkWwEGuow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12848,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":45}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File created successfully. Active hook executed"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":7,"totalTokenCount":12958,"cachedContentTokenCount":12202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12202}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Creating the Second Test File**\n\nI'll use the `write_file` tool to create `second-run.txt` with the content \"test2\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12826,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":47}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"test2","file_path":"second-run.txt"}},"thoughtSignature":"CiQBcsjafG+JDSqtKOK+ZvSjZQZmS91c1Gz0YyTiirI2u5+rhEIKZAFyyNp8DNXb+xHILTC+FVlEifqEHdrmfFNBLKojci1UIBhcZpQ4UXCMkxUXYKO34IjTlyLgSsjVbbXWEFXatb/z/RtTDcf51uc3YOEwlDScGempkJxfFgcPfIiD7bhuHBqdQfUKfAFyyNp8wZ71h+QjdfVw12PwDXWgGZ0Xed1GuyJXuqAwpWnwxDIvsDaPwDFYyLR1XDiIZZk4AvFCGt6HGMSLRuPh4K3i9CVnDc5hcjyvMIde0idAFMrgs2Mq5SARfCPrWkqyq2f0Q0WonUl2n7yr/sDQ78rx2E6qXyUJ8XMKfAFyyNp8DdTYLttyI0jknqAeZDxdFmHtpJUI8UKP5YHzpQc8Qn80OJcwhZSRH4HRKCqoC7Sukq/A5vJ5T468WqgjOoLlPLq02bYRTf/q6LC1ogEhdLHrcFv2jDeCdXJJ8NHv3O4DZAUAk1W5Gd0428zMFOxH3AgkWwEGuow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12850,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":47}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File created successfully. Active hook executed"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":7,"totalTokenCount":12958,"cachedContentTokenCount":12202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12202}]}}]} diff --git a/integration-tests/hooks-system.disabled-via-settings.responses b/integration-tests/hooks-system.disabled-via-settings.responses new file mode 100644 index 0000000000..1a0cbe1b8f --- /dev/null +++ b/integration-tests/hooks-system.disabled-via-settings.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Creating the Test File**\n\nI'll use the `write_file` tool to create `disabled-test.txt` with the content \"test\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12820,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":41}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"test","file_path":"disabled-test.txt"}},"thoughtSignature":"CiQBcsjafG+JDSqtKOK+ZvSjZQZmS91c1Gz0YyTiirI2u5+rhEIKZAFyyNp8DNXb+xHILTC+FVlEifqEHdrmfFNBLKojci1UIBhcZpQ4UXCMkxUXYKO34IjTlyLgSsjVbbXWEFXatb/z/RtTDcf51uc3YOEwlDScGempkJxfFgcPfIiD7bhuHBqdQfUKfAFyyNp8wZ71h+QjdfVw12PwDXWgGZ0Xed1GuyJXuqAwpWnwxDIvsDaPwDFYyLR1XDiIZZk4AvFCGt6HGMSLRuPh4K3i9CVnDc5hcjyvMIde0idAFMrgs2Mq5SARfCPrWkqyq2f0Q0WonUl2n7yr/sDQ78rx2E6qXyUJ8XMKfAFyyNp8DdTYLttyI0jknqAeZDxdFmHtpJUI8UKP5YHzpQc8Qn80OJcwhZSRH4HRKCqoC7Sukq/A5vJ5T468WqgjOoLlPLq02bYRTf/q6LC1ogEhdLHrcFv2jDeCdXJJ8NHv3O4DZAUAk1W5Gd0428zMFOxH3AgkWwEGuow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12844,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":41}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File created successfully. Enabled hook executed."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":8,"totalTokenCount":12959,"cachedContentTokenCount":12202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12202}]}}]} diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts index 4985da4e75..4b9ba03e59 100644 --- a/integration-tests/hooks-system.test.ts +++ b/integration-tests/hooks-system.test.ts @@ -1291,4 +1291,186 @@ fi`; } }); }); + + describe('Hook Disabling', () => { + it('should not execute hooks disabled in settings file', async () => { + await rig.setup('should not execute hooks disabled in settings file', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.disabled-via-settings.responses', + ), + }); + + // Create two hook scripts - one enabled, one disabled + const enabledHookScript = `#!/bin/bash +echo '{"decision": "allow", "systemMessage": "Enabled hook executed"}'`; + + const disabledHookScript = `#!/bin/bash +echo '{"decision": "block", "systemMessage": "Disabled hook should not execute", "reason": "This hook should be disabled"}'`; + + const enabledPath = join(rig.testDir!, 'enabled_hook.sh'); + const disabledPath = join(rig.testDir!, 'disabled_hook.sh'); + + writeFileSync(enabledPath, enabledHookScript); + writeFileSync(disabledPath, disabledHookScript); + const { execSync } = await import('node:child_process'); + execSync(`chmod +x "${enabledPath}"`); + execSync(`chmod +x "${disabledPath}"`); + + await rig.setup('should not execute hooks disabled in settings file', { + settings: { + tools: { + enableHooks: true, + }, + hooks: { + BeforeTool: [ + { + hooks: [ + { + type: 'command', + command: enabledPath, + timeout: 5000, + }, + { + type: 'command', + command: disabledPath, + timeout: 5000, + }, + ], + }, + ], + disabled: [disabledPath], // Disable the second hook + }, + }, + }); + + const prompt = + 'Create a file called disabled-test.txt with content "test"'; + const result = await rig.run(prompt); + + // Tool should execute (enabled hook allows it) + const foundWriteFile = await rig.waitForToolCall('write_file'); + expect(foundWriteFile).toBeTruthy(); + + // File should be created + const fileContent = rig.readFile('disabled-test.txt'); + expect(fileContent).toContain('test'); + + // Result should contain message from enabled hook but not from disabled hook + expect(result).toContain('Enabled hook executed'); + expect(result).not.toContain('Disabled hook should not execute'); + + // Check hook telemetry - only enabled hook should have executed + const hookLogs = rig.readHookLogs(); + const enabledHookLog = hookLogs.find( + (log) => log.hookCall.hook_name === enabledPath, + ); + const disabledHookLog = hookLogs.find( + (log) => log.hookCall.hook_name === disabledPath, + ); + + expect(enabledHookLog).toBeDefined(); + expect(disabledHookLog).toBeUndefined(); + }); + + it('should respect disabled hooks across multiple operations', async () => { + await rig.setup( + 'should respect disabled hooks across multiple operations', + { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.disabled-via-command.responses', + ), + }, + ); + + // Create two hook scripts - one that will be disabled, one that won't + const activeHookScript = `#!/bin/bash +echo '{"decision": "allow", "systemMessage": "Active hook executed"}'`; + + const disabledHookScript = `#!/bin/bash +echo '{"decision": "block", "systemMessage": "Disabled hook should not execute", "reason": "This hook is disabled"}'`; + + const activePath = join(rig.testDir!, 'active_hook.sh'); + const disabledPath = join(rig.testDir!, 'disabled_hook.sh'); + + writeFileSync(activePath, activeHookScript); + writeFileSync(disabledPath, disabledHookScript); + const { execSync } = await import('node:child_process'); + execSync(`chmod +x "${activePath}"`); + execSync(`chmod +x "${disabledPath}"`); + + await rig.setup( + 'should respect disabled hooks across multiple operations', + { + settings: { + tools: { + enableHooks: true, + }, + hooks: { + BeforeTool: [ + { + hooks: [ + { + type: 'command', + command: activePath, + timeout: 5000, + }, + { + type: 'command', + command: disabledPath, + timeout: 5000, + }, + ], + }, + ], + disabled: [disabledPath], // Disable the second hook + }, + }, + }, + ); + + // First run - only active hook should execute + const prompt1 = 'Create a file called first-run.txt with "test1"'; + const result1 = await rig.run(prompt1); + + // Tool should execute (active hook allows it) + const foundWriteFile1 = await rig.waitForToolCall('write_file'); + expect(foundWriteFile1).toBeTruthy(); + + // Result should contain active hook message but not disabled hook message + expect(result1).toContain('Active hook executed'); + expect(result1).not.toContain('Disabled hook should not execute'); + + // Check hook telemetry + const hookLogs1 = rig.readHookLogs(); + const activeHookLog1 = hookLogs1.find( + (log) => log.hookCall.hook_name === activePath, + ); + const disabledHookLog1 = hookLogs1.find( + (log) => log.hookCall.hook_name === disabledPath, + ); + + expect(activeHookLog1).toBeDefined(); + expect(disabledHookLog1).toBeUndefined(); + + // Second run - verify disabled hook stays disabled + const prompt2 = 'Create a file called second-run.txt with "test2"'; + const result2 = await rig.run(prompt2); + + const foundWriteFile2 = await rig.waitForToolCall('write_file'); + expect(foundWriteFile2).toBeTruthy(); + + // Same expectations as first run + expect(result2).toContain('Active hook executed'); + expect(result2).not.toContain('Disabled hook should not execute'); + + // Verify disabled hook still hasn't executed + const hookLogs2 = rig.readHookLogs(); + const disabledHookCalls = hookLogs2.filter( + (log) => log.hookCall.hook_name === disabledPath, + ); + expect(disabledHookCalls.length).toBe(0); + }); + }); }); diff --git a/packages/cli/src/commands/hooks.tsx b/packages/cli/src/commands/hooks.tsx new file mode 100644 index 0000000000..4475d33ab9 --- /dev/null +++ b/packages/cli/src/commands/hooks.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { migrateCommand } from './hooks/migrate.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; + +export const hooksCommand: CommandModule = { + command: 'hooks ', + aliases: ['hook'], + describe: 'Manage Gemini CLI hooks.', + builder: (yargs) => + yargs + .middleware(() => initializeOutputListenersAndFlush()) + .command(migrateCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/hooks/migrate.test.ts b/packages/cli/src/commands/hooks/migrate.test.ts new file mode 100644 index 0000000000..29811d39b1 --- /dev/null +++ b/packages/cli/src/commands/hooks/migrate.test.ts @@ -0,0 +1,518 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type MockInstance, +} from 'vitest'; +import * as fs from 'node:fs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { handleMigrateFromClaude } from './migrate.js'; + +vi.mock('node:fs'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as Mock; +const mockedFs = vi.mocked(fs); + +describe('migrate command', () => { + let mockSetValue: Mock; + let debugLoggerLogSpy: MockInstance; + let debugLoggerErrorSpy: MockInstance; + let originalCwd: () => string; + + beforeEach(() => { + vi.resetAllMocks(); + + mockSetValue = vi.fn(); + debugLoggerLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + + // Mock process.cwd() + originalCwd = process.cwd; + process.cwd = vi.fn(() => '/test/project'); + + mockedLoadSettings.mockReturnValue({ + merged: { + hooks: {}, + }, + setValue: mockSetValue, + workspace: { path: '/test/project/.gemini' }, + }); + }); + + afterEach(() => { + process.cwd = originalCwd; + vi.restoreAllMocks(); + }); + + it('should log error when no Claude settings files exist', async () => { + mockedFs.existsSync.mockReturnValue(false); + + await handleMigrateFromClaude(); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + 'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json', + ); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should migrate hooks from settings.json when it exists', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + command: 'echo "Before Edit"', + timeout: 30, + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockImplementation((path) => + path.toString().endsWith('settings.json'), + ); + + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooks', + expect.objectContaining({ + BeforeTool: expect.arrayContaining([ + expect.objectContaining({ + matcher: 'replace', + hooks: expect.arrayContaining([ + expect.objectContaining({ + command: 'echo "Before Edit"', + type: 'command', + timeout: 30, + }), + ]), + }), + ]), + }), + ); + + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Found Claude Code settings'), + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Migrating 1 hook event'), + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + '✓ Hooks successfully migrated to .gemini/settings.json', + ); + }); + + it('should prefer settings.local.json over settings.json', async () => { + const localSettings = { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: 'echo "Local session start"', + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(localSettings)); + + await handleMigrateFromClaude(); + + expect(mockedFs.readFileSync).toHaveBeenCalledWith( + expect.stringContaining('settings.local.json'), + 'utf-8', + ); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooks', + expect.objectContaining({ + SessionStart: expect.any(Array), + }), + ); + }); + + it('should migrate all supported event types', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [{ hooks: [{ type: 'command', command: 'echo 1' }] }], + PostToolUse: [{ hooks: [{ type: 'command', command: 'echo 2' }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo 3' }] }], + Stop: [{ hooks: [{ type: 'command', command: 'echo 4' }] }], + SubAgentStop: [{ hooks: [{ type: 'command', command: 'echo 5' }] }], + SessionStart: [{ hooks: [{ type: 'command', command: 'echo 6' }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: 'echo 7' }] }], + PreCompact: [{ hooks: [{ type: 'command', command: 'echo 8' }] }], + Notification: [{ hooks: [{ type: 'command', command: 'echo 9' }] }], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + + expect(migratedHooks).toHaveProperty('BeforeTool'); + expect(migratedHooks).toHaveProperty('AfterTool'); + expect(migratedHooks).toHaveProperty('BeforeAgent'); + expect(migratedHooks).toHaveProperty('AfterAgent'); + expect(migratedHooks).toHaveProperty('SessionStart'); + expect(migratedHooks).toHaveProperty('SessionEnd'); + expect(migratedHooks).toHaveProperty('PreCompress'); + expect(migratedHooks).toHaveProperty('Notification'); + }); + + it('should transform tool names in matchers', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + matcher: 'Edit|Bash|Read|Write|Glob|Grep', + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].matcher).toBe( + 'replace|run_shell_command|read_file|write_file|glob|grep', + ); + }); + + it('should replace $CLAUDE_PROJECT_DIR with $GEMINI_PROJECT_DIR', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: 'cd $CLAUDE_PROJECT_DIR && ls', + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe( + 'cd $GEMINI_PROJECT_DIR && ls', + ); + }); + + it('should preserve sequential flag', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + sequential: true, + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].sequential).toBe(true); + }); + + it('should preserve timeout values', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: 'echo "test"', + timeout: 60, + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].hooks[0].timeout).toBe(60); + }); + + it('should merge with existing Gemini hooks', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "claude"' }], + }, + ], + }, + }; + + mockedLoadSettings.mockReturnValue({ + merged: { + hooks: { + AfterTool: [ + { + hooks: [{ type: 'command', command: 'echo "existing"' }], + }, + ], + }, + }, + setValue: mockSetValue, + workspace: { path: '/test/project/.gemini' }, + }); + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks).toHaveProperty('BeforeTool'); + expect(migratedHooks).toHaveProperty('AfterTool'); + expect(migratedHooks.AfterTool[0].hooks[0].command).toBe('echo "existing"'); + expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe('echo "claude"'); + }); + + it('should handle JSON with comments', async () => { + const claudeSettingsWithComments = `{ + // This is a comment + "hooks": { + /* Block comment */ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo test" // Inline comment + } + ] + } + ] + } + }`; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(claudeSettingsWithComments); + + await handleMigrateFromClaude(); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooks', + expect.objectContaining({ + BeforeTool: expect.any(Array), + }), + ); + }); + + it('should handle malformed JSON gracefully', async () => { + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue('{ invalid json }'); + + await handleMigrateFromClaude(); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error reading'), + ); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should log info when no hooks are found in Claude settings', async () => { + const claudeSettings = { + someOtherSetting: 'value', + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'No hooks found in Claude Code settings to migrate.', + ); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should handle setValue errors gracefully', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + mockSetValue.mockImplementation(() => { + throw new Error('Failed to save'); + }); + + await handleMigrateFromClaude(); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + 'Error saving migrated hooks: Failed to save', + ); + }); + + it('should handle hooks with matcher but no command', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].matcher).toBe('replace'); + expect(migratedHooks.BeforeTool[0].hooks[0].type).toBe('command'); + }); + + it('should handle empty hooks array', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].hooks).toEqual([]); + }); + + it('should handle non-array event config gracefully', async () => { + const claudeSettings = { + hooks: { + PreToolUse: 'not an array', + PostToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks).not.toHaveProperty('BeforeTool'); + expect(migratedHooks).toHaveProperty('AfterTool'); + }); + + it('should display migration instructions after successful migration', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + '✓ Hooks successfully migrated to .gemini/settings.json', + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'Note: Set tools.enableHooks to true in your settings to enable the hook system.', + ); + }); +}); diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts new file mode 100644 index 0000000000..c2fe65d574 --- /dev/null +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; +import stripJsonComments from 'strip-json-comments'; + +interface MigrateArgs { + fromClaude: boolean; +} + +/** + * Mapping from Claude Code event names to Gemini event names + */ +const EVENT_MAPPING: Record = { + PreToolUse: 'BeforeTool', + PostToolUse: 'AfterTool', + UserPromptSubmit: 'BeforeAgent', + Stop: 'AfterAgent', + SubAgentStop: 'AfterAgent', // Gemini doesn't have sub-agents, map to AfterAgent + SessionStart: 'SessionStart', + SessionEnd: 'SessionEnd', + PreCompact: 'PreCompress', + Notification: 'Notification', +}; + +/** + * Mapping from Claude Code tool names to Gemini tool names + */ +const TOOL_NAME_MAPPING: Record = { + Edit: 'replace', + Bash: 'run_shell_command', + Read: 'read_file', + Write: 'write_file', + Glob: 'glob', + Grep: 'grep', + LS: 'ls', +}; + +/** + * Transform a matcher regex to update tool names from Claude to Gemini + */ +function transformMatcher(matcher: string | undefined): string | undefined { + if (!matcher) return matcher; + + let transformed = matcher; + for (const [claudeName, geminiName] of Object.entries(TOOL_NAME_MAPPING)) { + // Replace exact matches and matches within regex alternations + transformed = transformed.replace( + new RegExp(`\\b${claudeName}\\b`, 'g'), + geminiName, + ); + } + + return transformed; +} + +/** + * Migrate a Claude Code hook configuration to Gemini format + */ +function migrateClaudeHook(claudeHook: unknown): unknown { + if (!claudeHook || typeof claudeHook !== 'object') { + return claudeHook; + } + + const hook = claudeHook as Record; + const migrated: Record = {}; + + // Map command field + if ('command' in hook) { + migrated['command'] = hook['command']; + + // Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command + if (typeof migrated['command'] === 'string') { + migrated['command'] = migrated['command'].replace( + /\$CLAUDE_PROJECT_DIR/g, + '$GEMINI_PROJECT_DIR', + ); + } + } + + // Map type field + if ('type' in hook && hook['type'] === 'command') { + migrated['type'] = 'command'; + } + + // Map timeout field (Claude uses seconds, Gemini uses seconds) + if ('timeout' in hook && typeof hook['timeout'] === 'number') { + migrated['timeout'] = hook['timeout']; + } + + return migrated; +} + +/** + * Migrate Claude Code hooks configuration to Gemini format + */ +function migrateClaudeHooks(claudeConfig: unknown): Record { + if (!claudeConfig || typeof claudeConfig !== 'object') { + return {}; + } + + const config = claudeConfig as Record; + const geminiHooks: Record = {}; + + // Check if there's a hooks section + const hooksSection = config['hooks'] as Record | undefined; + if (!hooksSection || typeof hooksSection !== 'object') { + return {}; + } + + for (const [eventName, eventConfig] of Object.entries(hooksSection)) { + // Map event name + const geminiEventName = EVENT_MAPPING[eventName] || eventName; + + if (!Array.isArray(eventConfig)) { + continue; + } + + // Migrate each hook definition + const migratedDefinitions = eventConfig.map((def: unknown) => { + if (!def || typeof def !== 'object') { + return def; + } + + const definition = def as Record; + const migratedDef: Record = {}; + + // Transform matcher + if ( + 'matcher' in definition && + typeof definition['matcher'] === 'string' + ) { + migratedDef['matcher'] = transformMatcher(definition['matcher']); + } + + // Copy sequential flag + if ('sequential' in definition) { + migratedDef['sequential'] = definition['sequential']; + } + + // Migrate hooks array + if ('hooks' in definition && Array.isArray(definition['hooks'])) { + migratedDef['hooks'] = definition['hooks'].map(migrateClaudeHook); + } + + return migratedDef; + }); + + geminiHooks[geminiEventName] = migratedDefinitions; + } + + return geminiHooks; +} + +/** + * Handle migration from Claude Code + */ +export async function handleMigrateFromClaude() { + const workingDir = process.cwd(); + + // Look for Claude settings in .claude directory + const claudeDir = path.join(workingDir, '.claude'); + const claudeSettingsPath = path.join(claudeDir, 'settings.json'); + const claudeLocalSettingsPath = path.join(claudeDir, 'settings.local.json'); + + let claudeSettings: Record | null = null; + let sourceFile = ''; + + // Try to read settings.local.json first, then settings.json + if (fs.existsSync(claudeLocalSettingsPath)) { + sourceFile = claudeLocalSettingsPath; + try { + const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8'); + claudeSettings = JSON.parse(stripJsonComments(content)) as Record< + string, + unknown + >; + } catch (error) { + debugLogger.error( + `Error reading ${claudeLocalSettingsPath}: ${getErrorMessage(error)}`, + ); + } + } else if (fs.existsSync(claudeSettingsPath)) { + sourceFile = claudeSettingsPath; + try { + const content = fs.readFileSync(claudeSettingsPath, 'utf-8'); + claudeSettings = JSON.parse(stripJsonComments(content)) as Record< + string, + unknown + >; + } catch (error) { + debugLogger.error( + `Error reading ${claudeSettingsPath}: ${getErrorMessage(error)}`, + ); + } + } else { + debugLogger.error( + 'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json', + ); + return; + } + + if (!claudeSettings) { + return; + } + + debugLogger.log(`Found Claude Code settings in: ${sourceFile}`); + + // Migrate hooks + const migratedHooks = migrateClaudeHooks(claudeSettings); + + if (Object.keys(migratedHooks).length === 0) { + debugLogger.log('No hooks found in Claude Code settings to migrate.'); + return; + } + + debugLogger.log( + `Migrating ${Object.keys(migratedHooks).length} hook event(s)...`, + ); + + // Load current Gemini settings + const settings = loadSettings(workingDir); + + // Merge migrated hooks with existing hooks + const existingHooks = + (settings.merged.hooks as Record) || {}; + const mergedHooks = { ...existingHooks, ...migratedHooks }; + + // Update settings (setValue automatically saves) + try { + settings.setValue(SettingScope.Workspace, 'hooks', mergedHooks); + + debugLogger.log('✓ Hooks successfully migrated to .gemini/settings.json'); + debugLogger.log( + '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', + ); + debugLogger.log( + 'Note: Set tools.enableHooks to true in your settings to enable the hook system.', + ); + } catch (error) { + debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`); + } +} + +export const migrateCommand: CommandModule = { + command: 'migrate', + describe: 'Migrate hooks from Claude Code to Gemini CLI', + builder: (yargs) => + yargs.option('from-claude', { + describe: 'Migrate from Claude Code hooks', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + const args = argv as unknown as MigrateArgs; + if (args.fromClaude) { + await handleMigrateFromClaude(); + } else { + debugLogger.log( + 'Usage: gemini hooks migrate --from-claude\n\nMigrate hooks from Claude Code to Gemini CLI format.', + ); + } + await exitCli(); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d55b58adc2..bdae50ada3 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,6 +10,7 @@ import process from 'node:process'; import { mcpCommand } from '../commands/mcp.js'; import type { OutputFormat } from '@google/gemini-cli-core'; import { extensionsCommand } from '../commands/extensions.js'; +import { hooksCommand } from '../commands/hooks.js'; import { Config, setGeminiMdFilename as setServerGeminiMdFilename, @@ -281,6 +282,11 @@ export async function parseArguments(settings: Settings): Promise { yargsInstance.command(extensionsCommand); } + // Register hooks command if hooks are enabled + if (settings?.tools?.enableHooks) { + yargsInstance.command(hooksCommand); + } + yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 4250bd30d0..1ab089d3b6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -38,11 +38,17 @@ import { SettingPaths } from './settingPaths.js'; function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; let currentSchema: SettingsSchema | undefined = getSettingsSchema(); + let parent: SettingDefinition | undefined = undefined; for (const key of path) { if (!currentSchema || !currentSchema[key]) { + // Key not found in schema - check if parent has additionalProperties + if (parent?.additionalProperties?.mergeStrategy) { + return parent.additionalProperties.mergeStrategy; + } return undefined; } + parent = current; current = currentSchema[key]; currentSchema = current.properties; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 453f449697..758eb853e7 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -14,8 +14,6 @@ import type { BugCommandSettings, TelemetrySettings, AuthType, - HookDefinition, - HookEventName, } from '@google/gemini-cli-core'; import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, @@ -80,6 +78,11 @@ export interface SettingCollectionDefinition { * For example, a JSON schema generator can use this to point to a shared definition. */ ref?: string; + /** + * Optional merge strategy for dynamically added properties. + * Used when this collection definition is referenced via additionalProperties. + */ + mergeStrategy?: MergeStrategy; } export enum MergeStrategy { @@ -1422,11 +1425,165 @@ const SETTINGS_SCHEMA = { label: 'Hooks', category: 'Advanced', requiresRestart: false, - default: {} as { [K in HookEventName]?: HookDefinition[] }, + default: {}, description: 'Hook configurations for intercepting and customizing agent behavior.', showInDialog: false, - mergeStrategy: MergeStrategy.SHALLOW_MERGE, + properties: { + disabled: { + type: 'array', + label: 'Disabled Hooks', + category: 'Advanced', + requiresRestart: false, + default: [] as string[], + description: + 'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.', + showInDialog: false, + items: { + type: 'string', + description: 'Hook command name', + }, + mergeStrategy: MergeStrategy.UNION, + }, + BeforeTool: { + type: 'array', + label: 'Before Tool Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + AfterTool: { + type: 'array', + label: 'After Tool Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after tool execution. Can process results, log outputs, or trigger follow-up actions.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + BeforeAgent: { + type: 'array', + label: 'Before Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before agent loop starts. Can set up context or initialize resources.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + AfterAgent: { + type: 'array', + label: 'After Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after agent loop completes. Can perform cleanup or summarize results.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + Notification: { + type: 'array', + label: 'Notification Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute on notification events (errors, warnings, info). Can log or alert on specific conditions.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + SessionStart: { + type: 'array', + label: 'Session Start Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute when a session starts. Can initialize session-specific resources or state.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + SessionEnd: { + type: 'array', + label: 'Session End Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute when a session ends. Can perform cleanup or persist session data.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + PreCompress: { + type: 'array', + label: 'Pre-Compress Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before chat history compression. Can back up or analyze conversation before compression.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + BeforeModel: { + type: 'array', + label: 'Before Model Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before LLM requests. Can modify prompts, inject context, or control model parameters.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + AfterModel: { + type: 'array', + label: 'After Model Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after LLM responses. Can process outputs, extract information, or log interactions.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + BeforeToolSelection: { + type: 'array', + label: 'Before Tool Selection Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before tool selection. Can filter or prioritize available tools dynamically.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + additionalProperties: { + type: 'array', + description: + 'Custom hook event arrays that contain hook definitions for user-defined events', + mergeStrategy: MergeStrategy.CONCAT, + }, }, } as const satisfies SettingsSchema; @@ -1698,6 +1855,46 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< description: 'Accepts either a boolean flag or a string command name.', anyOf: [{ type: 'boolean' }, { type: 'string' }], }, + HookDefinitionArray: { + type: 'array', + description: 'Array of hook definition objects for a specific event.', + items: { + type: 'object', + description: + 'Hook definition specifying matcher pattern and hook configurations.', + properties: { + matcher: { + type: 'string', + description: + 'Pattern to match against the event context (tool name, notification type, etc.). Supports exact match, regex (/pattern/), and wildcards (*).', + }, + hooks: { + type: 'array', + description: 'Hooks to execute when the matcher matches.', + items: { + type: 'object', + description: 'Individual hook configuration.', + properties: { + type: { + type: 'string', + description: + 'Type of hook (currently only "command" supported).', + }, + command: { + type: 'string', + description: + 'Shell command to execute. Receives JSON input via stdin and returns JSON output via stdout.', + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for hook execution.', + }, + }, + }, + }, + }, + }, + }, }; export function getSettingsSchema(): SettingsSchemaType { diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 332a0377d0..d3ee0e8f0b 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -98,6 +98,7 @@ describe('BuiltinCommandLoader', () => { getFolderTrust: vi.fn().mockReturnValue(true), getEnableMessageBusIntegration: () => false, getEnableExtensionReloading: () => false, + getEnableHooks: () => false, } as unknown as Config; restoreCommandMock.mockReturnValue({ @@ -172,6 +173,7 @@ describe('BuiltinCommandLoader', () => { const mockConfigWithMessageBus = { ...mockConfig, getEnableMessageBusIntegration: () => true, + getEnableHooks: () => false, } as unknown as Config; const loader = new BuiltinCommandLoader(mockConfigWithMessageBus); const commands = await loader.loadCommands(new AbortController().signal); @@ -183,6 +185,7 @@ describe('BuiltinCommandLoader', () => { const mockConfigWithoutMessageBus = { ...mockConfig, getEnableMessageBusIntegration: () => false, + getEnableHooks: () => false, } as unknown as Config; const loader = new BuiltinCommandLoader(mockConfigWithoutMessageBus); const commands = await loader.loadCommands(new AbortController().signal); @@ -201,6 +204,7 @@ describe('BuiltinCommandLoader profile', () => { getCheckpointingEnabled: () => false, getEnableMessageBusIntegration: () => false, getEnableExtensionReloading: () => false, + getEnableHooks: () => false, } as unknown as Config; }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 2bbb0d8824..baccd7347a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -22,6 +22,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; @@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader { editorCommand, extensionsCommand(this.config?.getEnableExtensionReloading()), helpCommand, + ...(this.config?.getEnableHooks() ? [hooksCommand] : []), await ideCommand(), initCommand, mcpCommand, diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts new file mode 100644 index 0000000000..47b1282bc8 --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -0,0 +1,569 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { hooksCommand } from './hooksCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import type { HookRegistryEntry } from '@google/gemini-cli-core'; +import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core'; +import type { CommandContext } from './types.js'; + +describe('hooksCommand', () => { + let mockContext: CommandContext; + let mockHookSystem: { + getAllHooks: ReturnType; + setHookEnabled: ReturnType; + getRegistry: ReturnType; + }; + let mockConfig: { + getHookSystem: ReturnType; + }; + let mockSettings: { + merged: { + hooks?: { + disabled?: string[]; + }; + }; + setValue: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create mock hook system + mockHookSystem = { + getAllHooks: vi.fn().mockReturnValue([]), + setHookEnabled: vi.fn(), + getRegistry: vi.fn().mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + }), + }; + + // Create mock config + mockConfig = { + getHookSystem: vi.fn().mockReturnValue(mockHookSystem), + }; + + // Create mock settings + mockSettings = { + merged: { + hooks: { + disabled: [], + }, + }, + setValue: vi.fn(), + }; + + // Create mock context with config and settings + mockContext = createMockCommandContext({ + services: { + config: mockConfig, + settings: mockSettings, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('root command', () => { + it('should have the correct name and description', () => { + expect(hooksCommand.name).toBe('hooks'); + expect(hooksCommand.description).toBe('Manage hooks'); + }); + + it('should have all expected subcommands', () => { + expect(hooksCommand.subCommands).toBeDefined(); + expect(hooksCommand.subCommands).toHaveLength(3); + + const subCommandNames = hooksCommand.subCommands!.map((cmd) => cmd.name); + expect(subCommandNames).toContain('panel'); + expect(subCommandNames).toContain('enable'); + expect(subCommandNames).toContain('disable'); + }); + + it('should delegate to panel action when invoked without subcommand', async () => { + if (!hooksCommand.action) { + throw new Error('hooks command must have an action'); + } + + mockHookSystem.getAllHooks.mockReturnValue([ + createMockHook('test-hook', HookEventName.BeforeTool, true), + ]); + + await hooksCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HOOKS_LIST, + }), + expect.any(Number), + ); + }); + }); + + describe('panel subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + const result = await panelCmd.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return info message when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + const result = await panelCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Hook system is not enabled. Enable it in settings with tools.enableHooks', + }); + }); + + it('should return info message when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + const result = await panelCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'No hooks configured. Add hooks to your settings to get started.', + }); + }); + + it('should display hooks list when hooks are configured', async () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('echo-test', HookEventName.BeforeTool, true), + createMockHook('notify', HookEventName.AfterAgent, false), + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + await panelCmd.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HOOKS_LIST, + hooks: mockHooks, + }), + expect.any(Number), + ); + }); + }); + + describe('enable subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(contextWithoutConfig, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(mockContext, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should return error when hook name is not provided', async () => { + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /hooks enable ', + }); + }); + + it('should enable a hook and update settings', async () => { + // Update the context's settings with disabled hooks + mockContext.services.settings.merged.hooks = { + disabled: ['test-hook', 'other-hook'], + }; + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(mockContext, 'test-hook'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), + 'hooks.disabled', + ['other-hook'], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + true, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Hook "test-hook" enabled successfully.', + }); + }); + + it('should handle error when enabling hook fails', async () => { + mockSettings.setValue.mockImplementationOnce(() => { + throw new Error('Failed to save settings'); + }); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(mockContext, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to enable hook: Failed to save settings', + }); + }); + }); + + describe('disable subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(contextWithoutConfig, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should return error when hook name is not provided', async () => { + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /hooks disable ', + }); + }); + + it('should disable a hook and update settings', async () => { + mockContext.services.settings.merged.hooks = { + disabled: [], + }; + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, 'test-hook'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), + 'hooks.disabled', + ['test-hook'], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + false, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Hook "test-hook" disabled successfully.', + }); + }); + + it('should return info when hook is already disabled', async () => { + // Update the context's settings with the hook already disabled + mockContext.services.settings.merged.hooks = { + disabled: ['test-hook'], + }; + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, 'test-hook'); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(mockHookSystem.setHookEnabled).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Hook "test-hook" is already disabled.', + }); + }); + + it('should handle error when disabling hook fails', async () => { + mockContext.services.settings.merged.hooks = { + disabled: [], + }; + mockSettings.setValue.mockImplementationOnce(() => { + throw new Error('Failed to save settings'); + }); + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to disable hook: Failed to save settings', + }); + }); + }); + + describe('completion', () => { + it('should return empty array when config is not available', () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(contextWithoutConfig, 'test'); + expect(result).toEqual([]); + }); + + it('should return empty array when hook system is not enabled', () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(mockContext, 'test'); + expect(result).toEqual([]); + }); + + it('should return matching hook names', () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('test-hook-1', HookEventName.BeforeTool, true), + createMockHook('test-hook-2', HookEventName.AfterTool, true), + createMockHook('other-hook', HookEventName.AfterAgent, false), + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(mockContext, 'test'); + expect(result).toEqual(['test-hook-1', 'test-hook-2']); + }); + + it('should return all hook names when partial is empty', () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('hook-1', HookEventName.BeforeTool, true), + createMockHook('hook-2', HookEventName.AfterTool, true), + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(mockContext, ''); + expect(result).toEqual(['hook-1', 'hook-2']); + }); + + it('should handle hooks without command name gracefully', () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('test-hook', HookEventName.BeforeTool, true), + { + ...createMockHook('', HookEventName.AfterTool, true), + config: { command: '', type: HookType.Command, timeout: 30 }, + }, + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(mockContext, 'test'); + expect(result).toEqual(['test-hook']); + }); + }); +}); + +/** + * Helper function to create a mock HookRegistryEntry + */ +function createMockHook( + command: string, + eventName: HookEventName, + enabled: boolean, +): HookRegistryEntry { + return { + config: { + command, + type: HookType.Command, + timeout: 30, + }, + source: ConfigSource.Project, + eventName, + matcher: undefined, + sequential: false, + enabled, + }; +} diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts new file mode 100644 index 0000000000..bdf226e85b --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + CommandContext, + MessageActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType, type HistoryItemHooksList } from '../types.js'; +import type { HookRegistryEntry } from '@google/gemini-cli-core'; +import { getErrorMessage } from '@google/gemini-cli-core'; +import { SettingScope } from '../../config/settings.js'; + +/** + * Display a formatted list of hooks with their status + */ +async function panelAction( + context: CommandContext, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'info', + content: + 'Hook system is not enabled. Enable it in settings with tools.enableHooks', + }; + } + + const allHooks = hookSystem.getAllHooks(); + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: + 'No hooks configured. Add hooks to your settings to get started.', + }; + } + + const hooksListItem: HistoryItemHooksList = { + type: MessageType.HOOKS_LIST, + hooks: allHooks, + }; + + context.ui.addItem(hooksListItem, Date.now()); +} + +/** + * Enable a hook by name + */ +async function enableAction( + context: CommandContext, + args: string, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /hooks enable ', + }; + } + + // Get current disabled hooks from settings + const settings = context.services.settings; + const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); + + // Remove from disabled list if present + const newDisabledHooks = disabledHooks.filter( + (name: string) => name !== hookName, + ); + + // Update settings (setValue automatically saves) + try { + settings.setValue(SettingScope.User, 'hooks.disabled', newDisabledHooks); + + // Enable in hook system + hookSystem.setHookEnabled(hookName, true); + + return { + type: 'message', + messageType: 'info', + content: `Hook "${hookName}" enabled successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to enable hook: ${getErrorMessage(error)}`, + }; + } +} + +/** + * Disable a hook by name + */ +async function disableAction( + context: CommandContext, + args: string, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /hooks disable ', + }; + } + + // Get current disabled hooks from settings + const settings = context.services.settings; + const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); + + // Add to disabled list if not already present + if (!disabledHooks.includes(hookName)) { + const newDisabledHooks = [...disabledHooks, hookName]; + + // Update settings (setValue automatically saves) + try { + settings.setValue(SettingScope.User, 'hooks.disabled', newDisabledHooks); + + // Disable in hook system + hookSystem.setHookEnabled(hookName, false); + + return { + type: 'message', + messageType: 'info', + content: `Hook "${hookName}" disabled successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to disable hook: ${getErrorMessage(error)}`, + }; + } + } else { + return { + type: 'message', + messageType: 'info', + content: `Hook "${hookName}" is already disabled.`, + }; + } +} + +/** + * Completion function for hook names + */ +function completeHookNames( + context: CommandContext, + partialArg: string, +): string[] { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const allHooks = hookSystem.getAllHooks(); + const hookNames = allHooks.map((hook) => getHookDisplayName(hook)); + return hookNames.filter((name) => name.startsWith(partialArg)); +} + +/** + * Get a display name for a hook + */ +function getHookDisplayName(hook: HookRegistryEntry): string { + return hook.config.command || 'unknown-hook'; +} + +const panelCommand: SlashCommand = { + name: 'panel', + altNames: ['list', 'show'], + description: 'Display all registered hooks with their status', + kind: CommandKind.BUILT_IN, + action: panelAction, +}; + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a hook by name', + kind: CommandKind.BUILT_IN, + action: enableAction, + completion: completeHookNames, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable a hook by name', + kind: CommandKind.BUILT_IN, + action: disableAction, + completion: completeHookNames, +}; + +export const hooksCommand: SlashCommand = { + name: 'hooks', + description: 'Manage hooks', + kind: CommandKind.BUILT_IN, + subCommands: [panelCommand, enableCommand, disableCommand], + action: async (context: CommandContext) => panelCommand.action!(context, ''), +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 36920c0592..ae0a9e1a2f 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -30,6 +30,7 @@ import { getMCPServerStatus } from '@google/gemini-cli-core'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; +import { HooksList } from './views/HooksList.js'; import { ModelMessage } from './messages/ModelMessage.js'; interface HistoryItemDisplayProps { @@ -158,6 +159,9 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'chat_list' && ( )} + {itemForDisplay.type === 'hooks_list' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/views/HooksList.tsx b/packages/cli/src/ui/components/views/HooksList.tsx new file mode 100644 index 0000000000..b733381555 --- /dev/null +++ b/packages/cli/src/ui/components/views/HooksList.tsx @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; + +interface HooksListProps { + hooks: ReadonlyArray<{ + config: { command?: string; type: string; timeout?: number }; + source: string; + eventName: string; + matcher?: string; + sequential?: boolean; + enabled: boolean; + }>; +} + +export const HooksList: React.FC = ({ hooks }) => { + if (hooks.length === 0) { + return ( + + No hooks configured. + + ); + } + + // Group hooks by event name for better organization + const hooksByEvent = hooks.reduce( + (acc, hook) => { + if (!acc[hook.eventName]) { + acc[hook.eventName] = []; + } + acc[hook.eventName].push(hook); + return acc; + }, + {} as Record>, + ); + + return ( + + Configured Hooks: + + {Object.entries(hooksByEvent).map(([eventName, eventHooks]) => ( + + + {eventName}: + + + {eventHooks.map((hook, index) => { + const hookName = hook.config.command || 'unknown'; + const statusColor = hook.enabled ? 'green' : 'gray'; + const statusText = hook.enabled ? 'enabled' : 'disabled'; + + return ( + + + + {hookName} + {` [${statusText}]`} + + + + + Source: {hook.source} + {hook.matcher && ` | Matcher: ${hook.matcher}`} + {hook.sequential && ` | Sequential`} + {hook.config.timeout && + ` | Timeout: ${hook.config.timeout}s`} + + + + ); + })} + + + ))} + + + + Tip: Use `/hooks enable {''}` or `/hooks disable{' '} + {''}` to toggle hooks + + + + ); +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index d8ec6f62a1..84ffc40694 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -240,6 +240,18 @@ export type HistoryItemMcpStatus = HistoryItemBase & { showSchema: boolean; }; +export type HistoryItemHooksList = HistoryItemBase & { + type: 'hooks_list'; + hooks: Array<{ + config: { command?: string; type: string; timeout?: number }; + source: string; + eventName: string; + matcher?: string; + sequential?: boolean; + enabled: boolean; + }>; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -264,7 +276,8 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemMcpStatus - | HistoryItemChatList; + | HistoryItemChatList + | HistoryItemHooksList; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -286,6 +299,7 @@ export enum MessageType { TOOLS_LIST = 'tools_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', + HOOKS_LIST = 'hooks_list', } // Simplified message structure for internal feedback diff --git a/packages/cli/src/utils/deepMerge.test.ts b/packages/cli/src/utils/deepMerge.test.ts index 0603aafb3a..bc2e77140c 100644 --- a/packages/cli/src/utils/deepMerge.test.ts +++ b/packages/cli/src/utils/deepMerge.test.ts @@ -160,4 +160,43 @@ describe('customDeepMerge', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(({} as any).polluted).toBeUndefined(); }); + + it('should use additionalProperties merge strategy for dynamic properties', () => { + // Simulates how hooks work: hooks.disabled uses UNION, but hooks.BeforeTool (dynamic) uses CONCAT + const target = { + hooks: { + BeforeTool: [{ command: 'user-hook-1' }, { command: 'user-hook-2' }], + disabled: ['hook-a'], + }, + }; + const source = { + hooks: { + BeforeTool: [{ command: 'workspace-hook-1' }], + disabled: ['hook-b'], + }, + }; + + // Mock the getMergeStrategyForPath behavior for hooks + const getMergeStrategy = (path: string[]) => { + const p = path.join('.'); + // hooks.disabled uses UNION strategy (explicitly defined in schema) + if (p === 'hooks.disabled') return MergeStrategy.UNION; + // hooks.BeforeTool uses CONCAT strategy (via additionalProperties) + if (p === 'hooks.BeforeTool') return MergeStrategy.CONCAT; + return undefined; + }; + + const result = customDeepMerge(getMergeStrategy, target, source); + + // BeforeTool should concatenate + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result as any)['hooks']['BeforeTool']).toEqual([ + { command: 'user-hook-1' }, + { command: 'user-hook-2' }, + { command: 'workspace-hook-1' }, + ]); + // disabled should union (deduplicate) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result as any)['hooks']['disabled']).toEqual(['hook-a', 'hook-b']); + }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34a4304a75..899e1ef2d2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -312,9 +312,13 @@ export interface ConfigParameters { modelConfigServiceConfig?: ModelConfigServiceConfig; enableHooks?: boolean; experiments?: Experiments; - hooks?: { - [K in HookEventName]?: HookDefinition[]; - }; + hooks?: + | { + [K in HookEventName]?: HookDefinition[]; + } + | ({ + [K in HookEventName]?: HookDefinition[]; + } & { disabled?: string[] }); previewFeatures?: boolean; enableModelAvailabilityService?: boolean; experimentalJitContext?: boolean; @@ -429,6 +433,7 @@ export class Config { private readonly hooks: | { [K in HookEventName]?: HookDefinition[] } | undefined; + private readonly disabledHooks: string[]; private experiments: Experiments | undefined; private experimentsPromise: Promise | undefined; private hookSystem?: HookSystem; @@ -541,6 +546,10 @@ export class Config { this.useSmartEdit = params.useSmartEdit ?? true; this.useWriteTodos = params.useWriteTodos ?? true; this.enableHooks = params.enableHooks ?? false; + this.disabledHooks = + (params.hooks && 'disabled' in params.hooks + ? params.hooks.disabled + : undefined) ?? []; // Enable MessageBus integration if: // 1. Explicitly enabled via setting, OR @@ -1563,6 +1572,13 @@ export class Config { return this.hooks; } + /** + * Get disabled hooks list + */ + getDisabledHooks(): string[] { + return this.disabledHooks; + } + /** * Get experiments configuration */ diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts index 5c6906b838..093821ee19 100644 --- a/packages/core/src/hooks/hookRegistry.test.ts +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -50,6 +50,7 @@ describe('HookRegistry', () => { storage: mockStorage, getExtensions: vi.fn().mockReturnValue([]), getHooks: vi.fn().mockReturnValue({}), + getDisabledHooks: vi.fn().mockReturnValue([]), } as unknown as Config; hookRegistry = new HookRegistry(mockConfig); diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts index 82fda32975..3d14e5162d 100644 --- a/packages/core/src/hooks/hookRegistry.ts +++ b/packages/core/src/hooks/hookRegistry.ts @@ -196,19 +196,28 @@ export class HookRegistry { return; } + // Get disabled hooks list from settings + const disabledHooks = this.config.getDisabledHooks() || []; + for (const hookConfig of definition.hooks) { if ( hookConfig && typeof hookConfig === 'object' && this.validateHookConfig(hookConfig, eventName, source) ) { + // Check if this hook is in the disabled list + const hookName = this.getHookName({ + config: hookConfig, + } as HookRegistryEntry); + const isDisabled = disabledHooks.includes(hookName); + this.entries.push({ config: hookConfig, source, eventName, matcher: definition.matcher, sequential: definition.sequential, - enabled: true, + enabled: !isDisabled, }); } else { // Invalid hooks are logged and discarded here, they won't reach HookRunner diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts index 43f9e77936..94a350ec41 100644 --- a/packages/core/src/hooks/hookSystem.test.ts +++ b/packages/core/src/hooks/hookSystem.test.ts @@ -278,4 +278,162 @@ describe('HookSystem Integration', () => { expect(status.initialized).toBe(false); }); }); + + describe('hook disabling via settings', () => { + it('should not execute disabled hooks from settings', async () => { + // Create config with two hooks, one enabled and one disabled via settings + const configWithDisabled = new Config({ + model: 'gemini-1.5-flash', + targetDir: '/tmp/test-hooks-disabled', + sessionId: 'test-session-disabled', + debugMode: false, + cwd: '/tmp/test-hooks-disabled', + hooks: { + BeforeTool: [ + { + matcher: 'TestTool', + hooks: [ + { + type: HookType.Command, + command: 'echo "enabled-hook"', + timeout: 5000, + }, + { + type: HookType.Command, + command: 'echo "disabled-hook"', + timeout: 5000, + }, + ], + }, + ], + disabled: ['echo "disabled-hook"'], // Disable the second hook + }, + }); + + ( + configWithDisabled as unknown as { getMessageBus: () => unknown } + ).getMessageBus = () => undefined; + + const systemWithDisabled = new HookSystem(configWithDisabled); + await systemWithDisabled.initialize(); + + // Set up spawn mock - only enabled hook should execute + let executionCount = 0; + mockSpawn.mockStdoutOn.mockImplementation( + (event: string, callback: (data: Buffer) => void) => { + if (event === 'data') { + executionCount++; + setTimeout(() => callback(Buffer.from('output')), 5); + } + }, + ); + + mockSpawn.mockProcessOn.mockImplementation( + (event: string, callback: (code: number) => void) => { + if (event === 'close') { + setTimeout(() => callback(0), 10); + } + }, + ); + + const eventBus = systemWithDisabled.getEventHandler(); + const result = await eventBus.fireBeforeToolEvent('TestTool', { + test: 'data', + }); + + expect(result.success).toBe(true); + // Only the enabled hook should have executed + expect(executionCount).toBe(1); + }); + }); + + describe('hook disabling via command', () => { + it('should disable hook when setHookEnabled is called', async () => { + // Create config with a hook + const configForDisabling = new Config({ + model: 'gemini-1.5-flash', + targetDir: '/tmp/test-hooks-setEnabled', + sessionId: 'test-session-setEnabled', + debugMode: false, + cwd: '/tmp/test-hooks-setEnabled', + hooks: { + BeforeTool: [ + { + matcher: 'TestTool', + hooks: [ + { + type: HookType.Command, + command: 'echo "will-be-disabled"', + timeout: 5000, + }, + ], + }, + ], + }, + }); + + ( + configForDisabling as unknown as { getMessageBus: () => unknown } + ).getMessageBus = () => undefined; + + const systemForDisabling = new HookSystem(configForDisabling); + await systemForDisabling.initialize(); + + // First execution - hook should run + let executionCount = 0; + mockSpawn.mockStdoutOn.mockImplementation( + (event: string, callback: (data: Buffer) => void) => { + if (event === 'data') { + executionCount++; + setTimeout(() => callback(Buffer.from('output')), 5); + } + }, + ); + + mockSpawn.mockProcessOn.mockImplementation( + (event: string, callback: (code: number) => void) => { + if (event === 'close') { + setTimeout(() => callback(0), 10); + } + }, + ); + + const eventBus = systemForDisabling.getEventHandler(); + const result1 = await eventBus.fireBeforeToolEvent('TestTool', { + test: 'data', + }); + + expect(result1.success).toBe(true); + expect(executionCount).toBe(1); + + // Disable the hook via setHookEnabled (simulating /hooks disable command) + systemForDisabling.setHookEnabled('echo "will-be-disabled"', false); + + // Reset execution count + executionCount = 0; + + // Second execution - hook should NOT run + const result2 = await eventBus.fireBeforeToolEvent('TestTool', { + test: 'data', + }); + + expect(result2.success).toBe(true); + // Hook should not have executed + expect(executionCount).toBe(0); + + // Re-enable the hook + systemForDisabling.setHookEnabled('echo "will-be-disabled"', true); + + // Reset execution count + executionCount = 0; + + // Third execution - hook should run again + const result3 = await eventBus.fireBeforeToolEvent('TestTool', { + test: 'data', + }); + + expect(result3.success).toBe(true); + expect(executionCount).toBe(1); + }); + }); }); diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index 4bae9b7452..a2346a3972 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -15,8 +15,9 @@ export { HookAggregator } from './hookAggregator.js'; export { HookPlanner } from './hookPlanner.js'; export { HookEventHandler } from './hookEventHandler.js'; -// Export interfaces -export type { HookRegistryEntry, ConfigSource } from './hookRegistry.js'; +// Export interfaces and enums +export type { HookRegistryEntry } from './hookRegistry.js'; +export { ConfigSource } from './hookRegistry.js'; export type { AggregatedHookResult } from './hookAggregator.js'; export type { HookEventContext } from './hookPlanner.js'; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index ba179f888a..b094e0a553 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1370,7 +1370,99 @@ "markdownDescription": "Hook configurations for intercepting and customizing agent behavior.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `{}`", "default": {}, "type": "object", - "additionalProperties": true + "properties": { + "disabled": { + "title": "Disabled Hooks", + "description": "List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.", + "markdownDescription": "List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "BeforeTool": { + "title": "Before Tool Hooks", + "description": "Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.", + "markdownDescription": "Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "AfterTool": { + "title": "After Tool Hooks", + "description": "Hooks that execute after tool execution. Can process results, log outputs, or trigger follow-up actions.", + "markdownDescription": "Hooks that execute after tool execution. Can process results, log outputs, or trigger follow-up actions.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "BeforeAgent": { + "title": "Before Agent Hooks", + "description": "Hooks that execute before agent loop starts. Can set up context or initialize resources.", + "markdownDescription": "Hooks that execute before agent loop starts. Can set up context or initialize resources.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "AfterAgent": { + "title": "After Agent Hooks", + "description": "Hooks that execute after agent loop completes. Can perform cleanup or summarize results.", + "markdownDescription": "Hooks that execute after agent loop completes. Can perform cleanup or summarize results.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "Notification": { + "title": "Notification Hooks", + "description": "Hooks that execute on notification events (errors, warnings, info). Can log or alert on specific conditions.", + "markdownDescription": "Hooks that execute on notification events (errors, warnings, info). Can log or alert on specific conditions.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "SessionStart": { + "title": "Session Start Hooks", + "description": "Hooks that execute when a session starts. Can initialize session-specific resources or state.", + "markdownDescription": "Hooks that execute when a session starts. Can initialize session-specific resources or state.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "SessionEnd": { + "title": "Session End Hooks", + "description": "Hooks that execute when a session ends. Can perform cleanup or persist session data.", + "markdownDescription": "Hooks that execute when a session ends. Can perform cleanup or persist session data.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "PreCompress": { + "title": "Pre-Compress Hooks", + "description": "Hooks that execute before chat history compression. Can back up or analyze conversation before compression.", + "markdownDescription": "Hooks that execute before chat history compression. Can back up or analyze conversation before compression.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "BeforeModel": { + "title": "Before Model Hooks", + "description": "Hooks that execute before LLM requests. Can modify prompts, inject context, or control model parameters.", + "markdownDescription": "Hooks that execute before LLM requests. Can modify prompts, inject context, or control model parameters.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "AfterModel": { + "title": "After Model Hooks", + "description": "Hooks that execute after LLM responses. Can process outputs, extract information, or log interactions.", + "markdownDescription": "Hooks that execute after LLM responses. Can process outputs, extract information, or log interactions.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + }, + "BeforeToolSelection": { + "title": "Before Tool Selection Hooks", + "description": "Hooks that execute before tool selection. Can filter or prioritize available tools dynamically.", + "markdownDescription": "Hooks that execute before tool selection. Can filter or prioritize available tools dynamically.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "$ref": "#/$defs/HookDefinitionArray" + } + }, + "additionalProperties": { + "type": "array", + "items": {} + } } }, "$defs": { @@ -1709,6 +1801,42 @@ "type": "string" } ] + }, + "HookDefinitionArray": { + "type": "array", + "description": "Array of hook definition objects for a specific event.", + "items": { + "type": "object", + "description": "Hook definition specifying matcher pattern and hook configurations.", + "properties": { + "matcher": { + "type": "string", + "description": "Pattern to match against the event context (tool name, notification type, etc.). Supports exact match, regex (/pattern/), and wildcards (*)." + }, + "hooks": { + "type": "array", + "description": "Hooks to execute when the matcher matches.", + "items": { + "type": "object", + "description": "Individual hook configuration.", + "properties": { + "type": { + "type": "string", + "description": "Type of hook (currently only \"command\" supported)." + }, + "command": { + "type": "string", + "description": "Shell command to execute. Receives JSON input via stdin and returns JSON output via stdout." + }, + "timeout": { + "type": "number", + "description": "Timeout in milliseconds for hook execution." + } + } + } + } + } + } } } }