UNPKG

claude-chat-viewer

Version:

Interactive CLI tool to view and export Claude conversation histories

208 lines (175 loc) 7.38 kB
const fs = require('fs'); const path = require('path'); const os = require('os'); class ProjectDetector { constructor() { this.claudeDir = path.join(os.homedir(), '.claude', 'projects'); } /** * Get the current project path and find matching conversation * @param {string} currentDir - Current working directory * @returns {object} Project info and conversation path */ async detectCurrentProject(currentDir = process.cwd()) { // Normalize the current directory path const normalizedPath = this.normalizePath(currentDir); // Get all project directories const projectDirs = this.getProjectDirectories(); // Find matching project const matchingProject = this.findMatchingProject(normalizedPath, projectDirs); if (!matchingProject) { throw new Error('No Claude conversation found for current directory'); } // Find the most recent conversation for this project const conversation = await this.findMostRecentConversation(matchingProject); return { projectPath: currentDir, projectId: matchingProject, conversationPath: conversation.path, conversationInfo: conversation.info }; } /** * Normalize path for matching */ normalizePath(dirPath) { // Remove leading slash and replace path separators with hyphens return dirPath .replace(/^\//, '') .replace(/\//g, '-') .toLowerCase(); } /** * Get all project directories from Claude */ getProjectDirectories() { if (!fs.existsSync(this.claudeDir)) { throw new Error('Claude directory not found. Make sure Claude Code has been used.'); } return fs.readdirSync(this.claudeDir) .filter(dir => fs.statSync(path.join(this.claudeDir, dir)).isDirectory()); } /** * Find project that matches current directory */ findMatchingProject(normalizedPath, projectDirs) { // First try exact match for (const projectDir of projectDirs) { if (projectDir.toLowerCase() === normalizedPath) { return projectDir; } } // Try partial match (current dir might be a subdirectory) for (const projectDir of projectDirs) { if (normalizedPath.includes(projectDir.toLowerCase()) || projectDir.toLowerCase().includes(normalizedPath)) { return projectDir; } } // Try matching by path components const pathParts = normalizedPath.split('-'); for (const projectDir of projectDirs) { const projectParts = projectDir.toLowerCase().split('-'); // Check if project path ends with current directory name if (pathParts.length >= 2 && projectParts.length >= 2) { const currentDirName = pathParts[pathParts.length - 1]; const projectDirName = projectParts[projectParts.length - 1]; if (currentDirName === projectDirName) { // Additional check: see if more parts match let matchCount = 0; for (let i = 1; i <= Math.min(pathParts.length, projectParts.length); i++) { if (pathParts[pathParts.length - i] === projectParts[projectParts.length - i]) { matchCount++; } else { break; } } if (matchCount >= 2) { return projectDir; } } } } return null; } /** * Find most recent conversation for a project */ async findMostRecentConversation(projectId) { const projectPath = path.join(this.claudeDir, projectId); const files = fs.readdirSync(projectPath); const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); if (jsonlFiles.length === 0) { throw new Error('No conversation files found for project'); } // Get file stats and sort by modification time const fileStats = jsonlFiles.map(file => { const filePath = path.join(projectPath, file); const stats = fs.statSync(filePath); // Parse first line to get session info const content = fs.readFileSync(filePath, 'utf-8'); const firstLine = content.split('\n')[0]; let sessionInfo = {}; try { const data = JSON.parse(firstLine); sessionInfo = { startTime: data.timestamp, sessionId: data.sessionId || path.basename(file, '.jsonl') }; } catch (e) { // Fallback to file stats sessionInfo = { startTime: stats.birthtime.toISOString(), sessionId: path.basename(file, '.jsonl') }; } return { path: filePath, info: { filename: file, modified: stats.mtime, size: stats.size, ...sessionInfo } }; }); // Sort by modification time (most recent first) fileStats.sort((a, b) => b.info.modified - a.info.modified); // Check if we're in an active session const currentTime = new Date(); const mostRecent = fileStats[0]; const timeDiff = currentTime - mostRecent.info.modified; const isActive = timeDiff < 300000; // Less than 5 minutes old if (isActive) { console.log(`📍 Found active conversation (updated ${Math.round(timeDiff / 1000)}s ago)`); } else { console.log(`📍 Found most recent conversation from ${mostRecent.info.modified.toLocaleString()}`); } return mostRecent; } /** * List all conversations for current project */ async listProjectConversations(currentDir = process.cwd()) { const normalizedPath = this.normalizePath(currentDir); const projectDirs = this.getProjectDirectories(); const matchingProject = this.findMatchingProject(normalizedPath, projectDirs); if (!matchingProject) { return []; } const projectPath = path.join(this.claudeDir, matchingProject); const files = fs.readdirSync(projectPath); const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); return jsonlFiles.map(file => { const filePath = path.join(projectPath, file); const stats = fs.statSync(filePath); return { filename: file, path: filePath, modified: stats.mtime, size: stats.size }; }).sort((a, b) => b.modified - a.modified); } } module.exports = ProjectDetector;