mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 05:26:23 +00:00
2d5c0e34d7
Operators can register Anthropic-API-compatible endpoints (a local Qwen/Llama behind LiteLLM or claude-code-router, an LLM gateway) on the dashboard's Claude -> Models tab and pick one from a Model dropdown when spawning an agent. The agent still launches as the same claude binary with the same hooks, skills, connectors, plugins, and gates — only its ANTHROPIC_BASE_URL / ANTHROPIC_MODEL / ANTHROPIC_AUTH_TOKEN env differs — and it stays pinned to its backend across resumes. No selection keeps the worker's Claude subscription untouched. - claude_models table (+ agents.model_id pin), migration 0012 - control.models resolves a row into the launch env (API keys Fernet-encrypted at rest, decrypted only in the control container; placeholder key when none is stored so the subscription OAuth token never reaches a local endpoint) - /claude/models CRUD (admin-gated writes, key never returned), spawn route + worker + CLI (--model) pass the selection through, fail-fast on missing or disabled backends - dashboard: Models tab, spawn-form dropdown, model badge in the agents table - docs/local-models.md: why bare OpenAI-compatible servers break tool calling with Qwen-Coder, and working vLLM/LiteLLM/llama.cpp stacks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzDofD7gP63WpeLG8vEdZu
790 lines
27 KiB
TypeScript
790 lines
27 KiB
TypeScript
/* Claude — the management page for the Claude Code install agents run on: the account
|
|
* login (moved here from the old Claude Login page), plus the operator-managed skills,
|
|
* MCP connectors, plugins, and permission overrides. Everything except the login is a
|
|
* plain DB write that the control container applies at the NEXT launch of every agent —
|
|
* skills sync to the workers' user-level ~/.claude/skills, connectors become each run's
|
|
* --mcp-config file, and plugins/permissions fold into the generated settings.json. */
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useDashboard } from "@/components/store";
|
|
import type { ConnectorBody, ModelBody, PluginBody, SkillBody } from "@/components/store";
|
|
import { Badge, Button, Card, Input, Select, Tabs, Textarea, Toggle } from "@/components/ui";
|
|
import type { ClaudeConnector, ClaudeModel, ClaudePlugin, ClaudeSkill } from "@/lib/api";
|
|
import { ClaudeLoginPanel } from "@/components/sections/LoginSection";
|
|
|
|
/* KEY=VALUE-per-line <-> map helpers for connector env/headers. */
|
|
function parseKeyValues(text: string): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const line of text.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
const eq = trimmed.indexOf("=");
|
|
if (eq <= 0) continue;
|
|
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function formatKeyValues(map: Record<string, string> | null | undefined): string {
|
|
return Object.entries(map ?? {})
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function parseLines(text: string): string[] {
|
|
return text
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/* ---- Skills ------------------------------------------------------------------------ */
|
|
|
|
const emptySkill = { name: "", description: "", content: "", enabled: true };
|
|
|
|
/* Install-from-prompt: paste the "install prompt" a marketplace page (SkillsMP etc.)
|
|
* shows, and the worker runs it through a one-off headless claude, importing whatever
|
|
* it fetches as managed skills. Headless = nobody to answer questions, so the run makes
|
|
* the choices a human would be asked (scope, options) itself — always user scope,
|
|
* sensible defaults — and reports them; the import lands below for review/editing. */
|
|
function InstallCard() {
|
|
const s = useDashboard();
|
|
const [prompt, setPrompt] = useState("");
|
|
|
|
const run = async () => {
|
|
const ok = await s.installClaudeSkill(prompt.trim());
|
|
if (ok) setPrompt("");
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<div className="card-head" style={{ marginBottom: 14 }}>
|
|
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
|
Install from a marketplace prompt
|
|
</span>
|
|
</div>
|
|
<Textarea
|
|
label="Paste the skill's install prompt (from SkillsMP or any marketplace page)"
|
|
value={prompt}
|
|
onChange={setPrompt}
|
|
rows={5}
|
|
placeholder={"Install the pdf-tools skill from https://…\n(the whole prompt the marketplace tells you to paste into Claude)"}
|
|
/>
|
|
<div className="faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
|
Runs headlessly on the worker — nobody can answer questions mid-install, so when
|
|
the instructions offer choices (user vs repo scope, optional variants) Claude
|
|
picks the defaults itself: skills here are always <b>user scope</b> (Handler
|
|
syncs them to every worker), and recommended options win. What it chose is
|
|
reported in the result — review the imported skill below and edit or disable it
|
|
if a choice was wrong.
|
|
</div>
|
|
<div className="hstack mt14">
|
|
<Button variant="primary" disabled={s.cmd.busy || !prompt.trim()} onClick={run}>
|
|
{s.cmd.busy ? "Installing…" : "Run install"}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function SkillsPanel() {
|
|
const s = useDashboard();
|
|
const [form, setForm] = useState(emptySkill);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
|
|
const reset = () => {
|
|
setForm(emptySkill);
|
|
setEditingId(null);
|
|
};
|
|
|
|
const save = async () => {
|
|
const body: SkillBody = { ...form };
|
|
const ok =
|
|
editingId != null
|
|
? await s.updateClaudeSkill(editingId, body)
|
|
: await s.createClaudeSkill(body);
|
|
if (ok) reset();
|
|
};
|
|
|
|
const edit = (sk: ClaudeSkill) => {
|
|
setForm({
|
|
name: sk.name,
|
|
description: sk.description ?? "",
|
|
content: sk.content,
|
|
enabled: sk.enabled,
|
|
});
|
|
setEditingId(sk.id);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
|
|
Custom Claude Code skills, synced to every worker's{" "}
|
|
<span className="mono">~/.claude/skills</span> at each launch. The description is
|
|
what makes Claude pick the skill up — say when to use it. Install one from a
|
|
marketplace prompt, or author one by hand below.
|
|
</div>
|
|
<InstallCard />
|
|
<Card>
|
|
<div className="card-head" style={{ marginBottom: 14 }}>
|
|
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
|
{editingId != null ? `Edit skill · ${form.name}` : "Add a skill"}
|
|
</span>
|
|
</div>
|
|
<div className="form-grid">
|
|
<Input
|
|
label="Name (slug — becomes the skill directory)"
|
|
value={form.name}
|
|
onChange={(v) => setForm({ ...form, name: v })}
|
|
placeholder="deploy-checklist"
|
|
/>
|
|
<Input
|
|
label="Description (when should Claude use it?)"
|
|
value={form.description}
|
|
onChange={(v) => setForm({ ...form, description: v })}
|
|
placeholder="Use when preparing or reviewing a deploy."
|
|
/>
|
|
</div>
|
|
<div style={{ marginTop: 10 }}>
|
|
<Textarea
|
|
label="SKILL.md body (markdown)"
|
|
value={form.content}
|
|
onChange={(v) => setForm({ ...form, content: v })}
|
|
rows={8}
|
|
placeholder={"# Deploy checklist\n\n1. ..."}
|
|
/>
|
|
</div>
|
|
<div className="hstack mt14">
|
|
<Button
|
|
variant="primary"
|
|
disabled={!form.name.trim() || !form.content.trim()}
|
|
onClick={save}
|
|
>
|
|
{editingId != null ? "Save changes" : "Add skill"}
|
|
</Button>
|
|
{editingId != null && (
|
|
<Button variant="ghost" onClick={reset}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{s.claudeSkills.length === 0 && <div className="empty">No custom skills yet.</div>}
|
|
|
|
{s.claudeSkills.map((sk) => (
|
|
<Card key={sk.id}>
|
|
<div className="card-head">
|
|
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
|
|
{sk.name}
|
|
</span>
|
|
<div className="hstack">
|
|
<Badge tone={sk.enabled ? "success" : "neutral"}>
|
|
{sk.enabled ? "enabled" : "disabled"}
|
|
</Badge>
|
|
<Toggle on={sk.enabled} onClick={() => s.updateClaudeSkill(sk.id, { enabled: !sk.enabled })} />
|
|
<Button size="sm" variant="secondary" onClick={() => edit(sk)}>
|
|
Edit
|
|
</Button>
|
|
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeSkill(sk.id)}>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{sk.description && (
|
|
<div className="faint" style={{ fontSize: "var(--text-sm)", marginTop: 8 }}>
|
|
{sk.description}
|
|
</div>
|
|
)}
|
|
{sk.files.length > 0 && (
|
|
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
|
ships with: {sk.files.join(" · ")}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---- Connectors (MCP servers) ------------------------------------------------------ */
|
|
|
|
const TRANSPORT_OPTS = [
|
|
{ value: "stdio", label: "stdio (run a command)" },
|
|
{ value: "http", label: "http (remote server)" },
|
|
{ value: "sse", label: "sse (remote server, legacy)" },
|
|
];
|
|
|
|
const emptyConnector = {
|
|
name: "",
|
|
transport: "stdio" as ConnectorBody["transport"],
|
|
command: "",
|
|
args: "",
|
|
env: "",
|
|
url: "",
|
|
headers: "",
|
|
enabled: true,
|
|
};
|
|
|
|
function ConnectorsPanel() {
|
|
const s = useDashboard();
|
|
const [form, setForm] = useState(emptyConnector);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
|
|
const reset = () => {
|
|
setForm(emptyConnector);
|
|
setEditingId(null);
|
|
};
|
|
|
|
const save = async () => {
|
|
const body: ConnectorBody = {
|
|
name: form.name,
|
|
transport: form.transport,
|
|
command: form.command.trim() || null,
|
|
args: parseLines(form.args),
|
|
env: parseKeyValues(form.env),
|
|
url: form.url.trim() || null,
|
|
headers: parseKeyValues(form.headers),
|
|
enabled: form.enabled,
|
|
};
|
|
const ok =
|
|
editingId != null
|
|
? await s.updateClaudeConnector(editingId, body)
|
|
: await s.createClaudeConnector(body);
|
|
if (ok) reset();
|
|
};
|
|
|
|
const edit = (c: ClaudeConnector) => {
|
|
setForm({
|
|
name: c.name,
|
|
transport: c.transport,
|
|
command: c.command ?? "",
|
|
args: (c.args ?? []).join("\n"),
|
|
env: formatKeyValues(c.env),
|
|
url: c.url ?? "",
|
|
headers: formatKeyValues(c.headers),
|
|
enabled: c.enabled,
|
|
});
|
|
setEditingId(c.id);
|
|
};
|
|
|
|
const stdio = form.transport === "stdio";
|
|
|
|
return (
|
|
<>
|
|
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
|
|
MCP servers agents can reach. Passed to each run as its{" "}
|
|
<span className="mono">--mcp-config</span> file, so nothing lands in the
|
|
repository tree. stdio commands run inside the control container.
|
|
</div>
|
|
<Card>
|
|
<div className="card-head" style={{ marginBottom: 14 }}>
|
|
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
|
{editingId != null ? `Edit connector · ${form.name}` : "Add a connector"}
|
|
</span>
|
|
</div>
|
|
<div className="form-grid">
|
|
<Input
|
|
label="Name"
|
|
value={form.name}
|
|
onChange={(v) => setForm({ ...form, name: v })}
|
|
placeholder="github"
|
|
/>
|
|
<Select
|
|
label="Transport"
|
|
value={form.transport}
|
|
onChange={(v) => setForm({ ...form, transport: v as ConnectorBody["transport"] })}
|
|
options={TRANSPORT_OPTS}
|
|
/>
|
|
{stdio ? (
|
|
<>
|
|
<Input
|
|
label="Command"
|
|
value={form.command}
|
|
onChange={(v) => setForm({ ...form, command: v })}
|
|
placeholder="npx"
|
|
/>
|
|
<Textarea
|
|
label="Arguments (one per line)"
|
|
value={form.args}
|
|
onChange={(v) => setForm({ ...form, args: v })}
|
|
rows={3}
|
|
placeholder={"-y\n@modelcontextprotocol/server-github"}
|
|
/>
|
|
<Textarea
|
|
label="Environment (KEY=VALUE per line)"
|
|
value={form.env}
|
|
onChange={(v) => setForm({ ...form, env: v })}
|
|
rows={3}
|
|
placeholder="GITHUB_TOKEN=ghp_..."
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Input
|
|
label="URL"
|
|
value={form.url}
|
|
onChange={(v) => setForm({ ...form, url: v })}
|
|
placeholder="https://mcp.example.com/mcp"
|
|
/>
|
|
<Textarea
|
|
label="Headers (KEY=VALUE per line)"
|
|
value={form.headers}
|
|
onChange={(v) => setForm({ ...form, headers: v })}
|
|
rows={3}
|
|
placeholder="Authorization=Bearer ..."
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="hstack mt14">
|
|
<Button
|
|
variant="primary"
|
|
disabled={!form.name.trim() || (stdio ? !form.command.trim() : !form.url.trim())}
|
|
onClick={save}
|
|
>
|
|
{editingId != null ? "Save changes" : "Add connector"}
|
|
</Button>
|
|
{editingId != null && (
|
|
<Button variant="ghost" onClick={reset}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{s.claudeConnectors.length === 0 && <div className="empty">No connectors yet.</div>}
|
|
|
|
{s.claudeConnectors.map((c) => (
|
|
<Card key={c.id}>
|
|
<div className="card-head">
|
|
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
|
|
{c.name}
|
|
</span>
|
|
<div className="hstack">
|
|
<Badge tone="info">{c.transport}</Badge>
|
|
<Badge tone={c.enabled ? "success" : "neutral"}>
|
|
{c.enabled ? "enabled" : "disabled"}
|
|
</Badge>
|
|
<Toggle on={c.enabled} onClick={() => s.updateClaudeConnector(c.id, { enabled: !c.enabled })} />
|
|
<Button size="sm" variant="secondary" onClick={() => edit(c)}>
|
|
Edit
|
|
</Button>
|
|
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeConnector(c.id)}>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
|
{c.transport === "stdio"
|
|
? [c.command, ...(c.args ?? [])].join(" ")
|
|
: c.url}
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---- Plugins ----------------------------------------------------------------------- */
|
|
|
|
const emptyPlugin = { name: "", marketplace: "", marketplace_repo: "", enabled: true };
|
|
|
|
function PluginsPanel() {
|
|
const s = useDashboard();
|
|
const [form, setForm] = useState(emptyPlugin);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
|
|
const reset = () => {
|
|
setForm(emptyPlugin);
|
|
setEditingId(null);
|
|
};
|
|
|
|
const save = async () => {
|
|
const body: PluginBody = { ...form };
|
|
const ok =
|
|
editingId != null
|
|
? await s.updateClaudePlugin(editingId, body)
|
|
: await s.createClaudePlugin(body);
|
|
if (ok) reset();
|
|
};
|
|
|
|
const edit = (p: ClaudePlugin) => {
|
|
setForm({
|
|
name: p.name,
|
|
marketplace: p.marketplace,
|
|
marketplace_repo: p.marketplace_repo,
|
|
enabled: p.enabled,
|
|
});
|
|
setEditingId(p.id);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
|
|
Claude Code plugins, pinned to the marketplace serving them. Generated settings
|
|
declare the marketplace and enable the plugin, so headless runs install both on
|
|
boot.
|
|
</div>
|
|
<Card>
|
|
<div className="card-head" style={{ marginBottom: 14 }}>
|
|
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
|
{editingId != null ? `Edit plugin · ${form.name}` : "Add a plugin"}
|
|
</span>
|
|
</div>
|
|
<div className="form-grid">
|
|
<Input
|
|
label="Plugin name"
|
|
value={form.name}
|
|
onChange={(v) => setForm({ ...form, name: v })}
|
|
placeholder="code-reviewer"
|
|
/>
|
|
<Input
|
|
label="Marketplace key"
|
|
value={form.marketplace}
|
|
onChange={(v) => setForm({ ...form, marketplace: v })}
|
|
placeholder="acme-tools"
|
|
/>
|
|
<Input
|
|
label="Marketplace repo (owner/repo or git URL)"
|
|
value={form.marketplace_repo}
|
|
onChange={(v) => setForm({ ...form, marketplace_repo: v })}
|
|
placeholder="acme/claude-marketplace"
|
|
/>
|
|
</div>
|
|
<div className="hstack mt14">
|
|
<Button
|
|
variant="primary"
|
|
disabled={!form.name.trim() || !form.marketplace.trim() || !form.marketplace_repo.trim()}
|
|
onClick={save}
|
|
>
|
|
{editingId != null ? "Save changes" : "Add plugin"}
|
|
</Button>
|
|
{editingId != null && (
|
|
<Button variant="ghost" onClick={reset}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{s.claudePlugins.length === 0 && <div className="empty">No plugins yet.</div>}
|
|
|
|
{s.claudePlugins.map((p) => (
|
|
<Card key={p.id}>
|
|
<div className="card-head">
|
|
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
|
|
{p.name}@{p.marketplace}
|
|
</span>
|
|
<div className="hstack">
|
|
<Badge tone={p.enabled ? "success" : "neutral"}>
|
|
{p.enabled ? "enabled" : "disabled"}
|
|
</Badge>
|
|
<Toggle on={p.enabled} onClick={() => s.updateClaudePlugin(p.id, { enabled: !p.enabled })} />
|
|
<Button size="sm" variant="secondary" onClick={() => edit(p)}>
|
|
Edit
|
|
</Button>
|
|
<Button size="sm" variant="danger" onClick={() => s.deleteClaudePlugin(p.id)}>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
|
{p.marketplace_repo}
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---- Model backends ---------------------------------------------------------------- */
|
|
|
|
const emptyModel = {
|
|
name: "",
|
|
base_url: "",
|
|
model: "",
|
|
small_fast_model: "",
|
|
api_key: "",
|
|
env: "",
|
|
enabled: true,
|
|
};
|
|
|
|
function ModelsPanel() {
|
|
const s = useDashboard();
|
|
const [form, setForm] = useState(emptyModel);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
|
|
const reset = () => {
|
|
setForm(emptyModel);
|
|
setEditingId(null);
|
|
};
|
|
|
|
const save = async () => {
|
|
const body: ModelBody = {
|
|
name: form.name,
|
|
base_url: form.base_url.trim(),
|
|
model: form.model.trim(),
|
|
small_fast_model: form.small_fast_model.trim() || null,
|
|
api_key: form.api_key.trim() || null,
|
|
env: parseKeyValues(form.env),
|
|
enabled: form.enabled,
|
|
};
|
|
const ok =
|
|
editingId != null
|
|
? await s.updateClaudeModel(editingId, body)
|
|
: await s.createClaudeModel(body);
|
|
if (ok) reset();
|
|
};
|
|
|
|
const edit = (m: ClaudeModel) => {
|
|
setForm({
|
|
name: m.name,
|
|
base_url: m.base_url,
|
|
model: m.model,
|
|
small_fast_model: m.small_fast_model ?? "",
|
|
api_key: "", // write-only; blank = keep the stored key
|
|
env: formatKeyValues(m.env),
|
|
enabled: m.enabled,
|
|
});
|
|
setEditingId(m.id);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
|
|
Alternative model backends the spawn dropdown offers next to the Claude
|
|
subscription — the same <span className="mono">claude</span> binary pointed at a
|
|
different endpoint via <span className="mono">ANTHROPIC_BASE_URL</span>, so
|
|
skills, connectors, hooks, and gates apply unchanged. The endpoint must speak the{" "}
|
|
<b>Anthropic Messages API including tool use</b> — a bare OpenAI-compatible
|
|
server (Ollama, llama.cpp, LM Studio) breaks tool calling; front it with LiteLLM
|
|
or claude-code-router and enable the backend's native tool parser. See{" "}
|
|
<span className="mono">docs/local-models.md</span> for working Qwen-Coder stacks.
|
|
</div>
|
|
<Card>
|
|
<div className="card-head" style={{ marginBottom: 14 }}>
|
|
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
|
{editingId != null ? `Edit model · ${form.name}` : "Add a model backend"}
|
|
</span>
|
|
</div>
|
|
<div className="form-grid">
|
|
<Input
|
|
label="Name (what the spawn dropdown shows)"
|
|
value={form.name}
|
|
onChange={(v) => setForm({ ...form, name: v })}
|
|
placeholder="qwen3-coder"
|
|
/>
|
|
<Input
|
|
label="Base URL (Anthropic-compatible /v1/messages endpoint)"
|
|
value={form.base_url}
|
|
onChange={(v) => setForm({ ...form, base_url: v })}
|
|
placeholder="http://llm.lan:4000"
|
|
/>
|
|
<Input
|
|
label="Model id (as the endpoint serves it)"
|
|
value={form.model}
|
|
onChange={(v) => setForm({ ...form, model: v })}
|
|
placeholder="qwen3-coder-30b"
|
|
/>
|
|
<Input
|
|
label="Small/fast model id (optional — defaults to the main model)"
|
|
value={form.small_fast_model}
|
|
onChange={(v) => setForm({ ...form, small_fast_model: v })}
|
|
placeholder="qwen3-1.7b"
|
|
/>
|
|
<Input
|
|
label={editingId != null ? "API key (blank = keep stored key)" : "API key (optional)"}
|
|
value={form.api_key}
|
|
onChange={(v) => setForm({ ...form, api_key: v })}
|
|
placeholder="sk-… (encrypted at rest, never shown again)"
|
|
/>
|
|
<Textarea
|
|
label="Extra env overrides (KEY=VALUE per line, optional)"
|
|
value={form.env}
|
|
onChange={(v) => setForm({ ...form, env: v })}
|
|
rows={3}
|
|
placeholder={"API_TIMEOUT_MS=600000\nCLAUDE_CODE_MAX_OUTPUT_TOKENS=8192"}
|
|
/>
|
|
</div>
|
|
<div className="hstack mt14">
|
|
<Button
|
|
variant="primary"
|
|
disabled={!form.name.trim() || !form.base_url.trim() || !form.model.trim()}
|
|
onClick={save}
|
|
>
|
|
{editingId != null ? "Save changes" : "Add model"}
|
|
</Button>
|
|
{editingId != null && (
|
|
<Button variant="ghost" onClick={reset}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{s.claudeModels.length === 0 && (
|
|
<div className="empty">No model backends yet — agents run on the Claude subscription.</div>
|
|
)}
|
|
|
|
{s.claudeModels.map((m) => (
|
|
<Card key={m.id}>
|
|
<div className="card-head">
|
|
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
|
|
{m.name}
|
|
</span>
|
|
<div className="hstack">
|
|
{m.has_api_key && <Badge tone="info">key stored</Badge>}
|
|
<Badge tone={m.enabled ? "success" : "neutral"}>
|
|
{m.enabled ? "enabled" : "disabled"}
|
|
</Badge>
|
|
<Toggle on={m.enabled} onClick={() => s.updateClaudeModel(m.id, { enabled: !m.enabled })} />
|
|
<Button size="sm" variant="secondary" onClick={() => edit(m)}>
|
|
Edit
|
|
</Button>
|
|
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeModel(m.id)}>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
|
{m.model}
|
|
{m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""} @ {m.base_url}
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---- Permissions ------------------------------------------------------------------- */
|
|
|
|
const MODE_OPTS = [
|
|
{ value: "", label: "(keep server baseline)" },
|
|
{ value: "default", label: "default" },
|
|
{ value: "acceptEdits", label: "acceptEdits" },
|
|
{ value: "plan", label: "plan" },
|
|
{ value: "bypassPermissions", label: "bypassPermissions" },
|
|
];
|
|
|
|
function PermissionsPanel() {
|
|
const s = useDashboard();
|
|
const p = s.claudePermissions;
|
|
const [form, setForm] = useState<{ mode: string; allow: string; deny: string; ask: string } | null>(null);
|
|
|
|
// Seed the form from the loaded permissions once; afterwards the operator's draft wins.
|
|
if (form === null && p !== null) {
|
|
setForm({
|
|
mode: p.default_mode ?? "",
|
|
allow: p.allow.join("\n"),
|
|
deny: p.deny.join("\n"),
|
|
ask: p.ask.join("\n"),
|
|
});
|
|
return null;
|
|
}
|
|
if (form === null || p === null) {
|
|
return <div className="empty">Loading permissions…</div>;
|
|
}
|
|
|
|
const save = () =>
|
|
s.saveClaudePermissions({
|
|
default_mode: form.mode || null,
|
|
allow: parseLines(form.allow),
|
|
deny: parseLines(form.deny),
|
|
ask: parseLines(form.ask),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
|
|
Overrides merged over the server baseline into every generated{" "}
|
|
<span className="mono">settings.json</span>. Headless runs auto-deny anything that
|
|
would prompt, so allow rules are what let work proceed; the PreToolUse/Stop hooks
|
|
stay the hard gate regardless.
|
|
</div>
|
|
<Card>
|
|
<div className="form-grid">
|
|
<Select
|
|
label={`Default mode (baseline: ${p.base_mode})`}
|
|
value={form.mode}
|
|
onChange={(v) => setForm({ ...form, mode: v })}
|
|
options={MODE_OPTS}
|
|
/>
|
|
<div className="field">
|
|
<span className="field-label">Baseline allow rules (from server env)</span>
|
|
<div className="mono faint" style={{ fontSize: "var(--text-xs)", padding: "6px 0" }}>
|
|
{p.base_allow.length ? p.base_allow.join(" · ") : "—"}
|
|
</div>
|
|
</div>
|
|
<Textarea
|
|
label="Extra allow rules (one per line)"
|
|
value={form.allow}
|
|
onChange={(v) => setForm({ ...form, allow: v })}
|
|
rows={4}
|
|
placeholder={"Bash(npm *)\nWebFetch(domain:docs.example.com)"}
|
|
/>
|
|
<Textarea
|
|
label="Deny rules (one per line)"
|
|
value={form.deny}
|
|
onChange={(v) => setForm({ ...form, deny: v })}
|
|
rows={4}
|
|
placeholder={"Bash(rm -rf *)\nRead(./secrets/**)"}
|
|
/>
|
|
<Textarea
|
|
label="Ask rules (one per line — headless runs deny these)"
|
|
value={form.ask}
|
|
onChange={(v) => setForm({ ...form, ask: v })}
|
|
rows={4}
|
|
placeholder="Bash(git push *)"
|
|
/>
|
|
</div>
|
|
<div className="hstack mt14">
|
|
<Button variant="primary" onClick={save}>
|
|
Save permissions
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---- The page ---------------------------------------------------------------------- */
|
|
|
|
const TABS = [
|
|
{ value: "account", label: "Account" },
|
|
{ value: "models", label: "Models" },
|
|
{ value: "skills", label: "Skills" },
|
|
{ value: "connectors", label: "Connectors" },
|
|
{ value: "plugins", label: "Plugins" },
|
|
{ value: "permissions", label: "Permissions" },
|
|
];
|
|
|
|
export function ClaudeSection() {
|
|
const [tab, setTab] = useState("account");
|
|
|
|
return (
|
|
<>
|
|
<div className="section-head">
|
|
<div className="section-title">Claude</div>
|
|
<div className="section-desc">
|
|
Manage the Claude Code install agents run on: the account login, alternative
|
|
model backends, plus skills, MCP connectors, plugins, and permissions. Changes
|
|
apply to the next launch of every agent.
|
|
</div>
|
|
</div>
|
|
<div className="section-body">
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Tabs tabs={TABS} value={tab} onChange={setTab} />
|
|
</div>
|
|
{tab === "account" && <ClaudeLoginPanel />}
|
|
{tab === "models" && <ModelsPanel />}
|
|
{tab === "skills" && <SkillsPanel />}
|
|
{tab === "connectors" && <ConnectorsPanel />}
|
|
{tab === "plugins" && <PluginsPanel />}
|
|
{tab === "permissions" && <PermissionsPanel />}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|