fix(extensions): scope plugin install state to active project

This commit is contained in:
777genius 2026-04-16 21:55:50 +03:00
parent 58644b24c6
commit f2c5d52bdc
6 changed files with 432 additions and 104 deletions

View file

@ -48,6 +48,13 @@ export class PluginInstallService {
};
}
if (scope === 'project' && !projectPath) {
return {
state: 'error',
error: 'projectPath is required for project-scoped plugin installs',
};
}
// 3. Resolve qualifiedName from catalog (NOT from renderer)
const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) {
@ -123,6 +130,13 @@ export class PluginInstallService {
};
}
if (scope === 'project' && !projectPath) {
return {
state: 'error',
error: 'projectPath is required for project-scoped plugin uninstalls',
};
}
// Resolve qualifiedName from catalog
const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) {

View file

@ -60,23 +60,26 @@ interface TimedCache<T> {
// ── Service ────────────────────────────────────────────────────────────────
export class PluginInstallationStateService {
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null;
private installedCache = new Map<string, TimedCache<InstalledPluginEntry[]>>();
private countsCache: TimedCache<Map<string, number>> | null = null;
/**
* Get all installed plugins across all scopes.
* Returns merged list from installed_plugins.json with scope tags.
* Get installed plugins relevant to the active context.
* Always includes user scope. Project/local entries are only included when
* they are enabled for the active project.
*/
async getInstalledPlugins(_projectPath?: string): Promise<InstalledPluginEntry[]> {
if (
this.installedCache &&
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS
) {
return this.installedCache.data;
async getInstalledPlugins(projectPath?: string): Promise<InstalledPluginEntry[]> {
const normalizedProjectPath =
typeof projectPath === 'string' && path.isAbsolute(projectPath) ? projectPath : undefined;
const cacheKey = this.getInstalledCacheKey(normalizedProjectPath);
const cached = this.installedCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < INSTALLED_STATE_TTL_MS) {
return cached.data;
}
const entries = await this.readInstalledPlugins();
this.installedCache = { data: entries, fetchedAt: Date.now() };
const entries = await this.buildInstalledEntriesForContext(normalizedProjectPath);
this.installedCache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
return entries;
}
@ -97,7 +100,7 @@ export class PluginInstallationStateService {
* Invalidate all caches. Call after install/uninstall operations.
*/
invalidateCache(): void {
this.installedCache = null;
this.installedCache.clear();
this.countsCache = null;
}
@ -107,7 +110,81 @@ export class PluginInstallationStateService {
return path.join(getClaudeBasePath(), 'plugins');
}
private async readInstalledPlugins(): Promise<InstalledPluginEntry[]> {
private getInstalledCacheKey(projectPath?: string): string {
return projectPath ?? '__user__';
}
private async buildInstalledEntriesForContext(
projectPath?: string
): Promise<InstalledPluginEntry[]> {
const installedMetadata = await this.readInstalledPluginMetadata();
const metadataByKey = new Map<string, InstalledPluginEntry[]>();
for (const entry of installedMetadata) {
const key = this.getPluginScopeKey(entry.pluginId, entry.scope);
const matches = metadataByKey.get(key) ?? [];
matches.push(entry);
metadataByKey.set(key, matches);
}
const userEnabled = await this.readEnabledPlugins(
path.join(getClaudeBasePath(), 'settings.json')
);
const projectEnabled = projectPath
? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.json'))
: new Set<string>();
const localEnabled = projectPath
? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.local.json'))
: new Set<string>();
return [
...this.buildScopedEntries('user', userEnabled, metadataByKey),
...this.buildScopedEntries('project', projectEnabled, metadataByKey),
...this.buildScopedEntries('local', localEnabled, metadataByKey),
];
}
private buildScopedEntries(
scope: InstallScope,
enabledPlugins: Set<string>,
metadataByKey: Map<string, InstalledPluginEntry[]>
): InstalledPluginEntry[] {
return Array.from(enabledPlugins).map((pluginId) => {
const key = this.getPluginScopeKey(pluginId, scope);
const bestMatch = this.pickBestInstallationEntry(metadataByKey.get(key) ?? []);
return bestMatch
? {
...bestMatch,
pluginId,
scope,
}
: {
pluginId,
scope,
};
});
}
private getPluginScopeKey(pluginId: string, scope: InstallScope): string {
return `${pluginId}::${scope}`;
}
private pickBestInstallationEntry(entries: InstalledPluginEntry[]): InstalledPluginEntry | null {
if (entries.length === 0) {
return null;
}
return [...entries].sort((left, right) => {
const leftInstalledAt = Date.parse(left.installedAt ?? '');
const rightInstalledAt = Date.parse(right.installedAt ?? '');
const normalizedLeft = Number.isFinite(leftInstalledAt) ? leftInstalledAt : 0;
const normalizedRight = Number.isFinite(rightInstalledAt) ? rightInstalledAt : 0;
return normalizedRight - normalizedLeft;
})[0];
}
private async readInstalledPluginMetadata(): Promise<InstalledPluginEntry[]> {
const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
try {
@ -143,6 +220,31 @@ export class PluginInstallationStateService {
}
}
private async readEnabledPlugins(filePath: string): Promise<Set<string>> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as {
enabledPlugins?: Record<string, boolean> | null;
};
if (!json.enabledPlugins || typeof json.enabledPlugins !== 'object') {
return new Set<string>();
}
return new Set(
Object.entries(json.enabledPlugins)
.filter(([, enabled]) => enabled === true)
.map(([pluginId]) => pluginId)
);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return new Set<string>();
}
logger.error(`Failed to read plugin settings from ${filePath}:`, err);
return new Set<string>();
}
}
private async readInstallCounts(): Promise<Map<string, number>> {
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');

View file

@ -152,6 +152,8 @@ const CLI_HEALTHCHECK_FAILED_MESSAGE =
'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.';
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.';
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set,
@ -561,6 +563,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return;
const effectiveProjectPath =
request.scope === 'project'
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: request.projectPath;
const effectiveRequest =
effectiveProjectPath === request.projectPath
? request
: { ...request, projectPath: effectiveProjectPath };
const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try {
@ -572,15 +583,17 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const cliStatus = get().cliStatus;
const preflightError =
cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
effectiveRequest.scope === 'project' && !effectiveRequest.projectPath
? PROJECT_SCOPE_REQUIRED_MESSAGE
: cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
if (preflightError) {
set((prev) => ({
@ -596,7 +609,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}));
try {
const result = await api.plugins.install(request);
const result = await api.plugins.install(effectiveRequest);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
@ -634,12 +647,24 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return;
const effectiveProjectPath =
scope === 'project'
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: projectPath;
if (scope === 'project' && !effectiveProjectPath) {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
}));
try {
const result = await api.plugins.uninstall(pluginId, scope, projectPath);
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },

View file

@ -121,6 +121,14 @@ describe('PluginInstallService', () => {
expect(result.state).toBe('error');
expect(result.error).toContain('Command failed');
});
it('rejects project scope when projectPath is missing', async () => {
const result = await service.install({ pluginId: 'context7', scope: 'project' });
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
});
// ── uninstall ───────────────────────────────────────────────────────────────
@ -171,5 +179,13 @@ describe('PluginInstallService', () => {
expect(result.state).toBe('error');
expect(result.error).toContain('Cannot uninstall');
});
it('rejects project scope when projectPath is missing', async () => {
const result = await service.uninstall('context7', 'project');
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
});
});

View file

@ -25,47 +25,150 @@ describe('PluginInstallationStateService', () => {
});
describe('getInstalledPlugins', () => {
it('parses installed_plugins.json version 2 format', async () => {
const installedData = {
version: 2,
plugins: {
'context7@claude-plugins-official': [
{
scope: 'user',
installPath: '/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0',
version: '1.0.0',
installedAt: '2026-03-01T11:14:21.926Z',
it('returns user-scoped plugins enabled in user settings', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({
version: 2,
plugins: {
'context7@claude-plugins-official': [
{
scope: 'user',
installPath:
'/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0',
version: '1.0.0',
installedAt: '2026-03-01T11:14:21.926Z',
},
],
'typescript-lsp@claude-plugins-official': [
{
scope: 'project',
version: '1.0.0',
installedAt: '2026-03-03T10:00:00.000Z',
},
],
},
],
'typescript-lsp@claude-plugins-official': [
{
scope: 'user',
version: '1.0.0',
installedAt: '2026-03-02T10:00:00.000Z',
},
{
scope: 'project',
version: '1.0.0',
installedAt: '2026-03-03T10:00:00.000Z',
},
],
},
};
});
}
mockedFs.readFile.mockResolvedValue(JSON.stringify(installedData));
if (normalizedPath === '/tmp/mock-claude/settings.json') {
return JSON.stringify({
enabledPlugins: {
'context7@claude-plugins-official': true,
},
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalledPlugins();
expect(entries).toHaveLength(3);
expect(entries[0].pluginId).toBe('context7@claude-plugins-official');
expect(entries[0].scope).toBe('user');
expect(entries[0].version).toBe('1.0.0');
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
pluginId: 'context7@claude-plugins-official',
scope: 'user',
version: '1.0.0',
});
});
expect(entries[1].pluginId).toBe('typescript-lsp@claude-plugins-official');
expect(entries[1].scope).toBe('user');
it('includes project and local scopes only for the active project', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({
version: 2,
plugins: {
'context7@claude-plugins-official': [
{
scope: 'user',
version: '1.0.0',
installedAt: '2026-03-01T11:14:21.926Z',
},
],
'typescript-lsp@claude-plugins-official': [
{
scope: 'project',
version: '1.1.0',
installedAt: '2026-03-03T10:00:00.000Z',
},
],
'formatter@claude-plugins-official': [
{
scope: 'local',
version: '2.0.0',
installedAt: '2026-03-04T10:00:00.000Z',
},
],
},
});
}
expect(entries[2].pluginId).toBe('typescript-lsp@claude-plugins-official');
expect(entries[2].scope).toBe('project');
if (normalizedPath === '/tmp/mock-claude/settings.json') {
return JSON.stringify({
enabledPlugins: {
'context7@claude-plugins-official': true,
},
});
}
if (normalizedPath === '/tmp/project-a/.claude/settings.json') {
return JSON.stringify({
enabledPlugins: {
'typescript-lsp@claude-plugins-official': true,
},
});
}
if (normalizedPath === '/tmp/project-a/.claude/settings.local.json') {
return JSON.stringify({
enabledPlugins: {
'formatter@claude-plugins-official': true,
},
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalledPlugins('/tmp/project-a');
expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([
['context7@claude-plugins-official', 'user'],
['typescript-lsp@claude-plugins-official', 'project'],
['formatter@claude-plugins-official', 'local'],
]);
});
it('does not leak another project scope into the current project', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({
version: 2,
plugins: {
'typescript-lsp@claude-plugins-official': [
{
scope: 'project',
version: '1.1.0',
installedAt: '2026-03-03T10:00:00.000Z',
},
],
},
});
}
if (normalizedPath.endsWith('/settings.json')) {
return JSON.stringify({ enabledPlugins: {} });
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalledPlugins('/tmp/project-b');
expect(entries).toEqual([]);
});
it('returns empty array when file does not exist', async () => {
@ -78,22 +181,55 @@ describe('PluginInstallationStateService', () => {
});
it('returns empty array for unexpected version', async () => {
mockedFs.readFile.mockResolvedValue(JSON.stringify({ version: 1, plugins: {} }));
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({ version: 1, plugins: {} });
}
if (normalizedPath.endsWith('/settings.json')) {
return JSON.stringify({ enabledPlugins: {} });
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalledPlugins();
expect(entries).toEqual([]);
});
it('caches within TTL', async () => {
mockedFs.readFile.mockResolvedValue(
JSON.stringify({ version: 2, plugins: {} }),
);
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({ version: 2, plugins: {} });
}
if (normalizedPath.endsWith('/settings.json')) {
return JSON.stringify({ enabledPlugins: {} });
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
await service.getInstalledPlugins();
await service.getInstalledPlugins();
// Only one read
expect(mockedFs.readFile).toHaveBeenCalledTimes(1);
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
});
it('caches results independently per project path', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({ version: 2, plugins: {} });
}
if (normalizedPath.endsWith('/settings.json')) {
return JSON.stringify({ enabledPlugins: {} });
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
await service.getInstalledPlugins('/tmp/project-a');
await service.getInstalledPlugins('/tmp/project-b');
expect(mockedFs.readFile).toHaveBeenCalledTimes(8);
});
});
@ -140,15 +276,22 @@ describe('PluginInstallationStateService', () => {
describe('invalidateCache', () => {
it('forces re-read after invalidation', async () => {
mockedFs.readFile.mockResolvedValue(
JSON.stringify({ version: 2, plugins: {} }),
);
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({ version: 2, plugins: {} });
}
if (normalizedPath.endsWith('/settings.json')) {
return JSON.stringify({ enabledPlugins: {} });
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
await service.getInstalledPlugins();
service.invalidateCache();
await service.getInstalledPlugins();
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
});
});
});

View file

@ -116,6 +116,23 @@ const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): SkillDetail => (
...overrides,
});
const makeReadyCliStatus = () => ({
flavor: 'claude' as const,
displayName: 'Claude',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
installed: true,
installedVersion: '1.0.0',
binaryPath: '/usr/local/bin/claude',
latestVersion: '1.0.0',
updateAvailable: false,
authLoggedIn: true,
authStatusChecking: false,
authMethod: 'oauth_token' as const,
providers: [],
});
describe('extensionsSlice', () => {
let store: TestStore;
@ -298,24 +315,7 @@ describe('extensionsSlice', () => {
describe('installPlugin', () => {
it('sets progress to pending then success', async () => {
store.setState({
cliStatus: {
flavor: 'claude',
displayName: 'Claude',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
installed: true,
installedVersion: '1.0.0',
binaryPath: '/usr/local/bin/claude',
latestVersion: '1.0.0',
updateAvailable: false,
authLoggedIn: true,
authStatusChecking: false,
authMethod: 'oauth_token',
providers: [],
},
});
store.setState({ cliStatus: makeReadyCliStatus() });
const plugins = [makePlugin({ pluginId: 'a@m' })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
@ -330,24 +330,7 @@ describe('extensionsSlice', () => {
});
it('sets progress to error on failure', async () => {
store.setState({
cliStatus: {
flavor: 'claude',
displayName: 'Claude',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
installed: true,
installedVersion: '1.0.0',
binaryPath: '/usr/local/bin/claude',
latestVersion: '1.0.0',
updateAvailable: false,
authLoggedIn: true,
authStatusChecking: false,
authMethod: 'oauth_token',
providers: [],
},
});
store.setState({ cliStatus: makeReadyCliStatus() });
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
state: 'error',
error: 'Not found',
@ -357,6 +340,32 @@ describe('extensionsSlice', () => {
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error');
});
it('fills missing projectPath 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: 'project@m', scope: 'project' });
expect(api.plugins!.install).toHaveBeenCalledWith({
pluginId: 'project@m',
scope: 'project',
projectPath: '/tmp/project-a',
});
});
it('fails fast for project scope when there is no active project path', async () => {
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
expect(api.plugins!.install).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
expect(store.getState().installErrors['project@m']).toContain('active project');
});
});
describe('uninstallPlugin', () => {
@ -372,6 +381,25 @@ describe('extensionsSlice', () => {
await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
});
it('fills missing projectPath 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('project@m', 'project');
expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a');
});
it('fails fast for project uninstall when there is no active project path', async () => {
store.setState({ pluginCatalogProjectPath: null });
await store.getState().uninstallPlugin('project@m', 'project');
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
expect(store.getState().installErrors['project@m']).toContain('active project');
});
});
describe('installMcpServer', () => {