Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts
5243 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { IJSONSchema } from '../../../../../base/common/jsonSchema.js';
7
import * as nls from '../../../../../nls.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { joinPath } from '../../../../../base/common/resources.js';
10
import { isAbsolute } from '../../../../../base/common/path.js';
11
import { untildify } from '../../../../../base/common/labels.js';
12
import { OperatingSystem } from '../../../../../base/common/platform.js';
13
14
/**
15
* Enum of available hook types that can be configured in hooks .json
16
*/
17
export enum HookType {
18
SessionStart = 'SessionStart',
19
UserPromptSubmit = 'UserPromptSubmit',
20
PreToolUse = 'PreToolUse',
21
PostToolUse = 'PostToolUse',
22
PreCompact = 'PreCompact',
23
SubagentStart = 'SubagentStart',
24
SubagentStop = 'SubagentStop',
25
Stop = 'Stop',
26
}
27
28
/**
29
* String literal type derived from HookType enum values.
30
*/
31
export type HookTypeValue = `${HookType}`;
32
33
/**
34
* Metadata for hook types including localized labels and descriptions
35
*/
36
export const HOOK_TYPES = [
37
{
38
id: HookType.SessionStart,
39
label: nls.localize('hookType.sessionStart.label', "Session Start"),
40
description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins.")
41
},
42
{
43
id: HookType.UserPromptSubmit,
44
label: nls.localize('hookType.userPromptSubmit.label', "User Prompt Submit"),
45
description: nls.localize('hookType.userPromptSubmit.description', "Executed when the user submits a prompt to the agent.")
46
},
47
{
48
id: HookType.PreToolUse,
49
label: nls.localize('hookType.preToolUse.label', "Pre-Tool Use"),
50
description: nls.localize('hookType.preToolUse.description', "Executed before the agent uses any tool.")
51
},
52
{
53
id: HookType.PostToolUse,
54
label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"),
55
description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.")
56
},
57
{
58
id: HookType.PreCompact,
59
label: nls.localize('hookType.preCompact.label', "Pre-Compact"),
60
description: nls.localize('hookType.preCompact.description', "Executed before the agent compacts the conversation context.")
61
},
62
{
63
id: HookType.SubagentStart,
64
label: nls.localize('hookType.subagentStart.label', "Subagent Start"),
65
description: nls.localize('hookType.subagentStart.description', "Executed when a subagent is started.")
66
},
67
{
68
id: HookType.SubagentStop,
69
label: nls.localize('hookType.subagentStop.label', "Subagent Stop"),
70
description: nls.localize('hookType.subagentStop.description', "Executed when a subagent stops.")
71
},
72
{
73
id: HookType.Stop,
74
label: nls.localize('hookType.stop.label', "Stop"),
75
description: nls.localize('hookType.stop.description', "Executed when the agent stops.")
76
}
77
] as const;
78
79
/**
80
* A single hook command configuration.
81
*/
82
export interface IHookCommand {
83
readonly type: 'command';
84
/** Cross-platform command to execute. */
85
readonly command?: string;
86
/** Windows-specific command override. */
87
readonly windows?: string;
88
/** Linux-specific command override. */
89
readonly linux?: string;
90
/** macOS-specific command override. */
91
readonly osx?: string;
92
/** Resolved working directory URI. */
93
readonly cwd?: URI;
94
readonly env?: Record<string, string>;
95
readonly timeoutSec?: number;
96
/** Original JSON field name that provided the windows command. */
97
readonly windowsSource?: 'windows' | 'powershell';
98
/** Original JSON field name that provided the linux command. */
99
readonly linuxSource?: 'linux' | 'bash';
100
/** Original JSON field name that provided the osx command. */
101
readonly osxSource?: 'osx' | 'bash';
102
}
103
104
/**
105
* Collected hooks for a chat request, organized by hook type.
106
* This is passed to the extension host so it knows what hooks are available.
107
*/
108
export interface IChatRequestHooks {
109
readonly [HookType.SessionStart]?: readonly IHookCommand[];
110
readonly [HookType.UserPromptSubmit]?: readonly IHookCommand[];
111
readonly [HookType.PreToolUse]?: readonly IHookCommand[];
112
readonly [HookType.PostToolUse]?: readonly IHookCommand[];
113
readonly [HookType.PreCompact]?: readonly IHookCommand[];
114
readonly [HookType.SubagentStart]?: readonly IHookCommand[];
115
readonly [HookType.SubagentStop]?: readonly IHookCommand[];
116
readonly [HookType.Stop]?: readonly IHookCommand[];
117
}
118
119
/**
120
* JSON Schema for GitHub Copilot hook configuration files.
121
* Hooks enable executing custom shell commands at strategic points in an agent's workflow.
122
*/
123
const hookCommandSchema: IJSONSchema = {
124
type: 'object',
125
additionalProperties: true,
126
required: ['type'],
127
anyOf: [
128
{ required: ['command'] },
129
{ required: ['windows'] },
130
{ required: ['linux'] },
131
{ required: ['osx'] },
132
{ required: ['bash'] },
133
{ required: ['powershell'] }
134
],
135
errorMessage: nls.localize('hook.commandRequired', 'At least one of "command", "windows", "linux", or "osx" must be specified.'),
136
properties: {
137
type: {
138
type: 'string',
139
enum: ['command'],
140
description: nls.localize('hook.type', 'Must be "command".')
141
},
142
command: {
143
type: 'string',
144
description: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.')
145
},
146
windows: {
147
type: 'string',
148
description: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.')
149
},
150
linux: {
151
type: 'string',
152
description: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.')
153
},
154
osx: {
155
type: 'string',
156
description: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.')
157
},
158
cwd: {
159
type: 'string',
160
description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).')
161
},
162
env: {
163
type: 'object',
164
additionalProperties: { type: 'string' },
165
description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.')
166
},
167
timeoutSec: {
168
type: 'number',
169
default: 30,
170
description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 30).')
171
}
172
}
173
};
174
175
const hookArraySchema: IJSONSchema = {
176
type: 'array',
177
items: hookCommandSchema
178
};
179
180
export const hookFileSchema: IJSONSchema = {
181
$schema: 'http://json-schema.org/draft-07/schema#',
182
type: 'object',
183
description: nls.localize('hookFile.description', 'GitHub Copilot hook configuration file. Hooks enable executing custom shell commands at strategic points in an agent\'s workflow.'),
184
additionalProperties: true,
185
required: ['hooks'],
186
properties: {
187
hooks: {
188
type: 'object',
189
description: nls.localize('hookFile.hooks', 'Hook definitions organized by type.'),
190
additionalProperties: true,
191
properties: {
192
SessionStart: {
193
...hookArraySchema,
194
description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins. Use to initialize environments, log session starts, validate project state, or set up temporary resources.')
195
},
196
UserPromptSubmit: {
197
...hookArraySchema,
198
description: nls.localize('hookFile.userPromptSubmit', 'Executed when the user submits a prompt to the agent. Use to log user requests for auditing and usage analysis.')
199
},
200
PreToolUse: {
201
...hookArraySchema,
202
description: nls.localize('hookFile.preToolUse', 'Executed before the agent uses any tool. This is the most powerful hook as it can approve or deny tool executions. Use to block dangerous commands, enforce security policies, require approval for sensitive operations, or log tool usage.')
203
},
204
PostToolUse: {
205
...hookArraySchema,
206
description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.')
207
},
208
PreCompact: {
209
...hookArraySchema,
210
description: nls.localize('hookFile.preCompact', 'Executed before the agent compacts the conversation context. Use to save conversation state, export important information, or prepare for context reduction.')
211
},
212
SubagentStart: {
213
...hookArraySchema,
214
description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.')
215
},
216
SubagentStop: {
217
...hookArraySchema,
218
description: nls.localize('hookFile.subagentStop', 'Executed when a subagent stops. Use to log subagent completion, cleanup subagent resources, or aggregate subagent results.')
219
},
220
Stop: {
221
...hookArraySchema,
222
description: nls.localize('hookFile.stop', 'Executed when the agent session stops. Use to cleanup resources, generate final reports, or send completion notifications.')
223
}
224
}
225
}
226
},
227
defaultSnippets: [
228
{
229
label: nls.localize('hookFile.snippet.basic', 'Basic hook configuration'),
230
description: nls.localize('hookFile.snippet.basic.description', 'A basic hook configuration with common hooks'),
231
body: {
232
hooks: {
233
SessionStart: [
234
{
235
type: 'command',
236
command: '${1:echo "Session started" >> session.log}',
237
}
238
],
239
PreToolUse: [
240
{
241
type: 'command',
242
command: '${2:./scripts/validate.sh}',
243
timeoutSec: 15
244
}
245
]
246
}
247
}
248
}
249
]
250
};
251
252
/**
253
* URI for the hook schema registration.
254
*/
255
export const HOOK_SCHEMA_URI = 'vscode://schemas/hooks';
256
257
/**
258
* Glob pattern for hook files.
259
*/
260
export const HOOK_FILE_GLOB = '.github/hooks/*.json';
261
262
/**
263
* Normalizes a raw hook type identifier to the canonical HookType enum value.
264
* Only matches exact enum values. For tool-specific naming conventions (e.g., Claude, Copilot CLI),
265
* use the corresponding compat module's resolver function.
266
*/
267
export function toHookType(rawHookTypeId: string): HookType | undefined {
268
if (Object.values(HookType).includes(rawHookTypeId as HookType)) {
269
return rawHookTypeId as HookType;
270
}
271
return undefined;
272
}
273
274
/**
275
* Normalizes a raw hook command object, validating structure.
276
* Maps legacy bash/powershell fields to platform-specific overrides:
277
* - bash -> linux + osx
278
* - powershell -> windows
279
* This is an internal helper - use resolveHookCommand for the full resolution.
280
*/
281
function normalizeHookCommand(raw: Record<string, unknown>): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record<string, string>; timeoutSec?: number } | undefined {
282
if (raw.type !== 'command') {
283
return undefined;
284
}
285
286
const hasCommand = typeof raw.command === 'string' && raw.command.length > 0;
287
const hasBash = typeof raw.bash === 'string' && (raw.bash as string).length > 0;
288
const hasPowerShell = typeof raw.powershell === 'string' && (raw.powershell as string).length > 0;
289
290
// Platform overrides can be strings directly
291
const hasWindows = typeof raw.windows === 'string' && (raw.windows as string).length > 0;
292
const hasLinux = typeof raw.linux === 'string' && (raw.linux as string).length > 0;
293
const hasOsx = typeof raw.osx === 'string' && (raw.osx as string).length > 0;
294
295
// Map bash -> linux + osx (if not already specified)
296
// Map powershell -> windows (if not already specified)
297
const windows = hasWindows ? raw.windows as string : (hasPowerShell ? raw.powershell as string : undefined);
298
const linux = hasLinux ? raw.linux as string : (hasBash ? raw.bash as string : undefined);
299
const osx = hasOsx ? raw.osx as string : (hasBash ? raw.bash as string : undefined);
300
301
// Track source field names for editor focus (which JSON field to highlight)
302
const windowsSource: 'windows' | 'powershell' | undefined = hasWindows ? 'windows' : (hasPowerShell ? 'powershell' : undefined);
303
const linuxSource: 'linux' | 'bash' | undefined = hasLinux ? 'linux' : (hasBash ? 'bash' : undefined);
304
const osxSource: 'osx' | 'bash' | undefined = hasOsx ? 'osx' : (hasBash ? 'bash' : undefined);
305
306
return {
307
...(hasCommand && { command: raw.command as string }),
308
...(windows && { windows }),
309
...(linux && { linux }),
310
...(osx && { osx }),
311
...(windowsSource && { windowsSource }),
312
...(linuxSource && { linuxSource }),
313
...(osxSource && { osxSource }),
314
...(typeof raw.cwd === 'string' && { cwd: raw.cwd }),
315
...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record<string, string> }),
316
...(typeof raw.timeoutSec === 'number' && { timeoutSec: raw.timeoutSec }),
317
};
318
}
319
320
/**
321
* Gets a label for the given platform.
322
*/
323
export function getPlatformLabel(os: OperatingSystem): string {
324
if (os === OperatingSystem.Windows) {
325
return 'Windows';
326
} else if (os === OperatingSystem.Macintosh) {
327
return 'macOS';
328
} else if (os === OperatingSystem.Linux) {
329
return 'Linux';
330
}
331
return '';
332
}
333
334
/**
335
* Resolves the effective command for the given platform.
336
* This applies OS-specific overrides (windows, linux, osx) to get the actual command that will be executed.
337
* Similar to how launch.json handles platform-specific configurations in debugAdapter.ts.
338
*/
339
export function resolveEffectiveCommand(hook: IHookCommand, os: OperatingSystem): string | undefined {
340
// Select the platform-specific override based on the OS
341
if (os === OperatingSystem.Windows && hook.windows) {
342
return hook.windows;
343
} else if (os === OperatingSystem.Macintosh && hook.osx) {
344
return hook.osx;
345
} else if (os === OperatingSystem.Linux && hook.linux) {
346
return hook.linux;
347
}
348
349
// Fall back to the default command
350
return hook.command;
351
}
352
353
/**
354
* Checks if the hook is using a platform-specific command override.
355
*/
356
export function isUsingPlatformOverride(hook: IHookCommand, os: OperatingSystem): boolean {
357
if (os === OperatingSystem.Windows && hook.windows) {
358
return true;
359
} else if (os === OperatingSystem.Macintosh && hook.osx) {
360
return true;
361
} else if (os === OperatingSystem.Linux && hook.linux) {
362
return true;
363
}
364
return false;
365
}
366
367
/**
368
* Gets the source shell type for the effective command on the given platform.
369
* Returns 'powershell' if the Windows command came from a powershell field,
370
* 'bash' if the Linux/macOS command came from a bash field,
371
* or undefined for default shell handling.
372
*/
373
export function getEffectiveCommandSource(hook: IHookCommand, os: OperatingSystem): 'powershell' | 'bash' | undefined {
374
if (os === OperatingSystem.Windows && hook.windows && hook.windowsSource === 'powershell') {
375
return 'powershell';
376
} else if (os === OperatingSystem.Macintosh && hook.osx && hook.osxSource === 'bash') {
377
return 'bash';
378
} else if (os === OperatingSystem.Linux && hook.linux && hook.linuxSource === 'bash') {
379
return 'bash';
380
}
381
return undefined;
382
}
383
384
/**
385
* Gets the original JSON field key name for the given platform's command.
386
* Returns the actual field name from the JSON (e.g., 'bash' instead of 'osx' if bash was used).
387
* This is used for editor focus to highlight the correct field.
388
*/
389
export function getEffectiveCommandFieldKey(hook: IHookCommand, os: OperatingSystem): string {
390
if (os === OperatingSystem.Windows && hook.windows) {
391
return hook.windowsSource ?? 'windows';
392
} else if (os === OperatingSystem.Macintosh && hook.osx) {
393
return hook.osxSource ?? 'osx';
394
} else if (os === OperatingSystem.Linux && hook.linux) {
395
return hook.linuxSource ?? 'linux';
396
}
397
return 'command';
398
}
399
400
/**
401
* Formats a hook command for display.
402
* Resolves OS-specific overrides to show the effective command for the given platform.
403
* If using a platform-specific override, includes the platform as a prefix badge.
404
*/
405
export function formatHookCommandLabel(hook: IHookCommand, os: OperatingSystem): string {
406
const command = resolveEffectiveCommand(hook, os);
407
if (!command) {
408
return '';
409
}
410
411
// Add platform badge if using platform-specific override
412
if (isUsingPlatformOverride(hook, os)) {
413
const platformLabel = getPlatformLabel(os);
414
return `[${platformLabel}] ${command}`;
415
}
416
417
return command;
418
}
419
420
/**
421
* Resolves a raw hook command object to the canonical IHookCommand format.
422
* Normalizes the command and resolves the cwd path relative to the workspace root.
423
* @param raw The raw hook command object from JSON
424
* @param workspaceRootUri The workspace root URI to resolve relative cwd paths against
425
* @param userHome The user's home directory path for tilde expansion
426
*/
427
export function resolveHookCommand(raw: Record<string, unknown>, workspaceRootUri: URI | undefined, userHome: string): IHookCommand | undefined {
428
const normalized = normalizeHookCommand(raw);
429
if (!normalized) {
430
return undefined;
431
}
432
433
let cwdUri: URI | undefined;
434
if (normalized.cwd) {
435
// Expand tilde to user home directory
436
const expandedCwd = untildify(normalized.cwd, userHome);
437
if (isAbsolute(expandedCwd)) {
438
// Use absolute path directly
439
cwdUri = URI.file(expandedCwd);
440
} else if (workspaceRootUri) {
441
// Resolve relative to workspace root
442
cwdUri = joinPath(workspaceRootUri, expandedCwd);
443
}
444
} else {
445
cwdUri = workspaceRootUri;
446
}
447
448
return {
449
type: 'command',
450
...(normalized.command && { command: normalized.command }),
451
...(normalized.windows && { windows: normalized.windows }),
452
...(normalized.linux && { linux: normalized.linux }),
453
...(normalized.osx && { osx: normalized.osx }),
454
...(normalized.windowsSource && { windowsSource: normalized.windowsSource }),
455
...(normalized.linuxSource && { linuxSource: normalized.linuxSource }),
456
...(normalized.osxSource && { osxSource: normalized.osxSource }),
457
...(cwdUri && { cwd: cwdUri }),
458
...(normalized.env && { env: normalized.env }),
459
...(normalized.timeoutSec !== undefined && { timeoutSec: normalized.timeoutSec }),
460
};
461
}
462
463