claude-chat-viewer
Version:
Interactive CLI tool to view and export Claude conversation histories
208 lines (175 loc) • 7.38 kB
JavaScript
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;