mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 13:34:15 -07:00
feat(hooks): add support for friendly names and descriptions (#15174)
This commit is contained in:
@@ -282,6 +282,71 @@ describe('HookPlanner', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should deduplicate based on both name and command', () => {
|
||||
const mockEntries: HookRegistryEntry[] = [
|
||||
{
|
||||
config: {
|
||||
name: 'hook1',
|
||||
type: HookType.Command,
|
||||
command: './same.sh',
|
||||
},
|
||||
source: ConfigSource.Project,
|
||||
eventName: HookEventName.BeforeTool,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
config: {
|
||||
name: 'hook1',
|
||||
type: HookType.Command,
|
||||
command: './same.sh',
|
||||
},
|
||||
source: ConfigSource.User,
|
||||
eventName: HookEventName.BeforeTool,
|
||||
enabled: true,
|
||||
}, // Same name, same command -> deduplicate
|
||||
{
|
||||
config: {
|
||||
name: 'hook2',
|
||||
type: HookType.Command,
|
||||
command: './same.sh',
|
||||
},
|
||||
source: ConfigSource.Project,
|
||||
eventName: HookEventName.BeforeTool,
|
||||
enabled: true,
|
||||
}, // Different name, same command -> distinct
|
||||
{
|
||||
config: {
|
||||
name: 'hook1',
|
||||
type: HookType.Command,
|
||||
command: './different.sh',
|
||||
},
|
||||
source: ConfigSource.Project,
|
||||
eventName: HookEventName.BeforeTool,
|
||||
enabled: true,
|
||||
}, // Same name, different command -> distinct
|
||||
{
|
||||
config: { type: HookType.Command, command: './no-name.sh' },
|
||||
source: ConfigSource.Project,
|
||||
eventName: HookEventName.BeforeTool,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
config: { type: HookType.Command, command: './no-name.sh' },
|
||||
source: ConfigSource.User,
|
||||
eventName: HookEventName.BeforeTool,
|
||||
enabled: true,
|
||||
}, // No name, same command -> deduplicate
|
||||
];
|
||||
|
||||
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries);
|
||||
|
||||
const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool);
|
||||
|
||||
expect(plan).not.toBeNull();
|
||||
// hook1:same.sh (deduped), hook2:same.sh, hook1:different.sh, :no-name.sh (deduped)
|
||||
expect(plan!.hookConfigs).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should match trigger for session events', () => {
|
||||
const mockEntries: HookRegistryEntry[] = [
|
||||
{
|
||||
|
||||
@@ -141,7 +141,9 @@ export class HookPlanner {
|
||||
* Generate a unique key for a hook entry
|
||||
*/
|
||||
private getHookKey(entry: HookRegistryEntry): string {
|
||||
return `command:${entry.config.command}`;
|
||||
const name = entry.config.name || '';
|
||||
const command = entry.config.command || '';
|
||||
return `${name}:${command}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,39 @@ describe('HookRegistry', () => {
|
||||
expect(hookRegistry.getAllHooks()).toHaveLength(0);
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalled(); // At least some warnings should be logged
|
||||
});
|
||||
|
||||
it('should respect disabled hooks using friendly name', async () => {
|
||||
const mockHooksConfig = {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
name: 'disabled-hook',
|
||||
type: 'command',
|
||||
command: './hooks/test.sh',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Update mock to return the hooks configuration
|
||||
vi.mocked(mockConfig.getHooks).mockReturnValue(
|
||||
mockHooksConfig as unknown as {
|
||||
[K in HookEventName]?: HookDefinition[];
|
||||
},
|
||||
);
|
||||
vi.mocked(mockConfig.getDisabledHooks).mockReturnValue(['disabled-hook']);
|
||||
|
||||
await hookRegistry.initialize();
|
||||
|
||||
const hooks = hookRegistry.getAllHooks();
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].enabled).toBe(false);
|
||||
expect(
|
||||
hookRegistry.getHooksForEvent(HookEventName.BeforeTool),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHooksForEvent', () => {
|
||||
@@ -305,6 +338,77 @@ describe('HookRegistry', () => {
|
||||
'No hooks found matching "non-existent-hook"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer hook name over command for identification', async () => {
|
||||
const mockHooksConfig = {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
name: 'friendly-name',
|
||||
type: 'command',
|
||||
command: './hooks/test.sh',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(mockConfig.getHooks).mockReturnValue(
|
||||
mockHooksConfig as unknown as {
|
||||
[K in HookEventName]?: HookDefinition[];
|
||||
},
|
||||
);
|
||||
|
||||
await hookRegistry.initialize();
|
||||
|
||||
// Should be enabled initially
|
||||
let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
|
||||
expect(hooks).toHaveLength(1);
|
||||
|
||||
// Disable using friendly name
|
||||
hookRegistry.setHookEnabled('friendly-name', false);
|
||||
hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
|
||||
expect(hooks).toHaveLength(0);
|
||||
|
||||
// Identification by command should NOT work when name is present
|
||||
hookRegistry.setHookEnabled('./hooks/test.sh', true);
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
'No hooks found matching "./hooks/test.sh"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use command as identifier when name is missing', async () => {
|
||||
const mockHooksConfig = {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: './hooks/no-name.sh',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(mockConfig.getHooks).mockReturnValue(
|
||||
mockHooksConfig as unknown as {
|
||||
[K in HookEventName]?: HookDefinition[];
|
||||
},
|
||||
);
|
||||
|
||||
await hookRegistry.initialize();
|
||||
|
||||
// Should be enabled initially
|
||||
let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
|
||||
expect(hooks).toHaveLength(1);
|
||||
|
||||
// Disable using command
|
||||
hookRegistry.setHookEnabled('./hooks/no-name.sh', false);
|
||||
hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
|
||||
expect(hooks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed configuration handling', () => {
|
||||
|
||||
@@ -96,10 +96,12 @@ export class HookRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hook name for display purposes
|
||||
* Get hook name for identification and display purposes
|
||||
*/
|
||||
private getHookName(entry: HookRegistryEntry): string {
|
||||
return entry.config.command || 'unknown-command';
|
||||
private getHookName(
|
||||
entry: HookRegistryEntry | { config: HookConfig },
|
||||
): string {
|
||||
return entry.config.name || entry.config.command || 'unknown-command';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,18 @@ vi.mock('node:child_process', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock debugLogger using vi.hoisted
|
||||
const mockDebugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: mockDebugLogger,
|
||||
}));
|
||||
|
||||
// Mock console methods
|
||||
const mockConsole = {
|
||||
log: vi.fn(),
|
||||
@@ -162,6 +174,31 @@ describe('HookRunner', () => {
|
||||
expect(result.stderr).toBe(errorMessage);
|
||||
});
|
||||
|
||||
it('should use hook name in error messages if available', async () => {
|
||||
const namedConfig: HookConfig = {
|
||||
name: 'my-friendly-hook',
|
||||
type: HookType.Command,
|
||||
command: './hooks/fail.sh',
|
||||
};
|
||||
|
||||
// Mock error during spawn
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
throw new Error('Spawn error');
|
||||
});
|
||||
|
||||
await hookRunner.executeHook(
|
||||
namedConfig,
|
||||
HookEventName.BeforeTool,
|
||||
mockInput,
|
||||
);
|
||||
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'(hook: my-friendly-hook): Error: Spawn error',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle command hook timeout', async () => {
|
||||
const shortTimeoutConfig: HookConfig = {
|
||||
type: HookType.Command,
|
||||
|
||||
@@ -55,8 +55,8 @@ export class HookRunner {
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const hookSource = hookConfig.command || 'unknown';
|
||||
const errorMessage = `Hook execution failed for event '${eventName}' (source: ${hookSource}): ${error}`;
|
||||
const hookId = hookConfig.name || hookConfig.command || 'unknown';
|
||||
const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
|
||||
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
|
||||
|
||||
return {
|
||||
|
||||
@@ -40,6 +40,8 @@ export enum HookEventName {
|
||||
export interface CommandHookConfig {
|
||||
type: HookType.Command;
|
||||
command: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user