- 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>
442 lines
11 KiB
JavaScript
442 lines
11 KiB
JavaScript
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
|
|
};
|