first commit

This commit is contained in:
caraction
2025-06-25 14:29:07 +00:00
commit 5ea0e36207
60 changed files with 14674 additions and 0 deletions

207
server/claude-cli.js Normal file
View 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
};

View 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
View 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
View 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
};