fix(extensions): support local plugin scope actions

This commit is contained in:
777genius 2026-04-16 22:09:52 +03:00
parent 2b8062dfa3
commit 4502152427
6 changed files with 109 additions and 15 deletions

View file

@ -26,6 +26,10 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']);
const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
const UNINSTALL_TIMEOUT_MS = 30_000; const UNINSTALL_TIMEOUT_MS = 30_000;
function scopeRequiresProjectPath(scope?: string): boolean {
return scope === 'project' || scope === 'local';
}
export class PluginInstallService { export class PluginInstallService {
constructor(private readonly catalogService: PluginCatalogService) {} constructor(private readonly catalogService: PluginCatalogService) {}
@ -48,10 +52,10 @@ export class PluginInstallService {
}; };
} }
if (scope === 'project' && !projectPath) { if (scopeRequiresProjectPath(scope) && !projectPath) {
return { return {
state: 'error', 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 { return {
state: 'error', state: 'error',
error: 'projectPath is required for project-scoped plugin uninstalls', error: `projectPath is required for ${scope}-scoped plugin uninstalls`,
}; };
} }

View file

@ -48,7 +48,8 @@ interface PluginDetailDialogProps {
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
{ value: 'user', label: 'User (global)' }, { value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' }, { value: 'project', label: 'Project (shared)' },
{ value: 'local', label: 'Local (gitignored)' },
]; ];
export const PluginDetailDialog = ({ export const PluginDetailDialog = ({
@ -94,7 +95,7 @@ export const PluginDetailDialog = ({
}, [open, plugin?.pluginId]); }, [open, plugin?.pluginId]);
useEffect(() => { useEffect(() => {
if (scope === 'project' && !projectScopeAvailable) { if (scope !== 'user' && !projectScopeAvailable) {
setScope('user'); setScope('user');
} }
}, [projectScopeAvailable, scope]); }, [projectScopeAvailable, scope]);
@ -186,7 +187,7 @@ export const PluginDetailDialog = ({
<SelectItem <SelectItem
key={opt.value} key={opt.value}
value={opt.value} value={opt.value}
disabled={opt.value === 'project' && !projectScopeAvailable} disabled={opt.value !== 'user' && !projectScopeAvailable}
> >
{opt.label} {opt.label}
</SelectItem> </SelectItem>
@ -201,7 +202,7 @@ export const PluginDetailDialog = ({
installPlugin({ installPlugin({
pluginId: plugin.pluginId, pluginId: plugin.pluginId,
scope, scope,
...(scope === 'project' && pluginCatalogProjectPath ...(scope !== 'user' && pluginCatalogProjectPath
? { projectPath: pluginCatalogProjectPath } ? { projectPath: pluginCatalogProjectPath }
: {}), : {}),
}) })
@ -210,7 +211,7 @@ export const PluginDetailDialog = ({
uninstallPlugin( uninstallPlugin(
plugin.pluginId, plugin.pluginId,
scope, scope,
scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined
) )
} }
size="default" size="default"

View file

@ -225,7 +225,7 @@ const CLI_HEALTHCHECK_FAILED_MESSAGE =
const CLI_STATUS_UNKNOWN_MESSAGE = const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
const PROJECT_SCOPE_REQUIRED_MESSAGE = 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<AppState, [], [], ExtensionsSlice> = ( export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set, set,
@ -688,7 +688,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
if (!api.plugins) return; if (!api.plugins) return;
const effectiveProjectPath = const effectiveProjectPath =
request.scope === 'project' request.scope !== 'user'
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined) ? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: request.projectPath; : request.projectPath;
const effectiveRequest = const effectiveRequest =
@ -707,7 +707,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const cliStatus = get().cliStatus; const cliStatus = get().cliStatus;
const preflightError = const preflightError =
effectiveRequest.scope === 'project' && !effectiveRequest.projectPath effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
? PROJECT_SCOPE_REQUIRED_MESSAGE ? PROJECT_SCOPE_REQUIRED_MESSAGE
: cliStatus === null : cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE ? CLI_STATUS_UNKNOWN_MESSAGE
@ -770,10 +770,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
if (!api.plugins) return; if (!api.plugins) return;
const effectiveProjectPath = const effectiveProjectPath =
scope === 'project' scope && scope !== 'user'
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: projectPath; : projectPath;
if (scope === 'project' && !effectiveProjectPath) { if (scope && scope !== 'user' && !effectiveProjectPath) {
clearPluginSuccessResetTimer(pluginId); clearPluginSuccessResetTimer(pluginId);
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },

View file

@ -70,7 +70,7 @@ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] {
export interface PluginInstallRequest { export interface PluginInstallRequest {
pluginId: string; // canonical key — main resolves qualifiedName from catalog pluginId: string; // canonical key — main resolves qualifiedName from catalog
scope: InstallScope; scope: InstallScope;
projectPath?: string; // required for 'project' scope projectPath?: string; // required for repo-scoped installs ('project' or 'local')
} }
// ── Filters (renderer-only concern) ──────────────────────────────────────── // ── Filters (renderer-only concern) ────────────────────────────────────────

View file

@ -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 () => { it('returns error if plugin not found in catalog', async () => {
catalog = createMockCatalog({ catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
@ -129,6 +145,14 @@ describe('PluginInstallService', () => {
expect(result.error).toContain('projectPath is required'); expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled(); 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 ─────────────────────────────────────────────────────────────── // ── 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 () => { it('returns error if plugin not in catalog', async () => {
catalog = createMockCatalog({ catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
@ -187,5 +223,13 @@ describe('PluginInstallService', () => {
expect(result.error).toContain('projectPath is required'); expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled(); 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();
});
}); });
}); });

View file

@ -492,6 +492,32 @@ describe('extensionsSlice', () => {
expect(store.getState().installErrors['project@m']).toContain('active project'); 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<typeof vi.fn>).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 () => { it('clears older success reset timers before a new operation on the same plugin', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
store.setState({ cliStatus: makeReadyCliStatus() }); store.setState({ cliStatus: makeReadyCliStatus() });
@ -545,6 +571,25 @@ describe('extensionsSlice', () => {
expect(store.getState().installErrors['project@m']).toContain('active project'); 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<typeof vi.fn>).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 () => { it('does not restore idle state after project switch clears a pending success timer', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
store.setState({ store.setState({