Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts
13406 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 { VSBuffer } from '../../../../../base/common/buffer.js';
7
import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';
8
import { Codicon } from '../../../../../base/common/codicons.js';
9
import { parse as parseJSONC } from '../../../../../base/common/jsonc.js';
10
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
11
import { basename, dirname, joinPath } from '../../../../../base/common/resources.js';
12
import { ThemeIcon } from '../../../../../base/common/themables.js';
13
import { isUriComponents, URI } from '../../../../../base/common/uri.js';
14
import { localize, localize2 } from '../../../../../nls.js';
15
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
16
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
17
import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
18
import { IFileService } from '../../../../../platform/files/common/files.js';
19
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
20
import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
21
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
22
import { IQuickInputButton, IQuickInputService, IQuickTreeItem } from '../../../../../platform/quickinput/common/quickInput.js';
23
import { InstalledAgentPluginsViewId } from '../chat.js';
24
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
25
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
26
import { IPromptPath, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
27
import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js';
28
import { McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerTransportType } from '../../../mcp/common/mcpTypes.js';
29
import { CHAT_CATEGORY } from './chatActions.js';
30
31
const VALID_PLUGIN_NAME = /^[a-z0-9]([a-z0-9\-.]*[a-z0-9])?$/;
32
const INVALID_CONSECUTIVE = /--|[.][.]/;
33
34
export function validatePluginName(name: string): string | undefined {
35
if (!name) {
36
return localize('pluginNameRequired', "Plugin name is required.");
37
}
38
if (name.length > 64) {
39
return localize('pluginNameTooLong', "Plugin name must be at most 64 characters.");
40
}
41
if (!VALID_PLUGIN_NAME.test(name)) {
42
return localize('pluginNameInvalid', "Plugin name must contain only lowercase alphanumeric characters, hyphens, and periods, and must start and end with an alphanumeric character.");
43
}
44
if (INVALID_CONSECUTIVE.test(name)) {
45
return localize('pluginNameConsecutive', "Plugin name must not contain consecutive hyphens or periods.");
46
}
47
return undefined;
48
}
49
50
type ResourceType = 'instruction' | 'prompt' | 'agent' | 'skill' | 'hook' | 'mcp';
51
52
export interface IResourceTreeItem extends IQuickTreeItem {
53
readonly resourceType: ResourceType;
54
readonly promptPath?: IPromptPath;
55
readonly mcpServer?: { collection: McpCollectionDefinition; definition: McpServerDefinition };
56
children?: readonly IResourceTreeItem[];
57
}
58
59
interface IGroupTreeItem extends IQuickTreeItem {
60
readonly resourceType?: undefined;
61
children: IResourceTreeItem[];
62
}
63
64
function isUserDefined(storage: PromptsStorage): boolean {
65
return storage === PromptsStorage.local || storage === PromptsStorage.user;
66
}
67
68
function isUserDefinedMcpCollection(collection: McpCollectionDefinition): boolean {
69
const order = collection.order;
70
return order === McpCollectionSortOrder.User
71
|| order === McpCollectionSortOrder.WorkspaceFolder
72
|| order === McpCollectionSortOrder.Workspace;
73
}
74
75
/**
76
* Gets a display label for a prompt resource. Skills need special handling
77
* because their URI points to `SKILL.md`, so we use the parent directory name.
78
*/
79
export function getResourceLabel(r: IPromptPath): string {
80
if (r.name) {
81
return r.name;
82
}
83
if (r.type === PromptsType.skill && basename(r.uri).toLowerCase() === 'skill.md') {
84
return basename(dirname(r.uri));
85
}
86
return basename(r.uri);
87
}
88
89
/**
90
* Gets a filesystem-safe name for a resource, stripping any namespace prefix
91
* (e.g. `plugin:skillname` → `skillname`).
92
*/
93
export function getResourceFileName(r: IPromptPath): string {
94
const label = getResourceLabel(r);
95
const colonIndex = label.indexOf(':');
96
return colonIndex >= 0 ? label.substring(colonIndex + 1) : label;
97
}
98
99
class CreatePluginAction extends Action2 {
100
101
static readonly ID = 'workbench.action.chat.createPlugin';
102
103
constructor() {
104
super({
105
id: CreatePluginAction.ID,
106
title: localize2('chat.createPlugin', "Create Plugin"),
107
category: CHAT_CATEGORY,
108
f1: true,
109
precondition: ChatContextKeys.enabled,
110
icon: Codicon.save,
111
menu: [{
112
id: MenuId.ViewTitle,
113
when: ContextKeyExpr.and(
114
ContextKeyExpr.equals('view', InstalledAgentPluginsViewId),
115
ChatContextKeys.Setup.hidden.negate(),
116
ChatContextKeys.Setup.disabledInWorkspace.negate(),
117
),
118
group: 'navigation',
119
order: 2,
120
}],
121
});
122
}
123
124
override async run(accessor: ServicesAccessor): Promise<void> {
125
const quickInputService = accessor.get(IQuickInputService);
126
const promptsService = accessor.get(IPromptsService);
127
const mcpRegistry = accessor.get(IMcpRegistry);
128
const fileDialogService = accessor.get(IFileDialogService);
129
const fileService = accessor.get(IFileService);
130
const commandService = accessor.get(ICommandService);
131
const notificationService = accessor.get(INotificationService);
132
133
// Step 1: Gather resources
134
const [instructions, prompts, agents, skills, hooks] = await (async () => {
135
const cts = new CancellationTokenSource();
136
try {
137
return await Promise.all([
138
promptsService.listPromptFiles(PromptsType.instructions, cts.token),
139
promptsService.listPromptFiles(PromptsType.prompt, cts.token),
140
promptsService.listPromptFiles(PromptsType.agent, cts.token),
141
promptsService.listPromptFiles(PromptsType.skill, cts.token),
142
promptsService.listPromptFiles(PromptsType.hook, cts.token),
143
]);
144
} finally {
145
cts.dispose(true);
146
}
147
})();
148
149
const mcpCollections = mcpRegistry.collections.get();
150
151
// Step 2: Build tree items grouped by resource type
152
let showAll = false;
153
154
const buildTree = (): (IGroupTreeItem | IResourceTreeItem)[] => {
155
const groups: (IGroupTreeItem | IResourceTreeItem)[] = [];
156
157
const addGroup = (
158
resources: readonly IPromptPath[],
159
resourceType: ResourceType,
160
groupLabel: string,
161
icon: ThemeIcon,
162
) => {
163
const filtered = showAll ? resources : resources.filter(r => isUserDefined(r.storage));
164
if (filtered.length === 0) {
165
return;
166
}
167
const children: IResourceTreeItem[] = filtered.map(r => ({
168
label: getResourceLabel(r),
169
description: r.storage,
170
resourceType,
171
promptPath: r,
172
checked: false,
173
}));
174
groups.push({
175
label: groupLabel,
176
iconClass: ThemeIcon.asClassName(icon),
177
checked: undefined,
178
collapsed: false,
179
pickable: false,
180
children,
181
});
182
};
183
184
addGroup(instructions, 'instruction', localize('instructions', "Instructions"), Codicon.book);
185
addGroup(prompts, 'prompt', localize('prompts', "Prompts"), Codicon.comment);
186
addGroup(agents, 'agent', localize('agents', "Agents"), Codicon.copilot);
187
addGroup(skills, 'skill', localize('skills', "Skills"), Codicon.lightbulb);
188
addGroup(hooks, 'hook', localize('hooks', "Hooks"), Codicon.zap);
189
190
// MCP servers
191
const mcpChildren: IResourceTreeItem[] = [];
192
for (const collection of mcpCollections) {
193
if (!showAll && !isUserDefinedMcpCollection(collection)) {
194
continue;
195
}
196
const defs = collection.serverDefinitions.get();
197
for (const def of defs) {
198
mcpChildren.push({
199
label: def.label,
200
description: collection.label,
201
resourceType: 'mcp',
202
mcpServer: { collection, definition: def },
203
checked: false,
204
});
205
}
206
}
207
if (mcpChildren.length > 0) {
208
groups.push({
209
label: localize('mcpServers', "MCP Servers"),
210
iconClass: ThemeIcon.asClassName(Codicon.mcp),
211
checked: undefined,
212
collapsed: false,
213
pickable: false,
214
children: mcpChildren,
215
});
216
}
217
218
return groups;
219
};
220
221
// Step 3: Show QuickTree for multi-select with groupings
222
const disposables = new DisposableStore();
223
const tree = disposables.add(quickInputService.createQuickTree<IGroupTreeItem | IResourceTreeItem>());
224
tree.placeholder = localize('selectResources', "Select resources to include in the plugin");
225
tree.matchOnDescription = true;
226
tree.matchOnLabel = true;
227
tree.sortByLabel = false;
228
tree.title = localize('createPluginTitle', "Create Plugin");
229
tree.setItemTree(buildTree());
230
231
const toggleButton: IQuickInputButton = { iconClass: ThemeIcon.asClassName(Codicon.filter), tooltip: localize('showAll', "Show Built-in, Extension, and Plugin Resources") };
232
tree.buttons = [toggleButton];
233
234
disposables.add(tree.onDidTriggerButton((button: IQuickInputButton) => {
235
if (button === toggleButton) {
236
showAll = !showAll;
237
tree.setItemTree(buildTree());
238
}
239
}));
240
241
const selectedItems = await new Promise<readonly (IGroupTreeItem | IResourceTreeItem)[] | undefined>(resolve => {
242
disposables.add(tree.onDidAccept(() => {
243
resolve(tree.checkedLeafItems);
244
tree.hide();
245
}));
246
disposables.add(tree.onDidHide(() => {
247
resolve(undefined);
248
}));
249
tree.show();
250
});
251
252
disposables.dispose();
253
254
if (!selectedItems || selectedItems.length === 0) {
255
return;
256
}
257
258
const selected = selectedItems.filter((i): i is IResourceTreeItem => !!i.resourceType);
259
260
// Step 4: Ask for plugin name
261
const pluginName = await quickInputService.input({
262
prompt: localize('pluginNamePrompt', "Enter a name for the plugin"),
263
placeHolder: 'my-plugin',
264
validateInput: async (value: string) => validatePluginName(value),
265
});
266
267
if (!pluginName) {
268
return;
269
}
270
271
// Step 5: Ask where to save
272
const folderUris = await fileDialogService.showOpenDialog({
273
canSelectFiles: false,
274
canSelectFolders: true,
275
canSelectMany: false,
276
title: localize('selectPluginLocation', "Select Plugin Save Location"),
277
openLabel: localize('selectFolder', "Select Folder"),
278
});
279
280
if (!folderUris || folderUris.length === 0) {
281
return;
282
}
283
284
const targetDir = folderUris[0];
285
const pluginRoot = joinPath(targetDir, pluginName);
286
287
// Check if plugin directory already exists
288
if (await fileService.exists(pluginRoot)) {
289
notificationService.error(localize('pluginExists', "A directory named '{0}' already exists at this location. Please choose a different name or location.", pluginName));
290
return;
291
}
292
293
// Step 6: Create plugin structure
294
try {
295
await writePluginToDisk(fileService, pluginRoot, pluginName, selected);
296
297
// Step 7: Check for marketplace.json and update it
298
await updateMarketplaceIfNeeded(fileService, targetDir, pluginName);
299
300
// Step 8: Reveal the plugin directory in the OS file explorer
301
try {
302
await commandService.executeCommand('revealFileInOS', pluginRoot);
303
} catch {
304
// revealFileInOS may not be available for all URI schemes
305
}
306
307
notificationService.info(localize('pluginCreated', "Plugin '{0}' created successfully.", pluginName));
308
309
} catch (err) {
310
notificationService.error(localize('pluginCreateError', "Failed to create plugin: {0}", String(err)));
311
}
312
}
313
}
314
315
/**
316
* Writes a plugin directory structure to disk from selected resources.
317
*/
318
export async function writePluginToDisk(
319
fileService: IFileService,
320
pluginRoot: URI,
321
pluginName: string,
322
selected: readonly IResourceTreeItem[],
323
): Promise<void> {
324
await fileService.createFolder(pluginRoot);
325
326
// Create .plugin/plugin.json
327
const manifestDir = joinPath(pluginRoot, '.plugin');
328
await fileService.createFolder(manifestDir);
329
const manifest = {
330
name: pluginName,
331
version: '1.0.0',
332
description: '',
333
};
334
await fileService.writeFile(joinPath(manifestDir, 'plugin.json'), VSBuffer.fromString(JSON.stringify(manifest, null, '\t')));
335
336
// Group selected items by type
337
const byType = {
338
instruction: selected.filter(i => i.resourceType === 'instruction'),
339
prompt: selected.filter(i => i.resourceType === 'prompt'),
340
agent: selected.filter(i => i.resourceType === 'agent'),
341
skill: selected.filter(i => i.resourceType === 'skill'),
342
hook: selected.filter(i => i.resourceType === 'hook'),
343
mcp: selected.filter(i => i.resourceType === 'mcp'),
344
};
345
346
// Copy instructions → rules/
347
if (byType.instruction.length > 0) {
348
const rulesDir = joinPath(pluginRoot, 'rules');
349
await fileService.createFolder(rulesDir);
350
for (const item of byType.instruction) {
351
if (!item.promptPath) {
352
continue;
353
}
354
const name = getResourceFileName(item.promptPath);
355
const fileName = name.endsWith('.instructions.md') || name.endsWith('.mdc') || name.endsWith('.md')
356
? name
357
: name + '.instructions.md';
358
const content = await fileService.readFile(item.promptPath.uri);
359
await fileService.writeFile(joinPath(rulesDir, fileName), content.value);
360
}
361
}
362
363
// Copy prompts → commands/
364
if (byType.prompt.length > 0) {
365
const commandsDir = joinPath(pluginRoot, 'commands');
366
await fileService.createFolder(commandsDir);
367
for (const item of byType.prompt) {
368
if (!item.promptPath) {
369
continue;
370
}
371
const name = getResourceFileName(item.promptPath);
372
const fileName = name.endsWith('.md') ? name : name + '.md';
373
const content = await fileService.readFile(item.promptPath.uri);
374
await fileService.writeFile(joinPath(commandsDir, fileName), content.value);
375
}
376
}
377
378
// Copy agents → agents/
379
if (byType.agent.length > 0) {
380
const agentsDir = joinPath(pluginRoot, 'agents');
381
await fileService.createFolder(agentsDir);
382
for (const item of byType.agent) {
383
if (!item.promptPath) {
384
continue;
385
}
386
const name = getResourceFileName(item.promptPath);
387
const fileName = name.endsWith('.md') ? name : name + '.md';
388
const content = await fileService.readFile(item.promptPath.uri);
389
await fileService.writeFile(joinPath(agentsDir, fileName), content.value);
390
}
391
}
392
393
// Copy skills → skills/ (recursive directory copy)
394
if (byType.skill.length > 0) {
395
const skillsDir = joinPath(pluginRoot, 'skills');
396
await fileService.createFolder(skillsDir);
397
for (const item of byType.skill) {
398
if (!item.promptPath) {
399
continue;
400
}
401
const sourceUri = item.promptPath.uri;
402
const skillName = getResourceFileName(item.promptPath);
403
404
// The URI for a skill might point to the SKILL.md file or to the directory
405
const sourceName = basename(sourceUri);
406
const isFile = sourceName.toLowerCase() === 'skill.md';
407
const skillSourceDir = isFile ? joinPath(sourceUri, '..') : sourceUri;
408
409
const destSkillDir = joinPath(skillsDir, skillName);
410
await copyDirectory(fileService, skillSourceDir, destSkillDir);
411
}
412
}
413
414
// Copy hooks → hooks/hooks.json (merge all selected hook files)
415
if (byType.hook.length > 0) {
416
const hooksDir = joinPath(pluginRoot, 'hooks');
417
await fileService.createFolder(hooksDir);
418
419
const mergedHooks: Record<string, Record<string, unknown>[]> = {};
420
for (const item of byType.hook) {
421
if (!item.promptPath) {
422
continue;
423
}
424
try {
425
const content = await fileService.readFile(item.promptPath.uri);
426
const parsed = parseJSONC<Record<string, unknown>>(content.value.toString());
427
const hooksObj = (parsed?.hooks ?? parsed) as Record<string, unknown> | undefined;
428
if (hooksObj && typeof hooksObj === 'object') {
429
for (const [hookType, commands] of Object.entries(hooksObj)) {
430
if (Array.isArray(commands)) {
431
if (!mergedHooks[hookType]) {
432
mergedHooks[hookType] = [];
433
}
434
for (const cmd of commands) {
435
mergedHooks[hookType].push(serializeHookCommand(cmd));
436
}
437
}
438
}
439
}
440
} catch {
441
// Skip unparseable hook files
442
}
443
}
444
445
const hooksJson = { hooks: mergedHooks };
446
await fileService.writeFile(
447
joinPath(hooksDir, 'hooks.json'),
448
VSBuffer.fromString(JSON.stringify(hooksJson, null, '\t'))
449
);
450
}
451
452
// Export MCP servers → .mcp.json
453
if (byType.mcp.length > 0) {
454
const mcpServers: Record<string, object> = {};
455
for (const item of byType.mcp) {
456
if (!item.mcpServer) {
457
continue;
458
}
459
const def = item.mcpServer.definition;
460
mcpServers[def.label] = serializeMcpLaunch(def.launch);
461
}
462
const mcpJson = { mcpServers };
463
await fileService.writeFile(
464
joinPath(pluginRoot, '.mcp.json'),
465
VSBuffer.fromString(JSON.stringify(mcpJson, null, '\t'))
466
);
467
}
468
}
469
470
export function serializeHookCommand(cmd: Record<string, unknown>): Record<string, unknown> {
471
const result: Record<string, unknown> = { type: 'command' };
472
if (typeof cmd.command === 'string') {
473
result['command'] = cmd.command;
474
}
475
if (typeof cmd.windows === 'string') {
476
result['windows'] = cmd.windows;
477
}
478
if (typeof cmd.linux === 'string') {
479
result['linux'] = cmd.linux;
480
}
481
if (typeof cmd.osx === 'string') {
482
result['osx'] = cmd.osx;
483
}
484
if (cmd.cwd !== undefined) {
485
result['cwd'] = isUriComponents(cmd.cwd) ? URI.revive(cmd.cwd).fsPath : String(cmd.cwd);
486
}
487
if (cmd.env && typeof cmd.env === 'object' && Object.keys(cmd.env as Record<string, unknown>).length > 0) {
488
result['env'] = cmd.env;
489
}
490
if (typeof cmd.timeout === 'number') {
491
result['timeout'] = cmd.timeout;
492
}
493
return result;
494
}
495
496
export function serializeMcpLaunch(launch: McpServerDefinition['launch']): object {
497
if (launch.type === McpServerTransportType.Stdio) {
498
const result: Record<string, unknown> = {
499
type: 'stdio',
500
command: launch.command,
501
};
502
if (launch.args.length > 0) {
503
result['args'] = [...launch.args];
504
}
505
if (launch.cwd) {
506
result['cwd'] = launch.cwd;
507
}
508
if (Object.keys(launch.env).length > 0) {
509
result['env'] = { ...launch.env };
510
}
511
return result;
512
} else {
513
const result: Record<string, unknown> = {
514
type: 'http',
515
url: launch.uri.toString(),
516
};
517
if (launch.headers.length > 0) {
518
const headers: Record<string, string> = {};
519
for (const [key, value] of launch.headers) {
520
headers[key] = value;
521
}
522
result['headers'] = headers;
523
}
524
return result;
525
}
526
}
527
528
export async function copyDirectory(fileService: IFileService, source: URI, target: URI): Promise<void> {
529
const stat = await fileService.resolve(source);
530
if (stat.isDirectory) {
531
await fileService.createFolder(target);
532
if (stat.children) {
533
for (const child of stat.children) {
534
const childName = basename(child.resource);
535
await copyDirectory(fileService, child.resource, joinPath(target, childName));
536
}
537
}
538
} else {
539
const content = await fileService.readFile(source);
540
await fileService.writeFile(target, content.value);
541
}
542
}
543
544
const MARKETPLACE_PATHS = [
545
'marketplace.json',
546
'.plugin/marketplace.json',
547
];
548
549
export async function updateMarketplaceIfNeeded(fileService: IFileService, targetDir: URI, pluginName: string): Promise<void> {
550
for (const relPath of MARKETPLACE_PATHS) {
551
const marketplaceUri = joinPath(targetDir, relPath);
552
if (await fileService.exists(marketplaceUri)) {
553
try {
554
const content = await fileService.readFile(marketplaceUri);
555
const marketplace = parseJSONC<Record<string, unknown>>(content.value.toString());
556
if (marketplace && typeof marketplace === 'object') {
557
if (!Array.isArray(marketplace['plugins'])) {
558
marketplace['plugins'] = [];
559
}
560
561
const plugins = marketplace['plugins'] as { name?: string; source?: string }[];
562
563
// Skip if a plugin with this name already exists
564
if (plugins.some(p => p.name === pluginName)) {
565
return;
566
}
567
568
plugins.push({
569
name: pluginName,
570
source: `./${pluginName}/`,
571
});
572
573
await fileService.writeFile(
574
marketplaceUri,
575
VSBuffer.fromString(JSON.stringify(marketplace, null, '\t'))
576
);
577
}
578
} catch {
579
// Skip if marketplace.json is unparseable
580
}
581
return; // Only update the first found marketplace
582
}
583
}
584
}
585
586
export function registerCreatePluginAction(): DisposableStore {
587
const store = new DisposableStore();
588
store.add(registerAction2(CreatePluginAction));
589
return store;
590
}
591
592