mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 22:55:13 +00:00
feat(hooks): Hooks Commands Panel, Enable/Disable, and Migrate (#14225)
This commit is contained in:
@@ -816,10 +816,65 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
|
|
||||||
#### `hooks`
|
#### `hooks`
|
||||||
|
|
||||||
- **`hooks`** (object):
|
- **`hooks.disabled`** (array):
|
||||||
- **Description:** Hook configurations for intercepting and customizing agent
|
- **Description:** List of hook names (commands) that should be disabled.
|
||||||
behavior.
|
Hooks in this list will not execute even if configured.
|
||||||
- **Default:** `{}`
|
- **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 -->
|
<!-- SETTINGS-AUTOGEN:END -->
|
||||||
|
|
||||||
#### `mcpServers`
|
#### `mcpServers`
|
||||||
|
|||||||
@@ -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}]}}]}
|
||||||
@@ -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}]}}]}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
25
packages/cli/src/commands/hooks.tsx
Normal file
25
packages/cli/src/commands/hooks.tsx
Normal 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.
|
||||||
|
},
|
||||||
|
};
|
||||||
518
packages/cli/src/commands/hooks/migrate.test.ts
Normal file
518
packages/cli/src/commands/hooks/migrate.test.ts
Normal 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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
273
packages/cli/src/commands/hooks/migrate.ts
Normal file
273
packages/cli/src/commands/hooks/migrate.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import process from 'node:process';
|
|||||||
import { mcpCommand } from '../commands/mcp.js';
|
import { mcpCommand } from '../commands/mcp.js';
|
||||||
import type { OutputFormat } from '@google/gemini-cli-core';
|
import type { OutputFormat } from '@google/gemini-cli-core';
|
||||||
import { extensionsCommand } from '../commands/extensions.js';
|
import { extensionsCommand } from '../commands/extensions.js';
|
||||||
|
import { hooksCommand } from '../commands/hooks.js';
|
||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||||
@@ -281,6 +282,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
yargsInstance.command(extensionsCommand);
|
yargsInstance.command(extensionsCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register hooks command if hooks are enabled
|
||||||
|
if (settings?.tools?.enableHooks) {
|
||||||
|
yargsInstance.command(hooksCommand);
|
||||||
|
}
|
||||||
|
|
||||||
yargsInstance
|
yargsInstance
|
||||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||||
.alias('v', 'version')
|
.alias('v', 'version')
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ import { SettingPaths } from './settingPaths.js';
|
|||||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||||
let current: SettingDefinition | undefined = undefined;
|
let current: SettingDefinition | undefined = undefined;
|
||||||
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
||||||
|
let parent: SettingDefinition | undefined = undefined;
|
||||||
|
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
if (!currentSchema || !currentSchema[key]) {
|
if (!currentSchema || !currentSchema[key]) {
|
||||||
|
// Key not found in schema - check if parent has additionalProperties
|
||||||
|
if (parent?.additionalProperties?.mergeStrategy) {
|
||||||
|
return parent.additionalProperties.mergeStrategy;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
parent = current;
|
||||||
current = currentSchema[key];
|
current = currentSchema[key];
|
||||||
currentSchema = current.properties;
|
currentSchema = current.properties;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import type {
|
|||||||
BugCommandSettings,
|
BugCommandSettings,
|
||||||
TelemetrySettings,
|
TelemetrySettings,
|
||||||
AuthType,
|
AuthType,
|
||||||
HookDefinition,
|
|
||||||
HookEventName,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
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.
|
* For example, a JSON schema generator can use this to point to a shared definition.
|
||||||
*/
|
*/
|
||||||
ref?: string;
|
ref?: string;
|
||||||
|
/**
|
||||||
|
* Optional merge strategy for dynamically added properties.
|
||||||
|
* Used when this collection definition is referenced via additionalProperties.
|
||||||
|
*/
|
||||||
|
mergeStrategy?: MergeStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MergeStrategy {
|
export enum MergeStrategy {
|
||||||
@@ -1422,11 +1425,165 @@ const SETTINGS_SCHEMA = {
|
|||||||
label: 'Hooks',
|
label: 'Hooks',
|
||||||
category: 'Advanced',
|
category: 'Advanced',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: {} as { [K in HookEventName]?: HookDefinition[] },
|
default: {},
|
||||||
description:
|
description:
|
||||||
'Hook configurations for intercepting and customizing agent behavior.',
|
'Hook configurations for intercepting and customizing agent behavior.',
|
||||||
showInDialog: false,
|
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;
|
} 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.',
|
description: 'Accepts either a boolean flag or a string command name.',
|
||||||
anyOf: [{ type: 'boolean' }, { type: 'string' }],
|
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 {
|
export function getSettingsSchema(): SettingsSchemaType {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||||
getEnableMessageBusIntegration: () => false,
|
getEnableMessageBusIntegration: () => false,
|
||||||
getEnableExtensionReloading: () => false,
|
getEnableExtensionReloading: () => false,
|
||||||
|
getEnableHooks: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
restoreCommandMock.mockReturnValue({
|
restoreCommandMock.mockReturnValue({
|
||||||
@@ -172,6 +173,7 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
const mockConfigWithMessageBus = {
|
const mockConfigWithMessageBus = {
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
getEnableMessageBusIntegration: () => true,
|
getEnableMessageBusIntegration: () => true,
|
||||||
|
getEnableHooks: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
const loader = new BuiltinCommandLoader(mockConfigWithMessageBus);
|
const loader = new BuiltinCommandLoader(mockConfigWithMessageBus);
|
||||||
const commands = await loader.loadCommands(new AbortController().signal);
|
const commands = await loader.loadCommands(new AbortController().signal);
|
||||||
@@ -183,6 +185,7 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
const mockConfigWithoutMessageBus = {
|
const mockConfigWithoutMessageBus = {
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
getEnableMessageBusIntegration: () => false,
|
getEnableMessageBusIntegration: () => false,
|
||||||
|
getEnableHooks: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
const loader = new BuiltinCommandLoader(mockConfigWithoutMessageBus);
|
const loader = new BuiltinCommandLoader(mockConfigWithoutMessageBus);
|
||||||
const commands = await loader.loadCommands(new AbortController().signal);
|
const commands = await loader.loadCommands(new AbortController().signal);
|
||||||
@@ -201,6 +204,7 @@ describe('BuiltinCommandLoader profile', () => {
|
|||||||
getCheckpointingEnabled: () => false,
|
getCheckpointingEnabled: () => false,
|
||||||
getEnableMessageBusIntegration: () => false,
|
getEnableMessageBusIntegration: () => false,
|
||||||
getEnableExtensionReloading: () => false,
|
getEnableExtensionReloading: () => false,
|
||||||
|
getEnableHooks: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
|||||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
|
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||||
import { initCommand } from '../ui/commands/initCommand.js';
|
import { initCommand } from '../ui/commands/initCommand.js';
|
||||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||||
@@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
editorCommand,
|
editorCommand,
|
||||||
extensionsCommand(this.config?.getEnableExtensionReloading()),
|
extensionsCommand(this.config?.getEnableExtensionReloading()),
|
||||||
helpCommand,
|
helpCommand,
|
||||||
|
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
|
||||||
await ideCommand(),
|
await ideCommand(),
|
||||||
initCommand,
|
initCommand,
|
||||||
mcpCommand,
|
mcpCommand,
|
||||||
|
|||||||
569
packages/cli/src/ui/commands/hooksCommand.test.ts
Normal file
569
packages/cli/src/ui/commands/hooksCommand.test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
250
packages/cli/src/ui/commands/hooksCommand.ts
Normal file
250
packages/cli/src/ui/commands/hooksCommand.ts
Normal 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, ''),
|
||||||
|
};
|
||||||
@@ -30,6 +30,7 @@ import { getMCPServerStatus } from '@google/gemini-cli-core';
|
|||||||
import { ToolsList } from './views/ToolsList.js';
|
import { ToolsList } from './views/ToolsList.js';
|
||||||
import { McpStatus } from './views/McpStatus.js';
|
import { McpStatus } from './views/McpStatus.js';
|
||||||
import { ChatList } from './views/ChatList.js';
|
import { ChatList } from './views/ChatList.js';
|
||||||
|
import { HooksList } from './views/HooksList.js';
|
||||||
import { ModelMessage } from './messages/ModelMessage.js';
|
import { ModelMessage } from './messages/ModelMessage.js';
|
||||||
|
|
||||||
interface HistoryItemDisplayProps {
|
interface HistoryItemDisplayProps {
|
||||||
@@ -158,6 +159,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
{itemForDisplay.type === 'chat_list' && (
|
{itemForDisplay.type === 'chat_list' && (
|
||||||
<ChatList chats={itemForDisplay.chats} />
|
<ChatList chats={itemForDisplay.chats} />
|
||||||
)}
|
)}
|
||||||
|
{itemForDisplay.type === 'hooks_list' && (
|
||||||
|
<HooksList hooks={itemForDisplay.hooks} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
89
packages/cli/src/ui/components/views/HooksList.tsx
Normal file
89
packages/cli/src/ui/components/views/HooksList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -240,6 +240,18 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
|
|||||||
showSchema: boolean;
|
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
|
// 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
|
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
|
||||||
// 'tools' in historyItem.
|
// 'tools' in historyItem.
|
||||||
@@ -264,7 +276,8 @@ export type HistoryItemWithoutId =
|
|||||||
| HistoryItemExtensionsList
|
| HistoryItemExtensionsList
|
||||||
| HistoryItemToolsList
|
| HistoryItemToolsList
|
||||||
| HistoryItemMcpStatus
|
| HistoryItemMcpStatus
|
||||||
| HistoryItemChatList;
|
| HistoryItemChatList
|
||||||
|
| HistoryItemHooksList;
|
||||||
|
|
||||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||||
|
|
||||||
@@ -286,6 +299,7 @@ export enum MessageType {
|
|||||||
TOOLS_LIST = 'tools_list',
|
TOOLS_LIST = 'tools_list',
|
||||||
MCP_STATUS = 'mcp_status',
|
MCP_STATUS = 'mcp_status',
|
||||||
CHAT_LIST = 'chat_list',
|
CHAT_LIST = 'chat_list',
|
||||||
|
HOOKS_LIST = 'hooks_list',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simplified message structure for internal feedback
|
// Simplified message structure for internal feedback
|
||||||
|
|||||||
@@ -160,4 +160,43 @@ describe('customDeepMerge', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
expect(({} as any).polluted).toBeUndefined();
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -312,9 +312,13 @@ export interface ConfigParameters {
|
|||||||
modelConfigServiceConfig?: ModelConfigServiceConfig;
|
modelConfigServiceConfig?: ModelConfigServiceConfig;
|
||||||
enableHooks?: boolean;
|
enableHooks?: boolean;
|
||||||
experiments?: Experiments;
|
experiments?: Experiments;
|
||||||
hooks?: {
|
hooks?:
|
||||||
[K in HookEventName]?: HookDefinition[];
|
| {
|
||||||
};
|
[K in HookEventName]?: HookDefinition[];
|
||||||
|
}
|
||||||
|
| ({
|
||||||
|
[K in HookEventName]?: HookDefinition[];
|
||||||
|
} & { disabled?: string[] });
|
||||||
previewFeatures?: boolean;
|
previewFeatures?: boolean;
|
||||||
enableModelAvailabilityService?: boolean;
|
enableModelAvailabilityService?: boolean;
|
||||||
experimentalJitContext?: boolean;
|
experimentalJitContext?: boolean;
|
||||||
@@ -429,6 +433,7 @@ export class Config {
|
|||||||
private readonly hooks:
|
private readonly hooks:
|
||||||
| { [K in HookEventName]?: HookDefinition[] }
|
| { [K in HookEventName]?: HookDefinition[] }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
private readonly disabledHooks: string[];
|
||||||
private experiments: Experiments | undefined;
|
private experiments: Experiments | undefined;
|
||||||
private experimentsPromise: Promise<void> | undefined;
|
private experimentsPromise: Promise<void> | undefined;
|
||||||
private hookSystem?: HookSystem;
|
private hookSystem?: HookSystem;
|
||||||
@@ -541,6 +546,10 @@ export class Config {
|
|||||||
this.useSmartEdit = params.useSmartEdit ?? true;
|
this.useSmartEdit = params.useSmartEdit ?? true;
|
||||||
this.useWriteTodos = params.useWriteTodos ?? true;
|
this.useWriteTodos = params.useWriteTodos ?? true;
|
||||||
this.enableHooks = params.enableHooks ?? false;
|
this.enableHooks = params.enableHooks ?? false;
|
||||||
|
this.disabledHooks =
|
||||||
|
(params.hooks && 'disabled' in params.hooks
|
||||||
|
? params.hooks.disabled
|
||||||
|
: undefined) ?? [];
|
||||||
|
|
||||||
// Enable MessageBus integration if:
|
// Enable MessageBus integration if:
|
||||||
// 1. Explicitly enabled via setting, OR
|
// 1. Explicitly enabled via setting, OR
|
||||||
@@ -1563,6 +1572,13 @@ export class Config {
|
|||||||
return this.hooks;
|
return this.hooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get disabled hooks list
|
||||||
|
*/
|
||||||
|
getDisabledHooks(): string[] {
|
||||||
|
return this.disabledHooks;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get experiments configuration
|
* Get experiments configuration
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe('HookRegistry', () => {
|
|||||||
storage: mockStorage,
|
storage: mockStorage,
|
||||||
getExtensions: vi.fn().mockReturnValue([]),
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
getHooks: vi.fn().mockReturnValue({}),
|
getHooks: vi.fn().mockReturnValue({}),
|
||||||
|
getDisabledHooks: vi.fn().mockReturnValue([]),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
hookRegistry = new HookRegistry(mockConfig);
|
hookRegistry = new HookRegistry(mockConfig);
|
||||||
|
|||||||
@@ -196,19 +196,28 @@ export class HookRegistry {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get disabled hooks list from settings
|
||||||
|
const disabledHooks = this.config.getDisabledHooks() || [];
|
||||||
|
|
||||||
for (const hookConfig of definition.hooks) {
|
for (const hookConfig of definition.hooks) {
|
||||||
if (
|
if (
|
||||||
hookConfig &&
|
hookConfig &&
|
||||||
typeof hookConfig === 'object' &&
|
typeof hookConfig === 'object' &&
|
||||||
this.validateHookConfig(hookConfig, eventName, source)
|
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({
|
this.entries.push({
|
||||||
config: hookConfig,
|
config: hookConfig,
|
||||||
source,
|
source,
|
||||||
eventName,
|
eventName,
|
||||||
matcher: definition.matcher,
|
matcher: definition.matcher,
|
||||||
sequential: definition.sequential,
|
sequential: definition.sequential,
|
||||||
enabled: true,
|
enabled: !isDisabled,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Invalid hooks are logged and discarded here, they won't reach HookRunner
|
// Invalid hooks are logged and discarded here, they won't reach HookRunner
|
||||||
|
|||||||
@@ -278,4 +278,162 @@ describe('HookSystem Integration', () => {
|
|||||||
expect(status.initialized).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export { HookAggregator } from './hookAggregator.js';
|
|||||||
export { HookPlanner } from './hookPlanner.js';
|
export { HookPlanner } from './hookPlanner.js';
|
||||||
export { HookEventHandler } from './hookEventHandler.js';
|
export { HookEventHandler } from './hookEventHandler.js';
|
||||||
|
|
||||||
// Export interfaces
|
// Export interfaces and enums
|
||||||
export type { HookRegistryEntry, ConfigSource } from './hookRegistry.js';
|
export type { HookRegistryEntry } from './hookRegistry.js';
|
||||||
|
export { ConfigSource } from './hookRegistry.js';
|
||||||
export type { AggregatedHookResult } from './hookAggregator.js';
|
export type { AggregatedHookResult } from './hookAggregator.js';
|
||||||
export type { HookEventContext } from './hookPlanner.js';
|
export type { HookEventContext } from './hookPlanner.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1370,7 +1370,99 @@
|
|||||||
"markdownDescription": "Hook configurations for intercepting and customizing agent behavior.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `{}`",
|
"markdownDescription": "Hook configurations for intercepting and customizing agent behavior.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `{}`",
|
||||||
"default": {},
|
"default": {},
|
||||||
"type": "object",
|
"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": {
|
"$defs": {
|
||||||
@@ -1709,6 +1801,42 @@
|
|||||||
"type": "string"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user