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