Files
claudecodeui/server/projects.js

1063 lines
35 KiB
JavaScript
Executable File

/**
* PROJECT DISCOVERY AND MANAGEMENT SYSTEM
* ========================================
*
* This module manages project discovery for both Claude CLI and Cursor CLI sessions.
*
* ## Architecture Overview
*
* 1. **Claude Projects** (stored in ~/.claude/projects/)
* - Each project is a directory named with the project path encoded (/ replaced with -)
* - Contains .jsonl files with conversation history including 'cwd' field
* - Project metadata stored in ~/.claude/project-config.json
*
* 2. **Cursor Projects** (stored in ~/.cursor/chats/)
* - Each project directory is named with MD5 hash of the absolute project path
* - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6...
* - Contains session directories with SQLite databases (store.db)
* - Project path is NOT stored in the database - only in the MD5 hash
*
* ## Project Discovery Strategy
*
* 1. **Claude Projects Discovery**:
* - Scan ~/.claude/projects/ directory for Claude project folders
* - Extract actual project path from .jsonl files (cwd field)
* - Fall back to decoded directory name if no sessions exist
*
* 2. **Cursor Sessions Discovery**:
* - For each KNOWN project (from Claude or manually added)
* - Compute MD5 hash of the project's absolute path
* - Check if ~/.cursor/chats/{md5_hash}/ directory exists
* - Read session metadata from SQLite store.db files
*
* 3. **Manual Project Addition**:
* - Users can manually add project paths via UI
* - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag
* - Allows discovering Cursor sessions for projects without Claude sessions
*
* ## Critical Limitations
*
* - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of
* the cwd of each project. if someone has the time, you can try to reverse engineer it.
*
* - **Project relocation breaks history**: If a project directory is moved or renamed,
* the MD5 hash changes, making old Cursor sessions inaccessible unless the old
* path is known and manually added.
*
* ## Error Handling
*
* - Missing ~/.claude directory is handled gracefully with automatic creation
* - ENOENT errors are caught and handled without crashing
* - Empty arrays returned when no projects/sessions exist
*
* ## Caching Strategy
*
* - Project directory extraction is cached to minimize file I/O
* - Cache is cleared when project configuration changes
* - Session data is fetched on-demand, not cached
*/
import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
import crypto from 'crypto';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import os from 'os';
// Import TaskMaster detection functions
async function detectTaskMasterFolder(projectPath) {
try {
const taskMasterPath = path.join(projectPath, '.taskmaster');
// Check if .taskmaster directory exists
try {
const stats = await fs.stat(taskMasterPath);
if (!stats.isDirectory()) {
return {
hasTaskmaster: false,
reason: '.taskmaster exists but is not a directory'
};
}
} catch (error) {
if (error.code === 'ENOENT') {
return {
hasTaskmaster: false,
reason: '.taskmaster directory not found'
};
}
throw error;
}
// Check for key TaskMaster files
const keyFiles = [
'tasks/tasks.json',
'config.json'
];
const fileStatus = {};
let hasEssentialFiles = true;
for (const file of keyFiles) {
const filePath = path.join(taskMasterPath, file);
try {
await fs.access(filePath);
fileStatus[file] = true;
} catch (error) {
fileStatus[file] = false;
if (file === 'tasks/tasks.json') {
hasEssentialFiles = false;
}
}
}
// Parse tasks.json if it exists for metadata
let taskMetadata = null;
if (fileStatus['tasks/tasks.json']) {
try {
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
const tasksContent = await fs.readFile(tasksPath, 'utf8');
const tasksData = JSON.parse(tasksContent);
// Handle both tagged and legacy formats
let tasks = [];
if (tasksData.tasks) {
// Legacy format
tasks = tasksData.tasks;
} else {
// Tagged format - get tasks from all tags
Object.values(tasksData).forEach(tagData => {
if (tagData.tasks) {
tasks = tasks.concat(tagData.tasks);
}
});
}
// Calculate task statistics
const stats = tasks.reduce((acc, task) => {
acc.total++;
acc[task.status] = (acc[task.status] || 0) + 1;
// Count subtasks
if (task.subtasks) {
task.subtasks.forEach(subtask => {
acc.subtotalTasks++;
acc.subtasks = acc.subtasks || {};
acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
});
}
return acc;
}, {
total: 0,
subtotalTasks: 0,
pending: 0,
'in-progress': 0,
done: 0,
review: 0,
deferred: 0,
cancelled: 0,
subtasks: {}
});
taskMetadata = {
taskCount: stats.total,
subtaskCount: stats.subtotalTasks,
completed: stats.done || 0,
pending: stats.pending || 0,
inProgress: stats['in-progress'] || 0,
review: stats.review || 0,
completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
lastModified: (await fs.stat(tasksPath)).mtime.toISOString()
};
} catch (parseError) {
console.warn('Failed to parse tasks.json:', parseError.message);
taskMetadata = { error: 'Failed to parse tasks.json' };
}
}
return {
hasTaskmaster: true,
hasEssentialFiles,
files: fileStatus,
metadata: taskMetadata,
path: taskMasterPath
};
} catch (error) {
console.error('Error detecting TaskMaster folder:', error);
return {
hasTaskmaster: false,
reason: `Error checking directory: ${error.message}`
};
}
}
// Cache for extracted project directories
const projectDirectoryCache = new Map();
// Clear cache when needed (called when project files change)
function clearProjectDirectoryCache() {
projectDirectoryCache.clear();
}
// Load project configuration file
async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
try {
const configData = await fs.readFile(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
// Return empty config if file doesn't exist
return {};
}
}
// Save project configuration file
async function saveProjectConfig(config) {
const claudeDir = path.join(process.env.HOME, '.claude');
const configPath = path.join(claudeDir, 'project-config.json');
// Ensure the .claude directory exists
try {
await fs.mkdir(claudeDir, { recursive: true });
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
}
// Generate better display name from path
async function generateDisplayName(projectName, actualProjectDir = null) {
// Use actual project directory if provided, otherwise decode from project name
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
// Try to read package.json from the project path
try {
const packageJsonPath = path.join(projectPath, 'package.json');
const packageData = await fs.readFile(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageData);
// Return the name from package.json if it exists
if (packageJson.name) {
return packageJson.name;
}
} catch (error) {
// Fall back to path-based naming if package.json doesn't exist or can't be read
}
// If it starts with /, it's an absolute path
if (projectPath.startsWith('/')) {
const parts = projectPath.split('/').filter(Boolean);
// Return only the last folder name
return parts[parts.length - 1] || projectPath;
}
return projectPath;
}
// Extract the actual project directory from JSONL sessions (with caching)
async function extractProjectDirectory(projectName) {
// Check cache first
if (projectDirectoryCache.has(projectName)) {
return projectDirectoryCache.get(projectName);
}
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const cwdCounts = new Map();
let latestTimestamp = 0;
let latestCwd = null;
let extractedPath;
try {
// Check if the project directory exists
await fs.access(projectDir);
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
// Fall back to decoded project name if no sessions
extractedPath = projectName.replace(/-/g, '/');
} else {
// Process all JSONL files to collect cwd values
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
if (entry.cwd) {
// Count occurrences of each cwd
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
// Track the most recent cwd
const timestamp = new Date(entry.timestamp || 0).getTime();
if (timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestCwd = entry.cwd;
}
}
} catch (parseError) {
// Skip malformed lines
}
}
}
}
// Determine the best cwd to use
if (cwdCounts.size === 0) {
// No cwd found, fall back to decoded project name
extractedPath = projectName.replace(/-/g, '/');
} else if (cwdCounts.size === 1) {
// Only one cwd, use it
extractedPath = Array.from(cwdCounts.keys())[0];
} else {
// Multiple cwd values - prefer the most recent one if it has reasonable usage
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
const maxCount = Math.max(...cwdCounts.values());
// Use most recent if it has at least 25% of the max count
if (mostRecentCount >= maxCount * 0.25) {
extractedPath = latestCwd;
} else {
// Otherwise use the most frequently used cwd
for (const [cwd, count] of cwdCounts.entries()) {
if (count === maxCount) {
extractedPath = cwd;
break;
}
}
}
// Fallback (shouldn't reach here)
if (!extractedPath) {
extractedPath = latestCwd || projectName.replace(/-/g, '/');
}
}
}
// Cache the result
projectDirectoryCache.set(projectName, extractedPath);
return extractedPath;
} catch (error) {
// If the directory doesn't exist, just use the decoded project name
if (error.code === 'ENOENT') {
extractedPath = projectName.replace(/-/g, '/');
} else {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fall back to decoded project name for other errors
extractedPath = projectName.replace(/-/g, '/');
}
// Cache the fallback result too
projectDirectoryCache.set(projectName, extractedPath);
return extractedPath;
}
}
async function getProjects() {
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
const config = await loadProjectConfig();
const projects = [];
const existingProjects = new Set();
try {
// Check if the .claude/projects directory exists
await fs.access(claudeDir);
// First, get existing Claude projects from the file system
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
existingProjects.add(entry.name);
const projectPath = path.join(claudeDir, entry.name);
// Extract actual project directory from JSONL sessions
const actualProjectDir = await extractProjectDirectory(entry.name);
// Get display name from config or generate one
const customName = config[entry.name]?.displayName;
const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
const fullPath = actualProjectDir;
const project = {
name: entry.name,
path: actualProjectDir,
displayName: customName || autoDisplayName,
fullPath: fullPath,
isCustomName: !!customName,
sessions: []
};
// Try to get sessions for this project (just first 5 for performance)
try {
const sessionResult = await getSessions(entry.name, 5, 0);
project.sessions = sessionResult.sessions || [];
project.sessionMeta = {
hasMore: sessionResult.hasMore,
total: sessionResult.total
};
} catch (e) {
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
}
// Also fetch Cursor sessions for this project
try {
project.cursorSessions = await getCursorSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
project.cursorSessions = [];
}
// Add TaskMaster detection
try {
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
project.taskmaster = {
hasTaskmaster: taskMasterResult.hasTaskmaster,
hasEssentialFiles: taskMasterResult.hasEssentialFiles,
metadata: taskMasterResult.metadata,
status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured'
};
} catch (e) {
console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message);
project.taskmaster = {
hasTaskmaster: false,
hasEssentialFiles: false,
metadata: null,
status: 'error'
};
}
projects.push(project);
}
}
} catch (error) {
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
if (error.code !== 'ENOENT') {
console.error('Error reading projects directory:', error);
}
}
// Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
// Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath;
if (!actualProjectDir) {
try {
actualProjectDir = await extractProjectDirectory(projectName);
} catch (error) {
// Fall back to decoded project name
actualProjectDir = projectName.replace(/-/g, '/');
}
}
const project = {
name: projectName,
path: actualProjectDir,
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
fullPath: actualProjectDir,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: [],
cursorSessions: []
};
// Try to fetch Cursor sessions for manual projects too
try {
project.cursorSessions = await getCursorSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
}
// Add TaskMaster detection for manual projects
try {
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
// Determine TaskMaster status
let taskMasterStatus = 'not-configured';
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk
}
project.taskmaster = {
status: taskMasterStatus,
hasTaskmaster: taskMasterResult.hasTaskmaster,
hasEssentialFiles: taskMasterResult.hasEssentialFiles,
metadata: taskMasterResult.metadata
};
} catch (error) {
console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message);
project.taskmaster = {
status: 'error',
hasTaskmaster: false,
hasEssentialFiles: false,
error: error.message
};
}
projects.push(project);
}
}
return projects;
}
async function getSessions(projectName, limit = 5, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
return { sessions: [], hasMore: false, total: 0 };
}
// Sort files by modification time (newest first)
const filesWithStats = await Promise.all(
jsonlFiles.map(async (file) => {
const filePath = path.join(projectDir, file);
const stats = await fs.stat(filePath);
return { file, mtime: stats.mtime };
})
);
filesWithStats.sort((a, b) => b.mtime - a.mtime);
const allSessions = new Map();
const allEntries = [];
const uuidToSessionMap = new Map();
// Collect all sessions and entries from all files
for (const { file } of filesWithStats) {
const jsonlFile = path.join(projectDir, file);
const result = await parseJsonlSessions(jsonlFile);
result.sessions.forEach(session => {
if (!allSessions.has(session.id)) {
allSessions.set(session.id, session);
}
});
allEntries.push(...result.entries);
// Early exit optimization for large projects
if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
break;
}
}
// Build UUID-to-session mapping for timeline detection
allEntries.forEach(entry => {
if (entry.uuid && entry.sessionId) {
uuidToSessionMap.set(entry.uuid, entry.sessionId);
}
});
// Group sessions by first user message ID
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
// Find the first user message for each session
allEntries.forEach(entry => {
if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
// This is a first user message in a session (parentUuid is null)
const firstUserMsgId = entry.uuid;
if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
const session = allSessions.get(entry.sessionId);
if (session) {
if (!sessionGroups.has(firstUserMsgId)) {
sessionGroups.set(firstUserMsgId, {
latestSession: session,
allSessions: [session]
});
} else {
const group = sessionGroups.get(firstUserMsgId);
group.allSessions.push(session);
// Update latest session if this one is more recent
if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
group.latestSession = session;
}
}
}
}
}
});
// Collect all sessions that don't belong to any group (standalone sessions)
const groupedSessionIds = new Set();
sessionGroups.forEach(group => {
group.allSessions.forEach(session => groupedSessionIds.add(session.id));
});
const standaloneSessionsArray = Array.from(allSessions.values())
.filter(session => !groupedSessionIds.has(session.id));
// Combine grouped sessions (only show latest from each group) + standalone sessions
const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
const session = { ...group.latestSession };
// Add metadata about grouping
if (group.allSessions.length > 1) {
session.isGrouped = true;
session.groupSize = group.allSessions.length;
session.groupSessions = group.allSessions.map(s => s.id);
}
return session;
});
const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
const total = visibleSessions.length;
const paginatedSessions = visibleSessions.slice(offset, offset + limit);
const hasMore = offset + limit < total;
return {
sessions: paginatedSessions,
hasMore,
total,
offset,
limit
};
} catch (error) {
console.error(`Error reading sessions for project ${projectName}:`, error);
return { sessions: [], hasMore: false, total: 0 };
}
}
async function parseJsonlSessions(filePath) {
const sessions = new Map();
const entries = [];
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
entries.push(entry);
if (entry.sessionId) {
if (!sessions.has(entry.sessionId)) {
sessions.set(entry.sessionId, {
id: entry.sessionId,
summary: 'New Session',
messageCount: 0,
lastActivity: new Date(),
cwd: entry.cwd || ''
});
}
const session = sessions.get(entry.sessionId);
// Update summary from summary entries or first user message
if (entry.type === 'summary' && entry.summary) {
session.summary = entry.summary;
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
const content = entry.message.content;
if (typeof content === 'string' && content.length > 0 && !content.startsWith('<command-name>')) {
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
}
}
session.messageCount++;
if (entry.timestamp) {
session.lastActivity = new Date(entry.timestamp);
}
}
} catch (parseError) {
// Skip malformed lines silently
}
}
}
return {
sessions: Array.from(sessions.values()),
entries: entries
};
} catch (error) {
console.error('Error reading JSONL file:', error);
return { sessions: [], entries: [] };
}
}
// Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
return { messages: [], total: 0, hasMore: false };
}
const messages = [];
// Process all JSONL files to find messages for this session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
if (entry.sessionId === sessionId) {
messages.push(entry);
}
} catch (parseError) {
console.warn('Error parsing line:', parseError.message);
}
}
}
}
// Sort messages by timestamp
const sortedMessages = messages.sort((a, b) =>
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
);
const total = sortedMessages.length;
// If no limit is specified, return all messages (backward compatibility)
if (limit === null) {
return sortedMessages;
}
// Apply pagination - for recent messages, we need to slice from the end
// offset 0 should give us the most recent messages
const startIndex = Math.max(0, total - offset - limit);
const endIndex = total - offset;
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
const hasMore = startIndex > 0;
return {
messages: paginatedMessages,
total,
hasMore,
offset,
limit
};
} catch (error) {
console.error(`Error reading messages for session ${sessionId}:`, error);
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
}
}
// Rename a project's display name
async function renameProject(projectName, newDisplayName) {
const config = await loadProjectConfig();
if (!newDisplayName || newDisplayName.trim() === '') {
// Remove custom name if empty, will fall back to auto-generated
delete config[projectName];
} else {
// Set custom display name
config[projectName] = {
displayName: newDisplayName.trim()
};
}
await saveProjectConfig(config);
return true;
}
// Delete a session from a project
async function deleteSession(projectName, sessionId) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
throw new Error('No session files found for this project');
}
// Check all JSONL files to find which one contains the session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const content = await fs.readFile(jsonlFile, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
// Check if this file contains the session
const hasSession = lines.some(line => {
try {
const data = JSON.parse(line);
return data.sessionId === sessionId;
} catch {
return false;
}
});
if (hasSession) {
// Filter out all entries for this session
const filteredLines = lines.filter(line => {
try {
const data = JSON.parse(line);
return data.sessionId !== sessionId;
} catch {
return true; // Keep malformed lines
}
});
// Write back the filtered content
await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
return true;
}
}
throw new Error(`Session ${sessionId} not found in any files`);
} catch (error) {
console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
throw error;
}
}
// Check if a project is empty (has no sessions)
async function isProjectEmpty(projectName) {
try {
const sessionsResult = await getSessions(projectName, 1, 0);
return sessionsResult.total === 0;
} catch (error) {
console.error(`Error checking if project ${projectName} is empty:`, error);
return false;
}
}
// Delete an empty project
async function deleteProject(projectName) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) {
throw new Error('Cannot delete project with existing sessions');
}
// Remove the project directory
await fs.rm(projectDir, { recursive: true, force: true });
// Remove from project config
const config = await loadProjectConfig();
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
throw error;
}
}
// Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath);
try {
// Check if the path exists
await fs.access(absolutePath);
} catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
// Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-');
// Check if project already exists in config
const config = await loadProjectConfig();
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
if (config[projectName]) {
throw new Error(`Project already configured for path: ${absolutePath}`);
}
// Allow adding projects even if the directory exists - this enables tracking
// existing Claude Code or Cursor projects in the UI
// Add to config as manually added project
config[projectName] = {
manuallyAdded: true,
originalPath: absolutePath
};
if (displayName) {
config[projectName].displayName = displayName;
}
await saveProjectConfig(config);
return {
name: projectName,
path: absolutePath,
fullPath: absolutePath,
displayName: displayName || await generateDisplayName(projectName, absolutePath),
isManuallyAdded: true,
sessions: [],
cursorSessions: []
};
}
// Fetch Cursor sessions for a given project path
async function getCursorSessions(projectPath) {
try {
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
// Check if the directory exists
try {
await fs.access(cursorChatsPath);
} catch (error) {
// No sessions for this project
return [];
}
// List all session directories
const sessionDirs = await fs.readdir(cursorChatsPath);
const sessions = [];
for (const sessionId of sessionDirs) {
const sessionPath = path.join(cursorChatsPath, sessionId);
const storeDbPath = path.join(sessionPath, 'store.db');
try {
// Check if store.db exists
await fs.access(storeDbPath);
// Capture store.db mtime as a reliable fallback timestamp
let dbStatMtimeMs = null;
try {
const stat = await fs.stat(storeDbPath);
dbStatMtimeMs = stat.mtimeMs;
} catch (_) {}
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
// Parse metadata
let metadata = {};
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
metadata[row.key] = JSON.parse(jsonStr);
} else {
metadata[row.key] = row.value.toString();
}
} catch (e) {
metadata[row.key] = row.value.toString();
}
}
}
// Get message count
const messageCountResult = await db.get(`
SELECT COUNT(*) as count FROM blobs
`);
await db.close();
// Extract session info
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
// Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
let createdAt = null;
if (metadata.createdAt) {
createdAt = new Date(metadata.createdAt).toISOString();
} else if (dbStatMtimeMs) {
createdAt = new Date(dbStatMtimeMs).toISOString();
} else {
createdAt = new Date().toISOString();
}
sessions.push({
id: sessionId,
name: sessionName,
createdAt: createdAt,
lastActivity: createdAt, // For compatibility with Claude sessions
messageCount: messageCountResult.count || 0,
projectPath: projectPath
});
} catch (error) {
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
}
}
// Sort sessions by creation time (newest first)
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
// Return only the first 5 sessions for performance
return sessions.slice(0, 5);
} catch (error) {
console.error('Error fetching Cursor sessions:', error);
return [];
}
}
export {
getProjects,
getSessions,
getSessionMessages,
parseJsonlSessions,
renameProject,
deleteSession,
isProjectEmpty,
deleteProject,
addProjectManually,
loadProjectConfig,
saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
};