Files
agent-ui/server/routes/agents.ts
josedario87 ffceb2efc2 feat: Add configuration management UI for /agents page
- Add 6-tab horizontal bar: Files, Tools, MCPs, Plugins, Hooks, Skills
- Backend: permission parser, config/known-tools/skills/plugins/mcp-json endpoints
- Backend: POST endpoints for permissions, hooks, and MCP config
- Store: tool entries with 3-state toggle, MCP servers, hooks CRUD, skills/plugins fetch
- ToolsManager: search, grouped cards (base/MCP), ask/allow/deny cycle, parameterized rules
- McpManager: server cards with enable/disable, add/edit/delete modal
- PluginsManager: read-only global plugin cards from ~/.claude/plugins/
- HooksManager: accordion by event type, inline edit with matcher/command/timeout
- SkillsManager: two-column layout with SKILL.md preview and references
2026-02-15 18:29:15 -06:00

572 lines
17 KiB
TypeScript

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__<server>__<host>-<toolName>
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<string, Record<string, string[]>> } {
const mcpTools: Record<string, Record<string, string[]>> = {}
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<Response | null> {
if (req.method !== 'GET') return null
return jsonResponse(discoverAgents())
}
export async function handleAgentsFile(req: Request, url: URL): Promise<Response | null> {
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<Response | null> {
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<Response | null> {
if (req.method !== 'GET') return null
return jsonResponse(discoverKnownTools())
}
export async function handleAgentsSkills(req: Request, url: URL): Promise<Response | null> {
if (req.method !== 'GET') return null
const agentId = url.searchParams.get('agentId') || 'main'
return jsonResponse(discoverSkills(agentId))
}
export async function handleAgentsPlugins(req: Request): Promise<Response | null> {
if (req.method !== 'GET') return null
return jsonResponse(discoverPlugins())
}
export async function handleAgentsMcpJson(req: Request): Promise<Response | null> {
if (req.method !== 'GET') return null
const mcpPath = getMcpJsonPath()
const data = readJsonFile(mcpPath)
return jsonResponse(data || { mcpServers: {} })
}
export async function handleAgentsConfigPermissions(req: Request): Promise<Response | null> {
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<Response | null> {
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<Response | null> {
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)
}
}