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.

1 > /*--------------------------------------------------------------------------------------------- pluginParsers.ts ×47
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { parse as parseJSONC } from '../../../base/common/json.js';
7 > import { cloneAndChange, equals as objectEquals } from '../../../base/common/objects.js';
8 > import { isAbsolute } from '../../../base/common/path.js';
9 > import { basename, extname, isEqualOrParent, joinPath, normalizePath, isEqual as isURLEquals, dirname } from '../../../base/common/resources.js';
10 > import { escapeRegExpCharacters } from '../../../base/common/strings.js';
11 > import { hasKey, Mutable } from '../../../base/common/types.js';
12 > import { URI } from '../../../base/common/uri.js';
13 > import { IFileService } from '../../files/common/files.js';
14 > import { parseFrontMatter } from '../../../base/common/yaml.js';
15 > import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../mcp/common/mcpPlatformTypes.js';
16 > import { CustomizationType, McpServerStatus, type AgentCustomization, type HookCustomization, type McpServerCustomization, type RuleCustomization, type SkillCustomization } from '../../agentHost/common/state/protocol/state.js';
17 > import { DEFAULT_MCP_APP } from '../../agentHost/common/state/protocol/mcpAppDefaults.js';
18 > import { customizationId } from '../../agentHost/common/state/sessionState.js';
19 > import { readAgentPluginManifest } from './agentPluginParser.js';
20 >
21 > // ---------------------------------------------------------------------------
22 > // Types
23 > // ---------------------------------------------------------------------------
24 >
25 > /** A single hook command to execute. Platform resolution happens at conversion time. */
26 > export interface IParsedHookCommand {
27 > /** Cross-platform default command. */
28 > readonly command?: string;
29 > /** Windows-specific command. */
30 > readonly windows?: string;
31 > /** Linux-specific command. */
32 > readonly linux?: string;
33 > /** macOS-specific command. */
34 > readonly osx?: string;
35 > /** Working directory. */
36 > readonly cwd?: URI;
37 > /** Environment variables. */
38 > readonly env?: Record<string, string>;
39 > /** Timeout in seconds. */
40 > readonly timeout?: number;
41 > /** URI of the file this hook was defined in. */
42 > readonly sourceUri?: URI;
43 > }
44 >
45 > export namespace IParsedHookCommand {
46 > export function isEquals(a: IParsedHookCommand | undefined, b: IParsedHookCommand | undefined): boolean {
47 > if (a === b) { pluginParsers.ts ×4
48 return true;
49 }
50 > if (!a || !b) { pluginParsers.ts ×4
51 return false;
52 }
53 > return a.command === b.command pluginParsers.ts ×4
54 > && a.windows === b.windows pluginParsers.ts ×1
55 > && a.linux === b.linux
56 > && a.osx === b.osx
57 > && isURLEquals(a.cwd, b.cwd)
58 > && objectEquals(a.env, b.env)
59 > && a.timeout === b.timeout
60 > && isURLEquals(a.sourceUri, b.sourceUri);
63 >
64 > /** A group of hooks for a single lifecycle event. */
65 > export interface IParsedHookGroup {
66 > /** Canonical hook type identifier (e.g. `'SessionStart'`, `'PreToolUse'`). */
67 > readonly type: string;
68 > /** The commands to execute for this hook type. */
69 > readonly commands: readonly IParsedHookCommand[];
70 > /** URI where this hook is defined. */
71 > readonly uri: URI;
72 > /** Original key as it appears in the hook file. */
73 > readonly originalId: string;
74 > /**
75 > * Protocol-level projection of this hook group as a child customization.
76 > * Multiple groups parsed from the same file share the same `customization.id`
77 > * so consumers can dedupe by id when collecting customizations.
78 > */
79 > readonly customization: HookCustomization;
80 > }
81 >
82 > export interface IMcpServerDefinition {
83 > readonly name: string;
84 > readonly configuration: IMcpServerConfiguration;
85 > readonly uri: URI;
86 > /** Protocol-level projection of this MCP server as a child customization. */
87 > readonly customization: McpServerCustomization;
88 > }
89 >
90 > /** A named resource (skill, agent, command, or instruction) within a plugin. */
91 > export interface INamedPluginResource {
92 > readonly uri: URI;
93 > readonly name: string;
94 > /**
95 > * Optional short description, populated for resources whose readers
96 > * parse it from the file's YAML frontmatter (e.g. agents).
97 > */
98 > readonly description?: string;
99 > }
100 >
101 > /** A parsed agent paired with its protocol-level child customization. */
102 > export interface IParsedAgent extends INamedPluginResource {
103 > readonly customization: AgentCustomization;
104 > }
105 >
106 > /** A parsed skill paired with its protocol-level child customization. */
107 > export interface IParsedSkill extends INamedPluginResource {
108 > readonly customization: SkillCustomization;
109 > }
110 >
111 > /** A parsed rule (instruction) paired with its protocol-level child customization. */
112 > export interface IParsedRule extends INamedPluginResource {
113 > readonly customization: RuleCustomization;
114 > }
115 >
116 > /** The result of parsing a single plugin directory. */
117 > export interface IParsedPlugin {
118 > readonly format: PluginFormat;
119 > readonly hooks: readonly IParsedHookGroup[];
120 > readonly mcpServers: readonly IMcpServerDefinition[];
121 > readonly skills: readonly IParsedSkill[];
122 > readonly agents: readonly IParsedAgent[];
123 > readonly instructions: readonly IParsedRule[];
124 > }
125 >
126 > // ---------------------------------------------------------------------------
127 > // Plugin format detection
128 > // ---------------------------------------------------------------------------
129 >
130 > export const enum PluginFormat {
131 > Copilot,
132 > Claude,
133 > OpenPlugin,
134 > AgentPlugin,
135 > }
136 >
137 > export interface IPluginFormatConfig {
138 > readonly format: PluginFormat;
139 > readonly manifestPath: string;
140 > readonly hookConfigPath: string;
141 > readonly componentPaths?: Readonly<Partial<Record<PluginComponent, string | false>>>;
142 > readonly requiresManifest?: boolean;
143 > readonly pluginRootTokens: readonly string[];
144 > readonly pluginRootEnvVars: readonly string[];
145 > /** Parses hooks from a JSON object using the format's conventions. */
146 > parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: URI): IParsedHookGroup[];
147 > }
148 >
149 > export type PluginComponent = 'commands' | 'skills' | 'agents' | 'rules' | 'hooks' | 'mcpServers';
150 >
151 > const COPILOT_FORMAT: IPluginFormatConfig = {
152 > format: PluginFormat.Copilot,
153 > manifestPath: 'plugin.json',
154 > hookConfigPath: 'hooks.json',
155 > pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'],
156 > pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'],
157 > parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) {
158 return parseHooksJson(hookUri, json, workspaceRoot, userHome);
159 },
161 >
162 > const CLAUDE_FORMAT: IPluginFormatConfig = {
163 > format: PluginFormat.Claude,
164 > manifestPath: '.claude-plugin/plugin.json',
165 > hookConfigPath: 'hooks/hooks.json',
166 > pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'],
167 > pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'],
168 > parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {
169 return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT');
170 },
172 >
173 > const OPEN_PLUGIN_FORMAT: IPluginFormatConfig = {
174 > format: PluginFormat.OpenPlugin,
175 > manifestPath: '.plugin/plugin.json',
176 > hookConfigPath: 'hooks/hooks.json',
177 > pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'],
178 > pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'],
179 > parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {
180 > return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); pluginParsers.ts ×5
181 > },
183 >
184 > const AGENT_PLUGIN_FORMAT: IPluginFormatConfig = {
185 > format: PluginFormat.AgentPlugin,
186 > manifestPath: 'plugin.json',
187 > hookConfigPath: '',
188 > componentPaths: {
189 > commands: false,
190 > skills: 'skills',
191 > agents: false,
192 > rules: false,
193 > hooks: false,
194 > mcpServers: 'mcp.json',
195 > },
196 > requiresManifest: true,
197 > pluginRootTokens: [],
198 > pluginRootEnvVars: [],
199 > parseHooks() {
200 return [];
201 },
203 >
204 > export async function detectPluginFormat(pluginUri: URI, fileService: IFileService): Promise<IPluginFormatConfig> { pluginParsers.ts ×1
205 > if (await readAgentPluginManifest(pluginUri, fileService)) {
206 > return AGENT_PLUGIN_FORMAT; pluginParsers.ts ×1
207 > }
208 > if (await pathExists(joinPath(pluginUri, '.plugin', 'plugin.json'), fileService)) { pluginParsers.ts ×1
209 > return OPEN_PLUGIN_FORMAT; pluginParsers.ts ×1
210 > }
212 > const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude');
213 > if (isInClaudeDirectory || await pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'), fileService)) {
214 > return CLAUDE_FORMAT; pluginParsers.ts ×1
215 > }
217 > return COPILOT_FORMAT;
218 > }
220 > export async function readPluginManifest(pluginUri: URI, format: IPluginFormatConfig, fileService: IFileService): Promise<Record<string, unknown> | undefined> { pluginParsers.ts ×7
221 > if (format.format === PluginFormat.AgentPlugin) {
222 > const manifest = await readAgentPluginManifest(pluginUri, fileService); pluginParsers.ts ×5
223 > return manifest ? { ...manifest } : undefined;
224 > }
225 > const json = await readJsonFile(joinPath(pluginUri, format.manifestPath), fileService); pluginParsers.ts ×2
226 > return json && typeof json === 'object' && !Array.isArray(json) ? json as Record<string, unknown> : undefined; pluginParsers.ts ×7
227 > }
229 > export function getPluginManifestComponent(format: IPluginFormatConfig, component: PluginComponent, manifest: Record<string, unknown> | undefined): unknown {
230 > return format.componentPaths && Object.hasOwn(format.componentPaths, component) ? undefined : manifest?.[component]; pluginParsers.ts ×7
231 > }
233 > export function resolvePluginComponentDirs(
234 > pluginUri: URI, pluginParsers.ts ×7
235 > format: IPluginFormatConfig,
236 > component: PluginComponent,
237 > fallbackPath: string,
238 > manifestSection: unknown,
239 > boundaryUri?: URI,
240 > ): readonly URI[] {
241 > const componentPath = format.componentPaths?.[component];
242 > if (format.componentPaths && Object.hasOwn(format.componentPaths, component)) {
243 > return typeof componentPath === 'string' pluginParsers.ts ×5
244 > ? resolveComponentDirs(pluginUri, componentPath, emptyComponentPathConfig, boundaryUri)
245 > : [];
246 > }
247 > return resolveComponentDirs( pluginParsers.ts ×2
248 > pluginUri,
249 > fallbackPath,
250 > parseComponentPathConfig(manifestSection),
251 > boundaryUri,
252 > );
253 > }
255 > // ---------------------------------------------------------------------------
256 > // Child customization helpers
257 > // ---------------------------------------------------------------------------
258 >
259 > /**
260 > * Mints a child-customization id from a source uri plus an optional opaque
261 > * disambiguator. Used when multiple customizations are declared inline in
262 > * a single file (e.g. two MCP servers in one `.mcp.json`, or two hook
263 > * lifecycle groups in one hook file).
264 > *
265 > * Percent-encodes any pre-existing `#` in the URI before appending the
266 > * disambiguating fragment so the resulting id can never collide with a
267 > * URI that happens to already contain a matching fragment.
268 > */
269 > function buildChildId(uri: URI, disambiguator?: string): string { pluginParsers.ts ×1
270 > const base = customizationId(uri.toString());
271 > if (!disambiguator) {
272 > return base; pluginParsers.ts ×1
273 > }
274 > return `${base.replace(/#/g, '%23')}#${disambiguator}`; pluginParsers.ts ×2
275 > }
277 > function makeAgentCustomization(resource: INamedPluginResource): AgentCustomization { pluginParsers.ts ×2
278 > const uri = resource.uri.toString();
279 > return {
280 > type: CustomizationType.Agent,
281 > id: buildChildId(resource.uri),
282 > uri,
283 > name: resource.name,
284 > ...(resource.description ? { description: resource.description } : {}),
285 > };
286 > }
288 > function makeSkillCustomization(resource: INamedPluginResource): SkillCustomization { pluginParsers.ts ×2
289 > const uri = resource.uri.toString();
290 > return {
291 > type: CustomizationType.Skill,
292 > id: buildChildId(resource.uri),
293 > uri,
294 > name: resource.name,
295 > ...(resource.description ? { description: resource.description } : {}),
296 > };
297 > }
299 function makeRuleCustomization(resource: INamedPluginResource): RuleCustomization {
300 const uri = resource.uri.toString();
301 return {
302 type: CustomizationType.Rule,
303 id: buildChildId(resource.uri),
304 uri,
305 name: resource.name,
306 ...(resource.description ? { description: resource.description } : {}),
307 };
308 }
310 > function makeHookCustomization(hookUri: URI): HookCustomization { pluginParsers.ts ×15
311 > return {
312 > type: CustomizationType.Hook,
313 > id: buildChildId(hookUri),
314 > uri: hookUri.toString(),
315 > name: basename(hookUri),
316 > };
317 > }
319 > /**
320 > * Builds the protocol {@link McpServerCustomization} for an MCP server
321 > * declared at `definitionUri` (the manifest / settings / `.mcp.json` file
322 > * the server is defined in). The id is disambiguated by server `name` so
323 > * multiple servers declared in one file get distinct ids, and the entry
324 > * carries {@link DEFAULT_MCP_APP} so MCP App support is advertised
325 > * consistently with every other MCP customization.
326 > *
327 > * The seed state is {@link McpServerStatus.Stopped}: a declared-but-not-yet
328 > * connected server has not been started by any SDK, so it must not claim to
329 > * be {@link McpServerStatus.Starting}. The live state is enriched from the
330 > * SDK's reported status once a session materializes.
331 > */
332 > export function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization {
333 > return { pluginParsers.ts ×2
334 > type: CustomizationType.McpServer,
335 > id: buildChildId(definitionUri, `mcp=${encodeURIComponent(name)}`),
336 > uri: definitionUri.toString(),
337 > name,
338 > enabled: true,
339 > state: { kind: McpServerStatus.Stopped },
340 > mcpApp: DEFAULT_MCP_APP,
341 > };
342 > }
344 > // ---------------------------------------------------------------------------
345 > // Component path config
346 > // ---------------------------------------------------------------------------
347 >
348 > export interface IComponentPathConfig {
349 > readonly paths: readonly string[];
350 > readonly exclusive: boolean;
351 > }
352 >
353 > const emptyComponentPathConfig: IComponentPathConfig = { paths: [], exclusive: false };
354 >
355 > /**
356 > * Parses a manifest component path field into a normalized config.
357 > * Supports `undefined`, `string`, `string[]`, and `{ paths: string[], exclusive?: boolean }`.
358 > */
359 > export function parseComponentPathConfig(raw: unknown): IComponentPathConfig {
360 > if (raw === undefined || raw === null) { pluginParsers.ts ×1
361 > return emptyComponentPathConfig; pluginParsers.ts ×1
362 > }
364 > if (typeof raw === 'string') {
365 > const trimmed = raw.trim(); pluginParsers.ts ×1
366 > return trimmed ? { paths: [trimmed], exclusive: false } : emptyComponentPathConfig;
367 > }
369 > if (Array.isArray(raw)) {
370 > const paths = raw pluginParsers.ts ×1
371 > .filter(v => typeof v === 'string')
372 > .map(v => v.trim())
373 > .filter(v => v.length > 0);
374 > return { paths, exclusive: false };
375 > }
377 > if (typeof raw === 'object') {
378 > const obj = raw as Record<string, unknown>; pluginParsers.ts ×1
379 > if (Array.isArray(obj['paths'])) {
380 > const paths = (obj['paths'] as unknown[])
381 > .filter(v => typeof v === 'string')
382 > .map(v => v.trim())
383 > .filter(v => v.length > 0);
384 > const exclusive = obj['exclusive'] === true;
385 > return { paths, exclusive };
386 > }
387 > }
389 > return emptyComponentPathConfig;
390 > }
392 > /**
393 > * Resolves the directories to scan for a given component type, combining
394 > * the default directory with any custom paths from the manifest config.
395 > * Paths that resolve outside the boundary are silently ignored.
396 > * @param boundaryUri The outermost directory that resolved paths must stay within. Defaults to {@link pluginUri}.
397 > */
398 > export function resolveComponentDirs(pluginUri: URI, defaultDir: string, config: IComponentPathConfig, boundaryUri?: URI): readonly URI[] {
399 > const boundary = (boundaryUri && isEqualOrParent(pluginUri, boundaryUri)) ? boundaryUri : pluginUri; pluginParsers.ts ×3
400 > const dirs: URI[] = [];
401 > if (!config.exclusive) {
402 > dirs.push(joinPath(pluginUri, defaultDir)); pluginParsers.ts ×1
403 > }
404 > for (const p of config.paths) { pluginParsers.ts ×3
405 > const resolved = normalizePath(joinPath(pluginUri, p)); pluginParsers.ts ×2
406 > if (isEqualOrParent(resolved, boundary)) {
407 > dirs.push(resolved); pluginParsers.ts ×1
408 > }
410 > return dirs; pluginParsers.ts ×3
411 > }
413 > // ---------------------------------------------------------------------------
414 > // MCP server helpers
415 > // ---------------------------------------------------------------------------
416 >
417 > /**
418 > * Extracts the MCP server map from a raw JSON value. Accepts both the
419 > * wrapped format `{ mcpServers: { … } }` and the flat format.
420 > */
421 > export function resolveMcpServersMap(raw: unknown): Record<string, unknown> | undefined {
422 > if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { pluginParsers.ts ×2
423 > return undefined; pluginParsers.ts ×1
424 > }
425 > const obj = raw as Record<string, unknown>; pluginParsers.ts ×1
426 > return Object.hasOwn(obj, 'mcpServers')
427 > ? (obj.mcpServers as Record<string, unknown>) pluginParsers.ts ×1
428 > : obj; pluginParsers.ts ×1
431 > /**
432 > * Normalizes a raw JSON value into a typed MCP server configuration.
433 > */
434 > export function normalizeMcpServerConfiguration(rawConfig: unknown): IMcpServerConfiguration | undefined {
435 > if (!rawConfig || typeof rawConfig !== 'object') { pluginParsers.ts ×6
436 > return undefined; pluginParsers.ts ×1
437 > }
439 > const candidate = rawConfig as Record<string, unknown>;
440 > const type = typeof candidate['type'] === 'string' ? candidate['type'] : undefined; pluginParsers.ts ×6
441 >
442 > const command = typeof candidate['command'] === 'string' ? candidate['command'] : undefined;
443 > const url = typeof candidate['url'] === 'string' ? candidate['url'] : undefined;
444 > const args = Array.isArray(candidate['args']) ? candidate['args'].filter((value): value is string => typeof value === 'string') : undefined;
445 > const env = candidate['env'] && typeof candidate['env'] === 'object'
446 > ? Object.fromEntries(Object.entries(candidate['env'] as Record<string, unknown>) pluginParsers.ts ×1
447 > .filter(([, value]) => typeof value === 'string' || typeof value === 'number' || value === null)
448 > .map(([key, value]) => [key, value as string | number | null]))
449 > : undefined; pluginParsers.ts ×1
450 > const envFile = typeof candidate['envFile'] === 'string' ? candidate['envFile'] : undefined; pluginParsers.ts ×6
451 > const cwd = typeof candidate['cwd'] === 'string' ? candidate['cwd'] : undefined;
452 > const headers = candidate['headers'] && typeof candidate['headers'] === 'object'
453 > ? Object.fromEntries(Object.entries(candidate['headers'] as Record<string, unknown>) pluginParsers.ts ×1
454 > .filter(([, value]) => typeof value === 'string')
455 > .map(([key, value]) => [key, value as string]))
456 > : undefined; pluginParsers.ts ×1
457 > const dev = candidate['dev'] && typeof candidate['dev'] === 'object' ? candidate['dev'] as IMcpStdioServerConfiguration['dev'] : undefined; pluginParsers.ts ×6
458 >
459 > if (type === 'ws') {
460 > return undefined; pluginParsers.ts ×1
461 > }
463 > if (type === McpServerType.LOCAL || (!type && command)) { pluginParsers.ts ×6
464 > if (!command) { pluginParsers.ts ×1
465 > return undefined; pluginParsers.ts ×1
466 > }
467 > return { type: McpServerType.LOCAL, command, args, env, envFile, cwd, dev }; pluginParsers.ts ×1
468 > }
470 > if (type === McpServerType.REMOTE || type === 'streamable-http' || type === 'sse' || (!type && url)) { pluginParsers.ts ×6
471 > if (!url) { pluginParsers.ts ×3
472 return undefined;
473 }
474 > return { type: McpServerType.REMOTE, url, headers, dev }; pluginParsers.ts ×3
475 > }
476
477 return undefined;
478 }
480 > /**
481 > * Characters in a file path that require shell quoting to prevent
482 > * word splitting or interpretation by common shells.
483 > */
484 > const shellUnsafeChars = /[\s&|<>()^;!`"']/;
485 >
486 > /**
487 > * Replaces a plugin-root token in a shell command string with the
488 > * given fsPath, shell-quoting if the path contains special characters.
489 > */
490 > export function shellQuotePluginRootInCommand(command: string, fsPath: string, token: string) {
491 > if (!command.includes(token)) { pluginParsers.ts ×1
492 > return command; pluginParsers.ts ×1
493 > }
495 > if (!shellUnsafeChars.test(fsPath)) {
496 > return command.replaceAll(token, fsPath); pluginParsers.ts ×1
497 > }
499 > const escapedToken = escapeRegExpCharacters(token);
500 > const pattern = new RegExp(
501 > `(["']?)` + escapedToken + `([\\w./\\\\~:-]*)`,
502 > 'g',
503 > );
504 >
505 > return command.replace(pattern, (_match, leadingQuote: string, suffix: string) => {
506 > const fullPath = fsPath + suffix;
507 > if (leadingQuote) {
508 > return leadingQuote + fullPath; pluginParsers.ts ×1
509 > }
510 > return '"' + fullPath.replace(/"/g, '\\"') + '"'; pluginParsers.ts ×1
512 > }
514 > /**
515 > * Replaces plugin-root token references in MCP server definition string fields
516 > * with the plugin root filesystem path.
517 > */
518 > export function interpolateMcpPluginRoot(
519 > def: IMcpServerDefinition, pluginParsers.ts ×3
520 > fsPath: string,
521 > tokens: readonly string[],
522 > envVars: readonly string[],
523 > ): IMcpServerDefinition {
524 > const replace = (s: string) => tokens.reduce((result, token) => result.replaceAll(token, fsPath), s);
525 >
526 > const config = def.configuration;
527 > let interpolated: IMcpServerConfiguration;
528 >
529 > if (config.type === McpServerType.LOCAL) {
530 > const local: Mutable<IMcpStdioServerConfiguration> = { ...config }; pluginParsers.ts ×6
531 > local.command = replace(local.command);
532 > if (local.args) {
533 > local.args = local.args.map(replace); pluginParsers.ts ×1
534 > }
535 > if (local.cwd) { pluginParsers.ts ×6
536 > local.cwd = replace(local.cwd); pluginParsers.ts ×2
537 > }
538 > local.env = { ...local.env }; pluginParsers.ts ×6
539 > for (const [k, v] of Object.entries(local.env)) {
540 > if (typeof v === 'string') { pluginParsers.ts ×2
541 > local.env[k] = replace(v);
542 > }
543 > }
544 > for (const envVar of envVars) { pluginParsers.ts ×6
545 > local.env[envVar] = fsPath; pluginParsers.ts ×1
546 > }
547 > if (local.envFile) { pluginParsers.ts ×6
548 local.envFile = replace(local.envFile);
549 }
550 > interpolated = local; pluginParsers.ts ×6
551 > } else { pluginParsers.ts ×3
552 > const remote: Mutable<IMcpRemoteServerConfiguration> = { ...config }; pluginParsers.ts ×2
553 > remote.url = replace(remote.url);
554 > if (remote.headers) {
555 remote.headers = Object.fromEntries(
556 Object.entries(remote.headers).map(([k, v]) => [k, replace(v)])
557 );
558 }
559 > interpolated = remote; pluginParsers.ts ×2
560 > }
562 > return { name: def.name, configuration: interpolated, uri: def.uri, customization: def.customization };
563 > }
565 > /**
566 > * Regex matching bare `${VAR_NAME}` references (uppercase only) that are NOT
567 > * using VS Code's `${env:VAR}` colon-delimited syntax.
568 > */
569 > const BARE_ENV_VAR_RE = /\$\{(?![A-Za-z]+:)([A-Z_][A-Z0-9_]*)\}/g;
570 >
571 > /**
572 > * Converts bare `${VAR}` environment-variable references to VS Code `${env:VAR}` syntax.
573 > */
574 > export function convertBareEnvVarsToVsCodeSyntax(
575 > def: IMcpServerDefinition, pluginParsers.ts ×1
576 > ): IMcpServerDefinition {
577 > return cloneAndChange(def, (value) => {
578 > if (URI.isUri(value)) {
579 > return value;
580 > }
581 > if (typeof value === 'string') {
582 > const replaced = value.replace(BARE_ENV_VAR_RE, '${env:$1}');
583 > return replaced !== value ? replaced : undefined;
584 > }
585 > return undefined;
586 > });
587 > }
589 > // ---------------------------------------------------------------------------
590 > // Hook parsing helpers
591 > // ---------------------------------------------------------------------------
592 >
593 > /**
594 > * Maps known hook type identifiers from all formats (VS Code PascalCase,
595 > * Copilot CLI camelCase, Claude PascalCase) to canonical identifiers.
596 > */
597 > const HOOK_TYPE_MAP: Record<string, string> = {
598 > // PascalCase (VS Code / Claude)
599 > 'SessionStart': 'SessionStart',
600 > 'SessionEnd': 'SessionEnd',
601 > 'UserPromptSubmit': 'UserPromptSubmit',
602 > 'PreToolUse': 'PreToolUse',
603 > 'PostToolUse': 'PostToolUse',
604 > 'PreCompact': 'PreCompact',
605 > 'SubagentStart': 'SubagentStart',
606 > 'SubagentStop': 'SubagentStop',
607 > 'Stop': 'Stop',
608 > 'ErrorOccurred': 'ErrorOccurred',
609 > // camelCase (GitHub Copilot CLI)
610 > 'sessionStart': 'SessionStart',
611 > 'sessionEnd': 'SessionEnd',
612 > 'userPromptSubmitted': 'UserPromptSubmit',
613 > 'preToolUse': 'PreToolUse',
614 > 'postToolUse': 'PostToolUse',
615 > 'agentStop': 'Stop',
616 > 'subagentStop': 'SubagentStop',
617 > 'errorOccurred': 'ErrorOccurred',
618 > };
619 >
620 > /**
621 > * Normalizes a raw hook command object, validating structure and mapping
622 > * legacy `bash`/`powershell` fields to platform-specific overrides.
623 > */
624 > function normalizeHookCommand(raw: Record<string, unknown>): IParsedHookCommand | undefined { pluginParsers.ts ×15
625 > // Allow omitted type (Claude compatibility) — treat as 'command'
626 > if (raw.type !== undefined && raw.type !== 'command') {
627 > return undefined; pluginParsers.ts ×2
628 > }
630 > const hasCommand = typeof raw.command === 'string' && raw.command.length > 0;
631 > const hasBash = typeof raw.bash === 'string' && (raw.bash as string).length > 0;
632 > const hasPowerShell = typeof raw.powershell === 'string' && (raw.powershell as string).length > 0;
633 > const hasWindows = typeof raw.windows === 'string' && (raw.windows as string).length > 0;
634 > const hasLinux = typeof raw.linux === 'string' && (raw.linux as string).length > 0;
635 > const hasOsx = typeof raw.osx === 'string' && (raw.osx as string).length > 0;
636 >
637 > if (!hasCommand && !hasBash && !hasPowerShell && !hasWindows && !hasLinux && !hasOsx) {
638 return undefined;
639 }
641 > const windows = hasWindows ? raw.windows as string : (hasPowerShell ? raw.powershell as string : undefined);
642 > const linux = hasLinux ? raw.linux as string : (hasBash ? raw.bash as string : undefined);
643 > const osx = hasOsx ? raw.osx as string : (hasBash ? raw.bash as string : undefined);
644 >
645 > const timeout = typeof raw.timeout === 'number'
646 ? raw.timeout
647 > : (typeof raw.timeoutSec === 'number' ? raw.timeoutSec : undefined); pluginParsers.ts ×15
648 >
649 > return {
650 > ...(hasCommand && { command: raw.command as string }),
651 > ...(windows && { windows }),
652 > ...(linux && { linux }),
653 > ...(osx && { osx }),
654 > ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record<string, string> }),
655 > ...(timeout !== undefined && { timeout }),
656 > };
657 > }
659 > /**
660 > * Resolves a raw hook command JSON object into a {@link IParsedHookCommand},
661 > * normalizing fields and resolving the working directory.
662 > */
663 > function resolveHookCommand(raw: Record<string, unknown>, workspaceRoot: URI | undefined, userHome: URI): IParsedHookCommand | undefined { pluginParsers.ts ×15
664 > const normalized = normalizeHookCommand(raw);
665 > if (!normalized) {
666 > return undefined; pluginParsers.ts ×2
667 > }
669 > let cwdUri: URI | undefined;
670 > const rawCwd = typeof raw.cwd === 'string' ? raw.cwd : undefined;
671 > if (rawCwd) {
672 if (rawCwd.startsWith('~/')) {
673 cwdUri = URI.joinPath(userHome, rawCwd.substring(2));
674 } else if (isAbsolute(rawCwd)) {
675 cwdUri = URI.file(rawCwd);
676 } else if (workspaceRoot) {
677 cwdUri = joinPath(workspaceRoot, rawCwd);
678 }
679 > } else { pluginParsers.ts ×15
680 > cwdUri = workspaceRoot;
681 > }
682 >
683 > return { ...normalized, cwd: cwdUri };
684 > }
686 > /**
687 > * Extracts hook commands from an item that may be a direct command object
688 > * or a nested structure with a `matcher` (Claude format).
689 > */
690 > function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, userHome: URI): IParsedHookCommand[] { pluginParsers.ts ×15
691 > if (!item || typeof item !== 'object') {
692 return [];
693 }
695 > const itemObj = item as Record<string, unknown>;
696 > const commands: IParsedHookCommand[] = [];
697 >
698 > // Nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] }
699 > const nestedHooks = itemObj.hooks;
700 > if (nestedHooks !== undefined && Array.isArray(nestedHooks)) {
701 > for (const nested of nestedHooks) { pluginParsers.ts ×2
702 > if (!nested || typeof nested !== 'object') {
703 continue;
704 }
705 > const resolved = resolveHookCommand(nested as Record<string, unknown>, workspaceRoot, userHome); pluginParsers.ts ×2
706 > if (resolved) {
707 > commands.push(resolved);
708 > }
709 > }
710 > } else { pluginParsers.ts ×15
711 > const resolved = resolveHookCommand(itemObj, workspaceRoot, userHome); pluginParsers.ts ×1
712 > if (resolved) {
713 > commands.push(resolved);
714 > }
715 > }
717 > return commands;
718 > }
720 > /**
721 > * Parses hooks from a JSON object (any supported format).
722 > *
723 > * Handles Claude's `disableAllHooks` short-circuit, the `HOOK_TYPE_MAP`
724 > * canonicalization, and the nested `{ matcher, hooks: [...] }` command
725 > * form. Returns one {@link IParsedHookGroup} per recognized lifecycle
726 > * event; all groups parsed from the same file share a single
727 > * {@link IParsedHookGroup.customization} (keyed on `hookUri`), so callers
728 > * that only need the file-level customization can read it off any group.
729 > */
730 > export function parseHooksJson(
731 > hookUri: URI, pluginParsers.ts ×3
732 > json: unknown,
733 > workspaceRoot: URI | undefined,
734 > userHome: URI,
735 > ): IParsedHookGroup[] {
736 > if (!json || typeof json !== 'object') {
737 > return []; pluginParsers.ts ×1
738 > }
740 > const root = json as Record<string, unknown>;
741 >
742 > // Claude's disableAllHooks
743 > if (root.disableAllHooks === true) {
744 > return []; pluginParsers.ts ×1
745 > }
747 > const hooks = root.hooks;
748 > if (!hooks || typeof hooks !== 'object') { pluginParsers.ts ×3
749 > return []; pluginParsers.ts ×1
750 > }
752 > const hooksObj = hooks as Record<string, unknown>;
753 > const result: IParsedHookGroup[] = [];
754 > const customization = makeHookCustomization(hookUri);
755 >
756 > for (const originalId of Object.keys(hooksObj)) {
757 > const canonicalType = HOOK_TYPE_MAP[originalId];
758 > if (!canonicalType) {
759 > continue; pluginParsers.ts ×1
760 > }
762 > const hookArray = hooksObj[originalId];
763 > if (!Array.isArray(hookArray)) {
764 continue;
765 }
767 > const commands: IParsedHookCommand[] = [];
768 > for (const item of hookArray) {
769 > commands.push(...extractHookCommands(item, workspaceRoot, userHome));
770 > }
771 >
772 > if (commands.length > 0) {
773 > result.push({ type: canonicalType, commands, uri: hookUri, originalId, customization });
774 > }
775 > }
776 >
777 > return result;
778 > }
780 > /**
781 > * Applies plugin-root token interpolation to hook commands for
782 > * Claude and OpenPlugin formats.
783 > */
784 > export function interpolateHookPluginRoot(
785 > hookUri: URI, pluginParsers.ts ×5
786 > json: unknown,
787 > pluginUri: URI,
788 > workspaceRoot: URI | undefined,
789 > userHome: URI,
790 > token: string,
791 > envVar: string,
792 > ): IParsedHookGroup[] {
793 > const fsPath = pluginUri.fsPath;
794 > const typedJson = json as { hooks?: Record<string, unknown[]> };
795 >
796 > const mutateHookCommand = (hook: Record<string, unknown>): void => {
797 > for (const field of ['command', 'windows', 'linux', 'osx'] as const) {
798 > if (typeof hook[field] === 'string') {
799 > hook[field] = shellQuotePluginRootInCommand(hook[field] as string, fsPath, token);
800 > }
801 > }
802 >
803 > if (!hook.env || typeof hook.env !== 'object') {
804 > hook.env = {};
805 > }
806 > (hook.env as Record<string, string>)[envVar] = fsPath;
807 > };
808 >
809 > for (const lifecycle of Object.values(typedJson.hooks ?? {})) {
810 > if (!Array.isArray(lifecycle)) {
811 continue;
812 }
813 > for (const lifecycleEntry of lifecycle) { pluginParsers.ts ×5
814 > if (!lifecycleEntry || typeof lifecycleEntry !== 'object') {
815 continue;
816 }
817 > const entry = lifecycleEntry as { hooks?: Record<string, unknown>[] } & Record<string, unknown>; pluginParsers.ts ×5
818 > if (Array.isArray(entry.hooks)) {
819 > for (const hook of entry.hooks) {
820 > mutateHookCommand(hook);
821 > }
822 > } else {
823 mutateHookCommand(entry);
824 }
826 > }
827 >
828 > const replacer = (v: unknown): unknown => {
829 > return typeof v === 'string'
830 > ? v.replaceAll(token, pluginUri.fsPath)
831 > : undefined;
832 > };
833 >
834 > return parseHooksJson(hookUri, cloneAndChange(json, replacer), workspaceRoot, userHome);
835 > }
837 > // ---------------------------------------------------------------------------
838 > // Filesystem helpers
839 > // ---------------------------------------------------------------------------
840 >
841 > export async function readJsonFile(uri: URI, fileService: IFileService): Promise<unknown | undefined> { pluginParsers.ts ×3
842 > try {
843 > const fileContents = await fileService.readFile(uri);
844 > return parseJSONC(fileContents.value.toString()); pluginParsers.ts ×1
845 > } catch { pluginParsers.ts ×3
846 > return undefined; pluginParsers.ts ×1
847 > }
850 > export async function pathExists(resource: URI, fileService: IFileService): Promise<boolean> { pluginParsers.ts ×3
851 > try {
852 > await fileService.resolve(resource);
853 > return true; pluginParsers.ts ×1
854 > } catch { pluginParsers.ts ×3
855 > return false; pluginParsers.ts ×1
856 > }
859 > // ---------------------------------------------------------------------------
860 > // Component readers
861 > // ---------------------------------------------------------------------------
862 >
863 > const COMMAND_FILE_SUFFIX = '.md';
864 > const RULE_FILE_SUFFIX = '.mdc';
865 > const INSTRUCTION_FILE_SUFFIX = '.instructions.md';
866 >
867 > export async function readSkills( pluginParsers.ts ×9
868 > pluginRoot: URI,
869 > dirs: readonly URI[],
870 > fileService: IFileService,
871 > options?: { readonly childDirectoriesOnly?: boolean; readonly containmentRoot?: URI },
872 > ): Promise<readonly INamedPluginResource[]> {
873 > const seen = new Set<string>();
874 > const skills: INamedPluginResource[] = [];
875 >
876 > const addSkill = async (name: string, skillMd: URI) => {
877 > if (options?.containmentRoot && !await isResolvedWithin(options.containmentRoot, skillMd, fileService)) { pluginParsers.ts ×3
878 > return; pluginParsers.ts ×1
879 > }
880 > let description: string | undefined; pluginParsers.ts ×3
881 > try {
882 > const parsedInfo = await parseSkillFile(skillMd, fileService);
883 > description = parsedInfo.description;
884 > name = parsedInfo.name || name;
885 > } catch { pluginParsers.ts ×3
886 // Keep the existing best-effort discovery behavior for malformed skills.
887 }
888 > if (seen.has(name)) { pluginParsers.ts ×3
889 return;
890 }
891 > seen.add(name); pluginParsers.ts ×3
892 > skills.push({ uri: skillMd, name, ...(description ? { description } : {}) }); pluginParsers.ts ×3
893 > };
895 > await Promise.all(dirs.map(async dir => {
896 > if (!options?.childDirectoriesOnly) {
897 > const skillMd = URI.joinPath(dir, 'SKILL.md'); pluginParsers.ts ×5
898 > if (await pathExists(skillMd, fileService)) {
899 > await addSkill(basename(dir), skillMd); pluginParsers.ts ×1
900 > return;
901 > }
904 > let stat;
905 > try {
906 > stat = await fileService.resolve(dir);
907 > } catch {
908 > return; pluginParsers.ts ×1
909 > }
911 > if (!stat.isDirectory || !stat.children) { pluginParsers.ts ×9
912 return;
913 }
915 > await Promise.all(stat.children.map(async child => {
916 > const childSkillMd = URI.joinPath(child.resource, 'SKILL.md');
917 > if (await pathExists(childSkillMd, fileService)) {
918 > await addSkill(basename(child.resource), childSkillMd);
919 > }
920 > }));
922 >
923 > if (!options?.childDirectoriesOnly && skills.length === 0) {
924 > const rootSkillMd = URI.joinPath(pluginRoot, 'SKILL.md'); pluginParsers.ts ×2
925 > if (await pathExists(rootSkillMd, fileService)) {
926 > await addSkill(basename(pluginRoot), rootSkillMd); pluginParsers.ts ×1
927 > }
930 > skills.sort((a, b) => a.name.localeCompare(b.name));
931 > return skills;
932 > }
934 > export async function readPluginSkills(pluginRoot: URI, dirs: readonly URI[], format: IPluginFormatConfig, fileService: IFileService): Promise<readonly INamedPluginResource[]> { pluginParsers.ts ×7
935 > return readSkills(pluginRoot, dirs, fileService, format.format === PluginFormat.AgentPlugin
936 > ? { childDirectoriesOnly: true, containmentRoot: pluginRoot } pluginParsers.ts ×5
937 > : undefined); pluginParsers.ts ×7
938 > }
940 > async function isResolvedWithin(root: URI, resource: URI, fileService: IFileService): Promise<boolean> { pluginParsers.ts ×5
941 > try {
942 > const [resolvedRoot, resolvedResource] = await Promise.all([
943 > fileService.realpath(root),
944 > fileService.realpath(resource),
945 > ]);
946 > return isEqualOrParent(resolvedResource ?? normalizePath(resource), resolvedRoot ?? normalizePath(root));
947 > } catch {
948 return false;
949 }
952 > export async function readMarkdownComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> { pluginParsers.ts ×9
953 > const seen = new Set<string>();
954 > const items: INamedPluginResource[] = [];
955 >
956 > const addItem = (name: string, uri: URI) => {
957 > if (!seen.has(name)) { pluginParsers.ts ×2
958 > seen.add(name);
959 > items.push({ uri, name });
960 > }
961 > };
963 > for (const dir of dirs) {
964 > let stat; pluginParsers.ts ×5
965 > try {
966 > stat = await fileService.resolve(dir);
967 > } catch {
968 > continue; pluginParsers.ts ×1
969 > }
971 > if (stat.isFile && extname(dir).toLowerCase() === COMMAND_FILE_SUFFIX) { pluginParsers.ts ×5
972 > addItem(basename(dir).slice(0, -COMMAND_FILE_SUFFIX.length), dir); pluginParsers.ts ×1
973 > continue;
974 > }
976 > if (!stat.isDirectory || !stat.children) { pluginParsers.ts ×5
977 continue;
978 }
980 > for (const child of stat.children) {
981 > if (!child.isFile || extname(child.resource).toLowerCase() !== COMMAND_FILE_SUFFIX) {
982 continue;
983 }
984 > addItem(basename(child.resource).slice(0, -COMMAND_FILE_SUFFIX.length), child.resource); pluginParsers.ts ×3
985 > }
986 > }
988 > items.sort((a, b) => a.name.localeCompare(b.name));
989 > return items;
990 > }
992 function getInstructionFileName(resource: URI): string | undefined {
993 const fileName = basename(resource);
994 const lowerName = fileName.toLowerCase();
995 if (lowerName.endsWith(RULE_FILE_SUFFIX)) {
996 return fileName.slice(0, -RULE_FILE_SUFFIX.length);
997 }
998 if (lowerName.endsWith(INSTRUCTION_FILE_SUFFIX)) {
999 return fileName.slice(0, -INSTRUCTION_FILE_SUFFIX.length);
1000 }
1001 return undefined;
1002 }
1004 > /**
1005 > * Reads rule/instruction files from plugin `rules` component directories.
1006 > *
1007 > * Open Plugins rules are conventionally `.mdc` files. We also accept
1008 > * `.instructions.md` for compatibility with VS Code-discovered instructions
1009 > * bundled as synthetic plugins.
1010 > */
1011 > export async function readInstructionComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> { pluginParsers.ts ×11
1012 > const seen = new Set<string>();
1013 > const items: INamedPluginResource[] = [];
1014 >
1015 > const addItem = (name: string, uri: URI) => {
1016 if (!seen.has(name)) {
1017 seen.add(name);
1018 items.push({ uri, name });
1019 }
1020 };
1022 > for (const dir of dirs) {
1023 > let stat; pluginParsers.ts ×3
1024 > try {
1025 > stat = await fileService.resolve(dir);
1026 > } catch {
1027 > continue;
1028 > }
1029
1030 if (stat.isFile) {
1031 const instructionName = getInstructionFileName(dir);
1032 if (instructionName) {
1033 addItem(instructionName, dir);
1034 }
1035 continue;
1036 }
1037
1038 > if (!stat.isDirectory || !stat.children) { pluginParsers.ts ×3
1039 continue;
1040 }
1041
1042 for (const child of stat.children) {
1043 if (!child.isFile) {
1044 continue;
1045 }
1046 const instructionName = getInstructionFileName(child.resource);
1047 if (instructionName) {
1048 addItem(instructionName, child.resource);
1049 }
1050 }
1051 }
1053 > items.sort((a, b) => a.name.localeCompare(b.name));
1054 > return items;
1055 > }
1057 > /**
1058 > * Reads `.md` files in agent directories and enriches each entry with
1059 > * the optional `name` / `description` from YAML frontmatter. Falls back
1060 > * to the file-derived name when frontmatter is missing or unreadable.
1061 > */
1062 > export async function readAgentComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> { pluginParsers.ts ×1
1063 > const files = await readMarkdownComponents(dirs, fileService);
1064 > if (files.length === 0) {
1065 > return files; pluginParsers.ts ×1
1066 > }
1067 > const enriched = await Promise.all(files.map(async file => { pluginParsers.ts ×3
1068 > try {
1069 > const { name, description } = await parseAgentFile(file.uri, fileService);
1070 > return {
1071 > uri: file.uri,
1072 > name: name || file.name,
1073 > ...(description ? { description } : {}),
1074 > } satisfies INamedPluginResource;
1075 > } catch {
1076 return file;
1077 }
1078 > })); pluginParsers.ts ×3
1079 > // De-dupe again in case frontmatter `name` collides; first-seen wins.
1080 > const seen = new Set<string>();
1081 > const result: INamedPluginResource[] = [];
1082 > for (const item of enriched) {
1083 > if (seen.has(item.name)) {
1084 continue;
1085 }
1086 > seen.add(item.name); pluginParsers.ts ×3
1087 > result.push(item);
1088 > }
1089 > result.sort((a, b) => a.name.localeCompare(b.name));
1090 > return result;
1091 > }
1093 > export async function parseAgentFile(uri: URI, fileService: IFileService): Promise<{ name: string; description?: string; userInvocable?: boolean }> { pluginParsers.ts ×3
1094 > // 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.
1095 > const nameFromFile = basename(uri).replace(/(\.agent)?\.md$/i, '');
1096 > try {
1097 > const content = await fileService.readFile(uri);
1098 > const frontmatter = parseFrontMatter(content.value.toString()); pluginParsers.ts ×1
1099 > const name = frontmatter?.getStringValue('name')?.trim() || nameFromFile; pluginParsers.ts ×3
1100 > const description = frontmatter?.getStringValue('description')?.trim();
1101 > const userInvocable = frontmatter?.getBooleanValue('user-invocable');
1102 > return { name, description, userInvocable };
1103 > } catch {
1104 > return { name: nameFromFile }; pluginParsers.ts ×1
1105 > }
1108 > export async function parseSkillFile(uri: URI, fileService: IFileService): Promise<{ name: string; description?: string; userInvokable?: boolean }> { pluginParsers.ts ×2
1109 > try {
1110 > const content = await fileService.readFile(uri);
1111 > const frontmatter = parseFrontMatter(content.value.toString());
1112 > const name = frontmatter?.getStringValue('name')?.trim() || basename(dirname(uri));
1113 > const description = frontmatter?.getStringValue('description')?.trim();
1114 > const userInvokable = frontmatter?.getBooleanValue('user-invocable');
1115 > return { name, description, userInvokable };
1116 > } catch {
1117 return { name: basename(dirname(uri)) };
1118 }
1121 > export async function parseRuleFile(uri: URI, fileService: IFileService): Promise<{ name: string; description?: string; globs?: string[]; alwaysApply?: boolean }> { pluginParsers.ts ×2
1122 > const nameFromFile = basename(uri).replace(/(\.instructions)?\.md$/i, '');
1123 > try {
1124 > const content = await fileService.readFile(uri);
1125 > const frontmatter = parseFrontMatter(content.value.toString());
1126 > const name = frontmatter?.getStringValue('name')?.trim() || nameFromFile;
1127 > const description = frontmatter?.getStringValue('description')?.trim();
1128 > const globs = frontmatter?.getStringArrayValue('globs') ?? frontmatter?.getStringArrayValue('applyTo') ?? frontmatter?.getStringArrayValue('paths') ?? undefined;
1129 > const alwaysApply = frontmatter?.getBooleanValue('alwaysApply');
1130 > return { name, description, globs, alwaysApply };
1131 > } catch {
1132 return { name: nameFromFile };
1133 }
1136 > async function readHooks( pluginParsers.ts ×11
1137 > pluginUri: URI,
1138 > paths: readonly URI[],
1139 > formatConfig: IPluginFormatConfig,
1140 > fileService: IFileService,
1141 > workspaceRoot: URI | undefined,
1142 > userHome: URI,
1143 > ): Promise<readonly IParsedHookGroup[]> {
1144 > for (const hookPath of paths) {
1145 > const json = await readJsonFile(hookPath, fileService); pluginParsers.ts ×3
1146 > if (!json) {
1147 > continue;
1148 > }
1149
1150 return formatConfig.parseHooks(hookPath, json, pluginUri, workspaceRoot, userHome);
1151 }
1152 > return []; pluginParsers.ts ×11
1153 > }
1155 > async function readMcpServers( pluginParsers.ts ×4
1156 > pluginUri: URI,
1157 > paths: readonly URI[],
1158 > formatConfig: IPluginFormatConfig,
1159 > fileService: IFileService,
1160 > ): Promise<readonly IMcpServerDefinition[]> {
1161 > const merged = new Map<string, IMcpServerDefinition>();
1162 > for (const mcpPath of paths) {
1163 > if (formatConfig.format === PluginFormat.AgentPlugin && !await isResolvedWithin(pluginUri, mcpPath, fileService)) {
1164 continue;
1165 }
1166 > const json = await readJsonFile(mcpPath, fileService); pluginParsers.ts ×4
1167 > for (const def of parseMcpServerDefinitionMap(mcpPath, json, pluginUri.fsPath, formatConfig)) {
1168 > if (!merged.has(def.name)) { pluginParsers.ts ×1
1169 > merged.set(def.name, def);
1170 > }
1171 > }
1173 > return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
1174 > }
1176 > export async function readPluginMcpServers( pluginParsers.ts ×4
1177 > pluginUri: URI,
1178 > paths: readonly URI[],
1179 > format: IPluginFormatConfig,
1180 > fileService: IFileService,
1181 > ): Promise<readonly IMcpServerDefinition[]> {
1182 > return readMcpServers(pluginUri, paths, format, fileService);
1183 > }
1185 > export function parseMcpServerDefinitionMap(
1186 > definitionURI: URI, pluginParsers.ts ×7
1187 > raw: unknown,
1188 > pluginFsPath: string,
1189 > formatConfig: IPluginFormatConfig,
1190 > ): IMcpServerDefinition[] {
1191 > const mcpServers = resolveMcpServersMap(raw);
1192 > if (!mcpServers) {
1193 > return []; pluginParsers.ts ×1
1194 > }
1196 > const definitions: IMcpServerDefinition[] = [];
1197 > for (const [name, configValue] of Object.entries(mcpServers)) {
1198 > const configuration = normalizeMcpServerConfiguration(configValue);
1199 > if (!configuration) {
1200 continue;
1201 }
1203 > let def: IMcpServerDefinition = {
1204 > name,
1205 > configuration,
1206 > uri: definitionURI,
1207 > customization: makeMcpServerCustomization(definitionURI, name),
1208 > };
1209 > def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootTokens, formatConfig.pluginRootEnvVars);
1210 > if (formatConfig.format !== PluginFormat.AgentPlugin && def.configuration.type === McpServerType.LOCAL && def.configuration.cwd === undefined) {
1211 > def = { ...def, configuration: { ...def.configuration, cwd: pluginFsPath } }; pluginParsers.ts ×1
1212 > }
1213 > if (formatConfig.format !== PluginFormat.AgentPlugin) { pluginParsers.ts ×4
1214 > def = convertBareEnvVarsToVsCodeSyntax(def); pluginParsers.ts ×1
1215 > }
1216 > definitions.push(def); pluginParsers.ts ×4
1217 > }
1218 >
1219 > return definitions;
1220 > }
1222 > // ---------------------------------------------------------------------------
1223 > // Top-level parse function
1224 > // ---------------------------------------------------------------------------
1225 >
1226 > /**
1227 > * Parses a plugin directory to extract hooks, MCP servers, skills, agents,
1228 > * and instructions.
1229 > * This is the main entry point for the agent host to discover plugin contents.
1230 > */
1231 > export async function parsePlugin( pluginParsers.ts ×11
1232 > pluginUri: URI,
1233 > fileService: IFileService,
1234 > workspaceRoot: URI | undefined,
1235 > userHome: URI,
1236 > boundaryUri?: URI,
1237 > ): Promise<IParsedPlugin> {
1238 > const formatConfig = await detectPluginFormat(pluginUri, fileService);
1239 >
1240 > // Read manifest
1241 > const manifest = await readPluginManifest(pluginUri, formatConfig, fileService);
1242 > if (formatConfig.requiresManifest && !manifest) {
1243 throw new Error(`Plugin manifest '${joinPath(pluginUri, formatConfig.manifestPath).toString()}' is missing`);
1244 }
1246 > // Resolve component directories from manifest
1247 > const hookDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'hooks', formatConfig.hookConfigPath, manifest?.['hooks'], boundaryUri);
1248 > const mcpDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'mcpServers', '.mcp.json', manifest?.['mcpServers'], boundaryUri);
1249 > const skillDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'skills', 'skills', manifest?.['skills'], boundaryUri);
1250 > const agentDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'agents', 'agents', manifest?.['agents'], boundaryUri);
1251 > const instructionDirs = resolvePluginComponentDirs(pluginUri, formatConfig, 'rules', 'rules', manifest?.['rules'], boundaryUri);
1252 >
1253 > // Handle embedded MCP servers in manifest
1254 > let embeddedMcp: IMcpServerDefinition[] = [];
1255 > const mcpSection = getPluginManifestComponent(formatConfig, 'mcpServers', manifest);
1256 > if (mcpSection && typeof mcpSection === 'object' && !Array.isArray(mcpSection) && !(hasKey(mcpSection, { paths: true }))) {
1257 embeddedMcp = parseMcpServerDefinitionMap(
1258 joinPath(pluginUri, formatConfig.manifestPath),
1259 { mcpServers: mcpSection },
1260 pluginUri.fsPath,
1261 formatConfig,
1262 );
1263 }
1265 > // Handle embedded hooks in manifest
1266 > let embeddedHooks: IParsedHookGroup[] = [];
1267 > const hooksSection = getPluginManifestComponent(formatConfig, 'hooks', manifest);
1268 > if (hooksSection && typeof hooksSection === 'object' && !Array.isArray(hooksSection) && !(hasKey(hooksSection, { paths: true }))) {
1269 const manifestUri = joinPath(pluginUri, formatConfig.manifestPath);
1270 embeddedHooks = formatConfig.parseHooks(manifestUri, { hooks: hooksSection }, pluginUri, workspaceRoot, userHome);
1271 }
1273 > const [hooks, mcpServers, skills, agents, instructions] = await Promise.all([
1274 > embeddedHooks.length > 0
1275 ? Promise.resolve(embeddedHooks)
1276 > : readHooks(pluginUri, hookDirs, formatConfig, fileService, workspaceRoot, userHome), pluginParsers.ts ×11
1277 > embeddedMcp.length > 0
1278 ? Promise.resolve(embeddedMcp)
1279 > : readPluginMcpServers(pluginUri, mcpDirs, formatConfig, fileService), pluginParsers.ts ×11
1280 > readPluginSkills(pluginUri, skillDirs, formatConfig, fileService),
1281 > readAgentComponents(agentDirs, fileService),
1282 > readInstructionComponents(instructionDirs, fileService),
1283 > ]);
1284 >
1285 > return {
1286 > format: formatConfig.format,
1287 > hooks,
1288 > mcpServers,
1289 > skills: skills.map(toParsedSkill),
1290 > agents: agents.map(toParsedAgent),
1291 > instructions: instructions.map(toParsedRule),
1292 > };
1293 > }
1295 > /** Pairs an agent {@link INamedPluginResource} with its protocol-level {@link AgentCustomization}. */
1296 > export function toParsedAgent(resource: INamedPluginResource): IParsedAgent {
1297 > return { ...resource, customization: makeAgentCustomization(resource) }; pluginParsers.ts ×2
1298 > }
1300 > /** Pairs a skill {@link INamedPluginResource} with its protocol-level {@link SkillCustomization}. */
1301 > export function toParsedSkill(resource: INamedPluginResource): IParsedSkill {
1302 > return { ...resource, customization: makeSkillCustomization(resource) }; pluginParsers.ts ×2
1303 > }
1305 function toParsedRule(resource: INamedPluginResource): IParsedRule {
1306 return { ...resource, customization: makeRuleCustomization(resource) };
1307 }