@siteboon/claude-code-ui 1.8.2

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 (106) hide show
  1. package/.env.example +12 -0
  2. package/.nvmrc +1 -0
  3. package/LICENSE +675 -0
  4. package/README.md +275 -0
  5. package/index.html +48 -0
  6. package/package.json +84 -0
  7. package/postcss.config.js +6 -0
  8. package/public/convert-icons.md +53 -0
  9. package/public/favicon.png +0 -0
  10. package/public/favicon.svg +9 -0
  11. package/public/generate-icons.js +49 -0
  12. package/public/icons/claude-ai-icon.svg +1 -0
  13. package/public/icons/cursor.svg +1 -0
  14. package/public/icons/generate-icons.md +19 -0
  15. package/public/icons/icon-128x128.png +0 -0
  16. package/public/icons/icon-128x128.svg +12 -0
  17. package/public/icons/icon-144x144.png +0 -0
  18. package/public/icons/icon-144x144.svg +12 -0
  19. package/public/icons/icon-152x152.png +0 -0
  20. package/public/icons/icon-152x152.svg +12 -0
  21. package/public/icons/icon-192x192.png +0 -0
  22. package/public/icons/icon-192x192.svg +12 -0
  23. package/public/icons/icon-384x384.png +0 -0
  24. package/public/icons/icon-384x384.svg +12 -0
  25. package/public/icons/icon-512x512.png +0 -0
  26. package/public/icons/icon-512x512.svg +12 -0
  27. package/public/icons/icon-72x72.png +0 -0
  28. package/public/icons/icon-72x72.svg +12 -0
  29. package/public/icons/icon-96x96.png +0 -0
  30. package/public/icons/icon-96x96.svg +12 -0
  31. package/public/icons/icon-template.svg +12 -0
  32. package/public/logo.svg +9 -0
  33. package/public/manifest.json +61 -0
  34. package/public/screenshots/cli-selection.png +0 -0
  35. package/public/screenshots/desktop-main.png +0 -0
  36. package/public/screenshots/mobile-chat.png +0 -0
  37. package/public/screenshots/tools-modal.png +0 -0
  38. package/public/sw.js +49 -0
  39. package/server/claude-cli.js +391 -0
  40. package/server/cursor-cli.js +250 -0
  41. package/server/database/db.js +86 -0
  42. package/server/database/init.sql +16 -0
  43. package/server/index.js +1167 -0
  44. package/server/middleware/auth.js +80 -0
  45. package/server/projects.js +1063 -0
  46. package/server/routes/auth.js +135 -0
  47. package/server/routes/cursor.js +794 -0
  48. package/server/routes/git.js +823 -0
  49. package/server/routes/mcp-utils.js +48 -0
  50. package/server/routes/mcp.js +552 -0
  51. package/server/routes/taskmaster.js +1971 -0
  52. package/server/utils/mcp-detector.js +198 -0
  53. package/server/utils/taskmaster-websocket.js +129 -0
  54. package/src/App.jsx +751 -0
  55. package/src/components/ChatInterface.jsx +3485 -0
  56. package/src/components/ClaudeLogo.jsx +11 -0
  57. package/src/components/ClaudeStatus.jsx +107 -0
  58. package/src/components/CodeEditor.jsx +422 -0
  59. package/src/components/CreateTaskModal.jsx +88 -0
  60. package/src/components/CursorLogo.jsx +9 -0
  61. package/src/components/DarkModeToggle.jsx +35 -0
  62. package/src/components/DiffViewer.jsx +41 -0
  63. package/src/components/ErrorBoundary.jsx +73 -0
  64. package/src/components/FileTree.jsx +480 -0
  65. package/src/components/GitPanel.jsx +1283 -0
  66. package/src/components/ImageViewer.jsx +54 -0
  67. package/src/components/LoginForm.jsx +110 -0
  68. package/src/components/MainContent.jsx +577 -0
  69. package/src/components/MicButton.jsx +272 -0
  70. package/src/components/MobileNav.jsx +88 -0
  71. package/src/components/NextTaskBanner.jsx +695 -0
  72. package/src/components/PRDEditor.jsx +871 -0
  73. package/src/components/ProtectedRoute.jsx +44 -0
  74. package/src/components/QuickSettingsPanel.jsx +262 -0
  75. package/src/components/Settings.jsx +2023 -0
  76. package/src/components/SetupForm.jsx +135 -0
  77. package/src/components/Shell.jsx +663 -0
  78. package/src/components/Sidebar.jsx +1665 -0
  79. package/src/components/StandaloneShell.jsx +106 -0
  80. package/src/components/TaskCard.jsx +210 -0
  81. package/src/components/TaskDetail.jsx +406 -0
  82. package/src/components/TaskIndicator.jsx +108 -0
  83. package/src/components/TaskList.jsx +1054 -0
  84. package/src/components/TaskMasterSetupWizard.jsx +603 -0
  85. package/src/components/TaskMasterStatus.jsx +86 -0
  86. package/src/components/TodoList.jsx +91 -0
  87. package/src/components/Tooltip.jsx +91 -0
  88. package/src/components/ui/badge.jsx +31 -0
  89. package/src/components/ui/button.jsx +46 -0
  90. package/src/components/ui/input.jsx +19 -0
  91. package/src/components/ui/scroll-area.jsx +23 -0
  92. package/src/contexts/AuthContext.jsx +158 -0
  93. package/src/contexts/TaskMasterContext.jsx +324 -0
  94. package/src/contexts/TasksSettingsContext.jsx +95 -0
  95. package/src/contexts/ThemeContext.jsx +94 -0
  96. package/src/contexts/WebSocketContext.jsx +29 -0
  97. package/src/hooks/useAudioRecorder.js +109 -0
  98. package/src/hooks/useVersionCheck.js +39 -0
  99. package/src/index.css +822 -0
  100. package/src/lib/utils.js +6 -0
  101. package/src/main.jsx +10 -0
  102. package/src/utils/api.js +141 -0
  103. package/src/utils/websocket.js +109 -0
  104. package/src/utils/whisper.js +37 -0
  105. package/tailwind.config.js +63 -0
  106. package/vite.config.js +29 -0
