Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
43d64e2
refactor stop hook
DennisYu07 Feb 22, 2026
fdc47c4
rename event
DennisYu07 Feb 22, 2026
c16d1e6
change stop_hook_active
DennisYu07 Feb 22, 2026
44a1da9
align hook event with claude and add test for types.ts
DennisYu07 Feb 24, 2026
2e7ca49
refactor Aggregator for events and add test
DennisYu07 Feb 25, 2026
1b88ed7
refactor aggregator for event and add test
DennisYu07 Feb 25, 2026
872e165
refactor hookregisry and add test
DennisYu07 Feb 25, 2026
e63ad35
remove policy engine and safety check for folder
DennisYu07 Feb 26, 2026
48e55e5
align stop hook with claude and add test
DennisYu07 Feb 27, 2026
4346187
split some event to another PR
DennisYu07 Feb 27, 2026
1ce5bfb
resole conflict and remove hook extension definition
DennisYu07 Feb 28, 2026
dcf1ca7
fix test failed
DennisYu07 Feb 28, 2026
dcbb2ef
fix null array bug for hook disable
DennisYu07 Feb 28, 2026
b68650c
fix permission request issue
DennisYu07 Feb 28, 2026
3c9fcf9
add integration test for hook
DennisYu07 Feb 28, 2026
4db2aa9
remove redundant clienthooktrigger
DennisYu07 Feb 28, 2026
c9126e0
implementation 10 hooks
DennisYu07 Mar 2, 2026
4b18cfe
Revert "implementation 10 hooks"
DennisYu07 Mar 2, 2026
2fe5bee
add more test
DennisYu07 Mar 2, 2026
5da1f07
rename UserPromptSubmit
DennisYu07 Mar 2, 2026
3b229c0
fix test
DennisYu07 Mar 2, 2026
2629902
use concat result instead of override
DennisYu07 Mar 2, 2026
74e1bf1
remove conflict experimental
DennisYu07 Mar 2, 2026
3bf30d9
add ut
DennisYu07 Mar 2, 2026
e4e21bb
resolve comment
DennisYu07 Mar 2, 2026
981d1ae
resolve comment
DennisYu07 Mar 2, 2026
7b0929d
add integration test and --experimental-hooks
DennisYu07 Mar 3, 2026
423cc85
remove useless type
DennisYu07 Mar 3, 2026
4a44eb7
add more integration test for hooks
DennisYu07 Mar 3, 2026
7cde98e
move enable/disable to hooksConfig
DennisYu07 Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,946 changes: 1,946 additions & 0 deletions integration-tests/hook-integration/hooks.test.ts

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions packages/cli/src/commands/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type { CommandModule } from 'yargs';
import { enableCommand } from './hooks/enable.js';
import { disableCommand } from './hooks/disable.js';

export const hooksCommand: CommandModule = {
command: 'hooks <command>',
aliases: ['hook'],
describe: 'Manage Qwen Code hooks.',
builder: (yargs) =>
yargs
.command(enableCommand)
.command(disableCommand)
.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.
},
};
75 changes: 75 additions & 0 deletions packages/cli/src/commands/hooks/disable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type { CommandModule } from 'yargs';
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../config/settings.js';

const debugLogger = createDebugLogger('HOOKS_DISABLE');

interface DisableArgs {
hookName: string;
}

/**
* Disable a hook by adding it to the disabled list
*/
export async function handleDisableHook(hookName: string): Promise<void> {
const workingDir = process.cwd();
const settings = loadSettings(workingDir);

try {
// Get current hooks settings
const mergedSettings = settings.merged as
| Record<string, unknown>
| undefined;
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
string,
unknown
>;
const disabledHooks = (hooksSettings['disabled'] || []) as string[];

// Check if hook is already disabled
if (disabledHooks.includes(hookName)) {
debugLogger.info(`Hook "${hookName}" is already disabled.`);
return;
}

// Add hook to disabled list
const newDisabledHooks = [...disabledHooks, hookName];
const newHooksSettings = {
...hooksSettings,
disabled: newDisabledHooks,
};

// Save updated settings
settings.setValue(
SettingScope.Workspace,
'hooks' as keyof typeof settings.merged,
newHooksSettings as never,
);

debugLogger.info(`✓ Hook "${hookName}" has been disabled.`);
} catch (error) {
debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`);
}
}

export const disableCommand: CommandModule = {
command: 'disable <hook-name>',
describe: 'Disable an active hook',
builder: (yargs) =>
yargs.positional('hook-name', {
describe: 'Name of the hook to disable',
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
const args = argv as unknown as DisableArgs;
await handleDisableHook(args.hookName);
process.exit(0);
},
};
75 changes: 75 additions & 0 deletions packages/cli/src/commands/hooks/enable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type { CommandModule } from 'yargs';
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../config/settings.js';

const debugLogger = createDebugLogger('HOOKS_ENABLE');

interface EnableArgs {
hookName: string;
}

/**
* Enable a hook by removing it from the disabled list
*/
export async function handleEnableHook(hookName: string): Promise<void> {
const workingDir = process.cwd();
const settings = loadSettings(workingDir);

try {
// Get current hooks settings
const mergedSettings = settings.merged as
| Record<string, unknown>
| undefined;
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
string,
unknown
>;
const disabledHooks = (hooksSettings['disabled'] || []) as string[];

// Check if hook is in disabled list
if (!disabledHooks.includes(hookName)) {
debugLogger.info(`Hook "${hookName}" is not disabled.`);
return;
}

// Remove hook from disabled list
const newDisabledHooks = disabledHooks.filter((h) => h !== hookName);
const newHooksSettings = {
...hooksSettings,
disabled: newDisabledHooks,
};

// Save updated settings
settings.setValue(
SettingScope.Workspace,
'hooks' as keyof typeof settings.merged,
newHooksSettings as never,
);

debugLogger.info(`✓ Hook "${hookName}" has been enabled.`);
} catch (error) {
debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`);
}
}

