agent-ecosystem/src/renderer/store/slices/extensionsSlice.ts
iliya 4b4dccd13d feat: add skills management features and integrate skills API
- Introduced skills catalog management with functionalities to list, get details, preview, and apply skill changes.
- Implemented IPC handlers for skills-related actions, enhancing communication between renderer and main processes.
- Updated the UI to include a dedicated Skills panel within the extension store, improving user access to skills management.
- Added new constants and types for skills API integration, ensuring a structured approach to skills handling.
- Enhanced state management to support skills loading, error handling, and detail fetching.
2026-03-11 21:46:56 +02:00

800 lines
25 KiB
TypeScript

/**
* 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<string, string | null>;
pluginReadmeLoading: Record<string, boolean>;
// ── MCP catalog cache ──
mcpBrowseCatalog: McpCatalogItem[];
mcpBrowseNextCursor?: string;
mcpBrowseLoading: boolean;
mcpBrowseError: string | null;
mcpInstalledServers: InstalledMcpEntry[];
mcpInstalledProjectPath: string | null;
mcpDiagnostics: Record<string, McpServerDiagnostic>;
mcpDiagnosticsLoading: boolean;
mcpDiagnosticsError: string | null;
mcpDiagnosticsLastCheckedAt: number | null;
// ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>;
mcpInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>; // 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<string, SkillCatalogItem[]>;
skillsLoading: boolean;
skillsError: string | null;
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
skillsDetailLoadingById: Record<string, boolean>;
skillsMutationLoading: boolean;
skillsMutationError: string | null;
// ── GitHub Stars (supplementary) ──
mcpGitHubStars: Record<string, number>;
// ── Read actions ──
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
fetchPluginReadme: (pluginId: string) => void;
mcpBrowse: (cursor?: string) => Promise<void>;
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
runMcpDiagnostics: () => Promise<void>;
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
applySkillUpsert: (request: SkillUpsertRequest) => Promise<SkillDetail | null>;
previewSkillImport: (request: SkillImportRequest) => Promise<SkillReviewPreview>;
applySkillImport: (request: SkillImportRequest) => Promise<SkillDetail | null>;
deleteSkill: (request: SkillDeleteRequest) => Promise<void>;
// ── Mutation actions ──
installPlugin: (request: PluginInstallRequest) => Promise<void>;
uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise<void>;
installMcpServer: (request: McpInstallRequest) => Promise<void>;
installCustomMcpServer: (request: McpCustomInstallRequest) => Promise<void>;
uninstallMcpServer: (
registryId: string,
name: string,
scope?: string,
projectPath?: string
) => Promise<void>;
// ── API Keys actions ──
fetchApiKeys: () => Promise<void>;
fetchApiKeyStorageStatus: () => Promise<void>;
saveApiKey: (request: ApiKeySaveRequest) => Promise<void>;
deleteApiKey: (id: string) => Promise<void>;
// ── Tab opener ──
openExtensionsTab: () => void;
// ── GitHub Stars ──
fetchMcpGitHubStars: (repositoryUrls: string[]) => void;
}
// =============================================================================
// Slice Creator
// =============================================================================
let pluginFetchInFlight: Promise<void> | null = null;
let mcpDiagnosticsInFlight: Promise<void> | null = null;
/** Duration to show "success" state before returning to idle */
const SUCCESS_DISPLAY_MS = 2_000;
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
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
});
},
});