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:
678
services/claude-service.js
Normal file
678
services/claude-service.js
Normal file
@@ -0,0 +1,678 @@
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const os = require('os');
|
||||
const { SYSTEM_PROMPT } = require('./system-prompt');
|
||||
const { extractAllTags, generateOperationSummary, getDyadWriteTags } = require('./tag-parser');
|
||||
const ResponseProcessor = require('./response-processor');
|
||||
|
||||
class ClaudeCodeService extends EventEmitter {
|
||||
constructor(vaultPath) {
|
||||
super();
|
||||
this.vaultPath = vaultPath;
|
||||
this.sessions = new Map();
|
||||
this.claudeSessionsDir = path.join(vaultPath, 'Claude Sessions');
|
||||
this.responseProcessor = new ResponseProcessor(vaultPath);
|
||||
this.ensureDirectories();
|
||||
}
|
||||
|
||||
ensureDirectories() {
|
||||
if (!fs.existsSync(this.claudeSessionsDir)) {
|
||||
fs.mkdirSync(this.claudeSessionsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Claude Code session
|
||||
*/
|
||||
createSession(options = {}) {
|
||||
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const workingDir = options.workingDir || this.vaultPath;
|
||||
|
||||
console.log(`[ClaudeService] Creating session ${sessionId} in ${workingDir}`);
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
pid: null,
|
||||
process: null,
|
||||
workingDir,
|
||||
status: 'running',
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivity: new Date().toISOString(),
|
||||
outputBuffer: [],
|
||||
context: {
|
||||
messages: [],
|
||||
totalTokens: 0,
|
||||
maxTokens: 200000
|
||||
},
|
||||
metadata: options.metadata || {}
|
||||
};
|
||||
|
||||
// Add to sessions map
|
||||
this.sessions.set(sessionId, session);
|
||||
console.log(`[ClaudeService] Session ${sessionId} created successfully (using -p mode)`);
|
||||
|
||||
// Save session initialization
|
||||
this.saveSessionToVault(session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send command to a session using -p (print) mode
|
||||
*/
|
||||
sendCommand(sessionId, command) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Check if it's a historical session
|
||||
try {
|
||||
const historicalSessions = this.loadHistoricalSessions();
|
||||
const isHistorical = historicalSessions.some(s => s.id === sessionId);
|
||||
|
||||
if (isHistorical) {
|
||||
throw new Error(`Session ${sessionId} is a historical session and cannot accept new commands. Please start a new chat session.`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error from checking historical sessions
|
||||
}
|
||||
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.status !== 'running') {
|
||||
throw new Error(`Session ${sessionId} is not running (status: ${session.status})`);
|
||||
}
|
||||
|
||||
console.log(`[ClaudeService] Sending command to session ${sessionId}:`, command);
|
||||
|
||||
// Track command in context
|
||||
session.context.messages.push({
|
||||
role: 'user',
|
||||
content: command,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
session.lastActivity = new Date().toISOString();
|
||||
|
||||
this.emit('command-sent', {
|
||||
sessionId,
|
||||
command
|
||||
});
|
||||
|
||||
// Prepend system prompt to command for tag-based output
|
||||
const fullCommand = `${SYSTEM_PROMPT}\n\n${command}`;
|
||||
|
||||
// Spawn claude in -p (print) mode for this command
|
||||
const claude = spawn('claude', ['-p', fullCommand], {
|
||||
cwd: session.workingDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'], // Explicitly set stdio to get stdout/stderr
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color'
|
||||
}
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let stderrOutput = '';
|
||||
|
||||
claude.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.log(`[ClaudeService] [${sessionId}] stdout:`, text.substring(0, 100));
|
||||
output += text;
|
||||
|
||||
// Add to output buffer
|
||||
session.outputBuffer.push({
|
||||
type: 'stdout',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: text
|
||||
});
|
||||
|
||||
// Emit for real-time updates
|
||||
this.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stdout',
|
||||
content: text
|
||||
});
|
||||
});
|
||||
|
||||
claude.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.error(`[ClaudeService] [${sessionId}] stderr:`, text.substring(0, 100));
|
||||
stderrOutput += text;
|
||||
|
||||
session.outputBuffer.push({
|
||||
type: 'stderr',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: text
|
||||
});
|
||||
|
||||
this.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stderr',
|
||||
content: text
|
||||
});
|
||||
});
|
||||
|
||||
claude.on('close', (code) => {
|
||||
console.log(`[ClaudeService] [${sessionId}] Command completed with exit code ${code}`);
|
||||
|
||||
// Add assistant response to context
|
||||
if (output.trim()) {
|
||||
session.context.messages.push({
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Parse tags from output
|
||||
const tags = extractAllTags(output);
|
||||
if (tags.writes.length > 0 || tags.renames.length > 0 || tags.deletes.length > 0 || tags.dependencies.length > 0) {
|
||||
const operations = generateOperationSummary(tags);
|
||||
|
||||
this.emit('operations-detected', {
|
||||
sessionId,
|
||||
response: output,
|
||||
tags,
|
||||
operations
|
||||
});
|
||||
|
||||
console.log(`[ClaudeService] Detected ${operations.length} operations requiring approval`);
|
||||
}
|
||||
|
||||
this.emit('command-complete', {
|
||||
sessionId,
|
||||
exitCode: code,
|
||||
output
|
||||
});
|
||||
|
||||
// Save session to vault
|
||||
this.saveSessionToVault(session);
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error(`[ClaudeService] [${sessionId}] Process error:`, error);
|
||||
this.emit('session-error', {
|
||||
sessionId,
|
||||
error: error.message
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session details (handles both active and historical sessions)
|
||||
*/
|
||||
getSession(sessionId) {
|
||||
// First check active sessions
|
||||
let session = this.sessions.get(sessionId);
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
id: session.id,
|
||||
pid: session.pid,
|
||||
workingDir: session.workingDir,
|
||||
status: session.status,
|
||||
createdAt: session.createdAt,
|
||||
lastActivity: session.lastActivity,
|
||||
terminatedAt: session.terminatedAt,
|
||||
exitCode: session.exitCode,
|
||||
outputBuffer: session.outputBuffer,
|
||||
context: session.context,
|
||||
metadata: session.metadata
|
||||
};
|
||||
}
|
||||
|
||||
// If not found in active sessions, check historical sessions
|
||||
try {
|
||||
const historicalSessions = this.loadHistoricalSessions();
|
||||
const historical = historicalSessions.find(s => s.id === sessionId);
|
||||
|
||||
if (historical) {
|
||||
// Load the full session from file
|
||||
let sessionFiles = [];
|
||||
try {
|
||||
sessionFiles = fs.readdirSync(this.claudeSessionsDir);
|
||||
} catch (err) {
|
||||
console.error('Cannot read sessions directory:', err);
|
||||
throw new Error('Sessions directory not accessible');
|
||||
}
|
||||
|
||||
const sessionFile = sessionFiles.find(f => f.includes(sessionId));
|
||||
|
||||
if (sessionFile) {
|
||||
const filepath = path.join(this.claudeSessionsDir, sessionFile);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filepath)) {
|
||||
console.error('Session file does not exist:', filepath);
|
||||
throw new Error('Session file not found');
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filepath, 'utf-8');
|
||||
|
||||
// Parse output buffer from markdown
|
||||
const outputBuffer = this.parseOutputFromMarkdown(content);
|
||||
|
||||
return {
|
||||
id: historical.id,
|
||||
pid: null,
|
||||
workingDir: historical.workingDir,
|
||||
status: historical.status,
|
||||
createdAt: historical.created_at,
|
||||
lastActivity: historical.created_at,
|
||||
terminatedAt: historical.created_at,
|
||||
exitCode: null,
|
||||
outputBuffer,
|
||||
context: {
|
||||
messages: [],
|
||||
totalTokens: 0,
|
||||
maxTokens: 200000
|
||||
},
|
||||
metadata: {
|
||||
project: historical.project,
|
||||
historical: true
|
||||
}
|
||||
};
|
||||
} else {
|
||||
throw new Error('Session file not found in directory');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading historical session:', error);
|
||||
// Re-throw with more context
|
||||
if (error.message.includes('not found')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to load historical session: ${error.message}`);
|
||||
}
|
||||
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse output buffer from session markdown file
|
||||
*/
|
||||
parseOutputFromMarkdown(markdown) {
|
||||
const outputBuffer = [];
|
||||
const lines = markdown.split('\n');
|
||||
let currentSection = null;
|
||||
let currentContent = [];
|
||||
let currentTimestamp = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for output sections
|
||||
if (line.match(/^### \w+ - (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/)) {
|
||||
// Save previous section if exists
|
||||
if (currentSection && currentContent.length > 0) {
|
||||
outputBuffer.push({
|
||||
type: currentSection,
|
||||
timestamp: currentTimestamp,
|
||||
content: currentContent.join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
// Start new section
|
||||
const match = line.match(/^### (\w+) - (.+)$/);
|
||||
if (match) {
|
||||
currentSection = match[1].toLowerCase();
|
||||
currentTimestamp = match[2];
|
||||
currentContent = [];
|
||||
}
|
||||
} else if (currentSection && line.match(/^```$/)) {
|
||||
// End of code block
|
||||
if (currentContent.length > 0) {
|
||||
outputBuffer.push({
|
||||
type: currentSection,
|
||||
timestamp: currentTimestamp,
|
||||
content: currentContent.join('\n')
|
||||
});
|
||||
}
|
||||
currentSection = null;
|
||||
currentContent = [];
|
||||
} else if (currentSection) {
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection && currentContent.length > 0) {
|
||||
outputBuffer.push({
|
||||
type: currentSection,
|
||||
timestamp: currentTimestamp,
|
||||
content: currentContent.join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all sessions
|
||||
*/
|
||||
listSessions() {
|
||||
return Array.from(this.sessions.values()).map(session => {
|
||||
const metadata = this.calculateSessionMetadata(session);
|
||||
return {
|
||||
id: session.id,
|
||||
pid: session.pid,
|
||||
workingDir: session.workingDir,
|
||||
status: session.status,
|
||||
createdAt: session.createdAt,
|
||||
lastActivity: session.lastActivity,
|
||||
metadata: session.metadata,
|
||||
...metadata
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate enhanced session metadata
|
||||
*/
|
||||
calculateSessionMetadata(session) {
|
||||
const metadata = {
|
||||
lastMessage: null,
|
||||
fileCount: 0,
|
||||
messageCount: 0
|
||||
};
|
||||
|
||||
if (session.outputBuffer && session.outputBuffer.length > 0) {
|
||||
// Get last message
|
||||
const lastEntry = session.outputBuffer[session.outputBuffer.length - 1];
|
||||
metadata.lastMessage = this.extractMessagePreview(lastEntry.content);
|
||||
|
||||
// Count dyad-write tags (files created/modified)
|
||||
metadata.fileCount = session.outputBuffer.reduce((count, entry) => {
|
||||
const writeTags = getDyadWriteTags(entry.content);
|
||||
return count + writeTags.length;
|
||||
}, 0);
|
||||
|
||||
metadata.messageCount = session.outputBuffer.length;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract message preview (first 100 chars, stripped of tags)
|
||||
*/
|
||||
extractMessagePreview(content) {
|
||||
if (!content) {
|
||||
return 'No messages yet';
|
||||
}
|
||||
|
||||
// Remove dyad tags and strip markdown code blocks (chained for efficiency)
|
||||
let preview = content
|
||||
.replace(/<dyad-write[^>]*>[\s\S]*?<\/dyad-write>/g, '[File]')
|
||||
.replace(/<dyad-[a-z-]+(?:\s+[^>]*)?>/g, '')
|
||||
.replace(/```[\s\S]*?```/g, '[Code]');
|
||||
|
||||
// Get first 100 chars
|
||||
preview = preview.substring(0, 100);
|
||||
|
||||
// Truncate at last word boundary
|
||||
if (preview.length === 100) {
|
||||
const lastSpace = preview.lastIndexOf(' ');
|
||||
if (lastSpace > 50) {
|
||||
preview = preview.substring(0, lastSpace);
|
||||
} else {
|
||||
// If no good word boundary, truncate at a safe point
|
||||
preview = preview.substring(0, 97);
|
||||
}
|
||||
preview += '...';
|
||||
}
|
||||
|
||||
return preview.trim() || 'No messages yet';
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a session
|
||||
*/
|
||||
terminateSession(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.status !== 'running') {
|
||||
throw new Error(`Session ${sessionId} is not running`);
|
||||
}
|
||||
|
||||
session.process.kill();
|
||||
session.status = 'terminating';
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse context updates from output
|
||||
*/
|
||||
parseContextUpdate(session, output) {
|
||||
// Look for token usage patterns
|
||||
const tokenMatch = output.match(/(\d+) tokens? used/i);
|
||||
if (tokenMatch) {
|
||||
const tokens = parseInt(tokenMatch[1]);
|
||||
session.context.totalTokens = tokens;
|
||||
}
|
||||
|
||||
// Look for assistant responses
|
||||
if (output.includes('Claude:') || output.includes('Assistant:')) {
|
||||
session.context.messages.push({
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session to Obsidian vault
|
||||
*/
|
||||
saveSessionToVault(session) {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const filename = `${date}-${session.id}.md`;
|
||||
const filepath = path.join(this.claudeSessionsDir, filename);
|
||||
|
||||
const content = this.generateSessionMarkdown(session);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filepath, content, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Failed to save session to vault:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown representation of session
|
||||
*/
|
||||
generateSessionMarkdown(session) {
|
||||
const lines = [];
|
||||
|
||||
lines.push('---');
|
||||
lines.push(`type: claude-session`);
|
||||
lines.push(`session_id: ${session.id}`);
|
||||
lines.push(`status: ${session.status}`);
|
||||
lines.push(`created_at: ${session.createdAt}`);
|
||||
lines.push(`working_dir: ${session.workingDir}`);
|
||||
if (session.metadata.project) {
|
||||
lines.push(`project: ${session.metadata.project}`);
|
||||
}
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push(`# Claude Code Session: ${session.id}`);
|
||||
lines.push('');
|
||||
lines.push(`**Created**: ${session.createdAt}`);
|
||||
lines.push(`**Status**: ${session.status}`);
|
||||
lines.push(`**Working Directory**: \`${session.workingDir}\``);
|
||||
if (session.pid) {
|
||||
lines.push(`**PID**: ${session.pid}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Context summary
|
||||
lines.push('## Context Usage');
|
||||
lines.push('');
|
||||
lines.push(`- **Total Tokens**: ${session.context.totalTokens}`);
|
||||
lines.push(`- **Messages**: ${session.context.messages.length}`);
|
||||
lines.push(`- **Token Limit**: ${session.context.maxTokens}`);
|
||||
lines.push('');
|
||||
|
||||
// Output
|
||||
lines.push('## Session Output');
|
||||
lines.push('');
|
||||
|
||||
session.outputBuffer.forEach(entry => {
|
||||
lines.push(`### ${entry.type} - ${entry.timestamp}`);
|
||||
lines.push('');
|
||||
lines.push('```');
|
||||
lines.push(entry.content);
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load historical sessions from vault
|
||||
*/
|
||||
loadHistoricalSessions() {
|
||||
const sessions = [];
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(this.claudeSessionsDir);
|
||||
const sessionFiles = files.filter(f => f.endsWith('.md') && f.includes('session-'));
|
||||
|
||||
sessionFiles.forEach(file => {
|
||||
const filepath = path.join(this.claudeSessionsDir, file);
|
||||
const content = fs.readFileSync(filepath, 'utf-8');
|
||||
|
||||
// Parse frontmatter
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatter = {};
|
||||
frontmatterMatch[1].split('\n').forEach(line => {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
if (key && valueParts.length > 0) {
|
||||
frontmatter[key.trim()] = valueParts.join(':').trim();
|
||||
}
|
||||
});
|
||||
|
||||
// Parse output buffer from content
|
||||
const outputBuffer = this.parseOutputFromMarkdown(content);
|
||||
|
||||
// Calculate metadata
|
||||
const tempSession = { outputBuffer };
|
||||
const metadata = this.calculateSessionMetadata(tempSession);
|
||||
|
||||
sessions.push({
|
||||
id: frontmatter.session_id,
|
||||
status: frontmatter.status,
|
||||
createdAt: frontmatter.created_at,
|
||||
workingDir: frontmatter.working_dir,
|
||||
project: frontmatter.project,
|
||||
file: filepath,
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load historical sessions:', error);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context statistics
|
||||
*/
|
||||
getContextStats(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
totalTokens: session.context.totalTokens,
|
||||
maxTokens: session.context.maxTokens,
|
||||
usagePercentage: (session.context.totalTokens / session.context.maxTokens) * 100,
|
||||
messageCount: session.context.messages.length,
|
||||
messages: session.context.messages
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up terminated sessions
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
for (const [sessionId, session] of this.sessions.entries()) {
|
||||
const sessionAge = now - new Date(session.createdAt).getTime();
|
||||
|
||||
if (session.status === 'terminated' && sessionAge > maxAge) {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operations after user approval
|
||||
*/
|
||||
async executeOperations(sessionId, response, onProgress) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
console.log(`[ClaudeService] Executing operations for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
const results = await this.responseProcessor.processResponse(
|
||||
sessionId,
|
||||
response,
|
||||
{
|
||||
workingDir: session.workingDir,
|
||||
autoApprove: true,
|
||||
onProgress
|
||||
}
|
||||
);
|
||||
|
||||
this.emit('operations-executed', {
|
||||
sessionId,
|
||||
results
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`[ClaudeService] Error executing operations:`, error);
|
||||
this.emit('operations-error', {
|
||||
sessionId,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview operations without executing
|
||||
*/
|
||||
async previewOperations(sessionId, response) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
return await this.responseProcessor.previewOperations(response, session.workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeCodeService;
|
||||
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;
|
||||
298
services/response-processor.js
Normal file
298
services/response-processor.js
Normal file
@@ -0,0 +1,298 @@
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { extractAllTags } = require('./tag-parser');
|
||||
|
||||
/**
|
||||
* Response Processor - Executes dyad-style tags
|
||||
* Handles file operations, package installation, and commands
|
||||
*/
|
||||
|
||||
class ResponseProcessor {
|
||||
constructor(vaultPath) {
|
||||
this.vaultPath = vaultPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all tags from a response
|
||||
* Executes operations in order: delete → rename → write → dependencies → commands
|
||||
*/
|
||||
async processResponse(sessionId, response, options = {}) {
|
||||
const {
|
||||
workingDir = this.vaultPath,
|
||||
autoApprove = false,
|
||||
onProgress = null
|
||||
} = options;
|
||||
|
||||
const tags = extractAllTags(response);
|
||||
|
||||
// Track operations
|
||||
const results = {
|
||||
sessionId,
|
||||
workingDir,
|
||||
operations: [],
|
||||
errors: [],
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Execute in order: deletes first, then renames, then writes
|
||||
for (const tag of tags.deletes) {
|
||||
try {
|
||||
if (onProgress) onProgress({ type: 'delete', path: tag.path });
|
||||
const result = await this.executeDelete(workingDir, tag);
|
||||
results.operations.push(result);
|
||||
} catch (error) {
|
||||
results.errors.push({ type: 'delete', tag, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags.renames) {
|
||||
try {
|
||||
if (onProgress) onProgress({ type: 'rename', from: tag.from, to: tag.to });
|
||||
const result = await this.executeRename(workingDir, tag);
|
||||
results.operations.push(result);
|
||||
} catch (error) {
|
||||
results.errors.push({ type: 'rename', tag, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags.writes) {
|
||||
try {
|
||||
if (onProgress) onProgress({ type: 'write', path: tag.path });
|
||||
const result = await this.executeWrite(workingDir, tag);
|
||||
results.operations.push(result);
|
||||
} catch (error) {
|
||||
results.errors.push({ type: 'write', tag, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags.dependencies) {
|
||||
try {
|
||||
if (onProgress) onProgress({ type: 'install', packages: tag.packages });
|
||||
const result = await this.executeAddDependency(workingDir, tag);
|
||||
results.operations.push(result);
|
||||
} catch (error) {
|
||||
results.errors.push({ type: 'install', tag, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags.commands) {
|
||||
try {
|
||||
if (onProgress) onProgress({ type: 'command', command: tag.command });
|
||||
const result = await this.executeCommand(workingDir, tag);
|
||||
results.operations.push(result);
|
||||
} catch (error) {
|
||||
results.errors.push({ type: 'command', tag, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute dyad-write tag
|
||||
*/
|
||||
async executeWrite(workingDir, tag) {
|
||||
const fullPath = path.join(workingDir, tag.path);
|
||||
|
||||
// Security check
|
||||
if (!fullPath.startsWith(workingDir)) {
|
||||
throw new Error('Access denied: path outside working directory');
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fsSync.existsSync(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(fullPath, tag.content, 'utf-8');
|
||||
|
||||
return {
|
||||
type: 'write',
|
||||
path: tag.path,
|
||||
fullPath,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute dyad-rename tag
|
||||
*/
|
||||
async executeRename(workingDir, tag) {
|
||||
const fromPath = path.join(workingDir, tag.from);
|
||||
const toPath = path.join(workingDir, tag.to);
|
||||
|
||||
// Security check
|
||||
if (!fromPath.startsWith(workingDir) || !toPath.startsWith(workingDir)) {
|
||||
throw new Error('Access denied: path outside working directory');
|
||||
}
|
||||
|
||||
// Check if source exists
|
||||
if (!fsSync.existsSync(fromPath)) {
|
||||
throw new Error(`Source file not found: ${tag.from}`);
|
||||
}
|
||||
|
||||
// Create target directory if needed
|
||||
const toDir = path.dirname(toPath);
|
||||
if (!fsSync.existsSync(toDir)) {
|
||||
await fs.mkdir(toDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Rename/move file
|
||||
await fs.rename(fromPath, toPath);
|
||||
|
||||
return {
|
||||
type: 'rename',
|
||||
from: tag.from,
|
||||
to: tag.to,
|
||||
fromPath,
|
||||
toPath,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute dyad-delete tag
|
||||
*/
|
||||
async executeDelete(workingDir, tag) {
|
||||
const fullPath = path.join(workingDir, tag.path);
|
||||
|
||||
// Security check
|
||||
if (!fullPath.startsWith(workingDir)) {
|
||||
throw new Error('Access denied: path outside working directory');
|
||||
}
|
||||
|
||||
// Check if exists
|
||||
if (!fsSync.existsSync(fullPath)) {
|
||||
throw new Error(`File not found: ${tag.path}`);
|
||||
}
|
||||
|
||||
// Delete file or directory
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.rm(fullPath, { recursive: true, force: true });
|
||||
} else {
|
||||
await fs.unlink(fullPath);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'delete',
|
||||
path: tag.path,
|
||||
fullPath,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute dyad-add-dependency tag
|
||||
*/
|
||||
async executeAddDependency(workingDir, tag) {
|
||||
const packageJsonPath = path.join(workingDir, 'package.json');
|
||||
|
||||
// Check if package.json exists
|
||||
if (!fsSync.existsSync(packageJsonPath)) {
|
||||
throw new Error('No package.json found in working directory');
|
||||
}
|
||||
|
||||
// Install packages using npm
|
||||
return new Promise((resolve, reject) => {
|
||||
const packages = tag.packages.join(' ');
|
||||
const npm = spawn('npm', ['install', '--save', ...tag.packages], {
|
||||
cwd: workingDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: true
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
npm.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
npm.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
npm.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
type: 'install',
|
||||
packages: tag.packages,
|
||||
output,
|
||||
success: true
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`npm install failed: ${errorOutput}`));
|
||||
}
|
||||
});
|
||||
|
||||
npm.on('error', (error) => {
|
||||
reject(new Error(`Failed to spawn npm: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute dyad-command tag
|
||||
* Currently supports: rebuild, restart, refresh
|
||||
*/
|
||||
async executeCommand(workingDir, tag) {
|
||||
const commands = {
|
||||
rebuild: async () => {
|
||||
// Rebuild logic - could be project-specific
|
||||
return {
|
||||
type: 'command',
|
||||
command: tag.command,
|
||||
result: 'Rebuild triggered',
|
||||
success: true
|
||||
};
|
||||
},
|
||||
restart: async () => {
|
||||
// Restart dev server logic
|
||||
return {
|
||||
type: 'command',
|
||||
command: tag.command,
|
||||
result: 'Restart triggered',
|
||||
success: true
|
||||
};
|
||||
},
|
||||
refresh: async () => {
|
||||
// Refresh preview logic
|
||||
return {
|
||||
type: 'command',
|
||||
command: tag.command,
|
||||
result: 'Refresh triggered',
|
||||
success: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handler = commands[tag.command];
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown command: ${tag.command}`);
|
||||
}
|
||||
|
||||
return await handler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview operations without executing
|
||||
*/
|
||||
async previewOperations(response, workingDir = this.vaultPath) {
|
||||
const tags = extractAllTags(response);
|
||||
const { generateOperationSummary } = require('./tag-parser');
|
||||
|
||||
return {
|
||||
workingDir,
|
||||
operations: generateOperationSummary(tags),
|
||||
hasChanges: tags.writes.length > 0 || tags.renames.length > 0 || tags.deletes.length > 0,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ResponseProcessor;
|
||||
102
services/system-prompt.js
Normal file
102
services/system-prompt.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Dyad-style System Prompt - Aggressive Version
|
||||
* Forces Claude to use XML-like tags instead of markdown
|
||||
*/
|
||||
|
||||
const SYSTEM_PROMPT = `<role>
|
||||
You are an AI programming assistant that CREATES web applications AUTOMATICALLY.
|
||||
|
||||
# CRITICAL OUTPUT FORMAT - READ THIS FIRST
|
||||
|
||||
⚠️ **NEVER use markdown code blocks (\\\`\\\`\\\`)**
|
||||
⚠️ **ALWAYS use <dyad-write> tags for ALL code**
|
||||
⚠️ **When user asks to build/ create/ make something, IMMEDIATELY output the files**
|
||||
|
||||
## MANDATORY BEHAVIOR
|
||||
|
||||
When the user says:
|
||||
- "build X" → IMMEDIATELY output <dyad-write> tags with the files
|
||||
- "create X" → IMMEDIATELY output <dyad-write> tags with the files
|
||||
- "make X" → IMMEDIATELY output <dyad-write> tags with the files
|
||||
- "can you make X" → IMMEDIATELY output <dyad-write> tags with the files
|
||||
|
||||
DO NOT ask "Should I create it?" or "Do you want me to proceed?"
|
||||
JUST CREATE THE FILES AUTOMATICALLY.
|
||||
|
||||
## Tag Reference
|
||||
|
||||
<dyad-write path="relative/path/to/file">
|
||||
file content goes here
|
||||
NO markdown wrapping, just the raw code
|
||||
</dyad-write>
|
||||
|
||||
<dyad-add-dependency packages="package1 package2">
|
||||
|
||||
<dyad-command type="rebuild">
|
||||
|
||||
## Example - WRONG
|
||||
|
||||
User: "Create a calculator"
|
||||
Assistant: "Sure! I'll create a calculator for you. Here's the code:
|
||||
\\\`\\\`\\\`
|
||||
// code
|
||||
\\\`\\\`\\\`
|
||||
|
||||
## Example - CORRECT
|
||||
|
||||
User: "Create a calculator"
|
||||
Assistant: "I'll create a calculator for you.
|
||||
|
||||
<dyad-write path="calculator.html">
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Calculator</h1>
|
||||
</body>
|
||||
</html>
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="calculator.css">
|
||||
body { font-family: Arial; }
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="calculator.js">
|
||||
// Calculator logic here
|
||||
</dyad-write>"
|
||||
|
||||
## Rules
|
||||
|
||||
1. **NEVER** wrap code in \\\`\\\`\\\` markdown blocks
|
||||
2. **ALWAYS** use <dyad-write path="filename">content</dyad-write>
|
||||
3. When user wants something built, **JUST BUILD IT** - don't ask for permission
|
||||
4. Be brief in explanations, let the tags do the work
|
||||
5. Use relative paths from current directory
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Creating HTML page:
|
||||
<dyad-write path="index.html">
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>App</title></head>
|
||||
<body><h1>Hello</h1></body>
|
||||
</html>
|
||||
</dyad-write>
|
||||
|
||||
Creating multiple files:
|
||||
<dyad-write path="index.html">...</dyad-write>
|
||||
<dyad-write path="style.css">...</dyad-write>
|
||||
<dyad-write path="app.js">...</dyad-write>
|
||||
|
||||
Installing packages:
|
||||
<dyad-add-dependency packages="react react-dom">
|
||||
|
||||
---
|
||||
|
||||
REMEMBER: User asks → You AUTOMATICALLY create files with <dyad-write> tags
|
||||
NO markdown code blocks EVER
|
||||
</role>
|
||||
|
||||
`;
|
||||
|
||||
module.exports = { SYSTEM_PROMPT };
|
||||
236
services/tag-parser.js
Normal file
236
services/tag-parser.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Tag Parser for Dyad-style XML-like tags
|
||||
* Extracts operations from LLM responses
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse all dyad-write tags from response
|
||||
* Format: <dyad-write path="file.ext">content</dyad-write>
|
||||
*/
|
||||
function getDyadWriteTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<dyad-write\s+path="([^"]+)">([\s\S]*?)<\/dyad-write>/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'write',
|
||||
path: match[1],
|
||||
content: match[2].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all dyad-rename tags from response
|
||||
* Format: <dyad-rename from="old.ext" to="new.ext">
|
||||
*/
|
||||
function getDyadRenameTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<dyad-rename\s+from="([^"]+)"\s+to="([^"]+)">/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'rename',
|
||||
from: match[1],
|
||||
to: match[2]
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all dyad-delete tags from response
|
||||
* Format: <dyad-delete path="file.ext">
|
||||
*/
|
||||
function getDyadDeleteTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<dyad-delete\s+path="([^"]+)">/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'delete',
|
||||
path: match[1]
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all dyad-add-dependency tags from response
|
||||
* Format: <dyad-add-dependency packages="pkg1 pkg2">
|
||||
*/
|
||||
function getDyadAddDependencyTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<dyad-add-dependency\s+packages="([^"]+)">/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'add-dependency',
|
||||
packages: match[1].split(' ').filter(p => p.trim())
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all dyad-command tags from response
|
||||
* Format: <dyad-command type="rebuild|restart|refresh">
|
||||
*/
|
||||
function getDyadCommandTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<dyad-command\s+type="([^"]+)">/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'command',
|
||||
command: match[1]
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all dyad-chat-summary tags from response
|
||||
* Format: <dyad-chat-summary>summary text</dyad-chat-summary>
|
||||
*/
|
||||
function getDyadChatSummary(response) {
|
||||
const match = response.match(/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
type: 'chat-summary',
|
||||
summary: match[1].trim()
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all tags from response in order
|
||||
* Returns structured object with all tag types
|
||||
*/
|
||||
function extractAllTags(response) {
|
||||
return {
|
||||
writes: getDyadWriteTags(response),
|
||||
renames: getDyadRenameTags(response),
|
||||
deletes: getDyadDeleteTags(response),
|
||||
dependencies: getDyadAddDependencyTags(response),
|
||||
commands: getDyadCommandTags(response),
|
||||
chatSummary: getDyadChatSummary(response)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response contains any actionable tags
|
||||
*/
|
||||
function hasActionableTags(response) {
|
||||
const tags = extractAllTags(response);
|
||||
return (
|
||||
tags.writes.length > 0 ||
|
||||
tags.renames.length > 0 ||
|
||||
tags.deletes.length > 0 ||
|
||||
tags.dependencies.length > 0 ||
|
||||
tags.commands.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip all tags from response for display purposes
|
||||
*/
|
||||
function stripTags(response) {
|
||||
let stripped = response;
|
||||
|
||||
// Remove dyad-write tags
|
||||
stripped = stripped.replace(/<dyad-write\s+path="[^"]+">[\s\S]*?<\/dyad-write>/g, '[File operation]');
|
||||
|
||||
// Remove dyad-rename tags
|
||||
stripped = stripped.replace(/<dyad-rename\s+from="[^"]+"\s+to="[^"]+">/g, '[Rename operation]');
|
||||
|
||||
// Remove dyad-delete tags
|
||||
stripped = stripped.replace(/<dyad-delete\s+path="[^"]+">/g, '[Delete operation]');
|
||||
|
||||
// Remove dyad-add-dependency tags
|
||||
stripped = stripped.replace(/<dyad-add-dependency\s+packages="[^"]+">/g, '[Install packages]');
|
||||
|
||||
// Remove dyad-command tags
|
||||
stripped = stripped.replace(/<dyad-command\s+type="[^"]+">/g, '[Command]');
|
||||
|
||||
// Remove dyad-chat-summary tags
|
||||
stripped = stripped.replace(/<dyad-chat-summary>[\s\S]*?<\/dyad-chat-summary>/g, '');
|
||||
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary of operations for approval UI
|
||||
*/
|
||||
function generateOperationSummary(tags) {
|
||||
const operations = [];
|
||||
|
||||
tags.deletes.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'delete',
|
||||
description: `Delete ${tag.path}`,
|
||||
tag
|
||||
});
|
||||
});
|
||||
|
||||
tags.renames.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'rename',
|
||||
description: `Rename ${tag.from} → ${tag.to}`,
|
||||
tag
|
||||
});
|
||||
});
|
||||
|
||||
tags.writes.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'write',
|
||||
description: `Create/update ${tag.path}`,
|
||||
tag
|
||||
});
|
||||
});
|
||||
|
||||
tags.dependencies.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'install',
|
||||
description: `Install packages: ${tag.packages.join(', ')}`,
|
||||
tag
|
||||
});
|
||||
});
|
||||
|
||||
tags.commands.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'command',
|
||||
description: `Execute command: ${tag.command}`,
|
||||
tag
|
||||
});
|
||||
});
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDyadWriteTags,
|
||||
getDyadRenameTags,
|
||||
getDyadDeleteTags,
|
||||
getDyadAddDependencyTags,
|
||||
getDyadCommandTags,
|
||||
getDyadChatSummary,
|
||||
extractAllTags,
|
||||
hasActionableTags,
|
||||
stripTags,
|
||||
generateOperationSummary
|
||||
};
|
||||
384
services/terminal-service.js
Normal file
384
services/terminal-service.js
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Terminal Service - Manages PTY processes and WebSocket connections
|
||||
*/
|
||||
|
||||
const { spawn } = require('node-pty');
|
||||
const { Server } = require('ws');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class TerminalService {
|
||||
constructor() {
|
||||
this.terminals = new Map(); // terminalId -> { pty, ws, sessionId, workingDir, mode, createdAt }
|
||||
this.wsServer = null;
|
||||
this.logFile = path.join(process.env.HOME, 'obsidian-vault', '.claude-ide', 'terminal-logs.jsonl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket server for terminal I/O
|
||||
*/
|
||||
createServer(httpServer) {
|
||||
this.wsServer = new Server({ noServer: true });
|
||||
|
||||
// Handle WebSocket upgrade requests
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname;
|
||||
|
||||
// Match /claude/api/terminals/:id/ws
|
||||
const terminalMatch = pathname.match(/^\/claude\/api\/terminals\/([^/]+)\/ws$/);
|
||||
|
||||
if (terminalMatch) {
|
||||
const terminalId = terminalMatch[1];
|
||||
this.wsServer.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.wsServer.emit('connection', ws, request, terminalId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle WebSocket connections
|
||||
this.wsServer.on('connection', (ws, request, terminalId) => {
|
||||
this.handleConnection(terminalId, ws);
|
||||
});
|
||||
|
||||
console.log('[TerminalService] WebSocket server initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal PTY
|
||||
*/
|
||||
createTerminal(options) {
|
||||
const {
|
||||
workingDir = process.env.HOME,
|
||||
sessionId = null,
|
||||
mode = 'mixed',
|
||||
shell = process.env.SHELL || '/bin/bash'
|
||||
} = options;
|
||||
|
||||
// Generate unique terminal ID
|
||||
const terminalId = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
// Spawn PTY process
|
||||
const pty = spawn(shell, [], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: workingDir,
|
||||
env: process.env
|
||||
});
|
||||
|
||||
// Store terminal info
|
||||
const terminal = {
|
||||
id: terminalId,
|
||||
pty,
|
||||
ws: null,
|
||||
sessionId,
|
||||
workingDir,
|
||||
mode, // 'session', 'shell', or 'mixed'
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivity: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.terminals.set(terminalId, terminal);
|
||||
|
||||
// Log terminal creation
|
||||
this.logCommand(terminalId, null, `Terminal created in ${workingDir} (mode: ${mode})`);
|
||||
|
||||
console.log(`[TerminalService] Created terminal ${terminalId} in ${workingDir}`);
|
||||
|
||||
return { success: true, terminalId, terminal };
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Failed to create terminal:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket connection for terminal I/O
|
||||
*/
|
||||
handleConnection(terminalId, ws) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
|
||||
if (!terminal) {
|
||||
ws.close(1008, 'Terminal not found');
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.ws = ws;
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
|
||||
console.log(`[TerminalService] WebSocket connected for terminal ${terminalId}`);
|
||||
|
||||
// Handle incoming messages from client (user input)
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
|
||||
if (message.type === 'input') {
|
||||
// User typed something - send to PTY
|
||||
terminal.pty.write(message.data);
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
|
||||
// Log commands (basic detection of Enter key)
|
||||
if (message.data === '\r') {
|
||||
// Could extract and log command here
|
||||
}
|
||||
} else if (message.type === 'resize') {
|
||||
// Handle terminal resize
|
||||
terminal.pty.resize(message.cols, message.rows);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TerminalService] Error handling message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle PTY output - send to client
|
||||
terminal.pty.onData((data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'data',
|
||||
data: data
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle PTY exit
|
||||
terminal.pty.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[TerminalService] Terminal ${terminalId} exited: ${exitCode || signal}`);
|
||||
this.logCommand(terminalId, null, `Terminal exited: ${exitCode || signal}`);
|
||||
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal
|
||||
}));
|
||||
ws.close();
|
||||
}
|
||||
|
||||
this.terminals.delete(terminalId);
|
||||
});
|
||||
|
||||
// Handle WebSocket close
|
||||
ws.on('close', () => {
|
||||
console.log(`[TerminalService] WebSocket closed for terminal ${terminalId}`);
|
||||
|
||||
// Don't kill PTY immediately - allow reconnection
|
||||
// PTY will be killed after timeout or explicit close
|
||||
});
|
||||
|
||||
// Handle WebSocket error
|
||||
ws.on('error', (error) => {
|
||||
console.error(`[TerminalService] WebSocket error for terminal ${terminalId}:`, error);
|
||||
});
|
||||
|
||||
// Send initial welcome message
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ready',
|
||||
terminalId,
|
||||
workingDir: terminal.workingDir,
|
||||
mode: terminal.mode
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach terminal to a Claude Code session
|
||||
* Pipes session stdout/stderr to PTY
|
||||
*/
|
||||
attachToSession(terminalId, session) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
if (!session || !session.process) {
|
||||
return { success: false, error: 'Invalid session' };
|
||||
}
|
||||
|
||||
terminal.sessionId = session.id;
|
||||
terminal.mode = 'session';
|
||||
|
||||
// Pipe session output to PTY
|
||||
if (session.process.stdout) {
|
||||
session.process.stdout.on('data', (data) => {
|
||||
if (terminal.pty) {
|
||||
terminal.pty.write(data.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (session.process.stderr) {
|
||||
session.process.stderr.on('data', (data) => {
|
||||
if (terminal.pty) {
|
||||
// Write stderr in red
|
||||
terminal.pty.write(`\x1b[31m${data.toString()}\x1b[0m`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.logCommand(terminalId, null, `Attached to session ${session.id}`);
|
||||
|
||||
console.log(`[TerminalService] Terminal ${terminalId} attached to session ${session.id}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terminal mode (session/shell/mixed)
|
||||
*/
|
||||
setTerminalMode(terminalId, mode) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
terminal.mode = mode;
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
|
||||
// Notify client of mode change
|
||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
||||
terminal.ws.send(JSON.stringify({
|
||||
type: 'modeChanged',
|
||||
mode
|
||||
}));
|
||||
}
|
||||
|
||||
return { success: true, mode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close terminal and kill PTY process
|
||||
*/
|
||||
closeTerminal(terminalId) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Kill PTY process
|
||||
if (terminal.pty) {
|
||||
terminal.pty.kill();
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
||||
terminal.ws.close(1000, 'Terminal closed');
|
||||
}
|
||||
|
||||
this.terminals.delete(terminalId);
|
||||
|
||||
this.logCommand(terminalId, null, 'Terminal closed');
|
||||
|
||||
console.log(`[TerminalService] Terminal ${terminalId} closed`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error closing terminal ${terminalId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active terminals
|
||||
*/
|
||||
listTerminals() {
|
||||
const terminals = [];
|
||||
|
||||
for (const [id, terminal] of this.terminals.entries()) {
|
||||
terminals.push({
|
||||
id,
|
||||
workingDir: terminal.workingDir,
|
||||
sessionId: terminal.sessionId,
|
||||
mode: terminal.mode,
|
||||
createdAt: terminal.createdAt,
|
||||
lastActivity: terminal.lastActivity
|
||||
});
|
||||
}
|
||||
|
||||
return terminals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terminal by ID
|
||||
*/
|
||||
getTerminal(terminalId) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
terminal: {
|
||||
id: terminal.id,
|
||||
workingDir: terminal.workingDir,
|
||||
sessionId: terminal.sessionId,
|
||||
mode: terminal.mode,
|
||||
createdAt: terminal.createdAt,
|
||||
lastActivity: terminal.lastActivity
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log command to file
|
||||
*/
|
||||
async logCommand(terminalId, command, action) {
|
||||
try {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
terminalId,
|
||||
command: command || action,
|
||||
user: process.env.USER || 'unknown'
|
||||
};
|
||||
|
||||
// Ensure directory exists
|
||||
const logDir = path.dirname(this.logFile);
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
|
||||
// Append to log file
|
||||
await fs.appendFile(this.logFile, JSON.stringify(logEntry) + '\n');
|
||||
} catch (error) {
|
||||
console.error('[TerminalService] Failed to log command:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all terminals (called on server shutdown)
|
||||
*/
|
||||
async cleanup() {
|
||||
console.log('[TerminalService] Cleaning up all terminals...');
|
||||
|
||||
for (const [id, terminal] of this.terminals.entries()) {
|
||||
try {
|
||||
if (terminal.pty) {
|
||||
terminal.pty.kill();
|
||||
}
|
||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
||||
terminal.ws.close(1000, 'Server shutting down');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error cleaning up terminal ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.terminals.clear();
|
||||
|
||||
// Close WebSocket server
|
||||
if (this.wsServer) {
|
||||
this.wsServer.close();
|
||||
}
|
||||
|
||||
console.log('[TerminalService] Cleanup complete');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
const terminalService = new TerminalService();
|
||||
|
||||
module.exports = terminalService;
|
||||
441
services/xml-parser.js
Normal file
441
services/xml-parser.js
Normal file
@@ -0,0 +1,441 @@
|
||||
const { XMLParser } = require('fast-xml-parser');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Custom XML Tag Parser for Claude Code responses
|
||||
* Handles: <claude-write>, <claude-edit>, <claude-command>, <claude-dependency>
|
||||
*/
|
||||
class ClaudeXMLParser {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
textNodeName: '#text',
|
||||
...options
|
||||
};
|
||||
|
||||
this.parser = new XMLParser(this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude's response and extract all custom tags
|
||||
*/
|
||||
parseResponse(response) {
|
||||
const result = {
|
||||
writes: [],
|
||||
edits: [],
|
||||
commands: [],
|
||||
dependencies: [],
|
||||
previews: [],
|
||||
text: response
|
||||
};
|
||||
|
||||
// Extract <claude-write> tags
|
||||
result.writes = this.extractClaudeWriteTags(response);
|
||||
|
||||
// Extract <claude-edit> tags
|
||||
result.edits = this.extractClaudeEditTags(response);
|
||||
|
||||
// Extract <claude-command> tags
|
||||
result.commands = this.extractClaudeCommandTags(response);
|
||||
|
||||
// Extract <claude-dependency> tags
|
||||
result.dependencies = this.extractClaudeDependencyTags(response);
|
||||
|
||||
// Extract <claude-preview> tags
|
||||
result.previews = this.extractClaudePreviewTags(response);
|
||||
|
||||
// Clean text (remove tags for display)
|
||||
result.text = this.cleanText(response);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <claude-write> tags
|
||||
* Format: <claude-write path="src/file.ts">content</claude-write>
|
||||
*/
|
||||
extractClaudeWriteTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<claude-write\s+path="([^"]+)"\s*>([\s\S]*?)<\/claude-write>/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'write',
|
||||
path: match[1],
|
||||
content: match[2].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <claude-edit> tags
|
||||
* Format: <claude-edit path="src/file.ts" mode="replace">
|
||||
* <search>pattern</search>
|
||||
* <replace>replacement</replace>
|
||||
* </claude-edit>
|
||||
*/
|
||||
extractClaudeEditTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<claude-edit\s+path="([^"]+)"\s*(?:mode="([^"]+)")?\s*>([\s\S]*?)<\/claude-edit>/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
const content = match[3];
|
||||
const searchMatch = content.match(/<search>\s*([\s\S]*?)\s*<\/search>/);
|
||||
const replaceMatch = content.match(/<replace>\s*([\s\S]*?)\s*<\/replace>/);
|
||||
|
||||
tags.push({
|
||||
type: 'edit',
|
||||
path: match[1],
|
||||
mode: match[2] || 'replace',
|
||||
search: searchMatch ? searchMatch[1].trim() : '',
|
||||
replace: replaceMatch ? replaceMatch[1].trim() : ''
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <claude-command> tags
|
||||
* Format: <claude-command working-dir="/path">command</claude-command>
|
||||
*/
|
||||
extractClaudeCommandTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<claude-command\s+(?:working-dir="([^"]+)")?\s*>([\s\S]*?)<\/claude-command>/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'command',
|
||||
workingDir: match[1] || process.cwd(),
|
||||
command: match[2].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <claude-dependency> tags
|
||||
* Format: <claude-dependency package="package-name">install-command</claude-dependency>
|
||||
*/
|
||||
extractClaudeDependencyTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<claude-dependency\s+package="([^"]+)"\s*>([\s\S]*?)<\/claude-dependency>/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'dependency',
|
||||
package: match[1],
|
||||
command: match[2].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <claude-preview> tags
|
||||
* Format: <claude-preview url="http://localhost:3000" />
|
||||
*/
|
||||
extractClaudePreviewTags(response) {
|
||||
const tags = [];
|
||||
const regex = /<claude-preview\s+(?:url="([^"]+)"|port="(\d+)")\s*\/?>/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'preview',
|
||||
url: match[1] || `http://localhost:${match[2]}`,
|
||||
port: match[2]
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove XML tags from response for clean text display
|
||||
*/
|
||||
cleanText(response) {
|
||||
return response
|
||||
.replace(/<claude-write[^>]*>[\s\S]*?<\/claude-write>/g, '[Wrote file]')
|
||||
.replace(/<claude-edit[^>]*>[\s\S]*?<\/claude-edit>/g, '[Edited file]')
|
||||
.replace(/<claude-command[^>]*>[\s\S]*?<\/claude-command>/g, '[Executed command]')
|
||||
.replace(/<claude-dependency[^>]*>[\s\S]*?<\/claude-dependency>/g, '[Added dependency]')
|
||||
.replace(/<claude-preview[^>]*>/g, '[Updated preview]')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tag structure
|
||||
*/
|
||||
validateTag(tag) {
|
||||
const errors = [];
|
||||
|
||||
if (tag.type === 'write') {
|
||||
if (!tag.path) errors.push('Missing path attribute');
|
||||
if (tag.content === undefined) errors.push('Missing content');
|
||||
}
|
||||
|
||||
if (tag.type === 'edit') {
|
||||
if (!tag.path) errors.push('Missing path attribute');
|
||||
if (!tag.search) errors.push('Missing search pattern');
|
||||
if (!tag.replace) errors.push('Missing replacement');
|
||||
}
|
||||
|
||||
if (tag.type === 'command') {
|
||||
if (!tag.command) errors.push('Missing command');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response Processor - Executes parsed XML tags
|
||||
*/
|
||||
class ClaudeResponseProcessor {
|
||||
constructor(vaultPath) {
|
||||
this.vaultPath = vaultPath;
|
||||
this.parser = new ClaudeXMLParser();
|
||||
this.executedOperations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Claude's response and execute all operations
|
||||
*/
|
||||
async process(response, options = {}) {
|
||||
const { dryRun = false, basePath = this.vaultPath } = options;
|
||||
const parsed = this.parser.parseResponse(response);
|
||||
const results = {
|
||||
writes: [],
|
||||
edits: [],
|
||||
commands: [],
|
||||
dependencies: [],
|
||||
previews: [],
|
||||
errors: [],
|
||||
summary: {}
|
||||
};
|
||||
|
||||
// Execute write operations
|
||||
for (const writeOp of parsed.writes) {
|
||||
try {
|
||||
const validation = this.parser.validateTag(writeOp);
|
||||
if (!validation.valid) {
|
||||
results.errors.push({
|
||||
operation: 'write',
|
||||
errors: validation.errors
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await this.executeWrite(writeOp, basePath);
|
||||
}
|
||||
|
||||
results.writes.push({
|
||||
path: writeOp.path,
|
||||
size: writeOp.content.length,
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
operation: 'write',
|
||||
path: writeOp.path,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute edit operations
|
||||
for (const editOp of parsed.edits) {
|
||||
try {
|
||||
const validation = this.parser.validateTag(editOp);
|
||||
if (!validation.valid) {
|
||||
results.errors.push({
|
||||
operation: 'edit',
|
||||
errors: validation.errors
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await this.executeEdit(editOp, basePath);
|
||||
}
|
||||
|
||||
results.edits.push({
|
||||
path: editOp.path,
|
||||
mode: editOp.mode,
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
operation: 'edit',
|
||||
path: editOp.path,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command operations
|
||||
for (const cmdOp of parsed.commands) {
|
||||
try {
|
||||
const validation = this.parser.validateTag(cmdOp);
|
||||
if (!validation.valid) {
|
||||
results.errors.push({
|
||||
operation: 'command',
|
||||
errors: validation.errors
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
results.commands.push({
|
||||
command: cmdOp.command,
|
||||
workingDir: cmdOp.workingDir,
|
||||
success: true,
|
||||
output: null // Will be filled when executed
|
||||
});
|
||||
|
||||
if (!dryRun) {
|
||||
const output = await this.executeCommand(cmdOp);
|
||||
results.commands[results.commands.length - 1].output = output;
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
operation: 'command',
|
||||
command: cmdOp.command,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect dependency operations
|
||||
for (const depOp of parsed.dependencies) {
|
||||
results.dependencies.push({
|
||||
package: depOp.package,
|
||||
command: depOp.command
|
||||
});
|
||||
}
|
||||
|
||||
// Collect preview operations
|
||||
for (const previewOp of parsed.previews) {
|
||||
results.previews.push({
|
||||
url: previewOp.url,
|
||||
port: previewOp.port
|
||||
});
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
results.summary = {
|
||||
totalOperations: results.writes.length + results.edits.length +
|
||||
results.commands.length + results.dependencies.length,
|
||||
successful: results.writes.length + results.edits.length +
|
||||
results.commands.filter(c => c.success).length,
|
||||
failed: results.errors.length,
|
||||
filesWritten: results.writes.length,
|
||||
filesEdited: results.edits.length,
|
||||
commandsExecuted: results.commands.length
|
||||
};
|
||||
|
||||
return {
|
||||
parsed,
|
||||
results,
|
||||
cleanText: parsed.text
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute write operation
|
||||
*/
|
||||
async executeWrite(writeOp, basePath) {
|
||||
const fullPath = path.resolve(basePath, writeOp.path);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file
|
||||
fs.writeFileSync(fullPath, writeOp.content, 'utf-8');
|
||||
|
||||
this.executedOperations.push({
|
||||
type: 'write',
|
||||
path: fullPath,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute edit operation
|
||||
*/
|
||||
async executeEdit(editOp, basePath) {
|
||||
const fullPath = path.resolve(basePath, editOp.path);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`File not found: ${editOp.path}`);
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(fullPath, 'utf-8');
|
||||
|
||||
if (editOp.mode === 'replace') {
|
||||
// Simple string replacement
|
||||
content = content.replace(editOp.search, editOp.replace);
|
||||
} else if (editOp.mode === 'regex') {
|
||||
// Regex replacement
|
||||
const regex = new RegExp(editOp.search, 'g');
|
||||
content = content.replace(regex, editOp.replace);
|
||||
}
|
||||
|
||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
||||
|
||||
this.executedOperations.push({
|
||||
type: 'edit',
|
||||
path: fullPath,
|
||||
mode: editOp.mode,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command (returns command object for session to execute)
|
||||
*/
|
||||
async executeCommand(cmdOp) {
|
||||
// Commands are executed by the Claude Code session
|
||||
// This just returns the command for the session to handle
|
||||
return {
|
||||
command: cmdOp.command,
|
||||
workingDir: cmdOp.workingDir
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get executed operations history
|
||||
*/
|
||||
getExecutedOperations() {
|
||||
return this.executedOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear operations history
|
||||
*/
|
||||
clearHistory() {
|
||||
this.executedOperations = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ClaudeXMLParser,
|
||||
ClaudeResponseProcessor
|
||||
};
|
||||
Reference in New Issue
Block a user