Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentPlugins/common/pluginParsers.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
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 } from '../../../base/common/objects.js';
8
import { isAbsolute } from '../../../base/common/path.js';
9
import { untildify } from '../../../base/common/labels.js';
10
import { basename, extname, isEqualOrParent, joinPath, normalizePath } from '../../../base/common/resources.js';
11
import { escapeRegExpCharacters } from '../../../base/common/strings.js';
12
import { hasKey, Mutable } from '../../../base/common/types.js';
13
import { URI } from '../../../base/common/uri.js';
14
import { IFileService } from '../../files/common/files.js';
15
import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../mcp/common/mcpPlatformTypes.js';
16
17
// ---------------------------------------------------------------------------
18
// Types
19
// ---------------------------------------------------------------------------
20
21
/** A single hook command to execute. Platform resolution happens at conversion time. */
22
export interface IParsedHookCommand {
23
/** Cross-platform default command. */
24
readonly command?: string;
25
/** Windows-specific command. */
26
readonly windows?: string;
27
/** Linux-specific command. */
28
readonly linux?: string;
29
/** macOS-specific command. */
30
readonly osx?: string;
31
/** Working directory. */
32
readonly cwd?: URI;
33
/** Environment variables. */
34
readonly env?: Record<string, string>;
35
/** Timeout in seconds. */
36
readonly timeout?: number;
37
/** URI of the file this hook was defined in. */
38
readonly sourceUri?: URI;
39
}
40
41
/** A group of hooks for a single lifecycle event. */
42
export interface IParsedHookGroup {
43
/** Canonical hook type identifier (e.g. `'SessionStart'`, `'PreToolUse'`). */
44
readonly type: string;
45
/** The commands to execute for this hook type. */
46
readonly commands: readonly IParsedHookCommand[];
47
/** URI where this hook is defined. */
48
readonly uri: URI;
49
/** Original key as it appears in the hook file. */
50
readonly originalId: string;
51
}
52
53
export interface IMcpServerDefinition {
54
readonly name: string;
55
readonly configuration: IMcpServerConfiguration;
56
readonly uri: URI;
57
}
58
59
/** A named resource (skill, agent, command, or instruction) within a plugin. */
60
export interface INamedPluginResource {
61
readonly uri: URI;
62
readonly name: string;
63
}
64
65
/** The result of parsing a single plugin directory. */
66
export interface IParsedPlugin {
67
readonly hooks: readonly IParsedHookGroup[];
68
readonly mcpServers: readonly IMcpServerDefinition[];
69
readonly skills: readonly INamedPluginResource[];
70
readonly agents: readonly INamedPluginResource[];
71
}
72
73
// ---------------------------------------------------------------------------
74
// Plugin format detection
75
// ---------------------------------------------------------------------------
76
77
export const enum PluginFormat {
78
Copilot,
79
Claude,
80
OpenPlugin,
81
}
82
83
export interface IPluginFormatConfig {
84
readonly format: PluginFormat;
85
readonly manifestPath: string;
86
readonly hookConfigPath: string;
87
readonly pluginRootToken: string | undefined;
88
readonly pluginRootEnvVar: string | undefined;
89
/** Parses hooks from a JSON object using the format's conventions. */
90
parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: string): IParsedHookGroup[];
91
}
92
93
const COPILOT_FORMAT: IPluginFormatConfig = {
94
format: PluginFormat.Copilot,
95
manifestPath: 'plugin.json',
96
hookConfigPath: 'hooks.json',
97
pluginRootToken: undefined,
98
pluginRootEnvVar: undefined,
99
parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) {
100
return parseHooksJson(hookUri, json, workspaceRoot, userHome);
101
},
102
};
103
104
const CLAUDE_FORMAT: IPluginFormatConfig = {
105
format: PluginFormat.Claude,
106
manifestPath: '.claude-plugin/plugin.json',
107
hookConfigPath: 'hooks/hooks.json',
108
pluginRootToken: '${CLAUDE_PLUGIN_ROOT}',
109
pluginRootEnvVar: 'CLAUDE_PLUGIN_ROOT',
110
parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {
111
return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT');
112
},
113
};
114
115
const OPEN_PLUGIN_FORMAT: IPluginFormatConfig = {
116
format: PluginFormat.OpenPlugin,
117
manifestPath: '.plugin/plugin.json',
118
hookConfigPath: 'hooks/hooks.json',
119
pluginRootToken: '${PLUGIN_ROOT}',
120
pluginRootEnvVar: 'PLUGIN_ROOT',
121
parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) {
122
return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${PLUGIN_ROOT}', 'PLUGIN_ROOT');
123
},
124
};
125
126
export async function detectPluginFormat(pluginUri: URI, fileService: IFileService): Promise<IPluginFormatConfig> {
127
if (await pathExists(joinPath(pluginUri, '.plugin', 'plugin.json'), fileService)) {
128
return OPEN_PLUGIN_FORMAT;
129
}
130
131
const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude');
132
if (isInClaudeDirectory || await pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'), fileService)) {
133
return CLAUDE_FORMAT;
134
}
135
136
return COPILOT_FORMAT;
137
}
138
139
// ---------------------------------------------------------------------------
140
// Component path config
141
// ---------------------------------------------------------------------------
142
143
export interface IComponentPathConfig {
144
readonly paths: readonly string[];
145
readonly exclusive: boolean;
146
}
147
148
const emptyComponentPathConfig: IComponentPathConfig = { paths: [], exclusive: false };
149
150
/**
151
* Parses a manifest component path field into a normalized config.
152
* Supports `undefined`, `string`, `string[]`, and `{ paths: string[], exclusive?: boolean }`.
153
*/
154
export function parseComponentPathConfig(raw: unknown): IComponentPathConfig {
155
if (raw === undefined || raw === null) {
156
return emptyComponentPathConfig;
157
}
158
159
if (typeof raw === 'string') {
160
const trimmed = raw.trim();
161
return trimmed ? { paths: [trimmed], exclusive: false } : emptyComponentPathConfig;
162
}
163
164
if (Array.isArray(raw)) {
165
const paths = raw
166
.filter(v => typeof v === 'string')
167
.map(v => v.trim())
168
.filter(v => v.length > 0);
169
return { paths, exclusive: false };
170
}
171
172
if (typeof raw === 'object') {
173
const obj = raw as Record<string, unknown>;
174
if (Array.isArray(obj['paths'])) {
175
const paths = (obj['paths'] as unknown[])
176
.filter(v => typeof v === 'string')
177
.map(v => v.trim())
178
.filter(v => v.length > 0);
179
const exclusive = obj['exclusive'] === true;
180
return { paths, exclusive };
181
}
182
}
183
184
return emptyComponentPathConfig;
185
}
186
187
/**
188
* Resolves the directories to scan for a given component type, combining
189
* the default directory with any custom paths from the manifest config.
190
* Paths that resolve outside the plugin root are silently ignored.
191
*/
192
export function resolveComponentDirs(pluginUri: URI, defaultDir: string, config: IComponentPathConfig): readonly URI[] {
193
const dirs: URI[] = [];
194
if (!config.exclusive) {
195
dirs.push(joinPath(pluginUri, defaultDir));
196
}
197
for (const p of config.paths) {
198
const resolved = normalizePath(joinPath(pluginUri, p));
199
if (isEqualOrParent(resolved, pluginUri)) {
200
dirs.push(resolved);
201
}
202
}
203
return dirs;
204
}
205
206
// ---------------------------------------------------------------------------
207
// MCP server helpers
208
// ---------------------------------------------------------------------------
209
210
/**
211
* Extracts the MCP server map from a raw JSON value. Accepts both the
212
* wrapped format `{ mcpServers: { … } }` and the flat format.
213
*/
214
export function resolveMcpServersMap(raw: unknown): Record<string, unknown> | undefined {
215
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
216
return undefined;
217
}
218
const obj = raw as Record<string, unknown>;
219
return Object.hasOwn(obj, 'mcpServers')
220
? (obj.mcpServers as Record<string, unknown>)
221
: obj;
222
}
223
224
/**
225
* Normalizes a raw JSON value into a typed MCP server configuration.
226
*/
227
export function normalizeMcpServerConfiguration(rawConfig: unknown): IMcpServerConfiguration | undefined {
228
if (!rawConfig || typeof rawConfig !== 'object') {
229
return undefined;
230
}
231
232
const candidate = rawConfig as Record<string, unknown>;
233
const type = typeof candidate['type'] === 'string' ? candidate['type'] : undefined;
234
235
const command = typeof candidate['command'] === 'string' ? candidate['command'] : undefined;
236
const url = typeof candidate['url'] === 'string' ? candidate['url'] : undefined;
237
const args = Array.isArray(candidate['args']) ? candidate['args'].filter((value): value is string => typeof value === 'string') : undefined;
238
const env = candidate['env'] && typeof candidate['env'] === 'object'
239
? Object.fromEntries(Object.entries(candidate['env'] as Record<string, unknown>)
240
.filter(([, value]) => typeof value === 'string' || typeof value === 'number' || value === null)
241
.map(([key, value]) => [key, value as string | number | null]))
242
: undefined;
243
const envFile = typeof candidate['envFile'] === 'string' ? candidate['envFile'] : undefined;
244
const cwd = typeof candidate['cwd'] === 'string' ? candidate['cwd'] : undefined;
245
const headers = candidate['headers'] && typeof candidate['headers'] === 'object'
246
? Object.fromEntries(Object.entries(candidate['headers'] as Record<string, unknown>)
247
.filter(([, value]) => typeof value === 'string')
248
.map(([key, value]) => [key, value as string]))
249
: undefined;
250
const dev = candidate['dev'] && typeof candidate['dev'] === 'object' ? candidate['dev'] as IMcpStdioServerConfiguration['dev'] : undefined;
251
252
if (type === 'ws') {
253
return undefined;
254
}
255
256
if (type === McpServerType.LOCAL || (!type && command)) {
257
if (!command) {
258
return undefined;
259
}
260
return { type: McpServerType.LOCAL, command, args, env, envFile, cwd, dev };
261
}
262
263
if (type === McpServerType.REMOTE || type === 'sse' || (!type && url)) {
264
if (!url) {
265
return undefined;
266
}
267
return { type: McpServerType.REMOTE, url, headers, dev };
268
}
269
270
return undefined;
271
}
272
273
/**
274
* Characters in a file path that require shell quoting to prevent
275
* word splitting or interpretation by common shells.
276
*/
277
const shellUnsafeChars = /[\s&|<>()^;!`"']/;
278
279
/**
280
* Replaces a plugin-root token in a shell command string with the
281
* given fsPath, shell-quoting if the path contains special characters.
282
*/
283
export function shellQuotePluginRootInCommand(command: string, fsPath: string, token: string) {
284
if (!command.includes(token)) {
285
return command;
286
}
287
288
if (!shellUnsafeChars.test(fsPath)) {
289
return command.replaceAll(token, fsPath);
290
}
291
292
const escapedToken = escapeRegExpCharacters(token);
293
const pattern = new RegExp(
294
`(["']?)` + escapedToken + `([\\w./\\\\~:-]*)`,
295
'g',
296
);
297
298
return command.replace(pattern, (_match, leadingQuote: string, suffix: string) => {
299
const fullPath = fsPath + suffix;
300
if (leadingQuote) {
301
return leadingQuote + fullPath;
302
}
303
return '"' + fullPath.replace(/"/g, '\\"') + '"';
304
});
305
}
306
307
/**
308
* Replaces plugin-root token references in MCP server definition string fields
309
* with the plugin root filesystem path.
310
*/
311
export function interpolateMcpPluginRoot(
312
def: IMcpServerDefinition,
313
fsPath: string,
314
token: string,
315
envVar: string,
316
): IMcpServerDefinition {
317
const replace = (s: string) => s.replaceAll(token, fsPath);
318
319
const config = def.configuration;
320
let interpolated: IMcpServerConfiguration;
321
322
if (config.type === McpServerType.LOCAL) {
323
const local: Mutable<IMcpStdioServerConfiguration> = { ...config };
324
local.command = replace(local.command);
325
if (local.args) {
326
local.args = local.args.map(replace);
327
}
328
if (local.cwd) {
329
local.cwd = replace(local.cwd);
330
}
331
local.env = { ...local.env };
332
for (const [k, v] of Object.entries(local.env)) {
333
if (typeof v === 'string') {
334
local.env[k] = replace(v);
335
}
336
}
337
local.env[envVar] = fsPath;
338
if (local.envFile) {
339
local.envFile = replace(local.envFile);
340
}
341
interpolated = local;
342
} else {
343
const remote: Mutable<IMcpRemoteServerConfiguration> = { ...config };
344
remote.url = replace(remote.url);
345
if (remote.headers) {
346
remote.headers = Object.fromEntries(
347
Object.entries(remote.headers).map(([k, v]) => [k, replace(v)])
348
);
349
}
350
interpolated = remote;
351
}
352
353
return { name: def.name, configuration: interpolated, uri: def.uri };
354
}
355
356
/**
357
* Regex matching bare `${VAR_NAME}` references (uppercase only) that are NOT
358
* using VS Code's `${env:VAR}` colon-delimited syntax.
359
*/
360
const BARE_ENV_VAR_RE = /\$\{(?![A-Za-z]+:)([A-Z_][A-Z0-9_]*)\}/g;
361
362
/**
363
* Converts bare `${VAR}` environment-variable references to VS Code `${env:VAR}` syntax.
364
*/
365
export function convertBareEnvVarsToVsCodeSyntax(
366
def: IMcpServerDefinition,
367
): IMcpServerDefinition {
368
return cloneAndChange(def, (value) => {
369
if (URI.isUri(value)) {
370
return value;
371
}
372
if (typeof value === 'string') {
373
const replaced = value.replace(BARE_ENV_VAR_RE, '${env:$1}');
374
return replaced !== value ? replaced : undefined;
375
}
376
return undefined;
377
});
378
}
379
380
// ---------------------------------------------------------------------------
381
// Hook parsing helpers
382
// ---------------------------------------------------------------------------
383
384
/**
385
* Maps known hook type identifiers from all formats (VS Code PascalCase,
386
* Copilot CLI camelCase, Claude PascalCase) to canonical identifiers.
387
*/
388
const HOOK_TYPE_MAP: Record<string, string> = {
389
// PascalCase (VS Code / Claude)
390
'SessionStart': 'SessionStart',
391
'SessionEnd': 'SessionEnd',
392
'UserPromptSubmit': 'UserPromptSubmit',
393
'PreToolUse': 'PreToolUse',
394
'PostToolUse': 'PostToolUse',
395
'PreCompact': 'PreCompact',
396
'SubagentStart': 'SubagentStart',
397
'SubagentStop': 'SubagentStop',
398
'Stop': 'Stop',
399
'ErrorOccurred': 'ErrorOccurred',
400
// camelCase (GitHub Copilot CLI)
401
'sessionStart': 'SessionStart',
402
'sessionEnd': 'SessionEnd',
403
'userPromptSubmitted': 'UserPromptSubmit',
404
'preToolUse': 'PreToolUse',
405
'postToolUse': 'PostToolUse',
406
'agentStop': 'Stop',
407
'subagentStop': 'SubagentStop',
408
'errorOccurred': 'ErrorOccurred',
409
};
410
411
/**
412
* Normalizes a raw hook command object, validating structure and mapping
413
* legacy `bash`/`powershell` fields to platform-specific overrides.
414
*/
415
function normalizeHookCommand(raw: Record<string, unknown>): IParsedHookCommand | undefined {
416
// Allow omitted type (Claude compatibility) — treat as 'command'
417
if (raw.type !== undefined && raw.type !== 'command') {
418
return undefined;
419
}
420
421
const hasCommand = typeof raw.command === 'string' && raw.command.length > 0;
422
const hasBash = typeof raw.bash === 'string' && (raw.bash as string).length > 0;
423
const hasPowerShell = typeof raw.powershell === 'string' && (raw.powershell as string).length > 0;
424
const hasWindows = typeof raw.windows === 'string' && (raw.windows as string).length > 0;
425
const hasLinux = typeof raw.linux === 'string' && (raw.linux as string).length > 0;
426
const hasOsx = typeof raw.osx === 'string' && (raw.osx as string).length > 0;
427
428
if (!hasCommand && !hasBash && !hasPowerShell && !hasWindows && !hasLinux && !hasOsx) {
429
return undefined;
430
}
431
432
const windows = hasWindows ? raw.windows as string : (hasPowerShell ? raw.powershell as string : undefined);
433
const linux = hasLinux ? raw.linux as string : (hasBash ? raw.bash as string : undefined);
434
const osx = hasOsx ? raw.osx as string : (hasBash ? raw.bash as string : undefined);
435
436
const timeout = typeof raw.timeout === 'number'
437
? raw.timeout
438
: (typeof raw.timeoutSec === 'number' ? raw.timeoutSec : undefined);
439
440
return {
441
...(hasCommand && { command: raw.command as string }),
442
...(windows && { windows }),
443
...(linux && { linux }),
444
...(osx && { osx }),
445
...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record<string, string> }),
446
...(timeout !== undefined && { timeout }),
447
};
448
}
449
450
/**
451
* Resolves a raw hook command JSON object into a {@link IParsedHookCommand},
452
* normalizing fields and resolving the working directory.
453
*/
454
function resolveHookCommand(raw: Record<string, unknown>, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand | undefined {
455
const normalized = normalizeHookCommand(raw);
456
if (!normalized) {
457
return undefined;
458
}
459
460
let cwdUri: URI | undefined;
461
const rawCwd = typeof raw.cwd === 'string' ? raw.cwd : undefined;
462
if (rawCwd) {
463
const expanded = untildify(rawCwd, userHome);
464
if (isAbsolute(expanded)) {
465
cwdUri = URI.file(expanded);
466
} else if (workspaceRoot) {
467
cwdUri = joinPath(workspaceRoot, expanded);
468
}
469
} else {
470
cwdUri = workspaceRoot;
471
}
472
473
return { ...normalized, cwd: cwdUri };
474
}
475
476
/**
477
* Extracts hook commands from an item that may be a direct command object
478
* or a nested structure with a `matcher` (Claude format).
479
*/
480
function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand[] {
481
if (!item || typeof item !== 'object') {
482
return [];
483
}
484
485
const itemObj = item as Record<string, unknown>;
486
const commands: IParsedHookCommand[] = [];
487
488
// Nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] }
489
const nestedHooks = itemObj.hooks;
490
if (nestedHooks !== undefined && Array.isArray(nestedHooks)) {
491
for (const nested of nestedHooks) {
492
if (!nested || typeof nested !== 'object') {
493
continue;
494
}
495
const resolved = resolveHookCommand(nested as Record<string, unknown>, workspaceRoot, userHome);
496
if (resolved) {
497
commands.push(resolved);
498
}
499
}
500
} else {
501
const resolved = resolveHookCommand(itemObj, workspaceRoot, userHome);
502
if (resolved) {
503
commands.push(resolved);
504
}
505
}
506
507
return commands;
508
}
509
510
/**
511
* Parses hooks from a JSON object (any supported format).
512
*/
513
function parseHooksJson(
514
hookUri: URI,
515
json: unknown,
516
workspaceRoot: URI | undefined,
517
userHome: string,
518
): IParsedHookGroup[] {
519
if (!json || typeof json !== 'object') {
520
return [];
521
}
522
523
const root = json as Record<string, unknown>;
524
525
// Claude's disableAllHooks
526
if (root.disableAllHooks === true) {
527
return [];
528
}
529
530
const hooks = root.hooks;
531
if (!hooks || typeof hooks !== 'object') {
532
return [];
533
}
534
535
const hooksObj = hooks as Record<string, unknown>;
536
const result: IParsedHookGroup[] = [];
537
538
for (const originalId of Object.keys(hooksObj)) {
539
const canonicalType = HOOK_TYPE_MAP[originalId];
540
if (!canonicalType) {
541
continue;
542
}
543
544
const hookArray = hooksObj[originalId];
545
if (!Array.isArray(hookArray)) {
546
continue;
547
}
548
549
const commands: IParsedHookCommand[] = [];
550
for (const item of hookArray) {
551
commands.push(...extractHookCommands(item, workspaceRoot, userHome));
552
}
553
554
if (commands.length > 0) {
555
result.push({ type: canonicalType, commands, uri: hookUri, originalId });
556
}
557
}
558
559
return result;
560
}
561
562
/**
563
* Applies plugin-root token interpolation to hook commands for
564
* Claude and OpenPlugin formats.
565
*/
566
export function interpolateHookPluginRoot(
567
hookUri: URI,
568
json: unknown,
569
pluginUri: URI,
570
workspaceRoot: URI | undefined,
571
userHome: string,
572
token: string,
573
envVar: string,
574
): IParsedHookGroup[] {
575
const fsPath = pluginUri.fsPath;
576
const typedJson = json as { hooks?: Record<string, unknown[]> };
577
578
const mutateHookCommand = (hook: Record<string, unknown>): void => {
579
for (const field of ['command', 'windows', 'linux', 'osx'] as const) {
580
if (typeof hook[field] === 'string') {
581
hook[field] = shellQuotePluginRootInCommand(hook[field] as string, fsPath, token);
582
}
583
}
584
585
if (!hook.env || typeof hook.env !== 'object') {
586
hook.env = {};
587
}
588
(hook.env as Record<string, string>)[envVar] = fsPath;
589
};
590
591
for (const lifecycle of Object.values(typedJson.hooks ?? {})) {
592
if (!Array.isArray(lifecycle)) {
593
continue;
594
}
595
for (const lifecycleEntry of lifecycle) {
596
if (!lifecycleEntry || typeof lifecycleEntry !== 'object') {
597
continue;
598
}
599
const entry = lifecycleEntry as { hooks?: Record<string, unknown>[] } & Record<string, unknown>;
600
if (Array.isArray(entry.hooks)) {
601
for (const hook of entry.hooks) {
602
mutateHookCommand(hook);
603
}
604
} else {
605
mutateHookCommand(entry);
606
}
607
}
608
}
609
610
const replacer = (v: unknown): unknown => {
611
return typeof v === 'string'
612
? v.replaceAll(token, pluginUri.fsPath)
613
: undefined;
614
};
615
616
return parseHooksJson(hookUri, cloneAndChange(json, replacer), workspaceRoot, userHome);
617
}
618
619
// ---------------------------------------------------------------------------
620
// Filesystem helpers
621
// ---------------------------------------------------------------------------
622
623
export async function readJsonFile(uri: URI, fileService: IFileService): Promise<unknown | undefined> {
624
try {
625
const fileContents = await fileService.readFile(uri);
626
return parseJSONC(fileContents.value.toString());
627
} catch {
628
return undefined;
629
}
630
}
631
632
export async function pathExists(resource: URI, fileService: IFileService): Promise<boolean> {
633
try {
634
await fileService.resolve(resource);
635
return true;
636
} catch {
637
return false;
638
}
639
}
640
641
// ---------------------------------------------------------------------------
642
// Component readers
643
// ---------------------------------------------------------------------------
644
645
const COMMAND_FILE_SUFFIX = '.md';
646
647
export async function readSkills(pluginRoot: URI, dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> {
648
const seen = new Set<string>();
649
const skills: INamedPluginResource[] = [];
650
651
const addSkill = (name: string, skillMd: URI) => {
652
if (!seen.has(name)) {
653
seen.add(name);
654
skills.push({ uri: skillMd, name });
655
}
656
};
657
658
for (const dir of dirs) {
659
const skillMd = URI.joinPath(dir, 'SKILL.md');
660
if (await pathExists(skillMd, fileService)) {
661
addSkill(basename(dir), skillMd);
662
continue;
663
}
664
665
let stat;
666
try {
667
stat = await fileService.resolve(dir);
668
} catch {
669
continue;
670
}
671
672
if (!stat.isDirectory || !stat.children) {
673
continue;
674
}
675
676
for (const child of stat.children) {
677
const childSkillMd = URI.joinPath(child.resource, 'SKILL.md');
678
if (await pathExists(childSkillMd, fileService)) {
679
addSkill(basename(child.resource), childSkillMd);
680
}
681
}
682
}
683
684
if (skills.length === 0) {
685
const rootSkillMd = URI.joinPath(pluginRoot, 'SKILL.md');
686
if (await pathExists(rootSkillMd, fileService)) {
687
addSkill(basename(pluginRoot), rootSkillMd);
688
}
689
}
690
691
skills.sort((a, b) => a.name.localeCompare(b.name));
692
return skills;
693
}
694
695
export async function readMarkdownComponents(dirs: readonly URI[], fileService: IFileService): Promise<readonly INamedPluginResource[]> {
696
const seen = new Set<string>();
697
const items: INamedPluginResource[] = [];
698
699
const addItem = (name: string, uri: URI) => {
700
if (!seen.has(name)) {
701
seen.add(name);
702
items.push({ uri, name });
703
}
704
};
705
706
for (const dir of dirs) {
707
let stat;
708
try {
709
stat = await fileService.resolve(dir);
710
} catch {
711
continue;
712
}
713
714
if (stat.isFile && extname(dir).toLowerCase() === COMMAND_FILE_SUFFIX) {
715
addItem(basename(dir).slice(0, -COMMAND_FILE_SUFFIX.length), dir);
716
continue;
717
}
718
719
if (!stat.isDirectory || !stat.children) {
720
continue;
721
}
722
723
for (const child of stat.children) {
724
if (!child.isFile || extname(child.resource).toLowerCase() !== COMMAND_FILE_SUFFIX) {
725
continue;
726
}
727
addItem(basename(child.resource).slice(0, -COMMAND_FILE_SUFFIX.length), child.resource);
728
}
729
}
730
731
items.sort((a, b) => a.name.localeCompare(b.name));
732
return items;
733
}
734
735
async function readHooks(
736
pluginUri: URI,
737
paths: readonly URI[],
738
formatConfig: IPluginFormatConfig,
739
fileService: IFileService,
740
workspaceRoot: URI | undefined,
741
userHome: string,
742
): Promise<readonly IParsedHookGroup[]> {
743
for (const hookPath of paths) {
744
const json = await readJsonFile(hookPath, fileService);
745
if (!json) {
746
continue;
747
}
748
749
return formatConfig.parseHooks(hookPath, json, pluginUri, workspaceRoot, userHome);
750
}
751
return [];
752
}
753
754
async function readMcpServers(
755
paths: readonly URI[],
756
pluginFsPath: string,
757
formatConfig: IPluginFormatConfig,
758
fileService: IFileService,
759
): Promise<readonly IMcpServerDefinition[]> {
760
const merged = new Map<string, IMcpServerDefinition>();
761
for (const mcpPath of paths) {
762
const json = await readJsonFile(mcpPath, fileService);
763
for (const def of parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, formatConfig)) {
764
if (!merged.has(def.name)) {
765
merged.set(def.name, def);
766
}
767
}
768
}
769
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
770
}
771
772
export function parseMcpServerDefinitionMap(
773
definitionURI: URI,
774
raw: unknown,
775
pluginFsPath: string,
776
formatConfig: IPluginFormatConfig,
777
): IMcpServerDefinition[] {
778
const mcpServers = resolveMcpServersMap(raw);
779
if (!mcpServers) {
780
return [];
781
}
782
783
const definitions: IMcpServerDefinition[] = [];
784
for (const [name, configValue] of Object.entries(mcpServers)) {
785
const configuration = normalizeMcpServerConfiguration(configValue);
786
if (!configuration) {
787
continue;
788
}
789
790
let def: IMcpServerDefinition = { name, configuration, uri: definitionURI };
791
if (formatConfig.pluginRootToken && formatConfig.pluginRootEnvVar) {
792
def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootToken, formatConfig.pluginRootEnvVar);
793
}
794
def = convertBareEnvVarsToVsCodeSyntax(def);
795
definitions.push(def);
796
}
797
798
return definitions;
799
}
800
801
// ---------------------------------------------------------------------------
802
// Top-level parse function
803
// ---------------------------------------------------------------------------
804
805
/**
806
* Parses a plugin directory to extract hooks, MCP servers, skills, and agents.
807
* This is the main entry point for the agent host to discover plugin contents.
808
*/
809
export async function parsePlugin(
810
pluginUri: URI,
811
fileService: IFileService,
812
workspaceRoot: URI | undefined,
813
userHome: string,
814
): Promise<IParsedPlugin> {
815
const formatConfig = await detectPluginFormat(pluginUri, fileService);
816
817
// Read manifest
818
const manifestJson = await readJsonFile(joinPath(pluginUri, formatConfig.manifestPath), fileService);
819
const manifest = (manifestJson && typeof manifestJson === 'object') ? manifestJson as Record<string, unknown> : undefined;
820
821
// Resolve component directories from manifest
822
const hookDirs = resolveComponentDirs(pluginUri, formatConfig.hookConfigPath, parseComponentPathConfig(manifest?.['hooks']));
823
const mcpDirs = resolveComponentDirs(pluginUri, '.mcp.json', parseComponentPathConfig(manifest?.['mcpServers']));
824
const skillDirs = resolveComponentDirs(pluginUri, 'skills', parseComponentPathConfig(manifest?.['skills']));
825
const agentDirs = resolveComponentDirs(pluginUri, 'agents', parseComponentPathConfig(manifest?.['agents']));
826
827
// Handle embedded MCP servers in manifest
828
let embeddedMcp: IMcpServerDefinition[] = [];
829
const mcpSection = manifest?.['mcpServers'];
830
if (mcpSection && typeof mcpSection === 'object' && !Array.isArray(mcpSection) && !(hasKey(mcpSection, { paths: true }))) {
831
embeddedMcp = parseMcpServerDefinitionMap(
832
joinPath(pluginUri, formatConfig.manifestPath),
833
{ mcpServers: mcpSection },
834
pluginUri.fsPath,
835
formatConfig,
836
);
837
}
838
839
// Handle embedded hooks in manifest
840
let embeddedHooks: IParsedHookGroup[] = [];
841
const hooksSection = manifest?.['hooks'];
842
if (hooksSection && typeof hooksSection === 'object' && !Array.isArray(hooksSection) && !(hasKey(hooksSection, { paths: true }))) {
843
const manifestUri = joinPath(pluginUri, formatConfig.manifestPath);
844
embeddedHooks = formatConfig.parseHooks(manifestUri, { hooks: hooksSection }, pluginUri, workspaceRoot, userHome);
845
}
846
847
const [hooks, mcpServers, skills, agents] = await Promise.all([
848
embeddedHooks.length > 0
849
? Promise.resolve(embeddedHooks)
850
: readHooks(pluginUri, hookDirs, formatConfig, fileService, workspaceRoot, userHome),
851
embeddedMcp.length > 0
852
? Promise.resolve(embeddedMcp)
853
: readMcpServers(mcpDirs, pluginUri.fsPath, formatConfig, fileService),
854
readSkills(pluginUri, skillDirs, fileService),
855
readMarkdownComponents(agentDirs, fileService),
856
]);
857
858
return { hooks, mcpServers, skills, agents };
859
}
860
861
862