diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 4451a5e4620..a7a9b1c975f 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -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 { + await this.cloneManager.clone(url, { parentPath, postCloneAction: 'none' }); + } + + @command('_git.pull') + async pullRepository(repositoryPath: string): Promise { + 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 { let repositoryPath: string | undefined = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 8808d371f0a..0c1fba6ee3d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -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('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 { - // 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 { - 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(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(); private list: WorkbenchPagedList | 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 { + 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> { + 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(ViewExtensions.ViewsRegistry).registerViews([ diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 6a6889667d8..5b0f3ec73db 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -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); diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts new file mode 100644 index 00000000000..6bac30f57e9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -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 { + 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 { + 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>(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>(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, + ); + } +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 2698d168c45..1b413761b4b 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -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('agentPluginService'); @@ -43,6 +44,8 @@ export interface IAgentPlugin { readonly commands: IObservable; readonly skills: IObservable; readonly mcpServerDefinitions: IObservable; + /** Set when the plugin was installed from a marketplace repository. */ + readonly fromMarketplace?: IMarketplacePlugin; } export interface IAgentPluginService { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 5a9f9d4c0a8..5385d796b40 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -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(); 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> { + const result = new Map(); + 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 }); diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts new file mode 100644 index 00000000000..93a3ae5f045 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -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('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; + + /** + * 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; + + /** + * 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; +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index ecc7fdb86f3..95def92241c 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -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 { - 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}`); }