Skip to content

Commit a590327

Browse files
shay-cohclaude
andauthored
MLD-1373 - Agent plugin install VS Code support (#533)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 94c4343 commit a590327

17 files changed

Lines changed: 189 additions & 52 deletions

agent/common/install_flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func (r InstallFlagsResult) PathMode() bool {
4545
// InstallFlagsOptions configures harness install flag validation.
4646
type InstallFlagsOptions struct {
4747
// DefaultGlobalScope uses global scope when neither --global nor --project-dir is set.
48-
// Agent plugins (claude, cursor, codex) only support global installs.
48+
// Agent plugins (claude, cursor, codex, vscode) only support global installs.
4949
DefaultGlobalScope bool
5050
}
5151

agent/plugins/commands/install/install.go

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -327,31 +327,8 @@ func (ic *InstallCommand) resolveAgentTargetDirectories() ([]plugincommon.AgentT
327327
if ic.scope == agentcommon.InstallScopeProject && ic.projectDir == "" {
328328
return nil, fmt.Errorf("project directory is required for project-scoped install")
329329
}
330-
if ic.scope == agentcommon.InstallScopeProject {
331-
for _, agent := range ic.agents {
332-
agentLower := strings.ToLower(agent.Name)
333-
if agentLower == "claude" {
334-
return nil, fmt.Errorf(
335-
"claude does not support project-scoped plugin installs: " +
336-
"Claude plugin configuration is user-scoped only (~/.claude/settings.json). " +
337-
"Use --global to install there instead",
338-
)
339-
}
340-
if agentLower == "cursor" {
341-
return nil, fmt.Errorf(
342-
"cursor does not support project-scoped plugin installs: " +
343-
"Cursor only auto-discovers full plugins from ~/.cursor/plugins/local/. " +
344-
"Use --global to install there instead",
345-
)
346-
}
347-
if agentLower == "codex" {
348-
return nil, fmt.Errorf(
349-
"codex does not support project-scoped plugin installs: " +
350-
"Codex plugin configuration is user-scoped only (~/.codex/config.toml). " +
351-
"Use --global to install there instead",
352-
)
353-
}
354-
}
330+
if err := plugincommon.RejectUnsupportedProjectScope(ic.scope == agentcommon.InstallScopeProject, ic.agents, "install"); err != nil {
331+
return nil, err
355332
}
356333
isGlobal := ic.scope == agentcommon.InstallScopeGlobal
357334
// Path is "" because harness mode uses project or global scope

agent/plugins/commands/install/install_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,36 @@ func TestResolveAgentTargetDirectories_DefaultScopeUsesGlobalForCursor(t *testin
5353
assert.Equal(t, plugincommon.ScopeGlobal, targets[0].Scope)
5454
}
5555

56+
func TestResolveAgentTargetDirectories_DefaultScopeUsesGlobalForVSCode(t *testing.T) {
57+
globalBase := filepath.Join(t.TempDir(), "global", ".copilot", "installed-plugins")
58+
wantBase, err := filepath.Abs(globalBase)
59+
require.NoError(t, err)
60+
61+
cmd := NewInstallCommand().
62+
SetSlug("jfrog-plugin-timepass").
63+
SetRepoKey("plugins-local").
64+
SetAgents([]plugincommon.AgentSpec{{Name: "vscode", Config: plugincommon.AgentConfig{GlobalDir: globalBase}}})
65+
66+
targets, err := cmd.resolveAgentTargetDirectories()
67+
require.NoError(t, err)
68+
require.Len(t, targets, 1)
69+
assert.Equal(t, filepath.Join(wantBase, "plugins-local", "jfrog-plugin-timepass"), targets[0].DestinationDir)
70+
assert.Equal(t, plugincommon.ScopeGlobal, targets[0].Scope)
71+
}
72+
73+
func TestResolveAgentTargetDirectories_ProjectScopeVSCodeRejected(t *testing.T) {
74+
cmd := NewInstallCommand().
75+
SetSlug("my-plugin").
76+
SetAgents([]plugincommon.AgentSpec{{Name: "vscode", Config: plugincommon.AgentConfig{GlobalDir: "~/.copilot/installed-plugins"}}}).
77+
SetProjectDir(t.TempDir()).
78+
SetGlobal(false)
79+
80+
targets, err := cmd.resolveAgentTargetDirectories()
81+
require.Error(t, err)
82+
assert.Nil(t, targets)
83+
assert.Contains(t, err.Error(), "vscode does not support project-scoped plugin installs")
84+
}
85+
5686
func TestResolveAgentTargetDirectories_GlobalScope(t *testing.T) {
5787
globalBase := filepath.Join(t.TempDir(), "global", ".cursor", "plugins")
5888
wantBase, err := filepath.Abs(globalBase)

agent/plugins/commands/list/list.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func (lc *ListCommand) listLocalPlugins() error {
184184
}
185185
specs = append(specs, spec)
186186
}
187-
// claude/cursor/codex have no project-scoped plugin registry to list from — same
187+
// claude/cursor/codex/vscode have no project-scoped plugin registry to list from — same
188188
// restriction install/update already enforce (RejectUnsupportedProjectScope). Without
189189
// this, a non-global list would silently show claude/codex's one global native list
190190
// (see buildNativeRows) under a --project-dir the plugins were never actually scoped to.
@@ -260,7 +260,8 @@ func (lc *ListCommand) buildPluginRowsForHarness(registry map[string]agentcommon
260260
}
261261

262262
// buildDirRows resolves the install dir for agentName and lists installed plugins by
263-
// scanning it directly. Used for agents with no native plugin registry (e.g. cursor).
263+
// scanning it directly. Used for agents with no native plugin registry (e.g. cursor,
264+
// vscode).
264265
func (lc *ListCommand) buildDirRows(registry map[string]agentcommon.AgentSpec, agentName string) ([]localListRow, error) {
265266
spec, err := agentcommon.ResolveAgent(registry, agentName, pluginscommon.RegistryHelp)
266267
if err != nil {
@@ -286,6 +287,10 @@ func (lc *ListCommand) buildDirRows(registry map[string]agentcommon.AgentSpec, a
286287
projectDir = lc.projectDir
287288
}
288289

290+
if pluginscommon.UsesRepoKeyedLayout(agentName) {
291+
return lc.buildRepoKeyedRows(dir, projectDir, agentName, entries)
292+
}
293+
289294
var rows []localListRow
290295
for _, entry := range entries {
291296
if !entry.IsDir() {
@@ -299,6 +304,32 @@ func (lc *ListCommand) buildDirRows(registry map[string]agentcommon.AgentSpec, a
299304
return rows, nil
300305
}
301306

307+
// buildRepoKeyedRows lists plugins nested one level deeper, one subdirectory per
308+
// Artifactory repo (<dir>/<repoKey>/<slug>), e.g. vscode.
309+
func (lc *ListCommand) buildRepoKeyedRows(dir, projectDir, agentName string, entries []os.DirEntry) ([]localListRow, error) {
310+
var rows []localListRow
311+
for _, repoEntry := range entries {
312+
if !repoEntry.IsDir() || strings.HasPrefix(repoEntry.Name(), ".") {
313+
continue
314+
}
315+
repoDir := filepath.Join(dir, repoEntry.Name())
316+
pluginEntries, err := os.ReadDir(repoDir)
317+
if err != nil {
318+
return nil, fmt.Errorf("failed to read plugins directory %s: %w", repoDir, err)
319+
}
320+
for _, entry := range pluginEntries {
321+
if !entry.IsDir() {
322+
continue
323+
}
324+
row, ok := lc.buildRowForPlugin(filepath.Join(repoDir, entry.Name()), entry.Name(), projectDir, agentName)
325+
if ok {
326+
rows = append(rows, row)
327+
}
328+
}
329+
}
330+
return rows, nil
331+
}
332+
302333
// buildNativeRows lists every plugin agentName's own CLI reports as installed. The native
303334
// registries themselves don't track a description, but the plugin's own manifest usually
304335
// exists on disk at its native install path (e.g. .claude-plugin/plugin.json), so Description
@@ -544,7 +575,7 @@ func RunList(c *components.Context) error {
544575

545576
// resolveListScope applies the same project/global-scope default as install/update's
546577
// DefaultGlobalScope: when neither --global nor --project-dir is explicitly given, list
547-
// defaults to global scope, not project scope. claude/cursor/codex only support a global
578+
// defaults to global scope, not project scope. claude/cursor/codex/vscode only support a global
548579
// native registry/config anyway (see agentcommon.RejectUnsupportedProjectScope), so
549580
// defaulting to project scope here would just make list --harness (no flags) reject in
550581
// listLocalPlugins where install/update would have quietly gone global.

agent/plugins/commands/list/list_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,35 @@ func TestBuildPluginRowsForHarness_CursorUsesDirScan(t *testing.T) {
162162
assert.Equal(t, "web", rows[0].Name)
163163
assert.Equal(t, "2.0.0", rows[0].Version)
164164
}
165+
166+
func TestBuildPluginRowsForHarness_VSCodeScansRepoSubdirectories(t *testing.T) {
167+
restore := listNativePluginsFunc
168+
defer func() { listNativePluginsFunc = restore }()
169+
listNativePluginsFunc = func(string) ([]plugincommon.NativePluginInfo, error) {
170+
t.Fatal("listNativePluginsFunc must not be called for agents without a native registry")
171+
return nil, nil
172+
}
173+
174+
dir := t.TempDir()
175+
pluginDir := filepath.Join(dir, "installed-plugins", "plugins-local", "web")
176+
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, ".jfrog"), 0o755))
177+
require.NoError(t, agentcommon.WriteInstallInfoManifest(pluginDir, plugincommon.PluginInfoManifestFile, plugincommon.PluginInfoManifest{
178+
Repo: "plugins-local",
179+
Slug: "web",
180+
InstalledVersion: "2.0.0",
181+
Scope: "global",
182+
Agent: "vscode",
183+
}))
184+
185+
registry := map[string]agentcommon.AgentSpec{
186+
"vscode": {
187+
Config: agentcommon.AgentConfig{GlobalDir: filepath.Join(dir, "installed-plugins")},
188+
},
189+
}
190+
191+
rows, err := (&ListCommand{global: true}).buildPluginRowsForHarness(registry, "vscode")
192+
require.NoError(t, err)
193+
require.Len(t, rows, 1)
194+
assert.Equal(t, "web", rows[0].Name)
195+
assert.Equal(t, "2.0.0", rows[0].Version)
196+
}

agent/plugins/commands/update/update.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func RunUpdate(c *components.Context) error {
106106
}
107107

108108
// validateUpdateScope rejects project-scoped updates for agents whose native plugin
109-
// configuration only supports global scope (claude, cursor, codex — see
109+
// configuration only supports global scope (claude, cursor, codex, vscode — see
110110
// plugincommon.RejectUnsupportedProjectScope). --path mode and --global are always allowed;
111111
// a custom agent registered via agent-config.json is not restricted here.
112112
func validateUpdateScope(flags agentcommon.InstallFlagsResult) error {
@@ -224,8 +224,8 @@ func diagnoseNotFoundError(resolveErr error, slug, repoKey string, targets []plu
224224
}
225225

226226
// resolveUpdateTargets resolves the install targets for slug and injects the
227-
// Artifactory repo key into the path for claude/codex, matching install's
228-
// <GlobalDir>/<repoKey>/<slug> layout for those two agents.
227+
// Artifactory repo key into the path for claude/codex/vscode, matching install's
228+
// <GlobalDir>/<repoKey>/<slug> layout for those agents.
229229
func resolveUpdateTargets(opts update, slug string) ([]plugincommon.AgentTarget, error) {
230230
targets, err := agentcommon.ResolveAgentTargets(slug, opts.flags.AbsoluteInstallBaseDir, opts.flags.Specs, opts.flags.ProjectDirAbs, opts.flags.IsGlobal)
231231
if err != nil {
@@ -279,7 +279,7 @@ type discoveredPlugin struct {
279279
}
280280

281281
// discoverInstalledPluginTargets finds every jf-installed plugin (valid plugin-info.json)
282-
// under each --harness, grouped by (slug, repo); claude/codex scan every repo subdirectory
282+
// under each --harness, grouped by (slug, repo); claude/codex/vscode scan every repo subdirectory
283283
// (UsesRepoKeyedLayout), others read the repo from the manifest. repoFilter, when non-empty,
284284
// keeps only that exact repo.
285285
func discoverInstalledPluginTargets(flags agentcommon.InstallFlagsResult, repoFilter string) ([]discoveredPlugin, error) {

agent/plugins/commands/update/update_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,22 @@ func TestResolveUpdateTargets_CursorUnaffectedByRepoKey(t *testing.T) {
382382
assert.Equal(t, filepath.Join(globalBase, "web"), targets[0].DestinationDir)
383383
}
384384

385+
func TestResolveUpdateTargets_InjectsRepoKeyForVSCode(t *testing.T) {
386+
globalBase := t.TempDir()
387+
opts := update{
388+
repoKey: "my-repo",
389+
flags: agentcommon.InstallFlagsResult{
390+
Specs: []plugincommon.AgentSpec{{Name: "vscode", Config: agentcommon.AgentConfig{GlobalDir: globalBase}}},
391+
IsGlobal: true,
392+
},
393+
}
394+
395+
targets, err := resolveUpdateTargets(opts, "web")
396+
require.NoError(t, err)
397+
require.Len(t, targets, 1)
398+
assert.Equal(t, filepath.Join(globalBase, "my-repo", "web"), targets[0].DestinationDir)
399+
}
400+
385401
func TestRunUpdate_AllRejectsSlugFlag(t *testing.T) {
386402
ctx := newUpdateContext(t, nil, map[string]string{"harness": "claude", "slug": "web"}, map[string]bool{"all": true})
387403
err := RunUpdate(ctx)

agent/plugins/common/agents.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,25 @@ type AgentSpec = agentcommon.AgentSpec
1212
// User overrides come from agent-config.json -> "plugins-agents".
1313
var Agents = map[string]AgentConfig{
1414
// GlobalDir behavior varies by agent:
15-
// - Claude & Codex: The Artifactory repo key is injected as a subdirectory at install time
16-
// so each repo gets its own isolated marketplace directory:
17-
// Claude: <GlobalDir>/<repoKey>/<slug>
18-
// Codex: <GlobalDir>/<repoKey>/<slug>
15+
// - Claude, Codex & VS Code: The Artifactory repo key is injected as a subdirectory at
16+
// install time so each repo gets its own isolated marketplace directory:
17+
// Claude: <GlobalDir>/<repoKey>/<slug>
18+
// Codex: <GlobalDir>/<repoKey>/<slug>
19+
// VS Code: <GlobalDir>/<repoKey>/<slug>
1920
// - Cursor: No repo key injection; paths are used as-is:
2021
// Cursor: <GlobalDir>/<slug>
2122
//
2223
// Paths ending in /jfrog are rooted under ~/.jfrog for JFrog-specific plugin configurations.
2324
//
2425
// All agents support global scope only. Project scope is not supported because:
25-
// - Claude: Plugin config is user-scoped only (~/.claude/settings.json)
26-
// - Cursor: Only auto-discovers full plugins from ~/.cursor/plugins/local/
27-
// - Codex: Plugin config is user-scoped only (~/.codex/config.toml)
26+
// - Claude: Plugin config is user-scoped only (~/.claude/settings.json)
27+
// - Cursor: Only auto-discovers full plugins from ~/.cursor/plugins/local/
28+
// - Codex: Plugin config is user-scoped only (~/.codex/config.toml)
29+
// - VS Code: Only auto-discovers plugins from ~/.copilot/installed-plugins/
2830
"claude": {GlobalDir: "~/.claude/plugins/local/jfrog", ProjectDir: ""},
2931
"cursor": {GlobalDir: "~/.cursor/plugins/local", ProjectDir: ""},
3032
"codex": {GlobalDir: "~/.agents/plugins/local/jfrog", ProjectDir: ""},
33+
"vscode": {GlobalDir: "~/.copilot/installed-plugins", ProjectDir: ""},
3134
}
3235

3336
// RegistryHelp configures agent-config.json help text for plugins harness resolution.

agent/plugins/common/agents_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ func TestLoadAgentRegistry_BuiltInsOnly(t *testing.T) {
1717
registry, err := agentcommon.LoadAgentRegistry(Agents, agentcommon.PluginsAgentsKey)
1818
require.NoError(t, err)
1919

20-
for _, name := range []string{"claude", "cursor", "codex"} {
20+
for _, name := range []string{"claude", "cursor", "codex", "vscode"} {
2121
spec, ok := registry[name]
2222
require.True(t, ok, "expected built-in %q", name)
2323
assert.False(t, spec.FromConfig)
2424
}
2525
// Built-in registry must be exactly the supported plugin agents.
26-
assert.Len(t, registry, 3)
26+
assert.Len(t, registry, 4)
2727
}
2828

2929
func TestLoadAgentRegistry_OverridesAndAdds(t *testing.T) {
@@ -106,5 +106,5 @@ func TestSupportedAgentsList_OnlyPluginAgents(t *testing.T) {
106106
testutil.WithJfrogHome(t)
107107
got := agentcommon.SupportedAgentsList(Agents, agentcommon.PluginsAgentsKey)
108108
parts := strings.Split(got, ", ")
109-
assert.ElementsMatch(t, []string{"claude", "cursor", "codex"}, parts)
109+
assert.ElementsMatch(t, []string{"claude", "cursor", "codex", "vscode"}, parts)
110110
}

agent/plugins/common/install_targets.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ type AgentTarget = agentcommon.InstallTarget
2121

2222
// UsesRepoKeyedLayout reports whether agentName nests installed plugins one level deeper
2323
// under a repo-key subdirectory (see MarketplaceRootForAgent) instead of storing them
24-
// directly under GlobalDir. True only for claude and codex, so each Artifactory repo gets
25-
// its own isolated marketplace subdirectory; cursor/--path have no such nesting.
24+
// directly under GlobalDir. True for claude, codex and vscode, so each Artifactory repo
25+
// gets its own isolated marketplace subdirectory; cursor/--path have no such nesting.
2626
func UsesRepoKeyedLayout(agentName string) bool {
2727
switch strings.ToLower(agentName) {
28-
case "claude", "codex":
28+
case "claude", "codex", "vscode":
2929
return true
3030
default:
3131
return false
@@ -34,7 +34,7 @@ func UsesRepoKeyedLayout(agentName string) bool {
3434

3535
// MarketplaceRootForAgent returns the directory holding an agent's installed plugin slugs
3636
// directly as subdirectories (filepath.Join(result, slug) is the plugin's install dir).
37-
// For claude/codex that's <globalOrProjectDir>/<repoKey>, so repos never collide; every
37+
// For claude/codex/vscode that's <globalOrProjectDir>/<repoKey>, so repos never collide; every
3838
// other agent uses <globalOrProjectDir> unchanged and ignores repoKey.
3939
func MarketplaceRootForAgent(agentName, globalOrProjectDir, repoKey string) string {
4040
if UsesRepoKeyedLayout(agentName) {
@@ -43,7 +43,7 @@ func MarketplaceRootForAgent(agentName, globalOrProjectDir, repoKey string) stri
4343
return globalOrProjectDir
4444
}
4545

46-
// InjectRepoKey rewrites claude/codex targets' DestinationDir to
46+
// InjectRepoKey rewrites repo-keyed agents' (claude/codex/vscode) DestinationDir to
4747
// <GlobalDir>/<slug> → <GlobalDir>/<repoKey>/<slug>, so different repos never overwrite
4848
// each other's marketplace registration; other targets are unchanged. Callers needing the
4949
// marketplace root before the slug is known should use MarketplaceRootForAgent directly.

0 commit comments

Comments
 (0)