feat(hooks): Hooks Commands Panel, Enable/Disable, and Migrate (#14225)

This commit is contained in:
Edilmo Palencia
2025-12-03 10:01:57 -08:00
committed by GitHub
parent 08067acc71
commit b8c038f41f
24 changed files with 2568 additions and 16 deletions

View File

@@ -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:** `[]`
<!-- SETTINGS-AUTOGEN:END -->
#### `mcpServers`

View File

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

View File

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

View File

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

View File

@@ -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 <command>',
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.
},
};

View File

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

View File

@@ -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<string, string> = {
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<string, string> = {
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<string, unknown>;
const migrated: Record<string, unknown> = {};
// 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<string, unknown> {
if (!claudeConfig || typeof claudeConfig !== 'object') {
return {};
}
const config = claudeConfig as Record<string, unknown>;
const geminiHooks: Record<string, unknown> = {};
// Check if there's a hooks section
const hooksSection = config['hooks'] as Record<string, unknown> | 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<string, unknown>;
const migratedDef: Record<string, unknown> = {};
// 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<string, unknown> | 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<string, unknown>) || {};
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();
},
};

View File

@@ -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<CliArgs> {
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')

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof vi.fn>;
setHookEnabled: ReturnType<typeof vi.fn>;
getRegistry: ReturnType<typeof vi.fn>;
};
let mockConfig: {
getHookSystem: ReturnType<typeof vi.fn>;
};
let mockSettings: {
merged: {
hooks?: {
disabled?: string[];
};
};
setValue: ReturnType<typeof vi.fn>;
};
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 <hook-name>',
});
});
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 <hook-name>',
});
});
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,
};
}

View File

@@ -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<void | MessageActionReturn> {
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<void | MessageActionReturn> {
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 <hook-name>',
};
}
// 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<void | MessageActionReturn> {
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 <hook-name>',
};
}
// 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, ''),
};

View File

@@ -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<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'chat_list' && (
<ChatList chats={itemForDisplay.chats} />
)}
{itemForDisplay.type === 'hooks_list' && (
<HooksList hooks={itemForDisplay.hooks} />
)}
</Box>
);
};

View File

@@ -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<HooksListProps> = ({ hooks }) => {
if (hooks.length === 0) {
return (
<Box marginTop={1} marginBottom={1}>
<Text>No hooks configured.</Text>
</Box>
);
}
// 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<string, Array<(typeof hooks)[number]>>,
);
return (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text bold>Configured Hooks:</Text>
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
{Object.entries(hooksByEvent).map(([eventName, eventHooks]) => (
<Box key={eventName} flexDirection="column" marginBottom={1}>
<Text color="cyan" bold>
{eventName}:
</Text>
<Box flexDirection="column" paddingLeft={2}>
{eventHooks.map((hook, index) => {
const hookName = hook.config.command || 'unknown';
const statusColor = hook.enabled ? 'green' : 'gray';
const statusText = hook.enabled ? 'enabled' : 'disabled';
return (
<Box key={`${eventName}-${index}`} flexDirection="column">
<Box>
<Text>
<Text color="yellow">{hookName}</Text>
<Text color={statusColor}>{` [${statusText}]`}</Text>
</Text>
</Box>
<Box paddingLeft={2} flexDirection="column">
<Text dimColor>
Source: {hook.source}
{hook.matcher && ` | Matcher: ${hook.matcher}`}
{hook.sequential && ` | Sequential`}
{hook.config.timeout &&
` | Timeout: ${hook.config.timeout}s`}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
))}
</Box>
<Box marginTop={1}>
<Text dimColor>
Tip: Use `/hooks enable {'<hook-name>'}` or `/hooks disable{' '}
{'<hook-name>'}` to toggle hooks
</Text>
</Box>
</Box>
);
};

View File

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

View File

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

View File

@@ -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<void> | 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
*/

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
}
}
}
}
}
}
}
}
}