import { jsonResponse, errorResponse } from '../utils/cors' import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs' import { join, resolve, basename } from 'path' import { homedir } from 'os' const PROJECT_ROOT = resolve(import.meta.dir, '../..') // Allowed config file patterns (for path validation on read/write) const ALLOWED_PATTERNS = [ /^\.claude\/.*$/, /^\.claude-[^/]+\/.*$/, /^CLAUDE\.md$/, /^\.mcp\.json$/ ] // Skip .git internals (hundreds of binary/pack files, useless for debugging) const SKIP_DIRS = ['.git'] // Sensitive files blocked from read/write via API const BLOCKED_FILES = ['.credentials.json'] // Base tool names known to Claude Code const BASE_TOOLS = [ 'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Task', 'NotebookEdit', 'Skill', 'EnterPlanMode', 'ExitPlanMode', 'AskUserQuestion', 'TodoRead', 'TodoWrite' ] // Hook event types type HookEventType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'SessionStart' | 'Stop' | 'Notification' | 'PermissionRequest' const HOOK_EVENT_TYPES: HookEventType[] = [ 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'SessionStart', 'Stop', 'Notification', 'PermissionRequest' ] type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other' interface AgentFile { name: string path: string type: 'json' | 'markdown' | 'text' | 'jsonl' category: FileCategory size: number } interface UiConfig { label: string shortLabel: string color: string gradient: string terminalBg: string terminalBorder: string enabled: boolean } interface Agent { id: string name: string directory: string files: AgentFile[] uiConfig: UiConfig | null } interface ParsedPermission { raw: string tool: string params: string | null category: 'base' | 'mcp' server?: string host?: string } // ── Permission string parser ── function parsePermission(raw: string): ParsedPermission { // MCP tools: mcp____- const mcpMatch = raw.match(/^mcp__([^_]+(?:_[^_]+)?)__([^-]+)-(.+)$/) if (mcpMatch) { return { raw, tool: mcpMatch[3], params: null, category: 'mcp', server: mcpMatch[1].replace(/_/g, '-'), host: mcpMatch[2] } } // Parameterized: Tool(params) const paramMatch = raw.match(/^(\w+)\((.+)\)$/) if (paramMatch) { return { raw, tool: paramMatch[1], params: paramMatch[2], category: BASE_TOOLS.includes(paramMatch[1]) ? 'base' : 'mcp' } } // Simple: ToolName return { raw, tool: raw, params: null, category: BASE_TOOLS.includes(raw) ? 'base' : 'mcp' } } // ── File utilities ── function getFileType(filename: string): AgentFile['type'] { if (filename.endsWith('.jsonl')) return 'jsonl' if (filename.endsWith('.json')) return 'json' if (filename.endsWith('.md')) return 'markdown' return 'text' } function categorizeFile(relPath: string, filename: string): FileCategory { if (filename.includes('.backup')) return 'backups' if (relPath.includes('/plugins/')) return 'plugins' if (relPath.includes('/debug/')) return 'debug' if (relPath.includes('/cache/')) return 'cache' if (relPath.includes('/session-env/') || relPath.includes('/shell-snapshots/') || relPath.includes('/todos/') || relPath.includes('/projects/')) return 'sessions' if (filename === 'history.jsonl') return 'history' if (filename.endsWith('.md')) return 'instructions' if (filename === 'settings.json' || filename === '.claude.json' || filename === '.mcp.json') return 'config' if (filename.endsWith('.json')) return 'config' return 'other' } function isBlocked(filename: string): boolean { return BLOCKED_FILES.includes(filename) } function isAllowedPath(relativePath: string): boolean { const normalized = relativePath.replace(/\\/g, '/') return ALLOWED_PATTERNS.some(p => p.test(normalized)) } function scanDirectory(dir: string, relBase: string): AgentFile[] { const files: AgentFile[] = [] if (!existsSync(dir)) return files try { const entries = readdirSync(dir) for (const entry of entries) { const fullPath = join(dir, entry) const relPath = `${relBase}/${entry}` let stat try { stat = statSync(fullPath) } catch { continue } if (isBlocked(entry)) continue if (stat.isFile()) { files.push({ name: entry, path: relPath, type: getFileType(entry), category: categorizeFile(relPath, entry), size: stat.size }) } else if (stat.isDirectory()) { if (SKIP_DIRS.includes(entry)) continue files.push(...scanDirectory(fullPath, relPath)) } } } catch { /* permission errors */ } return files } function readUiConfig(agentDir: string): UiConfig | null { const uiPath = join(agentDir, 'ui.json') if (!existsSync(uiPath)) return null try { const raw = readFileSync(uiPath, 'utf-8') const data = JSON.parse(raw) return { label: data.label || '', shortLabel: data.shortLabel || '', color: data.color || '#6366f1', gradient: data.gradient || '', terminalBg: data.terminalBg || '#0f0a1a', terminalBorder: data.terminalBorder || '#6366f1', enabled: data.enabled !== false } } catch { return null } } function readJsonFile(path: string): any { try { if (!existsSync(path)) return null return JSON.parse(readFileSync(path, 'utf-8')) } catch { return null } } function writeJsonFile(path: string, data: any): void { const dir = resolve(path, '..') if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf-8') } // ── Config file targeting ── function getSettingsPath(agentId: string): string { if (agentId === 'main') { return join(PROJECT_ROOT, '.claude', 'settings.local.json') } return join(PROJECT_ROOT, `.claude-${agentId}`, 'settings.json') } function getSkillsDir(agentId: string): string { if (agentId === 'main') { return join(PROJECT_ROOT, '.claude', 'skills') } return join(PROJECT_ROOT, `.claude-${agentId}`, 'skills') } function getMcpJsonPath(): string { return join(PROJECT_ROOT, '.mcp.json') } // ── Agent discovery ── function discoverAgents(): Agent[] { const agents: Agent[] = [] const claudeDir = join(PROJECT_ROOT, '.claude') if (existsSync(claudeDir)) { const mainAgent: Agent = { id: 'main', name: 'Claude Code (main)', directory: '.claude', files: scanDirectory(claudeDir, '.claude'), uiConfig: readUiConfig(claudeDir) } const claudeMd = join(PROJECT_ROOT, 'CLAUDE.md') if (existsSync(claudeMd)) { const stat = statSync(claudeMd) mainAgent.files.unshift({ name: 'CLAUDE.md', path: 'CLAUDE.md', type: 'markdown', category: 'instructions', size: stat.size }) } const mcpJson = join(PROJECT_ROOT, '.mcp.json') if (existsSync(mcpJson)) { const stat = statSync(mcpJson) mainAgent.files.push({ name: '.mcp.json', path: '.mcp.json', type: 'json', category: 'config', size: stat.size }) } agents.push(mainAgent) } try { const rootEntries = readdirSync(PROJECT_ROOT) for (const entry of rootEntries) { if (!entry.startsWith('.claude-')) continue const fullPath = join(PROJECT_ROOT, entry) try { if (!statSync(fullPath).isDirectory()) continue } catch { continue } const agentId = entry.replace('.claude-', '') agents.push({ id: agentId, name: agentId.charAt(0).toUpperCase() + agentId.slice(1), directory: entry, files: scanDirectory(fullPath, entry), uiConfig: readUiConfig(fullPath) }) } } catch { /* ignore */ } return agents } // ── Skill discovery ── function discoverSkills(agentId: string): any[] { const skills: any[] = [] const skillsDir = getSkillsDir(agentId) if (!existsSync(skillsDir)) { // Fallback for sub-agents: try main agent's skills dir if (agentId !== 'main') { const mainSkillsDir = getSkillsDir('main') if (existsSync(mainSkillsDir)) { return discoverSkillsFromDir(mainSkillsDir) } } return skills } return discoverSkillsFromDir(skillsDir) } function discoverSkillsFromDir(dir: string): any[] { const skills: any[] = [] if (!existsSync(dir)) return skills try { const entries = readdirSync(dir) for (const entry of entries) { const entryPath = join(dir, entry) let stat try { stat = statSync(entryPath) } catch { continue } if (stat.isDirectory()) { const skillMdPath = join(entryPath, 'SKILL.md') if (existsSync(skillMdPath)) { const content = readFileSync(skillMdPath, 'utf-8') const nameMatch = content.match(/^#\s+(.+)/m) const descMatch = content.match(/^(?:#+\s+.+\n+)(.+)/m) // Find referenced files const references: { name: string; path: string }[] = [] try { const skillEntries = readdirSync(entryPath) for (const se of skillEntries) { if (se !== 'SKILL.md') { references.push({ name: se, path: join(entryPath, se) }) } } } catch { /* ignore */ } skills.push({ name: nameMatch ? nameMatch[1].trim() : entry, description: descMatch ? descMatch[1].trim() : '', path: entryPath, skillMdContent: content, references }) } } } } catch { /* ignore */ } return skills } // ── Plugin discovery ── function discoverPlugins(): any[] { const plugins: any[] = [] const pluginsDir = join(homedir(), '.claude', 'plugins', 'marketplaces') if (!existsSync(pluginsDir)) return plugins try { const entries = readdirSync(pluginsDir) for (const entry of entries) { const entryPath = join(pluginsDir, entry) let stat try { stat = statSync(entryPath) } catch { continue } if (stat.isDirectory()) { // Look for plugin manifest (package.json or plugin.json) const pkgPath = join(entryPath, 'package.json') const pluginJsonPath = join(entryPath, 'plugin.json') let manifest: any = readJsonFile(pkgPath) || readJsonFile(pluginJsonPath) if (manifest) { plugins.push({ name: manifest.name || entry, description: manifest.description || '', author: manifest.author || '', mcpConfig: manifest.mcpConfig || manifest.mcp || null, installed: true }) } else { plugins.push({ name: entry, description: '', author: '', mcpConfig: null, installed: true }) } } } } catch { /* ignore */ } return plugins } // ── Known tools discovery ── function discoverKnownTools(): { baseTools: string[]; mcpTools: Record> } { const mcpTools: Record> = {} const agents = discoverAgents() for (const agent of agents) { const settings = readJsonFile(getSettingsPath(agent.id)) if (!settings?.permissions) continue const allPerms = [ ...(settings.permissions.allow || []), ...(settings.permissions.deny || []) ] for (const raw of allPerms) { const parsed = parsePermission(raw) if (parsed.category === 'mcp' && parsed.server && parsed.host) { if (!mcpTools[parsed.server]) mcpTools[parsed.server] = {} if (!mcpTools[parsed.server][parsed.host]) mcpTools[parsed.server][parsed.host] = [] if (!mcpTools[parsed.server][parsed.host].includes(parsed.tool)) { mcpTools[parsed.server][parsed.host].push(parsed.tool) } } } } return { baseTools: BASE_TOOLS, mcpTools } } // ── Handlers ── export async function handleAgents(req: Request): Promise { if (req.method !== 'GET') return null return jsonResponse(discoverAgents()) } export async function handleAgentsFile(req: Request, url: URL): Promise { const filePath = url.searchParams.get('path') if (!filePath) return errorResponse('Missing "path" query parameter') const normalized = filePath.replace(/\\/g, '/') if (!isAllowedPath(normalized)) return errorResponse('Access denied: path not allowed', 403) if (isBlocked(basename(normalized))) return errorResponse('Access denied: sensitive file', 403) const absolutePath = resolve(PROJECT_ROOT, normalized) if (!absolutePath.startsWith(PROJECT_ROOT)) return errorResponse('Access denied: path traversal', 403) if (req.method === 'GET') { if (!existsSync(absolutePath)) return errorResponse('File not found', 404) try { const content = await Bun.file(absolutePath).text() return jsonResponse({ path: normalized, content }) } catch (e: any) { return errorResponse(`Failed to read file: ${e.message}`, 500) } } if (req.method === 'POST') { try { const body = await req.json() if (typeof body.content !== 'string') return errorResponse('Missing "content" field') if (normalized.endsWith('.json')) { try { JSON.parse(body.content) } catch { return errorResponse('Invalid JSON content') } } await Bun.write(absolutePath, body.content) return jsonResponse({ success: true, path: normalized }) } catch (e: any) { return errorResponse(`Failed to write file: ${e.message}`, 500) } } return null } export async function handleAgentsConfig(req: Request, url: URL): Promise { if (req.method !== 'GET') return null const agentId = url.searchParams.get('agentId') || 'main' const settingsPath = getSettingsPath(agentId) const settings = readJsonFile(settingsPath) || {} const allowRaw: string[] = settings.permissions?.allow || [] const denyRaw: string[] = settings.permissions?.deny || [] return jsonResponse({ agentId, configFile: settingsPath.replace(PROJECT_ROOT + '/', '').replace(PROJECT_ROOT + '\\', ''), permissions: { allow: allowRaw.map(parsePermission), deny: denyRaw.map(parsePermission) }, hooks: settings.hooks || {}, env: settings.env || {}, enableAllProjectMcpServers: settings.enableAllProjectMcpServers ?? false, enabledMcpjsonServers: settings.enabledMcpjsonServers || [] }) } export async function handleAgentsKnownTools(req: Request): Promise { if (req.method !== 'GET') return null return jsonResponse(discoverKnownTools()) } export async function handleAgentsSkills(req: Request, url: URL): Promise { if (req.method !== 'GET') return null const agentId = url.searchParams.get('agentId') || 'main' return jsonResponse(discoverSkills(agentId)) } export async function handleAgentsPlugins(req: Request): Promise { if (req.method !== 'GET') return null return jsonResponse(discoverPlugins()) } export async function handleAgentsMcpJson(req: Request): Promise { if (req.method !== 'GET') return null const mcpPath = getMcpJsonPath() const data = readJsonFile(mcpPath) return jsonResponse(data || { mcpServers: {} }) } export async function handleAgentsConfigPermissions(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() const agentId = body.agentId || 'main' const settingsPath = getSettingsPath(agentId) const settings = readJsonFile(settingsPath) || {} settings.permissions = { allow: body.permissions?.allow || [], deny: body.permissions?.deny || [] } writeJsonFile(settingsPath, settings) return jsonResponse({ success: true }) } catch (e: any) { return errorResponse(`Failed to save permissions: ${e.message}`, 500) } } export async function handleAgentsConfigHooks(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() const agentId = body.agentId || 'main' const settingsPath = getSettingsPath(agentId) const settings = readJsonFile(settingsPath) || {} settings.hooks = body.hooks || {} writeJsonFile(settingsPath, settings) return jsonResponse({ success: true }) } catch (e: any) { return errorResponse(`Failed to save hooks: ${e.message}`, 500) } } export async function handleAgentsConfigMcp(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() const mcpPath = getMcpJsonPath() writeJsonFile(mcpPath, { mcpServers: body.mcpServers || {} }) return jsonResponse({ success: true }) } catch (e: any) { return errorResponse(`Failed to save MCP config: ${e.message}`, 500) } }