Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
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
417 changes: 297 additions & 120 deletions docs/users/features/hooks.md

Large diffs are not rendered by default.

1,139 changes: 1,139 additions & 0 deletions integration-tests/hook-integration/hooks-advanced.test.ts

Large diffs are not rendered by default.

254 changes: 254 additions & 0 deletions integration-tests/hook-integration/mockHttpServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import {
createServer,
type Server,
type IncomingMessage,
type ServerResponse,
} from 'http';

/**
* Hook output type for HTTP hook responses
*/
export interface HookOutput {
continue?: boolean;
stopReason?: string;
suppressOutput?: boolean;
systemMessage?: string;
decision?: 'ask' | 'block' | 'deny' | 'approve' | 'allow';
reason?: string;
hookSpecificOutput?: Record<string, unknown>;
}

/**
* Mock HTTP Server for testing HTTP hooks
* Provides endpoints that simulate various hook response scenarios
*/
export class MockHttpServer {
private server: Server | null = null;
private port: number = 0;
private readonly responses: Map<
string,
HookOutput | ((input: Record<string, unknown>) => HookOutput)
> = new Map();
private readonly requestLogs: Array<{
url: string;
body: Record<string, unknown>;
timestamp: number;
}> = [];

/**
* Start the mock server on a random available port
*/
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => {
this.handleRequest(req, res);
});

this.server.listen(0, () => {
const address = this.server!.address();
if (address && typeof address === 'object') {
this.port = address.port;
resolve(this.port);
} else {
reject(new Error('Failed to get server port'));
}
});

this.server.on('error', reject);
});
}

/**
* Stop the mock server
*/
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {
this.server = null;
resolve();
});
} else {
resolve();
}
});
}

/**
* Get the server's base URL
*/
getUrl(): string {
return `http://127.0.0.1:${this.port}`;
}

/**
* Set response for a specific path
*/
setResponse(
path: string,
response: HookOutput | ((input: Record<string, unknown>) => HookOutput),
): void {
this.responses.set(path, response);
}

/**
* Get all received request logs
*/
getRequestLogs(): Array<{
url: string;
body: Record<string, unknown>;
timestamp: number;
}> {
return [...this.requestLogs];
}

/**
* Clear request logs
*/
clearRequestLogs(): void {
this.requestLogs.length = 0;
}

/**
* Handle incoming HTTP request
*/
private handleRequest(req: IncomingMessage, res: ServerResponse): void {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});

req.on('end', () => {
const parsedBody = JSON.parse(body || '{}');

// Log the request
this.requestLogs.push({
url: req.url || '/',
body: parsedBody,
timestamp: Date.now(),
});

// Find matching response
const response = this.responses.get(req.url || '/');

if (response) {
const output =
typeof response === 'function' ? response(parsedBody) : response;

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(output));
} else {
// Default response: allow with continue
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ continue: true }));
}
});

req.on('error', (err) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
}
}

/**
* Pre-defined response scenarios for HTTP hook testing
*/
export const HttpHookResponses = {
/** Allow execution */
allow: { decision: 'allow', continue: true } as HookOutput,

/** Block execution */
block: {
decision: 'block',
reason: 'Blocked by HTTP hook',
continue: false,
} as HookOutput,

/** Ask for permission */
ask: { decision: 'ask', reason: 'User confirmation required' } as HookOutput,

/** Deny execution */
deny: { decision: 'deny', reason: 'Denied by HTTP hook' } as HookOutput,

/** Return additional context */
withContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: context,
},
}),

/** Return system message */
withSystemMessage: (message: string): HookOutput => ({
continue: true,
systemMessage: message,
}),

/** PreToolUse allow with permission decision */
preToolUseAllow: {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Tool execution approved by HTTP hook',
},
} as HookOutput,

/** PreToolUse deny with permission decision */
preToolUseDeny: {
continue: false,
decision: 'deny',
reason: 'Tool execution denied by HTTP hook',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'Security policy violation',
},
} as HookOutput,

/** PreToolUse ask for confirmation */
preToolUseAsk: {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: 'Requires user confirmation',
},
} as HookOutput,

/** UserPromptSubmit with additional context */
userPromptSubmitContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: context,
},
}),

