mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-08 18:09:39 +00:00
1495 lines
57 KiB
JavaScript
Executable File
1495 lines
57 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Load environment variables from .env file
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname } from 'path';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// ANSI color codes for terminal output
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
bright: '\x1b[1m',
|
|
cyan: '\x1b[36m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
blue: '\x1b[34m',
|
|
dim: '\x1b[2m',
|
|
};
|
|
|
|
const c = {
|
|
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
|
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
|
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
|
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
|
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
|
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
|
};
|
|
|
|
try {
|
|
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);
|
|
|
|
import express from 'express';
|
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
import os from 'os';
|
|
import http from 'http';
|
|
import cors from 'cors';
|
|
import { promises as fsPromises } from 'fs';
|
|
import { spawn } from 'child_process';
|
|
import pty from 'node-pty';
|
|
import fetch from 'node-fetch';
|
|
import mime from 'mime-types';
|
|
|
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
|
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
|
import gitRoutes from './routes/git.js';
|
|
import authRoutes from './routes/auth.js';
|
|
import mcpRoutes from './routes/mcp.js';
|
|
import cursorRoutes from './routes/cursor.js';
|
|
import taskmasterRoutes from './routes/taskmaster.js';
|
|
import mcpUtilsRoutes from './routes/mcp-utils.js';
|
|
import commandsRoutes from './routes/commands.js';
|
|
import settingsRoutes from './routes/settings.js';
|
|
import agentRoutes from './routes/agent.js';
|
|
import projectsRoutes from './routes/projects.js';
|
|
import { initializeDatabase } from './database/db.js';
|
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
|
|
// File system watcher for projects folder
|
|
let projectsWatcher = null;
|
|
const connectedClients = new Set();
|
|
|
|
// Setup file system watcher for Claude projects folder using chokidar
|
|
async function setupProjectsWatcher() {
|
|
const chokidar = (await import('chokidar')).default;
|
|
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 {
|
|
|
|
// Clear project directory cache when files change
|
|
clearProjectDirectoryCache();
|
|
|
|
// 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 === WebSocket.OPEN) {
|
|
client.send(updateMessage);
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[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('[ERROR] Chokidar watcher error:', error);
|
|
})
|
|
.on('ready', () => {
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[ERROR] Failed to setup projects watcher:', error);
|
|
}
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
// Platform mode: always allow connection
|
|
if (process.env.VITE_IS_PLATFORM === 'true') {
|
|
const user = authenticateWebSocket(null); // Will return first user
|
|
if (!user) {
|
|
console.log('[WARN] Platform mode: No user found in database');
|
|
return false;
|
|
}
|
|
info.req.user = user;
|
|
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
|
|
return true;
|
|
}
|
|
|
|
// Normal mode: verify token
|
|
// Extract token from query parameters or headers
|
|
const url = new URL(info.req.url, 'http://localhost');
|
|
const token = url.searchParams.get('token') ||
|
|
info.req.headers.authorization?.split(' ')[1];
|
|
|
|
// Verify token
|
|
const user = authenticateWebSocket(token);
|
|
if (!user) {
|
|
console.log('[WARN] WebSocket authentication failed');
|
|
return false;
|
|
}
|
|
|
|
// Store user info in the request for later use
|
|
info.req.user = user;
|
|
console.log('[OK] WebSocket authenticated for user:', user.username);
|
|
return true;
|
|
}
|
|
});
|
|
|
|
// Make WebSocket server available to routes
|
|
app.locals.wss = wss;
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '50mb' }));
|
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
|
|
// Public health check endpoint (no authentication required)
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
// Optional API key validation (if configured)
|
|
app.use('/api', validateApiKey);
|
|
|
|
// Authentication routes (public)
|
|
app.use('/api/auth', authRoutes);
|
|
|
|
// Projects API Routes (protected)
|
|
app.use('/api/projects', authenticateToken, projectsRoutes);
|
|
|
|
// Git API Routes (protected)
|
|
app.use('/api/git', authenticateToken, gitRoutes);
|
|
|
|
// MCP API Routes (protected)
|
|
app.use('/api/mcp', authenticateToken, mcpRoutes);
|
|
|
|
// Cursor API Routes (protected)
|
|
app.use('/api/cursor', authenticateToken, cursorRoutes);
|
|
|
|
// TaskMaster API Routes (protected)
|
|
app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
|
|
|
|
// MCP utilities
|
|
app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
|
|
|
|
// Commands API Routes (protected)
|
|
app.use('/api/commands', authenticateToken, commandsRoutes);
|
|
|
|
// Settings API Routes (protected)
|
|
app.use('/api/settings', authenticateToken, settingsRoutes);
|
|
|
|
// Agent API Routes (uses API key authentication)
|
|
app.use('/api/agent', agentRoutes);
|
|
|
|
// Serve public files (like api-docs.html)
|
|
app.use(express.static(path.join(__dirname, '../public')));
|
|
|
|
// Static files served after API routes
|
|
// Add cache control: HTML files should not be cached, but assets can be cached
|
|
app.use(express.static(path.join(__dirname, '../dist'), {
|
|
setHeaders: (res, filePath) => {
|
|
if (filePath.endsWith('.html')) {
|
|
// Prevent HTML caching to avoid service worker issues after builds
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
|
// Cache static assets for 1 year (they have hashed names)
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
}
|
|
}
|
|
}));
|
|
|
|
// API Routes (protected)
|
|
app.get('/api/config', authenticateToken, (req, res) => {
|
|
const host = req.headers.host || `${req.hostname}:${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}`
|
|
});
|
|
});
|
|
|
|
// System update endpoint
|
|
app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
try {
|
|
// Get the project root directory (parent of server directory)
|
|
const projectRoot = path.join(__dirname, '..');
|
|
|
|
console.log('Starting system update from directory:', projectRoot);
|
|
|
|
// Run the update command
|
|
const updateCommand = 'git checkout main && git pull && npm install';
|
|
|
|
const child = spawn('sh', ['-c', updateCommand], {
|
|
cwd: projectRoot,
|
|
env: process.env
|
|
});
|
|
|
|
let output = '';
|
|
let errorOutput = '';
|
|
|
|
child.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
output += text;
|
|
console.log('Update output:', text);
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
const text = data.toString();
|
|
errorOutput += text;
|
|
console.error('Update error:', text);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
res.json({
|
|
success: true,
|
|
output: output || 'Update completed successfully',
|
|
message: 'Update completed. Please restart the server to apply changes.'
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Update command failed',
|
|
output: output,
|
|
errorOutput: errorOutput
|
|
});
|
|
}
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
console.error('Update process error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('System update error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects', authenticateToken, 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', authenticateToken, 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', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName, sessionId } = req.params;
|
|
const { limit, offset } = req.query;
|
|
|
|
// Parse limit and offset if provided
|
|
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
|
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
|
|
|
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
|
|
|
// Handle both old and new response formats
|
|
if (Array.isArray(result)) {
|
|
// Backward compatibility: no pagination parameters were provided
|
|
res.json({ messages: result });
|
|
} else {
|
|
// New format with pagination info
|
|
res.json(result);
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Rename project endpoint
|
|
app.put('/api/projects/:projectName/rename', authenticateToken, 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', authenticateToken, 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', authenticateToken, 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', authenticateToken, 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 });
|
|
}
|
|
});
|
|
|
|
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { path: dirPath } = req.query;
|
|
|
|
// Default to home directory if no path provided
|
|
const homeDir = os.homedir();
|
|
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
|
|
|
|
// Resolve and normalize the path
|
|
targetPath = path.resolve(targetPath);
|
|
|
|
// Security check - ensure path is accessible
|
|
try {
|
|
await fs.promises.access(targetPath);
|
|
const stats = await fs.promises.stat(targetPath);
|
|
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Path is not a directory' });
|
|
}
|
|
} catch (err) {
|
|
return res.status(404).json({ error: 'Directory not accessible' });
|
|
}
|
|
|
|
// Use existing getFileTree function with shallow depth (only direct children)
|
|
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
|
|
|
|
// Filter only directories and format for suggestions
|
|
const directories = fileTree
|
|
.filter(item => item.type === 'directory')
|
|
.map(item => ({
|
|
path: item.path,
|
|
name: item.name,
|
|
type: 'directory'
|
|
}))
|
|
.slice(0, 20); // Limit results
|
|
|
|
// Add common directories if browsing home directory
|
|
const suggestions = [];
|
|
if (targetPath === homeDir) {
|
|
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
|
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
|
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
|
|
|
suggestions.push(...existingCommon, ...otherDirs);
|
|
} else {
|
|
suggestions.push(...directories);
|
|
}
|
|
|
|
res.json({
|
|
path: targetPath,
|
|
suggestions: suggestions
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error browsing filesystem:', error);
|
|
res.status(500).json({ error: 'Failed to browse filesystem' });
|
|
}
|
|
});
|
|
|
|
// Read file content endpoint
|
|
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { filePath } = req.query;
|
|
|
|
console.log('[DEBUG] File read request:', projectName, filePath);
|
|
|
|
// Security: ensure the requested path is inside the project root
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Handle both absolute and relative paths
|
|
const resolved = path.isAbsolute(filePath)
|
|
? path.resolve(filePath)
|
|
: path.resolve(projectRoot, filePath);
|
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
}
|
|
|
|
const content = await fsPromises.readFile(resolved, 'utf8');
|
|
res.json({ content, path: resolved });
|
|
} 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', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { path: filePath } = req.query;
|
|
|
|
console.log('[DEBUG] Binary file serve request:', projectName, filePath);
|
|
|
|
// Security: ensure the requested path is inside the project root
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
const resolved = path.resolve(filePath);
|
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
}
|
|
|
|
// Check if file exists
|
|
try {
|
|
await fsPromises.access(resolved);
|
|
} catch (error) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
|
|
// Get file extension and set appropriate content type
|
|
const mimeType = mime.lookup(resolved) || 'application/octet-stream';
|
|
res.setHeader('Content-Type', mimeType);
|
|
|
|
// Stream the file
|
|
const fileStream = fs.createReadStream(resolved);
|
|
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', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName } = req.params;
|
|
const { filePath, content } = req.body;
|
|
|
|
console.log('[DEBUG] File save request:', projectName, filePath);
|
|
|
|
// Security: ensure the requested path is inside the project root
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|
}
|
|
|
|
if (content === undefined) {
|
|
return res.status(400).json({ error: 'Content is required' });
|
|
}
|
|
|
|
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
if (!projectRoot) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Handle both absolute and relative paths
|
|
const resolved = path.isAbsolute(filePath)
|
|
? path.resolve(filePath)
|
|
: path.resolve(projectRoot, filePath);
|
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
}
|
|
|
|
// Write the new content
|
|
await fsPromises.writeFile(resolved, content, 'utf8');
|
|
|
|
res.json({
|
|
success: true,
|
|
path: resolved,
|
|
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', authenticateToken, async (req, res) => {
|
|
try {
|
|
|
|
// Using fsPromises from import
|
|
|
|
// Use extractProjectDirectory to get the actual project path
|
|
let actualPath;
|
|
try {
|
|
actualPath = await extractProjectDirectory(req.params.projectName);
|
|
} catch (error) {
|
|
console.error('Error extracting project directory:', error);
|
|
// Fallback to simple dash replacement
|
|
actualPath = req.params.projectName.replace(/-/g, '/');
|
|
}
|
|
|
|
// Check if path exists
|
|
try {
|
|
await fsPromises.access(actualPath);
|
|
} catch (e) {
|
|
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
|
}
|
|
|
|
const files = await getFileTree(actualPath, 10, 0, true);
|
|
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
|
res.json(files);
|
|
} catch (error) {
|
|
console.error('[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('[INFO] Client connected to:', url);
|
|
|
|
// Parse URL to get pathname without query parameters
|
|
const urlObj = new URL(url, 'http://localhost');
|
|
const pathname = urlObj.pathname;
|
|
|
|
if (pathname === '/shell') {
|
|
handleShellConnection(ws);
|
|
} else if (pathname === '/ws') {
|
|
handleChatConnection(ws);
|
|
} else {
|
|
console.log('[WARN] Unknown WebSocket path:', pathname);
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
// Handle chat WebSocket connections
|
|
function handleChatConnection(ws) {
|
|
console.log('[INFO] 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('[DEBUG] User message:', data.command || '[Continue/Resume]');
|
|
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
|
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
|
|
// Use Claude Agents SDK
|
|
await queryClaudeSDK(data.command, data.options, ws);
|
|
} else if (data.type === 'cursor-command') {
|
|
console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
|
|
console.log('📁 Project:', data.options?.cwd || 'Unknown');
|
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
console.log('🤖 Model:', data.options?.model || 'default');
|
|
await spawnCursor(data.command, data.options, ws);
|
|
} else if (data.type === 'cursor-resume') {
|
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
|
await spawnCursor('', {
|
|
sessionId: data.sessionId,
|
|
resume: true,
|
|
cwd: data.options?.cwd
|
|
}, ws);
|
|
} else if (data.type === 'abort-session') {
|
|
console.log('[DEBUG] Abort session request:', data.sessionId);
|
|
const provider = data.provider || 'claude';
|
|
let success;
|
|
|
|
if (provider === 'cursor') {
|
|
success = abortCursorSession(data.sessionId);
|
|
} else {
|
|
// Use Claude Agents SDK
|
|
success = await abortClaudeSDKSession(data.sessionId);
|
|
}
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'session-aborted',
|
|
sessionId: data.sessionId,
|
|
provider,
|
|
success
|
|
}));
|
|
} else if (data.type === 'cursor-abort') {
|
|
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
|
const success = abortCursorSession(data.sessionId);
|
|
ws.send(JSON.stringify({
|
|
type: 'session-aborted',
|
|
sessionId: data.sessionId,
|
|
provider: 'cursor',
|
|
success
|
|
}));
|
|
} else if (data.type === 'check-session-status') {
|
|
// Check if a specific session is currently processing
|
|
const provider = data.provider || 'claude';
|
|
const sessionId = data.sessionId;
|
|
let isActive;
|
|
|
|
if (provider === 'cursor') {
|
|
isActive = isCursorSessionActive(sessionId);
|
|
} else {
|
|
// Use Claude Agents SDK
|
|
isActive = isClaudeSDKSessionActive(sessionId);
|
|
}
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'session-status',
|
|
sessionId,
|
|
provider,
|
|
isProcessing: isActive
|
|
}));
|
|
} else if (data.type === 'get-active-sessions') {
|
|
// Get all currently active sessions
|
|
const activeSessions = {
|
|
claude: getActiveClaudeSDKSessions(),
|
|
cursor: getActiveCursorSessions()
|
|
};
|
|
ws.send(JSON.stringify({
|
|
type: 'active-sessions',
|
|
sessions: activeSessions
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('[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;
|
|
const provider = data.provider || 'claude';
|
|
const initialCommand = data.initialCommand;
|
|
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
|
|
|
console.log('[INFO] Starting shell in:', projectPath);
|
|
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
|
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
|
if (initialCommand) {
|
|
console.log('⚡ Initial command:', initialCommand);
|
|
}
|
|
|
|
// First send a welcome message
|
|
let welcomeMsg;
|
|
if (isPlainShell) {
|
|
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
|
} else {
|
|
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
|
welcomeMsg = hasSession ?
|
|
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
|
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
}
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: welcomeMsg
|
|
}));
|
|
|
|
try {
|
|
// Prepare the shell command adapted to the platform and provider
|
|
let shellCommand;
|
|
if (isPlainShell) {
|
|
// Plain shell mode - just run the initial command in the project directory
|
|
if (os.platform() === 'win32') {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
|
|
} else {
|
|
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
|
|
}
|
|
} else if (provider === 'cursor') {
|
|
// Use cursor-agent command
|
|
if (os.platform() === 'win32') {
|
|
if (hasSession && sessionId) {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
|
|
} else {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
|
|
}
|
|
} else {
|
|
if (hasSession && sessionId) {
|
|
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
|
|
} else {
|
|
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
|
}
|
|
}
|
|
} else {
|
|
// Use claude command (default) or initialCommand if provided
|
|
const command = initialCommand || 'claude';
|
|
if (os.platform() === 'win32') {
|
|
if (hasSession && sessionId) {
|
|
// Try to resume session, but with fallback to new session if it fails
|
|
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
|
|
} else {
|
|
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
|
}
|
|
} else {
|
|
if (hasSession && sessionId) {
|
|
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
|
|
} else {
|
|
shellCommand = `cd "${projectPath}" && ${command}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('🔧 Executing shell command:', shellCommand);
|
|
|
|
// Use appropriate shell based on platform
|
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
|
|
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
|
|
env: {
|
|
...process.env,
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
FORCE_COLOR: '3',
|
|
// Override browser opening commands to echo URL for detection
|
|
BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"'
|
|
}
|
|
});
|
|
|
|
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
|
|
|
|
// Handle data output
|
|
shellProcess.onData((data) => {
|
|
if (ws.readyState === WebSocket.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('[DEBUG] 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], `[INFO] 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 === WebSocket.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] 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('[ERROR] Shell WebSocket error:', error.message);
|
|
if (ws.readyState === WebSocket.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('[ERROR] Shell WebSocket error:', error);
|
|
});
|
|
}
|
|
// Audio transcription endpoint
|
|
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
|
try {
|
|
const multer = (await import('multer')).default;
|
|
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 = (await import('form-data')).default;
|
|
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 = (await import('openai')).default;
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// Image upload endpoint
|
|
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
|
try {
|
|
const multer = (await import('multer')).default;
|
|
const path = (await import('path')).default;
|
|
const fs = (await import('fs')).promises;
|
|
const os = (await import('os')).default;
|
|
|
|
// Configure multer for image uploads
|
|
const storage = multer.diskStorage({
|
|
destination: async (req, file, cb) => {
|
|
const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
|
|
await fs.mkdir(uploadDir, { recursive: true });
|
|
cb(null, uploadDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
cb(null, uniqueSuffix + '-' + sanitizedName);
|
|
}
|
|
});
|
|
|
|
const fileFilter = (req, file, cb) => {
|
|
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
if (allowedMimes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
|
}
|
|
};
|
|
|
|
const upload = multer({
|
|
storage,
|
|
fileFilter,
|
|
limits: {
|
|
fileSize: 5 * 1024 * 1024, // 5MB
|
|
files: 5
|
|
}
|
|
});
|
|
|
|
// Handle multipart form data
|
|
upload.array('images', 5)(req, res, async (err) => {
|
|
if (err) {
|
|
return res.status(400).json({ error: err.message });
|
|
}
|
|
|
|
if (!req.files || req.files.length === 0) {
|
|
return res.status(400).json({ error: 'No image files provided' });
|
|
}
|
|
|
|
try {
|
|
// Process uploaded images
|
|
const processedImages = await Promise.all(
|
|
req.files.map(async (file) => {
|
|
// Read file and convert to base64
|
|
const buffer = await fs.readFile(file.path);
|
|
const base64 = buffer.toString('base64');
|
|
const mimeType = file.mimetype;
|
|
|
|
// Clean up temp file immediately
|
|
await fs.unlink(file.path);
|
|
|
|
return {
|
|
name: file.originalname,
|
|
data: `data:${mimeType};base64,${base64}`,
|
|
size: file.size,
|
|
mimeType: mimeType
|
|
};
|
|
})
|
|
);
|
|
|
|
res.json({ images: processedImages });
|
|
} catch (error) {
|
|
console.error('Error processing images:', error);
|
|
// Clean up any remaining files
|
|
await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
|
|
res.status(500).json({ error: 'Failed to process images' });
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error in image upload endpoint:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Get token usage for a specific session
|
|
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { projectName, sessionId } = req.params;
|
|
const homeDir = os.homedir();
|
|
|
|
// Extract actual project path
|
|
let projectPath;
|
|
try {
|
|
projectPath = await extractProjectDirectory(projectName);
|
|
} catch (error) {
|
|
console.error('Error extracting project directory:', error);
|
|
return res.status(500).json({ error: 'Failed to determine project path' });
|
|
}
|
|
|
|
// Construct the JSONL file path
|
|
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
// The encoding replaces /, spaces, ~, and _ with -
|
|
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
|
|
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
|
|
// Allow only safe characters in sessionId
|
|
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
if (!safeSessionId) {
|
|
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
}
|
|
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
|
|
// Constrain to projectDir
|
|
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
return res.status(400).json({ error: 'Invalid path' });
|
|
}
|
|
|
|
// Read and parse the JSONL file
|
|
let fileContent;
|
|
try {
|
|
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
}
|
|
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
}
|
|
const lines = fileContent.trim().split('\n');
|
|
|
|
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
let inputTokens = 0;
|
|
let cacheCreationTokens = 0;
|
|
let cacheReadTokens = 0;
|
|
|
|
// Find the latest assistant message with usage data (scan from end)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const entry = JSON.parse(lines[i]);
|
|
|
|
// Only count assistant messages which have usage data
|
|
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
const usage = entry.message.usage;
|
|
|
|
// Use token counts from latest assistant message only
|
|
inputTokens = usage.input_tokens || 0;
|
|
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
|
|
break; // Stop after finding the latest assistant message
|
|
}
|
|
} catch (parseError) {
|
|
// Skip lines that can't be parsed
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
|
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
|
|
res.json({
|
|
used: totalUsed,
|
|
total: contextWindow,
|
|
breakdown: {
|
|
input: inputTokens,
|
|
cacheCreation: cacheCreationTokens,
|
|
cacheRead: cacheReadTokens
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error reading session token usage:', error);
|
|
res.status(500).json({ error: 'Failed to read session token usage' });
|
|
}
|
|
});
|
|
|
|
// Serve React app for all other routes (excluding static files)
|
|
app.get('*', (req, res) => {
|
|
// Skip requests for static assets (files with extensions)
|
|
if (path.extname(req.path)) {
|
|
return res.status(404).send('Not found');
|
|
}
|
|
|
|
// Only serve index.html for HTML routes, not for static assets
|
|
// Static assets should already be handled by express.static middleware above
|
|
const indexPath = path.join(__dirname, '../dist/index.html');
|
|
|
|
// Check if dist/index.html exists (production build available)
|
|
if (fs.existsSync(indexPath)) {
|
|
// Set no-cache headers for HTML to prevent service worker issues
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
res.sendFile(indexPath);
|
|
} else {
|
|
// In development, redirect to Vite dev server only if dist doesn't exist
|
|
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
|
}
|
|
});
|
|
|
|
// Helper function to convert permissions to rwx format
|
|
function permToRwx(perm) {
|
|
const r = perm & 4 ? 'r' : '-';
|
|
const w = perm & 2 ? 'w' : '-';
|
|
const x = perm & 1 ? 'x' : '-';
|
|
return r + w + x;
|
|
}
|
|
|
|
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
|
// Using fsPromises from import
|
|
const items = [];
|
|
|
|
try {
|
|
const entries = await fsPromises.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 itemPath = path.join(dirPath, entry.name);
|
|
const item = {
|
|
name: entry.name,
|
|
path: itemPath,
|
|
type: entry.isDirectory() ? 'directory' : 'file'
|
|
};
|
|
|
|
// Get file stats for additional metadata
|
|
try {
|
|
const stats = await fsPromises.stat(itemPath);
|
|
item.size = stats.size;
|
|
item.modified = stats.mtime.toISOString();
|
|
|
|
// Convert permissions to rwx format
|
|
const mode = stats.mode;
|
|
const ownerPerm = (mode >> 6) & 7;
|
|
const groupPerm = (mode >> 3) & 7;
|
|
const otherPerm = mode & 7;
|
|
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
|
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
|
} catch (statError) {
|
|
// If stat fails, provide default values
|
|
item.size = 0;
|
|
item.modified = null;
|
|
item.permissions = '000';
|
|
item.permissionsRwx = '---------';
|
|
}
|
|
|
|
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 fsPromises.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 || 3001;
|
|
|
|
// Initialize database and start server
|
|
async function startServer() {
|
|
try {
|
|
// Initialize authentication database
|
|
await initializeDatabase();
|
|
|
|
// Check if running in production mode (dist folder exists)
|
|
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
|
const isProduction = fs.existsSync(distIndexPath);
|
|
|
|
// Log Claude implementation mode
|
|
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
|
|
console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
|
|
|
|
if (!isProduction) {
|
|
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
|
|
}
|
|
|
|
server.listen(PORT, '0.0.0.0', async () => {
|
|
const appInstallPath = path.join(__dirname, '..');
|
|
|
|
console.log('');
|
|
console.log(c.dim('═'.repeat(63)));
|
|
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
|
console.log(c.dim('═'.repeat(63)));
|
|
console.log('');
|
|
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
|
|
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
|
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
|
|
console.log('');
|
|
|
|
// Start watching the projects folder for changes
|
|
await setupProjectsWatcher();
|
|
});
|
|
} catch (error) {
|
|
console.error('[ERROR] Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
startServer();
|