mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 03:31:36 +00:00
Turn the Claude Login page into a full Claude management page
The dashboard's Claude page now manages the whole Claude Code install agents run on, not just the account login: - Skills: operator-authored SKILL.md rows, synced to each worker's user-level ~/.claude/skills at every launch. Managed dirs carry a .handler-managed marker so deletions in the UI propagate while hand-installed skills survive. - Connectors: MCP servers (stdio/http/sse) written per-launch as .claude/mcp-servers.json and passed to claude via --mcp-config, so nothing lands in the managed repo's tracked tree. - Plugins: marketplace-pinned plugins folded into generated settings as extraKnownMarketplaces + enabledPlugins, installing on boot of headless runs. - Permissions: defaultMode override plus allow/deny/ask rules merged over the env baseline into every generated settings.json. All of it is plain DB state (new claude_skills / claude_connectors / claude_plugins / claude_config tables, migration 0010) edited through the new admin-gated /claude/* API routes and applied by the control container at spawn and resume — changes reach the next launch of every agent with no redeploy. The login flow moved into the page's Account tab unchanged; /login redirects to /claude for old bookmarks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f42XmjtVsc3zQ9Dhn6DqZ
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
/* Claude management page. The shell (sidebar, banners, store) comes from the root
|
||||
* layout; this route contributes only its section, in the shared scroll frame. */
|
||||
"use client";
|
||||
|
||||
import { ClaudeSection } from "@/components/sections/ClaudeSection";
|
||||
|
||||
export default function ClaudePage() {
|
||||
return (
|
||||
<div className="main-scroll">
|
||||
<ClaudeSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
/* Claude Login page. The shell (sidebar, banners, store) comes from the root layout; this
|
||||
* route contributes only its section, in the shared scroll frame. */
|
||||
/* The old Claude Login route. The login flow now lives on the Claude management page
|
||||
* (/claude, Account tab); this stub only redirects old bookmarks there. */
|
||||
"use client";
|
||||
|
||||
import { LoginSection } from "@/components/sections/LoginSection";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="main-scroll">
|
||||
<LoginSection />
|
||||
</div>
|
||||
);
|
||||
export default function LoginRedirect() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace("/claude");
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
|
||||
* Servers / Activity / Shared / Claude Login) and the active route's page on the right,
|
||||
* Servers / Activity / Shared / Claude) and the active route's page on the right,
|
||||
* matching the design's hub layout. Each nav item is a real route, so pages are modular
|
||||
* and independently loadable; this shell lives in the root layout and persists across
|
||||
* navigation, keeping the store, polling loop, and auth alive between pages. Command
|
||||
@@ -29,8 +29,9 @@ const BADGES: Partial<Record<Section, { count: (s: Store) => number; accent?: (s
|
||||
servers: { count: (s) => s.hosts.length },
|
||||
activity: { count: (s) => s.commands.length },
|
||||
shared: { count: (s) => s.shared.context.length },
|
||||
login: {
|
||||
count: () => 0,
|
||||
claude: {
|
||||
// Everything managed on the page: skills + connectors + plugins.
|
||||
count: (s) => s.claudeSkills.length + s.claudeConnectors.length + s.claudePlugins.length,
|
||||
// Draw the eye to it until Claude is logged in on the host this session.
|
||||
accent: (s) => s.claudeLogin.status !== "done",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
/* 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, PluginBody, SkillBody } from "@/components/store";
|
||||
import { Badge, Button, Card, Input, Select, Tabs, Textarea, Toggle } from "@/components/ui";
|
||||
import type { ClaudeConnector, 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 };
|
||||
|
||||
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.
|
||||
</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 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>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 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: "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, 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 === "skills" && <SkillsPanel />}
|
||||
{tab === "connectors" && <ConnectorsPanel />}
|
||||
{tab === "plugins" && <PluginsPanel />}
|
||||
{tab === "permissions" && <PermissionsPanel />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Claude Login — drive the bundled `claude /login` OAuth flow on the host from the web UI.
|
||||
/* Claude account login panel (the Account tab of the Claude page) — drives the bundled
|
||||
* `claude /login` OAuth flow on the host from the web UI.
|
||||
*
|
||||
* Click "Log in to Claude" → a small OAuth-style popup window opens (like "Sign in with
|
||||
* Google") and the worker drives `claude /login` in the control container, selecting the
|
||||
@@ -27,7 +28,7 @@ function openLoginPopup(url: string): Window | null {
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginSection() {
|
||||
export function ClaudeLoginPanel() {
|
||||
const s = useDashboard();
|
||||
const { status, url, message } = s.claudeLogin;
|
||||
const [code, setCode] = useState("");
|
||||
@@ -66,16 +67,13 @@ export function LoginSection() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="section-title">Claude Login</div>
|
||||
<div className="section-desc">
|
||||
Log Claude Code in on the host so agents can run. This drives{" "}
|
||||
<span className="mono">claude /login</span> in the control container and picks the
|
||||
Claude account with a subscription.
|
||||
</div>
|
||||
<div className="faint" style={{ fontSize: "var(--text-sm)" }}>
|
||||
Log Claude Code in on the host so agents can run. This drives{" "}
|
||||
<span className="mono">claude /login</span> in the control container and picks the
|
||||
Claude account with a subscription.
|
||||
</div>
|
||||
|
||||
<div className="section-body" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, marginTop: 14 }}>
|
||||
{message && (
|
||||
<Callout tone={status === "error" ? "danger" : status === "done" ? "success" : "info"}>
|
||||
{message}
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
type ApiError,
|
||||
type Approval,
|
||||
type Checkmark,
|
||||
type ClaudeConnector,
|
||||
type ClaudePermissions,
|
||||
type ClaudePlugin,
|
||||
type ClaudeSkill,
|
||||
type Command,
|
||||
type Host,
|
||||
type LogEntry,
|
||||
@@ -38,7 +42,7 @@ export type Section =
|
||||
| "servers"
|
||||
| "activity"
|
||||
| "shared"
|
||||
| "login";
|
||||
| "claude";
|
||||
|
||||
/* The claude web-login flow, driven through the login_start / login_submit commands.
|
||||
* idle → starting → awaiting (have URL) → submitting → done | error */
|
||||
@@ -124,6 +128,51 @@ interface StoreValue {
|
||||
startClaudeLogin: () => Promise<void>;
|
||||
submitClaudeCode: (code: string) => Promise<boolean>;
|
||||
resetClaudeLogin: () => void;
|
||||
|
||||
// Claude management (skills / connectors / plugins / permissions)
|
||||
claudeSkills: ClaudeSkill[];
|
||||
claudeConnectors: ClaudeConnector[];
|
||||
claudePlugins: ClaudePlugin[];
|
||||
claudePermissions: ClaudePermissions | null;
|
||||
createClaudeSkill: (b: SkillBody) => Promise<boolean>;
|
||||
updateClaudeSkill: (id: number, b: Partial<SkillBody>) => Promise<boolean>;
|
||||
deleteClaudeSkill: (id: number) => Promise<void>;
|
||||
createClaudeConnector: (b: ConnectorBody) => Promise<boolean>;
|
||||
updateClaudeConnector: (id: number, b: Partial<ConnectorBody>) => Promise<boolean>;
|
||||
deleteClaudeConnector: (id: number) => Promise<void>;
|
||||
createClaudePlugin: (b: PluginBody) => Promise<boolean>;
|
||||
updateClaudePlugin: (id: number, b: Partial<PluginBody>) => Promise<boolean>;
|
||||
deleteClaudePlugin: (id: number) => Promise<void>;
|
||||
saveClaudePermissions: (b: PermissionsBody) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SkillBody {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface ConnectorBody {
|
||||
name: string;
|
||||
transport: "stdio" | "http" | "sse";
|
||||
command: string | null;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
url: string | null;
|
||||
headers: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface PluginBody {
|
||||
name: string;
|
||||
marketplace: string;
|
||||
marketplace_repo: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface PermissionsBody {
|
||||
default_mode: string | null;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
ask: string[];
|
||||
}
|
||||
|
||||
export interface SpawnBody {
|
||||
@@ -236,6 +285,10 @@ export function DashboardProvider({
|
||||
url: "",
|
||||
message: "",
|
||||
});
|
||||
const [claudeSkills, setClaudeSkills] = useState<ClaudeSkill[]>([]);
|
||||
const [claudeConnectors, setClaudeConnectors] = useState<ClaudeConnector[]>([]);
|
||||
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
|
||||
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
|
||||
|
||||
// Keep polling loop reading fresh values without re-subscribing every render.
|
||||
const sectionRef = useRef(section);
|
||||
@@ -374,6 +427,23 @@ export function DashboardProvider({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadClaude = useCallback(async () => {
|
||||
try {
|
||||
const [skills, connectors, plugins, permissions] = await Promise.all([
|
||||
clientRef.current.api<ClaudeSkill[]>("/claude/skills"),
|
||||
clientRef.current.api<ClaudeConnector[]>("/claude/connectors"),
|
||||
clientRef.current.api<ClaudePlugin[]>("/claude/plugins"),
|
||||
clientRef.current.api<ClaudePermissions>("/claude/permissions"),
|
||||
]);
|
||||
setClaudeSkills(skills);
|
||||
setClaudeConnectors(connectors);
|
||||
setClaudePlugins(plugins);
|
||||
setClaudePermissions(permissions);
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* One refresh cycle for whatever section is active (plus always-cheap projects/agents
|
||||
* so the nav counts and inbox stay live). */
|
||||
const tick = useCallback(async () => {
|
||||
@@ -396,7 +466,8 @@ export function DashboardProvider({
|
||||
if (s === "activity") await loadCommands();
|
||||
if (s === "schedules") await loadSchedules();
|
||||
if (s === "shared") await loadShared();
|
||||
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared]);
|
||||
if (s === "claude") await loadClaude();
|
||||
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude]);
|
||||
|
||||
// Initial load + polling loop. The first tick populates projects *and* agents (and the
|
||||
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
|
||||
@@ -425,8 +496,9 @@ export function DashboardProvider({
|
||||
if (s === "activity") void loadCommands();
|
||||
if (s === "schedules") void loadSchedules();
|
||||
if (s === "shared") void loadShared();
|
||||
if (s === "claude") void loadClaude();
|
||||
},
|
||||
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared],
|
||||
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude],
|
||||
);
|
||||
|
||||
const selectProject = useCallback(
|
||||
@@ -933,6 +1005,140 @@ export function DashboardProvider({
|
||||
setClaudeLogin({ status: "idle", url: "", message: "" });
|
||||
}, []);
|
||||
|
||||
// ---- claude management (skills / connectors / plugins / permissions) ----
|
||||
// Plain DB writes (no worker round-trip); every change applies to the NEXT launch of
|
||||
// every agent, which the success banners say explicitly.
|
||||
const claudeWrite = useCallback(
|
||||
async (fn: () => Promise<unknown>, okText: string): Promise<boolean> => {
|
||||
try {
|
||||
await fn();
|
||||
setCmd({ text: okText, error: false, busy: false });
|
||||
await loadClaude();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadClaude],
|
||||
);
|
||||
|
||||
const createClaudeSkill = useCallback(
|
||||
(b: SkillBody) =>
|
||||
claudeWrite(
|
||||
() =>
|
||||
clientRef.current.api("/claude/skills", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: b.name.trim(),
|
||||
description: b.description.trim() || null,
|
||||
content: b.content,
|
||||
enabled: b.enabled,
|
||||
},
|
||||
}),
|
||||
`skill '${b.name.trim()}' saved — applies to the next agent launch`,
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const updateClaudeSkill = useCallback(
|
||||
(id: number, b: Partial<SkillBody>) =>
|
||||
claudeWrite(
|
||||
() => clientRef.current.api(`/claude/skills/${id}`, { method: "PATCH", body: b }),
|
||||
"skill updated — applies to the next agent launch",
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const deleteClaudeSkill = useCallback(
|
||||
async (id: number) => {
|
||||
await claudeWrite(
|
||||
() => clientRef.current.api(`/claude/skills/${id}`, { method: "DELETE" }),
|
||||
"skill removed — gone from workers at the next launch",
|
||||
);
|
||||
},
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const createClaudeConnector = useCallback(
|
||||
(b: ConnectorBody) =>
|
||||
claudeWrite(
|
||||
() =>
|
||||
clientRef.current.api("/claude/connectors", {
|
||||
method: "POST",
|
||||
body: { ...b, name: b.name.trim() },
|
||||
}),
|
||||
`connector '${b.name.trim()}' saved — applies to the next agent launch`,
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const updateClaudeConnector = useCallback(
|
||||
(id: number, b: Partial<ConnectorBody>) =>
|
||||
claudeWrite(
|
||||
() => clientRef.current.api(`/claude/connectors/${id}`, { method: "PATCH", body: b }),
|
||||
"connector updated — applies to the next agent launch",
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const deleteClaudeConnector = useCallback(
|
||||
async (id: number) => {
|
||||
await claudeWrite(
|
||||
() => clientRef.current.api(`/claude/connectors/${id}`, { method: "DELETE" }),
|
||||
"connector removed — applies to the next agent launch",
|
||||
);
|
||||
},
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const createClaudePlugin = useCallback(
|
||||
(b: PluginBody) =>
|
||||
claudeWrite(
|
||||
() =>
|
||||
clientRef.current.api("/claude/plugins", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: b.name.trim(),
|
||||
marketplace: b.marketplace.trim(),
|
||||
marketplace_repo: b.marketplace_repo.trim(),
|
||||
enabled: b.enabled,
|
||||
},
|
||||
}),
|
||||
`plugin '${b.name.trim()}' saved — installs on the next agent launch`,
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const updateClaudePlugin = useCallback(
|
||||
(id: number, b: Partial<PluginBody>) =>
|
||||
claudeWrite(
|
||||
() => clientRef.current.api(`/claude/plugins/${id}`, { method: "PATCH", body: b }),
|
||||
"plugin updated — applies to the next agent launch",
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const deleteClaudePlugin = useCallback(
|
||||
async (id: number) => {
|
||||
await claudeWrite(
|
||||
() => clientRef.current.api(`/claude/plugins/${id}`, { method: "DELETE" }),
|
||||
"plugin removed — applies to the next agent launch",
|
||||
);
|
||||
},
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const saveClaudePermissions = useCallback(
|
||||
(b: PermissionsBody) =>
|
||||
claudeWrite(
|
||||
() => clientRef.current.api("/claude/permissions", { method: "PUT", body: b }),
|
||||
"permissions saved — apply to the next agent launch",
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const setSharedKey = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
try {
|
||||
@@ -997,6 +1203,20 @@ export function DashboardProvider({
|
||||
startClaudeLogin,
|
||||
submitClaudeCode,
|
||||
resetClaudeLogin,
|
||||
claudeSkills,
|
||||
claudeConnectors,
|
||||
claudePlugins,
|
||||
claudePermissions,
|
||||
createClaudeSkill,
|
||||
updateClaudeSkill,
|
||||
deleteClaudeSkill,
|
||||
createClaudeConnector,
|
||||
updateClaudeConnector,
|
||||
deleteClaudeConnector,
|
||||
createClaudePlugin,
|
||||
updateClaudePlugin,
|
||||
deleteClaudePlugin,
|
||||
saveClaudePermissions,
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -140,6 +140,52 @@ export interface Schedule {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* ---- Claude management (the dashboard's Claude page) ---- */
|
||||
|
||||
export interface ClaudeSkill {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type McpTransport = "stdio" | "http" | "sse";
|
||||
|
||||
export interface ClaudeConnector {
|
||||
id: number;
|
||||
name: string;
|
||||
transport: McpTransport;
|
||||
command?: string | null;
|
||||
args?: string[] | null;
|
||||
env?: Record<string, string> | null;
|
||||
url?: string | null;
|
||||
headers?: Record<string, string> | null;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ClaudePlugin {
|
||||
id: number;
|
||||
name: string;
|
||||
marketplace: string;
|
||||
marketplace_repo: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* Stored overrides + the env baseline they merge over at launch (read-only here). */
|
||||
export interface ClaudePermissions {
|
||||
default_mode?: string | null;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
ask: string[];
|
||||
base_mode: string;
|
||||
base_allow: string[];
|
||||
}
|
||||
|
||||
export interface SharedContext {
|
||||
key: string;
|
||||
value: string;
|
||||
|
||||
+5
-2
@@ -18,12 +18,15 @@ export const NAV_ROUTES: NavRoute[] = [
|
||||
{ key: "servers", href: "/servers", label: "Git Servers" },
|
||||
{ key: "activity", href: "/activity", label: "Activity" },
|
||||
{ key: "shared", href: "/shared", label: "Shared" },
|
||||
{ key: "login", href: "/login", label: "Claude Login" },
|
||||
{ key: "claude", href: "/claude", label: "Claude" },
|
||||
];
|
||||
|
||||
/* Map a browser path back to its section key. Trailing slashes (Next emits them under
|
||||
* `trailingSlash: true`) are normalized away; anything unrecognized falls back to Runs. */
|
||||
* `trailingSlash: true`) are normalized away; anything unrecognized falls back to Runs.
|
||||
* "/login" still maps to the Claude page — the login flow moved into it, and the old
|
||||
* route redirects there. */
|
||||
export function sectionFromPath(pathname: string): Section {
|
||||
const clean = pathname.replace(/\/+$/, "") || "/";
|
||||
if (clean === "/login") return "claude";
|
||||
return NAV_ROUTES.find((r) => r.href === clean)?.key ?? "runs";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user