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 { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
||||
import {
|
||||
|
|
@ -164,14 +165,20 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
const isRefreshing =
|
||||
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||
const cliStatusBanner = useMemo(() => {
|
||||
const providers = cliStatus?.providers ?? [];
|
||||
const isMultimodel = cliStatus?.flavor === 'agent_teams_orchestrator' && providers.length > 0;
|
||||
|
||||
if (cliStatusLoading || cliStatus === null) {
|
||||
return (
|
||||
<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" />
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -186,13 +193,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">
|
||||
{cliLaunchIssue
|
||||
? 'Claude CLI was found but failed to start'
|
||||
: 'Claude CLI is not available'}
|
||||
? 'The configured runtime was found but failed to start'
|
||||
: 'The configured runtime is not available'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
{cliLaunchIssue
|
||||
? 'Plugin installs are disabled until Claude CLI 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 passes its startup health check. Open the Dashboard to repair or reinstall it.'
|
||||
: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
|
||||
</p>
|
||||
{cliLaunchIssue && cliStatus.launchError && (
|
||||
<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 (
|
||||
<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" />
|
||||
|
|
@ -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 (
|
||||
<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" />
|
||||
|
|
@ -280,7 +349,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
{!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">
|
||||
<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>
|
||||
)}
|
||||
{/* Active sessions warning */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -16,15 +16,17 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
|||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||
|
||||
export const ApiKeysPanel = (): React.JSX.Element => {
|
||||
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore(
|
||||
useShallow((s) => ({
|
||||
apiKeys: s.apiKeys,
|
||||
apiKeysLoading: s.apiKeysLoading,
|
||||
apiKeysError: s.apiKeysError,
|
||||
storageStatus: s.apiKeyStorageStatus,
|
||||
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
||||
}))
|
||||
);
|
||||
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
apiKeys: s.apiKeys,
|
||||
apiKeysLoading: s.apiKeysLoading,
|
||||
apiKeysError: s.apiKeysError,
|
||||
storageStatus: s.apiKeyStorageStatus,
|
||||
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
||||
cliStatus: s.cliStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
||||
|
|
@ -49,9 +51,82 @@ export const ApiKeysPanel = (): React.JSX.Element => {
|
|||
};
|
||||
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface InstallButtonProps {
|
|||
isInstalled: boolean;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
section?: 'plugins' | 'mcp';
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'default';
|
||||
errorMessage?: string;
|
||||
|
|
@ -34,6 +35,7 @@ export const InstallButton = ({
|
|||
isInstalled,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
section = 'plugins',
|
||||
disabled,
|
||||
size = 'sm',
|
||||
errorMessage,
|
||||
|
|
@ -48,6 +50,7 @@ export const InstallButton = ({
|
|||
isInstalled,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
section,
|
||||
});
|
||||
const isDisabled = disabled || Boolean(disableReason);
|
||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ export const McpServerCard = ({
|
|||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isInstalled}
|
||||
section="mcp"
|
||||
onInstall={() =>
|
||||
installMcpServer({
|
||||
registryId: server.id,
|
||||
|
|
|
|||
|
|
@ -528,6 +528,7 @@ export const McpServerDetailDialog = ({
|
|||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isInstalledForScope}
|
||||
section="mcp"
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
disabled={installDisabled}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export const McpServersPanel = ({
|
|||
mcpDiagnosticsError,
|
||||
mcpDiagnosticsLastCheckedAt,
|
||||
runMcpDiagnostics,
|
||||
cliStatus,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
browseCatalog: s.mcpBrowseCatalog,
|
||||
|
|
@ -105,6 +106,7 @@ export const McpServersPanel = ({
|
|||
mcpDiagnosticsError: s.mcpDiagnosticsError,
|
||||
mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt,
|
||||
runMcpDiagnostics: s.runMcpDiagnostics,
|
||||
cliStatus: s.cliStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -118,8 +120,8 @@ export const McpServersPanel = ({
|
|||
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
|
||||
|
||||
useEffect(() => {
|
||||
void runMcpDiagnostics();
|
||||
}, [runMcpDiagnostics]);
|
||||
void runMcpDiagnostics(projectPath ?? undefined);
|
||||
}, [projectPath, runMcpDiagnostics]);
|
||||
|
||||
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
||||
useEffect(() => {
|
||||
|
|
@ -185,6 +187,12 @@ export const McpServersPanel = ({
|
|||
|
||||
// Sort displayed servers
|
||||
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)
|
||||
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-xs text-text-muted">
|
||||
{mcpDiagnosticsLoading ? (
|
||||
<>
|
||||
Checking installed MCP servers via Claude CLI (<code>claude mcp list</code>) ...
|
||||
</>
|
||||
<>Checking installed MCP servers via {runtimeLabel} ...</>
|
||||
) : mcpDiagnosticsLastCheckedAt ? (
|
||||
`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.
|
||||
</>
|
||||
)}
|
||||
|
|
@ -221,7 +227,7 @@ export const McpServersPanel = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void runMcpDiagnostics()}
|
||||
onClick={() => void runMcpDiagnostics(projectPath ?? undefined)}
|
||||
disabled={mcpDiagnosticsLoading}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
|
|
@ -235,7 +241,7 @@ export const McpServersPanel = ({
|
|||
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
|
||||
<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">
|
||||
<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 && (
|
||||
<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
|
||||
state={installProgress}
|
||||
isInstalled={isUserInstalled}
|
||||
section="plugins"
|
||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export const PluginDetailDialog = ({
|
|||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isInstalledForScope}
|
||||
section="plugins"
|
||||
onInstall={() =>
|
||||
installPlugin({
|
||||
pluginId: plugin.pluginId,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getCliProviderExtensionCapability } from '@shared/utils/providerExtensionCapabilities';
|
||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -125,11 +126,12 @@ export const PluginsPanel = ({
|
|||
hasActiveFilters,
|
||||
setPluginSort,
|
||||
}: PluginsPanelProps): React.JSX.Element => {
|
||||
const { catalog, loading, error } = useStore(
|
||||
const { catalog, loading, error, cliStatus } = useStore(
|
||||
useShallow((s) => ({
|
||||
catalog: s.pluginCatalog,
|
||||
loading: s.pluginCatalogLoading,
|
||||
error: s.pluginCatalogError,
|
||||
cliStatus: s.cliStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -175,9 +177,26 @@ export const PluginsPanel = ({
|
|||
}
|
||||
return counts.size;
|
||||
}, [catalog]);
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export const SkillsPanel = ({
|
|||
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
||||
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
||||
|
|
@ -226,6 +227,12 @@ export const SkillsPanel = ({
|
|||
|
||||
return (
|
||||
<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="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">
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export interface ExtensionsSlice {
|
|||
fetchPluginReadme: (pluginId: string) => void;
|
||||
mcpBrowse: (cursor?: string) => Promise<void>;
|
||||
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
||||
runMcpDiagnostics: () => Promise<void>;
|
||||
runMcpDiagnostics: (projectPath?: string) => Promise<void>;
|
||||
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
||||
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
||||
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
||||
|
|
@ -375,7 +375,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
const requestSeq = ++pluginCatalogRequestSeq;
|
||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||
|
||||
const promise = (async () => {
|
||||
let currentPromise: Promise<void> | null = null;
|
||||
currentPromise = (async () => {
|
||||
try {
|
||||
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
||||
set((prev) => {
|
||||
|
|
@ -431,14 +432,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
};
|
||||
});
|
||||
} finally {
|
||||
if (pluginFetchInFlight?.promise === promise) {
|
||||
if (currentPromise && pluginFetchInFlight?.promise === currentPromise) {
|
||||
pluginFetchInFlight = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
pluginFetchInFlight = { key: requestKey, promise };
|
||||
await promise;
|
||||
pluginFetchInFlight = { key: requestKey, promise: currentPromise };
|
||||
await currentPromise;
|
||||
},
|
||||
|
||||
// ── Plugin README fetch ──
|
||||
|
|
@ -537,7 +538,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
},
|
||||
|
||||
runMcpDiagnostics: async () => {
|
||||
runMcpDiagnostics: async (projectPath?: string) => {
|
||||
const mcpRegistry = api.mcpRegistry;
|
||||
if (!mcpRegistry) return;
|
||||
|
||||
|
|
@ -550,7 +551,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const diagnostics = await mcpRegistry.diagnose();
|
||||
const diagnostics = await mcpRegistry.diagnose(projectPath);
|
||||
set({
|
||||
mcpDiagnostics: Object.fromEntries(
|
||||
diagnostics.map((entry) => [entry.name, entry] as const)
|
||||
|
|
@ -960,7 +961,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
await Promise.all([
|
||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||
get().runMcpDiagnostics(),
|
||||
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
|
||||
]);
|
||||
|
||||
set((prev) => ({
|
||||
|
|
@ -1008,7 +1009,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
await Promise.all([
|
||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||
get().runMcpDiagnostics(),
|
||||
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
|
||||
]);
|
||||
|
||||
set((prev) => ({
|
||||
|
|
@ -1064,7 +1065,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
await Promise.all([
|
||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||
get().runMcpDiagnostics(),
|
||||
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
|
||||
]);
|
||||
|
||||
set((prev) => ({
|
||||
|
|
@ -1116,7 +1117,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
try {
|
||||
await api.apiKeys.save(request);
|
||||
// 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 });
|
||||
} catch (err) {
|
||||
set({
|
||||
|
|
@ -1133,6 +1134,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
try {
|
||||
await api.apiKeys.delete(id);
|
||||
await get().fetchCliStatus();
|
||||
set((prev) => ({
|
||||
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
* Pure-function normalizers for Extension Store data.
|
||||
*/
|
||||
|
||||
import {
|
||||
getCliProviderExtensionCapability,
|
||||
isCliExtensionCapabilityMutable,
|
||||
} from './providerExtensionCapabilities';
|
||||
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
InstallScope,
|
||||
|
|
@ -208,28 +213,74 @@ export function getExtensionActionDisableReason(options: {
|
|||
isInstalled: boolean;
|
||||
cliStatus: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError'
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||
> | null;
|
||||
cliStatusLoading: boolean;
|
||||
section?: 'plugins' | 'mcp';
|
||||
}): string | null {
|
||||
const { isInstalled, cliStatus, cliStatusLoading } = options;
|
||||
const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options;
|
||||
if (cliStatusLoading) {
|
||||
return 'Checking Claude CLI status...';
|
||||
return 'Checking runtime status...';
|
||||
}
|
||||
|
||||
if (cliStatus === null) {
|
||||
return 'Checking Claude CLI availability...';
|
||||
return 'Checking runtime availability...';
|
||||
}
|
||||
|
||||
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 '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) {
|
||||
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
|
||||
const providers = cliStatus.providers ?? [];
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -258,8 +258,12 @@ describe('PluginDetailDialog project context', () => {
|
|||
|
||||
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);
|
||||
expect(
|
||||
(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 () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -36,6 +36,20 @@ vi.mock('../../../src/renderer/api', () => ({
|
|||
stopWatching: 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(() => {
|
||||
store = createTestStore();
|
||||
vi.clearAllMocks();
|
||||
(api.cliInstaller!.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(makeReadyCliStatus());
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('ignores stale catalog responses for the same project key', async () => {
|
||||
let resolveFirst!: (value: SkillCatalogItem[]) => void;
|
||||
|
|
|
|||
|
|
@ -252,11 +252,28 @@ describe('getMcpInstallationSummaryLabel', () => {
|
|||
});
|
||||
|
||||
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', () => {
|
||||
expect(
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
|
||||
cliStatus: createDirectCliStatus({ authLoggedIn: false }),
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).toContain('not signed in');
|
||||
|
|
@ -266,7 +283,7 @@ describe('getExtensionActionDisableReason', () => {
|
|||
expect(
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
|
||||
cliStatus: createDirectCliStatus({ authLoggedIn: false }),
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
|
@ -276,10 +293,10 @@ describe('getExtensionActionDisableReason', () => {
|
|||
expect(
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: { installed: false, authLoggedIn: false, binaryPath: null, launchError: null },
|
||||
cliStatus: createDirectCliStatus({ installed: false, authLoggedIn: false }),
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).toContain('Claude CLI required');
|
||||
).toContain('configured runtime');
|
||||
});
|
||||
|
||||
it('surfaces startup health-check failures separately from missing CLI', () => {
|
||||
|
|
@ -287,15 +304,99 @@ describe('getExtensionActionDisableReason', () => {
|
|||
getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: {
|
||||
installed: false,
|
||||
authLoggedIn: false,
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
launchError: 'spawn EACCES',
|
||||
...createDirectCliStatus({
|
||||
installed: false,
|
||||
authLoggedIn: false,
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
launchError: 'spawn EACCES',
|
||||
}),
|
||||
},
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue