@siteboon/claude-code-ui 1.9.0 → 1.10.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.
@@ -4,6 +4,8 @@ import { promisify } from 'util';
4
4
  import path from 'path';
5
5
  import { promises as fs } from 'fs';
6
6
  import { extractProjectDirectory } from '../projects.js';
7
+ import { queryClaudeSDK } from '../claude-sdk.js';
8
+ import { spawnCursor } from '../cursor-cli.js';
7
9
 
8
10
  const router = express.Router();
9
11
  const execAsync = promisify(exec);
@@ -19,6 +21,35 @@ async function getActualProjectPath(projectName) {
19
21
  }
20
22
  }
21
23
 
24
+ // Helper function to strip git diff headers
25
+ function stripDiffHeaders(diff) {
26
+ if (!diff) return '';
27
+
28
+ const lines = diff.split('\n');
29
+ const filteredLines = [];
30
+ let startIncluding = false;
31
+
32
+ for (const line of lines) {
33
+ // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
34
+ if (line.startsWith('diff --git') ||
35
+ line.startsWith('index ') ||
36
+ line.startsWith('new file mode') ||
37
+ line.startsWith('deleted file mode') ||
38
+ line.startsWith('---') ||
39
+ line.startsWith('+++')) {
40
+ continue;
41
+ }
42
+
43
+ // Start including lines from @@ hunk headers onwards
44
+ if (line.startsWith('@@') || startIncluding) {
45
+ startIncluding = true;
46
+ filteredLines.push(line);
47
+ }
48
+ }
49
+
50
+ return filteredLines.join('\n');
51
+ }
52
+
22
53
  // Helper function to validate git repository
