feat(extensions): surface provider-aware capabilities in UI
This commit is contained in:
parent
096437b2fd
commit
b3427a64ab
15 changed files with 469 additions and 57 deletions
|
|
@ -7,6 +7,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
|
|
@ -164,14 +165,20 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const isRefreshing =
|
const isRefreshing =
|
||||||
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||||
const cliStatusBanner = useMemo(() => {
|
const cliStatusBanner = useMemo(() => {
|
||||||
|
const providers = cliStatus?.providers ?? [];
|
||||||
|
const isMultimodel = cliStatus?.flavor === 'agent_teams_orchestrator' && providers.length > 0;
|
||||||
|
|
||||||
if (cliStatusLoading || cliStatus === null) {
|
if (cliStatusLoading || cliStatus === null) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||||
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-text">Checking Claude CLI availability</p>
|
<p className="text-sm font-medium text-text">
|
||||||
|
Checking extensions runtime availability
|
||||||
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-text-muted">
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
|
Extensions need the configured runtime to manage plugins, MCP servers, skills, and
|
||||||
|
provider connections.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,13 +193,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-amber-300">
|
<p className="text-sm font-medium text-amber-300">
|
||||||
{cliLaunchIssue
|
{cliLaunchIssue
|
||||||
? 'Claude CLI was found but failed to start'
|
? 'The configured runtime was found but failed to start'
|
||||||
: 'Claude CLI is not available'}
|
: 'The configured runtime is not available'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-text-muted">
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
{cliLaunchIssue
|
{cliLaunchIssue
|
||||||
? 'Plugin installs are disabled until Claude CLI passes its startup health check. Open the Dashboard to repair or reinstall it.'
|
? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.'
|
||||||
: 'Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to install it and retry.'}
|
: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
|
||||||
</p>
|
</p>
|
||||||
{cliLaunchIssue && cliStatus.launchError && (
|
{cliLaunchIssue && cliStatus.launchError && (
|
||||||
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
|
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
|
||||||
|
|
@ -207,7 +214,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cliStatus.authLoggedIn) {
|
if (!isMultimodel && !cliStatus.authLoggedIn) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
|
|
@ -226,6 +233,68 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMultimodel) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface/70 mx-4 mt-3 rounded-md border border-border px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-text">Multimodel runtime capabilities</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
|
Provider support can differ by section. Plugins are shown only where the runtime
|
||||||
|
explicitly declares support.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const statusTone = provider.authenticated
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
|
||||||
|
: provider.supported
|
||||||
|
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
|
||||||
|
: 'border-border bg-surface-raised text-text-muted';
|
||||||
|
const statusLabel = provider.authenticated
|
||||||
|
? 'Connected'
|
||||||
|
: provider.supported
|
||||||
|
? 'Needs setup'
|
||||||
|
: 'Unsupported';
|
||||||
|
const pluginStatus = provider.capabilities.extensions.plugins.status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider.providerId}
|
||||||
|
className={`rounded-md border px-3 py-2 ${statusTone}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{provider.displayName}</p>
|
||||||
|
<p className="truncate text-[11px] text-text-muted">
|
||||||
|
{provider.statusMessage ?? provider.backend?.label ?? 'Ready to configure'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0">
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
Plugins: {pluginStatus === 'supported' ? 'supported' : 'limited'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
MCP: {provider.capabilities.extensions.mcp.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
Skills: {provider.capabilities.extensions.skills.ownership}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
|
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
|
||||||
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||||
|
|
@ -280,7 +349,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
{!cliInstalled && (
|
{!cliInstalled && (
|
||||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
|
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
|
||||||
<AlertTriangle className="size-4 shrink-0" />
|
<AlertTriangle className="size-4 shrink-0" />
|
||||||
Claude CLI is required to install or uninstall extensions. Install it from Settings.
|
The configured runtime is required to install or uninstall extensions. Install or
|
||||||
|
repair it from the Dashboard.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Active sessions warning */}
|
{/* Active sessions warning */}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* ApiKeysPanel — grid of saved API keys with add button and empty state.
|
* ApiKeysPanel — grid of saved API keys with add button and empty state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -16,15 +16,17 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
||||||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
export const ApiKeysPanel = (): React.JSX.Element => {
|
export const ApiKeysPanel = (): React.JSX.Element => {
|
||||||
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore(
|
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } =
|
||||||
useShallow((s) => ({
|
useStore(
|
||||||
apiKeys: s.apiKeys,
|
useShallow((s) => ({
|
||||||
apiKeysLoading: s.apiKeysLoading,
|
apiKeys: s.apiKeys,
|
||||||
apiKeysError: s.apiKeysError,
|
apiKeysLoading: s.apiKeysLoading,
|
||||||
storageStatus: s.apiKeyStorageStatus,
|
apiKeysError: s.apiKeysError,
|
||||||
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
storageStatus: s.apiKeyStorageStatus,
|
||||||
}))
|
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
||||||
);
|
cliStatus: s.cliStatus,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
||||||
|
|
@ -49,9 +51,82 @@ export const ApiKeysPanel = (): React.JSX.Element => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
|
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
|
||||||
|
const providerKeyCards = useMemo(() => {
|
||||||
|
if (!cliStatus?.providers?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
providerId: 'anthropic',
|
||||||
|
label: 'Anthropic runtime',
|
||||||
|
envVar: 'ANTHROPIC_API_KEY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: 'codex',
|
||||||
|
label: 'Codex runtime',
|
||||||
|
envVar: 'OPENAI_API_KEY',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).flatMap((item) => {
|
||||||
|
const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
authenticated: provider.authenticated,
|
||||||
|
apiKeyConfigured: provider.connection?.apiKeyConfigured ?? false,
|
||||||
|
sourceLabel: provider.connection?.apiKeySourceLabel ?? null,
|
||||||
|
statusMessage: provider.statusMessage ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}, [cliStatus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{providerKeyCards.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
{providerKeyCards.map((provider) => (
|
||||||
|
<div
|
||||||
|
key={provider.providerId}
|
||||||
|
className="bg-surface-raised/30 rounded-lg border border-border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text">{provider.label}</p>
|
||||||
|
<p className="mt-0.5 font-mono text-[11px] text-text-muted">{provider.envVar}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[11px] ${
|
||||||
|
provider.authenticated
|
||||||
|
? 'bg-emerald-500/10 text-emerald-300'
|
||||||
|
: provider.apiKeyConfigured
|
||||||
|
? 'bg-blue-500/10 text-blue-300'
|
||||||
|
: 'bg-amber-500/10 text-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{provider.authenticated
|
||||||
|
? 'Connected'
|
||||||
|
: provider.apiKeyConfigured
|
||||||
|
? 'Key configured'
|
||||||
|
: 'Key missing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
|
{provider.sourceLabel
|
||||||
|
? `Current source: ${provider.sourceLabel}.`
|
||||||
|
: 'No stored or environment key detected for this provider.'}
|
||||||
|
{provider.statusMessage ? ` ${provider.statusMessage}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ interface InstallButtonProps {
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
|
section?: 'plugins' | 'mcp';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'sm' | 'default';
|
size?: 'sm' | 'default';
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|
@ -34,6 +35,7 @@ export const InstallButton = ({
|
||||||
isInstalled,
|
isInstalled,
|
||||||
onInstall,
|
onInstall,
|
||||||
onUninstall,
|
onUninstall,
|
||||||
|
section = 'plugins',
|
||||||
disabled,
|
disabled,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
|
@ -48,6 +50,7 @@ export const InstallButton = ({
|
||||||
isInstalled,
|
isInstalled,
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
section,
|
||||||
});
|
});
|
||||||
const isDisabled = disabled || Boolean(disableReason);
|
const isDisabled = disabled || Boolean(disableReason);
|
||||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,7 @@ export const McpServerCard = ({
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isInstalled}
|
isInstalled={isInstalled}
|
||||||
|
section="mcp"
|
||||||
onInstall={() =>
|
onInstall={() =>
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
|
|
|
||||||
|
|
@ -528,6 +528,7 @@ export const McpServerDetailDialog = ({
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isInstalledForScope}
|
isInstalled={isInstalledForScope}
|
||||||
|
section="mcp"
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onUninstall={handleUninstall}
|
onUninstall={handleUninstall}
|
||||||
disabled={installDisabled}
|
disabled={installDisabled}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export const McpServersPanel = ({
|
||||||
mcpDiagnosticsError,
|
mcpDiagnosticsError,
|
||||||
mcpDiagnosticsLastCheckedAt,
|
mcpDiagnosticsLastCheckedAt,
|
||||||
runMcpDiagnostics,
|
runMcpDiagnostics,
|
||||||
|
cliStatus,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
browseCatalog: s.mcpBrowseCatalog,
|
browseCatalog: s.mcpBrowseCatalog,
|
||||||
|
|
@ -105,6 +106,7 @@ export const McpServersPanel = ({
|
||||||
mcpDiagnosticsError: s.mcpDiagnosticsError,
|
mcpDiagnosticsError: s.mcpDiagnosticsError,
|
||||||
mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt,
|
mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt,
|
||||||
runMcpDiagnostics: s.runMcpDiagnostics,
|
runMcpDiagnostics: s.runMcpDiagnostics,
|
||||||
|
cliStatus: s.cliStatus,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -118,8 +120,8 @@ export const McpServersPanel = ({
|
||||||
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
|
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void runMcpDiagnostics();
|
void runMcpDiagnostics(projectPath ?? undefined);
|
||||||
}, [runMcpDiagnostics]);
|
}, [projectPath, runMcpDiagnostics]);
|
||||||
|
|
||||||
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -185,6 +187,12 @@ export const McpServersPanel = ({
|
||||||
|
|
||||||
// Sort displayed servers
|
// Sort displayed servers
|
||||||
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
|
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
|
||||||
|
const runtimeLabel =
|
||||||
|
cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI';
|
||||||
|
const diagnosticsCommand =
|
||||||
|
cliStatus?.flavor === 'agent_teams_orchestrator'
|
||||||
|
? 'claude-multimodel mcp diagnose'
|
||||||
|
: 'claude mcp list';
|
||||||
|
|
||||||
// Find selected server (search in both lists to avoid losing selection during search toggle)
|
// Find selected server (search in both lists to avoid losing selection during search toggle)
|
||||||
const selectedServer = useMemo(() => {
|
const selectedServer = useMemo(() => {
|
||||||
|
|
@ -205,14 +213,12 @@ export const McpServersPanel = ({
|
||||||
<p className="text-sm font-medium text-text">MCP Health Status</p>
|
<p className="text-sm font-medium text-text">MCP Health Status</p>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{mcpDiagnosticsLoading ? (
|
{mcpDiagnosticsLoading ? (
|
||||||
<>
|
<>Checking installed MCP servers via {runtimeLabel} ...</>
|
||||||
Checking installed MCP servers via Claude CLI (<code>claude mcp list</code>) ...
|
|
||||||
</>
|
|
||||||
) : mcpDiagnosticsLastCheckedAt ? (
|
) : mcpDiagnosticsLastCheckedAt ? (
|
||||||
`Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
|
`Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Run diagnostics (<code>claude mcp list</code>) to verify installed MCP
|
Run diagnostics (<code>{diagnosticsCommand}</code>) to verify installed MCP
|
||||||
connectivity.
|
connectivity.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -221,7 +227,7 @@ export const McpServersPanel = ({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void runMcpDiagnostics()}
|
onClick={() => void runMcpDiagnostics(projectPath ?? undefined)}
|
||||||
disabled={mcpDiagnosticsLoading}
|
disabled={mcpDiagnosticsLoading}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
|
|
@ -235,7 +241,7 @@ export const McpServersPanel = ({
|
||||||
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
|
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
|
||||||
<div className="mt-4 border-t border-black/10 pt-4 dark:border-white/10">
|
<div className="mt-4 border-t border-black/10 pt-4 dark:border-white/10">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<p className="text-sm font-medium text-text">Claude MCP List Results</p>
|
<p className="text-sm font-medium text-text">Runtime MCP Diagnostics</p>
|
||||||
{allDiagnostics.length > 0 && (
|
{allDiagnostics.length > 0 && (
|
||||||
<span className="text-xs text-text-muted">{allDiagnostics.length} servers</span>
|
<span className="text-xs text-text-muted">{allDiagnostics.length} servers</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isUserInstalled}
|
isInstalled={isUserInstalled}
|
||||||
|
section="plugins"
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ export const PluginDetailDialog = ({
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isInstalledForScope}
|
isInstalled={isInstalledForScope}
|
||||||
|
section="plugins"
|
||||||
onInstall={() =>
|
onInstall={() =>
|
||||||
installPlugin({
|
installPlugin({
|
||||||
pluginId: plugin.pluginId,
|
pluginId: plugin.pluginId,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getCliProviderExtensionCapability } from '@shared/utils/providerExtensionCapabilities';
|
||||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||||
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -125,11 +126,12 @@ export const PluginsPanel = ({
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
setPluginSort,
|
setPluginSort,
|
||||||
}: PluginsPanelProps): React.JSX.Element => {
|
}: PluginsPanelProps): React.JSX.Element => {
|
||||||
const { catalog, loading, error } = useStore(
|
const { catalog, loading, error, cliStatus } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
catalog: s.pluginCatalog,
|
catalog: s.pluginCatalog,
|
||||||
loading: s.pluginCatalogLoading,
|
loading: s.pluginCatalogLoading,
|
||||||
error: s.pluginCatalogError,
|
error: s.pluginCatalogError,
|
||||||
|
cliStatus: s.cliStatus,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -175,9 +177,26 @@ export const PluginsPanel = ({
|
||||||
}
|
}
|
||||||
return counts.size;
|
return counts.size;
|
||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{cliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||||
|
(() => {
|
||||||
|
const codexProvider = cliStatus.providers.find(
|
||||||
|
(provider) => provider.providerId === 'codex'
|
||||||
|
);
|
||||||
|
if (!codexProvider) return null;
|
||||||
|
const capability = getCliProviderExtensionCapability(codexProvider, 'plugins');
|
||||||
|
if (capability.status === 'supported') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300">
|
||||||
|
Plugins currently apply to Anthropic sessions in the multimodel runtime.
|
||||||
|
{capability.reason ? ` ${capability.reason}` : ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Search + Sort + Installed only row */}
|
{/* Search + Sort + Installed only row */}
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export const SkillsPanel = ({
|
||||||
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
|
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
||||||
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||||
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
||||||
|
|
@ -226,6 +227,12 @@ export const SkillsPanel = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{cliStatus?.flavor === 'agent_teams_orchestrator' && (
|
||||||
|
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-300">
|
||||||
|
Skills are shared across providers in this runtime. A personal or project skill you edit
|
||||||
|
here is available to both Anthropic and Codex sessions that support skills.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="bg-surface-raised/20 rounded-xl border border-border p-4">
|
<div className="bg-surface-raised/20 rounded-xl border border-border p-4">
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div className="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
|
<div className="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export interface ExtensionsSlice {
|
||||||
fetchPluginReadme: (pluginId: string) => void;
|
fetchPluginReadme: (pluginId: string) => void;
|
||||||
mcpBrowse: (cursor?: string) => Promise<void>;
|
mcpBrowse: (cursor?: string) => Promise<void>;
|
||||||
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
||||||
runMcpDiagnostics: () => Promise<void>;
|
runMcpDiagnostics: (projectPath?: string) => Promise<void>;
|
||||||
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
||||||
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
||||||
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
||||||
|
|
@ -375,7 +375,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
const requestSeq = ++pluginCatalogRequestSeq;
|
const requestSeq = ++pluginCatalogRequestSeq;
|
||||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||||
|
|
||||||
const promise = (async () => {
|
let currentPromise: Promise<void> | null = null;
|
||||||
|
currentPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
|
|
@ -431,14 +432,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (pluginFetchInFlight?.promise === promise) {
|
if (currentPromise && pluginFetchInFlight?.promise === currentPromise) {
|
||||||
pluginFetchInFlight = null;
|
pluginFetchInFlight = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
pluginFetchInFlight = { key: requestKey, promise };
|
pluginFetchInFlight = { key: requestKey, promise: currentPromise };
|
||||||
await promise;
|
await currentPromise;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Plugin README fetch ──
|
// ── Plugin README fetch ──
|
||||||
|
|
@ -537,7 +538,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
runMcpDiagnostics: async () => {
|
runMcpDiagnostics: async (projectPath?: string) => {
|
||||||
const mcpRegistry = api.mcpRegistry;
|
const mcpRegistry = api.mcpRegistry;
|
||||||
if (!mcpRegistry) return;
|
if (!mcpRegistry) return;
|
||||||
|
|
||||||
|
|
@ -550,7 +551,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const diagnostics = await mcpRegistry.diagnose();
|
const diagnostics = await mcpRegistry.diagnose(projectPath);
|
||||||
set({
|
set({
|
||||||
mcpDiagnostics: Object.fromEntries(
|
mcpDiagnostics: Object.fromEntries(
|
||||||
diagnostics.map((entry) => [entry.name, entry] as const)
|
diagnostics.map((entry) => [entry.name, entry] as const)
|
||||||
|
|
@ -960,7 +961,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||||
get().runMcpDiagnostics(),
|
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -1008,7 +1009,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||||
get().runMcpDiagnostics(),
|
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -1064,7 +1065,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||||
get().runMcpDiagnostics(),
|
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -1116,7 +1117,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
try {
|
try {
|
||||||
await api.apiKeys.save(request);
|
await api.apiKeys.save(request);
|
||||||
// Refresh the list to get updated masked values
|
// Refresh the list to get updated masked values
|
||||||
const keys = await api.apiKeys.list();
|
const [keys] = await Promise.all([api.apiKeys.list(), get().fetchCliStatus()]);
|
||||||
set({ apiKeys: keys, apiKeySaving: false });
|
set({ apiKeys: keys, apiKeySaving: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -1133,6 +1134,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.apiKeys.delete(id);
|
await api.apiKeys.delete(id);
|
||||||
|
await get().fetchCliStatus();
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
* Pure-function normalizers for Extension Store data.
|
* Pure-function normalizers for Extension Store data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCliProviderExtensionCapability,
|
||||||
|
isCliExtensionCapabilityMutable,
|
||||||
|
} from './providerExtensionCapabilities';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
InstallScope,
|
InstallScope,
|
||||||
|
|
@ -208,28 +213,74 @@ export function getExtensionActionDisableReason(options: {
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
cliStatus: Pick<
|
cliStatus: Pick<
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError'
|
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||||
> | null;
|
> | null;
|
||||||
cliStatusLoading: boolean;
|
cliStatusLoading: boolean;
|
||||||
|
section?: 'plugins' | 'mcp';
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const { isInstalled, cliStatus, cliStatusLoading } = options;
|
const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options;
|
||||||
if (cliStatusLoading) {
|
if (cliStatusLoading) {
|
||||||
return 'Checking Claude CLI status...';
|
return 'Checking runtime status...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliStatus === null) {
|
if (cliStatus === null) {
|
||||||
return 'Checking Claude CLI availability...';
|
return 'Checking runtime availability...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliStatus.installed === false) {
|
if (cliStatus.installed === false) {
|
||||||
if (cliStatus.binaryPath && cliStatus.launchError) {
|
if (cliStatus.binaryPath && cliStatus.launchError) {
|
||||||
return 'Claude CLI was found but failed to start. Open the Dashboard to repair or reinstall it.';
|
return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
|
||||||
}
|
}
|
||||||
return 'Claude CLI required. Install it from the Dashboard.';
|
return 'The configured runtime is required. Install or repair it from the Dashboard.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isInstalled && !cliStatus.authLoggedIn) {
|
const providers = cliStatus.providers ?? [];
|
||||||
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
|
const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator' && providers.length > 0;
|
||||||
|
|
||||||
|
if (section === 'mcp') {
|
||||||
|
if (!isMultimodel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutableProviders = providers.filter((provider) =>
|
||||||
|
isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'mcp'))
|
||||||
|
);
|
||||||
|
if (mutableProviders.length > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = providers
|
||||||
|
.map((provider) => getCliProviderExtensionCapability(provider, 'mcp').reason)
|
||||||
|
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||||
|
|
||||||
|
return reason ?? 'MCP management is not supported by the current runtime.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMultimodel) {
|
||||||
|
if (!isInstalled && !cliStatus.authLoggedIn) {
|
||||||
|
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginProviders = providers.filter((provider) =>
|
||||||
|
isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'plugins'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pluginProviders.length === 0) {
|
||||||
|
const reason = providers
|
||||||
|
.map((provider) => getCliProviderExtensionCapability(provider, 'plugins').reason)
|
||||||
|
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||||
|
return reason ?? 'Plugin installs are not supported by the current runtime.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedProvider = pluginProviders.find((provider) => provider.authenticated);
|
||||||
|
if (!authenticatedProvider) {
|
||||||
|
return `${pluginProviders[0]?.displayName ?? 'Anthropic'} is not connected. Open the Dashboard to sign in.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -258,8 +258,12 @@ describe('PluginDetailDialog project context', () => {
|
||||||
|
|
||||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||||
expect(scopeSelect).not.toBeNull();
|
expect(scopeSelect).not.toBeNull();
|
||||||
expect(scopeSelect.querySelector('option[value="project"]')?.disabled).toBe(true);
|
expect(
|
||||||
expect(scopeSelect.querySelector('option[value="local"]')?.disabled).toBe(true);
|
(scopeSelect.querySelector('option[value="project"]') as HTMLOptionElement | null)?.disabled
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(scopeSelect.querySelector('option[value="local"]') as HTMLOptionElement | null)?.disabled
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,20 @@ vi.mock('../../../src/renderer/api', () => ({
|
||||||
stopWatching: vi.fn(),
|
stopWatching: vi.fn(),
|
||||||
onChanged: vi.fn(),
|
onChanged: vi.fn(),
|
||||||
},
|
},
|
||||||
|
apiKeys: {
|
||||||
|
list: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
lookup: vi.fn(),
|
||||||
|
getStorageStatus: vi.fn(),
|
||||||
|
},
|
||||||
|
cliInstaller: {
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
getProviderStatus: vi.fn(),
|
||||||
|
verifyProviderModels: vi.fn(),
|
||||||
|
invalidateStatus: vi.fn(),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -148,6 +162,7 @@ describe('extensionsSlice', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = createTestStore();
|
store = createTestStore();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
(api.cliInstaller!.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(makeReadyCliStatus());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -765,6 +780,61 @@ describe('extensionsSlice', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('provider-aware runtime refresh', () => {
|
||||||
|
it('passes projectPath through MCP diagnostics', async () => {
|
||||||
|
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await store.getState().runMcpDiagnostics('/tmp/project-a');
|
||||||
|
|
||||||
|
expect(api.mcpRegistry!.diagnose).toHaveBeenCalledWith('/tmp/project-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes CLI status after saving an API key', async () => {
|
||||||
|
(api.apiKeys!.save as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'k1' });
|
||||||
|
(api.apiKeys!.list as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'k1',
|
||||||
|
name: 'Codex key',
|
||||||
|
envVarName: 'OPENAI_API_KEY',
|
||||||
|
maskedValue: '***',
|
||||||
|
scope: 'user',
|
||||||
|
createdAt: '2026-04-17T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await store.getState().saveApiKey({
|
||||||
|
name: 'Codex key',
|
||||||
|
envVarName: 'OPENAI_API_KEY',
|
||||||
|
value: 'secret',
|
||||||
|
scope: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.cliInstaller!.getStatus).toHaveBeenCalled();
|
||||||
|
expect(store.getState().apiKeys).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes CLI status after deleting an API key', async () => {
|
||||||
|
store.setState({
|
||||||
|
apiKeys: [
|
||||||
|
{
|
||||||
|
id: 'k1',
|
||||||
|
name: 'Codex key',
|
||||||
|
envVarName: 'OPENAI_API_KEY',
|
||||||
|
maskedValue: '***',
|
||||||
|
scope: 'user',
|
||||||
|
createdAt: '2026-04-17T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
(api.apiKeys!.delete as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await store.getState().deleteApiKey('k1');
|
||||||
|
|
||||||
|
expect(api.cliInstaller!.getStatus).toHaveBeenCalled();
|
||||||
|
expect(store.getState().apiKeys).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('skills state hardening', () => {
|
describe('skills state hardening', () => {
|
||||||
it('ignores stale catalog responses for the same project key', async () => {
|
it('ignores stale catalog responses for the same project key', async () => {
|
||||||
let resolveFirst!: (value: SkillCatalogItem[]) => void;
|
let resolveFirst!: (value: SkillCatalogItem[]) => void;
|
||||||
|
|
|
||||||
|
|
@ -252,11 +252,28 @@ describe('getMcpInstallationSummaryLabel', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getExtensionActionDisableReason', () => {
|
describe('getExtensionActionDisableReason', () => {
|
||||||
|
const createDirectCliStatus = (
|
||||||
|
overrides: Partial<{
|
||||||
|
installed: boolean;
|
||||||
|
authLoggedIn: boolean;
|
||||||
|
binaryPath: string | null;
|
||||||
|
launchError: string | null;
|
||||||
|
}> = {}
|
||||||
|
) => ({
|
||||||
|
flavor: 'claude' as const,
|
||||||
|
installed: true,
|
||||||
|
authLoggedIn: true,
|
||||||
|
binaryPath: null,
|
||||||
|
launchError: null,
|
||||||
|
providers: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
it('requires auth only for install actions', () => {
|
it('requires auth only for install actions', () => {
|
||||||
expect(
|
expect(
|
||||||
getExtensionActionDisableReason({
|
getExtensionActionDisableReason({
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
|
cliStatus: createDirectCliStatus({ authLoggedIn: false }),
|
||||||
cliStatusLoading: false,
|
cliStatusLoading: false,
|
||||||
}),
|
}),
|
||||||
).toContain('not signed in');
|
).toContain('not signed in');
|
||||||
|
|
@ -266,7 +283,7 @@ describe('getExtensionActionDisableReason', () => {
|
||||||
expect(
|
expect(
|
||||||
getExtensionActionDisableReason({
|
getExtensionActionDisableReason({
|
||||||
isInstalled: true,
|
isInstalled: true,
|
||||||
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
|
cliStatus: createDirectCliStatus({ authLoggedIn: false }),
|
||||||
cliStatusLoading: false,
|
cliStatusLoading: false,
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
@ -276,10 +293,10 @@ describe('getExtensionActionDisableReason', () => {
|
||||||
expect(
|
expect(
|
||||||
getExtensionActionDisableReason({
|
getExtensionActionDisableReason({
|
||||||
isInstalled: true,
|
isInstalled: true,
|
||||||
cliStatus: { installed: false, authLoggedIn: false, binaryPath: null, launchError: null },
|
cliStatus: createDirectCliStatus({ installed: false, authLoggedIn: false }),
|
||||||
cliStatusLoading: false,
|
cliStatusLoading: false,
|
||||||
}),
|
}),
|
||||||
).toContain('Claude CLI required');
|
).toContain('configured runtime');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces startup health-check failures separately from missing CLI', () => {
|
it('surfaces startup health-check failures separately from missing CLI', () => {
|
||||||
|
|
@ -287,15 +304,99 @@ describe('getExtensionActionDisableReason', () => {
|
||||||
getExtensionActionDisableReason({
|
getExtensionActionDisableReason({
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
cliStatus: {
|
cliStatus: {
|
||||||
installed: false,
|
...createDirectCliStatus({
|
||||||
authLoggedIn: false,
|
installed: false,
|
||||||
binaryPath: '/usr/local/bin/claude',
|
authLoggedIn: false,
|
||||||
launchError: 'spawn EACCES',
|
binaryPath: '/usr/local/bin/claude',
|
||||||
|
launchError: 'spawn EACCES',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
cliStatusLoading: false,
|
cliStatusLoading: false,
|
||||||
}),
|
}),
|
||||||
).toContain('failed to start');
|
).toContain('failed to start');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables multimodel plugin installs when the runtime declares plugins unsupported', () => {
|
||||||
|
expect(
|
||||||
|
getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
section: 'plugins',
|
||||||
|
cliStatus: {
|
||||||
|
installed: true,
|
||||||
|
authLoggedIn: true,
|
||||||
|
binaryPath: '/usr/local/bin/claude-multimodel',
|
||||||
|
launchError: null,
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
supported: true,
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'unknown',
|
||||||
|
models: [],
|
||||||
|
canLoginFromUi: true,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
extensions: {
|
||||||
|
plugins: {
|
||||||
|
status: 'unsupported',
|
||||||
|
ownership: 'shared',
|
||||||
|
reason: 'Anthropic plugins unavailable',
|
||||||
|
},
|
||||||
|
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cliStatusLoading: false,
|
||||||
|
}),
|
||||||
|
).toContain('Anthropic plugins unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows multimodel MCP actions without aggregate auth when MCP support is declared', () => {
|
||||||
|
expect(
|
||||||
|
getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
section: 'mcp',
|
||||||
|
cliStatus: {
|
||||||
|
installed: true,
|
||||||
|
authLoggedIn: false,
|
||||||
|
binaryPath: '/usr/local/bin/claude-multimodel',
|
||||||
|
launchError: null,
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'codex',
|
||||||
|
displayName: 'Codex',
|
||||||
|
supported: true,
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'unknown',
|
||||||
|
models: [],
|
||||||
|
canLoginFromUi: true,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
extensions: {
|
||||||
|
plugins: { status: 'unsupported', ownership: 'shared', reason: null },
|
||||||
|
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cliStatusLoading: false,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sanitizeMcpServerName', () => {
|
describe('sanitizeMcpServerName', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue