chore(merge): sync dev into team snapshot split spike

This commit is contained in:
777genius 2026-04-17 08:54:27 +03:00
commit f92b77e3af
37 changed files with 4507 additions and 266 deletions

View file

@ -37,6 +37,10 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
const TIMEOUT_MS = 30_000;
function scopeRequiresProjectPath(scope?: string): boolean {
return scope === 'local' || scope === 'project';
}
export class McpInstallService {
constructor(private readonly aggregator: McpCatalogAggregator) {}
@ -59,6 +63,13 @@ export class McpInstallService {
};
}
if (scopeRequiresProjectPath(scope) && !projectPath) {
return {
state: 'error',
error: `projectPath is required for ${scope} scope`,
};
}
// 3. Validate env var keys (prevent command injection)
for (const key of Object.keys(envValues)) {
if (!ENV_KEY_RE.test(key)) {
@ -212,6 +223,10 @@ export class McpInstallService {
return { state: 'error', error: `Invalid scope: "${scope}".` };
}
if (scopeRequiresProjectPath(scope) && !projectPath) {
return { state: 'error', error: `projectPath is required for ${scope} scope` };
}
for (const key of Object.keys(envValues)) {
if (!ENV_KEY_RE.test(key)) {
return { state: 'error', error: `Invalid env var name: "${key}".` };
@ -319,6 +334,13 @@ export class McpInstallService {
};
}
if (scopeRequiresProjectPath(scope) && !projectPath) {
return {
state: 'error',
error: `projectPath is required for ${scope} scope`,
};
}
if (projectPath && !path.isAbsolute(projectPath)) {
return {
state: 'error',

View file

@ -3,8 +3,8 @@
*
* Sources:
* - User scope: ~/.claude.json mcpServers
* - Local scope: ~/.claude.json projects[projectPath].mcpServers
* - Project scope: .mcp.json in project root
* - Local scope: determined by Claude CLI (may also be in ~/.claude.json)
*
* Both files are managed by the Claude CLI. This service is read-only.
*/
@ -27,30 +27,30 @@ interface TimedCache<T> {
}
export class McpInstallationStateService {
private cache: TimedCache<InstalledMcpEntry[]> | null = null;
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
/**
* Get all installed MCP servers across user and project scopes.
* Get all installed MCP servers across user, local, and project scopes.
*/
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
// Cache is project-path-dependent, so invalidate on path change
if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
return this.cache.data;
const cacheKey = projectPath ?? '__user__';
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.data;
}
const entries: InstalledMcpEntry[] = [];
const claudeConfig = await this.readClaudeConfig();
// User scope: ~/.claude.json
const userEntries = await this.readUserMcpServers();
entries.push(...userEntries);
entries.push(...this.readUserMcpServers(claudeConfig));
// Project scope: .mcp.json
if (projectPath) {
const projectEntries = await this.readProjectMcpServers(projectPath);
entries.push(...projectEntries);
entries.push(...this.readLocalMcpServers(claudeConfig, projectPath));
entries.push(...(await this.readProjectMcpServers(projectPath)));
}
this.cache = { data: entries, fetchedAt: Date.now() };
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
return entries;
}
@ -58,14 +58,42 @@ export class McpInstallationStateService {
* Invalidate cache. Call after install/uninstall operations.
*/
invalidateCache(): void {
this.cache = null;
this.cache.clear();
}
// ── Private ────────────────────────────────────────────────────────────
private async readUserMcpServers(): Promise<InstalledMcpEntry[]> {
private async readClaudeConfig(): Promise<Record<string, unknown> | null> {
const configPath = path.join(getHomeDir(), '.claude.json');
return this.readMcpServersFromFile(configPath, 'user');
try {
const raw = await fs.readFile(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read MCP servers from ${configPath}:`, err);
return null;
}
}
private readUserMcpServers(config: Record<string, unknown> | null): InstalledMcpEntry[] {
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
}
private readLocalMcpServers(
config: Record<string, unknown> | null,
projectPath: string
): InstalledMcpEntry[] {
const projects =
config && typeof config.projects === 'object' && config.projects
? (config.projects as Record<string, unknown>)
: null;
const projectConfig =
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
? (projects[projectPath] as Record<string, unknown>)
: null;
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
}
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
@ -73,6 +101,27 @@ export class McpInstallationStateService {
return this.readMcpServersFromFile(configPath, 'project');
}
private readMcpServersFromConfig(
value: unknown,
scope: 'user' | 'project' | 'local'
): InstalledMcpEntry[] {
const mcpServers =
value && typeof value === 'object'
? (value as Record<string, { command?: string; url?: string }>)
: null;
if (!mcpServers) {
return [];
}
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
let transport: string | undefined;
if (config.command) transport = 'stdio';
else if (config.url) transport = 'http';
return { name, scope, transport };
});
}
private async readMcpServersFromFile(
filePath: string,
scope: 'user' | 'project'
@ -80,21 +129,7 @@ export class McpInstallationStateService {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as Record<string, unknown>;
const mcpServers = json.mcpServers as
| Record<string, { command?: string; url?: string }>
| undefined;
if (!mcpServers || typeof mcpServers !== 'object') {
return [];
}
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
let transport: string | undefined;
if (config.command) transport = 'stdio';
else if (config.url) transport = 'http';
return { name, scope, transport };
});
return this.readMcpServersFromConfig(json.mcpServers, scope);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];

View file

@ -323,6 +323,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<TabsContent value="plugins" className="mt-0 pt-4">
<PluginsPanel
projectPath={projectPath}
pluginFilters={tabState.pluginFilters}
pluginSort={tabState.pluginSort}
selectedPluginId={tabState.selectedPluginId}
@ -339,6 +340,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<TabsContent value="mcp-servers" className="mt-0 pt-4">
<McpServersPanel
projectPath={projectPath}
mcpSearchQuery={tabState.mcpSearchQuery}
mcpSearch={tabState.mcpSearch}
mcpSearchResults={tabState.mcpSearchResults}
@ -371,6 +373,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<CustomMcpServerDialog
open={customMcpDialogOpen}
onClose={() => setCustomMcpDialogOpen(false)}
projectPath={projectPath}
/>
</div>
</div>

View file

@ -37,14 +37,16 @@ const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
interface CustomMcpServerDialogProps {
open: boolean;
onClose: () => void;
projectPath: string | null;
}
type TransportMode = 'stdio' | 'http';
type HttpTransport = 'streamable-http' | 'sse' | 'http';
type Scope = 'local' | 'user';
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
@ -62,6 +64,7 @@ interface EnvEntry {
export const CustomMcpServerDialog = ({
open,
onClose,
projectPath,
}: CustomMcpServerDialogProps): React.JSX.Element => {
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
@ -101,6 +104,12 @@ export const CustomMcpServerDialog = ({
}
}, [open]);
useEffect(() => {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
}
}, [open, projectPath, scope]);
// Auto-fill env vars from saved API keys
useEffect(() => {
if (!open || envVars.length === 0 || !api.apiKeys) return;
@ -168,6 +177,7 @@ export const CustomMcpServerDialog = ({
const request: McpCustomInstallRequest = {
serverName,
scope,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
installSpec,
envValues,
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
@ -197,6 +207,7 @@ export const CustomMcpServerDialog = ({
const canSubmit =
serverName.trim() &&
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
!(scope !== 'user' && !projectPath) &&
!installing;
return (
@ -372,7 +383,11 @@ export const CustomMcpServerDialog = ({
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== 'user' && !projectPath}
>
{opt.label}
</SelectItem>
))}

View file

@ -11,18 +11,28 @@ import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import {
getMcpInstallationSummaryLabel,
getMcpOperationKey,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
import { Github as GithubIcon } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
import type {
InstalledMcpEntry,
McpCatalogItem,
McpServerDiagnostic,
} from '@shared/types/extensions';
interface McpServerCardProps {
server: McpCatalogItem;
isInstalled: boolean;
installedEntry?: InstalledMcpEntry | null;
installedEntries?: InstalledMcpEntry[];
diagnostic?: McpServerDiagnostic | null;
diagnosticsLoading?: boolean;
onClick: (serverId: string) => void;
@ -31,23 +41,42 @@ interface McpServerCardProps {
export const McpServerCard = ({
server,
isInstalled,
installedEntry,
installedEntries = [],
diagnostic,
diagnosticsLoading,
onClick,
}: McpServerCardProps): React.JSX.Element => {
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
const operationKey = getMcpOperationKey(server.id, 'user');
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const installError = useStore((s) => s.installErrors[server.id]);
const installError = useStore((s) => s.installErrors[operationKey]);
const stars = useStore((s) =>
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
);
const canAutoInstall = !!server.installSpec;
const normalizedInstalledEntries = installedEntries.length
? installedEntries
: installedEntry
? [installedEntry]
: [];
const requiresConfiguration =
server.installSpec?.type === 'http' ||
server.envVars.length > 0 ||
server.requiresAuth ||
(server.authHeaders?.length ?? 0) > 0;
const defaultServerName = sanitizeMcpServerName(server.name);
const userInstallEntry =
normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null;
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
const supportsDirectInstalledAction =
isInstalled &&
normalizedInstalledEntries.length === 1 &&
userInstallEntry?.name === defaultServerName &&
!requiresConfiguration;
const shouldShowDirectInstallButton =
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
const [imgError, setImgError] = useState(false);
const hasIcon = !!server.iconUrl && !imgError;
const diagnosticBadgeClass =
@ -103,7 +132,7 @@ export const McpServerCard = ({
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
{installSummaryLabel ?? 'Installed'}
</Badge>
)}
{isInstalled && diagnosticsLoading && !diagnostic && (
@ -224,7 +253,7 @@ export const McpServerCard = ({
</Tooltip>
)}
</div>
{canAutoInstall && !requiresConfiguration && (
{shouldShowDirectInstallButton && (
<div className="shrink-0">
<InstallButton
state={installProgress}
@ -232,19 +261,21 @@ export const McpServerCard = ({
onInstall={() =>
installMcpServer({
registryId: server.id,
serverName: sanitizeMcpServerName(server.name),
serverName: defaultServerName,
scope: 'user',
envValues: {},
headers: [],
})
}
onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))}
onUninstall={() =>
uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user')
}
size="sm"
errorMessage={installError}
/>
</div>
)}
{canAutoInstall && requiresConfiguration && (
{canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
<div className="shrink-0">
<Button
size="sm"

View file

@ -25,54 +25,81 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import {
getMcpInstallationSummaryLabel,
getMcpOperationKey,
getPreferredMcpInstallationEntry,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions';
import type {
InstalledMcpEntry,
McpCatalogItem,
McpHeaderDef,
McpServerDiagnostic,
} from '@shared/types/extensions';
interface McpServerDetailDialogProps {
server: McpCatalogItem | null;
isInstalled: boolean;
installedEntry?: InstalledMcpEntry | null;
installedEntries?: InstalledMcpEntry[];
diagnostic?: McpServerDiagnostic | null;
diagnosticsLoading?: boolean;
projectPath: string | null;
open: boolean;
onClose: () => void;
}
type Scope = 'local' | 'user';
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
export const McpServerDetailDialog = ({
server,
isInstalled,
installedEntry,
installedEntries = [],
diagnostic,
diagnosticsLoading,
projectPath,
open,
onClose,
}: McpServerDetailDialogProps): React.JSX.Element => {
const [scope, setScope] = useState<Scope>('user');
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
const installProgress = useStore(
(s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle'
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
);
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined));
const stars = useStore((s) =>
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
);
const [scope, setScope] = useState<Scope>('user');
const [serverName, setServerName] = useState('');
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
const [imgError, setImgError] = useState(false);
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
const normalizedInstalledEntries = installedEntries.length
? installedEntries
: installedEntry
? [installedEntry]
: [];
const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
const selectedInstalledEntry =
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
// Initialize form when dialog opens or server changes
useEffect(() => {
@ -80,7 +107,6 @@ export const McpServerDetailDialog = ({
return;
}
setServerName(sanitizeMcpServerName(server.name));
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
setHeaders(
(server.authHeaders ?? []).map((header) => ({
@ -93,10 +119,25 @@ export const McpServerDetailDialog = ({
locked: true,
}))
);
setScope('user');
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
setScope(preferredInstalledEntry?.scope ?? 'user');
setImgError(false);
setAutoFilledFields(new Set());
}, [server?.id, open]);
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
useEffect(() => {
if (!server || !open) {
return;
}
setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
}, [open, scope, selectedInstalledEntry?.name, server]);
useEffect(() => {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
}
}, [open, projectPath, scope]);
// Auto-fill env values from saved API keys
useEffect(() => {
@ -121,38 +162,6 @@ export const McpServerDetailDialog = ({
);
}, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-fill env vars from saved API keys
useEffect(() => {
if (!server || server.envVars.length === 0 || !api.apiKeys) return;
const envVarNames = server.envVars.map((env) => env.name);
void api.apiKeys.lookup(envVarNames).then(
(results) => {
if (results.length === 0) return;
const filled = new Set<string>();
const updates: Record<string, string> = {};
for (const r of results) {
updates[r.envVarName] = r.value;
filled.add(r.envVarName);
}
setEnvValues((prev) => {
const next = { ...prev };
for (const [k, v] of Object.entries(updates)) {
// Only auto-fill if the field is empty
if (!next[k]) {
next[k] = v;
}
}
return next;
});
setAutoFilledFields(filled);
},
() => {
// Silently ignore lookup failures
}
);
}, [server?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!server) return <></>;
const canAutoInstall = !!server.installSpec;
@ -169,7 +178,15 @@ export const McpServerDetailDialog = ({
const missingRequiredHeaders = headers.some(
(header) => header.isRequired && !header.value.trim()
);
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
const isInstalledForScope = selectedInstalledEntry !== null;
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
const scopeRequiresProjectPath = scope !== 'user' && !projectPath;
const installDisabled =
!serverName.trim() ||
missingRequiredEnvVars ||
missingRequiredHeaders ||
scopeRequiresProjectPath;
const diagnosticBadgeClass =
diagnostic?.status === 'connected'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
@ -184,13 +201,19 @@ export const McpServerDetailDialog = ({
registryId: server.id,
serverName,
scope,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
envValues,
headers,
});
};
const handleUninstall = () => {
uninstallMcpServer(server.id, serverName, scope);
uninstallMcpServer(
server.id,
uninstallServerName,
uninstallScope,
uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined
);
};
const addHeader = () => {
@ -233,7 +256,7 @@ export const McpServerDetailDialog = ({
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
{installSummaryLabel ?? 'Installed'}
</Badge>
)}
{server.source !== 'official' && <SourceBadge source={server.source} />}
@ -327,7 +350,7 @@ export const McpServerDetailDialog = ({
does not describe them. If connection fails after install, check the provider docs.
</div>
)}
{(isInstalled || diagnosticsLoading) && (
{isInstalledForScope && (
<div className="space-y-2 rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-text">Claude Status</span>
@ -366,7 +389,7 @@ export const McpServerDetailDialog = ({
{canAutoInstall && (
<div className="space-y-3 rounded-md border border-border bg-surface-raised p-4">
<h4 className="text-sm font-medium text-text">
{isInstalled ? 'Manage Installation' : 'Install Server'}
{isInstalledForScope ? 'Manage Installation' : 'Install Server'}
</h4>
{/* Server name */}
@ -380,6 +403,7 @@ export const McpServerDetailDialog = ({
onChange={(e) => setServerName(e.target.value)}
placeholder="my-server"
className="h-8 text-sm"
disabled={isInstalledForScope}
/>
</div>
@ -392,7 +416,11 @@ export const McpServerDetailDialog = ({
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== 'user' && !projectPath}
>
{opt.label}
</SelectItem>
))}
@ -499,7 +527,7 @@ export const McpServerDetailDialog = ({
<div className="flex justify-end pt-1">
<InstallButton
state={installProgress}
isInstalled={isInstalled}
isInstalled={isInstalledForScope}
onInstall={handleInstall}
onUninstall={handleUninstall}
disabled={installDisabled}

View file

@ -16,7 +16,10 @@ import {
import { useStore } from '@renderer/store';
import { formatRelativeTime } from '@renderer/utils/formatters';
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import {
getPreferredMcpInstallationEntry,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -55,6 +58,7 @@ function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatal
}
interface McpServersPanelProps {
projectPath: string | null;
mcpSearchQuery: string;
mcpSearch: (query: string) => void;
mcpSearchResults: McpCatalogItem[];
@ -65,6 +69,7 @@ interface McpServersPanelProps {
}
export const McpServersPanel = ({
projectPath,
mcpSearchQuery,
mcpSearch,
mcpSearchResults,
@ -107,10 +112,10 @@ export const McpServersPanel = ({
// Load initial browse data
useEffect(() => {
if (browseCatalog.length === 0 && !browseLoading) {
if (browseCatalog.length === 0 && !browseLoading && !browseError) {
void mcpBrowse();
}
}, [browseCatalog.length, browseLoading, mcpBrowse]);
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
useEffect(() => {
void runMcpDiagnostics();
@ -136,17 +141,24 @@ export const McpServersPanel = ({
[installedServers]
);
const installedEntriesByName = useMemo(
() => new Map(installedServers.map((entry) => [entry.name.toLowerCase(), entry] as const)),
[installedServers]
);
const installedEntriesByName = useMemo(() => {
const entriesByName = new Map<string, InstalledMcpEntry[]>();
for (const entry of installedServers) {
const key = entry.name.toLowerCase();
entriesByName.set(key, [...(entriesByName.get(key) ?? []), entry]);
}
return entriesByName;
}, [installedServers]);
/** Check if a catalog server is installed by comparing sanitized names */
const isServerInstalled = (server: McpCatalogItem): boolean =>
installedNames.has(sanitizeMcpServerName(server.name));
const getInstalledEntries = (server: McpCatalogItem): InstalledMcpEntry[] =>
installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? [];
const getInstalledEntry = (server: McpCatalogItem): InstalledMcpEntry | null =>
installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? null;
getPreferredMcpInstallationEntry(getInstalledEntries(server));
const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => {
const installedEntry = getInstalledEntry(server);
@ -374,6 +386,8 @@ export const McpServersPanel = ({
key={server.id}
server={server}
isInstalled={isServerInstalled(server)}
installedEntry={getInstalledEntry(server)}
installedEntries={getInstalledEntries(server)}
diagnostic={getDiagnostic(server)}
diagnosticsLoading={mcpDiagnosticsLoading}
onClick={setSelectedMcpServerId}
@ -400,8 +414,11 @@ export const McpServersPanel = ({
<McpServerDetailDialog
server={selectedServer}
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null}
installedEntries={selectedServer ? getInstalledEntries(selectedServer) : []}
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
diagnosticsLoading={mcpDiagnosticsLoading}
projectPath={projectPath}
open={selectedMcpServerId !== null}
onClose={() => setSelectedMcpServerId(null)}
/>

View file

@ -7,6 +7,7 @@ import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
getInstallationSummaryLabel,
getPluginOperationKey,
hasInstallationInScope,
inferCapabilities,
normalizeCategory,
@ -27,10 +28,11 @@ interface PluginCardProps {
export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => {
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
const operationKey = getPluginOperationKey(plugin.pluginId, 'user');
const installProgress = useStore((s) => s.pluginInstallProgress[operationKey] ?? 'idle');
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
const installError = useStore((s) => s.installErrors[operationKey]);
const isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
const baseStriped = index % 2 === 0;

View file

@ -27,6 +27,7 @@ import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
getInstallationSummaryLabel,
getPluginOperationKey,
hasInstallationInScope,
inferCapabilities,
normalizeCategory,
@ -44,6 +45,7 @@ interface PluginDetailDialogProps {
plugin: EnrichedPlugin | null;
open: boolean;
onClose: () => void;
projectPath: string | null;
}
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
@ -56,31 +58,20 @@ export const PluginDetailDialog = ({
plugin,
open,
onClose,
projectPath,
}: PluginDetailDialogProps): React.JSX.Element => {
const {
fetchPluginReadme,
readmes,
readmeLoading,
installPlugin,
uninstallPlugin,
pluginCatalogProjectPath,
} = useStore(
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme,
readmes: s.pluginReadmes,
readmeLoading: s.pluginReadmeLoading,
installPlugin: s.installPlugin,
uninstallPlugin: s.uninstallPlugin,
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
}))
);
const installProgress = useStore(
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
);
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
const [scope, setScope] = useState<InstallScope>('user');
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
const projectScopeAvailable = Boolean(projectPath);
useEffect(() => {
if (plugin && open) {
@ -100,6 +91,12 @@ export const PluginDetailDialog = ({
}
}, [projectScopeAvailable, scope]);
const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null;
const installProgress = useStore(
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
);
const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined));
if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin);
@ -202,16 +199,14 @@ export const PluginDetailDialog = ({
installPlugin({
pluginId: plugin.pluginId,
scope,
...(scope !== 'user' && pluginCatalogProjectPath
? { projectPath: pluginCatalogProjectPath }
: {}),
...(scope !== 'user' && projectPath ? { projectPath } : {}),
})
}
onUninstall={() =>
uninstallPlugin(
plugin.pluginId,
scope,
scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined
scope !== 'user' ? (projectPath ?? undefined) : undefined
)
}
size="default"

View file

@ -35,6 +35,7 @@ import type {
} from '@shared/types/extensions';
interface PluginsPanelProps {
projectPath: string | null;
pluginFilters: PluginFilters;
pluginSort: { field: PluginSortField; order: 'asc' | 'desc' };
selectedPluginId: string | null;
@ -111,6 +112,7 @@ function selectFilteredPlugins(
}
export const PluginsPanel = ({
projectPath,
pluginFilters,
pluginSort,
selectedPluginId,
@ -395,6 +397,7 @@ export const PluginsPanel = ({
plugin={selectedPlugin}
open={selectedPluginId !== null}
onClose={() => setSelectedPluginId(null)}
projectPath={projectPath}
/>
</div>
);

View file

@ -26,6 +26,8 @@ import { useStore } from '@renderer/store';
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { resolveSkillProjectPath } from './skillProjectUtils';
interface SkillDetailDialogProps {
skillId: string | null;
open: boolean;
@ -58,8 +60,13 @@ export const SkillDetailDialog = ({
useEffect(() => {
if (!open || !skillId) return;
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
}, [fetchSkillDetail, open, projectPath, skillId]);
void fetchSkillDetail(
skillId,
detail?.item.scope
? resolveSkillProjectPath(detail.item.scope, projectPath, detail.item.projectRoot)
: (projectPath ?? undefined)
).catch(() => undefined);
}, [detail?.item.projectRoot, detail?.item.scope, fetchSkillDetail, open, projectPath, skillId]);
useEffect(() => {
if (!open) {
@ -70,6 +77,9 @@ export const SkillDetailDialog = ({
}, [open]);
const item = detail?.item;
const effectiveProjectPath = item
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
: (projectPath ?? undefined);
function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string {
return `.${rootKind}`;
@ -92,7 +102,7 @@ export const SkillDetailDialog = ({
try {
await deleteSkill({
skillId: item.id,
projectPath: projectPath ?? undefined,
projectPath: effectiveProjectPath,
});
setDeleteConfirmOpen(false);
onDeleted();
@ -125,7 +135,7 @@ export const SkillDetailDialog = ({
variant="outline"
size="sm"
onClick={() => {
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
void fetchSkillDetail(skillId, effectiveProjectPath).catch(() => undefined);
}}
>
Retry
@ -288,7 +298,7 @@ export const SkillDetailDialog = ({
<Button
variant="outline"
size="sm"
onClick={() => void api.openPath(item.skillFile, projectPath ?? undefined)}
onClick={() => void api.openPath(item.skillFile, effectiveProjectPath)}
>
<ExternalLink className="mr-1.5 size-3.5" />
Open SKILL.md

View file

@ -32,7 +32,10 @@ import {
readSkillTemplateContent,
updateSkillTemplateFrontmatter,
} from './skillDraftUtils';
import { toSuggestedSkillFolderName } from './skillFolderNameUtils';
import { resolveSkillProjectPath } from './skillProjectUtils';
import { SkillReviewDialog } from './SkillReviewDialog';
import { validateSkillFolderName } from './skillValidationUtils';
import type {
SkillDetail,
@ -60,16 +63,6 @@ function parseInitialDescription(detail: SkillDetail | null): string {
return detail?.item.description ?? '';
}
function toSuggestedFolderName(value: string): string {
return value
.normalize('NFKD')
.replace(/[^\x00-\x7F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
export const SkillEditorDialog = ({
open,
mode,
@ -191,7 +184,7 @@ export const SkillEditorDialog = ({
notes: nextNotes,
});
const rawInput = readSkillTemplateContent(nextRawContent);
const suggestedFolderName = toSuggestedFolderName(nextName || 'New Skill');
const suggestedFolderName = toSuggestedSkillFolderName(nextName || 'New Skill');
const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody;
setScope(nextScope);
@ -227,15 +220,43 @@ export const SkillEditorDialog = ({
setMutationError(null);
}, [detail, mode, open, projectPath]);
useEffect(() => {
if (open) {
return;
}
setReviewPreview(null);
setReviewOpen(false);
setReviewLoading(false);
setSaveLoading(false);
setMutationError(null);
}, [open]);
useEffect(() => {
if (open && mode === 'create' && scope === 'project' && !projectPath) {
setScope('user');
}
}, [mode, open, projectPath, scope]);
useEffect(() => {
rawContentRef.current = rawContent;
}, [rawContent]);
const effectiveProjectPath = useMemo(
() =>
resolveSkillProjectPath(
scope,
projectPath,
mode === 'edit' ? detail?.item.projectRoot : undefined
),
[detail?.item.projectRoot, mode, projectPath, scope]
);
const request = useMemo(
() => ({
scope,
rootKind,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
projectPath: effectiveProjectPath,
folderName,
existingSkillId: mode === 'edit' ? detail?.item.id : undefined,
files: buildSkillDraftFiles({
@ -252,10 +273,10 @@ export const SkillEditorDialog = ({
includeReferences,
includeScripts,
mode,
projectPath,
rawContent,
rootKind,
scope,
effectiveProjectPath,
]
);
const draftFilePaths = useMemo(
@ -285,7 +306,11 @@ export const SkillEditorDialog = ({
if (!folderName.trim()) {
return 'Choose a folder name for this skill.';
}
if (scope === 'project' && !projectPath) {
const folderNameError = validateSkillFolderName(folderName);
if (folderNameError) {
return folderNameError;
}
if (scope === 'project' && !effectiveProjectPath) {
return 'Project skills need an active project.';
}
return null;
@ -468,7 +493,7 @@ export const SkillEditorDialog = ({
const nextValue = event.target.value;
setName(nextValue);
if (mode === 'create' && !folderNameEdited) {
setFolderName(toSuggestedFolderName(nextValue || 'New Skill'));
setFolderName(toSuggestedSkillFolderName(nextValue || 'New Skill'));
}
applyFormToRawContent({ name: nextValue });
}}
@ -713,6 +738,7 @@ export const SkillEditorDialog = ({
size="sm"
onClick={() => {
setManualRawEdit(false);
setCustomMarkdownDetected(false);
const nextRawContent = buildSkillTemplate({
name,
description,

View file

@ -22,7 +22,10 @@ import {
import { useStore } from '@renderer/store';
import { FileSearch, FolderOpen, X } from 'lucide-react';
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
import { SkillReviewDialog } from './SkillReviewDialog';
import { resolveSkillProjectPath } from './skillProjectUtils';
import { validateSkillFolderName, validateSkillImportSourceDir } from './skillValidationUtils';
import type { SkillReviewPreview } from '@shared/types/extensions';
@ -68,6 +71,7 @@ export const SkillImportDialog = ({
const [sourceDir, setSourceDir] = useState('');
const [folderName, setFolderName] = useState('');
const [folderNameEdited, setFolderNameEdited] = useState(false);
const [scope, setScope] = useState<'user' | 'project'>('user');
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
@ -80,6 +84,7 @@ export const SkillImportDialog = ({
if (!open) return;
setSourceDir('');
setFolderName('');
setFolderNameEdited(false);
setScope(projectPath ? 'project' : 'user');
setRootKind('claude');
setPreview(null);
@ -89,27 +94,63 @@ export const SkillImportDialog = ({
setMutationError(null);
}, [open, projectPath]);
useEffect(() => {
if (open) {
return;
}
setPreview(null);
setReviewOpen(false);
setReviewLoading(false);
setImportLoading(false);
setMutationError(null);
}, [open]);
useEffect(() => {
if (!open || folderNameEdited) {
return;
}
setFolderName(sourceDir.trim() ? getSuggestedSkillFolderNameFromPath(sourceDir) : '');
}, [folderNameEdited, open, sourceDir]);
useEffect(() => {
if (open && scope === 'project' && !projectPath) {
setScope('user');
}
}, [open, projectPath, scope]);
async function handleChooseFolder(): Promise<void> {
const selected = await api.config.selectFolders();
const first = selected[0];
if (!first) return;
setSourceDir(first);
if (!folderName) {
const segments = first.split(/[\\/]/u).filter(Boolean);
setFolderName(segments.at(-1) ?? '');
}
}
async function handleReview(): Promise<void> {
const normalizedSourceDir = sourceDir.trim();
const normalizedFolderName = folderName.trim();
const sourceDirError = validateSkillImportSourceDir(sourceDir);
if (sourceDirError) {
setMutationError(sourceDirError);
return;
}
const folderNameError =
normalizedFolderName.length > 0 ? validateSkillFolderName(normalizedFolderName) : null;
if (folderNameError) {
setMutationError(folderNameError);
return;
}
setReviewLoading(true);
setMutationError(null);
try {
const nextPreview = await previewSkillImport({
sourceDir,
folderName: folderName || undefined,
sourceDir: normalizedSourceDir,
folderName: normalizedFolderName || undefined,
scope,
rootKind,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
projectPath: resolveSkillProjectPath(scope, projectPath),
});
setPreview(nextPreview);
setReviewOpen(true);
@ -125,15 +166,18 @@ export const SkillImportDialog = ({
}
async function handleConfirmImport(): Promise<void> {
const normalizedSourceDir = sourceDir.trim();
const normalizedFolderName = folderName.trim();
setImportLoading(true);
setMutationError(null);
try {
const detail = await applySkillImport({
sourceDir,
folderName: folderName || undefined,
sourceDir: normalizedSourceDir,
folderName: normalizedFolderName || undefined,
scope,
rootKind,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
projectPath: resolveSkillProjectPath(scope, projectPath),
reviewPlanId: preview?.planId,
});
setReviewOpen(false);
@ -190,7 +234,10 @@ export const SkillImportDialog = ({
<Input
id="skill-import-folder"
value={folderName}
onChange={(event) => setFolderName(event.target.value)}
onChange={(event) => {
setFolderNameEdited(true);
setFolderName(event.target.value);
}}
placeholder="Defaults to source folder name"
/>
</div>
@ -260,7 +307,7 @@ export const SkillImportDialog = ({
</p>
<Button
onClick={() => void handleReview()}
disabled={!sourceDir || reviewLoading || importLoading}
disabled={!sourceDir.trim() || reviewLoading || importLoading}
>
<FileSearch className="mr-1.5 size-3.5" />
{reviewLoading ? 'Preparing...' : 'Review And Import'}

View file

@ -25,6 +25,7 @@ import { SearchInput } from '../common/SearchInput';
import { SkillDetailDialog } from './SkillDetailDialog';
import { SkillEditorDialog } from './SkillEditorDialog';
import { SkillImportDialog } from './SkillImportDialog';
import { resolveSkillProjectPath } from './skillProjectUtils';
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
@ -109,6 +110,7 @@ export const SkillsPanel = ({
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
const selectedSkillItemRef = useRef<SkillCatalogItem | SkillDetail['item'] | null>(null);
selectedSkillIdRef.current = selectedSkillId;
const mergedSkills = useMemo(
@ -116,6 +118,9 @@ export const SkillsPanel = ({
[projectSkills, userSkills]
);
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
selectedSkillItemRef.current = selectedSkillId
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
: null;
useEffect(() => {
if (!selectedSkillId) return;
@ -155,10 +160,19 @@ export const SkillsPanel = ({
if (!shouldRefresh) return;
void fetchSkillsCatalog(projectPath ?? undefined);
if (selectedSkillIdRef.current) {
void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined).catch(
() => undefined
);
const selectedSkillId = selectedSkillIdRef.current;
const selectedSkillItem = selectedSkillItemRef.current;
if (selectedSkillId) {
void fetchSkillDetail(
selectedSkillId,
selectedSkillItem
? resolveSkillProjectPath(
selectedSkillItem.scope,
projectPath,
selectedSkillItem.projectRoot
)
: (projectPath ?? undefined)
).catch(() => undefined);
}
});

View file

@ -0,0 +1,19 @@
export function toSuggestedSkillFolderName(value: string, fallback = 'new-skill'): string {
const normalized = value
.normalize('NFKD')
.replace(/[^\x00-\x7F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return normalized || fallback;
}
export function getSuggestedSkillFolderNameFromPath(
value: string,
fallback = 'imported-skill'
): string {
const segments = value.split(/[\\/]/u).filter(Boolean);
return toSuggestedSkillFolderName(segments.at(-1) ?? '', fallback);
}

View file

@ -0,0 +1,13 @@
import type { SkillScope } from '@shared/types/extensions';
export function resolveSkillProjectPath(
scope: SkillScope,
currentProjectPath: string | null,
itemProjectRoot?: string | null
): string | undefined {
if (scope !== 'project') {
return undefined;
}
return itemProjectRoot ?? currentProjectPath ?? undefined;
}

View file

@ -0,0 +1,31 @@
const MAX_SKILL_FOLDER_NAME_LENGTH = 255;
const INVALID_SKILL_FOLDER_NAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/u;
export function validateSkillImportSourceDir(value: string): string | null {
if (value.trim().length === 0) {
return 'Choose a skill folder to import.';
}
return null;
}
export function validateSkillFolderName(value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) {
return 'Choose a folder name for this skill.';
}
if (trimmed.length > MAX_SKILL_FOLDER_NAME_LENGTH) {
return `Folder name must be ${MAX_SKILL_FOLDER_NAME_LENGTH} characters or fewer.`;
}
if (trimmed === '.' || trimmed === '..') {
return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.';
}
if (INVALID_SKILL_FOLDER_NAME_CHARS.test(trimmed)) {
return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.';
}
return null;
}

View file

@ -58,6 +58,7 @@ export function useExtensionsTabState() {
// ── Debounced MCP search ──
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mcpSearchRequestSeqRef = useRef(0);
// Cleanup timer on unmount
useEffect(() => {
@ -65,11 +66,25 @@ export function useExtensionsTabState() {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
mcpSearchRequestSeqRef.current += 1;
};
}, []);
useEffect(() => {
if (activeSubTab !== 'plugins' && selectedPluginId !== null) {
setSelectedPluginId(null);
}
if (activeSubTab !== 'mcp-servers' && selectedMcpServerId !== null) {
setSelectedMcpServerId(null);
}
if (activeSubTab !== 'skills' && selectedSkillId !== null) {
setSelectedSkillId(null);
}
}, [activeSubTab, selectedMcpServerId, selectedPluginId, selectedSkillId]);
const mcpSearch = useCallback((query: string) => {
setMcpSearchQuery(query);
const requestId = ++mcpSearchRequestSeqRef.current;
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
@ -86,17 +101,25 @@ export function useExtensionsTabState() {
searchTimerRef.current = setTimeout(() => {
if (!api.mcpRegistry) {
setMcpSearchLoading(false);
if (mcpSearchRequestSeqRef.current === requestId) {
setMcpSearchLoading(false);
}
return;
}
void api.mcpRegistry.search(query).then(
(result: McpSearchResult) => {
if (mcpSearchRequestSeqRef.current !== requestId) {
return;
}
setMcpSearchResults(result.servers);
setMcpSearchWarnings(result.warnings);
setMcpSearchLoading(false);
},
() => {
if (mcpSearchRequestSeqRef.current !== requestId) {
return;
}
setMcpSearchLoading(false);
setMcpSearchWarnings(['Search failed']);
}

View file

@ -5,6 +5,7 @@
import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
@ -59,7 +60,7 @@ export interface ExtensionsSlice {
// ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>;
mcpInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>; // keyed by pluginId or registryId
installErrors: Record<string, string>; // keyed by scoped operation key
// ── API Keys ──
apiKeys: ApiKeyEntry[];
@ -130,6 +131,7 @@ export interface ExtensionsSlice {
let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol } | null = null;
let pluginCatalogRequestSeq = 0;
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
let mcpDiagnosticsInFlight: Promise<void> | null = null;
let skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0;
@ -150,6 +152,10 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
return new Set(catalog.map((plugin) => plugin.pluginId));
}
function buildPluginOperationKeys(pluginId: string): string[] {
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
}
function clearPluginOperationState(
pluginIds: Set<string>,
pluginInstallProgress: Record<string, ExtensionOperationState>,
@ -166,8 +172,10 @@ function clearPluginOperationState(
const nextInstallErrors = { ...installErrors };
for (const pluginId of pluginIds) {
delete nextPluginInstallProgress[pluginId];
delete nextInstallErrors[pluginId];
for (const operationKey of buildPluginOperationKeys(pluginId)) {
delete nextPluginInstallProgress[operationKey];
delete nextInstallErrors[operationKey];
}
}
return {
@ -176,40 +184,118 @@ function clearPluginOperationState(
};
}
function clearPluginSuccessResetTimer(pluginId: string): void {
const timer = pluginSuccessResetTimers.get(pluginId);
function clearPluginSuccessResetTimer(operationKey: string): void {
const timer = pluginSuccessResetTimers.get(operationKey);
if (!timer) {
return;
}
clearTimeout(timer);
pluginSuccessResetTimers.delete(pluginId);
pluginSuccessResetTimers.delete(operationKey);
}
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
for (const pluginId of pluginIds) {
clearPluginSuccessResetTimer(pluginId);
for (const operationKey of buildPluginOperationKeys(pluginId)) {
clearPluginSuccessResetTimer(operationKey);
}
}
}
function schedulePluginSuccessReset(
pluginId: string,
operationKey: string,
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
): void {
clearPluginSuccessResetTimer(pluginId);
clearPluginSuccessResetTimer(operationKey);
const timer = setTimeout(() => {
pluginSuccessResetTimers.delete(pluginId);
pluginSuccessResetTimers.delete(operationKey);
set((prev) => {
if (prev.pluginInstallProgress[pluginId] !== 'success') {
if (prev.pluginInstallProgress[operationKey] !== 'success') {
return {};
}
return {
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' },
};
});
}, SUCCESS_DISPLAY_MS);
pluginSuccessResetTimers.set(pluginId, timer);
pluginSuccessResetTimers.set(operationKey, timer);
}
function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string {
return `mcp-custom:${serverName}:${scope}`;
}
function clearMcpSuccessResetTimer(operationKey: string): void {
const timer = mcpSuccessResetTimers.get(operationKey);
if (!timer) {
return;
}
clearTimeout(timer);
mcpSuccessResetTimers.delete(operationKey);
}
function scheduleMcpSuccessReset(
operationKey: string,
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
): void {
clearMcpSuccessResetTimer(operationKey);
const timer = setTimeout(() => {
mcpSuccessResetTimers.delete(operationKey);
set((prev) => {
if (prev.mcpInstallProgress[operationKey] !== 'success') {
return {};
}
return {
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'idle' },
};
});
}, SUCCESS_DISPLAY_MS);
mcpSuccessResetTimers.set(operationKey, timer);
}
function clearMcpProjectScopedOperationState(
mcpInstallProgress: Record<string, ExtensionOperationState>,
installErrors: Record<string, string>
): {
mcpInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>;
} {
const nextMcpInstallProgress = { ...mcpInstallProgress };
const nextInstallErrors = { ...installErrors };
for (const operationKey of Object.keys(nextMcpInstallProgress)) {
if (
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
) {
delete nextMcpInstallProgress[operationKey];
}
}
for (const operationKey of Object.keys(nextInstallErrors)) {
if (
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
) {
delete nextInstallErrors[operationKey];
}
}
return {
mcpInstallProgress: nextMcpInstallProgress,
installErrors: nextInstallErrors,
};
}
function clearMcpProjectScopedSuccessResetTimers(): void {
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) {
clearMcpSuccessResetTimer(operationKey);
}
}
}
function getSkillsCatalogKey(projectPath?: string): string {
@ -226,6 +312,7 @@ 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.';
const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local'];
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set,
@ -425,9 +512,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
try {
const installed = await api.mcpRegistry.getInstalled(projectPath);
set({
mcpInstalledServers: installed,
mcpInstalledProjectPath: projectPath ?? null,
set((prev) => {
const nextProjectPath = projectPath ?? null;
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
const nextOperationState = isSameProjectContext
? {
mcpInstallProgress: prev.mcpInstallProgress,
installErrors: prev.installErrors,
}
: clearMcpProjectScopedOperationState(prev.mcpInstallProgress, prev.installErrors);
if (!isSameProjectContext) {
clearMcpProjectScopedSuccessResetTimers();
}
return {
mcpInstalledServers: installed,
mcpInstalledProjectPath: nextProjectPath,
mcpInstallProgress: nextOperationState.mcpInstallProgress,
installErrors: nextOperationState.installErrors,
};
});
} catch {
// Silently fail — installed state is supplementary
@ -692,6 +796,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
request.scope !== 'user'
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: request.projectPath;
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
const effectiveRequest =
effectiveProjectPath === request.projectPath
? request
@ -721,47 +826,47 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
: null;
if (preflightError) {
clearPluginSuccessResetTimer(request.pluginId);
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: preflightError },
}));
return;
}
clearPluginSuccessResetTimer(request.pluginId);
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
installErrors: { ...prev.installErrors, [operationKey]: '' },
}));
try {
const result = await api.plugins.install(effectiveRequest);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: {
...prev.installErrors,
[request.pluginId]: result.error ?? 'Install failed',
[operationKey]: result.error ?? 'Install failed',
},
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
}));
// Refresh catalog to pick up new installed state
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
schedulePluginSuccessReset(request.pluginId, set);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {
clearPluginSuccessResetTimer(request.pluginId);
clearPluginSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: message },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: message },
}));
}
},
@ -770,77 +875,85 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return;
const effectiveScope = scope ?? 'user';
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
const effectiveProjectPath =
scope && scope !== 'user'
effectiveScope !== 'user'
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: projectPath;
if (scope && scope !== 'user' && !effectiveProjectPath) {
clearPluginSuccessResetTimer(pluginId);
if (effectiveScope !== 'user' && !effectiveProjectPath) {
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE },
}));
return;
}
clearPluginSuccessResetTimer(pluginId);
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
}));
try {
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: {
...prev.installErrors,
[operationKey]: result.error ?? 'Uninstall failed',
},
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
}));
// Refresh catalog
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
schedulePluginSuccessReset(pluginId, set);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {
clearPluginSuccessResetTimer(pluginId);
clearPluginSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Uninstall failed';
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: message },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: message },
}));
}
},
// ── MCP install ──
installMcpServer: async (request: McpInstallRequest) => {
const operationKey = getMcpOperationKey(request.registryId, request.scope);
if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: {
...prev.installErrors,
[request.registryId]: 'MCP Registry not available',
[operationKey]: 'MCP Registry not available',
},
}));
return;
}
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
}));
try {
const result = await api.mcpRegistry.install(request);
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: {
...prev.installErrors,
[request.registryId]: result.error ?? 'Install failed',
[operationKey]: result.error ?? 'Install failed',
},
}));
return;
@ -852,27 +965,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
]);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
}));
setTimeout(() => {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
scheduleMcpSuccessReset(operationKey, set);
} catch (err) {
clearMcpSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
installErrors: { ...prev.installErrors, [request.registryId]: message },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: message },
}));
}
},
// ── MCP custom install ──
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
const operationScope = request.scope;
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope);
if (!api.mcpRegistry) {
const progressKey = `custom:${request.serverName}`;
clearMcpSuccessResetTimer(progressKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
@ -880,7 +992,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return;
}
const progressKey = `custom:${request.serverName}`;
clearMcpSuccessResetTimer(progressKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
}));
@ -904,12 +1016,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
}));
setTimeout(() => {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
scheduleMcpSuccessReset(progressKey, set);
} catch (err) {
clearMcpSuccessResetTimer(progressKey);
const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
@ -925,26 +1034,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
scope?: string,
projectPath?: string
) => {
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
const operationKey = getMcpOperationKey(registryId, operationScope);
if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: 'MCP Registry not available' },
}));
return;
}
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
}));
try {
const result = await api.mcpRegistry.uninstall(name, scope, projectPath);
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: {
...prev.installErrors,
[registryId]: result.error ?? 'Uninstall failed',
[operationKey]: result.error ?? 'Uninstall failed',
},
}));
return;
@ -956,19 +1069,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
]);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
}));
setTimeout(() => {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
scheduleMcpSuccessReset(operationKey, set);
} catch (err) {
clearMcpSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Uninstall failed';
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
installErrors: { ...prev.installErrors, [registryId]: message },
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: message },
}));
}
},

View file

@ -4,6 +4,7 @@
import type {
CliInstallationStatus,
InstalledMcpEntry,
InstalledPluginEntry,
InstallScope,
PluginCapability,
@ -100,6 +101,20 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
return `${pluginName}@${marketplaceName}`;
}
/**
* Namespaced operation-state key for plugin install/uninstall UI state.
*/
export function getPluginOperationKey(pluginId: string, scope: InstallScope): string {
return `plugin:${pluginId}:${scope}`;
}
/**
* Namespaced operation-state key for MCP install/uninstall UI state.
*/
export function getMcpOperationKey(registryId: string, scope: InstallScope): string {
return `mcp:${registryId}:${scope}`;
}
/**
* Check whether a plugin has an installation for the selected scope.
*/
@ -137,12 +152,64 @@ export function getInstallationSummaryLabel(
}
}
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
local: 0,
project: 1,
user: 2,
};
/**
* Pick the MCP installation entry that Claude will actually use.
* Scope precedence matches Claude Code: local > project > user.
*/
export function getPreferredMcpInstallationEntry(
installations: InstalledMcpEntry[]
): InstalledMcpEntry | null {
if (installations.length === 0) {
return null;
}
return [...installations].sort(
(left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope]
)[0]!;
}
/**
* Build a concise install-status label for MCP badges.
*/
export function getMcpInstallationSummaryLabel(
installations: Pick<InstalledMcpEntry, '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;
cliStatus: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError'
> | null;
cliStatusLoading: boolean;
}): string | null {
const { isInstalled, cliStatus, cliStatusLoading } = options;
@ -155,6 +222,9 @@ export function getExtensionActionDisableReason(options: {
}
if (cliStatus.installed === false) {
if (cliStatus.binaryPath && cliStatus.launchError) {
return 'Claude CLI was found but failed to start. Open the Dashboard to repair or reinstall it.';
}
return 'Claude CLI required. Install it from the Dashboard.';
}

View file

@ -130,6 +130,7 @@ describe('McpInstallService', () => {
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'local',
projectPath: '/tmp/test',
envValues: {},
headers: [],
});
@ -216,6 +217,34 @@ describe('McpInstallService', () => {
expect(result.state).toBe('error');
expect(result.error).toContain('Manual setup required');
});
it('rejects project scope install without project path', async () => {
const result = await service.install({
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'project',
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('rejects local scope install without project path', async () => {
const result = await service.install({
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'local',
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
});
// ── install: error masking ──────────────────────────────────────────────────
@ -259,6 +288,42 @@ describe('McpInstallService', () => {
});
});
describe('installCustom (validation)', () => {
it('rejects project scope custom install without project path', async () => {
const result = await service.installCustom({
serverName: 'custom-context7',
scope: 'project',
installSpec: {
type: 'stdio',
npmPackage: '@upstash/context7-mcp',
},
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('rejects local scope custom install without project path', async () => {
const result = await service.installCustom({
serverName: 'custom-context7',
scope: 'local',
installSpec: {
type: 'stdio',
npmPackage: '@upstash/context7-mcp',
},
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
});
// ── uninstall ───────────────────────────────────────────────────────────────
describe('uninstall', () => {
@ -293,6 +358,22 @@ describe('McpInstallService', () => {
expect(mockExecCli).not.toHaveBeenCalled();
});
it('rejects project scope uninstall without project path', 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 uninstall without project path', async () => {
const result = await service.uninstall('context7', 'local');
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('returns error on CLI failure', async () => {
mockExecCli.mockRejectedValue(new Error('Not found'));

View file

@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
vi.mock('@main/utils/pathDecoder', () => ({
getHomeDir: () => '/tmp/mock-home',
}));
vi.mock('node:fs/promises');
describe('McpInstallationStateService', () => {
let service: McpInstallationStateService;
const mockedFs = vi.mocked(fs);
beforeEach(() => {
service = new McpInstallationStateService();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getInstalled', () => {
it('includes local scope from the current project entry in ~/.claude.json', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath === '/tmp/mock-home/.claude.json') {
return JSON.stringify({
mcpServers: {
context7: { command: 'npx -y @upstash/context7-mcp' },
},
projects: {
'/tmp/project-a': {
mcpServers: {
stripe: { url: 'https://mcp.stripe.com' },
},
},
},
});
}
if (normalizedPath === '/tmp/project-a/.mcp.json') {
return JSON.stringify({
mcpServers: {
paypal: { url: 'https://mcp.paypal.com/mcp' },
},
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalled('/tmp/project-a');
expect(entries).toEqual([
{ name: 'context7', scope: 'user', transport: 'stdio' },
{ name: 'stripe', scope: 'local', transport: 'http' },
{ name: 'paypal', scope: 'project', transport: 'http' },
]);
});
it('caches results within TTL for the same project path', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath === '/tmp/mock-home/.claude.json') {
return JSON.stringify({
mcpServers: {
context7: { command: 'npx -y @upstash/context7-mcp' },
},
});
}
if (normalizedPath === '/tmp/project-a/.mcp.json') {
return JSON.stringify({
mcpServers: {
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
},
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
await service.getInstalled('/tmp/project-a');
await service.getInstalled('/tmp/project-a');
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
});
it('caches results independently per project path', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = String(filePath);
if (normalizedPath === '/tmp/mock-home/.claude.json') {
return JSON.stringify({
mcpServers: {
context7: { command: 'npx -y @upstash/context7-mcp' },
},
projects: {
'/tmp/project-a': {
mcpServers: {
stripe: { url: 'https://mcp.stripe.com' },
},
},
'/tmp/project-b': {
mcpServers: {
github: { command: 'uvx github-mcp' },
},
},
},
});
}
if (normalizedPath === '/tmp/project-a/.mcp.json') {
return JSON.stringify({
mcpServers: {
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
},
});
}
if (normalizedPath === '/tmp/project-b/.mcp.json') {
return JSON.stringify({
mcpServers: {
'repo-b-server': { command: 'uvx repo-b-mcp' },
},
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const projectAEntries = await service.getInstalled('/tmp/project-a');
const projectBEntries = await service.getInstalled('/tmp/project-b');
expect(projectAEntries).toEqual([
{ name: 'context7', scope: 'user', transport: 'stdio' },
{ name: 'stripe', scope: 'local', transport: 'http' },
{ name: 'repo-a-server', scope: 'project', transport: 'http' },
]);
expect(projectBEntries).toEqual([
{ name: 'context7', scope: 'user', transport: 'stdio' },
{ name: 'github', scope: 'local', transport: 'stdio' },
{ name: 'repo-b-server', scope: 'project', transport: 'stdio' },
]);
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
});
});
});

View file

@ -0,0 +1,262 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
interface StoreState {
installCustomMcpServer: ReturnType<typeof vi.fn>;
}
const storeState = {} as StoreState;
const lookupMock = vi.fn();
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/api', () => ({
api: {
apiKeys: {
lookup: (...args: unknown[]) => lookupMock(...args),
},
},
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
disabled,
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', null, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
React.createElement(
'select',
{
'data-testid': 'scope-select',
value,
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
},
children
),
SelectTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectValue: () => null,
SelectContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectItem: ({
children,
value,
disabled,
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
React.createElement('option', { value, disabled }, children),
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
Plus: Icon,
Server: Icon,
Trash2: Icon,
};
});
import { CustomMcpServerDialog } from '@renderer/components/extensions/mcp/CustomMcpServerDialog';
function setNativeValue(
element: HTMLInputElement | HTMLSelectElement,
value: string,
eventName: 'input' | 'change'
): void {
const prototype = element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
descriptor?.set?.call(element, value);
element.dispatchEvent(new Event(eventName, { bubbles: true }));
}
describe('CustomMcpServerDialog project scope', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined);
lookupMock.mockReset();
lookupMock.mockResolvedValue([]);
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('disables non-user scopes without an active project', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(CustomMcpServerDialog, {
open: true,
onClose: vi.fn(),
projectPath: null,
})
);
await Promise.resolve();
});
const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement;
const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement;
expect(projectOption.disabled).toBe(true);
expect(localOption.disabled).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes projectPath for project-scoped custom installs', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onClose = vi.fn();
const projectPath = '/tmp/custom-mcp-project';
await act(async () => {
root.render(
React.createElement(CustomMcpServerDialog, {
open: true,
onClose,
projectPath,
})
);
await Promise.resolve();
});
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
setNativeValue(nameInput, 'custom-context7', 'input');
setNativeValue(packageInput, '@upstash/context7-mcp', 'input');
setNativeValue(scopeSelect, 'project', 'change');
await Promise.resolve();
});
const installButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Install'
) as HTMLButtonElement;
expect(installButton.disabled).toBe(false);
await act(async () => {
installButton.click();
await Promise.resolve();
});
expect(storeState.installCustomMcpServer).toHaveBeenCalledWith(
expect.objectContaining({
serverName: 'custom-context7',
scope: 'project',
projectPath,
})
);
expect(onClose).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes projectPath for local-scoped custom installs', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onClose = vi.fn();
const projectPath = '/tmp/custom-mcp-project';
await act(async () => {
root.render(
React.createElement(CustomMcpServerDialog, {
open: true,
onClose,
projectPath,
})
);
await Promise.resolve();
});
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
setNativeValue(nameInput, 'local-context7', 'input');
setNativeValue(packageInput, '@upstash/context7-mcp', 'input');
setNativeValue(scopeSelect, 'local', 'change');
await Promise.resolve();
});
const installButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Install'
) as HTMLButtonElement;
expect(installButton.disabled).toBe(false);
await act(async () => {
installButton.click();
await Promise.resolve();
});
expect(storeState.installCustomMcpServer).toHaveBeenCalledWith(
expect.objectContaining({
serverName: 'local-context7',
scope: 'local',
projectPath,
})
);
expect(onClose).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,288 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
interface StoreState {
mcpInstallProgress: Record<string, string>;
installMcpServer: ReturnType<typeof vi.fn>;
uninstallMcpServer: ReturnType<typeof vi.fn>;
installErrors: Record<string, string>;
mcpGitHubStars: Record<string, number>;
}
const storeState = {} as StoreState;
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/api', () => ({
api: {
openExternal: vi.fn(),
},
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
}: React.PropsWithChildren<{
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: 'button' | 'submit' | 'reset';
}>) =>
React.createElement(
'button',
{
type,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
InstallButton: ({
state,
errorMessage,
}: {
state?: string;
errorMessage?: string;
}) =>
React.createElement(
'button',
{
type: 'button',
'data-testid': 'install-button',
'data-state': state,
'data-error': errorMessage,
},
'Install'
),
}));
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source),
}));
vi.mock('@renderer/utils/formatters', () => ({
formatCompactNumber: (value: number) => String(value),
formatRelativeTime: () => 'recently',
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
Clock: Icon,
Cloud: Icon,
Globe: Icon,
KeyRound: Icon,
Lock: Icon,
Monitor: Icon,
Star: Icon,
Tag: Icon,
Wrench: Icon,
Github: Icon,
};
});
import { McpServerCard } from '@renderer/components/extensions/mcp/McpServerCard';
function makeServer(): McpCatalogItem {
return {
id: 'io.github.upstash/context7',
name: 'Context7',
description: 'Docs server',
source: 'official',
installSpec: {
type: 'stdio',
npmPackage: '@upstash/context7-mcp',
},
envVars: [],
tools: [],
requiresAuth: false,
authHeaders: [],
};
}
describe('McpServerCard direct action safety', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.mcpInstallProgress = {};
storeState.installMcpServer = vi.fn();
storeState.uninstallMcpServer = vi.fn();
storeState.installErrors = {};
storeState.mcpGitHubStars = {};
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('falls back to Manage for installed entries that cannot be safely uninstalled directly', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onClick = vi.fn();
const installedEntry: InstalledMcpEntry = {
name: 'context7-local',
scope: 'local',
};
await act(async () => {
root.render(
React.createElement(McpServerCard, {
server: makeServer(),
isInstalled: true,
installedEntry,
diagnostic: null,
diagnosticsLoading: false,
onClick,
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="install-button"]')).toBeNull();
const manageButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Manage'
) as HTMLButtonElement | undefined;
expect(manageButton).toBeDefined();
await act(async () => {
manageButton?.click();
await Promise.resolve();
});
expect(onClick).toHaveBeenCalledWith('io.github.upstash/context7');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps direct actions for standard user-scope installs', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntry: InstalledMcpEntry = {
name: 'context7',
scope: 'user',
};
await act(async () => {
root.render(
React.createElement(McpServerCard, {
server: makeServer(),
isInstalled: true,
installedEntry,
installedEntries: [installedEntry],
diagnostic: null,
diagnosticsLoading: false,
onClick: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('falls back to Manage when the same server is installed in multiple scopes', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntries: InstalledMcpEntry[] = [
{ name: 'context7', scope: 'user' },
{ name: 'context7', scope: 'project' },
];
await act(async () => {
root.render(
React.createElement(McpServerCard, {
server: makeServer(),
isInstalled: true,
installedEntry: installedEntries[1],
installedEntries,
diagnostic: null,
diagnosticsLoading: false,
onClick: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Installed in 2 scopes');
expect(host.querySelector('[data-testid="install-button"]')).toBeNull();
expect(host.textContent).toContain('Manage');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('reads direct-action state from the user-scope operation key', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntry: InstalledMcpEntry = {
name: 'context7',
scope: 'user',
};
storeState.mcpInstallProgress = {
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error',
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending',
};
storeState.installErrors = {
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed',
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed',
};
await act(async () => {
root.render(
React.createElement(McpServerCard, {
server: makeServer(),
isInstalled: true,
installedEntry,
installedEntries: [installedEntry],
diagnostic: null,
diagnosticsLoading: false,
onClick: vi.fn(),
})
);
await Promise.resolve();
});
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
expect(installButton.dataset.state).toBe('pending');
expect(installButton.dataset.error).toBe('User failed');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,592 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
interface StoreState {
mcpInstallProgress: Record<string, string>;
installMcpServer: ReturnType<typeof vi.fn>;
uninstallMcpServer: ReturnType<typeof vi.fn>;
installErrors: Record<string, string>;
mcpGitHubStars: Record<string, number>;
}
const storeState = {} as StoreState;
const lookupMock = vi.fn();
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/api', () => ({
api: {
openExternal: vi.fn(),
apiKeys: {
lookup: (...args: unknown[]) => lookupMock(...args),
},
},
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
disabled,
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', null, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
React.createElement(
'select',
{
'data-testid': 'scope-select',
value,
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
},
children
),
SelectTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectValue: () => null,
SelectContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectItem: ({
children,
value,
disabled,
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
React.createElement('option', { value, disabled }, children),
}));
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
InstallButton: ({
isInstalled,
state,
errorMessage,
onInstall,
onUninstall,
}: {
isInstalled: boolean;
state?: string;
errorMessage?: string;
onInstall: () => void;
onUninstall: () => void;
}) =>
React.createElement(
'button',
{
type: 'button',
'data-testid': 'install-button',
'data-state': state,
'data-error': errorMessage,
onClick: () => (isInstalled ? onUninstall() : onInstall()),
},
isInstalled ? 'Uninstall' : 'Install'
),
}));
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source),
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
ExternalLink: Icon,
Lock: Icon,
Plus: Icon,
Star: Icon,
Trash2: Icon,
Wrench: Icon,
};
});
import { McpServerDetailDialog } from '@renderer/components/extensions/mcp/McpServerDetailDialog';
function makeServer(): McpCatalogItem {
return {
id: 'io.github.upstash/context7',
name: 'Context7',
description: 'Docs server',
source: 'official',
installSpec: {
type: 'stdio',
npmPackage: '@upstash/context7-mcp',
},
envVars: [],
tools: [],
requiresAuth: false,
authHeaders: [],
};
}
describe('McpServerDetailDialog installed entry handling', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.mcpInstallProgress = {};
storeState.installMcpServer = vi.fn();
storeState.uninstallMcpServer = vi.fn();
storeState.installErrors = {};
storeState.mcpGitHubStars = {};
lookupMock.mockReset();
lookupMock.mockResolvedValue([]);
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('uninstalls using the real installed server name and scope', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntry: InstalledMcpEntry = {
name: 'context7-local',
scope: 'local',
};
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: true,
installedEntry,
diagnostic: null,
diagnosticsLoading: false,
projectPath: '/tmp/project',
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const serverNameInput = host.querySelector('#server-name') as HTMLInputElement;
expect(serverNameInput).not.toBeNull();
expect(serverNameInput.value).toBe('context7-local');
expect(serverNameInput.disabled).toBe(true);
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
expect(scopeSelect.value).toBe('local');
const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
await act(async () => {
uninstallButton.click();
await Promise.resolve();
});
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
'io.github.upstash/context7',
'context7-local',
'local',
'/tmp/project'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('looks up saved API keys only once per dialog open', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const server = makeServer();
server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }];
lookupMock.mockResolvedValue([
{
envVarName: 'CONTEXT7_API_KEY',
value: 'secret',
},
]);
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server,
isInstalled: false,
installedEntry: null,
diagnostic: null,
diagnosticsLoading: false,
projectPath: null,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(lookupMock).toHaveBeenCalledTimes(1);
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']);
const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement;
const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement;
expect(projectOption.disabled).toBe(true);
expect(localOption.disabled).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes project path for project-scoped installs and uninstalls', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const projectPath = '/tmp/project-context7';
const installedEntry: InstalledMcpEntry = {
name: 'context7-project',
scope: 'project',
};
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: true,
installedEntry,
installedEntries: [installedEntry],
diagnostic: null,
diagnosticsLoading: false,
projectPath,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
expect(scopeSelect.value).toBe('project');
const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
await act(async () => {
uninstallButton.click();
await Promise.resolve();
});
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
'io.github.upstash/context7',
'context7-project',
'project',
projectPath
);
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: false,
installedEntry: null,
installedEntries: [],
diagnostic: null,
diagnosticsLoading: false,
projectPath,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const installScopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
installScopeSelect.value = 'project';
installScopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
});
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
await act(async () => {
installButton.click();
await Promise.resolve();
});
expect(storeState.installMcpServer).toHaveBeenCalledWith(
expect.objectContaining({
registryId: 'io.github.upstash/context7',
scope: 'project',
projectPath,
})
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes project path for local-scoped installs and uninstalls', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const projectPath = '/tmp/local-context7';
const installedEntry: InstalledMcpEntry = {
name: 'context7-local',
scope: 'local',
};
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: true,
installedEntry,
installedEntries: [installedEntry],
diagnostic: null,
diagnosticsLoading: false,
projectPath,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
await act(async () => {
uninstallButton.click();
await Promise.resolve();
});
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
'io.github.upstash/context7',
'context7-local',
'local',
projectPath
);
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: false,
installedEntry: null,
installedEntries: [],
diagnostic: null,
diagnosticsLoading: false,
projectPath,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
scopeSelect.value = 'local';
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
});
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
await act(async () => {
installButton.click();
await Promise.resolve();
});
expect(storeState.installMcpServer).toHaveBeenCalledWith(
expect.objectContaining({
registryId: 'io.github.upstash/context7',
scope: 'local',
projectPath,
})
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses selected scope instead of aggregated installed state', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntry: InstalledMcpEntry = {
name: 'context7',
scope: 'user',
};
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: true,
installedEntry,
installedEntries: [installedEntry],
diagnostic: null,
diagnosticsLoading: false,
projectPath: '/tmp/project',
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
scopeSelect.value = 'project';
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
});
const actionButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
expect(actionButton.textContent).toBe('Install');
await act(async () => {
actionButton.click();
await Promise.resolve();
});
expect(storeState.installMcpServer).toHaveBeenCalledWith(
expect.objectContaining({
registryId: 'io.github.upstash/context7',
scope: 'project',
projectPath: '/tmp/project',
})
);
expect(storeState.uninstallMcpServer).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('defaults to the highest-precedence installed scope', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntries: InstalledMcpEntry[] = [
{ name: 'context7', scope: 'user' },
{ name: 'context7-shared', scope: 'project' },
];
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: true,
installedEntry: installedEntries[0],
installedEntries,
diagnostic: null,
diagnosticsLoading: false,
projectPath: '/tmp/project',
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
const serverNameInput = host.querySelector('#server-name') as HTMLInputElement;
expect(scopeSelect.value).toBe('project');
expect(serverNameInput.value).toBe('context7-shared');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('reads install state from the selected scope operation key', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
storeState.mcpInstallProgress = {
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success',
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error',
};
storeState.installErrors = {
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed',
};
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: false,
installedEntry: null,
installedEntries: [],
diagnostic: null,
diagnosticsLoading: false,
projectPath: '/tmp/project',
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
expect(installButton.dataset.state).toBe('success');
expect(installButton.dataset.error ?? '').toBe('');
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
scopeSelect.value = 'project';
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
});
expect(installButton.dataset.state).toBe('error');
expect(installButton.dataset.error).toBe('Project failed');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,214 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
interface StoreState {
mcpBrowseCatalog: Array<{
id: string;
name: string;
description: string;
source: 'official' | 'glama';
installSpec: null;
envVars: [];
tools: [];
requiresAuth: boolean;
}>;
mcpBrowseNextCursor?: string;
mcpBrowseLoading: boolean;
mcpBrowseError: string | null;
mcpBrowse: ReturnType<typeof vi.fn>;
mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>;
fetchMcpGitHubStars: ReturnType<typeof vi.fn>;
mcpDiagnostics: Record<string, never>;
mcpDiagnosticsLoading: boolean;
mcpDiagnosticsError: string | null;
mcpDiagnosticsLastCheckedAt: number | null;
runMcpDiagnostics: ReturnType<typeof vi.fn>;
}
const storeState = {} as StoreState;
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector,
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
disabled,
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
SelectTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement('button', { type: 'button' }, children),
SelectValue: () => React.createElement('span', null, 'select-value'),
SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) =>
React.createElement('button', { type: 'button' }, children),
}));
vi.mock('@renderer/components/extensions/common/SearchInput', () => ({
SearchInput: ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) =>
React.createElement('input', {
value,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value),
}),
}));
vi.mock('@renderer/components/extensions/mcp/McpServerCard', () => ({
McpServerCard: ({ server }: { server: { id: string; name: string } }) =>
React.createElement('div', { 'data-testid': 'mcp-card', 'data-server-id': server.id }, server.name),
}));
vi.mock('@renderer/components/extensions/mcp/McpServerDetailDialog', () => ({
McpServerDetailDialog: ({ open }: { open: boolean }) =>
open ? React.createElement('div', { 'data-testid': 'mcp-detail' }) : null,
}));
vi.mock('@renderer/utils/formatters', () => ({
formatRelativeTime: () => 'just now',
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
AlertTriangle: Icon,
RefreshCw: Icon,
Search: Icon,
Server: Icon,
};
});
import { McpServersPanel } from '@renderer/components/extensions/mcp/McpServersPanel';
describe('McpServersPanel initial browse loading', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.mcpBrowseCatalog = [];
storeState.mcpBrowseNextCursor = undefined;
storeState.mcpBrowseLoading = false;
storeState.mcpBrowseError = null;
storeState.mcpBrowse = vi.fn();
storeState.mcpInstalledServers = [];
storeState.fetchMcpGitHubStars = vi.fn();
storeState.mcpDiagnostics = {};
storeState.mcpDiagnosticsLoading = false;
storeState.mcpDiagnosticsError = null;
storeState.mcpDiagnosticsLastCheckedAt = null;
storeState.runMcpDiagnostics = vi.fn();
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('loads the catalog once on first mount when browse state is empty', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(McpServersPanel, {
projectPath: null,
mcpSearchQuery: '',
mcpSearch: vi.fn(),
mcpSearchResults: [],
mcpSearchLoading: false,
mcpSearchWarnings: [],
selectedMcpServerId: null,
setSelectedMcpServerId: vi.fn(),
})
);
await Promise.resolve();
});
expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1);
expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not auto-retry browse after an error with an empty catalog', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(McpServersPanel, {
projectPath: null,
mcpSearchQuery: '',
mcpSearch: vi.fn(),
mcpSearchResults: [],
mcpSearchLoading: false,
mcpSearchWarnings: [],
selectedMcpServerId: null,
setSelectedMcpServerId: vi.fn(),
})
);
await Promise.resolve();
});
expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1);
storeState.mcpBrowseError = 'Registry unavailable';
await act(async () => {
root.render(
React.createElement(McpServersPanel, {
projectPath: null,
mcpSearchQuery: '',
mcpSearch: vi.fn(),
mcpSearchResults: [],
mcpSearchLoading: false,
mcpSearchWarnings: [],
selectedMcpServerId: null,
setSelectedMcpServerId: vi.fn(),
})
);
await Promise.resolve();
});
expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,269 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EnrichedPlugin } from '@shared/types/extensions';
interface StoreState {
fetchPluginReadme: ReturnType<typeof vi.fn>;
pluginReadmes: Record<string, string | null>;
pluginReadmeLoading: Record<string, boolean>;
installPlugin: ReturnType<typeof vi.fn>;
uninstallPlugin: ReturnType<typeof vi.fn>;
pluginCatalogProjectPath: string | null;
pluginInstallProgress: Record<string, string>;
installErrors: Record<string, string>;
}
const storeState = {} as StoreState;
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector,
}));
vi.mock('@renderer/api', () => ({
api: {
openExternal: vi.fn(),
},
}));
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
MarkdownViewer: ({ content }: { content: string }) =>
React.createElement('div', { 'data-testid': 'markdown' }, content),
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
}>) =>
React.createElement(
'button',
{
type,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) =>
React.createElement('div', { 'data-testid': 'dialog-content' }, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
React.createElement(
'select',
{
'data-testid': 'scope-select',
value,
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
},
children
),
SelectTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectValue: () => null,
SelectContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectItem: ({
children,
value,
disabled,
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
React.createElement(
'option',
{
value,
disabled,
},
children
),
}));
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
InstallButton: ({
isInstalled,
onInstall,
onUninstall,
}: {
isInstalled: boolean;
onInstall: () => void;
onUninstall: () => void;
}) =>
React.createElement(
'button',
{
type: 'button',
'data-testid': 'install-button',
onClick: () => (isInstalled ? onUninstall() : onInstall()),
},
isInstalled ? 'Uninstall' : 'Install'
),
}));
vi.mock('@renderer/components/extensions/common/InstallCountBadge', () => ({
InstallCountBadge: ({ count }: { count: number }) =>
React.createElement('span', { 'data-testid': 'install-count' }, String(count)),
}));
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
SourceBadge: ({ source }: { source: string }) =>
React.createElement('span', { 'data-testid': 'source-badge' }, source),
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
ExternalLink: Icon,
Loader2: Icon,
Mail: Icon,
};
});
import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog';
const makePlugin = (): EnrichedPlugin => ({
pluginId: 'context7@claude-plugins-official',
marketplaceId: 'context7@claude-plugins-official',
qualifiedName: 'context7@claude-plugins-official',
name: 'Context7',
source: 'official',
description: 'Fresh docs in Claude',
category: 'docs',
author: { name: 'Anthropic', email: 'help@example.com' },
version: '1.0.0',
homepage: 'https://example.com/context7',
tags: [],
hasLspServers: false,
hasMcpServers: true,
hasAgents: false,
hasCommands: false,
hasHooks: false,
isExternal: true,
installCount: 42,
isInstalled: false,
installations: [],
});
describe('PluginDetailDialog project context', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.fetchPluginReadme = vi.fn();
storeState.pluginReadmes = {};
storeState.pluginReadmeLoading = {};
storeState.installPlugin = vi.fn();
storeState.uninstallPlugin = vi.fn();
storeState.pluginCatalogProjectPath = '/tmp/global-project';
storeState.pluginInstallProgress = {};
storeState.installErrors = {};
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('uses the current tab project path for project-scope installs', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const plugin = makePlugin();
await act(async () => {
root.render(
React.createElement(PluginDetailDialog, {
plugin,
open: true,
onClose: vi.fn(),
projectPath: '/tmp/tab-project',
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
expect(scopeSelect).not.toBeNull();
await act(async () => {
scopeSelect.value = 'project';
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
});
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
expect(installButton).not.toBeNull();
await act(async () => {
installButton.click();
await Promise.resolve();
});
expect(storeState.installPlugin).toHaveBeenCalledWith({
pluginId: plugin.pluginId,
scope: 'project',
projectPath: '/tmp/tab-project',
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('disables project and local scopes when the current tab has no project', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(PluginDetailDialog, {
plugin: makePlugin(),
open: true,
onClose: vi.fn(),
projectPath: null,
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
expect(scopeSelect).not.toBeNull();
expect(scopeSelect.querySelector('option[value="project"]')?.disabled).toBe(true);
expect(scopeSelect.querySelector('option[value="local"]')?.disabled).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,280 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SkillDetail } from '@shared/types/extensions';
interface StoreState {
fetchSkillDetail: ReturnType<typeof vi.fn>;
deleteSkill: ReturnType<typeof vi.fn>;
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
skillsDetailLoadingById: Record<string, boolean>;
skillsDetailErrorById: Record<string, string | null>;
}
const storeState = {} as StoreState;
const openPathMock = vi.fn();
const showInFolderMock = vi.fn();
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: <T,>(selector: T) => selector,
}));
vi.mock('@renderer/api', () => ({
api: {
openPath: (...args: unknown[]) => openPathMock(...args),
showInFolder: (...args: unknown[]) => showInFolderMock(...args),
},
}));
vi.mock('@renderer/components/chat/viewers/CodeBlockViewer', () => ({
CodeBlockViewer: () => React.createElement('div', null, 'Code'),
}));
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
MarkdownViewer: () => React.createElement('div', null, 'Markdown'),
}));
vi.mock('@renderer/components/ui/alert-dialog', () => ({
AlertDialog: ({
open,
children,
}: React.PropsWithChildren<{
open: boolean;
onOpenChange?: (next: boolean) => void;
}>) => (open ? React.createElement('div', null, children) : null),
AlertDialogAction: ({
children,
onClick,
disabled,
}: React.PropsWithChildren<{ onClick?: () => void; disabled?: boolean }>) =>
React.createElement(
'button',
{
type: 'button',
disabled,
onClick,
},
children
),
AlertDialogCancel: ({ children }: React.PropsWithChildren<{ disabled?: boolean }>) =>
React.createElement('button', { type: 'button' }, children),
AlertDialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
AlertDialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
AlertDialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
AlertDialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
AlertDialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h3', null, children),
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
disabled,
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', null, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
AlertTriangle: Icon,
ExternalLink: Icon,
FolderOpen: Icon,
Pencil: Icon,
Trash2: Icon,
};
});
import { SkillDetailDialog } from '@renderer/components/extensions/skills/SkillDetailDialog';
function makeDetail(overrides: Partial<SkillDetail['item']>): SkillDetail {
return {
item: {
id: '/tmp/project-a/.claude/skills/review-helper',
sourceType: 'filesystem',
name: 'Review Helper',
description: 'Helps with code review',
folderName: 'review-helper',
scope: 'project',
rootKind: 'claude',
projectRoot: '/tmp/project-a',
discoveryRoot: '/tmp/project-a/.claude/skills',
skillDir: '/tmp/project-a/.claude/skills/review-helper',
skillFile: '/tmp/project-a/.claude/skills/review-helper/SKILL.md',
metadata: {},
invocationMode: 'auto',
flags: {
hasScripts: false,
hasReferences: false,
hasAssets: false,
},
isValid: true,
issues: [],
modifiedAt: 1,
...overrides,
},
body: 'body',
rawContent: '# Review Helper',
rawFrontmatter: null,
referencesFiles: [],
scriptFiles: [],
assetFiles: [],
};
}
describe('SkillDetailDialog', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.fetchSkillDetail = vi.fn().mockResolvedValue(undefined);
storeState.deleteSkill = vi.fn().mockResolvedValue(undefined);
storeState.skillsDetailsById = {};
storeState.skillsDetailLoadingById = {};
storeState.skillsDetailErrorById = {};
openPathMock.mockReset();
showInFolderMock.mockReset();
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('uses the skill project root for project-scoped open and delete actions', async () => {
const detail = makeDetail({});
storeState.skillsDetailsById[detail.item.id] = detail;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onDeleted = vi.fn();
await act(async () => {
root.render(
React.createElement(SkillDetailDialog, {
skillId: detail.item.id,
open: true,
onClose: vi.fn(),
projectPath: '/tmp/project-b',
onEdit: vi.fn(),
onDeleted,
})
);
await Promise.resolve();
});
const openButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Open SKILL.md')
) as HTMLButtonElement;
await act(async () => {
openButton.click();
await Promise.resolve();
});
expect(openPathMock).toHaveBeenCalledWith(detail.item.skillFile, '/tmp/project-a');
const deleteButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent === 'Delete'
) as HTMLButtonElement;
await act(async () => {
deleteButton.click();
await Promise.resolve();
});
const confirmDeleteButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent === 'Delete Skill'
) as HTMLButtonElement;
await act(async () => {
confirmDeleteButton.click();
await Promise.resolve();
});
expect(storeState.deleteSkill).toHaveBeenCalledWith({
skillId: detail.item.id,
projectPath: '/tmp/project-a',
});
expect(onDeleted).toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not forward the current project path for personal skills', async () => {
const detail = makeDetail({
id: '/Users/me/.claude/skills/review-helper',
scope: 'user',
projectRoot: null,
discoveryRoot: '/Users/me/.claude/skills',
skillDir: '/Users/me/.claude/skills/review-helper',
skillFile: '/Users/me/.claude/skills/review-helper/SKILL.md',
});
storeState.skillsDetailsById[detail.item.id] = detail;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(SkillDetailDialog, {
skillId: detail.item.id,
open: true,
onClose: vi.fn(),
projectPath: '/tmp/project-b',
onEdit: vi.fn(),
onDeleted: vi.fn(),
})
);
await Promise.resolve();
});
const openButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Open SKILL.md')
) as HTMLButtonElement;
await act(async () => {
openButton.click();
await Promise.resolve();
});
expect(openPathMock).toHaveBeenCalledWith(detail.item.skillFile, undefined);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,363 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SkillDetail } from '@shared/types/extensions';
interface StoreState {
previewSkillUpsert: ReturnType<typeof vi.fn>;
applySkillUpsert: ReturnType<typeof vi.fn>;
}
const storeState = {} as StoreState;
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/hooks/useMarkdownScrollSync', () => ({
useMarkdownScrollSync: () => ({
handleCodeScroll: vi.fn(),
handlePreviewScroll: vi.fn(),
previewScrollRef: { current: null },
}),
}));
vi.mock('@renderer/components/team/editor/MarkdownPreviewPane', () => ({
MarkdownPreviewPane: () => React.createElement('div', null, 'Preview'),
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
disabled,
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/checkbox', () => ({
Checkbox: ({
checked,
onCheckedChange,
}: {
checked?: boolean;
onCheckedChange?: (value: boolean) => void;
className?: string;
}) =>
React.createElement('input', {
type: 'checkbox',
checked,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onCheckedChange?.(event.target.checked),
}),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', null, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) =>
React.createElement('label', { htmlFor }, children),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
disabled,
}: React.PropsWithChildren<{
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
}>) =>
React.createElement(
'select',
{
value,
disabled,
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
},
children
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectItem: ({
children,
value,
disabled,
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
React.createElement('option', { value, disabled }, children),
}));
vi.mock('@renderer/components/ui/textarea', () => ({
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) =>
React.createElement('textarea', props),
}));
vi.mock('@renderer/components/extensions/skills/SkillCodeEditor', () => ({
SkillCodeEditor: () => React.createElement('div', null, 'Editor'),
}));
vi.mock('@renderer/components/extensions/skills/SkillReviewDialog', () => ({
SkillReviewDialog: ({ open }: { open: boolean }) =>
open ? React.createElement('div', { 'data-testid': 'skill-review-dialog' }, 'Review') : null,
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
FileSearch: Icon,
RotateCcw: Icon,
X: Icon,
};
});
import { SkillEditorDialog } from '@renderer/components/extensions/skills/SkillEditorDialog';
function makeDetail(rawContent: string): SkillDetail {
return {
item: {
id: '/tmp/project/.claude/skills/custom-skill',
sourceType: 'filesystem',
name: 'Custom Skill',
description: 'Custom markdown skill',
folderName: 'custom-skill',
scope: 'project',
rootKind: 'claude',
projectRoot: '/tmp/project',
discoveryRoot: '/tmp/project/.claude/skills',
skillDir: '/tmp/project/.claude/skills/custom-skill',
skillFile: '/tmp/project/.claude/skills/custom-skill/SKILL.md',
metadata: {},
invocationMode: 'auto',
flags: {
hasScripts: false,
hasReferences: false,
hasAssets: false,
},
isValid: true,
issues: [],
modifiedAt: 1,
},
body: rawContent,
rawContent,
rawFrontmatter: null,
referencesFiles: [],
scriptFiles: [],
assetFiles: [],
};
}
describe('SkillEditorDialog', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.previewSkillUpsert = vi.fn();
storeState.applySkillUpsert = vi.fn();
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('unlocks structured editing after resetting a custom markdown skill', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const detail = makeDetail(`---
name: Custom Skill
description: Custom markdown skill
---
# Custom Skill
This file uses a freeform layout without generated sections.
`);
await act(async () => {
root.render(
React.createElement(SkillEditorDialog, {
open: true,
mode: 'edit',
projectPath: '/tmp/project',
projectLabel: 'Project',
detail,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('#skill-when-to-use')).toBeNull();
const resetButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Reset From Structured Fields')
) as HTMLButtonElement;
expect(resetButton).toBeDefined();
await act(async () => {
resetButton.click();
await Promise.resolve();
});
const whenToUseField = host.querySelector('#skill-when-to-use') as HTMLTextAreaElement;
expect(whenToUseField).not.toBeNull();
expect(whenToUseField.disabled).toBe(false);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('clears review state when the editor closes externally', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
storeState.previewSkillUpsert.mockResolvedValue({
planId: 'plan-1',
targetSkillDir: '/tmp/project/.claude/skills/new-skill',
changes: [
{
relativePath: 'SKILL.md',
absolutePath: '/tmp/project/.claude/skills/new-skill/SKILL.md',
action: 'create',
oldContent: null,
newContent: '# Skill',
isBinary: false,
},
],
warnings: [],
summary: { created: 1, updated: 0, deleted: 0, binary: 0 },
});
await act(async () => {
root.render(
React.createElement(SkillEditorDialog, {
open: true,
mode: 'create',
projectPath: '/tmp/project',
projectLabel: 'Project',
detail: null,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Review And Create')
) as HTMLButtonElement;
await act(async () => {
reviewButton.click();
await Promise.resolve();
await Promise.resolve();
});
expect(host.querySelector('[data-testid="skill-review-dialog"]')).not.toBeNull();
await act(async () => {
root.render(
React.createElement(SkillEditorDialog, {
open: false,
mode: 'create',
projectPath: '/tmp/project',
projectLabel: 'Project',
detail: null,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="skill-review-dialog"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('blocks review locally when the folder name is invalid', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(SkillEditorDialog, {
open: true,
mode: 'create',
projectPath: '/tmp/project',
projectLabel: 'Project',
detail: null,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
const folderInput = host.querySelector('#skill-folder') as HTMLInputElement;
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setValue?.call(folderInput, 'bad/name');
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Review And Create')
) as HTMLButtonElement;
await act(async () => {
reviewButton.click();
await Promise.resolve();
});
expect(storeState.previewSkillUpsert).not.toHaveBeenCalled();
expect(host.textContent).toContain(
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,474 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
interface StoreState {
previewSkillImport: ReturnType<typeof vi.fn>;
applySkillImport: ReturnType<typeof vi.fn>;
}
const storeState = {} as StoreState;
const selectFoldersMock = vi.fn();
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/api', () => ({
api: {
config: {
selectFolders: (...args: unknown[]) => selectFoldersMock(...args),
},
},
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
disabled,
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', null, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
DialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) =>
React.createElement('label', { htmlFor }, children),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
React.createElement(
'select',
{
'data-testid': 'select',
value,
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
},
children
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectItem: ({
children,
value,
disabled,
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
React.createElement('option', { value, disabled }, children),
}));
vi.mock('@renderer/components/extensions/skills/SkillReviewDialog', () => ({
SkillReviewDialog: ({ open }: { open: boolean }) =>
open ? React.createElement('div', { 'data-testid': 'skill-review-dialog' }, 'Review') : null,
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
FileSearch: Icon,
FolderOpen: Icon,
X: Icon,
};
});
import { SkillImportDialog } from '@renderer/components/extensions/skills/SkillImportDialog';
describe('SkillImportDialog', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.previewSkillImport = vi.fn();
storeState.applySkillImport = vi.fn();
selectFoldersMock.mockReset();
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('keeps destination folder empty until a source folder is chosen', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
expect(sourceInput.value).toBe('');
expect(folderInput.value).toBe('');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps destination folder name synced with the chosen source until edited manually', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
selectFoldersMock
.mockResolvedValueOnce(['/tmp/first-skill'])
.mockResolvedValueOnce(['/tmp/second-skill'])
.mockResolvedValueOnce(['/tmp/third-skill']);
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const browseButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.includes('Browse')
) as HTMLButtonElement;
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
await act(async () => {
browseButton.click();
await Promise.resolve();
});
expect(sourceInput.value).toBe('/tmp/first-skill');
expect(folderInput.value).toBe('first-skill');
await act(async () => {
browseButton.click();
await Promise.resolve();
});
expect(sourceInput.value).toBe('/tmp/second-skill');
expect(folderInput.value).toBe('second-skill');
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setValue?.call(folderInput, 'custom-name');
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
expect(folderInput.value).toBe('custom-name');
await act(async () => {
browseButton.click();
await Promise.resolve();
});
expect(sourceInput.value).toBe('/tmp/third-skill');
expect(folderInput.value).toBe('custom-name');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('sanitizes the suggested destination folder when the source folder name is not CLI-safe', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
selectFoldersMock.mockResolvedValueOnce(['/tmp/My Skill Folder']);
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const browseButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.includes('Browse')
) as HTMLButtonElement;
await act(async () => {
browseButton.click();
await Promise.resolve();
});
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
expect(folderInput.value).toBe('my-skill-folder');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('falls back to user scope when the project context disappears mid-dialog', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: '/tmp/project-a',
projectLabel: 'Project A',
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement;
expect(scopeSelect.value).toBe('project');
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const updatedScopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement;
expect(updatedScopeSelect.value).toBe('user');
const projectOption = Array.from(updatedScopeSelect.options).find(
(option) => option.value === 'project'
) as HTMLOptionElement;
expect(projectOption.disabled).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('clears review state when the import dialog closes externally', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
storeState.previewSkillImport.mockResolvedValue({
planId: 'plan-1',
targetSkillDir: '/tmp/imported-skill',
changes: [
{
relativePath: 'SKILL.md',
absolutePath: '/tmp/imported-skill/SKILL.md',
action: 'create',
oldContent: null,
newContent: '# Skill',
isBinary: false,
},
],
warnings: [],
summary: { created: 1, updated: 0, deleted: 0, binary: 0 },
});
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setValue?.call(sourceInput, '/tmp/source-skill');
sourceInput.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Review And Import')
) as HTMLButtonElement;
await act(async () => {
reviewButton.click();
await Promise.resolve();
await Promise.resolve();
});
expect(host.querySelector('[data-testid="skill-review-dialog"]')).not.toBeNull();
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: false,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="skill-review-dialog"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('blocks import review locally when the folder name is invalid', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setValue?.call(sourceInput, '/tmp/source-skill');
sourceInput.dispatchEvent(new Event('input', { bubbles: true }));
setValue?.call(folderInput, 'bad/name');
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Review And Import')
) as HTMLButtonElement;
await act(async () => {
reviewButton.click();
await Promise.resolve();
});
expect(storeState.previewSkillImport).not.toHaveBeenCalled();
expect(host.textContent).toContain(
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps review disabled for whitespace-only source folders', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setValue?.call(sourceInput, ' ');
sourceInput.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Review And Import')
) as HTMLButtonElement;
expect(reviewButton.disabled).toBe(true);
await act(async () => {
reviewButton.click();
await Promise.resolve();
});
expect(storeState.previewSkillImport).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,235 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SkillCatalogItem } from '@shared/types/extensions';
interface StoreState {
fetchSkillsCatalog: ReturnType<typeof vi.fn>;
fetchSkillDetail: ReturnType<typeof vi.fn>;
skillsCatalogLoadingByProjectPath: Record<string, boolean>;
skillsCatalogErrorByProjectPath: Record<string, string | null>;
skillsDetailsById: Record<string, unknown>;
skillsUserCatalog: SkillCatalogItem[];
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
}
const storeState = {} as StoreState;
const startWatchingMock = vi.fn();
const stopWatchingMock = vi.fn();
const onChangedMock = vi.fn();
let skillsChangedHandler: ((event: {
scope: 'user' | 'project';
projectPath: string | null;
path: string;
type: 'create' | 'change' | 'delete';
}) => void) | null = null;
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: <T,>(selector: T) => selector,
}));
vi.mock('@renderer/api', () => ({
api: {
skills: {
startWatching: (...args: unknown[]) => startWatchingMock(...args),
stopWatching: (...args: unknown[]) => stopWatchingMock(...args),
onChanged: (...args: unknown[]) => onChangedMock(...args),
},
},
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
variant?: string;
size?: string;
className?: string;
}>) =>
React.createElement(
'button',
{
type,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/popover', () => ({
Popover: ({ children }: React.PropsWithChildren<{ open?: boolean; onOpenChange?: (open: boolean) => void }>) =>
React.createElement(React.Fragment, null, children),
PopoverTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
PopoverContent: ({ children }: React.PropsWithChildren) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/extensions/common/SearchInput', () => ({
SearchInput: ({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) =>
React.createElement('input', {
value,
placeholder,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value),
}),
}));
vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({
SkillDetailDialog: () => null,
}));
vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({
SkillEditorDialog: () => null,
}));
vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({
SkillImportDialog: () => null,
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
AlertTriangle: Icon,
ArrowUpAZ: Icon,
ArrowUpDown: Icon,
BookOpen: Icon,
Check: Icon,
CheckCircle2: Icon,
Clock3: Icon,
Download: Icon,
Plus: Icon,
Search: Icon,
};
});
import { SkillsPanel } from '@renderer/components/extensions/skills/SkillsPanel';
function makeUserSkill(): SkillCatalogItem {
return {
id: '/Users/me/.claude/skills/review-helper',
sourceType: 'filesystem',
name: 'Review Helper',
description: 'Helps with code review',
folderName: 'review-helper',
scope: 'user',
rootKind: 'claude',
projectRoot: null,
discoveryRoot: '/Users/me/.claude/skills',
skillDir: '/Users/me/.claude/skills/review-helper',
skillFile: '/Users/me/.claude/skills/review-helper/SKILL.md',
metadata: {},
invocationMode: 'auto',
flags: {
hasScripts: false,
hasReferences: false,
hasAssets: false,
},
isValid: true,
issues: [],
modifiedAt: 1,
};
}
describe('SkillsPanel', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined);
storeState.fetchSkillDetail = vi.fn().mockResolvedValue(undefined);
storeState.skillsCatalogLoadingByProjectPath = {};
storeState.skillsCatalogErrorByProjectPath = {};
storeState.skillsDetailsById = {};
storeState.skillsUserCatalog = [makeUserSkill()];
storeState.skillsProjectCatalogByProjectPath = {
'/tmp/project-a': [],
};
startWatchingMock.mockReset();
stopWatchingMock.mockReset();
onChangedMock.mockReset();
skillsChangedHandler = null;
startWatchingMock.mockResolvedValue('watch-1');
onChangedMock.mockImplementation((handler: typeof skillsChangedHandler) => {
skillsChangedHandler = handler;
return () => {
skillsChangedHandler = null;
};
});
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('refetches personal skill details without forcing the current project path', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const skill = storeState.skillsUserCatalog[0]!;
await act(async () => {
root.render(
React.createElement(SkillsPanel, {
projectPath: '/tmp/project-a',
projectLabel: 'Project A',
skillsSearchQuery: '',
setSkillsSearchQuery: vi.fn(),
skillsSort: 'name-asc',
setSkillsSort: vi.fn(),
selectedSkillId: skill.id,
setSelectedSkillId: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(startWatchingMock).toHaveBeenCalledWith('/tmp/project-a');
expect(skillsChangedHandler).not.toBeNull();
await act(async () => {
skillsChangedHandler?.({
scope: 'user',
projectPath: null,
path: `${skill.skillDir}/SKILL.md`,
type: 'change',
});
await Promise.resolve();
});
expect(storeState.fetchSkillsCatalog).toHaveBeenCalledWith('/tmp/project-a');
expect(storeState.fetchSkillDetail).toHaveBeenCalledWith(skill.id, undefined);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
getSuggestedSkillFolderNameFromPath,
toSuggestedSkillFolderName,
} from '../../../../../src/renderer/components/extensions/skills/skillFolderNameUtils';
describe('skillFolderNameUtils', () => {
it('creates a safe slug from a human-readable skill name', () => {
expect(toSuggestedSkillFolderName('Review Helper 2')).toBe('review-helper-2');
});
it('falls back to a safe default when the name cannot be slugged', () => {
expect(toSuggestedSkillFolderName('Привет мир')).toBe('new-skill');
});
it('sanitizes imported folder names from the selected source path', () => {
expect(getSuggestedSkillFolderNameFromPath('/tmp/My Skill Folder')).toBe(
'my-skill-folder'
);
});
it('uses an import-specific fallback when the source folder name is unusable', () => {
expect(getSuggestedSkillFolderNameFromPath('/tmp/技能')).toBe('imported-skill');
});
});

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { resolveSkillProjectPath } from '../../../../../src/renderer/components/extensions/skills/skillProjectUtils';
describe('resolveSkillProjectPath', () => {
it('returns undefined for user-scoped skills', () => {
expect(resolveSkillProjectPath('user', '/tmp/project-a', '/tmp/project-a')).toBeUndefined();
});
it('prefers the skill project root over the current tab project for project-scoped skills', () => {
expect(resolveSkillProjectPath('project', '/tmp/project-b', '/tmp/project-a')).toBe(
'/tmp/project-a'
);
});
it('falls back to the current tab project when a project skill has no embedded root', () => {
expect(resolveSkillProjectPath('project', '/tmp/project-a', null)).toBe('/tmp/project-a');
});
});

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
validateSkillFolderName,
validateSkillImportSourceDir,
} from '../../../../../src/renderer/components/extensions/skills/skillValidationUtils';
describe('skillValidationUtils', () => {
it('rejects empty import source folders', () => {
expect(validateSkillImportSourceDir(' ')).toBe('Choose a skill folder to import.');
});
it('accepts non-empty import source folders', () => {
expect(validateSkillImportSourceDir('/tmp/source-skill')).toBeNull();
});
it('accepts normal folder names', () => {
expect(validateSkillFolderName('review-helper')).toBeNull();
});
it('rejects empty folder names', () => {
expect(validateSkillFolderName(' ')).toBe('Choose a folder name for this skill.');
});
it('rejects invalid filesystem characters', () => {
expect(validateSkillFolderName('bad/name')).toBe(
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
);
});
it('rejects dot segments', () => {
expect(validateSkillFolderName('..')).toBe(
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
);
});
});

View file

@ -0,0 +1,249 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useExtensionsTabState } from '../../../src/renderer/hooks/useExtensionsTabState';
import type { McpCatalogItem } from '@shared/types/extensions';
type ExtensionsTabState = ReturnType<typeof useExtensionsTabState>;
let capturedState: ExtensionsTabState | null = null;
const mcpSearchMock = vi.fn();
vi.mock('@renderer/api', () => ({
api: {
mcpRegistry: {
search: (...args: unknown[]) => mcpSearchMock(...args),
},
},
}));
function Harness(): null {
capturedState = useExtensionsTabState();
return null;
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeMcpServer(id: string): McpCatalogItem {
return {
id,
name: id,
description: `${id} description`,
source: 'official',
installSpec: null,
envVars: [],
tools: [],
requiresAuth: false,
};
}
describe('useExtensionsTabState', () => {
beforeEach(() => {
mcpSearchMock.mockReset();
mcpSearchMock.mockResolvedValue({ servers: [], warnings: [] });
});
afterEach(() => {
capturedState = null;
document.body.innerHTML = '';
vi.useRealTimers();
});
it('clears selected plugin when leaving the plugins sub-tab', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
capturedState?.setSelectedPluginId('context7@claude-plugins-official');
await Promise.resolve();
});
expect(capturedState?.selectedPluginId).toBe('context7@claude-plugins-official');
await act(async () => {
capturedState?.setActiveSubTab('mcp-servers');
await Promise.resolve();
});
expect(capturedState?.selectedPluginId).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('clears selected MCP server when leaving the MCP sub-tab', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
capturedState?.setActiveSubTab('mcp-servers');
await Promise.resolve();
});
await act(async () => {
capturedState?.setSelectedMcpServerId('server-1');
await Promise.resolve();
});
expect(capturedState?.selectedMcpServerId).toBe('server-1');
await act(async () => {
capturedState?.setActiveSubTab('skills');
await Promise.resolve();
});
expect(capturedState?.selectedMcpServerId).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('clears selected skill when leaving the skills sub-tab', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
capturedState?.setActiveSubTab('skills');
await Promise.resolve();
});
await act(async () => {
capturedState?.setSelectedSkillId('skill-1');
await Promise.resolve();
});
expect(capturedState?.selectedSkillId).toBe('skill-1');
await act(async () => {
capturedState?.setActiveSubTab('api-keys');
await Promise.resolve();
});
expect(capturedState?.selectedSkillId).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('ignores stale MCP search responses that resolve out of order', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
const first = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>();
const second = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>();
mcpSearchMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
capturedState?.mcpSearch('first');
await vi.advanceTimersByTimeAsync(300);
});
await act(async () => {
capturedState?.mcpSearch('second');
await vi.advanceTimersByTimeAsync(300);
});
await act(async () => {
second.resolve({ servers: [makeMcpServer('second-result')], warnings: ['new warning'] });
await Promise.resolve();
});
expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']);
expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']);
await act(async () => {
first.resolve({ servers: [makeMcpServer('first-result')], warnings: ['old warning'] });
await Promise.resolve();
});
expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']);
expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('drops in-flight MCP search results after clearing the query', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
const pending = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>();
mcpSearchMock.mockReturnValueOnce(pending.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
capturedState?.mcpSearch('context7');
await vi.advanceTimersByTimeAsync(300);
});
expect(capturedState?.mcpSearchLoading).toBe(true);
await act(async () => {
capturedState?.mcpSearch('');
await Promise.resolve();
});
expect(capturedState?.mcpSearchQuery).toBe('');
expect(capturedState?.mcpSearchResults).toEqual([]);
expect(capturedState?.mcpSearchWarnings).toEqual([]);
expect(capturedState?.mcpSearchLoading).toBe(false);
await act(async () => {
pending.resolve({ servers: [makeMcpServer('stale-result')], warnings: ['stale warning'] });
await Promise.resolve();
});
expect(capturedState?.mcpSearchResults).toEqual([]);
expect(capturedState?.mcpSearchWarnings).toEqual([]);
expect(capturedState?.mcpSearchLoading).toBe(false);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -40,6 +40,10 @@ vi.mock('../../../src/renderer/api', () => ({
}));
import { api } from '../../../src/renderer/api';
import {
getMcpOperationKey,
getPluginOperationKey,
} from '../../../src/shared/utils/extensionNormalizers';
import type {
EnrichedPlugin,
@ -133,6 +137,11 @@ const makeReadyCliStatus = () => ({
providers: [],
});
const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') =>
getPluginOperationKey(pluginId, scope);
const mcpOperationKey = (registryId: string, scope: 'user' | 'project' | 'local' = 'user') =>
getMcpOperationKey(registryId, scope);
describe('extensionsSlice', () => {
let store: TestStore;
@ -187,10 +196,10 @@ describe('extensionsSlice', () => {
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
pluginCatalogProjectPath: '/tmp/project-a',
pluginInstallProgress: {
'project-a@m': 'error',
[pluginOperationKey('project-a@m', 'project')]: 'error',
},
installErrors: {
'project-a@m': 'Install failed',
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
'mcp-server': 'Keep me',
},
});
@ -201,8 +210,10 @@ describe('extensionsSlice', () => {
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().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
).toBeUndefined();
expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
});
@ -266,10 +277,10 @@ describe('extensionsSlice', () => {
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
pluginCatalogProjectPath: '/tmp/project-a',
pluginInstallProgress: {
'project-a@m': 'error',
[pluginOperationKey('project-a@m', 'project')]: 'error',
},
installErrors: {
'project-a@m': 'Install failed',
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
'mcp-server': 'Keep me',
},
});
@ -278,8 +289,10 @@ describe('extensionsSlice', () => {
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().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
).toBeUndefined();
expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
});
});
@ -363,6 +376,43 @@ describe('extensionsSlice', () => {
expect(store.getState().mcpInstalledServers).toEqual(installed);
});
it('clears stale project- and local-scoped MCP operation state when project changes', async () => {
store.setState({
mcpInstalledProjectPath: '/tmp/project-a',
mcpInstallProgress: {
[mcpOperationKey('project-server', 'project')]: 'error',
[mcpOperationKey('local-server', 'local')]: 'success',
[mcpOperationKey('user-server', 'user')]: 'pending',
},
installErrors: {
[mcpOperationKey('project-server', 'project')]: 'Project failed',
[mcpOperationKey('local-server', 'local')]: 'Local failed',
[mcpOperationKey('user-server', 'user')]: 'Keep user state',
'plugin:test@marketplace:user': 'Keep plugin state',
'mcp-custom:custom-server:project': 'Clear custom project state',
},
});
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
await store.getState().mcpFetchInstalled('/tmp/project-b');
expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b');
expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined();
expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined();
expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe(
'pending',
);
expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined();
expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined();
expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe(
'Keep user state',
);
expect(store.getState().installErrors['mcp-custom:custom-server:project']).toBeUndefined();
expect(store.getState().installErrors['plugin:test@marketplace:user']).toBe(
'Keep plugin state',
);
});
});
describe('openExtensionsTab', () => {
@ -448,10 +498,10 @@ describe('extensionsSlice', () => {
const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
// During execution, should be pending
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');
await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
});
it('sets progress to error on failure', async () => {
@ -463,7 +513,7 @@ describe('extensionsSlice', () => {
await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' });
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error');
expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error');
});
it('fills missing projectPath from the active Extensions project context', async () => {
@ -488,8 +538,12 @@ describe('extensionsSlice', () => {
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');
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
'active project',
);
});
it('fills missing projectPath for local scope from the active Extensions project context', async () => {
@ -514,8 +568,24 @@ describe('extensionsSlice', () => {
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');
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
'active project',
);
});
it('keeps user-scope state isolated from local-scope failures', async () => {
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' });
expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe(
'error',
);
expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
});
it('clears older success reset timers before a new operation on the same plugin', async () => {
@ -527,14 +597,14 @@ describe('extensionsSlice', () => {
.mockResolvedValueOnce({ state: 'error', error: 'second failure' });
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');
await vi.advanceTimersByTimeAsync(2_000);
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');
});
});
@ -546,10 +616,10 @@ describe('extensionsSlice', () => {
const promise = store.getState().uninstallPlugin('test@m', 'user');
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');
await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
});
it('fills missing projectPath from the active Extensions project context', async () => {
@ -567,8 +637,12 @@ describe('extensionsSlice', () => {
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');
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
'active project',
);
});
it('fills missing projectPath for local uninstall from the active Extensions project context', async () => {
@ -586,8 +660,12 @@ describe('extensionsSlice', () => {
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');
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
'active project',
);
});
it('does not restore idle state after project switch clears a pending success timer', async () => {
@ -602,14 +680,14 @@ describe('extensionsSlice', () => {
(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');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
await store.getState().fetchPluginCatalog('/tmp/project-b');
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();
await vi.advanceTimersByTimeAsync(2_000);
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();
});
});
@ -627,10 +705,44 @@ describe('extensionsSlice', () => {
headers: [],
});
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending');
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
'pending',
);
await promise;
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
'success',
);
});
it('does not restore idle state after project switch clears a pending project-scope success timer', async () => {
vi.useFakeTimers();
store.setState({
mcpInstalledProjectPath: '/tmp/project-a',
});
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
await store.getState().installMcpServer({
registryId: 'test-id',
serverName: 'test-server',
scope: 'project',
projectPath: '/tmp/project-a',
envValues: {},
headers: [],
});
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe(
'success',
);
await store.getState().mcpFetchInstalled('/tmp/project-b');
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined();
await vi.advanceTimersByTimeAsync(2_000);
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined();
});
});
@ -642,10 +754,14 @@ describe('extensionsSlice', () => {
const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user');
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending');
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
'pending',
);
await promise;
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
'success',
);
});
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { PluginCatalogItem } from '@shared/types/extensions';
import type { InstalledMcpEntry, PluginCatalogItem } from '@shared/types/extensions';
import {
buildPluginId,
@ -8,6 +8,10 @@ import {
getExtensionActionDisableReason,
getCapabilityLabel,
getInstallationSummaryLabel,
getMcpInstallationSummaryLabel,
getMcpOperationKey,
getPreferredMcpInstallationEntry,
getPluginOperationKey,
getPrimaryCapabilityLabel,
hasInstallationInScope,
inferCapabilities,
@ -152,6 +156,22 @@ describe('buildPluginId', () => {
});
});
describe('getPluginOperationKey', () => {
it('namespaces plugin operation keys by scope', () => {
expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe(
'plugin:context7@claude-plugins-official:local',
);
});
});
describe('getMcpOperationKey', () => {
it('namespaces MCP operation keys by scope', () => {
expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe(
'mcp:io.github.upstash/context7:project',
);
});
});
describe('hasInstallationInScope', () => {
it('returns true when the selected scope exists', () => {
expect(
@ -193,12 +213,50 @@ describe('getInstallationSummaryLabel', () => {
});
});
describe('getPreferredMcpInstallationEntry', () => {
it('returns null when there are no MCP installs', () => {
expect(getPreferredMcpInstallationEntry([])).toBeNull();
});
it('prefers local scope over project and user', () => {
const installations: InstalledMcpEntry[] = [
{ name: 'context7', scope: 'user' },
{ name: 'context7', scope: 'project' },
{ name: 'context7', scope: 'local' },
];
expect(getPreferredMcpInstallationEntry(installations)).toEqual({
name: 'context7',
scope: 'local',
});
});
});
describe('getMcpInstallationSummaryLabel', () => {
it('returns null when there are no MCP installations', () => {
expect(getMcpInstallationSummaryLabel([])).toBeNull();
});
it('describes a single local MCP installation', () => {
expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally');
});
it('summarizes multiple MCP scopes', () => {
expect(
getMcpInstallationSummaryLabel([
{ scope: 'user' },
{ scope: 'project' },
])
).toBe('Installed in 2 scopes');
});
});
describe('getExtensionActionDisableReason', () => {
it('requires auth only for install actions', () => {
expect(
getExtensionActionDisableReason({
isInstalled: false,
cliStatus: { installed: true, authLoggedIn: false },
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
cliStatusLoading: false,
}),
).toContain('not signed in');
@ -208,7 +266,7 @@ describe('getExtensionActionDisableReason', () => {
expect(
getExtensionActionDisableReason({
isInstalled: true,
cliStatus: { installed: true, authLoggedIn: false },
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
cliStatusLoading: false,
}),
).toBeNull();
@ -218,11 +276,26 @@ describe('getExtensionActionDisableReason', () => {
expect(
getExtensionActionDisableReason({
isInstalled: true,
cliStatus: { installed: false, authLoggedIn: false },
cliStatus: { installed: false, authLoggedIn: false, binaryPath: null, launchError: null },
cliStatusLoading: false,
}),
).toContain('Claude CLI required');
});
it('surfaces startup health-check failures separately from missing CLI', () => {
expect(
getExtensionActionDisableReason({
isInstalled: false,
cliStatus: {
installed: false,
authLoggedIn: false,
binaryPath: '/usr/local/bin/claude',
launchError: 'spawn EACCES',
},
cliStatusLoading: false,
}),
).toContain('failed to start');
});
});
describe('sanitizeMcpServerName', () => {