import express from 'express'; import { spawn } from 'child_process'; import path from 'path'; import os from 'os'; import { promises as fs } from 'fs'; import crypto from 'crypto'; import { apiKeysDb, githubTokensDb } from '../database/db.js'; import { addProjectManually } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); // Middleware to validate API key for external requests const validateExternalApiKey = (req, res, next) => { const apiKey = req.headers['x-api-key'] || req.query.apiKey; if (!apiKey) { return res.status(401).json({ error: 'API key required' }); } const user = apiKeysDb.validateApiKey(apiKey); if (!user) { return res.status(401).json({ error: 'Invalid or inactive API key' }); } req.user = user; next(); }; /** * Get the remote URL of a git repository * @param {string} repoPath - Path to the git repository * @returns {Promise} - Remote URL of the repository */ async function getGitRemoteUrl(repoPath) { return new Promise((resolve, reject) => { const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], { cwd: repoPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; gitProcess.stdout.on('data', (data) => { stdout += data.toString(); }); gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code === 0) { resolve(stdout.trim()); } else { reject(new Error(`Failed to get git remote: ${stderr}`)); } }); gitProcess.on('error', (error) => { reject(new Error(`Failed to execute git: ${error.message}`)); }); }); } /** * Normalize GitHub URLs for comparison * @param {string} url - GitHub URL * @returns {string} - Normalized URL */ function normalizeGitHubUrl(url) { // Remove .git suffix let normalized = url.replace(/\.git$/, ''); // Convert SSH to HTTPS format for comparison normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/'); // Remove trailing slash normalized = normalized.replace(/\/$/, ''); return normalized.toLowerCase(); } /** * Clone a GitHub repository to a directory * @param {string} githubUrl - GitHub repository URL * @param {string} githubToken - Optional GitHub token for private repos * @param {string} projectPath - Path for cloning the repository * @returns {Promise} - Path to the cloned repository */ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) { return new Promise(async (resolve, reject) => { try { // Validate GitHub URL if (!githubUrl || !githubUrl.includes('github.com')) { throw new Error('Invalid GitHub URL'); } const cloneDir = path.resolve(projectPath); // Check if directory already exists try { await fs.access(cloneDir); // Directory exists - check if it's a git repo with the same URL try { const existingUrl = await getGitRemoteUrl(cloneDir); const normalizedExisting = normalizeGitHubUrl(existingUrl); const normalizedRequested = normalizeGitHubUrl(githubUrl); if (normalizedExisting === normalizedRequested) { console.log('โœ… Repository already exists at path with correct URL'); return resolve(cloneDir); } else { throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`); } } catch (gitError) { throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`); } } catch (accessError) { // Directory doesn't exist - proceed with clone } // Ensure parent directory exists await fs.mkdir(path.dirname(cloneDir), { recursive: true }); // Prepare the git clone URL with authentication if token is provided let cloneUrl = githubUrl; if (githubToken) { // Convert HTTPS URL to authenticated URL // Example: https://github.com/user/repo -> https://token@github.com/user/repo cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`); } console.log('๐Ÿ”„ Cloning repository:', githubUrl); console.log('๐Ÿ“ Destination:', cloneDir); // Execute git clone const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], { stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; gitProcess.stdout.on('data', (data) => { stdout += data.toString(); }); gitProcess.stderr.on('data', (data) => { stderr += data.toString(); console.log('Git stderr:', data.toString()); }); gitProcess.on('close', (code) => { if (code === 0) { console.log('โœ… Repository cloned successfully'); resolve(cloneDir); } else { console.error('โŒ Git clone failed:', stderr); reject(new Error(`Git clone failed: ${stderr}`)); } }); gitProcess.on('error', (error) => { reject(new Error(`Failed to execute git: ${error.message}`)); }); } catch (error) { reject(error); } }); } /** * Clean up a temporary project directory and its Claude session * @param {string} projectPath - Path to the project directory * @param {string} sessionId - Session ID to clean up */ async function cleanupProject(projectPath, sessionId = null) { try { // Only clean up projects in the external-projects directory if (!projectPath.includes('.claude/external-projects')) { console.warn('โš ๏ธ Refusing to clean up non-external project:', projectPath); return; } console.log('๐Ÿงน Cleaning up project:', projectPath); await fs.rm(projectPath, { recursive: true, force: true }); console.log('โœ… Project cleaned up'); // Also clean up the Claude session directory if sessionId provided if (sessionId) { try { const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId); console.log('๐Ÿงน Cleaning up session directory:', sessionPath); await fs.rm(sessionPath, { recursive: true, force: true }); console.log('โœ… Session directory cleaned up'); } catch (error) { console.error('โš ๏ธ Failed to clean up session directory:', error.message); } } } catch (error) { console.error('โŒ Failed to clean up project:', error); } } /** * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events */ class SSEStreamWriter { constructor(res) { this.res = res; this.sessionId = null; } send(data) { if (this.res.writableEnded) { return; } // Format as SSE this.res.write(`data: ${JSON.stringify(data)}\n\n`); } end() { if (!this.res.writableEnded) { this.res.write('data: {"type":"done"}\n\n'); this.res.end(); } } setSessionId(sessionId) { this.sessionId = sessionId; } getSessionId() { return this.sessionId; } } /** * Non-streaming response collector */ class ResponseCollector { constructor() { this.messages = []; this.sessionId = null; } send(data) { // Store ALL messages for now - we'll filter when returning this.messages.push(data); // Extract sessionId if present if (typeof data === 'string') { try { const parsed = JSON.parse(data); if (parsed.sessionId) { this.sessionId = parsed.sessionId; } } catch (e) { // Not JSON, ignore } } else if (data && data.sessionId) { this.sessionId = data.sessionId; } } end() { // Do nothing - we'll collect all messages } setSessionId(sessionId) { this.sessionId = sessionId; } getSessionId() { return this.sessionId; } getMessages() { return this.messages; } /** * Get filtered assistant messages only */ getAssistantMessages() { const assistantMessages = []; for (const msg of this.messages) { // Skip initial status message if (msg && msg.type === 'status') { continue; } // Handle JSON strings if (typeof msg === 'string') { try { const parsed = JSON.parse(msg); // Only include claude-response messages with assistant type if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') { assistantMessages.push(parsed.data); } } catch (e) { // Not JSON, skip } } } return assistantMessages; } /** * Calculate total tokens from all messages */ getTotalTokens() { let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheCreation = 0; for (const msg of this.messages) { let data = msg; // Parse if string if (typeof msg === 'string') { try { data = JSON.parse(msg); } catch (e) { continue; } } // Extract usage from claude-response messages if (data && data.type === 'claude-response' && data.data) { const msgData = data.data; if (msgData.message && msgData.message.usage) { const usage = msgData.message.usage; totalInput += usage.input_tokens || 0; totalOutput += usage.output_tokens || 0; totalCacheRead += usage.cache_read_input_tokens || 0; totalCacheCreation += usage.cache_creation_input_tokens || 0; } } } return { inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreationTokens: totalCacheCreation, totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation }; } } // =============================== // External API Endpoint // =============================== /** * POST /api/agent * * Trigger an AI agent (Claude or Cursor) to work on a project * * Body: * - githubUrl: string (conditionally required) - GitHub repository URL to clone * - projectPath: string (conditionally required) - Path to existing project or where to clone * - message: string (required) - Message to send to the AI agent * - provider: string (optional) - 'claude' or 'cursor' (default: 'claude') * - stream: boolean (optional) - Whether to stream responses (default: true) * - model: string (optional) - Model to use (for Cursor) * - cleanup: boolean (optional) - Whether to cleanup project after completion (default: true) * - githubToken: string (optional) - GitHub token for private repos (overrides stored token) * * Note: Either githubUrl OR projectPath must be provided. If both are provided, githubUrl will be cloned to projectPath. */ router.post('/', validateExternalApiKey, async (req, res) => { const { githubUrl, projectPath, message, provider = 'claude', model, githubToken } = req.body; // Parse stream and cleanup as booleans (handle string "true"/"false" from curl) const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true'); const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true'); // Validate inputs if (!githubUrl && !projectPath) { return res.status(400).json({ error: 'Either githubUrl or projectPath is required' }); } if (!message || !message.trim()) { return res.status(400).json({ error: 'message is required' }); } if (!['claude', 'cursor'].includes(provider)) { return res.status(400).json({ error: 'provider must be "claude" or "cursor"' }); } let finalProjectPath = null; let writer = null; try { // Determine the final project path if (githubUrl) { // Clone repository (to projectPath if provided, otherwise generate path) const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id); let targetPath; if (projectPath) { targetPath = projectPath; } else { // Generate a unique path for cloning const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex'); targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash); } finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath); } else { // Use existing project path finalProjectPath = path.resolve(projectPath); // Verify the path exists try { await fs.access(finalProjectPath); } catch (error) { throw new Error(`Project path does not exist: ${finalProjectPath}`); } } // Register the project (or use existing registration) let project; try { project = await addProjectManually(finalProjectPath); console.log('๐Ÿ“ฆ Project registered:', project); } catch (error) { // If project already exists, that's fine - continue with the existing registration if (error.message && error.message.includes('Project already configured')) { console.log('๐Ÿ“ฆ Using existing project registration for:', finalProjectPath); project = { path: finalProjectPath }; } else { throw error; } } // Set up writer based on streaming mode if (stream) { // Set up SSE headers for streaming res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering writer = new SSEStreamWriter(res); // Send initial status writer.send({ type: 'status', message: githubUrl ? 'Repository cloned and session started' : 'Session started', projectPath: finalProjectPath }); } else { // Non-streaming mode: collect messages writer = new ResponseCollector(); // Collect initial status message writer.send({ type: 'status', message: githubUrl ? 'Repository cloned and session started' : 'Session started', projectPath: finalProjectPath }); } // Start the appropriate session if (provider === 'claude') { console.log('๐Ÿค– Starting Claude SDK session'); await queryClaudeSDK(message.trim(), { projectPath: finalProjectPath, cwd: finalProjectPath, sessionId: null, // New session permissionMode: 'bypassPermissions' // Bypass all permissions for API calls }, writer); } else if (provider === 'cursor') { console.log('๐Ÿ–ฑ๏ธ Starting Cursor CLI session'); await spawnCursor(message.trim(), { projectPath: finalProjectPath, cwd: finalProjectPath, sessionId: null, // New session model: model || undefined, skipPermissions: true // Bypass permissions for Cursor }, writer); } // Handle response based on streaming mode if (stream) { // Streaming mode: end the SSE stream writer.end(); } else { // Non-streaming mode: send filtered messages and token summary as JSON const assistantMessages = writer.getAssistantMessages(); const tokenSummary = writer.getTotalTokens(); res.json({ success: true, sessionId: writer.getSessionId(), messages: assistantMessages, tokens: tokenSummary, projectPath: finalProjectPath }); } // Clean up if requested if (cleanup && githubUrl) { // Only cleanup if we cloned a repo (not for existing project paths) const sessionIdForCleanup = writer.getSessionId(); setTimeout(() => { cleanupProject(finalProjectPath, sessionIdForCleanup); }, 5000); } } catch (error) { console.error('โŒ External session error:', error); // Clean up on error if (finalProjectPath && cleanup && githubUrl) { const sessionIdForCleanup = writer ? writer.getSessionId() : null; cleanupProject(finalProjectPath, sessionIdForCleanup); } if (stream) { // For streaming, send error event and stop if (!writer) { // Set up SSE headers if not already done res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); writer = new SSEStreamWriter(res); } if (!res.writableEnded) { writer.send({ type: 'error', error: error.message, message: `Failed: ${error.message}` }); writer.end(); } } else if (!res.headersSent) { res.status(500).json({ success: false, error: error.message }); } } }); export default router;