Skip to content

Commit dd6c1d7

Browse files
committed
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)
1 parent 2f76a2d commit dd6c1d7

File tree

16 files changed

+1551
-45
lines changed

16 files changed

+1551
-45
lines changed

extensions/git/src/commands.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,19 +1040,29 @@ export class CommandCenter {
10401040
}
10411041

10421042
@command('_git.cloneRepository')
1043-
async cloneRepository(url: string, parentPath: string): Promise<void> {
1043+
async cloneRepository(url: string, localPath: string): Promise<void> {
10441044
const opts = {
10451045
location: ProgressLocation.Notification,
10461046
title: l10n.t('Cloning git repository "{0}"...', url),
10471047
cancellable: true
10481048
};
10491049

1050+
const parentPath = path.dirname(localPath);
1051+
const targetName = path.basename(localPath);
1052+
10501053
await window.withProgress(
10511054
opts,
1052-
(progress, token) => this.model.git.clone(url, { parentPath, progress }, token)
1055+
(progress, token) => this.model.git.clone(url, { parentPath, targetName, progress }, token)
10531056
);
10541057
}
10551058

1059+
@command('_git.checkout')
1060+
async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise<void> {
1061+
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
1062+
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
1063+
await repo.checkout(treeish, [], detached ? { detached: true } : {});
1064+
}
1065+
10561066
@command('_git.pull')
10571067
async pullRepository(repositoryPath: string): Promise<void> {
10581068
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);

extensions/git/src/git.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct';
378378

379379
export interface ICloneOptions {
380380
readonly parentPath: string;
381+
readonly targetName?: string;
381382
readonly progress: Progress<{ increment: number }>;
382383
readonly recursive?: boolean;
383384
readonly ref?: string;
@@ -433,14 +434,16 @@ export class Git {
433434
}
434435

435436
async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise<string> {
436-
const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
437+
const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
437438
let folderName = baseFolderName;
438439
let folderPath = path.join(options.parentPath, folderName);
439440
let count = 1;
440441

441-
while (count < 20 && await new Promise(c => exists(folderPath, c))) {
442-
folderName = `${baseFolderName}-${count++}`;
443-
folderPath = path.join(options.parentPath, folderName);
442+
if (!options.targetName) {
443+
while (count < 20 && await new Promise(c => exists(folderPath, c))) {
444+
folderName = `${baseFolderName}-${count++}`;
445+
folderPath = path.join(options.parentPath, folderName);
446+
}
444447
}
445448

446449
await mkdirp(options.parentPath);

src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/
1212
import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
1313
import { Schemas, matchesScheme } from '../../../../../base/common/network.js';
1414
import { autorun } from '../../../../../base/common/observable.js';
15-
import { basename, dirname, joinPath } from '../../../../../base/common/resources.js';
15+
import { dirname, joinPath } from '../../../../../base/common/resources.js';
1616
import { URI } from '../../../../../base/common/uri.js';
1717
import { generateUuid } from '../../../../../base/common/uuid.js';
1818
import { TokenizationRegistry } from '../../../../../editor/common/languages.js';
@@ -202,6 +202,7 @@ export class AgentPluginEditor extends EditorPane {
202202
description: item.description,
203203
version: '',
204204
source: item.source,
205+
sourceDescriptor: item.sourceDescriptor,
205206
marketplace: item.marketplace,
206207
marketplaceReference: item.marketplaceReference,
207208
marketplaceType: item.marketplaceType,
@@ -222,6 +223,7 @@ export class AgentPluginEditor extends EditorPane {
222223
name: item.name,
223224
description: mp.description,
224225
source: mp.source,
226+
sourceDescriptor: mp.sourceDescriptor,
225227
marketplace: mp.marketplace,
226228
marketplaceReference: mp.marketplaceReference,
227229
marketplaceType: mp.marketplaceType,
@@ -267,7 +269,7 @@ export class AgentPluginEditor extends EditorPane {
267269
}
268270

269271
private installedPluginToItem(plugin: IAgentPlugin): IInstalledPluginItem {
270-
const name = basename(plugin.uri);
272+
const name = plugin.label;
271273
const description = plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(dirname(plugin.uri), { relative: true });
272274
const marketplace = plugin.fromMarketplace?.marketplace;
273275
return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin };
@@ -517,6 +519,7 @@ class InstallPluginEditorAction extends Action {
517519
description: this.item.description,
518520
version: '',
519521
source: this.item.source,
522+
sourceDescriptor: this.item.sourceDescriptor,
520523
marketplace: this.item.marketplace,
521524
marketplaceReference: this.item.marketplaceReference,
522525
marketplaceType: this.item.marketplaceType,

src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { URI } from '../../../../../base/common/uri.js';
77
import type { IAgentPlugin } from '../../common/plugins/agentPluginService.js';
8-
import type { IMarketplaceReference, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js';
8+
import type { IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js';
99

1010
export const enum AgentPluginItemKind {
1111
Installed = 'installed',
@@ -25,6 +25,7 @@ export interface IMarketplacePluginItem {
2525
readonly name: string;
2626
readonly description: string;
2727
readonly source: string;
28+
readonly sourceDescriptor: IPluginSourceDescriptor;
2829
readonly marketplace: string;
2930
readonly marketplaceReference: IMarketplaceReference;
3031
readonly marketplaceType: MarketplaceType;

src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres
1818
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
1919
import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js';
2020
import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js';
21-
import { IMarketplacePlugin, IMarketplaceReference, MarketplaceReferenceKind, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js';
21+
import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';
2222

2323
const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1';
2424

@@ -176,7 +176,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
176176
this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE);
177177
}
178178

179-
private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string): Promise<void> {
179+
private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise<void> {
180180
try {
181181
await this._progressService.withProgress(
182182
{
@@ -186,7 +186,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
186186
},
187187
async () => {
188188
await this._fileService.createFolder(dirname(repoDir));
189-
await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, dirname(repoDir).fsPath);
189+
await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath);
190190
}
191191
);
192192
} catch (err) {
@@ -212,4 +212,178 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
212212
}
213213
return pluginDir;
214214
}
215+
216+
getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI {
217+
switch (sourceDescriptor.kind) {
218+
case PluginSourceKind.RelativePath:
219+
throw new Error('Use getPluginInstallUri() for relative-path sources');
220+
case PluginSourceKind.GitHub: {
221+
const [owner, repo] = sourceDescriptor.repo.split('/');
222+
return joinPath(this._cacheRoot, 'github.com', owner, repo, ...this._getSourceRevisionCacheSuffix(sourceDescriptor));
223+
}
224+
case PluginSourceKind.GitUrl: {
225+
const segments = this._gitUrlCacheSegments(sourceDescriptor.url, sourceDescriptor.ref, sourceDescriptor.sha);
226+
return joinPath(this._cacheRoot, ...segments);
227+
}
228+
case PluginSourceKind.Npm:
229+
return joinPath(this._cacheRoot, 'npm', sanitizePackageName(sourceDescriptor.package), 'node_modules', sourceDescriptor.package);
230+
case PluginSourceKind.Pip:
231+
return joinPath(this._cacheRoot, 'pip', sanitizePackageName(sourceDescriptor.package));
232+
}
233+
}
234+
235+
async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise<URI> {
236+
const descriptor = plugin.sourceDescriptor;
237+
switch (descriptor.kind) {
238+
case PluginSourceKind.RelativePath:
239+
return this.ensureRepository(plugin.marketplaceReference, options);
240+
case PluginSourceKind.GitHub: {
241+
const cloneUrl = `https://github.com/${descriptor.repo}.git`;
242+
const repoDir = this.getPluginSourceInstallUri(descriptor);
243+
const repoExists = await this._fileService.exists(repoDir);
244+
if (repoExists) {
245+
await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.repo);
246+
return repoDir;
247+
}
248+
const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", descriptor.repo);
249+
const failureLabel = options?.failureLabel ?? descriptor.repo;
250+
await this._cloneRepository(repoDir, cloneUrl, progressTitle, failureLabel, descriptor.ref);
251+
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
252+
return repoDir;
253+
}
254+
case PluginSourceKind.GitUrl: {
255+
const repoDir = this.getPluginSourceInstallUri(descriptor);
256+
const repoExists = await this._fileService.exists(repoDir);
257+
if (repoExists) {
258+
await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.url);
259+
return repoDir;
260+
}
261+
const progressTitle = options?.progressTitle ?? localize('cloningPluginSourceUrl', "Cloning plugin source '{0}'...", descriptor.url);
262+
const failureLabel = options?.failureLabel ?? descriptor.url;
263+
await this._cloneRepository(repoDir, descriptor.url, progressTitle, failureLabel, descriptor.ref);
264+
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
265+
return repoDir;
266+
}
267+
case PluginSourceKind.Npm: {
268+
// npm/pip install directories are managed by the install service.
269+
// Return the expected install URI without performing installation.
270+
return joinPath(this._cacheRoot, 'npm', sanitizePackageName(descriptor.package));
271+
}
272+
case PluginSourceKind.Pip: {
273+
return joinPath(this._cacheRoot, 'pip', sanitizePackageName(descriptor.package));
274+
}
275+
}
276+
}
277+
278+
async updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise<void> {
279+
const descriptor = plugin.sourceDescriptor;
280+
if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) {
281+
return;
282+
}
283+
284+
const repoDir = this.getPluginSourceInstallUri(descriptor);
285+
const repoExists = await this._fileService.exists(repoDir);
286+
if (!repoExists) {
287+
this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`);
288+
return;
289+
}
290+
291+
const updateLabel = options?.pluginName ?? plugin.name;
292+
const failureLabel = options?.failureLabel ?? updateLabel;
293+
294+
try {
295+
await this._progressService.withProgress(
296+
{
297+
location: ProgressLocation.Notification,
298+
title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel),
299+
cancellable: false,
300+
},
301+
async () => {
302+
await this._commandService.executeCommand('git.openRepository', repoDir.fsPath);
303+
if (descriptor.sha) {
304+
await this._commandService.executeCommand('git.fetch', repoDir.fsPath);
305+
} else {
306+
await this._commandService.executeCommand('_git.pull', repoDir.fsPath);
307+
}
308+
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
309+
}
310+
);
311+
} catch (err) {
312+
this._logService.error(`[AgentPluginRepositoryService] Failed to update plugin source ${updateLabel}:`, err);
313+
this._notificationService.notify({
314+
severity: Severity.Error,
315+
message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)),
316+
actions: {
317+
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
318+
this._commandService.executeCommand('git.showOutput');
319+
})],
320+
},
321+
});
322+
}
323+
}
324+
325+
private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] {
326+
try {
327+
const parsed = URI.parse(url);
328+
const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase();
329+
const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, '');
330+
const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_'));
331+
return [authority, ...segments, ...this._getSourceRevisionCacheSuffix(ref, sha)];
332+
} catch {
333+
return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...this._getSourceRevisionCacheSuffix(ref, sha)];
334+
}
335+
}
336+
337+
private _getSourceRevisionCacheSuffix(descriptorOrRef: IPluginSourceDescriptor | string | undefined, sha?: string): string[] {
338+
if (typeof descriptorOrRef === 'object' && descriptorOrRef) {
339+
if (descriptorOrRef.kind === PluginSourceKind.GitHub || descriptorOrRef.kind === PluginSourceKind.GitUrl) {
340+
return this._getSourceRevisionCacheSuffix(descriptorOrRef.ref, descriptorOrRef.sha);
341+
}
342+
return [];
343+
}
344+
345+
const ref = descriptorOrRef;
346+
if (sha) {
347+
return [`sha_${sanitizePackageName(sha)}`];
348+
}
349+
if (ref) {
350+
return [`ref_${sanitizePackageName(ref)}`];
351+
}
352+
return [];
353+
}
354+
355+
private async _checkoutPluginSourceRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string): Promise<void> {
356+
if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) {
357+
return;
358+
}
359+
360+
if (!descriptor.sha && !descriptor.ref) {
361+
return;
362+
}
363+
364+
try {
365+
if (descriptor.sha) {
366+
await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.sha, true);
367+
return;
368+
}
369+
370+
await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.ref);
371+
} catch (err) {
372+
this._logService.error(`[AgentPluginRepositoryService] Failed to checkout plugin source revision for ${failureLabel}:`, err);
373+
this._notificationService.notify({
374+
severity: Severity.Error,
375+
message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)),
376+
actions: {
377+
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
378+
this._commandService.executeCommand('git.showOutput');
379+
})],
380+
},
381+
});
382+
throw err;
383+
}
384+
}
385+
}
386+
387+
function sanitizePackageName(name: string): string {
388+
return name.replace(/[\\/:*?"<>|]/g, '_');
215389
}

src/vs/workbench/contrib/chat/browser/agentPluginsView.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { Event } from '../../../../base/common/event.js';
1515
import { Disposable, DisposableStore, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
1616
import { autorun } from '../../../../base/common/observable.js';
1717
import { IPagedModel, PagedModel } from '../../../../base/common/paging.js';
18-
import { basename, dirname, joinPath } from '../../../../base/common/resources.js';
18+
import { dirname, joinPath } from '../../../../base/common/resources.js';
1919
import { URI } from '../../../../base/common/uri.js';
2020
import { localize, localize2 } from '../../../../nls.js';
2121
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
@@ -53,7 +53,7 @@ export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.install
5353
//#region Item model
5454

5555
function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem {
56-
const name = basename(plugin.uri);
56+
const name = plugin.label;
5757
const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true });
5858
const marketplace = plugin.fromMarketplace?.marketplace;
5959
return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin };
@@ -65,6 +65,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin
6565
name: plugin.name,
6666
description: plugin.description,
6767
source: plugin.source,
68+
sourceDescriptor: plugin.sourceDescriptor,
6869
marketplace: plugin.marketplace,
6970
marketplaceReference: plugin.marketplaceReference,
7071
marketplaceType: plugin.marketplaceType,
@@ -92,6 +93,7 @@ class InstallPluginAction extends Action {
9293
description: this.item.description,
9394
version: '',
9495
source: this.item.source,
96+
sourceDescriptor: this.item.sourceDescriptor,
9597
marketplace: this.item.marketplace,
9698
marketplaceReference: this.item.marketplaceReference,
9799
marketplaceType: this.item.marketplaceType,
@@ -443,6 +445,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView<IAgentPlugi
443445
description: m.description,
444446
version: '',
445447
source: m.source,
448+
sourceDescriptor: m.sourceDescriptor,
446449
marketplace: m.marketplace,
447450
marketplaceReference: m.marketplaceReference,
448451
marketplaceType: m.marketplaceType,

0 commit comments

Comments
 (0)