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