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