Initial commit: Obsidian Web Interface for Claude Code
- 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>
This commit is contained in:
515
services/claude-workflow-service.js
Normal file
515
services/claude-workflow-service.js
Normal file
@@ -0,0 +1,515 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user