From 55477c4cc83e449dd58ebc020a74b78a2ab07bfd Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 8 Feb 2026 21:02:17 -0800 Subject: [PATCH] Update configure hooks flow and supported paths (#293643) * update * updates * Update src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * update handling * PR * test * fix test * cleanup * nit * cleanup * clean --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chat.contribution.ts | 2 +- .../chat/browser/promptSyntax/hookActions.ts | 501 +++++++++++++++--- .../promptSyntax/newPromptFileActions.ts | 176 +----- .../common/chatService/chatServiceImpl.ts | 2 +- .../chat/common/participants/chatAgents.ts | 2 +- .../config/promptFileLocations.ts | 27 +- .../common/promptSyntax/hookCompatibility.ts | 9 +- .../chat/common/promptSyntax/hookSchema.ts | 8 +- .../promptSyntax/utils/promptFilesLocator.ts | 80 +-- .../computeAutomaticInstructions.test.ts | 3 +- .../config/promptFileLocations.test.ts | 68 +-- .../service/promptsService.test.ts | 3 +- src/vscode-dts/vscode.proposed.chatHooks.d.ts | 2 +- 13 files changed, 526 insertions(+), 357 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index fff665a40c0..07491ab067c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -939,7 +939,7 @@ configurationRegistry.registerConfiguration({ title: nls.localize('chat.hookFilesLocations.title', "Hook File Locations",), markdownDescription: nls.localize( 'chat.hookFilesLocations.description', - "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`hooks.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", + "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`*.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", HOOK_DOCUMENTATION_URL, ), default: { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index fcddd50ab6e..c2b43e81907 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; import { ChatViewId } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -11,19 +14,23 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; -import { NEW_HOOK_COMMAND_ID } from './newPromptFileActions.js'; +import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { OS } from '../../../../../base/common/platform.js'; @@ -32,9 +39,198 @@ import { OS } from '../../../../../base/common/platform.js'; */ const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks'; +interface IHookTypeQuickPickItem extends IQuickPickItem { + readonly hookType: typeof HOOK_TYPES[number]; +} + interface IHookQuickPickItem extends IQuickPickItem { readonly hookEntry?: IParsedHook; - readonly commandId?: string; + readonly isAddNewHook?: boolean; +} + +interface IHookFileQuickPickItem extends IQuickPickItem { + readonly fileUri?: URI; + readonly isCreateNewFile?: boolean; +} + +/** + * Detects if existing hooks use Copilot CLI naming convention (camelCase). + * Returns true if any existing key matches the Copilot CLI format. + */ +function usesCopilotCliNaming(hooksObj: Record): boolean { + for (const key of Object.keys(hooksObj)) { + // Check if any key resolves to a Copilot CLI hook type + if (resolveCopilotCliHookType(key) !== undefined) { + return true; + } + } + return false; +} + +/** + * Gets the appropriate key name for a hook type based on the naming convention used in the file. + */ +function getHookTypeKeyName(hookTypeId: HookType, useCopilotCliNamingConvention: boolean): string { + if (useCopilotCliNamingConvention) { + const copilotCliName = getCopilotCliHookTypeName(hookTypeId); + if (copilotCliName) { + return copilotCliName; + } + } + // Fall back to PascalCase (enum value) + return hookTypeId; +} + +/** + * Adds a hook to an existing hook file. + */ +async function addHookToFile( + hookFileUri: URI, + hookTypeId: HookType, + fileService: IFileService, + editorService: IEditorService, + notificationService: INotificationService, + bulkEditService: IBulkEditService +): Promise { + // Parse existing file + let hooksContent: { hooks: Record }; + const fileExists = await fileService.exists(hookFileUri); + + if (fileExists) { + const existingContent = await fileService.readFile(hookFileUri); + try { + hooksContent = JSON.parse(existingContent.value.toString()); + // Ensure hooks object exists + if (!hooksContent.hooks) { + hooksContent.hooks = {}; + } + } catch { + // If parsing fails, show error and open file for user to fix + notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks file. Please fix the JSON syntax errors and try again.")); + await editorService.openEditor({ resource: hookFileUri }); + return; + } + } else { + // Create new structure + hooksContent = { hooks: {} }; + } + + // Detect naming convention from existing keys + const useCopilotCliNamingConvention = usesCopilotCliNaming(hooksContent.hooks); + const hookTypeKeyName = getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); + + // Also check if there's an existing key for this hook type (with either naming) + // Find existing key that resolves to the same hook type + let existingKeyForType: string | undefined; + for (const key of Object.keys(hooksContent.hooks)) { + const resolvedType = resolveCopilotCliHookType(key); + if (resolvedType === hookTypeId || key === hookTypeId) { + existingKeyForType = key; + break; + } + } + + // Use existing key if found, otherwise use the detected naming convention + const keyToUse = existingKeyForType ?? hookTypeKeyName; + + // Add the new hook entry (append if hook type already exists) + const newHookEntry = { + type: 'command', + command: '' + }; + let newHookIndex: number; + if (!hooksContent.hooks[keyToUse]) { + hooksContent.hooks[keyToUse] = [newHookEntry]; + newHookIndex = 0; + } else { + hooksContent.hooks[keyToUse].push(newHookEntry); + newHookIndex = hooksContent.hooks[keyToUse].length - 1; + } + + // Write the file + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + + // Check if the file is already open in an editor + const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); + + if (existingEditor) { + // File is already open - first focus the editor, then update its model directly + await editorService.openEditor({ + resource: hookFileUri, + options: { + pinned: false + } + }); + + // Get the code editor and update its content directly + const editor = getCodeEditor(editorService.activeTextEditorControl); + if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) { + const model = editor.getModel(); + // Apply the full content replacement using executeEdits + model.pushEditOperations([], [{ + range: model.getFullModelRange(), + text: jsonContent + }], () => null); + + // Find and apply the selection + const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command'); + if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) { + editor.setSelection({ + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn + }); + editor.revealLineInCenter(selection.startLineNumber); + } + } else { + // Fallback: active editor/model check failed, apply via bulk edit service + await bulkEditService.apply([ + new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) + ], { label: localize('addHook', "Add Hook") }); + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command'); + + // Re-open editor with selection + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + } + } else { + // File is not currently open in an editor + if (!fileExists) { + // File doesn't exist - write new file directly and open + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + } else { + // File exists but isn't open - open it first, then use bulk edit for undo support + await editorService.openEditor({ + resource: hookFileUri, + options: { pinned: false } + }); + + // Apply the edit via bulk edit service for proper undo support + await bulkEditService.apply([ + new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) + ], { label: localize('addHook', "Add Hook") }); + } + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command'); + + // Open editor with selection (or re-focus if already open) + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + } } /** @@ -48,10 +244,11 @@ export async function showConfigureHooksQuickPick( const quickInputService = accessor.get(IQuickInputService); const fileService = accessor.get(IFileService); const labelService = accessor.get(ILabelService); - const commandService = accessor.get(ICommandService); const editorService = accessor.get(IEditorService); const workspaceService = accessor.get(IWorkspaceContextService); const pathService = accessor.get(IPathService); + const notificationService = accessor.get(INotificationService); + const bulkEditService = accessor.get(IBulkEditService); const remoteAgentService = accessor.get(IRemoteAgentService); // Get the remote OS (or fall back to local OS) @@ -63,6 +260,8 @@ export async function showConfigureHooksQuickPick( const workspaceRootUri = workspaceFolder?.uri; const userHomeUri = await pathService.userHome(); const userHome = userHomeUri.fsPath ?? userHomeUri.path; + + // Parse all hook files upfront to count hooks per type const hookEntries = await parseAllHookFiles( promptsService, fileService, @@ -73,46 +272,55 @@ export async function showConfigureHooksQuickPick( CancellationToken.None ); - // Build quick pick items grouped by hook type - const items: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + // Count hooks per type + const hookCountByType = new Map(); + for (const entry of hookEntries) { + hookCountByType.set(entry.hookType, (hookCountByType.get(entry.hookType) ?? 0) + 1); + } - // Add "New Hook..." option at the top - items.push({ - label: `$(plus) ${localize('commands.new-hook.label', 'Add new hook...')}`, - commandId: NEW_HOOK_COMMAND_ID, + // Step 1: Show all lifecycle events with hook counts + const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { + const count = hookCountByType.get(hookType.id) ?? 0; + const countLabel = count > 0 ? ` (${count})` : ''; + return { + label: `${hookType.label}${countLabel}`, + description: hookType.description, + hookType + }; + }); + + const selectedHookType = await quickInputService.pick(hookTypeItems, { + placeHolder: localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'), + title: localize('commands.hooks.title', 'Hooks') + }); + + if (!selectedHookType) { + return; + } + + // Filter hooks by the selected type + const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType.hookType.id); + + // Step 2: Show "Add new hook" + existing hooks of this type + const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Add new hook" option at the top + hookItems.push({ + label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, + isAddNewHook: true, alwaysShow: true }); - // Group entries by hook type - const groupedByType = new Map(); - for (const entry of hookEntries) { - const existing = groupedByType.get(entry.hookType) ?? []; - existing.push(entry); - groupedByType.set(entry.hookType, existing); - } - - // Sort hook types by their position in HOOK_TYPES - const sortedHookTypes = Array.from(groupedByType.keys()).sort((a, b) => { - const indexA = HOOK_TYPES.findIndex(h => h.id === a); - const indexB = HOOK_TYPES.findIndex(h => h.id === b); - return indexA - indexB; - }); - - // Add entries grouped by hook type - for (const hookTypeId of sortedHookTypes) { - const entries = groupedByType.get(hookTypeId)!; - const hookType = HOOK_TYPES.find(h => h.id === hookTypeId)!; - - items.push({ + // Add existing hooks + if (hooksOfType.length > 0) { + hookItems.push({ type: 'separator', - label: hookType.label + label: localize('existingHooks', "Existing Hooks") }); - for (const entry of entries) { - // Use relative path from labelService for consistent display + for (const entry of hooksOfType) { const description = labelService.getUriLabel(entry.fileUri, { relative: true }); - - items.push({ + hookItems.push({ label: entry.commandLabel, description, hookEntry: entry @@ -120,51 +328,208 @@ export async function showConfigureHooksQuickPick( } } - // Show empty state message if no hooks found - if (hookEntries.length === 0) { - items.push({ - type: 'separator', - label: localize('noHooks', "No hooks configured") + // Auto-execute if only "Add new hook" is available (no existing hooks) + let selectedHook: IHookQuickPickItem | undefined; + if (hooksOfType.length === 0) { + selectedHook = hookItems[0] as IHookQuickPickItem; + } else { + selectedHook = await quickInputService.pick(hookItems, { + placeHolder: localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'), + title: selectedHookType.hookType.label }); } - const selected = await quickInputService.pick(items, { - placeHolder: localize('commands.hooks.placeholder', 'Select a hook to open or add a new hook'), - title: localize('commands.hooks.title', 'Hooks') - }); + if (!selectedHook) { + return; + } - if (selected) { - if (selected.commandId) { - await commandService.executeCommand(selected.commandId); - } else if (selected.hookEntry) { - const entry = selected.hookEntry; - let selection: ITextEditorSelection | undefined; + // Handle clicking on existing hook (focus into command) + if (selectedHook.hookEntry) { + const entry = selectedHook.hookEntry; + let selection: ITextEditorSelection | undefined; - // Determine the command field name to highlight based on target platform - const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); + // Determine the command field name to highlight based on target platform + const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); - // Try to find the command field to highlight - if (commandFieldName) { - try { - const content = await fileService.readFile(entry.fileUri); - selection = findHookCommandSelection( - content.value.toString(), - entry.originalHookTypeId, - entry.index, - commandFieldName - ); - } catch { - // Ignore errors and just open without selection - } + // Try to find the command field to highlight + if (commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + commandFieldName + ); + } catch { + // Ignore errors and just open without selection + } + } + + await editorService.openEditor({ + resource: entry.fileUri, + options: { + selection, + pinned: false + } + }); + return; + } + + // Step 3: Handle "Add new hook" - show create new file + existing hook files + if (selectedHook.isAddNewHook) { + // Get existing hook files (local storage only, not User Data) + const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); + + const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Create new hook config file" option at the top + fileItems.push({ + label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, + isCreateNewFile: true, + alwaysShow: true + }); + + // Add existing hook files + if (hookFiles.length > 0) { + fileItems.push({ + type: 'separator', + label: localize('existingHookFiles', "Existing Hook Files") + }); + + for (const hookFile of hookFiles) { + const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); + fileItems.push({ + label: relativePath, + fileUri: hookFile.uri + }); + } + } + + // Auto-execute if no existing hook files + let selectedFile: IHookFileQuickPickItem | undefined; + if (hookFiles.length === 0) { + selectedFile = fileItems[0] as IHookFileQuickPickItem; + } else { + selectedFile = await quickInputService.pick(fileItems, { + placeHolder: localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'), + title: localize('commands.hooks.addHook.title', 'Add Hook') + }); + } + + if (!selectedFile) { + return; + } + + // Handle creating new hook config file + if (selectedFile.isCreateNewFile) { + // Get source folders for hooks, filter to local storage only (no User Data) + const allFolders = await promptsService.getSourceFolders(PromptsType.hook); + const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); + + if (localFolders.length === 0) { + notificationService.error(localize('commands.hook.noLocalFolders', "No local hook folder found. Please configure a hooks folder in your workspace.")); + return; } + // Auto-select if only one folder, otherwise show picker + let selectedFolder = localFolders[0]; + if (localFolders.length > 1) { + const folderItems = localFolders.map(folder => ({ + label: labelService.getUriLabel(folder.uri, { relative: true }), + folder + })); + const pickedFolder = await quickInputService.pick(folderItems, { + placeHolder: localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'), + title: localize('commands.hook.selectFolder.title', 'Hook File Location') + }); + if (!pickedFolder) { + return; + } + selectedFolder = pickedFolder.folder; + } + + // Ask for filename + const fileName = await quickInputService.input({ + prompt: localize('commands.hook.filename.prompt', "Enter hook file name"), + placeHolder: localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"), + validateInput: async (value) => { + if (!value || !value.trim()) { + return localize('commands.hook.filename.required', "File name is required"); + } + const name = value.trim(); + // Basic validation - no path separators or invalid characters + if (/[/\\:*?"<>|]/.test(name)) { + return localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); + } + return undefined; + } + }); + + if (!fileName) { + return; + } + + // Create the hooks folder if it doesn't exist + await fileService.createFolder(selectedFolder.uri); + + // Use user-provided filename with .json extension + const hookFileName = fileName.trim().endsWith('.json') ? fileName.trim() : `${fileName.trim()}.json`; + const hookFileUri = URI.joinPath(selectedFolder.uri, hookFileName); + + // Check if file already exists + if (await fileService.exists(hookFileUri)) { + // File exists - add hook to it instead of creating new + await addHookToFile( + hookFileUri, + selectedHookType.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); + return; + } + + // Create new hook file with the selected hook type + const hooksContent = { + hooks: { + [selectedHookType.hookType.id]: [ + { + type: 'command', + command: '' + } + ] + } + }; + + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, selectedHookType.hookType.id, 0, 'command'); + + // Open editor with selection await editorService.openEditor({ - resource: entry.fileUri, + resource: hookFileUri, options: { selection, pinned: false } }); + return; + } + + // Handle adding hook to existing file + if (selectedFile.fileUri) { + await addHookToFile( + selectedFile.fileUri, + selectedHookType.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); } } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 6a1e8d11054..3b1f279ef88 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -5,7 +5,6 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { VSBuffer } from '../../../../../base/common/buffer.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { SnippetController2 } from '../../../../../editor/contrib/snippet/browser/snippetController2.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -26,11 +25,7 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { getCleanPromptName, SKILL_FILENAME, HOOKS_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; -import { findHookCommandSelection } from './hookUtils.js'; -import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; -import { Range } from '../../../../../editor/common/core/range.js'; +import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; class AbstractNewPromptFileAction extends Action2 { @@ -180,11 +175,6 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `---`, `\${3:Define the functionality provided by this skill, including detailed instructions and examples}`, ].join('\n'); - case PromptsType.hook: - return JSON.stringify({ - version: 1, - hooks: {} - }, null, 4); default: throw new Error(`Unsupported prompt type: ${promptType}`); } @@ -196,7 +186,6 @@ export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt'; export const NEW_INSTRUCTIONS_COMMAND_ID = 'workbench.command.new.instructions'; export const NEW_AGENT_COMMAND_ID = 'workbench.command.new.agent'; export const NEW_SKILL_COMMAND_ID = 'workbench.command.new.skill'; -export const NEW_HOOK_COMMAND_ID = 'workbench.command.new.hook'; class NewPromptFileAction extends AbstractNewPromptFileAction { constructor() { @@ -299,168 +288,6 @@ class NewSkillFileAction extends Action2 { } } -class NewHookFileAction extends Action2 { - constructor() { - super({ - id: NEW_HOOK_COMMAND_ID, - title: localize('commands.new.hook.local.title', "New Hook..."), - f1: false, - precondition: ChatContextKeys.enabled, - category: CHAT_CATEGORY, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib - }, - menu: { - id: MenuId.CommandPalette, - when: ChatContextKeys.enabled - } - }); - } - - public override async run(accessor: ServicesAccessor) { - const editorService = accessor.get(IEditorService); - const fileService = accessor.get(IFileService); - const instaService = accessor.get(IInstantiationService); - const quickInputService = accessor.get(IQuickInputService); - const bulkEditService = accessor.get(IBulkEditService); - - const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, PromptsType.hook); - if (!selectedFolder) { - return; - } - - // Ask which hook type to add - const hookTypeItems = HOOK_TYPES.map(hookType => ({ - id: hookType.id, - label: hookType.label, - description: hookType.description - })); - - const selectedHookType = await quickInputService.pick(hookTypeItems, { - placeHolder: localize('commands.new.hook.type.placeholder', "Select a hook type to add"), - title: localize('commands.new.hook.type.title', "Add Hook") - }); - - if (!selectedHookType) { - return; - } - - // Create the hooks folder if it doesn't exist - await fileService.createFolder(selectedFolder.uri); - - // Use fixed hooks.json filename - const hookFileUri = URI.joinPath(selectedFolder.uri, HOOKS_FILENAME); - - // Check if hooks.json already exists - let hooksContent: { hooks: Record }; - const fileExists = await fileService.exists(hookFileUri); - - if (fileExists) { - // Parse existing file - const existingContent = await fileService.readFile(hookFileUri); - try { - hooksContent = JSON.parse(existingContent.value.toString()); - // Ensure hooks object exists - if (!hooksContent.hooks) { - hooksContent.hooks = {}; - } - } catch { - // If parsing fails, show error and open file for user to fix - const notificationService = accessor.get(INotificationService); - notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks.json. Please fix the JSON syntax errors and try again.")); - await editorService.openEditor({ resource: hookFileUri }); - return; - } - } else { - // Create new structure - hooksContent = { hooks: {} }; - } - - // Add the new hook entry (append if hook type already exists) - const hookTypeId = selectedHookType.id as HookType; - const newHookEntry = { - type: 'command', - command: '' - }; - let newHookIndex: number; - if (!hooksContent.hooks[hookTypeId]) { - hooksContent.hooks[hookTypeId] = [newHookEntry]; - newHookIndex = 0; - } else { - hooksContent.hooks[hookTypeId].push(newHookEntry); - newHookIndex = hooksContent.hooks[hookTypeId].length - 1; - } - - // Write the file - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - - // Check if the file is already open in an editor - const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); - - if (existingEditor) { - // File is already open - first focus the editor, then update its model directly - await editorService.openEditor({ - resource: hookFileUri, - options: { - pinned: false - } - }); - - // Get the code editor and update its content directly - const editor = getCodeEditor(editorService.activeTextEditorControl); - if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) { - const model = editor.getModel(); - // Apply the full content replacement using executeEdits - model.pushEditOperations([], [{ - range: model.getFullModelRange(), - text: jsonContent - }], () => null); - - // Find and apply the selection - const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); - if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) { - editor.setSelection({ - startLineNumber: selection.startLineNumber, - startColumn: selection.startColumn, - endLineNumber: selection.endLineNumber, - endColumn: selection.endColumn - }); - editor.revealLineInCenter(selection.startLineNumber); - } - } - } else { - // File is not currently open in an editor - if (!fileExists) { - // File doesn't exist - write new file directly and open - await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); - } else { - // File exists but isn't open - open it first, then use bulk edit for undo support - await editorService.openEditor({ - resource: hookFileUri, - options: { pinned: false } - }); - - // Apply the edit via bulk edit service for proper undo support - await bulkEditService.apply([ - new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) - ], { label: localize('addHook', "Add Hook") }); - } - - // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); - - // Open editor with selection (or re-focus if already open) - await editorService.openEditor({ - resource: hookFileUri, - options: { - selection, - pinned: false - } - }); - } - } -} - class NewUntitledPromptFileAction extends Action2 { constructor() { super({ @@ -506,6 +333,5 @@ export function registerNewPromptFileActions(): void { registerAction2(NewInstructionsFileAction); registerAction2(NewAgentFileAction); registerAction2(NewSkillFileAction); - registerAction2(NewHookFileAction); registerAction2(NewUntitledPromptFileAction); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d78cefbe85f..69054bd688a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -903,7 +903,7 @@ export class ChatService extends Disposable implements IChatService { let detectedAgent: IChatAgentData | undefined; let detectedCommand: IChatAgentCommand | undefined; - // Collect hooks from hooks.json files + // Collect hooks from hook .json files let collectedHooks: IChatRequestHooks | undefined; try { collectedHooks = await this.promptsService.getHooks(token); diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 6a30ac19797..e0dd7ca927a 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -153,7 +153,7 @@ export interface IChatAgentRequest { editedFileEvents?: IChatAgentEditedFileEvent[]; /** * Collected hooks configuration for this request. - * Contains all hooks defined in hooks.json files, organized by hook type. + * Contains all hooks defined in hooks .json files, organized by hook type. */ hooks?: IChatRequestHooks; /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 87524c6ce11..9c095bae97f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -55,11 +55,6 @@ export const CLAUDE_LOCAL_MD_FILENAME = 'CLAUDE.local.md'; */ export const CLAUDE_CONFIG_FOLDER = '.claude'; -/** - * Default hook file name (case insensitive). - */ -export const HOOKS_FILENAME = 'hooks.json'; - /** * Copilot custom instructions file name. */ @@ -185,9 +180,10 @@ export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ /** * Default hook file paths. + * Entries can be either a directory or a specific file path (.json) */ export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ - { path: '.github/hooks/hooks.json', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: '.github/hooks', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, { path: '.claude/settings.local.json', source: PromptFileSource.ClaudeWorkspaceLocal, storage: PromptsStorage.local }, { path: '.claude/settings.json', source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, { path: '~/.claude/settings.json', source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, @@ -203,6 +199,11 @@ function isInAgentsFolder(fileUri: URI): boolean { /** * Gets the prompt file type from the provided path. + * + * Note: This function assumes the URI is already known to be a prompt file + * (e.g., from a configured prompt source folder). It does not validate that + * arbitrary URIs are prompt files - for example, any .json file will return + * PromptsType.hook regardless of its location. */ export function getPromptFileType(fileUri: URI): PromptsType | undefined { const filename = basename(fileUri.path); @@ -229,18 +230,12 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { return PromptsType.agent; } - // Check if it's a hooks.json file (case insensitive) - if (filename.toLowerCase() === HOOKS_FILENAME.toLowerCase()) { + // Any .json file is treated as a hook file. + // The caller is responsible for only passing URIs from valid prompt source folders. + if (filename.toLowerCase().endsWith('.json')) { return PromptsType.hook; } - // Check if it's a settings.local.json or settings.json file in a .claude folder - if (filename.toLowerCase() === 'settings.local.json' || filename.toLowerCase() === 'settings.json') { - const dir = dirname(fileUri.path); - if (basename(dir) === CLAUDE_CONFIG_FOLDER) { - return PromptsType.hook; - } - } return undefined; } @@ -262,7 +257,7 @@ export function getPromptFileExtension(type: PromptsType): string { case PromptsType.skill: return SKILL_FILENAME; case PromptsType.hook: - return HOOKS_FILENAME; + return '.json'; default: throw new Error('Unknown prompt type'); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index cf33b6e5efc..e3c483d3811 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -30,7 +30,7 @@ export interface IResolvedHookEntry { * Supported hook file formats. */ export enum HookSourceFormat { - /** GitHub Copilot hooks.json format */ + /** GitHub Copilot hooks .json format */ Copilot = 'copilot', /** Claude settings.json / settings.local.json format */ Claude = 'claude', @@ -48,11 +48,6 @@ export function getHookSourceFormat(fileUri: URI): HookSourceFormat { return HookSourceFormat.Claude; } - // Copilot format: hooks.json (typically in .github/hooks/) - if (filename === 'hooks.json') { - return HookSourceFormat.Copilot; - } - // Default to Copilot format return HookSourceFormat.Copilot; } @@ -66,7 +61,7 @@ export function isReadOnlyHookSource(format: HookSourceFormat): boolean { } /** - * Parses hooks from a Copilot hooks.json file (our native format). + * Parses hooks from a Copilot hooks .json file (our native format). */ export function parseCopilotHooks( json: unknown, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 6c72752440b..297a0535e13 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -12,7 +12,7 @@ import { untildify } from '../../../../../base/common/labels.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; /** - * Enum of available hook types that can be configured in hooks.json + * Enum of available hook types that can be configured in hooks .json */ export enum HookType { SessionStart = 'SessionStart', @@ -37,7 +37,7 @@ export const HOOK_TYPES = [ { id: HookType.SessionStart, label: nls.localize('hookType.sessionStart.label', "Session Start"), - description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins or when resuming an existing session.") + description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins.") }, { id: HookType.UserPromptSubmit, @@ -191,7 +191,7 @@ export const hookFileSchema: IJSONSchema = { properties: { SessionStart: { ...hookArraySchema, - description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins or when resuming an existing session. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') + 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.') }, UserPromptSubmit: { ...hookArraySchema, @@ -257,7 +257,7 @@ export const HOOK_SCHEMA_URI = 'vscode://schemas/hooks'; /** * Glob pattern for hook files. */ -export const HOOK_FILE_GLOB = 'hooks/hooks.json'; +export const HOOK_FILE_GLOB = '.github/hooks/*.json'; /** * Normalizes a raw hook type identifier to the canonical HookType enum value. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 38146a5809d..374238c3318 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -12,7 +12,7 @@ import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../ import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, DEFAULT_HOOK_FILE_PATHS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, HOOKS_SOURCE_FOLDER } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -88,8 +88,8 @@ export class PromptFilesLocator { // Filter to only user storage locations const result = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user); - // Also include the VS Code user data prompts folder (for all types except skills) - if (type !== PromptsType.skill) { + // Also include the VS Code user data prompts folder for certain types + if (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt) { const userDataPromptsHome = this.userDataService.currentProfile.promptsHome; return [ ...result, @@ -113,22 +113,28 @@ export class PromptFilesLocator { private getSourceFoldersSync(type: PromptsType, userHome: URI): readonly URI[] { const result: URI[] = []; const { folders } = this.workspaceService.getWorkspace(); - const defaultFolders = getPromptFileDefaultLocations(type); + const defaultFileOrFolders = getPromptFileDefaultLocations(type); - for (const sourceFolder of defaultFolders) { - let folderPath: URI; - if (sourceFolder.storage === PromptsStorage.local) { + const getFolderUri = (type: PromptsType, fileOrFolderPath: URI): URI => { + // For hooks, the paths are sometimes file paths, so get the parent directory in that case + if (type === PromptsType.hook && fileOrFolderPath.path.toLowerCase().endsWith('.json')) { + return dirname(fileOrFolderPath); + } + return fileOrFolderPath; + }; + + for (const sourceFileOrFolder of defaultFileOrFolders) { + let fileOrFolderPath: URI; + if (sourceFileOrFolder.storage === PromptsStorage.local) { for (const workspaceFolder of folders) { - folderPath = joinPath(workspaceFolder.uri, sourceFolder.path); - // For hooks, the paths are file paths, so get the parent directory - result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); + fileOrFolderPath = joinPath(workspaceFolder.uri, sourceFileOrFolder.path); + result.push(getFolderUri(type, fileOrFolderPath)); } - } else if (sourceFolder.storage === PromptsStorage.user) { + } else if (sourceFileOrFolder.storage === PromptsStorage.user) { // For tilde paths, strip the ~/ prefix before joining with userHome - const relativePath = isTildePath(sourceFolder.path) ? sourceFolder.path.substring(2) : sourceFolder.path; - folderPath = joinPath(userHome, relativePath); - // For hooks, the paths are file paths, so get the parent directory - result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); + const relativePath = isTildePath(sourceFileOrFolder.path) ? sourceFileOrFolder.path.substring(2) : sourceFileOrFolder.path; + fileOrFolderPath = joinPath(userHome, relativePath); + result.push(getFolderUri(type, fileOrFolderPath)); } } @@ -202,11 +208,36 @@ export class PromptFilesLocator { /** * Gets the hook source folders for creating new hooks. - * Returns only the Copilot hooks folder (.github/hooks) since Claude paths are read-only. + * Returns folders from config, excluding user storage and Claude paths (which are read-only). */ public async getHookSourceFolders(): Promise { - const { folders } = this.workspaceService.getWorkspace(); - return folders.map(folder => joinPath(folder.uri, HOOKS_SOURCE_FOLDER)); + const userHome = await this.pathService.userHome(); + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.hook); + + // Ignore claude folders since they aren't first-class supported, so we don't want to create invalid formats + // Check for .claude as an actual path segment (starts with ".claude/" or contains "/.claude/") + const allowedHookFolders = configuredLocations.filter(loc => + !loc.path.startsWith('.claude/') && !loc.path.includes('/.claude/') + ); + + // Convert to absolute URIs + const result = new ResourceSet(); + const absoluteLocations = this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders, userHome); + + for (const location of absoluteLocations) { + // For hook configs, entries are directories unless the path ends with .json (specific file) + // Default entries have filePattern, user entries don't but are still directories + const isSpecificFile = location.uri.path.endsWith('.json'); + if (isSpecificFile) { + // It's a specific file path (like .github/hooks/hooks.json), use parent directory + result.add(dirname(location.uri)); + } else { + // It's a directory path (like .github/hooks or .github/books) + result.add(location.uri); + } + } + + return [...result]; } /** @@ -342,20 +373,7 @@ export class PromptFilesLocator { private getLocalParentFolders(type: PromptsType): readonly { parent: URI; filePattern?: string }[] { const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); - if (type === PromptsType.agent) { - configuredLocations.push(...DEFAULT_AGENT_SOURCE_FOLDERS); - } else if (type === PromptsType.hook) { - configuredLocations.push(...DEFAULT_HOOK_FILE_PATHS); - } - const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, undefined); - - // For hooks, the paths are file paths (e.g., '.github/hooks/hooks.json'), not folder paths. - // We need to watch the parent directories of these files. - if (type === PromptsType.hook) { - return absoluteLocations.map((location) => ({ parent: dirname(location.uri) })); - } - return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri)); } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 79195b6d4ff..183aaee49ef 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -29,7 +29,7 @@ import { TestContextService, TestUserDataProfileService } from '../../../../../t import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; -import { INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../common/promptSyntax/service/promptsServiceImpl.js'; @@ -73,6 +73,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { '.claude/skills': true }); + testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { [AGENTS_SOURCE_FOLDER]: true }); instaService.stub(IConfigurationService, testConfigService); instaService.stub(IUserDataProfileService, new TestUserDataProfileService()); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts index a81e59d0ec6..f6d859e9f27 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts @@ -83,50 +83,25 @@ suite('promptFileLocations', function () { assert.strictEqual(getPromptFileType(uri), PromptsType.skill); }); - test('hooks.json should be recognized as hook', () => { - const uri = URI.file('/workspace/.github/hooks/hooks.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + // Note: getPromptFileType assumes the URI is from a valid prompt source folder. + // Any .json file returns PromptsType.hook - the caller filters by folder. + test('any .json file should be recognized as hook', () => { + assert.strictEqual(getPromptFileType(URI.file('/workspace/.github/hooks/hooks.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.github/hooks/custom-hooks.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.claude/settings.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.claude/settings.local.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/any/path/config.json')), PromptsType.hook); }); - test('HOOKS.JSON (uppercase) should be recognized as hook', () => { - const uri = URI.file('/workspace/.github/hooks/HOOKS.JSON'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + test('.json files are case insensitive', () => { + assert.strictEqual(getPromptFileType(URI.file('/workspace/.github/hooks/HOOKS.JSON')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.claude/SETTINGS.JSON')), PromptsType.hook); }); - test('hooks.json in any folder should be recognized as hook', () => { - const uri = URI.file('/workspace/some/other/path/hooks.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('settings.json in .claude folder should be recognized as hook', () => { - const uri = URI.file('/workspace/.claude/settings.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('settings.local.json in .claude folder should be recognized as hook', () => { - const uri = URI.file('/workspace/.claude/settings.local.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('SETTINGS.JSON (uppercase) in .claude folder should be recognized as hook', () => { - const uri = URI.file('/workspace/.claude/SETTINGS.JSON'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('settings.json outside .claude folder should NOT be recognized as hook', () => { - const uri = URI.file('/workspace/.github/settings.json'); + test('non-json file in .github/hooks folder should NOT be recognized as hook', () => { + const uri = URI.file('/workspace/.github/hooks/readme.md'); assert.strictEqual(getPromptFileType(uri), undefined); }); - - test('settings.local.json outside .claude folder should NOT be recognized as hook', () => { - const uri = URI.file('/workspace/some/path/settings.local.json'); - assert.strictEqual(getPromptFileType(uri), undefined); - }); - - test('settings.json in ~/.claude folder should be recognized as hook', () => { - const uri = URI.file('/Users/user/.claude/settings.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); }); suite('getCleanPromptName', () => { @@ -208,20 +183,13 @@ suite('promptFileLocations', function () { assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/SKILL2.md')), false); }); - test('hooks.json should return true', () => { - assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/hooks/hooks.json')), true); - }); - - test('settings.json in .claude folder should return true', () => { + // Note: Any .json file returns true because getPromptFileType returns hook for all JSON. + // The caller is responsible for only passing URIs from valid prompt source folders. + test('any .json file should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/hooks/custom-hooks.json')), true); assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.json')), true); - }); - - test('settings.local.json in .claude folder should return true', () => { assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.local.json')), true); - }); - - test('settings.json outside .claude folder should return false', () => { - assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/settings.json')), false); + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/settings.json')), true); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 9980d5b4f22..f4e54cac7f1 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -38,7 +38,7 @@ import { TestContextService, TestUserDataProfileService } from '../../../../../. import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; -import { CLAUDE_CONFIG_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; @@ -74,6 +74,7 @@ suite('PromptsService', () => { testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true }); + testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { [AGENTS_SOURCE_FOLDER]: true }); instaService.stub(IConfigurationService, testConfigService); instaService.stub(IWorkbenchEnvironmentService, {}); diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index a03c439b30b..51ef72bcdbe 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -63,7 +63,7 @@ declare module 'vscode' { export namespace chat { /** * Execute all hooks of the specified type for the current chat session. - * Hooks are configured in hooks.json files in the workspace. + * Hooks are configured in hooks .json files in the workspace. * * @param hookType The type of hook to execute. * @param options Hook execution options including the input data.