feat(extensions): add provider-aware runtime adapters

This commit is contained in:
777genius 2026-04-17 10:08:13 +03:00
parent 1de59cb84f
commit 096437b2fd
25 changed files with 768 additions and 225 deletions

View file

@ -84,6 +84,7 @@ import {
SkillsCatalogService,
SkillsMutationService,
SkillsWatcherService,
createExtensionsRuntimeAdapter,
} from './services/extensions';
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
import { HttpServer } from './services/infrastructure/HttpServer';
@ -871,8 +872,9 @@ async function initializeServices(): Promise<void> {
const officialMcpRegistry = new OfficialMcpRegistryService();
const glamaMcpService = new GlamaMcpEnrichmentService();
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
const mcpStateService = new McpInstallationStateService();
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService();
const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter();
const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter);
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter);
const skillsCatalogService = new SkillsCatalogService();
const skillsMutationService = new SkillsMutationService();
skillsWatcherService = new SkillsWatcherService();
@ -884,8 +886,11 @@ async function initializeServices(): Promise<void> {
);
// Install services — resolve binary dynamically via ClaudeBinaryResolver
const pluginInstallService = new PluginInstallService(pluginCatalogService);
const mcpInstallService = new McpInstallService(mcpAggregator);
const pluginInstallService = new PluginInstallService(
pluginCatalogService,
extensionsRuntimeAdapter
);
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
const apiKeyService = new ApiKeyService();
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
// warmup() and ensureInstalled() are deferred to after window creation

View file

@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService {
return mcpHealthDiagnostics;
}
async function handleMcpDiagnose(): Promise<IpcResult<McpServerDiagnostic[]>> {
return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose());
async function handleMcpDiagnose(
_event: IpcMainInvokeEvent,
projectPath?: string
): Promise<IpcResult<McpServerDiagnostic[]>> {
return wrapHandler('mcpDiagnose', () =>
getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined)
);
}
// ── Install/Uninstall Handlers ────────────────────────────────────────────

View file

@ -22,6 +22,11 @@ export { SkillsCatalogService } from './skills/SkillsCatalogService';
export { SkillsMutationService } from './skills/SkillsMutationService';
export { SkillsWatcherService } from './skills/SkillsWatcherService';
export { SkillValidator } from './skills/SkillValidator';
export {
ClaudeExtensionsAdapter,
createExtensionsRuntimeAdapter,
MultimodelExtensionsAdapter,
} from './runtime/ExtensionsRuntimeAdapter';
export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService';
export { McpInstallationStateService } from './state/McpInstallationStateService';
export { PluginInstallationStateService } from './state/PluginInstallationStateService';

View file

