- Full IDE with terminal integration using xterm.js - Session management with local and web sessions - HTML preview functionality - Multi-terminal support with session picker Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
516 lines
14 KiB
JavaScript
516 lines
14 KiB
JavaScript
const { spawn } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const EventEmitter = require('events');
|
|
|
|
/**
|
|
* Enhanced Claude Service with Full Development Workflow
|
|
* Supports: Planning → Brainstorming → Coding → Live Preview
|
|
*/
|
|
class ClaudeWorkflowService extends EventEmitter {
|
|
constructor(vaultPath) {
|
|
super();
|
|
this.vaultPath = vaultPath;
|
|
this.sessions = new Map();
|
|
}
|
|
|
|
/**
|
|
* Create a new session with workflow tracking
|
|
*/
|
|
createSession(options = {}) {
|
|
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
const workingDir = options.workingDir || this.vaultPath;
|
|
|
|
const session = {
|
|
id: sessionId,
|
|
workingDir,
|
|
status: 'running',
|
|
createdAt: new Date().toISOString(),
|
|
lastActivity: new Date().toISOString(),
|
|
outputBuffer: [],
|
|
|
|
// Workflow stages
|
|
workflow: {
|
|
currentPhase: 'planning', // planning, brainstorming, coding, preview
|
|
phases: {
|
|
planning: { status: 'pending', output: [] },
|
|
brainstorming: { status: 'pending', output: [] },
|
|
coding: { status: 'pending', output: [], filesCreated: [] },
|
|
preview: { status: 'pending', url: null, processId: null }
|
|
}
|
|
},
|
|
|
|
// Tool usage tracking
|
|
toolsUsed: [],
|
|
|
|
metadata: options.metadata || {}
|
|
};
|
|
|
|
this.sessions.set(sessionId, session);
|
|
console.log(`[WorkflowService] Session ${sessionId} created`);
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Send command with automatic workflow detection
|
|
*/
|
|
async sendCommand(sessionId, command) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
throw new Error(`Session ${sessionId} not found`);
|
|
}
|
|
|
|
console.log(`[WorkflowService] Processing command:`, command);
|
|
|
|
// Detect phase based on command content
|
|
const phase = this.detectPhase(command);
|
|
session.workflow.currentPhase = phase;
|
|
|
|
// Update phase status
|
|
session.workflow.phases[phase].status = 'active';
|
|
|
|
// Execute command based on phase
|
|
let result;
|
|
switch (phase) {
|
|
case 'planning':
|
|
result = await this.executePlanningPhase(session, command);
|
|
break;
|
|
case 'brainstorming':
|
|
result = await this.executeBrainstormingPhase(session, command);
|
|
break;
|
|
case 'coding':
|
|
result = await this.executeCodingPhase(session, command);
|
|
break;
|
|
case 'preview':
|
|
result = await this.executePreviewPhase(session, command);
|
|
break;
|
|
default:
|
|
result = await this.executeDefaultPhase(session, command);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Detect which phase the command belongs to
|
|
*/
|
|
detectPhase(command) {
|
|
const lower = command.toLowerCase();
|
|
|
|
if (lower.includes('plan') || lower.includes('design') || lower.includes('architecture')) {
|
|
return 'planning';
|
|
}
|
|
if (lower.includes('brainstorm') || lower.includes('ideas') || lower.includes('approach')) {
|
|
return 'brainstorming';
|
|
}
|
|
if (lower.includes('create') || lower.includes('build') || lower.includes('implement') ||
|
|
lower.includes('write code') || lower.includes('make a')) {
|
|
return 'coding';
|
|
}
|
|
if (lower.includes('preview') || lower.includes('run') || lower.includes('start') ||
|
|
lower.includes('deploy') || lower.includes('test')) {
|
|
return 'preview';
|
|
}
|
|
|
|
return 'planning'; // default
|
|
}
|
|
|
|
/**
|
|
* Execute Planning Phase - Show structured plan
|
|
*/
|
|
async executePlanningPhase(session, command) {
|
|
console.log(`[WorkflowService] ===== PLANNING PHASE =====`);
|
|
|
|
const claude = spawn('claude', ['-p', command], {
|
|
cwd: session.workingDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, PATH: process.env.PATH },
|
|
shell: true
|
|
});
|
|
|
|
let output = '';
|
|
let structuredPlan = null;
|
|
|
|
claude.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
output += text;
|
|
|
|
// Try to extract structured plan
|
|
const planMatch = text.match(/## Plan\n([\s\S]*?)(?=\n##|$)/);
|
|
if (planMatch) {
|
|
structuredPlan = this.parsePlan(planMatch[1]);
|
|
}
|
|
|
|
this.emitPhaseUpdate(session, 'planning', text);
|
|
});
|
|
|
|
await new Promise(resolve => claude.on('close', resolve));
|
|
|
|
session.workflow.phases.planning.status = 'completed';
|
|
session.workflow.phases.planning.output.push({ content: output, timestamp: new Date() });
|
|
|
|
return {
|
|
phase: 'planning',
|
|
output,
|
|
structuredPlan
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute Brainstorming Phase - Explore alternatives
|
|
*/
|
|
async executeBrainstormingPhase(session, command) {
|
|
console.log(`[WorkflowService] ===== BRAINSTORMING PHASE =====`);
|
|
|
|
const brainstormingPrompt = `
|
|
Task: ${command}
|
|
|
|
Please brainstorm 2-3 different approaches to solve this. For each approach:
|
|
1. Describe the approach briefly
|
|
2. List pros and cons
|
|
3. Recommend the best option with reasoning
|
|
|
|
Format your response as:
|
|
## Approach 1: [Name]
|
|
**Description**: ...
|
|
**Pros**: ...
|
|
**Cons**: ...
|
|
|
|
## Recommendation
|
|
[Your recommended approach with reasoning]
|
|
`;
|
|
|
|
const claude = spawn('claude', ['-p', brainstormingPrompt], {
|
|
cwd: session.workingDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, PATH: process.env.PATH },
|
|
shell: true
|
|
});
|
|
|
|
let output = '';
|
|
let alternatives = [];
|
|
|
|
claude.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
output += text;
|
|
|
|
// Extract alternatives
|
|
const approaches = text.match(/## Approach \d+: ([^\n]+)/g);
|
|
if (approaches) {
|
|
alternatives = approaches.map(a => a.replace('## Approach \\d+: ', ''));
|
|
}
|
|
|
|
this.emitPhaseUpdate(session, 'brainstorming', text);
|
|
});
|
|
|
|
await new Promise(resolve => claude.on('close', resolve));
|
|
|
|
session.workflow.phases.brainstorming.status = 'completed';
|
|
session.workflow.phases.brainstorming.output.push({ content: output, timestamp: new Date() });
|
|
|
|
return {
|
|
phase: 'brainstorming',
|
|
output,
|
|
alternatives
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute Coding Phase - Actually create files
|
|
*/
|
|
async executeCodingPhase(session, command) {
|
|
console.log(`[WorkflowService] ===== CODING PHASE =====`);
|
|
|
|
// First, get the plan from Claude
|
|
const planningPrompt = `
|
|
Task: ${command}
|
|
|
|
Please provide a detailed implementation plan including:
|
|
1. File structure (what files to create)
|
|
2. For each file, specify the exact content in a code block
|
|
|
|
Format:
|
|
## File Structure
|
|
- file1.ext
|
|
- file2.ext
|
|
|
|
## Files to Create
|
|
|
|
### file1.ext
|
|
\`\`\`language
|
|
code here
|
|
\`\`\`
|
|
|
|
### file2.ext
|
|
\`\`\`language
|
|
code here
|
|
\`\`\`
|
|
`;
|
|
|
|
const claude = spawn('claude', ['-p', planningPrompt], {
|
|
cwd: session.workingDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, PATH: process.env.PATH },
|
|
shell: true
|
|
});
|
|
|
|
let output = '';
|
|
const filesToCreate = [];
|
|
|
|
claude.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
output += text;
|
|
|
|
// Extract files to create
|
|
const fileMatches = text.match(/### ([^\n]+)\n```(\w+)?\n([\s\S]*?)```/g);
|
|
if (fileMatches) {
|
|
fileMatches.forEach(match => {
|
|
const [, filename, lang, content] = match.match(/### ([^\n]+)\n```(\w+)?\n([\s\S]*?)```/);
|
|
filesToCreate.push({ filename, content: content.trim() });
|
|
});
|
|
}
|
|
|
|
this.emitPhaseUpdate(session, 'coding', text);
|
|
});
|
|
|
|
await new Promise(resolve => claude.on('close', resolve));
|
|
|
|
// Actually create the files
|
|
const createdFiles = [];
|
|
for (const file of filesToCreate) {
|
|
try {
|
|
const filePath = path.join(session.workingDir, file.filename);
|
|
const dir = path.dirname(filePath);
|
|
|
|
// Create directory if needed
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
// Write file
|
|
fs.writeFileSync(filePath, file.content, 'utf-8');
|
|
createdFiles.push(file.filename);
|
|
|
|
console.log(`[WorkflowService] Created file: ${file.filename}`);
|
|
|
|
this.emit('file-created', {
|
|
sessionId: session.id,
|
|
filename: file.filename,
|
|
path: filePath
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error(`[WorkflowService] Failed to create ${file.filename}:`, error);
|
|
}
|
|
}
|
|
|
|
session.workflow.phases.coding.status = 'completed';
|
|
session.workflow.phases.coding.output.push({ content: output, timestamp: new Date() });
|
|
session.workflow.phases.coding.filesCreated = createdFiles;
|
|
|
|
return {
|
|
phase: 'coding',
|
|
output,
|
|
filesCreated: createdFiles
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute Preview Phase - Run and preview the app
|
|
*/
|
|
async executePreviewPhase(session, command) {
|
|
console.log(`[WorkflowService] ===== PREVIEW PHASE =====`);
|
|
|
|
// Detect package.json to determine how to run
|
|
const packageJsonPath = path.join(session.workingDir, 'package.json');
|
|
let startCommand = null;
|
|
let port = null;
|
|
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
|
|
// Check for common scripts
|
|
if (packageJson.scripts) {
|
|
if (packageJson.scripts.dev) startCommand = 'npm run dev';
|
|
else if (packageJson.scripts.start) startCommand = 'npm start';
|
|
else if (packageJson.scripts.preview) startCommand = 'npm run preview';
|
|
}
|
|
|
|
// Try to detect port
|
|
if (packageJson.scripts?.dev?.includes('--port')) {
|
|
const portMatch = packageJson.scripts.dev.match(/--port (\d+)/);
|
|
if (portMatch) port = parseInt(portMatch[1]);
|
|
}
|
|
}
|
|
|
|
if (!startCommand) {
|
|
// Check for common files
|
|
if (fs.existsSync(path.join(session.workingDir, 'index.html'))) {
|
|
// Static HTML - use simple HTTP server
|
|
startCommand = 'npx -y http-server -p 8080';
|
|
port = 8080;
|
|
} else if (fs.existsSync(path.join(session.workingDir, 'vite.config.js'))) {
|
|
startCommand = 'npm run dev -- --port 5173';
|
|
port = 5173;
|
|
}
|
|
}
|
|
|
|
if (!startCommand) {
|
|
return {
|
|
phase: 'preview',
|
|
error: 'Could not determine how to run this project. Please specify the start command.',
|
|
suggestion: 'Try running: npm install && npm run dev'
|
|
};
|
|
}
|
|
|
|
console.log(`[WorkflowService] Starting: ${startCommand}`);
|
|
|
|
// Start the dev server
|
|
const serverProcess = spawn('sh', ['-c', startCommand], {
|
|
cwd: session.workingDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, PATH: process.env.PATH },
|
|
detached: true
|
|
});
|
|
|
|
let serverOutput = '';
|
|
|
|
serverProcess.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
serverOutput += text;
|
|
|
|
// Try to extract port from output
|
|
const portMatch = text.match(/(?:localhost|http:\/\/):(\d+)/);
|
|
if (portMatch && !port) {
|
|
port = parseInt(portMatch[1]);
|
|
}
|
|
|
|
this.emitPhaseUpdate(session, 'preview', `[Server] ${text}`);
|
|
});
|
|
|
|
serverProcess.stderr.on('data', (data) => {
|
|
const text = data.toString();
|
|
serverOutput += text;
|
|
this.emitPhaseUpdate(session, 'preview', `[Server Error] ${text}`);
|
|
});
|
|
|
|
// Wait a bit for server to start
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
|
|
// Unref to keep it running in background
|
|
serverProcess.unref();
|
|
|
|
const previewUrl = port ? `http://localhost:${port}` : null;
|
|
|
|
session.workflow.phases.preview.status = 'running';
|
|
session.workflow.phases.preview.url = previewUrl;
|
|
session.workflow.phases.preview.processId = serverProcess.pid;
|
|
session.workflow.phases.preview.output.push({ content: serverOutput, timestamp: new Date() });
|
|
|
|
this.emit('preview-ready', {
|
|
sessionId: session.id,
|
|
url: previewUrl,
|
|
processId: serverProcess.pid
|
|
});
|
|
|
|
return {
|
|
phase: 'preview',
|
|
command: startCommand,
|
|
url: previewUrl,
|
|
output: serverOutput
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Default phase execution
|
|
*/
|
|
async executeDefaultPhase(session, command) {
|
|
const claude = spawn('claude', ['-p', command], {
|
|
cwd: session.workingDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, PATH: process.env.PATH },
|
|
shell: true
|
|
});
|
|
|
|
let output = '';
|
|
claude.stdout.on('data', (data) => {
|
|
output += data.toString();
|
|
this.emit('session-output', { sessionId: session.id, content: data.toString() });
|
|
});
|
|
|
|
await new Promise(resolve => claude.on('close', resolve));
|
|
return { phase: 'default', output };
|
|
}
|
|
|
|
/**
|
|
* Parse structured plan from Claude output
|
|
*/
|
|
parsePlan(text) {
|
|
const steps = [];
|
|
const stepMatches = text.match(/\d+\.([^\n]+)/g);
|
|
|
|
if (stepMatches) {
|
|
stepMatches.forEach(match => {
|
|
steps.push(match.replace(/^\d+\.\s*/, ''));
|
|
});
|
|
}
|
|
|
|
return { steps };
|
|
}
|
|
|
|
/**
|
|
* Emit phase update for real-time UI updates
|
|
*/
|
|
emitPhaseUpdate(session, phase, content) {
|
|
this.emit('phase-update', {
|
|
sessionId: session.id,
|
|
phase,
|
|
content,
|
|
timestamp: new Date()
|
|
});
|
|
|
|
// Also add to output buffer
|
|
session.outputBuffer.push({
|
|
type: 'stdout',
|
|
timestamp: new Date().toISOString(),
|
|
content,
|
|
phase
|
|
});
|
|
|
|
session.lastActivity = new Date().toISOString();
|
|
}
|
|
|
|
/**
|
|
* Get session details
|
|
*/
|
|
getSession(sessionId) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
throw new Error(`Session ${sessionId} not found`);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Stop preview server
|
|
*/
|
|
stopPreview(sessionId) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || !session.workflow.phases.preview.processId) {
|
|
return { success: false, error: 'No preview server running' };
|
|
}
|
|
|
|
try {
|
|
process.kill(-session.workflow.phases.preview.processId, 'SIGTERM');
|
|
session.workflow.phases.preview.status = 'stopped';
|
|
session.workflow.phases.preview.processId = null;
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = ClaudeWorkflowService;
|