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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
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, '../..')
|
||||
|
||||
@@ -18,6 +19,22 @@ 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 {
|
||||
@@ -46,6 +63,53 @@ interface Agent {
|
||||
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'
|
||||
@@ -54,21 +118,15 @@ function getFileType(filename: string): AgentFile['type'] {
|
||||
}
|
||||
|
||||
function categorizeFile(relPath: string, filename: string): FileCategory {
|
||||
// Backups first (most specific)
|
||||
if (filename.includes('.backup')) return 'backups'
|
||||
|
||||
// By directory
|
||||
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'
|
||||
|
||||
// By filename
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -134,10 +192,46 @@ function readUiConfig(agentDir: string): UiConfig | 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[] = []
|
||||
|
||||
// Main agent (.claude/ + root files)
|
||||
const claudeDir = join(PROJECT_ROOT, '.claude')
|
||||
if (existsSync(claudeDir)) {
|
||||
const mainAgent: Agent = {
|
||||
@@ -148,7 +242,6 @@ function discoverAgents(): Agent[] {
|
||||
uiConfig: readUiConfig(claudeDir)
|
||||
}
|
||||
|
||||
// Root CLAUDE.md
|
||||
const claudeMd = join(PROJECT_ROOT, 'CLAUDE.md')
|
||||
if (existsSync(claudeMd)) {
|
||||
const stat = statSync(claudeMd)
|
||||
@@ -158,7 +251,6 @@ function discoverAgents(): Agent[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Root .mcp.json
|
||||
const mcpJson = join(PROJECT_ROOT, '.mcp.json')
|
||||
if (existsSync(mcpJson)) {
|
||||
const stat = statSync(mcpJson)
|
||||
@@ -171,7 +263,6 @@ function discoverAgents(): Agent[] {
|
||||
agents.push(mainAgent)
|
||||
}
|
||||
|
||||
// Other agents (.claude-*/)
|
||||
try {
|
||||
const rootEntries = readdirSync(PROJECT_ROOT)
|
||||
for (const entry of rootEntries) {
|
||||
@@ -193,6 +284,147 @@ function discoverAgents(): Agent[] {
|
||||
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())
|
||||
@@ -237,3 +469,103 @@ export async function handleAgentsFile(req: Request, url: URL): Promise<Response
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user