@ -9,12 +9,14 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { createLogger } from '@shared/utils/logger';
import path from 'path';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type {
McpCustomInstallRequest,
McpInstallRequest,
@ -42,7 +44,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
}
export class McpInstallService {
constructor(private readonly aggregator: McpCatalogAggregator) {}
constructor(
private readonly aggregator: McpCatalogAggregator,
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
async install(request: McpInstallRequest): Promise<OperationResult> {
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
@ -180,11 +185,12 @@ export class McpInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
const { stderr } = await execCli(claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
if (stderr) {
@ -295,11 +301,12 @@ export class McpInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
const { stderr } = await execCli(claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
if (stderr) {
@ -364,11 +371,12 @@ export class McpInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
await execCli(claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
return { state: 'success' };
} catch (err) {

View file

@ -7,12 +7,14 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { createLogger } from '@shared/utils/logger';
import path from 'path';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
const logger = createLogger('Extensions:PluginInstall');
@ -31,7 +33,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
}
export class PluginInstallService {
constructor(private readonly catalogService: PluginCatalogService) {}
constructor(
private readonly catalogService: PluginCatalogService,
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
async install(request: PluginInstallRequest): Promise<OperationResult> {
const { pluginId, scope, projectPath } = request;
@ -95,11 +100,12 @@ export class PluginInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
const { stdout, stderr } = await execCli(claudeBinary, args, {
timeout: INSTALL_TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
if (stderr && !stdout) {
@ -175,11 +181,12 @@ export class PluginInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
await execCli(claudeBinary, args, {
timeout: UNINSTALL_TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
return { state: 'success' };
} catch (err) {

View file

@ -0,0 +1,134 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
import { execCli } from '@main/utils/childProcess';
import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { McpConfigStateReader } from './McpConfigStateReader';
import { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput } from './mcpDiagnosticsParser';
import { parseInstalledMcpJsonOutput } from './mcpRuntimeJson';
import type { CliFlavor } from '@shared/types';
import type { InstalledMcpEntry, McpServerDiagnostic } from '@shared/types/extensions';
const MCP_LIST_TIMEOUT_MS = 15_000;
const MCP_DIAGNOSE_TIMEOUT_MS = 30_000;
export interface ExtensionsRuntimeAdapter {
readonly flavor: CliFlavor;
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]>;
diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]>;
}
export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter {
readonly flavor = 'claude' as const;
constructor(private readonly stateReader = new McpConfigStateReader()) {}
async buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
const { env } = await buildProviderAwareCliEnv({
binaryPath,
connectionMode: 'augment',
});
return env;
}
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
return this.stateReader.readInstalled(projectPath);
}
async diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
throw new Error(CLI_NOT_FOUND_MESSAGE);
}
const env = await this.buildManagementCliEnv(binaryPath);
const { stdout, stderr } = await execCli(binaryPath, ['mcp', 'list'], {
timeout: MCP_DIAGNOSE_TIMEOUT_MS,
cwd: projectPath,
env,
});
return parseMcpDiagnosticsOutput([stdout, stderr].filter(Boolean).join('\n'));
}
}
export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter {
readonly flavor = 'agent_teams_orchestrator' as const;
async buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
const { env } = await buildProviderAwareCliEnv({
binaryPath,
connectionMode: 'augment',
});
return env;
}
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
throw new Error(CLI_NOT_FOUND_MESSAGE);
}
const env = await this.buildManagementCliEnv(binaryPath);
const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], {
timeout: MCP_LIST_TIMEOUT_MS,
cwd: projectPath,
env,
});
return parseInstalledMcpJsonOutput(stdout);
}
async diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
throw new Error(CLI_NOT_FOUND_MESSAGE);
}
const env = await this.buildManagementCliEnv(binaryPath);
const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], {
timeout: MCP_DIAGNOSE_TIMEOUT_MS,
cwd: projectPath,
env,
});
return parseMcpDiagnosticsJsonOutput(stdout);
}
}
class RuntimeSwitchingExtensionsAdapter implements ExtensionsRuntimeAdapter {
constructor(
private readonly claudeAdapter: ClaudeExtensionsAdapter,
private readonly multimodelAdapter: MultimodelExtensionsAdapter
) {}
private getActiveAdapter(): ExtensionsRuntimeAdapter {
return getConfiguredCliFlavor() === 'claude' ? this.claudeAdapter : this.multimodelAdapter;
}
get flavor(): CliFlavor {
return this.getActiveAdapter().flavor;
}
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
return this.getActiveAdapter().buildManagementCliEnv(binaryPath);
}
getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
return this.getActiveAdapter().getInstalledMcp(projectPath);
}
diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
return this.getActiveAdapter().diagnoseMcp(projectPath);
}
}
export function createExtensionsRuntimeAdapter(): ExtensionsRuntimeAdapter {
return new RuntimeSwitchingExtensionsAdapter(
new ClaudeExtensionsAdapter(),
new MultimodelExtensionsAdapter()
);
}

View file

