@siteboon/claude-code-ui 1.25.2 → 1.26.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.
Files changed (43) hide show
  1. package/README.de.md +239 -0
  2. package/README.ja.md +115 -230
  3. package/README.ko.md +116 -231
  4. package/README.md +2 -1
  5. package/README.ru.md +75 -54
  6. package/README.zh-CN.md +121 -238
  7. package/dist/assets/index-C08k8QbP.css +32 -0
  8. package/dist/assets/{index-DF_FFT3b.js → index-DnXcHp5q.js} +249 -242
  9. package/dist/index.html +2 -2
  10. package/dist/sw.js +59 -3
  11. package/package.json +3 -2
  12. package/server/claude-sdk.js +106 -62
  13. package/server/cli.js +10 -7
  14. package/server/cursor-cli.js +59 -73
  15. package/server/database/db.js +142 -1
  16. package/server/database/init.sql +28 -1
  17. package/server/gemini-cli.js +46 -48
  18. package/server/gemini-response-handler.js +12 -73
  19. package/server/index.js +82 -55
  20. package/server/middleware/auth.js +2 -2
  21. package/server/openai-codex.js +43 -28
  22. package/server/projects.js +1 -1
  23. package/server/providers/claude/adapter.js +278 -0
  24. package/server/providers/codex/adapter.js +248 -0
  25. package/server/providers/cursor/adapter.js +353 -0
  26. package/server/providers/gemini/adapter.js +186 -0
  27. package/server/providers/registry.js +44 -0
  28. package/server/providers/types.js +119 -0
  29. package/server/providers/utils.js +29 -0
  30. package/server/routes/agent.js +7 -5
  31. package/server/routes/cli-auth.js +38 -0
  32. package/server/routes/codex.js +1 -19
  33. package/server/routes/gemini.js +0 -30
  34. package/server/routes/git.js +48 -20
  35. package/server/routes/messages.js +61 -0
  36. package/server/routes/plugins.js +5 -1
  37. package/server/routes/settings.js +99 -1
  38. package/server/routes/taskmaster.js +2 -2
  39. package/server/services/notification-orchestrator.js +227 -0
  40. package/server/services/vapid-keys.js +35 -0
  41. package/server/utils/plugin-loader.js +53 -4
  42. package/shared/networkHosts.js +22 -0
  43. package/dist/assets/index-WNTmA_ug.css +0 -32
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared provider utilities.
3
+ *
4
+ * @module providers/utils
5
+ */
6
+
7
+ /**
8
+ * Prefixes that indicate internal/system content which should be hidden from the UI.
9
+ * @type {readonly string[]}
10
+ */
11
+ export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
12
+ '<command-name>',
13
+ '<command-message>',
14
+ '<command-args>',
15
+ '<local-command-stdout>',
16
+ '<system-reminder>',
17
+ 'Caveat:',
18
+ 'This session is being continued from a previous',
19
+ '[Request interrupted',
20
+ ]);
21
+
22
+ /**
23
+ * Check if user text content is internal/system that should be skipped.
24
+ * @param {string} content
25
+ * @returns {boolean}
26
+ */
27
+ export function isInternalContent(content) {
28
+ return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
29
+ }
@@ -450,9 +450,10 @@ async function cleanupProject(projectPath, sessionId = null) {
450
450
  * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
451
451
  */
452
452
  class SSEStreamWriter {
453
- constructor(res) {
453
+ constructor(res, userId = null) {
454
454
  this.res = res;
455
455
  this.sessionId = null;
456
+ this.userId = userId;
456
457
  this.isSSEStreamWriter = true; // Marker for transport detection
457
458
  }
458
459
 
@@ -485,9 +486,10 @@ class SSEStreamWriter {
485
486
  * Non-streaming response collector
486
487
  */
487
488
  class ResponseCollector {
488
- constructor() {
489
+ constructor(userId = null) {
489
490
  this.messages = [];
490
491
  this.sessionId = null;
492
+ this.userId = userId;
491
493
  }
492
494
 
493
495
  send(data) {
@@ -920,7 +922,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
920
922
  res.setHeader('Connection', 'keep-alive');
921
923
  res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
922
924
 
923
- writer = new SSEStreamWriter(res);
925
+ writer = new SSEStreamWriter(res, req.user.id);
924
926
 
925
927
  // Send initial status
926
928
  writer.send({
@@ -930,7 +932,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
930
932
  });
931
933
  } else {
932
934
  // Non-streaming mode: collect messages
933
- writer = new ResponseCollector();
935
+ writer = new ResponseCollector(req.user.id);
934
936
 
935
937
  // Collect initial status message
936
938
  writer.send({
@@ -1219,7 +1221,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1219
1221
  res.setHeader('Cache-Control', 'no-cache');
1220
1222
  res.setHeader('Connection', 'keep-alive');
1221
1223
  res.setHeader('X-Accel-Buffering', 'no');
1222
- writer = new SSEStreamWriter(res);
1224
+ writer = new SSEStreamWriter(res, req.user.id);
1223
1225
  }
1224
1226
 
1225
1227
  if (!res.writableEnded) {
@@ -96,10 +96,27 @@ router.get('/gemini/status', async (req, res) => {
96
96
  }
97
97
  });
98
98
 
99
+ async function loadClaudeSettingsEnv() {
100
+ try {
101
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
102
+ const content = await fs.readFile(settingsPath, 'utf8');
103
+ const settings = JSON.parse(content);
104
+
105
+ if (settings?.env && typeof settings.env === 'object') {
106
+ return settings.env;
107
+ }
108
+ } catch (error) {
109
+ // Ignore missing or malformed settings and fall back to other auth sources.
110
+ }
111
+
112
+ return {};
113
+ }
114
+
99
115
  /**
100
116
  * Checks Claude authentication credentials using two methods with priority order:
101
117
  *
102
118
  * Priority 1: ANTHROPIC_API_KEY environment variable
119
+ * Priority 1b: ~/.claude/settings.json env values
103
120
  * Priority 2: ~/.claude/.credentials.json OAuth tokens
104
121
  *
105
122
  * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
@@ -128,6 +145,27 @@ async function checkClaudeCredentials() {
128
145
  };
129
146
  }
130
147
 
148
+ // Priority 1b: Check ~/.claude/settings.json env values.
149
+ // Claude Code can read proxy/auth values from settings.json even when the
150
+ // CloudCLI server process itself was not started with those env vars exported.
151
+ const settingsEnv = await loadClaudeSettingsEnv();
152
+
153
+ if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
154
+ return {
155
+ authenticated: true,
156
+ email: 'API Key Auth',
157
+ method: 'api_key'
158
+ };
159
+ }
160
+
161
+ if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
162
+ return {
163
+ authenticated: true,
164
+ email: 'Configured via settings.json',
165
+ method: 'api_key'
166
+ };
167
+ }
168
+
131
169
  // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
132
170
  // This is the standard authentication method used by Claude CLI after running
133
171
  // 'claude /login' or 'claude setup-token' commands.
@@ -4,7 +4,7 @@ import { promises as fs } from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import TOML from '@iarna/toml';
7
- import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
7
+ import { getCodexSessions, deleteCodexSession } from '../projects.js';
8
8
  import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
9
9
 
10
10
  const router = express.Router();
@@ -68,24 +68,6 @@ router.get('/sessions', async (req, res) => {
68
68
  }
69
69
  });
70
70
 
71
- router.get('/sessions/:sessionId/messages', async (req, res) => {
72
- try {
73
- const { sessionId } = req.params;
74
- const { limit, offset } = req.query;
75
-
76
- const result = await getCodexSessionMessages(
77
- sessionId,
78
- limit ? parseInt(limit, 10) : null,
79
- offset ? parseInt(offset, 10) : 0
80
- );
81
-
82
- res.json({ success: true, ...result });
83
- } catch (error) {
84
- console.error('Error fetching Codex session messages:', error);
85
- res.status(500).json({ success: false, error: error.message });
86
- }
87
- });
88
-
89
71
  router.delete('/sessions/:sessionId', async (req, res) => {
90
72
  try {
91
73
  const { sessionId } = req.params;
@@ -1,39 +1,9 @@
1
1
  import express from 'express';
2
2
  import sessionManager from '../sessionManager.js';
3
3
  import { sessionNamesDb } from '../database/db.js';
4
- import { getGeminiCliSessionMessages } from '../projects.js';
5
4
 
6
5
  const router = express.Router();
7
6
 
8
- router.get('/sessions/:sessionId/messages', async (req, res) => {
9
- try {
10
- const { sessionId } = req.params;
11
-
12
- if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
13
- return res.status(400).json({ success: false, error: 'Invalid session ID format' });
14
- }
15
-
16
- let messages = sessionManager.getSessionMessages(sessionId);
17
-
18
- // Fallback to Gemini CLI sessions on disk
19
- if (messages.length === 0) {
20
- messages = await getGeminiCliSessionMessages(sessionId);
21
- }
22
-
23
- res.json({
24
- success: true,
25
- messages: messages,
26
- total: messages.length,
27
- hasMore: false,
28
- offset: 0,
29
- limit: messages.length
30
- });
31
- } catch (error) {
32
- console.error('Error fetching Gemini session messages:', error);
33
- res.status(500).json({ success: false, error: error.message });
34
- }
35
- });
36
-
37
7
  router.delete('/sessions/:sessionId', async (req, res) => {
38
8
  try {
39
9
  const { sessionId } = req.params;
@@ -651,26 +651,28 @@ router.get('/branches', async (req, res) => {
651
651
 
652
652
  // Get all branches
653
653
  const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
654
-
655
- // Parse branches
656
- const branches = stdout
654
+
655
+ const rawLines = stdout
657
656
  .split('\n')
658
- .map(branch => branch.trim())
659
- .filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer
660
- .map(branch => {
661
- // Remove asterisk from current branch
662
- if (branch.startsWith('* ')) {
663
- return branch.substring(2);
664
- }
665
- // Remove remotes/ prefix
666
- if (branch.startsWith('remotes/origin/')) {
667
- return branch.substring(15);
668
- }
669
- return branch;
670
- })
671
- .filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates
672
-
673
- res.json({ branches });
657
+ .map(b => b.trim())
658
+ .filter(b => b && !b.includes('->'));
659
+
660
+ // Local branches (may start with '* ' for current)
661
+ const localBranches = rawLines
662
+ .filter(b => !b.startsWith('remotes/'))
663
+ .map(b => (b.startsWith('* ') ? b.substring(2) : b));
664
+
665
+ // Remote branches — strip 'remotes/<remote>/' prefix
666
+ const remoteBranches = rawLines
667
+ .filter(b => b.startsWith('remotes/'))
668
+ .map(b => b.replace(/^remotes\/[^/]+\//, ''))
669
+ .filter(name => !localBranches.includes(name)); // skip if already a local branch
670
+
671
+ // Backward-compat flat list (local + unique remotes, deduplicated)
672
+ const branches = [...localBranches, ...remoteBranches]
673
+ .filter((b, i, arr) => arr.indexOf(b) === i);
674
+
675
+ res.json({ branches, localBranches, remoteBranches });
674
676
  } catch (error) {
675
677
  console.error('Git branches error:', error);
676
678
  res.json({ error: error.message });
@@ -721,6 +723,32 @@ router.post('/create-branch', async (req, res) => {
721
723
  }
722
724
  });
723
725
 
726
+ // Delete a local branch
727
+ router.post('/delete-branch', async (req, res) => {
728
+ const { project, branch } = req.body;
729
+
730
+ if (!project || !branch) {
731
+ return res.status(400).json({ error: 'Project name and branch name are required' });
732
+ }
733
+
734
+ try {
735
+ const projectPath = await getActualProjectPath(project);
736
+ await validateGitRepository(projectPath);
737
+
738
+ // Safety: cannot delete the currently checked-out branch
739
+ const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
740
+ if (currentBranch.trim() === branch) {
741
+ return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
742
+ }
743
+
744
+ const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
745
+ res.json({ success: true, output: stdout });
746
+ } catch (error) {
747
+ console.error('Git delete branch error:', error);
748
+ res.status(500).json({ error: error.message });
749
+ }
750
+ });
751
+
724
752
  // Get recent commits
725
753
  router.get('/commits', async (req, res) => {
726
754
  const { project, limit = 10 } = req.query;
@@ -740,7 +768,7 @@ router.get('/commits', async (req, res) => {
740
768
  // Get commit log with stats
741
769
  const { stdout } = await spawnAsync(
742
770
  'git',
743
- ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
771
+ ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
744
772
  { cwd: projectPath },
745
773
  );
746
774
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Unified messages endpoint.
3
+ *
4
+ * GET /api/sessions/:sessionId/messages?provider=claude&projectName=foo&limit=50&offset=0
5
+ *
6
+ * Replaces the four provider-specific session message endpoints with a single route
7
+ * that delegates to the appropriate adapter via the provider registry.
8
+ *
9
+ * @module routes/messages
10
+ */
11
+
12
+ import express from 'express';
13
+ import { getProvider, getAllProviders } from '../providers/registry.js';
14
+
15
+ const router = express.Router();
16
+
17
+ /**
18
+ * GET /api/sessions/:sessionId/messages
19
+ *
20
+ * Auth: authenticateToken applied at mount level in index.js
21
+ *
22
+ * Query params:
23
+ * provider - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude')
24
+ * projectName - required for claude provider
25
+ * projectPath - required for cursor provider (absolute path used for cwdId hash)
26
+ * limit - page size (omit or null for all)
27
+ * offset - pagination offset (default: 0)
28
+ */
29
+ router.get('/:sessionId/messages', async (req, res) => {
30
+ try {
31
+ const { sessionId } = req.params;
32
+ const provider = req.query.provider || 'claude';
33
+ const projectName = req.query.projectName || '';
34
+ const projectPath = req.query.projectPath || '';
35
+ const limitParam = req.query.limit;
36
+ const limit = limitParam !== undefined && limitParam !== null && limitParam !== ''
37
+ ? parseInt(limitParam, 10)
38
+ : null;
39
+ const offset = parseInt(req.query.offset || '0', 10);
40
+
41
+ const adapter = getProvider(provider);
42
+ if (!adapter) {
43
+ const available = getAllProviders().join(', ');
44
+ return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
45
+ }
46
+
47
+ const result = await adapter.fetchHistory(sessionId, {
48
+ projectName,
49
+ projectPath,
50
+ limit,
51
+ offset,
52
+ });
53
+
54
+ return res.json(result);
55
+ } catch (error) {
56
+ console.error('Error fetching unified messages:', error);
57
+ return res.status(500).json({ error: 'Failed to fetch messages' });
58
+ }
59
+ });
60
+
61
+ export default router;
@@ -81,6 +81,10 @@ router.get('/:name/assets/*', (req, res) => {
81
81
 
82
82
  const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
83
83
  res.setHeader('Content-Type', contentType);
84
+ // Prevent CDN/proxy caching of plugin assets so updates take effect immediately
85
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
86
+ res.setHeader('Pragma', 'no-cache');
87
+ res.setHeader('Expires', '0');
84
88
  const stream = fs.createReadStream(resolvedPath);
85
89
  stream.on('error', () => {
86
90
  if (!res.headersSent) {
@@ -236,7 +240,7 @@ router.all('/:name/rpc/*', async (req, res) => {
236
240
  'content-type': req.headers['content-type'] || 'application/json',
237
241
  };
238
242
 
239
- // Add per-plugin secrets as X-Plugin-Secret-* headers
243
+ // Add per-plugin user-configured secrets as X-Plugin-Secret-* headers
240
244
  for (const [key, value] of Object.entries(secrets)) {
241
245
  headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
242
246
  }
@@ -1,5 +1,7 @@
1
1
  import express from 'express';
2
- import { apiKeysDb, credentialsDb } from '../database/db.js';
2
+ import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
3
+ import { getPublicKey } from '../services/vapid-keys.js';
4
+ import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
3
5
 
4
6
  const router = express.Router();
5
7
 
@@ -175,4 +177,100 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
175
177
  }
176
178
  });
177
179
 
180
+ // ===============================
181
+ // Notification Preferences
182
+ // ===============================
183
+
184
+ router.get('/notification-preferences', async (req, res) => {
185
+ try {
186
+ const preferences = notificationPreferencesDb.getPreferences(req.user.id);
187
+ res.json({ success: true, preferences });
188
+ } catch (error) {
189
+ console.error('Error fetching notification preferences:', error);
190
+ res.status(500).json({ error: 'Failed to fetch notification preferences' });
191
+ }
192
+ });
193
+
194
+ router.put('/notification-preferences', async (req, res) => {
195
+ try {
196
+ const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
197
+ res.json({ success: true, preferences });
198
+ } catch (error) {
199
+ console.error('Error saving notification preferences:', error);
200
+ res.status(500).json({ error: 'Failed to save notification preferences' });
201
+ }
202
+ });
203
+
204
+ // ===============================
205
+ // Push Subscription Management
206
+ // ===============================
207
+
208
+ router.get('/push/vapid-public-key', async (req, res) => {
209
+ try {
210
+ const publicKey = getPublicKey();
211
+ res.json({ publicKey });
212
+ } catch (error) {
213
+ console.error('Error fetching VAPID public key:', error);
214
+ res.status(500).json({ error: 'Failed to fetch VAPID public key' });
215
+ }
216
+ });
217
+
218
+ router.post('/push/subscribe', async (req, res) => {
219
+ try {
220
+ const { endpoint, keys } = req.body;
221
+ if (!endpoint || !keys?.p256dh || !keys?.auth) {
222
+ return res.status(400).json({ error: 'Missing subscription fields' });
223
+ }
224
+ pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
225
+
226
+ // Enable webPush in preferences so the confirmation goes through the full pipeline
227
+ const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
228
+ if (!currentPrefs?.channels?.webPush) {
229
+ notificationPreferencesDb.updatePreferences(req.user.id, {
230
+ ...currentPrefs,
231
+ channels: { ...currentPrefs?.channels, webPush: true },
232
+ });
233
+ }
234
+
235
+ res.json({ success: true });
236
+
237
+ // Send a confirmation push through the full notification pipeline
238
+ const event = createNotificationEvent({
239
+ provider: 'system',
240
+ kind: 'info',
241
+ code: 'push.enabled',
242
+ meta: { message: 'Push notifications are now enabled!' },
243
+ severity: 'info'
244
+ });
245
+ notifyUserIfEnabled({ userId: req.user.id, event });
246
+ } catch (error) {
247
+ console.error('Error saving push subscription:', error);
248
+ res.status(500).json({ error: 'Failed to save push subscription' });
249
+ }
250
+ });
251
+
252
+ router.post('/push/unsubscribe', async (req, res) => {
253
+ try {
254
+ const { endpoint } = req.body;
255
+ if (!endpoint) {
256
+ return res.status(400).json({ error: 'Missing endpoint' });
257
+ }
258
+ pushSubscriptionsDb.removeSubscription(endpoint);
259
+
260
+ // Disable webPush in preferences to match subscription state
261
+ const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
262
+ if (currentPrefs?.channels?.webPush) {
263
+ notificationPreferencesDb.updatePreferences(req.user.id, {
264
+ ...currentPrefs,
265
+ channels: { ...currentPrefs.channels, webPush: false },
266
+ });
267
+ }
268
+
269
+ res.json({ success: true });
270
+ } catch (error) {
271
+ console.error('Error removing push subscription:', error);
272
+ res.status(500).json({ error: 'Failed to remove push subscription' });
273
+ }
274
+ });
275
+
178
276
  export default router;
@@ -529,7 +529,7 @@ router.get('/next/:projectName', async (req, res) => {
529
529
 
530
530
  // Fallback to loading tasks and finding next one locally
531
531
  // Use localhost to bypass proxy for internal server-to-server calls
532
- const tasksResponse = await fetch(`http://localhost:${process.env.PORT || 3001}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
532
+ const tasksResponse = await fetch(`http://localhost:${process.env.SERVER_PORT || process.env.PORT || '3001'}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
533
533
  headers: {
534
534
  'Authorization': req.headers.authorization
535
535
  }
@@ -1960,4 +1960,4 @@ Brief description of what this web application will do and why it's needed.
1960
1960
  ];
1961
1961
  }
1962
1962
 
1963
- export default router;
1963
+ export default router;