mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-28 11:37:39 +00:00
* feat: integrate Gemini AI agent provider - Core Backend: Ported gemini-cli.js and gemini-response-handler.js to establish the CLI bridge. Registered 'gemini' as an active provider within index.js. - Core Frontend: Extended QuickSettingsPanel.jsx, Settings.jsx, and AgentListItem.jsx to render the Gemini provider option, models (gemini-pro, gemini-flash, etc.), and handle OAuth states. - WebSocket Pipeline: Added support for gemini-command executions in backend and payload processing of gemini-response and gemini-error streams in useChatRealtimeHandlers.ts. Resolved JSON double-stringification and sessionId stripping issues in the transmission handler. - Platform Compatibility: Added scripts/fix-node-pty.js postinstall script and modified posix_spawnp calls with sh -c wrapper to prevent ENOEXEC and MacOS permission errors when spawning the gemini headless binary. - UX & Design: Imported official Google Gemini branding via GeminiLogo.jsx and gemini-ai-icon.svg. Updated translations (chat.json) for en, zh-CN, and ko locales. * fix: propagate gemini permission mode from settings to cli - Added Gemini Permissions UI in Settings to toggle Auto Edit and YOLO modes - Synced gemini permission mode to localStorage - Passed permissionMode in useChatComposerState for Gemini commands - Mapped frontend permission modes to --yolo and --approval-mode options in gemini-cli.js * feat(gemini): Refactor Gemini CLI integration to use stream-json - Replaced regex buffering text-system with NDJSON stream parsing - Added fallback for restricted models like gemini-3.1-pro-preview * feat(gemini): Render tool_use and tool_result UI bubbles - Forwarded gemini tool NDJSON objects to the websocket - Added React state handlers in useChatRealtimeHandlers to match Claude's tool UI behavior * feat(gemini): Add native session resumption and UI token tracking - Captured cliSessionId from init events to map ClaudeCodeUI's chat sessionId directly into Gemini's internal session manager. - Updated gemini-cli.js spawn arguments to append the --resume proxy flag instead of naively dumping the accumulated chat history into the command prompt. - Handled result stream objects by proxying total_tokens back into the frontend's claude-status tracker to natively populate the UI label. - Eliminated gemini-3 model proxy filter entirely. * fix(gemini): Fix static 'Claude' name rendering in chat UI header - Added "gemini": "Gemini" translation strings to messageTypes across English, Korean, and Chinese loc dictionaries. - Updated AssistantThinkingIndicator and MessageComponent ternary checks to identify provider === 'gemini' and render the appropriate brand label instead of statically defaulting to Claude. * feat: Add Gemini session persistence API mapping and Sidebar UI * fix(gemini): Watch ~/.gemini/sessions for live UI updates Added the .gemini/sessions directory to PROVIDER_WATCH_PATHS so that Chokidar emits projects_updated websocket events when new Gemini sessions are created or modified, fixing live sidebar updates. * fix(gemini): Fix Gemini authentication status display in Settings UI - Injected 'checkGeminiAuthStatus' into the Settings.jsx React effect hook so that the UI can poll and render the 'geminiAuthStatus' state. - Updated 'checkGeminiCredentials()' inside server/routes/cli-auth.js to read from '~/.gemini/oauth_creds.json' and '~/.gemini/google_accounts.json', resolving the email address correctly. * Use logo-only icon for gemini * feat(gemini): Add Gemini 3 preview models to UI selection list * Fix Gemini CLI session resume bug and PR #422 review nitpicks * Fix Gemini tool calls disappearing from UI after completion * fix(gemini): resolve outstanding PR #422 feedback and stabilize gemini CLI timeouts * fix(gemini): resolve resume flag and shell session initialization issues This commit addresses the remaining PR comments for the Gemini CLI integration: - Moves the `--resume` flag logic outside the prompt command block, ensuring Gemini sessions correctly resume even when a new prompt isn't passed. - Updates `handleShellConnection` to correctly lookup the native `cliSessionId` from the internal `sessionId` when spawning Gemini sessions in a plain shell. - Refactors dynamic import of `sessionManager.js` back to a native static import for code consistency. * chore: fix TypeScript errors and remove gemini CLI dependency * fix: use cross-spawn on Windows to resolve gemini.cmd correctly --------- Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
378 lines
9.7 KiB
JavaScript
378 lines
9.7 KiB
JavaScript
import express from 'express';
|
|
import { spawn } from 'child_process';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
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: 'credentials_file'
|
|
});
|
|
}
|
|
|
|
return res.json({
|
|
authenticated: false,
|
|
email: null,
|
|
error: credentialsResult.error || 'Not authenticated'
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error checking Claude auth status:', error);
|
|
res.status(500).json({
|
|
authenticated: false,
|
|
email: 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
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Checks Claude authentication credentials using two methods with priority order:
|
|
*
|
|
* Priority 1: ANTHROPIC_API_KEY environment variable
|
|
* 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<Object>} 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() {
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
authenticated: false,
|
|
email: null
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
authenticated: false,
|
|
email: 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;
|
|
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;
|
|
}
|
|
|
|
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;
|