Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/vscode-node/slashCommands/hooksCommand.ts
13406 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 * as vscode from 'vscode';6import { INativeEnvService } from '../../../../../platform/env/common/envService';7import { createDirectoryIfNotExists, IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService';8import { ILogService } from '../../../../../platform/log/common/logService';9import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';10import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';11import { URI } from '../../../../../util/vs/base/common/uri';12import { IClaudeSlashCommandHandler, registerClaudeSlashCommand } from './claudeSlashCommandRegistry';1314/**15* HOOKS CONFIGURATION WIZARD16* ==========================17*18* This wizard supports two distinct flows: CREATE and EDIT.19*20* ## CREATE FLOW (new matcher)21* Used when adding a completely new hook configuration.22*23* 1. Select hook event (e.g., PreToolUse, PostToolUse, etc.)24* 2. For tool-based hooks: Select "+ Add new matcher..." option25* 3. Enter the new matcher pattern (e.g., "Bash", "Edit", "*")26* 4. Enter the hook command to run27* 5. Select where to save (Workspace (local), Workspace, or User settings)28* 6. Configuration is written to the selected settings file29* 7. The settings file is opened with cursor at the new hook command30*31* ## EDIT FLOW (existing matcher)32* Used when modifying hooks for an existing matcher.33*34* 1. Select hook event (e.g., PreToolUse, PostToolUse, etc.)35* 2. For tool-based hooks: Select an existing matcher (grouped by file path)36* 3. Select existing hook command to edit, or "+ Add new hook..."37* - If editing existing: Input box pre-filled with current command38* - If adding new: Empty input box for new command39* 4. Changes are written back to the SAME settings file where the matcher was found40* (no location picker - we preserve the original source)41* 5. The settings file is opened with cursor at the modified hook command42*43* ## Lifecycle Hooks (no matcher)44* For hooks like UserPromptSubmit, Stop, SessionStart, etc.:45* - The matcher is implicitly "*" (matches all)46* - Flow is similar but skips the matcher selection step47* - Uses the same Create vs Edit logic based on whether hooks already exist48*/4950/**51* Hook event types matching Claude Code SDK52* See: https://platform.claude.com/docs/en/agent-sdk/hooks53*54* Tool-based hooks receive tool_name and tool_input - matchers filter by tool name.55* Lifecycle hooks receive event-specific data - matchers are ignored.56*/57const HOOK_EVENTS = [58// Tool-based hooks (matcher filters by tool name)59{60id: 'PreToolUse',61label: vscode.l10n.t('Before tool execution'),62needsMatcher: true,63inputDescription: vscode.l10n.t('Exit 0: allow, Exit 2: block with stderr to model.'),64jsonSchema: '{ "tool_name": string, "tool_input": object }',65},66{67id: 'PostToolUse',68label: vscode.l10n.t('After tool execution'),69needsMatcher: true,70inputDescription: vscode.l10n.t('Runs after tool completes successfully.'),71jsonSchema: '{ "tool_name": string, "tool_input": object, "tool_response": string }',72},73{74id: 'PostToolUseFailure',75label: vscode.l10n.t('After tool execution fails'),76needsMatcher: true,77inputDescription: vscode.l10n.t('Runs when a tool fails or is interrupted.'),78jsonSchema: '{ "tool_name": string, "tool_input": object, "error": string, "is_interrupt": boolean }',79},80{81id: 'PermissionRequest',82label: vscode.l10n.t('When permission dialog would be displayed'),83needsMatcher: true,84inputDescription: vscode.l10n.t('Custom permission handling. Exit 0: allow, Exit 2: deny.'),85jsonSchema: '{ "tool_name": string, "tool_input": object, "permission_suggestions": string[] }',86},87// Lifecycle hooks (matchers ignored, fires for all events of this type)88{89id: 'UserPromptSubmit',90label: vscode.l10n.t('When the user submits a prompt'),91needsMatcher: false,92inputDescription: vscode.l10n.t('Exit 0: allow, Exit 2: block with stderr to model.'),93jsonSchema: '{ "prompt": string }',94},95{96id: 'Stop',97label: vscode.l10n.t('When agent execution stops'),98needsMatcher: false,99inputDescription: vscode.l10n.t('Use to save state or clean up resources.'),100jsonSchema: '{ "stop_hook_active": boolean }',101},102{103id: 'SubagentStart',104label: vscode.l10n.t('When a subagent is initialized'),105needsMatcher: false,106inputDescription: vscode.l10n.t('Track parallel task spawning.'),107jsonSchema: '{ "agent_id": string, "agent_type": string }',108},109{110id: 'SubagentStop',111label: vscode.l10n.t('When a subagent completes'),112needsMatcher: false,113inputDescription: vscode.l10n.t('Aggregate results from parallel tasks.'),114jsonSchema: '{ "agent_id": string, "agent_transcript_path": string, "stop_hook_active": boolean }',115},116{117id: 'PreCompact',118label: vscode.l10n.t('Before conversation compaction'),119needsMatcher: false,120inputDescription: vscode.l10n.t('Archive transcript before summarizing.'),121jsonSchema: '{ "trigger": "manual" | "auto", "custom_instructions": string }',122},123{124id: 'SessionStart',125label: vscode.l10n.t('When a session is initialized'),126needsMatcher: false,127inputDescription: vscode.l10n.t('Initialize logging and telemetry.'),128jsonSchema: '{ "source": "startup" | "resume" | "clear" | "compact" }',129},130{131id: 'SessionEnd',132label: vscode.l10n.t('When a session terminates'),133needsMatcher: false,134inputDescription: vscode.l10n.t('Clean up temporary resources.'),135jsonSchema: '{ "reason": "clear" | "logout" | "prompt_input_exit" | "other" }',136},137{138id: 'Notification',139label: vscode.l10n.t('When agent status messages are sent'),140needsMatcher: false,141inputDescription: vscode.l10n.t('Send updates to Slack or dashboards.'),142jsonSchema: '{ "message": string, "notification_type": string, "title": string }',143},144] as const;145146type HookEventId = typeof HOOK_EVENTS[number]['id'];147type HookEvent = typeof HOOK_EVENTS[number];148149/**150* Settings location type: 'local' or 'shared' for workspace, 'user' for global151*/152type SettingsLocationType = 'local' | 'shared' | 'user';153154/**155* A resolved settings location with full path and label.156* For multi-root workspaces, there's one local/shared pair per workspace folder.157*/158interface SettingsLocation {159/** The type of location */160type: SettingsLocationType;161/** Display label (e.g., "my-project (local)", "my-project", "User") */162label: string;163/** Full path to the settings file */164settingsPath: URI;165/** For workspace locations, the workspace folder URI */166workspaceFolder?: URI;167}168169interface HookConfig {170type: 'command';171command: string;172}173174interface MatcherConfig {175matcher: string;176hooks: HookConfig[];177}178179interface HooksSettings {180hooks?: Partial<Record<HookEventId, MatcherConfig[]>>;181}182183/**184* A matcher with its source location tracked185*/186interface MatcherWithSource {187matcher: string;188location: SettingsLocation;189}190191/**192* A hook command with its source location tracked193*/194interface HookWithSource {195command: string;196location: SettingsLocation;197}198199interface IHooksWizardResult {200event: string;201matcher: string;202command: string;203location: string;204mode: 'create' | 'edit';205}206207/**208* Slash command handler for configuring Claude Code hooks.209* Launches a QuickPick wizard to configure hook events, matchers, and commands.210*211* Supports two flows:212* - CREATE: Add new matcher → enter command → select save location213* - EDIT: Select existing matcher → select/add hook → saves to original location214*/215export class HooksSlashCommand implements IClaudeSlashCommandHandler {216readonly commandName = 'hooks';217readonly description = 'Configure Claude Code hooks for tool execution and events';218readonly commandId = 'copilot.claude.hooks';219220constructor(221@IWorkspaceService private readonly workspaceService: IWorkspaceService,222@IFileSystemService private readonly fileSystemService: IFileSystemService,223@INativeEnvService private readonly envService: INativeEnvService,224@ILogService private readonly logService: ILogService,225) { }226227async handle(228_args: string,229stream: vscode.ChatResponseStream | undefined,230_token: CancellationToken231): Promise<vscode.ChatResult> {232stream?.markdown(vscode.l10n.t('Opening hooks configuration...'));233234// Fire and forget - wizard runs in background235this._runWizard().catch(error => {236this.logService.error('[HooksSlashCommand] Error running hooks wizard:', error);237vscode.window.showErrorMessage(238vscode.l10n.t('Error configuring hook: {0}', error instanceof Error ? error.message : String(error))239);240});241242return {};243}244245private async _runWizard(): Promise<IHooksWizardResult | undefined> {246// Step 1: Select hook event247const eventConfig = await this._selectHookEvent();248if (!eventConfig) {249return undefined;250}251252// Step 2: Determine mode and get matcher253let matcher: string;254let targetLocation: SettingsLocation;255let mode: 'create' | 'edit';256257if (eventConfig.needsMatcher) {258// Tool-based hook: show matchers with source locations259const matcherResult = await this._selectOrCreateMatcher(eventConfig);260if (!matcherResult) {261return undefined;262}263matcher = matcherResult.matcher;264mode = matcherResult.mode;265266if (mode === 'edit') {267// Edit mode: use the location where matcher was found268targetLocation = matcherResult.location!;269270// Show existing hooks for this matcher and allow edit/add271const hookResult = await this._selectOrAddHookForEdit(eventConfig, matcher, targetLocation);272if (!hookResult) {273return undefined;274}275276// Save to the original location277await this._saveHookConfig(eventConfig.id, matcher, hookResult.command, targetLocation, hookResult.originalCommand);278279// Open the file at the hook position280await this._openFileAtHook(targetLocation, hookResult.command);281282return this._showSuccessAndReturn(eventConfig, matcher, hookResult.command, targetLocation, mode);283} else {284// Create mode: enter command, then pick location285const command = await this._enterCommand(eventConfig, matcher);286if (!command) {287return undefined;288}289290const location = await this._selectSaveLocation();291if (!location) {292return undefined;293}294295await this._saveHookConfig(eventConfig.id, matcher, command, location);296297// Open the file at the hook position298await this._openFileAtHook(location, command);299300return this._showSuccessAndReturn(eventConfig, matcher, command, location, mode);301}302} else {303// Lifecycle hook: matcher is always "*"304matcher = '*';305306// Check if hooks already exist for this event307const existingHooks = await this._getExistingHooksWithSource(eventConfig.id, matcher);308309if (existingHooks.length > 0) {310// Edit mode: show existing hooks311const hookResult = await this._selectOrAddHookFromList(eventConfig, matcher, existingHooks);312if (!hookResult) {313return undefined;314}315316if (hookResult.mode === 'edit') {317// Editing existing hook - save to its original location318await this._saveHookConfig(eventConfig.id, matcher, hookResult.command, hookResult.location!, hookResult.originalCommand);319await this._openFileAtHook(hookResult.location!, hookResult.command);320return this._showSuccessAndReturn(eventConfig, matcher, hookResult.command, hookResult.location!, 'edit');321} else {322// Adding new hook - ask where to save323const location = await this._selectSaveLocation();324if (!location) {325return undefined;326}327await this._saveHookConfig(eventConfig.id, matcher, hookResult.command, location);328await this._openFileAtHook(location, hookResult.command);329return this._showSuccessAndReturn(eventConfig, matcher, hookResult.command, location, 'create');330}331} else {332// Create mode: no existing hooks333const command = await this._enterCommand(eventConfig, matcher);334if (!command) {335return undefined;336}337338const location = await this._selectSaveLocation();339if (!location) {340return undefined;341}342343await this._saveHookConfig(eventConfig.id, matcher, command, location);344await this._openFileAtHook(location, command);345return this._showSuccessAndReturn(eventConfig, matcher, command, location, 'create');346}347}348}349350/**351* Opens the settings file and positions cursor at the hook command.352*/353private async _openFileAtHook(location: SettingsLocation, command: string): Promise<void> {354try {355const document = await vscode.workspace.openTextDocument(vscode.Uri.file(location.settingsPath.fsPath));356const text = document.getText();357358// Find the line containing the command359const commandEscaped = command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');360const regex = new RegExp(`"command"\\s*:\\s*"${commandEscaped}"`);361const match = regex.exec(text);362363let position = new vscode.Position(0, 0);364if (match) {365const beforeMatch = text.substring(0, match.index);366const lineNumber = (beforeMatch.match(/\n/g) || []).length;367const lastNewline = beforeMatch.lastIndexOf('\n');368const column = match.index - lastNewline - 1 + match[0].indexOf(command);369position = new vscode.Position(lineNumber, column);370}371372const editor = await vscode.window.showTextDocument(document, {373selection: new vscode.Range(position, position),374preview: false,375});376377// Reveal the line in center of editor378editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.InCenter);379} catch (error) {380this.logService.warn(`[HooksSlashCommand] Failed to open file at hook position: ${error}`);381}382}383384private _showSuccessAndReturn(385eventConfig: HookEvent,386matcher: string,387command: string,388location: SettingsLocation,389mode: 'create' | 'edit'390): IHooksWizardResult {391return {392event: eventConfig.label,393matcher,394command,395location: location.label,396mode,397};398}399400private async _selectHookEvent(): Promise<HookEvent | undefined> {401const items = HOOK_EVENTS.map((event, index) => ({402label: `${index + 1}. ${event.id}`,403description: event.label,404event,405}));406407const selected = await vscode.window.showQuickPick(items, {408title: vscode.l10n.t('Configure Hook'),409placeHolder: vscode.l10n.t('Which hook would you like to configure?'),410matchOnDetail: true,411ignoreFocusOut: true412});413414return selected?.event;415}416417/**418* Shows existing matchers with their source locations (grouped by location label), plus option to add new.419* Returns the selected matcher and whether we're in create or edit mode.420*/421private async _selectOrCreateMatcher(eventConfig: HookEvent): Promise<{422matcher: string;423mode: 'create' | 'edit';424location?: SettingsLocation;425} | undefined> {426const existingMatchers = await this._getExistingMatchersWithSource(eventConfig.id);427428type MatcherItem = vscode.QuickPickItem & {429isAddNew: boolean;430matcher?: string;431location?: SettingsLocation;432};433434const addNewItem: MatcherItem = {435label: '$(add) ' + vscode.l10n.t('Add new matcher...'),436isAddNew: true,437};438439// Group matchers by location label and create items with separators440const items: (MatcherItem | vscode.QuickPickItem)[] = [addNewItem];441442// Group by location label443const matchersByLocation = new Map<string, MatcherWithSource[]>();444for (const m of existingMatchers) {445const existing = matchersByLocation.get(m.location.label) || [];446existing.push(m);447matchersByLocation.set(m.location.label, existing);448}449450for (const [locationLabel, matchers] of matchersByLocation) {451// Add separator for this location452items.push({453label: locationLabel,454kind: vscode.QuickPickItemKind.Separator,455});456457// Add matchers from this location458for (const m of matchers) {459items.push({460label: m.matcher,461isAddNew: false,462matcher: m.matcher,463location: m.location,464});465}466}467468const selected = await vscode.window.showQuickPick(items, {469title: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),470placeHolder: vscode.l10n.t('Which tool should trigger this hook?'),471ignoreFocusOut: true,472}) as MatcherItem | undefined;473474if (!selected) {475return undefined;476}477478if (selected.isAddNew) {479// Create mode: prompt for new matcher480const newMatcher = await vscode.window.showInputBox({481title: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),482prompt: vscode.l10n.t('Enter a tool name or pattern (e.g., "Bash", "Edit", or "*" for all)'),483placeHolder: vscode.l10n.t('Which tool should trigger this hook?'),484ignoreFocusOut: true,485});486487if (!newMatcher) {488return undefined;489}490491return { matcher: newMatcher, mode: 'create' };492}493494// Edit mode: use existing matcher and its location495return {496matcher: selected.matcher!,497mode: 'edit',498location: selected.location,499};500}501502/**503* For edit mode: shows hooks for a specific matcher at a specific location.504* Allows editing existing or adding new (which also goes to that location).505*/506private async _selectOrAddHookForEdit(507eventConfig: HookEvent,508matcher: string,509location: SettingsLocation510): Promise<{ command: string; originalCommand?: string } | undefined> {511const existingHooks = await this._getHooksAtLocation(eventConfig.id, matcher, location);512513type HookItem = vscode.QuickPickItem & {514isAddNew: boolean;515command?: string;516};517518const addNewItem: HookItem = {519label: '$(add) ' + vscode.l10n.t('Add new hook...'),520isAddNew: true,521};522523// Build items with location label separator524const items: (HookItem | vscode.QuickPickItem)[] = [addNewItem];525526if (existingHooks.length > 0) {527items.push({528label: location.label,529kind: vscode.QuickPickItemKind.Separator,530});531532for (const h of existingHooks) {533items.push({534label: h,535isAddNew: false,536command: h,537});538}539}540541const selected = await vscode.window.showQuickPick(items, {542title: vscode.l10n.t('Configure Hook: {0} → {1}', eventConfig.id, matcher),543placeHolder: vscode.l10n.t('Select a hook to edit or add a new one'),544ignoreFocusOut: true,545}) as HookItem | undefined;546547if (!selected) {548return undefined;549}550551if (selected.isAddNew) {552// Add new hook to this location553const command = await this._enterCommand(eventConfig, matcher, location.label);554if (!command) {555return undefined;556}557return { command };558}559560// Edit existing hook561const editedCommand = await vscode.window.showInputBox({562title: vscode.l10n.t('Edit Hook: {0} → {1}', eventConfig.id, matcher),563value: selected.command,564prompt: vscode.l10n.t('Modifying {0}. Stdin Input: {1}', location.label, eventConfig.jsonSchema),565placeHolder: './my-hook-script.sh',566ignoreFocusOut: true,567});568569if (!editedCommand) {570return undefined;571}572573return { command: editedCommand, originalCommand: selected.command };574}575576/**577* For lifecycle hooks: shows all hooks across all locations, grouped by location label.578*/579private async _selectOrAddHookFromList(580eventConfig: HookEvent,581matcher: string,582existingHooks: HookWithSource[]583): Promise<{584command: string;585mode: 'create' | 'edit';586location?: SettingsLocation;587originalCommand?: string;588} | undefined> {589type HookItem = vscode.QuickPickItem & {590isAddNew: boolean;591command?: string;592location?: SettingsLocation;593};594595const addNewItem: HookItem = {596label: '$(add) ' + vscode.l10n.t('Add new hook...'),597isAddNew: true,598};599600// Build items with location label separators601const items: (HookItem | vscode.QuickPickItem)[] = [addNewItem];602603// Group by location label604const hooksByLocation = new Map<string, HookWithSource[]>();605for (const h of existingHooks) {606const existing = hooksByLocation.get(h.location.label) || [];607existing.push(h);608hooksByLocation.set(h.location.label, existing);609}610611for (const [locationLabel, hooks] of hooksByLocation) {612items.push({613label: locationLabel,614kind: vscode.QuickPickItemKind.Separator,615});616617for (const h of hooks) {618items.push({619label: h.command,620isAddNew: false,621command: h.command,622location: h.location,623});624}625}626627const selected = await vscode.window.showQuickPick(items, {628title: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),629placeHolder: vscode.l10n.t('Select a hook to edit or add a new one'),630ignoreFocusOut: true,631}) as HookItem | undefined;632633if (!selected) {634return undefined;635}636637if (selected.isAddNew) {638// Create mode: enter command (location will be asked later)639const command = await this._enterCommand(eventConfig, matcher);640if (!command) {641return undefined;642}643return { command, mode: 'create' };644}645646// Edit mode: edit existing hook647const editedCommand = await vscode.window.showInputBox({648title: vscode.l10n.t('Edit Hook: {0}', eventConfig.id),649value: selected.command,650prompt: vscode.l10n.t('Modifying {0}. Stdin Input: {1}', selected.location!.label, eventConfig.jsonSchema),651placeHolder: './my-hook-script.sh',652ignoreFocusOut: true,653});654655if (!editedCommand) {656return undefined;657}658659return {660command: editedCommand,661mode: 'edit',662location: selected.location,663originalCommand: selected.command,664};665}666667private async _enterCommand(eventConfig: HookEvent, matcher: string, locationLabel?: string): Promise<string | undefined> {668const promptText = locationLabel669? vscode.l10n.t('Modifying {0}. Stdin Input: {1}', locationLabel, eventConfig.jsonSchema)670: vscode.l10n.t('What shell command should run? Stdin Input: {0}', eventConfig.jsonSchema);671672return vscode.window.showInputBox({673title: eventConfig.needsMatcher674? vscode.l10n.t('Configure Hook: {0} → {1}', eventConfig.id, matcher)675: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),676placeHolder: './my-hook-script.sh',677prompt: promptText,678ignoreFocusOut: true,679});680}681682private async _selectSaveLocation(): Promise<SettingsLocation | undefined> {683type LocationItem = vscode.QuickPickItem & {684location: SettingsLocation;685};686687const items: LocationItem[] = [];688const homeDir = this.envService.userHome.fsPath;689const workspaceFolders = this.workspaceService.getWorkspaceFolders();690691// Add workspace-level locations for each workspace folder692for (const folderUri of workspaceFolders) {693const folderName = this.workspaceService.getWorkspaceFolderName(folderUri);694695// Workspace (local)696const localPath = URI.joinPath(folderUri, '.claude', 'settings.local.json');697items.push({698label: workspaceFolders.length > 1699? vscode.l10n.t('Workspace (local) - {0}', folderName)700: vscode.l10n.t('Workspace (local)'),701description: `${folderName}/.claude/settings.local.json`,702location: {703type: 'local',704label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace (local) - {0}', folderName) : vscode.l10n.t('Workspace (local)'),705workspaceFolder: folderUri,706settingsPath: localPath,707},708});709710// Workspace (shared)711const sharedPath = URI.joinPath(folderUri, '.claude', 'settings.json');712items.push({713label: workspaceFolders.length > 1714? vscode.l10n.t('Workspace - {0}', folderName)715: vscode.l10n.t('Workspace'),716description: `${folderName}/.claude/settings.json`,717location: {718type: 'shared',719label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace - {0}', folderName) : vscode.l10n.t('Workspace'),720workspaceFolder: folderUri,721settingsPath: sharedPath,722},723});724}725726// Add user-level location727const userPath = URI.joinPath(this.envService.userHome, '.claude', 'settings.json');728let userDisplayPath = userPath.fsPath;729if (homeDir && userDisplayPath.startsWith(homeDir)) {730userDisplayPath = '~' + userDisplayPath.slice(homeDir.length);731}732items.push({733label: vscode.l10n.t('User'),734description: userDisplayPath,735location: {736type: 'user',737label: vscode.l10n.t('User'),738settingsPath: userPath,739},740});741742const selected = await vscode.window.showQuickPick(items, {743title: vscode.l10n.t('Save Hook Configuration'),744placeHolder: vscode.l10n.t('Where should this hook be saved?'),745ignoreFocusOut: true,746});747748return selected?.location;749}750751/**752* Gets all possible settings locations for reading existing hooks.753*/754private _getAllSettingsLocations(): SettingsLocation[] {755const locations: SettingsLocation[] = [];756const workspaceFolders = this.workspaceService.getWorkspaceFolders();757758// Add workspace-level locations for each workspace folder759for (const folderUri of workspaceFolders) {760const folderName = this.workspaceService.getWorkspaceFolderName(folderUri);761762// Workspace (local)763locations.push({764type: 'local',765label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace (local) - {0}', folderName) : vscode.l10n.t('Workspace (local)'),766workspaceFolder: folderUri,767settingsPath: URI.joinPath(folderUri, '.claude', 'settings.local.json'),768});769770// Workspace (shared)771locations.push({772type: 'shared',773label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace - {0}', folderName) : vscode.l10n.t('Workspace'),774workspaceFolder: folderUri,775settingsPath: URI.joinPath(folderUri, '.claude', 'settings.json'),776});777}778779// Add user-level location780locations.push({781type: 'user',782label: vscode.l10n.t('User'),783settingsPath: URI.joinPath(this.envService.userHome, '.claude', 'settings.json'),784});785786return locations;787}788789private async _loadSettings(settingsPath: URI): Promise<HooksSettings> {790try {791const content = await this.fileSystemService.readFile(settingsPath);792return JSON.parse(new TextDecoder().decode(content)) as HooksSettings;793} catch {794return {};795}796}797798private async _saveSettings(settingsPath: URI, settings: HooksSettings): Promise<void> {799const dirPath = URI.joinPath(settingsPath, '..');800await createDirectoryIfNotExists(this.fileSystemService, dirPath);801802const content = JSON.stringify(settings, null, ' ');803await this.fileSystemService.writeFile(settingsPath, new TextEncoder().encode(content));804}805806/**807* Saves a hook configuration.808* If originalCommand is provided, replaces that command; otherwise adds new.809*/810private async _saveHookConfig(811event: HookEventId,812matcher: string,813command: string,814location: SettingsLocation,815originalCommand?: string816): Promise<void> {817const settingsPath = location.settingsPath;818const settings = await this._loadSettings(settingsPath);819820if (!settings.hooks) {821settings.hooks = {};822}823824if (!settings.hooks[event]) {825settings.hooks[event] = [];826}827828let matcherConfig = settings.hooks[event]!.find(m => m.matcher === matcher);829if (!matcherConfig) {830matcherConfig = { matcher, hooks: [] };831settings.hooks[event]!.push(matcherConfig);832}833834if (originalCommand) {835// Edit mode: replace the original command836const hookIndex = matcherConfig.hooks.findIndex(h => h.command === originalCommand);837if (hookIndex >= 0) {838matcherConfig.hooks[hookIndex] = { type: 'command', command };839} else {840// Original not found, just add new841matcherConfig.hooks.push({ type: 'command', command });842}843} else {844// Create mode: add if not already present845const existingHook = matcherConfig.hooks.find(h => h.command === command);846if (!existingHook) {847matcherConfig.hooks.push({ type: 'command', command });848}849}850851await this._saveSettings(settingsPath, settings);852}853854/**855* Gets all matchers for an event, tracking which settings file each came from.856*/857private async _getExistingMatchersWithSource(event: HookEventId): Promise<MatcherWithSource[]> {858const matchers: MatcherWithSource[] = [];859const allLocations = this._getAllSettingsLocations();860861for (const location of allLocations) {862try {863const settings = await this._loadSettings(location.settingsPath);864if (settings.hooks?.[event]) {865for (const matcherConfig of settings.hooks[event]!) {866// Check if we already have this matcher from a higher-priority location867const existing = matchers.find(m => m.matcher === matcherConfig.matcher);868if (!existing) {869matchers.push({870matcher: matcherConfig.matcher,871location,872});873}874}875}876} catch {877// Ignore errors, settings file might not exist878}879}880881return matchers;882}883884/**885* Gets all hooks for an event/matcher, tracking which settings file each came from.886*/887private async _getExistingHooksWithSource(event: HookEventId, matcher: string): Promise<HookWithSource[]> {888const hooks: HookWithSource[] = [];889const allLocations = this._getAllSettingsLocations();890891for (const location of allLocations) {892try {893const settings = await this._loadSettings(location.settingsPath);894if (settings.hooks?.[event]) {895const matcherConfig = settings.hooks[event]!.find(m => m.matcher === matcher);896if (matcherConfig) {897for (const hook of matcherConfig.hooks) {898hooks.push({899command: hook.command,900location,901});902}903}904}905} catch {906// Ignore errors, settings file might not exist907}908}909910return hooks;911}912913/**914* Gets hooks for a specific matcher at a specific location only.915*/916private async _getHooksAtLocation(event: HookEventId, matcher: string, location: SettingsLocation): Promise<string[]> {917try {918const settings = await this._loadSettings(location.settingsPath);919if (settings.hooks?.[event]) {920const matcherConfig = settings.hooks[event]!.find(m => m.matcher === matcher);921if (matcherConfig) {922return matcherConfig.hooks.map(h => h.command);923}924}925} catch {926// Ignore errors927}928return [];929}930}931932// Self-register the hooks command933registerClaudeSlashCommand(HooksSlashCommand);934935936