Path: blob/main/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Codicon } from '../../../../base/common/codicons.js';6import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js';7import { IDisposable } from '../../../../base/common/lifecycle.js';8import { Emitter, Event } from '../../../../base/common/event.js';9import { joinPath } from '../../../../base/common/resources.js';10import { ThemeIcon } from '../../../../base/common/themables.js';11import { URI } from '../../../../base/common/uri.js';12import { localize } from '../../../../nls.js';13import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';14import { AICustomizationManagementSection, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js';15import { PromptsType } from './promptSyntax/promptTypes.js';16import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js';17import { IAgentSource, IChatPromptSlashCommand, ICustomAgent, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js';18import { CancellationToken } from '../../../../base/common/cancellation.js';19import { SessionType } from './chatSessionsService.js';20import { CustomAgent } from './promptSyntax/service/promptsServiceImpl.js';21import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';22import { getCanonicalPluginCommandId } from './plugins/agentPluginService.js';2324export const ICustomizationHarnessService = createDecorator<ICustomizationHarnessService>('customizationHarnessService');2526/**27* Override for a management section's create-button behavior.28*/29export interface ISectionOverride {30/**31* Label for the primary button. Required when `commandId` or `rootFile`32* is set. Ignored otherwise (the widget uses its default label).33*/34readonly label?: string;35/** When set, the primary button invokes this command (e.g. hooks quick pick). */36readonly commandId?: string;37/** When set, the primary button creates this file at the workspace root. */38readonly rootFile?: string;39/**40* Custom type label for the dropdown workspace/user create actions41* (e.g. "Rule" instead of "Instruction"). When undefined, the42* section's default type label is used.43*/44readonly typeLabel?: string;45/**46* Root-level file shortcuts added to the dropdown (e.g. `['AGENTS.md']`).47* Each entry creates a "New {filename}" action that creates the file at48* the workspace root. Harnesses that don't support a file simply omit it.49*/50readonly rootFileShortcuts?: readonly string[];51/**52* File extension override for new files created under this section.53* When set, files are created with this extension (e.g. `.md` for54* Claude rules) instead of the default for the prompt type55* (e.g. `.instructions.md`).56*/57readonly fileExtension?: string;58}5960export interface ICustomizationItemAction {61readonly id: string;62readonly label: string;63readonly tooltip?: string;64readonly icon?: ThemeIcon;65readonly enabled?: boolean;66run(): void | Promise<void>;67}6869/**70* Describes a single harness option for the UI toggle.71*/72export interface IHarnessDescriptor {73/**74* The harness/session-type identifier.75*/76readonly id: string;77readonly label: string;78readonly icon: ThemeIcon;79/**80* Management sections that should be hidden when this harness is active.81* For example, Claude does not support prompt files so the Prompts82* section is hidden.83*/84readonly hiddenSections?: readonly string[];85/**86* Workspace sub-paths that this harness recognizes for file creation.87* When set, the directory picker for new customization files only offers88* workspace directories under these sub-paths (e.g. `.claude` for Claude).89* When `undefined`, all workspace directories are shown (Local harness).90*/91readonly workspaceSubpaths?: readonly string[];92/**93* When `true`, the "Generate with AI" sparkle button is hidden and replaced94* with a plain "New X" manual-creation button (like sessions).95*/96readonly hideGenerateButton?: boolean;97/**98* Per-section overrides for the create button behavior.99*100* A `commandId` entry replaces the button entirely with a command101* invocation (e.g. Claude hooks → `copilot.claude.hooks`).102*103* A `rootFile` entry makes the primary button create a specific file104* at the workspace root (e.g. Claude instructions → `CLAUDE.md`).105* When combined with `typeLabel`, the dropdown create actions use106* that label instead of the section's default (e.g. "Rule" instead107* of "Instruction").108*/109readonly sectionOverrides?: ReadonlyMap<string, ISectionOverride>;110/**111* The chat agent ID that must be registered for this harness to appear.112* When `undefined`, the harness is always available (e.g. Local).113*/114readonly requiredAgentId?: string;115/**116* Instruction file patterns that this harness recognizes.117* Each entry is either an exact filename (e.g. `'CLAUDE.md'`) or a118* path prefix ending with `/` (e.g. `'.claude/rules/'`).119* When set, instruction items that don't match any pattern are filtered out.120* When `undefined`, all instruction files are shown.121*/122readonly instructionFileFilter?: readonly string[];123/**124* Returns the storage source filter that should be applied to customization125* items of the given type when this harness is active.126*/127getStorageSourceFilter(type: PromptsType): IStorageSourceFilter;128/**129* When set, this harness is backed by an extension-contributed provider130* that can supply customization items directly (bypassing promptsService131* discovery and filtering).132*/133readonly itemProvider?: ICustomizationItemProvider;134/**135* When `true`, the "Troubleshoot" action is available in item context136* menus. This opens chat with the `/troubleshoot` command pre-filled137* for the selected customization.138*/139readonly supportsTroubleshoot?: boolean;140/**141* When set, this harness uses an opt-out sync model where all eligible142* local customizations are synced by default. The UI shows disable143* affordances when this harness is active.144*/145readonly syncProvider?: ICustomizationSyncProvider;146/**147* Optional plugin-management actions shown in the Plugins section toolbar.148* Harnesses can use these to replace the default local install/create149* actions with environment-specific commands (for example, configuring150* plugins on a remote agent host).151*/152readonly pluginActions?: readonly ICustomizationItemAction[];153}154155/**156* Represents a customization item provided by any source.157*/158export interface ICustomizationItem {159/** Optional stable identity used by list widgets when URI alone is not unique. */160readonly itemKey?: string;161readonly uri: URI;162readonly type: string;163readonly name: string;164readonly description?: string;165/** Storage origin (local, user, extension, plugin). Set by providers that know the source. */166readonly storage?: PromptsStorage;167/** The extension identifier that contributed this customization, if any. */168readonly extensionId: string | undefined;169/** The URI of the plugin that contributed this customization, if any. */170readonly pluginUri: URI | undefined;171/** Server-reported loading status for this customization. */172readonly status?: 'loading' | 'loaded' | 'degraded' | 'error';173/** Human-readable status detail (e.g. error message or warning). */174readonly statusMessage?: string;175/** Whether this customization is currently enabled. */176readonly enabled?: boolean;177/** When set, items with the same groupKey are displayed under a shared collapsible header. */178readonly groupKey?: string;179/** When set, shows a small inline badge next to the item name (e.g. an applyTo glob pattern). */180readonly badge?: string;181/** Tooltip shown when hovering the badge. */182readonly badgeTooltip?: string;183/**184* Whether this customization item can be invoked by the user.185* Relevant for prompt / skill and custom agents186*/187readonly userInvocable?: boolean;188/** Optional inline/context-menu actions specific to this item. */189readonly actions?: readonly ICustomizationItemAction[];190}191192/**193* Provider interface for extension-contributed harnesses that supply194* customization items directly from their SDK.195*/196export interface ICustomizationItemProvider {197/**198* Event that fires when the provider's customizations change.199*/200readonly onDidChange: Event<void>;201/**202* Provide the customization items this harness supports.203*/204provideChatSessionCustomizations(token: CancellationToken): Promise<ICustomizationItem[] | undefined>;205}206207/**208* Provider interface for harnesses that use an opt-out sync model.209*210* Every eligible local customization is synced by default; the user211* can disable individual items. The persisted set captures only the212* user's opt-outs.213*/214export interface ICustomizationSyncProvider {215readonly onDidChange: Event<void>;216isDisabled(uri: URI): boolean;217setDisabled(uri: URI, disabled: boolean): void;218}219220/**221* Service that manages the active customization harness and provides222* per-type storage source filters based on the selected harness.223*224* The default (core) registration exposes a single "VS Code" harness225* that shows all storage sources. The sessions window overrides this226* to provide CLI-scoped harnesses.227*/228export interface ICustomizationHarnessService {229readonly _serviceBrand: undefined;230231/**232* The currently active harness.233*/234readonly activeHarness: IObservable<string>;235236/**237* All harnesses available in this window.238* When only one harness is available the UI should hide the toggle.239*/240readonly availableHarnesses: IObservable<readonly IHarnessDescriptor[]>;241242/**243* Finds the descriptor of the harness with the given id, or `undefined` if no such harness exists.244* @param sessionType The harness id (sessionType)245*/246findHarnessById(sessionType: string): IHarnessDescriptor | undefined;247248/**249* Changes the active harness. The new id must be present in250* `availableHarnesses`.251*/252setActiveHarness(sessionType: string): void;253254/**255* Convenience: returns the storage source filter for the active harness256* and the given customization type.257*/258getStorageSourceFilter(type: PromptsType): IStorageSourceFilter;259260/**261* Returns the descriptor of the currently active harness.262*/263getActiveDescriptor(): IHarnessDescriptor;264265/**266* Registers an external harness contributed by an extension.267* The harness appears in the UI toggle alongside static harnesses.268* Returns a disposable that removes the harness when disposed.269*/270registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable;271272273/**274* Fires when one of the provided slash commands changes.275*/276readonly onDidChangeSlashCommands: Event<{ readonly sessionType: string }>;277278/**279* Fires when one of the provided custom agents changes.280*/281readonly onDidChangeCustomAgents: Event<{ readonly sessionType: string }>;282283/**284* Returns the prompt and skill slash commands for the given session type.285* Provider-backed harnesses contribute their own items directly; the default286* VS Code harness falls back to the core prompts service.287*/288getSlashCommands(sessionType: string, token: CancellationToken): Promise<readonly IChatPromptSlashCommand[]>;289290/**291* Returns the custom agents for the given session type.292* Provider-backed harnesses select items via their own provider and resolve293* details via the core prompts service.294*/295getCustomAgents(sessionType: string, token: CancellationToken): Promise<readonly ICustomAgent[]>;296297/**298* Resolves a slash command to its full metadata, including the parsed prompt file for prompt commands.299* Provider-backed harnesses resolve their own items directly; the default VS Code harness falls back to the core prompts service.300*/301resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise<IResolvedChatPromptSlashCommand | undefined>;302}303304/**305* Minimal slash-command metadata resolved from the active harness.306*/307export interface ICustomizationSlashCommand {308readonly uri: URI;309readonly type: PromptsType.prompt | PromptsType.skill;310readonly name: string;311readonly description?: string;312readonly userInvocable: boolean;313readonly sessionTypes?: readonly string[];314}315316// #region Shared filter constants317318/**319* Empty filter returned when no harness is registered yet.320*/321const EMPTY_FILTER: IStorageSourceFilter = {322sources: [],323};324325/**326* Empty descriptor returned when no harness is registered yet.327*/328const EMPTY_DESCRIPTOR: IHarnessDescriptor = {329id: '',330label: '',331icon: Codicon.sparkle,332getStorageSourceFilter: () => ({ sources: [] }),333};334335336/**337* Hooks filter — local, user, and plugin sources.338*/339const HOOKS_FILTER: IStorageSourceFilter = {340sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin],341};342343// #endregion344345// #region Well-known user directories346347/**348* Returns the user-home directories accessible to the Copilot CLI harness.349*/350export function getCliUserRoots(userHome: URI): readonly URI[] {351return [352joinPath(userHome, '.copilot'),353joinPath(userHome, '.claude'),354joinPath(userHome, '.agents'),355];356}357358// #endregion359360// #region Harness descriptor factories361362/**363* Builds the full source list from the base set (local, user, plugin)364* plus any additional sources specific to the window type.365*366* Core passes `[PromptsStorage.extension]`; sessions passes its367* BUILTIN_STORAGE constant.368*/369function buildAllSources(extras: readonly string[]): readonly string[] {370return [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, ...extras];371}372373/**374* Creates a "VS Code" harness descriptor that shows all storage sources375* with no user-root restrictions.376*/377export function createVSCodeHarnessDescriptor(extras: readonly string[]): IHarnessDescriptor {378const filter: IStorageSourceFilter = { sources: buildAllSources(extras) };379return {380id: SessionType.Local,381label: localize('harness.local', "Local"),382icon: ThemeIcon.fromId(Codicon.vm.id),383supportsTroubleshoot: true,384sectionOverrides: new Map([385[AICustomizationManagementSection.Instructions, {386rootFileShortcuts: [AGENT_MD_FILENAME],387}],388]),389getStorageSourceFilter: () => filter,390};391}392393/**394* Creates a harness descriptor that restricts user-file roots for most395* types (agents, skills, instructions) while leaving hooks and prompts396* unrestricted. Used for restricted harnesses like CLI.397*/398interface IRestrictedHarnessOptions {399readonly hiddenSections?: readonly string[];400readonly workspaceSubpaths?: readonly string[];401readonly hideGenerateButton?: boolean;402readonly sectionOverrides?: ReadonlyMap<string, ISectionOverride>;403readonly requiredAgentId?: string;404readonly instructionFileFilter?: readonly string[];405}406407function createRestrictedHarnessDescriptor(408id: string,409label: string,410icon: ThemeIcon,411restrictedUserRoots: readonly URI[],412extras: readonly string[],413options?: IRestrictedHarnessOptions,414): IHarnessDescriptor {415const allSources = buildAllSources(extras);416const allRootsFilter: IStorageSourceFilter = { sources: allSources };417const restrictedFilter: IStorageSourceFilter = { sources: allSources, includedUserFileRoots: restrictedUserRoots };418return {419id,420label,421icon,422hiddenSections: options?.hiddenSections,423workspaceSubpaths: options?.workspaceSubpaths,424hideGenerateButton: options?.hideGenerateButton,425sectionOverrides: options?.sectionOverrides,426requiredAgentId: options?.requiredAgentId,427instructionFileFilter: options?.instructionFileFilter,428getStorageSourceFilter(type: PromptsType): IStorageSourceFilter {429if (type === PromptsType.hook) {430return HOOKS_FILTER;431}432if (type === PromptsType.prompt) {433return allRootsFilter;434}435return restrictedFilter;436},437};438}439440/**441* Creates a "Copilot CLI" harness descriptor.442*/443export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[]): IHarnessDescriptor {444return createRestrictedHarnessDescriptor(445SessionType.CopilotCLI,446localize('harness.cli', "Copilot CLI"),447ThemeIcon.fromId(Codicon.copilot.id),448cliUserRoots,449extras,450{451hideGenerateButton: true,452requiredAgentId: 'copilotcli',453workspaceSubpaths: ['.github', '.copilot', '.agents', '.claude'],454sectionOverrides: new Map([455[AICustomizationManagementSection.Instructions, {456rootFileShortcuts: [AGENT_MD_FILENAME],457}],458]),459},460);461}462463// #endregion464465// #region Helpers466467/**468* Tests whether a file path belongs to one of the given workspace sub-paths.469* Matches on path segment boundaries to avoid false positives470* (e.g. `.claude` must appear as `/.claude/` in the path, not as part of471* a longer segment like `not.claude`).472*/473export function matchesWorkspaceSubpath(filePath: string, subpaths: readonly string[]): boolean {474return subpaths.some(sp => filePath.includes(`/${sp}/`) || filePath.endsWith(`/${sp}`));475}476477/**478* Tests whether an instruction file matches one of the harness's recognized479* instruction file patterns. Patterns can be exact filenames (e.g. `CLAUDE.md`)480* or path prefixes ending with `/` (e.g. `.claude/rules/`).481*/482export function matchesInstructionFileFilter(filePath: string, filters: readonly string[]): boolean {483const name = filePath.substring(filePath.lastIndexOf('/') + 1);484return filters.some(f => {485if (f.endsWith('/')) {486// Path prefix: check if the file is under this directory487return filePath.includes(`/${f}`) || filePath.startsWith(f);488}489return name === f;490});491}492493// #endregion494495// #region Base implementation496497/**498* Reusable base implementation of {@link ICustomizationHarnessService}.499* Concrete registrations only need to supply the list of harness500* descriptors and a default harness id.501*/502export class CustomizationHarnessServiceBase implements ICustomizationHarnessService {503declare readonly _serviceBrand: undefined;504private readonly _onDidChangeSlashCommands = new Emitter<{ readonly sessionType: string }>();505readonly onDidChangeSlashCommands = this._onDidChangeSlashCommands.event;506private readonly _onDidChangeCustomAgents = new Emitter<{ readonly sessionType: string }>();507readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event;508private readonly _providerListeners: IDisposable[] = [];509private _isDisposed = false;510511private readonly _activeHarness: ISettableObservable<string>;512readonly activeHarness: IObservable<string>;513514private readonly _staticHarnesses: readonly IHarnessDescriptor[];515private readonly _externalHarnesses: IHarnessDescriptor[] = [];516private readonly _availableHarnesses: ISettableObservable<readonly IHarnessDescriptor[]>;517readonly availableHarnesses: IObservable<readonly IHarnessDescriptor[]>;518519constructor(520staticHarnesses: readonly IHarnessDescriptor[],521defaultHarness: string,522private readonly promptsService: IPromptsService,523) {524this._staticHarnesses = staticHarnesses;525this.promptsService = promptsService;526this._activeHarness = observableValue<string>(this, defaultHarness);527this.activeHarness = this._activeHarness;528this._availableHarnesses = observableValue<readonly IHarnessDescriptor[]>(this, [...this._staticHarnesses]);529this.availableHarnesses = this._availableHarnesses;530this._rebindProviderListeners();531}532533private _getAllHarnesses(): readonly IHarnessDescriptor[] {534// External harnesses shadow static ones with the same id so that535// extension-contributed harnesses can upgrade a built-in entry.536const externalIds = new Set(this._externalHarnesses.map(h => h.id));537return [538...this._staticHarnesses.filter(h => !externalIds.has(h.id)),539...this._externalHarnesses,540];541}542543private _refreshAvailableHarnesses(): void {544if (this._isDisposed) {545return;546}547this._availableHarnesses.set(this._getAllHarnesses(), undefined);548this._rebindProviderListeners();549}550551private _rebindProviderListeners(): void {552for (const listener of this._providerListeners) {553listener.dispose();554}555this._providerListeners.length = 0;556for (const harness of this._getAllHarnesses()) {557const provider = harness.itemProvider;558if (!provider) {559this._providerListeners.push(this.promptsService.onDidChangeSlashCommands(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id })));560this._providerListeners.push(this.promptsService.onDidChangeCustomAgents(() => this._onDidChangeCustomAgents.fire({ sessionType: harness.id })));561} else {562this._providerListeners.push(provider.onDidChange(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id })));563this._providerListeners.push(provider.onDidChange(() => this._onDidChangeCustomAgents.fire({ sessionType: harness.id })));564}565}566}567568dispose(): void {569this._isDisposed = true;570for (const listener of this._providerListeners) {571listener.dispose();572}573this._providerListeners.length = 0;574this._onDidChangeSlashCommands.dispose();575this._onDidChangeCustomAgents.dispose();576}577578registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable {579this._externalHarnesses.push(descriptor);580this._refreshAvailableHarnesses();581return {582dispose: () => {583if (this._isDisposed) {584return;585}586const idx = this._externalHarnesses.indexOf(descriptor);587if (idx >= 0) {588this._externalHarnesses.splice(idx, 1);589this._refreshAvailableHarnesses();590// If the removed harness was active, only fall back when no591// remaining harness (e.g. the restored static one) shares the id.592if (this._activeHarness.get() === descriptor.id) {593const all = this._getAllHarnesses();594if (!all.some(h => h.id === descriptor.id) && all.length > 0) {595this._activeHarness.set(all[0].id, undefined);596}597}598}599}600};601}602603findHarnessById(id: string): IHarnessDescriptor | undefined {604return this._getAllHarnesses().find(h => h.id === id);605}606607setActiveHarness(id: string): void {608const harness = this.findHarnessById(id);609if (harness) {610this._activeHarness.set(id, undefined);611}612}613614getStorageSourceFilter(type: PromptsType): IStorageSourceFilter {615const activeId = this._activeHarness.get();616const all = this._getAllHarnesses();617if (all.length === 0) {618return EMPTY_FILTER;619}620const descriptor = all.find(h => h.id === activeId) ?? all[0];621return descriptor?.getStorageSourceFilter(type) ?? EMPTY_FILTER;622}623624getActiveDescriptor(): IHarnessDescriptor {625const activeId = this._activeHarness.get();626const all = this._getAllHarnesses();627if (all.length === 0) {628return EMPTY_DESCRIPTOR;629}630return all.find(h => h.id === activeId) ?? all[0];631}632633async getSlashCommands(sessionType: string, token: CancellationToken): Promise<readonly IChatPromptSlashCommand[]> {634const harness = this.findHarnessById(sessionType);635if (!harness || !harness.itemProvider) {636const commands = await this.promptsService.getPromptSlashCommands(token);637return commands.filter(command => matchesSessionType(command.sessionTypes, sessionType));638}639640const items = await harness.itemProvider.provideChatSessionCustomizations(token);641if (!items) {642return [];643}644const result = [];645for (const item of items) {646if ((item.enabled !== false) && (item.type === PromptsType.prompt || item.type === PromptsType.skill)) {647result.push({648uri: item.uri,649type: item.type as PromptsType.prompt | PromptsType.skill,650name: item.pluginUri ? getCanonicalPluginCommandId({ uri: item.pluginUri }, item.name) : item.name,651description: item.description,652userInvocable: item.userInvocable ?? true,653storage: item.storage ?? PromptsStorage.local,654sessionTypes: [sessionType],655});656}657}658return result;659}660661async getCustomAgents(sessionType: string, token: CancellationToken): Promise<readonly ICustomAgent[]> {662const harness = this.findHarnessById(sessionType);663if (!harness || !harness.itemProvider) {664const allAgents = await this.promptsService.getCustomAgents(token);665return allAgents.filter(agent => matchesSessionType(agent.sessionTypes, sessionType));666}667668const items = await harness.itemProvider.provideChatSessionCustomizations(token);669if (!items) {670return [];671}672673const getSource = (item: ICustomizationItem): IAgentSource => {674if (item.storage === PromptsStorage.extension && item.extensionId) {675return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(item.extensionId) };676} else if (item.storage === PromptsStorage.plugin && item.pluginUri) {677return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri };678} else if (item.storage === PromptsStorage.user) {679return { storage: PromptsStorage.user };680}681return { storage: PromptsStorage.local };682};683684const result: ICustomAgent[] = [];685for (const item of items) {686if (item.type === PromptsType.agent) {687const promptFile = await this.promptsService.parseNew(item.uri, token);688const extra = {689name: item.name,690description: item.description,691sessionTypes: [sessionType],692hooks: undefined,693source: getSource(item),694type: PromptsType.agent,695enabled: item.enabled !== false,696};697result.push(CustomAgent.fromParsedPromptFile(promptFile, extra));698}699}700return result;701}702703public async resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise<IResolvedChatPromptSlashCommand | undefined> {704const commands = await this.getSlashCommands(sessionType, token);705const command = commands.find(cmd => cmd.name === name);706if (command) {707const parsedPromptFile = await this.promptsService.parseNew(command.uri, token);708return {709...command,710userInvocable: parsedPromptFile.header?.userInvocable ?? command.userInvocable,711parsedPromptFile,712};713}714return undefined;715}716}717718// #endregion719720721