mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 14:09:39 +00:00
first commit
This commit is contained in:
207
server/claude-cli.js
Normal file
207
server/claude-cli.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnClaude(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
// Build Claude CLI command - start with print/resume flags first
|
||||
const args = [];
|
||||
|
||||
// Add print flag with command if we have a command
|
||||
if (command && command.trim()) {
|
||||
args.push('--print', command);
|
||||
}
|
||||
|
||||
// Add resume flag if resuming
|
||||
if (resume && sessionId) {
|
||||
args.push('--resume', sessionId);
|
||||
}
|
||||
|
||||
// Add basic flags
|
||||
args.push('--output-format', 'stream-json', '--verbose');
|
||||
|
||||
// Add model for new sessions
|
||||
if (!resume) {
|
||||
args.push('--model', 'sonnet');
|
||||
}
|
||||
|
||||
// Add tools settings flags
|
||||
if (settings.skipPermissions) {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
|
||||
} else {
|
||||
// Only add allowed/disallowed tools if not skipping permissions
|
||||
// Add allowed tools
|
||||
if (settings.allowedTools && settings.allowedTools.length > 0) {
|
||||
for (const tool of settings.allowedTools) {
|
||||
args.push('--allowedTools', tool);
|
||||
console.log('✅ Allowing tool:', tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Add disallowed tools
|
||||
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
|
||||
for (const tool of settings.disallowedTools) {
|
||||
args.push('--disallowedTools', tool);
|
||||
console.log('❌ Disallowing tool:', tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
const workingDir = cwd || process.cwd();
|
||||
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
||||
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
||||
}).join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
console.log('🔍 Full command args:', args);
|
||||
|
||||
const claudeProcess = spawn('claude', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
});
|
||||
|
||||
// Store process reference for potential abort
|
||||
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
||||
activeClaudeProcesses.set(processKey, claudeProcess);
|
||||
|
||||
// Handle stdout (streaming JSON responses)
|
||||
claudeProcess.stdout.on('data', (data) => {
|
||||
const rawOutput = data.toString();
|
||||
console.log('📤 Claude CLI stdout:', rawOutput);
|
||||
|
||||
const lines = rawOutput.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
console.log('📄 Parsed JSON response:', response);
|
||||
|
||||
// Capture session ID if it's in the response
|
||||
if (response.session_id && !capturedSessionId) {
|
||||
capturedSessionId = response.session_id;
|
||||
console.log('📝 Captured session ID:', capturedSessionId);
|
||||
|
||||
// Update process key with captured session ID
|
||||
if (processKey !== capturedSessionId) {
|
||||
activeClaudeProcesses.delete(processKey);
|
||||
activeClaudeProcesses.set(capturedSessionId, claudeProcess);
|
||||
}
|
||||
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Send parsed response to WebSocket
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-response',
|
||||
data: response
|
||||
}));
|
||||
} catch (parseError) {
|
||||
console.log('📄 Non-JSON response:', line);
|
||||
// If not JSON, send as raw text
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-output',
|
||||
data: line
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
claudeProcess.stderr.on('data', (data) => {
|
||||
console.error('Claude CLI stderr:', data.toString());
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-error',
|
||||
error: data.toString()
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
claudeProcess.on('close', (code) => {
|
||||
console.log(`Claude CLI process exited with code ${code}`);
|
||||
|
||||
// Clean up process reference
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeClaudeProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-complete',
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
}));
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Claude CLI exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
claudeProcess.on('error', (error) => {
|
||||
console.error('Claude CLI process error:', error);
|
||||
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeClaudeProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-error',
|
||||
error: error.message
|
||||
}));
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Handle stdin for interactive mode
|
||||
if (command) {
|
||||
// For --print mode with arguments, we don't need to write to stdin
|
||||
claudeProcess.stdin.end();
|
||||
} else {
|
||||
// For interactive mode, we need to write the command to stdin if provided later
|
||||
// Keep stdin open for interactive session
|
||||
if (command !== undefined) {
|
||||
claudeProcess.stdin.write(command + '\n');
|
||||
claudeProcess.stdin.end();
|
||||
}
|
||||
// If no command provided, stdin stays open for interactive use
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function abortClaudeSession(sessionId) {
|
||||
const process = activeClaudeProcesses.get(sessionId);
|
||||
if (process) {
|
||||
console.log(`🛑 Aborting Claude session: ${sessionId}`);
|
||||
process.kill('SIGTERM');
|
||||
activeClaudeProcesses.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
spawnClaude,
|
||||
abortClaudeSession
|
||||
};
|
||||
162
server/claude-cli.js.backup.1750077611635
Normal file
162
server/claude-cli.js.backup.1750077611635
Normal file
@@ -0,0 +1,162 @@
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
async function spawnClaude(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
console.log('🔧 Using tools settings:', settings);
|
||||
|
||||
// Build Claude CLI command - start with basic flags
|
||||
const args = ['--output-format', 'stream-json', '--verbose'];
|
||||
|
||||
// Add tools settings flags
|
||||
if (settings.skipPermissions) {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
console.log('⚠️ Using --dangerously-skip-permissions');
|
||||
}
|
||||
|
||||
// Add print flag if we have a command
|
||||
if (command && command.trim()) {
|
||||
args.push('--print');
|
||||
}
|
||||
|
||||
// Add resume flag if resuming (after --print)
|
||||
if (resume && sessionId) {
|
||||
args.push('--resume', sessionId);
|
||||
}
|
||||
|
||||
// Add allowed tools
|
||||
if (settings.allowedTools && settings.allowedTools.length > 0) {
|
||||
for (const tool of settings.allowedTools) {
|
||||
args.push('--allowedTools', tool);
|
||||
console.log('✅ Allowing tool:', tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Add disallowed tools
|
||||
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
|
||||
for (const tool of settings.disallowedTools) {
|
||||
args.push('--disallowedTools', tool);
|
||||
console.log('❌ Disallowing tool:', tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the command as the final argument
|
||||
if (command && command.trim()) {
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
const workingDir = projectPath || cwd || process.cwd();
|
||||
console.log('Spawning Claude CLI:', 'claude', args.join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
|
||||
const claudeProcess = spawn('claude', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
// Handle stdout (streaming JSON responses)
|
||||
claudeProcess.stdout.on('data', (data) => {
|
||||
const lines = data.toString().split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
|
||||
// Capture session ID if it's in the response
|
||||
if (response.session_id && !capturedSessionId) {
|
||||
capturedSessionId = response.session_id;
|
||||
console.log('📝 Captured session ID:', capturedSessionId);
|
||||
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Send parsed response to WebSocket
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-response',
|
||||
data: response
|
||||
}));
|
||||
} catch (parseError) {
|
||||
// If not JSON, send as raw text
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-output',
|
||||
data: line
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
claudeProcess.stderr.on('data', (data) => {
|
||||
console.error('Claude CLI stderr:', data.toString());
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-error',
|
||||
error: data.toString()
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
claudeProcess.on('close', (code) => {
|
||||
console.log(`Claude CLI process exited with code ${code}`);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-complete',
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
}));
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Claude CLI exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
claudeProcess.on('error', (error) => {
|
||||
console.error('Claude CLI process error:', error);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-error',
|
||||
error: error.message
|
||||
}));
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Handle stdin for interactive mode
|
||||
if (command) {
|
||||
// For --print mode with arguments, we don't need to write to stdin
|
||||
claudeProcess.stdin.end();
|
||||
} else {
|
||||
// For interactive mode, we need to write the command to stdin if provided later
|
||||
// Keep stdin open for interactive session
|
||||
if (command !== undefined) {
|
||||
claudeProcess.stdin.write(command + '\n');
|
||||
claudeProcess.stdin.end();
|
||||
}
|
||||
// If no command provided, stdin stays open for interactive use
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
spawnClaude
|
||||
};
|
||||
719
server/index.js
Normal file
719
server/index.js
Normal file
@@ -0,0 +1,719 @@
|
||||
// Load environment variables from .env file
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmedLine.split('=');
|
||||
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||
process.env[key] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
console.log('PORT from env:', process.env.PORT);
|
||||
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs').promises;
|
||||
const { spawn } = require('child_process');
|
||||
const os = require('os');
|
||||
const pty = require('node-pty');
|
||||
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects');
|
||||
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
|
||||
|
||||
// File system watcher for projects folder
|
||||
let projectsWatcher = null;
|
||||
const connectedClients = new Set();
|
||||
|
||||
// Setup file system watcher for Claude projects folder using chokidar
|
||||
function setupProjectsWatcher() {
|
||||
const chokidar = require('chokidar');
|
||||
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
||||
|
||||
if (projectsWatcher) {
|
||||
projectsWatcher.close();
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize chokidar watcher with optimized settings
|
||||
projectsWatcher = chokidar.watch(claudeProjectsPath, {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/*.tmp',
|
||||
'**/*.swp',
|
||||
'**/.DS_Store'
|
||||
],
|
||||
persistent: true,
|
||||
ignoreInitial: true, // Don't fire events for existing files on startup
|
||||
followSymlinks: false,
|
||||
depth: 10, // Reasonable depth limit
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100, // Wait 100ms for file to stabilize
|
||||
pollInterval: 50
|
||||
}
|
||||
});
|
||||
|
||||
// Debounce function to prevent excessive notifications
|
||||
let debounceTimer;
|
||||
const debouncedUpdate = async (eventType, filePath) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
|
||||
// Get updated projects list
|
||||
const updatedProjects = await getProjects();
|
||||
|
||||
// Notify all connected clients about the project changes
|
||||
const updateMessage = JSON.stringify({
|
||||
type: 'projects_updated',
|
||||
projects: updatedProjects,
|
||||
timestamp: new Date().toISOString(),
|
||||
changeType: eventType,
|
||||
changedFile: path.relative(claudeProjectsPath, filePath)
|
||||
});
|
||||
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === client.OPEN) {
|
||||
client.send(updateMessage);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error handling project changes:', error);
|
||||
}
|
||||
}, 300); // 300ms debounce (slightly faster than before)
|
||||
};
|
||||
|
||||
// Set up event listeners
|
||||
projectsWatcher
|
||||
.on('add', (filePath) => debouncedUpdate('add', filePath))
|
||||
.on('change', (filePath) => debouncedUpdate('change', filePath))
|
||||
.on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
|
||||
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
|
||||
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
|
||||
.on('error', (error) => {
|
||||
console.error('❌ Chokidar watcher error:', error);
|
||||
})
|
||||
.on('ready', () => {
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to setup projects watcher:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the first non-localhost IP address
|
||||
function getServerIP() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name]) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Single WebSocket server that handles both paths
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
verifyClient: (info) => {
|
||||
console.log('WebSocket connection attempt to:', info.req.url);
|
||||
return true; // Accept all connections for now
|
||||
}
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
|
||||
// API Routes
|
||||
app.get('/api/config', (req, res) => {
|
||||
// Always use the server's actual IP and port for WebSocket connections
|
||||
const serverIP = getServerIP();
|
||||
const host = `${serverIP}:${PORT}`;
|
||||
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
|
||||
|
||||
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
|
||||
|
||||
res.json({
|
||||
serverPort: PORT,
|
||||
wsUrl: `${protocol}://${host}`
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/projects', async (req, res) => {
|
||||
try {
|
||||
const projects = await getProjects();
|
||||
res.json(projects);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectName/sessions', async (req, res) => {
|
||||
try {
|
||||
const { limit = 5, offset = 0 } = req.query;
|
||||
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get messages for a specific session
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const messages = await getSessionMessages(projectName, sessionId);
|
||||
res.json({ messages });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Rename project endpoint
|
||||
app.put('/api/projects/:projectName/rename', async (req, res) => {
|
||||
try {
|
||||
const { displayName } = req.body;
|
||||
await renameProject(req.params.projectName, displayName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete session endpoint
|
||||
app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
await deleteSession(projectName, sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete project endpoint (only if empty)
|
||||
app.delete('/api/projects/:projectName', async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
await deleteProject(projectName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create project endpoint
|
||||
app.post('/api/projects/create', async (req, res) => {
|
||||
try {
|
||||
const { path: projectPath } = req.body;
|
||||
|
||||
if (!projectPath || !projectPath.trim()) {
|
||||
return res.status(400).json({ error: 'Project path is required' });
|
||||
}
|
||||
|
||||
const project = await addProjectManually(projectPath.trim());
|
||||
res.json({ success: true, project });
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Read file content endpoint
|
||||
app.get('/api/projects/:projectName/file', async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { filePath } = req.query;
|
||||
|
||||
console.log('📄 File read request:', projectName, filePath);
|
||||
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Security check - ensure the path is safe and absolute
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
res.json({ content, path: filePath });
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
} else if (error.code === 'EACCES') {
|
||||
res.status(403).json({ error: 'Permission denied' });
|
||||
} else {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Serve binary file content endpoint (for images, etc.)
|
||||
app.get('/api/projects/:projectName/files/content', async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { path: filePath } = req.query;
|
||||
|
||||
console.log('🖼️ Binary file serve request:', projectName, filePath);
|
||||
|
||||
const fs = require('fs');
|
||||
const mime = require('mime-types');
|
||||
|
||||
// Security check - ensure the path is safe and absolute
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// Get file extension and set appropriate content type
|
||||
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
|
||||
// Stream the file
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
fileStream.on('error', (error) => {
|
||||
console.error('Error streaming file:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Error reading file' });
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error serving binary file:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save file content endpoint
|
||||
app.put('/api/projects/:projectName/file', async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { filePath, content } = req.body;
|
||||
|
||||
console.log('💾 File save request:', projectName, filePath);
|
||||
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Security check - ensure the path is safe and absolute
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
|
||||
// Create backup of original file
|
||||
try {
|
||||
const backupPath = filePath + '.backup.' + Date.now();
|
||||
await fs.copyFile(filePath, backupPath);
|
||||
console.log('📋 Created backup:', backupPath);
|
||||
} catch (backupError) {
|
||||
console.warn('Could not create backup:', backupError.message);
|
||||
}
|
||||
|
||||
// Write the new content
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
path: filePath,
|
||||
message: 'File saved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'File or directory not found' });
|
||||
} else if (error.code === 'EACCES') {
|
||||
res.status(403).json({ error: 'Permission denied' });
|
||||
} else {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectName/files', async (req, res) => {
|
||||
try {
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const projectPath = path.join(process.env.HOME, '.claude', 'projects', req.params.projectName);
|
||||
|
||||
// Try different methods to get the actual project path
|
||||
let actualPath = projectPath;
|
||||
|
||||
try {
|
||||
// First try to read metadata.json
|
||||
const metadataPath = path.join(projectPath, 'metadata.json');
|
||||
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
||||
actualPath = metadata.path || metadata.cwd;
|
||||
} catch (e) {
|
||||
// Fallback: try to find the actual path by testing different dash interpretations
|
||||
let testPath = req.params.projectName;
|
||||
if (testPath.startsWith('-')) {
|
||||
testPath = testPath.substring(1);
|
||||
}
|
||||
|
||||
// Try to intelligently decode the path by testing which directories exist
|
||||
const pathParts = testPath.split('-');
|
||||
actualPath = '/' + pathParts.join('/');
|
||||
|
||||
// If the simple replacement doesn't work, try to find the correct path
|
||||
// by testing combinations where some dashes might be part of directory names
|
||||
if (!require('fs').existsSync(actualPath)) {
|
||||
// Try different combinations of dash vs slash
|
||||
for (let i = pathParts.length - 1; i >= 0; i--) {
|
||||
let testParts = [...pathParts];
|
||||
// Try joining some parts with dashes instead of slashes
|
||||
for (let j = i; j < testParts.length - 1; j++) {
|
||||
testParts[j] = testParts[j] + '-' + testParts[j + 1];
|
||||
testParts.splice(j + 1, 1);
|
||||
let testActualPath = '/' + testParts.join('/');
|
||||
if (require('fs').existsSync(testActualPath)) {
|
||||
actualPath = testActualPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (require('fs').existsSync(actualPath)) break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
try {
|
||||
await fs.access(actualPath);
|
||||
} catch (e) {
|
||||
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
||||
}
|
||||
|
||||
const files = await getFileTree(actualPath, 3, 0, true);
|
||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
||||
console.log('📄 Found', files.length, 'files/folders, including', hiddenFiles.length, 'hidden files');
|
||||
console.log('🔍 Hidden files:', hiddenFiles.map(f => f.name));
|
||||
res.json(files);
|
||||
} catch (error) {
|
||||
console.error('❌ File tree error:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket connection handler that routes based on URL path
|
||||
wss.on('connection', (ws, request) => {
|
||||
const url = request.url;
|
||||
console.log('🔗 Client connected to:', url);
|
||||
|
||||
if (url === '/shell') {
|
||||
handleShellConnection(ws);
|
||||
} else if (url === '/ws') {
|
||||
handleChatConnection(ws);
|
||||
} else {
|
||||
console.log('❌ Unknown WebSocket path:', url);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle chat WebSocket connections
|
||||
function handleChatConnection(ws) {
|
||||
console.log('💬 Chat WebSocket connected');
|
||||
|
||||
// Add to connected clients for project updates
|
||||
connectedClients.add(ws);
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
if (data.type === 'claude-command') {
|
||||
console.log('💬 User message:', data.command || '[Continue/Resume]');
|
||||
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
|
||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||
await spawnClaude(data.command, data.options, ws);
|
||||
} else if (data.type === 'abort-session') {
|
||||
console.log('🛑 Abort session request:', data.sessionId);
|
||||
const success = abortClaudeSession(data.sessionId);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session-aborted',
|
||||
sessionId: data.sessionId,
|
||||
success
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Chat WebSocket error:', error.message);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
error: error.message
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Chat client disconnected');
|
||||
// Remove from connected clients
|
||||
connectedClients.delete(ws);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle shell WebSocket connections
|
||||
function handleShellConnection(ws) {
|
||||
console.log('🐚 Shell client connected');
|
||||
let shellProcess = null;
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
console.log('📨 Shell message received:', data.type);
|
||||
|
||||
if (data.type === 'init') {
|
||||
// Initialize shell with project path and session info
|
||||
const projectPath = data.projectPath || process.cwd();
|
||||
const sessionId = data.sessionId;
|
||||
const hasSession = data.hasSession;
|
||||
|
||||
console.log('🚀 Starting shell in:', projectPath);
|
||||
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : 'New session');
|
||||
|
||||
// First send a welcome message
|
||||
const welcomeMsg = hasSession ?
|
||||
`\x1b[36mResuming Claude session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
||||
`\x1b[36mStarting new Claude session in: ${projectPath}\x1b[0m\r\n`;
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: welcomeMsg
|
||||
}));
|
||||
|
||||
try {
|
||||
// Build shell command that changes to project directory first, then runs claude
|
||||
let claudeCommand = 'claude';
|
||||
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to new session if it fails
|
||||
claudeCommand = `claude --resume ${sessionId} || claude`;
|
||||
}
|
||||
|
||||
// Create shell command that cds to the project directory first
|
||||
const shellCommand = `cd "${projectPath}" && ${claudeCommand}`;
|
||||
|
||||
console.log('🔧 Executing shell command:', shellCommand);
|
||||
|
||||
// Start shell using PTY for proper terminal emulation
|
||||
shellProcess = pty.spawn('bash', ['-c', shellCommand], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.env.HOME || '/', // Start from home directory
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
// Override browser opening commands to echo URL for detection
|
||||
BROWSER: 'echo "OPEN_URL:"'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
|
||||
|
||||
// Handle data output
|
||||
shellProcess.onData((data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
let outputData = data;
|
||||
|
||||
// Check for various URL opening patterns
|
||||
const patterns = [
|
||||
// Direct browser opening commands
|
||||
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
|
||||
// BROWSER environment variable override
|
||||
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
||||
// Git and other tools opening URLs
|
||||
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
// General URL patterns that might be opened
|
||||
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
/View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
/Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
|
||||
];
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
let match;
|
||||
while ((match = pattern.exec(data)) !== null) {
|
||||
const url = match[1];
|
||||
console.log('🔗 Detected URL for opening:', url);
|
||||
|
||||
// Send URL opening message to client
|
||||
ws.send(JSON.stringify({
|
||||
type: 'url_open',
|
||||
url: url
|
||||
}));
|
||||
|
||||
// Replace the OPEN_URL pattern with a user-friendly message
|
||||
if (pattern.source.includes('OPEN_URL')) {
|
||||
outputData = outputData.replace(match[0], `🌐 Opening in browser: ${url}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send regular output
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: outputData
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
shellProcess.onExit((exitCode) => {
|
||||
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
||||
}));
|
||||
}
|
||||
shellProcess = null;
|
||||
});
|
||||
|
||||
} catch (spawnError) {
|
||||
console.error('❌ Error spawning process:', spawnError);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
|
||||
}));
|
||||
}
|
||||
|
||||
} else if (data.type === 'input') {
|
||||
// Send input to shell process
|
||||
if (shellProcess && shellProcess.write) {
|
||||
try {
|
||||
shellProcess.write(data.data);
|
||||
} catch (error) {
|
||||
console.error('Error writing to shell:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('No active shell process to send input to');
|
||||
}
|
||||
} else if (data.type === 'resize') {
|
||||
// Handle terminal resize
|
||||
if (shellProcess && shellProcess.resize) {
|
||||
console.log('Terminal resize requested:', data.cols, 'x', data.rows);
|
||||
shellProcess.resize(data.cols, data.rows);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Shell WebSocket error:', error.message);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Shell client disconnected');
|
||||
if (shellProcess && shellProcess.kill) {
|
||||
console.log('🔴 Killing shell process:', shellProcess.pid);
|
||||
shellProcess.kill();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ Shell WebSocket error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Serve React app for all other routes
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
});
|
||||
|
||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||
const fs = require('fs').promises;
|
||||
const items = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Debug: log all entries including hidden files
|
||||
if (entry.name.startsWith('.')) {
|
||||
console.log('📁 Found hidden file/folder:', entry.name, 'at depth:', currentDepth);
|
||||
}
|
||||
|
||||
// Skip only heavy build directories
|
||||
if (entry.name === 'node_modules' ||
|
||||
entry.name === 'dist' ||
|
||||
entry.name === 'build') continue;
|
||||
|
||||
const item = {
|
||||
name: entry.name,
|
||||
path: path.join(dirPath, entry.name),
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||
// Recursively get subdirectories but limit depth
|
||||
try {
|
||||
// Check if we can access the directory before trying to read it
|
||||
await fs.access(item.path, fs.constants.R_OK);
|
||||
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
||||
} catch (e) {
|
||||
// Silently skip directories we can't access (permission denied, etc.)
|
||||
item.children = [];
|
||||
}
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
} catch (error) {
|
||||
// Only log non-permission errors to avoid spam
|
||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||
console.error('Error reading directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
||||
|
||||
// Start watching the projects folder for changes
|
||||
setupProjectsWatcher();
|
||||
});
|
||||
473
server/projects.js
Normal file
473
server/projects.js
Normal file
@@ -0,0 +1,473 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
// 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 configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// Generate better display name from path
|
||||
function generateDisplayName(projectName) {
|
||||
// Convert "-home-user-projects-myapp" to a readable format
|
||||
let path = projectName.replace(/-/g, '/');
|
||||
|
||||
// If it starts with /, it's an absolute path
|
||||
if (path.startsWith('/')) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length > 3) {
|
||||
// Show last 2 folders with ellipsis: "...projects/myapp"
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
} else {
|
||||
// Show full path if short: "/home/user"
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
async function getProjects() {
|
||||
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
const projects = [];
|
||||
const existingProjects = new Set();
|
||||
|
||||
try {
|
||||
// First, get existing 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);
|
||||
|
||||
// Get display name from config or generate one
|
||||
const customName = config[entry.name]?.displayName;
|
||||
const autoDisplayName = generateDisplayName(entry.name);
|
||||
const fullPath = entry.name.replace(/-/g, '/');
|
||||
|
||||
const project = {
|
||||
name: entry.name,
|
||||
path: projectPath,
|
||||
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);
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
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) {
|
||||
const fullPath = projectName.replace(/-/g, '/');
|
||||
|
||||
const project = {
|
||||
name: projectName,
|
||||
path: null, // No physical path yet
|
||||
displayName: projectConfig.displayName || generateDisplayName(projectName),
|
||||
fullPath: fullPath,
|
||||
isCustomName: !!projectConfig.displayName,
|
||||
isManuallyAdded: true,
|
||||
sessions: []
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// For performance, get file stats to sort by modification time
|
||||
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 };
|
||||
})
|
||||
);
|
||||
|
||||
// Sort files by modification time (newest first) for better performance
|
||||
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
const allSessions = new Map();
|
||||
let processedCount = 0;
|
||||
|
||||
// Process files in order of modification time
|
||||
for (const { file } of filesWithStats) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
const sessions = await parseJsonlSessions(jsonlFile);
|
||||
|
||||
// Merge sessions, avoiding duplicates by session ID
|
||||
sessions.forEach(session => {
|
||||
if (!allSessions.has(session.id)) {
|
||||
allSessions.set(session.id, session);
|
||||
}
|
||||
});
|
||||
|
||||
processedCount++;
|
||||
|
||||
// Early exit optimization: if we have enough sessions and processed recent files
|
||||
if (allSessions.size >= (limit + offset) * 2 && processedCount >= Math.min(3, filesWithStats.length)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by last activity
|
||||
const sortedSessions = Array.from(allSessions.values()).sort((a, b) =>
|
||||
new Date(b.lastActivity) - new Date(a.lastActivity)
|
||||
);
|
||||
|
||||
const total = sortedSessions.length;
|
||||
const paginatedSessions = sortedSessions.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();
|
||||
|
||||
try {
|
||||
const fileStream = require('fs').createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
// console.log(`[JSONL Parser] Reading file: ${filePath}`);
|
||||
let lineCount = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
lineCount++;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
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 if this is a summary entry
|
||||
if (entry.type === 'summary' && entry.summary) {
|
||||
session.summary = entry.summary;
|
||||
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
|
||||
// Use first user message as summary if no summary entry exists
|
||||
const content = entry.message.content;
|
||||
if (typeof content === 'string' && content.length > 0) {
|
||||
// Skip command messages that start with <command-name>
|
||||
if (!content.startsWith('<command-name>')) {
|
||||
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count messages instead of storing them all
|
||||
session.messageCount = (session.messageCount || 0) + 1;
|
||||
|
||||
// Update last activity
|
||||
if (entry.timestamp) {
|
||||
session.lastActivity = new Date(entry.timestamp);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn(`[JSONL Parser] Error parsing line ${lineCount}:`, parseError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(`[JSONL Parser] Processed ${lineCount} lines, found ${sessions.size} sessions`);
|
||||
} catch (error) {
|
||||
console.error('Error reading JSONL file:', error);
|
||||
}
|
||||
|
||||
// Convert Map to Array and sort by last activity
|
||||
return Array.from(sessions.values()).sort((a, b) =>
|
||||
new Date(b.lastActivity) - new Date(a.lastActivity)
|
||||
);
|
||||
}
|
||||
|
||||
// Get messages for a specific session
|
||||
async function getSessionMessages(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) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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 = require('fs').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
|
||||
return messages.sort((a, b) =>
|
||||
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error reading messages for session ${sessionId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 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 or as a folder
|
||||
const config = await loadProjectConfig();
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
await fs.access(projectDir);
|
||||
throw new Error(`Project already exists for path: ${absolutePath}`);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (config[projectName]) {
|
||||
throw new Error(`Project already configured for path: ${absolutePath}`);
|
||||
}
|
||||
|
||||
// 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: null,
|
||||
fullPath: absolutePath,
|
||||
displayName: displayName || generateDisplayName(projectName),
|
||||
isManuallyAdded: true,
|
||||
sessions: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
getProjects,
|
||||
getSessions,
|
||||
getSessionMessages,
|
||||
parseJsonlSessions,
|
||||
renameProject,
|
||||
deleteSession,
|
||||
isProjectEmpty,
|
||||
deleteProject,
|
||||
addProjectManually,
|
||||
loadProjectConfig,
|
||||
saveProjectConfig
|
||||
};
|
||||
Reference in New Issue
Block a user