diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index f3f91d74..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,6 +52,13 @@ export class PluginInstallService { }; } + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { + state: 'error', + error: `projectPath is required for ${scope}-scoped plugin installs`, + }; + } + // 3. Resolve qualifiedName from catalog (NOT from renderer) const resolved = await this.catalogService.resolvePlugin(pluginId); if (!resolved) { @@ -123,6 +134,13 @@ export class PluginInstallService { }; } + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { + state: 'error', + error: `projectPath is required for ${scope}-scoped plugin uninstalls`, + }; + } + // Resolve qualifiedName from catalog const resolved = await this.catalogService.resolvePlugin(pluginId); if (!resolved) { diff --git a/src/main/services/extensions/state/PluginInstallationStateService.ts b/src/main/services/extensions/state/PluginInstallationStateService.ts index 382b07f8..42c6c1a8 100644 --- a/src/main/services/extensions/state/PluginInstallationStateService.ts +++ b/src/main/services/extensions/state/PluginInstallationStateService.ts @@ -60,23 +60,26 @@ interface TimedCache { // ── Service ──────────────────────────────────────────────────────────────── export class PluginInstallationStateService { - private installedCache: TimedCache | null = null; + private installedCache = new Map>(); private countsCache: TimedCache> | 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 { - if ( - this.installedCache && - Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS - ) { - return this.installedCache.data; + async getInstalledPlugins(projectPath?: string): Promise { + 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 { + private getInstalledCacheKey(projectPath?: string): string { + return projectPath ?? '__user__'; + } + + private async buildInstalledEntriesForContext( + projectPath?: string + ): Promise { + const installedMetadata = await this.readInstalledPluginMetadata(); + const metadataByKey = new Map(); + + 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(); + const localEnabled = projectPath + ? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.local.json')) + : new Set(); + + return [ + ...this.buildScopedEntries('user', userEnabled, metadataByKey), + ...this.buildScopedEntries('project', projectEnabled, metadataByKey), + ...this.buildScopedEntries('local', localEnabled, metadataByKey), + ]; + } + + private buildScopedEntries( + scope: InstallScope, + enabledPlugins: Set, + metadataByKey: Map + ): 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 { const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json'); try { @@ -143,6 +220,31 @@ export class PluginInstallationStateService { } } + private async readEnabledPlugins(filePath: string): Promise> { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as { + enabledPlugins?: Record | null; + }; + + if (!json.enabledPlugins || typeof json.enabledPlugins !== 'object') { + return new Set(); + } + + 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(); + } + logger.error(`Failed to read plugin settings from ${filePath}:`, err); + return new Set(); + } + } + private async readInstallCounts(): Promise> { const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json'); diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index ec905ee0..7b736687 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, + apiKeysLoading, pluginCatalogLoading, mcpBrowseLoading, skillsLoading, @@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { fetchSkillsCatalog: s.fetchSkillsCatalog, mcpBrowse: s.mcpBrowse, mcpFetchInstalled: s.mcpFetchInstalled, + apiKeysLoading: s.apiKeysLoading, pluginCatalogLoading: s.pluginCatalogLoading, mcpBrowseLoading: s.mcpBrowseLoading, skillsLoading: s.skillsLoading, @@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => { // Refresh all data (plugins + MCP browse + installed + skills) const handleRefresh = useCallback(() => { + void fetchCliStatus(); + void fetchApiKeys(); void fetchPluginCatalog(projectPath ?? undefined, true); void mcpBrowse(); // re-fetch first page void mcpFetchInstalled(projectPath ?? undefined); void fetchSkillsCatalog(projectPath ?? undefined); - }, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]); + }, [ + fetchApiKeys, + fetchCliStatus, + fetchPluginCatalog, + fetchSkillsCatalog, + mcpBrowse, + mcpFetchInstalled, + projectPath, + ]); - const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const isRefreshing = + cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; const cliStatusBanner = useMemo(() => { if (cliStatusLoading || cliStatus === null) { return ( diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 93bdf907..2bc48112 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { Check, Loader2, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -43,18 +44,11 @@ export const InstallButton = ({ cliStatusLoading: s.cliStatusLoading, })) ); - const cliUnknown = cliStatus === null; - const cliMissing = cliStatus?.installed === false; - const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; - const disableReason = cliStatusLoading - ? 'Checking Claude CLI status...' - : cliUnknown - ? 'Checking Claude CLI availability...' - : cliMissing - ? 'Claude CLI required. Install it from the Dashboard.' - : authMissing - ? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.' - : null; + const disableReason = getExtensionActionDisableReason({ + isInstalled, + cliStatus, + cliStatusLoading, + }); const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 3a2a9bfb..24ca2d87 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -5,7 +5,9 @@ import { Badge } from '@renderer/components/ui/badge'; import { useStore } from '@renderer/store'; import { + getInstallationSummaryLabel, getCapabilityLabel, + hasInstallationInScope, inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; @@ -29,6 +31,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J const installPlugin = useStore((s) => s.installPlugin); const uninstallPlugin = useStore((s) => s.uninstallPlugin); const installError = useStore((s) => s.installErrors[plugin.pluginId]); + const isUserInstalled = hasInstallationInScope(plugin.installations, 'user'); + const installSummaryLabel = getInstallationSummaryLabel(plugin.installations); const baseStriped = index % 2 === 0; const smStriped = Math.floor(index / 2) % 2 === 0; const xlStriped = Math.floor(index / 3) % 2 === 0; @@ -81,12 +85,12 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
- {plugin.isInstalled && ( + {installSummaryLabel && ( - Installed + {installSummaryLabel} )}
@@ -112,9 +116,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
e.stopPropagation()}> installPlugin({ pluginId: plugin.pluginId, scope: 'user' })} - onUninstall={() => uninstallPlugin(plugin.pluginId)} + onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')} size="sm" errorMessage={installError} /> diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 23721946..8d6e05ac 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -25,7 +25,9 @@ import { } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; import { + getInstallationSummaryLabel, getCapabilityLabel, + hasInstallationInScope, inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; @@ -46,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 = ({ @@ -54,13 +57,21 @@ export const PluginDetailDialog = ({ open, onClose, }: PluginDetailDialogProps): React.JSX.Element => { - const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( + const { + fetchPluginReadme, + readmes, + readmeLoading, + installPlugin, + uninstallPlugin, + pluginCatalogProjectPath, + } = useStore( useShallow((s) => ({ fetchPluginReadme: s.fetchPluginReadme, readmes: s.pluginReadmes, readmeLoading: s.pluginReadmeLoading, installPlugin: s.installPlugin, uninstallPlugin: s.uninstallPlugin, + pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); const installProgress = useStore( @@ -69,6 +80,7 @@ export const PluginDetailDialog = ({ const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); const [scope, setScope] = useState('user'); + const projectScopeAvailable = Boolean(pluginCatalogProjectPath); useEffect(() => { if (plugin && open) { @@ -76,12 +88,26 @@ export const PluginDetailDialog = ({ } }, [plugin, open, fetchPluginReadme]); + useEffect(() => { + if (open) { + setScope('user'); + } + }, [open, plugin?.pluginId]); + + useEffect(() => { + if (scope !== 'user' && !projectScopeAvailable) { + setScope('user'); + } + }, [projectScopeAvailable, scope]); + if (!plugin) return <>; const capabilities = inferCapabilities(plugin); const category = normalizeCategory(plugin.category); const readme = readmes[plugin.pluginId]; const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false; + const isInstalledForScope = hasInstallationInScope(plugin.installations, scope); + const installSummaryLabel = getInstallationSummaryLabel(plugin.installations); return ( !o && onClose()}> @@ -93,12 +119,12 @@ export const PluginDetailDialog = ({ {plugin.description}
- {plugin.isInstalled && ( + {installSummaryLabel && ( - Installed + {installSummaryLabel} )} @@ -158,7 +184,11 @@ export const PluginDetailDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} @@ -167,9 +197,23 @@ export const PluginDetailDialog = ({
installPlugin({ pluginId: plugin.pluginId, scope })} - onUninstall={() => uninstallPlugin(plugin.pluginId, scope)} + isInstalled={isInstalledForScope} + onInstall={() => + installPlugin({ + pluginId: plugin.pluginId, + scope, + ...(scope !== 'user' && pluginCatalogProjectPath + ? { projectPath: pluginCatalogProjectPath } + : {}), + }) + } + onUninstall={() => + uninstallPlugin( + plugin.pluginId, + scope, + scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined + ) + } size="default" errorMessage={installError} /> diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index ab8d94e9..5480b7c8 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -2,7 +2,7 @@ * PluginsPanel — search, filter, sort and browse the plugin catalog. */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -142,6 +142,18 @@ export const PluginsPanel = ({ [catalog, selectedPluginId] ); + useEffect(() => { + if (selectedPluginId && !loading && !selectedPlugin) { + setSelectedPluginId(null); + } + }, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]); + + useEffect(() => { + if (error && selectedPluginId) { + setSelectedPluginId(null); + } + }, [error, selectedPluginId, setSelectedPluginId]); + const sortValue = `${pluginSort.field}:${pluginSort.order}`; const activeFilterCount = pluginFilters.categories.length + diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index dbb32224..7932eeeb 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -127,7 +127,9 @@ export interface ExtensionsSlice { // Slice Creator // ============================================================================= -let pluginFetchInFlight: Promise | null = null; +let pluginFetchInFlight: { key: string; promise: Promise } | null = null; +let pluginCatalogRequestSeq = 0; +const pluginSuccessResetTimers = new Map>(); let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -140,6 +142,76 @@ function hasAnyLoading(loadingMap: Record): boolean { return Object.values(loadingMap).some(Boolean); } +function getPluginCatalogKey(projectPath?: string): string { + return projectPath ?? '__user__'; +} + +function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { + return new Set(catalog.map((plugin) => plugin.pluginId)); +} + +function clearPluginOperationState( + pluginIds: Set, + pluginInstallProgress: Record, + installErrors: Record +): { + pluginInstallProgress: Record; + installErrors: Record; +} { + if (pluginIds.size === 0) { + return { pluginInstallProgress, installErrors }; + } + + const nextPluginInstallProgress = { ...pluginInstallProgress }; + const nextInstallErrors = { ...installErrors }; + + for (const pluginId of pluginIds) { + delete nextPluginInstallProgress[pluginId]; + delete nextInstallErrors[pluginId]; + } + + return { + pluginInstallProgress: nextPluginInstallProgress, + installErrors: nextInstallErrors, + }; +} + +function clearPluginSuccessResetTimer(pluginId: string): void { + const timer = pluginSuccessResetTimers.get(pluginId); + if (!timer) { + return; + } + + clearTimeout(timer); + pluginSuccessResetTimers.delete(pluginId); +} + +function clearPluginSuccessResetTimers(pluginIds: Set): void { + for (const pluginId of pluginIds) { + clearPluginSuccessResetTimer(pluginId); + } +} + +function schedulePluginSuccessReset( + pluginId: string, + set: Parameters>[0] +): void { + clearPluginSuccessResetTimer(pluginId); + const timer = setTimeout(() => { + pluginSuccessResetTimers.delete(pluginId); + set((prev) => { + if (prev.pluginInstallProgress[pluginId] !== 'success') { + return {}; + } + + return { + pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, + }; + }); + }, SUCCESS_DISPLAY_MS); + pluginSuccessResetTimers.set(pluginId, timer); +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -152,6 +224,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- and local-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( set, @@ -203,34 +277,80 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const requestKey = getPluginCatalogKey(projectPath); // Dedup concurrent requests - if (pluginFetchInFlight && !forceRefresh) { - await pluginFetchInFlight; + if (pluginFetchInFlight && !forceRefresh && pluginFetchInFlight.key === requestKey) { + await pluginFetchInFlight.promise; return; } + const requestSeq = ++pluginCatalogRequestSeq; 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, + set((prev) => { + if (requestSeq !== pluginCatalogRequestSeq) { + return {}; + } + + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; + const pluginIdsToClear = isSameProjectContext + ? new Set() + : new Set([...buildPluginIdSet(prev.pluginCatalog), ...buildPluginIdSet(result)]); + const nextOperationState = clearPluginOperationState( + pluginIdsToClear, + prev.pluginInstallProgress, + prev.installErrors + ); + clearPluginSuccessResetTimers(pluginIdsToClear); + + return { + pluginCatalog: result, + pluginCatalogLoading: false, + pluginCatalogError: null, + pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, + }; }); } catch (err) { - set({ - pluginCatalogLoading: false, - pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', + set((prev) => { + if (requestSeq !== pluginCatalogRequestSeq) { + return {}; + } + + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; + const nextOperationState = clearPluginOperationState( + isSameProjectContext ? new Set() : buildPluginIdSet(prev.pluginCatalog), + prev.pluginInstallProgress, + prev.installErrors + ); + clearPluginSuccessResetTimers( + isSameProjectContext ? new Set() : buildPluginIdSet(prev.pluginCatalog) + ); + + return { + pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], + pluginCatalogLoading: false, + pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', + pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, + }; }); } finally { - pluginFetchInFlight = null; + if (pluginFetchInFlight?.promise === promise) { + pluginFetchInFlight = null; + } } })(); - pluginFetchInFlight = promise; + pluginFetchInFlight = { key: requestKey, promise }; await promise; }, @@ -238,7 +358,13 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; const state = get(); - if (pluginId in state.pluginReadmes || state.pluginReadmeLoading[pluginId]) return; + const cachedReadme = state.pluginReadmes[pluginId]; + if ( + (cachedReadme !== undefined && cachedReadme !== null) || + state.pluginReadmeLoading[pluginId] + ) { + return; + } set((prev) => ({ pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true }, @@ -561,6 +687,15 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const effectiveProjectPath = + request.scope !== 'user' + ? (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,17 +707,20 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, @@ -590,13 +728,14 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, installErrors: { ...prev.installErrors, [request.pluginId]: '' }, })); 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' }, @@ -615,13 +754,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(request.pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(request.pluginId); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, @@ -634,12 +769,26 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const effectiveProjectPath = + scope && scope !== 'user' + ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) + : projectPath; + if (scope && scope !== 'user' && !effectiveProjectPath) { + clearPluginSuccessResetTimer(pluginId); + set((prev) => ({ + pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, + installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, + })); + return; + } + + clearPluginSuccessResetTimer(pluginId); 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' }, @@ -655,12 +804,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(pluginId); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ 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/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 5f86ca7b..7684925a 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -2,7 +2,13 @@ * Pure-function normalizers for Extension Store data. */ -import type { PluginCapability, PluginCatalogItem } from '@shared/types/extensions'; +import type { + CliInstallationStatus, + InstallScope, + InstalledPluginEntry, + PluginCapability, + PluginCatalogItem, +} from '@shared/types'; /** * Normalize a repository URL for dedup comparison. @@ -94,6 +100,71 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri return `${pluginName}@${marketplaceName}`; } +/** + * Check whether a plugin has an installation for the selected scope. + */ +export function hasInstallationInScope( + installations: Pick[], + scope: InstallScope +): boolean { + return installations.some((installation) => installation.scope === scope); +} + +/** + * Build a concise install-status label for plugin badges. + */ +export function getInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + if (scopes.length === 0) { + return null; + } + + if (scopes.length > 1) { + return `Installed in ${scopes.length} scopes`; + } + + switch (scopes[0]) { + case 'user': + return 'Installed globally'; + case 'project': + return 'Installed in project'; + case 'local': + return 'Installed locally'; + default: + return 'Installed'; + } +} + +/** + * Install actions require Claude auth, but uninstall only requires a working CLI. + */ +export function getExtensionActionDisableReason(options: { + isInstalled: boolean; + cliStatus: Pick | null; + cliStatusLoading: boolean; +}): string | null { + const { isInstalled, cliStatus, cliStatusLoading } = options; + if (cliStatusLoading) { + return 'Checking Claude CLI status...'; + } + + if (cliStatus === null) { + return 'Checking Claude CLI availability...'; + } + + if (cliStatus.installed === false) { + return 'Claude CLI required. Install it from the Dashboard.'; + } + + if (!isInstalled && !cliStatus.authLoggedIn) { + return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'; + } + + return null; +} + /** * Sanitize an MCP server display name into a CLI-safe server name. * Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService. diff --git a/test/main/services/extensions/PluginInstallService.test.ts b/test/main/services/extensions/PluginInstallService.test.ts index 6e8f625e..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'], @@ -121,6 +137,22 @@ 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(); + }); + + 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 ─────────────────────────────────────────────────────────────── @@ -151,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'], @@ -171,5 +215,21 @@ 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(); + }); + + 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/main/services/extensions/PluginInstallationStateService.test.ts b/test/main/services/extensions/PluginInstallationStateService.test.ts index 1a2c2d97..16f2d2e1 100644 --- a/test/main/services/extensions/PluginInstallationStateService.test.ts +++ b/test/main/services/extensions/PluginInstallationStateService.test.ts @@ -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); }); }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index a69507fa..058b32d6 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -116,6 +116,23 @@ const makeSkillDetail = (overrides: Partial = {}): 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; @@ -125,6 +142,7 @@ describe('extensionsSlice', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -149,6 +167,121 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogError).toBe('boom'); expect(store.getState().pluginCatalogLoading).toBe(false); }); + + it('clears stale catalog when a different project fetch fails', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + }); + (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalog).toEqual([]); + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalogError).toBe('boom'); + }); + + it('clears plugin operation state when switching project context', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([ + makePlugin({ pluginId: 'project-b@m' }), + ]); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); + }); + + it('dedups concurrent requests for the same project key', async () => { + let resolveFetch!: (plugins: EnrichedPlugin[]) => void; + const inFlight = new Promise((resolve) => { + resolveFetch = resolve; + }); + (api.plugins!.getAll as ReturnType).mockImplementation(() => inFlight); + + const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + + expect(api.plugins!.getAll).toHaveBeenCalledTimes(1); + + resolveFetch([makePlugin({ pluginId: 'same@m' })]); + await Promise.all([firstFetch, secondFetch]); + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-a'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual(['same@m']); + }); + + it('keeps the newest project catalog when project changes mid-flight', async () => { + let resolveProjectA!: (plugins: EnrichedPlugin[]) => void; + let resolveProjectB!: (plugins: EnrichedPlugin[]) => void; + const projectAFetch = new Promise((resolve) => { + resolveProjectA = resolve; + }); + const projectBFetch = new Promise((resolve) => { + resolveProjectB = resolve; + }); + + (api.plugins!.getAll as ReturnType) + .mockImplementationOnce(() => projectAFetch) + .mockImplementationOnce(() => projectBFetch); + + const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(api.plugins!.getAll).toHaveBeenCalledTimes(2); + + resolveProjectB([makePlugin({ pluginId: 'project-b@m' })]); + await secondFetch; + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ + 'project-b@m', + ]); + + resolveProjectA([makePlugin({ pluginId: 'project-a@m' })]); + await firstFetch; + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ + 'project-b@m', + ]); + }); + + it('clears plugin operation state when a different project fetch fails', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalog).toEqual([]); + expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); + }); }); describe('fetchPluginReadme', () => { @@ -171,6 +304,15 @@ describe('extensionsSlice', () => { expect(api.plugins!.getReadme).not.toHaveBeenCalled(); }); + + it('retries README fetch when the cached value is null', () => { + store.setState({ pluginReadmes: { 'test@m': null } }); + (api.plugins!.getReadme as ReturnType).mockResolvedValue(null); + + store.getState().fetchPluginReadme('test@m'); + + expect(api.plugins!.getReadme).toHaveBeenCalledWith('test@m'); + }); }); describe('mcpBrowse', () => { @@ -298,24 +440,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).mockResolvedValue(plugins); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); @@ -330,24 +455,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).mockResolvedValue({ state: 'error', error: 'Not found', @@ -357,6 +465,77 @@ 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).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'); + }); + + 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() }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType) + .mockResolvedValueOnce({ state: 'success' }) + .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + }); }); describe('uninstallPlugin', () => { @@ -372,6 +551,66 @@ 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).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'); + }); + + 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({ + pluginCatalogProjectPath: '/tmp/project-a', + pluginCatalog: [makePlugin({ pluginId: 'test@m' })], + }); + (api.plugins!.getAll as ReturnType) + .mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })]) + .mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('test@m', 'user'); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + }); }); describe('installMcpServer', () => { diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index ce40a00d..7905fd99 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -5,8 +5,11 @@ import type { PluginCatalogItem } from '@shared/types/extensions'; import { buildPluginId, formatInstallCount, + getExtensionActionDisableReason, getCapabilityLabel, + getInstallationSummaryLabel, getPrimaryCapabilityLabel, + hasInstallationInScope, inferCapabilities, normalizeCategory, normalizeRepoUrl, @@ -149,6 +152,79 @@ describe('buildPluginId', () => { }); }); +describe('hasInstallationInScope', () => { + it('returns true when the selected scope exists', () => { + expect( + hasInstallationInScope( + [ + { scope: 'user' }, + { scope: 'project' }, + ], + 'project', + ), + ).toBe(true); + }); + + it('returns false when the selected scope is missing', () => { + expect(hasInstallationInScope([{ scope: 'user' }], 'project')).toBe(false); + }); +}); + +describe('getInstallationSummaryLabel', () => { + it('returns null when there are no installations', () => { + expect(getInstallationSummaryLabel([])).toBeNull(); + }); + + it('describes a single global installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'user' }])).toBe('Installed globally'); + }); + + it('describes a single project installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'project' }])).toBe('Installed in project'); + }); + + it('summarizes multiple scopes without pretending they are global', () => { + expect( + getInstallationSummaryLabel([ + { scope: 'project' }, + { scope: 'user' }, + ]), + ).toBe('Installed in 2 scopes'); + }); +}); + +describe('getExtensionActionDisableReason', () => { + it('requires auth only for install actions', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('not signed in'); + }); + + it('allows uninstall when CLI is present but auth is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toBeNull(); + }); + + it('still blocks actions when the CLI is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: false, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('Claude CLI required'); + }); +}); + describe('sanitizeMcpServerName', () => { it('lowercases and replaces spaces with dashes', () => { expect(sanitizeMcpServerName('My Server')).toBe('my-server');