export const enableCommand: CommandModule = {
command: 'enable <hook-name>',
describe: 'Enable a disabled hook',
builder: (yargs) =>
yargs.positional('hook-name', {
describe: 'Name of the hook to enable',
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
const args = argv as unknown as EnableArgs;
await handleEnableHook(args.hookName);
process.exit(0);
},
};
22 changes: 19 additions & 3 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import { hooksCommand } from '../commands/hooks.js';
import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
Expand Down Expand Up @@ -124,6 +125,7 @@ export interface CliArgs {
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalLsp: boolean | undefined;
experimentalHooks: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
openaiLogging: boolean | undefined;
Expand Down Expand Up @@ -337,6 +339,12 @@ export async function parseArguments(): Promise<CliArgs> {
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
default: false,
})
.option('experimental-hooks', {
type: 'boolean',
description:
'Enable experimental hooks feature for lifecycle event customization',
default: false,
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
Expand Down Expand Up @@ -561,7 +569,9 @@ export async function parseArguments(): Promise<CliArgs> {
// Register MCP subcommands
.command(mcpCommand)
// Register Extension subcommands
.command(extensionsCommand);
.command(extensionsCommand)
// Register Hooks subcommands
.command(hooksCommand);

yargsInstance
.version(await getCliVersion()) // This will enable the --version flag based on package.json
Expand All @@ -580,9 +590,11 @@ export async function parseArguments(): Promise<CliArgs> {
// and not return to main CLI logic
if (
result._.length > 0 &&
(result._[0] === 'mcp' || result._[0] === 'extensions')
(result._[0] === 'mcp' ||
result._[0] === 'extensions' ||
result._[0] === 'hooks')
) {
// MCP commands handle their own execution and process exit
// MCP/Extensions/Hooks commands handle their own execution and process exit
process.exit(0);
}

Expand Down Expand Up @@ -1021,6 +1033,10 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
hooks: settings.hooks,
hooksConfig: settings.hooksConfig,
enableHooks:
argv.experimentalHooks === true || settings.hooksConfig?.enabled === true,
channel: argv.channel,
// Precedence: explicit CLI flag > settings file > default(true).
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,75 @@ const SETTINGS_SCHEMA = {
description: 'Configuration for web search providers.',
showInDialog: false,
},

hooksConfig: {
type: 'object',
label: 'Hooks Config',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Hook configurations for intercepting and customizing agent behavior.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: true,
default: true,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
},
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,
mergeStrategy: MergeStrategy.UNION,
},
},
},

hooks: {
type: 'object',
label: 'Hooks',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Hook event configurations for extending CLI behavior at various lifecycle points.',
showInDialog: false,
properties: {
UserPromptSubmit: {
type: 'array',
label: 'Before Agent Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute before agent processing. Can modify prompts or inject context.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
Stop: {
type: 'array',
label: 'After Agent Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute after agent processing. Can post-process responses or log interactions.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
},
},
} as const satisfies SettingsSchema;

export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ describe('gemini.tsx main function kitty protocol', () => {
authType: undefined,
maxSessionTurns: undefined,
experimentalLsp: undefined,
experimentalHooks: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { editorCommand } from '../ui/commands/editorCommand.js';
import { exportCommand } from '../ui/commands/exportCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { languageCommand } from '../ui/commands/languageCommand.js';
Expand Down Expand Up @@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
exportCommand,
extensionsCommand,
helpCommand,
hooksCommand,
await ideCommand(),
initCommand,
languageCommand,
Expand Down
Loading