UNPKG

@adiontaegerron/claude-sub-agent-manager

Version:

A CLI tool for managing Claude Code sub-agents in your projects

1,490 lines (1,247 loc) 66.3 kB
const express = require('express'); const cors = require('cors'); const bodyParser = require('body-parser'); const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); const Anthropic = require('@anthropic-ai/sdk'); const dotenv = require('dotenv'); const multer = require('multer'); const sqlite3 = require('sqlite3').verbose(); // Load environment variables dotenv.config(); // Function to get configuration function getConfig() { const configPath = process.env.CLAUDE_AGENTS_CONFIG; if (configPath && fsSync.existsSync(configPath)) { try { const configContent = fsSync.readFileSync(configPath, 'utf8'); return JSON.parse(configContent); } catch (error) { console.error('Error reading config file:', error); } } return {}; } // Token usage tracking let tokenUsage = { total: 0, byAgent: {}, bySession: 0, dailyLimit: 100000, // Default daily limit warningThreshold: 0.8 // Warn at 80% usage }; // Initialize SQLite database const dbPath = process.env.CLAUDE_AGENTS_ROOT ? path.join(process.env.CLAUDE_AGENTS_ROOT, '.claude', 'tasks.db') : './tasks.db'; const db = new sqlite3.Database(dbPath, (err) => { if (err) { console.error('Error opening database:', err); } else { console.log('Connected to SQLite database'); // Create tasks table if it doesn't exist db.run(`CREATE TABLE IF NOT EXISTS task_progress ( id TEXT PRIMARY KEY, agent_name TEXT NOT NULL, task_index INTEGER NOT NULL, description TEXT, status TEXT DEFAULT 'pending', progress INTEGER DEFAULT 0, queued BOOLEAN DEFAULT 0, subtasks TEXT DEFAULT '[]', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, (err) => { if (err) { console.error('Error creating table:', err); } else { console.log('Task progress table ready'); // Create workflows table db.run(`CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, project_dir TEXT NOT NULL, name TEXT NOT NULL, description TEXT, tasks TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, (err) => { if (err) { console.error('Error creating workflows table:', err); } else { console.log('Workflows table ready'); } }); // Create index for faster queries db.run(`CREATE INDEX IF NOT EXISTS idx_agent_task ON task_progress(agent_name, task_index)`, (err) => { if (err) { console.error('Error creating index:', err); } }); } }); } }); // Initialize Anthropic client const config = getConfig(); const apiKey = process.env.ANTHROPIC_API_KEY || config.apiKey; const offlineMode = config.offlineMode || !apiKey; if (!apiKey && !offlineMode) { console.warn('\n⚠️ Warning: No Anthropic API key found!'); console.warn('Running in OFFLINE MODE - AI features disabled'); console.warn('To enable AI features, set your API key using one of these methods:'); console.warn('1. Environment variable: export ANTHROPIC_API_KEY="your-key"'); console.warn('2. Config file: Add "apiKey" to .claude-agents.json'); console.warn('3. .env file: Create .env with ANTHROPIC_API_KEY=your-key\n'); } else if (offlineMode) { console.log('✓ Running in OFFLINE MODE (AI features disabled)'); } const anthropic = apiKey ? new Anthropic({ apiKey: apiKey }) : null; const app = express(); // Configure multer for file uploads const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 // 5MB limit } }); // Middleware app.use(cors()); app.use(bodyParser.json()); // Serve static frontend files const staticPath = path.join(__dirname, 'static'); const frontendPath = path.join(__dirname, 'frontend', 'dist'); if (fsSync.existsSync(staticPath)) { // Use static folder (for npm package) app.use(express.static(staticPath)); } else if (fsSync.existsSync(frontendPath)) { // Use frontend/dist (for development) app.use(express.static(frontendPath)); } // Catch-all route to serve index.html for client-side routing app.get('*', (req, res, next) => { // Skip API routes if (req.path.startsWith('/api')) { return next(); } let indexPath = path.join(staticPath, 'index.html'); if (!fsSync.existsSync(indexPath)) { indexPath = path.join(frontendPath, 'index.html'); } if (fsSync.existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(404).send('Frontend not found. Please ensure the package is properly installed.'); } }); // Get parent directory path app.get('/api/parent-directory', (req, res) => { // Use configured root or current working directory const projectRoot = process.env.CLAUDE_AGENTS_ROOT || process.cwd(); const config = getConfig(); res.json({ path: projectRoot, agentsPath: path.join(projectRoot, config.agentsDirectory || '.claude/agents') }) }) // Validate directory endpoint app.post('/api/validate-directory', async (req, res) => { const { directory } = req.body; const config = getConfig(); try { const stats = await fs.stat(directory); if (!stats.isDirectory()) { return res.status(400).json({ valid: false, error: 'Path is not a directory' }); } // Try to access the directory await fs.access(directory, fs.constants.W_OK); const agentsDir = config.agentsDirectory || '.claude/agents'; res.json({ valid: true, fullPath: path.join(directory, agentsDir) }); } catch (error) { res.status(400).json({ valid: false, error: error.message }); } }); // List agents in a directory app.get('/api/list-agents/:encodedDir', async (req, res) => { const directory = decodeURIComponent(req.params.encodedDir); const agentsDir = path.join(directory, '.claude', 'agents'); try { const files = await fs.readdir(agentsDir); const agents = []; // Get all task progress from SQLite const allTaskProgress = await new Promise((resolve, reject) => { db.all('SELECT * FROM task_progress ORDER BY agent_name, task_index', (err, rows) => { if (err) reject(err); else resolve(rows); }); }); // Group task progress by agent name const taskProgressByAgent = {}; allTaskProgress.forEach(row => { if (!taskProgressByAgent[row.agent_name]) { taskProgressByAgent[row.agent_name] = []; } taskProgressByAgent[row.agent_name].push({ description: row.description || '', status: row.status || 'pending', queued: row.queued === 1, progress: row.progress || 0, subtasks: JSON.parse(row.subtasks || '[]') }); }); for (const file of files) { if (file.endsWith('.md')) { const content = await fs.readFile(path.join(agentsDir, file), 'utf-8'); const match = content.match(/^---\n([\s\S]*?)\n---/); if (match) { const frontmatter = match[1]; const nameMatch = frontmatter.match(/name:\s*(.+)/); const descMatch = frontmatter.match(/description:\s*(.+)/); const agentName = nameMatch ? nameMatch[1].trim() : file.replace('.md', ''); // Get tasks from SQLite or from JSON file if no SQLite data let tasks = taskProgressByAgent[agentName] || []; if (tasks.length === 0) { // Fall back to JSON file for backward compatibility const taskFile = path.join(directory, '.claude', 'tasks', `${agentName}-tasks.json`); try { const taskData = await fs.readFile(taskFile, 'utf-8'); tasks = JSON.parse(taskData); } catch (err) { tasks = []; } } agents.push({ filename: file, name: agentName, description: descMatch ? descMatch[1].trim() : 'No description', tasks: tasks }); } } } res.json({ agents }); } catch (error) { if (error.code === 'ENOENT') { res.json({ agents: [] }); } else { res.status(500).json({ error: error.message }); } } }); // Generate agent with Claude app.post('/api/generate-agent', async (req, res) => { const { name, description } = req.body; // Check if we're in offline mode if (offlineMode || !anthropic) { // Use offline templates try { const templates = JSON.parse(fsSync.readFileSync(path.join(__dirname, 'offline-templates.json'), 'utf8')).templates; // Find best matching template based on name/description const searchTerms = `${name} ${description}`.toLowerCase(); let bestMatch = null; let bestScore = 0; for (const [key, template] of Object.entries(templates)) { const templateTerms = `${template.name} ${template.description}`.toLowerCase(); const score = searchTerms.split(' ').filter(term => templateTerms.includes(term)).length; if (score > bestScore) { bestScore = score; bestMatch = template; } } if (bestMatch) { // Customize the template with the provided name const customPrompt = bestMatch.systemPrompt.replace(bestMatch.name, name); return res.json({ systemPrompt: customPrompt, offlineMode: true, message: 'Generated from offline template (AI features disabled)' }); } else { // Return a generic template return res.json({ systemPrompt: `You are a ${name} specialist.\n\n${description}\n\nApproach tasks systematically and provide clear, actionable solutions.`, offlineMode: true, message: 'Generated basic template (AI features disabled)' }); } } catch (error) { return res.status(500).json({ error: 'Failed to load offline templates' }); } } try { const prompt = `You are an expert at creating Claude Code sub-agents based on the documentation at https://docs.anthropic.com/en/docs/claude-code/sub-agents. Create a sub-agent with the following details: - Name: ${name} - Description: ${description} Generate a comprehensive system prompt for this sub-agent that: 1. Clearly defines the agent's role and expertise 2. Includes specific instructions and best practices 3. Provides a structured approach to solving problems in its domain 4. Uses proactive language to encourage automatic delegation 5. Is detailed and actionable Return ONLY the system prompt text (no markdown formatting, no explanations).`; const config = getConfig(); const message = await anthropic.messages.create({ model: config.model || 'claude-3-haiku-20240307', // Use configurable model, default to Haiku max_tokens: config.maxTokensPerRequest || 1000, // Configurable max tokens messages: [{ role: 'user', content: prompt }] }); const systemPrompt = message.content[0].text; res.json({ systemPrompt }); } catch (error) { console.error('Claude API error:', error); // Check for credit balance error if (error.message && error.message.includes('credit_balance_too_low')) { return res.status(402).json({ error: 'Anthropic API credit balance too low. Please add credits to your account at https://console.anthropic.com/account/balance', type: 'credit_error' }); } res.status(500).json({ error: 'Failed to generate agent: ' + error.message }); } }); // Enhance existing prompt app.post('/api/enhance-prompt', async (req, res) => { const { currentPrompt, name, description } = req.body; // In offline mode, provide enhancement tips instead if (offlineMode || !anthropic) { const tips = [ "Add specific responsibilities and tasks", "Include best practices for the role", "Define clear success criteria", "Add examples of expected outputs", "Include collaboration guidelines", "Specify tools and technologies to use" ]; return res.json({ systemPrompt: currentPrompt, offlineMode: true, message: "AI enhancement unavailable in offline mode", tips: tips }); } try { const prompt = `You are an expert at creating Claude Code sub-agents based on the documentation at https://docs.anthropic.com/en/docs/claude-code/sub-agents. Enhance and improve this existing sub-agent system prompt: Current prompt: ${currentPrompt} Agent details: - Name: ${name} - Description: ${description} Improve the prompt to: 1. Be more comprehensive and detailed 2. Include better structure and organization 3. Add specific best practices and constraints 4. Use more proactive language 5. Make it more actionable and effective Return ONLY the enhanced system prompt text (no markdown formatting, no explanations).`; const config = getConfig(); const message = await anthropic.messages.create({ model: config.model || 'claude-3-haiku-20240307', // Use configurable model, default to Haiku max_tokens: config.maxTokensPerRequest || 1000, // Configurable max tokens messages: [{ role: 'user', content: prompt }] }); const enhancedPrompt = message.content[0].text; res.json({ systemPrompt: enhancedPrompt }); } catch (error) { console.error('Claude API error:', error); // Check for credit balance error if (error.message && error.message.includes('credit_balance_too_low')) { return res.status(402).json({ error: 'Anthropic API credit balance too low. Please add credits to your account at https://console.anthropic.com/account/balance', type: 'credit_error' }); } res.status(500).json({ error: 'Failed to enhance prompt: ' + error.message }); } }); // Create agent file app.post('/api/create-agent', async (req, res) => { const { directory, name, description, systemPrompt } = req.body; // Validate name format if (!/^[a-z-]+$/.test(name)) { return res.status(400).json({ error: 'Agent name must contain only lowercase letters and hyphens' }); } const agentsDir = path.join(directory, '.claude', 'agents'); const agentFile = path.join(agentsDir, `${name}.md`); const statusDir = path.join(directory, '.claude', 'agents-status'); const statusFile = path.join(statusDir, `${name}-status.md`); try { // Create directories if they don't exist await fs.mkdir(agentsDir, { recursive: true }); await fs.mkdir(statusDir, { recursive: true }); // Check if agent already exists try { await fs.access(agentFile); return res.status(400).json({ error: `Agent '${name}' already exists in this directory` }); } catch { // File doesn't exist, which is good } // Create agent content with status file instructions const enhancedSystemPrompt = `${systemPrompt} ## Status Tracking You have a status file at .claude/agents-status/${name}-status.md that you should update regularly. Use the following format for your status file: \`\`\`markdown --- agent: ${name} last_updated: YYYY-MM-DD HH:MM:SS status: active|completed|blocked --- # Current Plan [Describe your current approach and strategy] # Todo List - [ ] Task 1 description - [x] Completed task - [ ] Task 3 description # Progress Updates ## YYYY-MM-DD HH:MM:SS [Describe what you accomplished and any blockers] ## YYYY-MM-DD HH:MM:SS [Another update] \`\`\` Always update this file when: 1. Starting a new task 2. Completing significant work 3. Encountering blockers 4. Changing your approach`; const content = `--- name: ${name} description: ${description} --- ${enhancedSystemPrompt} `; // Write agent file await fs.writeFile(agentFile, content, 'utf-8'); // Create initial status file const initialStatus = `--- agent: ${name} last_updated: ${new Date().toISOString()} status: active --- # Current Plan Agent initialized. Awaiting first task. # Todo List - [ ] Awaiting first task assignment # Progress Updates ## ${new Date().toISOString()} Agent created and ready for tasks. `; await fs.writeFile(statusFile, initialStatus, 'utf-8'); res.json({ success: true, filePath: agentFile, statusPath: statusFile, message: `Agent '${name}' created successfully with status tracking` }); } catch (error) { console.error('File creation error:', error); res.status(500).json({ error: 'Failed to create agent file: ' + error.message }); } }); // Get agent status app.get('/api/agent-status/:encodedDir/:agentName', async (req, res) => { const directory = decodeURIComponent(req.params.encodedDir); const agentName = req.params.agentName; const statusFile = path.join(directory, '.claude', 'agents-status', `${agentName}-status.md`); try { const content = await fs.readFile(statusFile, 'utf-8'); // Parse the frontmatter and content const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (match) { const frontmatter = match[1]; const markdownContent = match[2]; // Parse frontmatter const status = {}; frontmatter.split('\n').forEach(line => { const [key, ...valueParts] = line.split(':'); if (key && valueParts.length) { status[key.trim()] = valueParts.join(':').trim(); } }); // Parse todo items const todoMatches = [...markdownContent.matchAll(/- \[([ xX])\] (.+)/g)]; const todos = todoMatches.map(match => ({ completed: match[1].toLowerCase() === 'x', text: match[2] })); // Parse current plan const planMatch = markdownContent.match(/# Current Plan\s*\n\n([^#]+)/); const currentPlan = planMatch ? planMatch[1].trim() : ''; res.json({ ...status, content: markdownContent, todos, currentPlan, exists: true }); } else { res.json({ exists: false, content: content }); } } catch (error) { if (error.code === 'ENOENT') { res.json({ exists: false }); } else { res.status(500).json({ error: error.message }); } } }); // Update agent status (for future use by agents) app.post('/api/agent-status/:encodedDir/:agentName', async (req, res) => { const directory = decodeURIComponent(req.params.encodedDir); const agentName = req.params.agentName; const { content } = req.body; const statusFile = path.join(directory, '.claude', 'agents-status', `${agentName}-status.md`); try { await fs.writeFile(statusFile, content, 'utf-8'); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Update agent tasks app.post('/api/update-agent-tasks/:encodedDir/:agentName', async (req, res) => { try { const directory = decodeURIComponent(req.params.encodedDir); const { agentName } = req.params; const { tasks } = req.body; // Update SQLite database // First, remove all existing tasks for this agent await new Promise((resolve, reject) => { db.run('DELETE FROM task_progress WHERE agent_name = ?', [agentName], (err) => { if (err) reject(err); else resolve(); }); }); // Insert new tasks if (tasks && tasks.length > 0) { const stmt = db.prepare(`INSERT INTO task_progress (id, agent_name, task_index, description, status, progress, queued, subtasks) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); tasks.forEach((task, index) => { const id = `${agentName}-${index}`; const taskObj = typeof task === 'string' ? { description: task, status: 'pending', queued: false, progress: 0, subtasks: [] } : task; stmt.run( id, agentName, index, taskObj.description || taskObj.task || '', taskObj.status || 'pending', taskObj.progress || 0, taskObj.queued ? 1 : 0, JSON.stringify(taskObj.subtasks || []) ); }); stmt.finalize(); } // Also save to JSON file for backward compatibility const agentFile = path.join(directory, '.claude', 'agents', `${agentName}.md`); const tasksDir = path.join(directory, '.claude', 'tasks'); const taskFile = path.join(tasksDir, `${agentName}-tasks.json`); // Ensure tasks directory exists await fs.mkdir(tasksDir, { recursive: true }); // Save tasks to dedicated JSON file await fs.writeFile(taskFile, JSON.stringify(tasks, null, 2), 'utf-8'); // Also update agent file for backward compatibility const content = await fs.readFile(agentFile, 'utf-8'); const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) { return res.status(400).json({ error: 'Invalid agent file format' }); } let frontmatter = match[1]; const body = match[2]; // Remove existing tasks if any - updated regex to handle complex task structures frontmatter = frontmatter.replace(/tasks:\s*\n((?:(?:\s*-\s*.+\n)+(?:\s{4,}.+\n)*)*)/g, ''); // Add new tasks if any if (tasks && tasks.length > 0) { const tasksYaml = 'tasks:\n' + tasks.map(task => { if (typeof task === 'string') { return ` - ${task}`; } else { // Enhanced task format return ` - description: ${task.description || task.task || ''} status: ${task.status || 'pending'} queued: ${task.queued || false} progress: ${task.progress || 0} subtasks: ${task.subtasks && task.subtasks.length > 0 ? '\n' + task.subtasks.map(st => ` - description: ${st.description}\n completed: ${st.completed || false}`).join('\n') : '[]'}`; } }).join('\n'); frontmatter = frontmatter.trim() + '\n' + tasksYaml; } // Write updated content const updatedContent = `---\n${frontmatter}\n---\n${body}`; await fs.writeFile(agentFile, updatedContent, 'utf-8'); res.json({ success: true, message: 'Tasks updated successfully' }); } catch (error) { console.error('Error updating tasks:', error); res.status(500).json({ error: error.message }); } }); // Update single task progress (for agents to use) app.post('/api/update-task-progress/:encodedDir/:agentName/:taskIndex', async (req, res) => { try { const directory = decodeURIComponent(req.params.encodedDir); const { agentName, taskIndex } = req.params; const { status, progress, subtasks, queued } = req.body; const tasksDir = path.join(directory, '.claude', 'tasks'); const taskFile = path.join(tasksDir, `${agentName}-tasks.json`); // Ensure tasks directory exists await fs.mkdir(tasksDir, { recursive: true }); // Read current tasks or get from agent file if task file doesn't exist let tasks = []; try { const taskData = await fs.readFile(taskFile, 'utf-8'); tasks = JSON.parse(taskData); } catch (err) { // If task file doesn't exist, try to get tasks from agent file const agentFile = path.join(directory, '.claude', 'agents', `${agentName}.md`); try { const agentContent = await fs.readFile(agentFile, 'utf-8'); const match = agentContent.match(/^---\n([\s\S]*?)\n---/); if (match) { const frontmatter = match[1]; // Parse tasks from frontmatter const tasksMatch = frontmatter.match(/tasks:\s*\n((?:\s*-\s*.+\n?)*)/); if (tasksMatch) { const taskLines = tasksMatch[1] .split('\n') .filter(line => line.trim().startsWith('-')) .map(line => line.trim().substring(1).trim()) .filter(task => task.length > 0); // Convert to task objects tasks = taskLines.map(task => ({ description: task, status: 'pending', queued: false, progress: 0, subtasks: [] })); // Save to task file await fs.writeFile(taskFile, JSON.stringify(tasks, null, 2), 'utf-8'); } } } catch (agentErr) { return res.status(404).json({ error: 'Agent not found and no task file exists' }); } } // Update specific task const index = parseInt(taskIndex); if (index >= 0 && index < tasks.length) { if (status !== undefined) tasks[index].status = status; if (progress !== undefined) tasks[index].progress = progress; if (subtasks !== undefined) tasks[index].subtasks = subtasks; if (queued !== undefined) tasks[index].queued = queued; // Save updated tasks await fs.writeFile(taskFile, JSON.stringify(tasks, null, 2), 'utf-8'); res.json({ success: true, message: 'Task progress updated' }); } else { res.status(400).json({ error: 'Invalid task index' }); } } catch (error) { console.error('Error updating task progress:', error); res.status(500).json({ error: error.message }); } }); // Get agent tasks (for agents to check their tasks) app.get('/api/get-agent-tasks/:encodedDir/:agentName', async (req, res) => { try { const directory = decodeURIComponent(req.params.encodedDir); const { agentName } = req.params; const tasksDir = path.join(directory, '.claude', 'tasks'); const taskFile = path.join(tasksDir, `${agentName}-tasks.json`); // Try to read task file try { const taskData = await fs.readFile(taskFile, 'utf-8'); const tasks = JSON.parse(taskData); res.json({ success: true, tasks }); } catch (err) { // If task file doesn't exist, try to get from agent file const agentFile = path.join(directory, '.claude', 'agents', `${agentName}.md`); try { const agentContent = await fs.readFile(agentFile, 'utf-8'); const match = agentContent.match(/^---\n([\s\S]*?)\n---/); if (match) { const frontmatter = match[1]; // Parse tasks from frontmatter const tasksMatch = frontmatter.match(/tasks:\s*\n((?:\s*-\s*.+\n?)*)/); if (tasksMatch) { const taskLines = tasksMatch[1] .split('\n') .filter(line => line.trim().startsWith('-')) .map(line => line.trim().substring(1).trim()) .filter(task => task.length > 0); // Convert to task objects const tasks = taskLines.map(task => ({ description: task, status: 'pending', queued: false, progress: 0, subtasks: [] })); // Create task file await fs.mkdir(tasksDir, { recursive: true }); await fs.writeFile(taskFile, JSON.stringify(tasks, null, 2), 'utf-8'); res.json({ success: true, tasks }); } else { res.json({ success: true, tasks: [] }); } } else { res.status(400).json({ error: 'Invalid agent file format' }); } } catch (agentErr) { res.status(404).json({ error: 'Agent not found' }); } } } catch (error) { console.error('Error getting agent tasks:', error); res.status(500).json({ error: error.message }); } }); // Get agent content (for editing) app.get('/api/get-agent-content/:encodedDir/:agentName', async (req, res) => { try { const directory = decodeURIComponent(req.params.encodedDir); const { agentName } = req.params; const agentFile = path.join(directory, '.claude', 'agents', `${agentName}.md`); // Check if agent exists const agentExists = await fs.access(agentFile).then(() => true).catch(() => false); if (!agentExists) { return res.status(404).json({ error: 'Agent not found' }); } // Read agent file const content = await fs.readFile(agentFile, 'utf-8'); const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) { return res.status(400).json({ error: 'Invalid agent file format' }); } const frontmatter = match[1]; const systemPrompt = match[2]; // Parse frontmatter const nameMatch = frontmatter.match(/name:\s*(.+)/); const descMatch = frontmatter.match(/description:\s*(.+)/); res.json({ name: nameMatch ? nameMatch[1].trim() : agentName, description: descMatch ? descMatch[1].trim() : '', systemPrompt: systemPrompt.trim() }); } catch (error) { console.error('Error reading agent content:', error); res.status(500).json({ error: 'Failed to read agent content' }); } }); // Update agent app.post('/api/update-agent', async (req, res) => { const { directory, oldName, newName, description, systemPrompt } = req.body; // Validate name format if (!/^[a-z-]+$/.test(newName)) { return res.status(400).json({ error: 'Agent name must contain only lowercase letters and hyphens' }); } const agentsDir = path.join(directory, '.claude', 'agents'); const oldAgentFile = path.join(agentsDir, `${oldName}.md`); const newAgentFile = path.join(agentsDir, `${newName}.md`); const oldStatusFile = path.join(directory, '.claude', 'agents-status', `${oldName}-status.md`); const newStatusFile = path.join(directory, '.claude', 'agents-status', `${newName}-status.md`); try { // Check if old agent exists const oldAgentExists = await fs.access(oldAgentFile).then(() => true).catch(() => false); if (!oldAgentExists) { return res.status(404).json({ error: 'Agent not found' }); } // Check if new name already exists (if different) if (oldName !== newName) { const newAgentExists = await fs.access(newAgentFile).then(() => true).catch(() => false); if (newAgentExists) { return res.status(400).json({ error: `Agent '${newName}' already exists` }); } } // Create enhanced system prompt with status tracking const enhancedSystemPrompt = `${systemPrompt} ## Status Tracking You have a status file at .claude/agents-status/${newName}-status.md that you should update regularly. Use the following format for your status file: \`\`\`markdown --- agent: ${newName} last_updated: YYYY-MM-DD HH:MM:SS status: active|completed|blocked --- # Current Plan [Describe your current approach and strategy] # Todo List - [ ] Task 1 description - [x] Completed task - [ ] Task 3 description # Progress Updates ## YYYY-MM-DD HH:MM:SS [Describe what you accomplished and any blockers] ## YYYY-MM-DD HH:MM:SS [Another update] \`\`\` Always update this file when: 1. Starting a new task 2. Completing significant work 3. Encountering blockers 4. Changing your approach`; // Create new agent content const content = `--- name: ${newName} description: ${description} --- ${enhancedSystemPrompt} `; // Write new agent file await fs.writeFile(newAgentFile, content, 'utf-8'); // Delete old agent file if name changed if (oldName !== newName) { await fs.unlink(oldAgentFile).catch(err => console.warn(`Failed to delete old agent file: ${err.message}`)); // Rename status file if it exists const oldStatusExists = await fs.access(oldStatusFile).then(() => true).catch(() => false); if (oldStatusExists) { await fs.rename(oldStatusFile, newStatusFile).catch(err => console.warn(`Failed to rename status file: ${err.message}`)); } } res.json({ success: true, message: `Agent updated successfully${oldName !== newName ? ` and renamed from '${oldName}' to '${newName}'` : ''}` }); } catch (error) { console.error('Error updating agent:', error); res.status(500).json({ error: 'Failed to update agent: ' + error.message }); } }); // Load agent templates from directory - REMOVED DUPLICATE ENDPOINT // Tech Stack API Endpoints // Get tech stack technologies and common stacks app.get('/api/tech-stack/technologies', async (req, res) => { try { // Try multiple possible locations for tech stack data let techStackDataPath = null; const possiblePaths = [ // When installed as NPM package path.join(__dirname, 'tech-stack-data.json'), // When running from source path.join(__dirname, 'tech-stack-data.json'), // Fallback to current directory path.join(process.cwd(), 'tech-stack-data.json') ]; for (const dataPath of possiblePaths) { if (fsSync.existsSync(dataPath)) { techStackDataPath = dataPath; break; } } if (!techStackDataPath) { console.log('Tech stack data file not found in any of these locations:', possiblePaths); return res.json({ technologies: {}, commonStacks: {} }); } console.log('Loading tech stack data from:', techStackDataPath); const techStackData = JSON.parse(await fs.readFile(techStackDataPath, 'utf-8')); res.json({ technologies: techStackData.technologies, commonStacks: techStackData.commonStacks }); } catch (error) { console.error('Error loading tech stack data:', error); res.status(500).json({ error: 'Failed to load tech stack data' }); } }); // Get global tech stack for a project app.get('/api/tech-stack/global/:encodedDir', async (req, res) => { try { const directory = decodeURIComponent(req.params.encodedDir); const techStackFile = path.join(directory, '.claude', 'tech-stack.json'); const exists = await fs.access(techStackFile).then(() => true).catch(() => false); if (exists) { const techStack = JSON.parse(await fs.readFile(techStackFile, 'utf-8')); res.json({ techStack }); } else { res.json({ techStack: {} }); } } catch (error) { console.error('Error loading global tech stack:', error); res.status(500).json({ error: 'Failed to load global tech stack' }); } }); // Save global tech stack for a project app.post('/api/tech-stack/global', async (req, res) => { try { const { directory, techStack } = req.body; if (!directory) { return res.status(400).json({ error: 'Directory is required' }); } const claudeDir = path.join(directory, '.claude'); const techStackFile = path.join(claudeDir, 'tech-stack.json'); // Ensure .claude directory exists await fs.mkdir(claudeDir, { recursive: true }); // Save tech stack await fs.writeFile(techStackFile, JSON.stringify(techStack, null, 2), 'utf-8'); res.json({ success: true, message: 'Global tech stack saved successfully' }); } catch (error) { console.error('Error saving global tech stack:', error); res.status(500).json({ error: 'Failed to save global tech stack' }); } }); // Get agent-specific tech stack app.get('/api/tech-stack/agent/:encodedDir/:agentName', async (req, res) => { try { const directory = decodeURIComponent(req.params.encodedDir); const agentName = req.params.agentName; const agentFile = path.join(directory, '.claude', 'agents', `${agentName}.md`); const exists = await fs.access(agentFile).then(() => true).catch(() => false); if (exists) { const content = await fs.readFile(agentFile, 'utf-8'); // Parse YAML frontmatter for tech stack const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/); if (frontmatterMatch) { const frontmatter = frontmatterMatch[1]; const techStackMatch = frontmatter.match(/techStack:\s*\n([\s\S]*?)(?=\n\w|$)/); if (techStackMatch) { // Parse the tech stack YAML const techStackYaml = techStackMatch[1]; const techStack = {}; // Simple YAML parsing for tech stack const lines = techStackYaml.split('\n'); let currentCategory = null; for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('-')) { currentCategory = trimmed.replace(':', ''); techStack[currentCategory] = []; } else if (trimmed.startsWith('-') && currentCategory) { const tech = trimmed.replace('-', '').trim(); if (tech) { techStack[currentCategory].push(tech); } } } res.json({ techStack }); } else { res.json({ techStack: {} }); } } else { res.json({ techStack: {} }); } } else { res.status(404).json({ error: 'Agent not found' }); } } catch (error) { console.error('Error loading agent tech stack:', error); res.status(500).json({ error: 'Failed to load agent tech stack' }); } }); // Save agent-specific tech stack app.post('/api/tech-stack/agent', async (req, res) => { try { const { directory, agentName, techStack } = req.body; if (!directory || !agentName) { return res.status(400).json({ error: 'Directory and agent name are required' }); } const agentFile = path.join(directory, '.claude', 'agents', `${agentName}.md`); const exists = await fs.access(agentFile).then(() => true).catch(() => false); if (!exists) { return res.status(404).json({ error: 'Agent not found' }); } const content = await fs.readFile(agentFile, 'utf-8'); // Parse existing frontmatter const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/); let newContent = content; if (frontmatterMatch) { // Update existing frontmatter const frontmatter = frontmatterMatch[1]; let updatedFrontmatter = frontmatter; // Remove existing techStack if present updatedFrontmatter = updatedFrontmatter.replace(/techStack:\s*\n([\s\S]*?)(?=\n\w|$)/g, ''); // Add new techStack if (Object.keys(techStack).length > 0) { const techStackYaml = Object.entries(techStack) .filter(([_, techs]) => techs && techs.length > 0) .map(([category, techs]) => ` ${category}:\n${techs.map(tech => ` - ${tech}`).join('\n')}`) .join('\n'); updatedFrontmatter += `\ntechStack:\n${techStackYaml}`; } newContent = content.replace(frontmatterMatch[0], `---\n${updatedFrontmatter}\n---\n`); } else { // Create new frontmatter const techStackYaml = Object.entries(techStack) .filter(([_, techs]) => techs && techs.length > 0) .map(([category, techs]) => ` ${category}:\n${techs.map(tech => ` - ${tech}`).join('\n')}`) .join('\n'); const newFrontmatter = `---\ntechStack:\n${techStackYaml}\n---\n\n`; newContent = newFrontmatter + content; } await fs.writeFile(agentFile, newContent, 'utf-8'); res.json({ success: true, message: 'Agent tech stack saved successfully' }); } catch (error) { console.error('Error saving agent tech stack:', error); res.status(500).json({ error: 'Failed to save agent tech stack' }); } }); // Delete an agent app.delete('/api/delete-agent/:encodedDir/:agentName', async (req, res) => { try { const directory = decodeURIComponent(req.params.encodedDir); const { agentName } = req.params; const agentFile = path.join(directory, '.claude', 'agents', `${agentName}.md`); const statusFile = path.join(directory, '.claude', 'agents-status', `${agentName}-status.md`); // Check if agent exists const agentExists = await fs.access(agentFile).then(() => true).catch(() => false); if (!agentExists) { return res.status(404).json({ error: 'Agent not found' }); } // Delete both files const deletePromises = [ fs.unlink(agentFile).catch(err => console.warn(`Failed to delete agent file: ${err.message}`)), fs.unlink(statusFile).catch(err => console.warn(`Failed to delete status file: ${err.message}`)) ]; await Promise.all(deletePromises); res.json({ success: true, message: `Agent ${agentName} deleted successfully` }); } catch (error) { console.error('Error deleting agent:', error); res.status(500).json({ error: 'Failed to delete agent' }); } }); // Analyze content and suggest agents app.post('/api/analyze-for-agents', async (req, res) => { try { const { content, fileType } = req.body; const prompt = `You are an expert at analyzing projects and determining what Claude Code sub-agents would be helpful. Analyze the following ${fileType || 'content'} and suggest Claude Code sub-agents that would be useful for this project. For each agent, provide: 1. A clear, descriptive name (use kebab-case, e.g., "frontend-developer") 2. A brief description of what the agent does 3. A comprehensive system prompt that gives the agent its role, responsibilities, and guidelines Return your response as a JSON array of agent suggestions. Content to analyze: ${content} IMPORTANT: Return ONLY a valid JSON array, no markdown formatting or extra text. Example format: [ { "name": "agent-name", "description": "Brief description", "systemPrompt": "Detailed system prompt..." } ]`; const config = getConfig(); const response = await anthropic.messages.create({ model: config.model || 'claude-3-haiku-20240307', // Use configurable model, default to Haiku max_tokens: config.maxTokensPerRequest || 2000, // Configurable max tokens messages: [{ role: 'user', content: prompt }] }); try { // Parse the JSON response const suggestions = JSON.parse(response.content[0].text); res.json({ suggestions: Array.isArray(suggestions) ? suggestions : [] }); } catch (parseError) { // If parsing fails, try to extract JSON from the response const jsonMatch = response.content[0].text.match(/\[[\s\S]*\]/); if (jsonMatch) { try { const suggestions = JSON.parse(jsonMatch[0]); res.json({ suggestions: Array.isArray(suggestions) ? suggestions : [] }); } catch (e) { console.error('Failed to parse extracted JSON:', e); res.json({ suggestions: [], error: 'Failed to parse Claude response' }); } } else { console.error('No JSON array found in response'); res.json({ suggestions: [], error: 'Claude did not return a valid JSON array' }); } } } catch (error) { console.error('Error analyzing content:', error); res.status(500).json({ suggestions: [], error: error.message || 'Failed to analyze content' }); } }); // Analyze uploaded file and suggest agents app.post('/api/analyze-file-for-agents', upload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ suggestions: [], error: 'No file uploaded' }); } // Convert file buffer to text const content = req.file.buffer.toString('utf-8'); const fileType = path.extname(req.file.originalname); // Use the same analysis endpoint logic const prompt = `You are an expert at analyzing projects and determining what Claude Code sub-agents would be helpful. Analyze the following ${fileType} file content and suggest Claude Code sub-agents that would be useful for this project. For each agent, provide: 1. A clear, descriptive name (use kebab-case, e.g., "frontend-developer") 2. A brief description of what the agent does 3. A comprehensive system prompt that gives the agent its role, responsibilities, and guidelines Return your response as a JSON array of agent suggestions. File: ${req.file.originalname} Content: ${content} IMPORTANT: Return ONLY a valid JSON array, no markdown formatting or extra text.`; const config = getConfig(); const response = await anthropic.messages.create({ model: config.model || 'claude-3-haiku-20240307', // Use configurable model, default to Haiku max_tokens: config.maxTokensPerRequest || 2000, // Configurable max tokens messages: [{ role: 'user', content: prompt }] }); try { // Parse the JSON response const suggestions = JSON.parse(response.content[0].text); res.json({ suggestions: Array.isArray(suggestions) ? suggestions : [] }); } catch (parseError) { // If parsing fails, try to extract JSON from the response const jsonMatch = response.content[0].text.match(/\[[\s\S]*\]/); if (jsonMatch) { try { const suggestions = JSON.parse(jsonMatch[0]); res.json({ suggestions: Array.isArray(suggestions) ? suggestions : [] }); } catch (e) { console.error('Failed to parse extracted JSON:', e); res.json({ suggestions: [], error: 'Failed to parse Claude response' }); } } else { console.error('No JSON array found in response'); res.json({ suggestions: [], error: 'Claude did not return a valid JSON array' }); } } } catch (error) { console.error('Error analyzing file:', error); res.status(500).json({ suggestions: [], error: error.message || 'Failed to analyze file' }); } }); // Terminal management endpoints // Support multiple terminal instances const terminals = new Map(); // Map of terminalId -> { process, port } let terminalIdCounter = 1; // Start terminal session app.post('/api/terminal/start', async (req, res) => { try { const terminalId = terminalIdCounter++; // Don't kill existing terminals - allow multiple instances // Find an available port starting from 7681 (ttyd default) const { spawn } = require('child_process'); const net = require('net'); const findAvailablePort = (startPort) => { return new Promise((resolve) => { const server = net.createServer(); server.listen(startPort, () => { const port = server.address().port; server.close(() => resolve(port)); }); server.on('error', () => { resolve(findAvailablePort(startPort + 1)); }); }); }; // Don't kill existing terminals - find a unique port for this instance const basePort = 7681; const maxPort = basePort + 20; // Allow up to 20 terminals let port = null; // Find an available port that's not being used by our terminals for (let p = basePort; p <= maxPort; p++) { let isUsedByUs = false; for (const [id, term] of terminals) { if (term.port === p) { isUsedByUs = true; break; } } if (!isUsedByUs) { const isAvailable = await new Promise((resolve) => { const server = net.createServer(); server.listen(p, () => { server.close(() => resolve(true)); }); server.on('error', () => resolve(false)); }); if (isAvailable) { port = p; break; } } } if (!port) { return res.status(500).json({ error: 'No available ports for new terminal' }); } // Start ttyd process with unique title // Each terminal runs on a different port, so they are independent const terminalProcess = spawn('ttyd', [ '-p', port.toString(), '-W', // Enable writable mode '-t', `titleFixed=Terminal ${terminalId}`, '-t', 'theme=dark', '-t', 'fontSize=14', '-d', '7', // Debug level 'bash' // Start a new bash shell ], { stdio: 'inherit', // Let ttyd handle its own I/O cwd: process.cwd(), detached: false }); // Store terminal info terminals.set(terminalId, { process: terminalProcess, port: port, id: terminalId }); terminalProcess.on('error', (error) => { console.error(`Failed to start ttyd for terminal ${terminalId}:`, error); terminals.delete(terminalId); }); terminalProcess.on('exit', (code) => { console.log(`ttyd process for terminal ${terminalId} exited with code ${code}`); terminals.delete(terminalId); }); // Wait for ttyd to fully start before returning // Check if the port is actually listening before returning const checkPort = async () => { const net = require('net'); return new Promise((resolve) => { const client = new net.Socket(); client.setTimeout(100); client.on('connect', () => { client.destroy(); resolve(true); }); client.on('timeout', () => { client.destroy(); resolve(false); }); client.on('error', () => { resolve(false); }); client.connect(port, 'localhost'); }); }; // Wait for ttyd to be ready let retries = 0; const waitForReady = async () => { if (await checkPort()) { res.json({ terminalId: terminalId, url: `http://localhost:${port}`, port: port, status: 'running' }); } else if (retries < 10) { retries++; setTimeout(waitForReady, 200); } else { terminals.delete(terminalId); terminalProcess.kill(); res.status(500).json({ error: 'Terminal failed to start' }); } }; setTimeout(waitForReady, 500); } catch (error) { console.error('Error starting terminal:', error); res.status(500).json({ error: error.message }); } }); // Stop specific terminal session app.post('/api/terminal/stop/:terminalId', async (req, res) => { try { const terminalId = parseInt(req.params.terminalId); const terminal = terminals.get(terminalId); if (terminal) { terminal.process.kill(); terminals.delete(terminalId); res.json({ status: 'stopped',