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:
uroma
2026-01-19 16:29:44 +00:00
Unverified
commit 0dd2083556
44 changed files with 18955 additions and 0 deletions

441
services/xml-parser.js Normal file
View 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
};