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:
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