23
54
  async function validateGitRepository(projectPath) {
24
55
  try {
@@ -122,32 +153,39 @@ router.get('/diff', async (req, res) => {
122
153
  // Validate git repository
123
154
  await validateGitRepository(projectPath);
124
155
 
125
- // Check if file is untracked
156
+ // Check if file is untracked or deleted
126
157
  const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
127
158
  const isUntracked = statusOutput.startsWith('??');
128
-
159
+ const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
160
+
129
161
  let diff;
130
162
  if (isUntracked) {
131
163
  // For untracked files, show the entire file content as additions
132
164
  const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
133
165
  const lines = fileContent.split('\n');
134
- diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
166
+ diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
135
167
  lines.map(line => `+${line}`).join('\n');
168
+ } else if (isDeleted) {
169
+ // For deleted files, show the entire file content from HEAD as deletions
170
+ const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
171
+ const lines = fileContent.split('\n');
172
+ diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
173
+ lines.map(line => `-${line}`).join('\n');
136
174
  } else {
137
175
  // Get diff for tracked files
138
176
  // First check for unstaged changes (working tree vs index)
139
177
  const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
140
-
178
+
141
179
  if (unstagedDiff) {
142
180
  // Show unstaged changes if they exist
143
- diff = unstagedDiff;
181
+ diff = stripDiffHeaders(unstagedDiff);
144
182
  } else {
145
183
  // If no unstaged changes, check for staged changes (index vs HEAD)
146
184
  const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
147
- diff = stagedDiff || '';
185
+ diff = stripDiffHeaders(stagedDiff) || '';
148
186
  }
149
187
  }
150
-
188
+
151
189
  res.json({ diff });
152
190
  } catch (error) {
153
191
  console.error('Git diff error:', error);
@@ -155,6 +193,61 @@ router.get('/diff', async (req, res) => {
155
193
  }
156
194
  });
157
195
 
196
+ // Get file content with diff information for CodeEditor
197
+ router.get('/file-with-diff', async (req, res) => {
198
+ const { project, file } = req.query;
199
+
200
+ if (!project || !file) {
201
+ return res.status(400).json({ error: 'Project name and file path are required' });
202
+ }
203
+
204
+ try {
205
+ const projectPath = await getActualProjectPath(project);
206
+
207
+ // Validate git repository
208
+ await validateGitRepository(projectPath);
209
+
210
+ // Check file status
211
+ const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
212
+ const isUntracked = statusOutput.startsWith('??');
213
+ const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
214
+
215
+ let currentContent = '';
216
+ let oldContent = '';
217
+
218
+ if (isDeleted) {
219
+ // For deleted files, get content from HEAD
220
+ const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
221
+ oldContent = headContent;
222
+ currentContent = headContent; // Show the deleted content in editor
223
+ } else {
224
+ // Get current file content
225
+ currentContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
226
+
227
+ if (!isUntracked) {
228
+ // Get the old content from HEAD for tracked files
229
+ try {
230
+ const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
231
+ oldContent = headContent;
232
+ } catch (error) {
233
+ // File might be newly added to git (staged but not committed)
234
+ oldContent = '';
235
+ }
236
+ }
237
+ }
238
+
239
+ res.json({
240
+ currentContent,
241
+ oldContent,
242
+ isDeleted,
243
+ isUntracked
244
+ });
245
+ } catch (error) {
246
+ console.error('Git file-with-diff error:', error);
247
+ res.json({ error: error.message });
248
+ }
249
+ });
250
+
158
251
  // Commit changes
159
252
  router.post('/commit', async (req, res) => {
160
253
  const { project, message, files } = req.body;
@@ -343,19 +436,24 @@ router.get('/commit-diff', async (req, res) => {
343
436
  }
344
437
  });
345
438
 
346
- // Generate commit message based on staged changes
439
+ // Generate commit message based on staged changes using AI
347
440
  router.post('/generate-commit-message', async (req, res) => {
348
- const { project, files } = req.body;
349
-
441
+ const { project, files, provider = 'claude' } = req.body;
442
+
350
443
  if (!project || !files || files.length === 0) {
351
444
  return res.status(400).json({ error: 'Project name and files are required' });
352
445
  }
353
446
 
447
+ // Validate provider
448
+ if (!['claude', 'cursor'].includes(provider)) {
449
+ return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
450
+ }
451
+
354
452
  try {
355
453
  const projectPath = await getActualProjectPath(project);
356
-
454
+
357
455
  // Get diff for selected files
358
- let combinedDiff = '';
456
+ let diffContext = '';
359
457
  for (const file of files) {
360
458
  try {
361
459
  const { stdout } = await execAsync(
@@ -363,17 +461,30 @@ router.post('/generate-commit-message', async (req, res) => {
363
461
  { cwd: projectPath }
364
462
  );
365
463
  if (stdout) {
366
- combinedDiff += `\n--- ${file} ---\n${stdout}`;
464
+ diffContext += `\n--- ${file} ---\n${stdout}`;
367
465
  }
368
466
  } catch (error) {
369
467
  console.error(`Error getting diff for ${file}:`, error);
370
468
  }
371
469
  }
372
-
373
- // Use AI to generate commit message (simple implementation)
374
- // In a real implementation, you might want to use GPT or Claude API
375
- const message = generateSimpleCommitMessage(files, combinedDiff);
376
-
470
+
471
+ // If no diff found, might be untracked files
472
+ if (!diffContext.trim()) {
473
+ // Try to get content of untracked files
474
+ for (const file of files) {
475
+ try {
476
+ const filePath = path.join(projectPath, file);
477
+ const content = await fs.readFile(filePath, 'utf-8');
478
+ diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
479
+ } catch (error) {
480
+ console.error(`Error reading file ${file}:`, error);
481
+ }
482
+ }
483
+ }
484
+
485
+ // Generate commit message using AI
486
+ const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
487
+
377
488
  res.json({ message });
378
489
  } catch (error) {
379
490
  console.error('Generate commit message error:', error);
@@ -381,46 +492,145 @@ router.post('/generate-commit-message', async (req, res) => {
381
492
  }
382
493
  });
383
494
 
384
- // Simple commit message generator (can be replaced with AI)
385
- function generateSimpleCommitMessage(files, diff) {
386
- const fileCount = files.length;
387
- const isMultipleFiles = fileCount > 1;
388
-
389
- // Analyze the diff to determine the type of change
390
- const additions = (diff.match(/^\+[^+]/gm) || []).length;
391
- const deletions = (diff.match(/^-[^-]/gm) || []).length;
392
-
393
- // Determine the primary action
394
- let action = 'Update';
395
- if (additions > 0 && deletions === 0) {
396
- action = 'Add';
397
- } else if (deletions > 0 && additions === 0) {
398
- action = 'Remove';
399
- } else if (additions > deletions * 2) {
400
- action = 'Enhance';
401
- } else if (deletions > additions * 2) {
402
- action = 'Refactor';
403
- }
404
-
405
- // Generate message based on files
406
- if (isMultipleFiles) {
407
- const components = new Set(files.map(f => {
408
- const parts = f.split('/');
409
- return parts[parts.length - 2] || parts[0];
410
- }));
411
-
412
- if (components.size === 1) {
413
- return `${action} ${[...components][0]} component`;
414
- } else {
415
- return `${action} multiple components`;
495
+ /**
496
+ * Generates a commit message using AI (Claude SDK or Cursor CLI)
497
+ * @param {Array<string>} files - List of changed files
498
+ * @param {string} diffContext - Git diff content
499
+ * @param {string} provider - 'claude' or 'cursor'
500
+ * @param {string} projectPath - Project directory path
501
+ * @returns {Promise<string>} Generated commit message
502
+ */
503
+ async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
504
+ // Create the prompt
505
+ const prompt = `You are a git commit message generator. Based on the following file changes and diffs, generate a commit message in conventional commit format.
506
+
507
+ REQUIREMENTS:
508
+ - Use conventional commit format: type(scope): subject
509
+ - Include a body that explains what changed and why
510
+ - Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
511
+ - Keep subject line under 50 characters
512
+ - Wrap body at 72 characters
513
+ - Be specific and descriptive
514
+ - Return ONLY the commit message, nothing else - no markdown, no explanations, no code blocks
515
+
516
+ FILES CHANGED:
517
+ ${files.map(f => `- ${f}`).join('\n')}
518
+
519
+ DIFFS:
520
+ ${diffContext.substring(0, 4000)}
521
+
522
+ Generate the commit message now:`;
523
+
524
+ try {
525
+ // Create a simple writer that collects the response
526
+ let responseText = '';
527
+ const writer = {
528
+ send: (data) => {
529
+ try {
530
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
531
+ console.log('🔍 Writer received message type:', parsed.type);
532
+
533
+ // Handle different message formats from Claude SDK and Cursor CLI
534
+ // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
535
+ if (parsed.type === 'claude-response' && parsed.data) {
536
+ const message = parsed.data.message || parsed.data;
537
+ console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));
538
+ if (message.content && Array.isArray(message.content)) {
539
+ // Extract text from content array
540
+ for (const item of message.content) {
541
+ if (item.type === 'text' && item.text) {
542
+ console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
543
+ responseText += item.text;
544
+ }
545
+ }
546
+ }
547
+ }
548
+ // Cursor CLI sends: {type: 'cursor-output', output: '...'}
549
+ else if (parsed.type === 'cursor-output' && parsed.output) {
550
+ console.log('✅ Cursor output:', parsed.output.substring(0, 100));
551
+ responseText += parsed.output;
552
+ }
553
+ // Also handle direct text messages
554
+ else if (parsed.type === 'text' && parsed.text) {
555
+ console.log('✅ Direct text:', parsed.text.substring(0, 100));
556
+ responseText += parsed.text;
557
+ }
558
+ } catch (e) {
559
+ // Ignore parse errors
560
+ console.error('Error parsing writer data:', e);
561
+ }
562
+ },
563
+ setSessionId: () => {}, // No-op for this use case
564
+ };
565
+
566
+ console.log('🚀 Calling AI agent with provider:', provider);
567
+ console.log('📝 Prompt length:', prompt.length);
568
+
569
+ // Call the appropriate agent
570
+ if (provider === 'claude') {
571
+ await queryClaudeSDK(prompt, {
572
+ cwd: projectPath,
573
+ permissionMode: 'bypassPermissions',
574
+ model: 'sonnet'
575
+ }, writer);
576
+ } else if (provider === 'cursor') {
577
+ await spawnCursor(prompt, {
578
+ cwd: projectPath,
579
+ skipPermissions: true
580
+ }, writer);
416
581
  }
417
- } else {
418
- const fileName = files[0].split('/').pop();
419
- const componentName = fileName.replace(/\.(jsx?|tsx?|css|scss)$/, '');
420
- return `${action} ${componentName}`;
582
+
583
+ console.log('📊 Total response text collected:', responseText.length, 'characters');
584
+ console.log('📄 Response preview:', responseText.substring(0, 200));
585
+
586
+ // Clean up the response
587
+ const cleanedMessage = cleanCommitMessage(responseText);
588
+ console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
589
+
590
+ return cleanedMessage || 'chore: update files';
591
+ } catch (error) {
592
+ console.error('Error generating commit message with AI:', error);
593
+ // Fallback to simple message
594
+ return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
421
595
  }
422
596
  }
423
597
 
598
+ /**
599
+ * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
600
+ * @param {string} text - Raw AI response
601
+ * @returns {string} Clean commit message
602
+ */
603
+ function cleanCommitMessage(text) {
604
+ if (!text || !text.trim()) {
605
+ return '';
606
+ }
607
+
608
+ let cleaned = text.trim();
609
+
610
+ // Remove markdown code blocks
611
+ cleaned = cleaned.replace(/```[a-z]*\n/g, '');
612
+ cleaned = cleaned.replace(/```/g, '');
613
+
614
+ // Remove markdown headers
615
+ cleaned = cleaned.replace(/^#+\s*/gm, '');
616
+
617
+ // Remove leading/trailing quotes
618
+ cleaned = cleaned.replace(/^["']|["']$/g, '');
619
+
620
+ // If there are multiple lines, take everything (subject + body)
621
+ // Just clean up extra blank lines
622
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
623
+
624
+ // Remove any explanatory text before the actual commit message
625
+ // Look for conventional commit pattern and start from there
626
+ const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
627
+ if (conventionalCommitMatch) {
628
+ cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
629
+ }
630
+
631
+ return cleaned.trim();
632
+ }
633
+
424
634
  // Get remote status (ahead/behind commits with smart remote detection)
425
635
  router.get('/remote-status', async (req, res) => {
426
636
  const { project } = req.query;
@@ -0,0 +1,178 @@
1
+ import express from 'express';
2
+ import { apiKeysDb, credentialsDb } from '../database/db.js';
3
+
4
+ const router = express.Router();
5
+
6
+ // ===============================
7
+ // API Keys Management
8
+ // ===============================
9
+
10
+ // Get all API keys for the authenticated user
11
+ router.get('/api-keys', async (req, res) => {
12
+ try {
13
+ const apiKeys = apiKeysDb.getApiKeys(req.user.id);
14
+ // Don't send the full API key in the list for security
15
+ const sanitizedKeys = apiKeys.map(key => ({
16
+ ...key,
17
+ api_key: key.api_key.substring(0, 10) + '...'
18
+ }));
19
+ res.json({ apiKeys: sanitizedKeys });
20
+ } catch (error) {
21
+ console.error('Error fetching API keys:', error);
22
+ res.status(500).json({ error: 'Failed to fetch API keys' });
23
+ }
24
+ });
25
+
26
+ // Create a new API key
27
+ router.post('/api-keys', async (req, res) => {
28
+ try {
29
+ const { keyName } = req.body;
30
+
31
+ if (!keyName || !keyName.trim()) {
32
+ return res.status(400).json({ error: 'Key name is required' });
33
+ }
34
+
35
+ const result = apiKeysDb.createApiKey(req.user.id, keyName.trim());
36
+ res.json({
37
+ success: true,
38
+ apiKey: result
39
+ });
40
+ } catch (error) {
41
+ console.error('Error creating API key:', error);
42
+ res.status(500).json({ error: 'Failed to create API key' });
43
+ }
44
+ });
45
+
46
+ // Delete an API key
47
+ router.delete('/api-keys/:keyId', async (req, res) => {
48
+ try {
49
+ const { keyId } = req.params;
50
+ const success = apiKeysDb.deleteApiKey(req.user.id, parseInt(keyId));
51
+
52
+ if (success) {
53
+ res.json({ success: true });
54
+ } else {
55
+ res.status(404).json({ error: 'API key not found' });
56
+ }
57
+ } catch (error) {
58
+ console.error('Error deleting API key:', error);
59
+ res.status(500).json({ error: 'Failed to delete API key' });
60
+ }
61
+ });
62
+
63
+ // Toggle API key active status
64
+ router.patch('/api-keys/:keyId/toggle', async (req, res) => {
65
+ try {
66
+ const { keyId } = req.params;
67
+ const { isActive } = req.body;
68
+
69
+ if (typeof isActive !== 'boolean') {
70
+ return res.status(400).json({ error: 'isActive must be a boolean' });
71
+ }
72
+
73
+ const success = apiKeysDb.toggleApiKey(req.user.id, parseInt(keyId), isActive);
74
+
75
+ if (success) {
76
+ res.json({ success: true });
77
+ } else {
78
+ res.status(404).json({ error: 'API key not found' });
79
+ }
80
+ } catch (error) {
81
+ console.error('Error toggling API key:', error);
82
+ res.status(500).json({ error: 'Failed to toggle API key' });
83
+ }
84
+ });
85
+
86
+ // ===============================
87
+ // Generic Credentials Management
88
+ // ===============================
89
+
90
+ // Get all credentials for the authenticated user (optionally filtered by type)
91
+ router.get('/credentials', async (req, res) => {
92
+ try {
93
+ const { type } = req.query;
94
+ const credentials = credentialsDb.getCredentials(req.user.id, type || null);
95
+ // Don't send the actual credential values for security
96
+ res.json({ credentials });
97
+ } catch (error) {
98
+ console.error('Error fetching credentials:', error);
99
+ res.status(500).json({ error: 'Failed to fetch credentials' });
100
+ }
101
+ });
102
+
103
+ // Create a new credential
104
+ router.post('/credentials', async (req, res) => {
105
+ try {
106
+ const { credentialName, credentialType, credentialValue, description } = req.body;
107
+
108
+ if (!credentialName || !credentialName.trim()) {
109
+ return res.status(400).json({ error: 'Credential name is required' });
110
+ }
111
+
112
+ if (!credentialType || !credentialType.trim()) {
113
+ return res.status(400).json({ error: 'Credential type is required' });
114
+ }
115
+
116
+ if (!credentialValue || !credentialValue.trim()) {
117
+ return res.status(400).json({ error: 'Credential value is required' });
118
+ }
119
+
120
+ const result = credentialsDb.createCredential(
121
+ req.user.id,
122
+ credentialName.trim(),
123
+ credentialType.trim(),
124
+ credentialValue.trim(),
125
+ description?.trim() || null
126
+ );
127
+
128
+ res.json({
129
+ success: true,
130
+ credential: result
131
+ });
132
+ } catch (error) {
133
+ console.error('Error creating credential:', error);
134
+ res.status(500).json({ error: 'Failed to create credential' });
135
+ }
136
+ });
137
+
138
+ // Delete a credential
139
+ router.delete('/credentials/:credentialId', async (req, res) => {
140
+ try {
141
+ const { credentialId } = req.params;
142
+ const success = credentialsDb.deleteCredential(req.user.id, parseInt(credentialId));
143
+
144
+ if (success) {
145
+ res.json({ success: true });
146
+ } else {
147
+ res.status(404).json({ error: 'Credential not found' });
148
+ }
149
+ } catch (error) {
150
+ console.error('Error deleting credential:', error);
151
+ res.status(500).json({ error: 'Failed to delete credential' });
152
+ }
153
+ });
154
+
155
+ // Toggle credential active status
156
+ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
157
+ try {
158
+ const { credentialId } = req.params;
159
+ const { isActive } = req.body;
160
+
161
+ if (typeof isActive !== 'boolean') {
162
+ return res.status(400).json({ error: 'isActive must be a boolean' });
163
+ }
164
+
165
+ const success = credentialsDb.toggleCredential(req.user.id, parseInt(credentialId), isActive);
166
+
167
+ if (success) {
168
+ res.json({ success: true });
169
+ } else {
170
+ res.status(404).json({ error: 'Credential not found' });
171
+ }
172
+ } catch (error) {
173
+ console.error('Error toggling credential:', error);
174
+ res.status(500).json({ error: 'Failed to toggle credential' });
175
+ }
176
+ });
177
+
178
+ export default router;