mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-26 19:56:37 +08:00
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:
@@ -2,27 +2,444 @@
|
|||||||
// Agent Definitions Section
|
// Agent Definitions Section
|
||||||
// ========================================
|
// ========================================
|
||||||
// Settings section for viewing and editing Codex/Claude agent model and effort fields
|
// 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 { 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 { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Checkbox } from '@/components/ui/Checkbox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
fetchAgentDefinitions,
|
fetchAgentDefinitions,
|
||||||
updateAgentDefinition,
|
updateAgentDefinition,
|
||||||
batchUpdateAgentDefinitions,
|
batchUpdateAgentDefinitions,
|
||||||
|
fetchMcpConfig,
|
||||||
type AgentDefinition,
|
type AgentDefinition,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
|
||||||
// ========== Effort options ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
const CODEX_EFFORTS = ['', 'low', 'medium', 'high'];
|
const CODEX_EFFORTS = ['', 'low', 'medium', 'high'];
|
||||||
const CLAUDE_EFFORTS = ['', 'low', 'medium', 'high', 'max'];
|
const CLAUDE_EFFORTS = ['', 'low', 'medium', 'high', 'max'];
|
||||||
const CLAUDE_MODEL_PRESETS = ['sonnet', 'opus', 'haiku', 'inherit'];
|
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">→</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 ==========
|
// ========== Agent Card ==========
|
||||||
|
|
||||||
@@ -35,6 +452,7 @@ function AgentCard({ agent, onSaved }: AgentCardProps) {
|
|||||||
const [model, setModel] = useState(agent.model);
|
const [model, setModel] = useState(agent.model);
|
||||||
const [effort, setEffort] = useState(agent.effort);
|
const [effort, setEffort] = useState(agent.effort);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
const isDirty = model !== agent.model || effort !== agent.effort;
|
const isDirty = model !== agent.model || effort !== agent.effort;
|
||||||
const effortOptions = agent.type === 'codex' ? CODEX_EFFORTS : CLAUDE_EFFORTS;
|
const effortOptions = agent.type === 'codex' ? CODEX_EFFORTS : CLAUDE_EFFORTS;
|
||||||
@@ -42,7 +460,7 @@ function AgentCard({ agent, onSaved }: AgentCardProps) {
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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 (model !== agent.model) body.model = model;
|
||||||
if (effort !== agent.effort) body.effort = effort;
|
if (effort !== agent.effort) body.effort = effort;
|
||||||
await updateAgentDefinition(agent.type, agent.name, body);
|
await updateAgentDefinition(agent.type, agent.name, body);
|
||||||
@@ -62,6 +480,7 @@ function AgentCard({ agent, onSaved }: AgentCardProps) {
|
|||||||
}, [agent.model, agent.effort]);
|
}, [agent.model, agent.effort]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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">
|
<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">
|
<Badge variant={agent.type === 'codex' ? 'default' : 'secondary'} className="text-xs shrink-0">
|
||||||
{agent.type}
|
{agent.type}
|
||||||
@@ -134,6 +553,27 @@ function AgentCard({ agent, onSaved }: AgentCardProps) {
|
|||||||
<Save className="w-3 h-3 mr-1" />
|
<Save className="w-3 h-3 mr-1" />
|
||||||
{saving ? '...' : 'Save'}
|
{saving ? '...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Advanced toggle (claude only) */}
|
||||||
|
{agent.type === 'claude' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded hover:bg-accent transition-colors',
|
||||||
|
showAdvanced && 'text-primary'
|
||||||
|
)}
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
title="Advanced Settings"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Settings Panel */}
|
||||||
|
{showAdvanced && agent.type === 'claude' && (
|
||||||
|
<AdvancedSettings agent={agent} onSaved={onSaved} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6213,6 +6213,18 @@ export interface AgentDefinition {
|
|||||||
model: string;
|
model: string;
|
||||||
effort: string;
|
effort: string;
|
||||||
description: 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[] }> {
|
export async function fetchAgentDefinitions(): Promise<{ agents: AgentDefinition[] }> {
|
||||||
@@ -6222,8 +6234,8 @@ export async function fetchAgentDefinitions(): Promise<{ agents: AgentDefinition
|
|||||||
export async function updateAgentDefinition(
|
export async function updateAgentDefinition(
|
||||||
type: 'codex' | 'claude',
|
type: 'codex' | 'claude',
|
||||||
name: string,
|
name: string,
|
||||||
body: { filePath: string; model?: string; effort?: string }
|
body: { filePath: string; [key: string]: string | undefined }
|
||||||
): Promise<{ success: boolean; name: string; type: string; model?: string; effort?: string }> {
|
): Promise<{ success: boolean; name: string; type: string }> {
|
||||||
return fetchApi(`/api/agent-definitions/${type}/${encodeURIComponent(name)}`, {
|
return fetchApi(`/api/agent-definitions/${type}/${encodeURIComponent(name)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ interface AgentDefinition {
|
|||||||
model: string;
|
model: string;
|
||||||
effort: string;
|
effort: string;
|
||||||
description: 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 ==========
|
// ========== Parsing helpers ==========
|
||||||
@@ -39,9 +51,54 @@ function parseCodexToml(content: string, filePath: string, installationPath: str
|
|||||||
model: modelMatch?.[1] ?? '',
|
model: modelMatch?.[1] ?? '',
|
||||||
effort: effortMatch?.[1] ?? '',
|
effort: effortMatch?.[1] ?? '',
|
||||||
description: descMatch?.[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 {
|
function parseClaudeMd(content: string, filePath: string, installationPath: string): AgentDefinition | null {
|
||||||
// Extract YAML frontmatter between --- delimiters
|
// Extract YAML frontmatter between --- delimiters
|
||||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
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 fm = fmMatch[1];
|
||||||
const nameMatch = fm.match(/^name:\s*(.+)$/m);
|
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
|
// description can be multi-line with |, just grab first line
|
||||||
const descMatch = fm.match(/^description:\s*\|?\s*\n?\s*(.+)$/m);
|
const descMatch = fm.match(/^description:\s*\|?\s*\n?\s*(.+)$/m);
|
||||||
|
|
||||||
@@ -61,9 +116,20 @@ function parseClaudeMd(content: string, filePath: string, installationPath: stri
|
|||||||
type: 'claude',
|
type: 'claude',
|
||||||
filePath,
|
filePath,
|
||||||
installationPath,
|
installationPath,
|
||||||
model: modelMatch?.[1].trim() ?? '',
|
model: extractSimpleField(fm, 'model'),
|
||||||
effort: effortMatch?.[1].trim() ?? '',
|
effort: extractSimpleField(fm, 'effort'),
|
||||||
description: descMatch?.[1].trim() ?? '',
|
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);
|
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 ==========
|
// ========== Validation ==========
|
||||||
|
|
||||||
const CODEX_EFFORTS = ['low', 'medium', 'high'];
|
const CODEX_EFFORTS = ['low', 'medium', 'high'];
|
||||||
const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max'];
|
const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max'];
|
||||||
const CLAUDE_MODEL_SHORTCUTS = ['sonnet', 'opus', 'haiku', 'inherit'];
|
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 {
|
function validateEffort(type: 'codex' | 'claude', effort: string): boolean {
|
||||||
if (!effort) return true; // empty = no change
|
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) => {
|
handlePostRequest(req, res, async (body: unknown) => {
|
||||||
try {
|
try {
|
||||||
const { filePath, model, effort } = body as {
|
const b = body as Record<string, string | undefined>;
|
||||||
filePath: string;
|
const filePath = b.filePath;
|
||||||
model?: string;
|
const model = b.model;
|
||||||
effort?: string;
|
const effort = b.effort;
|
||||||
};
|
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return { error: 'filePath is required', status: 400 };
|
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 };
|
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');
|
let content = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
if (agentType === 'codex') {
|
if (agentType === 'codex') {
|
||||||
@@ -299,11 +446,31 @@ export async function handleAgentDefinitionsRoutes(ctx: RouteContext): Promise<b
|
|||||||
} else {
|
} else {
|
||||||
if (model) content = updateClaudeMdField(content, 'model', model);
|
if (model) content = updateClaudeMdField(content, 'model', model);
|
||||||
if (effort) content = updateClaudeMdField(content, 'effort', effort);
|
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');
|
writeFileSync(filePath, content, 'utf-8');
|
||||||
|
|
||||||
return { success: true, name: agentName, type: agentType, model, effort };
|
return { success: true, name: agentName, type: agentType };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: (err as Error).message, status: 500 };
|
return { error: (err as Error).message, status: 500 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user