Path: blob/main/src/vs/platform/agentPlugins/common/pluginParsers.ts
13394 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 { parse as parseJSONC } from '../../../base/common/json.js';6import { cloneAndChange } from '../../../base/common/objects.js';7import { isAbsolute } from '../../../base/common/path.js';8import { untildify } from '../../../base/common/labels.js';9import { basename, extname, isEqualOrParent, joinPath, normalizePath } from '../../../base/common/resources.js';10import { escapeRegExpCharacters } from '../../../base/common/strings.js';11import { hasKey, Mutable } from '../../../base/common/types.js';12import { URI } from '../../../base/common/uri.js';13import { IFileService } from '../../files/common/files.js';14import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../mcp/common/mcpPlatformTypes.js';1516// ---------------------------------------------------------------------------17// Types18// ---------------------------------------------------------------------------1920/** A single hook command to execute. Platform resolution happens at conversion time. */21export interface IParsedHookCommand {22/** Cross-platform default command. */23readonly command?: string;24/** Windows-specific command. */25readonly windows?: string;26/** Linux-specific command. */27readonly linux?: string;28/** macOS-specific command. */29readonly osx?: string;30/** Working directory. */31readonly cwd?: URI;32/** Environment variables. */33readonly env?: Record<string, string>;34/** Timeout in seconds. */35readonly timeout?: number;36/** URI of the file this hook was defined in. */37readonly sourceUri?: URI;38}3940/** A group of hooks for a single lifecycle event. */41export interface IParsedHookGroup {42/** Canonical hook type identifier (e.g. `'SessionStart'`, `'PreToolUse'`). */43readonly type: string;44/** The commands to execute for this hook type. */45readonly commands: readonly IParsedHookCommand[];46/** URI where this hook is defined. */47readonly uri: URI;48/** Original key as it appears in the hook file. */49readonly originalId: string;50}5152export interface IMcpServerDefinition {53readonly name: string;54readonly configuration: IMcpServerConfiguration;55readonly uri: URI;56}5758/** A named resource (skill, agent, command, or instruction) within a plugin. */59export interface INamedPluginResource {60readonly uri: URI;61readonly name: string;62}6364/** The result of parsing a single plugin directory. */65export interface IParsedPlugin {66readonly hooks: readonly IParsedHookGroup[];67readonly mcpServers: readonly IMcpServerDefinition[];68readonly skills: readonly INamedPluginResource[];69readonly agents: readonly INamedPluginResource[];70}7172// ---------------------------------------------------------------------------73// Plugin format detection74// ---------------------------------------------------------------------------7576export const enum PluginFormat {77Copilot,78Claude,79OpenPlugin,80}8182export interface IPluginFormatConfig {83readonly format: PluginFormat;84readonly manifestPath: string;85readonly hookConfigPath: string;86readonly pluginRootToken: string | undefined;87readonly pluginRootEnvVar: string | undefined;88/** Parses hooks from a JSON object using the format's conventions. */89parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: string): IParsedHookGroup[];90}9192const COPILOT_FORMAT: IPluginFormatConfig = {93format: PluginFormat.Copilot,94manifestPath: 'plugin.json',95hookConfigPath: 'hooks.json',96pluginRootToken: undefined,97pluginRootEnvVar: undefined,98parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) {99return parseHooksJson(hookUri, json, workspaceRoot, userHome);100},101};102103const CLAUDE_FORMAT: IPluginFormatConfig = {104format: PluginFormat.Claude,105manifestPath: '.claude-plugin/plugin.json',106hookConfigPath: 'hooks/hooks.json',107pluginRootToken: '${CLAUDE_PLUGIN_ROOT}',108pluginRootEnvVar: 'CLAUDE_PLUGIN_ROOT',109parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {110return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT');111},112};113114const OPEN_PLUGIN_FORMAT: IPluginFormatConfig = {115format: PluginFormat.OpenPlugin,116manifestPath: '.plugin/plugin.json',117hookConfigPath: 'hooks/hooks.json',118pluginRootToken: '${PLUGIN_ROOT}',119pluginRootEnvVar: 'PLUGIN_ROOT',120parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {121return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${PLUGIN_ROOT}', 'PLUGIN_ROOT');122},123};124125export async function detectPluginFormat(pluginUri: URI, fileService: IFileService): Promise<IPluginFormatConfig> {126if (await pathExists(joinPath(pluginUri, '.plugin', 'plugin.json'), fileService)) {127return OPEN_PLUGIN_FORMAT;128}129130const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude');131if (isInClaudeDirectory || await pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'), fileService)) {132return CLAUDE_FORMAT;133}134135return COPILOT_FORMAT;136}137138// ---------------------------------------------------------------------------139// Component path config140// ---------------------------------------------------------------------------141142export interface IComponentPathConfig {143readonly paths: readonly string[];144readonly exclusive: boolean;145}146147const emptyComponentPathConfig: IComponentPathConfig = { paths: [], exclusive: false };148149/**150* Parses a manifest component path field into a normalized config.151* Supports `undefined`, `string`, `string[]`, and `{ paths: string[], exclusive?: boolean }`.152*/153export function parseComponentPathConfig(raw: unknown): IComponentPathConfig {154if (raw === undefined || raw === null) {155return emptyComponentPathConfig;156}157158if (typeof raw === 'string') {159const trimmed = raw.trim();160return trimmed ? { paths: [trimmed], exclusive: false } : emptyComponentPathConfig;161}162163if (Array.isArray(raw)) {164const paths = raw165.filter(v => typeof v === 'string')166.map(v => v.trim())167.filter(v => v.length > 0);168return { paths, exclusive: false };169}170171if (typeof raw === 'object') {172const obj = raw as Record<string, unknown>;173if (Array.isArray(obj['paths'])) {174const paths = (obj['paths'] as unknown[])175.filter(v => typeof v === 'string')176.map(v => v.trim())177.filter(v => v.length > 0);178const exclusive = obj['exclusive'] === true;179return { paths, exclusive };180}181}182183return emptyComponentPathConfig;184}185186/**187* Resolves the directories to scan for a given component type, combining188* the default directory with any custom paths from the manifest config.189* Paths that resolve outside the plugin root are silently ignored.190*/191export function resolveComponentDirs(pluginUri: URI, defaultDir: string, config: IComponentPathConfig): readonly URI[] {192const dirs: URI[] = [];193if (!config.exclusive) {194dirs.push(joinPath(pluginUri, defaultDir));195}196for (const p of config.paths) {197const resolved = normalizePath(joinPath(pluginUri, p));198if (isEqualOrParent(resolved, pluginUri)) {199dirs.push(resolved);200}201}202return dirs;203}204205// ---------------------------------------------------------------------------206// MCP server helpers207// ---------------------------------------------------------------------------208209/**210* Extracts the MCP server map from a raw JSON value. Accepts both the211* wrapped format `{ mcpServers: { … } }` and the flat format.212*/213export function resolveMcpServersMap(raw: unknown): Record<string, unknown> | undefined {214if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {215return undefined;216}217const obj = raw as Record<string, unknown>;218return Object.hasOwn(obj, 'mcpServers')219? (obj.mcpServers as Record<string, unknown>)220: obj;221}222223/**224* Normalizes a raw JSON value into a typed MCP server configuration.225*/226export function normalizeMcpServerConfiguration(rawConfig: unknown): IMcpServerConfiguration | undefined {227if (!rawConfig || typeof rawConfig !== 'object') {228return undefined;229}230231const candidate = rawConfig as Record<string, unknown>;232const type = typeof candidate['type'] === 'string' ? candidate['type'] : undefined;233234const command = typeof candidate['command'] === 'string' ? candidate['command'] : undefined;235const url = typeof candidate['url'] === 'string' ? candidate['url'] : undefined;236const args = Array.isArray(candidate['args']) ? candidate['args'].filter((value): value is string => typeof value === 'string') : undefined;237const env = candidate['env'] && typeof candidate['env'] === 'object'238? Object.fromEntries(Object.entries(candidate['env'] as Record<string, unknown>)239.filter(([, value]) => typeof value === 'string' || typeof value === 'number' || value === null)240.map(([key, value]) => [key, value as string | number | null]))241: undefined;242const envFile = typeof candidate['envFile'] === 'string' ? candidate['envFile'] : undefined;243const cwd = typeof candidate['cwd'] === 'string' ? candidate['cwd'] : undefined;244const headers = candidate['headers'] && typeof candidate['headers'] === 'object'245? Object.fromEntries(Object.entries(candidate['headers'] as Record<string, unknown>)246.filter(([, value]) => typeof value === 'string')247.map(([key, value]) => [key, value as string]))248: undefined;249const dev = candidate['dev'] && typeof candidate['dev'] === 'object' ? candidate['dev'] as IMcpStdioServerConfiguration['dev'] : undefined;250251if (type === 'ws') {252return undefined;253}254255if (type === McpServerType.LOCAL || (!type && command)) {256if (!command) {257return undefined;258}259return { type: McpServerType.LOCAL, command, args, env, envFile, cwd, dev };260}261262if (type === McpServerType.REMOTE || type === 'sse' || (!type && url)) {263if (!url) {264return undefined;265}266return { type: McpServerType.REMOTE, url, headers, dev };267}268269return undefined;270}271272/**273* Characters in a file path that require shell quoting to prevent274* word splitting or interpretation by common shells.275*/276const shellUnsafeChars = /[\s&|<>()^;!`"']/;277278/**279* Replaces a plugin-root token in a shell command string with the280* given fsPath, shell-quoting if the path contains special characters.281*/282export function shellQuotePluginRootInCommand(command: string, fsPath: string, token: string) {283if (!command.includes(token)) {284return command;285}286287if (!shellUnsafeChars.test(fsPath)) {288return command.replaceAll(token, fsPath);289}290291const escapedToken = escapeRegExpCharacters(token);292const pattern = new RegExp(293`(["']?)` + escapedToken + `([\\w./\\\\~:-]*)`,294'g',295);296297return command.replace(pattern, (_match, leadingQuote: string, suffix: string) => {298const fullPath = fsPath + suffix;299if (leadingQuote) {300return leadingQuote + fullPath;301}302return '"' + fullPath.replace(/"/g, '\\"') + '"';303});304}305306/**307* Replaces plugin-root token references in MCP server definition string fields308* with the plugin root filesystem path.309*/310export function interpolateMcpPluginRoot(311def: IMcpServerDefinition,312fsPath: string,313token: string,314envVar: string,315): IMcpServerDefinition {316const replace = (s: string) => s.replaceAll(token, fsPath);317318const config = def.configuration;319let interpolated: IMcpServerConfiguration;320321if (config.type === McpServerType.LOCAL) {322const local: Mutable<IMcpStdioServerConfiguration> = { ...config };323local.command = replace(local.command);324if (local.args) {325local.args = local.args.map(replace);326}327if (local.cwd) {328local.cwd = replace(local.cwd);329}330local.env = { ...local.env };331for (const [k, v] of Object.entries(local.env)) {332if (typeof v === 'string') {333local.env[k] = replace(v);334}335}336local.env[envVar] = fsPath;337if (local.envFile) {338local.envFile = replace(local.envFile);339}340interpolated = local;341} else {342const remote: Mutable<IMcpRemoteServerConfiguration> = { ...config };343remote.url = replace(remote.url);344if (remote.headers) {345remote.headers = Object.fromEntries(346Object.entries(remote.headers).map(([k, v]) => [k, replace(v)])347);348}349interpolated = remote;350}351352return { name: def.name, configuration: interpolated, uri: def.uri };353}354355/**356* Regex matching bare `${VAR_NAME}` references (uppercase only) that are NOT357* using VS Code's `${env:VAR}` colon-delimited syntax.358*/359const BARE_ENV_VAR_RE = /\$\{(?![A-Za-z]+:)([A-Z_][A-Z0-9_]*)\}/g;360361/**362* Converts bare `${VAR}` environment-variable references to VS Code `${env:VAR}` syntax.363*/364export function convertBareEnvVarsToVsCodeSyntax(365def: IMcpServerDefinition,366): IMcpServerDefinition {367return cloneAndChange(def, (value) => {368if (URI.isUri(value)) {369return value;370}371if (typeof value === 'string') {372const replaced = value.replace(BARE_ENV_VAR_RE, '${env:$1}');373return replaced !== value ? replaced : undefined;374}375return undefined;376});377}378379// ---------------------------------------------------------------------------380// Hook parsing helpers381// ---------------------------------------------------------------------------382383/**384* Maps known hook type identifiers from all formats (VS Code PascalCase,385* Copilot CLI camelCase, Claude PascalCase) to canonical identifiers.386*/387const HOOK_TYPE_MAP: Record<string, string> = {388// PascalCase (VS Code / Claude)389'SessionStart': 'SessionStart',390'SessionEnd': 'SessionEnd',391'UserPromptSubmit': 'UserPromptSubmit',392'PreToolUse': 'PreToolUse',393'PostToolUse': 'PostToolUse',394'PreCompact': 'PreCompact',395'SubagentStart': 'SubagentStart',396'SubagentStop': 'SubagentStop',397'Stop': 'Stop',398'ErrorOccurred': 'ErrorOccurred',399// camelCase (GitHub Copilot CLI)400'sessionStart': 'SessionStart',401'sessionEnd': 'SessionEnd',402'userPromptSubmitted': 'UserPromptSubmit',403'preToolUse': 'PreToolUse',404'postToolUse': 'PostToolUse',405'agentStop': 'Stop',406'subagentStop': 'SubagentStop',407'errorOccurred': 'ErrorOccurred',408};409410/**411* Normalizes a raw hook command object, validating structure and mapping412* legacy `bash`/`powershell` fields to platform-specific overrides.413*/414function normalizeHookCommand(raw: Record<string, unknown>): IParsedHookCommand | undefined {415// Allow omitted type (Claude compatibility) — treat as 'command'416if (raw.type !== undefined && raw.type !== 'command') {417return undefined;418}419420const hasCommand = typeof raw.command === 'string' && raw.command.length > 0;421const hasBash = typeof raw.bash === 'string' && (raw.bash as string).length > 0;422const hasPowerShell = typeof raw.powershell === 'string' && (raw.powershell as string).length > 0;423const hasWindows = typeof raw.windows === 'string' && (raw.windows as string).length > 0;424const hasLinux = typeof raw.linux === 'string' && (raw.linux as string).length > 0;425const hasOsx = typeof raw.osx === 'string' && (raw.osx as string).length > 0;426427if (!hasCommand && !hasBash && !hasPowerShell && !hasWindows && !hasLinux && !hasOsx) {428return undefined;429}430431const windows = hasWindows ? raw.windows as string : (hasPowerShell ? raw.powershell as string : undefined);432const linux = hasLinux ? raw.linux as string : (hasBash ? raw.bash as string : undefined);433const osx = hasOsx ? raw.osx as string : (hasBash ? raw.bash as string : undefined);434435const timeout = typeof raw.timeout === 'number'436? raw.timeout437: (typeof raw.timeoutSec === 'number' ? raw.timeoutSec : undefined);438439return {440...(hasCommand && { command: raw.command as string }),441...(windows && { windows }),442...(linux && { linux }),443...(osx && { osx }),444...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record<string, string> }),445...(timeout !== undefined && { timeout }),446};447}448449/**450* Resolves a raw hook command JSON object into a {@link IParsedHookCommand},451* normalizing fields and resolving the working directory.452*/453function resolveHookCommand(raw: Record<string, unknown>, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand | undefined {454const normalized = normalizeHookCommand(raw);455if (!normalized) {456return undefined;457}458459let cwdUri: URI | undefined;460const rawCwd = typeof raw.cwd === 'string' ? raw.cwd : undefined;461if (rawCwd) {462const expanded = untildify(rawCwd, userHome);463if (isAbsolute(expanded)) {464cwdUri = URI.file(expanded);465} else if (workspaceRoot) {466cwdUri = joinPath(workspaceRoot, expanded);467}468} else {469cwdUri = workspaceRoot;470}471472return { ...normalized, cwd: cwdUri };473}474475/**476* Extracts hook commands from an item that may be a direct command object477* or a nested structure with a `matcher` (Claude format).478*/479function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand[] {480if (!item || typeof item !== 'object') {481return [];482}483484const itemObj = item as Record<string, unknown>;485const commands: IParsedHookCommand[] = [];486487// Nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] }488const nestedHooks = itemObj.hooks;489if (nestedHooks !== undefined && Array.isArray(nestedHooks)) {490for (const nested of nestedHooks) {491if (!nested || typeof nested !== 'object') {492continue;493}494const resolved = resolveHookCommand(nested as Record<string, unknown>, workspaceRoot, userHome);495if (resolved) {496commands.push(resolved);497}498}499} else {500const resolved = resolveHookCommand(itemObj, workspaceRoot, userHome);501if (resolved) {502commands.push(resolved);503}504}505506return commands;507}508509/**510* Parses hooks from a JSON object (any supported format).511*/512function parseHooksJson(513hookUri: URI,514json: unknown,515workspaceRoot: URI | undefined,516userHome: string,517): IParsedHookGroup[] {518if (!json || typeof json !== 'object') {519return [];520}521522const root = json as Record<string, unknown>;523524// Claude's disableAllHooks525if (root.disableAllHooks === true) {526return [];527}528529const hooks = root.hooks;530if (!hooks || typeof hooks !== 'object') {531return [];532}533534const hooksObj = hooks as Record<string, unknown>;535const result: IParsedHookGroup[] = [];536537for (const originalId of Object.keys(hooksObj)) {538const canonicalType = HOOK_TYPE_MAP[originalId];539if (!canonicalType) {540continue;541}542543const hookArray = hooksObj[originalId];544if (!Array.isArray(hookArray)) {545continue;546}547548const commands: IParsedHookCommand[] = [];549for (const item of hookArray) {550commands.push(...extractHookCommands(item, workspaceRoot, userHome));551}552553if (commands.length > 0) {554result.push({ type: canonicalType, commands, uri: hookUri, originalId });555}556}557558return result;559}560561/**562* Applies plugin-root token interpolation to hook commands for563* Claude and OpenPlugin formats.564*/565export function interpolateHookPluginRoot(566hookUri: URI,567json: unknown,568pluginUri: URI,569workspaceRoot: URI | undefined,570userHome: string,571token: string,572envVar: string,573): IParsedHookGroup[] {574const fsPath = pluginUri.fsPath;575const typedJson = json as { hooks?: Record<string, unknown[]> };576577const mutateHookCommand = (hook: Record<string, unknown>): void => {578for (const field of ['command', 'windows', 'linux', 'osx'] as const) {579if (typeof hook[field] === 'string') {580hook[field] = shellQuotePluginRootInCommand(hook[field] as string, fsPath, token);581}582}583584if (!hook.env || typeof hook.env !== 'object') {585hook.env = {};586}587(hook.env as Record<string, string>)[envVar] = fsPath;588};589590for (const lifecycle of Object.values(typedJson.hooks ?? {})) {591if (!Array.isArray(lifecycle)) {592continue;593}594for (const lifecycleEntry of lifecycle) {595if (!lifecycleEntry || typeof lifecycleEntry !== 'object') {596continue;597}598const entry = lifecycleEntry as { hooks?: Record<string, unknown>[] } & Record<string, unknown>;599if (Array.isArray(entry.hooks)) {600for (const hook of entry.hooks) {601mutateHookCommand(hook);602}603} else {604mutateHookCommand(entry);605}606}607}608609const replacer = (v: unknown): unknown => {610return typeof v === 'string'611? v.replaceAll(token, pluginUri.fsPath)612: undefined;613};614615return parseHooksJson(hookUri, cloneAndChange(json, replacer), workspaceRoot, userHome);616}617618// ---------------------------------------------------------------------------619// Filesystem helpers620// ---------------------------------------------------------------------------621622export async function readJsonFile(uri: URI, fileService: IFileService): Promise<unknown | undefined> {623try {624const fileContents = await fileService.readFile(uri);625return parseJSONC(fileContents.value.toString());626} catch {627return undefined;628}629}630631export async function pathExists(resource: URI, fileService: IFileService): Promise<boolean> {632try {633await fileService.resolve(resource);634return true;635} catch {636return false;637}638}639640// ---------------------------------------------------------------------------641// Component readers642// ---------------------------------------------------------------------------643644const COMMAND_FILE_SUFFIX = '.md';645646export async function readSkills(pluginRoot: URI, dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> {647const seen = new Set<string>();648const skills: INamedPluginResource[] = [];649650const addSkill = (name: string, skillMd: URI) => {651if (!seen.has(name)) {652seen.add(name);653skills.push({ uri: skillMd, name });654}655};656657for (const dir of dirs) {658const skillMd = URI.joinPath(dir, 'SKILL.md');659if (await pathExists(skillMd, fileService)) {660addSkill(basename(dir), skillMd);661continue;662}663664let stat;665try {666stat = await fileService.resolve(dir);667} catch {668continue;669}670671if (!stat.isDirectory || !stat.children) {672continue;673}674675for (const child of stat.children) {676const childSkillMd = URI.joinPath(child.resource, 'SKILL.md');677if (await pathExists(childSkillMd, fileService)) {678addSkill(basename(child.resource), childSkillMd);679}680}681}682683if (skills.length === 0) {684const rootSkillMd = URI.joinPath(pluginRoot, 'SKILL.md');685if (await pathExists(rootSkillMd, fileService)) {686addSkill(basename(pluginRoot), rootSkillMd);687}688}689690skills.sort((a, b) => a.name.localeCompare(b.name));691return skills;692}693694export async function readMarkdownComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> {695const seen = new Set<string>();696const items: INamedPluginResource[] = [];697698const addItem = (name: string, uri: URI) => {699if (!seen.has(name)) {700seen.add(name);701items.push({ uri, name });702}703};704705for (const dir of dirs) {706let stat;707try {708stat = await fileService.resolve(dir);709} catch {710continue;711}712713if (stat.isFile && extname(dir).toLowerCase() === COMMAND_FILE_SUFFIX) {714addItem(basename(dir).slice(0, -COMMAND_FILE_SUFFIX.length), dir);715continue;716}717718if (!stat.isDirectory || !stat.children) {719continue;720}721722for (const child of stat.children) {723if (!child.isFile || extname(child.resource).toLowerCase() !== COMMAND_FILE_SUFFIX) {724continue;725}726addItem(basename(child.resource).slice(0, -COMMAND_FILE_SUFFIX.length), child.resource);727}728}729730items.sort((a, b) => a.name.localeCompare(b.name));731return items;732}733734async function readHooks(735pluginUri: URI,736paths: readonly URI[],737formatConfig: IPluginFormatConfig,738fileService: IFileService,739workspaceRoot: URI | undefined,740userHome: string,741): Promise<readonly IParsedHookGroup[]> {742for (const hookPath of paths) {743const json = await readJsonFile(hookPath, fileService);744if (!json) {745continue;746}747748return formatConfig.parseHooks(hookPath, json, pluginUri, workspaceRoot, userHome);749}750return [];751}752753async function readMcpServers(754paths: readonly URI[],755pluginFsPath: string,756formatConfig: IPluginFormatConfig,757fileService: IFileService,758): Promise<readonly IMcpServerDefinition[]> {759const merged = new Map<string, IMcpServerDefinition>();760for (const mcpPath of paths) {761const json = await readJsonFile(mcpPath, fileService);762for (const def of parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, formatConfig)) {763if (!merged.has(def.name)) {764merged.set(def.name, def);765}766}767}768return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));769}770771export function parseMcpServerDefinitionMap(772definitionURI: URI,773raw: unknown,774pluginFsPath: string,775formatConfig: IPluginFormatConfig,776): IMcpServerDefinition[] {777const mcpServers = resolveMcpServersMap(raw);778if (!mcpServers) {779return [];780}781782const definitions: IMcpServerDefinition[] = [];783for (const [name, configValue] of Object.entries(mcpServers)) {784const configuration = normalizeMcpServerConfiguration(configValue);785if (!configuration) {786continue;787}788789let def: IMcpServerDefinition = { name, configuration, uri: definitionURI };790if (formatConfig.pluginRootToken && formatConfig.pluginRootEnvVar) {791def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootToken, formatConfig.pluginRootEnvVar);792}793def = convertBareEnvVarsToVsCodeSyntax(def);794definitions.push(def);795}796797return definitions;798}799800// ---------------------------------------------------------------------------801// Top-level parse function802// ---------------------------------------------------------------------------803804/**805* Parses a plugin directory to extract hooks, MCP servers, skills, and agents.806* This is the main entry point for the agent host to discover plugin contents.807*/808export async function parsePlugin(809pluginUri: URI,810fileService: IFileService,811workspaceRoot: URI | undefined,812userHome: string,813): Promise<IParsedPlugin> {814const formatConfig = await detectPluginFormat(pluginUri, fileService);815816// Read manifest817const manifestJson = await readJsonFile(joinPath(pluginUri, formatConfig.manifestPath), fileService);818const manifest = (manifestJson && typeof manifestJson === 'object') ? manifestJson as Record<string, unknown> : undefined;819820// Resolve component directories from manifest821const hookDirs = resolveComponentDirs(pluginUri, formatConfig.hookConfigPath, parseComponentPathConfig(manifest?.['hooks']));822const mcpDirs = resolveComponentDirs(pluginUri, '.mcp.json', parseComponentPathConfig(manifest?.['mcpServers']));823const skillDirs = resolveComponentDirs(pluginUri, 'skills', parseComponentPathConfig(manifest?.['skills']));824const agentDirs = resolveComponentDirs(pluginUri, 'agents', parseComponentPathConfig(manifest?.['agents']));825826// Handle embedded MCP servers in manifest827let embeddedMcp: IMcpServerDefinition[] = [];828const mcpSection = manifest?.['mcpServers'];829if (mcpSection && typeof mcpSection === 'object' && !Array.isArray(mcpSection) && !(hasKey(mcpSection, { paths: true }))) {830embeddedMcp = parseMcpServerDefinitionMap(831joinPath(pluginUri, formatConfig.manifestPath),832{ mcpServers: mcpSection },833pluginUri.fsPath,834formatConfig,835);836}837838// Handle embedded hooks in manifest839let embeddedHooks: IParsedHookGroup[] = [];840const hooksSection = manifest?.['hooks'];841if (hooksSection && typeof hooksSection === 'object' && !Array.isArray(hooksSection) && !(hasKey(hooksSection, { paths: true }))) {842const manifestUri = joinPath(pluginUri, formatConfig.manifestPath);843embeddedHooks = formatConfig.parseHooks(manifestUri, { hooks: hooksSection }, pluginUri, workspaceRoot, userHome);844}845846const [hooks, mcpServers, skills, agents] = await Promise.all([847embeddedHooks.length > 0848? Promise.resolve(embeddedHooks)849: readHooks(pluginUri, hookDirs, formatConfig, fileService, workspaceRoot, userHome),850embeddedMcp.length > 0851? Promise.resolve(embeddedMcp)852: readMcpServers(mcpDirs, pluginUri.fsPath, formatConfig, fileService),853readSkills(pluginUri, skillDirs, fileService),854readMarkdownComponents(agentDirs, fileService),855]);856857return { hooks, mcpServers, skills, agents };858}859860861862