Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts
13399 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 * as vscode from 'vscode';6import { INativeEnvService } from '../../../platform/env/common/envService';7import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';8import { ILogService } from '../../../platform/log/common/logService';9import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';10import { Emitter } from '../../../util/vs/base/common/event';11import { Disposable } from '../../../util/vs/base/common/lifecycle';12import { basename } from '../../../util/vs/base/common/resources';13import { URI } from '../../../util/vs/base/common/uri';14import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService';15import { ClaudeSessionUri } from '../claude/common/claudeSessionUri';16import { IPromptsService } from '../../../platform/promptFiles/common/promptsService';1718// TODO: Consider reporting Claude slash commands (from Query.supportedCommands()) when appropriate19// TODO: Report MCP servers when ChatSessionCustomizationType.Mcp is available (use Query.mcpServerStatus())2021/**22* Hard-coded CLAUDE.md instruction file names that Claude recognizes.23* Per workspace folder: CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md, .claude/CLAUDE.local.md24* User home: ~/.claude/CLAUDE.md25*/26const WORKSPACE_INSTRUCTION_PATHS = [27'CLAUDE.md',28'CLAUDE.local.md',29['.claude', 'CLAUDE.md'] as const,30['.claude', 'CLAUDE.local.md'] as const,31] as const;3233const HOME_INSTRUCTION_PATHS = [34['.claude', 'CLAUDE.md'] as const,35] as const;3637/**38* Hook event IDs that Claude supports, matching the HookEvent types from39* the Claude Agent SDK. Used to discover hooks from .claude/settings.json.40*/41const HOOK_EVENT_IDS = [42'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest',43'UserPromptSubmit', 'Stop', 'SubagentStart', 'SubagentStop',44'PreCompact', 'SessionStart', 'SessionEnd', 'Notification',45] as const;4647interface HookConfig {48readonly type: string;49readonly command: string;50}5152interface MatcherConfig {53readonly matcher: string;54readonly hooks: HookConfig[];55}5657interface HooksSettings {58readonly hooks?: Partial<Record<string, MatcherConfig[]>>;59}6061export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider {6263private readonly _onDidChange = this._register(new Emitter<void>());64readonly onDidChange = this._onDidChange.event;6566static get metadata(): vscode.ChatSessionCustomizationProviderMetadata {67return {68label: 'Claude',69iconId: 'claude',70supportedTypes: [71vscode.ChatSessionCustomizationType.Agent,72vscode.ChatSessionCustomizationType.Skill,73vscode.ChatSessionCustomizationType.Instructions,74vscode.ChatSessionCustomizationType.Hook,75],76};77}7879constructor(80@IPromptsService private readonly promptsService: IPromptsService,81@IClaudeRuntimeDataService private readonly runtimeDataService: IClaudeRuntimeDataService,82@IWorkspaceService private readonly workspaceService: IWorkspaceService,83@IFileSystemService private readonly fileSystemService: IFileSystemService,84@INativeEnvService private readonly envService: INativeEnvService,85@ILogService private readonly logService: ILogService,86) {87super();8889this._register(this.runtimeDataService.onDidChange(() => this._onDidChange.fire()));90this._register(this.promptsService.onDidChangeCustomAgents(() => this._onDidChange.fire()));91this._register(this.promptsService.onDidChangeSkills(() => this._onDidChange.fire()));92this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => this._onDidChange.fire()));93}9495async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {96const items: vscode.ChatSessionCustomizationItem[] = [];9798// Agents: hybrid approach — file-based .claude/ agents merged with SDK-provided agents.99// File-based agents are available immediately; SDK agents appear once a session starts.100const sdkAgents = this.runtimeDataService.getAgents();101const sdkAgentNames = new Set(sdkAgents.map(a => a.name.toLowerCase()));102103// SDK agents (built-in subagents like "Explore") — preferred when available104for (const agent of sdkAgents) {105items.push({106uri: URI.from({ scheme: ClaudeSessionUri.scheme, path: `/agents/${agent.name}` }),107type: vscode.ChatSessionCustomizationType.Agent,108name: agent.name,109description: agent.description,110extensionId: undefined,111pluginUri: undefined,112// No groupKey — vscode infers Built-in from non-file: scheme113});114}115116// File-based agents from .claude/ paths — shown pre-session, deduplicated with SDK117for (const agent of await this.promptsService.getCustomAgents(token)) {118if (agent.enabled && isEnabledForClaudeCode(agent) && this.isClaudePath(agent.uri)) {119const name = agent.name;120if (!sdkAgentNames.has(name.toLowerCase())) {121items.push({122uri: agent.uri,123type: vscode.ChatSessionCustomizationType.Agent,124name,125description: agent.description,126extensionId: agent.extensionId,127pluginUri: agent.pluginUri,128});129}130}131}132133const agentItems = items.filter(i => i.type === vscode.ChatSessionCustomizationType.Agent);134this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`);135136// Instructions from hard-coded CLAUDE.md paths (checked for existence)137const instructionItems = await this.discoverInstructions();138items.push(...instructionItems);139this.logService.debug(`[ClaudeCustomizationProvider] instructions (${instructionItems.length}): ${instructionItems.map(i => i.name).join(', ') || '(none)'}`);140141// Skills from .claude/skills/ directories (user-defined SKILL.md files)142const skillItems: vscode.ChatSessionCustomizationItem[] = [];143for (const skill of await this.promptsService.getSkills(token)) {144if (this.isClaudePath(skill.uri)) {145const item: vscode.ChatSessionCustomizationItem = {146uri: skill.uri,147type: vscode.ChatSessionCustomizationType.Skill,148name: skill.name,149description: skill.description,150extensionId: skill.extensionId,151pluginUri: skill.pluginUri,152};153skillItems.push(item);154}155}156items.push(...skillItems);157this.logService.debug(`[ClaudeCustomizationProvider] skills (${skillItems.length}): ${skillItems.map(s => s.name).join(', ') || '(none)'}`);158159// Hooks from .claude/settings.json files160const hookItems = await this.discoverHooks();161items.push(...hookItems);162this.logService.debug(`[ClaudeCustomizationProvider] hooks (${hookItems.length}): ${hookItems.map(h => h.name).join(', ') || '(none)'}`);163164this.logService.debug(`[ClaudeCustomizationProvider] total: ${items.length} items`);165return items;166}167168private async discoverInstructions(): Promise<vscode.ChatSessionCustomizationItem[]> {169const items: vscode.ChatSessionCustomizationItem[] = [];170const candidates: URI[] = [];171172for (const folder of this.workspaceService.getWorkspaceFolders()) {173for (const entry of WORKSPACE_INSTRUCTION_PATHS) {174if (typeof entry === 'string') {175candidates.push(URI.joinPath(folder, entry));176} else {177candidates.push(URI.joinPath(folder, ...entry));178}179}180}181182for (const entry of HOME_INSTRUCTION_PATHS) {183candidates.push(URI.joinPath(this.envService.userHome, ...entry));184}185186for (const uri of candidates) {187if (await this.fileExists(uri)) {188const name = basename(uri).replace(/\.md$/i, '');189items.push({190uri,191type: vscode.ChatSessionCustomizationType.Instructions,192name,193description: undefined,194extensionId: undefined,195pluginUri: undefined,196});197}198}199200return items;201}202203private async fileExists(uri: URI): Promise<boolean> {204try {205await this.fileSystemService.stat(uri);206return true;207} catch {208return false;209}210}211212private async discoverHooks(): Promise<vscode.ChatSessionCustomizationItem[]> {213const items: vscode.ChatSessionCustomizationItem[] = [];214const settingsPaths = this.getSettingsFilePaths();215216for (const settingsUri of settingsPaths) {217try {218const content = await this.fileSystemService.readFile(settingsUri);219const settings: HooksSettings = JSON.parse(new TextDecoder().decode(content));220if (!settings.hooks) {221continue;222}223224for (const eventId of HOOK_EVENT_IDS) {225const matchers = settings.hooks[eventId];226if (!matchers || matchers.length === 0) {227continue;228}229230for (const matcher of matchers) {231for (const hook of matcher.hooks) {232const matcherLabel = matcher.matcher === '*' ? '' : ` (${matcher.matcher})`;233items.push({234uri: settingsUri,235type: vscode.ChatSessionCustomizationType.Hook,236name: `${eventId}${matcherLabel}`,237description: hook.command,238extensionId: undefined,239pluginUri: undefined,240});241}242}243}244} catch {245// Settings file doesn't exist or is invalid — skip246}247}248249return items;250}251252private getSettingsFilePaths(): URI[] {253const paths: URI[] = [];254255for (const folder of this.workspaceService.getWorkspaceFolders()) {256paths.push(URI.joinPath(folder, '.claude', 'settings.json'));257paths.push(URI.joinPath(folder, '.claude', 'settings.local.json'));258}259260paths.push(URI.joinPath(this.envService.userHome, '.claude', 'settings.json'));261return paths;262}263264private isClaudePath(uri: URI): boolean {265const folders = this.workspaceService.getWorkspaceFolders();266for (const folder of folders) {267const folderPath = folder.path.endsWith('/') ? folder.path : folder.path + '/';268if (uri.path.startsWith(folderPath)) {269const relative = uri.path.slice(folderPath.length);270if (relative.startsWith('.claude/')) {271return true;272}273}274}275276// Also check user home .claude/ directory277const homePath = this.envService.userHome.path;278const homePrefix = homePath.endsWith('/') ? homePath : homePath + '/';279if (uri.path.startsWith(homePrefix)) {280const relative = uri.path.slice(homePrefix.length);281if (relative.startsWith('.claude/')) {282return true;283}284}285286return false;287}288}289290export function isEnabledForClaudeCode(customization: { sessionTypes?: readonly string[] }): boolean {291const sessionTypes = customization.sessionTypes;292return sessionTypes === undefined || sessionTypes.includes('claude-code') || false;293}294295296