import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { GroupedModel } from '../../../src/types/agent-settings'; import { resolveClaudeCli } from '../../utils/resolve-claude-cli'; import { serverLog } from '../../utils/server-logger'; import { posixUserBinDirs, probeViaLoginShell } from '../../utils/cli-resolver-helpers'; import { buildClaudeAgentEnv, buildSpawnClaudeCodeProcess, getClaudeAgentDebugFilePath, } from '../../utils/resolve-claude-agent-env'; /** Windows npm global installs may create .cmd or .ps1 wrappers — try both */ function winNpmCandidates(dir: string, name: string): string[] { return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]; } /** * On Windows, `where` may return an extensionless Unix shell script (e.g. `…/npm/opencode`). * This file exists but can't be executed. Prefer `.cmd` or `.ps1` wrapper at the same location. */ function resolveWinExtension(binPath: string): string { if (process.platform !== 'win32') return binPath; // Already has a usable extension if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath; // Try .cmd then .ps1 for (const ext of ['.cmd', '.ps1']) { if (existsSync(binPath + ext)) return binPath + ext; } return binPath; } /** Build a shell command to invoke a resolved binary (handles .ps1 on Windows) */ function buildExecCmd(binPath: string, args: string): string { if (binPath.endsWith('.ps1')) { return `powershell -ExecutionPolicy Bypass -File "${binPath}" ${args}`; } return `"${binPath}" ${args}`; } interface ConnectBody { agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli'; } interface ConnectResult { connected: boolean; models: GroupedModel[]; error?: string; warning?: string; notInstalled?: boolean; /** Human-readable connection status, e.g. "Connected via API key" */ connectionInfo?: string; /** Config file path for the hint (client renders localized text) */ hintPath?: string; } /** * POST /api/ai/connect-agent * Actively connects to a local CLI tool and fetches its supported models. */ export default defineEventHandler(async (event) => { const body = await readBody(event); setResponseHeaders(event, { 'Content-Type': 'application/json' }); if (!body?.agent) { return { connected: false, models: [], error: 'Missing agent field' } satisfies ConnectResult; } if (body.agent === 'claude-code') { return connectClaudeCode(); } if (body.agent === 'codex-cli') { return connectCodexCli(); } if (body.agent === 'opencode') { return connectOpenCode(); } if (body.agent === 'copilot') { return connectCopilot(); } if (body.agent === 'gemini-cli') { return connectGeminiCli(); } return { connected: false, models: [], error: `Unknown agent: ${body.agent}`, } satisfies ConnectResult; }); /** * Fallback models when supportedModels() fails. * Used with third-party API proxies (e.g. Claude Router) that don't support * the model-listing endpoint. Covers common model IDs routers typically expose. */ const FALLBACK_CLAUDE_MODELS: GroupedModel[] = [ { value: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', description: '', provider: 'anthropic', }, { value: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', description: '', provider: 'anthropic', }, { value: 'claude-sonnet-4-5-20250514', displayName: 'Claude Sonnet 4.5', description: '', provider: 'anthropic', }, { value: 'claude-haiku-4-5-20251001', displayName: 'Claude Haiku 4.5', description: '', provider: 'anthropic', }, { value: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet', description: '', provider: 'anthropic', }, { value: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet', description: '', provider: 'anthropic', }, { value: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku', description: '', provider: 'anthropic', }, ]; /** Connect to Claude Code via Agent SDK and fetch real supported models */ async function connectClaudeCode(): Promise { serverLog.info('[connect-agent] connecting to Claude Code...'); const claudePath = resolveClaudeCli(); serverLog.info(`[connect-agent] resolved claude path: ${claudePath ?? 'NOT FOUND'}`); if (!claudePath) { return { connected: false, models: [], notInstalled: true, error: 'Claude Code CLI not found' }; } try { const { query } = await import('@anthropic-ai/claude-agent-sdk'); const env = buildClaudeAgentEnv(); const debugFile = getClaudeAgentDebugFilePath(); serverLog.info(`[connect-agent] claude env keys: ${Object.keys(env).join(', ')}`); serverLog.info(`[connect-agent] claude debugFile: ${debugFile ?? 'none'}`); const spawnProcess = buildSpawnClaudeCodeProcess(); const q = query({ prompt: '', options: { maxTurns: 1, tools: [], permissionMode: 'plan', persistSession: false, env, ...(debugFile ? { debugFile } : {}), ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), ...(spawnProcess ? { spawnClaudeCodeProcess: spawnProcess } : {}), }, }); serverLog.info('[connect-agent] querying supportedModels...'); const raw = await q.supportedModels(); // Fetch account info (email, org, subscription type) let account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string; } | null = null; try { account = await q.accountInfo(); serverLog.info( `[connect-agent] claude account: email=${account?.email ?? 'n/a'}, type=${account?.subscriptionType ?? 'n/a'}, source=${account?.apiKeySource ?? 'n/a'}`, ); } catch { serverLog.info('[connect-agent] accountInfo() not available'); } q.close(); const models: GroupedModel[] = raw.map((m) => ({ value: m.value, displayName: m.displayName, description: m.description, provider: 'anthropic' as const, })); serverLog.info(`[connect-agent] claude connected, ${models.length} models found`); const claudeInfo = buildClaudeConnectionInfo(env, account); return { connected: true, models, ...claudeInfo }; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to connect'; serverLog.error(`[connect-agent] claude connection error: ${msg}`); // Some custom base-URL setups do not support the supportedModels() call, // causing "query closed before response". Fall back to a default model list // so users can still connect and choose a model. if (/closed before|closed early|query closed/i.test(msg)) { serverLog.info( '[connect-agent] using fallback model list after supportedModels() closed early', ); const fallbackEnv = buildClaudeAgentEnv(); const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null); // Read debug log for diagnostic warning — the process may have written // useful info (e.g. TLS errors, auth failures) before exiting let warning: string | undefined; const debugPath = getClaudeAgentDebugFilePath(); if (debugPath) { try { const raw = readFileSync(debugPath, 'utf-8'); const lines = raw.split('\n').filter((l) => l.trim().length > 0); const tail = lines.slice(-10).join('\n'); if (tail) { // Surface specific issues as warnings if (/certificate|CERT_|ssl|tls/i.test(tail)) { warning = 'TLS/SSL error detected. Check the endpoint certificate chain and local trust configuration.'; } else if (/EPERM|operation not permitted/i.test(tail)) { warning = 'Permission error writing config. Try: echo {} > %USERPROFILE%\\.claude.json'; } else if (/stderr exit=/i.test(tail)) { // Show captured stderr const stderrMatch = tail.match(/\[stderr exit=\d+\]\s*(.+)/s); if (stderrMatch) { warning = `Claude Code stderr: ${stderrMatch[1].slice(0, 300)}`; } } } } catch { /* debug file not available */ } } return { connected: true, models: FALLBACK_CLAUDE_MODELS, ...claudeInfo, ...(warning ? { warning } : {}), }; } return { connected: false, models: [], error: friendlyClaudeError(msg) }; } } /** Resolve config file path (cross-platform) */ function configPath(unixPath: string, winPath: string): string { return process.platform === 'win32' ? winPath : unixPath; } /** Build Claude connection info from env + SDK account info */ function buildClaudeConnectionInfo( env: Record, account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string; } | null, ): { connectionInfo: string; hintPath?: string } { const hp = configPath('~/.claude/settings.json', '%USERPROFILE%\\.claude\\settings.json'); const apiKey = env.ANTHROPIC_API_KEY; const baseUrl = env.ANTHROPIC_BASE_URL; if (account?.email) { const sub = account.subscriptionType ?? 'subscription'; return { connectionInfo: `Connected via ${sub} (${account.email})`, hintPath: hp }; } if (apiKey && baseUrl) { return { connectionInfo: 'Connected via API key (custom base URL)', hintPath: hp }; } if (apiKey) { const masked = apiKey.length > 12 ? `${apiKey.slice(0, 8)}...` : '***'; return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }; } return { connectionInfo: 'Connected via subscription', hintPath: hp }; } /** Decode a JWT payload (no verification — just base64url decode the middle part) */ function decodeJwtPayload(token: string): Record | null { try { const parts = token.split('.'); if (parts.length !== 3) return null; // base64url → base64 → Buffer → JSON const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8')); } catch { return null; } } /** Build Codex CLI connection info by reading ~/.codex/auth.json + JWT tokens */ async function buildCodexConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> { const { readFile } = await import('node:fs/promises'); const { homedir } = await import('node:os'); const { join } = await import('node:path'); const hp = configPath('~/.codex/config.toml', '%USERPROFILE%\\.codex\\config.toml'); if (process.env.OPENAI_API_KEY) { const key = process.env.OPENAI_API_KEY; const masked = key.length > 12 ? `${key.slice(0, 8)}...` : '***'; return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }; } try { const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); const authPath = join(codexHome, 'auth.json'); const raw = await readFile(authPath, 'utf-8'); const auth = JSON.parse(raw) as { auth_mode?: string; tokens?: { id_token?: string } }; const idToken = auth.tokens?.id_token; if (idToken) { const payload = decodeJwtPayload(idToken); if (payload) { const email = payload.email as string | undefined; const authClaims = payload['https://api.openai.com/auth'] as | Record | undefined; const plan = authClaims?.chatgpt_plan_type as string | undefined; serverLog.info(`[connect-agent] codex JWT: email=${email ?? 'n/a'}, plan=${plan ?? 'n/a'}`); if (email) { const label = plan ?? auth.auth_mode ?? 'subscription'; return { connectionInfo: `Connected via ${label} (${email})`, hintPath: hp }; } } } if (auth.auth_mode) { return { connectionInfo: `Connected via ${auth.auth_mode}`, hintPath: hp }; } } catch { /* auth.json not found */ } return { connectionInfo: 'Connected via Codex CLI', hintPath: hp }; } /** Map raw Agent SDK errors to user-friendly messages */ function friendlyClaudeError(raw: string): string { if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) { return 'Claude Code exited with code 1. Run "claude login" to authenticate, or set ANTHROPIC_API_KEY in ~/.claude/settings.json.'; } if (/exited with code/i.test(raw)) { return 'Unable to connect. Claude Code process exited unexpectedly.'; } if (/not found|ENOENT/i.test(raw)) { return 'Claude Code CLI not found. Please install it first.'; } if (/timed?\s*out/i.test(raw)) { return 'Connection timed out. Please try again.'; } return raw; } /** * Fallback: parse model IDs from Codex's bundled latest-model.md when * models_cache.json is missing (e.g. fresh Windows install). * Only includes text/reasoning models (skips image, audio, video, embedding, moderation). */ async function parseCodexLatestModelMd(codexHome: string): Promise { const { readFile } = await import('node:fs/promises'); const { join } = await import('node:path'); const mdPath = join( codexHome, 'skills', '.system', 'openai-docs', 'references', 'latest-model.md', ); try { const content = await readFile(mdPath, 'utf-8'); const models: GroupedModel[] = []; // Match markdown table rows: | `model-id` | description | const rowRe = /^\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|/gm; const skipRe = /image|audio|tts|transcribe|realtime|sora|video|embedding|moderation/i; let match: RegExpExecArray | null; const seen = new Set(); while ((match = rowRe.exec(content)) !== null) { const slug = match[1]; const desc = match[2].trim(); if (skipRe.test(slug) || skipRe.test(desc) || seen.has(slug)) continue; seen.add(slug); models.push({ value: slug, displayName: slug, description: desc, provider: 'openai' as const, }); } return models; } catch { return []; } } /** Connect to Codex CLI and fetch its supported models from the local cache */ async function connectCodexCli(): Promise { serverLog.info('[connect-agent] connecting to Codex CLI...'); try { const { execSync } = await import('node:child_process'); const { readFile } = await import('node:fs/promises'); const { homedir } = await import('node:os'); const { join } = await import('node:path'); const isWin = process.platform === 'win32'; serverLog.info( `[connect-agent] codex platform=${process.platform}, SHELL=${process.env.SHELL ?? 'unset'}`, ); // Check if codex binary exists — PATH, login shell (macOS/Linux), npm prefix, common locations let which = ''; // 1. PATH lookup try { const whichCmd = isWin ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'; serverLog.info(`[connect-agent] codex PATH lookup: ${whichCmd}`); const result = execSync(whichCmd, { encoding: 'utf-8', timeout: 5000, }) .trim() .split(/\r?\n/)[0] ?.trim() ?? ''; if (result && existsSync(result)) which = resolveWinExtension(result); serverLog.info( `[connect-agent] codex PATH result: "${result}" resolved="${which}" (exists=${result ? existsSync(result) : false})`, ); } catch (err) { serverLog.info( `[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`, ); } // 2. macOS/Linux: probe the user's login shell for nvm/pnpm/bun/mise shims if (!which && !isWin) { const viaShell = probeViaLoginShell('codex', 'connect-agent:codex'); if (viaShell) which = viaShell; } // 3. npm prefix -g (Windows: npm global creates .cmd or .ps1 wrappers) if (!which && isWin) { try { serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g'); const prefix = execSync('npm.cmd prefix -g', { encoding: 'utf-8', timeout: 5000, }).trim(); serverLog.info(`[connect-agent] codex npm global prefix: "${prefix}"`); if (prefix) { for (const bin of winNpmCandidates(prefix, 'codex')) { serverLog.info( `[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`, ); if (existsSync(bin)) { which = bin; break; } } } } catch (err) { serverLog.info( `[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`, ); } } // 4. Common install locations if (!which) { const candidates = isWin ? [ ...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'codex'), ...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'codex'), ...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'codex'), ] : posixUserBinDirs().map((dir) => join(dir, 'codex')); for (const c of candidates) { const exists = c ? existsSync(c) : false; serverLog.info(`[connect-agent] codex candidate: "${c}" (exists=${exists})`); if (c && exists) { which = c; break; } } } if (!which) { serverLog.warn('[connect-agent] codex not found'); return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }; } serverLog.info(`[connect-agent] codex resolved: "${which}"`); // Verify codex is responsive — always use the resolved path const versionCmd = buildExecCmd(which, '--version') + ' 2>&1'; try { const ver = execSync(versionCmd, { encoding: 'utf-8', timeout: 5000 }).trim(); serverLog.info(`[connect-agent] codex version: ${ver}`); } catch (err) { serverLog.error( `[connect-agent] codex --version failed: ${err instanceof Error ? err.message : err}`, ); return { connected: false, models: [], error: 'Codex CLI not responding' }; } // Read models from Codex CLI's local models cache (best-effort) let models: GroupedModel[] = []; const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); const cachePath = join(codexHome, 'models_cache.json'); try { const raw = await readFile(cachePath, 'utf-8'); const cache = JSON.parse(raw) as { models?: Array<{ slug: string; display_name: string; description: string; visibility: string; priority: number; }>; }; if (cache.models && Array.isArray(cache.models)) { models = cache.models .filter((m) => m.visibility === 'list') .sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999)) .map((m) => ({ value: m.slug, displayName: m.display_name, description: m.description ?? '', provider: 'openai' as const, })); } } catch { serverLog.info('[connect-agent] codex models cache not available'); } // Fallback: parse models from Codex's bundled latest-model.md reference if (models.length === 0) { models = await parseCodexLatestModelMd(codexHome); if (models.length > 0) { serverLog.info( `[connect-agent] codex models loaded from latest-model.md: ${models.length}`, ); } } serverLog.info(`[connect-agent] codex connected, ${models.length} models found`); const codexInfo = await buildCodexConnectionInfo(); const warning = models.length === 0 ? 'No models found. Try running codex once to populate the model cache.' : undefined; return { connected: true, models, warning, ...codexInfo }; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to connect'; serverLog.error(`[connect-agent] codex connection error: ${msg}`); return { connected: false, models: [], error: msg }; } } /** Resolve the opencode binary path, checking PATH then common install locations. */ async function resolveOpencodeBinary(): Promise { const { execSync } = await import('node:child_process'); const { existsSync } = await import('node:fs'); const { homedir } = await import('node:os'); const { join } = await import('node:path'); const isWin = process.platform === 'win32'; serverLog.info( `[resolve-opencode] platform=${process.platform}, isWindows=${isWin}, SHELL=${process.env.SHELL ?? 'unset'}`, ); // 1. Try PATH lookup try { const cmd = isWin ? 'where opencode 2>nul' : 'which opencode 2>/dev/null'; serverLog.info(`[resolve-opencode] PATH lookup: ${cmd}`); const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }) .trim() .split(/\r?\n/)[0] ?.trim(); serverLog.info( `[resolve-opencode] PATH result: "${result}" (exists=${result ? existsSync(result) : false})`, ); if (result && existsSync(result)) return resolveWinExtension(result); } catch (err) { serverLog.info( `[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`, ); } // 2. macOS/Linux login-shell probe for nvm/pnpm/bun/mise/asdf shims if (!isWin) { const viaShell = probeViaLoginShell('opencode', 'resolve-opencode'); if (viaShell) return viaShell; } // 3. Try `npm prefix -g` to find actual npm global bin directory // On Windows, must use `npm.cmd` since Electron spawns cmd.exe try { const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g'; serverLog.info(`[resolve-opencode] npm prefix lookup: ${npmCmd}`); const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim(); serverLog.info(`[resolve-opencode] npm global prefix: "${prefix}"`); if (prefix) { if (isWin) { for (const bin of winNpmCandidates(prefix, 'opencode')) { serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`); if (existsSync(bin)) return bin; } } else { const bin = join(prefix, 'bin', 'opencode'); serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`); if (existsSync(bin)) return bin; } } } catch (err) { serverLog.info( `[resolve-opencode] npm prefix -g failed: ${err instanceof Error ? err.message : err}`, ); } // 4. Common install locations const home = homedir(); const candidates = isWin ? [ // npm global (.cmd + .ps1) ...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'opencode'), ...winNpmCandidates(join(process.env.ProgramFiles || '', 'nodejs'), 'opencode'), // nvm-windows / fnm ...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'opencode'), ...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'opencode'), // Scoop join(home, 'scoop', 'shims', 'opencode.exe'), join(process.env.LOCALAPPDATA || '', 'Programs', 'opencode', 'opencode.exe'), ] : [ // curl installer (https://opencode.ai/install) join(home, '.opencode', 'bin', 'opencode'), // npm global (non-standard prefix) join(home, '.npm-global', 'bin', 'opencode'), // All the common user-local/package-manager bin dirs ...posixUserBinDirs().map((dir) => join(dir, 'opencode')), ]; for (const c of candidates) { const exists = c ? existsSync(c) : false; serverLog.info(`[resolve-opencode] candidate: "${c}" (exists=${exists})`); if (c && exists) return c; } serverLog.warn( '[resolve-opencode] no opencode binary found after PATH, login-shell probe, and candidate scan', ); return undefined; } /** Connect to OpenCode and fetch its configured providers/models. */ async function connectOpenCode(): Promise { serverLog.info('[connect-agent] connecting to OpenCode...'); try { const binaryPath = await resolveOpencodeBinary(); serverLog.info(`[connect-agent] resolved opencode path: ${binaryPath ?? 'NOT FOUND'}`); if (!binaryPath) { return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' }; } const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client'); serverLog.info('[connect-agent] creating opencode client...'); const { client, server } = await getOpencodeClient(binaryPath); serverLog.info('[connect-agent] fetching opencode providers...'); const { data, error } = await client.config.providers(); releaseOpencodeServer(server); if (error) { serverLog.error(`[connect-agent] opencode providers error: ${JSON.stringify(error)}`); return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.', }; } const models: GroupedModel[] = []; for (const provider of data?.providers ?? []) { if (!provider.models) continue; for (const [, model] of Object.entries(provider.models)) { models.push({ value: `${provider.id}/${model.id}`, displayName: model.name || model.id, description: `via ${provider.name || provider.id}`, provider: 'opencode' as const, }); } } if (models.length === 0) { serverLog.info('[connect-agent] opencode: no models found'); return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.', }; } const providerNames = (data?.providers ?? []).map((p) => p.name || p.id).filter(Boolean); const providerSummary = providerNames.length > 0 ? `Connected (${providerNames.slice(0, 3).join(', ')}${providerNames.length > 3 ? ` +${providerNames.length - 3}` : ''})` : 'Connected via OpenCode server'; serverLog.info(`[connect-agent] opencode connected, ${models.length} models found`); return { connected: true, models, connectionInfo: providerSummary, hintPath: configPath('~/.opencode/config.json', '%USERPROFILE%\\.opencode\\config.json'), }; } catch (error) { const raw = error instanceof Error ? error.message : 'Failed to connect'; serverLog.error(`[connect-agent] opencode connection error: ${raw}`); return { connected: false, models: [], error: friendlyOpenCodeError(raw) }; } } /** Connect to GitHub Copilot CLI via @github/copilot-sdk and fetch available models. */ async function connectCopilot(): Promise { serverLog.info('[connect-agent] connecting to Copilot...'); // Use standalone copilot binary to avoid Bun's node:sqlite issue const { resolveCopilotCli, resolveCliPathForSdk } = await import('../../utils/copilot-client'); const rawCliPath = resolveCopilotCli(); serverLog.info(`[connect-agent] resolved copilot path: ${rawCliPath ?? 'NOT FOUND'}`); if (!rawCliPath) { return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found', }; } // On Windows, .cmd wrappers cause "spawn EINVAL" — resolve to .js entry point const cliPath = resolveCliPathForSdk(rawCliPath); try { const { CopilotClient } = await import('@github/copilot-sdk'); // In Electron, the SDK spawns `process.execPath` (= OpenPencil.exe) to run // the .js entry. ELECTRON_RUN_AS_NODE makes that binary behave as plain Node // so it doesn't reject `index.js` as a stray positional argument. const client = new CopilotClient({ autoStart: true, cliPath, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, }); serverLog.info('[connect-agent] starting copilot client...'); await client.start(); let models: GroupedModel[] = []; try { serverLog.info('[connect-agent] listing copilot models...'); const modelList = await client.listModels(); models = modelList .filter((m) => !m.policy || m.policy.state === 'enabled') .map((m) => ({ value: m.id, displayName: m.name, description: m.capabilities?.supports?.vision ? 'vision' : '', provider: 'copilot' as const, })); } catch (listErr) { const msg = listErr instanceof Error ? listErr.message : 'Failed to list models'; serverLog.error(`[connect-agent] copilot listModels error: ${msg}`); await client.stop().catch(() => {}); return { connected: false, models: [], error: friendlyCopilotError(msg) }; } // Try to get auth status for user info const copilotHintPath = configPath( '~/.config/github-copilot/config.json', '%USERPROFILE%\\.config\\github-copilot\\config.json', ); let copilotInfo: { connectionInfo: string; hintPath?: string } = { connectionInfo: 'Connected via GitHub', hintPath: copilotHintPath, }; try { const authStatus = await client.getAuthStatus(); serverLog.info(`[connect-agent] copilot auth: ${JSON.stringify(authStatus)}`); if (authStatus?.login) { const method = authStatus.authType ? ` (${authStatus.authType})` : ''; copilotInfo = { connectionInfo: `Connected as @${authStatus.login}${method}`, hintPath: copilotHintPath, }; } else if (authStatus?.statusMessage) { copilotInfo = { connectionInfo: authStatus.statusMessage, hintPath: copilotHintPath }; } } catch (authErr) { serverLog.warn( `[connect-agent] copilot getAuthStatus failed: ${authErr instanceof Error ? authErr.message : authErr}`, ); } await client.stop(); if (models.length === 0) { serverLog.info('[connect-agent] copilot: no models found'); return { connected: false, models: [], error: 'No models found. Run "copilot login" to authenticate first.', }; } serverLog.info(`[connect-agent] copilot connected, ${models.length} models found`); return { connected: true, models, ...copilotInfo }; } catch (error) { const raw = error instanceof Error ? error.message : 'Failed to connect'; serverLog.error(`[connect-agent] copilot connection error: ${raw}`); return { connected: false, models: [], error: friendlyCopilotError(raw) }; } } /** Map Copilot SDK errors to user-friendly messages */ function friendlyCopilotError(raw: string): string { if (/not found|ENOENT/i.test(raw)) { return 'GitHub Copilot CLI not found. Install it from https://docs.github.com/copilot/how-tos/copilot-cli'; } if (/not authenticated|authenticate first|auth|unauthenticated|login/i.test(raw)) { return 'Not authenticated. Run "copilot login" in your terminal first.'; } if (/timed?\s*out/i.test(raw)) { return 'Connection timed out. Please try again.'; } return raw; } /** Map OpenCode connection errors to user-friendly messages */ function friendlyOpenCodeError(raw: string): string { if (/ECONNREFUSED/i.test(raw)) { return 'OpenCode server not running. Start it with "opencode" in your terminal first.'; } if (/not found|ENOENT/i.test(raw)) { return 'OpenCode CLI not found. Please install it first.'; } if (/timed?\s*out/i.test(raw)) { return 'Connection timed out. Please try again.'; } return raw; } /** Fallback model list when dynamic fetch fails */ const FALLBACK_GEMINI_MODELS: GroupedModel[] = [ { value: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro', description: 'Most capable', provider: 'gemini', }, { value: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash', description: 'Fast + capable', provider: 'gemini', }, { value: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', description: 'Thinking model', provider: 'gemini', }, { value: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', description: 'Fast + thinking', provider: 'gemini', }, { value: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash', description: 'Fast model', provider: 'gemini', }, ]; /** Fetch available models from Gemini API using local auth credentials */ async function fetchGeminiModels(): Promise { const { readFile } = await import('node:fs/promises'); const { homedir } = await import('node:os'); const { join } = await import('node:path'); // Build auth header — try API key first, then OAuth token let authUrl: (base: string) => string; let headers: Record = {}; const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; if (envKey) { authUrl = (base) => `${base}?key=${envKey}`; } else { // Read OAuth token const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json'); const raw = await readFile(oauthPath, 'utf-8'); const creds = JSON.parse(raw) as { access_token?: string; expiry_date?: number }; if (!creds.access_token) throw new Error('No access token'); if (creds.expiry_date && Date.now() > creds.expiry_date - 60_000) throw new Error('Token expired'); authUrl = (base) => base; headers = { Authorization: `Bearer ${creds.access_token}` }; } const res = await fetch(authUrl('https://generativelanguage.googleapis.com/v1beta/models'), { headers, }); if (!res.ok) throw new Error(`API ${res.status}`); const data = (await res.json()) as { models?: Array<{ name?: string; displayName?: string; description?: string; supportedGenerationMethods?: string[]; }>; }; const models: GroupedModel[] = []; const seen = new Set(); for (const m of data.models ?? []) { // Only include models that support generateContent (text generation) if (!m.supportedGenerationMethods?.includes('generateContent')) continue; const id = m.name?.replace('models/', '') ?? ''; if (!id || seen.has(id)) continue; // Skip embedding, AQA, and legacy models if (/embed|aqa|^chat-bison|^text-bison|^gemini-1\.0/i.test(id)) continue; seen.add(id); models.push({ value: id, displayName: m.displayName ?? id, description: m.description?.slice(0, 60) ?? '', provider: 'gemini' as const, }); } // Sort: gemini-3 first, then 2.5, then others models.sort((a, b) => { const order = (v: string) => { if (v.includes('gemini-3')) return 0; if (v.includes('gemini-2.5-pro')) return 1; if (v.includes('gemini-2.5-flash')) return 2; if (v.includes('gemini-2.0')) return 3; return 4; }; return order(a.value) - order(b.value); }); return models; } /** Connect to Gemini CLI and return available models. */ async function connectGeminiCli(): Promise { serverLog.info('[connect-agent] connecting to Gemini CLI...'); try { const { resolveGeminiCli } = await import('../../utils/resolve-gemini-cli'); const binPath = resolveGeminiCli(); serverLog.info(`[connect-agent] resolved gemini path: ${binPath ?? 'NOT FOUND'}`); if (!binPath) { return { connected: false, models: [], notInstalled: true, error: 'Gemini CLI not found' }; } // Verify binary responds const { execSync } = await import('node:child_process'); const versionCmd = buildExecCmd(binPath, '--version'); try { const ver = execSync(`${versionCmd} 2>&1`, { encoding: 'utf-8', timeout: 10000 }).trim(); serverLog.info(`[connect-agent] gemini version: ${ver}`); } catch (err) { serverLog.error( `[connect-agent] gemini --version failed: ${err instanceof Error ? err.message : err}`, ); return { connected: false, models: [], error: 'Gemini CLI not responding' }; } // Dynamically fetch models, fallback to hardcoded list let models: GroupedModel[]; try { models = await fetchGeminiModels(); serverLog.info(`[connect-agent] gemini: fetched ${models.length} models from API`); } catch (err) { serverLog.info( `[connect-agent] gemini: model fetch failed (${err instanceof Error ? err.message : err}), using fallback`, ); models = FALLBACK_GEMINI_MODELS; } const geminiInfo = await buildGeminiConnectionInfo(); const warning = models.length === 0 ? 'No models found. Try running "gemini" once to authenticate.' : undefined; if (models.length === 0) models = FALLBACK_GEMINI_MODELS; serverLog.info(`[connect-agent] gemini connected, ${models.length} models`); return { connected: true, models, warning, ...geminiInfo }; } catch (error) { const raw = error instanceof Error ? error.message : 'Failed to connect'; serverLog.error(`[connect-agent] gemini connection error: ${raw}`); return { connected: false, models: [], error: friendlyGeminiError(raw) }; } } /** Build Gemini CLI connection info from local config files */ async function buildGeminiConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> { const { readFile } = await import('node:fs/promises'); const { homedir } = await import('node:os'); const { join } = await import('node:path'); const hp = configPath('~/.gemini/settings.json', '%USERPROFILE%\\.gemini\\settings.json'); // Check env for API key const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; if (envKey) { const masked = envKey.length > 12 ? `${envKey.slice(0, 8)}...` : '***'; return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }; } // Check OAuth creds (Gemini CLI login) try { const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json'); await readFile(oauthPath, 'utf-8'); // Check existence // Try to get account email try { const accountsPath = join(homedir(), '.gemini', 'google_accounts.json'); const accountsRaw = await readFile(accountsPath, 'utf-8'); const accounts = JSON.parse(accountsRaw) as { active?: string }; if (accounts.active) { return { connectionInfo: `Connected via Google (${accounts.active})`, hintPath: hp }; } } catch { /* no accounts file */ } return { connectionInfo: 'Connected via Google OAuth', hintPath: hp }; } catch { /* no OAuth creds */ } return { connectionInfo: 'Connected via Gemini CLI', hintPath: hp }; } /** Map Gemini CLI errors to user-friendly messages */ function friendlyGeminiError(raw: string): string { if (/not found|ENOENT/i.test(raw)) { return 'Gemini CLI not found. Install it with: npm install -g @anthropic-ai/gemini-cli'; } if (/not authenticated|authenticate|auth|login/i.test(raw)) { return 'Not authenticated. Run "gemini" in your terminal first to set up authentication.'; } if (/timed?\s*out/i.test(raw)) { return 'Connection timed out. Please try again.'; } return raw; }