@ -0,0 +1,101 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import type { InstalledMcpEntry } from '@shared/types/extensions';
const logger = createLogger('Extensions:McpConfigStateReader');
export class McpConfigStateReader {
async readInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
const entries: InstalledMcpEntry[] = [];
const claudeConfig = await this.readClaudeConfig();
entries.push(...this.readUserMcpServers(claudeConfig));
if (projectPath) {
entries.push(...this.readLocalMcpServers(claudeConfig, projectPath));
entries.push(...(await this.readProjectMcpServers(projectPath)));
}
return entries;
}
private async readClaudeConfig(): Promise<Record<string, unknown> | null> {
const configPath = path.join(getHomeDir(), '.claude.json');
try {
const raw = await fs.readFile(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read MCP servers from ${configPath}:`, err);
return null;
}
}
private readUserMcpServers(config: Record<string, unknown> | null): InstalledMcpEntry[] {
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
}
private readLocalMcpServers(
config: Record<string, unknown> | null,
projectPath: string
): InstalledMcpEntry[] {
const projects =
config && typeof config.projects === 'object' && config.projects
? (config.projects as Record<string, unknown>)
: null;
const projectConfig =
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
? (projects[projectPath] as Record<string, unknown>)
: null;
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
}
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
const configPath = path.join(projectPath, '.mcp.json');
return this.readMcpServersFromFile(configPath, 'project');
}
private readMcpServersFromConfig(
value: unknown,
scope: 'user' | 'project' | 'local'
): InstalledMcpEntry[] {
const mcpServers =
value && typeof value === 'object'
? (value as Record<string, { command?: string; url?: string }>)
: null;
if (!mcpServers) {
return [];
}
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
let transport: string | undefined;
if (config.command) transport = 'stdio';
else if (config.url) transport = 'http';
return { name, scope, transport };
});
}
private async readMcpServersFromFile(
filePath: string,
scope: 'user' | 'project'
): Promise<InstalledMcpEntry[]> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as Record<string, unknown>;
return this.readMcpServersFromConfig(json.mcpServers, scope);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
logger.error(`Failed to read MCP servers from ${filePath}:`, err);
return [];
}
}
}

View file

@ -0,0 +1,126 @@
import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions';
interface McpDiagnoseJsonEntry {
name?: string;
target?: string;
status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout';
statusLabel?: string;
}
interface McpDiagnoseJsonPayload {
checkedAt?: string;
diagnostics?: McpDiagnoseJsonEntry[];
}
function extractJsonObject<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return JSON.parse(trimmed.slice(start, end + 1)) as T;
}
throw new Error('No JSON object found in CLI output');
}
}
function parseStatusChunk(statusChunk: string): {
status: McpServerHealthStatus;
statusLabel: string;
} {
const symbol = statusChunk[0];
const label = statusChunk.slice(1).trim() || 'Unknown';
switch (symbol) {
case '✓':
return { status: 'connected', statusLabel: label };
case '!':
return { status: 'needs-authentication', statusLabel: label };
case '✗':
return { status: 'failed', statusLabel: label };
default:
return { status: 'unknown', statusLabel: statusChunk };
}
}
function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null {
const statusSeparatorIdx = line.lastIndexOf(' - ');
if (statusSeparatorIdx === -1) {
return null;
}
const descriptor = line.slice(0, statusSeparatorIdx).trim();
const statusChunk = line.slice(statusSeparatorIdx + 3).trim();
const nameSeparatorIdx = descriptor.indexOf(': ');
if (nameSeparatorIdx === -1) {
return null;
}
const name = descriptor.slice(0, nameSeparatorIdx).trim();
const target = descriptor.slice(nameSeparatorIdx + 2).trim();
if (!name || !target) {
return null;
}
const { status, statusLabel } = parseStatusChunk(statusChunk);
return {
name,
target,
status,
statusLabel,
rawLine: line,
checkedAt,
};
}
export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] {
const checkedAt = Date.now();
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health'))
.map((line) => parseDiagnosticLine(line, checkedAt))
.filter((entry): entry is McpServerDiagnostic => entry !== null);
}
export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnostic[] {
const parsed = extractJsonObject<McpDiagnoseJsonPayload>(output);
const checkedAtValue = parsed.checkedAt ? Date.parse(parsed.checkedAt) : Number.NaN;
const checkedAt = Number.isFinite(checkedAtValue) ? checkedAtValue : Date.now();
return (parsed.diagnostics ?? []).flatMap<McpServerDiagnostic>((entry) => {
if (
typeof entry.name !== 'string' ||
typeof entry.target !== 'string' ||
typeof entry.statusLabel !== 'string'
) {
return [];
}
const normalizedStatus: McpServerHealthStatus =
entry.status === 'connected'
? 'connected'
: entry.status === 'needs-authentication'
? 'needs-authentication'
: entry.status === 'failed' || entry.status === 'timeout'
? 'failed'
: 'unknown';
const rawLine = `${entry.name}: ${entry.target} - ${entry.statusLabel}`;
return [
{
name: entry.name,
target: entry.target,
status: normalizedStatus,
statusLabel: entry.statusLabel,
rawLine,
checkedAt,
},
];
});
}

View file

@ -0,0 +1,47 @@
import type { InstalledMcpEntry } from '@shared/types/extensions';
interface McpListJsonServer {
name?: string;
scope?: string;
transport?: string;
}
interface McpListJsonPayload {
servers?: McpListJsonServer[];
}
function extractJsonObject<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return JSON.parse(trimmed.slice(start, end + 1)) as T;
}
throw new Error('No JSON object found in CLI output');
}
}
function isSupportedScope(scope: unknown): scope is InstalledMcpEntry['scope'] {
return scope === 'user' || scope === 'project' || scope === 'local';
}
export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] {
const parsed = extractJsonObject<McpListJsonPayload>(output);
return (parsed.servers ?? []).flatMap<InstalledMcpEntry>((entry) => {
if (typeof entry.name !== 'string' || !isSupportedScope(entry.scope)) {
return [];
}
return [
{
name: entry.name,
scope: entry.scope,
transport: typeof entry.transport === 'string' ? entry.transport : undefined,
},
];
});
}

View file

@ -1,97 +1,27 @@
/**
* Runs `claude mcp list` and parses per-server health statuses.
* Resolves MCP diagnostics through the active runtime adapter.
*
* Direct Claude mode parses `claude mcp list` text output.
* Multimodel mode uses the structured `mcp diagnose --json` runtime contract.
*/
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { createLogger } from '@shared/utils/logger';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import {
parseMcpDiagnosticsJsonOutput,
parseMcpDiagnosticsOutput,
} from '../runtime/mcpDiagnosticsParser';
import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { McpServerDiagnostic } from '@shared/types/extensions';
const logger = createLogger('Extensions:McpHealthDiagnostics');
const TIMEOUT_MS = 30_000;
export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput };
export class McpHealthDiagnosticsService {
async diagnose(): Promise<McpServerDiagnostic[]> {
const claudeBinary = await ClaudeBinaryResolver.resolve();
if (!claudeBinary) {
throw new Error(CLI_NOT_FOUND_MESSAGE);
}
constructor(
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], {
timeout: TIMEOUT_MS,
env: buildEnrichedEnv(claudeBinary),
});
const output = [stdout, stderr].filter(Boolean).join('\n');
const diagnostics = parseMcpDiagnosticsOutput(output);
logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`);
return diagnostics;
}
}
export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] {
const checkedAt = Date.now();
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health'))
.map((line) => parseDiagnosticLine(line, checkedAt))
.filter((entry): entry is McpServerDiagnostic => entry !== null);
}
function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null {
const statusSeparatorIdx = line.lastIndexOf(' - ');
if (statusSeparatorIdx === -1) {
return null;
}
const descriptor = line.slice(0, statusSeparatorIdx).trim();
const statusChunk = line.slice(statusSeparatorIdx + 3).trim();
const nameSeparatorIdx = descriptor.indexOf(': ');
if (nameSeparatorIdx === -1) {
return null;
}
const name = descriptor.slice(0, nameSeparatorIdx).trim();
const target = descriptor.slice(nameSeparatorIdx + 2).trim();
if (!name || !target) {
return null;
}
const { status, statusLabel } = parseStatusChunk(statusChunk);
return {
name,
target,
status,
statusLabel,
rawLine: line,
checkedAt,
};
}
function parseStatusChunk(statusChunk: string): {
status: McpServerHealthStatus;
statusLabel: string;
} {
const symbol = statusChunk[0];
const label = statusChunk.slice(1).trim() || 'Unknown';
switch (symbol) {
case '✓':
return { status: 'connected', statusLabel: label };
case '!':
return { status: 'needs-authentication', statusLabel: label };
case '✗':
return { status: 'failed', statusLabel: label };
default:
return { status: 'unknown', statusLabel: statusChunk };
async diagnose(projectPath?: string): Promise<McpServerDiagnostic[]> {
return this.runtimeAdapter.diagnoseMcp(projectPath);
}
}

View file

@ -1,24 +1,15 @@
/**
* Reads installed MCP server state from the filesystem.
* Resolves installed MCP server state through the active runtime adapter.
*
* Sources:
* - User scope: ~/.claude.json mcpServers
* - Local scope: ~/.claude.json projects[projectPath].mcpServers
* - Project scope: .mcp.json in project root
*
* Both files are managed by the Claude CLI. This service is read-only.
* Direct Claude mode reads CLI-managed config files.
* Multimodel mode uses the structured `mcp list --json` runtime contract.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { InstalledMcpEntry } from '@shared/types/extensions';
const logger = createLogger('Extensions:McpState');
const CACHE_TTL_MS = 10_000; // 10 seconds
interface TimedCache<T> {
@ -29,113 +20,23 @@ interface TimedCache<T> {
export class McpInstallationStateService {
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
/**
* Get all installed MCP servers across user, local, and project scopes.
*/
constructor(
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
const cacheKey = projectPath ?? '__user__';
const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.data;
}
const entries: InstalledMcpEntry[] = [];
const claudeConfig = await this.readClaudeConfig();
// User scope: ~/.claude.json
entries.push(...this.readUserMcpServers(claudeConfig));
if (projectPath) {
entries.push(...this.readLocalMcpServers(claudeConfig, projectPath));
entries.push(...(await this.readProjectMcpServers(projectPath)));
}
const entries = await this.runtimeAdapter.getInstalledMcp(projectPath);
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
return entries;
}
/**
* Invalidate cache. Call after install/uninstall operations.
*/
invalidateCache(): void {
this.cache.clear();
}
// ── Private ────────────────────────────────────────────────────────────
private async readClaudeConfig(): Promise<Record<string, unknown> | null> {
const configPath = path.join(getHomeDir(), '.claude.json');
try {
const raw = await fs.readFile(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read MCP servers from ${configPath}:`, err);
return null;
}
}
private readUserMcpServers(config: Record<string, unknown> | null): InstalledMcpEntry[] {
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
}
private readLocalMcpServers(
config: Record<string, unknown> | null,
projectPath: string
): InstalledMcpEntry[] {
const projects =
config && typeof config.projects === 'object' && config.projects
? (config.projects as Record<string, unknown>)
: null;
const projectConfig =
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
? (projects[projectPath] as Record<string, unknown>)
: null;
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
}
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
const configPath = path.join(projectPath, '.mcp.json');
return this.readMcpServersFromFile(configPath, 'project');
}
private readMcpServersFromConfig(
value: unknown,
scope: 'user' | 'project' | 'local'
): InstalledMcpEntry[] {
const mcpServers =
value && typeof value === 'object'
? (value as Record<string, { command?: string; url?: string }>)
: null;
if (!mcpServers) {
return [];
}
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
let transport: string | undefined;
if (config.command) transport = 'stdio';
else if (config.url) transport = 'http';
return { name, scope, transport };
});
}
private async readMcpServersFromFile(
filePath: string,
scope: 'user' | 'project'
): Promise<InstalledMcpEntry[]> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as Record<string, unknown>;
return this.readMcpServersFromConfig(json.mcpServers, scope);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
logger.error(`Failed to read MCP servers from ${filePath}:`, err);
return [];
}
}
}

View file

@ -30,6 +30,7 @@ import {
} from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import { createHash } from 'crypto';
import { createWriteStream, existsSync, promises as fsp } from 'fs';
import http from 'http';
@ -145,7 +146,13 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
...provider,
modelVerificationState: provider.modelVerificationState ?? 'idle',
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
capabilities: { ...provider.capabilities },
capabilities: {
...provider.capabilities,
extensions: {
...createDefaultCliExtensionCapabilities(),
...provider.capabilities.extensions,
},
},
selectedBackendId: provider.selectedBackendId ?? null,
resolvedBackendId: provider.resolvedBackendId ?? null,
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
@ -467,6 +474,7 @@ export class CliInstallerService {
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: null,
}))
@ -528,7 +536,13 @@ export class CliInstallerService {
authMethod: provider.authMethod,
selectedBackendId: provider.selectedBackendId ?? null,
resolvedBackendId: provider.resolvedBackendId ?? null,
capabilities: { ...provider.capabilities },
capabilities: {
...provider.capabilities,
extensions: {
...createDefaultCliExtensionCapabilities(),
...provider.capabilities.extensions,
},
},
backend: provider.backend ? { ...provider.backend } : null,
},
};

View file

@ -1,6 +1,7 @@
import { execCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
@ -13,6 +14,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
interface RuntimeExtensionCapabilityResponse {
status?: 'supported' | 'read-only' | 'unsupported';
ownership?: 'shared' | 'provider-scoped';
reason?: string | null;
}
interface RuntimeExtensionCapabilitiesResponse {
plugins?: RuntimeExtensionCapabilityResponse;
mcp?: RuntimeExtensionCapabilityResponse;
skills?: RuntimeExtensionCapabilityResponse;
apiKeys?: RuntimeExtensionCapabilityResponse;
}
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
@ -27,6 +41,7 @@ interface ProviderStatusCommandResponse {
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
@ -84,6 +99,7 @@ interface UnifiedRuntimeStatusResponse {
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
@ -129,6 +145,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
@ -139,6 +156,39 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
};
}
function mapRuntimeExtensionCapabilities(
capabilities?: RuntimeExtensionCapabilitiesResponse
): CliProviderStatus['capabilities']['extensions'] {
const defaults = createDefaultCliExtensionCapabilities();
return {
plugins: {
...defaults.plugins,
status: capabilities?.plugins?.status ?? defaults.plugins.status,
ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership,
reason: capabilities?.plugins?.reason ?? defaults.plugins.reason,
},
mcp: {
...defaults.mcp,
status: capabilities?.mcp?.status ?? defaults.mcp.status,
ownership: capabilities?.mcp?.ownership ?? defaults.mcp.ownership,
reason: capabilities?.mcp?.reason ?? defaults.mcp.reason,
},
skills: {
...defaults.skills,
status: capabilities?.skills?.status ?? defaults.skills.status,
ownership: capabilities?.skills?.ownership ?? defaults.skills.ownership,
reason: capabilities?.skills?.reason ?? defaults.skills.reason,
},
apiKeys: {
...defaults.apiKeys,
status: capabilities?.apiKeys?.status ?? defaults.apiKeys.status,
ownership: capabilities?.apiKeys?.ownership ?? defaults.apiKeys.ownership,
reason: capabilities?.apiKeys?.reason ?? defaults.apiKeys.reason,
},
};
}
function extractModelIds(
models: (string | { id?: string; label?: string; description?: string })[] | undefined
): string[] {
@ -203,6 +253,7 @@ export class ClaudeMultimodelBridgeService {
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
},
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
@ -325,6 +376,7 @@ export class ClaudeMultimodelBridgeService {
provider.capabilities = {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
};
}
} catch (error) {
@ -428,6 +480,7 @@ export class ClaudeMultimodelBridgeService {
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
},
backend: runtimeStatus.backend?.kind
? {

View file

@ -1578,7 +1578,8 @@ const electronAPI: ElectronAPI = {
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
getInstalled: (projectPath?: string) =>
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
diagnose: () => invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE),
diagnose: (projectPath?: string) =>
invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE, projectPath),
install: (request: McpInstallRequest) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
installCustom: (request: McpCustomInstallRequest) =>

View file

@ -4,6 +4,7 @@
import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import type { AppState } from '../types';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
@ -36,6 +37,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: null,
}));

View file

@ -57,6 +57,22 @@ export interface CliExternalRuntimeDiagnostic {
detailMessage?: string | null;
}
export type CliExtensionCapabilityStatus = 'supported' | 'read-only' | 'unsupported';
export type CliExtensionOwnership = 'shared' | 'provider-scoped';
export interface CliExtensionCapability {
status: CliExtensionCapabilityStatus;
ownership: CliExtensionOwnership;
reason?: string | null;
}
export interface CliExtensionCapabilities {
plugins: CliExtensionCapability;
mcp: CliExtensionCapability;
skills: CliExtensionCapability;
apiKeys: CliExtensionCapability;
}
export type CliProviderModelAvailabilityStatus =
| 'checking'
| 'available'
@ -85,6 +101,7 @@ export interface CliProviderStatus {
capabilities: {
teamLaunch: boolean;
oneShot: boolean;
extensions: CliExtensionCapabilities;
};
selectedBackendId?: string | null;
resolvedBackendId?: string | null;

View file

@ -52,7 +52,7 @@ export interface McpCatalogAPI {
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
getById: (registryId: string) => Promise<McpCatalogItem | null>;
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
diagnose: () => Promise<McpServerDiagnostic[]>;
diagnose: (projectPath?: string) => Promise<McpServerDiagnostic[]>;
install: (request: McpInstallRequest) => Promise<OperationResult>;
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;

View file

@ -0,0 +1,48 @@
import type {
CliExtensionCapabilities,
CliExtensionCapability,
CliProviderStatus,
} from '@shared/types';
const SUPPORTED_SHARED_CAPABILITY: CliExtensionCapability = {
status: 'supported',
ownership: 'shared',
reason: null,
};
export function createDefaultCliExtensionCapabilities(
overrides?: Partial<CliExtensionCapabilities>
): CliExtensionCapabilities {
return {
plugins: { ...SUPPORTED_SHARED_CAPABILITY },
mcp: { ...SUPPORTED_SHARED_CAPABILITY },
skills: { ...SUPPORTED_SHARED_CAPABILITY },
apiKeys: { ...SUPPORTED_SHARED_CAPABILITY },
...overrides,
};
}
export function getCliProviderExtensionCapabilities(
provider: Pick<CliProviderStatus, 'capabilities'>
): CliExtensionCapabilities {
return provider.capabilities.extensions ?? createDefaultCliExtensionCapabilities();
}
export function getCliProviderExtensionCapability(
provider: Pick<CliProviderStatus, 'capabilities'>,
section: keyof CliExtensionCapabilities
): CliExtensionCapability {
return getCliProviderExtensionCapabilities(provider)[section];
}
export function isCliExtensionCapabilityAvailable(
capability: Pick<CliExtensionCapability, 'status'>
): boolean {
return capability.status === 'supported' || capability.status === 'read-only';
}
export function isCliExtensionCapabilityMutable(
capability: Pick<CliExtensionCapability, 'status'>
): boolean {
return capability.status === 'supported';
}

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { parseMcpDiagnosticsOutput } from '@main/services/extensions/state/McpHealthDiagnosticsService';
import {
McpHealthDiagnosticsService,
parseMcpDiagnosticsJsonOutput,
parseMcpDiagnosticsOutput,
} from '@main/services/extensions/state/McpHealthDiagnosticsService';
describe('parseMcpDiagnosticsOutput', () => {
it('parses mixed MCP health lines from claude mcp list', () => {
@ -40,4 +44,65 @@ another log line`);
expect(diagnostics).toEqual([]);
});
it('parses structured multimodel MCP diagnostics JSON', () => {
const diagnostics = parseMcpDiagnosticsJsonOutput(
JSON.stringify({
checkedAt: '2026-04-17T10:00:00.000Z',
diagnostics: [
{
name: 'context7',
target: 'npx -y @upstash/context7-mcp',
status: 'connected',
statusLabel: 'Connected',
},
{
name: 'tavily',
target: 'https://mcp.tavily.com/mcp',
status: 'timeout',
statusLabel: 'Timed out',
},
],
})
);
expect(diagnostics).toEqual([
expect.objectContaining({
name: 'context7',
status: 'connected',
statusLabel: 'Connected',
}),
expect.objectContaining({
name: 'tavily',
status: 'failed',
statusLabel: 'Timed out',
}),
]);
});
});
describe('McpHealthDiagnosticsService', () => {
it('delegates diagnostics to the active runtime adapter', async () => {
const diagnoseMcp = vi.fn().mockResolvedValue([
{
name: 'context7',
target: 'npx -y @upstash/context7-mcp',
status: 'connected',
statusLabel: 'Connected',
rawLine: 'context7: npx -y @upstash/context7-mcp - Connected',
checkedAt: 1,
},
]);
const service = new McpHealthDiagnosticsService({
flavor: 'agent_teams_orchestrator',
buildManagementCliEnv: vi.fn(),
getInstalledMcp: vi.fn(),
diagnoseMcp,
});
await expect(service.diagnose('/tmp/project-a')).resolves.toEqual([
expect.objectContaining({ name: 'context7' }),
]);
expect(diagnoseMcp).toHaveBeenCalledWith('/tmp/project-a');
});
});

