openpencil/server/api/ai/connect-agent.ts
Kayshen Xu e25feb56fc V0.4.3 (#50)
* chore(ai): update dependencies and enhance logging functionality

- Bump version of `@github/copilot-sdk` and related packages to `0.1.32` and `1.0.7` for improved features and bug fixes.
- Update Discord invite links across multiple README files to the new server.
- Introduce a new logging utility in `server/utils/server-logger.ts` for better server process logging, including automatic log cleanup and directory management.
- Enhance the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands.
- Refactor `resolve-claude-cli.ts` and `resolve-copilot-cli.ts` to include detailed logging for CLI binary resolution processes.

This update improves dependency management, enhances user experience with updated links, and provides better insights into server operations through logging.

* chore: bump version from 0.4.0 to 0.4.3 in package.json
2026-03-18 21:34:58 +08:00

592 lines
24 KiB
TypeScript

import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { existsSync } from 'node:fs'
import type { GroupedModel } from '../../../src/types/agent-settings'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { serverLog } from '../../utils/server-logger'
import {
buildClaudeAgentEnv,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
interface ConnectBody {
agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot'
}
interface ConnectResult {
connected: boolean
models: GroupedModel[]
error?: 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<ConnectBody>(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()
}
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<ConnectResult> {
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 q = query({
prompt: '',
options: {
maxTurns: 1,
tools: [],
permissionMode: 'plan',
persistSession: false,
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
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}`)
// Third-party API proxies often don't 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 (proxy detected)')
const fallbackEnv = buildClaudeAgentEnv()
const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null)
return { connected: true, models: FALLBACK_CLAUDE_MODELS, ...claudeInfo }
}
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<string, string | undefined>,
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 endpoint)', 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<string, unknown> | 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.json', '%USERPROFILE%\\.codex\\config.json')
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 authPath = join(homedir(), '.codex', '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<string, unknown> | 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
}
/** Connect to Codex CLI and fetch its supported models from the local cache */
async function connectCodexCli(): Promise<ConnectResult> {
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'
// Check if codex binary exists — PATH, npm prefix, then 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 = result
serverLog.info(`[connect-agent] codex PATH result: "${result}" (exists=${result ? existsSync(result) : false})`)
} catch (err) {
serverLog.info(`[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. npm prefix -g (Windows: npm global creates .cmd 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) {
const bin = join(prefix, 'codex.cmd')
serverLog.info(`[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) which = bin
}
} catch (err) {
serverLog.info(`[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
}
}
// 3. Common install locations
if (!which && isWin) {
const candidates = [
join(process.env.APPDATA || '', 'npm', 'codex.cmd'),
join(process.env.NVM_SYMLINK || '', 'codex.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'codex.cmd'),
]
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 — on Windows, use the resolved path or .cmd wrapper
const versionCmd = isWin ? `"${which}" --version 2>&1` : 'codex --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
let models: GroupedModel[] = []
const cachePath = join(homedir(), '.codex', 'models_cache.json')
serverLog.info(`[connect-agent] codex models cache: "${cachePath}" (exists=${existsSync(cachePath)})`)
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 (cacheErr) {
serverLog.info(`[connect-agent] codex cache read failed: ${cacheErr instanceof Error ? cacheErr.message : cacheErr}`)
}
if (models.length === 0) {
serverLog.info('[connect-agent] codex: no models found')
return { connected: false, models: [], error: 'No models found. Try running codex once to populate the model cache.' }
}
serverLog.info(`[connect-agent] codex connected, ${models.length} models found`)
const codexInfo = await buildCodexConnectionInfo()
return { connected: true, models, ...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<string | undefined> {
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}`)
// 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 result
} catch (err) {
serverLog.info(`[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. 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) {
const bin = isWin ? join(prefix, 'opencode.cmd') : 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}`)
}
// 3. Common install locations
// npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux)
// curl installer → ~/.opencode/bin (macOS/Linux)
// Homebrew → /usr/local/bin or /opt/homebrew/bin (macOS)
const home = homedir()
const candidates = isWin
? [
// npm global
join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
join(process.env.ProgramFiles || '', 'nodejs', 'opencode.cmd'),
// nvm-windows / fnm
join(process.env.NVM_SYMLINK || '', 'opencode.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'opencode.cmd'),
// 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
join(home, '.npm-global', 'bin', 'opencode'),
'/usr/local/bin/opencode',
// Homebrew
'/opt/homebrew/bin/opencode',
join(home, '.local', 'bin', '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.info('[resolve-opencode] no opencode binary found')
return undefined
}
/** Connect to OpenCode and fetch its configured providers/models. */
async function connectOpenCode(): Promise<ConnectResult> {
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()
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<ConnectResult> {
serverLog.info('[connect-agent] connecting to Copilot...')
// Use standalone copilot binary to avoid Bun's node:sqlite issue
const { resolveCopilotCli } = await import('../../utils/copilot-client')
const cliPath = resolveCopilotCli()
serverLog.info(`[connect-agent] resolved copilot path: ${cliPath ?? 'NOT FOUND'}`)
if (!cliPath) {
return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found' }
}
try {
const { CopilotClient } = await import('@github/copilot-sdk')
const client = new CopilotClient({ autoStart: true, cliPath })
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
}