From e293195ad0cdb31363f20c87a8197dbd9500001b Mon Sep 17 00:00:00 2001 From: catlog22 Date: Tue, 24 Mar 2026 20:59:06 +0800 Subject: [PATCH] feat: add advanced frontmatter config for Claude agent definitions Support all Claude agent frontmatter fields (color, permissionMode, memory, maxTurns, background, isolation, tools, disallowedTools, skills, mcpServers, hooks) with MCP server picker, hooks editor, and progressive disclosure UI. Co-Authored-By: Claude Opus 4.6 --- .../settings/AgentDefinitionsSection.tsx | 576 +++++++++++++++--- ccw/frontend/src/lib/api.ts | 16 +- .../core/routes/agent-definitions-routes.ts | 187 +++++- 3 files changed, 699 insertions(+), 80 deletions(-) diff --git a/ccw/frontend/src/components/settings/AgentDefinitionsSection.tsx b/ccw/frontend/src/components/settings/AgentDefinitionsSection.tsx index 6ff22624..0ddff4ea 100644 --- a/ccw/frontend/src/components/settings/AgentDefinitionsSection.tsx +++ b/ccw/frontend/src/components/settings/AgentDefinitionsSection.tsx @@ -2,27 +2,444 @@ // Agent Definitions Section // ======================================== // Settings section for viewing and editing Codex/Claude agent model and effort fields +// Claude agents support advanced frontmatter config: MCP servers, hooks, permissions, etc. import { useState, useEffect, useCallback } from 'react'; -import { Bot, ChevronDown, ChevronRight, Save, RefreshCw } from 'lucide-react'; +import { Bot, ChevronDown, ChevronRight, Save, RefreshCw, Settings2, Plus, X } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Badge } from '@/components/ui/Badge'; +import { Checkbox } from '@/components/ui/Checkbox'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { fetchAgentDefinitions, updateAgentDefinition, batchUpdateAgentDefinitions, + fetchMcpConfig, type AgentDefinition, } from '@/lib/api'; -// ========== Effort options ========== +// ========== Constants ========== const CODEX_EFFORTS = ['', 'low', 'medium', 'high']; const CLAUDE_EFFORTS = ['', 'low', 'medium', 'high', 'max']; const CLAUDE_MODEL_PRESETS = ['sonnet', 'opus', 'haiku', 'inherit']; +const PERMISSION_MODES = ['', 'default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan']; +const MEMORY_OPTIONS = ['', 'user', 'project', 'local']; +const ISOLATION_OPTIONS = ['', 'worktree']; +const COLOR_OPTIONS = ['', 'purple', 'blue', 'yellow', 'green', 'red']; +const HOOK_EVENTS = ['PreToolUse', 'PostToolUse', 'Stop']; + +const selectClass = 'h-7 text-xs rounded border border-input bg-background px-2'; +const labelClass = 'text-xs text-muted-foreground shrink-0'; + +// ========== YAML helpers ========== + +function parseMcpServersYaml(yaml: string): string[] { + if (!yaml) return []; + const servers: string[] = []; + const lines = yaml.split('\n'); + for (const line of lines) { + const match = line.match(/^\s+-\s+(.+)$/); + if (match) servers.push(match[1].trim()); + } + return servers; +} + +function serializeMcpServersYaml(servers: string[]): string { + if (servers.length === 0) return ''; + return `mcpServers:\n${servers.map(s => ` - ${s}`).join('\n')}`; +} + +interface HookEntry { + matcher: string; + command: string; +} + +interface ParsedHooks { + PreToolUse: HookEntry[]; + PostToolUse: HookEntry[]; + Stop: HookEntry[]; +} + +function parseHooksYaml(yaml: string): ParsedHooks { + const result: ParsedHooks = { PreToolUse: [], PostToolUse: [], Stop: [] }; + if (!yaml) return result; + + let currentEvent: string | null = null; + let currentMatcher: string | null = null; + const lines = yaml.split('\n'); + + for (const line of lines) { + // Top-level event: " PreToolUse:" + const eventMatch = line.match(/^\s{2,4}(\w+):$/); + if (eventMatch && (eventMatch[1] in result)) { + currentEvent = eventMatch[1]; + currentMatcher = null; + continue; + } + // Matcher line: " - matcher: "Bash"" + const matcherMatch = line.match(/^\s+-\s+matcher:\s*["']?([^"'\n]+)["']?$/); + if (matcherMatch && currentEvent) { + currentMatcher = matcherMatch[1].trim(); + continue; + } + // Command line: " command: "./scripts/validate.sh" + const commandMatch = line.match(/^\s+command:\s*["']?([^"'\n]+)["']?$/); + if (commandMatch && currentEvent && currentMatcher !== null) { + result[currentEvent as keyof ParsedHooks].push({ + matcher: currentMatcher, + command: commandMatch[1].trim(), + }); + currentMatcher = null; + continue; + } + // type: command line — skip, we only support command type + } + return result; +} + +function serializeHooksYaml(hooks: ParsedHooks): string { + const parts: string[] = []; + for (const event of HOOK_EVENTS) { + const entries = hooks[event as keyof ParsedHooks]; + if (entries.length === 0) continue; + parts.push(` ${event}:`); + for (const entry of entries) { + parts.push(` - matcher: "${entry.matcher}"`); + parts.push(` hooks:`); + parts.push(` - type: command`); + parts.push(` command: "${entry.command}"`); + } + } + if (parts.length === 0) return ''; + return `hooks:\n${parts.join('\n')}`; +} + +// ========== Advanced Settings (Claude only) ========== + +interface AdvancedSettingsProps { + agent: AgentDefinition; + onSaved: () => void; +} + +function AdvancedSettings({ agent, onSaved }: AdvancedSettingsProps) { + // Quick settings + const [color, setColor] = useState(agent.color); + const [permissionMode, setPermissionMode] = useState(agent.permissionMode); + const [memory, setMemory] = useState(agent.memory); + const [maxTurns, setMaxTurns] = useState(agent.maxTurns); + const [background, setBackground] = useState(agent.background === 'true'); + const [isolation, setIsolation] = useState(agent.isolation); + + // Tools + const [tools, setTools] = useState(agent.tools); + const [disallowedTools, setDisallowedTools] = useState(agent.disallowedTools); + const [skills, setSkills] = useState(agent.skills); + + // MCP Servers + const [installedServers, setInstalledServers] = useState([]); + const [selectedServers, setSelectedServers] = useState(() => parseMcpServersYaml(agent.mcpServers)); + const [mcpLoading, setMcpLoading] = useState(false); + const [customServer, setCustomServer] = useState(''); + + // Hooks + const [hooks, setHooks] = useState(() => parseHooksYaml(agent.hooks)); + const [newHookEvent, setNewHookEvent] = useState('PreToolUse'); + + const [saving, setSaving] = useState(false); + + // Load installed MCP servers + useEffect(() => { + let cancelled = false; + setMcpLoading(true); + fetchMcpConfig() + .then((config) => { + if (cancelled) return; + const names = new Set(); + // Collect from all sources + if (config.userServers) Object.keys(config.userServers).forEach(n => names.add(n)); + if (config.globalServers) Object.keys(config.globalServers).forEach(n => names.add(n)); + if (config.projects) { + Object.values(config.projects).forEach((proj: any) => { + if (proj?.mcpServers) Object.keys(proj.mcpServers).forEach(n => names.add(n)); + }); + } + setInstalledServers(Array.from(names).sort()); + }) + .catch(() => {}) + .finally(() => { if (!cancelled) setMcpLoading(false); }); + return () => { cancelled = true; }; + }, []); + + // Sync state on agent prop change + useEffect(() => { + setColor(agent.color); + setPermissionMode(agent.permissionMode); + setMemory(agent.memory); + setMaxTurns(agent.maxTurns); + setBackground(agent.background === 'true'); + setIsolation(agent.isolation); + setTools(agent.tools); + setDisallowedTools(agent.disallowedTools); + setSkills(agent.skills); + setSelectedServers(parseMcpServersYaml(agent.mcpServers)); + setHooks(parseHooksYaml(agent.hooks)); + }, [agent]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const body: { filePath: string; [key: string]: string | undefined } = { filePath: agent.filePath }; + + // Only send changed fields + if (color !== agent.color) body.color = color; + if (permissionMode !== agent.permissionMode) body.permissionMode = permissionMode; + if (memory !== agent.memory) body.memory = memory; + if (maxTurns !== agent.maxTurns) body.maxTurns = maxTurns; + const bgStr = background ? 'true' : ''; + if (bgStr !== agent.background) body.background = bgStr; + if (isolation !== agent.isolation) body.isolation = isolation; + if (tools !== agent.tools) body.tools = tools; + if (disallowedTools !== agent.disallowedTools) body.disallowedTools = disallowedTools; + if (skills !== agent.skills) body.skills = skills; + + // MCP servers + const newMcpYaml = serializeMcpServersYaml(selectedServers); + if (newMcpYaml !== agent.mcpServers) body.mcpServers = newMcpYaml; + + // Hooks + const newHooksYaml = serializeHooksYaml(hooks); + if (newHooksYaml !== agent.hooks) body.hooks = newHooksYaml; + + // Only save if something changed + const changedKeys = Object.keys(body).filter(k => k !== 'filePath'); + if (changedKeys.length === 0) { + toast.info('No changes to save'); + return; + } + + await updateAgentDefinition(agent.type, agent.name, body); + toast.success(`Updated ${agent.name} advanced settings`); + onSaved(); + } catch (err) { + toast.error(`Failed: ${(err as Error).message}`); + } finally { + setSaving(false); + } + }, [agent, color, permissionMode, memory, maxTurns, background, isolation, tools, disallowedTools, skills, selectedServers, hooks, onSaved]); + + const toggleServer = (name: string) => { + setSelectedServers(prev => + prev.includes(name) ? prev.filter(s => s !== name) : [...prev, name] + ); + }; + + const addCustomServer = () => { + const name = customServer.trim(); + if (name && !selectedServers.includes(name)) { + setSelectedServers(prev => [...prev, name]); + setCustomServer(''); + } + }; + + const addHook = () => { + setHooks(prev => ({ + ...prev, + [newHookEvent]: [...prev[newHookEvent as keyof ParsedHooks], { matcher: '', command: '' }], + })); + }; + + const removeHook = (event: string, idx: number) => { + setHooks(prev => ({ + ...prev, + [event]: prev[event as keyof ParsedHooks].filter((_, i) => i !== idx), + })); + }; + + const updateHook = (event: string, idx: number, field: 'matcher' | 'command', value: string) => { + setHooks(prev => ({ + ...prev, + [event]: prev[event as keyof ParsedHooks].map((h, i) => i === idx ? { ...h, [field]: value } : h), + })); + }; + + return ( +
+ {/* Quick Settings Row 1 */} +
+
+ Color: + +
+
+ Permission: + +
+
+ Memory: + +
+
+ MaxTurns: + setMaxTurns(e.target.value)} + placeholder="—" + /> +
+
+ setBackground(v === true)} + /> + Background +
+
+ Isolation: + +
+
+ + {/* Tools Row */} +
+
+ Tools: + setTools(e.target.value)} + placeholder="Read, Write, Bash, Glob, Grep" + /> +
+
+ Disallowed: + setDisallowedTools(e.target.value)} + placeholder="e.g. Edit" + /> +
+
+ Skills: + setSkills(e.target.value)} + placeholder="e.g. api-conventions, error-handling" + /> +
+
+ + {/* MCP Servers Picker */} +
+ MCP Servers: + {mcpLoading ? ( + Loading... + ) : ( +
+ {installedServers.map(name => ( + + ))} + {/* Show selected servers not in installed list */} + {selectedServers.filter(s => !installedServers.includes(s)).map(name => ( + + ))} +
+ setCustomServer(e.target.value)} + placeholder="+ Custom" + onKeyDown={e => { if (e.key === 'Enter') addCustomServer(); }} + /> + +
+
+ )} +
+ + {/* Hooks Editor */} +
+ Hooks: + {HOOK_EVENTS.map(event => { + const entries = hooks[event as keyof ParsedHooks]; + if (entries.length === 0) return null; + return ( +
+ {event}: + {entries.map((entry, idx) => ( +
+ updateHook(event, idx, 'matcher', e.target.value)} + placeholder="matcher (e.g. Bash)" + /> + + updateHook(event, idx, 'command', e.target.value)} + placeholder="command" + /> + +
+ ))} +
+ ); + })} +
+ + +
+
+ + {/* Save Button */} +
+ +
+
+ ); +} // ========== Agent Card ========== @@ -35,6 +452,7 @@ function AgentCard({ agent, onSaved }: AgentCardProps) { const [model, setModel] = useState(agent.model); const [effort, setEffort] = useState(agent.effort); const [saving, setSaving] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); const isDirty = model !== agent.model || effort !== agent.effort; const effortOptions = agent.type === 'codex' ? CODEX_EFFORTS : CLAUDE_EFFORTS; @@ -42,7 +460,7 @@ function AgentCard({ agent, onSaved }: AgentCardProps) { const handleSave = useCallback(async () => { setSaving(true); try { - const body: { filePath: string; model?: string; effort?: string } = { filePath: agent.filePath }; + const body: { filePath: string; [key: string]: string | undefined } = { filePath: agent.filePath }; if (model !== agent.model) body.model = model; if (effort !== agent.effort) body.effort = effort; await updateAgentDefinition(agent.type, agent.name, body); @@ -62,78 +480,100 @@ function AgentCard({ agent, onSaved }: AgentCardProps) { }, [agent.model, agent.effort]); return ( -
- - {agent.type} - - - {agent.name} - +
+
+ + {agent.type} + + + {agent.name} + - {/* Model input */} -
- Model: - {agent.type === 'claude' ? ( -
- { + if (e.target.value === '__custom__') return; + setModel(e.target.value); + }} + > + {CLAUDE_MODEL_PRESETS.map(m => ( + + ))} + {!CLAUDE_MODEL_PRESETS.includes(model) && ( + + )} + {!CLAUDE_MODEL_PRESETS.includes(model) && ( - + setModel(e.target.value)} + placeholder="model id" + /> )} - - {!CLAUDE_MODEL_PRESETS.includes(model) && ( - setModel(e.target.value)} - placeholder="model id" - /> +
+ ) : ( + setModel(e.target.value)} + placeholder="model id" + /> + )} +
+ + {/* Effort select */} +
+ Effort: + +
+ + {/* Save button */} + + + {/* Advanced toggle (claude only) */} + {agent.type === 'claude' && ( + )}
- {/* Effort select */} -
- Effort: - -
- - {/* Save button */} - + {/* Advanced Settings Panel */} + {showAdvanced && agent.type === 'claude' && ( + + )}
); } diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 28125e51..b188e5e1 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -6213,6 +6213,18 @@ export interface AgentDefinition { model: string; effort: string; description: string; + // Claude-only advanced fields (empty string for codex) + tools: string; + disallowedTools: string; + permissionMode: string; + maxTurns: string; + skills: string; + mcpServers: string; + hooks: string; + memory: string; + background: string; + color: string; + isolation: string; } export async function fetchAgentDefinitions(): Promise<{ agents: AgentDefinition[] }> { @@ -6222,8 +6234,8 @@ export async function fetchAgentDefinitions(): Promise<{ agents: AgentDefinition export async function updateAgentDefinition( type: 'codex' | 'claude', name: string, - body: { filePath: string; model?: string; effort?: string } -): Promise<{ success: boolean; name: string; type: string; model?: string; effort?: string }> { + body: { filePath: string; [key: string]: string | undefined } +): Promise<{ success: boolean; name: string; type: string }> { return fetchApi(`/api/agent-definitions/${type}/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(body), diff --git a/ccw/src/core/routes/agent-definitions-routes.ts b/ccw/src/core/routes/agent-definitions-routes.ts index ab0a438c..367d41ae 100644 --- a/ccw/src/core/routes/agent-definitions-routes.ts +++ b/ccw/src/core/routes/agent-definitions-routes.ts @@ -19,6 +19,18 @@ interface AgentDefinition { model: string; effort: string; description: string; + // Claude-only advanced fields (empty string for codex) + tools: string; + disallowedTools: string; + permissionMode: string; + maxTurns: string; + skills: string; + mcpServers: string; + hooks: string; + memory: string; + background: string; + color: string; + isolation: string; } // ========== Parsing helpers ========== @@ -39,9 +51,54 @@ function parseCodexToml(content: string, filePath: string, installationPath: str model: modelMatch?.[1] ?? '', effort: effortMatch?.[1] ?? '', description: descMatch?.[1] ?? '', + tools: '', + disallowedTools: '', + permissionMode: '', + maxTurns: '', + skills: '', + mcpServers: '', + hooks: '', + memory: '', + background: '', + color: '', + isolation: '', }; } +function extractSimpleField(fm: string, field: string): string { + const match = fm.match(new RegExp(`^${field}:\\s*(.+)$`, 'm')); + return match?.[1].trim() ?? ''; +} + +function extractYamlBlock(fm: string, field: string): string { + const regex = new RegExp(`^${field}:(.*)$`, 'm'); + const match = fm.match(regex); + if (!match) return ''; + + const startIdx = fm.indexOf(match[0]); + const afterField = fm.slice(startIdx + match[0].length); + const lines = afterField.split(/\r?\n/); + const blockLines: string[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + // Stop at next top-level field (non-indented, non-empty line with "key:") + if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && /^\S+:/.test(line)) break; + // Also stop at empty line followed by non-indented content (but include blank lines within the block) + blockLines.push(line); + } + + // Trim trailing empty lines + while (blockLines.length > 0 && blockLines[blockLines.length - 1].trim() === '') blockLines.pop(); + + if (blockLines.length === 0) { + // Inline value only (e.g. "mcpServers: foo") + return match[1].trim(); + } + + return `${field}:${match[1]}\n${blockLines.join('\n')}`; +} + function parseClaudeMd(content: string, filePath: string, installationPath: string): AgentDefinition | null { // Extract YAML frontmatter between --- delimiters const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); @@ -49,8 +106,6 @@ function parseClaudeMd(content: string, filePath: string, installationPath: stri const fm = fmMatch[1]; const nameMatch = fm.match(/^name:\s*(.+)$/m); - const modelMatch = fm.match(/^model:\s*(.+)$/m); - const effortMatch = fm.match(/^effort:\s*(.+)$/m); // description can be multi-line with |, just grab first line const descMatch = fm.match(/^description:\s*\|?\s*\n?\s*(.+)$/m); @@ -61,9 +116,20 @@ function parseClaudeMd(content: string, filePath: string, installationPath: stri type: 'claude', filePath, installationPath, - model: modelMatch?.[1].trim() ?? '', - effort: effortMatch?.[1].trim() ?? '', + model: extractSimpleField(fm, 'model'), + effort: extractSimpleField(fm, 'effort'), description: descMatch?.[1].trim() ?? '', + tools: extractSimpleField(fm, 'tools'), + disallowedTools: extractSimpleField(fm, 'disallowedTools'), + permissionMode: extractSimpleField(fm, 'permissionMode'), + maxTurns: extractSimpleField(fm, 'maxTurns'), + skills: extractSimpleField(fm, 'skills'), + memory: extractSimpleField(fm, 'memory'), + background: extractSimpleField(fm, 'background'), + color: extractSimpleField(fm, 'color'), + isolation: extractSimpleField(fm, 'isolation'), + mcpServers: extractYamlBlock(fm, 'mcpServers'), + hooks: extractYamlBlock(fm, 'hooks'), }; } @@ -167,11 +233,74 @@ function updateClaudeMdField(content: string, field: string, value: string): str return fmMatch[1] + fm + fmMatch[3] + content.slice(fmMatch[0].length); } +function removeClaudeMdField(content: string, field: string): string { + const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/); + if (!fmMatch) return content; + + let fm = fmMatch[2]; + // Remove simple field line + const fieldRegex = new RegExp(`^${field}:\\s*.*$\\n?`, 'm'); + fm = fm.replace(fieldRegex, ''); + + return fmMatch[1] + fm + fmMatch[3] + content.slice(fmMatch[0].length); +} + +function updateClaudeMdComplexField(content: string, field: string, value: string): string { + const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/); + if (!fmMatch) return content; + + let fm = fmMatch[2]; + + // Find existing block: field line + all indented lines after it + const blockStartRegex = new RegExp(`^${field}:(.*)$`, 'm'); + const blockMatch = fm.match(blockStartRegex); + + if (blockMatch) { + // Find the full block extent + const startIdx = fm.indexOf(blockMatch[0]); + const before = fm.slice(0, startIdx); + const afterStart = fm.slice(startIdx + blockMatch[0].length); + const lines = afterStart.split(/\r?\n/); + let endOffset = 0; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && /^\S+:/.test(line)) break; + endOffset = i; + } + + // Reconstruct: keep lines after the block + const remainingLines = lines.slice(endOffset + 1); + const after = remainingLines.length > 0 ? '\n' + remainingLines.join('\n') : ''; + + if (!value) { + // Remove the block entirely + fm = before.replace(/\n$/, '') + after; + } else { + fm = before + value + after; + } + } else if (value) { + // Insert before end of frontmatter + fm = fm.trimEnd() + '\n' + value; + } + + return fmMatch[1] + fm + fmMatch[3] + content.slice(fmMatch[0].length); +} + // ========== Validation ========== const CODEX_EFFORTS = ['low', 'medium', 'high']; const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max']; const CLAUDE_MODEL_SHORTCUTS = ['sonnet', 'opus', 'haiku', 'inherit']; +const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan']; +const CLAUDE_MEMORY_OPTIONS = ['user', 'project', 'local']; +const CLAUDE_ISOLATION_OPTIONS = ['worktree']; +const CLAUDE_COLOR_OPTIONS = ['purple', 'blue', 'yellow', 'green', 'red']; + +// Simple fields that can be updated with updateClaudeMdField +const CLAUDE_SIMPLE_FIELDS = ['tools', 'disallowedTools', 'permissionMode', 'maxTurns', 'skills', 'memory', 'background', 'color', 'isolation'] as const; +// Complex fields that need updateClaudeMdComplexField +const CLAUDE_COMPLEX_FIELDS = ['mcpServers', 'hooks'] as const; function validateEffort(type: 'codex' | 'claude', effort: string): boolean { if (!effort) return true; // empty = no change @@ -272,11 +401,10 @@ export async function handleAgentDefinitionsRoutes(ctx: RouteContext): Promise { try { - const { filePath, model, effort } = body as { - filePath: string; - model?: string; - effort?: string; - }; + const b = body as Record; + const filePath = b.filePath; + const model = b.model; + const effort = b.effort; if (!filePath) { return { error: 'filePath is required', status: 400 }; @@ -291,6 +419,25 @@ export async function handleAgentDefinitionsRoutes(ctx: RouteContext): Promise