From 9703fe73cf910716b255891e76ec5dafd43696b9 Mon Sep 17 00:00:00 2001 From: Abdul Tawab <122252873+AbdulTawabJuly@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:42:04 +0500 Subject: [PATCH] feat(cli): Hooks enable-all/disable-all feature with dynamic status (#15552) --- docs/hooks/index.md | 26 +- .../cli/src/ui/commands/hooksCommand.test.ts | 297 +++++++++++++++++- packages/cli/src/ui/commands/hooksCommand.ts | 194 ++++++++++-- .../cli/src/ui/components/views/HooksList.tsx | 191 ++++++----- 4 files changed, 569 insertions(+), 139 deletions(-) diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 0c62957a9a..66dfa6ef56 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -533,14 +533,29 @@ Use the `/hooks panel` command to view all registered hooks: This command displays: -- All active hooks organized by event +- All configured hooks organized by event - Hook source (user, project, system) - Hook type (command or plugin) -- Execution status and recent output +- Individual hook status (enabled/disabled) -### Enable and disable hooks +### Enable and disable all hooks at once -You can temporarily enable or disable individual hooks using commands: +You can enable or disable all hooks at once using commands: + +```bash +/hooks enable-all +/hooks disable-all +``` + +These commands provide a shortcut to enable or disable all configured hooks +without managing them individually. The `enable-all` command removes all hooks +from the `hooks.disabled` array, while `disable-all` adds all configured hooks +to the disabled list. Changes take effect immediately without requiring a +restart. + +### Enable and disable individual hooks + +You can enable or disable individual hooks using commands: ```bash /hooks enable hook-name @@ -549,7 +564,8 @@ You can temporarily enable or disable individual hooks using commands: These commands allow you to control hook execution without editing configuration files. The hook name should match the `name` field in your hook configuration. -Changes made via these commands are persisted to your global User settings +Changes made via these commands are persisted to your settings. The settings are +saved to workspace scope if available, otherwise to your global user settings (`~/.gemini/settings.json`). ### Disabled hooks configuration diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 54a3edc991..aa4eb12971 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -21,12 +21,16 @@ describe('hooksCommand', () => { }; let mockConfig: { getHookSystem: ReturnType; + getEnableHooks: ReturnType; }; let mockSettings: { merged: { hooks?: { disabled?: string[]; }; + tools?: { + enableHooks?: boolean; + }; }; setValue: ReturnType; }; @@ -46,6 +50,7 @@ describe('hooksCommand', () => { // Create mock config mockConfig = { getHookSystem: vi.fn().mockReturnValue(mockHookSystem), + getEnableHooks: vi.fn().mockReturnValue(true), }; // Create mock settings @@ -79,12 +84,14 @@ describe('hooksCommand', () => { it('should have all expected subcommands', () => { expect(hooksCommand.subCommands).toBeDefined(); - expect(hooksCommand.subCommands).toHaveLength(3); + expect(hooksCommand.subCommands).toHaveLength(5); const subCommandNames = hooksCommand.subCommands!.map((cmd) => cmd.name); expect(subCommandNames).toContain('panel'); expect(subCommandNames).toContain('enable'); expect(subCommandNames).toContain('disable'); + expect(subCommandNames).toContain('enable-all'); + expect(subCommandNames).toContain('disable-all'); }); it('should delegate to panel action when invoked without subcommand', async () => { @@ -131,7 +138,7 @@ describe('hooksCommand', () => { }); }); - it('should return info message when hook system is not enabled', async () => { + it('should display panel even when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const panelCmd = hooksCommand.subCommands!.find( @@ -141,18 +148,22 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - const result = await panelCmd.action(mockContext, ''); + await panelCmd.action(mockContext, ''); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'Hook system is not enabled. Enable it in settings with hooks.enabled.', - }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HOOKS_LIST, + hooks: [], + }), + expect.any(Number), + ); }); - it('should return info message when no hooks are configured', async () => { + it('should display panel when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); + (mockContext.services.settings.merged as Record)[ + 'tools' + ] = { enableHooks: true }; const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', @@ -161,14 +172,15 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - const result = await panelCmd.action(mockContext, ''); + await panelCmd.action(mockContext, ''); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'No hooks configured. Add hooks to your settings to get started.', - }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HOOKS_LIST, + hooks: [], + }), + expect.any(Number), + ); }); it('should display hooks list when hooks are configured', async () => { @@ -178,6 +190,9 @@ describe('hooksCommand', () => { ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + (mockContext.services.settings.merged as Record)[ + 'tools' + ] = { enableHooks: true }; const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', @@ -562,6 +577,254 @@ describe('hooksCommand', () => { expect(result).toEqual(['test-hook']); }); }); + + describe('enable-all subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(contextWithoutConfig, ''); + + 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 enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should enable all disabled hooks', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, false), + createMockHook('hook-2', HookEventName.AfterTool, false), + createMockHook('hook-3', HookEventName.BeforeAgent, true), // already enabled + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), + 'hooks.disabled', + [], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-1', + true, + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-2', + true, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Enabled 2 hook(s) successfully.', + }); + }); + + it('should return info when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }); + }); + + it('should return info when all hooks are already enabled', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, true), + createMockHook('hook-2', HookEventName.AfterTool, true), + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'All hooks are already enabled.', + }); + }); + }); + + describe('disable-all subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(contextWithoutConfig, ''); + + 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 disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should disable all enabled hooks', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, true), + createMockHook('hook-2', HookEventName.AfterTool, true), + createMockHook('hook-3', HookEventName.BeforeAgent, false), // already disabled + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), + 'hooks.disabled', + ['hook-1', 'hook-2', 'hook-3'], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-1', + false, + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-2', + false, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Disabled 2 hook(s) successfully.', + }); + }); + + it('should return info when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }); + }); + + it('should return info when all hooks are already disabled', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, false), + createMockHook('hook-2', HookEventName.AfterTool, false), + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'All hooks are already disabled.', + }); + }); + }); }); /** diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 8028173a84..1017474952 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -30,24 +30,7 @@ async function panelAction( } const hookSystem = config.getHookSystem(); - if (!hookSystem) { - return { - type: 'message', - messageType: 'info', - content: - 'Hook system is not enabled. Enable it in settings with hooks.enabled.', - }; - } - - 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 allHooks = hookSystem?.getAllHooks() || []; const hooksListItem: HistoryItemHooksList = { type: MessageType.HOOKS_LIST, @@ -102,7 +85,10 @@ async function enableAction( // Update settings (setValue automatically saves) try { - settings.setValue(SettingScope.User, 'hooks.disabled', newDisabledHooks); + const scope = settings.workspace + ? SettingScope.Workspace + : SettingScope.User; + settings.setValue(scope, 'hooks.disabled', newDisabledHooks); // Enable in hook system hookSystem.setHookEnabled(hookName, true); @@ -165,7 +151,10 @@ async function disableAction( // Update settings (setValue automatically saves) try { - settings.setValue(SettingScope.User, 'hooks.disabled', newDisabledHooks); + const scope = settings.workspace + ? SettingScope.Workspace + : SettingScope.User; + settings.setValue(scope, 'hooks.disabled', newDisabledHooks); // Disable in hook system hookSystem.setHookEnabled(hookName, false); @@ -216,6 +205,145 @@ function getHookDisplayName(hook: HookRegistryEntry): string { return hook.config.name || hook.config.command || 'unknown-hook'; } +/** + * Enable all hooks by clearing the disabled list + */ +async function enableAllAction( + context: CommandContext, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const settings = context.services.settings; + const allHooks = hookSystem.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }; + } + + const disabledHooks = allHooks.filter((hook) => !hook.enabled); + if (disabledHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'All hooks are already enabled.', + }; + } + + try { + const scope = settings.workspace + ? SettingScope.Workspace + : SettingScope.User; + settings.setValue(scope, 'hooks.disabled', []); + + for (const hook of disabledHooks) { + const hookName = getHookDisplayName(hook); + hookSystem.setHookEnabled(hookName, true); + } + + return { + type: 'message', + messageType: 'info', + content: `Enabled ${disabledHooks.length} hook(s) successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to enable hooks: ${getErrorMessage(error)}`, + }; + } +} + +/** + * Disable all hooks by adding all hooks to the disabled list + */ +async function disableAllAction( + context: CommandContext, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const settings = context.services.settings; + const allHooks = hookSystem.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }; + } + + const enabledHooks = allHooks.filter((hook) => hook.enabled); + if (enabledHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'All hooks are already disabled.', + }; + } + + try { + const allHookNames = allHooks.map((hook) => getHookDisplayName(hook)); + const scope = settings.workspace + ? SettingScope.Workspace + : SettingScope.User; + settings.setValue(scope, 'hooks.disabled', allHookNames); + + for (const hook of enabledHooks) { + const hookName = getHookDisplayName(hook); + hookSystem.setHookEnabled(hookName, false); + } + + return { + type: 'message', + messageType: 'info', + content: `Disabled ${enabledHooks.length} hook(s) successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to disable hooks: ${getErrorMessage(error)}`, + }; + } +} + const panelCommand: SlashCommand = { name: 'panel', altNames: ['list', 'show'], @@ -242,10 +370,34 @@ const disableCommand: SlashCommand = { completion: completeHookNames, }; +const enableAllCommand: SlashCommand = { + name: 'enable-all', + altNames: ['enableall'], + description: 'Enable all disabled hooks', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: enableAllAction, +}; + +const disableAllCommand: SlashCommand = { + name: 'disable-all', + altNames: ['disableall'], + description: 'Disable all enabled hooks', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: disableAllAction, +}; + export const hooksCommand: SlashCommand = { name: 'hooks', description: 'Manage hooks', kind: CommandKind.BUILT_IN, - subCommands: [panelCommand, enableCommand, disableCommand], + subCommands: [ + panelCommand, + enableCommand, + disableCommand, + enableAllCommand, + disableAllCommand, + ], action: async (context: CommandContext) => panelCommand.action!(context, ''), }; diff --git a/packages/cli/src/ui/components/views/HooksList.tsx b/packages/cli/src/ui/components/views/HooksList.tsx index c2b8d8a7d7..629a7b5b83 100644 --- a/packages/cli/src/ui/components/views/HooksList.tsx +++ b/packages/cli/src/ui/components/views/HooksList.tsx @@ -25,105 +25,104 @@ interface HooksListProps { }>; } -export const HooksList: React.FC = ({ hooks }) => ( - - - Hooks are scripts or programs that Gemini CLI executes at specific points - in the agentic loop, allowing you to intercept and customize behavior. - +export const HooksList: React.FC = ({ hooks }) => { + if (hooks.length === 0) { + return ( + + + No hooks configured. + + + ); + } - - - ⚠️ Security Warning: - - - Hooks can execute arbitrary commands on your system. Only use hooks from - sources you trust. Review hook scripts carefully. - - + // 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>, + ); - - - Learn more:{' '} - https://geminicli.com/docs/hooks - - + return ( + + + + ⚠️ Security Warning: + + + Hooks can execute arbitrary commands on your system. Only use hooks + from sources you trust. Review hook scripts carefully. + + - - {hooks.length === 0 ? ( - No hooks configured. - ) : ( - <> - - Registered Hooks: - - - {Object.entries( - hooks.reduce( - (acc, hook) => { - if (!acc[hook.eventName]) { - acc[hook.eventName] = []; - } - acc[hook.eventName].push(hook); - return acc; - }, - {} as Record>, - ), - ).map(([eventName, eventHooks]) => ( - - - {eventName}: - - - {eventHooks.map((hook, index) => { - const hookName = - hook.config.name || hook.config.command || 'unknown'; - const statusColor = hook.enabled ? 'green' : 'gray'; - const statusText = hook.enabled ? 'enabled' : 'disabled'; + + + Learn more:{' '} + https://geminicli.com/docs/hooks + + - return ( - - - - {hookName} - {` [${statusText}]`} - - - - {hook.config.description && ( - - {hook.config.description} - - )} - - Source: {hook.source} - {hook.config.name && - hook.config.command && - ` | Command: ${hook.config.command}`} - {hook.matcher && ` | Matcher: ${hook.matcher}`} - {hook.sequential && ` | Sequential`} - {hook.config.timeout && - ` | Timeout: ${hook.config.timeout}s`} - - - - ); - })} - - - ))} + + Configured Hooks: + + + {Object.entries(hooksByEvent).map(([eventName, eventHooks]) => ( + + + {eventName}: + + + {eventHooks.map((hook, index) => { + const hookName = + hook.config.name || hook.config.command || 'unknown'; + const statusColor = hook.enabled + ? theme.status.success + : theme.text.secondary; + const statusText = hook.enabled ? 'enabled' : 'disabled'; + + return ( + + + + {hookName} + {` [${statusText}]`} + + + + {hook.config.description && ( + {hook.config.description} + )} + + Source: {hook.source} + {hook.config.name && + hook.config.command && + ` | Command: ${hook.config.command}`} + {hook.matcher && ` | Matcher: ${hook.matcher}`} + {hook.sequential && ` | Sequential`} + {hook.config.timeout && + ` | Timeout: ${hook.config.timeout}s`} + + + + ); + })} + - - )} + ))} + + + + Tip: Use /hooks enable {''} or{' '} + /hooks disable {''} to toggle individual + hooks. Use /hooks enable-all or{' '} + /hooks disable-all to toggle all hooks at once. + + - - - - Tip: Use `/hooks enable {''}` or `/hooks disable{' '} - {''}` to toggle hooks - - - -); + ); +};