src/vs/platform/agentPlugins/common/pluginParsers.ts
1307 LOC · 1172 covered · 135 uncovered · 265 ranges · 1983 concepts · 110 introducers · 1120 tests
File neighbourhood
The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.
Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file
In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.
Graph controls are ready.
Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.
/*---------------------------------------------------------------------------------------------
pluginParsers.ts ×47
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { parse as parseJSONC } from '../../../base/common/json.js';
import { cloneAndChange, equals as objectEquals } from '../../../base/common/objects.js';
import { isAbsolute } from '../../../base/common/path.js';
import { basename, extname, isEqualOrParent, joinPath, normalizePath, isEqual as isURLEquals, dirname } from '../../../base/common/resources.js';
import { escapeRegExpCharacters } from '../../../base/common/strings.js';
import { hasKey, Mutable } from '../../../base/common/types.js';
import { URI } from '../../../base/common/uri.js';
import { IFileService } from '../../files/common/files.js';
import { parseFrontMatter } from '../../../base/common/yaml.js';
import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../mcp/common/mcpPlatformTypes.js';
import { CustomizationType, McpServerStatus, type AgentCustomization, type HookCustomization, type McpServerCustomization, type RuleCustomization, type SkillCustomization } from '../../agentHost/common/state/protocol/state.js';
import { DEFAULT_MCP_APP } from '../../agentHost/common/state/protocol/mcpAppDefaults.js';
import { customizationId } from '../../agentHost/common/state/sessionState.js';
import { readAgentPluginManifest } from './agentPluginParser.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single hook command to execute. Platform resolution happens at conversion time. */
export interface IParsedHookCommand {
/** Cross-platform default command. */
readonly command?: string;
/** Windows-specific command. */
readonly windows?: string;
/** Linux-specific command. */
readonly linux?: string;
/** macOS-specific command. */
readonly osx?: string;
/** Working directory. */
readonly cwd?: URI;
/** Environment variables. */
readonly env?: Record<string, string>;
/** Timeout in seconds. */
readonly timeout?: number;
/** URI of the file this hook was defined in. */
readonly sourceUri?: URI;
}
export namespace IParsedHookCommand {
export function isEquals(a: IParsedHookCommand | undefined, b: IParsedHookCommand | undefined): boolean {
return true;
}
return false;
}
&& a.linux === b.linux
&& a.osx === b.osx
&& isURLEquals(a.cwd, b.cwd)
&& objectEquals(a.env, b.env)
&& a.timeout === b.timeout
&& isURLEquals(a.sourceUri, b.sourceUri);
/** A group of hooks for a single lifecycle event. */
export interface IParsedHookGroup {
/** Canonical hook type identifier (e.g. `'SessionStart'`, `'PreToolUse'`). */
readonly type: string;
/** The commands to execute for this hook type. */
readonly commands: readonly IParsedHookCommand[];
/** URI where this hook is defined. */
readonly uri: URI;
/** Original key as it appears in the hook file. */
readonly originalId: string;
/**
* Protocol-level projection of this hook group as a child customization.
* Multiple groups parsed from the same file share the same `customization.id`
* so consumers can dedupe by id when collecting customizations.
*/
readonly customization: HookCustomization;
}
export interface IMcpServerDefinition {
readonly name: string;
readonly configuration: IMcpServerConfiguration;
readonly uri: URI;
/** Protocol-level projection of this MCP server as a child customization. */
readonly customization: McpServerCustomization;
}
/** A named resource (skill, agent, command, or instruction) within a plugin. */
export interface INamedPluginResource {
readonly uri: URI;
readonly name: string;
/**
* Optional short description, populated for resources whose readers
* parse it from the file's YAML frontmatter (e.g. agents).
*/
readonly description?: string;
}
/** A parsed agent paired with its protocol-level child customization. */
export interface IParsedAgent extends INamedPluginResource {
readonly customization: AgentCustomization;
}
/** A parsed skill paired with its protocol-level child customization. */
export interface IParsedSkill extends INamedPluginResource {
readonly customization: SkillCustomization;
}
/** A parsed rule (instruction) paired with its protocol-level child customization. */
export interface IParsedRule extends INamedPluginResource {
readonly customization: RuleCustomization;
}
/** The result of parsing a single plugin directory. */
export interface IParsedPlugin {
readonly format: PluginFormat;
readonly hooks: readonly IParsedHookGroup[];
readonly mcpServers: readonly IMcpServerDefinition[];
readonly skills: readonly IParsedSkill[];
readonly agents: readonly IParsedAgent[];
readonly instructions: readonly IParsedRule[];
}
// ---------------------------------------------------------------------------
// Plugin format detection
// ---------------------------------------------------------------------------
export const enum PluginFormat {
Copilot,
Claude,
OpenPlugin,
AgentPlugin,
}
export interface IPluginFormatConfig {
readonly format: PluginFormat;
readonly manifestPath: string;
readonly hookConfigPath: string;
readonly componentPaths?: Readonly<Partial<Record<PluginComponent, string | false>>>;
readonly requiresManifest?: boolean;
readonly pluginRootTokens: readonly string[];
readonly pluginRootEnvVars: readonly string[];
/** Parses hooks from a JSON object using the format's conventions. */
parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: URI): IParsedHookGroup[];
}
export type PluginComponent = 'commands' | 'skills' | 'agents' | 'rules' | 'hooks' | 'mcpServers';
const COPILOT_FORMAT: IPluginFormatConfig = {
format: PluginFormat.Copilot,
manifestPath: 'plugin.json',
hookConfigPath: 'hooks.json',
pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'],
pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'],
parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) {
return parseHooksJson(hookUri, json, workspaceRoot, userHome);
},
const CLAUDE_FORMAT: IPluginFormatConfig = {
format: PluginFormat.Claude,
manifestPath: '.claude-plugin/plugin.json',
hookConfigPath: 'hooks/hooks.json',
pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'],
pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'],
parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {
return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT');
},
const OPEN_PLUGIN_FORMAT: IPluginFormatConfig = {
format: PluginFormat.OpenPlugin,
manifestPath: '.plugin/plugin.json',
hookConfigPath: 'hooks/hooks.json',
pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'],
pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'],
parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {
return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${PLUGIN_ROOT}', 'PLUGIN_ROOT');
pluginParsers.ts ×5
},
const AGENT_PLUGIN_FORMAT: IPluginFormatConfig = {
format: PluginFormat.AgentPlugin,
manifestPath: 'plugin.json',
hookConfigPath: '',
componentPaths: {
commands: false,
skills: 'skills',
agents: false,
rules: false,
hooks: false,
mcpServers: 'mcp.json',
},
requiresManifest: true,
pluginRootTokens: [],
pluginRootEnvVars: [],
parseHooks() {
return [];
},
export async function detectPluginFormat(pluginUri: URI, fileService: IFileService): Promise<IPluginFormatConfig> {
pluginParsers.ts ×1
if (await readAgentPluginManifest(pluginUri, fileService)) {
}
if (await pathExists(joinPath(pluginUri, '.plugin', 'plugin.json'), fileService)) {
pluginParsers.ts ×1
}
const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude');
if (isInClaudeDirectory || await pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'), fileService)) {
}
return COPILOT_FORMAT;
}
export async function readPluginManifest(pluginUri: URI, format: IPluginFormatConfig, fileService: IFileService): Promise<Record<string, unknown> | undefined> {
pluginParsers.ts ×7
if (format.format === PluginFormat.AgentPlugin) {
return manifest ? { ...manifest } : undefined;
}
const json = await readJsonFile(joinPath(pluginUri, format.manifestPath), fileService);
pluginParsers.ts ×2
return json && typeof json === 'object' && !Array.isArray(json) ? json as Record<string, unknown> : undefined;
pluginParsers.ts ×7
}
export function getPluginManifestComponent(format: IPluginFormatConfig, component: PluginComponent, manifest: Record<string, unknown> | undefined): unknown {
return format.componentPaths && Object.hasOwn(format.componentPaths, component) ? undefined : manifest?.[component];
pluginParsers.ts ×7
}
export function resolvePluginComponentDirs(
format: IPluginFormatConfig,
component: PluginComponent,
fallbackPath: string,
manifestSection: unknown,
boundaryUri?: URI,
): readonly URI[] {
const componentPath = format.componentPaths?.[component];
if (format.componentPaths && Object.hasOwn(format.componentPaths, component)) {
? resolveComponentDirs(pluginUri, componentPath, emptyComponentPathConfig, boundaryUri)
: [];
}
pluginUri,
fallbackPath,
parseComponentPathConfig(manifestSection),
boundaryUri,
);
}
// ---------------------------------------------------------------------------
// Child customization helpers
// ---------------------------------------------------------------------------
/**
* Mints a child-customization id from a source uri plus an optional opaque
* disambiguator. Used when multiple customizations are declared inline in
* a single file (e.g. two MCP servers in one `.mcp.json`, or two hook
* lifecycle groups in one hook file).
*
* Percent-encodes any pre-existing `#` in the URI before appending the
* disambiguating fragment so the resulting id can never collide with a
* URI that happens to already contain a matching fragment.
*/
const base = customizationId(uri.toString());
if (!disambiguator) {
}
}
function makeAgentCustomization(resource: INamedPluginResource): AgentCustomization {
pluginParsers.ts ×2
const uri = resource.uri.toString();
return {
type: CustomizationType.Agent,
id: buildChildId(resource.uri),
uri,
name: resource.name,
...(resource.description ? { description: resource.description } : {}),
};
}
function makeSkillCustomization(resource: INamedPluginResource): SkillCustomization {
pluginParsers.ts ×2
const uri = resource.uri.toString();
return {
type: CustomizationType.Skill,
id: buildChildId(resource.uri),
uri,
name: resource.name,
...(resource.description ? { description: resource.description } : {}),
};
}
function makeRuleCustomization(resource: INamedPluginResource): RuleCustomization {
const uri = resource.uri.toString();
return {
type: CustomizationType.Rule,
id: buildChildId(resource.uri),
uri,
name: resource.name,
...(resource.description ? { description: resource.description } : {}),
};
}
return {
type: CustomizationType.Hook,
id: buildChildId(hookUri),
uri: hookUri.toString(),
name: basename(hookUri),
};
}
/**
* Builds the protocol {@link McpServerCustomization} for an MCP server
* declared at `definitionUri` (the manifest / settings / `.mcp.json` file
* the server is defined in). The id is disambiguated by server `name` so
* multiple servers declared in one file get distinct ids, and the entry
* carries {@link DEFAULT_MCP_APP} so MCP App support is advertised
* consistently with every other MCP customization.
*
* The seed state is {@link McpServerStatus.Stopped}: a declared-but-not-yet
* connected server has not been started by any SDK, so it must not claim to
* be {@link McpServerStatus.Starting}. The live state is enriched from the
* SDK's reported status once a session materializes.
*/
export function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization {
type: CustomizationType.McpServer,
id: buildChildId(definitionUri, `mcp=${encodeURIComponent(name)}`),
uri: definitionUri.toString(),
name,
enabled: true,
state: { kind: McpServerStatus.Stopped },
mcpApp: DEFAULT_MCP_APP,
};
}
// ---------------------------------------------------------------------------
// Component path config
// ---------------------------------------------------------------------------
export interface IComponentPathConfig {
readonly paths: readonly string[];
readonly exclusive: boolean;
}
const emptyComponentPathConfig: IComponentPathConfig = { paths: [], exclusive: false };
/**
* Parses a manifest component path field into a normalized config.
* Supports `undefined`, `string`, `string[]`, and `{ paths: string[], exclusive?: boolean }`.
*/
export function parseComponentPathConfig(raw: unknown): IComponentPathConfig {
}
if (typeof raw === 'string') {
return trimmed ? { paths: [trimmed], exclusive: false } : emptyComponentPathConfig;
}
if (Array.isArray(raw)) {
.filter(v => typeof v === 'string')
.map(v => v.trim())
.filter(v => v.length > 0);
return { paths, exclusive: false };
}
if (typeof raw === 'object') {
if (Array.isArray(obj['paths'])) {
const paths = (obj['paths'] as unknown[])
.filter(v => typeof v === 'string')
.map(v => v.trim())
.filter(v => v.length > 0);
const exclusive = obj['exclusive'] === true;
return { paths, exclusive };
}
}
return emptyComponentPathConfig;
}
/**
* Resolves the directories to scan for a given component type, combining
* the default directory with any custom paths from the manifest config.
* Paths that resolve outside the boundary are silently ignored.
* @param boundaryUri The outermost directory that resolved paths must stay within. Defaults to {@link pluginUri}.
*/
export function resolveComponentDirs(pluginUri: URI, defaultDir: string, config: IComponentPathConfig, boundaryUri?: URI): readonly URI[] {
const boundary = (boundaryUri && isEqualOrParent(pluginUri, boundaryUri)) ? boundaryUri : pluginUri;
pluginParsers.ts ×3
const dirs: URI[] = [];
if (!config.exclusive) {
}
if (isEqualOrParent(resolved, boundary)) {
}
}
// ---------------------------------------------------------------------------
// MCP server helpers
// ---------------------------------------------------------------------------
/**
* Extracts the MCP server map from a raw JSON value. Accepts both the
* wrapped format `{ mcpServers: { … } }` and the flat format.
*/
export function resolveMcpServersMap(raw: unknown): Record<string, unknown> | undefined {
}
return Object.hasOwn(obj, 'mcpServers')
/**
* Normalizes a raw JSON value into a typed MCP server configuration.
*/
export function normalizeMcpServerConfiguration(rawConfig: unknown): IMcpServerConfiguration | undefined {
}
const candidate = rawConfig as Record<string, unknown>;
const type = typeof candidate['type'] === 'string' ? candidate['type'] : undefined;
pluginParsers.ts ×6
const command = typeof candidate['command'] === 'string' ? candidate['command'] : undefined;
const url = typeof candidate['url'] === 'string' ? candidate['url'] : undefined;
const args = Array.isArray(candidate['args']) ? candidate['args'].filter((value): value is string => typeof value === 'string') : undefined;
const env = candidate['env'] && typeof candidate['env'] === 'object'
? Object.fromEntries(Object.entries(candidate['env'] as Record<string, unknown>)
pluginParsers.ts ×1
.filter(([, value]) => typeof value === 'string' || typeof value === 'number' || value === null)
.map(([key, value]) => [key, value as string | number | null]))
const envFile = typeof candidate['envFile'] === 'string' ? candidate['envFile'] : undefined;
pluginParsers.ts ×6
const cwd = typeof candidate['cwd'] === 'string' ? candidate['cwd'] : undefined;
const headers = candidate['headers'] && typeof candidate['headers'] === 'object'
? Object.fromEntries(Object.entries(candidate['headers'] as Record<string, unknown>)
pluginParsers.ts ×1
.filter(([, value]) => typeof value === 'string')
.map(([key, value]) => [key, value as string]))
const dev = candidate['dev'] && typeof candidate['dev'] === 'object' ? candidate['dev'] as IMcpStdioServerConfiguration['dev'] : undefined;
pluginParsers.ts ×6
if (type === 'ws') {
}
}
return { type: McpServerType.LOCAL, command, args, env, envFile, cwd, dev };
pluginParsers.ts ×1
}
if (type === McpServerType.REMOTE || type === 'streamable-http' || type === 'sse' || (!type && url)) {
pluginParsers.ts ×6
return undefined;
}
}
return undefined;
}
/**
* Characters in a file path that require shell quoting to prevent
* word splitting or interpretation by common shells.
*/
const shellUnsafeChars = /[\s&|<>()^;!`"']/;
/**
* Replaces a plugin-root token in a shell command string with the
* given fsPath, shell-quoting if the path contains special characters.
*/
export function shellQuotePluginRootInCommand(command: string, fsPath: string, token: string) {
}
if (!shellUnsafeChars.test(fsPath)) {
}
const escapedToken = escapeRegExpCharacters(token);
const pattern = new RegExp(
`(["']?)` + escapedToken + `([\\w./\\\\~:-]*)`,
'g',
);
return command.replace(pattern, (_match, leadingQuote: string, suffix: string) => {
const fullPath = fsPath + suffix;
if (leadingQuote) {
}
}
/**
* Replaces plugin-root token references in MCP server definition string fields
* with the plugin root filesystem path.
*/
export function interpolateMcpPluginRoot(
fsPath: string,
tokens: readonly string[],
envVars: readonly string[],
): IMcpServerDefinition {
const replace = (s: string) => tokens.reduce((result, token) => result.replaceAll(token, fsPath), s);
const config = def.configuration;
let interpolated: IMcpServerConfiguration;
if (config.type === McpServerType.LOCAL) {
local.command = replace(local.command);
if (local.args) {
}
}
for (const [k, v] of Object.entries(local.env)) {
local.env[k] = replace(v);
}
}
}
local.envFile = replace(local.envFile);
}
remote.url = replace(remote.url);
if (remote.headers) {
remote.headers = Object.fromEntries(
Object.entries(remote.headers).map(([k, v]) => [k, replace(v)])
);
}
}
return { name: def.name, configuration: interpolated, uri: def.uri, customization: def.customization };
}
/**
* Regex matching bare `${VAR_NAME}` references (uppercase only) that are NOT
* using VS Code's `${env:VAR}` colon-delimited syntax.
*/
const BARE_ENV_VAR_RE = /\$\{(?![A-Za-z]+:)([A-Z_][A-Z0-9_]*)\}/g;
/**
* Converts bare `${VAR}` environment-variable references to VS Code `${env:VAR}` syntax.
*/
export function convertBareEnvVarsToVsCodeSyntax(
): IMcpServerDefinition {
return cloneAndChange(def, (value) => {
if (URI.isUri(value)) {
return value;
}
if (typeof value === 'string') {
const replaced = value.replace(BARE_ENV_VAR_RE, '${env:$1}');
return replaced !== value ? replaced : undefined;
}
return undefined;
});
}
// ---------------------------------------------------------------------------
// Hook parsing helpers
// ---------------------------------------------------------------------------
/**
* Maps known hook type identifiers from all formats (VS Code PascalCase,
* Copilot CLI camelCase, Claude PascalCase) to canonical identifiers.
*/
const HOOK_TYPE_MAP: Record<string, string> = {
// PascalCase (VS Code / Claude)
'SessionStart': 'SessionStart',
'SessionEnd': 'SessionEnd',
'UserPromptSubmit': 'UserPromptSubmit',
'PreToolUse': 'PreToolUse',
'PostToolUse': 'PostToolUse',
'PreCompact': 'PreCompact',
'SubagentStart': 'SubagentStart',
'SubagentStop': 'SubagentStop',
'Stop': 'Stop',
'ErrorOccurred': 'ErrorOccurred',
// camelCase (GitHub Copilot CLI)
'sessionStart': 'SessionStart',
'sessionEnd': 'SessionEnd',
'userPromptSubmitted': 'UserPromptSubmit',
'preToolUse': 'PreToolUse',
'postToolUse': 'PostToolUse',
'agentStop': 'Stop',
'subagentStop': 'SubagentStop',
'errorOccurred': 'ErrorOccurred',
};
/**
* Normalizes a raw hook command object, validating structure and mapping
* legacy `bash`/`powershell` fields to platform-specific overrides.
*/
function normalizeHookCommand(raw: Record<string, unknown>): IParsedHookCommand | undefined {
pluginParsers.ts ×15
// Allow omitted type (Claude compatibility) — treat as 'command'
if (raw.type !== undefined && raw.type !== 'command') {
}
const hasCommand = typeof raw.command === 'string' && raw.command.length > 0;
const hasBash = typeof raw.bash === 'string' && (raw.bash as string).length > 0;
const hasPowerShell = typeof raw.powershell === 'string' && (raw.powershell as string).length > 0;
const hasWindows = typeof raw.windows === 'string' && (raw.windows as string).length > 0;
const hasLinux = typeof raw.linux === 'string' && (raw.linux as string).length > 0;
const hasOsx = typeof raw.osx === 'string' && (raw.osx as string).length > 0;
if (!hasCommand && !hasBash && !hasPowerShell && !hasWindows && !hasLinux && !hasOsx) {
return undefined;
}
const windows = hasWindows ? raw.windows as string : (hasPowerShell ? raw.powershell as string : undefined);
const linux = hasLinux ? raw.linux as string : (hasBash ? raw.bash as string : undefined);
const osx = hasOsx ? raw.osx as string : (hasBash ? raw.bash as string : undefined);
const timeout = typeof raw.timeout === 'number'
? raw.timeout
return {
...(hasCommand && { command: raw.command as string }),
...(windows && { windows }),
...(linux && { linux }),
...(osx && { osx }),
...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record<string, string> }),
...(timeout !== undefined && { timeout }),
};
}
/**
* Resolves a raw hook command JSON object into a {@link IParsedHookCommand},
* normalizing fields and resolving the working directory.
*/
function resolveHookCommand(raw: Record<string, unknown>, workspaceRoot: URI | undefined, userHome: URI): IParsedHookCommand | undefined {
pluginParsers.ts ×15
const normalized = normalizeHookCommand(raw);
if (!normalized) {
}
let cwdUri: URI | undefined;
const rawCwd = typeof raw.cwd === 'string' ? raw.cwd : undefined;
if (rawCwd) {
if (rawCwd.startsWith('~/')) {
cwdUri = URI.joinPath(userHome, rawCwd.substring(2));
} else if (isAbsolute(rawCwd)) {
cwdUri = URI.file(rawCwd);
} else if (workspaceRoot) {
cwdUri = joinPath(workspaceRoot, rawCwd);
}
cwdUri = workspaceRoot;
}
return { ...normalized, cwd: cwdUri };
}
/**
* Extracts hook commands from an item that may be a direct command object
* or a nested structure with a `matcher` (Claude format).
*/
function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, userHome: URI): IParsedHookCommand[] {
pluginParsers.ts ×15
if (!item || typeof item !== 'object') {
return [];
}
const itemObj = item as Record<string, unknown>;
const commands: IParsedHookCommand[] = [];
// Nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] }
const nestedHooks = itemObj.hooks;
if (nestedHooks !== undefined && Array.isArray(nestedHooks)) {
if (!nested || typeof nested !== 'object') {
continue;
}
const resolved = resolveHookCommand(nested as Record<string, unknown>, workspaceRoot, userHome);
pluginParsers.ts ×2
if (resolved) {
commands.push(resolved);
}
}
if (resolved) {
commands.push(resolved);
}
}
return commands;
}
/**
* Parses hooks from a JSON object (any supported format).
*
* Handles Claude's `disableAllHooks` short-circuit, the `HOOK_TYPE_MAP`
* canonicalization, and the nested `{ matcher, hooks: [...] }` command
* form. Returns one {@link IParsedHookGroup} per recognized lifecycle
* event; all groups parsed from the same file share a single
* {@link IParsedHookGroup.customization} (keyed on `hookUri`), so callers
* that only need the file-level customization can read it off any group.
*/
export function parseHooksJson(
json: unknown,
workspaceRoot: URI | undefined,
userHome: URI,
): IParsedHookGroup[] {
if (!json || typeof json !== 'object') {
}
const root = json as Record<string, unknown>;
// Claude's disableAllHooks
if (root.disableAllHooks === true) {
}
const hooks = root.hooks;
}
const hooksObj = hooks as Record<string, unknown>;
const result: IParsedHookGroup[] = [];
const customization = makeHookCustomization(hookUri);
for (const originalId of Object.keys(hooksObj)) {
const canonicalType = HOOK_TYPE_MAP[originalId];
if (!canonicalType) {
}
const hookArray = hooksObj[originalId];
if (!Array.isArray(hookArray)) {
continue;
}
const commands: IParsedHookCommand[] = [];
for (const item of hookArray) {
commands.push(...extractHookCommands(item, workspaceRoot, userHome));
}
if (commands.length > 0) {
result.push({ type: canonicalType, commands, uri: hookUri, originalId, customization });
}
}
return result;
}
/**
* Applies plugin-root token interpolation to hook commands for
* Claude and OpenPlugin formats.
*/
export function interpolateHookPluginRoot(
json: unknown,
pluginUri: URI,
workspaceRoot: URI | undefined,
userHome: URI,
token: string,
envVar: string,
): IParsedHookGroup[] {
const fsPath = pluginUri.fsPath;
const typedJson = json as { hooks?: Record<string, unknown[]> };
const mutateHookCommand = (hook: Record<string, unknown>): void => {
for (const field of ['command', 'windows', 'linux', 'osx'] as const) {
if (typeof hook[field] === 'string') {
hook[field] = shellQuotePluginRootInCommand(hook[field] as string, fsPath, token);
}
}
if (!hook.env || typeof hook.env !== 'object') {
hook.env = {};
}
(hook.env as Record<string, string>)[envVar] = fsPath;
};
for (const lifecycle of Object.values(typedJson.hooks ?? {})) {
if (!Array.isArray(lifecycle)) {
continue;
}
if (!lifecycleEntry || typeof lifecycleEntry !== 'object') {
continue;
}
const entry = lifecycleEntry as { hooks?: Record<string, unknown>[] } & Record<string, unknown>;
pluginParsers.ts ×5
if (Array.isArray(entry.hooks)) {
for (const hook of entry.hooks) {
mutateHookCommand(hook);
}
} else {
mutateHookCommand(entry);
}
}
const replacer = (v: unknown): unknown => {
return typeof v === 'string'
? v.replaceAll(token, pluginUri.fsPath)
: undefined;
};
return parseHooksJson(hookUri, cloneAndChange(json, replacer), workspaceRoot, userHome);
}
// ---------------------------------------------------------------------------
// Filesystem helpers
// ---------------------------------------------------------------------------
export async function readJsonFile(uri: URI, fileService: IFileService): Promise<unknown | undefined> {
pluginParsers.ts ×3
try {
const fileContents = await fileService.readFile(uri);
}
export async function pathExists(resource: URI, fileService: IFileService): Promise<boolean> {
pluginParsers.ts ×3
try {
await fileService.resolve(resource);
}
// ---------------------------------------------------------------------------
// Component readers
// ---------------------------------------------------------------------------
const COMMAND_FILE_SUFFIX = '.md';
const RULE_FILE_SUFFIX = '.mdc';
const INSTRUCTION_FILE_SUFFIX = '.instructions.md';
pluginRoot: URI,
dirs: readonly URI[],
fileService: IFileService,
options?: { readonly childDirectoriesOnly?: boolean; readonly containmentRoot?: URI },
): Promise<readonly INamedPluginResource[]> {
const seen = new Set<string>();
const skills: INamedPluginResource[] = [];
const addSkill = async (name: string, skillMd: URI) => {
if (options?.containmentRoot && !await isResolvedWithin(options.containmentRoot, skillMd, fileService)) {
pluginParsers.ts ×3
}
try {
const parsedInfo = await parseSkillFile(skillMd, fileService);
description = parsedInfo.description;
name = parsedInfo.name || name;
// Keep the existing best-effort discovery behavior for malformed skills.
}
return;
}
skills.push({ uri: skillMd, name, ...(description ? { description } : {}) });
pluginParsers.ts ×3
};
await Promise.all(dirs.map(async dir => {
if (!options?.childDirectoriesOnly) {
if (await pathExists(skillMd, fileService)) {
return;
}
let stat;
try {
stat = await fileService.resolve(dir);
} catch {
}
return;
}
await Promise.all(stat.children.map(async child => {
const childSkillMd = URI.joinPath(child.resource, 'SKILL.md');
if (await pathExists(childSkillMd, fileService)) {
await addSkill(basename(child.resource), childSkillMd);
}
}));
if (!options?.childDirectoriesOnly && skills.length === 0) {
if (await pathExists(rootSkillMd, fileService)) {
}
skills.sort((a, b) => a.name.localeCompare(b.name));
return skills;
}
export async function readPluginSkills(pluginRoot: URI, dirs: readonly URI[], format: IPluginFormatConfig, fileService: IFileService): Promise<readonly INamedPluginResource[]> {
pluginParsers.ts ×7
return readSkills(pluginRoot, dirs, fileService, format.format === PluginFormat.AgentPlugin
}
async function isResolvedWithin(root: URI, resource: URI, fileService: IFileService): Promise<boolean> {
pluginParsers.ts ×5
try {
const [resolvedRoot, resolvedResource] = await Promise.all([
fileService.realpath(root),
fileService.realpath(resource),
]);
return isEqualOrParent(resolvedResource ?? normalizePath(resource), resolvedRoot ?? normalizePath(root));
} catch {
return false;
}
export async function readMarkdownComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> {
pluginParsers.ts ×9
const seen = new Set<string>();
const items: INamedPluginResource[] = [];
const addItem = (name: string, uri: URI) => {
seen.add(name);
items.push({ uri, name });
}
};
for (const dir of dirs) {
try {
stat = await fileService.resolve(dir);
} catch {
}
continue;
}
continue;
}
for (const child of stat.children) {
if (!child.isFile || extname(child.resource).toLowerCase() !== COMMAND_FILE_SUFFIX) {
continue;
}
addItem(basename(child.resource).slice(0, -COMMAND_FILE_SUFFIX.length), child.resource);
pluginParsers.ts ×3
}
}
items.sort((a, b) => a.name.localeCompare(b.name));
return items;
}
function getInstructionFileName(resource: URI): string | undefined {
const fileName = basename(resource);
const lowerName = fileName.toLowerCase();
if (lowerName.endsWith(RULE_FILE_SUFFIX)) {
return fileName.slice(0, -RULE_FILE_SUFFIX.length);
}
if (lowerName.endsWith(INSTRUCTION_FILE_SUFFIX)) {
return fileName.slice(0, -INSTRUCTION_FILE_SUFFIX.length);
}
return undefined;
}
/**
* Reads rule/instruction files from plugin `rules` component directories.
*
* Open Plugins rules are conventionally `.mdc` files. We also accept
* `.instructions.md` for compatibility with VS Code-discovered instructions
* bundled as synthetic plugins.
*/
export async function readInstructionComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> {
pluginParsers.ts ×11
const seen = new Set<string>();
const items: INamedPluginResource[] = [];
const addItem = (name: string, uri: URI) => {
if (!seen.has(name)) {
seen.add(name);
items.push({ uri, name });
}
};
for (const dir of dirs) {
try {
stat = await fileService.resolve(dir);
} catch {
continue;
}
if (stat.isFile) {
const instructionName = getInstructionFileName(dir);
if (instructionName) {
addItem(instructionName, dir);
}
continue;
}
continue;
}
for (const child of stat.children) {
if (!child.isFile) {
continue;
}
const instructionName = getInstructionFileName(child.resource);
if (instructionName) {
addItem(instructionName, child.resource);
}
}
}
items.sort((a, b) => a.name.localeCompare(b.name));
return items;
}
/**
* Reads `.md` files in agent directories and enriches each entry with
* the optional `name` / `description` from YAML frontmatter. Falls back
* to the file-derived name when frontmatter is missing or unreadable.
*/
export async function readAgentComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> {
pluginParsers.ts ×1
const files = await readMarkdownComponents(dirs, fileService);
if (files.length === 0) {
}
try {
const { name, description } = await parseAgentFile(file.uri, fileService);
return {
uri: file.uri,
name: name || file.name,
...(description ? { description } : {}),
} satisfies INamedPluginResource;
} catch {
return file;
}
// De-dupe again in case frontmatter `name` collides; first-seen wins.
const seen = new Set<string>();
const result: INamedPluginResource[] = [];
for (const item of enriched) {
if (seen.has(item.name)) {
continue;
}
result.push(item);
}
result.sort((a, b) => a.name.localeCompare(b.name));
return result;
}
export async function parseAgentFile(uri: URI, fileService: IFileService): Promise<{ name: string; description?: string; userInvocable?: boolean }> {
pluginParsers.ts ×3
// Use regex to strip the trailing `.agent.md` or .md before parsing, so we can fall back to a cleaner name if frontmatter is missing or broken.
const nameFromFile = basename(uri).replace(/(\.agent)?\.md$/i, '');
try {
const content = await fileService.readFile(uri);
const name = frontmatter?.getStringValue('name')?.trim() || nameFromFile;
pluginParsers.ts ×3
const description = frontmatter?.getStringValue('description')?.trim();
const userInvocable = frontmatter?.getBooleanValue('user-invocable');
return { name, description, userInvocable };
} catch {
}
export async function parseSkillFile(uri: URI, fileService: IFileService): Promise<{ name: string; description?: string; userInvokable?: boolean }> {
pluginParsers.ts ×2
try {
const content = await fileService.readFile(uri);
const frontmatter = parseFrontMatter(content.value.toString());
const name = frontmatter?.getStringValue('name')?.trim() || basename(dirname(uri));
const description = frontmatter?.getStringValue('description')?.trim();
const userInvokable = frontmatter?.getBooleanValue('user-invocable');
return { name, description, userInvokable };
} catch {
return { name: basename(dirname(uri)) };
}
export async function parseRuleFile(uri: URI, fileService: IFileService): Promise<{ name: string; description?: string; globs?: string[]; alwaysApply?: boolean }> {
pluginParsers.ts ×2
const nameFromFile = basename(uri).replace(/(\.instructions)?\.md$/i, '');
try {
const content = await fileService.readFile(uri);
const frontmatter = parseFrontMatter(content.value.toString());
const name = frontmatter?.getStringValue('name')?.trim() || nameFromFile;
const description = frontmatter?.getStringValue('description')?.trim();
const globs = frontmatter?.getStringArrayValue('globs') ?? frontmatter?.getStringArrayValue('applyTo') ?? frontmatter?.getStringArrayValue('paths') ?? undefined;
const alwaysApply = frontmatter?.getBooleanValue('alwaysApply');
return { name, description, globs, alwaysApply };
} catch {
return { name: nameFromFile };
}
pluginUri: URI,
paths: readonly URI[],
formatConfig: IPluginFormatConfig,
fileService: IFileService,
workspaceRoot: URI | undefined,
userHome: URI,
): Promise<readonly IParsedHookGroup[]> {
for (const hookPath of paths) {
if (!json) {
continue;
}
return formatConfig.parseHooks(hookPath, json, pluginUri, workspaceRoot, userHome);
}
}
pluginUri: URI,
paths: readonly URI[],
formatConfig: IPluginFormatConfig,
fileService: IFileService,
): Promise<readonly IMcpServerDefinition[]> {
const merged = new Map<string, IMcpServerDefinition>();
for (const mcpPath of paths) {
if (formatConfig.format === PluginFormat.AgentPlugin && !await isResolvedWithin(pluginUri, mcpPath, fileService)) {
continue;
}
for (const def of parseMcpServerDefinitionMap(mcpPath, json, pluginUri.fsPath, formatConfig)) {
merged.set(def.name, def);
}
}
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
}
pluginUri: URI,
paths: readonly URI[],
format: IPluginFormatConfig,
fileService: IFileService,
): Promise<readonly IMcpServerDefinition[]> {
return readMcpServers(pluginUri, paths, format, fileService);
}
export function parseMcpServerDefinitionMap(
raw: unknown,
pluginFsPath: string,
formatConfig: IPluginFormatConfig,
): IMcpServerDefinition[] {
const mcpServers = resolveMcpServersMap(raw);
if (!mcpServers) {
}
const definitions: IMcpServerDefinition[] = [];
for (const [name, configValue] of Object.entries(mcpServers)) {
const configuration = normalizeMcpServerConfiguration(configValue);
if (!configuration) {
continue;
}
let def: IMcpServerDefinition = {
name,
configuration,
uri: definitionURI,
customization: makeMcpServerCustomization(definitionURI, name),
};
def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootTokens, formatConfig.pluginRootEnvVars);
if (formatConfig.format !== PluginFormat.AgentPlugin && def.configuration.type === McpServerType.LOCAL && def.configuration.cwd === undefined) {
def = { ...def, configuration: { ...def.configuration, cwd: pluginFsPath } };
pluginParsers.ts ×1
}
}
}
return definitions;
}
// ---------------------------------------------------------------------------
// Top-level parse function
// ---------------------------------------------------------------------------
/**
* Parses a plugin directory to extract hooks, MCP servers, skills, agents,
* and instructions.
* This is the main entry point for the agent host to discover plugin contents.
*/
pluginUri: URI,
fileService: IFileService,
workspaceRoot: URI | undefined,
userHome: URI,
boundaryUri?: URI,
): Promise<IParsedPlugin> {
const formatConfig = await detectPluginFormat(pluginUri, fileService);
// Read manifest
const manifest = await readPluginManifest(pluginUri, formatConfig, fileService);
if (formatConfig.requiresManifest && !manifest) {
throw new Error(`Plugin manifest '${joinPath(pluginUri, formatConfig.manifestPath).toString()}' is missing`);
}
// Resolve component directories from manifest
const hookDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'hooks', formatConfig.hookConfigPath, manifest?.['hooks'], boundaryUri);
const mcpDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'mcpServers', '.mcp.json', manifest?.['mcpServers'], boundaryUri);
const skillDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'skills', 'skills', manifest?.['skills'], boundaryUri);
const agentDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'agents', 'agents', manifest?.['agents'], boundaryUri);
const instructionDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'rules', 'rules', manifest?.['rules'], boundaryUri);
// Handle embedded MCP servers in manifest
let embeddedMcp: IMcpServerDefinition[] = [];
const mcpSection = getPluginManifestComponent(formatConfig, 'mcpServers', manifest);
if (mcpSection && typeof mcpSection === 'object' && !Array.isArray(mcpSection) && !(hasKey(mcpSection, { paths: true }))) {
embeddedMcp = parseMcpServerDefinitionMap(
joinPath(pluginUri, formatConfig.manifestPath),
{ mcpServers: mcpSection },
pluginUri.fsPath,
formatConfig,
);
}
// Handle embedded hooks in manifest
let embeddedHooks: IParsedHookGroup[] = [];
const hooksSection = getPluginManifestComponent(formatConfig, 'hooks', manifest);
if (hooksSection && typeof hooksSection === 'object' && !Array.isArray(hooksSection) && !(hasKey(hooksSection, { paths: true }))) {
const manifestUri = joinPath(pluginUri, formatConfig.manifestPath);
embeddedHooks = formatConfig.parseHooks(manifestUri, { hooks: hooksSection }, pluginUri, workspaceRoot, userHome);
}
const [hooks, mcpServers, skills, agents, instructions] = await Promise.all([
embeddedHooks.length > 0
? Promise.resolve(embeddedHooks)
: readHooks(pluginUri, hookDirs, formatConfig, fileService, workspaceRoot, userHome),
pluginParsers.ts ×11
embeddedMcp.length > 0
? Promise.resolve(embeddedMcp)
readPluginSkills(pluginUri, skillDirs, formatConfig, fileService),
readAgentComponents(agentDirs, fileService),
readInstructionComponents(instructionDirs, fileService),
]);
return {
format: formatConfig.format,
hooks,
mcpServers,
skills: skills.map(toParsedSkill),
agents: agents.map(toParsedAgent),
instructions: instructions.map(toParsedRule),
};
}
/** Pairs an agent {@link INamedPluginResource} with its protocol-level {@link AgentCustomization}. */
export function toParsedAgent(resource: INamedPluginResource): IParsedAgent {
}
/** Pairs a skill {@link INamedPluginResource} with its protocol-level {@link SkillCustomization}. */
export function toParsedSkill(resource: INamedPluginResource): IParsedSkill {
}
function toParsedRule(resource: INamedPluginResource): IParsedRule {
return { ...resource, customization: makeRuleCustomization(resource) };
}