/** * Extensions slice — global catalog caches shared across all Extensions tabs. * Per-tab UI state lives in useExtensionsTabState() hook, NOT here. */ import { api } from '@renderer/api'; import type { AppState } from '../types'; import type { ApiKeyEntry, ApiKeySaveRequest, ApiKeyStorageStatus, EnrichedPlugin, ExtensionOperationState, InstallScope, InstalledMcpEntry, McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, McpServerDiagnostic, PluginInstallRequest, SkillCatalogItem, SkillDeleteRequest, SkillDetail, SkillImportRequest, SkillReviewPreview, SkillUpsertRequest, } from '@shared/types/extensions'; import type { StateCreator } from 'zustand'; // ============================================================================= // Slice Interface // ============================================================================= export interface ExtensionsSlice { // ── Plugin catalog cache ── pluginCatalog: EnrichedPlugin[]; pluginCatalogLoading: boolean; pluginCatalogError: string | null; pluginCatalogProjectPath: string | null; pluginReadmes: Record; pluginReadmeLoading: Record; // ── MCP catalog cache ── mcpBrowseCatalog: McpCatalogItem[]; mcpBrowseNextCursor?: string; mcpBrowseLoading: boolean; mcpBrowseError: string | null; mcpInstalledServers: InstalledMcpEntry[]; mcpInstalledProjectPath: string | null; mcpDiagnostics: Record; mcpDiagnosticsLoading: boolean; mcpDiagnosticsError: string | null; mcpDiagnosticsLastCheckedAt: number | null; // ── Install progress ── pluginInstallProgress: Record; mcpInstallProgress: Record; installErrors: Record; // keyed by pluginId or registryId // ── API Keys ── apiKeys: ApiKeyEntry[]; apiKeysLoading: boolean; apiKeysError: string | null; apiKeySaving: boolean; apiKeyStorageStatus: ApiKeyStorageStatus | null; // ── Skills catalog cache ── skillsUserCatalog: SkillCatalogItem[]; skillsProjectCatalogByProjectPath: Record; skillsLoading: boolean; skillsError: string | null; skillsDetailsById: Record; skillsDetailLoadingById: Record; skillsMutationLoading: boolean; skillsMutationError: string | null; // ── GitHub Stars (supplementary) ── mcpGitHubStars: Record; // ── Read actions ── fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise; fetchPluginReadme: (pluginId: string) => void; mcpBrowse: (cursor?: string) => Promise; mcpFetchInstalled: (projectPath?: string) => Promise; runMcpDiagnostics: () => Promise; fetchSkillsCatalog: (projectPath?: string) => Promise; fetchSkillDetail: (skillId: string, projectPath?: string) => Promise; previewSkillUpsert: (request: SkillUpsertRequest) => Promise; applySkillUpsert: (request: SkillUpsertRequest) => Promise; previewSkillImport: (request: SkillImportRequest) => Promise; applySkillImport: (request: SkillImportRequest) => Promise; deleteSkill: (request: SkillDeleteRequest) => Promise; // ── Mutation actions ── installPlugin: (request: PluginInstallRequest) => Promise; uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise; installMcpServer: (request: McpInstallRequest) => Promise; installCustomMcpServer: (request: McpCustomInstallRequest) => Promise; uninstallMcpServer: ( registryId: string, name: string, scope?: string, projectPath?: string ) => Promise; // ── API Keys actions ── fetchApiKeys: () => Promise; fetchApiKeyStorageStatus: () => Promise; saveApiKey: (request: ApiKeySaveRequest) => Promise; deleteApiKey: (id: string) => Promise; // ── Tab opener ── openExtensionsTab: () => void; // ── GitHub Stars ── fetchMcpGitHubStars: (repositoryUrls: string[]) => void; } // ============================================================================= // Slice Creator // ============================================================================= let pluginFetchInFlight: Promise | null = null; let mcpDiagnosticsInFlight: Promise | null = null; /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; export const createExtensionsSlice: StateCreator = ( set, get ) => ({ // ── Initial state ── pluginCatalog: [], pluginCatalogLoading: false, pluginCatalogError: null, pluginCatalogProjectPath: null, pluginReadmes: {}, pluginReadmeLoading: {}, mcpBrowseCatalog: [], mcpBrowseNextCursor: undefined, mcpBrowseLoading: false, mcpBrowseError: null, mcpInstalledServers: [], mcpInstalledProjectPath: null, mcpDiagnostics: {}, mcpDiagnosticsLoading: false, mcpDiagnosticsError: null, mcpDiagnosticsLastCheckedAt: null, pluginInstallProgress: {}, mcpInstallProgress: {}, installErrors: {}, apiKeys: [], apiKeysLoading: false, apiKeysError: null, apiKeySaving: false, apiKeyStorageStatus: null, skillsUserCatalog: [], skillsProjectCatalogByProjectPath: {}, skillsLoading: false, skillsError: null, skillsDetailsById: {}, skillsDetailLoadingById: {}, skillsMutationLoading: false, skillsMutationError: null, mcpGitHubStars: {}, // ── Plugin catalog fetch ── fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => { if (!api.plugins) return; // Dedup concurrent requests if (pluginFetchInFlight && !forceRefresh) { await pluginFetchInFlight; return; } set({ pluginCatalogLoading: true, pluginCatalogError: null }); const promise = (async () => { try { const result = await api.plugins!.getAll(projectPath, forceRefresh); set({ pluginCatalog: result, pluginCatalogLoading: false, pluginCatalogProjectPath: projectPath ?? null, }); } catch (err) { set({ pluginCatalogLoading: false, pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', }); } finally { pluginFetchInFlight = null; } })(); pluginFetchInFlight = promise; await promise; }, // ── Plugin README fetch ── fetchPluginReadme: (pluginId: string) => { if (!api.plugins) return; const state = get(); if (pluginId in state.pluginReadmes || state.pluginReadmeLoading[pluginId]) return; set((prev) => ({ pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true }, })); void api.plugins.getReadme(pluginId).then( (readme) => { set((prev) => ({ pluginReadmes: { ...prev.pluginReadmes, [pluginId]: readme }, pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: false }, })); }, () => { set((prev) => ({ pluginReadmes: { ...prev.pluginReadmes, [pluginId]: null }, pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: false }, })); } ); }, // ── MCP browse ── mcpBrowse: async (cursor?: string) => { if (!api.mcpRegistry) return; set({ mcpBrowseLoading: true, mcpBrowseError: null }); try { const result = await api.mcpRegistry.browse(cursor); set((prev) => { if (!cursor) { return { mcpBrowseCatalog: result.servers, mcpBrowseNextCursor: result.nextCursor, mcpBrowseLoading: false, }; } // Deduplicate: existing IDs take precedence const existingIds = new Set(prev.mcpBrowseCatalog.map((s) => s.id)); const newServers = result.servers.filter((s) => !existingIds.has(s.id)); return { mcpBrowseCatalog: [...prev.mcpBrowseCatalog, ...newServers], mcpBrowseNextCursor: result.nextCursor, mcpBrowseLoading: false, }; }); } catch (err) { set({ mcpBrowseLoading: false, mcpBrowseError: err instanceof Error ? err.message : 'Failed to browse MCP servers', }); } }, // ── MCP installed fetch ── mcpFetchInstalled: async (projectPath?: string) => { if (!api.mcpRegistry) return; try { const installed = await api.mcpRegistry.getInstalled(projectPath); set({ mcpInstalledServers: installed, mcpInstalledProjectPath: projectPath ?? null, }); } catch { // Silently fail — installed state is supplementary } }, runMcpDiagnostics: async () => { const mcpRegistry = api.mcpRegistry; if (!mcpRegistry) return; if (mcpDiagnosticsInFlight) { await mcpDiagnosticsInFlight; return; } set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null }); const promise = (async () => { try { const diagnostics = await mcpRegistry.diagnose(); set({ mcpDiagnostics: Object.fromEntries( diagnostics.map((entry) => [entry.name, entry] as const) ), mcpDiagnosticsLoading: false, mcpDiagnosticsLastCheckedAt: Date.now(), }); } catch (err) { set({ mcpDiagnosticsLoading: false, mcpDiagnosticsError: err instanceof Error ? err.message : 'Failed to check MCP server health', }); } finally { mcpDiagnosticsInFlight = null; } })(); mcpDiagnosticsInFlight = promise; await promise; }, fetchSkillsCatalog: async (projectPath?: string) => { if (!api.skills) return; set({ skillsLoading: true, skillsError: null }); try { const skills = await api.skills.list(projectPath); set((prev) => ({ skillsLoading: false, skillsError: null, skillsUserCatalog: skills.filter((skill) => skill.scope === 'user'), skillsProjectCatalogByProjectPath: projectPath ? { ...prev.skillsProjectCatalogByProjectPath, [projectPath]: skills.filter((skill) => skill.scope === 'project'), } : prev.skillsProjectCatalogByProjectPath, })); } catch (err) { set({ skillsLoading: false, skillsError: err instanceof Error ? err.message : 'Failed to load skills', }); } }, fetchSkillDetail: async (skillId: string, projectPath?: string) => { if (!api.skills) return; set((prev) => ({ skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: true }, })); try { const detail = await api.skills.getDetail(skillId, projectPath); set((prev) => ({ skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: detail }, skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false }, })); } catch { set((prev) => ({ skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: null }, skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false }, })); } }, previewSkillUpsert: async (request: SkillUpsertRequest) => { if (!api.skills) { throw new Error('Skills API is not available'); } set({ skillsMutationLoading: true, skillsMutationError: null }); try { const preview = await api.skills.previewUpsert(request); set({ skillsMutationLoading: false }); return preview; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to review skill changes'; set({ skillsMutationLoading: false, skillsMutationError: message }); throw err; } }, applySkillUpsert: async (request: SkillUpsertRequest) => { if (!api.skills) { throw new Error('Skills API is not available'); } set({ skillsMutationLoading: true, skillsMutationError: null }); try { const detail = await api.skills.applyUpsert(request); await get().fetchSkillsCatalog(request.projectPath); set((prev) => ({ skillsMutationLoading: false, skillsDetailsById: detail?.item.id ? { ...prev.skillsDetailsById, [detail.item.id]: detail } : prev.skillsDetailsById, })); return detail; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to save skill'; set({ skillsMutationLoading: false, skillsMutationError: message }); throw err; } }, previewSkillImport: async (request: SkillImportRequest) => { if (!api.skills) { throw new Error('Skills API is not available'); } set({ skillsMutationLoading: true, skillsMutationError: null }); try { const preview = await api.skills.previewImport(request); set({ skillsMutationLoading: false }); return preview; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to review import changes'; set({ skillsMutationLoading: false, skillsMutationError: message }); throw err; } }, applySkillImport: async (request: SkillImportRequest) => { if (!api.skills) { throw new Error('Skills API is not available'); } set({ skillsMutationLoading: true, skillsMutationError: null }); try { const detail = await api.skills.applyImport(request); await get().fetchSkillsCatalog(request.projectPath); set((prev) => ({ skillsMutationLoading: false, skillsDetailsById: detail?.item.id ? { ...prev.skillsDetailsById, [detail.item.id]: detail } : prev.skillsDetailsById, })); return detail; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to import skill'; set({ skillsMutationLoading: false, skillsMutationError: message }); throw err; } }, deleteSkill: async (request: SkillDeleteRequest) => { if (!api.skills) { throw new Error('Skills API is not available'); } set({ skillsMutationLoading: true, skillsMutationError: null }); try { await api.skills.deleteSkill(request); await get().fetchSkillsCatalog(request.projectPath); set((prev) => { const nextDetails = { ...prev.skillsDetailsById }; delete nextDetails[request.skillId]; return { skillsMutationLoading: false, skillsDetailsById: nextDetails, }; }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to delete skill'; set({ skillsMutationLoading: false, skillsMutationError: message }); throw err; } }, // ── Plugin install ── installPlugin: async (request: PluginInstallRequest) => { if (!api.plugins) return; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, })); try { const result = await api.plugins.install(request); if (result.state === 'error') { set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, installErrors: { ...prev.installErrors, [request.pluginId]: result.error ?? 'Install failed', }, })); return; } set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' }, })); // Refresh catalog to pick up new installed state void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); // Return to idle after brief success display setTimeout(() => { set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' }, })); }, SUCCESS_DISPLAY_MS); } catch (err) { const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, installErrors: { ...prev.installErrors, [request.pluginId]: message }, })); } }, // ── Plugin uninstall ── uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => { if (!api.plugins) return; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, })); try { const result = await api.plugins.uninstall(pluginId, scope, projectPath); if (result.state === 'error') { set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' }, })); return; } set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' }, })); // Refresh catalog void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); setTimeout(() => { set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, })); }, SUCCESS_DISPLAY_MS); } catch (err) { const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, installErrors: { ...prev.installErrors, [pluginId]: message }, })); } }, // ── MCP install ── installMcpServer: async (request: McpInstallRequest) => { if (!api.mcpRegistry) { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, installErrors: { ...prev.installErrors, [request.registryId]: 'MCP Registry not available', }, })); return; } set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' }, })); try { const result = await api.mcpRegistry.install(request); if (result.state === 'error') { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, installErrors: { ...prev.installErrors, [request.registryId]: result.error ?? 'Install failed', }, })); return; } await Promise.all([ get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined), get().runMcpDiagnostics(), ]); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' }, })); setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' }, })); }, SUCCESS_DISPLAY_MS); } catch (err) { const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, installErrors: { ...prev.installErrors, [request.registryId]: message }, })); } }, // ── MCP custom install ── installCustomMcpServer: async (request: McpCustomInstallRequest) => { if (!api.mcpRegistry) { const progressKey = `custom:${request.serverName}`; set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, })); return; } const progressKey = `custom:${request.serverName}`; set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, })); try { const result = await api.mcpRegistry.installCustom(request); if (result.state === 'error') { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' }, })); return; } await Promise.all([ get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined), get().runMcpDiagnostics(), ]); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' }, })); setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' }, })); }, SUCCESS_DISPLAY_MS); } catch (err) { const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, installErrors: { ...prev.installErrors, [progressKey]: message }, })); } }, // ── MCP uninstall ── uninstallMcpServer: async ( registryId: string, name: string, scope?: string, projectPath?: string ) => { if (!api.mcpRegistry) { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' }, })); return; } set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' }, })); try { const result = await api.mcpRegistry.uninstall(name, scope, projectPath); if (result.state === 'error') { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, installErrors: { ...prev.installErrors, [registryId]: result.error ?? 'Uninstall failed', }, })); return; } await Promise.all([ get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined), get().runMcpDiagnostics(), ]); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' }, })); setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' }, })); }, SUCCESS_DISPLAY_MS); } catch (err) { const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, installErrors: { ...prev.installErrors, [registryId]: message }, })); } }, // ── API Keys fetch ── fetchApiKeys: async () => { if (!api.apiKeys) return; set({ apiKeysLoading: true, apiKeysError: null }); try { const keys = await api.apiKeys.list(); set({ apiKeys: keys, apiKeysLoading: false }); } catch (err) { set({ apiKeysLoading: false, apiKeysError: err instanceof Error ? err.message : 'Failed to load API keys', }); } }, fetchApiKeyStorageStatus: async () => { if (!api.apiKeys) return; try { const status = await api.apiKeys.getStorageStatus(); set({ apiKeyStorageStatus: status }); } catch { // Non-critical — UI will just not show the info icon } }, // ── API Key save ── saveApiKey: async (request: ApiKeySaveRequest) => { if (!api.apiKeys) return; set({ apiKeySaving: true, apiKeysError: null }); try { await api.apiKeys.save(request); // Refresh the list to get updated masked values const keys = await api.apiKeys.list(); set({ apiKeys: keys, apiKeySaving: false }); } catch (err) { set({ apiKeySaving: false, apiKeysError: err instanceof Error ? err.message : 'Failed to save API key', }); throw err; // Re-throw so the dialog can show the error } }, // ── API Key delete ── deleteApiKey: async (id: string) => { if (!api.apiKeys) return; try { await api.apiKeys.delete(id); set((prev) => ({ apiKeys: prev.apiKeys.filter((k) => k.id !== id), })); } catch (err) { set({ apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key', }); } }, // ── Tab opener ── openExtensionsTab: () => { const state = get(); const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId); const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions'); if (existingTab) { state.setActiveTab(existingTab.id); return; } state.openTab({ type: 'extensions', label: 'Extensions', projectId: state.selectedProjectId ?? state.activeProjectId ?? undefined, }); }, // ── GitHub Stars (fire-and-forget) ── fetchMcpGitHubStars: (repositoryUrls: string[]) => { if (!api.mcpRegistry || repositoryUrls.length === 0) return; void api.mcpRegistry .githubStars(repositoryUrls) .then((stars) => { if (Object.keys(stars).length > 0) { set((prev) => ({ mcpGitHubStars: { ...prev.mcpGitHubStars, ...stars }, })); } }) .catch(() => { // Silent failure — stars are supplementary data }); }, });