Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,14 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
python-version: '3.12'
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-asyncio pytest-cov
pip install -e ".[all]"
pip install pytest pytest-asyncio pytest-cov httpx

- name: Run unit tests
run: |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { Plus, Trash2 } from "lucide-react";
import type { z } from "zod";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";

import type { agentDefinitionSchema } from "../schema";
import { StringListEditor } from "./string-list-editor";

type AgentDef = z.infer<typeof agentDefinitionSchema>;

export function AgentsEditor({ value, onChange }: { value: Record<string, AgentDef>; onChange: (v: Record<string, AgentDef>) => void }) {
const entries = Object.entries(value);
const addAgent = () => onChange({ ...value, [`agent-${entries.length + 1}`]: { description: "", prompt: "" } });
const removeAgent = (name: string) => { const next = { ...value }; delete next[name]; onChange(next); };
const updateAgentName = (oldName: string, newName: string) => {
const next: Record<string, AgentDef> = {};
for (const [k, v] of Object.entries(value)) next[k === oldName ? newName : k] = v;
onChange(next);
};
const updateAgent = (name: string, agent: AgentDef) => onChange({ ...value, [name]: agent });

return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">Define custom sub-agents with their own prompt, tools, and model.</p>
{entries.map(([name, agent]) => (
<div key={name} className="border rounded-md p-3 space-y-3">
<div className="flex items-center gap-2">
<Input className="font-mono text-xs w-1/3" value={name} placeholder="agent-name" onChange={(e) => updateAgentName(name, e.target.value)} />
<Select value={agent.model ?? "inherit"} onValueChange={(m) => updateAgent(name, { ...agent, model: m === "inherit" ? null : m as AgentDef["model"] })}>
<SelectTrigger className="w-32"><SelectValue placeholder="Model" /></SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit</SelectItem>
<SelectItem value="sonnet">Sonnet</SelectItem>
<SelectItem value="opus">Opus</SelectItem>
<SelectItem value="haiku">Haiku</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="ghost" size="icon" className="ml-auto h-8 w-8" onClick={() => removeAgent(name)}><Trash2 className="h-3 w-3" /></Button>
</div>
<Input className="text-xs" placeholder="Description" value={agent.description} onChange={(e) => updateAgent(name, { ...agent, description: e.target.value })} />
<Textarea className="font-mono text-xs" placeholder="Agent prompt..." rows={3} value={agent.prompt} onChange={(e) => updateAgent(name, { ...agent, prompt: e.target.value })} />
<div>
<Label className="text-xs text-muted-foreground">Tools</Label>
<StringListEditor value={agent.tools ?? []} onChange={(t) => updateAgent(name, { ...agent, tools: t })} placeholder="Tool name" />
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addAgent}><Plus className="h-3 w-3 mr-1" /> Add Agent</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import { useRef } from "react";
import { Plus, Trash2 } from "lucide-react";
import type { z } from "zod";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

import type { hookMatcherFormSchema } from "../schema";

const HOOK_EVENTS = ["PreToolUse", "PostToolUse", "PostToolUseFailure", "UserPromptSubmit", "Stop", "SubagentStop", "PreCompact", "Notification", "SubagentStart", "PermissionRequest"] as const;
type HookMatcherFormValue = z.infer<typeof hookMatcherFormSchema>;

export function HooksEditor({ value, onChange }: { value: Record<string, HookMatcherFormValue[]>; onChange: (v: Record<string, HookMatcherFormValue[]>) => void }) {
const nextId = useRef(0);
const idsMap = useRef<Record<string, number[]>>({});

const getIds = (event: string, length: number) => {
if (!idsMap.current[event]) idsMap.current[event] = [];
const ids = idsMap.current[event];
while (ids.length < length) ids.push(nextId.current++);
ids.length = length;
return ids;
};

const addHook = (event: string) => {
getIds(event, (value[event] ?? []).length).push(nextId.current++);
onChange({ ...value, [event]: [...(value[event] ?? []), {}] });
};
const removeHook = (event: string, index: number) => {
getIds(event, (value[event] ?? []).length).splice(index, 1);
const existing = [...(value[event] ?? [])];
existing.splice(index, 1);
if (existing.length === 0) { const next = { ...value }; delete next[event]; onChange(next); }
else onChange({ ...value, [event]: existing });
};
const updateHook = (event: string, index: number, hook: HookMatcherFormValue) => {
const existing = [...(value[event] ?? [])];
existing[index] = hook;
onChange({ ...value, [event]: existing });
};

return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">Hooks fire Python callbacks at lifecycle events. Matcher patterns filter tool names (e.g. &quot;Bash&quot;, &quot;Write|Edit&quot;).</p>
{HOOK_EVENTS.map((event) => {
const hooks = value[event] ?? [];
const ids = getIds(event, hooks.length);
return (
<div key={event} className="space-y-2">
<div className="flex items-center justify-between">
<Label className="font-mono">{event}</Label>
<Button type="button" variant="outline" size="sm" onClick={() => addHook(event)}><Plus className="h-3 w-3 mr-1" /> Add</Button>
</div>
{hooks.map((hook, i) => (
<div key={ids[i]} className="flex items-center gap-2">
<Input className="font-mono text-xs flex-1" placeholder="matcher (e.g. Bash)" value={hook.matcher ?? ""} onChange={(e) => updateHook(event, i, { ...hook, matcher: e.target.value || null })} />
<Input className="font-mono text-xs w-24" type="number" placeholder="timeout" value={hook.timeout ?? ""} onChange={(e) => updateHook(event, i, { ...hook, timeout: e.target.value ? Number(e.target.value) : undefined })} />
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeHook(event, i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
</div>
);
})}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { Plus, Trash2 } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export function KeyValueEditor({
value,
onChange,
keyPlaceholder = "KEY",
valuePlaceholder = "value",
}: {
value: Record<string, string | null>;
onChange: (v: Record<string, string | null>) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
}) {
const entries = Object.entries(value);
const addEntry = () => onChange({ ...value, "": "" });
const removeEntry = (key: string) => {
const next = { ...value };
delete next[key];
onChange(next);
};
const updateEntry = (oldKey: string, newKey: string, newVal: string | null) => {
const next: Record<string, string | null> = {};
for (const [k, v] of Object.entries(value)) {
if (k === oldKey) {
next[newKey] = newVal;
} else {
next[k] = v;
}
}
onChange(next);
};

return (
<div className="space-y-2">
{entries.map(([k, v]) => (
<div key={k} className="flex items-center gap-2">
<Input
className="font-mono text-xs w-1/3"
placeholder={keyPlaceholder}
value={k}
onChange={(e) => updateEntry(k, e.target.value, v)}
/>
<Input
className="font-mono text-xs flex-1"
placeholder={valuePlaceholder}
value={v ?? ""}
onChange={(e) => updateEntry(k, k, e.target.value || null)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => removeEntry(k)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addEntry}>
<Plus className="h-3 w-3 mr-1" /> Add
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import { Plus, Trash2 } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";

import { StringListEditor } from "./string-list-editor";
import { KeyValueEditor } from "./key-value-editor";

// Wider than the discriminated union schema — the editor needs to access all
// fields during editing before the type discriminant narrows them.
export type McpFormServer = {
type: "stdio" | "sse" | "http";
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
};

export function McpServersEditor({ value, onChange }: { value: Record<string, McpFormServer>; onChange: (v: Record<string, McpFormServer>) => void }) {
const entries = Object.entries(value);

const addServer = () => {
onChange({ ...value, [`server-${entries.length + 1}`]: { type: "stdio", command: "", args: [], env: {} } });
};
const removeServer = (name: string) => {
const next = { ...value };
delete next[name];
onChange(next);
};
const updateServerName = (oldName: string, newName: string) => {
const next: Record<string, McpFormServer> = {};
for (const [k, v] of Object.entries(value)) next[k === oldName ? newName : k] = v;
onChange(next);
};
const updateServer = (name: string, server: McpFormServer) => onChange({ ...value, [name]: server });

return (
<div className="space-y-3">
{entries.map(([name, server]) => (
<div key={name} className="border rounded-md p-3 space-y-3">
<div className="flex items-center gap-2">
<Input className="font-mono text-xs w-1/3" value={name} placeholder="server-name" onChange={(e) => updateServerName(name, e.target.value)} />
<Select value={server.type ?? "stdio"} onValueChange={(t) => {
if (t === "stdio") updateServer(name, { type: "stdio", command: "", args: [], env: {} });
else updateServer(name, { type: t as "sse" | "http", url: "", headers: {} });
}}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="ghost" size="icon" className="ml-auto h-8 w-8" onClick={() => removeServer(name)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{(server.type ?? "stdio") === "stdio" ? (
<>
<Input className="font-mono text-xs" placeholder="command (e.g. uvx mcp-server-fetch)" value={server.command ?? ""} onChange={(e) => updateServer(name, { ...server, command: e.target.value })} />
<div>
<Label className="text-xs text-muted-foreground">Args</Label>
<StringListEditor value={server.args ?? []} onChange={(a) => updateServer(name, { ...server, args: a })} placeholder="--arg" />
</div>
<div>
<Label className="text-xs text-muted-foreground">Environment</Label>
<KeyValueEditor value={server.env ?? {}} onChange={(e) => updateServer(name, { ...server, env: e as Record<string, string> })} />
</div>
</>
) : (
<>
<Input className="font-mono text-xs" placeholder={server.type === "sse" ? "https://server.example.com/sse" : "https://server.example.com/mcp"} value={server.url ?? ""} onChange={(e) => updateServer(name, { ...server, url: e.target.value })} />
<div>
<Label className="text-xs text-muted-foreground">Headers</Label>
<KeyValueEditor value={server.headers ?? {}} onChange={(h) => updateServer(name, { ...server, headers: h as Record<string, string> })} keyPlaceholder="Header-Name" valuePlaceholder="Header value" />
</div>
</>
)}
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addServer}>
<Plus className="h-3 w-3 mr-1" /> Add MCP Server
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { useState } from "react";

import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";

export function OutputFormatField({
value,
onChange,
disabled,
}: {
value: { type?: string; schema?: Record<string, unknown> } | undefined;
onChange: (v: typeof value) => void;
disabled?: boolean;
}) {
const [rawJson, setRawJson] = useState(value ? JSON.stringify(value, null, 2) : "");
const [jsonError, setJsonError] = useState<string | null>(null);

const handleChange = (text: string) => {
setRawJson(text);
if (!text.trim()) {
setJsonError(null);
onChange(undefined);
return;
}
try {
onChange(JSON.parse(text));
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
};

return (
<FormItem>
<FormLabel>JSON Schema</FormLabel>
<FormControl>
<Textarea
placeholder='{"type": "json_schema", "schema": {"type": "object", ...}}'
className={`font-mono text-xs ${jsonError ? "border-destructive" : ""}`}
rows={6}
disabled={disabled}
value={rawJson}
onChange={(e) => handleChange(e.target.value)}
/>
</FormControl>
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
<FormDescription>Structured output format (Messages API schema)</FormDescription>
<FormMessage />
</FormItem>
);
}
Loading
Loading