From 48245be52df69281aec430f2372bcba82d017de5 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 4 Feb 2026 18:25:13 +0800 Subject: [PATCH] feat(desktop): add provider IPC handlers - Add provider.ts with handlers for list, current, set, saveApiKey, importOAuth - Import OAuth credentials from CLI tools (Claude Code, Codex) - Register provider handlers in IPC index - Expose provider API in preload.ts with TypeScript types Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/electron-env.d.ts | 31 +++ apps/desktop/electron/ipc/index.ts | 3 + apps/desktop/electron/ipc/provider.ts | 312 ++++++++++++++++++++++++ apps/desktop/electron/preload.ts | 44 ++++ 4 files changed, 390 insertions(+) create mode 100644 apps/desktop/electron/ipc/provider.ts diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 880abc6a..7777fdf2 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -101,6 +101,27 @@ interface LocalChatEvent { } } +interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + interface ElectronAPI { hub: { init: () => Promise @@ -145,6 +166,16 @@ interface ElectronAPI { updateStyle: (style: string) => Promise updateUser: (content: string) => Promise } + provider: { + list: () => Promise + listAvailable: () => Promise + current: () => Promise + set: (providerId: string, modelId?: string) => Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> + getMeta: (providerId: string) => Promise + isAvailable: (providerId: string) => Promise + saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }> + importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }> + } localChat: { subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }> unsubscribe: (agentId: string) => Promise<{ ok: boolean }> diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index d0971eb0..fc11179c 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -5,11 +5,13 @@ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js' export { registerSkillsIpcHandlers } from './skills.js' export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' export { registerProfileIpcHandlers } from './profile.js' +export { registerProviderIpcHandlers } from './provider.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' import { registerProfileIpcHandlers } from './profile.js' +import { registerProviderIpcHandlers } from './provider.js' /** * Register all IPC handlers. @@ -20,6 +22,7 @@ export function registerAllIpcHandlers(): void { registerAgentIpcHandlers() registerSkillsIpcHandlers() registerProfileIpcHandlers() + registerProviderIpcHandlers() } /** diff --git a/apps/desktop/electron/ipc/provider.ts b/apps/desktop/electron/ipc/provider.ts new file mode 100644 index 00000000..3f31dc9c --- /dev/null +++ b/apps/desktop/electron/ipc/provider.ts @@ -0,0 +1,312 @@ +/** + * Provider IPC handlers for Electron main process. + * + * Manages LLM provider listing, status checking, and switching. + * Mirrors the CLI `/provider` command functionality. + */ +import { ipcMain } from 'electron' +import { getCurrentHub } from './hub.js' +import { + getProviderList, + getAvailableProviders, + getCurrentProvider, + getProviderMeta, + isProviderAvailable, + getLoginInstructions, + type ProviderInfo, +} from '../../../../src/agent/providers/index.js' +import { + readClaudeCliCredentials, + readCodexCliCredentials, +} from '../../../../src/agent/providers/oauth/cli-credentials.js' +import { credentialManager } from '../../../../src/agent/credentials.js' + +/** + * Provider info returned to renderer (matches ProviderInfo from registry). + */ +export interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +/** + * Current provider/model info returned to renderer. + */ +export interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + +/** + * Get the default agent from Hub. + */ +function getDefaultAgent() { + const hub = getCurrentHub() + if (!hub) return null + + const agentIds = hub.listAgents() + if (agentIds.length === 0) return null + + return hub.getAgent(agentIds[0]) ?? null +} + +/** + * Register all Provider-related IPC handlers. + */ +export function registerProviderIpcHandlers(): void { + /** + * List all providers with their status. + * This is the main listing function, similar to CLI `/provider` command. + */ + ipcMain.handle('provider:list', async (): Promise => { + const providers = getProviderList() + + return providers.map((p: ProviderInfo) => ({ + id: p.id, + name: p.name, + authMethod: p.authMethod, + available: p.available, + configured: p.configured, + current: p.current, + defaultModel: p.defaultModel, + models: p.models, + loginUrl: p.loginUrl, + loginCommand: p.loginCommand, + loginInstructions: getLoginInstructions(p.id), + })) + }) + + /** + * List only available (configured) providers. + */ + ipcMain.handle('provider:listAvailable', async (): Promise => { + const providers = getAvailableProviders() + + return providers.map((p: ProviderInfo) => ({ + id: p.id, + name: p.name, + authMethod: p.authMethod, + available: p.available, + configured: p.configured, + current: p.current, + defaultModel: p.defaultModel, + models: p.models, + loginUrl: p.loginUrl, + loginCommand: p.loginCommand, + loginInstructions: getLoginInstructions(p.id), + })) + }) + + /** + * Get current provider and model from the active agent. + */ + ipcMain.handle('provider:current', async (): Promise => { + const agent = getDefaultAgent() + + if (agent) { + // Get from actual agent instance + const info = agent.getProviderInfo() + const meta = getProviderMeta(info.provider) + + return { + provider: info.provider, + model: info.model, + providerName: meta?.name, + available: isProviderAvailable(info.provider), + } + } + + // Fallback to credentials default + const defaultProvider = getCurrentProvider() + const meta = getProviderMeta(defaultProvider) + + return { + provider: defaultProvider, + model: meta?.defaultModel, + providerName: meta?.name, + available: isProviderAvailable(defaultProvider), + } + }) + + /** + * Switch the agent to a different provider and/or model. + */ + ipcMain.handle( + 'provider:set', + async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => { + const agent = getDefaultAgent() + + if (!agent) { + return { ok: false, error: 'No agent available' } + } + + // Validate provider exists + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + + // Check if provider is available + if (!isProviderAvailable(providerId)) { + const instructions = getLoginInstructions(providerId) + return { + ok: false, + error: `Provider "${providerId}" is not configured.\n${instructions}`, + } + } + + // Validate model if specified + if (modelId && !meta.models.includes(modelId)) { + return { + ok: false, + error: `Model "${modelId}" is not available for provider "${providerId}". Available: ${meta.models.join(', ')}`, + } + } + + try { + const result = agent.setProvider(providerId, modelId) + console.log(`[IPC] Provider switched to: ${result.provider}, model: ${result.model}`) + + return { + ok: true, + provider: result.provider, + model: result.model, + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to switch provider: ${message}`) + return { ok: false, error: message } + } + } + ) + + /** + * Get metadata for a specific provider. + */ + ipcMain.handle('provider:getMeta', async (_event, providerId: string) => { + const meta = getProviderMeta(providerId) + if (!meta) { + return { error: `Unknown provider: ${providerId}` } + } + + return { + id: meta.id, + name: meta.name, + authMethod: meta.authMethod, + defaultModel: meta.defaultModel, + models: meta.models, + loginUrl: meta.loginUrl, + loginCommand: meta.loginCommand, + available: isProviderAvailable(providerId), + loginInstructions: getLoginInstructions(providerId), + } + }) + + /** + * Check if a specific provider is available (has valid credentials). + */ + ipcMain.handle('provider:isAvailable', async (_event, providerId: string): Promise => { + return isProviderAvailable(providerId) + }) + + /** + * Save API key for a provider to credentials.json5. + * After saving, the provider should become available. + */ + ipcMain.handle( + 'provider:saveApiKey', + async (_event, providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => { + try { + // Validate provider exists and uses API key auth + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + if (meta.authMethod !== 'api-key') { + return { ok: false, error: `Provider "${providerId}" uses ${meta.authMethod} authentication, not API key` } + } + + // Save the API key + credentialManager.setLlmProviderApiKey(providerId, apiKey) + console.log(`[IPC] API key saved for provider: ${providerId}`) + + return { ok: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to save API key: ${message}`) + return { ok: false, error: message } + } + } + ) + + /** + * Import OAuth credentials from CLI tools (claude-code, codex). + * Reads from CLI credential storage and saves to credentials.json5. + */ + ipcMain.handle( + 'provider:importOAuth', + async (_event, providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => { + try { + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + if (meta.authMethod !== 'oauth') { + return { ok: false, error: `Provider "${providerId}" does not use OAuth authentication` } + } + + // Read credentials from CLI tool + if (providerId === 'claude-code') { + const creds = readClaudeCliCredentials() + if (!creds) { + return { ok: false, error: 'No Claude Code credentials found. Run "claude login" first.' } + } + if (creds.expires <= Date.now()) { + return { ok: false, error: 'Claude Code credentials have expired. Run "claude login" again.' } + } + + // Save to credentials.json5 + const token = creds.type === 'oauth' ? creds.access : creds.token + const refreshToken = creds.type === 'oauth' ? creds.refresh : undefined + credentialManager.setLlmProviderOAuthToken(providerId, token, refreshToken, creds.expires) + console.log(`[IPC] OAuth credentials imported for: ${providerId}`) + + return { ok: true, expiresAt: creds.expires } + } + + if (providerId === 'openai-codex') { + const creds = readCodexCliCredentials() + if (!creds) { + return { ok: false, error: 'No Codex credentials found. Run "codex login" first.' } + } + if (creds.expires <= Date.now()) { + return { ok: false, error: 'Codex credentials have expired. Run "codex login" again.' } + } + + // Save to credentials.json5 + credentialManager.setLlmProviderOAuthToken(providerId, creds.access, creds.refresh, creds.expires) + console.log(`[IPC] OAuth credentials imported for: ${providerId}`) + + return { ok: true, expiresAt: creds.expires } + } + + return { ok: false, error: `OAuth import not supported for provider: ${providerId}` } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to import OAuth credentials: ${message}`) + return { ok: false, error: message } + } + } + ) +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f554ef4b..16093ac0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -44,6 +44,27 @@ export interface ProfileData { userContent: string | undefined } +export interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +export interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + // Local chat event types (for direct IPC communication without Gateway) export interface LocalChatEvent { agentId: string @@ -134,6 +155,29 @@ const electronAPI = { updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content), }, + // Provider management + provider: { + /** List all providers with their status */ + list: (): Promise => ipcRenderer.invoke('provider:list'), + /** List only available (configured) providers */ + listAvailable: (): Promise => ipcRenderer.invoke('provider:listAvailable'), + /** Get current provider and model from the active agent */ + current: (): Promise => ipcRenderer.invoke('provider:current'), + /** Switch the agent to a different provider and/or model */ + set: (providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => + ipcRenderer.invoke('provider:set', providerId, modelId), + /** Get metadata for a specific provider */ + getMeta: (providerId: string) => ipcRenderer.invoke('provider:getMeta', providerId), + /** Check if a specific provider is available */ + isAvailable: (providerId: string): Promise => ipcRenderer.invoke('provider:isAvailable', providerId), + /** Save API key for a provider */ + saveApiKey: (providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('provider:saveApiKey', providerId, apiKey), + /** Import OAuth credentials from CLI tools (claude-code, codex) */ + importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => + ipcRenderer.invoke('provider:importOAuth', providerId), + }, + // Local chat (direct IPC, no Gateway required) localChat: { /** Subscribe to agent events for local direct chat */