View file

@ -1,10 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
vi.mock('@main/utils/pathDecoder', () => ({
getHomeDir: () => '/tmp/mock-home',
getClaudeBasePath: () => '/tmp/mock-home/.claude',
setClaudeBasePathOverride: vi.fn(),
}));
vi.mock('node:fs/promises');
@ -14,7 +18,9 @@ describe('McpInstallationStateService', () => {
const mockedFs = vi.mocked(fs);
beforeEach(() => {
service = new McpInstallationStateService();
service = new McpInstallationStateService(
new ClaudeExtensionsAdapter(new McpConfigStateReader())
);
vi.clearAllMocks();
});
@ -146,5 +152,28 @@ describe('McpInstallationStateService', () => {
]);
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
});
it('supports multimodel MCP state through the runtime adapter contract', async () => {
const getInstalledMcp = vi
.fn()
.mockResolvedValueOnce([{ name: 'context7', scope: 'user', transport: 'stdio' }])
.mockResolvedValueOnce([{ name: 'repo-mcp', scope: 'project', transport: 'http' }]);
service = new McpInstallationStateService({
flavor: 'agent_teams_orchestrator',
buildManagementCliEnv: vi.fn(),
diagnoseMcp: vi.fn(),
getInstalledMcp,
});
await expect(service.getInstalled('/tmp/project-a')).resolves.toEqual([
{ name: 'context7', scope: 'user', transport: 'stdio' },
]);
await expect(service.getInstalled('/tmp/project-b')).resolves.toEqual([
{ name: 'repo-mcp', scope: 'project', transport: 'http' },
]);
expect(getInstalledMcp).toHaveBeenCalledTimes(2);
expect(getInstalledMcp).toHaveBeenNthCalledWith(1, '/tmp/project-a');
expect(getInstalledMcp).toHaveBeenNthCalledWith(2, '/tmp/project-b');
});
});
});

