Skip to content

Commit 499d792

Browse files
christsoclaude
andauthored
feat(plugin): smart scope resolution for plugin uninstall (#64)
When no --scope flag is given, uninstall now checks both project and user scopes and removes the plugin from all scopes where it exists. This eliminates the confusing error when a plugin is installed in user scope but the user doesn't specify --scope user. - Add hasPlugin() and hasUserPlugin() helpers for non-destructive lookup - Auto-uninstall from both scopes when no --scope specified - Always show scope label in install/uninstall success messages - Clear "Plugin not found" error when plugin doesn't exist in any scope Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5d8e3c6 commit 499d792

4 files changed

Lines changed: 265 additions & 29 deletions

File tree

src/cli/commands/plugin.ts

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
getWellKnownMarketplaces,
99
} from '../../core/marketplace.js';
1010
import { syncWorkspace, syncUserWorkspace } from '../../core/sync.js';
11-
import { addPlugin, removePlugin } from '../../core/workspace-modify.js';
12-
import { addUserPlugin, removeUserPlugin } from '../../core/user-workspace.js';
11+
import { addPlugin, removePlugin, hasPlugin } from '../../core/workspace-modify.js';
12+
import { addUserPlugin, removeUserPlugin, hasUserPlugin } from '../../core/user-workspace.js';
1313
import { isJsonMode, jsonOutput } from '../json-output.js';
1414
import { buildDescription, conciseSubcommands } from '../help.js';
1515
import {
@@ -637,7 +637,7 @@ const pluginInstallCmd = command({
637637
if (result.autoRegistered) {
638638
console.log(`\u2713 Auto-registered marketplace: ${result.autoRegistered}`);
639639
}
640-
console.log(`\u2713 Installed plugin${isUser ? ' (user scope)' : ''}: ${plugin}`);
640+
console.log(`\u2713 Installed plugin (${isUser ? 'user' : 'project'} scope): ${plugin}`);
641641

642642
const { ok: syncOk } = isUser
643643
? await runUserSyncAndPrint()
@@ -673,48 +673,122 @@ const pluginUninstallCmd = command({
673673
},
674674
handler: async ({ plugin, scope }) => {
675675
try {
676-
const isUser = scope === 'user';
677-
const result = isUser
678-
? await removeUserPlugin(plugin)
679-
: await removePlugin(plugin);
676+
// When an explicit scope is given, only uninstall from that scope
677+
if (scope) {
678+
const isUser = scope === 'user';
679+
const result = isUser
680+
? await removeUserPlugin(plugin)
681+
: await removePlugin(plugin);
682+
683+
if (!result.success) {
684+
if (isJsonMode()) {
685+
jsonOutput({ success: false, command: 'plugin uninstall', error: result.error ?? 'Unknown error' });
686+
process.exit(1);
687+
}
688+
console.error(`Error: ${result.error}`);
689+
process.exit(1);
690+
}
680691

681-
if (!result.success) {
682692
if (isJsonMode()) {
683-
jsonOutput({ success: false, command: 'plugin uninstall', error: result.error ?? 'Unknown error' });
693+
const { ok, syncData } = isUser
694+
? await runUserSyncAndPrint()
695+
: await runSyncAndPrint();
696+
jsonOutput({
697+
success: ok,
698+
command: 'plugin uninstall',
699+
data: { plugin, scope, syncResult: syncData },
700+
...(!ok && { error: 'Sync completed with failures' }),
701+
});
702+
if (!ok) process.exit(1);
703+
return;
704+
}
705+
706+
console.log(`\u2713 Uninstalled plugin (${scope} scope): ${plugin}`);
707+
const { ok: syncOk } = isUser
708+
? await runUserSyncAndPrint()
709+
: await runSyncAndPrint();
710+
if (!syncOk) process.exit(1);
711+
return;
712+
}
713+
714+
// No explicit scope: uninstall from all scopes where the plugin exists
715+
const inProject = await hasPlugin(plugin);
716+
const inUser = await hasUserPlugin(plugin);
717+
718+
if (!inProject && !inUser) {
719+
const error = `Plugin not found: ${plugin}`;
720+
if (isJsonMode()) {
721+
jsonOutput({ success: false, command: 'plugin uninstall', error });
684722
process.exit(1);
685723
}
686-
console.error(`Error: ${result.error}`);
724+
console.error(`Error: ${error}`);
687725
process.exit(1);
688726
}
689727

728+
const removedScopes: string[] = [];
729+
730+
if (inProject) {
731+
const result = await removePlugin(plugin);
732+
if (!result.success) {
733+
if (isJsonMode()) {
734+
jsonOutput({ success: false, command: 'plugin uninstall', error: result.error ?? 'Unknown error' });
735+
process.exit(1);
736+
}
737+
console.error(`Error: ${result.error}`);
738+
process.exit(1);
739+
}
740+
removedScopes.push('project');
741+
}
742+
743+
if (inUser) {
744+
const result = await removeUserPlugin(plugin);
745+
if (!result.success) {
746+
if (isJsonMode()) {
747+
jsonOutput({ success: false, command: 'plugin uninstall', error: result.error ?? 'Unknown error' });
748+
process.exit(1);
749+
}
750+
console.error(`Error: ${result.error}`);
751+
process.exit(1);
752+
}
753+
removedScopes.push('user');
754+
}
755+
690756
if (isJsonMode()) {
691-
const { ok, syncData } = isUser
692-
? await runUserSyncAndPrint()
693-
: await runSyncAndPrint();
757+
const syncResults: Record<string, ReturnType<typeof buildSyncData> | null> = {};
758+
let allOk = true;
759+
if (removedScopes.includes('project')) {
760+
const { ok, syncData } = await runSyncAndPrint();
761+
syncResults.project = syncData;
762+
if (!ok) allOk = false;
763+
}
764+
if (removedScopes.includes('user')) {
765+
const { ok, syncData } = await runUserSyncAndPrint();
766+
syncResults.user = syncData;
767+
if (!ok) allOk = false;
768+
}
694769
jsonOutput({
695-
success: ok,
770+
success: allOk,
696771
command: 'plugin uninstall',
697-
data: {
698-
plugin,
699-
scope: isUser ? 'user' : 'project',
700-
syncResult: syncData,
701-
},
702-
...(!ok && { error: 'Sync completed with failures' }),
772+
data: { plugin, scopes: removedScopes, syncResults },
773+
...(!allOk && { error: 'Sync completed with failures' }),
703774
});
704-
if (!ok) {
705-
process.exit(1);
706-
}
775+
if (!allOk) process.exit(1);
707776
return;
708777
}
709778

710-
console.log(`\u2713 Uninstalled plugin${isUser ? ' (user scope)' : ''}: ${plugin}`);
779+
const scopeLabel = removedScopes.join(' + ');
780+
console.log(`\u2713 Uninstalled plugin (${scopeLabel} scope): ${plugin}`);
711781

712-
const { ok: syncOk } = isUser
713-
? await runUserSyncAndPrint()
714-
: await runSyncAndPrint();
715-
if (!syncOk) {
716-
process.exit(1);
782+
let syncOk = true;
783+
if (removedScopes.includes('project')) {
784+
const { ok } = await runSyncAndPrint();
785+
if (!ok) syncOk = false;
786+
}
787+
if (removedScopes.includes('user')) {
788+
const { ok } = await runUserSyncAndPrint();
789+
if (!ok) syncOk = false;
717790
}
791+
if (!syncOk) process.exit(1);
718792
} catch (error) {
719793
if (error instanceof Error) {
720794
if (isJsonMode()) {

src/core/user-workspace.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ export async function addUserPlugin(plugin: string): Promise<ModifyResult> {
112112
return addPluginToUserConfig(plugin, configPath);
113113
}
114114

115+
/**
116+
* Check if a plugin exists in the user-level workspace config.
117+
* @param plugin - Plugin source to find (exact match or partial match)
118+
* @returns true if the plugin is found
119+
*/
120+
export async function hasUserPlugin(plugin: string): Promise<boolean> {
121+
const config = await getUserWorkspaceConfig();
122+
if (!config) return false;
123+
124+
// Exact match first
125+
if (config.plugins.indexOf(plugin) !== -1) return true;
126+
127+
// Partial match
128+
return config.plugins.some(
129+
(p) => p.startsWith(`${plugin}@`) || p === plugin,
130+
);
131+
}
132+
115133
/**
116134
* Remove a plugin from the user-level workspace config.
117135
*/

src/core/workspace-modify.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,39 @@ async function addPluginToConfig(
145145
}
146146
}
147147

148+
/**
149+
* Check if a plugin exists in .allagents/workspace.yaml (project scope)
150+
* @param plugin - Plugin source to find (exact match or partial match)
151+
* @param workspacePath - Path to workspace directory (default: cwd)
152+
* @returns true if the plugin is found
153+
*/
154+
export async function hasPlugin(
155+
plugin: string,
156+
workspacePath: string = process.cwd(),
157+
): Promise<boolean> {
158+
const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE);
159+
if (!existsSync(configPath)) return false;
160+
161+
try {
162+
const content = await readFile(configPath, 'utf-8');
163+
const config = load(content) as WorkspaceConfig;
164+
165+
// Exact match first
166+
if (config.plugins.indexOf(plugin) !== -1) return true;
167+
168+
// Partial match
169+
if (!isPluginSpec(plugin)) {
170+
return config.plugins.some(
171+
(p) => p.startsWith(`${plugin}@`) || p === plugin,
172+
);
173+
}
174+
175+
return false;
176+
} catch {
177+
return false;
178+
}
179+
}
180+
148181
/**
149182
* Remove a plugin from .allagents/workspace.yaml
150183
* @param plugin - Plugin source to remove (exact match or partial match)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
2+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { dump } from 'js-yaml';
6+
7+
describe('plugin uninstall smart scope resolution', () => {
8+
let tempHome: string;
9+
let tempProject: string;
10+
let originalHome: string;
11+
12+
beforeEach(async () => {
13+
tempHome = await mkdtemp(join(tmpdir(), 'allagents-uninstall-home-'));
14+
tempProject = await mkdtemp(join(tmpdir(), 'allagents-uninstall-proj-'));
15+
originalHome = process.env.HOME || '';
16+
process.env.HOME = tempHome;
17+
});
18+
19+
afterEach(async () => {
20+
process.env.HOME = originalHome;
21+
await rm(tempHome, { recursive: true, force: true });
22+
await rm(tempProject, { recursive: true, force: true });
23+
});
24+
25+
async function setupProjectWorkspace(plugins: string[]) {
26+
const configDir = join(tempProject, '.allagents');
27+
await mkdir(configDir, { recursive: true });
28+
await writeFile(
29+
join(configDir, 'workspace.yaml'),
30+
dump({ repositories: [], plugins, clients: ['claude'] }, { lineWidth: -1 }),
31+
);
32+
}
33+
34+
async function setupUserWorkspace(plugins: string[]) {
35+
const configDir = join(tempHome, '.allagents');
36+
await mkdir(configDir, { recursive: true });
37+
await writeFile(
38+
join(configDir, 'workspace.yaml'),
39+
dump({ repositories: [], plugins, clients: ['claude'] }, { lineWidth: -1 }),
40+
);
41+
}
42+
43+
test('hasPlugin returns true for exact match in project scope', async () => {
44+
await setupProjectWorkspace(['my-plugin@marketplace']);
45+
const { hasPlugin } = await import('../../src/core/workspace-modify.js');
46+
expect(await hasPlugin('my-plugin@marketplace', tempProject)).toBe(true);
47+
});
48+
49+
test('hasPlugin returns true for partial match in project scope', async () => {
50+
await setupProjectWorkspace(['my-plugin@marketplace']);
51+
const { hasPlugin } = await import('../../src/core/workspace-modify.js');
52+
expect(await hasPlugin('my-plugin', tempProject)).toBe(true);
53+
});
54+
55+
test('hasPlugin returns false when plugin not in project scope', async () => {
56+
await setupProjectWorkspace(['other-plugin@marketplace']);
57+
const { hasPlugin } = await import('../../src/core/workspace-modify.js');
58+
expect(await hasPlugin('my-plugin', tempProject)).toBe(false);
59+
});
60+
61+
test('hasPlugin returns false when no project workspace exists', async () => {
62+
const { hasPlugin } = await import('../../src/core/workspace-modify.js');
63+
expect(await hasPlugin('my-plugin', tempProject)).toBe(false);
64+
});
65+
66+
test('hasUserPlugin returns true for exact match in user scope', async () => {
67+
await setupUserWorkspace(['my-plugin@marketplace']);
68+
const { hasUserPlugin } = await import('../../src/core/user-workspace.js');
69+
expect(await hasUserPlugin('my-plugin@marketplace')).toBe(true);
70+
});
71+
72+
test('hasUserPlugin returns true for partial match in user scope', async () => {
73+
await setupUserWorkspace(['my-plugin@marketplace']);
74+
const { hasUserPlugin } = await import('../../src/core/user-workspace.js');
75+
expect(await hasUserPlugin('my-plugin')).toBe(true);
76+
});
77+
78+
test('hasUserPlugin returns false when plugin not in user scope', async () => {
79+
await setupUserWorkspace(['other-plugin@marketplace']);
80+
const { hasUserPlugin } = await import('../../src/core/user-workspace.js');
81+
expect(await hasUserPlugin('my-plugin')).toBe(false);
82+
});
83+
84+
test('hasUserPlugin returns false when no user workspace exists', async () => {
85+
const { hasUserPlugin } = await import('../../src/core/user-workspace.js');
86+
expect(await hasUserPlugin('my-plugin')).toBe(false);
87+
});
88+
89+
test('removePlugin succeeds when plugin is in project scope', async () => {
90+
await setupProjectWorkspace(['my-plugin@marketplace']);
91+
const { removePlugin } = await import('../../src/core/workspace-modify.js');
92+
const result = await removePlugin('my-plugin@marketplace', tempProject);
93+
expect(result.success).toBe(true);
94+
});
95+
96+
test('removeUserPlugin succeeds when plugin is in user scope', async () => {
97+
await setupUserWorkspace(['my-plugin@marketplace']);
98+
const { removeUserPlugin } = await import('../../src/core/user-workspace.js');
99+
const result = await removeUserPlugin('my-plugin@marketplace');
100+
expect(result.success).toBe(true);
101+
});
102+
103+
test('removePlugin fails when plugin only in user scope', async () => {
104+
await setupProjectWorkspace([]);
105+
await setupUserWorkspace(['my-plugin@marketplace']);
106+
const { removePlugin } = await import('../../src/core/workspace-modify.js');
107+
const result = await removePlugin('my-plugin@marketplace', tempProject);
108+
expect(result.success).toBe(false);
109+
expect(result.error).toContain('not found');
110+
});
111+
});

0 commit comments

Comments
 (0)