mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 08:15:56 +01:00
Add support for agent-scoped hooks (#299029)
This commit is contained in:
@@ -43,7 +43,7 @@ import { IPathService } from '../../../../services/path/common/pathService.js';
|
||||
import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js';
|
||||
import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js';
|
||||
import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js';
|
||||
import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js';
|
||||
import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js';
|
||||
import { parse as parseJSONC } from '../../../../../base/common/json.js';
|
||||
import { Schemas } from '../../../../../base/common/network.js';
|
||||
import { OS } from '../../../../../base/common/platform.js';
|
||||
@@ -65,6 +65,8 @@ export interface IAICustomizationListItem {
|
||||
readonly description?: string;
|
||||
readonly storage: PromptsStorage;
|
||||
readonly promptType: PromptsType;
|
||||
/** When set, overrides `storage` for display grouping purposes. */
|
||||
readonly groupKey?: string;
|
||||
nameMatches?: IMatch[];
|
||||
descriptionMatches?: IMatch[];
|
||||
}
|
||||
@@ -75,7 +77,7 @@ export interface IAICustomizationListItem {
|
||||
interface IGroupHeaderEntry {
|
||||
readonly type: 'group-header';
|
||||
readonly id: string;
|
||||
readonly storage: PromptsStorage;
|
||||
readonly groupKey: string;
|
||||
readonly label: string;
|
||||
readonly icon: ThemeIcon;
|
||||
readonly count: number;
|
||||
@@ -311,7 +313,7 @@ export class AICustomizationListWidget extends Disposable {
|
||||
private allItems: IAICustomizationListItem[] = [];
|
||||
private displayEntries: IListEntry[] = [];
|
||||
private searchQuery: string = '';
|
||||
private readonly collapsedGroups = new Set<PromptsStorage>();
|
||||
private readonly collapsedGroups = new Set<string>();
|
||||
private readonly dropdownActionDisposables = this._register(new DisposableStore());
|
||||
|
||||
private readonly delayedFilter = new Delayer<void>(200);
|
||||
@@ -827,6 +829,37 @@ export class AICustomizationListWidget extends Disposable {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also include hooks defined in agent frontmatter (not in sessions window)
|
||||
// TODO: add this back when Copilot CLI supports this
|
||||
const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : [];
|
||||
for (const agent of agents) {
|
||||
if (!agent.hooks) {
|
||||
continue;
|
||||
}
|
||||
for (const hookType of Object.values(HookType)) {
|
||||
const hookCommands = agent.hooks[hookType];
|
||||
if (!hookCommands || hookCommands.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const hookMeta = HOOK_METADATA[hookType];
|
||||
for (let i = 0; i < hookCommands.length; i++) {
|
||||
const hook = hookCommands[i];
|
||||
const cmdLabel = formatHookCommandLabel(hook, OS);
|
||||
const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel;
|
||||
items.push({
|
||||
id: `${agent.uri.toString()}#hook:${hookType}[${i}]`,
|
||||
uri: agent.uri,
|
||||
name: hookMeta?.label ?? hookType,
|
||||
filename: basename(agent.uri),
|
||||
description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`,
|
||||
storage: agent.source.storage,
|
||||
groupKey: 'agents',
|
||||
promptType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For instructions, fetch prompt files and group by storage
|
||||
const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None);
|
||||
@@ -940,15 +973,17 @@ export class AICustomizationListWidget extends Disposable {
|
||||
// Group items by storage
|
||||
const promptType = sectionToPromptType(this.currentSection);
|
||||
const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources);
|
||||
const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [
|
||||
{ storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] },
|
||||
{ storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] },
|
||||
{ storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] },
|
||||
{ storage: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] },
|
||||
].filter(g => visibleSources.has(g.storage));
|
||||
const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [
|
||||
{ groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] },
|
||||
{ groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] },
|
||||
{ groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] },
|
||||
{ groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] },
|
||||
{ groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] },
|
||||
].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents');
|
||||
|
||||
for (const item of matchedItems) {
|
||||
const group = groups.find(g => g.storage === item.storage);
|
||||
const key = item.groupKey ?? item.storage;
|
||||
const group = groups.find(g => g.groupKey === key);
|
||||
if (group) {
|
||||
group.items.push(item);
|
||||
}
|
||||
@@ -967,12 +1002,12 @@ export class AICustomizationListWidget extends Disposable {
|
||||
continue;
|
||||
}
|
||||
|
||||
const collapsed = this.collapsedGroups.has(group.storage);
|
||||
const collapsed = this.collapsedGroups.has(group.groupKey);
|
||||
|
||||
this.displayEntries.push({
|
||||
type: 'group-header',
|
||||
id: `group-${group.storage}`,
|
||||
storage: group.storage,
|
||||
id: `group-${group.groupKey}`,
|
||||
groupKey: group.groupKey,
|
||||
label: group.label,
|
||||
icon: group.icon,
|
||||
count: group.items.length,
|
||||
@@ -997,10 +1032,10 @@ export class AICustomizationListWidget extends Disposable {
|
||||
* Toggles the collapsed state of a group.
|
||||
*/
|
||||
private toggleGroup(entry: IGroupHeaderEntry): void {
|
||||
if (this.collapsedGroups.has(entry.storage)) {
|
||||
this.collapsedGroups.delete(entry.storage);
|
||||
if (this.collapsedGroups.has(entry.groupKey)) {
|
||||
this.collapsedGroups.delete(entry.groupKey);
|
||||
} else {
|
||||
this.collapsedGroups.add(entry.storage);
|
||||
this.collapsedGroups.add(entry.groupKey);
|
||||
}
|
||||
this.filterItems();
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js';
|
||||
import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';
|
||||
import { IFileService } from '../../../../../platform/files/common/files.js';
|
||||
import { HOOK_METADATA, HOOKS_BY_TARGET, HookType, IHookTypeMeta } from '../../common/promptSyntax/hookTypes.js';
|
||||
import { getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';
|
||||
import { formatHookCommandLabel, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';
|
||||
import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js';
|
||||
import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js';
|
||||
import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.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 { findHookCommandSelection, findHookCommandInYaml, 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';
|
||||
@@ -348,7 +348,8 @@ export async function showConfigureHooksQuickPick(
|
||||
workspaceRootUri,
|
||||
userHome,
|
||||
targetOS,
|
||||
CancellationToken.None
|
||||
CancellationToken.None,
|
||||
{ includeAgentHooks: true }
|
||||
);
|
||||
|
||||
// Count hooks per type
|
||||
@@ -445,6 +446,10 @@ export async function showConfigureHooksQuickPick(
|
||||
// Filter hooks by the selected type
|
||||
const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType);
|
||||
|
||||
// Separate hooks by source
|
||||
const fileHooks = hooksOfType.filter(h => !h.agentName);
|
||||
const agentHooks = hooksOfType.filter(h => h.agentName);
|
||||
|
||||
// Step 2: Show "Add new hook" + existing hooks of this type
|
||||
const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = [];
|
||||
|
||||
@@ -455,14 +460,14 @@ export async function showConfigureHooksQuickPick(
|
||||
alwaysShow: true
|
||||
});
|
||||
|
||||
// Add existing hooks
|
||||
if (hooksOfType.length > 0) {
|
||||
// Add existing file-based hooks
|
||||
if (fileHooks.length > 0) {
|
||||
hookItems.push({
|
||||
type: 'separator',
|
||||
label: localize('existingHooks', "Existing Hooks")
|
||||
});
|
||||
|
||||
for (const entry of hooksOfType) {
|
||||
for (const entry of fileHooks) {
|
||||
const description = labelService.getUriLabel(entry.fileUri, { relative: true });
|
||||
hookItems.push({
|
||||
label: entry.commandLabel,
|
||||
@@ -472,6 +477,26 @@ export async function showConfigureHooksQuickPick(
|
||||
}
|
||||
}
|
||||
|
||||
// Add agent-defined hooks grouped by agent name
|
||||
if (agentHooks.length > 0) {
|
||||
const agentNames = [...new Set(agentHooks.map(h => h.agentName!))];
|
||||
for (const agentName of agentNames) {
|
||||
hookItems.push({
|
||||
type: 'separator',
|
||||
label: localize('agentHooks', "Agent: {0}", agentName)
|
||||
});
|
||||
|
||||
for (const entry of agentHooks.filter(h => h.agentName === agentName)) {
|
||||
const description = labelService.getUriLabel(entry.fileUri, { relative: true });
|
||||
hookItems.push({
|
||||
label: entry.commandLabel,
|
||||
description,
|
||||
hookEntry: entry
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-execute if only "Add new hook" is available (no existing hooks)
|
||||
if (hooksOfType.length === 0) {
|
||||
selectedHook = hookItems[0] as IHookQuickPickItem;
|
||||
@@ -500,22 +525,34 @@ export async function showConfigureHooksQuickPick(
|
||||
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);
|
||||
|
||||
// Try to find the command field to highlight
|
||||
if (commandFieldName) {
|
||||
if (entry.agentName) {
|
||||
// Agent hook: search the YAML frontmatter for the command
|
||||
try {
|
||||
const content = await fileService.readFile(entry.fileUri);
|
||||
selection = findHookCommandSelection(
|
||||
content.value.toString(),
|
||||
entry.originalHookTypeId,
|
||||
entry.index,
|
||||
commandFieldName
|
||||
);
|
||||
const commandText = formatHookCommandLabel(entry.command, targetOS);
|
||||
if (commandText) {
|
||||
selection = findHookCommandInYaml(content.value.toString(), commandText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors and just open without selection
|
||||
}
|
||||
} else {
|
||||
// File hook: use JSON-based selection finder
|
||||
const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.openEditor) {
|
||||
|
||||
@@ -114,6 +114,54 @@ export function findHookCommandSelection(content: string, hookType: string, inde
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the selection range for a hook command string in a YAML/Markdown file
|
||||
* (e.g., an agent `.md` file with YAML frontmatter).
|
||||
*
|
||||
* Searches for the command text within command field lines and selects the value.
|
||||
* Supports all hook command field keys: command, windows, linux, osx, bash, powershell.
|
||||
*
|
||||
* @param content The full file content
|
||||
* @param commandText The command string to locate
|
||||
* @returns The selection range, or undefined if not found
|
||||
*/
|
||||
export function findHookCommandInYaml(content: string, commandText: string): ITextEditorSelection | undefined {
|
||||
const commandFieldKeys = ['command', 'windows', 'linux', 'osx', 'bash', 'powershell'];
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
// Only match lines whose YAML key is a known command field
|
||||
const matchedKey = commandFieldKeys.find(key =>
|
||||
trimmed.startsWith(`${key}:`) || trimmed.startsWith(`- ${key}:`)
|
||||
);
|
||||
if (!matchedKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Search after the colon to avoid matching within the key name itself
|
||||
const colonIdx = line.indexOf(':');
|
||||
const idx = line.indexOf(commandText, colonIdx + 1);
|
||||
if (idx !== -1) {
|
||||
// Verify this is a full match (not a substring of a longer command)
|
||||
const afterIdx = idx + commandText.length;
|
||||
const charAfter = afterIdx < line.length ? line.charCodeAt(afterIdx) : -1;
|
||||
// Accept if what follows is end of line, a quote, or whitespace
|
||||
if (charAfter === -1 || charAfter === 34 /* " */ || charAfter === 39 /* ' */ || charAfter === 32 /* space */ || charAfter === 9 /* tab */) {
|
||||
return {
|
||||
startLineNumber: i + 1,
|
||||
startColumn: idx + 1,
|
||||
endLineNumber: i + 1,
|
||||
endColumn: idx + 1 + commandText.length
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed hook information.
|
||||
*/
|
||||
@@ -129,11 +177,15 @@ export interface IParsedHook {
|
||||
originalHookTypeId: string;
|
||||
/** If true, this hook is disabled via `disableAllHooks: true` in its file */
|
||||
disabled?: boolean;
|
||||
/** If set, this hook came from a custom agent's frontmatter */
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export interface IParseAllHookFilesOptions {
|
||||
/** Additional file URIs to parse (e.g., files skipped due to disableAllHooks) */
|
||||
additionalDisabledFileUris?: readonly URI[];
|
||||
/** If true, also collect hooks from custom agent frontmatter */
|
||||
includeAgentHooks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,5 +279,40 @@ export async function parseAllHookFiles(
|
||||
}
|
||||
}
|
||||
|
||||
// Collect hooks from custom agents' frontmatter
|
||||
if (options?.includeAgentHooks) {
|
||||
const agents = await promptsService.getCustomAgents(token);
|
||||
for (const agent of agents) {
|
||||
if (!agent.hooks) {
|
||||
continue;
|
||||
}
|
||||
for (const hookTypeValue of Object.values(HookType)) {
|
||||
const commands = agent.hooks[hookTypeValue];
|
||||
if (!commands || commands.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const hookTypeMeta = HOOK_METADATA[hookTypeValue];
|
||||
if (!hookTypeMeta) {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const command = commands[i];
|
||||
const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)');
|
||||
parsedHooks.push({
|
||||
hookType: hookTypeValue,
|
||||
hookTypeLabel: hookTypeMeta.label,
|
||||
command,
|
||||
commandLabel,
|
||||
fileUri: agent.uri,
|
||||
filePath: labelService.getUriLabel(agent.uri, { relative: true }),
|
||||
index: i,
|
||||
originalHookTypeId: hookTypeValue,
|
||||
agentName: agent.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedHooks;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../langua
|
||||
import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js';
|
||||
import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js';
|
||||
import { IPromptsService } from '../promptSyntax/service/promptsService.js';
|
||||
import { ChatRequestHooks } from '../promptSyntax/hookSchema.js';
|
||||
import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js';
|
||||
|
||||
const serializedChatKey = 'interactive.sessions';
|
||||
|
||||
@@ -959,6 +959,20 @@ export class ChatService extends Disposable implements IChatService {
|
||||
this.logService.warn('[ChatService] Failed to collect hooks:', error);
|
||||
}
|
||||
|
||||
// Merge hooks from the selected custom agent's frontmatter (if any)
|
||||
const agentName = options?.modeInfo?.modeInstructions?.name;
|
||||
if (agentName) {
|
||||
try {
|
||||
const agents = await this.promptsService.getCustomAgents(token, model.sessionResource);
|
||||
const customAgent = agents.find(a => a.name === agentName);
|
||||
if (customAgent?.hooks) {
|
||||
collectedHooks = mergeHooks(collectedHooks, customAgent.hooks);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.warn('[ChatService] Failed to collect agent hooks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const stopWatch = new StopWatch(false);
|
||||
store.add(token.onCancellationRequested(() => {
|
||||
this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`);
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { toHookType, resolveHookCommand, IHookCommand } from './hookSchema.js';
|
||||
import { toHookType, IHookCommand, extractHookCommandsFromItem } from './hookSchema.js';
|
||||
import { HOOKS_BY_TARGET, HookType } from './hookTypes.js';
|
||||
import { Target } from './promptTypes.js';
|
||||
|
||||
export { extractHookCommandsFromItem };
|
||||
|
||||
/**
|
||||
* Cached inverse mapping from HookType to Claude hook type name.
|
||||
* Lazily computed on first access.
|
||||
@@ -132,60 +134,4 @@ export function parseClaudeHooks(
|
||||
return { hooks: result, disabledAllHooks: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract hook commands from an item that could be:
|
||||
* 1. A direct command object: { type: 'command', command: '...' }
|
||||
* 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] }
|
||||
*
|
||||
* This allows Copilot format to handle Claude-style entries if pasted.
|
||||
* Also handles Claude's leniency where 'type' field can be omitted.
|
||||
*/
|
||||
export function extractHookCommandsFromItem(
|
||||
item: unknown,
|
||||
workspaceRootUri: URI | undefined,
|
||||
userHome: string
|
||||
): IHookCommand[] {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemObj = item as Record<string, unknown>;
|
||||
const commands: IHookCommand[] = [];
|
||||
|
||||
// Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] }
|
||||
const nestedHooks = itemObj.hooks;
|
||||
if (nestedHooks !== undefined && Array.isArray(nestedHooks)) {
|
||||
for (const nestedHook of nestedHooks) {
|
||||
if (!nestedHook || typeof nestedHook !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeForResolve(nestedHook as Record<string, unknown>);
|
||||
const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome);
|
||||
if (resolved) {
|
||||
commands.push(resolved);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct command object
|
||||
const normalized = normalizeForResolve(itemObj);
|
||||
const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome);
|
||||
if (resolved) {
|
||||
commands.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a hook command object for resolving.
|
||||
* Claude format allows omitting the 'type' field, treating it as 'command'.
|
||||
* This ensures compatibility when Claude-style hooks are pasted into Copilot format.
|
||||
*/
|
||||
function normalizeForResolve(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
// If type is missing or already 'command', ensure it's set to 'command'
|
||||
if (raw.type === undefined || raw.type === 'command') {
|
||||
return { ...raw, type: 'command' };
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { untildify } from '../../../../../base/common/labels.js';
|
||||
import { OperatingSystem } from '../../../../../base/common/platform.js';
|
||||
import { HookType, HOOKS_BY_TARGET, HOOK_METADATA } from './hookTypes.js';
|
||||
import { Target } from './promptTypes.js';
|
||||
import { IValue, IMapValue } from './promptFileParser.js';
|
||||
|
||||
/**
|
||||
* A single hook command configuration.
|
||||
@@ -46,6 +47,43 @@ export type ChatRequestHooks = {
|
||||
readonly [K in HookType]?: readonly IHookCommand[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges two sets of hooks by concatenating the command arrays for each hook type.
|
||||
* Additional hooks are appended after the base hooks.
|
||||
*/
|
||||
export function mergeHooks(base: ChatRequestHooks | undefined, additional: ChatRequestHooks): ChatRequestHooks {
|
||||
if (!base) {
|
||||
return additional;
|
||||
}
|
||||
|
||||
const result: Partial<Record<HookType, readonly IHookCommand[]>> = { ...base };
|
||||
for (const hookType of Object.values(HookType)) {
|
||||
const baseArr = base[hookType];
|
||||
const additionalArr = additional[hookType];
|
||||
if (additionalArr && additionalArr.length > 0) {
|
||||
result[hookType] = baseArr ? [...baseArr, ...additionalArr] : additionalArr;
|
||||
}
|
||||
}
|
||||
return result as ChatRequestHooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions for hook command fields, used by both the JSON schema and the hover provider.
|
||||
*/
|
||||
export const HOOK_COMMAND_FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
type: nls.localize('hook.type', 'Must be "command".'),
|
||||
command: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.'),
|
||||
windows: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.'),
|
||||
linux: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.'),
|
||||
osx: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.'),
|
||||
bash: nls.localize('hook.bash', 'Bash command for Linux and macOS.'),
|
||||
powershell: nls.localize('hook.powershell', 'PowerShell command for Windows.'),
|
||||
cwd: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).'),
|
||||
env: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.'),
|
||||
timeout: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).'),
|
||||
timeoutSec: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 10).'),
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON Schema for GitHub Copilot hook configuration files.
|
||||
* Hooks enable executing custom shell commands at strategic points in an agent's workflow.
|
||||
@@ -67,37 +105,37 @@ const vscodeHookCommandSchema: IJSONSchema = {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['command'],
|
||||
description: nls.localize('hook.type', 'Must be "command".')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.type
|
||||
},
|
||||
command: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.command
|
||||
},
|
||||
windows: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.windows
|
||||
},
|
||||
linux: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.linux
|
||||
},
|
||||
osx: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.osx
|
||||
},
|
||||
cwd: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.cwd
|
||||
},
|
||||
env: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.env
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
default: 30,
|
||||
description: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.timeout
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -142,29 +180,29 @@ const copilotCliHookCommandSchema: IJSONSchema = {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['command'],
|
||||
description: nls.localize('hook.type', 'Must be "command".')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.type
|
||||
},
|
||||
bash: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.bash', 'Bash command for Linux and macOS.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.bash
|
||||
},
|
||||
powershell: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.powershell', 'PowerShell command for Windows.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.powershell
|
||||
},
|
||||
cwd: {
|
||||
type: 'string',
|
||||
description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.cwd
|
||||
},
|
||||
env: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.env
|
||||
},
|
||||
timeoutSec: {
|
||||
type: 'number',
|
||||
default: 10,
|
||||
description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 10).')
|
||||
description: HOOK_COMMAND_FIELD_DESCRIPTIONS.timeoutSec
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -444,3 +482,155 @@ export function resolveHookCommand(raw: Record<string, unknown>, workspaceRootUr
|
||||
...(normalized.timeout !== undefined && { timeout: normalized.timeout }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract hook commands from an item that could be:
|
||||
* 1. A direct command object: { type: 'command', command: '...' }
|
||||
* 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] }
|
||||
*
|
||||
* This allows Copilot format to handle Claude-style entries if pasted.
|
||||
* Also handles Claude's leniency where 'type' field can be omitted.
|
||||
*/
|
||||
export function extractHookCommandsFromItem(
|
||||
item: unknown,
|
||||
workspaceRootUri: URI | undefined,
|
||||
userHome: string
|
||||
): IHookCommand[] {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemObj = item as Record<string, unknown>;
|
||||
const commands: IHookCommand[] = [];
|
||||
|
||||
// Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] }
|
||||
const nestedHooks = itemObj.hooks;
|
||||
if (nestedHooks !== undefined && Array.isArray(nestedHooks)) {
|
||||
for (const nestedHook of nestedHooks) {
|
||||
if (!nestedHook || typeof nestedHook !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeForResolve(nestedHook as Record<string, unknown>);
|
||||
const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome);
|
||||
if (resolved) {
|
||||
commands.push(resolved);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct command object
|
||||
const normalized = normalizeForResolve(itemObj);
|
||||
const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome);
|
||||
if (resolved) {
|
||||
commands.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a hook command object for resolving.
|
||||
* Claude format allows omitting the 'type' field, treating it as 'command'.
|
||||
* This ensures compatibility when Claude-style hooks are pasted into Copilot format.
|
||||
*/
|
||||
function normalizeForResolve(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
// If type is missing or already 'command', ensure it's set to 'command'
|
||||
if (raw.type === undefined || raw.type === 'command') {
|
||||
return { ...raw, type: 'command' };
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an {@link IValue} YAML AST node into a plain JavaScript value
|
||||
* (string, array, or object) suitable for passing to hook parsing helpers.
|
||||
*/
|
||||
function yamlValueToPlain(value: IValue): unknown {
|
||||
switch (value.type) {
|
||||
case 'scalar':
|
||||
return value.value;
|
||||
case 'sequence':
|
||||
return value.items.map(yamlValueToPlain);
|
||||
case 'map': {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const prop of value.properties) {
|
||||
obj[prop.key.value] = yamlValueToPlain(prop.value);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses hooks from a subagent's YAML frontmatter `hooks` attribute.
|
||||
*
|
||||
* Supports two formats for hook entries:
|
||||
*
|
||||
* 1. **Direct command** (our format, without matcher):
|
||||
* ```yaml
|
||||
* hooks:
|
||||
* PreToolUse:
|
||||
* - type: command
|
||||
* command: "./scripts/validate.sh"
|
||||
* ```
|
||||
*
|
||||
* 2. **Nested with matcher** (Claude Code format):
|
||||
* ```yaml
|
||||
* hooks:
|
||||
* PreToolUse:
|
||||
* - matcher: "Bash"
|
||||
* hooks:
|
||||
* - type: command
|
||||
* command: "./scripts/validate.sh"
|
||||
* ```
|
||||
*
|
||||
* @param hooksMap The raw YAML map value from the `hooks` frontmatter attribute.
|
||||
* @param workspaceRootUri Workspace root for resolving relative `cwd` paths.
|
||||
* @param userHome User home directory path for tilde expansion.
|
||||
* @param target The agent's target, used to resolve hook type names correctly.
|
||||
* @returns Resolved hooks organized by hook type, ready for use in {@link ChatRequestHooks}.
|
||||
*/
|
||||
export function parseSubagentHooksFromYaml(
|
||||
hooksMap: IMapValue,
|
||||
workspaceRootUri: URI | undefined,
|
||||
userHome: string,
|
||||
target: Target = Target.Undefined,
|
||||
): ChatRequestHooks {
|
||||
const result: Record<string, IHookCommand[]> = {};
|
||||
const targetHookMap = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined];
|
||||
|
||||
for (const prop of hooksMap.properties) {
|
||||
const hookTypeName = prop.key.value;
|
||||
|
||||
// Resolve hook type name using the target's own map first, then fall back to canonical names
|
||||
const hookType = targetHookMap[hookTypeName] ?? toHookType(hookTypeName);
|
||||
if (!hookType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The value must be a sequence (array of hook entries)
|
||||
if (prop.value.type !== 'sequence') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commands: IHookCommand[] = [];
|
||||
|
||||
for (const item of prop.value.items) {
|
||||
// Convert the YAML AST node to a plain object so the existing
|
||||
// extractHookCommandsFromItem helper can handle both direct
|
||||
// commands and nested matcher structures.
|
||||
const plainItem = yamlValueToPlain(item);
|
||||
const extracted = extractHookCommandsFromItem(plainItem, workspaceRootUri, userHome);
|
||||
commands.push(...extracted);
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
if (!result[hookType]) {
|
||||
result[hookType] = [];
|
||||
}
|
||||
result[hookType].push(...commands);
|
||||
}
|
||||
}
|
||||
|
||||
return result as ChatRequestHooks;
|
||||
}
|
||||
|
||||
@@ -167,6 +167,10 @@ export const customAgentAttributes: Record<string, IAttributeDefinition> = {
|
||||
type: 'map',
|
||||
description: localize('promptHeader.agent.github', 'GitHub-specific configuration for the agent, such as token permissions.'),
|
||||
},
|
||||
[PromptHeaderAttributes.hooks]: {
|
||||
type: 'map',
|
||||
description: localize('promptHeader.agent.hooks', 'Lifecycle hooks scoped to this agent. Define hooks that run only while this agent is active.'),
|
||||
},
|
||||
};
|
||||
|
||||
// Attribute metadata for skill files (`SKILL.md`).
|
||||
|
||||
@@ -15,10 +15,12 @@ import { IChatModeService } from '../../chatModes.js';
|
||||
import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js';
|
||||
import { IPromptsService } from '../service/promptsService.js';
|
||||
import { Iterable } from '../../../../../../base/common/iterator.js';
|
||||
import { ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js';
|
||||
import { IMapValue, ISequenceValue, IValue, IHeaderAttribute, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js';
|
||||
import { getAttributeDefinition, getTarget, getValidAttributeNames, knownClaudeTools, knownGithubCopilotTools, IValueEntry, ClaudeHeaderAttributes, } from './promptFileAttributes.js';
|
||||
import { localize } from '../../../../../../nls.js';
|
||||
import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js';
|
||||
import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js';
|
||||
import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js';
|
||||
|
||||
export class PromptHeaderAutocompletion implements CompletionItemProvider {
|
||||
/**
|
||||
@@ -91,6 +93,33 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider {
|
||||
const colonPosition = colonIndex !== -1 ? new Position(position.lineNumber, colonIndex + 1) : undefined;
|
||||
|
||||
if (!colonPosition || position.isBeforeOrEqual(colonPosition)) {
|
||||
// Check if the position is inside a multi-line attribute (e.g., hooks map).
|
||||
// In that case, provide value completions for that attribute instead of attribute name completions.
|
||||
let containingAttribute = header.attributes.find(({ range }) =>
|
||||
range.startLineNumber < position.lineNumber && position.lineNumber <= range.endLineNumber);
|
||||
if (!containingAttribute) {
|
||||
// Handle trailing empty lines after a map-valued attribute:
|
||||
// The YAML parser's range ends at the last parsed child, but logically
|
||||
// an empty line before the next attribute still belongs to the map.
|
||||
for (let i = header.attributes.length - 1; i >= 0; i--) {
|
||||
const attr = header.attributes[i];
|
||||
if (attr.range.endLineNumber < position.lineNumber && attr.value.type === 'map') {
|
||||
const nextAttr = header.attributes[i + 1];
|
||||
const nextStartLine = nextAttr ? nextAttr.range.startLineNumber : headerRange.endLineNumber;
|
||||
if (position.lineNumber < nextStartLine) {
|
||||
containingAttribute = attr;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (containingAttribute) {
|
||||
const attrLineText = model.getLineContent(containingAttribute.range.startLineNumber);
|
||||
const attrColonIndex = attrLineText.indexOf(':');
|
||||
if (attrColonIndex !== -1) {
|
||||
return this.provideValueCompletions(model, position, header, new Position(containingAttribute.range.startLineNumber, attrColonIndex + 1), promptType, containingAttribute);
|
||||
}
|
||||
}
|
||||
return this.provideAttributeNameCompletions(model, position, header, colonPosition, promptType);
|
||||
} else if (colonPosition && colonPosition.isBefore(position)) {
|
||||
return this.provideValueCompletions(model, position, header, colonPosition, promptType);
|
||||
@@ -116,6 +145,11 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider {
|
||||
if (colonPosition) {
|
||||
return key;
|
||||
}
|
||||
// For map-valued attributes, insert a snippet with the nested structure
|
||||
if (key === PromptHeaderAttributes.hooks && promptType === PromptsType.agent && target !== Target.Claude) {
|
||||
const hookNames = Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]);
|
||||
return `${key}:\n \${1|${hookNames.join(',')}|}:\n - type: command\n command: "$2"`;
|
||||
}
|
||||
const valueSuggestions = this.getValueSuggestions(promptType, key, target);
|
||||
if (valueSuggestions.length > 0) {
|
||||
return `${key}: \${0:${valueSuggestions[0].name}}`;
|
||||
@@ -146,10 +180,11 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider {
|
||||
header: PromptHeader,
|
||||
colonPosition: Position,
|
||||
promptType: PromptsType,
|
||||
preFoundAttribute?: IHeaderAttribute,
|
||||
): Promise<CompletionList | undefined> {
|
||||
const suggestions: CompletionItem[] = [];
|
||||
const posLineNumber = position.lineNumber;
|
||||
const attribute = header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber);
|
||||
const attribute = preFoundAttribute ?? header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber);
|
||||
if (!attribute) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -200,6 +235,18 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (attribute.key === PromptHeaderAttributes.hooks) {
|
||||
if (attribute.value.type === 'map') {
|
||||
// Inside the hooks map — suggest hook event type names as sub-keys
|
||||
return this.provideHookEventCompletions(model, position, attribute.value, target);
|
||||
}
|
||||
// When hooks value is not yet a map (e.g., user is mid-edit on a nested line),
|
||||
// still provide hook event completions with no existing keys.
|
||||
if (position.lineNumber !== attribute.range.startLineNumber) {
|
||||
const emptyMap: IMapValue = { type: 'map', properties: [], range: attribute.value.range };
|
||||
return this.provideHookEventCompletions(model, position, emptyMap, target);
|
||||
}
|
||||
}
|
||||
const lineContent = model.getLineContent(attribute.range.startLineNumber);
|
||||
const whilespaceAfterColon = (lineContent.substring(colonPosition.column).match(/^\s*/)?.[0].length) ?? 0;
|
||||
const entries = this.getValueSuggestions(promptType, attribute.key, target);
|
||||
@@ -229,9 +276,290 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider {
|
||||
};
|
||||
suggestions.push(item);
|
||||
}
|
||||
if (attribute.key === PromptHeaderAttributes.hooks && promptType === PromptsType.agent) {
|
||||
const hookSnippet = [
|
||||
'',
|
||||
' ${1|' + Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]).join(',') + '|}:',
|
||||
' - type: command',
|
||||
' command: "$2"'
|
||||
].join('\n');
|
||||
const item: CompletionItem = {
|
||||
label: localize('promptHeaderAutocompletion.newHook', "New Hook"),
|
||||
kind: CompletionItemKind.Snippet,
|
||||
insertText: whilespaceAfterColon === 0 ? ` ${hookSnippet}` : hookSnippet,
|
||||
insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range: new Range(position.lineNumber, colonPosition.column + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)),
|
||||
};
|
||||
suggestions.push(item);
|
||||
}
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides completions inside the `hooks:` map.
|
||||
* Determines what to suggest based on nesting depth:
|
||||
* - At hook event level: suggest event names (SessionStart, PreToolUse, etc.)
|
||||
* - Inside a command object: suggest command fields (type, command, timeout, etc.)
|
||||
*/
|
||||
private provideHookEventCompletions(
|
||||
model: ITextModel,
|
||||
position: Position,
|
||||
hooksMap: IMapValue,
|
||||
target: Target,
|
||||
): CompletionList | undefined {
|
||||
// Check if the cursor is on the value side of an existing hook event key (e.g., "SessionEnd:|")
|
||||
// In that case, offer a command entry snippet instead of event name completions.
|
||||
const hookEventOnLine = hooksMap.properties.find(p => p.key.range.startLineNumber === position.lineNumber);
|
||||
if (hookEventOnLine) {
|
||||
const lineText = model.getLineContent(position.lineNumber);
|
||||
const colonIdx = lineText.indexOf(':');
|
||||
if (colonIdx !== -1 && position.column > colonIdx + 1) {
|
||||
const whilespaceAfterColon = (lineText.substring(colonIdx + 1).match(/^\s*/)?.[0].length) ?? 0;
|
||||
const commandSnippet = [
|
||||
'',
|
||||
' - type: command',
|
||||
' command: "$1"',
|
||||
].join('\n');
|
||||
return {
|
||||
suggestions: [{
|
||||
label: localize('promptHeaderAutocompletion.newCommand', "New Command"),
|
||||
documentation: localize('promptHeaderAutocompletion.newCommand.description', "Add a new command entry to this hook."),
|
||||
kind: CompletionItemKind.Snippet,
|
||||
insertText: whilespaceAfterColon === 0 ? ` ${commandSnippet}` : commandSnippet,
|
||||
insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range: new Range(position.lineNumber, colonIdx + 1 + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)),
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to provide command field completions if cursor is inside a command object
|
||||
const commandFieldCompletions = this.provideHookCommandFieldCompletions(model, position, hooksMap, target);
|
||||
if (commandFieldCompletions) {
|
||||
return commandFieldCompletions;
|
||||
}
|
||||
|
||||
// Otherwise provide hook event name completions
|
||||
const suggestions: CompletionItem[] = [];
|
||||
const hooksByTarget = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined];
|
||||
|
||||
const lineText = model.getLineContent(position.lineNumber);
|
||||
const firstNonWhitespace = lineText.search(/\S/);
|
||||
const isEmptyLine = firstNonWhitespace === -1;
|
||||
// Start the range after leading whitespace so VS Code's completion
|
||||
// filtering matches the hook name prefix the user has typed.
|
||||
const rangeStartColumn = isEmptyLine ? position.column : firstNonWhitespace + 1;
|
||||
|
||||
// Exclude hook keys on the current line so the user sees all options while editing a key
|
||||
const existingKeys = new Set(
|
||||
hooksMap.properties
|
||||
.filter(p => p.key.range.startLineNumber !== position.lineNumber)
|
||||
.map(p => p.key.value)
|
||||
);
|
||||
|
||||
// Supplement with text-based scanning: when incomplete YAML causes the
|
||||
// parser to drop subsequent keys, scan the model for lines that look
|
||||
// like hook event entries (e.g., " UserPromptSubmit:") at the expected
|
||||
// indentation.
|
||||
const expectedIndent = hooksMap.properties.length > 0
|
||||
? hooksMap.properties[0].key.range.startColumn - 1
|
||||
: -1;
|
||||
if (expectedIndent >= 0) {
|
||||
const scanEnd = model.getLineCount();
|
||||
for (let lineNum = hooksMap.range.endLineNumber + 1; lineNum <= scanEnd; lineNum++) {
|
||||
if (lineNum === position.lineNumber) {
|
||||
continue;
|
||||
}
|
||||
const lt = model.getLineContent(lineNum);
|
||||
const lineIndent = lt.search(/\S/);
|
||||
if (lineIndent === -1) {
|
||||
continue;
|
||||
}
|
||||
if (lineIndent < expectedIndent) {
|
||||
break; // Left the hooks map scope
|
||||
}
|
||||
if (lineIndent === expectedIndent) {
|
||||
const match = lt.match(/^\s+(\S+)\s*:/);
|
||||
if (match) {
|
||||
existingKeys.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether the current line already has a colon (editing an existing key)
|
||||
const lineHasColon = lineText.indexOf(':') !== -1;
|
||||
|
||||
for (const [hookName, hookType] of Object.entries(hooksByTarget)) {
|
||||
if (existingKeys.has(hookName)) {
|
||||
continue;
|
||||
}
|
||||
const meta = HOOK_METADATA[hookType];
|
||||
let insertText: string;
|
||||
if (isEmptyLine) {
|
||||
// On empty lines, insert a full hook snippet with command placeholder
|
||||
insertText = [
|
||||
`${hookName}:`,
|
||||
` - type: command`,
|
||||
` command: "$1"`,
|
||||
].join('\n');
|
||||
} else if (lineHasColon) {
|
||||
// On existing key lines, only replace the key name to preserve nested content
|
||||
insertText = `${hookName}:`;
|
||||
} else {
|
||||
// Typing a new event name — omit the colon so the user can
|
||||
// trigger the next completion (e.g., New Command snippet) by typing ':'
|
||||
insertText = hookName;
|
||||
}
|
||||
suggestions.push({
|
||||
label: hookName,
|
||||
documentation: meta?.description,
|
||||
kind: CompletionItemKind.Property,
|
||||
insertText,
|
||||
insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range: new Range(position.lineNumber, rangeStartColumn, position.lineNumber, model.getLineMaxColumn(position.lineNumber)),
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides completions for hook command fields (type, command, windows, etc.)
|
||||
* when the cursor is inside a command object within the hooks map.
|
||||
* Detects nesting by checking if the position falls within a sequence item
|
||||
* of a hook event's value.
|
||||
*/
|
||||
private provideHookCommandFieldCompletions(
|
||||
model: ITextModel,
|
||||
position: Position,
|
||||
hooksMap: IMapValue,
|
||||
target: Target,
|
||||
): CompletionList | undefined {
|
||||
// Find which hook event's command list the cursor is in
|
||||
const containingCommandMap = this.findContainingCommandMap(model, position, hooksMap);
|
||||
if (!containingCommandMap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isCopilotCli = target === Target.GitHubCopilot;
|
||||
const validFields = isCopilotCli
|
||||
? ['type', 'bash', 'powershell', 'cwd', 'env', 'timeoutSec']
|
||||
: ['type', 'command', 'windows', 'linux', 'osx', 'bash', 'powershell', 'cwd', 'env', 'timeout'];
|
||||
|
||||
const existingFields = new Set(
|
||||
containingCommandMap.properties
|
||||
.filter(p => p.key.range.startLineNumber !== position.lineNumber)
|
||||
.map(p => p.key.value)
|
||||
);
|
||||
|
||||
const lineText = model.getLineContent(position.lineNumber);
|
||||
const firstNonWhitespace = lineText.search(/\S/);
|
||||
const isEmptyLine = firstNonWhitespace === -1;
|
||||
// Skip past the YAML sequence indicator `- ` so the range starts at the
|
||||
// actual field name; otherwise VS Code's completion filter would see the
|
||||
// `- ` prefix and reject valid field names.
|
||||
const dashPrefixMatch = lineText.match(/^(\s*-\s+)/);
|
||||
const fieldStart = dashPrefixMatch ? dashPrefixMatch[1].length : firstNonWhitespace;
|
||||
const rangeStartColumn = isEmptyLine ? position.column : fieldStart + 1;
|
||||
const colonIndex = lineText.indexOf(':');
|
||||
|
||||
const suggestions: CompletionItem[] = [];
|
||||
for (const fieldName of validFields) {
|
||||
if (existingFields.has(fieldName)) {
|
||||
continue;
|
||||
}
|
||||
const desc = HOOK_COMMAND_FIELD_DESCRIPTIONS[fieldName];
|
||||
const insertText = colonIndex !== -1 ? fieldName : `${fieldName}: $0`;
|
||||
suggestions.push({
|
||||
label: fieldName,
|
||||
documentation: desc,
|
||||
kind: CompletionItemKind.Property,
|
||||
insertText,
|
||||
insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range: new Range(position.lineNumber, rangeStartColumn, position.lineNumber, colonIndex !== -1 ? colonIndex + 1 : model.getLineMaxColumn(position.lineNumber)),
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.length > 0 ? { suggestions } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the hooks map AST to find the command map object containing the position.
|
||||
* Handles both direct command objects and nested matcher format.
|
||||
* Also handles trailing lines after the last parsed property of a command map.
|
||||
*/
|
||||
private findContainingCommandMap(model: ITextModel, position: Position, hooksMap: IMapValue): IMapValue | undefined {
|
||||
for (let i = 0; i < hooksMap.properties.length; i++) {
|
||||
const prop = hooksMap.properties[i];
|
||||
if (prop.value.type !== 'sequence') {
|
||||
continue;
|
||||
}
|
||||
// Check if cursor is within the sequence's range, or on a trailing line after it
|
||||
const seqRange = prop.value.range;
|
||||
const nextProp = hooksMap.properties[i + 1];
|
||||
const isInSeq = seqRange.containsPosition(position);
|
||||
const isTrailingSeq = !isInSeq
|
||||
&& seqRange.endLineNumber < position.lineNumber
|
||||
&& (!nextProp || nextProp.key.range.startLineNumber > position.lineNumber);
|
||||
|
||||
if (isInSeq || isTrailingSeq) {
|
||||
// For trailing lines, verify the cursor is indented deeper than
|
||||
// the hook event key — otherwise it belongs to the parent map.
|
||||
if (isTrailingSeq) {
|
||||
const lineText = model.getLineContent(position.lineNumber);
|
||||
const firstNonWs = lineText.search(/\S/);
|
||||
const effectiveIndent = firstNonWs === -1 ? position.column - 1 : firstNonWs;
|
||||
const hookKeyIndent = prop.key.range.startColumn - 1;
|
||||
if (effectiveIndent <= hookKeyIndent) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const result = this.findCommandMapInSequence(position, prop.value);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private findCommandMapInSequence(position: Position, sequence: ISequenceValue): IMapValue | undefined {
|
||||
for (let i = 0; i < sequence.items.length; i++) {
|
||||
const item = sequence.items[i];
|
||||
if (item.type !== 'map') {
|
||||
// Handle partial typing: a scalar on the cursor line means the user
|
||||
// is starting to type a command entry (e.g., "- t").
|
||||
if (item.type === 'scalar' && item.range.startLineNumber === position.lineNumber) {
|
||||
return { type: 'map', properties: [], range: item.range };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if position is within or just after this map item's parsed range.
|
||||
// The parser's range may not include a trailing line being typed.
|
||||
const isInRange = item.range.containsPosition(position);
|
||||
const isTrailing = !isInRange
|
||||
&& item.range.endLineNumber < position.lineNumber
|
||||
&& (i + 1 >= sequence.items.length || sequence.items[i + 1].range.startLineNumber > position.lineNumber);
|
||||
|
||||
if (!isInRange && !isTrailing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for nested matcher format: { hooks: [...] }
|
||||
const nestedHooks = item.properties.find(p => p.key.value === 'hooks');
|
||||
if (nestedHooks?.value.type === 'sequence') {
|
||||
const result = this.findCommandMapInSequence(position, nestedHooks.value);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): readonly IValueEntry[] {
|
||||
const attributeDesc = getAttributeDefinition(attribute, promptType, target);
|
||||
if (attributeDesc?.enums) {
|
||||
|
||||
@@ -15,8 +15,10 @@ import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/lan
|
||||
import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js';
|
||||
import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js';
|
||||
import { IPromptsService } from '../service/promptsService.js';
|
||||
import { IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js';
|
||||
import { IHeaderAttribute, ISequenceValue, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js';
|
||||
import { ClaudeHeaderAttributes, getAttributeDefinition, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptFileAttributes.js';
|
||||
import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js';
|
||||
import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js';
|
||||
|
||||
export class PromptHoverProvider implements HoverProvider {
|
||||
/**
|
||||
@@ -86,6 +88,8 @@ export class PromptHoverProvider implements HoverProvider {
|
||||
return this.getAgentHover(attribute, position, description);
|
||||
case PromptHeaderAttributes.handOffs:
|
||||
return this.getHandsOffHover(attribute, position, target);
|
||||
case PromptHeaderAttributes.hooks:
|
||||
return this.getHooksHover(attribute, position, description, target);
|
||||
case PromptHeaderAttributes.infer:
|
||||
return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invocable` and `disable-model-invocation` instead.'), attribute.range);
|
||||
default:
|
||||
@@ -232,6 +236,62 @@ export class PromptHoverProvider implements HoverProvider {
|
||||
return this.createHover(lines.join('\n'), agentAttribute.range);
|
||||
}
|
||||
|
||||
private getHooksHover(attribute: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined {
|
||||
const value = attribute.value;
|
||||
if (value.type === 'map') {
|
||||
const hooksByTarget = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined];
|
||||
for (const prop of value.properties) {
|
||||
// Hover on a hook event name key (e.g., SessionStart, PreToolUse)
|
||||
if (prop.key.range.containsPosition(position)) {
|
||||
const hookType = hooksByTarget[prop.key.value];
|
||||
if (hookType) {
|
||||
const meta = HOOK_METADATA[hookType];
|
||||
return this.createHover(`**${meta.label}**\n\n${meta.description}`, prop.key.range);
|
||||
}
|
||||
}
|
||||
// Hover inside hook command entries
|
||||
if (prop.value.type === 'sequence') {
|
||||
const hover = this.getHookCommandItemHover(prop.value, position);
|
||||
if (hover) {
|
||||
return hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.createHover(baseMessage, attribute.range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively searches hook command items for hover information.
|
||||
* Handles both direct command objects and nested matcher format
|
||||
* (e.g., `{ matcher: "...", hooks: [{ type: command, ... }] }`).
|
||||
*/
|
||||
private getHookCommandItemHover(sequence: ISequenceValue, position: Position): Hover | undefined {
|
||||
for (const item of sequence.items) {
|
||||
if (item.type !== 'map' || !item.range.containsPosition(position)) {
|
||||
continue;
|
||||
}
|
||||
// Check for nested matcher format: { hooks: [...] }
|
||||
const nestedHooks = item.properties.find(p => p.key.value === 'hooks');
|
||||
if (nestedHooks && nestedHooks.value.type === 'sequence') {
|
||||
const hover = this.getHookCommandItemHover(nestedHooks.value, position);
|
||||
if (hover) {
|
||||
return hover;
|
||||
}
|
||||
}
|
||||
// Check fields of the command object itself
|
||||
for (const field of item.properties) {
|
||||
if (field.key.range.containsPosition(position) || field.value.range.containsPosition(position)) {
|
||||
const desc = HOOK_COMMAND_FIELD_DESCRIPTIONS[field.key.value];
|
||||
if (desc) {
|
||||
return this.createHover(desc, field.key.range);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getHandsOffHover(attribute: IHeaderAttribute, position: Position, target: Target): Hover | undefined {
|
||||
const handoffsBaseMessage = getAttributeDefinition(PromptHeaderAttributes.handOffs, PromptsType.agent, target)?.description!;
|
||||
if (!isVSCodeOrDefaultTarget(target)) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Lazy } from '../../../../../../base/common/lazy.js';
|
||||
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
|
||||
import { dirname } from '../../../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { HOOKS_BY_TARGET } from '../hookTypes.js';
|
||||
import { GithubPromptHeaderAttributes } from './promptFileAttributes.js';
|
||||
|
||||
export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider';
|
||||
@@ -191,6 +192,7 @@ export class PromptValidator {
|
||||
this.validateUserInvokable(attributes, report);
|
||||
this.validateDisableModelInvocation(attributes, report);
|
||||
this.validateTools(attributes, ChatModeKind.Agent, target, report);
|
||||
this.validateHooks(attributes, target, report);
|
||||
if (isVSCodeOrDefaultTarget(target)) {
|
||||
this.validateModel(attributes, ChatModeKind.Agent, report);
|
||||
this.validateHandoffs(attributes, report);
|
||||
@@ -545,6 +547,119 @@ export class PromptValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private validateHooks(attributes: IHeaderAttribute[], target: Target, report: (markers: IMarkerData) => void): undefined {
|
||||
const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.hooks);
|
||||
if (!attribute) {
|
||||
return;
|
||||
}
|
||||
if (attribute.value.type !== 'map') {
|
||||
report(toMarker(localize('promptValidator.hooksMustBeMap', "The 'hooks' attribute must be a map of hook event types to command arrays."), attribute.value.range, MarkerSeverity.Error));
|
||||
return;
|
||||
}
|
||||
const validHookNames = new Set(Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]));
|
||||
for (const prop of attribute.value.properties) {
|
||||
if (!validHookNames.has(prop.key.value)) {
|
||||
report(toMarker(localize('promptValidator.unknownHookType', "Unknown hook event type '{0}'. Supported: {1}.", prop.key.value, Array.from(validHookNames).join(', ')), prop.key.range, MarkerSeverity.Warning));
|
||||
}
|
||||
if (prop.value.type !== 'sequence') {
|
||||
report(toMarker(localize('promptValidator.hookValueMustBeArray', "Hook event '{0}' must have an array of command objects as its value.", prop.key.value), prop.value.range, MarkerSeverity.Error));
|
||||
continue;
|
||||
}
|
||||
for (const item of prop.value.items) {
|
||||
this.validateHookCommand(item, target, report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateHookCommand(item: IValue, target: Target, report: (markers: IMarkerData) => void): void {
|
||||
if (item.type !== 'map') {
|
||||
report(toMarker(localize('promptValidator.hookCommandMustBeObject', "Each hook command must be an object."), item.range, MarkerSeverity.Error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect nested matcher format: { matcher?: "...", hooks: [{ type: 'command', command: '...' }] }
|
||||
const hooksProperty = item.properties.find(p => p.key.value === 'hooks');
|
||||
if (hooksProperty) {
|
||||
// Validate that only known matcher properties are present
|
||||
for (const prop of item.properties) {
|
||||
if (prop.key.value !== 'hooks' && prop.key.value !== 'matcher') {
|
||||
report(toMarker(localize('promptValidator.unknownMatcherProperty', "Unknown property '{0}' in hook matcher.", prop.key.value), prop.key.range, MarkerSeverity.Warning));
|
||||
}
|
||||
}
|
||||
if (hooksProperty.value.type !== 'sequence') {
|
||||
report(toMarker(localize('promptValidator.nestedHooksMustBeArray', "The 'hooks' property in a matcher must be an array of command objects."), hooksProperty.value.range, MarkerSeverity.Error));
|
||||
return;
|
||||
}
|
||||
for (const nestedItem of hooksProperty.value.items) {
|
||||
this.validateHookCommand(nestedItem, target, report);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isCopilotCli = target === Target.GitHubCopilot;
|
||||
|
||||
// Determine valid and command-providing properties based on target
|
||||
const validCommandFields = isCopilotCli
|
||||
? new Set(['bash', 'powershell'])
|
||||
: new Set(['command', 'windows', 'linux', 'osx', 'bash', 'powershell']);
|
||||
|
||||
const validProperties = isCopilotCli
|
||||
? new Set(['type', 'bash', 'powershell', 'cwd', 'env', 'timeoutSec'])
|
||||
: new Set(['type', 'command', 'windows', 'linux', 'osx', 'bash', 'powershell', 'cwd', 'env', 'timeout']);
|
||||
|
||||
let hasType = false;
|
||||
let hasCommandField = false;
|
||||
|
||||
for (const prop of item.properties) {
|
||||
const key = prop.key.value;
|
||||
|
||||
if (!validProperties.has(key)) {
|
||||
report(toMarker(localize('promptValidator.unknownHookProperty', "Unknown property '{0}' in hook command.", key), prop.key.range, MarkerSeverity.Warning));
|
||||
}
|
||||
|
||||
if (key === 'type') {
|
||||
hasType = true;
|
||||
if (prop.value.type !== 'scalar' || prop.value.value !== 'command') {
|
||||
report(toMarker(localize('promptValidator.hookTypeMustBeCommand', "The 'type' property in a hook command must be 'command'."), prop.value.range, MarkerSeverity.Error));
|
||||
}
|
||||
} else if (validCommandFields.has(key)) {
|
||||
hasCommandField = true;
|
||||
if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) {
|
||||
report(toMarker(localize('promptValidator.hookCommandFieldMustBeNonEmptyString', "The '{0}' property in a hook command must be a non-empty string.", key), prop.value.range, MarkerSeverity.Error));
|
||||
}
|
||||
} else if (key === 'cwd') {
|
||||
if (prop.value.type !== 'scalar') {
|
||||
report(toMarker(localize('promptValidator.hookCwdMustBeString', "The 'cwd' property in a hook command must be a string."), prop.value.range, MarkerSeverity.Error));
|
||||
}
|
||||
} else if (key === 'env') {
|
||||
if (prop.value.type !== 'map') {
|
||||
report(toMarker(localize('promptValidator.hookEnvMustBeMap', "The 'env' property in a hook command must be a map of string values."), prop.value.range, MarkerSeverity.Error));
|
||||
} else {
|
||||
for (const envProp of prop.value.properties) {
|
||||
if (envProp.value.type !== 'scalar') {
|
||||
report(toMarker(localize('promptValidator.hookEnvValueMustBeString', "Environment variable '{0}' must have a string value.", envProp.key.value), envProp.value.range, MarkerSeverity.Error));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key === 'timeout' || key === 'timeoutSec') {
|
||||
if (prop.value.type !== 'scalar' || isNaN(Number(prop.value.value))) {
|
||||
report(toMarker(localize('promptValidator.hookTimeoutMustBeNumber', "The '{0}' property in a hook command must be a number.", key), prop.value.range, MarkerSeverity.Error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasType) {
|
||||
report(toMarker(localize('promptValidator.hookMissingType', "Hook command is missing required property 'type'."), item.range, MarkerSeverity.Error));
|
||||
}
|
||||
if (!hasCommandField) {
|
||||
if (isCopilotCli) {
|
||||
report(toMarker(localize('promptValidator.hookMissingCopilotCommand', "Hook command must specify at least one of 'bash' or 'powershell'."), item.range, MarkerSeverity.Error));
|
||||
} else {
|
||||
report(toMarker(localize('promptValidator.hookMissingCommand', "Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'."), item.range, MarkerSeverity.Error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateHandoffs(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined {
|
||||
const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.handOffs);
|
||||
if (!attribute) {
|
||||
@@ -761,7 +876,7 @@ function isTrueOrFalse(value: IValue): boolean {
|
||||
const allAttributeNames: Record<PromptsType, string[]> = {
|
||||
[PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint],
|
||||
[PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent],
|
||||
[PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation, GithubPromptHeaderAttributes.github],
|
||||
[PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.hooks, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation, GithubPromptHeaderAttributes.github],
|
||||
[PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation],
|
||||
[PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter
|
||||
};
|
||||
@@ -846,6 +961,8 @@ export function getAttributeDescription(attributeName: string, promptType: Promp
|
||||
return localize('promptHeader.agent.infer', 'Controls visibility of the agent.');
|
||||
case PromptHeaderAttributes.agents:
|
||||
return localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.');
|
||||
case PromptHeaderAttributes.hooks:
|
||||
return localize('promptHeader.agent.hooks', 'Lifecycle hooks scoped to this agent. Define hooks that run only while this agent is active.');
|
||||
case PromptHeaderAttributes.userInvocable:
|
||||
return localize('promptHeader.agent.userInvocable', 'Whether the agent can be selected and invoked by users in the UI.');
|
||||
case PromptHeaderAttributes.disableModelInvocation:
|
||||
|
||||
@@ -84,6 +84,7 @@ export namespace PromptHeaderAttributes {
|
||||
export const userInvokable = 'user-invokable';
|
||||
export const userInvocable = 'user-invocable';
|
||||
export const disableModelInvocation = 'disable-model-invocation';
|
||||
export const hooks = 'hooks';
|
||||
}
|
||||
|
||||
export class PromptHeader {
|
||||
@@ -317,6 +318,20 @@ export class PromptHeader {
|
||||
return this.getBooleanAttribute(PromptHeaderAttributes.disableModelInvocation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw 'hooks' attribute value from the header.
|
||||
* Returns the YAML map value if present, or undefined. The caller is
|
||||
* responsible for converting this to `ChatRequestHooks` via
|
||||
* {@link parseSubagentHooksFromYaml}.
|
||||
*/
|
||||
public get hooksRaw(): IMapValue | undefined {
|
||||
const attr = this._parsedHeader.attributes.find(a => a.key === PromptHeaderAttributes.hooks);
|
||||
if (attr?.value.type === 'map') {
|
||||
return attr.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getBooleanAttribute(key: string): boolean | undefined {
|
||||
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
|
||||
if (attribute?.value.type === 'scalar') {
|
||||
|
||||
@@ -225,6 +225,11 @@ export interface ICustomAgent {
|
||||
*/
|
||||
readonly agents?: readonly string[];
|
||||
|
||||
/**
|
||||
* Lifecycle hooks scoped to this subagent.
|
||||
*/
|
||||
readonly hooks?: ChatRequestHooks;
|
||||
|
||||
/**
|
||||
* Where the agent was loaded from.
|
||||
*/
|
||||
|
||||
@@ -36,7 +36,7 @@ import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../p
|
||||
import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, IPromptDiscoveryLogEntry } from './promptsService.js';
|
||||
import { Delayer } from '../../../../../../base/common/async.js';
|
||||
import { Schemas } from '../../../../../../base/common/network.js';
|
||||
import { ChatRequestHooks, IHookCommand } from '../hookSchema.js';
|
||||
import { ChatRequestHooks, IHookCommand, parseSubagentHooksFromYaml } from '../hookSchema.js';
|
||||
import { HookType } from '../hookTypes.js';
|
||||
import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js';
|
||||
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
|
||||
@@ -678,6 +678,12 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
let agentFiles = await this.listPromptFiles(PromptsType.agent, token);
|
||||
const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent);
|
||||
agentFiles = agentFiles.filter(promptPath => !disabledAgents.has(promptPath.uri));
|
||||
|
||||
// Get user home for tilde expansion in hook cwd paths
|
||||
const userHomeUri = await this.pathService.userHome();
|
||||
const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path;
|
||||
const defaultFolder = this.workspaceService.getWorkspace().folders[0];
|
||||
|
||||
const customAgentsResults = await Promise.allSettled(
|
||||
agentFiles.map(async (promptPath): Promise<ICustomAgent> => {
|
||||
const uri = promptPath.uri;
|
||||
@@ -733,7 +739,17 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
if (target === Target.Claude && tools) {
|
||||
tools = mapClaudeTools(tools);
|
||||
}
|
||||
return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, agentInstructions, source };
|
||||
|
||||
// Parse hooks from the frontmatter if present
|
||||
let hooks: ChatRequestHooks | undefined;
|
||||
const hooksRaw = ast.header.hooksRaw;
|
||||
if (hooksRaw) {
|
||||
const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? defaultFolder;
|
||||
const workspaceRootUri = hookWorkspaceFolder?.uri;
|
||||
hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target);
|
||||
}
|
||||
|
||||
return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source };
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ import { ILanguageModelsService } from '../../languageModels.js';
|
||||
import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js';
|
||||
import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js';
|
||||
import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js';
|
||||
import { ChatRequestHooks } from '../../promptSyntax/hookSchema.js';
|
||||
import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js';
|
||||
import { HookType } from '../../promptSyntax/hookTypes.js';
|
||||
import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js';
|
||||
import { isBuiltinAgent } from '../../promptSyntax/utils/promptsServiceUtils.js';
|
||||
import {
|
||||
@@ -260,6 +261,20 @@ export class RunSubagentTool extends Disposable implements IToolImpl {
|
||||
this.logService.warn('[ChatService] Failed to collect hooks:', error);
|
||||
}
|
||||
|
||||
// Merge subagent-level hooks (from the agent's frontmatter) with global hooks.
|
||||
// Remap Stop hooks to SubagentStop since the agent is running as a subagent.
|
||||
if (subagent?.hooks) {
|
||||
const remapped: ChatRequestHooks = { ...subagent.hooks };
|
||||
if (remapped[HookType.Stop]) {
|
||||
const stopHooks = remapped[HookType.Stop];
|
||||
(remapped as Record<string, unknown>)[HookType.SubagentStop] = remapped[HookType.SubagentStop]
|
||||
? [...remapped[HookType.SubagentStop], ...stopHooks]
|
||||
: stopHooks;
|
||||
(remapped as Record<string, unknown>)[HookType.Stop] = undefined;
|
||||
}
|
||||
collectedHooks = mergeHooks(collectedHooks, remapped);
|
||||
}
|
||||
|
||||
// Build the agent request
|
||||
const agentRequest: IChatAgentRequest = {
|
||||
sessionResource: invocation.context.sessionResource,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import assert from 'assert';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js';
|
||||
import { findHookCommandInYaml, findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js';
|
||||
import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js';
|
||||
import { buildNewHookEntry, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js';
|
||||
|
||||
@@ -722,4 +722,232 @@ suite('hookUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('findHookCommandInYaml', () => {
|
||||
|
||||
test('finds unquoted command value', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - command: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'echo hello');
|
||||
assert.deepStrictEqual(result, {
|
||||
startLineNumber: 4,
|
||||
startColumn: 16,
|
||||
endLineNumber: 4,
|
||||
endColumn: 26
|
||||
});
|
||||
});
|
||||
|
||||
test('finds double-quoted command value', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - command: "echo hello"',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'echo hello');
|
||||
});
|
||||
|
||||
test('finds single-quoted command value', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
` - command: 'echo hello'`,
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'echo hello');
|
||||
});
|
||||
|
||||
test('finds command without list prefix', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' command: run-lint',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'run-lint');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'run-lint');
|
||||
});
|
||||
|
||||
test('does not match substring of a longer command', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - command: echo hello-world',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns undefined when command is not found', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - command: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo goodbye');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns undefined when no command lines exist', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: my-agent',
|
||||
'description: An agent',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for empty content', () => {
|
||||
const result = findHookCommandInYaml('', 'echo hello');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('finds first matching command when multiple exist', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - command: echo hello',
|
||||
' userPromptSubmit:',
|
||||
' - command: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.startLineNumber, 4);
|
||||
});
|
||||
|
||||
test('ignores lines that are not command fields', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: run command echo hello',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - command: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.startLineNumber, 5);
|
||||
});
|
||||
|
||||
test('handles command with special characters', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' preToolUse:',
|
||||
' - command: echo "foo" > /tmp/out.txt',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo "foo" > /tmp/out.txt');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'echo "foo" > /tmp/out.txt');
|
||||
});
|
||||
|
||||
test('matches command followed by trailing whitespace', () => {
|
||||
const content = [
|
||||
'---',
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - command: echo hello ',
|
||||
'---',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'echo hello');
|
||||
});
|
||||
|
||||
test('finds short command that is a substring of the key name', () => {
|
||||
const content = [
|
||||
'hooks:',
|
||||
' Stop:',
|
||||
' - timeout: 10',
|
||||
' command: "a"',
|
||||
' type: command',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'a');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'a');
|
||||
assert.strictEqual(result.startLineNumber, 4);
|
||||
});
|
||||
|
||||
test('finds short command in bash field that is a substring of the key name', () => {
|
||||
const content = [
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - bash: "a"',
|
||||
' type: command',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'a');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'a');
|
||||
assert.strictEqual(result.startLineNumber, 3);
|
||||
});
|
||||
|
||||
test('finds command in powershell field', () => {
|
||||
const content = [
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - powershell: "echo hello"',
|
||||
' type: command',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'echo hello');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'echo hello');
|
||||
assert.strictEqual(result.startLineNumber, 3);
|
||||
});
|
||||
|
||||
test('finds command in windows field', () => {
|
||||
const content = [
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - windows: "dir"',
|
||||
' type: command',
|
||||
].join('\n');
|
||||
const result = findHookCommandInYaml(content, 'dir');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(getSelectedText(content, result), 'dir');
|
||||
assert.strictEqual(result.startLineNumber, 3);
|
||||
});
|
||||
|
||||
test('finds command in linux and osx fields', () => {
|
||||
const content = [
|
||||
'hooks:',
|
||||
' sessionStart:',
|
||||
' - linux: "ls"',
|
||||
' osx: "ls -G"',
|
||||
' type: command',
|
||||
].join('\n');
|
||||
const linuxResult = findHookCommandInYaml(content, 'ls');
|
||||
assert.ok(linuxResult);
|
||||
assert.strictEqual(getSelectedText(content, linuxResult), 'ls');
|
||||
assert.strictEqual(linuxResult.startLineNumber, 3);
|
||||
|
||||
const osxResult = findHookCommandInYaml(content, 'ls -G');
|
||||
assert.ok(osxResult);
|
||||
assert.strictEqual(getSelectedText(content, osxResult), 'ls -G');
|
||||
assert.strictEqual(osxResult.startLineNumber, 4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,6 +143,7 @@ suite('PromptHeaderAutocompletion', () => {
|
||||
{ label: 'disable-model-invocation', result: 'disable-model-invocation: ${0:true}' },
|
||||
{ label: 'github', result: 'github: $0' },
|
||||
{ label: 'handoffs', result: 'handoffs: $0' },
|
||||
{ label: 'hooks', result: 'hooks:\n ${1|SessionStart,SessionEnd,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop,ErrorOccurred|}:\n - type: command\n command: "$2"' },
|
||||
{ label: 'model', result: 'model: ${0:MAE 4 (olama)}' },
|
||||
{ label: 'name', result: 'name: $0' },
|
||||
{ label: 'target', result: 'target: ${0:vscode}' },
|
||||
@@ -390,6 +391,249 @@ suite('PromptHeaderAutocompletion', () => {
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(!labels.includes('BG Agent Model (copilot)'), 'Models with targetChatSessionType should be excluded from agent model array completions');
|
||||
});
|
||||
|
||||
test('complete hooks value with New Hook snippet', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks: |',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(actual, [
|
||||
{
|
||||
label: 'New Hook',
|
||||
result: 'hooks: \n ${1|SessionStart,SessionEnd,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop,ErrorOccurred|}:\n - type: command\n command: "$2"'
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('complete hooks value with New Hook snippet for vscode target', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'target: vscode',
|
||||
'hooks: |',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(actual, [
|
||||
{
|
||||
label: 'New Hook',
|
||||
result: 'hooks: \n ${1|SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop|}:\n - type: command\n command: "$2"'
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('complete hook event names inside hooks map', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: "echo hi"',
|
||||
' |',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label).sort();
|
||||
// SessionStart should be excluded since it already exists
|
||||
assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present');
|
||||
assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested');
|
||||
assert.ok(labels.includes('PreToolUse'), 'PreToolUse should be suggested');
|
||||
assert.ok(labels.includes('Stop'), 'Stop should be suggested');
|
||||
});
|
||||
|
||||
test('complete hook event names for vscode target excludes existing hooks', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'target: vscode',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: "echo hi"',
|
||||
' PreToolUse:',
|
||||
' - type: command',
|
||||
' command: "lint"',
|
||||
' |',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label).sort();
|
||||
assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present');
|
||||
assert.ok(!labels.includes('PreToolUse'), 'PreToolUse should not be suggested when already present');
|
||||
assert.ok(labels.includes('UserPromptSubmit'), 'UserPromptSubmit should be suggested');
|
||||
assert.ok(labels.includes('PostToolUse'), 'PostToolUse should be suggested');
|
||||
// SessionEnd is not available for vscode target
|
||||
assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be available for vscode target');
|
||||
});
|
||||
|
||||
test('complete hook event names on empty line before existing hooks', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' |',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: "echo hi"',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label).sort();
|
||||
assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present');
|
||||
assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested');
|
||||
assert.ok(labels.includes('PreToolUse'), 'PreToolUse should be suggested');
|
||||
});
|
||||
|
||||
test('complete hook event names while editing existing key name', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' S|:',
|
||||
' - type: command',
|
||||
' command: "echo hi"',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label).sort();
|
||||
assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested');
|
||||
assert.ok(labels.includes('SubagentStart'), 'SubagentStart should be suggested');
|
||||
assert.ok(labels.includes('Stop'), 'Stop should be suggested');
|
||||
// Verify insertText only replaces the key (no full snippet)
|
||||
const sessionStartItem = actual.find(a => a.label === 'SessionStart');
|
||||
assert.ok(sessionStartItem);
|
||||
assert.strictEqual(sessionStartItem.result, ' SessionStart:');
|
||||
});
|
||||
|
||||
test('hooks: cursor right after colon triggers New Hook snippet', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks: |',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(labels.includes('New Hook'), 'New Hook snippet should be suggested');
|
||||
});
|
||||
|
||||
test('hooks: typing event name on next line triggers hook events', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' S|',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested');
|
||||
assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested');
|
||||
assert.ok(labels.includes('Stop'), 'Stop should be suggested');
|
||||
});
|
||||
|
||||
test('typing field name in first command entry triggers command fields', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionEnd:',
|
||||
' - t|',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(labels.includes('type'), 'type should be suggested');
|
||||
assert.ok(labels.includes('command'), 'command should be suggested');
|
||||
assert.ok(labels.includes('timeout'), 'timeout should be suggested');
|
||||
});
|
||||
|
||||
test('typing field name after existing field triggers remaining command fields', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionEnd:',
|
||||
' - type: command',
|
||||
' c|',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(labels.includes('command'), 'command should be suggested');
|
||||
assert.ok(labels.includes('cwd'), 'cwd should be suggested');
|
||||
assert.ok(!labels.includes('type'), 'type should not be suggested when already present');
|
||||
});
|
||||
|
||||
test('typing event name after existing hook triggers hook events', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionEnd:',
|
||||
' - type: command',
|
||||
' command: echo "Session ended."',
|
||||
' U|',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(labels.includes('UserPromptSubmit'), 'UserPromptSubmit should be suggested');
|
||||
assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be suggested when already present');
|
||||
});
|
||||
|
||||
test('typing event name between existing hooks triggers hook events', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionEnd:',
|
||||
' - type: command',
|
||||
' command: echo "Session ended."',
|
||||
' S|',
|
||||
' UserPromptSubmit:',
|
||||
' - type: command',
|
||||
' command: echo "User submitted."',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested');
|
||||
assert.ok(labels.includes('Stop'), 'Stop should be suggested');
|
||||
assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be suggested when already present');
|
||||
assert.ok(!labels.includes('UserPromptSubmit'), 'UserPromptSubmit should not be suggested when already present');
|
||||
});
|
||||
|
||||
test('cursor after hook event colon triggers New Command snippet', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionEnd: |',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
const actual = await getCompletions(content, PromptsType.agent);
|
||||
const labels = actual.map(a => a.label);
|
||||
assert.ok(labels.includes('New Command'), 'New Command snippet should be suggested');
|
||||
assert.strictEqual(actual.length, 1, 'Only one suggestion should be returned');
|
||||
});
|
||||
});
|
||||
|
||||
suite('claude agent header completions', () => {
|
||||
|
||||
@@ -551,7 +551,7 @@ suite('PromptValidator', () => {
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, github, handoffs, model, name, target, tools, user-invocable.` },
|
||||
{ severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, github, handoffs, hooks, model, name, target, tools, user-invocable.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -1416,6 +1416,358 @@ suite('PromptValidator', () => {
|
||||
assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`);
|
||||
}
|
||||
});
|
||||
|
||||
test('hooks - valid hook commands', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: echo hello',
|
||||
' PreToolUse:',
|
||||
' - type: command',
|
||||
' command: ./validate.sh',
|
||||
' cwd: scripts',
|
||||
' timeout: 30',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(markers, []);
|
||||
});
|
||||
|
||||
test('hooks - must be a map', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks: invalid',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'hooks' attribute must be a map of hook event types to command arrays.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - unknown hook event type', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' UnknownEvent:',
|
||||
' - type: command',
|
||||
' command: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Warning, message: `Unknown hook event type 'UnknownEvent'. Supported: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, SubagentStart, SubagentStop, Stop, ErrorOccurred.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - hook value must be array', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart: invalid',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `Hook event 'SessionStart' must have an array of command objects as its value.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - command item must be object', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - just a string',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `Each hook command must be an object.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - missing type property', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - command: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `Hook command is missing required property 'type'.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - type must be command', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: script',
|
||||
' command: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - missing command field', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - empty command string', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: ""',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'command' property in a hook command must be a non-empty string.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - platform-specific commands are valid', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' windows: echo hello',
|
||||
' linux: echo hello',
|
||||
' osx: echo hello',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(markers, []);
|
||||
});
|
||||
|
||||
test('hooks - env must be a map with string values', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: echo hello',
|
||||
' env: invalid',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'env' property in a hook command must be a map of string values.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - valid env map', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: echo hello',
|
||||
' env:',
|
||||
' NODE_ENV: production',
|
||||
' DEBUG: "true"',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(markers, []);
|
||||
});
|
||||
|
||||
test('hooks - unknown property warns', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: echo hello',
|
||||
' unknownProp: value',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Warning, message: `Unknown property 'unknownProp' in hook command.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - timeout must be number', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: echo hello',
|
||||
' timeout: not-a-number',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'timeout' property in a hook command must be a number.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - cwd must be string', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: command',
|
||||
' command: echo hello',
|
||||
' cwd:',
|
||||
' - array',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'cwd' property in a hook command must be a string.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - multiple errors in one command', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' SessionStart:',
|
||||
' - type: script',
|
||||
' unknownProp: value',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` },
|
||||
{ severity: MarkerSeverity.Warning, message: `Unknown property 'unknownProp' in hook command.` },
|
||||
{ severity: MarkerSeverity.Error, message: `Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - nested matcher format is valid', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' UserPromptSubmit:',
|
||||
' - hooks:',
|
||||
' - type: command',
|
||||
' command: "echo foo"',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(markers, []);
|
||||
});
|
||||
|
||||
test('hooks - nested matcher validates inner commands', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' PreToolUse:',
|
||||
' - matcher: Bash',
|
||||
' hooks:',
|
||||
' - type: script',
|
||||
' command: "echo foo"',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks - nested hooks must be array', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: "Test"',
|
||||
'hooks:',
|
||||
' PreToolUse:',
|
||||
' - hooks: invalid',
|
||||
'---',
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.agent);
|
||||
assert.deepStrictEqual(
|
||||
markers.map(m => ({ severity: m.severity, message: m.message })),
|
||||
[
|
||||
{ severity: MarkerSeverity.Error, message: `The 'hooks' property in a matcher must be an array of command objects.` },
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite('instructions', () => {
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
|
||||
import assert from 'assert';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand } from '../../../common/promptSyntax/hookSchema.js';
|
||||
import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand, parseSubagentHooksFromYaml } from '../../../common/promptSyntax/hookSchema.js';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { OperatingSystem } from '../../../../../../base/common/platform.js';
|
||||
import { HookType } from '../../../common/promptSyntax/hookTypes.js';
|
||||
import { Range } from '../../../../../../editor/common/core/range.js';
|
||||
|
||||
suite('HookSchema', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
@@ -485,4 +487,162 @@ suite('HookSchema', () => {
|
||||
assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Windows), 'default-command');
|
||||
});
|
||||
});
|
||||
|
||||
suite('parseSubagentHooksFromYaml', () => {
|
||||
|
||||
const workspaceRoot = URI.file('/workspace');
|
||||
const userHome = '/home/user';
|
||||
|
||||
const dummyRange = new Range(1, 1, 1, 1);
|
||||
|
||||
function makeScalar(value: string): import('../../../common/promptSyntax/promptFileParser.js').IScalarValue {
|
||||
return { type: 'scalar', value, range: dummyRange, format: 'none' };
|
||||
}
|
||||
|
||||
function makeMap(entries: Record<string, import('../../../common/promptSyntax/promptFileParser.js').IValue>): import('../../../common/promptSyntax/promptFileParser.js').IMapValue {
|
||||
const properties = Object.entries(entries).map(([key, value]) => ({
|
||||
key: makeScalar(key),
|
||||
value,
|
||||
}));
|
||||
return { type: 'map', properties, range: dummyRange };
|
||||
}
|
||||
|
||||
function makeSequence(items: import('../../../common/promptSyntax/promptFileParser.js').IValue[]): import('../../../common/promptSyntax/promptFileParser.js').ISequenceValue {
|
||||
return { type: 'sequence', items, range: dummyRange };
|
||||
}
|
||||
|
||||
test('parses direct command format (without matcher)', () => {
|
||||
// hooks:
|
||||
// PreToolUse:
|
||||
// - type: command
|
||||
// command: "./scripts/validate.sh"
|
||||
const hooksMap = makeMap({
|
||||
'PreToolUse': makeSequence([
|
||||
makeMap({
|
||||
'type': makeScalar('command'),
|
||||
'command': makeScalar('./scripts/validate.sh'),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome);
|
||||
|
||||
assert.strictEqual(result[HookType.PreToolUse]?.length, 1);
|
||||
assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate.sh');
|
||||
});
|
||||
|
||||
test('parses Claude format (with matcher)', () => {
|
||||
// hooks:
|
||||
// PreToolUse:
|
||||
// - matcher: "Bash"
|
||||
// hooks:
|
||||
// - type: command
|
||||
// command: "./scripts/validate-readonly.sh"
|
||||
const hooksMap = makeMap({
|
||||
'PreToolUse': makeSequence([
|
||||
makeMap({
|
||||
'matcher': makeScalar('Bash'),
|
||||
'hooks': makeSequence([
|
||||
makeMap({
|
||||
'type': makeScalar('command'),
|
||||
'command': makeScalar('./scripts/validate-readonly.sh'),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome);
|
||||
|
||||
assert.strictEqual(result[HookType.PreToolUse]?.length, 1);
|
||||
assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate-readonly.sh');
|
||||
});
|
||||
|
||||
test('parses multiple hook types', () => {
|
||||
const hooksMap = makeMap({
|
||||
'PreToolUse': makeSequence([
|
||||
makeMap({
|
||||
'type': makeScalar('command'),
|
||||
'command': makeScalar('./scripts/pre.sh'),
|
||||
}),
|
||||
]),
|
||||
'PostToolUse': makeSequence([
|
||||
makeMap({
|
||||
'matcher': makeScalar('Edit|Write'),
|
||||
'hooks': makeSequence([
|
||||
makeMap({
|
||||
'type': makeScalar('command'),
|
||||
'command': makeScalar('./scripts/lint.sh'),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome);
|
||||
|
||||
assert.strictEqual(result[HookType.PreToolUse]?.length, 1);
|
||||
assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/pre.sh');
|
||||
assert.strictEqual(result[HookType.PostToolUse]?.length, 1);
|
||||
assert.strictEqual(result[HookType.PostToolUse]![0].command, './scripts/lint.sh');
|
||||
});
|
||||
|
||||
test('skips unknown hook types', () => {
|
||||
const hooksMap = makeMap({
|
||||
'UnknownHook': makeSequence([
|
||||
makeMap({
|
||||
'type': makeScalar('command'),
|
||||
'command': makeScalar('echo "ignored"'),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome);
|
||||
|
||||
assert.strictEqual(result[HookType.PreToolUse], undefined);
|
||||
assert.strictEqual(result[HookType.PostToolUse], undefined);
|
||||
});
|
||||
|
||||
test('handles command without type field', () => {
|
||||
const hooksMap = makeMap({
|
||||
'PreToolUse': makeSequence([
|
||||
makeMap({
|
||||
'command': makeScalar('./scripts/validate.sh'),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome);
|
||||
|
||||
assert.strictEqual(result[HookType.PreToolUse]?.length, 1);
|
||||
assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate.sh');
|
||||
});
|
||||
|
||||
test('resolves cwd relative to workspace', () => {
|
||||
const hooksMap = makeMap({
|
||||
'SessionStart': makeSequence([
|
||||
makeMap({
|
||||
'type': makeScalar('command'),
|
||||
'command': makeScalar('echo "start"'),
|
||||
'cwd': makeScalar('src'),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome);
|
||||
|
||||
assert.strictEqual(result[HookType.SessionStart]?.length, 1);
|
||||
assert.deepStrictEqual(result[HookType.SessionStart]![0].cwd, URI.file('/workspace/src'));
|
||||
});
|
||||
|
||||
test('skips non-sequence hook values', () => {
|
||||
const hooksMap = makeMap({
|
||||
'PreToolUse': makeScalar('not-a-sequence'),
|
||||
});
|
||||
|
||||
const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome);
|
||||
|
||||
assert.strictEqual(result[HookType.PreToolUse], undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -794,6 +794,7 @@ suite('PromptsService', () => {
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -850,6 +851,7 @@ suite('PromptsService', () => {
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'),
|
||||
source: { storage: PromptsStorage.local },
|
||||
},
|
||||
@@ -925,6 +927,7 @@ suite('PromptsService', () => {
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -943,6 +946,7 @@ suite('PromptsService', () => {
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1013,6 +1017,7 @@ suite('PromptsService', () => {
|
||||
argumentHint: undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/github-agent.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1031,6 +1036,7 @@ suite('PromptsService', () => {
|
||||
tools: undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/vscode-agent.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1049,6 +1055,7 @@ suite('PromptsService', () => {
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1126,6 +1133,7 @@ suite('PromptsService', () => {
|
||||
argumentHint: undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1146,6 +1154,7 @@ suite('PromptsService', () => {
|
||||
argumentHint: undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1165,6 +1174,7 @@ suite('PromptsService', () => {
|
||||
argumentHint: undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1221,6 +1231,7 @@ suite('PromptsService', () => {
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
agents: undefined,
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
}
|
||||
@@ -1291,6 +1302,7 @@ suite('PromptsService', () => {
|
||||
argumentHint: undefined,
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1309,6 +1321,7 @@ suite('PromptsService', () => {
|
||||
tools: undefined,
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
@@ -1327,6 +1340,7 @@ suite('PromptsService', () => {
|
||||
tools: undefined,
|
||||
target: Target.Undefined,
|
||||
visibility: { userInvocable: true, agentInvocable: true },
|
||||
hooks: undefined,
|
||||
uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'),
|
||||
source: { storage: PromptsStorage.local }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user