@@ -0,0 +1,1063 @@
1
+ /**
2
+ * PROJECT DISCOVERY AND MANAGEMENT SYSTEM
3
+ * ========================================
4
+ *
5
+ * This module manages project discovery for both Claude CLI and Cursor CLI sessions.
6
+ *
7
+ * ## Architecture Overview
8
+ *
9
+ * 1. **Claude Projects** (stored in ~/.claude/projects/)
10
+ * - Each project is a directory named with the project path encoded (/ replaced with -)
11
+ * - Contains .jsonl files with conversation history including 'cwd' field
12
+ * - Project metadata stored in ~/.claude/project-config.json
13
+ *
14
+ * 2. **Cursor Projects** (stored in ~/.cursor/chats/)
15
+ * - Each project directory is named with MD5 hash of the absolute project path
16
+ * - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6...
17
+ * - Contains session directories with SQLite databases (store.db)
18
+ * - Project path is NOT stored in the database - only in the MD5 hash
19
+ *
20
+ * ## Project Discovery Strategy
21
+ *
22
+ * 1. **Claude Projects Discovery**:
23
+ * - Scan ~/.claude/projects/ directory for Claude project folders
24
+ * - Extract actual project path from .jsonl files (cwd field)
25
+ * - Fall back to decoded directory name if no sessions exist
26
+ *
27
+ * 2. **Cursor Sessions Discovery**:
28
+ * - For each KNOWN project (from Claude or manually added)
29
+ * - Compute MD5 hash of the project's absolute path
30
+ * - Check if ~/.cursor/chats/{md5_hash}/ directory exists
31
+ * - Read session metadata from SQLite store.db files
32
+ *
33
+ * 3. **Manual Project Addition**:
34
+ * - Users can manually add project paths via UI
35
+ * - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag
36
+ * - Allows discovering Cursor sessions for projects without Claude sessions
37
+ *
38
+ * ## Critical Limitations
39
+ *
40
+ * - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of
41
+ * the cwd of each project. if someone has the time, you can try to reverse engineer it.
42
+ *
43
+ * - **Project relocation breaks history**: If a project directory is moved or renamed,
44
+ * the MD5 hash changes, making old Cursor sessions inaccessible unless the old
45
+ * path is known and manually added.
46
+ *
47
+ * ## Error Handling
48
+ *
49
+ * - Missing ~/.claude directory is handled gracefully with automatic creation
50
+ * - ENOENT errors are caught and handled without crashing
51
+ * - Empty arrays returned when no projects/sessions exist
52
+ *
53
+ * ## Caching Strategy
54
+ *
55
+ * - Project directory extraction is cached to minimize file I/O
56
+ * - Cache is cleared when project configuration changes
57
+ * - Session data is fetched on-demand, not cached
58
+ */
59
+
60
+ import { promises as fs } from 'fs';
61
+ import fsSync from 'fs';
62
+ import path from 'path';
63
+ import readline from 'readline';
64
+ import crypto from 'crypto';
65
+ import sqlite3 from 'sqlite3';
66
+ import { open } from 'sqlite';
67
+ import os from 'os';
68
+
69
+ // Import TaskMaster detection functions
70
+ async function detectTaskMasterFolder(projectPath) {
71
+ try {
72
+ const taskMasterPath = path.join(projectPath, '.taskmaster');
73
+
74
+ // Check if .taskmaster directory exists
75
+ try {
76
+ const stats = await fs.stat(taskMasterPath);
77
+ if (!stats.isDirectory()) {
78
+ return {
79
+ hasTaskmaster: false,
80
+ reason: '.taskmaster exists but is not a directory'
81
+ };
82
+ }
83
+ } catch (error) {
84
+ if (error.code === 'ENOENT') {
85
+ return {
86
+ hasTaskmaster: false,
87
+ reason: '.taskmaster directory not found'
88
+ };
89
+ }
90
+ throw error;
91
+ }
92
+
93
+ // Check for key TaskMaster files
94
+ const keyFiles = [
95
+ 'tasks/tasks.json',
96
+ 'config.json'
97
+ ];
98
+
99
+ const fileStatus = {};
100
+ let hasEssentialFiles = true;
101
+
102
+ for (const file of keyFiles) {
103
+ const filePath = path.join(taskMasterPath, file);
104
+ try {
105
+ await fs.access(filePath);
106
+ fileStatus[file] = true;
107
+ } catch (error) {
108
+ fileStatus[file] = false;
109
+ if (file === 'tasks/tasks.json') {
110
+ hasEssentialFiles = false;
111
+ }
112
+ }
113
+ }
114
+
115
+ // Parse tasks.json if it exists for metadata
116
+ let taskMetadata = null;
117
+ if (fileStatus['tasks/tasks.json']) {
118
+ try {
119
+ const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
120
+ const tasksContent = await fs.readFile(tasksPath, 'utf8');
121
+ const tasksData = JSON.parse(tasksContent);
122
+
123
+ // Handle both tagged and legacy formats
124
+ let tasks = [];
125
+ if (tasksData.tasks) {
126
+ // Legacy format
127
+ tasks = tasksData.tasks;
128
+ } else {
129
+ // Tagged format - get tasks from all tags
130
+ Object.values(tasksData).forEach(tagData => {
131
+ if (tagData.tasks) {
132
+ tasks = tasks.concat(tagData.tasks);
133
+ }
134
+ });
135
+ }
136
+
137
+ // Calculate task statistics
138
+ const stats = tasks.reduce((acc, task) => {
139
+ acc.total++;
140
+ acc[task.status] = (acc[task.status] || 0) + 1;
141
+
142
+ // Count subtasks
143
+ if (task.subtasks) {
144
+ task.subtasks.forEach(subtask => {
145
+ acc.subtotalTasks++;
146
+ acc.subtasks = acc.subtasks || {};
147
+ acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
148
+ });
149
+ }
150
+
151
+ return acc;
152
+ }, {
153
+ total: 0,
154
+ subtotalTasks: 0,
155
+ pending: 0,
156
+ 'in-progress': 0,
157
+ done: 0,
158
+ review: 0,
159
+ deferred: 0,
160
+ cancelled: 0,
161
+ subtasks: {}
162
+ });
163
+
164
+ taskMetadata = {
165
+ taskCount: stats.total,
166
+ subtaskCount: stats.subtotalTasks,
167
+ completed: stats.done || 0,
168
+ pending: stats.pending || 0,
169
+ inProgress: stats['in-progress'] || 0,
170
+ review: stats.review || 0,
171
+ completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
172
+ lastModified: (await fs.stat(tasksPath)).mtime.toISOString()
173
+ };
174
+ } catch (parseError) {
175
+ console.warn('Failed to parse tasks.json:', parseError.message);
176
+ taskMetadata = { error: 'Failed to parse tasks.json' };
177
+ }
178
+ }
179
+
180
+ return {
181
+ hasTaskmaster: true,
182
+ hasEssentialFiles,
183
+ files: fileStatus,
184
+ metadata: taskMetadata,
185
+ path: taskMasterPath
186
+ };
187
+
188
+ } catch (error) {
189
+ console.error('Error detecting TaskMaster folder:', error);
190
+ return {
191
+ hasTaskmaster: false,
192
+ reason: `Error checking directory: ${error.message}`
193
+ };
194
+ }
195
+ }
196
+
197
+ // Cache for extracted project directories
198
+ const projectDirectoryCache = new Map();
199
+
200
+ // Clear cache when needed (called when project files change)
201
+ function clearProjectDirectoryCache() {
202
+ projectDirectoryCache.clear();
203
+ }
204
+
205
+ // Load project configuration file
206
+ async function loadProjectConfig() {
207
+ const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
208
+ try {
209
+ const configData = await fs.readFile(configPath, 'utf8');
210
+ return JSON.parse(configData);
211
+ } catch (error) {
212
+ // Return empty config if file doesn't exist
213
+ return {};
214
+ }
215
+ }
216
+
217
+ // Save project configuration file
218
+ async function saveProjectConfig(config) {
219
+ const claudeDir = path.join(process.env.HOME, '.claude');
220
+ const configPath = path.join(claudeDir, 'project-config.json');
221
+
222
+ // Ensure the .claude directory exists
223
+ try {
224
+ await fs.mkdir(claudeDir, { recursive: true });
225
+ } catch (error) {
226
+ if (error.code !== 'EEXIST') {
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
232
+ }
233
+
234
+ // Generate better display name from path
235
+ async function generateDisplayName(projectName, actualProjectDir = null) {
236
+ // Use actual project directory if provided, otherwise decode from project name
237
+ let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
238
+
239
+ // Try to read package.json from the project path
240
+ try {
241
+ const packageJsonPath = path.join(projectPath, 'package.json');
242
+ const packageData = await fs.readFile(packageJsonPath, 'utf8');
243
+ const packageJson = JSON.parse(packageData);
244
+
245
+ // Return the name from package.json if it exists
246
+ if (packageJson.name) {
247
+ return packageJson.name;
248
+ }
249
+ } catch (error) {
250
+ // Fall back to path-based naming if package.json doesn't exist or can't be read
251
+ }
252
+
253
+ // If it starts with /, it's an absolute path
254
+ if (projectPath.startsWith('/')) {
255
+ const parts = projectPath.split('/').filter(Boolean);
256
+ // Return only the last folder name
257
+ return parts[parts.length - 1] || projectPath;
258
+ }
259
+
260
+ return projectPath;
261
+ }
262
+
263
+ // Extract the actual project directory from JSONL sessions (with caching)
264
+ async function extractProjectDirectory(projectName) {
265
+ // Check cache first
266
+ if (projectDirectoryCache.has(projectName)) {
267
+ return projectDirectoryCache.get(projectName);
268
+ }
269
+
270
+
271
+ const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
272
+ const cwdCounts = new Map();
273
+ let latestTimestamp = 0;
274
+ let latestCwd = null;
275
+ let extractedPath;
276
+
277
+ try {
278
+ // Check if the project directory exists
279
+ await fs.access(projectDir);
280
+
281
+ const files = await fs.readdir(projectDir);
282
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
283
+
284
+ if (jsonlFiles.length === 0) {
285
+ // Fall back to decoded project name if no sessions
286
+ extractedPath = projectName.replace(/-/g, '/');
287
+ } else {
288
+ // Process all JSONL files to collect cwd values
289
+ for (const file of jsonlFiles) {
290
+ const jsonlFile = path.join(projectDir, file);
291
+ const fileStream = fsSync.createReadStream(jsonlFile);
292
+ const rl = readline.createInterface({
293
+ input: fileStream,
294
+ crlfDelay: Infinity
295
+ });
296
+
297
+ for await (const line of rl) {
298
+ if (line.trim()) {
299
+ try {
300
+ const entry = JSON.parse(line);
301
+
302
+ if (entry.cwd) {
303
+ // Count occurrences of each cwd
304
+ cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
305
+
306
+ // Track the most recent cwd
307
+ const timestamp = new Date(entry.timestamp || 0).getTime();
308
+ if (timestamp > latestTimestamp) {
309
+ latestTimestamp = timestamp;
310
+ latestCwd = entry.cwd;
311
+ }
312
+ }
313
+ } catch (parseError) {
314
+ // Skip malformed lines
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ // Determine the best cwd to use
321
+ if (cwdCounts.size === 0) {
322
+ // No cwd found, fall back to decoded project name
323
+ extractedPath = projectName.replace(/-/g, '/');
324
+ } else if (cwdCounts.size === 1) {
325
+ // Only one cwd, use it
326
+ extractedPath = Array.from(cwdCounts.keys())[0];
327
+ } else {
328
+ // Multiple cwd values - prefer the most recent one if it has reasonable usage
329
+ const mostRecentCount = cwdCounts.get(latestCwd) || 0;
330
+ const maxCount = Math.max(...cwdCounts.values());
331
+
332
+ // Use most recent if it has at least 25% of the max count
333
+ if (mostRecentCount >= maxCount * 0.25) {
334
+ extractedPath = latestCwd;
335
+ } else {
336
+ // Otherwise use the most frequently used cwd
337
+ for (const [cwd, count] of cwdCounts.entries()) {
338
+ if (count === maxCount) {
339
+ extractedPath = cwd;
340
+ break;
341
+ }
342
+ }
343
+ }
344
+
345
+ // Fallback (shouldn't reach here)
346
+ if (!extractedPath) {
347
+ extractedPath = latestCwd || projectName.replace(/-/g, '/');
348
+ }
349
+ }
350
+ }
351
+
352
+ // Cache the result
353
+ projectDirectoryCache.set(projectName, extractedPath);
354
+
355
+ return extractedPath;
356
+
357
+ } catch (error) {
358
+ // If the directory doesn't exist, just use the decoded project name
359
+ if (error.code === 'ENOENT') {
360
+ extractedPath = projectName.replace(/-/g, '/');
361
+ } else {
362
+ console.error(`Error extracting project directory for ${projectName}:`, error);
363
+ // Fall back to decoded project name for other errors
364
+ extractedPath = projectName.replace(/-/g, '/');
365
+ }
366
+
367
+ // Cache the fallback result too
368
+ projectDirectoryCache.set(projectName, extractedPath);
369
+
370
+ return extractedPath;
371
+ }
372
+ }
373
+
374
+ async function getProjects() {
375
+ const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
376
+ const config = await loadProjectConfig();
377
+ const projects = [];
378
+ const existingProjects = new Set();
379
+
380
+ try {
381
+ // Check if the .claude/projects directory exists
382
+ await fs.access(claudeDir);
383
+
384
+ // First, get existing Claude projects from the file system
385
+ const entries = await fs.readdir(claudeDir, { withFileTypes: true });
386
+
387
+ for (const entry of entries) {
388
+ if (entry.isDirectory()) {
389
+ existingProjects.add(entry.name);
390
+ const projectPath = path.join(claudeDir, entry.name);
391
+
392
+ // Extract actual project directory from JSONL sessions
393
+ const actualProjectDir = await extractProjectDirectory(entry.name);
394
+
395
+ // Get display name from config or generate one
396
+ const customName = config[entry.name]?.displayName;
397
+ const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
398
+ const fullPath = actualProjectDir;
399
+
400
+ const project = {
401
+ name: entry.name,
402
+ path: actualProjectDir,
403
+ displayName: customName || autoDisplayName,
404
+ fullPath: fullPath,
405
+ isCustomName: !!customName,
406
+ sessions: []
407
+ };
408
+
409
+ // Try to get sessions for this project (just first 5 for performance)
410
+ try {
411
+ const sessionResult = await getSessions(entry.name, 5, 0);
412
+ project.sessions = sessionResult.sessions || [];
413
+ project.sessionMeta = {
414
+ hasMore: sessionResult.hasMore,
415
+ total: sessionResult.total
416
+ };
417
+ } catch (e) {
418
+ console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
419
+ }
420
+
421
+ // Also fetch Cursor sessions for this project
422
+ try {
423
+ project.cursorSessions = await getCursorSessions(actualProjectDir);
424
+ } catch (e) {
425
+ console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
426
+ project.cursorSessions = [];
427
+ }
428
+
429
+ // Add TaskMaster detection
430
+ try {
431
+ const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
432
+ project.taskmaster = {
433
+ hasTaskmaster: taskMasterResult.hasTaskmaster,
434
+ hasEssentialFiles: taskMasterResult.hasEssentialFiles,
435
+ metadata: taskMasterResult.metadata,
436
+ status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured'
437
+ };
438
+ } catch (e) {
439
+ console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message);
440
+ project.taskmaster = {
441
+ hasTaskmaster: false,
442
+ hasEssentialFiles: false,
443
+ metadata: null,
444
+ status: 'error'
445
+ };
446
+ }
447
+
448
+ projects.push(project);
449
+ }
450
+ }
451
+ } catch (error) {
452
+ // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
453
+ if (error.code !== 'ENOENT') {
454
+ console.error('Error reading projects directory:', error);
455
+ }
456
+ }
457
+
458
+ // Add manually configured projects that don't exist as folders yet
459
+ for (const [projectName, projectConfig] of Object.entries(config)) {
460
+ if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
461
+ // Use the original path if available, otherwise extract from potential sessions
462
+ let actualProjectDir = projectConfig.originalPath;
463
+
464
+ if (!actualProjectDir) {
465
+ try {
466
+ actualProjectDir = await extractProjectDirectory(projectName);
467
+ } catch (error) {
468
+ // Fall back to decoded project name
469
+ actualProjectDir = projectName.replace(/-/g, '/');
470
+ }
471
+ }
472
+
473
+ const project = {
474
+ name: projectName,
475
+ path: actualProjectDir,
476
+ displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
477
+ fullPath: actualProjectDir,
478
+ isCustomName: !!projectConfig.displayName,
479
+ isManuallyAdded: true,
480
+ sessions: [],
481
+ cursorSessions: []
482
+ };
483
+
484
+ // Try to fetch Cursor sessions for manual projects too
485
+ try {
486
+ project.cursorSessions = await getCursorSessions(actualProjectDir);
487
+ } catch (e) {
488
+ console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
489
+ }
490
+
491
+ // Add TaskMaster detection for manual projects
492
+ try {
493
+ const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
494
+
495
+ // Determine TaskMaster status
496
+ let taskMasterStatus = 'not-configured';
497
+ if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
498
+ taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk
499
+ }
500
+
501
+ project.taskmaster = {
502
+ status: taskMasterStatus,
503
+ hasTaskmaster: taskMasterResult.hasTaskmaster,
504
+ hasEssentialFiles: taskMasterResult.hasEssentialFiles,
505
+ metadata: taskMasterResult.metadata
506
+ };
507
+ } catch (error) {
508
+ console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message);
509
+ project.taskmaster = {
510
+ status: 'error',
511
+ hasTaskmaster: false,
512
+ hasEssentialFiles: false,
513
+ error: error.message
514
+ };
515
+ }
516
+
517
+ projects.push(project);
518
+ }
519
+ }
520
+
521
+ return projects;
522
+ }
523
+
524
+ async function getSessions(projectName, limit = 5, offset = 0) {
525
+ const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
526
+
527
+ try {
528
+ const files = await fs.readdir(projectDir);
529
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
530
+
531
+ if (jsonlFiles.length === 0) {
532
+ return { sessions: [], hasMore: false, total: 0 };
533
+ }
534
+
535
+ // Sort files by modification time (newest first)
536
+ const filesWithStats = await Promise.all(
537
+ jsonlFiles.map(async (file) => {
538
+ const filePath = path.join(projectDir, file);
539
+ const stats = await fs.stat(filePath);
540
+ return { file, mtime: stats.mtime };
541
+ })
542
+ );
543
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
544
+
545
+ const allSessions = new Map();
546
+ const allEntries = [];
547
+ const uuidToSessionMap = new Map();
548
+
549
+ // Collect all sessions and entries from all files
550
+ for (const { file } of filesWithStats) {
551
+ const jsonlFile = path.join(projectDir, file);
552
+ const result = await parseJsonlSessions(jsonlFile);
553
+
554
+ result.sessions.forEach(session => {
555
+ if (!allSessions.has(session.id)) {
556
+ allSessions.set(session.id, session);
557
+ }
558
+ });
559
+
560
+ allEntries.push(...result.entries);
561
+
562
+ // Early exit optimization for large projects
563
+ if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
564
+ break;
565
+ }
566
+ }
567
+
568
+ // Build UUID-to-session mapping for timeline detection
569
+ allEntries.forEach(entry => {
570
+ if (entry.uuid && entry.sessionId) {
571
+ uuidToSessionMap.set(entry.uuid, entry.sessionId);
572
+ }
573
+ });
574
+
575
+ // Group sessions by first user message ID
576
+ const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
577
+ const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
578
+
579
+ // Find the first user message for each session
580
+ allEntries.forEach(entry => {
581
+ if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
582
+ // This is a first user message in a session (parentUuid is null)
583
+ const firstUserMsgId = entry.uuid;
584
+
585
+ if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
586
+ sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
587
+
588
+ const session = allSessions.get(entry.sessionId);
589
+ if (session) {
590
+ if (!sessionGroups.has(firstUserMsgId)) {
591
+ sessionGroups.set(firstUserMsgId, {
592
+ latestSession: session,
593
+ allSessions: [session]
594
+ });
595
+ } else {
596
+ const group = sessionGroups.get(firstUserMsgId);
597
+ group.allSessions.push(session);
598
+
599
+ // Update latest session if this one is more recent
600
+ if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
601
+ group.latestSession = session;
602
+ }
603
+ }
604
+ }
605
+ }
606
+ }
607
+ });
608
+
609
+ // Collect all sessions that don't belong to any group (standalone sessions)
610
+ const groupedSessionIds = new Set();
611
+ sessionGroups.forEach(group => {
612
+ group.allSessions.forEach(session => groupedSessionIds.add(session.id));
613
+ });
614
+
615
+ const standaloneSessionsArray = Array.from(allSessions.values())
616
+ .filter(session => !groupedSessionIds.has(session.id));
617
+
618
+ // Combine grouped sessions (only show latest from each group) + standalone sessions
619
+ const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
620
+ const session = { ...group.latestSession };
621
+ // Add metadata about grouping
622
+ if (group.allSessions.length > 1) {
623
+ session.isGrouped = true;
624
+ session.groupSize = group.allSessions.length;
625
+ session.groupSessions = group.allSessions.map(s => s.id);
626
+ }
627
+ return session;
628
+ });
629
+ const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
630
+ .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
631
+
632
+ const total = visibleSessions.length;
633
+ const paginatedSessions = visibleSessions.slice(offset, offset + limit);
634
+ const hasMore = offset + limit < total;
635
+
636
+ return {
637
+ sessions: paginatedSessions,
638
+ hasMore,
639
+ total,
640
+ offset,
641
+ limit
642
+ };
643
+ } catch (error) {
644
+ console.error(`Error reading sessions for project ${projectName}:`, error);
645
+ return { sessions: [], hasMore: false, total: 0 };
646
+ }
647
+ }
648
+
649
+ async function parseJsonlSessions(filePath) {
650
+ const sessions = new Map();
651
+ const entries = [];
652
+
653
+ try {
654
+ const fileStream = fsSync.createReadStream(filePath);
655
+ const rl = readline.createInterface({
656
+ input: fileStream,
657
+ crlfDelay: Infinity
658
+ });
659
+
660
+ for await (const line of rl) {
661
+ if (line.trim()) {
662
+ try {
663
+ const entry = JSON.parse(line);
664
+ entries.push(entry);
665
+
666
+ if (entry.sessionId) {
667
+ if (!sessions.has(entry.sessionId)) {
668
+ sessions.set(entry.sessionId, {
669
+ id: entry.sessionId,
670
+ summary: 'New Session',
671
+ messageCount: 0,
672
+ lastActivity: new Date(),
673
+ cwd: entry.cwd || ''
674
+ });
675
+ }
676
+
677
+ const session = sessions.get(entry.sessionId);
678
+
679
+ // Update summary from summary entries or first user message
680
+ if (entry.type === 'summary' && entry.summary) {
681
+ session.summary = entry.summary;
682
+ } else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
683
+ const content = entry.message.content;
684
+ if (typeof content === 'string' && content.length > 0 && !content.startsWith('<command-name>')) {
685
+ session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
686
+ }
687
+ }
688
+
689
+ session.messageCount++;
690
+
691
+ if (entry.timestamp) {
692
+ session.lastActivity = new Date(entry.timestamp);
693
+ }
694
+ }
695
+ } catch (parseError) {
696
+ // Skip malformed lines silently
697
+ }
698
+ }
699
+ }
700
+
701
+ return {
702
+ sessions: Array.from(sessions.values()),
703
+ entries: entries
704
+ };
705
+
706
+ } catch (error) {
707
+ console.error('Error reading JSONL file:', error);
708
+ return { sessions: [], entries: [] };
709
+ }
710
+ }
711
+
712
+ // Get messages for a specific session with pagination support
713
+ async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
714
+ const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
715
+
716
+ try {
717
+ const files = await fs.readdir(projectDir);
718
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
719
+
720
+ if (jsonlFiles.length === 0) {
721
+ return { messages: [], total: 0, hasMore: false };
722
+ }
723
+
724
+ const messages = [];
725
+
726
+ // Process all JSONL files to find messages for this session
727
+ for (const file of jsonlFiles) {
728
+ const jsonlFile = path.join(projectDir, file);
729
+ const fileStream = fsSync.createReadStream(jsonlFile);
730
+ const rl = readline.createInterface({
731
+ input: fileStream,
732
+ crlfDelay: Infinity
733
+ });
734
+
735
+ for await (const line of rl) {
736
+ if (line.trim()) {
737
+ try {
738
+ const entry = JSON.parse(line);
739
+ if (entry.sessionId === sessionId) {
740
+ messages.push(entry);
741
+ }
742
+ } catch (parseError) {
743
+ console.warn('Error parsing line:', parseError.message);
744
+ }
745
+ }
746
+ }
747
+ }
748
+
749
+ // Sort messages by timestamp
750
+ const sortedMessages = messages.sort((a, b) =>
751
+ new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
752
+ );
753
+
754
+ const total = sortedMessages.length;
755
+
756
+ // If no limit is specified, return all messages (backward compatibility)
757
+ if (limit === null) {
758
+ return sortedMessages;
759
+ }
760
+
761
+ // Apply pagination - for recent messages, we need to slice from the end
762
+ // offset 0 should give us the most recent messages
763
+ const startIndex = Math.max(0, total - offset - limit);
764
+ const endIndex = total - offset;
765
+ const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
766
+ const hasMore = startIndex > 0;
767
+
768
+ return {
769
+ messages: paginatedMessages,
770
+ total,
771
+ hasMore,
772
+ offset,
773
+ limit
774
+ };
775
+ } catch (error) {
776
+ console.error(`Error reading messages for session ${sessionId}:`, error);
777
+ return limit === null ? [] : { messages: [], total: 0, hasMore: false };
778
+ }
779
+ }
780
+
781
+ // Rename a project's display name
782
+ async function renameProject(projectName, newDisplayName) {
783
+ const config = await loadProjectConfig();
784
+
785
+ if (!newDisplayName || newDisplayName.trim() === '') {
786
+ // Remove custom name if empty, will fall back to auto-generated
787
+ delete config[projectName];
788
+ } else {
789
+ // Set custom display name
790
+ config[projectName] = {
791
+ displayName: newDisplayName.trim()
792
+ };
793
+ }
794
+
795
+ await saveProjectConfig(config);
796
+ return true;
797
+ }
798
+
799
+ // Delete a session from a project
800
+ async function deleteSession(projectName, sessionId) {
801
+ const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
802
+
803
+ try {
804
+ const files = await fs.readdir(projectDir);
805
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
806
+
807
+ if (jsonlFiles.length === 0) {
808
+ throw new Error('No session files found for this project');
809
+ }
810
+
811
+ // Check all JSONL files to find which one contains the session
812
+ for (const file of jsonlFiles) {
813
+ const jsonlFile = path.join(projectDir, file);
814
+ const content = await fs.readFile(jsonlFile, 'utf8');
815
+ const lines = content.split('\n').filter(line => line.trim());
816
+
817
+ // Check if this file contains the session
818
+ const hasSession = lines.some(line => {
819
+ try {
820
+ const data = JSON.parse(line);
821
+ return data.sessionId === sessionId;
822
+ } catch {
823
+ return false;
824
+ }
825
+ });
826
+
827
+ if (hasSession) {
828
+ // Filter out all entries for this session
829
+ const filteredLines = lines.filter(line => {
830
+ try {
831
+ const data = JSON.parse(line);
832
+ return data.sessionId !== sessionId;
833
+ } catch {
834
+ return true; // Keep malformed lines
835
+ }
836
+ });
837
+
838
+ // Write back the filtered content
839
+ await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
840
+ return true;
841
+ }
842
+ }
843
+
844
+ throw new Error(`Session ${sessionId} not found in any files`);
845
+ } catch (error) {
846
+ console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
847
+ throw error;
848
+ }
849
+ }
850
+
851
+ // Check if a project is empty (has no sessions)
852
+ async function isProjectEmpty(projectName) {
853
+ try {
854
+ const sessionsResult = await getSessions(projectName, 1, 0);
855
+ return sessionsResult.total === 0;
856
+ } catch (error) {
857
+ console.error(`Error checking if project ${projectName} is empty:`, error);
858
+ return false;
859
+ }
860
+ }
861
+
862
+ // Delete an empty project
863
+ async function deleteProject(projectName) {
864
+ const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
865
+
866
+ try {
867
+ // First check if the project is empty
868
+ const isEmpty = await isProjectEmpty(projectName);
869
+ if (!isEmpty) {
870
+ throw new Error('Cannot delete project with existing sessions');
871
+ }
872
+
873
+ // Remove the project directory
874
+ await fs.rm(projectDir, { recursive: true, force: true });
875
+
876
+ // Remove from project config
877
+ const config = await loadProjectConfig();
878
+ delete config[projectName];
879
+ await saveProjectConfig(config);
880
+
881
+ return true;
882
+ } catch (error) {
883
+ console.error(`Error deleting project ${projectName}:`, error);
884
+ throw error;
885
+ }
886
+ }
887
+
888
+ // Add a project manually to the config (without creating folders)
889
+ async function addProjectManually(projectPath, displayName = null) {
890
+ const absolutePath = path.resolve(projectPath);
891
+
892
+ try {
893
+ // Check if the path exists
894
+ await fs.access(absolutePath);
895
+ } catch (error) {
896
+ throw new Error(`Path does not exist: ${absolutePath}`);
897
+ }
898
+
899
+ // Generate project name (encode path for use as directory name)
900
+ const projectName = absolutePath.replace(/\//g, '-');
901
+
902
+ // Check if project already exists in config
903
+ const config = await loadProjectConfig();
904
+ const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
905
+
906
+ if (config[projectName]) {
907
+ throw new Error(`Project already configured for path: ${absolutePath}`);
908
+ }
909
+
910
+ // Allow adding projects even if the directory exists - this enables tracking
911
+ // existing Claude Code or Cursor projects in the UI
912
+
913
+ // Add to config as manually added project
914
+ config[projectName] = {
915
+ manuallyAdded: true,
916
+ originalPath: absolutePath
917
+ };
918
+
919
+ if (displayName) {
920
+ config[projectName].displayName = displayName;
921
+ }
922
+
923
+ await saveProjectConfig(config);
924
+
925
+
926
+ return {
927
+ name: projectName,
928
+ path: absolutePath,
929
+ fullPath: absolutePath,
930
+ displayName: displayName || await generateDisplayName(projectName, absolutePath),
931
+ isManuallyAdded: true,
932
+ sessions: [],
933
+ cursorSessions: []
934
+ };
935
+ }
936
+
937
+ // Fetch Cursor sessions for a given project path
938
+ async function getCursorSessions(projectPath) {
939
+ try {
940
+ // Calculate cwdID hash for the project path (Cursor uses MD5 hash)
941
+ const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
942
+ const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
943
+
944
+ // Check if the directory exists
945
+ try {
946
+ await fs.access(cursorChatsPath);
947
+ } catch (error) {
948
+ // No sessions for this project
949
+ return [];
950
+ }
951
+
952
+ // List all session directories
953
+ const sessionDirs = await fs.readdir(cursorChatsPath);
954
+ const sessions = [];
955
+
956
+ for (const sessionId of sessionDirs) {
957
+ const sessionPath = path.join(cursorChatsPath, sessionId);
958
+ const storeDbPath = path.join(sessionPath, 'store.db');
959
+
960
+ try {
961
+ // Check if store.db exists
962
+ await fs.access(storeDbPath);
963
+
964
+ // Capture store.db mtime as a reliable fallback timestamp
965
+ let dbStatMtimeMs = null;
966
+ try {
967
+ const stat = await fs.stat(storeDbPath);
968
+ dbStatMtimeMs = stat.mtimeMs;
969
+ } catch (_) {}
970
+
971
+ // Open SQLite database
972
+ const db = await open({
973
+ filename: storeDbPath,
974
+ driver: sqlite3.Database,
975
+ mode: sqlite3.OPEN_READONLY
976
+ });
977
+
978
+ // Get metadata from meta table
979
+ const metaRows = await db.all(`
980
+ SELECT key, value FROM meta
981
+ `);
982
+
983
+ // Parse metadata
984
+ let metadata = {};
985
+ for (const row of metaRows) {
986
+ if (row.value) {
987
+ try {
988
+ // Try to decode as hex-encoded JSON
989
+ const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
990
+ if (hexMatch) {
991
+ const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
992
+ metadata[row.key] = JSON.parse(jsonStr);
993
+ } else {
994
+ metadata[row.key] = row.value.toString();
995
+ }
996
+ } catch (e) {
997
+ metadata[row.key] = row.value.toString();
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ // Get message count
1003
+ const messageCountResult = await db.get(`
1004
+ SELECT COUNT(*) as count FROM blobs
1005
+ `);
1006
+
1007
+ await db.close();
1008
+
1009
+ // Extract session info
1010
+ const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
1011
+
1012
+ // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
1013
+ let createdAt = null;
1014
+ if (metadata.createdAt) {
1015
+ createdAt = new Date(metadata.createdAt).toISOString();
1016
+ } else if (dbStatMtimeMs) {
1017
+ createdAt = new Date(dbStatMtimeMs).toISOString();
1018
+ } else {
1019
+ createdAt = new Date().toISOString();
1020
+ }
1021
+
1022
+ sessions.push({
1023
+ id: sessionId,
1024
+ name: sessionName,
1025
+ createdAt: createdAt,
1026
+ lastActivity: createdAt, // For compatibility with Claude sessions
1027
+ messageCount: messageCountResult.count || 0,
1028
+ projectPath: projectPath
1029
+ });
1030
+
1031
+ } catch (error) {
1032
+ console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
1033
+ }
1034
+ }
1035
+
1036
+ // Sort sessions by creation time (newest first)
1037
+ sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
1038
+
1039
+ // Return only the first 5 sessions for performance
1040
+ return sessions.slice(0, 5);
1041
+
1042
+ } catch (error) {
1043
+ console.error('Error fetching Cursor sessions:', error);
1044
+ return [];
1045
+ }
1046
+ }
1047
+
1048
+
1049
+ export {
1050
+ getProjects,
1051
+ getSessions,
1052
+ getSessionMessages,
1053
+ parseJsonlSessions,
1054
+ renameProject,
1055
+ deleteSession,
1056
+ isProjectEmpty,
1057
+ deleteProject,
1058
+ addProjectManually,
1059
+ loadProjectConfig,
1060
+ saveProjectConfig,
1061
+ extractProjectDirectory,
1062
+ clearProjectDirectoryCache
1063
+ };