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
This commit is contained in:
Connor Peet
2026-03-04 07:20:21 -08:00
committed by GitHub
parent 8a03516dd4
commit a4e35e0d69
16 changed files with 1571 additions and 45 deletions

View File

@@ -1040,19 +1040,29 @@ export class CommandCenter {
}
@command('_git.cloneRepository')
async cloneRepository(url: string, parentPath: string): Promise<void> {
async cloneRepository(url: string, localPath: string, ref?: string): Promise<void> {
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<void> {
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<void> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);

View File

@@ -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<string> {
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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<void> {
private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise<void> {
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<URI> {
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<void> {
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<void> {
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, '_');
}

View File

@@ -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<IAgentPlugi
description: m.description,
version: '',
source: m.source,
sourceDescriptor: m.sourceDescriptor,
marketplace: m.marketplace,
marketplaceReference: m.marketplaceReference,
marketplaceType: m.marketplaceType,

View File

@@ -3,13 +3,21 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancelablePromise, timeout } from '../../../../base/common/async.js';
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { isWindows } from '../../../../base/common/platform.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js';
import { TerminalCapability, type ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js';
import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js';
import { IPluginInstallService } from '../common/plugins/pluginInstallService.js';
import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js';
import { getPluginSourceLabel, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, IPluginMarketplaceService, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';
export class PluginInstallService implements IPluginInstallService {
declare readonly _serviceBrand: undefined;
@@ -19,9 +27,58 @@ export class PluginInstallService implements IPluginInstallService {
@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,
@IFileService private readonly _fileService: IFileService,
@INotificationService private readonly _notificationService: INotificationService,
@IDialogService private readonly _dialogService: IDialogService,
@ITerminalService private readonly _terminalService: ITerminalService,
@IProgressService private readonly _progressService: IProgressService,
@ILogService private readonly _logService: ILogService,
) { }
async installPlugin(plugin: IMarketplacePlugin): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<number | undefined> {
return new Promise<number | undefined>(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<void> = 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, `'\\''`)}'`;
}
}

View File

@@ -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<IAgentPluginRepositoryService>('agentPluginRepositoryService');
@@ -61,4 +61,26 @@ export interface IAgentPluginRepositoryService {
* Pulls latest changes for a cloned marketplace repository.
*/
pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise<void>;
/**
* 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<URI>;
/**
* 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<void>;
}

View File

@@ -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<boolean>;
setEnabled(enabled: boolean): void;
/** Removes this plugin from its discovery source (config or installed storage). */

View File

@@ -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,

View File

@@ -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<readonly IStoredInstalledPlugin[]>({
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';

View File

@@ -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<boolean>,
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']);
});
});

View File

@@ -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<IMarketplacePlugin> & { 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<boolean>);
ensureRepositoryResult: URI;
ensurePluginSourceResult: URI;
/** Plugin source install URI, per kind */
pluginSourceInstallUris: Map<string, URI>;
/** 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<MockState>): { 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<unknown>) => 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<unknown>) => 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'));
});
});
});

View File

@@ -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');
});
});

View File

@@ -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: () => { },

View File

@@ -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,