Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { VSBuffer } from '../../../../../base/common/buffer.js';6import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { parse as parseJSONC } from '../../../../../base/common/jsonc.js';9import { DisposableStore } from '../../../../../base/common/lifecycle.js';10import { basename, dirname, joinPath } from '../../../../../base/common/resources.js';11import { ThemeIcon } from '../../../../../base/common/themables.js';12import { isUriComponents, URI } from '../../../../../base/common/uri.js';13import { localize, localize2 } from '../../../../../nls.js';14import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';15import { ICommandService } from '../../../../../platform/commands/common/commands.js';16import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';17import { IFileService } from '../../../../../platform/files/common/files.js';18import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';19import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';20import { INotificationService } from '../../../../../platform/notification/common/notification.js';21import { IQuickInputButton, IQuickInputService, IQuickTreeItem } from '../../../../../platform/quickinput/common/quickInput.js';22import { InstalledAgentPluginsViewId } from '../chat.js';23import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';24import { PromptsType } from '../../common/promptSyntax/promptTypes.js';25import { IPromptPath, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';26import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js';27import { McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerTransportType } from '../../../mcp/common/mcpTypes.js';28import { CHAT_CATEGORY } from './chatActions.js';2930const VALID_PLUGIN_NAME = /^[a-z0-9]([a-z0-9\-.]*[a-z0-9])?$/;31const INVALID_CONSECUTIVE = /--|[.][.]/;3233export function validatePluginName(name: string): string | undefined {34if (!name) {35return localize('pluginNameRequired', "Plugin name is required.");36}37if (name.length > 64) {38return localize('pluginNameTooLong', "Plugin name must be at most 64 characters.");39}40if (!VALID_PLUGIN_NAME.test(name)) {41return localize('pluginNameInvalid', "Plugin name must contain only lowercase alphanumeric characters, hyphens, and periods, and must start and end with an alphanumeric character.");42}43if (INVALID_CONSECUTIVE.test(name)) {44return localize('pluginNameConsecutive', "Plugin name must not contain consecutive hyphens or periods.");45}46return undefined;47}4849type ResourceType = 'instruction' | 'prompt' | 'agent' | 'skill' | 'hook' | 'mcp';5051export interface IResourceTreeItem extends IQuickTreeItem {52readonly resourceType: ResourceType;53readonly promptPath?: IPromptPath;54readonly mcpServer?: { collection: McpCollectionDefinition; definition: McpServerDefinition };55children?: readonly IResourceTreeItem[];56}5758interface IGroupTreeItem extends IQuickTreeItem {59readonly resourceType?: undefined;60children: IResourceTreeItem[];61}6263function isUserDefined(storage: PromptsStorage): boolean {64return storage === PromptsStorage.local || storage === PromptsStorage.user;65}6667function isUserDefinedMcpCollection(collection: McpCollectionDefinition): boolean {68const order = collection.order;69return order === McpCollectionSortOrder.User70|| order === McpCollectionSortOrder.WorkspaceFolder71|| order === McpCollectionSortOrder.Workspace;72}7374/**75* Gets a display label for a prompt resource. Skills need special handling76* because their URI points to `SKILL.md`, so we use the parent directory name.77*/78export function getResourceLabel(r: IPromptPath): string {79if (r.name) {80return r.name;81}82if (r.type === PromptsType.skill && basename(r.uri).toLowerCase() === 'skill.md') {83return basename(dirname(r.uri));84}85return basename(r.uri);86}8788/**89* Gets a filesystem-safe name for a resource, stripping any namespace prefix90* (e.g. `plugin:skillname` → `skillname`).91*/92export function getResourceFileName(r: IPromptPath): string {93const label = getResourceLabel(r);94const colonIndex = label.indexOf(':');95return colonIndex >= 0 ? label.substring(colonIndex + 1) : label;96}9798class CreatePluginAction extends Action2 {99100static readonly ID = 'workbench.action.chat.createPlugin';101102constructor() {103super({104id: CreatePluginAction.ID,105title: localize2('chat.createPlugin', "Create Plugin"),106category: CHAT_CATEGORY,107f1: true,108precondition: ChatContextKeys.enabled,109icon: Codicon.save,110menu: [{111id: MenuId.ViewTitle,112when: ContextKeyExpr.and(113ContextKeyExpr.equals('view', InstalledAgentPluginsViewId),114ChatContextKeys.Setup.hidden.negate(),115ChatContextKeys.Setup.disabledInWorkspace.negate(),116),117group: 'navigation',118order: 2,119}],120});121}122123override async run(accessor: ServicesAccessor): Promise<void> {124const quickInputService = accessor.get(IQuickInputService);125const promptsService = accessor.get(IPromptsService);126const mcpRegistry = accessor.get(IMcpRegistry);127const fileDialogService = accessor.get(IFileDialogService);128const fileService = accessor.get(IFileService);129const commandService = accessor.get(ICommandService);130const notificationService = accessor.get(INotificationService);131132// Step 1: Gather resources133const [instructions, prompts, agents, skills, hooks] = await (async () => {134const cts = new CancellationTokenSource();135try {136return await Promise.all([137promptsService.listPromptFiles(PromptsType.instructions, cts.token),138promptsService.listPromptFiles(PromptsType.prompt, cts.token),139promptsService.listPromptFiles(PromptsType.agent, cts.token),140promptsService.listPromptFiles(PromptsType.skill, cts.token),141promptsService.listPromptFiles(PromptsType.hook, cts.token),142]);143} finally {144cts.dispose(true);145}146})();147148const mcpCollections = mcpRegistry.collections.get();149150// Step 2: Build tree items grouped by resource type151let showAll = false;152153const buildTree = (): (IGroupTreeItem | IResourceTreeItem)[] => {154const groups: (IGroupTreeItem | IResourceTreeItem)[] = [];155156const addGroup = (157resources: readonly IPromptPath[],158resourceType: ResourceType,159groupLabel: string,160icon: ThemeIcon,161) => {162const filtered = showAll ? resources : resources.filter(r => isUserDefined(r.storage));163if (filtered.length === 0) {164return;165}166const children: IResourceTreeItem[] = filtered.map(r => ({167label: getResourceLabel(r),168description: r.storage,169resourceType,170promptPath: r,171checked: false,172}));173groups.push({174label: groupLabel,175iconClass: ThemeIcon.asClassName(icon),176checked: undefined,177collapsed: false,178pickable: false,179children,180});181};182183addGroup(instructions, 'instruction', localize('instructions', "Instructions"), Codicon.book);184addGroup(prompts, 'prompt', localize('prompts', "Prompts"), Codicon.comment);185addGroup(agents, 'agent', localize('agents', "Agents"), Codicon.copilot);186addGroup(skills, 'skill', localize('skills', "Skills"), Codicon.lightbulb);187addGroup(hooks, 'hook', localize('hooks', "Hooks"), Codicon.zap);188189// MCP servers190const mcpChildren: IResourceTreeItem[] = [];191for (const collection of mcpCollections) {192if (!showAll && !isUserDefinedMcpCollection(collection)) {193continue;194}195const defs = collection.serverDefinitions.get();196for (const def of defs) {197mcpChildren.push({198label: def.label,199description: collection.label,200resourceType: 'mcp',201mcpServer: { collection, definition: def },202checked: false,203});204}205}206if (mcpChildren.length > 0) {207groups.push({208label: localize('mcpServers', "MCP Servers"),209iconClass: ThemeIcon.asClassName(Codicon.mcp),210checked: undefined,211collapsed: false,212pickable: false,213children: mcpChildren,214});215}216217return groups;218};219220// Step 3: Show QuickTree for multi-select with groupings221const disposables = new DisposableStore();222const tree = disposables.add(quickInputService.createQuickTree<IGroupTreeItem | IResourceTreeItem>());223tree.placeholder = localize('selectResources', "Select resources to include in the plugin");224tree.matchOnDescription = true;225tree.matchOnLabel = true;226tree.sortByLabel = false;227tree.title = localize('createPluginTitle', "Create Plugin");228tree.setItemTree(buildTree());229230const toggleButton: IQuickInputButton = { iconClass: ThemeIcon.asClassName(Codicon.filter), tooltip: localize('showAll', "Show Built-in, Extension, and Plugin Resources") };231tree.buttons = [toggleButton];232233disposables.add(tree.onDidTriggerButton((button: IQuickInputButton) => {234if (button === toggleButton) {235showAll = !showAll;236tree.setItemTree(buildTree());237}238}));239240const selectedItems = await new Promise<readonly (IGroupTreeItem | IResourceTreeItem)[] | undefined>(resolve => {241disposables.add(tree.onDidAccept(() => {242resolve(tree.checkedLeafItems);243tree.hide();244}));245disposables.add(tree.onDidHide(() => {246resolve(undefined);247}));248tree.show();249});250251disposables.dispose();252253if (!selectedItems || selectedItems.length === 0) {254return;255}256257const selected = selectedItems.filter((i): i is IResourceTreeItem => !!i.resourceType);258259// Step 4: Ask for plugin name260const pluginName = await quickInputService.input({261prompt: localize('pluginNamePrompt', "Enter a name for the plugin"),262placeHolder: 'my-plugin',263validateInput: async (value: string) => validatePluginName(value),264});265266if (!pluginName) {267return;268}269270// Step 5: Ask where to save271const folderUris = await fileDialogService.showOpenDialog({272canSelectFiles: false,273canSelectFolders: true,274canSelectMany: false,275title: localize('selectPluginLocation', "Select Plugin Save Location"),276openLabel: localize('selectFolder', "Select Folder"),277});278279if (!folderUris || folderUris.length === 0) {280return;281}282283const targetDir = folderUris[0];284const pluginRoot = joinPath(targetDir, pluginName);285286// Check if plugin directory already exists287if (await fileService.exists(pluginRoot)) {288notificationService.error(localize('pluginExists', "A directory named '{0}' already exists at this location. Please choose a different name or location.", pluginName));289return;290}291292// Step 6: Create plugin structure293try {294await writePluginToDisk(fileService, pluginRoot, pluginName, selected);295296// Step 7: Check for marketplace.json and update it297await updateMarketplaceIfNeeded(fileService, targetDir, pluginName);298299// Step 8: Reveal the plugin directory in the OS file explorer300try {301await commandService.executeCommand('revealFileInOS', pluginRoot);302} catch {303// revealFileInOS may not be available for all URI schemes304}305306notificationService.info(localize('pluginCreated', "Plugin '{0}' created successfully.", pluginName));307308} catch (err) {309notificationService.error(localize('pluginCreateError', "Failed to create plugin: {0}", String(err)));310}311}312}313314/**315* Writes a plugin directory structure to disk from selected resources.316*/317export async function writePluginToDisk(318fileService: IFileService,319pluginRoot: URI,320pluginName: string,321selected: readonly IResourceTreeItem[],322): Promise<void> {323await fileService.createFolder(pluginRoot);324325// Create .plugin/plugin.json326const manifestDir = joinPath(pluginRoot, '.plugin');327await fileService.createFolder(manifestDir);328const manifest = {329name: pluginName,330version: '1.0.0',331description: '',332};333await fileService.writeFile(joinPath(manifestDir, 'plugin.json'), VSBuffer.fromString(JSON.stringify(manifest, null, '\t')));334335// Group selected items by type336const byType = {337instruction: selected.filter(i => i.resourceType === 'instruction'),338prompt: selected.filter(i => i.resourceType === 'prompt'),339agent: selected.filter(i => i.resourceType === 'agent'),340skill: selected.filter(i => i.resourceType === 'skill'),341hook: selected.filter(i => i.resourceType === 'hook'),342mcp: selected.filter(i => i.resourceType === 'mcp'),343};344345// Copy instructions → rules/346if (byType.instruction.length > 0) {347const rulesDir = joinPath(pluginRoot, 'rules');348await fileService.createFolder(rulesDir);349for (const item of byType.instruction) {350if (!item.promptPath) {351continue;352}353const name = getResourceFileName(item.promptPath);354const fileName = name.endsWith('.instructions.md') || name.endsWith('.mdc') || name.endsWith('.md')355? name356: name + '.instructions.md';357const content = await fileService.readFile(item.promptPath.uri);358await fileService.writeFile(joinPath(rulesDir, fileName), content.value);359}360}361362// Copy prompts → commands/363if (byType.prompt.length > 0) {364const commandsDir = joinPath(pluginRoot, 'commands');365await fileService.createFolder(commandsDir);366for (const item of byType.prompt) {367if (!item.promptPath) {368continue;369}370const name = getResourceFileName(item.promptPath);371const fileName = name.endsWith('.md') ? name : name + '.md';372const content = await fileService.readFile(item.promptPath.uri);373await fileService.writeFile(joinPath(commandsDir, fileName), content.value);374}375}376377// Copy agents → agents/378if (byType.agent.length > 0) {379const agentsDir = joinPath(pluginRoot, 'agents');380await fileService.createFolder(agentsDir);381for (const item of byType.agent) {382if (!item.promptPath) {383continue;384}385const name = getResourceFileName(item.promptPath);386const fileName = name.endsWith('.md') ? name : name + '.md';387const content = await fileService.readFile(item.promptPath.uri);388await fileService.writeFile(joinPath(agentsDir, fileName), content.value);389}390}391392// Copy skills → skills/ (recursive directory copy)393if (byType.skill.length > 0) {394const skillsDir = joinPath(pluginRoot, 'skills');395await fileService.createFolder(skillsDir);396for (const item of byType.skill) {397if (!item.promptPath) {398continue;399}400const sourceUri = item.promptPath.uri;401const skillName = getResourceFileName(item.promptPath);402403// The URI for a skill might point to the SKILL.md file or to the directory404const sourceName = basename(sourceUri);405const isFile = sourceName.toLowerCase() === 'skill.md';406const skillSourceDir = isFile ? joinPath(sourceUri, '..') : sourceUri;407408const destSkillDir = joinPath(skillsDir, skillName);409await copyDirectory(fileService, skillSourceDir, destSkillDir);410}411}412413// Copy hooks → hooks/hooks.json (merge all selected hook files)414if (byType.hook.length > 0) {415const hooksDir = joinPath(pluginRoot, 'hooks');416await fileService.createFolder(hooksDir);417418const mergedHooks: Record<string, Record<string, unknown>[]> = {};419for (const item of byType.hook) {420if (!item.promptPath) {421continue;422}423try {424const content = await fileService.readFile(item.promptPath.uri);425const parsed = parseJSONC<Record<string, unknown>>(content.value.toString());426const hooksObj = (parsed?.hooks ?? parsed) as Record<string, unknown> | undefined;427if (hooksObj && typeof hooksObj === 'object') {428for (const [hookType, commands] of Object.entries(hooksObj)) {429if (Array.isArray(commands)) {430if (!mergedHooks[hookType]) {431mergedHooks[hookType] = [];432}433for (const cmd of commands) {434mergedHooks[hookType].push(serializeHookCommand(cmd));435}436}437}438}439} catch {440// Skip unparseable hook files441}442}443444const hooksJson = { hooks: mergedHooks };445await fileService.writeFile(446joinPath(hooksDir, 'hooks.json'),447VSBuffer.fromString(JSON.stringify(hooksJson, null, '\t'))448);449}450451// Export MCP servers → .mcp.json452if (byType.mcp.length > 0) {453const mcpServers: Record<string, object> = {};454for (const item of byType.mcp) {455if (!item.mcpServer) {456continue;457}458const def = item.mcpServer.definition;459mcpServers[def.label] = serializeMcpLaunch(def.launch);460}461const mcpJson = { mcpServers };462await fileService.writeFile(463joinPath(pluginRoot, '.mcp.json'),464VSBuffer.fromString(JSON.stringify(mcpJson, null, '\t'))465);466}467}468469export function serializeHookCommand(cmd: Record<string, unknown>): Record<string, unknown> {470const result: Record<string, unknown> = { type: 'command' };471if (typeof cmd.command === 'string') {472result['command'] = cmd.command;473}474if (typeof cmd.windows === 'string') {475result['windows'] = cmd.windows;476}477if (typeof cmd.linux === 'string') {478result['linux'] = cmd.linux;479}480if (typeof cmd.osx === 'string') {481result['osx'] = cmd.osx;482}483if (cmd.cwd !== undefined) {484result['cwd'] = isUriComponents(cmd.cwd) ? URI.revive(cmd.cwd).fsPath : String(cmd.cwd);485}486if (cmd.env && typeof cmd.env === 'object' && Object.keys(cmd.env as Record<string, unknown>).length > 0) {487result['env'] = cmd.env;488}489if (typeof cmd.timeout === 'number') {490result['timeout'] = cmd.timeout;491}492return result;493}494495export function serializeMcpLaunch(launch: McpServerDefinition['launch']): object {496if (launch.type === McpServerTransportType.Stdio) {497const result: Record<string, unknown> = {498type: 'stdio',499command: launch.command,500};501if (launch.args.length > 0) {502result['args'] = [...launch.args];503}504if (launch.cwd) {505result['cwd'] = launch.cwd;506}507if (Object.keys(launch.env).length > 0) {508result['env'] = { ...launch.env };509}510return result;511} else {512const result: Record<string, unknown> = {513type: 'http',514url: launch.uri.toString(),515};516if (launch.headers.length > 0) {517const headers: Record<string, string> = {};518for (const [key, value] of launch.headers) {519headers[key] = value;520}521result['headers'] = headers;522}523return result;524}525}526527export async function copyDirectory(fileService: IFileService, source: URI, target: URI): Promise<void> {528const stat = await fileService.resolve(source);529if (stat.isDirectory) {530await fileService.createFolder(target);531if (stat.children) {532for (const child of stat.children) {533const childName = basename(child.resource);534await copyDirectory(fileService, child.resource, joinPath(target, childName));535}536}537} else {538const content = await fileService.readFile(source);539await fileService.writeFile(target, content.value);540}541}542543const MARKETPLACE_PATHS = [544'marketplace.json',545'.plugin/marketplace.json',546];547548export async function updateMarketplaceIfNeeded(fileService: IFileService, targetDir: URI, pluginName: string): Promise<void> {549for (const relPath of MARKETPLACE_PATHS) {550const marketplaceUri = joinPath(targetDir, relPath);551if (await fileService.exists(marketplaceUri)) {552try {553const content = await fileService.readFile(marketplaceUri);554const marketplace = parseJSONC<Record<string, unknown>>(content.value.toString());555if (marketplace && typeof marketplace === 'object') {556if (!Array.isArray(marketplace['plugins'])) {557marketplace['plugins'] = [];558}559560const plugins = marketplace['plugins'] as { name?: string; source?: string }[];561562// Skip if a plugin with this name already exists563if (plugins.some(p => p.name === pluginName)) {564return;565}566567plugins.push({568name: pluginName,569source: `./${pluginName}/`,570});571572await fileService.writeFile(573marketplaceUri,574VSBuffer.fromString(JSON.stringify(marketplace, null, '\t'))575);576}577} catch {578// Skip if marketplace.json is unparseable579}580return; // Only update the first found marketplace581}582}583}584585export function registerCreatePluginAction(): DisposableStore {586const store = new DisposableStore();587store.add(registerAction2(CreatePluginAction));588return store;589}590591592