/** PostToolUse with additional context */
postToolUseContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: context,
},
}),

/** Stop hook with stop reason */
stopWithReason: (reason: string): HookOutput => ({
continue: true,
stopReason: reason,
hookSpecificOutput: {
hookEventName: 'Stop',
additionalContext: `Stop reason: ${reason}`,
},
}),
};
42 changes: 36 additions & 6 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,18 +131,31 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
items: {
type: 'object',
description:
'A hook configuration entry that defines a command to execute.',
'A hook configuration entry that defines a hook to execute.',
properties: {
type: {
type: 'string',
description: 'The type of hook.',
enum: ['command'],
description: 'The type of hook. Note: "function" type is only available via SDK registration, not settings.json.',
enum: ['command', 'http'],
required: true,
},
command: {
type: 'string',
description: 'The command to execute when the hook is triggered.',
required: true,
description: 'The command to execute when the hook is triggered. Required for "command" type.',
},
url: {
type: 'string',
description: 'The URL to send the POST request to. Required for "http" type.',
},
headers: {
type: 'object',
description: 'HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).',
additionalProperties: { type: 'string' },
},
allowedEnvVars: {
type: 'array',
description: 'List of environment variables allowed for interpolation in headers and URL.',
items: { type: 'string' },
},
name: {
type: 'string',
Expand All @@ -154,14 +167,31 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds for the hook execution.',
description: 'Timeout in seconds for the hook execution.',
},
env: {
type: 'object',
description:
'Environment variables to set when executing the hook command.',
additionalProperties: { type: 'string' },
},
async: {
type: 'boolean',
description: 'Whether to execute the hook asynchronously (non-blocking, for "command" type only).',
},
once: {
type: 'boolean',
description: 'Whether to execute the hook only once per session (for "http" type).',
},
statusMessage: {
type: 'string',
description: 'A message to display while the hook is executing.',
},
shell: {
type: 'string',
description: 'The shell to use for command execution.',
enum: ['bash', 'powershell'],
},
},
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ export default {
'User Settings': 'Benutzereinstellungen',
'System Settings': 'Systemeinstellungen',
Extensions: 'Erweiterungen',
'Session (temporary)': 'Sitzung (temporär)',
// Hooks - Status
'✓ Enabled': '✓ Aktiviert',
'✗ Disabled': '✗ Deaktiviert',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ export default {
'User Settings': 'ユーザー設定',
'System Settings': 'システム設定',
Extensions: '拡張機能',
'Session (temporary)': 'セッション(一時)',
// Hooks - Status
'✓ Enabled': '✓ 有効',
'✗ Disabled': '✗ 無効',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ export default {
'User Settings': 'Configurações do Usuário',
'System Settings': 'Configurações do Sistema',
Extensions: 'Extensões',
'Session (temporary)': 'Sessão (temporário)',
// Hooks - Status
'✓ Enabled': '✓ Ativado',
'✗ Disabled': '✗ Desativado',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ru.js
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ export default {
'User Settings': 'Пользовательские настройки',
'System Settings': 'Системные настройки',
Extensions: 'Расширения',
'Session (temporary)': 'Сессия (временно)',
// Hooks - Status
'✓ Enabled': '✓ Включен',
'✗ Disabled': '✗ Отключен',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ export default {
'User Settings': '用户设置',
'System Settings': '系统设置',
Extensions: '扩展',
'Session (temporary)': '会话(临时)',
// Hooks - Status
'✓ Enabled': '✓ 已启用',
'✗ Disabled': '✗ 已禁用',
Expand Down
16 changes: 0 additions & 16 deletions packages/cli/src/ui/commands/hooksCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,4 @@ describe('hooksCommand', () => {
});
});
});

describe('non-interactive mode', () => {
it('should list hooks in non-interactive mode', async () => {
const nonInteractiveContext = createMockCommandContext({
services: {
config: mockConfig,
},
executionMode: 'non_interactive',
});

const result = await hooksCommand.action!(nonInteractiveContext, '');

// In non-interactive mode, it should return a message
expect(result).toHaveProperty('type', 'message');
});
});
});
Loading
Loading