feat(cli): Hooks enable-all/disable-all feature with dynamic status (#15552)

This commit is contained in:
Abdul Tawab
2026-01-12 12:42:04 +05:00
committed by GitHub
parent 93b57b82c1
commit 9703fe73cf
4 changed files with 569 additions and 139 deletions
+280 -17
View File
@@ -21,12 +21,16 @@ describe('hooksCommand', () => {
};
let mockConfig: {
getHookSystem: ReturnType<typeof vi.fn>;
getEnableHooks: ReturnType<typeof vi.fn>;
};
let mockSettings: {
merged: {
hooks?: {
disabled?: string[];
};
tools?: {
enableHooks?: boolean;
};
};
setValue: ReturnType<typeof vi.fn>;
};
@@ -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<string, unknown>)[
'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<string, unknown>)[
'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.',
});
});
});
});
/**
+173 -21
View File
@@ -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<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 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<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 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, ''),
};
@@ -25,105 +25,104 @@ interface HooksListProps {
}>;
}
export const HooksList: React.FC<HooksListProps> = ({ hooks }) => (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text>
Hooks are scripts or programs that Gemini CLI executes at specific points
in the agentic loop, allowing you to intercept and customize behavior.
</Text>
export const HooksList: React.FC<HooksListProps> = ({ hooks }) => {
if (hooks.length === 0) {
return (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Box marginTop={1}>
<Text>No hooks configured.</Text>
</Box>
</Box>
);
}
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning} bold underline>
Security Warning:
</Text>
<Text color={theme.status.warning}>
Hooks can execute arbitrary commands on your system. Only use hooks from
sources you trust. Review hook scripts carefully.
</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]>>,
);
<Box marginTop={1}>
<Text>
Learn more:{' '}
<Text color={theme.text.link}>https://geminicli.com/docs/hooks</Text>
</Text>
</Box>
return (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning} bold underline>
Security Warning:
</Text>
<Text color={theme.status.warning}>
Hooks can execute arbitrary commands on your system. Only use hooks
from sources you trust. Review hook scripts carefully.
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
{hooks.length === 0 ? (
<Text>No hooks configured.</Text>
) : (
<>
<Text bold underline>
Registered Hooks:
</Text>
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
{Object.entries(
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]>>,
),
).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.name || hook.config.command || 'unknown';
const statusColor = hook.enabled ? 'green' : 'gray';
const statusText = hook.enabled ? 'enabled' : 'disabled';
<Box marginTop={1}>
<Text>
Learn more:{' '}
<Text color={theme.text.link}>https://geminicli.com/docs/hooks</Text>
</Text>
</Box>
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">
{hook.config.description && (
<Text italic color={theme.text.primary}>
{hook.config.description}
</Text>
)}
<Text dimColor>
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`}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
))}
<Box marginTop={1}>
<Text bold>Configured Hooks:</Text>
</Box>
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
{Object.entries(hooksByEvent).map(([eventName, eventHooks]) => (
<Box key={eventName} flexDirection="column" marginBottom={1}>
<Text color={theme.text.accent} bold>
{eventName}:
</Text>
<Box flexDirection="column" paddingLeft={2}>
{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 (
<Box key={`${eventName}-${index}`} flexDirection="column">
<Box>
<Text>
<Text color={theme.text.accent}>{hookName}</Text>
<Text color={statusColor}>{` [${statusText}]`}</Text>
</Text>
</Box>
<Box paddingLeft={2} flexDirection="column">
{hook.config.description && (
<Text italic>{hook.config.description}</Text>
)}
<Text dimColor>
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`}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
</>
)}
))}
</Box>
<Box marginTop={1}>
<Text dimColor>
Tip: Use <Text bold>/hooks enable {'<hook-name>'}</Text> or{' '}
<Text bold>/hooks disable {'<hook-name>'}</Text> to toggle individual
hooks. Use <Text bold>/hooks enable-all</Text> or{' '}
<Text bold>/hooks disable-all</Text> to toggle all hooks at once.
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>
Tip: Use `/hooks enable {'<hook-name>'}` or `/hooks disable{' '}
{'<hook-name>'}` to toggle hooks
</Text>
</Box>
</Box>
);
);
};