diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 919d8632..1489962b 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -26,13 +26,14 @@ import { } from './services/notification-orchestrator.js'; import { claudeAdapter } from './providers/claude/adapter.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; -const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']); +const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']); function createRequestId() { if (typeof crypto.randomUUID === 'function') { @@ -705,8 +706,14 @@ async function queryClaudeSDK(command, options = {}, ws) { // Clean up temporary image files on error await cleanupTempFiles(tempImagePaths, tempDir); + // Check if Claude CLI is installed for a clearer error message + const installed = getStatusChecker('claude')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code' + : error.message; + // Send error to WebSocket - ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); + ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); notifyRunFailed({ userId: ws?.userId || null, provider: 'claude', @@ -714,8 +721,6 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionName: sessionSummary, error }); - - throw error; } } diff --git a/server/cursor-cli.js b/server/cursor-cli.js index aedd7e0b..f6193369 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -3,6 +3,7 @@ import crossSpawn from 'cross-spawn'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { cursorAdapter } from './providers/cursor/adapter.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -294,7 +295,13 @@ async function spawnCursor(command, options = {}, ws) { const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); - ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); + // Check if Cursor CLI is installed for a clearer error message + const installed = getStatusChecker('cursor')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Cursor CLI is not installed. Please install it from https://cursor.com' + : error.message; + + ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); notifyTerminalState({ error }); settleOnce(() => reject(error)); diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 86472707..62aa5307 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -10,6 +10,7 @@ import sessionManager from './sessionManager.js'; import GeminiResponseHandler from './gemini-response-handler.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; let activeGeminiProcesses = new Map(); // Track active processes by session ID @@ -380,6 +381,15 @@ async function spawnGemini(command, options = {}, ws) { notifyTerminalState({ code }); resolve(); } else { + // code 127 = shell "command not found" — check installation + if (code === 127) { + const installed = getStatusChecker('gemini')?.checkInstalled() ?? true; + if (!installed) { + const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; + ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' })); + } + } + notifyTerminalState({ code, error: code === null ? 'Gemini CLI process was terminated or timed out' : null @@ -394,8 +404,14 @@ async function spawnGemini(command, options = {}, ws) { const finalSessionId = capturedSessionId || sessionId || processKey; activeGeminiProcesses.delete(finalSessionId); + // Check if Gemini CLI is installed for a clearer error message + const installed = getStatusChecker('gemini')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli' + : error.message; + const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; - ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' })); + ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' })); notifyTerminalState({ error }); reject(error); diff --git a/server/openai-codex.js b/server/openai-codex.js index 0169a3b6..99a8e435 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -17,6 +17,7 @@ import { Codex } from '@openai/codex-sdk'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { codexAdapter } from './providers/codex/adapter.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; // Track active sessions const activeCodexSessions = new Map(); @@ -308,7 +309,14 @@ export async function queryCodex(command, options = {}, ws) { if (!wasAborted) { console.error('[Codex] Error:', error); - sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' })); + + // Check if Codex SDK is available for a clearer error message + const installed = getStatusChecker('codex')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Codex CLI is not configured. Please set up authentication first.' + : error.message; + + sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' })); if (!terminalFailure) { notifyRunFailed({ userId: ws?.userId || null, diff --git a/server/providers/claude/status.js b/server/providers/claude/status.js new file mode 100644 index 00000000..7b25ff8c --- /dev/null +++ b/server/providers/claude/status.js @@ -0,0 +1,129 @@ +/** + * Claude Provider Status + * + * Checks whether Claude Code CLI is installed and whether the user + * has valid authentication credentials. + * + * @module providers/claude/status + */ + +import { execFileSync } from 'child_process'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * Check if Claude Code CLI is installed and available. + * Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH. + * @returns {boolean} + */ +export function checkInstalled() { + const cliPath = process.env.CLAUDE_CLI_PATH || 'claude'; + try { + execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + + if (!installed) { + return { + installed, + authenticated: false, + email: null, + method: null, + error: 'Claude Code CLI is not installed' + }; + } + + const credentialsResult = await checkCredentials(); + + if (credentialsResult.authenticated) { + return { + installed, + authenticated: true, + email: credentialsResult.email || 'Authenticated', + method: credentialsResult.method || null, + error: null + }; + } + + return { + installed, + authenticated: false, + email: null, + method: null, + error: credentialsResult.error || 'Not authenticated' + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +async function loadSettingsEnv() { + try { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + const content = await fs.readFile(settingsPath, 'utf8'); + const settings = JSON.parse(content); + + if (settings?.env && typeof settings.env === 'object') { + return settings.env; + } + } catch { + // Ignore missing or malformed settings. + } + + return {}; +} + +/** + * Checks Claude authentication credentials. + * + * Priority 1: ANTHROPIC_API_KEY environment variable + * Priority 1b: ~/.claude/settings.json env values + * Priority 2: ~/.claude/.credentials.json OAuth tokens + */ +async function checkCredentials() { + if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + const settingsEnv = await loadSettingsEnv(); + + if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) { + return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' }; + } + + try { + const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); + const content = await fs.readFile(credPath, 'utf8'); + const creds = JSON.parse(content); + + const oauth = creds.claudeAiOauth; + if (oauth && oauth.accessToken) { + const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt; + if (!isExpired) { + return { + authenticated: true, + email: creds.email || creds.user || null, + method: 'credentials_file' + }; + } + } + + return { authenticated: false, email: null, method: null }; + } catch { + return { authenticated: false, email: null, method: null }; + } +} diff --git a/server/providers/codex/status.js b/server/providers/codex/status.js new file mode 100644 index 00000000..cf1c273f --- /dev/null +++ b/server/providers/codex/status.js @@ -0,0 +1,78 @@ +/** + * Codex Provider Status + * + * Checks whether the user has valid Codex authentication credentials. + * Codex uses an SDK that makes direct API calls (no external binary), + * so installation check always returns true if the server is running. + * + * @module providers/codex/status + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * Check if Codex is installed. + * Codex SDK is bundled with this application — no external binary needed. + * @returns {boolean} + */ +export function checkInstalled() { + return true; +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + const result = await checkCredentials(); + + return { + installed, + authenticated: result.authenticated, + email: result.email || null, + error: result.error || null + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +async function checkCredentials() { + try { + const authPath = path.join(os.homedir(), '.codex', 'auth.json'); + const content = await fs.readFile(authPath, 'utf8'); + const auth = JSON.parse(content); + + const tokens = auth.tokens || {}; + + if (tokens.id_token || tokens.access_token) { + let email = 'Authenticated'; + if (tokens.id_token) { + try { + const parts = tokens.id_token.split('.'); + if (parts.length >= 2) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + email = payload.email || payload.user || 'Authenticated'; + } + } catch { + email = 'Authenticated'; + } + } + + return { authenticated: true, email }; + } + + if (auth.OPENAI_API_KEY) { + return { authenticated: true, email: 'API Key Auth' }; + } + + return { authenticated: false, email: null, error: 'No valid tokens found' }; + } catch (error) { + if (error.code === 'ENOENT') { + return { authenticated: false, email: null, error: 'Codex not configured' }; + } + return { authenticated: false, email: null, error: error.message }; + } +} diff --git a/server/providers/cursor/status.js b/server/providers/cursor/status.js new file mode 100644 index 00000000..127e35b7 --- /dev/null +++ b/server/providers/cursor/status.js @@ -0,0 +1,128 @@ +/** + * Cursor Provider Status + * + * Checks whether cursor-agent CLI is installed and whether the user + * is logged in. + * + * @module providers/cursor/status + */ + +import { execFileSync, spawn } from 'child_process'; + +/** + * Check if cursor-agent CLI is installed. + * @returns {boolean} + */ +export function checkInstalled() { + try { + execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + + if (!installed) { + return { + installed, + authenticated: false, + email: null, + error: 'Cursor CLI is not installed' + }; + } + + const result = await checkCursorLogin(); + + return { + installed, + authenticated: result.authenticated, + email: result.email || null, + error: result.error || null + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +function checkCursorLogin() { + return new Promise((resolve) => { + let processCompleted = false; + + const timeout = setTimeout(() => { + if (!processCompleted) { + processCompleted = true; + if (childProcess) { + childProcess.kill(); + } + resolve({ + authenticated: false, + email: null, + error: 'Command timeout' + }); + } + }, 5000); + + let childProcess; + try { + childProcess = spawn('cursor-agent', ['status']); + } catch { + clearTimeout(timeout); + processCompleted = true; + resolve({ + authenticated: false, + email: null, + error: 'Cursor CLI not found or not installed' + }); + return; + } + + let stdout = ''; + let stderr = ''; + + childProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + childProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + childProcess.on('close', (code) => { + if (processCompleted) return; + processCompleted = true; + clearTimeout(timeout); + + if (code === 0) { + const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); + + if (emailMatch) { + resolve({ authenticated: true, email: emailMatch[1] }); + } else if (stdout.includes('Logged in')) { + resolve({ authenticated: true, email: 'Logged in' }); + } else { + resolve({ authenticated: false, email: null, error: 'Not logged in' }); + } + } else { + resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' }); + } + }); + + childProcess.on('error', () => { + if (processCompleted) return; + processCompleted = true; + clearTimeout(timeout); + + resolve({ + authenticated: false, + email: null, + error: 'Cursor CLI not found or not installed' + }); + }); + }); +} diff --git a/server/providers/gemini/status.js b/server/providers/gemini/status.js new file mode 100644 index 00000000..385f889f --- /dev/null +++ b/server/providers/gemini/status.js @@ -0,0 +1,111 @@ +/** + * Gemini Provider Status + * + * Checks whether Gemini CLI is installed and whether the user + * has valid authentication credentials. + * + * @module providers/gemini/status + */ + +import { execFileSync } from 'child_process'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * Check if Gemini CLI is installed. + * Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH. + * @returns {boolean} + */ +export function checkInstalled() { + const cliPath = process.env.GEMINI_PATH || 'gemini'; + try { + execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + + if (!installed) { + return { + installed, + authenticated: false, + email: null, + error: 'Gemini CLI is not installed' + }; + } + + const result = await checkCredentials(); + + return { + installed, + authenticated: result.authenticated, + email: result.email || null, + error: result.error || null + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +async function checkCredentials() { + if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) { + return { authenticated: true, email: 'API Key Auth' }; + } + + try { + const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + const content = await fs.readFile(credsPath, 'utf8'); + const creds = JSON.parse(content); + + if (creds.access_token) { + let email = 'OAuth Session'; + + try { + const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`); + if (tokenRes.ok) { + const tokenInfo = await tokenRes.json(); + if (tokenInfo.email) { + email = tokenInfo.email; + } + } else if (!creds.refresh_token) { + return { + authenticated: false, + email: null, + error: 'Access token invalid and no refresh token found' + }; + } else { + // Token might be expired but we have a refresh token, so CLI will refresh it + email = await getActiveAccountEmail() || email; + } + } catch { + // Network error, fallback to checking local accounts file + email = await getActiveAccountEmail() || email; + } + + return { authenticated: true, email }; + } + + return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' }; + } catch { + return { authenticated: false, email: null, error: 'Gemini CLI not configured' }; + } +} + +async function getActiveAccountEmail() { + try { + const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const accContent = await fs.readFile(accPath, 'utf8'); + const accounts = JSON.parse(accContent); + return accounts.active || null; + } catch { + return null; + } +} diff --git a/server/providers/registry.js b/server/providers/registry.js index 236c909e..4f62b60b 100644 --- a/server/providers/registry.js +++ b/server/providers/registry.js @@ -1,8 +1,9 @@ /** * Provider Registry * - * Centralizes provider adapter lookup. All code that needs a provider adapter - * should go through this registry instead of importing individual adapters directly. + * Centralizes provider adapter and status checker lookup. All code that needs + * a provider adapter or status checker should go through this registry instead + * of importing individual modules directly. * * @module providers/registry */ @@ -12,6 +13,11 @@ import { cursorAdapter } from './cursor/adapter.js'; import { codexAdapter } from './codex/adapter.js'; import { geminiAdapter } from './gemini/adapter.js'; +import * as claudeStatus from './claude/status.js'; +import * as cursorStatus from './cursor/status.js'; +import * as codexStatus from './codex/status.js'; +import * as geminiStatus from './gemini/status.js'; + /** * @typedef {import('./types.js').ProviderAdapter} ProviderAdapter * @typedef {import('./types.js').SessionProvider} SessionProvider @@ -20,12 +26,20 @@ import { geminiAdapter } from './gemini/adapter.js'; /** @type {Map} */ const providers = new Map(); +/** @type {Map boolean, checkStatus: () => Promise }>} */ +const statusCheckers = new Map(); + // Register built-in providers providers.set('claude', claudeAdapter); providers.set('cursor', cursorAdapter); providers.set('codex', codexAdapter); providers.set('gemini', geminiAdapter); +statusCheckers.set('claude', claudeStatus); +statusCheckers.set('cursor', cursorStatus); +statusCheckers.set('codex', codexStatus); +statusCheckers.set('gemini', geminiStatus); + /** * Get a provider adapter by name. * @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini') @@ -35,6 +49,15 @@ export function getProvider(name) { return providers.get(name); } +/** + * Get a provider status checker by name. + * @param {string} name - Provider name + * @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise } | undefined} + */ +export function getStatusChecker(name) { + return statusCheckers.get(name); +} + /** * Get all registered provider names. * @returns {string[]} diff --git a/server/providers/types.js b/server/providers/types.js index 5541525b..9867b077 100644 --- a/server/providers/types.js +++ b/server/providers/types.js @@ -69,6 +69,19 @@ * @property {object} [tokenUsage] - Token usage data (provider-specific) */ +// ─── Provider Status ──────────────────────────────────────────────────────── + +/** + * Result of a provider status check (installation + authentication). + * + * @typedef {Object} ProviderStatus + * @property {boolean} installed - Whether the provider's CLI/SDK is available + * @property {boolean} authenticated - Whether valid credentials exist + * @property {string|null} email - User email or auth method identifier + * @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file') + * @property {string|null} [error] - Error message if not installed or not authenticated + */ + // ─── Provider Adapter Interface ────────────────────────────────────────────── /** diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index 78ffa30b..4183e83f 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -1,434 +1,27 @@ +/** + * CLI Auth Routes + * + * Thin router that delegates to per-provider status checkers + * registered in the provider registry. + * + * @module routes/cli-auth + */ + import express from 'express'; -import { spawn } from 'child_process'; -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; +import { getAllProviders, getStatusChecker } from '../providers/registry.js'; const router = express.Router(); -router.get('/claude/status', async (req, res) => { - try { - const credentialsResult = await checkClaudeCredentials(); - - if (credentialsResult.authenticated) { - return res.json({ - authenticated: true, - email: credentialsResult.email || 'Authenticated', - method: credentialsResult.method // 'api_key' or 'credentials_file' - }); - } - - return res.json({ - authenticated: false, - email: null, - method: null, - error: credentialsResult.error || 'Not authenticated' - }); - - } catch (error) { - console.error('Error checking Claude auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - method: null, - error: error.message - }); - } -}); - -router.get('/cursor/status', async (req, res) => { - try { - const result = await checkCursorStatus(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Cursor auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -router.get('/codex/status', async (req, res) => { - try { - const result = await checkCodexCredentials(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Codex auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -router.get('/gemini/status', async (req, res) => { - try { - const result = await checkGeminiCredentials(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Gemini auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -async function loadClaudeSettingsEnv() { - try { - const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); - const content = await fs.readFile(settingsPath, 'utf8'); - const settings = JSON.parse(content); - - if (settings?.env && typeof settings.env === 'object') { - return settings.env; - } - } catch (error) { - // Ignore missing or malformed settings and fall back to other auth sources. - } - - return {}; -} - -/** - * Checks Claude authentication credentials using two methods with priority order: - * - * Priority 1: ANTHROPIC_API_KEY environment variable - * Priority 1b: ~/.claude/settings.json env values - * Priority 2: ~/.claude/.credentials.json OAuth tokens - * - * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions. - * This matching behavior ensures consistency with how the SDK authenticates. - * - * References: - * - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code - * "Claude Code prioritizes environment variable API keys over authenticated subscriptions" - * - https://platform.claude.com/docs/en/agent-sdk/overview - * SDK authentication documentation - * - * @returns {Promise} Authentication status with { authenticated, email, method } - * - authenticated: boolean indicating if valid credentials exist - * - email: user email or auth method identifier - * - method: 'api_key' for env var, 'credentials_file' for OAuth tokens - */ -async function checkClaudeCredentials() { - // Priority 1: Check for ANTHROPIC_API_KEY environment variable - // The SDK checks this first and uses it if present, even if OAuth tokens exist. - // When set, API calls are charged via pay-as-you-go rates instead of subscription. - if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth', - method: 'api_key' - }; - } - - // Priority 1b: Check ~/.claude/settings.json env values. - // Claude Code can read proxy/auth values from settings.json even when the - // CloudCLI server process itself was not started with those env vars exported. - const settingsEnv = await loadClaudeSettingsEnv(); - - if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth', - method: 'api_key' - }; - } - - if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) { - return { - authenticated: true, - email: 'Configured via settings.json', - method: 'api_key' - }; - } - - // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens - // This is the standard authentication method used by Claude CLI after running - // 'claude /login' or 'claude setup-token' commands. - try { - const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); - const content = await fs.readFile(credPath, 'utf8'); - const creds = JSON.parse(content); - - const oauth = creds.claudeAiOauth; - if (oauth && oauth.accessToken) { - const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt; - - if (!isExpired) { - return { - authenticated: true, - email: creds.email || creds.user || null, - method: 'credentials_file' - }; - } - } - - return { - authenticated: false, - email: null, - method: null - }; - } catch (error) { - return { - authenticated: false, - email: null, - method: null - }; - } -} - -function checkCursorStatus() { - return new Promise((resolve) => { - let processCompleted = false; - - const timeout = setTimeout(() => { - if (!processCompleted) { - processCompleted = true; - if (childProcess) { - childProcess.kill(); - } - resolve({ - authenticated: false, - email: null, - error: 'Command timeout' - }); - } - }, 5000); - - let childProcess; +for (const provider of getAllProviders()) { + router.get(`/${provider}/status`, async (req, res) => { try { - childProcess = spawn('cursor-agent', ['status']); - } catch (err) { - clearTimeout(timeout); - processCompleted = true; - resolve({ - authenticated: false, - email: null, - error: 'Cursor CLI not found or not installed' - }); - return; + const checker = getStatusChecker(provider); + res.json(await checker.checkStatus()); + } catch (error) { + console.error(`Error checking ${provider} status:`, error); + res.status(500).json({ authenticated: false, error: error.message }); } - - let stdout = ''; - let stderr = ''; - - childProcess.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - childProcess.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - childProcess.on('close', (code) => { - if (processCompleted) return; - processCompleted = true; - clearTimeout(timeout); - - if (code === 0) { - const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); - - if (emailMatch) { - resolve({ - authenticated: true, - email: emailMatch[1], - output: stdout - }); - } else if (stdout.includes('Logged in')) { - resolve({ - authenticated: true, - email: 'Logged in', - output: stdout - }); - } else { - resolve({ - authenticated: false, - email: null, - error: 'Not logged in' - }); - } - } else { - resolve({ - authenticated: false, - email: null, - error: stderr || 'Not logged in' - }); - } - }); - - childProcess.on('error', (err) => { - if (processCompleted) return; - processCompleted = true; - clearTimeout(timeout); - - resolve({ - authenticated: false, - email: null, - error: 'Cursor CLI not found or not installed' - }); - }); }); } -async function checkCodexCredentials() { - try { - const authPath = path.join(os.homedir(), '.codex', 'auth.json'); - const content = await fs.readFile(authPath, 'utf8'); - const auth = JSON.parse(content); - - // Tokens are nested under 'tokens' key - const tokens = auth.tokens || {}; - - // Check for valid tokens (id_token or access_token) - if (tokens.id_token || tokens.access_token) { - // Try to extract email from id_token JWT payload - let email = 'Authenticated'; - if (tokens.id_token) { - try { - // JWT is base64url encoded: header.payload.signature - const parts = tokens.id_token.split('.'); - if (parts.length >= 2) { - // Decode the payload (second part) - const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); - email = payload.email || payload.user || 'Authenticated'; - } - } catch { - // If JWT decoding fails, use fallback - email = 'Authenticated'; - } - } - - return { - authenticated: true, - email - }; - } - - // Also check for OPENAI_API_KEY as fallback auth method - if (auth.OPENAI_API_KEY) { - return { - authenticated: true, - email: 'API Key Auth' - }; - } - - return { - authenticated: false, - email: null, - error: 'No valid tokens found' - }; - } catch (error) { - if (error.code === 'ENOENT') { - return { - authenticated: false, - email: null, - error: 'Codex not configured' - }; - } - return { - authenticated: false, - email: null, - error: error.message - }; - } -} - -async function checkGeminiCredentials() { - if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth' - }; - } - - try { - const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); - const content = await fs.readFile(credsPath, 'utf8'); - const creds = JSON.parse(content); - - if (creds.access_token) { - let email = 'OAuth Session'; - - try { - // Validate token against Google API - const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`); - if (tokenRes.ok) { - const tokenInfo = await tokenRes.json(); - if (tokenInfo.email) { - email = tokenInfo.email; - } - } else if (!creds.refresh_token) { - // Token invalid and no refresh token available - return { - authenticated: false, - email: null, - error: 'Access token invalid and no refresh token found' - }; - } else { - // Token might be expired but we have a refresh token, so CLI will refresh it - try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); - const accContent = await fs.readFile(accPath, 'utf8'); - const accounts = JSON.parse(accContent); - if (accounts.active) { - email = accounts.active; - } - } catch (e) { } - } - } catch (e) { - // Network error, fallback to checking local accounts file - try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); - const accContent = await fs.readFile(accPath, 'utf8'); - const accounts = JSON.parse(accContent); - if (accounts.active) { - email = accounts.active; - } - } catch (err) { } - } - - return { - authenticated: true, - email: email - }; - } - - return { - authenticated: false, - email: null, - error: 'No valid tokens found in oauth_creds' - }; - } catch (error) { - return { - authenticated: false, - email: null, - error: 'Gemini CLI not configured' - }; - } -} - export default router; diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index e6da236d..69e8fc3c 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -160,6 +160,9 @@ export default function ChatComposer({ (r) => r.toolName === 'AskUserQuestion' ); + // Hide the thinking/status bar while any permission request is pending + const hasPendingPermissions = pendingPermissionRequests.length > 0; + // On mobile, when input is focused, float the input box at the bottom const mobileFloatingClass = isInputFocused ? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]' @@ -167,7 +170,7 @@ export default function ChatComposer({ return (
- {!hasQuestionPanel && ( + {!hasPendingPermissions && (