mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-31 00:10:04 +08:00
plugins: enable installing plugins from marketplaces
Supports both Copilot marketplaces as well as Claude marketplaces (when configured). Still todo: - Currently enumerating plugins hit public GH APIs. But this would fail for private repos. In this case we should generalize the PluginInstallService to allow cloning the repo for the purpose of enumeration, not just install. - Updating plugins still needs to be hooked up. - Marketplace-installed plugins should get their own Discovery implementation rather than configuring the setting. - We should normalize the type of plugin a bit so it flows from the marketplace type rather than getting re-discovered from disk.
This commit is contained in:
@@ -9,7 +9,7 @@ import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem,
|
||||
import TelemetryReporter from '@vscode/extension-telemetry';
|
||||
import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator';
|
||||
import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git';
|
||||
import { Git, GitError, Stash, Worktree } from './git';
|
||||
import { Git, GitError, Repository as GitRepository, Stash, Worktree } from './git';
|
||||
import { Model } from './model';
|
||||
import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository';
|
||||
import { DiffEditorSelectionHunkToolbarContext, LineChange, applyLineChanges, getIndexDiffInformation, getModifiedRange, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges, compareLineChanges } from './staging';
|
||||
@@ -1037,6 +1037,18 @@ export class CommandCenter {
|
||||
await this.cloneManager.clone(url, { parentPath, recursive: true });
|
||||
}
|
||||
|
||||
@command('_git.cloneRepository')
|
||||
async cloneRepository(url: string, parentPath: string): Promise<void> {
|
||||
await this.cloneManager.clone(url, { parentPath, postCloneAction: 'none' });
|
||||
}
|
||||
|
||||
@command('_git.pull')
|
||||
async pullRepository(repositoryPath: string): Promise<void> {
|
||||
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
|
||||
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
|
||||
await repo.pull();
|
||||
}
|
||||
|
||||
@command('git.init')
|
||||
async init(skipFolderPrompt = false): Promise<void> {
|
||||
let repositoryPath: string | undefined = undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
|
||||
import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js';
|
||||
import { IPagedRenderer } from '../../../../base/browser/ui/list/listPaging.js';
|
||||
import { Action, IAction, Separator } from '../../../../base/common/actions.js';
|
||||
import { RunOnceScheduler } from '../../../../base/common/async.js';
|
||||
import { CancellationTokenSource } from '../../../../base/common/cancellation.js';
|
||||
import { Disposable, DisposableStore, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { autorun } from '../../../../base/common/observable.js';
|
||||
@@ -15,10 +16,9 @@ import { IPagedModel, PagedModel } from '../../../../base/common/paging.js';
|
||||
import { basename, dirname, joinPath } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { localize, localize2 } from '../../../../nls.js';
|
||||
import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
|
||||
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
|
||||
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
@@ -36,9 +36,9 @@ import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution
|
||||
import { AbstractExtensionsListView } from '../../extensions/browser/extensionsViews.js';
|
||||
import { DefaultViewsContext, SearchAgentPluginsContext } from '../../extensions/common/extensions.js';
|
||||
import { ChatContextKeys } from '../common/actions/chatContextKeys.js';
|
||||
import { ChatConfiguration } from '../common/constants.js';
|
||||
import { IAgentPlugin, IAgentPluginService } from '../common/plugins/agentPluginService.js';
|
||||
import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js';
|
||||
import { IPluginInstallService } from '../common/plugins/pluginInstallService.js';
|
||||
import { IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
export const HasInstalledAgentPluginsContext = new RawContextKey<boolean>('hasInstalledAgentPlugins', false);
|
||||
|
||||
@@ -53,6 +53,7 @@ interface IInstalledPluginItem {
|
||||
readonly kind: AgentPluginItemKind.Installed;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly marketplace?: string;
|
||||
readonly plugin: IAgentPlugin;
|
||||
}
|
||||
|
||||
@@ -60,7 +61,9 @@ interface IMarketplacePluginItem {
|
||||
readonly kind: AgentPluginItemKind.Marketplace;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly source: string;
|
||||
readonly marketplace: string;
|
||||
readonly marketplaceType: MarketplaceType;
|
||||
readonly readmeUri?: URI;
|
||||
}
|
||||
|
||||
@@ -68,8 +71,9 @@ type IAgentPluginItem = IInstalledPluginItem | IMarketplacePluginItem;
|
||||
|
||||
function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem {
|
||||
const name = basename(plugin.uri);
|
||||
const description = labelService.getUriLabel(dirname(plugin.uri), { relative: true });
|
||||
return { kind: AgentPluginItemKind.Installed, name, description, plugin };
|
||||
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 };
|
||||
}
|
||||
|
||||
function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePluginItem {
|
||||
@@ -77,7 +81,9 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin
|
||||
kind: AgentPluginItemKind.Marketplace,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
source: plugin.source,
|
||||
marketplace: plugin.marketplace,
|
||||
marketplaceType: plugin.marketplaceType,
|
||||
readmeUri: plugin.readmeUri,
|
||||
};
|
||||
}
|
||||
@@ -91,17 +97,21 @@ class InstallPluginAction extends Action {
|
||||
|
||||
constructor(
|
||||
private readonly item: IMarketplacePluginItem,
|
||||
@IDialogService private readonly dialogService: IDialogService,
|
||||
@IPluginInstallService private readonly pluginInstallService: IPluginInstallService,
|
||||
) {
|
||||
super(InstallPluginAction.ID, localize('install', "Install"), 'extension-action label prominent install');
|
||||
}
|
||||
|
||||
override async run(): Promise<void> {
|
||||
// TODO: implement actual plugin installation
|
||||
this.dialogService.info(
|
||||
localize('installNotSupported', "Plugin Installation"),
|
||||
localize('installNotSupportedDetail', "Installing '{0}' from '{1}' is not yet supported.", this.item.name, this.item.marketplace)
|
||||
);
|
||||
await this.pluginInstallService.installPlugin({
|
||||
name: this.item.name,
|
||||
description: this.item.description,
|
||||
version: '',
|
||||
source: this.item.source,
|
||||
marketplace: this.item.marketplace,
|
||||
marketplaceType: this.item.marketplaceType,
|
||||
readmeUri: this.item.readmeUri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,28 +150,13 @@ class UninstallPluginAction extends Action {
|
||||
|
||||
constructor(
|
||||
private readonly plugin: IAgentPlugin,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IDialogService private readonly dialogService: IDialogService,
|
||||
@IPluginInstallService private readonly pluginInstallService: IPluginInstallService,
|
||||
) {
|
||||
super(UninstallPluginAction.ID, localize('uninstall', "Uninstall"));
|
||||
}
|
||||
|
||||
override async run(): Promise<void> {
|
||||
const { confirmed } = await this.dialogService.confirm({
|
||||
message: localize('confirmUninstall', "Are you sure you want to uninstall the plugin '{0}'?", basename(this.plugin.uri)),
|
||||
detail: localize('confirmUninstallDetail', "This will remove the plugin path from your settings. The plugin files will not be deleted."),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPaths = this.configurationService.getValue<readonly unknown[]>(ChatConfiguration.PluginPaths) ?? [];
|
||||
const pluginFsPath = this.plugin.uri.fsPath;
|
||||
const filteredPaths = currentPaths.filter(
|
||||
p => typeof p === 'string' && p !== pluginFsPath
|
||||
);
|
||||
await this.configurationService.updateValue(ChatConfiguration.PluginPaths, filteredPaths, ConfigurationTarget.USER_LOCAL);
|
||||
this.pluginInstallService.uninstallPlugin(this.plugin.uri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +253,7 @@ class AgentPluginRenderer implements IPagedRenderer<IAgentPluginItem, IAgentPlug
|
||||
data.elementDisposables.push(installAction);
|
||||
data.actionbar.push([installAction], { icon: true, label: true });
|
||||
} else {
|
||||
data.detail.textContent = '';
|
||||
data.detail.textContent = element.marketplace ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +282,12 @@ export class AgentPluginsListView extends AbstractExtensionsListView<IAgentPlugi
|
||||
private readonly queryCts = new MutableDisposable<CancellationTokenSource>();
|
||||
private list: WorkbenchPagedList<IAgentPluginItem> | null = null;
|
||||
private listContainer: HTMLElement | null = null;
|
||||
private currentQuery = '@agentPlugins';
|
||||
private readonly refreshOnPluginsChangedScheduler = this._register(new RunOnceScheduler(() => {
|
||||
if (this.list) {
|
||||
void this.show(this.currentQuery);
|
||||
}
|
||||
}, 0));
|
||||
private bodyTemplate: {
|
||||
messageContainer: HTMLElement;
|
||||
messageBox: HTMLElement;
|
||||
@@ -306,9 +307,17 @@ export class AgentPluginsListView extends AbstractExtensionsListView<IAgentPlugi
|
||||
@IOpenerService openerService: IOpenerService,
|
||||
@IAgentPluginService private readonly agentPluginService: IAgentPluginService,
|
||||
@IPluginMarketplaceService private readonly pluginMarketplaceService: IPluginMarketplaceService,
|
||||
@IPluginInstallService private readonly pluginInstallService: IPluginInstallService,
|
||||
@ILabelService private readonly labelService: ILabelService,
|
||||
) {
|
||||
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
|
||||
|
||||
this._register(autorun(reader => {
|
||||
this.agentPluginService.plugins.read(reader);
|
||||
if (this.list && this.isBodyVisible()) {
|
||||
this.refreshOnPluginsChangedScheduler.schedule();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
protected override renderBody(container: HTMLElement): void {
|
||||
@@ -400,14 +409,29 @@ export class AgentPluginsListView extends AbstractExtensionsListView<IAgentPlugi
|
||||
}
|
||||
|
||||
async show(query: string): Promise<IPagedModel<IAgentPluginItem>> {
|
||||
this.currentQuery = query;
|
||||
const text = query.replace(/@agentPlugins/i, '').trim();
|
||||
|
||||
const items = await Promise.all([
|
||||
const [installed, marketplace] = await Promise.all([
|
||||
this.queryInstalled(),
|
||||
this.queryMarketplace(text),
|
||||
]);
|
||||
|
||||
const model = new PagedModel(items.flat());
|
||||
// Filter out marketplace items that are already installed
|
||||
const installedPaths = new Set(installed.map(i => i.plugin.uri.toString()));
|
||||
const filteredMarketplace = marketplace.filter(m => {
|
||||
const expectedUri = this.pluginInstallService.getPluginInstallUri({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
version: '',
|
||||
source: m.source,
|
||||
marketplace: m.marketplace,
|
||||
marketplaceType: m.marketplaceType,
|
||||
});
|
||||
return !installedPaths.has(expectedUri.toString());
|
||||
});
|
||||
|
||||
const model = new PagedModel([...installed, ...filteredMarketplace]);
|
||||
if (this.list) {
|
||||
this.list.model = model;
|
||||
}
|
||||
@@ -473,7 +497,7 @@ export class AgentPluginsViewsContribution extends Disposable implements IWorkbe
|
||||
|
||||
const hasInstalledKey = HasInstalledAgentPluginsContext.bindTo(contextKeyService);
|
||||
this._register(autorun(reader => {
|
||||
hasInstalledKey.set(agentPluginService.allPlugins.read(reader).length > 0);
|
||||
hasInstalledKey.set(agentPluginService.plugins.read(reader).length > 0);
|
||||
}));
|
||||
|
||||
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([
|
||||
|
||||
@@ -136,8 +136,10 @@ import './widget/input/editor/chatInputEditorHover.js';
|
||||
import { LanguageModelToolsConfirmationService } from './tools/languageModelToolsConfirmationService.js';
|
||||
import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js';
|
||||
import { AgentPluginService, ConfiguredAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js';
|
||||
import { IPluginInstallService } from '../common/plugins/pluginInstallService.js';
|
||||
import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js';
|
||||
import { AgentPluginsViewsContribution } from './agentPluginsView.js';
|
||||
import { PluginInstallService } from './pluginInstallService.js';
|
||||
import './promptSyntax/promptCodingAgentActionContribution.js';
|
||||
import './promptSyntax/promptToolsCodeLensProvider.js';
|
||||
import { ChatSlashCommandsContribution } from './chatSlashCommands.js';
|
||||
@@ -1611,6 +1613,7 @@ registerSingleton(IChatAgentNameService, ChatAgentNameService, InstantiationType
|
||||
registerSingleton(IChatVariablesService, ChatVariablesService, InstantiationType.Delayed);
|
||||
registerSingleton(IAgentPluginService, AgentPluginService, InstantiationType.Delayed);
|
||||
registerSingleton(IPluginMarketplaceService, PluginMarketplaceService, InstantiationType.Delayed);
|
||||
registerSingleton(IPluginInstallService, PluginInstallService, InstantiationType.Delayed);
|
||||
registerSingleton(ILanguageModelToolsService, LanguageModelToolsService, InstantiationType.Delayed);
|
||||
registerSingleton(ILanguageModelToolsConfirmationService, LanguageModelToolsConfirmationService, InstantiationType.Delayed);
|
||||
registerSingleton(IVoiceChatService, VoiceChatService, InstantiationType.Delayed);
|
||||
|
||||
188
src/vs/workbench/contrib/chat/browser/pluginInstallService.ts
Normal file
188
src/vs/workbench/contrib/chat/browser/pluginInstallService.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Action } from '../../../../base/common/actions.js';
|
||||
import { dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { ICommandService } from '../../../../platform/commands/common/commands.js';
|
||||
import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { IEnvironmentService } from '../../../../platform/environment/common/environment.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 { ChatConfiguration } from '../common/constants.js';
|
||||
import { IPluginInstallService } from '../common/plugins/pluginInstallService.js';
|
||||
import { IMarketplacePlugin, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
export class PluginInstallService implements IPluginInstallService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _cacheRoot: URI;
|
||||
|
||||
constructor(
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IEnvironmentService environmentService: IEnvironmentService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@INotificationService private readonly _notificationService: INotificationService,
|
||||
@IProgressService private readonly _progressService: IProgressService,
|
||||
) {
|
||||
this._cacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins');
|
||||
}
|
||||
|
||||
async installPlugin(plugin: IMarketplacePlugin): Promise<void> {
|
||||
const repoDir = this._getRepoCacheDir(plugin.marketplaceType, plugin.marketplace);
|
||||
const repoExists = await this._fileService.exists(repoDir);
|
||||
|
||||
if (!repoExists) {
|
||||
const repoUrl = `https://github.com/${plugin.marketplace}.git`;
|
||||
try {
|
||||
await this._progressService.withProgress(
|
||||
{
|
||||
location: ProgressLocation.Notification,
|
||||
title: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name),
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
await this._commandService.executeCommand('_git.cloneRepository', repoUrl, dirname(repoDir).fsPath);
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
this._logService.error(`[PluginInstallService] Failed to clone ${repoUrl}:`, err);
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('cloneFailed', "Failed to install plugin '{0}': {1}", plugin.name, err?.message ?? String(err)),
|
||||
actions: {
|
||||
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
|
||||
this._commandService.executeCommand('git.showOutput');
|
||||
})],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pluginDir: URI;
|
||||
try {
|
||||
pluginDir = this._getPluginDir(repoDir, plugin.source);
|
||||
} catch {
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('pluginDirInvalid', "Plugin source directory '{0}' is invalid for repository '{1}'.", plugin.source, plugin.marketplace),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginExists = await this._fileService.exists(pluginDir);
|
||||
if (!pluginExists) {
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('pluginDirNotFound', "Plugin source directory '{0}' not found in repository '{1}'.", plugin.source, plugin.marketplace),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._addPluginPath(pluginDir.fsPath);
|
||||
}
|
||||
|
||||
async updatePlugin(plugin: IMarketplacePlugin): Promise<void> {
|
||||
const repoDir = this._getRepoCacheDir(plugin.marketplaceType, plugin.marketplace);
|
||||
const repoExists = await this._fileService.exists(repoDir);
|
||||
if (!repoExists) {
|
||||
this._logService.warn(`[PluginInstallService] Cannot update plugin '${plugin.name}': repository not cloned`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this._progressService.withProgress(
|
||||
{
|
||||
location: ProgressLocation.Notification,
|
||||
title: localize('updatingPlugin', "Updating plugin '{0}'...", plugin.name),
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
await this._commandService.executeCommand('_git.pull', repoDir.fsPath);
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
this._logService.error(`[PluginInstallService] Failed to update ${plugin.marketplace}:`, err);
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('pullFailed', "Failed to update plugin '{0}': {1}", plugin.name, err?.message ?? String(err)),
|
||||
actions: {
|
||||
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
|
||||
this._commandService.executeCommand('git.showOutput');
|
||||
})],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the cache directory for a marketplace repository.
|
||||
* Structure: `cacheRoot/{type}/{owner}/{repo}`
|
||||
*/
|
||||
private _getRepoCacheDir(type: MarketplaceType, marketplace: string): URI {
|
||||
const [owner, repo] = marketplace.split('/');
|
||||
return joinPath(this._cacheRoot, type, owner, repo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the plugin directory within a cloned repository using the
|
||||
* marketplace plugin's `source` field (the subdirectory path within the repo).
|
||||
*/
|
||||
private _getPluginDir(repoDir: URI, source: string): URI {
|
||||
const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, '');
|
||||
const pluginDir = normalizedSource ? joinPath(repoDir, normalizedSource) : repoDir;
|
||||
if (!isEqualOrParent(pluginDir, repoDir)) {
|
||||
throw new Error(`Invalid plugin source path '${source}'`);
|
||||
}
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
uninstallPlugin(pluginUri: URI): void {
|
||||
this._removePluginPath(pluginUri.fsPath);
|
||||
}
|
||||
|
||||
getPluginInstallUri(plugin: IMarketplacePlugin): URI {
|
||||
const repoDir = this._getRepoCacheDir(plugin.marketplaceType, plugin.marketplace);
|
||||
return this._getPluginDir(repoDir, plugin.source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given file-system path to `chat.plugins.paths` in user-local config.
|
||||
*/
|
||||
private _addPluginPath(fsPath: string): void {
|
||||
const current = this._configurationService.getValue<Record<string, boolean>>(ChatConfiguration.PluginPaths) ?? {};
|
||||
if (Object.prototype.hasOwnProperty.call(current, fsPath)) {
|
||||
return;
|
||||
}
|
||||
this._configurationService.updateValue(
|
||||
ChatConfiguration.PluginPaths,
|
||||
{ ...current, [fsPath]: true },
|
||||
ConfigurationTarget.USER_LOCAL,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the given file-system path from `chat.plugins.paths` in user-local config.
|
||||
*/
|
||||
private _removePluginPath(fsPath: string): void {
|
||||
const current = this._configurationService.getValue<Record<string, boolean>>(ChatConfiguration.PluginPaths) ?? {};
|
||||
if (!Object.prototype.hasOwnProperty.call(current, fsPath)) {
|
||||
return;
|
||||
}
|
||||
const updated = { ...current };
|
||||
delete updated[fsPath];
|
||||
this._configurationService.updateValue(
|
||||
ChatConfiguration.PluginPaths,
|
||||
updated,
|
||||
ConfigurationTarget.USER_LOCAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { SyncDescriptor0 } from '../../../../../platform/instantiation/common/de
|
||||
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js';
|
||||
import { HookType, IHookCommand } from '../promptSyntax/hookSchema.js';
|
||||
import { IMarketplacePlugin } from './pluginMarketplaceService.js';
|
||||
|
||||
export const IAgentPluginService = createDecorator<IAgentPluginService>('agentPluginService');
|
||||
|
||||
@@ -43,6 +44,8 @@ export interface IAgentPlugin {
|
||||
readonly commands: IObservable<readonly IAgentPluginCommand[]>;
|
||||
readonly skills: IObservable<readonly IAgentPluginSkill[]>;
|
||||
readonly mcpServerDefinitions: IObservable<readonly IAgentPluginMcpServerDefinition[]>;
|
||||
/** Set when the plugin was installed from a marketplace repository. */
|
||||
readonly fromMarketplace?: IMarketplacePlugin;
|
||||
}
|
||||
|
||||
export interface IAgentPluginService {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RunOnceScheduler } from '../../../../../base/common/async.js';
|
||||
import { CancellationToken } from '../../../../../base/common/cancellation.js';
|
||||
import { parse as parseJSONC } from '../../../../../base/common/json.js';
|
||||
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
|
||||
import { ResourceSet } from '../../../../../base/common/map.js';
|
||||
@@ -30,6 +31,8 @@ import { parseCopilotHooks } from '../promptSyntax/hookCompatibility.js';
|
||||
import { parseClaudeHooks } from '../promptSyntax/hookClaudeCompat.js';
|
||||
import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginCommand, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js';
|
||||
import { cloneAndChange } from '../../../../../base/common/objects.js';
|
||||
import { IPluginInstallService } from './pluginInstallService.js';
|
||||
import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js';
|
||||
|
||||
const COMMAND_FILE_SUFFIX = '.md';
|
||||
|
||||
@@ -148,6 +151,8 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent
|
||||
constructor(
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@IPluginInstallService private readonly _pluginInstallService: IPluginInstallService,
|
||||
@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,
|
||||
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
|
||||
@IPathService private readonly _pathService: IPathService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@@ -179,6 +184,8 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent
|
||||
const plugins: IAgentPlugin[] = [];
|
||||
const seenPluginUris = new Set<string>();
|
||||
const config = this._pluginPathsConfig.get();
|
||||
// todo: temporary, we should have a dedicated discovery from the marketplace
|
||||
const marketplacePluginsByInstallUri = await this._getMarketplacePluginsByInstallUri();
|
||||
|
||||
for (const [path, enabled] of Object.entries(config)) {
|
||||
if (!path.trim()) {
|
||||
@@ -204,7 +211,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent
|
||||
if (!seenPluginUris.has(key)) {
|
||||
const adapter = await this._detectPluginFormatAdapter(stat.resource);
|
||||
seenPluginUris.add(key);
|
||||
plugins.push(this._toPlugin(stat.resource, path, enabled, adapter));
|
||||
plugins.push(this._toPlugin(stat.resource, path, enabled, adapter, marketplacePluginsByInstallUri.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +222,24 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private async _getMarketplacePluginsByInstallUri(): Promise<Map<string, IMarketplacePlugin>> {
|
||||
const result = new Map<string, IMarketplacePlugin>();
|
||||
let marketplacePlugins: readonly IMarketplacePlugin[];
|
||||
try {
|
||||
marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins(CancellationToken.None);
|
||||
} catch (err) {
|
||||
this._logService.debug('[ConfiguredAgentPluginDiscovery] Failed to fetch marketplace plugins for provenance mapping:', err);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const marketplacePlugin of marketplacePlugins) {
|
||||
const installUri = this._pluginInstallService.getPluginInstallUri(marketplacePlugin);
|
||||
result.set(installUri.toString(), marketplacePlugin);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a plugin path to one or more resource URIs. Absolute paths are
|
||||
* used directly; relative paths are resolved against each workspace folder.
|
||||
@@ -285,7 +310,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent
|
||||
}
|
||||
}
|
||||
|
||||
private _toPlugin(uri: URI, configKey: string, initialEnabled: boolean, adapter: IAgentPluginFormatAdapter): IAgentPlugin {
|
||||
private _toPlugin(uri: URI, configKey: string, initialEnabled: boolean, adapter: IAgentPluginFormatAdapter, fromMarketplace: IMarketplacePlugin | undefined): IAgentPlugin {
|
||||
const key = uri.toString();
|
||||
const existing = this._pluginEntries.get(key);
|
||||
if (existing) {
|
||||
@@ -353,6 +378,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent
|
||||
commands,
|
||||
skills,
|
||||
mcpServerDefinitions,
|
||||
fromMarketplace,
|
||||
};
|
||||
|
||||
this._pluginEntries.set(key, { store, plugin, adapter });
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IMarketplacePlugin } from './pluginMarketplaceService.js';
|
||||
|
||||
export const IPluginInstallService = createDecorator<IPluginInstallService>('pluginInstallService');
|
||||
|
||||
export interface IPluginInstallService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Clones the marketplace repository (if not already cached) and registers
|
||||
* the plugin's source directory in the user's `chat.plugins.paths` config.
|
||||
*/
|
||||
installPlugin(plugin: IMarketplacePlugin): Promise<void>;
|
||||
|
||||
/**
|
||||
* Removes the plugin from `chat.plugins.paths` config.
|
||||
*/
|
||||
uninstallPlugin(pluginUri: URI): void;
|
||||
|
||||
/**
|
||||
* Pulls the latest changes for an already-cloned marketplace repository.
|
||||
*/
|
||||
updatePlugin(plugin: IMarketplacePlugin): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns the URI where a marketplace plugin would be installed on disk.
|
||||
* Used to determine whether a marketplace plugin is already installed.
|
||||
*/
|
||||
getPluginInstallUri(plugin: IMarketplacePlugin): URI;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CancellationToken } from '../../../../../base/common/cancellation.js';
|
||||
import { joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
|
||||
@@ -11,16 +12,28 @@ import { asJson, IRequestService } from '../../../../../platform/request/common/
|
||||
import { ILogService } from '../../../../../platform/log/common/log.js';
|
||||
import { ChatConfiguration } from '../constants.js';
|
||||
|
||||
export const enum MarketplaceType {
|
||||
Copilot = 'copilot',
|
||||
Claude = 'claude',
|
||||
}
|
||||
|
||||
export interface IMarketplacePlugin {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly version: string;
|
||||
/** Subdirectory within the repository where the plugin lives. */
|
||||
readonly source: string;
|
||||
/** The `owner/repo` identifier of the marketplace repository. */
|
||||
readonly marketplace: string;
|
||||
/** The type of marketplace this plugin comes from. */
|
||||
readonly marketplaceType: MarketplaceType;
|
||||
readonly readmeUri?: URI;
|
||||
}
|
||||
|
||||
interface IMarketplaceJson {
|
||||
readonly metadata?: {
|
||||
readonly pluginRoot?: string;
|
||||
};
|
||||
readonly plugins?: readonly {
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
@@ -37,11 +50,12 @@ export interface IPluginMarketplaceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths within a repository where marketplace.json can be found, checked in order.
|
||||
* Marketplace definition files by type, checked in order per repository.
|
||||
* The first match determines the marketplace type.
|
||||
*/
|
||||
const MARKETPLACE_JSON_PATHS = [
|
||||
'.github/plugin/marketplace.json',
|
||||
'.claude-plugin/marketplace.json',
|
||||
const MARKETPLACE_DEFINITIONS: { type: MarketplaceType; path: string }[] = [
|
||||
{ type: MarketplaceType.Copilot, path: '.github/plugin/marketplace.json' },
|
||||
{ type: MarketplaceType.Claude, path: '.claude-plugin/marketplace.json' },
|
||||
];
|
||||
|
||||
export class PluginMarketplaceService implements IPluginMarketplaceService {
|
||||
@@ -64,11 +78,11 @@ export class PluginMarketplaceService implements IPluginMarketplaceService {
|
||||
}
|
||||
|
||||
private async _fetchFromRepo(repo: string, token: CancellationToken): Promise<IMarketplacePlugin[]> {
|
||||
for (const jsonPath of MARKETPLACE_JSON_PATHS) {
|
||||
for (const def of MARKETPLACE_DEFINITIONS) {
|
||||
if (token.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
const url = `https://raw.githubusercontent.com/${repo}/main/${jsonPath}`;
|
||||
const url = `https://raw.githubusercontent.com/${repo}/main/${def.path}`;
|
||||
try {
|
||||
const context = await this._requestService.request({ type: 'GET', url }, token);
|
||||
if (context.res.statusCode !== 200) {
|
||||
@@ -84,16 +98,22 @@ export class PluginMarketplaceService implements IPluginMarketplaceService {
|
||||
.filter((p): p is { name: string; description?: string; version?: string; source?: string } =>
|
||||
typeof p.name === 'string' && !!p.name
|
||||
)
|
||||
.map(p => {
|
||||
const source = p.source ?? '';
|
||||
return {
|
||||
.flatMap(p => {
|
||||
const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? '');
|
||||
if (!source) {
|
||||
this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${repo}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
name: p.name,
|
||||
description: p.description ?? '',
|
||||
version: p.version ?? '',
|
||||
source,
|
||||
marketplace: repo,
|
||||
marketplaceType: def.type,
|
||||
readmeUri: getMarketplaceReadmeUri(repo, source),
|
||||
};
|
||||
}];
|
||||
});
|
||||
} catch (err) {
|
||||
this._logService.debug(`[PluginMarketplaceService] Failed to fetch marketplace.json from ${url}:`, err);
|
||||
@@ -105,8 +125,38 @@ export class PluginMarketplaceService implements IPluginMarketplaceService {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMarketplacePath(value: string): string {
|
||||
let normalized = value.trim().replace(/\\/g, '/');
|
||||
normalized = normalized.replace(/^\.?\/+/, '').replace(/\/+$/g, '');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve plugin source from marketplace metadata.
|
||||
* - If pluginRoot exists, plugin source is resolved relative to it.
|
||||
* - If source already includes pluginRoot, it's preserved.
|
||||
* Validation of whether the final path is allowed is performed by the install service.
|
||||
*/
|
||||
function resolvePluginSource(pluginRoot: string | undefined, source: string): string | undefined {
|
||||
const normalizedRoot = pluginRoot ? normalizeMarketplacePath(pluginRoot) : '';
|
||||
const normalizedSource = normalizeMarketplacePath(source);
|
||||
const repoRoot = URI.file('/');
|
||||
const pluginRootUri = normalizedRoot ? normalizePath(joinPath(repoRoot, normalizedRoot)) : repoRoot;
|
||||
|
||||
if (!normalizedSource) {
|
||||
return normalizedRoot || undefined;
|
||||
}
|
||||
|
||||
if (normalizedRoot && (normalizedSource === normalizedRoot || normalizedSource.startsWith(`${normalizedRoot}/`))) {
|
||||
return normalizedSource;
|
||||
}
|
||||
|
||||
const resolvedUri = normalizePath(joinPath(pluginRootUri, normalizedSource));
|
||||
return relativePath(repoRoot, resolvedUri) ?? undefined;
|
||||
}
|
||||
|
||||
function getMarketplaceReadmeUri(repo: string, source: string): URI {
|
||||
const normalizedSource = source.trim().replace(/^\/+|\/+$/g, '');
|
||||
const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, '');
|
||||
const readmePath = normalizedSource ? `${normalizedSource}/README.md` : 'README.md';
|
||||
return URI.parse(`https://github.com/${repo}/blob/main/${readmePath}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user