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 <noreply@anthropic.com>
This commit is contained in:
catlog22
2026-03-24 20:59:06 +08:00
parent c744a80ef9
commit e293195ad0
3 changed files with 699 additions and 80 deletions

View File

@@ -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<string[]>([]);
const [selectedServers, setSelectedServers] = useState<string[]>(() => parseMcpServersYaml(agent.mcpServers));
const [mcpLoading, setMcpLoading] = useState(false);
const [customServer, setCustomServer] = useState('');
// Hooks
const [hooks, setHooks] = useState<ParsedHooks>(() => 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<string>();
// 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 (
<div className="mt-2 ml-6 p-3 rounded-md border border-border bg-muted/20 space-y-4">
{/* Quick Settings Row 1 */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1">
<span className={labelClass}>Color:</span>
<select className={selectClass} value={color} onChange={e => setColor(e.target.value)}>
{COLOR_OPTIONS.map(c => <option key={c} value={c}>{c || '—'}</option>)}
</select>
</div>
<div className="flex items-center gap-1">
<span className={labelClass}>Permission:</span>
<select className={selectClass} value={permissionMode} onChange={e => setPermissionMode(e.target.value)}>
{PERMISSION_MODES.map(p => <option key={p} value={p}>{p || '—'}</option>)}
</select>
</div>
<div className="flex items-center gap-1">
<span className={labelClass}>Memory:</span>
<select className={selectClass} value={memory} onChange={e => setMemory(e.target.value)}>
{MEMORY_OPTIONS.map(m => <option key={m} value={m}>{m || '—'}</option>)}
</select>
</div>
<div className="flex items-center gap-1">
<span className={labelClass}>MaxTurns:</span>
<Input
className="h-7 text-xs w-[60px]"
type="number"
value={maxTurns}
onChange={e => setMaxTurns(e.target.value)}
placeholder="—"
/>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
checked={background}
onCheckedChange={(v) => setBackground(v === true)}
/>
<span className={labelClass}>Background</span>
</div>
<div className="flex items-center gap-1">
<span className={labelClass}>Isolation:</span>
<select className={selectClass} value={isolation} onChange={e => setIsolation(e.target.value)}>
{ISOLATION_OPTIONS.map(i => <option key={i} value={i}>{i || '—'}</option>)}
</select>
</div>
</div>
{/* Tools Row */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className={labelClass}>Tools:</span>
<Input
className="h-7 text-xs flex-1"
value={tools}
onChange={e => setTools(e.target.value)}
placeholder="Read, Write, Bash, Glob, Grep"
/>
</div>
<div className="flex items-center gap-2">
<span className={labelClass}>Disallowed:</span>
<Input
className="h-7 text-xs flex-1"
value={disallowedTools}
onChange={e => setDisallowedTools(e.target.value)}
placeholder="e.g. Edit"
/>
</div>
<div className="flex items-center gap-2">
<span className={labelClass}>Skills:</span>
<Input
className="h-7 text-xs flex-1"
value={skills}
onChange={e => setSkills(e.target.value)}
placeholder="e.g. api-conventions, error-handling"
/>
</div>
</div>
{/* MCP Servers Picker */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground">MCP Servers:</span>
{mcpLoading ? (
<span className="text-xs text-muted-foreground ml-2">Loading...</span>
) : (
<div className="flex flex-wrap gap-3 ml-1">
{installedServers.map(name => (
<label key={name} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox
checked={selectedServers.includes(name)}
onCheckedChange={() => toggleServer(name)}
/>
<span className="text-foreground">{name}</span>
</label>
))}
{/* Show selected servers not in installed list */}
{selectedServers.filter(s => !installedServers.includes(s)).map(name => (
<label key={name} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked onCheckedChange={() => toggleServer(name)} />
<span className="text-foreground italic">{name}</span>
</label>
))}
<div className="flex items-center gap-1">
<Input
className="h-6 text-xs w-[120px]"
value={customServer}
onChange={e => setCustomServer(e.target.value)}
placeholder="+ Custom"
onKeyDown={e => { if (e.key === 'Enter') addCustomServer(); }}
/>
<Button variant="ghost" size="sm" className="h-6 px-1" onClick={addCustomServer} disabled={!customServer.trim()}>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
)}
</div>
{/* Hooks Editor */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground">Hooks:</span>
{HOOK_EVENTS.map(event => {
const entries = hooks[event as keyof ParsedHooks];
if (entries.length === 0) return null;
return (
<div key={event} className="ml-1 space-y-1">
<span className="text-xs text-muted-foreground font-medium">{event}:</span>
{entries.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2 ml-3">
<Input
className="h-6 text-xs w-[100px]"
value={entry.matcher}
onChange={e => updateHook(event, idx, 'matcher', e.target.value)}
placeholder="matcher (e.g. Bash)"
/>
<span className="text-xs text-muted-foreground">&rarr;</span>
<Input
className="h-6 text-xs flex-1"
value={entry.command}
onChange={e => updateHook(event, idx, 'command', e.target.value)}
placeholder="command"
/>
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => removeHook(event, idx)}
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
);
})}
<div className="flex items-center gap-2 ml-1">
<select className={selectClass} value={newHookEvent} onChange={e => setNewHookEvent(e.target.value)}>
{HOOK_EVENTS.map(e => <option key={e} value={e}>{e}</option>)}
</select>
<Button variant="outline" size="sm" className="h-6 px-2 text-xs" onClick={addHook}>
<Plus className="w-3 h-3 mr-1" />
Add Hook
</Button>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button variant="default" size="sm" className="h-7" disabled={saving} onClick={handleSave}>
<Save className="w-3 h-3 mr-1" />
{saving ? 'Saving...' : 'Save Advanced'}
</Button>
</div>
</div>
);
}
// ========== 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 (
<div className="flex items-center gap-3 py-2 px-3 rounded-md border border-border bg-card hover:bg-accent/30 transition-colors">
<Badge variant={agent.type === 'codex' ? 'default' : 'secondary'} className="text-xs shrink-0">
{agent.type}
</Badge>
<span className="text-sm font-medium text-foreground min-w-[140px] truncate" title={agent.name}>
{agent.name}
</span>
<div>
<div className="flex items-center gap-3 py-2 px-3 rounded-md border border-border bg-card hover:bg-accent/30 transition-colors">
<Badge variant={agent.type === 'codex' ? 'default' : 'secondary'} className="text-xs shrink-0">
{agent.type}
</Badge>
<span className="text-sm font-medium text-foreground min-w-[140px] truncate" title={agent.name}>
{agent.name}
</span>
{/* Model input */}
<div className="flex items-center gap-1 flex-1 min-w-0">
<span className="text-xs text-muted-foreground shrink-0">Model:</span>
{agent.type === 'claude' ? (
<div className="flex gap-1 flex-1 min-w-0">
<select
className="h-7 text-xs rounded border border-input bg-background px-2 shrink-0"
value={CLAUDE_MODEL_PRESETS.includes(model) ? model : '__custom__'}
onChange={(e) => {
if (e.target.value === '__custom__') return;
setModel(e.target.value);
}}
>
{CLAUDE_MODEL_PRESETS.map(m => (
<option key={m} value={m}>{m || '(none)'}</option>
))}
{/* Model input */}
<div className="flex items-center gap-1 flex-1 min-w-0">
<span className="text-xs text-muted-foreground shrink-0">Model:</span>
{agent.type === 'claude' ? (
<div className="flex gap-1 flex-1 min-w-0">
<select
className="h-7 text-xs rounded border border-input bg-background px-2 shrink-0"
value={CLAUDE_MODEL_PRESETS.includes(model) ? model : '__custom__'}
onChange={(e) => {
if (e.target.value === '__custom__') return;
setModel(e.target.value);
}}
>
{CLAUDE_MODEL_PRESETS.map(m => (
<option key={m} value={m}>{m || '(none)'}</option>
))}
{!CLAUDE_MODEL_PRESETS.includes(model) && (
<option value="__custom__">custom</option>
)}
</select>
{!CLAUDE_MODEL_PRESETS.includes(model) && (
<option value="__custom__">custom</option>
<Input
className="h-7 text-xs flex-1 min-w-[100px]"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="model id"
/>
)}
</select>
{!CLAUDE_MODEL_PRESETS.includes(model) && (
<Input
className="h-7 text-xs flex-1 min-w-[100px]"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="model id"
/>
</div>
) : (
<Input
className="h-7 text-xs flex-1 min-w-[100px]"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="model id"
/>
)}
</div>
{/* Effort select */}
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-muted-foreground">Effort:</span>
<select
className="h-7 text-xs rounded border border-input bg-background px-2"
value={effort}
onChange={(e) => setEffort(e.target.value)}
>
{effortOptions.map(e => (
<option key={e} value={e}>{e || '—'}</option>
))}
</select>
</div>
{/* Save button */}
<Button
variant="outline"
size="sm"
className="h-7 px-2 shrink-0"
disabled={!isDirty || saving}
onClick={handleSave}
>
<Save className="w-3 h-3 mr-1" />
{saving ? '...' : 'Save'}
</Button>
{/* Advanced toggle (claude only) */}
{agent.type === 'claude' && (
<button
type="button"
className={cn(
'p-1 rounded hover:bg-accent transition-colors',
showAdvanced && 'text-primary'
)}
</div>
) : (
<Input
className="h-7 text-xs flex-1 min-w-[100px]"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="model id"
/>
onClick={() => setShowAdvanced(!showAdvanced)}
title="Advanced Settings"
>
<Settings2 className="w-4 h-4" />
</button>
)}
</div>
{/* Effort select */}
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-muted-foreground">Effort:</span>
<select
className="h-7 text-xs rounded border border-input bg-background px-2"
value={effort}
onChange={(e) => setEffort(e.target.value)}
>
{effortOptions.map(e => (
<option key={e} value={e}>{e || '—'}</option>
))}
</select>
</div>
{/* Save button */}
<Button
variant="outline"
size="sm"
className="h-7 px-2 shrink-0"
disabled={!isDirty || saving}
onClick={handleSave}
>
<Save className="w-3 h-3 mr-1" />
{saving ? '...' : 'Save'}
</Button>
{/* Advanced Settings Panel */}
{showAdvanced && agent.type === 'claude' && (
<AdvancedSettings agent={agent} onSaved={onSaved} />
)}
</div>
);
}

View File

@@ -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),

View File

@@ -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<b
handlePostRequest(req, res, async (body: unknown) => {
try {
const { filePath, model, effort } = body as {
filePath: string;
model?: string;
effort?: string;
};
const b = body as Record<string, string | undefined>;
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<b
return { error: 'Invalid model value', status: 400 };
}
// Validate enum fields for claude agents
if (agentType === 'claude') {
if (b.permissionMode && !CLAUDE_PERMISSION_MODES.includes(b.permissionMode)) {
return { error: `Invalid permissionMode: ${b.permissionMode}. Valid: ${CLAUDE_PERMISSION_MODES.join(', ')}`, status: 400 };
}
if (b.memory && !CLAUDE_MEMORY_OPTIONS.includes(b.memory)) {
return { error: `Invalid memory: ${b.memory}. Valid: ${CLAUDE_MEMORY_OPTIONS.join(', ')}`, status: 400 };
}
if (b.isolation && !CLAUDE_ISOLATION_OPTIONS.includes(b.isolation)) {
return { error: `Invalid isolation: ${b.isolation}. Valid: ${CLAUDE_ISOLATION_OPTIONS.join(', ')}`, status: 400 };
}
if (b.maxTurns && isNaN(Number(b.maxTurns))) {
return { error: `Invalid maxTurns: must be a number`, status: 400 };
}
if (b.background && b.background !== 'true' && b.background !== 'false') {
return { error: `Invalid background: must be true or false`, status: 400 };
}
}
let content = readFileSync(filePath, 'utf-8');
if (agentType === 'codex') {
@@ -299,11 +446,31 @@ export async function handleAgentDefinitionsRoutes(ctx: RouteContext): Promise<b
} else {
if (model) content = updateClaudeMdField(content, 'model', model);
if (effort) content = updateClaudeMdField(content, 'effort', effort);
// Handle simple fields: set or remove
for (const field of CLAUDE_SIMPLE_FIELDS) {
if (field in b) {
const val = b[field];
if (val) {
content = updateClaudeMdField(content, field, val);
} else {
content = removeClaudeMdField(content, field);
}
}
}
// Handle complex fields (mcpServers, hooks): set or remove
for (const field of CLAUDE_COMPLEX_FIELDS) {
if (field in b) {
const val = b[field];
content = updateClaudeMdComplexField(content, field, val ?? '');
}
}
}
writeFileSync(filePath, content, 'utf-8');
return { success: true, name: agentName, type: agentType, model, effort };
return { success: true, name: agentName, type: agentType };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}