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

This commit is contained in:
Edilmo Palencia
2025-12-03 10:01:57 -08:00
committed by GitHub
parent 08067acc71
commit b8c038f41f
24 changed files with 2568 additions and 16 deletions
+25
View File
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { migrateCommand } from './hooks/migrate.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const hooksCommand: CommandModule = {
command: 'hooks <command>',
aliases: ['hook'],
describe: 'Manage Gemini CLI hooks.',
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(migrateCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
},
};
@@ -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
View File
@@ -0,0 +1,273 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { exitCli } from '../utils.js';
import stripJsonComments from 'strip-json-comments';
interface MigrateArgs {
fromClaude: boolean;
}
/**
* Mapping from Claude Code event names to Gemini event names
*/
const EVENT_MAPPING: Record<string, string> = {
PreToolUse: 'BeforeTool',
PostToolUse: 'AfterTool',
UserPromptSubmit: 'BeforeAgent',
Stop: 'AfterAgent',
SubAgentStop: 'AfterAgent', // Gemini doesn't have sub-agents, map to AfterAgent
SessionStart: 'SessionStart',
SessionEnd: 'SessionEnd',
PreCompact: 'PreCompress',
Notification: 'Notification',
};
/**
* Mapping from Claude Code tool names to Gemini tool names
*/
const TOOL_NAME_MAPPING: Record<string, string> = {
Edit: 'replace',
Bash: 'run_shell_command',
Read: 'read_file',
Write: 'write_file',
Glob: 'glob',
Grep: 'grep',
LS: 'ls',
};
/**
* Transform a matcher regex to update tool names from Claude to Gemini
*/
function transformMatcher(matcher: string | undefined): string | undefined {
if (!matcher) return matcher;
let transformed = matcher;
for (const [claudeName, geminiName] of Object.entries(TOOL_NAME_MAPPING)) {
// Replace exact matches and matches within regex alternations
transformed = transformed.replace(
new RegExp(`\\b${claudeName}\\b`, 'g'),
geminiName,
);
}
return transformed;
}
/**
* Migrate a Claude Code hook configuration to Gemini format
*/
function migrateClaudeHook(claudeHook: unknown): unknown {
if (!claudeHook || typeof claudeHook !== 'object') {
return claudeHook;
}
const hook = claudeHook as Record<string, unknown>;
const migrated: Record<string, unknown> = {};
// Map command field
if ('command' in hook) {
migrated['command'] = hook['command'];
// Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command
if (typeof migrated['command'] === 'string') {
migrated['command'] = migrated['command'].replace(
/\$CLAUDE_PROJECT_DIR/g,
'$GEMINI_PROJECT_DIR',
);
}
}
// Map type field
if ('type' in hook && hook['type'] === 'command') {
migrated['type'] = 'command';
}
// Map timeout field (Claude uses seconds, Gemini uses seconds)
if ('timeout' in hook && typeof hook['timeout'] === 'number') {
migrated['timeout'] = hook['timeout'];
}
return migrated;
}
/**
* Migrate Claude Code hooks configuration to Gemini format
*/
function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
if (!claudeConfig || typeof claudeConfig !== 'object') {
return {};
}
const config = claudeConfig as Record<string, unknown>;
const geminiHooks: Record<string, unknown> = {};
// Check if there's a hooks section
const hooksSection = config['hooks'] as Record<string, unknown> | undefined;
if (!hooksSection || typeof hooksSection !== 'object') {
return {};
}
for (const [eventName, eventConfig] of Object.entries(hooksSection)) {
// Map event name
const geminiEventName = EVENT_MAPPING[eventName] || eventName;
if (!Array.isArray(eventConfig)) {
continue;
}
// Migrate each hook definition
const migratedDefinitions = eventConfig.map((def: unknown) => {
if (!def || typeof def !== 'object') {
return def;
}
const definition = def as Record<string, unknown>;
const migratedDef: Record<string, unknown> = {};
// Transform matcher
if (
'matcher' in definition &&
typeof definition['matcher'] === 'string'
) {
migratedDef['matcher'] = transformMatcher(definition['matcher']);
}
// Copy sequential flag
if ('sequential' in definition) {
migratedDef['sequential'] = definition['sequential'];
}
// Migrate hooks array
if ('hooks' in definition && Array.isArray(definition['hooks'])) {
migratedDef['hooks'] = definition['hooks'].map(migrateClaudeHook);
}
return migratedDef;
});
geminiHooks[geminiEventName] = migratedDefinitions;
}
return geminiHooks;
}
/**
* Handle migration from Claude Code
*/
export async function handleMigrateFromClaude() {
const workingDir = process.cwd();
// Look for Claude settings in .claude directory
const claudeDir = path.join(workingDir, '.claude');
const claudeSettingsPath = path.join(claudeDir, 'settings.json');
const claudeLocalSettingsPath = path.join(claudeDir, 'settings.local.json');
let claudeSettings: Record<string, unknown> | null = null;
let sourceFile = '';
// Try to read settings.local.json first, then settings.json
if (fs.existsSync(claudeLocalSettingsPath)) {
sourceFile = claudeLocalSettingsPath;
try {
const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8');
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
>;
} catch (error) {
debugLogger.error(
`Error reading ${claudeLocalSettingsPath}: ${getErrorMessage(error)}`,
);
}
} else if (fs.existsSync(claudeSettingsPath)) {
sourceFile = claudeSettingsPath;
try {
const content = fs.readFileSync(claudeSettingsPath, 'utf-8');
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
>;
} catch (error) {
debugLogger.error(
`Error reading ${claudeSettingsPath}: ${getErrorMessage(error)}`,
);
}
} else {
debugLogger.error(
'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json',
);
return;
}
if (!claudeSettings) {
return;
}
debugLogger.log(`Found Claude Code settings in: ${sourceFile}`);
// Migrate hooks
const migratedHooks = migrateClaudeHooks(claudeSettings);
if (Object.keys(migratedHooks).length === 0) {
debugLogger.log('No hooks found in Claude Code settings to migrate.');
return;
}
debugLogger.log(
`Migrating ${Object.keys(migratedHooks).length} hook event(s)...`,
);
// Load current Gemini settings
const settings = loadSettings(workingDir);
// Merge migrated hooks with existing hooks
const existingHooks =
(settings.merged.hooks as Record<string, unknown>) || {};
const mergedHooks = { ...existingHooks, ...migratedHooks };
// Update settings (setValue automatically saves)
try {
settings.setValue(SettingScope.Workspace, 'hooks', mergedHooks);
debugLogger.log('✓ Hooks successfully migrated to .gemini/settings.json');
debugLogger.log(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
debugLogger.log(
'Note: Set tools.enableHooks to true in your settings to enable the hook system.',
);
} catch (error) {
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
}
}
export const migrateCommand: CommandModule = {
command: 'migrate',
describe: 'Migrate hooks from Claude Code to Gemini CLI',
builder: (yargs) =>
yargs.option('from-claude', {
describe: 'Migrate from Claude Code hooks',
type: 'boolean',
default: false,
}),
handler: async (argv) => {
const args = argv as unknown as MigrateArgs;
if (args.fromClaude) {
await handleMigrateFromClaude();
} else {
debugLogger.log(
'Usage: gemini hooks migrate --from-claude\n\nMigrate hooks from Claude Code to Gemini CLI format.',
);
}
await exitCli();
},
};