const express = require('express'); const session = require('express-session'); const bcrypt = require('bcryptjs'); const path = require('path'); const fs = require('fs'); const MarkdownIt = require('markdown-it'); const hljs = require('highlight.js'); const WebSocket = require('ws'); const ClaudeCodeService = require('./services/claude-service'); const terminalService = require('./services/terminal-service'); const { db } = require('./services/database'); const app = express(); const md = new MarkdownIt({ html: true, linkify: true, typographer: true, highlight: function (str, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(str, { language: lang }).value; } catch (__) {} } return ''; } }); // Configuration const VAULT_PATH = '/home/uroma/obsidian-vault'; const PORT = 3010; const SESSION_SECRET = 'obsidian-web-secret-' + Math.random().toString(36).substring(2); // Users database (in production, use a real database) const users = { admin: { passwordHash: bcrypt.hashSync('!@#$q1w2e3r4!A', 10) } }; // Initialize Claude Code Service const claudeService = new ClaudeCodeService(VAULT_PATH); // Cleanup old sessions every hour setInterval(() => { claudeService.cleanup(); }, 60 * 60 * 1000); // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: false, // Set to true if using HTTPS maxAge: 24 * 60 * 60 * 1000 // 24 hours } })); // Authentication middleware function requireAuth(req, res, next) { if (req.session.userId) { next(); } else { res.status(401).json({ error: 'Unauthorized' }); } } // Routes // Sessions landing page (root of /claude/) app.get('/claude/', (req, res) => { if (req.session.userId) { // Authenticated - show landing page res.sendFile(path.join(__dirname, 'public', 'claude-landing.html')); } else { // Not authenticated - show login page res.sendFile(path.join(__dirname, 'public', 'index.html')); } }); // IDE routes app.get('/claude/ide', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'claude-ide', 'index.html')); }); app.get('/claude/ide/*', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'claude-ide', 'index.html')); }); // Serve static files (must come after specific routes) app.use('/claude', express.static(path.join(__dirname, 'public'))); // Login route app.post('/claude/api/login', (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } const user = users[username]; if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); } const isValid = bcrypt.compareSync(password, user.passwordHash); if (!isValid) { return res.status(401).json({ error: 'Invalid credentials' }); } req.session.userId = username; req.session.username = username; res.json({ success: true, username }); }); // Logout route app.post('/claude/api/logout', (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: 'Logout failed' }); } res.json({ success: true }); }); }); // Check auth status app.get('/claude/api/auth/status', (req, res) => { if (req.session.userId) { res.json({ authenticated: true, username: req.session.username }); } else { res.json({ authenticated: false }); } }); // Get file tree app.get('/claude/api/files', requireAuth, (req, res) => { try { const getFileTree = (dir, relativePath = '') => { const items = []; const files = fs.readdirSync(dir); files.forEach(file => { if (file === '.obsidian') return; // Skip Obsidian config const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); const relPath = path.join(relativePath, file); if (stat.isDirectory()) { items.push({ name: file, type: 'folder', path: relPath, children: getFileTree(fullPath, relPath) }); } else if (file.endsWith('.md')) { items.push({ name: file, type: 'file', path: relPath }); } }); return items.sort((a, b) => { if (a.type === b.type) return a.name.localeCompare(b.name); return a.type === 'folder' ? -1 : 1; }); }; const tree = getFileTree(VAULT_PATH); res.json({ tree }); } catch (error) { console.error('Error reading files:', error); res.status(500).json({ error: 'Failed to read files' }); } }); // Get file content app.get('/claude/api/file/*', requireAuth, (req, res) => { try { const filePath = req.path.replace('/claude/api/file/', ''); const fullPath = path.join(VAULT_PATH, filePath); // Security check if (!fullPath.startsWith(VAULT_PATH)) { return res.status(403).json({ error: 'Access denied' }); } if (!fs.existsSync(fullPath)) { return res.status(404).json({ error: 'File not found' }); } const content = fs.readFileSync(fullPath, 'utf-8'); const stats = fs.statSync(fullPath); // Check if file is HTML const isHtmlFile = filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm'); if (isHtmlFile) { // Return raw HTML content without processing res.json({ path: filePath, content: content, // Raw content html: `
${content.replace(//g, '>')}
`, frontmatter: {}, modified: stats.mtime, created: stats.birthtime }); return; } // Parse frontmatter for non-HTML files let frontmatter = {}; let markdownContent = content; const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (frontmatterMatch) { try { frontmatterMatch[1].split('\n').forEach(line => { const [key, ...valueParts] = line.split(':'); if (key && valueParts.length > 0) { frontmatter[key.trim()] = valueParts.join(':').trim(); } }); } catch (e) { console.error('Error parsing frontmatter:', e); } markdownContent = content.replace(/^---\n[\s\S]*?\n---\n?/, ''); } // Convert markdown to HTML const htmlContent = md.render(markdownContent); res.json({ path: filePath, content: markdownContent, html: htmlContent, frontmatter, modified: stats.mtime, created: stats.birthtime }); } catch (error) { console.error('Error reading file:', error); res.status(500).json({ error: 'Failed to read file' }); } }); // Save file content app.put('/claude/api/file/*', requireAuth, (req, res) => { try { const filePath = req.path.replace('/claude/api/file/', ''); const fullPath = path.join(VAULT_PATH, filePath); const { content } = req.body; // Security check if (!fullPath.startsWith(VAULT_PATH)) { return res.status(403).json({ error: 'Access denied' }); } // Create directory if it doesn't exist const dir = path.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(fullPath, content, 'utf-8'); res.json({ success: true }); } catch (error) { console.error('Error saving file:', error); res.status(500).json({ error: 'Failed to save file' }); } }); // Create new file app.post('/claude/api/file', requireAuth, (req, res) => { try { const { path: filePath, content = '' } = req.body; const fullPath = path.join(VAULT_PATH, filePath); // Security check if (!fullPath.startsWith(VAULT_PATH)) { return res.status(403).json({ error: 'Access denied' }); } // Create directory if it doesn't exist const dir = path.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } if (fs.existsSync(fullPath)) { return res.status(409).json({ error: 'File already exists' }); } fs.writeFileSync(fullPath, content, 'utf-8'); res.json({ success: true, path: filePath }); } catch (error) { console.error('Error creating file:', error); res.status(500).json({ error: 'Failed to create file' }); } }); // Search files app.get('/claude/api/search', requireAuth, (req, res) => { try { const { q } = req.query; if (!q) { return res.json({ results: [] }); } const searchInDir = (dir, relativePath = '') => { const results = []; const files = fs.readdirSync(dir); files.forEach(file => { if (file === '.obsidian') return; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); const relPath = path.join(relativePath, file); if (stat.isDirectory()) { results.push(...searchInDir(fullPath, relPath)); } else if (file.endsWith('.md')) { const content = fs.readFileSync(fullPath, 'utf-8'); const lowerContent = content.toLowerCase(); const lowerQ = q.toLowerCase(); if (lowerContent.includes(lowerQ) || file.toLowerCase().includes(lowerQ)) { // Extract a preview const lines = content.split('\n'); const previewLines = []; for (const line of lines) { if (line.toLowerCase().includes(lowerQ)) { previewLines.push(line.trim()); if (previewLines.length >= 3) break; } } results.push({ path: relPath, name: file, preview: previewLines.join('...').substring(0, 200) }); } } }); return results; }; const results = searchInDir(VAULT_PATH); res.json({ results }); } catch (error) { console.error('Error searching:', error); res.status(500).json({ error: 'Search failed' }); } }); // Get recent files app.get('/claude/api/recent', requireAuth, (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const files = []; const getFilesRecursively = (dir, relativePath = '') => { const items = fs.readdirSync(dir); items.forEach(file => { if (file === '.obsidian') return; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); const relPath = path.join(relativePath, file); if (stat.isDirectory()) { getFilesRecursively(fullPath, relPath); } else if (file.endsWith('.md')) { files.push({ path: relPath, name: file, modified: stat.mtime }); } }); }; getFilesRecursively(VAULT_PATH); files.sort((a, b) => b.modified - a.modified); res.json({ files: files.slice(0, limit) }); } catch (error) { console.error('Error getting recent files:', error); res.status(500).json({ error: 'Failed to get recent files' }); } }); // ============================================ // Claude Code IDE API Routes // ============================================ // Session Management app.get('/claude/api/claude/sessions', requireAuth, (req, res) => { try { const activeSessions = claudeService.listSessions(); const historicalSessions = claudeService.loadHistoricalSessions(); res.json({ active: activeSessions, historical: historicalSessions }); } catch (error) { console.error('Error listing sessions:', error); res.status(500).json({ error: 'Failed to list sessions' }); } }); app.post('/claude/api/claude/sessions', requireAuth, (req, res) => { try { const { workingDir, metadata } = req.body; const session = claudeService.createSession({ workingDir, metadata }); res.json({ success: true, session: { id: session.id, pid: session.pid, workingDir: session.workingDir, status: session.status, createdAt: session.createdAt } }); } catch (error) { console.error('Error creating session:', error); res.status(500).json({ error: 'Failed to create session' }); } }); app.get('/claude/api/claude/sessions/:id', requireAuth, (req, res) => { try { const session = claudeService.getSession(req.params.id); res.json({ session }); } catch (error) { res.status(404).json({ error: error.message }); } }); app.post('/claude/api/claude/sessions/:id/command', requireAuth, (req, res) => { try { const { command } = req.body; const result = claudeService.sendCommand(req.params.id, command); res.json(result); } catch (error) { res.status(400).json({ error: error.message }); } }); // Context Management app.get('/claude/api/claude/sessions/:id/context', requireAuth, (req, res) => { try { const stats = claudeService.getContextStats(req.params.id); res.json({ stats }); } catch (error) { res.status(404).json({ error: error.message }); } }); // Operations Management - Preview operations before executing app.post('/claude/api/claude/sessions/:id/operations/preview', requireAuth, async (req, res) => { try { const { response } = req.body; const preview = await claudeService.previewOperations(req.params.id, response); res.json({ preview }); } catch (error) { console.error('Error previewing operations:', error); res.status(400).json({ error: error.message }); } }); // Operations Management - Execute approved operations app.post('/claude/api/claude/sessions/:id/operations/execute', requireAuth, async (req, res) => { try { const { response } = req.body; // Set up progress callback const onProgress = (progress) => { // Emit progress via WebSocket to all subscribed clients wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify({ type: 'operation-progress', sessionId: req.params.id, progress })); } }); }; const results = await claudeService.executeOperations(req.params.id, response, onProgress); res.json({ results }); } catch (error) { console.error('Error executing operations:', error); res.status(400).json({ error: error.message }); } }); // Update session metadata app.patch('/claude/api/claude/sessions/:id', requireAuth, async (req, res) => { try { const { metadata } = req.body; const sessionId = req.params.id; // Get session let session = claudeService.sessions.get(sessionId); if (!session) { // Try to load from historical sessions const historicalSessions = claudeService.loadHistoricalSessions(); const historical = historicalSessions.find(s => s.id === sessionId); if (!historical) { return res.status(404).json({ error: 'Session not found' }); } // For historical sessions, we can't update metadata directly // Return error for now return res.status(400).json({ error: 'Cannot update historical session metadata' }); } // Update metadata if (metadata && typeof metadata === 'object') { session.metadata = { ...session.metadata, ...metadata }; } res.json({ success: true, session: { id: session.id, metadata: session.metadata, workingDir: session.workingDir } }); } catch (error) { console.error('Error updating session:', error); res.status(500).json({ error: error.message }); } }); // Duplicate session app.post('/claude/api/claude/sessions/:id/duplicate', requireAuth, (req, res) => { try { const sessionId = req.params.id; // Get source session let sourceSession = claudeService.sessions.get(sessionId); if (!sourceSession) { return res.status(404).json({ error: 'Source session not found' }); } // Security check: validate workingDir is within VAULT_PATH const fullPath = path.resolve(sourceSession.workingDir); if (!fullPath.startsWith(VAULT_PATH)) { return res.status(400).json({ error: 'Invalid working directory' }); } // Create new session with same settings const newSession = claudeService.createSession({ workingDir: sourceSession.workingDir, metadata: { ...sourceSession.metadata, duplicatedFrom: sessionId, source: 'web-ide' } }); res.json({ success: true, session: { id: newSession.id, pid: newSession.pid, workingDir: newSession.workingDir, status: newSession.status, createdAt: newSession.createdAt, metadata: newSession.metadata } }); } catch (error) { console.error('Error duplicating session:', error); res.status(500).json({ error: 'Failed to duplicate session' }); } }); // Delete session app.delete('/claude/api/claude/sessions/:id', requireAuth, (req, res) => { try { const sessionId = req.params.id; // Check if session exists const session = claudeService.sessions.get(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } // Kill the claude process if running using service method if (session.status === 'running' && session.process) { try { claudeService.terminateSession(sessionId); } catch (error) { // If termination fails, log but continue with deletion console.error(`Error terminating session ${sessionId}:`, error.message); } } // Remove from sessions map claudeService.sessions.delete(sessionId); // Delete session file if exists const sessionFile = path.join(claudeService.claudeSessionsDir, `${sessionId}.md`); if (fs.existsSync(sessionFile)) { fs.unlinkSync(sessionFile); } res.json({ success: true }); } catch (error) { console.error('Error deleting session:', error); res.status(500).json({ error: 'Failed to delete session' }); } }); // Preview Management - Start preview server app.post('/claude/api/claude/sessions/:id/preview/start', requireAuth, async (req, res) => { try { const { workingDir } = req.body; // For now, return a simple response // In a full implementation, this would integrate with the workflow service // to detect project type and start appropriate dev server res.json({ success: true, url: null, port: null, processId: null, message: 'Preview functionality requires workflow service integration' }); } catch (error) { console.error('Error starting preview:', error); res.status(400).json({ error: error.message }); } }); // Preview Management - Stop preview server app.post('/claude/api/claude/sessions/:id/preview/stop', requireAuth, async (req, res) => { try { // For now, return a simple response // In a full implementation, this would stop the preview server res.json({ success: true, message: 'Preview stopped' }); } catch (error) { console.error('Error stopping preview:', error); res.status(400).json({ error: error.message }); } }); // Project Management app.get('/claude/api/claude/projects', requireAuth, (req, res) => { try { const projectsDir = path.join(VAULT_PATH, 'Claude Projects'); let projects = []; if (fs.existsSync(projectsDir)) { const files = fs.readdirSync(projectsDir); const projectFiles = files.filter(f => f.endsWith('.md') && !f.includes('active-projects') && !f.includes('templates') ); projects = projectFiles.map(file => { const filepath = path.join(projectsDir, file); const stat = fs.statSync(filepath); return { name: file.replace('.md', ''), path: `Claude Projects/${file}`, modified: stat.mtime }; }); } res.json({ projects }); } catch (error) { console.error('Error listing projects:', error); res.status(500).json({ error: 'Failed to list projects' }); } }); app.post('/claude/api/claude/projects', requireAuth, (req, res) => { try { const { name, description, type } = req.body; const projectsDir = path.join(VAULT_PATH, 'Claude Projects'); if (!fs.existsSync(projectsDir)) { fs.mkdirSync(projectsDir, { recursive: true }); } const filename = `${name}.md`; const filepath = path.join(projectsDir, filename); if (fs.existsSync(filepath)) { return res.status(409).json({ error: 'Project already exists' }); } const content = `--- type: project name: ${name} created: ${new Date().toISOString()} status: planning --- # ${name} ${description || ''} ## Planning \`\`\`tasks path includes ${name} not done sort by created \`\`\` ## Tasks ## Notes --- Created via Claude Code Web IDE `; fs.writeFileSync(filepath, content, 'utf-8'); res.json({ success: true, project: { name, path: `Claude Projects/${filename}` } }); } catch (error) { console.error('Error creating project:', error); res.status(500).json({ error: 'Failed to create project' }); } }); // ============================================ // Project CRUD API Endpoints (SQLite) // ============================================ // Helper function to validate project ID function validateProjectId(id) { const idNum = parseInt(id, 10); if (isNaN(idNum) || idNum <= 0 || !Number.isInteger(idNum)) { return null; } return idNum; } // Helper function to validate project path is within allowed scope function validateProjectPath(projectPath) { // Resolve the absolute path const resolvedPath = path.resolve(projectPath); // For now, we'll allow any absolute path // In production, you might want to restrict to specific directories // Check if it's an absolute path if (!path.isAbsolute(resolvedPath)) { return { valid: false, error: 'Path must be absolute' }; } // Check for path traversal attempts const normalizedPath = path.normalize(projectPath); if (normalizedPath !== projectPath && !projectPath.startsWith('..')) { // Path contained relative components that were normalized return { valid: false, error: 'Path contains invalid components' }; } return { valid: true, path: resolvedPath }; } // GET /api/projects - List all active projects app.get('/api/projects', requireAuth, (req, res) => { try { const projects = db.prepare(` SELECT id, name, description, icon, color, path, createdAt, lastActivity FROM projects WHERE deletedAt IS NULL ORDER BY lastActivity DESC `).all(); // Add sessionCount (0 for now, will be implemented in Task 3) const projectsWithSessionCount = projects.map(project => ({ ...project, sessionCount: 0 })); res.json({ success: true, projects: projectsWithSessionCount }); } catch (error) { console.error('Error listing projects:', error); res.status(500).json({ error: 'Failed to list projects' }); } }); // POST /api/projects - Create new project app.post('/api/projects', requireAuth, (req, res) => { try { const { name, path: projectPath, description, icon, color } = req.body; // Validate required fields if (!name || !projectPath) { return res.status(400).json({ error: 'Name and path are required' }); } // Validate path const pathValidation = validateProjectPath(projectPath); if (!pathValidation.valid) { return res.status(400).json({ error: pathValidation.error }); } // Check for duplicate names (only among non-deleted projects) const existing = db.prepare(` SELECT id FROM projects WHERE name = ? AND deletedAt IS NULL `).get(name); if (existing) { return res.status(409).json({ error: 'Project with this name already exists' }); } const now = new Date().toISOString(); // Set defaults const projectIcon = icon || '📁'; const projectColor = color || '#4a9eff'; const result = db.prepare(` INSERT INTO projects (name, description, icon, color, path, createdAt, lastActivity) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(name, description || null, projectIcon, projectColor, pathValidation.path, now, now); // Get the inserted project const project = db.prepare(` SELECT id, name, description, icon, color, path, createdAt, lastActivity FROM projects WHERE id = ? `).get(result.lastInsertRowid); res.status(201).json({ success: true, project: { ...project, sessionCount: 0 } }); } catch (error) { console.error('Error creating project:', error); res.status(500).json({ error: 'Failed to create project' }); } }); // PUT /api/projects/:id - Update project app.put('/api/projects/:id', requireAuth, (req, res) => { try { const { id } = req.params; const { name, description, icon, color, path: projectPath } = req.body; // Validate ID const validatedId = validateProjectId(id); if (!validatedId) { return res.status(400).json({ error: 'Invalid project ID' }); } // Validate path if provided if (projectPath !== undefined) { const pathValidation = validateProjectPath(projectPath); if (!pathValidation.valid) { return res.status(400).json({ error: pathValidation.error }); } } // Check if project exists and is not deleted const existing = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NULL `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found' }); } // If updating name, check for duplicates if (name) { const duplicate = db.prepare(` SELECT id FROM projects WHERE name = ? AND id != ? AND deletedAt IS NULL `).get(name, validatedId); if (duplicate) { return res.status(409).json({ error: 'Project with this name already exists' }); } } // Build update query dynamically based on provided fields const updates = []; const values = []; if (name !== undefined) { updates.push('name = ?'); values.push(name); } if (description !== undefined) { updates.push('description = ?'); values.push(description); } if (icon !== undefined) { updates.push('icon = ?'); values.push(icon); } if (color !== undefined) { updates.push('color = ?'); values.push(color); } if (projectPath !== undefined) { updates.push('path = ?'); const pathValidation = validateProjectPath(projectPath); values.push(pathValidation.path); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } values.push(validatedId); db.prepare(` UPDATE projects SET ${updates.join(', ')} WHERE id = ? AND deletedAt IS NULL `).run(...values); // Get the updated project const project = db.prepare(` SELECT id, name, description, icon, color, path, createdAt, lastActivity FROM projects WHERE id = ? `).get(validatedId); res.json({ success: true, project: { ...project, sessionCount: 0 } }); } catch (error) { console.error('Error updating project:', error); res.status(500).json({ error: 'Failed to update project' }); } }); // DELETE /api/projects/:id - Soft delete project app.delete('/api/projects/:id', requireAuth, (req, res) => { try { const { id } = req.params; // Validate ID const validatedId = validateProjectId(id); if (!validatedId) { return res.status(400).json({ error: 'Invalid project ID' }); } // Check if project exists and is not already deleted const existing = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NULL `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found' }); } // Soft delete by setting deletedAt const now = new Date().toISOString(); db.prepare(` UPDATE projects SET deletedAt = ? WHERE id = ? `).run(now, validatedId); res.json({ success: true }); } catch (error) { console.error('Error soft deleting project:', error); res.status(500).json({ error: 'Failed to delete project' }); } }); // POST /api/projects/:id/restore - Restore project from recycle bin app.post('/api/projects/:id/restore', requireAuth, (req, res) => { try { const { id } = req.params; // Validate ID const validatedId = validateProjectId(id); if (!validatedId) { return res.status(400).json({ error: 'Invalid project ID' }); } // Check if project exists and is in recycle bin const existing = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NOT NULL `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found in recycle bin' }); } // Restore by setting deletedAt to NULL db.prepare(` UPDATE projects SET deletedAt = NULL WHERE id = ? `).run(validatedId); res.json({ success: true }); } catch (error) { console.error('Error restoring project:', error); res.status(500).json({ error: 'Failed to restore project' }); } }); // DELETE /api/projects/:id/permanent - Permanently delete project app.delete('/api/projects/:id/permanent', requireAuth, (req, res) => { try { const { id } = req.params; // Validate ID const validatedId = validateProjectId(id); if (!validatedId) { return res.status(400).json({ error: 'Invalid project ID' }); } // Check if project exists const existing = db.prepare(` SELECT id FROM projects WHERE id = ? `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found' }); } // Permanently delete the project db.prepare(` DELETE FROM projects WHERE id = ? `).run(validatedId); res.json({ success: true }); } catch (error) { console.error('Error permanently deleting project:', error); res.status(500).json({ error: 'Failed to permanently delete project' }); } }); // GET /api/recycle-bin - List deleted items app.get('/api/recycle-bin', requireAuth, (req, res) => { try { const items = db.prepare(` SELECT id, name, description, icon, path, deletedAt FROM projects WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC `).all(); // Add sessionCount (0 for now, will be implemented in Task 4) const itemsWithSessionCount = items.map(item => ({ ...item, sessionCount: 0 })); res.json({ success: true, items: itemsWithSessionCount }); } catch (error) { console.error('Error listing recycle bin:', error); res.status(500).json({ error: 'Failed to list recycle bin' }); } }); // ===== Terminal API Endpoints ===== // Create a new terminal app.post('/claude/api/terminals', requireAuth, (req, res) => { try { const { workingDir, sessionId, mode } = req.body; const result = terminalService.createTerminal({ workingDir, sessionId, mode }); if (result.success) { res.json({ success: true, terminalId: result.terminalId, terminal: result.terminal }); } else { res.status(500).json({ error: result.error }); } } catch (error) { console.error('Error creating terminal:', error); res.status(500).json({ error: 'Failed to create terminal' }); } }); // Get list of active terminals app.get('/claude/api/terminals', requireAuth, (req, res) => { try { const terminals = terminalService.listTerminals(); res.json({ success: true, terminals }); } catch (error) { console.error('Error listing terminals:', error); res.status(500).json({ error: 'Failed to list terminals' }); } }); // Get specific terminal info app.get('/claude/api/terminals/:id', requireAuth, (req, res) => { try { const result = terminalService.getTerminal(req.params.id); if (result.success) { res.json(result); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error getting terminal:', error); res.status(500).json({ error: 'Failed to get terminal' }); } }); // Set terminal mode app.post('/claude/api/terminals/:id/mode', requireAuth, (req, res) => { try { const { mode } = req.body; const result = terminalService.setTerminalMode(req.params.id, mode); if (result.success) { res.json({ success: true, mode: result.mode }); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error setting terminal mode:', error); res.status(500).json({ error: 'Failed to set terminal mode' }); } }); // Attach terminal to session app.post('/claude/api/terminals/:id/attach', requireAuth, async (req, res) => { try { const { sessionId } = req.body; // Get the session const session = claudeService.getSession(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } const result = terminalService.attachToSession(req.params.id, session); if (result.success) { res.json({ success: true }); } else { res.status(500).json({ error: result.error }); } } catch (error) { console.error('Error attaching terminal to session:', error); res.status(500).json({ error: 'Failed to attach terminal' }); } }); // Close terminal app.delete('/claude/api/terminals/:id', requireAuth, (req, res) => { try { const result = terminalService.closeTerminal(req.params.id); if (result.success) { res.json({ success: true }); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error closing terminal:', error); res.status(500).json({ error: 'Failed to close terminal' }); } }); // Get recent directories for terminal picker app.get('/claude/api/files/recent-dirs', requireAuth, (req, res) => { try { // Get recent directories from terminal state or provide defaults const recentDirs = [ process.env.HOME, process.env.HOME + '/obsidian-vault', process.cwd() ]; // Add directories from active terminals const terminals = terminalService.listTerminals(); const terminalDirs = terminals.map(t => t.workingDir); // Merge and deduplicate const allDirs = [...new Set([...recentDirs, ...terminalDirs])]; res.json({ success: true, directories: allDirs }); } catch (error) { console.error('Error getting recent directories:', error); res.status(500).json({ error: 'Failed to get recent directories' }); } }); // Get saved terminal state for restoration app.get('/claude/api/claude/terminal-restore', requireAuth, async (req, res) => { try { const statePath = path.join(VAULT_PATH, '.claude-ide', 'terminal-state.json'); try { const stateData = await fs.promises.readFile(statePath, 'utf-8'); const state = JSON.parse(stateData); res.json({ success: true, state }); } catch (error) { // No saved state exists res.json({ success: true, state: null }); } } catch (error) { console.error('Error getting terminal state:', error); res.status(500).json({ error: 'Failed to get terminal state' }); } }); // Get local Claude CLI sessions app.get('/claude/api/claude/local-sessions', requireAuth, async (req, res) => { try { const claudeDir = path.join(process.env.HOME, '.claude'); const sessionsDir = path.join(claudeDir, 'sessions'); const localSessions = []; // Check if sessions directory exists try { const sessionDirs = await fs.promises.readdir(sessionsDir); for (const sessionDir of sessionDirs) { const sessionPath = path.join(sessionsDir, sessionDir); const stats = await fs.promises.stat(sessionPath); if (!stats.isDirectory()) continue; // Read session info const infoPath = path.join(sessionPath, 'info.json'); const infoData = await fs.promises.readFile(infoPath, 'utf-8'); const info = JSON.parse(infoData); // Check if session file exists const sessionFilePath = path.join(sessionPath, 'session.json'); let hasSessionFile = false; try { await fs.promises.access(sessionFilePath); hasSessionFile = true; } catch { // Session file doesn't exist } localSessions.push({ id: sessionDir, type: 'local', createdAt: info.createdAt || new Date(stats.mtime).toISOString(), lastActivity: info.lastMessageAt || new Date(stats.mtime).toISOString(), projectName: info.projectName || 'Local Session', status: 'local', // Local sessions don't have running status hasSessionFile }); } // Sort by last activity localSessions.sort((a, b) => { const dateA = new Date(a.lastActivity); const dateB = new Date(b.lastActivity); return dateB - dateA; }); } catch (error) { console.error('Error reading local sessions:', error); } res.json({ success: true, sessions: localSessions }); } catch (error) { console.error('Error getting local sessions:', error); res.status(500).json({ error: 'Failed to get local sessions' }); } }); // Start server and WebSocket server const server = app.listen(PORT, () => { console.log(`Obsidian Web Interface running on port ${PORT}`); console.log(`Access at: http://localhost:${PORT}/claude`); }); // Initialize terminal service WebSocket server terminalService.createServer(server); // WebSocket server for real-time chat const wss = new WebSocket.Server({ server, path: '/claude/api/claude/chat', verifyClient: (info, cb) => { // Parse session cookie const sessionCookie = info.req.headers.cookie?.match(/connect.sid=([^;]+)/); if (!sessionCookie) { console.log('WebSocket rejected: No session cookie'); return cb(false, 401, 'Unauthorized: No session cookie'); } // Store session ID for later use info.req.sessionId = sessionCookie[1]; // Accept the connection (we'll validate session fully after connection) console.log('WebSocket connection accepted for session:', sessionCookie[1].substring(0, 10) + '...'); cb(true); } }); // Store connected clients with their session IDs const clients = new Map(); wss.on('connection', (ws, req) => { const clientId = Math.random().toString(36).substr(2, 9); const sessionId = req.sessionId; console.log(`WebSocket client connected: ${clientId}, session: ${sessionId ? sessionId.substring(0, 10) : 'none'}...`); // Store client info clients.set(clientId, { ws, sessionId, userId: null }); ws.on('message', async (message) => { try { const data = JSON.parse(message); console.log('WebSocket message received:', data.type); if (data.type === 'command') { const { sessionId, command } = data; console.log(`Sending command to session ${sessionId}: ${command.substring(0, 50)}...`); // Send command to Claude Code try { claudeService.sendCommand(sessionId, command); console.log(`Command sent successfully to session ${sessionId}`); } catch (error) { console.error('Error sending command:', error); ws.send(JSON.stringify({ type: 'error', error: error.message })); } } else if (data.type === 'subscribe') { // Subscribe to a specific session's output const { sessionId } = data; const client = clients.get(clientId); if (client) { client.sessionId = sessionId; console.log(`Client ${clientId} subscribed to session ${sessionId}`); } } } catch (error) { console.error('WebSocket error:', error); ws.send(JSON.stringify({ type: 'error', error: error.message })); } }); ws.on('close', () => { console.log(`WebSocket client disconnected: ${clientId}`); clients.delete(clientId); }); // Send connected message ws.send(JSON.stringify({ type: 'connected', message: 'Connected to Claude Code chat', clientId })); }); // Forward Claude Code output to all subscribed WebSocket clients claudeService.on('session-output', (output) => { console.log(`Session output for ${output.sessionId}:`, output.type); console.log(`Content preview:`, output.content.substring(0, 100)); // Send to all clients subscribed to this session let clientsSent = 0; clients.forEach((client, clientId) => { if (client.sessionId === output.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'output', sessionId: output.sessionId, data: output })); clientsSent++; console.log(`✓ Sent output to client ${clientId}`); } catch (error) { console.error(`Error sending to client ${clientId}:`, error); } } }); if (clientsSent === 0) { console.log(`⚠ No clients subscribed to session ${output.sessionId}`); } }); // Forward operations-detected event claudeService.on('operations-detected', (data) => { console.log(`Operations detected for session ${data.sessionId}:`, data.operations.length, 'operations'); clients.forEach((client, clientId) => { if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'operations-detected', sessionId: data.sessionId, response: data.response, tags: data.tags, operations: data.operations })); } catch (error) { console.error(`Error sending operations to client ${clientId}:`, error); } } }); }); // Forward operations-executed event claudeService.on('operations-executed', (data) => { console.log(`Operations executed for session ${data.sessionId}`); clients.forEach((client, clientId) => { if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'operations-executed', sessionId: data.sessionId, results: data.results })); } catch (error) { console.error(`Error sending execution results to client ${clientId}:`, error); } } }); }); // Forward operations-error event claudeService.on('operations-error', (data) => { console.error(`Operations error for session ${data.sessionId}:`, data.error); clients.forEach((client, clientId) => { if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'operations-error', sessionId: data.sessionId, error: data.error })); } catch (error) { console.error(`Error sending error to client ${clientId}:`, error); } } }); }); console.log(`WebSocket server running on ws://localhost:${PORT}/claude/api/claude/chat`); // Graceful shutdown process.on('SIGINT', async () => { console.log('\nShutting down gracefully...'); await terminalService.cleanup(); process.exit(0); }); process.on('SIGTERM', async () => { console.log('\nReceived SIGTERM, shutting down gracefully...'); await terminalService.cleanup(); process.exit(0); });