View file

@ -93,7 +93,16 @@ describe('ClaudeMultimodelBridgeService', () => {
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
capabilities: { teamLaunch: true, oneShot: true },
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: { status: 'supported', 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 },
},
},
backend: { kind: 'anthropic', label: 'Anthropic' },
},
codex: {
@ -102,7 +111,20 @@ describe('ClaudeMultimodelBridgeService', () => {
verificationState: 'verified',
canLoginFromUi: true,
statusMessage: 'Not connected',
capabilities: { teamLaunch: true, oneShot: true },
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: {
status: 'unsupported',
ownership: 'shared',
reason: 'Anthropic only',
},
mcp: { status: 'supported', ownership: 'shared', reason: null },
skills: { status: 'supported', ownership: 'shared', reason: null },
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
},
},
backend: { kind: 'openai', label: 'OpenAI' },
},
},
@ -166,6 +188,15 @@ describe('ClaudeMultimodelBridgeService', () => {
authenticated: false,
models: ['gpt-5-codex'],
statusMessage: 'Not connected',
capabilities: {
extensions: {
plugins: {
status: 'unsupported',
ownership: 'shared',
reason: 'Anthropic only',
},
},
},
});
expect(providers[2]).toMatchObject({
providerId: 'gemini',

View file

@ -17,6 +17,7 @@ import {
CliProviderModelAvailabilityService,
type ProviderModelAvailabilityContext,
} from '@main/services/runtime/CliProviderModelAvailabilityService';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
function createContext(models: string[]): ProviderModelAvailabilityContext {
return {
@ -33,6 +34,7 @@ function createContext(models: string[]): ProviderModelAvailabilityContext {
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: {
kind: 'openai',
@ -78,12 +80,12 @@ describe('CliProviderModelAvailabilityService', () => {
connectionIssues: {},
});
execCliMock.mockRejectedValue(
new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.")
new Error("The 'gpt-5.4' model is not supported when using Codex with a ChatGPT account.")
);
const onUpdate = vi.fn();
const service = new CliProviderModelAvailabilityService(onUpdate);
service.getSnapshot(createContext(['gpt-5.2-codex']));
service.getSnapshot(createContext(['gpt-5.4']));
await vi.waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith(
@ -92,7 +94,7 @@ describe('CliProviderModelAvailabilityService', () => {
expect.objectContaining({
modelAvailability: [
expect.objectContaining({
modelId: 'gpt-5.2-codex',
modelId: 'gpt-5.4',
status: 'unavailable',
reason: 'Not available with Codex ChatGPT subscription',
}),

View file

@ -149,6 +149,7 @@ vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
}));
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
function createCodexProvider(
overrides?: Partial<CliProviderStatus['connection']> & {
@ -169,6 +170,7 @@ function createCodexProvider(
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'auto',
resolvedBackendId: 'adapter',
@ -210,6 +212,7 @@ function createAnthropicProvider(
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
@ -241,6 +244,7 @@ function createGeminiProvider(): CliProviderStatus {
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'auto',
resolvedBackendId: 'api',

View file

@ -5,6 +5,7 @@ import {
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
} from '@renderer/components/runtime/providerConnectionUi';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import type { CliProviderStatus } from '@shared/types';
@ -27,6 +28,7 @@ function createAnthropicProvider(
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
@ -64,6 +66,7 @@ function createCodexProvider(
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'auto',
resolvedBackendId: 'adapter',

View file

@ -51,6 +51,7 @@ vi.mock('@renderer/api', () => ({
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import type { CliInstallationStatus } from '@shared/types';
@ -196,7 +197,11 @@ describe('cliInstallerSlice', () => {
statusMessage: 'Runtime found, but startup health check failed.',
models: [],
canLoginFromUi: false,
capabilities: { teamLaunch: false, oneShot: false },
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: null,
},
],