Files
claudecodeui/server/index.js

872 lines
29 KiB
JavaScript
Executable File

// 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 fetch = require('node-fetch');
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git');
// 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')));
// Git API Routes
app.use('/api/git', gitRoutes);
// 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);
});
}
// Audio transcription endpoint
app.post('/api/transcribe', async (req, res) => {
try {
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
// Handle multipart form data
upload.single('audio')(req, res, async (err) => {
if (err) {
return res.status(400).json({ error: 'Failed to process audio file' });
}
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });
}
try {
// Create form data for OpenAI
const FormData = require('form-data');
const formData = new FormData();
formData.append('file', req.file.buffer, {
filename: req.file.originalname,
contentType: req.file.mimetype
});
formData.append('model', 'whisper-1');
formData.append('response_format', 'json');
formData.append('language', 'en');
// Make request to OpenAI
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
...formData.getHeaders()
},
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
}
const data = await response.json();
let transcribedText = data.text || '';
// Check if enhancement mode is enabled
const mode = req.body.mode || 'default';
// If no transcribed text, return empty
if (!transcribedText) {
return res.json({ text: '' });
}
// If default mode, return transcribed text without enhancement
if (mode === 'default') {
return res.json({ text: transcribedText });
}
// Handle different enhancement modes
try {
const OpenAI = require('openai');
const openai = new OpenAI({ apiKey });
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
switch (mode) {
case 'prompt':
systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
Your enhanced prompt should:
1. Be specific and unambiguous
2. Include relevant context and constraints
3. Specify the desired output format
4. Use clear, actionable language
5. Include examples where helpful
6. Consider edge cases and potential ambiguities
Transform this rough instruction into a well-crafted prompt:
"${transcribedText}"
Enhanced prompt:`;
break;
case 'vibe':
case 'instructions':
case 'architect':
systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
temperature = 0.5; // Lower temperature for more controlled output
prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
IMPORTANT RULES:
- Format as clear, step-by-step instructions
- Add reasonable implementation details based on common patterns
- Only include details directly related to what was asked
- Do NOT add features or functionality not mentioned
- Keep the original intent and scope intact
- Use clear, actionable language an agent can follow
Transform this idea into agent-friendly instructions:
"${transcribedText}"
Agent instructions:`;
break;
default:
// No enhancement needed
break;
}
// Only make GPT call if we have a prompt
if (prompt) {
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemMessage },
{ role: 'user', content: prompt }
],
temperature: temperature,
max_tokens: maxTokens
});
transcribedText = completion.choices[0].message.content || transcribedText;
}
} catch (gptError) {
console.error('GPT processing error:', gptError);
// Fall back to original transcription if GPT fails
}
res.json({ text: transcribedText });
} catch (error) {
console.error('Transcription error:', error);
res.status(500).json({ error: error.message });
}
});
} catch (error) {
console.error('Endpoint error:', error);
res.status(500).json({ error: 'Internal server 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
// 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();
});