/** * @fileoverview Case management routes. * Handles CRUD for cases (directories under ~/codeman-cases and linked folders), * fix-plan reading, and ralph-wizard file serving. */ import { FastifyInstance } from 'fastify'; import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { ApiResponse, CaseInfo } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json'); /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */ async function readLinkedCases(): Promise> { try { return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8')); } catch (err) { // Only warn on real I/O errors, not ENOENT (file missing) or SyntaxError (corrupted JSON) if ((err as NodeJS.ErrnoException).code && (err as NodeJS.ErrnoException).code !== 'ENOENT') { console.warn('[Server] Failed to read linked cases:', err); } return {}; } } /** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */ async function resolveCasePath(name: string): Promise { const linkedCases = await readLinkedCases(); if (linkedCases[name]) return linkedCases[name]; return join(CASES_DIR, name); } export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void { // ═══════════════════════════════════════════════════════════════ // Case CRUD (list, create, link, detail, fix-plan) // ═══════════════════════════════════════════════════════════════ // ========== List Cases ========== app.get('/api/cases', async (): Promise => { const cases: CaseInfo[] = []; // Get cases from CASES_DIR try { const entries = await fs.readdir(CASES_DIR, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { cases.push({ name: e.name, path: join(CASES_DIR, e.name), hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')), }); } } } catch { // CASES_DIR may not exist yet } // Get linked cases const linkedCases = await readLinkedCases(); const existingNames = new Set(cases.map((c) => c.name)); for (const [name, path] of Object.entries(linkedCases)) { if (!existingNames.has(name) && existsSync(path)) { cases.push({ name, path, hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), }); } } return cases; }); app.post('/api/cases', async (req): Promise> => { const result = CreateCaseSchema.safeParse(req.body); if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } const { name, description } = result.data; const casePath = validatePathWithinBase(name, CASES_DIR); if (!casePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } if (existsSync(casePath)) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); } try { mkdirSync(casePath, { recursive: true }); mkdirSync(join(casePath, 'src'), { recursive: true }); // Read settings to get custom template path const templatePath = await ctx.getDefaultClaudeMdPath(); const claudeMd = generateClaudeMd(name, description || '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications await writeHooksConfig(casePath); ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath }); return { success: true, data: { case: { name, path: casePath } } }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // Link an existing folder as a case app.post('/api/cases/link', async (req): Promise> => { const lcResult = LinkCaseSchema.safeParse(req.body); if (!lcResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { name, path: folderPath } = lcResult.data; // Expand ~ to home directory const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; // Validate the folder exists if (!existsSync(expandedPath)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`); } // Check if case name already exists in CASES_DIR const casePath = join(CASES_DIR, name); if (existsSync(casePath)) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.'); } // Load existing linked cases const linkedCases = await readLinkedCases(); // Check if name is already linked if (linkedCases[name]) { return createErrorResponse( ApiErrorCode.ALREADY_EXISTS, `Case "${name}" is already linked to ${linkedCases[name]}` ); } // Save the linked case linkedCases[name] = expandedPath; try { const codemanDir = join(homedir(), '.codeman'); if (!existsSync(codemanDir)) { mkdirSync(codemanDir, { recursive: true }); } await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2)); ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath }); return { success: true, data: { case: { name, path: expandedPath } } }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; if (!validatePathWithinBase(name, CASES_DIR)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } const casePath = await resolveCasePath(name); if (!existsSync(casePath)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); } const linked = casePath !== join(CASES_DIR, name); return { name, path: casePath, hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')), ...(linked && { linked: true }), }; }); // Read @fix_plan.md from a case directory (for wizard to detect existing plans) app.get('/api/cases/:name/fix-plan', async (req) => { const { name } = req.params as { name: string }; if (!validatePathWithinBase(name, CASES_DIR)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } // Get case path (check linked cases first, then CASES_DIR) const casePath = await resolveCasePath(name); const fixPlanPath = join(casePath, '@fix_plan.md'); if (!existsSync(fixPlanPath)) { return { success: true, exists: false, content: null, todos: [] }; } try { const content = await fs.readFile(fixPlanPath, 'utf-8'); // Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown) const todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; priority: string | null; }> = []; const todoPattern = /^-\s*\[([ xX-])\]\s*(.+)$/; const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i; const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i; const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i; const completedHeaderPattern = /^##\s*Completed/i; let currentPriority: string | null = null; let inCompletedSection = false; for (const line of content.split('\n')) { const trimmed = line.trim(); if (p0HeaderPattern.test(trimmed)) { currentPriority = 'P0'; inCompletedSection = false; continue; } if (p1HeaderPattern.test(trimmed)) { currentPriority = 'P1'; inCompletedSection = false; continue; } if (p2HeaderPattern.test(trimmed)) { currentPriority = 'P2'; inCompletedSection = false; continue; } if (completedHeaderPattern.test(trimmed)) { inCompletedSection = true; continue; } const match = trimmed.match(todoPattern); if (match) { const [, checkboxState, taskContent] = match; let status: 'pending' | 'in_progress' | 'completed'; if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') { status = 'completed'; } else if (checkboxState === '-') { status = 'in_progress'; } else { status = 'pending'; } todos.push({ content: taskContent.trim(), status, priority: inCompletedSection ? null : currentPriority, }); } } // Calculate stats in a single pass for better performance let pending = 0, inProgress = 0, completed = 0; for (const t of todos) { if (t.status === 'pending') pending++; else if (t.status === 'in_progress') inProgress++; else if (t.status === 'completed') completed++; } const stats = { total: todos.length, pending, inProgress, completed }; return { success: true, exists: true, content, todos, stats, }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`); } }); // ═══════════════════════════════════════════════════════════════ // Ralph Wizard Files (per-case prompt/result serving) // ═══════════════════════════════════════════════════════════════ // ========== List Wizard Files ========== app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { const { caseName } = req.params as { caseName: string }; if (!validatePathWithinBase(caseName, CASES_DIR)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } const casePath = await resolveCasePath(caseName); const wizardDir = join(casePath, 'ralph-wizard'); if (!existsSync(wizardDir)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found'); } // List all subdirectories and their files const files: Array<{ agentType: string; promptFile?: string; resultFile?: string }> = []; const entries = readdirSync(wizardDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const agentDir = join(wizardDir, entry.name); const agentFiles: { agentType: string; promptFile?: string; resultFile?: string } = { agentType: entry.name, }; if (existsSync(join(agentDir, 'prompt.md'))) { agentFiles.promptFile = `${entry.name}/prompt.md`; } if (existsSync(join(agentDir, 'result.json'))) { agentFiles.resultFile = `${entry.name}/result.json`; } if (agentFiles.promptFile || agentFiles.resultFile) { files.push(agentFiles); } } } return { success: true, data: { files, caseName } }; }); // Read a specific ralph-wizard file // Cache disabled to ensure fresh prompts when starting new plan generations app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => { const { caseName, filePath } = req.params as { caseName: string; filePath: string }; if (!validatePathWithinBase(caseName, CASES_DIR)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } // Prevent browser caching - prompts change between plan generations reply.header('Cache-Control', 'no-store, no-cache, must-revalidate'); reply.header('Pragma', 'no-cache'); reply.header('Expires', '0'); const casePath = await resolveCasePath(caseName); const wizardDir = join(casePath, 'ralph-wizard'); // Decode the file path (it may be URL encoded) const decodedPath = decodeURIComponent(filePath); const fullPath = join(wizardDir, decodedPath); // Security: ensure path is within wizard directory const resolvedPath = resolve(fullPath); const resolvedWizard = resolve(wizardDir); if (!resolvedPath.startsWith(resolvedWizard)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file path'); } let content: string; try { content = await fs.readFile(fullPath, 'utf-8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); } throw err; } const isJson = filePath.endsWith('.json'); // Parse JSON content safely (may contain invalid JSON or unescaped control characters) let parsed: unknown = null; if (isJson) { try { parsed = JSON.parse(content); } catch { // Try repairing common JSON issues (unescaped control characters, trailing commas) try { let repaired = content; // Fix trailing commas before closing brackets repaired = repaired.replace(/,(\s*[\]}])/g, '$1'); // Fix unescaped control characters within JSON strings repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => { return match .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\t/g, '\\t') .replace( // eslint-disable-next-line no-control-regex /[\x00-\x1f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}` ); }); parsed = JSON.parse(repaired); } catch { // Still invalid - return null for parsed, content available as raw string } } } return { success: true, data: { content, filePath: decodedPath, isJson, parsed, }, }; }); }