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 INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
const UNINSTALL_TIMEOUT_MS = 30_000; const UNINSTALL_TIMEOUT_MS = 30_000;
function scopeRequiresProjectPath(scope?: string): boolean {
return scope === 'project' || scope === 'local';
}
export class PluginInstallService { export class PluginInstallService {
constructor(private readonly catalogService: PluginCatalogService) {} 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) // 3. Resolve qualifiedName from catalog (NOT from renderer)
const resolved = await this.catalogService.resolvePlugin(pluginId); const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) { 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 // Resolve qualifiedName from catalog
const resolved = await this.catalogService.resolvePlugin(pluginId); const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) { if (!resolved) {

View file

@ -60,23 +60,26 @@ interface TimedCache<T> {
// ── Service ──────────────────────────────────────────────────────────────── // ── Service ────────────────────────────────────────────────────────────────
export class PluginInstallationStateService { export class PluginInstallationStateService {
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null; private installedCache = new Map<string, TimedCache<InstalledPluginEntry[]>>();
private countsCache: TimedCache<Map<string, number>> | null = null; private countsCache: TimedCache<Map<string, number>> | null = null;
/** /**
* Get all installed plugins across all scopes. * Get installed plugins relevant to the active context.
* Returns merged list from installed_plugins.json with scope tags. * Always includes user scope. Project/local entries are only included when
* they are enabled for the active project.
*/ */
async getInstalledPlugins(_projectPath?: string): Promise<InstalledPluginEntry[]> { async getInstalledPlugins(projectPath?: string): Promise<InstalledPluginEntry[]> {
if ( const normalizedProjectPath =
this.installedCache && typeof projectPath === 'string' && path.isAbsolute(projectPath) ? projectPath : undefined;
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS const cacheKey = this.getInstalledCacheKey(normalizedProjectPath);
) { const cached = this.installedCache.get(cacheKey);
return this.installedCache.data;
if (cached && Date.now() - cached.fetchedAt < INSTALLED_STATE_TTL_MS) {
return cached.data;
} }
const entries = await this.readInstalledPlugins(); const entries = await this.buildInstalledEntriesForContext(normalizedProjectPath);
this.installedCache = { data: entries, fetchedAt: Date.now() }; this.installedCache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
return entries; return entries;
} }
@ -97,7 +100,7 @@ export class PluginInstallationStateService {
* Invalidate all caches. Call after install/uninstall operations. * Invalidate all caches. Call after install/uninstall operations.
*/ */
invalidateCache(): void { invalidateCache(): void {
this.installedCache = null; this.installedCache.clear();
this.countsCache = null; this.countsCache = null;
} }
@ -107,7 +110,81 @@ export class PluginInstallationStateService {
return path.join(getClaudeBasePath(), 'plugins'); 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'); const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
try { 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>> { private async readInstallCounts(): Promise<Map<string, number>> {
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json'); const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');

View file

@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
fetchSkillsCatalog, fetchSkillsCatalog,
mcpBrowse, mcpBrowse,
mcpFetchInstalled, mcpFetchInstalled,
apiKeysLoading,
pluginCatalogLoading, pluginCatalogLoading,
mcpBrowseLoading, mcpBrowseLoading,
skillsLoading, skillsLoading,
@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
fetchSkillsCatalog: s.fetchSkillsCatalog, fetchSkillsCatalog: s.fetchSkillsCatalog,
mcpBrowse: s.mcpBrowse, mcpBrowse: s.mcpBrowse,
mcpFetchInstalled: s.mcpFetchInstalled, mcpFetchInstalled: s.mcpFetchInstalled,
apiKeysLoading: s.apiKeysLoading,
pluginCatalogLoading: s.pluginCatalogLoading, pluginCatalogLoading: s.pluginCatalogLoading,
mcpBrowseLoading: s.mcpBrowseLoading, mcpBrowseLoading: s.mcpBrowseLoading,
skillsLoading: s.skillsLoading, skillsLoading: s.skillsLoading,
@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
// Refresh all data (plugins + MCP browse + installed + skills) // Refresh all data (plugins + MCP browse + installed + skills)
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void fetchCliStatus();
void fetchApiKeys();
void fetchPluginCatalog(projectPath ?? undefined, true); void fetchPluginCatalog(projectPath ?? undefined, true);
void mcpBrowse(); // re-fetch first page void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled(projectPath ?? undefined); void mcpFetchInstalled(projectPath ?? undefined);
void fetchSkillsCatalog(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(() => { const cliStatusBanner = useMemo(() => {
if (cliStatusLoading || cliStatus === null) { if (cliStatusLoading || cliStatus === null) {
return ( return (

View file

@ -13,6 +13,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
import { Check, Loader2, Trash2 } from 'lucide-react'; import { Check, Loader2, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -43,18 +44,11 @@ export const InstallButton = ({
cliStatusLoading: s.cliStatusLoading, cliStatusLoading: s.cliStatusLoading,
})) }))
); );
const cliUnknown = cliStatus === null; const disableReason = getExtensionActionDisableReason({
const cliMissing = cliStatus?.installed === false; isInstalled,
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; cliStatus,
const disableReason = cliStatusLoading 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 isDisabled = disabled || Boolean(disableReason); const isDisabled = disabled || Boolean(disableReason);
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);

View file

@ -5,7 +5,9 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getInstallationSummaryLabel,
getCapabilityLabel, getCapabilityLabel,
hasInstallationInScope,
inferCapabilities, inferCapabilities,
normalizeCategory, normalizeCategory,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
@ -29,6 +31,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
const installPlugin = useStore((s) => s.installPlugin); const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin); const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const installError = useStore((s) => s.installErrors[plugin.pluginId]); 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 baseStriped = index % 2 === 0;
const smStriped = Math.floor(index / 2) % 2 === 0; const smStriped = Math.floor(index / 2) % 2 === 0;
const xlStriped = Math.floor(index / 3) % 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>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<InstallCountBadge count={plugin.installCount} /> <InstallCountBadge count={plugin.installCount} />
{plugin.isInstalled && ( {installSummaryLabel && (
<Badge <Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400" className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline" variant="outline"
> >
Installed {installSummaryLabel}
</Badge> </Badge>
)} )}
</div> </div>
@ -112,9 +116,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
<div className="shrink-0" onClick={(e) => e.stopPropagation()}> <div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<InstallButton <InstallButton
state={installProgress} state={installProgress}
isInstalled={plugin.isInstalled} isInstalled={isUserInstalled}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })} onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
onUninstall={() => uninstallPlugin(plugin.pluginId)} onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
size="sm" size="sm"
errorMessage={installError} errorMessage={installError}
/> />

View file

@ -25,7 +25,9 @@ import {
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getInstallationSummaryLabel,
getCapabilityLabel, getCapabilityLabel,
hasInstallationInScope,
inferCapabilities, inferCapabilities,
normalizeCategory, normalizeCategory,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
@ -46,7 +48,8 @@ interface PluginDetailDialogProps {
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
{ value: 'user', label: 'User (global)' }, { value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' }, { value: 'project', label: 'Project (shared)' },
{ value: 'local', label: 'Local (gitignored)' },
]; ];
export const PluginDetailDialog = ({ export const PluginDetailDialog = ({
@ -54,13 +57,21 @@ export const PluginDetailDialog = ({
open, open,
onClose, onClose,
}: PluginDetailDialogProps): React.JSX.Element => { }: PluginDetailDialogProps): React.JSX.Element => {
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( const {
fetchPluginReadme,
readmes,
readmeLoading,
installPlugin,
uninstallPlugin,
pluginCatalogProjectPath,
} = useStore(
useShallow((s) => ({ useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme, fetchPluginReadme: s.fetchPluginReadme,
readmes: s.pluginReadmes, readmes: s.pluginReadmes,
readmeLoading: s.pluginReadmeLoading, readmeLoading: s.pluginReadmeLoading,
installPlugin: s.installPlugin, installPlugin: s.installPlugin,
uninstallPlugin: s.uninstallPlugin, uninstallPlugin: s.uninstallPlugin,
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
})) }))
); );
const installProgress = useStore( const installProgress = useStore(
@ -69,6 +80,7 @@ export const PluginDetailDialog = ({
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
const [scope, setScope] = useState<InstallScope>('user'); const [scope, setScope] = useState<InstallScope>('user');
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
useEffect(() => { useEffect(() => {
if (plugin && open) { if (plugin && open) {
@ -76,12 +88,26 @@ export const PluginDetailDialog = ({
} }
}, [plugin, open, fetchPluginReadme]); }, [plugin, open, fetchPluginReadme]);
useEffect(() => {
if (open) {
setScope('user');
}
}, [open, plugin?.pluginId]);
useEffect(() => {
if (scope !== 'user' && !projectScopeAvailable) {
setScope('user');
}
}, [projectScopeAvailable, scope]);
if (!plugin) return <></>; if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin); const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category); const category = normalizeCategory(plugin.category);
const readme = readmes[plugin.pluginId]; const readme = readmes[plugin.pluginId];
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false; const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@ -93,12 +119,12 @@ export const PluginDetailDialog = ({
<DialogDescription className="mt-1">{plugin.description}</DialogDescription> <DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{plugin.isInstalled && ( {installSummaryLabel && (
<Badge <Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400" className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline" variant="outline"
> >
Installed {installSummaryLabel}
</Badge> </Badge>
)} )}
<SourceBadge source={plugin.source} /> <SourceBadge source={plugin.source} />
@ -158,7 +184,11 @@ export const PluginDetailDialog = ({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SCOPE_OPTIONS.map((opt) => ( {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} {opt.label}
</SelectItem> </SelectItem>
))} ))}
@ -167,9 +197,23 @@ export const PluginDetailDialog = ({
</div> </div>
<InstallButton <InstallButton
state={installProgress} state={installProgress}
isInstalled={plugin.isInstalled} isInstalled={isInstalledForScope}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })} onInstall={() =>
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)} installPlugin({
pluginId: plugin.pluginId,
scope,
...(scope !== 'user' && pluginCatalogProjectPath
? { projectPath: pluginCatalogProjectPath }
: {}),
})
}
onUninstall={() =>
uninstallPlugin(
plugin.pluginId,
scope,
scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined
)
}
size="default" size="default"
errorMessage={installError} errorMessage={installError}
/> />

View file

@ -2,7 +2,7 @@
* PluginsPanel search, filter, sort and browse the plugin catalog. * 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 { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -142,6 +142,18 @@ export const PluginsPanel = ({
[catalog, selectedPluginId] [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 sortValue = `${pluginSort.field}:${pluginSort.order}`;
const activeFilterCount = const activeFilterCount =
pluginFilters.categories.length + pluginFilters.categories.length +

View file

@ -127,7 +127,9 @@ export interface ExtensionsSlice {
// Slice Creator // 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 mcpDiagnosticsInFlight: Promise<void> | null = null;
let skillsCatalogRequestSeq = 0; let skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0; let skillsDetailRequestSeq = 0;
@ -140,6 +142,76 @@ function hasAnyLoading(loadingMap: Record<string, boolean>): boolean {
return Object.values(loadingMap).some(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 { function getSkillsCatalogKey(projectPath?: string): string {
return projectPath ?? USER_SKILLS_CATALOG_KEY; 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.'; '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 = const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; '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> = ( export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set, set,
@ -203,34 +277,80 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── Plugin catalog fetch ── // ── Plugin catalog fetch ──
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => { fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
if (!api.plugins) return; if (!api.plugins) return;
const requestKey = getPluginCatalogKey(projectPath);
// Dedup concurrent requests // Dedup concurrent requests
if (pluginFetchInFlight && !forceRefresh) { if (pluginFetchInFlight && !forceRefresh && pluginFetchInFlight.key === requestKey) {
await pluginFetchInFlight; await pluginFetchInFlight.promise;
return; return;
} }
const requestSeq = ++pluginCatalogRequestSeq;
set({ pluginCatalogLoading: true, pluginCatalogError: null }); set({ pluginCatalogLoading: true, pluginCatalogError: null });
const promise = (async () => { const promise = (async () => {
try { try {
const result = await api.plugins!.getAll(projectPath, forceRefresh); const result = await api.plugins!.getAll(projectPath, forceRefresh);
set({ set((prev) => {
pluginCatalog: result, if (requestSeq !== pluginCatalogRequestSeq) {
pluginCatalogLoading: false, return {};
pluginCatalogProjectPath: projectPath ?? null, }
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) { } catch (err) {
set({ set((prev) => {
pluginCatalogLoading: false, if (requestSeq !== pluginCatalogRequestSeq) {
pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', 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 { } finally {
pluginFetchInFlight = null; if (pluginFetchInFlight?.promise === promise) {
pluginFetchInFlight = null;
}
} }
})(); })();
pluginFetchInFlight = promise; pluginFetchInFlight = { key: requestKey, promise };
await promise; await promise;
}, },
@ -238,7 +358,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
fetchPluginReadme: (pluginId: string) => { fetchPluginReadme: (pluginId: string) => {
if (!api.plugins) return; if (!api.plugins) return;
const state = get(); 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) => ({ set((prev) => ({
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true }, pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true },
@ -561,6 +687,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => { installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return; 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(); const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try { try {
@ -572,17 +707,20 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const cliStatus = get().cliStatus; const cliStatus = get().cliStatus;
const preflightError = const preflightError =
cliStatus === null effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
? CLI_STATUS_UNKNOWN_MESSAGE ? PROJECT_SCOPE_REQUIRED_MESSAGE
: !cliStatus.installed : cliStatus === null
? cliStatus.binaryPath && cliStatus.launchError ? CLI_STATUS_UNKNOWN_MESSAGE
? CLI_HEALTHCHECK_FAILED_MESSAGE : !cliStatus.installed
: CLI_NOT_FOUND_MESSAGE ? cliStatus.binaryPath && cliStatus.launchError
: !cliStatus.authLoggedIn ? CLI_HEALTHCHECK_FAILED_MESSAGE
? CLI_AUTH_REQUIRED_MESSAGE : CLI_NOT_FOUND_MESSAGE
: null; : !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
if (preflightError) { if (preflightError) {
clearPluginSuccessResetTimer(request.pluginId);
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
@ -590,13 +728,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return; return;
} }
clearPluginSuccessResetTimer(request.pluginId);
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
installErrors: { ...prev.installErrors, [request.pluginId]: '' }, installErrors: { ...prev.installErrors, [request.pluginId]: '' },
})); }));
try { try {
const result = await api.plugins.install(request); const result = await api.plugins.install(effectiveRequest);
if (result.state === 'error') { if (result.state === 'error') {
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
@ -615,13 +754,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// Refresh catalog to pick up new installed state // Refresh catalog to pick up new installed state
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
// Return to idle after brief success display schedulePluginSuccessReset(request.pluginId, set);
setTimeout(() => {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch (err) { } catch (err) {
clearPluginSuccessResetTimer(request.pluginId);
const message = err instanceof Error ? err.message : 'Install failed'; const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
@ -634,12 +769,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => { uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return; 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) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
})); }));
try { try {
const result = await api.plugins.uninstall(pluginId, scope, projectPath); const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
if (result.state === 'error') { if (result.state === 'error') {
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
@ -655,12 +804,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// Refresh catalog // Refresh catalog
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
setTimeout(() => { schedulePluginSuccessReset(pluginId, set);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch (err) { } catch (err) {
clearPluginSuccessResetTimer(pluginId);
const message = err instanceof Error ? err.message : 'Uninstall failed'; const message = err instanceof Error ? err.message : 'Uninstall failed';
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },

View file

@ -70,7 +70,7 @@ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] {
export interface PluginInstallRequest { export interface PluginInstallRequest {
pluginId: string; // canonical key — main resolves qualifiedName from catalog pluginId: string; // canonical key — main resolves qualifiedName from catalog
scope: InstallScope; scope: InstallScope;
projectPath?: string; // required for 'project' scope projectPath?: string; // required for repo-scoped installs ('project' or 'local')
} }
// ── Filters (renderer-only concern) ──────────────────────────────────────── // ── Filters (renderer-only concern) ────────────────────────────────────────

View file

@ -2,7 +2,13 @@
* Pure-function normalizers for Extension Store data. * 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. * Normalize a repository URL for dedup comparison.
@ -94,6 +100,71 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
return `${pluginName}@${marketplaceName}`; 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. * Sanitize an MCP server display name into a CLI-safe server name.
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService. * 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 () => { it('returns error if plugin not found in catalog', async () => {
catalog = createMockCatalog({ catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
@ -121,6 +137,22 @@ describe('PluginInstallService', () => {
expect(result.state).toBe('error'); expect(result.state).toBe('error');
expect(result.error).toContain('Command failed'); 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 ─────────────────────────────────────────────────────────────── // ── 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 () => { it('returns error if plugin not in catalog', async () => {
catalog = createMockCatalog({ catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
@ -171,5 +215,21 @@ describe('PluginInstallService', () => {
expect(result.state).toBe('error'); expect(result.state).toBe('error');
expect(result.error).toContain('Cannot uninstall'); 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', () => { describe('getInstalledPlugins', () => {
it('parses installed_plugins.json version 2 format', async () => { it('returns user-scoped plugins enabled in user settings', async () => {
const installedData = { mockedFs.readFile.mockImplementation(async (filePath) => {
version: 2, const normalizedPath = String(filePath);
plugins: { if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
'context7@claude-plugins-official': [ return JSON.stringify({
{ version: 2,
scope: 'user', plugins: {
installPath: '/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0', 'context7@claude-plugins-official': [
version: '1.0.0', {
installedAt: '2026-03-01T11:14:21.926Z', 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(); const entries = await service.getInstalledPlugins();
expect(entries).toHaveLength(3); expect(entries).toHaveLength(1);
expect(entries[0].pluginId).toBe('context7@claude-plugins-official'); expect(entries[0]).toMatchObject({
expect(entries[0].scope).toBe('user'); pluginId: 'context7@claude-plugins-official',
expect(entries[0].version).toBe('1.0.0'); scope: 'user',
version: '1.0.0',
});
});
expect(entries[1].pluginId).toBe('typescript-lsp@claude-plugins-official'); it('includes project and local scopes only for the active project', async () => {
expect(entries[1].scope).toBe('user'); 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'); if (normalizedPath === '/tmp/mock-claude/settings.json') {
expect(entries[2].scope).toBe('project'); 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 () => { it('returns empty array when file does not exist', async () => {
@ -78,22 +181,55 @@ describe('PluginInstallationStateService', () => {
}); });
it('returns empty array for unexpected version', async () => { 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(); const entries = await service.getInstalledPlugins();
expect(entries).toEqual([]); expect(entries).toEqual([]);
}); });
it('caches within TTL', async () => { it('caches within TTL', async () => {
mockedFs.readFile.mockResolvedValue( mockedFs.readFile.mockImplementation(async (filePath) => {
JSON.stringify({ version: 2, plugins: {} }), 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();
await service.getInstalledPlugins(); await service.getInstalledPlugins();
// Only one read expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
expect(mockedFs.readFile).toHaveBeenCalledTimes(1); });
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', () => { describe('invalidateCache', () => {
it('forces re-read after invalidation', async () => { it('forces re-read after invalidation', async () => {
mockedFs.readFile.mockResolvedValue( mockedFs.readFile.mockImplementation(async (filePath) => {
JSON.stringify({ version: 2, plugins: {} }), 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();
service.invalidateCache(); service.invalidateCache();
await service.getInstalledPlugins(); 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, ...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', () => { describe('extensionsSlice', () => {
let store: TestStore; let store: TestStore;
@ -125,6 +142,7 @@ describe('extensionsSlice', () => {
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@ -149,6 +167,121 @@ describe('extensionsSlice', () => {
expect(store.getState().pluginCatalogError).toBe('boom'); expect(store.getState().pluginCatalogError).toBe('boom');
expect(store.getState().pluginCatalogLoading).toBe(false); 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', () => { describe('fetchPluginReadme', () => {
@ -171,6 +304,15 @@ describe('extensionsSlice', () => {
expect(api.plugins!.getReadme).not.toHaveBeenCalled(); 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', () => { describe('mcpBrowse', () => {
@ -298,24 +440,7 @@ describe('extensionsSlice', () => {
describe('installPlugin', () => { describe('installPlugin', () => {
it('sets progress to pending then success', async () => { it('sets progress to pending then success', async () => {
store.setState({ store.setState({ cliStatus: makeReadyCliStatus() });
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: [],
},
});
const plugins = [makePlugin({ pluginId: 'a@m' })]; const plugins = [makePlugin({ pluginId: 'a@m' })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins); (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' }); (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 () => { it('sets progress to error on failure', async () => {
store.setState({ store.setState({ cliStatus: makeReadyCliStatus() });
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: [],
},
});
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
state: 'error', state: 'error',
error: 'Not found', error: 'Not found',
@ -357,6 +465,77 @@ describe('extensionsSlice', () => {
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error'); 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', () => { describe('uninstallPlugin', () => {
@ -372,6 +551,66 @@ describe('extensionsSlice', () => {
await promise; await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); 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', () => { describe('installMcpServer', () => {

View file

@ -5,8 +5,11 @@ import type { PluginCatalogItem } from '@shared/types/extensions';
import { import {
buildPluginId, buildPluginId,
formatInstallCount, formatInstallCount,
getExtensionActionDisableReason,
getCapabilityLabel, getCapabilityLabel,
getInstallationSummaryLabel,
getPrimaryCapabilityLabel, getPrimaryCapabilityLabel,
hasInstallationInScope,
inferCapabilities, inferCapabilities,
normalizeCategory, normalizeCategory,
normalizeRepoUrl, 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', () => { describe('sanitizeMcpServerName', () => {
it('lowercases and replaces spaces with dashes', () => { it('lowercases and replaces spaces with dashes', () => {
expect(sanitizeMcpServerName('My Server')).toBe('my-server'); expect(sanitizeMcpServerName('My Server')).toBe('my-server');