Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/vscode-node/slashCommands/hooksCommand.ts
13406 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 * as vscode from 'vscode';
7
import { INativeEnvService } from '../../../../../platform/env/common/envService';
8
import { createDirectoryIfNotExists, IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService';
9
import { ILogService } from '../../../../../platform/log/common/logService';
10
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
11
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
12
import { URI } from '../../../../../util/vs/base/common/uri';
13
import { IClaudeSlashCommandHandler, registerClaudeSlashCommand } from './claudeSlashCommandRegistry';
14
15
/**
16
* HOOKS CONFIGURATION WIZARD
17
* ==========================
18
*
19
* This wizard supports two distinct flows: CREATE and EDIT.
20
*
21
* ## CREATE FLOW (new matcher)
22
* Used when adding a completely new hook configuration.
23
*
24
* 1. Select hook event (e.g., PreToolUse, PostToolUse, etc.)
25
* 2. For tool-based hooks: Select "+ Add new matcher..." option
26
* 3. Enter the new matcher pattern (e.g., "Bash", "Edit", "*")
27
* 4. Enter the hook command to run
28
* 5. Select where to save (Workspace (local), Workspace, or User settings)
29
* 6. Configuration is written to the selected settings file
30
* 7. The settings file is opened with cursor at the new hook command
31
*
32
* ## EDIT FLOW (existing matcher)
33
* Used when modifying hooks for an existing matcher.
34
*
35
* 1. Select hook event (e.g., PreToolUse, PostToolUse, etc.)
36
* 2. For tool-based hooks: Select an existing matcher (grouped by file path)
37
* 3. Select existing hook command to edit, or "+ Add new hook..."
38
* - If editing existing: Input box pre-filled with current command
39
* - If adding new: Empty input box for new command
40
* 4. Changes are written back to the SAME settings file where the matcher was found
41
* (no location picker - we preserve the original source)
42
* 5. The settings file is opened with cursor at the modified hook command
43
*
44
* ## Lifecycle Hooks (no matcher)
45
* For hooks like UserPromptSubmit, Stop, SessionStart, etc.:
46
* - The matcher is implicitly "*" (matches all)
47
* - Flow is similar but skips the matcher selection step
48
* - Uses the same Create vs Edit logic based on whether hooks already exist
49
*/
50
51
/**
52
* Hook event types matching Claude Code SDK
53
* See: https://platform.claude.com/docs/en/agent-sdk/hooks
54
*
55
* Tool-based hooks receive tool_name and tool_input - matchers filter by tool name.
56
* Lifecycle hooks receive event-specific data - matchers are ignored.
57
*/
58
const HOOK_EVENTS = [
59
// Tool-based hooks (matcher filters by tool name)
60
{
61
id: 'PreToolUse',
62
label: vscode.l10n.t('Before tool execution'),
63
needsMatcher: true,
64
inputDescription: vscode.l10n.t('Exit 0: allow, Exit 2: block with stderr to model.'),
65
jsonSchema: '{ "tool_name": string, "tool_input": object }',
66
},
67
{
68
id: 'PostToolUse',
69
label: vscode.l10n.t('After tool execution'),
70
needsMatcher: true,
71
inputDescription: vscode.l10n.t('Runs after tool completes successfully.'),
72
jsonSchema: '{ "tool_name": string, "tool_input": object, "tool_response": string }',
73
},
74
{
75
id: 'PostToolUseFailure',
76
label: vscode.l10n.t('After tool execution fails'),
77
needsMatcher: true,
78
inputDescription: vscode.l10n.t('Runs when a tool fails or is interrupted.'),
79
jsonSchema: '{ "tool_name": string, "tool_input": object, "error": string, "is_interrupt": boolean }',
80
},
81
{
82
id: 'PermissionRequest',
83
label: vscode.l10n.t('When permission dialog would be displayed'),
84
needsMatcher: true,
85
inputDescription: vscode.l10n.t('Custom permission handling. Exit 0: allow, Exit 2: deny.'),
86
jsonSchema: '{ "tool_name": string, "tool_input": object, "permission_suggestions": string[] }',
87
},
88
// Lifecycle hooks (matchers ignored, fires for all events of this type)
89
{
90
id: 'UserPromptSubmit',
91
label: vscode.l10n.t('When the user submits a prompt'),
92
needsMatcher: false,
93
inputDescription: vscode.l10n.t('Exit 0: allow, Exit 2: block with stderr to model.'),
94
jsonSchema: '{ "prompt": string }',
95
},
96
{
97
id: 'Stop',
98
label: vscode.l10n.t('When agent execution stops'),
99
needsMatcher: false,
100
inputDescription: vscode.l10n.t('Use to save state or clean up resources.'),
101
jsonSchema: '{ "stop_hook_active": boolean }',
102
},
103
{
104
id: 'SubagentStart',
105
label: vscode.l10n.t('When a subagent is initialized'),
106
needsMatcher: false,
107
inputDescription: vscode.l10n.t('Track parallel task spawning.'),
108
jsonSchema: '{ "agent_id": string, "agent_type": string }',
109
},
110
{
111
id: 'SubagentStop',
112
label: vscode.l10n.t('When a subagent completes'),
113
needsMatcher: false,
114
inputDescription: vscode.l10n.t('Aggregate results from parallel tasks.'),
115
jsonSchema: '{ "agent_id": string, "agent_transcript_path": string, "stop_hook_active": boolean }',
116
},
117
{
118
id: 'PreCompact',
119
label: vscode.l10n.t('Before conversation compaction'),
120
needsMatcher: false,
121
inputDescription: vscode.l10n.t('Archive transcript before summarizing.'),
122
jsonSchema: '{ "trigger": "manual" | "auto", "custom_instructions": string }',
123
},
124
{
125
id: 'SessionStart',
126
label: vscode.l10n.t('When a session is initialized'),
127
needsMatcher: false,
128
inputDescription: vscode.l10n.t('Initialize logging and telemetry.'),
129
jsonSchema: '{ "source": "startup" | "resume" | "clear" | "compact" }',
130
},
131
{
132
id: 'SessionEnd',
133
label: vscode.l10n.t('When a session terminates'),
134
needsMatcher: false,
135
inputDescription: vscode.l10n.t('Clean up temporary resources.'),
136
jsonSchema: '{ "reason": "clear" | "logout" | "prompt_input_exit" | "other" }',
137
},
138
{
139
id: 'Notification',
140
label: vscode.l10n.t('When agent status messages are sent'),
141
needsMatcher: false,
142
inputDescription: vscode.l10n.t('Send updates to Slack or dashboards.'),
143
jsonSchema: '{ "message": string, "notification_type": string, "title": string }',
144
},
145
] as const;
146
147
type HookEventId = typeof HOOK_EVENTS[number]['id'];
148
type HookEvent = typeof HOOK_EVENTS[number];
149
150
/**
151
* Settings location type: 'local' or 'shared' for workspace, 'user' for global
152
*/
153
type SettingsLocationType = 'local' | 'shared' | 'user';
154
155
/**
156
* A resolved settings location with full path and label.
157
* For multi-root workspaces, there's one local/shared pair per workspace folder.
158
*/
159
interface SettingsLocation {
160
/** The type of location */
161
type: SettingsLocationType;
162
/** Display label (e.g., "my-project (local)", "my-project", "User") */
163
label: string;
164
/** Full path to the settings file */
165
settingsPath: URI;
166
/** For workspace locations, the workspace folder URI */
167
workspaceFolder?: URI;
168
}
169
170
interface HookConfig {
171
type: 'command';
172
command: string;
173
}
174
175
interface MatcherConfig {
176
matcher: string;
177
hooks: HookConfig[];
178
}
179
180
interface HooksSettings {
181
hooks?: Partial<Record<HookEventId, MatcherConfig[]>>;
182
}
183
184
/**
185
* A matcher with its source location tracked
186
*/
187
interface MatcherWithSource {
188
matcher: string;
189
location: SettingsLocation;
190
}
191
192
/**
193
* A hook command with its source location tracked
194
*/
195
interface HookWithSource {
196
command: string;
197
location: SettingsLocation;
198
}
199
200
interface IHooksWizardResult {
201
event: string;
202
matcher: string;
203
command: string;
204
location: string;
205
mode: 'create' | 'edit';
206
}
207
208
/**
209
* Slash command handler for configuring Claude Code hooks.
210
* Launches a QuickPick wizard to configure hook events, matchers, and commands.
211
*
212
* Supports two flows:
213
* - CREATE: Add new matcher → enter command → select save location
214
* - EDIT: Select existing matcher → select/add hook → saves to original location
215
*/
216
export class HooksSlashCommand implements IClaudeSlashCommandHandler {
217
readonly commandName = 'hooks';
218
readonly description = 'Configure Claude Code hooks for tool execution and events';
219
readonly commandId = 'copilot.claude.hooks';
220
221
constructor(
222
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
223
@IFileSystemService private readonly fileSystemService: IFileSystemService,
224
@INativeEnvService private readonly envService: INativeEnvService,
225
@ILogService private readonly logService: ILogService,
226
) { }
227
228
async handle(
229
_args: string,
230
stream: vscode.ChatResponseStream | undefined,
231
_token: CancellationToken
232
): Promise<vscode.ChatResult> {
233
stream?.markdown(vscode.l10n.t('Opening hooks configuration...'));
234
235
// Fire and forget - wizard runs in background
236
this._runWizard().catch(error => {
237
this.logService.error('[HooksSlashCommand] Error running hooks wizard:', error);
238
vscode.window.showErrorMessage(
239
vscode.l10n.t('Error configuring hook: {0}', error instanceof Error ? error.message : String(error))
240
);
241
});
242
243
return {};
244
}
245
246
private async _runWizard(): Promise<IHooksWizardResult | undefined> {
247
// Step 1: Select hook event
248
const eventConfig = await this._selectHookEvent();
249
if (!eventConfig) {
250
return undefined;
251
}
252
253
// Step 2: Determine mode and get matcher
254
let matcher: string;
255
let targetLocation: SettingsLocation;
256
let mode: 'create' | 'edit';
257
258
if (eventConfig.needsMatcher) {
259
// Tool-based hook: show matchers with source locations
260
const matcherResult = await this._selectOrCreateMatcher(eventConfig);
261
if (!matcherResult) {
262
return undefined;
263
}
264
matcher = matcherResult.matcher;
265
mode = matcherResult.mode;
266
267
if (mode === 'edit') {
268
// Edit mode: use the location where matcher was found
269
targetLocation = matcherResult.location!;
270
271
// Show existing hooks for this matcher and allow edit/add
272
const hookResult = await this._selectOrAddHookForEdit(eventConfig, matcher, targetLocation);
273
if (!hookResult) {
274
return undefined;
275
}
276
277
// Save to the original location
278
await this._saveHookConfig(eventConfig.id, matcher, hookResult.command, targetLocation, hookResult.originalCommand);
279
280
// Open the file at the hook position
281
await this._openFileAtHook(targetLocation, hookResult.command);
282
283
return this._showSuccessAndReturn(eventConfig, matcher, hookResult.command, targetLocation, mode);
284
} else {
285
// Create mode: enter command, then pick location
286
const command = await this._enterCommand(eventConfig, matcher);
287
if (!command) {
288
return undefined;
289
}
290
291
const location = await this._selectSaveLocation();
292
if (!location) {
293
return undefined;
294
}
295
296
await this._saveHookConfig(eventConfig.id, matcher, command, location);
297
298
// Open the file at the hook position
299
await this._openFileAtHook(location, command);
300
301
return this._showSuccessAndReturn(eventConfig, matcher, command, location, mode);
302
}
303
} else {
304
// Lifecycle hook: matcher is always "*"
305
matcher = '*';
306
307
// Check if hooks already exist for this event
308
const existingHooks = await this._getExistingHooksWithSource(eventConfig.id, matcher);
309
310
if (existingHooks.length > 0) {
311
// Edit mode: show existing hooks
312
const hookResult = await this._selectOrAddHookFromList(eventConfig, matcher, existingHooks);
313
if (!hookResult) {
314
return undefined;
315
}
316
317
if (hookResult.mode === 'edit') {
318
// Editing existing hook - save to its original location
319
await this._saveHookConfig(eventConfig.id, matcher, hookResult.command, hookResult.location!, hookResult.originalCommand);
320
await this._openFileAtHook(hookResult.location!, hookResult.command);
321
return this._showSuccessAndReturn(eventConfig, matcher, hookResult.command, hookResult.location!, 'edit');
322
} else {
323
// Adding new hook - ask where to save
324
const location = await this._selectSaveLocation();
325
if (!location) {
326
return undefined;
327
}
328
await this._saveHookConfig(eventConfig.id, matcher, hookResult.command, location);
329
await this._openFileAtHook(location, hookResult.command);
330
return this._showSuccessAndReturn(eventConfig, matcher, hookResult.command, location, 'create');
331
}
332
} else {
333
// Create mode: no existing hooks
334
const command = await this._enterCommand(eventConfig, matcher);
335
if (!command) {
336
return undefined;
337
}
338
339
const location = await this._selectSaveLocation();
340
if (!location) {
341
return undefined;
342
}
343
344
await this._saveHookConfig(eventConfig.id, matcher, command, location);
345
await this._openFileAtHook(location, command);
346
return this._showSuccessAndReturn(eventConfig, matcher, command, location, 'create');
347
}
348
}
349
}
350
351
/**
352
* Opens the settings file and positions cursor at the hook command.
353
*/
354
private async _openFileAtHook(location: SettingsLocation, command: string): Promise<void> {
355
try {
356
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(location.settingsPath.fsPath));
357
const text = document.getText();
358
359
// Find the line containing the command
360
const commandEscaped = command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
361
const regex = new RegExp(`"command"\\s*:\\s*"${commandEscaped}"`);
362
const match = regex.exec(text);
363
364
let position = new vscode.Position(0, 0);
365
if (match) {
366
const beforeMatch = text.substring(0, match.index);
367
const lineNumber = (beforeMatch.match(/\n/g) || []).length;
368
const lastNewline = beforeMatch.lastIndexOf('\n');
369
const column = match.index - lastNewline - 1 + match[0].indexOf(command);
370
position = new vscode.Position(lineNumber, column);
371
}
372
373
const editor = await vscode.window.showTextDocument(document, {
374
selection: new vscode.Range(position, position),
375
preview: false,
376
});
377
378
// Reveal the line in center of editor
379
editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.InCenter);
380
} catch (error) {
381
this.logService.warn(`[HooksSlashCommand] Failed to open file at hook position: ${error}`);
382
}
383
}
384
385
private _showSuccessAndReturn(
386
eventConfig: HookEvent,
387
matcher: string,
388
command: string,
389
location: SettingsLocation,
390
mode: 'create' | 'edit'
391
): IHooksWizardResult {
392
return {
393
event: eventConfig.label,
394
matcher,
395
command,
396
location: location.label,
397
mode,
398
};
399
}
400
401
private async _selectHookEvent(): Promise<HookEvent | undefined> {
402
const items = HOOK_EVENTS.map((event, index) => ({
403
label: `${index + 1}. ${event.id}`,
404
description: event.label,
405
event,
406
}));
407
408
const selected = await vscode.window.showQuickPick(items, {
409
title: vscode.l10n.t('Configure Hook'),
410
placeHolder: vscode.l10n.t('Which hook would you like to configure?'),
411
matchOnDetail: true,
412
ignoreFocusOut: true
413
});
414
415
return selected?.event;
416
}
417
418
/**
419
* Shows existing matchers with their source locations (grouped by location label), plus option to add new.
420
* Returns the selected matcher and whether we're in create or edit mode.
421
*/
422
private async _selectOrCreateMatcher(eventConfig: HookEvent): Promise<{
423
matcher: string;
424
mode: 'create' | 'edit';
425
location?: SettingsLocation;
426
} | undefined> {
427
const existingMatchers = await this._getExistingMatchersWithSource(eventConfig.id);
428
429
type MatcherItem = vscode.QuickPickItem & {
430
isAddNew: boolean;
431
matcher?: string;
432
location?: SettingsLocation;
433
};
434
435
const addNewItem: MatcherItem = {
436
label: '$(add) ' + vscode.l10n.t('Add new matcher...'),
437
isAddNew: true,
438
};
439
440
// Group matchers by location label and create items with separators
441
const items: (MatcherItem | vscode.QuickPickItem)[] = [addNewItem];
442
443
// Group by location label
444
const matchersByLocation = new Map<string, MatcherWithSource[]>();
445
for (const m of existingMatchers) {
446
const existing = matchersByLocation.get(m.location.label) || [];
447
existing.push(m);
448
matchersByLocation.set(m.location.label, existing);
449
}
450
451
for (const [locationLabel, matchers] of matchersByLocation) {
452
// Add separator for this location
453
items.push({
454
label: locationLabel,
455
kind: vscode.QuickPickItemKind.Separator,
456
});
457
458
// Add matchers from this location
459
for (const m of matchers) {
460
items.push({
461
label: m.matcher,
462
isAddNew: false,
463
matcher: m.matcher,
464
location: m.location,
465
});
466
}
467
}
468
469
const selected = await vscode.window.showQuickPick(items, {
470
title: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),
471
placeHolder: vscode.l10n.t('Which tool should trigger this hook?'),
472
ignoreFocusOut: true,
473
}) as MatcherItem | undefined;
474
475
if (!selected) {
476
return undefined;
477
}
478
479
if (selected.isAddNew) {
480
// Create mode: prompt for new matcher
481
const newMatcher = await vscode.window.showInputBox({
482
title: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),
483
prompt: vscode.l10n.t('Enter a tool name or pattern (e.g., "Bash", "Edit", or "*" for all)'),
484
placeHolder: vscode.l10n.t('Which tool should trigger this hook?'),
485
ignoreFocusOut: true,
486
});
487
488
if (!newMatcher) {
489
return undefined;
490
}
491
492
return { matcher: newMatcher, mode: 'create' };
493
}
494
495
// Edit mode: use existing matcher and its location
496
return {
497
matcher: selected.matcher!,
498
mode: 'edit',
499
location: selected.location,
500
};
501
}
502
503
/**
504
* For edit mode: shows hooks for a specific matcher at a specific location.
505
* Allows editing existing or adding new (which also goes to that location).
506
*/
507
private async _selectOrAddHookForEdit(
508
eventConfig: HookEvent,
509
matcher: string,
510
location: SettingsLocation
511
): Promise<{ command: string; originalCommand?: string } | undefined> {
512
const existingHooks = await this._getHooksAtLocation(eventConfig.id, matcher, location);
513
514
type HookItem = vscode.QuickPickItem & {
515
isAddNew: boolean;
516
command?: string;
517
};
518
519
const addNewItem: HookItem = {
520
label: '$(add) ' + vscode.l10n.t('Add new hook...'),
521
isAddNew: true,
522
};
523
524
// Build items with location label separator
525
const items: (HookItem | vscode.QuickPickItem)[] = [addNewItem];
526
527
if (existingHooks.length > 0) {
528
items.push({
529
label: location.label,
530
kind: vscode.QuickPickItemKind.Separator,
531
});
532
533
for (const h of existingHooks) {
534
items.push({
535
label: h,
536
isAddNew: false,
537
command: h,
538
});
539
}
540
}
541
542
const selected = await vscode.window.showQuickPick(items, {
543
title: vscode.l10n.t('Configure Hook: {0} → {1}', eventConfig.id, matcher),
544
placeHolder: vscode.l10n.t('Select a hook to edit or add a new one'),
545
ignoreFocusOut: true,
546
}) as HookItem | undefined;
547
548
if (!selected) {
549
return undefined;
550
}
551
552
if (selected.isAddNew) {
553
// Add new hook to this location
554
const command = await this._enterCommand(eventConfig, matcher, location.label);
555
if (!command) {
556
return undefined;
557
}
558
return { command };
559
}
560
561
// Edit existing hook
562
const editedCommand = await vscode.window.showInputBox({
563
title: vscode.l10n.t('Edit Hook: {0} → {1}', eventConfig.id, matcher),
564
value: selected.command,
565
prompt: vscode.l10n.t('Modifying {0}. Stdin Input: {1}', location.label, eventConfig.jsonSchema),
566
placeHolder: './my-hook-script.sh',
567
ignoreFocusOut: true,
568
});
569
570
if (!editedCommand) {
571
return undefined;
572
}
573
574
return { command: editedCommand, originalCommand: selected.command };
575
}
576
577
/**
578
* For lifecycle hooks: shows all hooks across all locations, grouped by location label.
579
*/
580
private async _selectOrAddHookFromList(
581
eventConfig: HookEvent,
582
matcher: string,
583
existingHooks: HookWithSource[]
584
): Promise<{
585
command: string;
586
mode: 'create' | 'edit';
587
location?: SettingsLocation;
588
originalCommand?: string;
589
} | undefined> {
590
type HookItem = vscode.QuickPickItem & {
591
isAddNew: boolean;
592
command?: string;
593
location?: SettingsLocation;
594
};
595
596
const addNewItem: HookItem = {
597
label: '$(add) ' + vscode.l10n.t('Add new hook...'),
598
isAddNew: true,
599
};
600
601
// Build items with location label separators
602
const items: (HookItem | vscode.QuickPickItem)[] = [addNewItem];
603
604
// Group by location label
605
const hooksByLocation = new Map<string, HookWithSource[]>();
606
for (const h of existingHooks) {
607
const existing = hooksByLocation.get(h.location.label) || [];
608
existing.push(h);
609
hooksByLocation.set(h.location.label, existing);
610
}
611
612
for (const [locationLabel, hooks] of hooksByLocation) {
613
items.push({
614
label: locationLabel,
615
kind: vscode.QuickPickItemKind.Separator,
616
});
617
618
for (const h of hooks) {
619
items.push({
620
label: h.command,
621
isAddNew: false,
622
command: h.command,
623
location: h.location,
624
});
625
}
626
}
627
628
const selected = await vscode.window.showQuickPick(items, {
629
title: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),
630
placeHolder: vscode.l10n.t('Select a hook to edit or add a new one'),
631
ignoreFocusOut: true,
632
}) as HookItem | undefined;
633
634
if (!selected) {
635
return undefined;
636
}
637
638
if (selected.isAddNew) {
639
// Create mode: enter command (location will be asked later)
640
const command = await this._enterCommand(eventConfig, matcher);
641
if (!command) {
642
return undefined;
643
}
644
return { command, mode: 'create' };
645
}
646
647
// Edit mode: edit existing hook
648
const editedCommand = await vscode.window.showInputBox({
649
title: vscode.l10n.t('Edit Hook: {0}', eventConfig.id),
650
value: selected.command,
651
prompt: vscode.l10n.t('Modifying {0}. Stdin Input: {1}', selected.location!.label, eventConfig.jsonSchema),
652
placeHolder: './my-hook-script.sh',
653
ignoreFocusOut: true,
654
});
655
656
if (!editedCommand) {
657
return undefined;
658
}
659
660
return {
661
command: editedCommand,
662
mode: 'edit',
663
location: selected.location,
664
originalCommand: selected.command,
665
};
666
}
667
668
private async _enterCommand(eventConfig: HookEvent, matcher: string, locationLabel?: string): Promise<string | undefined> {
669
const promptText = locationLabel
670
? vscode.l10n.t('Modifying {0}. Stdin Input: {1}', locationLabel, eventConfig.jsonSchema)
671
: vscode.l10n.t('What shell command should run? Stdin Input: {0}', eventConfig.jsonSchema);
672
673
return vscode.window.showInputBox({
674
title: eventConfig.needsMatcher
675
? vscode.l10n.t('Configure Hook: {0} → {1}', eventConfig.id, matcher)
676
: vscode.l10n.t('Configure Hook: {0}', eventConfig.id),
677
placeHolder: './my-hook-script.sh',
678
prompt: promptText,
679
ignoreFocusOut: true,
680
});
681
}
682
683
private async _selectSaveLocation(): Promise<SettingsLocation | undefined> {
684
type LocationItem = vscode.QuickPickItem & {
685
location: SettingsLocation;
686
};
687
688
const items: LocationItem[] = [];
689
const homeDir = this.envService.userHome.fsPath;
690
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
691
692
// Add workspace-level locations for each workspace folder
693
for (const folderUri of workspaceFolders) {
694
const folderName = this.workspaceService.getWorkspaceFolderName(folderUri);
695
696
// Workspace (local)
697
const localPath = URI.joinPath(folderUri, '.claude', 'settings.local.json');
698
items.push({
699
label: workspaceFolders.length > 1
700
? vscode.l10n.t('Workspace (local) - {0}', folderName)
701
: vscode.l10n.t('Workspace (local)'),
702
description: `${folderName}/.claude/settings.local.json`,
703
location: {
704
type: 'local',
705
label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace (local) - {0}', folderName) : vscode.l10n.t('Workspace (local)'),
706
workspaceFolder: folderUri,
707
settingsPath: localPath,
708
},
709
});
710
711
// Workspace (shared)
712
const sharedPath = URI.joinPath(folderUri, '.claude', 'settings.json');
713
items.push({
714
label: workspaceFolders.length > 1
715
? vscode.l10n.t('Workspace - {0}', folderName)
716
: vscode.l10n.t('Workspace'),
717
description: `${folderName}/.claude/settings.json`,
718
location: {
719
type: 'shared',
720
label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace - {0}', folderName) : vscode.l10n.t('Workspace'),
721
workspaceFolder: folderUri,
722
settingsPath: sharedPath,
723
},
724
});
725
}
726
727
// Add user-level location
728
const userPath = URI.joinPath(this.envService.userHome, '.claude', 'settings.json');
729
let userDisplayPath = userPath.fsPath;
730
if (homeDir && userDisplayPath.startsWith(homeDir)) {
731
userDisplayPath = '~' + userDisplayPath.slice(homeDir.length);
732
}
733
items.push({
734
label: vscode.l10n.t('User'),
735
description: userDisplayPath,
736
location: {
737
type: 'user',
738
label: vscode.l10n.t('User'),
739
settingsPath: userPath,
740
},
741
});
742
743
const selected = await vscode.window.showQuickPick(items, {
744
title: vscode.l10n.t('Save Hook Configuration'),
745
placeHolder: vscode.l10n.t('Where should this hook be saved?'),
746
ignoreFocusOut: true,
747
});
748
749
return selected?.location;
750
}
751
752
/**
753
* Gets all possible settings locations for reading existing hooks.
754
*/
755
private _getAllSettingsLocations(): SettingsLocation[] {
756
const locations: SettingsLocation[] = [];
757
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
758
759
// Add workspace-level locations for each workspace folder
760
for (const folderUri of workspaceFolders) {
761
const folderName = this.workspaceService.getWorkspaceFolderName(folderUri);
762
763
// Workspace (local)
764
locations.push({
765
type: 'local',
766
label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace (local) - {0}', folderName) : vscode.l10n.t('Workspace (local)'),
767
workspaceFolder: folderUri,
768
settingsPath: URI.joinPath(folderUri, '.claude', 'settings.local.json'),
769
});
770
771
// Workspace (shared)
772
locations.push({
773
type: 'shared',
774
label: workspaceFolders.length > 1 ? vscode.l10n.t('Workspace - {0}', folderName) : vscode.l10n.t('Workspace'),
775
workspaceFolder: folderUri,
776
settingsPath: URI.joinPath(folderUri, '.claude', 'settings.json'),
777
});
778
}
779
780
// Add user-level location
781
locations.push({
782
type: 'user',
783
label: vscode.l10n.t('User'),
784
settingsPath: URI.joinPath(this.envService.userHome, '.claude', 'settings.json'),
785
});
786
787
return locations;
788
}
789
790
private async _loadSettings(settingsPath: URI): Promise<HooksSettings> {
791
try {
792
const content = await this.fileSystemService.readFile(settingsPath);
793
return JSON.parse(new TextDecoder().decode(content)) as HooksSettings;
794
} catch {
795
return {};
796
}
797
}
798
799
private async _saveSettings(settingsPath: URI, settings: HooksSettings): Promise<void> {
800
const dirPath = URI.joinPath(settingsPath, '..');
801
await createDirectoryIfNotExists(this.fileSystemService, dirPath);
802
803
const content = JSON.stringify(settings, null, ' ');
804
await this.fileSystemService.writeFile(settingsPath, new TextEncoder().encode(content));
805
}
806
807
/**
808
* Saves a hook configuration.
809
* If originalCommand is provided, replaces that command; otherwise adds new.
810
*/
811
private async _saveHookConfig(
812
event: HookEventId,
813
matcher: string,
814
command: string,
815
location: SettingsLocation,
816
originalCommand?: string
817
): Promise<void> {
818
const settingsPath = location.settingsPath;
819
const settings = await this._loadSettings(settingsPath);
820
821
if (!settings.hooks) {
822
settings.hooks = {};
823
}
824
825
if (!settings.hooks[event]) {
826
settings.hooks[event] = [];
827
}
828
829
let matcherConfig = settings.hooks[event]!.find(m => m.matcher === matcher);
830
if (!matcherConfig) {
831
matcherConfig = { matcher, hooks: [] };
832
settings.hooks[event]!.push(matcherConfig);
833
}
834
835
if (originalCommand) {
836
// Edit mode: replace the original command
837
const hookIndex = matcherConfig.hooks.findIndex(h => h.command === originalCommand);
838
if (hookIndex >= 0) {
839
matcherConfig.hooks[hookIndex] = { type: 'command', command };
840
} else {
841
// Original not found, just add new
842
matcherConfig.hooks.push({ type: 'command', command });
843
}
844
} else {
845
// Create mode: add if not already present
846
const existingHook = matcherConfig.hooks.find(h => h.command === command);
847
if (!existingHook) {
848
matcherConfig.hooks.push({ type: 'command', command });
849
}
850
}
851
852
await this._saveSettings(settingsPath, settings);
853
}
854
855
/**
856
* Gets all matchers for an event, tracking which settings file each came from.
857
*/
858
private async _getExistingMatchersWithSource(event: HookEventId): Promise<MatcherWithSource[]> {
859
const matchers: MatcherWithSource[] = [];
860
const allLocations = this._getAllSettingsLocations();
861
862
for (const location of allLocations) {
863
try {
864
const settings = await this._loadSettings(location.settingsPath);
865
if (settings.hooks?.[event]) {
866
for (const matcherConfig of settings.hooks[event]!) {
867
// Check if we already have this matcher from a higher-priority location
868
const existing = matchers.find(m => m.matcher === matcherConfig.matcher);
869
if (!existing) {
870
matchers.push({
871
matcher: matcherConfig.matcher,
872
location,
873
});
874
}
875
}
876
}
877
} catch {
878
// Ignore errors, settings file might not exist
879
}
880
}
881
882
return matchers;
883
}
884
885
/**
886
* Gets all hooks for an event/matcher, tracking which settings file each came from.
887
*/
888
private async _getExistingHooksWithSource(event: HookEventId, matcher: string): Promise<HookWithSource[]> {
889
const hooks: HookWithSource[] = [];
890
const allLocations = this._getAllSettingsLocations();
891
892
for (const location of allLocations) {
893
try {
894
const settings = await this._loadSettings(location.settingsPath);
895
if (settings.hooks?.[event]) {
896
const matcherConfig = settings.hooks[event]!.find(m => m.matcher === matcher);
897
if (matcherConfig) {
898
for (const hook of matcherConfig.hooks) {
899
hooks.push({
900
command: hook.command,
901
location,
902
});
903
}
904
}
905
}
906
} catch {
907
// Ignore errors, settings file might not exist
908
}
909
}
910
911
return hooks;
912
}
913
914
/**
915
* Gets hooks for a specific matcher at a specific location only.
916
*/
917
private async _getHooksAtLocation(event: HookEventId, matcher: string, location: SettingsLocation): Promise<string[]> {
918
try {
919
const settings = await this._loadSettings(location.settingsPath);
920
if (settings.hooks?.[event]) {
921
const matcherConfig = settings.hooks[event]!.find(m => m.matcher === matcher);
922
if (matcherConfig) {
923
return matcherConfig.hooks.map(h => h.command);
924
}
925
}
926
} catch {
927
// Ignore errors
928
}
929
return [];
930
}
931
}
932
933
// Self-register the hooks command
934
registerClaudeSlashCommand(HooksSlashCommand);
935
936