diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index 4a3a7711..0b994f9f 100644 --- a/src/main/services/extensions/install/PluginInstallService.ts +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -26,6 +26,10 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']); const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos const UNINSTALL_TIMEOUT_MS = 30_000; +function scopeRequiresProjectPath(scope?: string): boolean { + return scope === 'project' || scope === 'local'; +} + export class PluginInstallService { constructor(private readonly catalogService: PluginCatalogService) {} @@ -48,10 +52,10 @@ export class PluginInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project-scoped plugin installs', + error: `projectPath is required for ${scope}-scoped plugin installs`, }; } @@ -130,10 +134,10 @@ export class PluginInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project-scoped plugin uninstalls', + error: `projectPath is required for ${scope}-scoped plugin uninstalls`, }; } diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 74d76b76..8d6e05ac 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -48,7 +48,8 @@ interface PluginDetailDialogProps { const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, + { value: 'project', label: 'Project (shared)' }, + { value: 'local', label: 'Local (gitignored)' }, ]; export const PluginDetailDialog = ({ @@ -94,7 +95,7 @@ export const PluginDetailDialog = ({ }, [open, plugin?.pluginId]); useEffect(() => { - if (scope === 'project' && !projectScopeAvailable) { + if (scope !== 'user' && !projectScopeAvailable) { setScope('user'); } }, [projectScopeAvailable, scope]); @@ -186,7 +187,7 @@ export const PluginDetailDialog = ({ {opt.label} @@ -201,7 +202,7 @@ export const PluginDetailDialog = ({ installPlugin({ pluginId: plugin.pluginId, scope, - ...(scope === 'project' && pluginCatalogProjectPath + ...(scope !== 'user' && pluginCatalogProjectPath ? { projectPath: pluginCatalogProjectPath } : {}), }) @@ -210,7 +211,7 @@ export const PluginDetailDialog = ({ uninstallPlugin( plugin.pluginId, scope, - scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined + scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined ) } size="default" diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 6c207740..7932eeeb 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -225,7 +225,7 @@ const CLI_HEALTHCHECK_FAILED_MESSAGE = const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = - 'Project-scoped plugins require an active project in the Extensions tab.'; + 'Project- and local-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( set, @@ -688,7 +688,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, diff --git a/src/shared/types/extensions/plugin.ts b/src/shared/types/extensions/plugin.ts index 698f800b..5ddb4efc 100644 --- a/src/shared/types/extensions/plugin.ts +++ b/src/shared/types/extensions/plugin.ts @@ -70,7 +70,7 @@ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] { export interface PluginInstallRequest { pluginId: string; // canonical key — main resolves qualifiedName from catalog scope: InstallScope; - projectPath?: string; // required for 'project' scope + projectPath?: string; // required for repo-scoped installs ('project' or 'local') } // ── Filters (renderer-only concern) ──────────────────────────────────────── diff --git a/test/main/services/extensions/PluginInstallService.test.ts b/test/main/services/extensions/PluginInstallService.test.ts index b88c1b1e..8223d06d 100644 --- a/test/main/services/extensions/PluginInstallService.test.ts +++ b/test/main/services/extensions/PluginInstallService.test.ts @@ -85,6 +85,22 @@ describe('PluginInstallService', () => { ); }); + it('adds local scope flag and cwd for local installs', async () => { + mockExecCli.mockResolvedValue({ stdout: '', stderr: '' }); + + await service.install({ + pluginId: 'context7', + scope: 'local', + projectPath: '/tmp/test-project', + }); + + expect(mockExecCli).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['plugin', 'install', '-s', 'local', 'context7@claude-plugins-official'], + expect.objectContaining({ cwd: '/tmp/test-project' }), + ); + }); + it('returns error if plugin not found in catalog', async () => { catalog = createMockCatalog({ resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], @@ -129,6 +145,14 @@ describe('PluginInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope when projectPath is missing', async () => { + const result = await service.install({ pluginId: 'context7', scope: 'local' }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('local-scoped'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── uninstall ─────────────────────────────────────────────────────────────── @@ -159,6 +183,18 @@ describe('PluginInstallService', () => { ); }); + it('adds scope flag for local scope', async () => { + mockExecCli.mockResolvedValue({ stdout: '', stderr: '' }); + + await service.uninstall('context7', 'local', '/tmp/test-project'); + + expect(mockExecCli).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['plugin', 'uninstall', '-s', 'local', 'context7@claude-plugins-official'], + expect.objectContaining({ cwd: '/tmp/test-project' }), + ); + }); + it('returns error if plugin not in catalog', async () => { catalog = createMockCatalog({ resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], @@ -187,5 +223,13 @@ describe('PluginInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope when projectPath is missing', async () => { + const result = await service.uninstall('context7', 'local'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('local-scoped'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 5d9479eb..058b32d6 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -492,6 +492,32 @@ describe('extensionsSlice', () => { expect(store.getState().installErrors['project@m']).toContain('active project'); }); + it('fills missing projectPath for local scope from the active Extensions project context', async () => { + store.setState({ + cliStatus: makeReadyCliStatus(), + pluginCatalogProjectPath: '/tmp/project-a', + }); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); + + expect(api.plugins!.install).toHaveBeenCalledWith({ + pluginId: 'local@m', + scope: 'local', + projectPath: '/tmp/project-a', + }); + }); + + it('fails fast for local scope when there is no active project path', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); + + await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); + + expect(api.plugins!.install).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); + expect(store.getState().installErrors['local@m']).toContain('active project'); + }); + it('clears older success reset timers before a new operation on the same plugin', async () => { vi.useFakeTimers(); store.setState({ cliStatus: makeReadyCliStatus() }); @@ -545,6 +571,25 @@ describe('extensionsSlice', () => { expect(store.getState().installErrors['project@m']).toContain('active project'); }); + it('fills missing projectPath for local uninstall from the active Extensions project context', async () => { + store.setState({ pluginCatalogProjectPath: '/tmp/project-a' }); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('local@m', 'local'); + + expect(api.plugins!.uninstall).toHaveBeenCalledWith('local@m', 'local', '/tmp/project-a'); + }); + + it('fails fast for local uninstall when there is no active project path', async () => { + store.setState({ pluginCatalogProjectPath: null }); + + await store.getState().uninstallPlugin('local@m', 'local'); + + expect(api.plugins!.uninstall).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); + expect(store.getState().installErrors['local@m']).toContain('active project'); + }); + it('does not restore idle state after project switch clears a pending success timer', async () => { vi.useFakeTimers(); store.setState({