From a4e35e0d6992b16f5eaac751457641cd9bc4099b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 4 Mar 2026 07:20:21 -0800 Subject: [PATCH] chat: add support for agent plugin sources (#299081) * chat: add support for agent plugin sources - Adds support for agent plugins to reference sources as specified in PLUGIN_SOURCES.md, enabling installation from GitHub, npm, pip, and other package registries - Integrates source parsing and validation into the plugin installation service and repository service - Adds comprehensive test coverage for plugin source handling and installation from various sources - Creates PLUGIN_SOURCES.md documentation describing how to specify plugin source configurations (Commit message generated by Copilot) * comments * windows fixes and fault handling * fix tests --- extensions/git/src/commands.ts | 14 +- extensions/git/src/git.ts | 11 +- .../agentPluginEditor/agentPluginEditor.ts | 7 +- .../agentPluginEditor/agentPluginItems.ts | 3 +- .../browser/agentPluginRepositoryService.ts | 180 +++++- .../contrib/chat/browser/agentPluginsView.ts | 7 +- .../chat/browser/pluginInstallService.ts | 270 +++++++- .../plugins/agentPluginRepositoryService.ts | 24 +- .../chat/common/plugins/agentPluginService.ts | 2 + .../common/plugins/agentPluginServiceImpl.ts | 1 + .../plugins/pluginMarketplaceService.ts | 280 +++++++- .../agentPluginRepositoryService.test.ts | 48 +- .../plugins/pluginInstallService.test.ts | 611 ++++++++++++++++++ .../plugins/pluginMarketplaceService.test.ts | 152 ++++- .../service/promptsService.test.ts | 3 +- .../common/discovery/pluginMcpDiscovery.ts | 3 +- 16 files changed, 1571 insertions(+), 45 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 64898026821..d3eae80b5e1 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -1040,19 +1040,29 @@ export class CommandCenter { } @command('_git.cloneRepository') - async cloneRepository(url: string, parentPath: string): Promise { + async cloneRepository(url: string, localPath: string, ref?: string): Promise { const opts = { location: ProgressLocation.Notification, title: l10n.t('Cloning git repository "{0}"...', url), cancellable: true }; + const parentPath = path.dirname(localPath); + const targetName = path.basename(localPath); + await window.withProgress( opts, - (progress, token) => this.model.git.clone(url, { parentPath, progress }, token) + (progress, token) => this.model.git.clone(url, { parentPath, targetName, progress, ref }, token) ); } + @command('_git.checkout') + async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + await repo.checkout(treeish, [], detached ? { detached: true } : {}); + } + @command('_git.pull') async pullRepository(repositoryPath: string): Promise { const dotGit = await this.git.getRepositoryDotGit(repositoryPath); diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5f7d1100f70..1bd20a42c54 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -378,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct'; export interface ICloneOptions { readonly parentPath: string; + readonly targetName?: string; readonly progress: Progress<{ increment: number }>; readonly recursive?: boolean; readonly ref?: string; @@ -433,14 +434,16 @@ export class Git { } async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise { - const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; + const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; let folderName = baseFolderName; let folderPath = path.join(options.parentPath, folderName); let count = 1; - while (count < 20 && await new Promise(c => exists(folderPath, c))) { - folderName = `${baseFolderName}-${count++}`; - folderPath = path.join(options.parentPath, folderName); + if (!options.targetName) { + while (count < 20 && await new Promise(c => exists(folderPath, c))) { + folderName = `${baseFolderName}-${count++}`; + folderPath = path.join(options.parentPath, folderName); + } } await mkdirp(options.parentPath); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts index df8124325ee..bca23294bf6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -12,7 +12,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/ import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas, matchesScheme } from '../../../../../base/common/network.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; +import { dirname, joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; @@ -202,6 +202,7 @@ export class AgentPluginEditor extends EditorPane { description: item.description, version: '', source: item.source, + sourceDescriptor: item.sourceDescriptor, marketplace: item.marketplace, marketplaceReference: item.marketplaceReference, marketplaceType: item.marketplaceType, @@ -222,6 +223,7 @@ export class AgentPluginEditor extends EditorPane { name: item.name, description: mp.description, source: mp.source, + sourceDescriptor: mp.sourceDescriptor, marketplace: mp.marketplace, marketplaceReference: mp.marketplaceReference, marketplaceType: mp.marketplaceType, @@ -267,7 +269,7 @@ export class AgentPluginEditor extends EditorPane { } private installedPluginToItem(plugin: IAgentPlugin): IInstalledPluginItem { - const name = basename(plugin.uri); + const name = plugin.label; const description = plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(dirname(plugin.uri), { relative: true }); const marketplace = plugin.fromMarketplace?.marketplace; return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; @@ -517,6 +519,7 @@ class InstallPluginEditorAction extends Action { description: this.item.description, version: '', source: this.item.source, + sourceDescriptor: this.item.sourceDescriptor, marketplace: this.item.marketplace, marketplaceReference: this.item.marketplaceReference, marketplaceType: this.item.marketplaceType, diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts index 20ec5ed4009..9f1b8f8e97c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../../base/common/uri.js'; import type { IAgentPlugin } from '../../common/plugins/agentPluginService.js'; -import type { IMarketplaceReference, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; +import type { IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; export const enum AgentPluginItemKind { Installed = 'installed', @@ -25,6 +25,7 @@ export interface IMarketplacePluginItem { readonly name: string; readonly description: string; readonly source: string; + readonly sourceDescriptor: IPluginSourceDescriptor; readonly marketplace: string; readonly marketplaceReference: IMarketplaceReference; readonly marketplaceType: MarketplaceType; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 7b2244345e8..41abb16b8f8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -18,7 +18,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, IMarketplaceReference, MarketplaceReferenceKind, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1'; @@ -176,7 +176,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE); } - private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string): Promise { + private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise { try { await this._progressService.withProgress( { @@ -186,7 +186,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi }, async () => { await this._fileService.createFolder(dirname(repoDir)); - await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, dirname(repoDir).fsPath); + await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref); } ); } catch (err) { @@ -212,4 +212,178 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } return pluginDir; } + + getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI { + switch (sourceDescriptor.kind) { + case PluginSourceKind.RelativePath: + throw new Error('Use getPluginInstallUri() for relative-path sources'); + case PluginSourceKind.GitHub: { + const [owner, repo] = sourceDescriptor.repo.split('/'); + return joinPath(this._cacheRoot, 'github.com', owner, repo, ...this._getSourceRevisionCacheSuffix(sourceDescriptor)); + } + case PluginSourceKind.GitUrl: { + const segments = this._gitUrlCacheSegments(sourceDescriptor.url, sourceDescriptor.ref, sourceDescriptor.sha); + return joinPath(this._cacheRoot, ...segments); + } + case PluginSourceKind.Npm: + return joinPath(this._cacheRoot, 'npm', sanitizePackageName(sourceDescriptor.package), 'node_modules', sourceDescriptor.package); + case PluginSourceKind.Pip: + return joinPath(this._cacheRoot, 'pip', sanitizePackageName(sourceDescriptor.package)); + } + } + + async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise { + const descriptor = plugin.sourceDescriptor; + switch (descriptor.kind) { + case PluginSourceKind.RelativePath: + return this.ensureRepository(plugin.marketplaceReference, options); + case PluginSourceKind.GitHub: { + const cloneUrl = `https://github.com/${descriptor.repo}.git`; + const repoDir = this.getPluginSourceInstallUri(descriptor); + const repoExists = await this._fileService.exists(repoDir); + if (repoExists) { + await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.repo); + return repoDir; + } + const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", descriptor.repo); + const failureLabel = options?.failureLabel ?? descriptor.repo; + await this._cloneRepository(repoDir, cloneUrl, progressTitle, failureLabel, descriptor.ref); + await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel); + return repoDir; + } + case PluginSourceKind.GitUrl: { + const repoDir = this.getPluginSourceInstallUri(descriptor); + const repoExists = await this._fileService.exists(repoDir); + if (repoExists) { + await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.url); + return repoDir; + } + const progressTitle = options?.progressTitle ?? localize('cloningPluginSourceUrl', "Cloning plugin source '{0}'...", descriptor.url); + const failureLabel = options?.failureLabel ?? descriptor.url; + await this._cloneRepository(repoDir, descriptor.url, progressTitle, failureLabel, descriptor.ref); + await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel); + return repoDir; + } + case PluginSourceKind.Npm: { + // npm/pip install directories are managed by the install service. + // Return the expected install URI without performing installation. + return joinPath(this._cacheRoot, 'npm', sanitizePackageName(descriptor.package)); + } + case PluginSourceKind.Pip: { + return joinPath(this._cacheRoot, 'pip', sanitizePackageName(descriptor.package)); + } + } + } + + async updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise { + const descriptor = plugin.sourceDescriptor; + if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) { + return; + } + + const repoDir = this.getPluginSourceInstallUri(descriptor); + const repoExists = await this._fileService.exists(repoDir); + if (!repoExists) { + this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`); + return; + } + + const updateLabel = options?.pluginName ?? plugin.name; + const failureLabel = options?.failureLabel ?? updateLabel; + + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel), + cancellable: false, + }, + async () => { + await this._commandService.executeCommand('git.openRepository', repoDir.fsPath); + if (descriptor.sha) { + await this._commandService.executeCommand('git.fetch', repoDir.fsPath); + } else { + await this._commandService.executeCommand('_git.pull', repoDir.fsPath); + } + await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel); + } + ); + } catch (err) { + this._logService.error(`[AgentPluginRepositoryService] Failed to update plugin source ${updateLabel}:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)), + actions: { + primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + } + } + + private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] { + try { + const parsed = URI.parse(url); + const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase(); + const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, ''); + const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_')); + return [authority, ...segments, ...this._getSourceRevisionCacheSuffix(ref, sha)]; + } catch { + return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...this._getSourceRevisionCacheSuffix(ref, sha)]; + } + } + + private _getSourceRevisionCacheSuffix(descriptorOrRef: IPluginSourceDescriptor | string | undefined, sha?: string): string[] { + if (typeof descriptorOrRef === 'object' && descriptorOrRef) { + if (descriptorOrRef.kind === PluginSourceKind.GitHub || descriptorOrRef.kind === PluginSourceKind.GitUrl) { + return this._getSourceRevisionCacheSuffix(descriptorOrRef.ref, descriptorOrRef.sha); + } + return []; + } + + const ref = descriptorOrRef; + if (sha) { + return [`sha_${sanitizePackageName(sha)}`]; + } + if (ref) { + return [`ref_${sanitizePackageName(ref)}`]; + } + return []; + } + + private async _checkoutPluginSourceRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string): Promise { + if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) { + return; + } + + if (!descriptor.sha && !descriptor.ref) { + return; + } + + try { + if (descriptor.sha) { + await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.sha, true); + return; + } + + await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.ref); + } catch (err) { + this._logService.error(`[AgentPluginRepositoryService] Failed to checkout plugin source revision for ${failureLabel}:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)), + actions: { + primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + throw err; + } + } +} + +function sanitizePackageName(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, '_'); } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 129cbad928f..0033b0888ef 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -17,7 +17,7 @@ import { Disposable, DisposableStore, disposeIfDisposable, IDisposable, isDispos import { ThemeIcon } from '../../../../base/common/themables.js'; import { autorun } from '../../../../base/common/observable.js'; import { IPagedModel, PagedModel } from '../../../../base/common/paging.js'; -import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; +import { dirname, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -56,7 +56,7 @@ export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.install //#region Item model function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem { - const name = basename(plugin.uri); + const name = plugin.label; const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true }); const marketplace = plugin.fromMarketplace?.marketplace; return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; @@ -68,6 +68,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin name: plugin.name, description: plugin.description, source: plugin.source, + sourceDescriptor: plugin.sourceDescriptor, marketplace: plugin.marketplace, marketplaceReference: plugin.marketplaceReference, marketplaceType: plugin.marketplaceType, @@ -95,6 +96,7 @@ class InstallPluginAction extends Action { description: this.item.description, version: '', source: this.item.source, + sourceDescriptor: this.item.sourceDescriptor, marketplace: this.item.marketplace, marketplaceReference: this.item.marketplaceReference, marketplaceType: this.item.marketplaceType, @@ -518,6 +520,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView { + switch (plugin.sourceDescriptor.kind) { + case PluginSourceKind.RelativePath: + return this._installRelativePathPlugin(plugin); + case PluginSourceKind.GitHub: + case PluginSourceKind.GitUrl: + return this._installGitPlugin(plugin); + case PluginSourceKind.Npm: + return this._installNpmPlugin(plugin, plugin.sourceDescriptor); + case PluginSourceKind.Pip: + return this._installPipPlugin(plugin, plugin.sourceDescriptor); + } + } + + async updatePlugin(plugin: IMarketplacePlugin): Promise { + switch (plugin.sourceDescriptor.kind) { + case PluginSourceKind.RelativePath: + return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, { + pluginName: plugin.name, + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + case PluginSourceKind.GitHub: + case PluginSourceKind.GitUrl: + return this._pluginRepositoryService.updatePluginSource(plugin, { + pluginName: plugin.name, + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + case PluginSourceKind.Npm: + return this._installNpmPlugin(plugin, plugin.sourceDescriptor); + case PluginSourceKind.Pip: + return this._installPipPlugin(plugin, plugin.sourceDescriptor); + } + } + + getPluginInstallUri(plugin: IMarketplacePlugin): URI { + if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) { + return this._pluginRepositoryService.getPluginInstallUri(plugin); + } + return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor); + } + + // --- Relative-path source (existing git-based flow) ----------------------- + + private async _installRelativePathPlugin(plugin: IMarketplacePlugin): Promise { try { await this._pluginRepositoryService.ensureRepository(plugin.marketplaceReference, { progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), @@ -55,15 +112,212 @@ export class PluginInstallService implements IPluginInstallService { this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); } - async updatePlugin(plugin: IMarketplacePlugin): Promise { - return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, { - pluginName: plugin.name, - failureLabel: plugin.name, - marketplaceType: plugin.marketplaceType, + // --- GitHub / Git URL source (independent clone) -------------------------- + + private async _installGitPlugin(plugin: IMarketplacePlugin): Promise { + let pluginDir: URI; + try { + pluginDir = await this._pluginRepositoryService.ensurePluginSource(plugin, { + progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + } catch { + return; + } + + const pluginExists = await this._fileService.exists(pluginDir); + if (!pluginExists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pluginSourceNotFound', "Plugin source '{0}' not found after cloning.", getPluginSourceLabel(plugin.sourceDescriptor)), + }); + return; + } + + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); + } + + // --- npm source ----------------------------------------------------------- + + private async _installNpmPlugin(plugin: IMarketplacePlugin, source: INpmPluginSource): Promise { + const packageSpec = source.version ? `${source.package}@${source.version}` : source.package; + const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin); + const args = ['npm', 'install', '--prefix', installDir.fsPath, packageSpec]; + if (source.registry) { + args.push('--registry', source.registry); + } + const command = this._formatShellCommand(args); + + const confirmed = await this._confirmTerminalCommand(plugin.name, command); + if (!confirmed) { + return; + } + + const { success, terminal } = await this._runTerminalCommand( + command, + localize('installingNpmPlugin', "Installing npm plugin '{0}'...", plugin.name), + ); + if (!success) { + return; + } + + const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(source); + const pluginExists = await this._fileService.exists(pluginDir); + if (!pluginExists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('npmPluginNotFound', "npm package '{0}' was not found after installation.", source.package), + }); + return; + } + + terminal?.dispose(); + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); + } + + // --- pip source ----------------------------------------------------------- + + private async _installPipPlugin(plugin: IMarketplacePlugin, source: IPipPluginSource): Promise { + const packageSpec = source.version ? `${source.package}==${source.version}` : source.package; + const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin); + const args = ['pip', 'install', '--target', installDir.fsPath, packageSpec]; + if (source.registry) { + args.push('--index-url', source.registry); + } + const command = this._formatShellCommand(args); + + const confirmed = await this._confirmTerminalCommand(plugin.name, command); + if (!confirmed) { + return; + } + + const { success, terminal } = await this._runTerminalCommand( + command, + localize('installingPipPlugin', "Installing pip plugin '{0}'...", plugin.name), + ); + if (!success) { + return; + } + + const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(source); + const pluginExists = await this._fileService.exists(pluginDir); + if (!pluginExists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pipPluginNotFound', "pip package '{0}' was not found after installation.", source.package), + }); + return; + } + + terminal?.dispose(); + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); + } + + // --- Helpers -------------------------------------------------------------- + + private async _confirmTerminalCommand(pluginName: string, command: string): Promise { + const { confirmed } = await this._dialogService.confirm({ + type: 'question', + message: localize('confirmPluginInstall', "Install Plugin '{0}'?", pluginName), + detail: localize('confirmPluginInstallDetail', "This will run the following command in a terminal:\n\n{0}", command), + primaryButton: localize({ key: 'confirmInstall', comment: ['&& denotes a mnemonic'] }, "&&Install"), + }); + return confirmed; + } + + private async _runTerminalCommand(command: string, progressTitle: string) { + let terminal: ITerminalInstance | undefined; + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: progressTitle, + cancellable: false, + }, + async () => { + terminal = await this._terminalService.createTerminal({ + config: { + name: localize('pluginInstallTerminal', "Plugin Install"), + forceShellIntegration: true, + isTransient: true, + isFeatureTerminal: true, + }, + }); + + await terminal.processReady; + this._terminalService.setActiveInstance(terminal); + + const commandResultPromise = this._waitForTerminalCommandCompletion(terminal); + await terminal.runCommand(command, true); + const exitCode = await commandResultPromise; + if (exitCode !== 0) { + throw new Error(localize('terminalCommandExitCode', "Command exited with code {0}", exitCode)); + } + } + ); + return { success: true, terminal }; + } catch (err) { + this._logService.error('[PluginInstallService] Terminal command failed:', err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('terminalCommandFailed', "Plugin installation command failed: {0}", err?.message ?? String(err)), + }); + return { success: false, terminal }; + } + } + + private _waitForTerminalCommandCompletion(terminal: ITerminalInstance): Promise { + return new Promise(resolve => { + const disposables = new DisposableStore(); + let isResolved = false; + + const resolveAndDispose = (exitCode: number | undefined): void => { + if (isResolved) { + return; + } + isResolved = true; + disposables.dispose(); + resolve(exitCode); + }; + + const attachCommandFinishedListener = (): void => { + const commandDetection = terminal.capabilities.get(TerminalCapability.CommandDetection); + if (!commandDetection) { + return; + } + + disposables.add(commandDetection.onCommandFinished((command: ITerminalCommand) => { + resolveAndDispose(command.exitCode ?? 0); + })); + }; + + attachCommandFinishedListener(); + disposables.add(terminal.capabilities.onDidAddCommandDetectionCapability(() => attachCommandFinishedListener())); + + const timeoutHandle: CancelablePromise = timeout(120_000); + disposables.add(toDisposable(() => timeoutHandle.cancel())); + void timeoutHandle.then(() => { + if (isResolved) { + return; + } + this._logService.warn('[PluginInstallService] Terminal command completion timed out'); + resolveAndDispose(undefined); + }); }); } - getPluginInstallUri(plugin: IMarketplacePlugin): URI { - return this._pluginRepositoryService.getPluginInstallUri(plugin); + private _formatShellCommand(args: readonly string[]): string { + const [command, ...rest] = args; + return [command, ...rest.map(arg => this._shellEscapeArg(arg))].join(' '); + } + + private _shellEscapeArg(value: string): string { + if (isWindows) { + // PowerShell: use double quotes, escape backticks, dollar signs, and double quotes + return `"${value.replace(/[`$"]/g, '`$&')}"`; + } + // POSIX shells: use single quotes, escape by ending quote, adding escaped quote, reopening + return `'${value.replace(/'/g, `'\\''`)}'`; } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts index 3f9a9bffda5..cfb76de1c1d 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IMarketplacePlugin, IMarketplaceReference, MarketplaceType } from './pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from './pluginMarketplaceService.js'; export const IAgentPluginRepositoryService = createDecorator('agentPluginRepositoryService'); @@ -61,4 +61,26 @@ export interface IAgentPluginRepositoryService { * Pulls latest changes for a cloned marketplace repository. */ pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise; + + /** + * Returns the local install URI for a plugin based on its + * {@link IPluginSourceDescriptor}. For non-relative-path sources + * (github, url, npm, pip), this computes a cache location independent + * of the marketplace repository. + */ + getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI; + + /** + * Ensures the plugin source is available locally. For github/url sources + * this clones the repository into the cache. For npm/pip sources this is + * a no-op (installation via terminal is handled by the install service). + */ + ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise; + + /** + * Updates a plugin source that is stored outside the marketplace repository. + * For github/url sources this pulls latest changes and reapplies pinned + * ref/sha checkout. For npm/pip sources this is a no-op. + */ + updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index bbc65030621..4efdce7078e 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -44,6 +44,8 @@ export interface IAgentPluginMcpServerDefinition { export interface IAgentPlugin { readonly uri: URI; + /** Human-readable display name for the plugin. */ + readonly label: string; readonly enabled: IObservable; setEnabled(enabled: boolean): void; /** Removes this plugin from its discovery source (config or installed storage). */ diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index bfd8c768e5e..722ff6da576 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -416,6 +416,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const plugin: PluginEntry = { uri, + label: fromMarketplace?.name ?? basename(uri), enabled, setEnabled: setEnabledCallback, remove: removeCallback, diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 11a8db54823..dbd6e9b7b17 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -45,12 +45,64 @@ export interface IMarketplaceReference { readonly localRepositoryUri?: URI; } +export const enum PluginSourceKind { + RelativePath = 'relativePath', + GitHub = 'github', + GitUrl = 'url', + Npm = 'npm', + Pip = 'pip', +} + +export interface IRelativePathPluginSource { + readonly kind: PluginSourceKind.RelativePath; + /** Resolved relative path within the marketplace repository. */ + readonly path: string; +} + +export interface IGitHubPluginSource { + readonly kind: PluginSourceKind.GitHub; + readonly repo: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface IGitUrlPluginSource { + readonly kind: PluginSourceKind.GitUrl; + /** Full git repository URL (must end with .git). */ + readonly url: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface INpmPluginSource { + readonly kind: PluginSourceKind.Npm; + readonly package: string; + readonly version?: string; + readonly registry?: string; +} + +export interface IPipPluginSource { + readonly kind: PluginSourceKind.Pip; + readonly package: string; + readonly version?: string; + readonly registry?: string; +} + +export type IPluginSourceDescriptor = + | IRelativePathPluginSource + | IGitHubPluginSource + | IGitUrlPluginSource + | INpmPluginSource + | IPipPluginSource; + export interface IMarketplacePlugin { readonly name: string; readonly description: string; readonly version: string; - /** Subdirectory within the repository where the plugin lives. */ + /** Subdirectory within the repository where the plugin lives (for relative-path sources). */ readonly source: string; + /** Structured source descriptor indicating how the plugin should be fetched/installed. */ + readonly sourceDescriptor: IPluginSourceDescriptor; /** Marketplace label shown in UI and plugin provenance. */ readonly marketplace: string; /** Canonical reference for clone/update/install location resolution. */ @@ -60,6 +112,18 @@ export interface IMarketplacePlugin { readonly readmeUri?: URI; } +/** Raw JSON shape of a remote plugin source object in marketplace.json. */ +interface IJsonPluginSource { + readonly source: string; + readonly repo?: string; + readonly url?: string; + readonly package?: string; + readonly ref?: string; + readonly sha?: string; + readonly version?: string; + readonly registry?: string; +} + interface IMarketplaceJson { readonly metadata?: { readonly pluginRoot?: string; @@ -68,7 +132,7 @@ interface IMarketplaceJson { readonly name?: string; readonly description?: string; readonly version?: string; - readonly source?: string; + readonly source?: string | IJsonPluginSource; }[]; } @@ -118,6 +182,23 @@ interface IStoredInstalledPlugin { readonly enabled: boolean; } +/** + * Ensures that an {@link IMarketplacePlugin} loaded from storage has a + * {@link IMarketplacePlugin.sourceDescriptor sourceDescriptor}. Plugins + * persisted before the sourceDescriptor field was introduced will only + * have the legacy `source` string — this function synthesises a + * {@link PluginSourceKind.RelativePath} descriptor from it. + */ +function ensureSourceDescriptor(plugin: IMarketplacePlugin): IMarketplacePlugin { + if (plugin.sourceDescriptor) { + return plugin; + } + return { + ...plugin, + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: plugin.source }, + }; +} + const installedPluginsMemento = observableMemento({ defaultValue: [], key: 'chat.plugins.installed.v1', @@ -151,7 +232,12 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) ); - this.installedPlugins = this._installedPluginsStore.map(s => revive(s)); + this.installedPlugins = this._installedPluginsStore.map(s => + (revive(s) as readonly IMarketplaceInstalledPlugin[]).map(e => ({ + ...e, + plugin: ensureSourceDescriptor(e.plugin), + })) + ); this.onDidChangeMarketplaces = Event.filter( _configurationService.onDidChangeConfiguration, @@ -213,21 +299,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke continue; } const plugins = json.plugins - .filter((p): p is { name: string; description?: string; version?: string; source?: string } => + .filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } => typeof p.name === 'string' && !!p.name ) .flatMap(p => { - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${repo}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`); + const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, { + pluginName: p.name, + logService: this._logService, + logPrefix: `[PluginMarketplaceService]`, + }); + if (!sourceDescriptor) { return []; } + const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : ''; + return [{ name: p.name, description: p.description ?? '', version: p.version ?? '', source, + sourceDescriptor, marketplace: reference.displayLabel, marketplaceReference: reference, marketplaceType: def.type, @@ -293,7 +385,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke continue; } - const plugins = entry.plugins.map(plugin => ({ + const plugins = entry.plugins.map(plugin => ensureSourceDescriptor({ ...plugin, marketplace: reference.displayLabel, marketplaceReference: reference, @@ -344,9 +436,11 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void { const current = this.installedPlugins.get(); if (current.some(e => isEqual(e.pluginUri, pluginUri))) { - return; + // Still update to trigger watchers to re-check, something might have happened that we want to know about + this._installedPluginsStore.set([...current], undefined); + } else { + this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); } - this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); } removeInstalledPlugin(pluginUri: URI): void { @@ -391,21 +485,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } return json.plugins - .filter((p): p is { name: string; description?: string; version?: string; source?: string } => + .filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } => typeof p.name === 'string' && !!p.name ) .flatMap(p => { - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${reference.rawValue}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`); + const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, { + pluginName: p.name, + logService: this._logService, + logPrefix: `[PluginMarketplaceService]`, + }); + if (!sourceDescriptor) { return []; } + const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : ''; + return [{ name: p.name, description: p.description ?? '', version: p.version ?? '', source, + sourceDescriptor, marketplace: reference.displayLabel, marketplaceReference: reference, marketplaceType: def.type, @@ -597,6 +697,158 @@ function resolvePluginSource(pluginRoot: string | undefined, source: string): st return relativePath(repoRoot, resolvedUri) ?? undefined; } +/** + * Parse a raw `source` field from marketplace.json into a structured + * {@link IPluginSourceDescriptor}. Accepts either a relative-path string + * or a JSON object with a `source` discriminant indicating the kind. + */ +export function parsePluginSource( + rawSource: string | IJsonPluginSource | undefined, + pluginRoot: string | undefined, + logContext: { pluginName: string; logService: ILogService; logPrefix: string }, +): IPluginSourceDescriptor | undefined { + if (rawSource === undefined || rawSource === null) { + // Treat missing source the same as empty string → pluginRoot or repo root. + const resolved = resolvePluginSource(pluginRoot, ''); + if (resolved === undefined) { + return undefined; + } + return { kind: PluginSourceKind.RelativePath, path: resolved }; + } + + // String source → legacy relative-path behaviour. + if (typeof rawSource === 'string') { + const resolved = resolvePluginSource(pluginRoot, rawSource); + if (resolved === undefined) { + return undefined; + } + return { kind: PluginSourceKind.RelativePath, path: resolved }; + } + + // Object source → discriminated by `rawSource.source`. + if (typeof rawSource !== 'object' || typeof rawSource.source !== 'string') { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': source object is missing a 'source' discriminant`); + return undefined; + } + + switch (rawSource.source) { + case 'github': { + if (typeof rawSource.repo !== 'string' || !rawSource.repo) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source is missing required 'repo' field`); + return undefined; + } + if (!isValidGitHubRepo(rawSource.repo)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source repo must be in 'owner/repo' format`); + return undefined; + } + if (!isOptionalString(rawSource.ref)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'ref' must be a string when provided`); + return undefined; + } + if (!isOptionalGitSha(rawSource.sha)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'sha' must be a full 40-character commit hash when provided`); + return undefined; + } + return { + kind: PluginSourceKind.GitHub, + repo: rawSource.repo, + ref: rawSource.ref, + sha: rawSource.sha, + }; + } + case 'url': { + if (typeof rawSource.url !== 'string' || !rawSource.url) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source is missing required 'url' field`); + return undefined; + } + if (!rawSource.url.toLowerCase().endsWith('.git')) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source must end with '.git'`); + return undefined; + } + if (!isOptionalString(rawSource.ref)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'ref' must be a string when provided`); + return undefined; + } + if (!isOptionalGitSha(rawSource.sha)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'sha' must be a full 40-character commit hash when provided`); + return undefined; + } + return { + kind: PluginSourceKind.GitUrl, + url: rawSource.url, + ref: rawSource.ref, + sha: rawSource.sha, + }; + } + case 'npm': { + if (typeof rawSource.package !== 'string' || !rawSource.package) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source is missing required 'package' field`); + return undefined; + } + if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source 'version' and 'registry' must be strings when provided`); + return undefined; + } + return { + kind: PluginSourceKind.Npm, + package: rawSource.package, + version: rawSource.version, + registry: rawSource.registry, + }; + } + case 'pip': { + if (typeof rawSource.package !== 'string' || !rawSource.package) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source is missing required 'package' field`); + return undefined; + } + if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source 'version' and 'registry' must be strings when provided`); + return undefined; + } + return { + kind: PluginSourceKind.Pip, + package: rawSource.package, + version: rawSource.version, + registry: rawSource.registry, + }; + } + default: + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': unknown source kind '${rawSource.source}'`); + return undefined; + } +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === 'string'; +} + +function isOptionalGitSha(value: unknown): value is string | undefined { + return value === undefined || (typeof value === 'string' && /^[0-9a-fA-F]{40}$/.test(value)); +} + +function isValidGitHubRepo(repo: string): boolean { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo); +} + +/** + * Returns a human-readable label for a plugin source descriptor, + * suitable for error messages and UI display. + */ +export function getPluginSourceLabel(descriptor: IPluginSourceDescriptor): string { + switch (descriptor.kind) { + case PluginSourceKind.RelativePath: + return descriptor.path || '.'; + case PluginSourceKind.GitHub: + return descriptor.repo; + case PluginSourceKind.GitUrl: + return descriptor.url; + case PluginSourceKind.Npm: + return descriptor.version ? `${descriptor.package}@${descriptor.version}` : descriptor.package; + case PluginSourceKind.Pip: + return descriptor.version ? `${descriptor.package}==${descriptor.version}` : descriptor.package; + } +} + function getMarketplaceReadmeUri(repo: string, source: string): URI { const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); const readmePath = normalizedSource ? `${normalizedSource}/README.md` : 'README.md'; diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index d5b0366678f..0aad10fc122 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -15,7 +15,7 @@ import { INotificationService } from '../../../../../../platform/notification/co import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; suite('AgentPluginRepositoryService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -32,6 +32,7 @@ suite('AgentPluginRepositoryService', () => { description: '', version: '', source, + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: source }, marketplace: marketplaceReference.displayLabel, marketplaceReference, marketplaceType: MarketplaceType.Copilot, @@ -40,7 +41,7 @@ suite('AgentPluginRepositoryService', () => { function createService( onExists?: (resource: URI) => Promise, - onExecuteCommand?: (id: string) => void, + onExecuteCommand?: (id: string, ...args: unknown[]) => void, ): AgentPluginRepositoryService { const instantiationService = store.add(new TestInstantiationService()); @@ -53,8 +54,8 @@ suite('AgentPluginRepositoryService', () => { } as unknown as IProgressService; instantiationService.stub(ICommandService, { - executeCommand: async (id: string) => { - onExecuteCommand?.(id); + executeCommand: async (id: string, ...args: unknown[]) => { + onExecuteCommand?.(id, ...args); return undefined; }, } as unknown as ICommandService); @@ -170,4 +171,43 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(uri.path, '/tmp/marketplace-repo'); assert.strictEqual(commandInvocationCount, 0); }); + + test('builds revision-aware install URI for github plugin sources', () => { + const service = createService(); + const uri = service.getPluginSourceInstallUri({ + kind: PluginSourceKind.GitHub, + repo: 'owner/repo', + ref: 'release/v1', + }); + + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/owner/repo/ref_release_v1'); + }); + + test('updates git plugin source by pulling and checking out requested revision', async () => { + const commands: string[] = []; + const service = createService(async () => true, (id: string) => { + commands.push(id); + }); + + await service.updatePluginSource({ + name: 'my-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { + kind: PluginSourceKind.GitHub, + repo: 'owner/repo', + sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', + }, + marketplace: 'owner/repo', + marketplaceReference: parseMarketplaceReference('owner/repo')!, + marketplaceType: MarketplaceType.Copilot, + }, { + pluginName: 'my-plugin', + failureLabel: 'my-plugin', + marketplaceType: MarketplaceType.Copilot, + }); + + assert.deepStrictEqual(commands, ['git.openRepository', 'git.fetch', '_git.checkout']); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts new file mode 100644 index 00000000000..7a55baa369a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -0,0 +1,611 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; +import { ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { PluginInstallService } from '../../../browser/pluginInstallService.js'; +import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../../../common/plugins/agentPluginRepositoryService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; + +suite('PluginInstallService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + // --- Factory helpers ------------------------------------------------------- + + function makeMarketplaceRef(marketplace: string): IMarketplaceReference { + const ref = parseMarketplaceReference(marketplace); + assert.ok(ref); + return ref!; + } + + function createPlugin(overrides: Partial & { sourceDescriptor: IPluginSourceDescriptor }): IMarketplacePlugin { + return { + name: overrides.name ?? 'test-plugin', + description: overrides.description ?? '', + version: overrides.version ?? '', + source: overrides.source ?? '', + sourceDescriptor: overrides.sourceDescriptor, + marketplace: overrides.marketplace ?? 'microsoft/vscode', + marketplaceReference: overrides.marketplaceReference ?? makeMarketplaceRef('microsoft/vscode'), + marketplaceType: overrides.marketplaceType ?? MarketplaceType.Copilot, + readmeUri: overrides.readmeUri, + }; + } + + // --- Mock tracking types --------------------------------------------------- + + interface MockState { + notifications: { severity: number; message: string }[]; + addedPlugins: { uri: string; plugin: IMarketplacePlugin }[]; + dialogConfirmResult: boolean; + fileExistsResult: boolean | ((uri: URI) => Promise); + ensureRepositoryResult: URI; + ensurePluginSourceResult: URI; + /** Plugin source install URI, per kind */ + pluginSourceInstallUris: Map; + /** The commands that were sent to the terminal */ + terminalCommands: string[]; + /** Simulated exit code from terminal */ + terminalExitCode: number; + /** Whether the terminal resolves the command completion at all */ + terminalCompletes: boolean; + pullRepositoryCalls: { marketplace: IMarketplaceReference; options?: IPullRepositoryOptions }[]; + updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[]; + } + + function createDefaults(): MockState { + return { + notifications: [], + addedPlugins: [], + dialogConfirmResult: true, + fileExistsResult: true, + ensureRepositoryResult: URI.file('/cache/agentPlugins/github.com/microsoft/vscode'), + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-package'), + pluginSourceInstallUris: new Map(), + terminalCommands: [], + terminalExitCode: 0, + terminalCompletes: true, + pullRepositoryCalls: [], + updatePluginSourceCalls: [], + }; + } + + function createService(stateOverrides?: Partial): { service: PluginInstallService; state: MockState } { + const state: MockState = { ...createDefaults(), ...stateOverrides }; + const instantiationService = store.add(new TestInstantiationService()); + + // IFileService + instantiationService.stub(IFileService, { + exists: async (resource: URI) => { + if (typeof state.fileExistsResult === 'function') { + return state.fileExistsResult(resource); + } + return state.fileExistsResult; + }, + } as unknown as IFileService); + + // INotificationService + instantiationService.stub(INotificationService, { + notify: (notification: { severity: number; message: string }) => { + state.notifications.push({ severity: notification.severity, message: notification.message }); + return undefined; + }, + } as unknown as INotificationService); + + // IDialogService + instantiationService.stub(IDialogService, { + confirm: async () => ({ confirmed: state.dialogConfirmResult }), + } as unknown as IDialogService); + + // ITerminalService — the mock coordinates runCommand and onCommandFinished + // so the command ID matches, just like a real terminal would. + instantiationService.stub(ITerminalService, { + createTerminal: async () => { + let finishedCallback: ((cmd: { id: string; exitCode: number }) => void) | undefined; + return { + processReady: Promise.resolve(), + dispose: () => { }, + runCommand: (command: string, _addNewLine?: boolean) => { + state.terminalCommands.push(command); + // Simulate command completing after runCommand is called + if (finishedCallback) { + finishedCallback({ id: 'command', exitCode: state.terminalExitCode }); + } + }, + capabilities: { + get: () => state.terminalCompletes ? { + onCommandFinished: (callback: (cmd: { id: string; exitCode: number }) => void) => { + finishedCallback = callback; + return { dispose() { } }; + }, + } : undefined, + onDidAddCommandDetectionCapability: () => ({ dispose() { } }), + }, + }; + }, + setActiveInstance: () => { }, + } as unknown as ITerminalService); + + // IProgressService + instantiationService.stub(IProgressService, { + withProgress: async (_options: unknown, callback: (...args: unknown[]) => Promise) => callback(), + } as unknown as IProgressService); + + // ILogService + instantiationService.stub(ILogService, new NullLogService()); + + // IAgentPluginRepositoryService + instantiationService.stub(IAgentPluginRepositoryService, { + getPluginInstallUri: (plugin: IMarketplacePlugin) => { + return URI.joinPath(state.ensureRepositoryResult, plugin.source); + }, + getRepositoryUri: () => state.ensureRepositoryResult, + ensureRepository: async (_marketplace: IMarketplaceReference, _options?: IEnsureRepositoryOptions) => { + return state.ensureRepositoryResult; + }, + pullRepository: async (marketplace: IMarketplaceReference, options?: IPullRepositoryOptions) => { + state.pullRepositoryCalls.push({ marketplace, options }); + }, + getPluginSourceInstallUri: (descriptor: IPluginSourceDescriptor) => { + const key = descriptor.kind; + return state.pluginSourceInstallUris.get(key) ?? URI.file(`/cache/agentPlugins/${key}/default`); + }, + ensurePluginSource: async () => state.ensurePluginSourceResult, + updatePluginSource: async (plugin: IMarketplacePlugin, options?: IPullRepositoryOptions) => { + state.updatePluginSourceCalls.push({ plugin, options }); + }, + } as unknown as IAgentPluginRepositoryService); + + // IPluginMarketplaceService + instantiationService.stub(IPluginMarketplaceService, { + addInstalledPlugin: (uri: URI, plugin: IMarketplacePlugin) => { + state.addedPlugins.push({ uri: uri.toString(), plugin }); + }, + } as unknown as IPluginMarketplaceService); + + const service = instantiationService.createInstance(PluginInstallService); + return { service, state }; + } + + // ========================================================================= + // getPluginInstallUri + // ========================================================================= + + suite('getPluginInstallUri', () => { + + test('delegates to getPluginInstallUri for relative-path plugins', () => { + const { service } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode/plugins/myPlugin'); + }); + + test('delegates to getPluginSourceInstallUri for npm plugins', () => { + const npmUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['npm', npmUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, npmUri.path); + }); + + test('delegates to getPluginSourceInstallUri for pip plugins', () => { + const pipUri = URI.file('/cache/agentPlugins/pip/my-pkg'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['pip', pipUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, pipUri.path); + }); + + test('delegates to getPluginSourceInstallUri for github plugins', () => { + const ghUri = URI.file('/cache/agentPlugins/github.com/owner/repo'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['github', ghUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, ghUri.path); + }); + }); + + // ========================================================================= + // installPlugin — relative path + // ========================================================================= + + suite('installPlugin — relative path', () => { + + test('installs a relative-path plugin when directory exists', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.ok(state.addedPlugins[0].uri.includes('plugins/myPlugin')); + assert.strictEqual(state.notifications.length, 0); + }); + + test('notifies error when plugin directory does not exist', async () => { + const { service, state } = createService({ fileExistsResult: false }); + const plugin = createPlugin({ + source: 'plugins/missing', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/missing' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + + test('does not install when ensureRepository throws', async () => { + const { state } = createService(); + // Override ensureRepository to throw + const instantiationService = store.add(new TestInstantiationService()); + const repoService = { + ensureRepository: async () => { throw new Error('clone failed'); }, + getPluginInstallUri: () => URI.file('/x'), + getPluginSourceInstallUri: () => URI.file('/x'), + }; + instantiationService.stub(IAgentPluginRepositoryService, repoService as unknown as IAgentPluginRepositoryService); + instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService); + instantiationService.stub(INotificationService, { notify: (n: { severity: number; message: string }) => { state.notifications.push(n); } } as unknown as INotificationService); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) } as unknown as IDialogService); + instantiationService.stub(ITerminalService, {} as unknown as ITerminalService); + instantiationService.stub(IProgressService, { withProgress: async (_o: unknown, cb: () => Promise) => cb() } as unknown as IProgressService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IPluginMarketplaceService, { addInstalledPlugin: () => { } } as unknown as IPluginMarketplaceService); + const svc = instantiationService.createInstance(PluginInstallService); + + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + await svc.installPlugin(plugin); + + // Should return without installing or crashing + assert.strictEqual(state.addedPlugins.length, 0); + }); + }); + + // ========================================================================= + // installPlugin — GitHub / GitUrl + // ========================================================================= + + suite('installPlugin — git sources', () => { + + test('installs a GitHub plugin when source exists after clone', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('installs a GitUrl plugin when source exists after clone', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/example.com/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('notifies error when cloned directory does not exist', async () => { + const { service, state } = createService({ + fileExistsResult: false, + ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + }); + + // ========================================================================= + // installPlugin — npm + // ========================================================================= + + suite('installPlugin — npm', () => { + + test('runs npm install and registers plugin on success', async () => { + const npmInstallUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg'); + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', npmInstallUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('npm')); + assert.ok(state.terminalCommands[0].includes('install')); + assert.ok(state.terminalCommands[0].includes('my-pkg')); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('includes version in npm install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', version: '1.2.3' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('my-pkg@1.2.3')); + }); + + test('includes registry in npm install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', registry: 'https://custom.registry.com' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('--registry')); + assert.ok(state.terminalCommands[0].includes('https://custom.registry.com')); + }); + + test('does not install when user declines confirmation', async () => { + const { service, state } = createService({ dialogConfirmResult: false }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('notifies error when npm package directory not found after install', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + // exists returns true for ensurePluginSource but false for the final check + fileExistsResult: false, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + + test('notifies error when terminal command fails with non-zero exit code', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + terminalExitCode: 1, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('failed')); + }); + }); + + // ========================================================================= + // installPlugin — pip + // ========================================================================= + + suite('installPlugin — pip', () => { + + test('runs pip install and registers plugin on success', async () => { + const pipInstallUri = URI.file('/cache/agentPlugins/pip/my-pkg'); + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', pipInstallUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('pip')); + assert.ok(state.terminalCommands[0].includes('install')); + assert.ok(state.terminalCommands[0].includes('my-pkg')); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('includes version with == syntax in pip install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', version: '2.0.0' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('my-pkg==2.0.0')); + }); + + test('includes registry with --index-url in pip install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', registry: 'https://pypi.custom.com/simple' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('--index-url')); + assert.ok(state.terminalCommands[0].includes('https://pypi.custom.com/simple')); + }); + + test('does not install when user declines confirmation', async () => { + const { service, state } = createService({ dialogConfirmResult: false }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('notifies error when pip package directory not found after install', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + fileExistsResult: false, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + }); + + // ========================================================================= + // updatePlugin + // ========================================================================= + + suite('updatePlugin', () => { + + test('calls pullRepository for relative-path plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.pullRepositoryCalls.length, 1); + assert.strictEqual(state.updatePluginSourceCalls.length, 0); + }); + + test('calls updatePluginSource for GitHub plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.updatePluginSourceCalls.length, 1); + assert.strictEqual(state.pullRepositoryCalls.length, 0); + }); + + test('calls updatePluginSource for GitUrl plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.updatePluginSourceCalls.length, 1); + assert.strictEqual(state.pullRepositoryCalls.length, 0); + }); + + test('re-installs for npm plugin updates', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.updatePlugin(plugin); + + // npm update goes through the same install flow + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('npm')); + }); + + test('re-installs for pip plugin updates', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('pip')); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 46bb4fbcb3f..da94ecb5615 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -15,7 +15,7 @@ import { IStorageService, InMemoryStorageService } from '../../../../../../platf import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, parseMarketplaceReference, parseMarketplaceReferences } from '../../../common/plugins/pluginMarketplaceService.js'; +import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js'; suite('PluginMarketplaceService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -142,6 +142,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/my-plugin' } as const, marketplace: marketplaceRef.displayLabel, marketplaceReference: marketplaceRef, marketplaceType: MarketplaceType.Copilot, @@ -165,3 +166,152 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { assert.strictEqual(result, undefined); }); }); + +suite('parsePluginSource', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const logContext = { + pluginName: 'test', + logService: new NullLogService(), + logPrefix: '[test]', + }; + + test('parses string source as RelativePath', () => { + const result = parsePluginSource('./my-plugin', undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'my-plugin' }); + }); + + test('parses string source with pluginRoot', () => { + const result = parsePluginSource('sub', 'plugins', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'plugins/sub' }); + }); + + test('parses undefined source as RelativePath using pluginRoot', () => { + const result = parsePluginSource(undefined, 'root', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'root' }); + }); + + test('parses empty string source as RelativePath using pluginRoot', () => { + const result = parsePluginSource('', 'base', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'base' }); + }); + + test('returns undefined for empty source without pluginRoot', () => { + assert.strictEqual(parsePluginSource('', undefined, logContext), undefined); + }); + + test('parses github object source', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined }); + }); + + test('parses github object source with ref and sha', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }); + }); + + test('returns undefined for github source missing repo', () => { + assert.strictEqual(parsePluginSource({ source: 'github' }, undefined, logContext), undefined); + }); + + test('returns undefined for github source with invalid repo format', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner' }, undefined, logContext), undefined); + }); + + test('returns undefined for github source with invalid sha', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', sha: 'abc123' }, undefined, logContext), undefined); + }); + + test('parses url object source', () => { + const result = parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin.git' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitUrl, url: 'https://gitlab.com/team/plugin.git', ref: undefined, sha: undefined }); + }); + + test('returns undefined for url source missing url field', () => { + assert.strictEqual(parsePluginSource({ source: 'url' }, undefined, logContext), undefined); + }); + + test('returns undefined for url source not ending in .git', () => { + assert.strictEqual(parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin' }, undefined, logContext), undefined); + }); + + test('parses npm object source', () => { + const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: undefined, registry: undefined }); + }); + + test('parses npm object source with version and registry', () => { + const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' }); + }); + + test('returns undefined for npm source missing package', () => { + assert.strictEqual(parsePluginSource({ source: 'npm' }, undefined, logContext), undefined); + }); + + test('returns undefined for npm source with non-string version', () => { + assert.strictEqual(parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: 123 } as never, undefined, logContext), undefined); + }); + + test('parses pip object source', () => { + const result = parsePluginSource({ source: 'pip', package: 'my-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: undefined, registry: undefined }); + }); + + test('parses pip object source with version and registry', () => { + const result = parsePluginSource({ source: 'pip', package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' }); + }); + + test('returns undefined for pip source missing package', () => { + assert.strictEqual(parsePluginSource({ source: 'pip' }, undefined, logContext), undefined); + }); + + test('returns undefined for pip source with non-string registry', () => { + assert.strictEqual(parsePluginSource({ source: 'pip', package: 'my-plugin', registry: 42 } as never, undefined, logContext), undefined); + }); + + test('returns undefined for unknown source kind', () => { + assert.strictEqual(parsePluginSource({ source: 'unknown' }, undefined, logContext), undefined); + }); + + test('returns undefined for object source without source discriminant', () => { + assert.strictEqual(parsePluginSource({ package: 'test' } as never, undefined, logContext), undefined); + }); +}); + +suite('getPluginSourceLabel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('formats relative path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: 'plugins/foo' }), 'plugins/foo'); + }); + + test('formats empty relative path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: '' }), '.'); + }); + + test('formats github source', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo' }), 'owner/repo'); + }); + + test('formats url source', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }), 'https://example.com/repo.git'); + }); + + test('formats npm source without version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin' }), '@acme/plugin'); + }); + + test('formats npm source with version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin', version: '1.0.0' }), '@acme/plugin@1.0.0'); + }); + + test('formats pip source without version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin' }), 'my-plugin'); + }); + + test('formats pip source with version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin', version: '2.0' }), 'my-plugin==2.0'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 6130e821b99..7d51bb6782f 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -11,7 +11,7 @@ import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; import { ISettableObservable, observableValue } from '../../../../../../../base/common/observable.js'; -import { relativePath } from '../../../../../../../base/common/resources.js'; +import { basename, relativePath } from '../../../../../../../base/common/resources.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; @@ -3505,6 +3505,7 @@ suite('PromptsService', () => { return { plugin: { uri: URI.file(path), + label: basename(URI.file(path)), enabled, setEnabled: () => { }, remove: () => { }, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 904987c9e34..a173a33b7d2 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -8,7 +8,6 @@ import { Disposable, DisposableResourceMap } from '../../../../../base/common/li import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename } from '../../../../../base/common/resources.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; @@ -61,7 +60,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { const collectionId = `plugin.${plugin.uri}`; return this._mcpRegistry.registerCollection({ id: collectionId, - label: `${basename(plugin.uri)} (Agent Plugin)`, + label: `${plugin.label} (Agent Plugin)`, remoteAuthority: plugin.uri.scheme === Schemas.vscodeRemote ? plugin.uri.authority : null, configTarget: ConfigurationTarget.USER, scope: StorageScope.PROFILE,