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({