feat(extensions): surface provider-aware capabilities in UI

This commit is contained in:
777genius 2026-04-17 10:08:33 +03:00
parent 096437b2fd
commit b3427a64ab
15 changed files with 469 additions and 57 deletions

View file

@ -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 */}

View file

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

View file

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

View file

@ -258,6 +258,7 @@ export const McpServerCard = ({
<InstallButton
state={installProgress}
isInstalled={isInstalled}
section="mcp"
onInstall={() =>
installMcpServer({
registryId: server.id,

View file

@ -528,6 +528,7 @@ export const McpServerDetailDialog = ({
<InstallButton
state={installProgress}
isInstalled={isInstalledForScope}
section="mcp"
onInstall={handleInstall}
onUninstall={handleUninstall}
disabled={installDisabled}

View file

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

View file

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

View file

@ -195,6 +195,7 @@ export const PluginDetailDialog = ({
<InstallButton
state={installProgress}
isInstalled={isInstalledForScope}
section="plugins"
onInstall={() =>
installPlugin({
pluginId: plugin.pluginId,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {