@siteboon/claude-code-ui 1.20.1 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ import { addProjectManually } from '../projects.js';
9
9
  import { queryClaudeSDK } from '../claude-sdk.js';
10
10
  import { spawnCursor } from '../cursor-cli.js';
11
11
  import { queryCodex } from '../openai-codex.js';
12
+ import { spawnGemini } from '../gemini-cli.js';
12
13
  import { Octokit } from '@octokit/rest';
13
14
  import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
14
15
  import { IS_PLATFORM } from '../constants/config.js';
@@ -629,7 +630,7 @@ class ResponseCollector {
629
630
  * - Source for auto-generated branch names (if createBranch=true and no branchName)
630
631
  * - Fallback for PR title if no commits are made
631
632
  *
632
- * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
633
+ * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
633
634
  * Default: 'claude'
634
635
  *
635
636
  * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -747,7 +748,7 @@ class ResponseCollector {
747
748
  * Input Validations (400 Bad Request):
748
749
  * - Either githubUrl OR projectPath must be provided (not neither)
749
750
  * - message must be non-empty string
750
- * - provider must be 'claude' or 'cursor'
751
+ * - provider must be 'claude', 'cursor', 'codex', or 'gemini'
751
752
  * - createBranch/createPR requires githubUrl OR projectPath (not neither)
752
753
  * - branchName must pass Git naming rules (if provided)
753
754
  *
@@ -855,8 +856,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
855
856
  return res.status(400).json({ error: 'message is required' });
856
857
  }
857
858
 
858
- if (!['claude', 'cursor', 'codex'].includes(provider)) {
859
- return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
859
+ if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
860
+ return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
860
861
  }
861
862
 
862
863
  // Validate GitHub branch/PR creation requirements
@@ -971,6 +972,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
971
972
  model: model || CODEX_MODELS.DEFAULT,
972
973
  permissionMode: 'bypassPermissions'
973
974
  }, writer);
975
+ } else if (provider === 'gemini') {
976
+ console.log('✨ Starting Gemini CLI session');
977
+
978
+ await spawnGemini(message.trim(), {
979
+ projectPath: finalProjectPath,
980
+ cwd: finalProjectPath,
981
+ sessionId: null,
982
+ model: model,
983
+ skipPermissions: true // CLI mode bypasses permissions
984
+ }, writer);
974
985
  }
975
986
 
976
987
  // Handle GitHub branch and PR creation after successful agent completion
@@ -74,6 +74,46 @@ router.get('/codex/status', async (req, res) => {
74
74
  }
75
75
  });
76
76
 
77
+ router.get('/gemini/status', async (req, res) => {
78
+ try {
79
+ const result = await checkGeminiCredentials();
80
+
81
+ res.json({
82
+ authenticated: result.authenticated,
83
+ email: result.email,
84
+ error: result.error
85
+ });
86
+
87
+ } catch (error) {
88
+ console.error('Error checking Gemini auth status:', error);
89
+ res.status(500).json({
90
+ authenticated: false,
91
+ email: null,
92
+ error: error.message
93
+ });
94
+ }
95
+ });
96
+
97
+ /**
98
+ * Checks Claude authentication credentials using two methods with priority order:
99
+ *
100
+ * Priority 1: ANTHROPIC_API_KEY environment variable
101
+ * Priority 2: ~/.claude/.credentials.json OAuth tokens
102
+ *
103
+ * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
104
+ * This matching behavior ensures consistency with how the SDK authenticates.
105
+ *
106
+ * References:
107
+ * - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
108
+ * "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
109
+ * - https://platform.claude.com/docs/en/agent-sdk/overview
110
+ * SDK authentication documentation
111
+ *
112
+ * @returns {Promise<Object>} Authentication status with { authenticated, email, method }
113
+ * - authenticated: boolean indicating if valid credentials exist
114
+ * - email: user email or auth method identifier
115
+ * - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
116
+ */
77
117
  async function checkClaudeCredentials() {
78
118
  try {
79
119
  const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -260,4 +300,78 @@ async function checkCodexCredentials() {
260
300
  }
261
301
  }
262
302
 
303
+ async function checkGeminiCredentials() {
304
+ if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
305
+ return {
306
+ authenticated: true,
307
+ email: 'API Key Auth'
308
+ };
309
+ }
310
+
311
+ try {
312
+ const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
313
+ const content = await fs.readFile(credsPath, 'utf8');
314
+ const creds = JSON.parse(content);
315
+
316
+ if (creds.access_token) {
317
+ let email = 'OAuth Session';
318
+
319
+ try {
320
+ // Validate token against Google API
321
+ const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
322
+ if (tokenRes.ok) {
323
+ const tokenInfo = await tokenRes.json();
324
+ if (tokenInfo.email) {
325
+ email = tokenInfo.email;
326
+ }
327
+ } else if (!creds.refresh_token) {
328
+ // Token invalid and no refresh token available
329
+ return {
330
+ authenticated: false,
331
+ email: null,
332
+ error: 'Access token invalid and no refresh token found'
333
+ };
334
+ } else {
335
+ // Token might be expired but we have a refresh token, so CLI will refresh it
336
+ try {
337
+ const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
338
+ const accContent = await fs.readFile(accPath, 'utf8');
339
+ const accounts = JSON.parse(accContent);
340
+ if (accounts.active) {
341
+ email = accounts.active;
342
+ }
343
+ } catch (e) { }
344
+ }
345
+ } catch (e) {
346
+ // Network error, fallback to checking local accounts file
347
+ try {
348
+ const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
349
+ const accContent = await fs.readFile(accPath, 'utf8');
350
+ const accounts = JSON.parse(accContent);
351
+ if (accounts.active) {
352
+ email = accounts.active;
353
+ }
354
+ } catch (err) { }
355
+ }
356
+
357
+ return {
358
+ authenticated: true,
359
+ email: email
360
+ };
361
+ }
362
+
363
+ return {
364
+ authenticated: false,
365
+ email: null,
366
+ error: 'No valid tokens found in oauth_creds'
367
+ };
368
+ } catch (error) {
369
+ return {
370
+ authenticated: false,
371
+ email: null,
372
+ error: 'Gemini CLI not configured'
373
+ };
374
+ }
375
+ }
376
+
263
377
  export default router;
@@ -0,0 +1,46 @@
1
+ import express from 'express';
2
+ import sessionManager from '../sessionManager.js';
3
+
4
+ const router = express.Router();
5
+
6
+ router.get('/sessions/:sessionId/messages', async (req, res) => {
7
+ try {
8
+ const { sessionId } = req.params;
9
+
10
+ if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
11
+ return res.status(400).json({ success: false, error: 'Invalid session ID format' });
12
+ }
13
+
14
+ const messages = sessionManager.getSessionMessages(sessionId);
15
+
16
+ res.json({
17
+ success: true,
18
+ messages: messages,
19
+ total: messages.length,
20
+ hasMore: false,
21
+ offset: 0,
22
+ limit: messages.length
23
+ });
24
+ } catch (error) {
25
+ console.error('Error fetching Gemini session messages:', error);
26
+ res.status(500).json({ success: false, error: error.message });
27
+ }
28
+ });
29
+
30
+ router.delete('/sessions/:sessionId', async (req, res) => {
31
+ try {
32
+ const { sessionId } = req.params;
33
+
34
+ if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
35
+ return res.status(400).json({ success: false, error: 'Invalid session ID format' });
36
+ }
37
+
38
+ await sessionManager.deleteSession(sessionId);
39
+ res.json({ success: true });
40
+ } catch (error) {
41
+ console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
42
+ res.status(500).json({ success: false, error: error.message });
43
+ }
44
+ });
45
+
46
+ export default router;
@@ -0,0 +1,226 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ class SessionManager {
6
+ constructor() {
7
+ // Store sessions in memory with conversation history
8
+ this.sessions = new Map();
9
+ this.maxSessions = 100;
10
+ this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
11
+ this.ready = this.init();
12
+ }
13
+
14
+ async init() {
15
+ await this.initSessionsDir();
16
+ await this.loadSessions();
17
+ }
18
+
19
+ async initSessionsDir() {
20
+ try {
21
+ await fs.mkdir(this.sessionsDir, { recursive: true });
22
+ } catch (error) {
23
+ // console.error('Error creating sessions directory:', error);
24
+ }
25
+ }
26
+
27
+ // Create a new session
28
+ createSession(sessionId, projectPath) {
29
+ const session = {
30
+ id: sessionId,
31
+ projectPath: projectPath,
32
+ messages: [],
33
+ createdAt: new Date(),
34
+ lastActivity: new Date()
35
+ };
36
+
37
+ // Evict oldest session from memory if we exceed limit
38
+ if (this.sessions.size >= this.maxSessions) {
39
+ const oldestKey = this.sessions.keys().next().value;
40
+ if (oldestKey) this.sessions.delete(oldestKey);
41
+ }
42
+
43
+ this.sessions.set(sessionId, session);
44
+ this.saveSession(sessionId);
45
+
46
+ return session;
47
+ }
48
+
49
+ // Add a message to session
50
+ addMessage(sessionId, role, content) {
51
+ let session = this.sessions.get(sessionId);
52
+
53
+ if (!session) {
54
+ // Create session if it doesn't exist
55
+ session = this.createSession(sessionId, '');
56
+ }
57
+
58
+ const message = {
59
+ role: role, // 'user' or 'assistant'
60
+ content: content,
61
+ timestamp: new Date()
62
+ };
63
+
64
+ session.messages.push(message);
65
+ session.lastActivity = new Date();
66
+
67
+ this.saveSession(sessionId);
68
+
69
+ return session;
70
+ }
71
+
72
+ // Get session by ID
73
+ getSession(sessionId) {
74
+ return this.sessions.get(sessionId);
75
+ }
76
+
77
+ // Get all sessions for a project
78
+ getProjectSessions(projectPath) {
79
+ const sessions = [];
80
+
81
+ for (const [id, session] of this.sessions) {
82
+ if (session.projectPath === projectPath) {
83
+ sessions.push({
84
+ id: session.id,
85
+ summary: this.getSessionSummary(session),
86
+ messageCount: session.messages.length,
87
+ lastActivity: session.lastActivity
88
+ });
89
+ }
90
+ }
91
+
92
+ return sessions.sort((a, b) =>
93
+ new Date(b.lastActivity) - new Date(a.lastActivity)
94
+ );
95
+ }
96
+
97
+ // Get session summary
98
+ getSessionSummary(session) {
99
+ if (session.messages.length === 0) {
100
+ return 'New Session';
101
+ }
102
+
103
+ // Find first user message
104
+ const firstUserMessage = session.messages.find(m => m.role === 'user');
105
+ if (firstUserMessage) {
106
+ const content = firstUserMessage.content;
107
+ return content.length > 50 ? content.substring(0, 50) + '...' : content;
108
+ }
109
+
110
+ return 'New Session';
111
+ }
112
+
113
+ // Build conversation context for Gemini
114
+ buildConversationContext(sessionId, maxMessages = 10) {
115
+ const session = this.sessions.get(sessionId);
116
+
117
+ if (!session || session.messages.length === 0) {
118
+ return '';
119
+ }
120
+
121
+ // Get last N messages for context
122
+ const recentMessages = session.messages.slice(-maxMessages);
123
+
124
+ let context = 'Here is the conversation history:\n\n';
125
+
126
+ for (const msg of recentMessages) {
127
+ if (msg.role === 'user') {
128
+ context += `User: ${msg.content}\n`;
129
+ } else {
130
+ context += `Assistant: ${msg.content}\n`;
131
+ }
132
+ }
133
+
134
+ context += '\nBased on the conversation history above, please answer the following:\n';
135
+
136
+ return context;
137
+ }
138
+
139
+ // Prevent path traversal
140
+ _safeFilePath(sessionId) {
141
+ const safeId = String(sessionId).replace(/[/\\]|\.\./g, '');
142
+ return path.join(this.sessionsDir, `${safeId}.json`);
143
+ }
144
+
145
+ // Save session to disk
146
+ async saveSession(sessionId) {
147
+ const session = this.sessions.get(sessionId);
148
+ if (!session) return;
149
+
150
+ try {
151
+ const filePath = this._safeFilePath(sessionId);
152
+ await fs.writeFile(filePath, JSON.stringify(session, null, 2));
153
+ } catch (error) {
154
+ // console.error('Error saving session:', error);
155
+ }
156
+ }
157
+
158
+ // Load sessions from disk
159
+ async loadSessions() {
160
+ try {
161
+ const files = await fs.readdir(this.sessionsDir);
162
+
163
+ for (const file of files) {
164
+ if (file.endsWith('.json')) {
165
+ try {
166
+ const filePath = path.join(this.sessionsDir, file);
167
+ const data = await fs.readFile(filePath, 'utf8');
168
+ const session = JSON.parse(data);
169
+
170
+ // Convert dates
171
+ session.createdAt = new Date(session.createdAt);
172
+ session.lastActivity = new Date(session.lastActivity);
173
+ session.messages.forEach(msg => {
174
+ msg.timestamp = new Date(msg.timestamp);
175
+ });
176
+
177
+ this.sessions.set(session.id, session);
178
+ } catch (error) {
179
+ // console.error(`Error loading session ${file}:`, error);
180
+ }
181
+ }
182
+ }
183
+
184
+ // Enforce eviction after loading to prevent massive memory usage
185
+ while (this.sessions.size > this.maxSessions) {
186
+ const oldestKey = this.sessions.keys().next().value;
187
+ if (oldestKey) this.sessions.delete(oldestKey);
188
+ }
189
+ } catch (error) {
190
+ // console.error('Error loading sessions:', error);
191
+ }
192
+ }
193
+
194
+ // Delete a session
195
+ async deleteSession(sessionId) {
196
+ this.sessions.delete(sessionId);
197
+
198
+ try {
199
+ const filePath = this._safeFilePath(sessionId);
200
+ await fs.unlink(filePath);
201
+ } catch (error) {
202
+ // console.error('Error deleting session file:', error);
203
+ }
204
+ }
205
+
206
+ // Get session messages for display
207
+ getSessionMessages(sessionId) {
208
+ const session = this.sessions.get(sessionId);
209
+ if (!session) return [];
210
+
211
+ return session.messages.map(msg => ({
212
+ type: 'message',
213
+ message: {
214
+ role: msg.role,
215
+ content: msg.content
216
+ },
217
+ timestamp: msg.timestamp.toISOString()
218
+ }));
219
+ }
220
+ }
221
+
222
+ // Singleton instance
223
+ const sessionManager = new SessionManager();
224
+
225
+ export const ready = sessionManager.ready;
226
+ export default sessionManager;
@@ -65,3 +65,22 @@ export const CODEX_MODELS = {
65
65
 
66
66
  DEFAULT: 'gpt-5.3-codex'
67
67
  };
68
+
69
+ /**
70
+ * Gemini Models
71
+ */
72
+ export const GEMINI_MODELS = {
73
+ OPTIONS: [
74
+ { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
75
+ { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
76
+ { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
77
+ { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
78
+ { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
79
+ { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
80
+ { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
81
+ { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
82
+ { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
83
+ ],
84
+
85
+ DEFAULT: 'gemini-2.5-flash'
86
+ };