Merge branch 'dev' into spike/team-snapshot-split-plan

This commit is contained in:
777genius 2026-04-16 22:11:16 +03:00
commit 821e23e633
14 changed files with 1079 additions and 157 deletions

View file

@ -26,6 +26,10 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']);
const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
const 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) {

View file

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

View file

@ -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 (

View file

@ -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);

View file

@ -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
</div>
<div className="flex shrink-0 items-center gap-2">
<InstallCountBadge count={plugin.installCount} />
{plugin.isInstalled && (
{installSummaryLabel && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
{installSummaryLabel}
</Badge>
)}
</div>
@ -112,9 +116,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<InstallButton
state={installProgress}
isInstalled={plugin.isInstalled}
isInstalled={isUserInstalled}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
onUninstall={() => uninstallPlugin(plugin.pluginId)}
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
size="sm"
errorMessage={installError}
/>

View file

@ -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<InstallScope>('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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@ -93,12 +119,12 @@ export const PluginDetailDialog = ({
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{plugin.isInstalled && (
{installSummaryLabel && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
{installSummaryLabel}
</Badge>
)}
<SourceBadge source={plugin.source} />
@ -158,7 +184,11 @@ export const PluginDetailDialog = ({
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== 'user' && !projectScopeAvailable}
>
{opt.label}
</SelectItem>
))}
@ -167,9 +197,23 @@ export const PluginDetailDialog = ({
</div>
<InstallButton
state={installProgress}
isInstalled={plugin.isInstalled}
onInstall={() => 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}
/>

View file

@ -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 +

View file

@ -127,7 +127,9 @@ export interface ExtensionsSlice {
// Slice Creator
// =============================================================================
let pluginFetchInFlight: Promise<void> | null = null;
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
let pluginCatalogRequestSeq = 0;
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
let mcpDiagnosticsInFlight: Promise<void> | null = null;
let skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0;
@ -140,6 +142,76 @@ function hasAnyLoading(loadingMap: Record<string, boolean>): boolean {
return Object.values(loadingMap).some(Boolean);
}
function getPluginCatalogKey(projectPath?: string): string {
return projectPath ?? '__user__';
}
function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
return new Set(catalog.map((plugin) => plugin.pluginId));
}
function clearPluginOperationState(
pluginIds: Set<string>,
pluginInstallProgress: Record<string, ExtensionOperationState>,
installErrors: Record<string, string>
): {
pluginInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>;
} {
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<string>): void {
for (const pluginId of pluginIds) {
clearPluginSuccessResetTimer(pluginId);
}
}
function schedulePluginSuccessReset(
pluginId: string,
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[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<AppState, [], [], ExtensionsSlice> = (
set,
@ -203,34 +277,80 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── Plugin catalog fetch ──
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
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<string>()
: 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<string>() : buildPluginIdSet(prev.pluginCatalog),
prev.pluginInstallProgress,
prev.installErrors
);
clearPluginSuccessResetTimers(
isSameProjectContext ? new Set<string>() : 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<AppState, [], [], ExtensionsSli
fetchPluginReadme: (pluginId: string) => {
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<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => {
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<AppState, [], [], ExtensionsSli
const cliStatus = get().cliStatus;
const preflightError =
cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
? PROJECT_SCOPE_REQUIRED_MESSAGE
: cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
if (preflightError) {
clearPluginSuccessResetTimer(request.pluginId);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
@ -590,13 +728,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return;
}
clearPluginSuccessResetTimer(request.pluginId);
set((prev) => ({
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<AppState, [], [], ExtensionsSli
// 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);
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<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
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<AppState, [], [], ExtensionsSli
// Refresh catalog
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
setTimeout(() => {
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' },

View file

@ -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) ────────────────────────────────────────

View file

@ -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<InstalledPluginEntry, 'scope'>[],
scope: InstallScope
): boolean {
return installations.some((installation) => installation.scope === scope);
}
/**
* Build a concise install-status label for plugin badges.
*/
export function getInstallationSummaryLabel(
installations: Pick<InstalledPluginEntry, 'scope'>[]
): 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<CliInstallationStatus, 'installed' | 'authLoggedIn'> | 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.

View file

@ -85,6 +85,22 @@ describe('PluginInstallService', () => {
);
});
it('adds local scope flag and cwd for local installs', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.install({
pluginId: 'context7',
scope: 'local',
projectPath: '/tmp/test-project',
});
expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude',
['plugin', 'install', '-s', 'local', 'context7@claude-plugins-official'],
expect.objectContaining({ cwd: '/tmp/test-project' }),
);
});
it('returns error if plugin not found in catalog', async () => {
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();
});
});
});

View file

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

View file

@ -116,6 +116,23 @@ const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): SkillDetail => (
...overrides,
});
const makeReadyCliStatus = () => ({
flavor: 'claude' as const,
displayName: 'Claude',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
installed: true,
installedVersion: '1.0.0',
binaryPath: '/usr/local/bin/claude',
latestVersion: '1.0.0',
updateAvailable: false,
authLoggedIn: true,
authStatusChecking: false,
authMethod: 'oauth_token' as const,
providers: [],
});
describe('extensionsSlice', () => {
let store: TestStore;
@ -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<typeof vi.fn>).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<typeof vi.fn>).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<EnrichedPlugin[]>((resolve) => {
resolveFetch = resolve;
});
(api.plugins!.getAll as ReturnType<typeof vi.fn>).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<EnrichedPlugin[]>((resolve) => {
resolveProjectA = resolve;
});
const projectBFetch = new Promise<EnrichedPlugin[]>((resolve) => {
resolveProjectB = resolve;
});
(api.plugins!.getAll as ReturnType<typeof vi.fn>)
.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.install as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({ state: 'success' });
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
expect(api.plugins!.install).toHaveBeenCalledWith({
pluginId: 'project@m',
scope: 'project',
projectPath: '/tmp/project-a',
});
});
it('fails fast for project scope when there is no active project path', async () => {
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
expect(api.plugins!.install).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
expect(store.getState().installErrors['project@m']).toContain('active project');
});
it('fills missing projectPath for local scope from the active Extensions project context', async () => {
store.setState({
cliStatus: makeReadyCliStatus(),
pluginCatalogProjectPath: '/tmp/project-a',
});
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });
expect(api.plugins!.install).toHaveBeenCalledWith({
pluginId: 'local@m',
scope: 'local',
projectPath: '/tmp/project-a',
});
});
it('fails fast for local scope when there is no active project path', async () => {
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });
expect(api.plugins!.install).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
expect(store.getState().installErrors['local@m']).toContain('active project');
});
it('clears older success reset timers before a new operation on the same plugin', async () => {
vi.useFakeTimers();
store.setState({ cliStatus: makeReadyCliStatus() });
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.plugins!.install as ReturnType<typeof vi.fn>)
.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<typeof vi.fn>).mockResolvedValue({ state: 'success' });
await store.getState().uninstallPlugin('project@m', 'project');
expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a');
});
it('fails fast for project uninstall when there is no active project path', async () => {
store.setState({ pluginCatalogProjectPath: null });
await store.getState().uninstallPlugin('project@m', 'project');
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
expect(store.getState().installErrors['project@m']).toContain('active project');
});
it('fills missing projectPath for local uninstall from the active Extensions project context', async () => {
store.setState({ pluginCatalogProjectPath: '/tmp/project-a' });
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
await store.getState().uninstallPlugin('local@m', 'local');
expect(api.plugins!.uninstall).toHaveBeenCalledWith('local@m', 'local', '/tmp/project-a');
});
it('fails fast for local uninstall when there is no active project path', async () => {
store.setState({ pluginCatalogProjectPath: null });
await store.getState().uninstallPlugin('local@m', 'local');
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
expect(store.getState().installErrors['local@m']).toContain('active project');
});
it('does not restore idle state after project switch clears a pending success timer', async () => {
vi.useFakeTimers();
store.setState({
pluginCatalogProjectPath: '/tmp/project-a',
pluginCatalog: [makePlugin({ pluginId: 'test@m' })],
});
(api.plugins!.getAll as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })])
.mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]);
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).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', () => {

View file

@ -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');