mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
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>
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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<string, unknown>): 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<void> {
|
||||
// Parse existing file
|
||||
let hooksContent: { hooks: Record<string, unknown[]> };
|
||||
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<HookType, number>();
|
||||
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<HookType, IParsedHook[]>();
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown[]> };
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<readonly URI[]> {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user