mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-09 08:39:39 +00:00
396 lines
15 KiB
JavaScript
Executable File
396 lines
15 KiB
JavaScript
Executable File
import { spawn } from 'child_process';
|
|
import crossSpawn from 'cross-spawn';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
// Use cross-spawn on Windows for better command execution
|
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
|
|
|
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
|
|
|
async function spawnClaude(command, options = {}, ws) {
|
|
return new Promise(async (resolve, reject) => {
|
|
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
|
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
|
|
|
// Use tools settings passed from frontend, or defaults
|
|
const settings = toolsSettings || {
|
|
allowedTools: [],
|
|
disallowedTools: [],
|
|
skipPermissions: false
|
|
};
|
|
|
|
// Build Claude CLI command - start with print/resume flags first
|
|
const args = [];
|
|
|
|
// Add print flag with command if we have a command
|
|
if (command && command.trim()) {
|
|
|
|
// Separate arguments for better cross-platform compatibility
|
|
// This prevents issues with spaces and quotes on Windows
|
|
args.push('--print');
|
|
args.push(command);
|
|
}
|
|
|
|
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
|
const workingDir = cwd || process.cwd();
|
|
|
|
// Handle images by saving them to temporary files and passing paths to Claude
|
|
const tempImagePaths = [];
|
|
let tempDir = null;
|
|
if (images && images.length > 0) {
|
|
try {
|
|
// Create temp directory in the project directory so Claude can access it
|
|
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
|
|
// Save each image to a temp file
|
|
for (const [index, image] of images.entries()) {
|
|
// Extract base64 data and mime type
|
|
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
|
if (!matches) {
|
|
console.error('Invalid image data format');
|
|
continue;
|
|
}
|
|
|
|
const [, mimeType, base64Data] = matches;
|
|
const extension = mimeType.split('/')[1] || 'png';
|
|
const filename = `image_${index}.${extension}`;
|
|
const filepath = path.join(tempDir, filename);
|
|
|
|
// Write base64 data to file
|
|
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
|
tempImagePaths.push(filepath);
|
|
}
|
|
|
|
// Include the full image paths in the prompt for Claude to reference
|
|
// Only modify the command if we actually have images and a command
|
|
if (tempImagePaths.length > 0 && command && command.trim()) {
|
|
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
|
const modifiedCommand = command + imageNote;
|
|
|
|
// Update the command in args - now that --print and command are separate
|
|
const printIndex = args.indexOf('--print');
|
|
if (printIndex !== -1 && printIndex + 1 < args.length && args[printIndex + 1] === command) {
|
|
args[printIndex + 1] = modifiedCommand;
|
|
}
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
console.error('Error processing images for Claude:', error);
|
|
}
|
|
}
|
|
|
|
// Add resume flag if resuming
|
|
if (resume && sessionId) {
|
|
args.push('--resume', sessionId);
|
|
}
|
|
|
|
// Add basic flags
|
|
args.push('--output-format', 'stream-json', '--verbose');
|
|
|
|
// Add MCP config flag only if MCP servers are configured
|
|
try {
|
|
console.log('🔍 Starting MCP config check...');
|
|
// Use already imported modules (fs.promises is imported as fs, path, os)
|
|
const fsSync = await import('fs'); // Import synchronous fs methods
|
|
console.log('✅ Successfully imported fs sync methods');
|
|
|
|
// Check for MCP config in ~/.claude.json
|
|
const claudeConfigPath = path.join(os.homedir(), '.claude.json');
|
|
|
|
console.log(`🔍 Checking for MCP configs in: ${claudeConfigPath}`);
|
|
console.log(` Claude config exists: ${fsSync.existsSync(claudeConfigPath)}`);
|
|
|
|
let hasMcpServers = false;
|
|
|
|
// Check Claude config for MCP servers
|
|
if (fsSync.existsSync(claudeConfigPath)) {
|
|
try {
|
|
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
|
|
|
|
// Check global MCP servers
|
|
if (claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0) {
|
|
console.log(`✅ Found ${Object.keys(claudeConfig.mcpServers).length} global MCP servers`);
|
|
hasMcpServers = true;
|
|
}
|
|
|
|
// Check project-specific MCP servers
|
|
if (!hasMcpServers && claudeConfig.claudeProjects) {
|
|
const currentProjectPath = process.cwd();
|
|
const projectConfig = claudeConfig.claudeProjects[currentProjectPath];
|
|
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
|
|
console.log(`✅ Found ${Object.keys(projectConfig.mcpServers).length} project MCP servers`);
|
|
hasMcpServers = true;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log(`❌ Failed to parse Claude config:`, e.message);
|
|
}
|
|
}
|
|
|
|
console.log(`🔍 hasMcpServers result: ${hasMcpServers}`);
|
|
|
|
if (hasMcpServers) {
|
|
// Use Claude config file if it has MCP servers
|
|
let configPath = null;
|
|
|
|
if (fsSync.existsSync(claudeConfigPath)) {
|
|
try {
|
|
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
|
|
|
|
// Check if we have any MCP servers (global or project-specific)
|
|
const hasGlobalServers = claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0;
|
|
const currentProjectPath = process.cwd();
|
|
const projectConfig = claudeConfig.claudeProjects && claudeConfig.claudeProjects[currentProjectPath];
|
|
const hasProjectServers = projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0;
|
|
|
|
if (hasGlobalServers || hasProjectServers) {
|
|
configPath = claudeConfigPath;
|
|
}
|
|
} catch (e) {
|
|
// No valid config found
|
|
}
|
|
}
|
|
|
|
if (configPath) {
|
|
console.log(`📡 Adding MCP config: ${configPath}`);
|
|
args.push('--mcp-config', configPath);
|
|
} else {
|
|
console.log('⚠️ MCP servers detected but no valid config file found');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// If there's any error checking for MCP configs, don't add the flag
|
|
console.log('❌ MCP config check failed:', error.message);
|
|
console.log('📍 Error stack:', error.stack);
|
|
console.log('Note: MCP config check failed, proceeding without MCP support');
|
|
}
|
|
|
|
// Add model for new sessions
|
|
if (!resume) {
|
|
args.push('--model', 'sonnet');
|
|
}
|
|
|
|
// Add permission mode if specified (works for both new and resumed sessions)
|
|
if (permissionMode && permissionMode !== 'default') {
|
|
args.push('--permission-mode', permissionMode);
|
|
console.log('🔒 Using permission mode:', permissionMode);
|
|
}
|
|
|
|
// Add tools settings flags
|
|
// Don't use --dangerously-skip-permissions when in plan mode
|
|
if (settings.skipPermissions && permissionMode !== 'plan') {
|
|
args.push('--dangerously-skip-permissions');
|
|
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
|
|
} else {
|
|
// Only add allowed/disallowed tools if not skipping permissions
|
|
|
|
// Collect all allowed tools, including plan mode defaults
|
|
let allowedTools = [...(settings.allowedTools || [])];
|
|
|
|
// Add plan mode specific tools
|
|
if (permissionMode === 'plan') {
|
|
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
|
|
// Add plan mode tools that aren't already in the allowed list
|
|
for (const tool of planModeTools) {
|
|
if (!allowedTools.includes(tool)) {
|
|
allowedTools.push(tool);
|
|
}
|
|
}
|
|
console.log('📝 Plan mode: Added default allowed tools:', planModeTools);
|
|
}
|
|
|
|
// Add allowed tools
|
|
if (allowedTools.length > 0) {
|
|
for (const tool of allowedTools) {
|
|
args.push('--allowedTools', tool);
|
|
console.log('✅ Allowing tool:', tool);
|
|
}
|
|
}
|
|
|
|
// Add disallowed tools
|
|
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
|
|
for (const tool of settings.disallowedTools) {
|
|
args.push('--disallowedTools', tool);
|
|
console.log('❌ Disallowing tool:', tool);
|
|
}
|
|
}
|
|
|
|
// Log when skip permissions is disabled due to plan mode
|
|
if (settings.skipPermissions && permissionMode === 'plan') {
|
|
console.log('📝 Skip permissions disabled due to plan mode');
|
|
}
|
|
}
|
|
|
|
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
|
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
|
}).join(' '));
|
|
console.log('Working directory:', workingDir);
|
|
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
|
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
|
|
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
|
|
|
|
// Use Claude CLI from environment variable or default to 'claude'
|
|
const claudePath = process.env.CLAUDE_CLI_PATH || 'claude';
|
|
console.log('🔍 Using Claude CLI path:', claudePath);
|
|
|
|
const claudeProcess = spawnFunction(claudePath, args, {
|
|
cwd: workingDir,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env } // Inherit all environment variables
|
|
});
|
|
|
|
// Attach temp file info to process for cleanup later
|
|
claudeProcess.tempImagePaths = tempImagePaths;
|
|
claudeProcess.tempDir = tempDir;
|
|
|
|
// Store process reference for potential abort
|
|
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
|
activeClaudeProcesses.set(processKey, claudeProcess);
|
|
|
|
// Handle stdout (streaming JSON responses)
|
|
claudeProcess.stdout.on('data', (data) => {
|
|
const rawOutput = data.toString();
|
|
console.log('📤 Claude CLI stdout:', rawOutput);
|
|
|
|
const lines = rawOutput.split('\n').filter(line => line.trim());
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const response = JSON.parse(line);
|
|
console.log('📄 Parsed JSON response:', response);
|
|
|
|
// Capture session ID if it's in the response
|
|
if (response.session_id && !capturedSessionId) {
|
|
capturedSessionId = response.session_id;
|
|
console.log('📝 Captured session ID:', capturedSessionId);
|
|
|
|
// Update process key with captured session ID
|
|
if (processKey !== capturedSessionId) {
|
|
activeClaudeProcesses.delete(processKey);
|
|
activeClaudeProcesses.set(capturedSessionId, claudeProcess);
|
|
}
|
|
|
|
// Send session-created event only once for new sessions
|
|
if (!sessionId && !sessionCreatedSent) {
|
|
sessionCreatedSent = true;
|
|
ws.send(JSON.stringify({
|
|
type: 'session-created',
|
|
sessionId: capturedSessionId
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Send parsed response to WebSocket
|
|
ws.send(JSON.stringify({
|
|
type: 'claude-response',
|
|
data: response
|
|
}));
|
|
} catch (parseError) {
|
|
console.log('📄 Non-JSON response:', line);
|
|
// If not JSON, send as raw text
|
|
ws.send(JSON.stringify({
|
|
type: 'claude-output',
|
|
data: line
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle stderr
|
|
claudeProcess.stderr.on('data', (data) => {
|
|
console.error('Claude CLI stderr:', data.toString());
|
|
ws.send(JSON.stringify({
|
|
type: 'claude-error',
|
|
error: data.toString()
|
|
}));
|
|
});
|
|
|
|
// Handle process completion
|
|
claudeProcess.on('close', async (code) => {
|
|
console.log(`Claude CLI process exited with code ${code}`);
|
|
|
|
// Clean up process reference
|
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
|
activeClaudeProcesses.delete(finalSessionId);
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'claude-complete',
|
|
exitCode: code,
|
|
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
|
}));
|
|
|
|
// Clean up temporary image files if any
|
|
if (claudeProcess.tempImagePaths && claudeProcess.tempImagePaths.length > 0) {
|
|
for (const imagePath of claudeProcess.tempImagePaths) {
|
|
await fs.unlink(imagePath).catch(err =>
|
|
console.error(`Failed to delete temp image ${imagePath}:`, err)
|
|
);
|
|
}
|
|
if (claudeProcess.tempDir) {
|
|
await fs.rm(claudeProcess.tempDir, { recursive: true, force: true }).catch(err =>
|
|
console.error(`Failed to delete temp directory ${claudeProcess.tempDir}:`, err)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Claude CLI exited with code ${code}`));
|
|
}
|
|
});
|
|
|
|
// Handle process errors
|
|
claudeProcess.on('error', (error) => {
|
|
console.error('Claude CLI process error:', error);
|
|
|
|
// Clean up process reference on error
|
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
|
activeClaudeProcesses.delete(finalSessionId);
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'claude-error',
|
|
error: error.message
|
|
}));
|
|
|
|
reject(error);
|
|
});
|
|
|
|
// Handle stdin for interactive mode
|
|
if (command) {
|
|
// For --print mode with arguments, we don't need to write to stdin
|
|
claudeProcess.stdin.end();
|
|
} else {
|
|
// For interactive mode, we need to write the command to stdin if provided later
|
|
// Keep stdin open for interactive session
|
|
if (command !== undefined) {
|
|
claudeProcess.stdin.write(command + '\n');
|
|
claudeProcess.stdin.end();
|
|
}
|
|
// If no command provided, stdin stays open for interactive use
|
|
}
|
|
});
|
|
}
|
|
|
|
function abortClaudeSession(sessionId) {
|
|
const process = activeClaudeProcesses.get(sessionId);
|
|
if (process) {
|
|
console.log(`🛑 Aborting Claude session: ${sessionId}`);
|
|
process.kill('SIGTERM');
|
|
activeClaudeProcesses.delete(sessionId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export {
|
|
spawnClaude,
|
|
abortClaudeSession
|
|
};
|