mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-31 09:26:25 +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:
@@ -252,22 +252,30 @@ What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`)
|
||||
- **Activity** — every enqueued command with its status (queued → running → done/failed) —
|
||||
the audit log of what the dashboard triggered. The UI polls `GET /commands/{id}` for
|
||||
live status.
|
||||
- **Claude Login** — log Claude Code in on the host from the browser (see below), so agents
|
||||
spawn against a real authenticated `claude` with no shell access to the container.
|
||||
- **Claude** — the management page for the Claude Code install agents run on. The account
|
||||
login lives here (see below), plus web-managed **skills**, **MCP connectors**,
|
||||
**plugins**, and **permission overrides**. These are plain DB rows the control container
|
||||
applies at every launch: skills sync to each worker's user-level `~/.claude/skills`
|
||||
(marker-file managed, so hand-installed skills survive), enabled connectors become the
|
||||
run's `--mcp-config` file (nothing lands in the repo tree), and plugins/permissions fold
|
||||
into the generated per-agent `settings.json` — so a change in the UI reaches the next
|
||||
launch of every agent, no redeploy.
|
||||
|
||||
The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/agents/{n}/kill`,
|
||||
`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, `POST …/sync`,
|
||||
`POST /login/start`, `POST /login/submit`, and `GET /commands[/{id}]`; hosts as `/hosts`;
|
||||
schedules as `/schedules` + `/projects/{id}/schedules`; project mutation as
|
||||
`PATCH`/`DELETE /projects/{id}`. Run the worker with `handler worker` (the control image's
|
||||
default command).
|
||||
`PATCH`/`DELETE /projects/{id}`; Claude management as `/claude/skills`,
|
||||
`/claude/connectors`, `/claude/plugins` (CRUD), and `GET`/`PUT /claude/permissions`
|
||||
(reads with the normal token, writes admin-gated). Run the worker with `handler worker`
|
||||
(the control image's default command).
|
||||
|
||||
### Claude login from the web UI
|
||||
|
||||
Agents *are* `claude` processes, so the control container needs a logged-in Claude Code.
|
||||
Because that container has no interactive shell in normal operation, the **Claude Login**
|
||||
pane logs it in from the browser — the same command-queue handoff every other control
|
||||
action uses:
|
||||
Because that container has no interactive shell in normal operation, the **Claude** page's
|
||||
Account tab logs it in from the browser — the same command-queue handoff every other
|
||||
control action uses:
|
||||
|
||||
1. **Log in to Claude** enqueues a `login_start` command. The worker opens `claude` in a
|
||||
dedicated (wide) tmux session in the control container, navigates whatever onboarding a
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..config import get_settings
|
||||
from .routes import (
|
||||
agents,
|
||||
approvals,
|
||||
claude,
|
||||
commands,
|
||||
hosts,
|
||||
interaction,
|
||||
@@ -48,6 +49,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(approvals.router)
|
||||
app.include_router(commands.router)
|
||||
app.include_router(login.router)
|
||||
app.include_router(claude.router)
|
||||
app.include_router(hosts.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(shared.router)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Claude management: skills, MCP connectors, plugins, and permission overrides.
|
||||
|
||||
The dashboard's Claude page edits these rows directly — no worker round-trip, because
|
||||
nothing here touches a live process. The control container reads the same tables at
|
||||
every launch: skills sync to the worker's user-level ``~/.claude/skills``, connectors
|
||||
become the run's ``--mcp-config`` file, and plugins/permissions fold into the generated
|
||||
per-agent ``settings.json`` (``control.settings_gen`` / ``control.claude_gen``). Changes
|
||||
therefore apply to the *next* launch of every agent, not to runs already in flight.
|
||||
|
||||
Reads take the normal token; writes take the admin token (they shape what every agent
|
||||
is allowed to do). The login flow stays under ``/login`` — it needs the worker's tmux.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from ...config import get_settings
|
||||
from ...db import repository as repo
|
||||
from ..deps import db_conn, require_admin, require_auth
|
||||
from ..schemas import (
|
||||
ClaudeConnectorIn,
|
||||
ClaudeConnectorOut,
|
||||
ClaudeConnectorUpdateIn,
|
||||
ClaudePermissionsIn,
|
||||
ClaudePermissionsOut,
|
||||
ClaudePluginIn,
|
||||
ClaudePluginOut,
|
||||
ClaudePluginUpdateIn,
|
||||
ClaudeSkillIn,
|
||||
ClaudeSkillOut,
|
||||
ClaudeSkillUpdateIn,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/claude", tags=["claude"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
# ---- skills ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _skill_or_404(conn: Connection, skill_id: int) -> dict:
|
||||
skill = repo.get_claude_skill(conn, skill_id)
|
||||
if skill is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"skill {skill_id} not found")
|
||||
return skill
|
||||
|
||||
|
||||
@router.get("/skills", response_model=list[ClaudeSkillOut])
|
||||
def list_skills(conn: Connection = Depends(db_conn)) -> list[dict]:
|
||||
return repo.list_claude_skills(conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills",
|
||||
response_model=ClaudeSkillOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def create_skill(body: ClaudeSkillIn, conn: Connection = Depends(db_conn)) -> dict:
|
||||
if repo.get_claude_skill_by_name(conn, body.name) is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"skill '{body.name}' exists")
|
||||
return repo.create_claude_skill(
|
||||
conn, body.name, body.content, description=body.description, enabled=body.enabled
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/skills/{skill_id}", response_model=ClaudeSkillOut, dependencies=[Depends(require_admin)]
|
||||
)
|
||||
def update_skill(
|
||||
skill_id: int, body: ClaudeSkillUpdateIn, conn: Connection = Depends(db_conn)
|
||||
) -> dict:
|
||||
_skill_or_404(conn, skill_id)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
if "name" in fields:
|
||||
clash = repo.get_claude_skill_by_name(conn, fields["name"])
|
||||
if clash is not None and clash["id"] != skill_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, detail=f"skill '{fields['name']}' exists"
|
||||
)
|
||||
return repo.update_claude_skill(conn, skill_id, **fields)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}", dependencies=[Depends(require_admin)])
|
||||
def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict:
|
||||
skill = _skill_or_404(conn, skill_id)
|
||||
repo.delete_claude_skill(conn, skill_id)
|
||||
return {"deleted": skill["name"]}
|
||||
|
||||
|
||||
# ---- connectors (MCP servers) ---------------------------------------------------------
|
||||
|
||||
|
||||
def _connector_or_404(conn: Connection, connector_id: int) -> dict:
|
||||
connector = repo.get_claude_connector(conn, connector_id)
|
||||
if connector is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, detail=f"connector {connector_id} not found"
|
||||
)
|
||||
return connector
|
||||
|
||||
|
||||
@router.get("/connectors", response_model=list[ClaudeConnectorOut])
|
||||
def list_connectors(conn: Connection = Depends(db_conn)) -> list[dict]:
|
||||
return repo.list_claude_connectors(conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/connectors",
|
||||
response_model=ClaudeConnectorOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def create_connector(body: ClaudeConnectorIn, conn: Connection = Depends(db_conn)) -> dict:
|
||||
if repo.get_claude_connector_by_name(conn, body.name) is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"connector '{body.name}' exists")
|
||||
return repo.create_claude_connector(
|
||||
conn,
|
||||
body.name,
|
||||
body.transport,
|
||||
command=body.command,
|
||||
args=body.args,
|
||||
env=body.env,
|
||||
url=body.url,
|
||||
headers=body.headers,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/connectors/{connector_id}",
|
||||
response_model=ClaudeConnectorOut,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def update_connector(
|
||||
connector_id: int, body: ClaudeConnectorUpdateIn, conn: Connection = Depends(db_conn)
|
||||
) -> dict:
|
||||
current = _connector_or_404(conn, connector_id)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
if "name" in fields:
|
||||
clash = repo.get_claude_connector_by_name(conn, fields["name"])
|
||||
if clash is not None and clash["id"] != connector_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, detail=f"connector '{fields['name']}' exists"
|
||||
)
|
||||
# Re-check the transport/field pairing against the merged row, so a PATCH can't
|
||||
# produce a stdio connector without a command or an http one without a url.
|
||||
merged = {**current, **fields}
|
||||
if merged["transport"] == "stdio":
|
||||
if not (merged.get("command") or "").strip():
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, detail="a stdio connector needs a command"
|
||||
)
|
||||
elif not (merged.get("url") or "").strip().startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"an {merged['transport']} connector needs an http(s) url",
|
||||
)
|
||||
return repo.update_claude_connector(conn, connector_id, **fields)
|
||||
|
||||
|
||||
@router.delete("/connectors/{connector_id}", dependencies=[Depends(require_admin)])
|
||||
def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) -> dict:
|
||||
connector = _connector_or_404(conn, connector_id)
|
||||
repo.delete_claude_connector(conn, connector_id)
|
||||
return {"deleted": connector["name"]}
|
||||
|
||||
|
||||
# ---- plugins --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plugin_or_404(conn: Connection, plugin_id: int) -> dict:
|
||||
plugin = repo.get_claude_plugin(conn, plugin_id)
|
||||
if plugin is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"plugin {plugin_id} not found")
|
||||
return plugin
|
||||
|
||||
|
||||
@router.get("/plugins", response_model=list[ClaudePluginOut])
|
||||
def list_plugins(conn: Connection = Depends(db_conn)) -> list[dict]:
|
||||
return repo.list_claude_plugins(conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plugins",
|
||||
response_model=ClaudePluginOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def create_plugin(body: ClaudePluginIn, conn: Connection = Depends(db_conn)) -> dict:
|
||||
if repo.get_claude_plugin_by_key(conn, body.name, body.marketplace) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
detail=f"plugin '{body.name}@{body.marketplace}' exists",
|
||||
)
|
||||
return repo.create_claude_plugin(
|
||||
conn, body.name, body.marketplace, body.marketplace_repo, enabled=body.enabled
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/plugins/{plugin_id}", response_model=ClaudePluginOut, dependencies=[Depends(require_admin)]
|
||||
)
|
||||
def update_plugin(
|
||||
plugin_id: int, body: ClaudePluginUpdateIn, conn: Connection = Depends(db_conn)
|
||||
) -> dict:
|
||||
current = _plugin_or_404(conn, plugin_id)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
if "name" in fields or "marketplace" in fields:
|
||||
merged = {**current, **fields}
|
||||
clash = repo.get_claude_plugin_by_key(conn, merged["name"], merged["marketplace"])
|
||||
if clash is not None and clash["id"] != plugin_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
detail=f"plugin '{merged['name']}@{merged['marketplace']}' exists",
|
||||
)
|
||||
return repo.update_claude_plugin(conn, plugin_id, **fields)
|
||||
|
||||
|
||||
@router.delete("/plugins/{plugin_id}", dependencies=[Depends(require_admin)])
|
||||
def delete_plugin(plugin_id: int, conn: Connection = Depends(db_conn)) -> dict:
|
||||
plugin = _plugin_or_404(conn, plugin_id)
|
||||
repo.delete_claude_plugin(conn, plugin_id)
|
||||
return {"deleted": f"{plugin['name']}@{plugin['marketplace']}"}
|
||||
|
||||
|
||||
# ---- permissions ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _permissions_out(stored: dict | None) -> dict:
|
||||
s = get_settings()
|
||||
stored = stored or {}
|
||||
return {
|
||||
"default_mode": stored.get("default_mode"),
|
||||
"allow": stored.get("allow", []),
|
||||
"deny": stored.get("deny", []),
|
||||
"ask": stored.get("ask", []),
|
||||
"base_mode": s.headless_permission_mode,
|
||||
"base_allow": s.headless_allowed_tools_list,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=ClaudePermissionsOut)
|
||||
def get_permissions(conn: Connection = Depends(db_conn)) -> dict:
|
||||
return _permissions_out(repo.get_claude_config(conn, "permissions"))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/permissions",
|
||||
response_model=ClaudePermissionsOut,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def put_permissions(body: ClaudePermissionsIn, conn: Connection = Depends(db_conn)) -> dict:
|
||||
stored = {
|
||||
"default_mode": body.default_mode,
|
||||
"allow": [r.strip() for r in body.allow if r.strip()],
|
||||
"deny": [r.strip() for r in body.deny if r.strip()],
|
||||
"ask": [r.strip() for r in body.ask if r.strip()],
|
||||
}
|
||||
repo.set_claude_config(conn, "permissions", stored)
|
||||
return _permissions_out(stored)
|
||||
@@ -351,6 +351,165 @@ class ResumeOut(BaseModel):
|
||||
detail: str
|
||||
|
||||
|
||||
# ---- Claude management (dashboard Claude page) -----------------------------------------
|
||||
|
||||
# Names become filesystem dirnames (skills) and JSON object keys (connectors,
|
||||
# marketplaces), so keep them to a safe slug — no separators, no dot-prefix.
|
||||
_SLUG_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
|
||||
# settings.json permissions.defaultMode values claude accepts.
|
||||
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
||||
McpTransport = Literal["stdio", "http", "sse"]
|
||||
|
||||
# "owner/repo" marketplaces resolve as GitHub sources; anything else must be a git URL.
|
||||
_GIT_URL_RE = re.compile(r"^(https?://|git@|ssh://)")
|
||||
|
||||
|
||||
class ClaudeSkillIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
|
||||
description: str | None = None
|
||||
content: str = Field(min_length=1)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ClaudeSkillUpdateIn(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN)
|
||||
description: str | None = None
|
||||
content: str | None = Field(default=None, min_length=1)
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ClaudeSkillOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
content: str
|
||||
enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ClaudeConnectorIn(BaseModel):
|
||||
"""An MCP server agents may reach: ``stdio`` runs ``command`` in the control
|
||||
container, ``http``/``sse`` point at ``url``. Written per-launch as the run's
|
||||
``--mcp-config`` file."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
|
||||
transport: McpTransport = "stdio"
|
||||
command: str | None = None
|
||||
args: list[str] = Field(default_factory=list)
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
url: str | None = None
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_transport_fields(self) -> ClaudeConnectorIn:
|
||||
if self.transport == "stdio":
|
||||
if not (self.command or "").strip():
|
||||
raise ValueError("a stdio connector needs a command")
|
||||
elif not (self.url or "").strip() or not self.url.strip().startswith(
|
||||
("http://", "https://")
|
||||
):
|
||||
raise ValueError(f"an {self.transport} connector needs an http(s) url")
|
||||
return self
|
||||
|
||||
|
||||
class ClaudeConnectorUpdateIn(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN)
|
||||
transport: McpTransport | None = None
|
||||
command: str | None = None
|
||||
args: list[str] | None = None
|
||||
env: dict[str, str] | None = None
|
||||
url: str | None = None
|
||||
headers: dict[str, str] | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ClaudeConnectorOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
transport: str
|
||||
command: str | None = None
|
||||
args: list[str] | None = None
|
||||
env: dict[str, str] | None = None
|
||||
url: str | None = None
|
||||
headers: dict[str, str] | None = None
|
||||
enabled: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ClaudePluginIn(BaseModel):
|
||||
"""A plugin pinned to the marketplace serving it. ``marketplace_repo`` is
|
||||
``owner/repo`` (GitHub) or a git URL; generated settings carry it as an extra known
|
||||
marketplace with the plugin enabled, so headless runs install it on boot."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
|
||||
marketplace: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
|
||||
marketplace_repo: str = Field(min_length=1)
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator("marketplace_repo")
|
||||
@classmethod
|
||||
def _check_repo(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not (_REPO_RE.match(v) or _GIT_URL_RE.match(v)):
|
||||
raise ValueError("marketplace_repo must be owner/repo or a git URL")
|
||||
return v
|
||||
|
||||
|
||||
class ClaudePluginUpdateIn(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN)
|
||||
marketplace: str | None = Field(
|
||||
default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN
|
||||
)
|
||||
marketplace_repo: str | None = Field(default=None, min_length=1)
|
||||
enabled: bool | None = None
|
||||
|
||||
@field_validator("marketplace_repo")
|
||||
@classmethod
|
||||
def _check_repo(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not (_REPO_RE.match(v) or _GIT_URL_RE.match(v)):
|
||||
raise ValueError("marketplace_repo must be owner/repo or a git URL")
|
||||
return v
|
||||
|
||||
|
||||
class ClaudePluginOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
marketplace: str
|
||||
marketplace_repo: str
|
||||
enabled: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ClaudePermissionsIn(BaseModel):
|
||||
"""The operator's permission overrides, merged over the env-configured baseline at
|
||||
launch: ``default_mode`` (null = keep the baseline) plus extra allow/deny/ask rules."""
|
||||
|
||||
default_mode: PermissionMode | None = None
|
||||
allow: list[str] = Field(default_factory=list)
|
||||
deny: list[str] = Field(default_factory=list)
|
||||
ask: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ClaudePermissionsOut(ClaudePermissionsIn):
|
||||
"""Stored overrides plus the env baseline they merge over, so the UI can show the
|
||||
effective policy without guessing at server config."""
|
||||
|
||||
base_mode: str
|
||||
base_allow: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SharedContextIn(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{3471:function(e,a,n){Promise.resolve().then(n.t.bind(n,7960,23)),Promise.resolve().then(n.bind(n,5520))},5520:function(e,a,n){"use strict";n.d(a,{AppFrame:function(){return p}});var t=n(7437),s=n(2265),l=n(9376),r=n(171),o=n(7648);let i=[{key:"runs",href:"/",label:"Runs"},{key:"repositories",href:"/repositories",label:"Repositories"},{key:"agents",href:"/agents",label:"Agents"},{key:"schedules",href:"/schedules",label:"Schedules"},{key:"approvals",href:"/approvals",label:"Approvals"},{key:"servers",href:"/servers",label:"Git Servers"},{key:"activity",href:"/activity",label:"Activity"},{key:"shared",href:"/shared",label:"Shared"},{key:"claude",href:"/claude",label:"Claude"}];function c(e){var a,n;let t=e.replace(/\/+$/,"")||"/";return"/login"===t?"claude":null!==(n=null===(a=i.find(e=>e.href===t))||void 0===a?void 0:a.key)&&void 0!==n?n:"runs"}let u={runs:{count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},repositories:{count:e=>e.projects.length},agents:{count:e=>e.agents.length},schedules:{count:e=>e.schedules.length},approvals:{count:e=>e.approvals.length},servers:{count:e=>e.hosts.length},activity:{count:e=>e.commands.length},shared:{count:e=>e.shared.context.length},claude:{count:e=>e.claudeSkills.length+e.claudeConnectors.length+e.claudePlugins.length,accent:e=>"done"!==e.claudeLogin.status}};function d(e){let{onSignOut:a,children:n}=e,d=(0,r.Q)(),h=c((0,l.usePathname)()),{setSection:m}=d;return(0,s.useEffect)(()=>{m(h)},[h,m]),(0,t.jsxs)("div",{className:"app",children:[(0,t.jsxs)("aside",{className:"sidebar",children:[(0,t.jsxs)("div",{className:"brand",children:[(0,t.jsx)("span",{className:"logo"}),"Claude Monitor"]}),i.map(e=>{var a,n,s;let l=u[e.key],r=null!==(n=null==l?void 0:l.count(d))&&void 0!==n?n:0,i=null!==(s=null==l?void 0:null===(a=l.accent)||void 0===a?void 0:a.call(l,d))&&void 0!==s&&s;return(0,t.jsxs)(o.default,{href:e.href,className:"nav-item".concat(h===e.key?" active":""),children:[(0,t.jsx)("span",{children:e.label}),(0,t.jsx)("span",{className:"count",style:i?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,t.jsx)("div",{className:"sidebar-spacer"}),(0,t.jsxs)("div",{className:"sidebar-foot",children:[(0,t.jsxs)("button",{className:"nav-item",onClick:d.refresh,title:"Refresh now",children:[(0,t.jsx)("span",{children:"Refresh"}),(0,t.jsx)("span",{className:"count",children:"↻"})]}),(0,t.jsx)("button",{className:"nav-item",onClick:a,title:"Sign out / change token",children:(0,t.jsx)("span",{children:"Sign out"})})]})]}),(0,t.jsxs)("main",{className:"main",children:[d.cmd.text&&(0,t.jsx)("p",{className:"banner ".concat(d.cmd.error?"err":"ok"),style:{marginTop:16},children:d.cmd.text}),d.lastError&&(0,t.jsx)("p",{className:"banner err",style:{marginTop:12},children:d.lastError}),n]})]})}function h(e){let{error:a,onSubmit:n}=e,[l,r]=(0,s.useState)("");return(0,t.jsx)("div",{className:"gate",children:(0,t.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let a=l.trim();a&&n(a)},children:[(0,t.jsxs)("div",{className:"gate-brand",children:[(0,t.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,t.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,t.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:l,onChange:e=>r(e.target.value),autoFocus:!0}),a&&(0,t.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:a}),(0,t.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let m="handler_token";function p(e){let{children:a}=e,[n,o]=(0,s.useState)(null),[i,u]=(0,s.useState)(""),p=(0,l.usePathname)();(0,s.useEffect)(()=>{let e=window.localStorage.getItem(m);e&&o(e)},[]);let g=(0,s.useCallback)(e=>{window.localStorage.setItem(m,e),u(""),o(e)},[]),v=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null)},[]),f=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null),u("Invalid token — please try again.")},[]);return n?(0,t.jsx)(r._,{token:n,onUnauthorized:f,initialSection:c(p),children:(0,t.jsx)(d,{onSignOut:v,children:a})}):(0,t.jsx)(h,{error:i,onSubmit:g})}},7960:function(){}},function(e){e.O(0,[587,258,171,971,117,744],function(){return e(e.s=3471)}),_N_E=e.O()}]);
|
||||
@@ -1 +0,0 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{1072:function(e,n,s){Promise.resolve().then(s.t.bind(s,7960,23)),Promise.resolve().then(s.bind(s,5520))},5520:function(e,n,s){"use strict";s.d(n,{AppFrame:function(){return p}});var t=s(7437),a=s(2265),l=s(9376),r=s(171),o=s(7648);let i=[{key:"runs",href:"/",label:"Runs"},{key:"repositories",href:"/repositories",label:"Repositories"},{key:"agents",href:"/agents",label:"Agents"},{key:"schedules",href:"/schedules",label:"Schedules"},{key:"approvals",href:"/approvals",label:"Approvals"},{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"}];function c(e){var n,s;let t=e.replace(/\/+$/,"")||"/";return null!==(s=null===(n=i.find(e=>e.href===t))||void 0===n?void 0:n.key)&&void 0!==s?s:"runs"}let u={runs:{count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},repositories:{count:e=>e.projects.length},agents:{count:e=>e.agents.length},schedules:{count:e=>e.schedules.length},approvals:{count:e=>e.approvals.length},servers:{count:e=>e.hosts.length},activity:{count:e=>e.commands.length},shared:{count:e=>e.shared.context.length},login:{count:()=>0,accent:e=>"done"!==e.claudeLogin.status}};function d(e){let{onSignOut:n,children:s}=e,d=(0,r.Q)(),h=c((0,l.usePathname)()),{setSection:m}=d;return(0,a.useEffect)(()=>{m(h)},[h,m]),(0,t.jsxs)("div",{className:"app",children:[(0,t.jsxs)("aside",{className:"sidebar",children:[(0,t.jsxs)("div",{className:"brand",children:[(0,t.jsx)("span",{className:"logo"}),"Claude Monitor"]}),i.map(e=>{var n,s,a;let l=u[e.key],r=null!==(s=null==l?void 0:l.count(d))&&void 0!==s?s:0,i=null!==(a=null==l?void 0:null===(n=l.accent)||void 0===n?void 0:n.call(l,d))&&void 0!==a&&a;return(0,t.jsxs)(o.default,{href:e.href,className:"nav-item".concat(h===e.key?" active":""),children:[(0,t.jsx)("span",{children:e.label}),(0,t.jsx)("span",{className:"count",style:i?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,t.jsx)("div",{className:"sidebar-spacer"}),(0,t.jsxs)("div",{className:"sidebar-foot",children:[(0,t.jsxs)("button",{className:"nav-item",onClick:d.refresh,title:"Refresh now",children:[(0,t.jsx)("span",{children:"Refresh"}),(0,t.jsx)("span",{className:"count",children:"↻"})]}),(0,t.jsx)("button",{className:"nav-item",onClick:n,title:"Sign out / change token",children:(0,t.jsx)("span",{children:"Sign out"})})]})]}),(0,t.jsxs)("main",{className:"main",children:[d.cmd.text&&(0,t.jsx)("p",{className:"banner ".concat(d.cmd.error?"err":"ok"),style:{marginTop:16},children:d.cmd.text}),d.lastError&&(0,t.jsx)("p",{className:"banner err",style:{marginTop:12},children:d.lastError}),s]})]})}function h(e){let{error:n,onSubmit:s}=e,[l,r]=(0,a.useState)("");return(0,t.jsx)("div",{className:"gate",children:(0,t.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let n=l.trim();n&&s(n)},children:[(0,t.jsxs)("div",{className:"gate-brand",children:[(0,t.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,t.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,t.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:l,onChange:e=>r(e.target.value),autoFocus:!0}),n&&(0,t.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:n}),(0,t.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let m="handler_token";function p(e){let{children:n}=e,[s,o]=(0,a.useState)(null),[i,u]=(0,a.useState)(""),p=(0,l.usePathname)();(0,a.useEffect)(()=>{let e=window.localStorage.getItem(m);e&&o(e)},[]);let v=(0,a.useCallback)(e=>{window.localStorage.setItem(m,e),u(""),o(e)},[]),g=(0,a.useCallback)(()=>{window.localStorage.removeItem(m),o(null)},[]),f=(0,a.useCallback)(()=>{window.localStorage.removeItem(m),o(null),u("Invalid token — please try again.")},[]);return s?(0,t.jsx)(r._,{token:s,onUnauthorized:f,initialSection:c(p),children:(0,t.jsx)(d,{onSignOut:g,children:n})}):(0,t.jsx)(h,{error:i,onSubmit:v})}},7960:function(){}},function(e){e.O(0,[587,258,171,971,117,744],function(){return e(e.s=1072)}),_N_E=e.O()}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[626],{6258:function(e,u,n){Promise.resolve().then(n.bind(n,6374))},6374:function(e,u,n){"use strict";n.r(u),n.d(u,{default:function(){return s}});var t=n(2265),r=n(9376);function s(){let e=(0,r.useRouter)();return(0,t.useEffect)(()=>{e.replace("/claude")},[e]),null}},9376:function(e,u,n){"use strict";var t=n(5475);n.o(t,"usePathname")&&n.d(u,{usePathname:function(){return t.usePathname}}),n.o(t,"useRouter")&&n.d(u,{useRouter:function(){return t.useRouter}})}},function(e){e.O(0,[971,117,744],function(){return e(e.s=6258)}),_N_E=e.O()}]);
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{2730:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(2730)}),_N_E=e.O()}]);
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{6994:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(6994)}),_N_E=e.O()}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[7839,["171","static/chunks/171-ba6c418b542791c8.js","263","static/chunks/app/activity/page-7be158338498be57.js"],"default",1]
|
||||
3:I[7839,["171","static/chunks/171-58fbf67bb7636c62.js","263","static/chunks/app/activity/page-809b415dd18cb14e.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["activity",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["activity",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","activity","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["activity",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["activity",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","activity","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[2506,["171","static/chunks/171-ba6c418b542791c8.js","718","static/chunks/app/agents/page-4ae3a1f91119de97.js"],"default",1]
|
||||
3:I[2506,["171","static/chunks/171-58fbf67bb7636c62.js","718","static/chunks/app/agents/page-ed5717b0ac0347b8.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["agents",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["agents",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","agents","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["agents",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["agents",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","agents","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[2651,["171","static/chunks/171-ba6c418b542791c8.js","163","static/chunks/app/approvals/page-423d906e199db97b.js"],"default",1]
|
||||
3:I[2651,["171","static/chunks/171-58fbf67bb7636c62.js","163","static/chunks/app/approvals/page-8d5622be330f4d01.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["approvals",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["approvals",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","approvals","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["approvals",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["approvals",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","approvals","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[6529,["171","static/chunks/171-58fbf67bb7636c62.js","877","static/chunks/app/claude/page-47f8d9b94703c22a.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["claude",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["claude",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","claude","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[3807,["171","static/chunks/171-ba6c418b542791c8.js","931","static/chunks/app/page-52e9c359249c3b73.js"],"default",1]
|
||||
4:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
3:I[3807,["171","static/chunks/171-58fbf67bb7636c62.js","931","static/chunks/app/page-cf68f34e95b90a40.js"],"default",1]
|
||||
4:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
5:I[4707,[],""]
|
||||
6:I[6423,[],""]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[9347,["171","static/chunks/171-ba6c418b542791c8.js","626","static/chunks/app/login/page-2f7fdd148631e2b5.js"],"default",1]
|
||||
3:I[6374,["626","static/chunks/app/login/page-b08c6695be5632dd.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[3641,["171","static/chunks/171-ba6c418b542791c8.js","27","static/chunks/app/repositories/page-5820d64699ff6a87.js"],"default",1]
|
||||
3:I[3641,["171","static/chunks/171-58fbf67bb7636c62.js","27","static/chunks/app/repositories/page-fb0c29b8dc7ed817.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["repositories",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["repositories",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","repositories","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["repositories",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["repositories",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","repositories","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[5124,["171","static/chunks/171-ba6c418b542791c8.js","95","static/chunks/app/schedules/page-93cc8b9829ad18f2.js"],"default",1]
|
||||
3:I[5124,["171","static/chunks/171-58fbf67bb7636c62.js","95","static/chunks/app/schedules/page-db675df274be2677.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["schedules",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["schedules",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","schedules","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["schedules",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["schedules",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","schedules","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[4646,["171","static/chunks/171-ba6c418b542791c8.js","664","static/chunks/app/servers/page-874e20e9ad86bc3f.js"],"default",1]
|
||||
3:I[4646,["171","static/chunks/171-58fbf67bb7636c62.js","664","static/chunks/app/servers/page-7cfc66d88412e3d1.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["servers",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["servers",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","servers","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["servers",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["servers",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","servers","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[9475,["171","static/chunks/171-ba6c418b542791c8.js","856","static/chunks/app/shared/page-e449c1a6ddda32bf.js"],"default",1]
|
||||
3:I[9475,["171","static/chunks/171-58fbf67bb7636c62.js","856","static/chunks/app/shared/page-dc7068fcf86ab8e4.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
|
||||
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"children":["shared",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shared",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shared","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
|
||||
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["shared",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shared",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shared","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
|
||||
1:null
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Materialize the web-managed Claude config for a launch: MCP connectors and skills.
|
||||
|
||||
The dashboard's Claude page edits database rows; this module is where those rows become
|
||||
real files the ``claude`` process reads, applied by ``control.spawn`` before every
|
||||
launch (spawn and resume both), so a change in the web UI reaches the next run of every
|
||||
agent:
|
||||
|
||||
- **Connectors** become ``<working_dir>/.claude/mcp-servers.json`` — passed to claude as
|
||||
``--mcp-config`` (see ``control.headless``), so nothing lands in the managed repo's
|
||||
tracked tree and a repo's own committed ``.mcp.json`` is never touched.
|
||||
- **Skills** sync to the user-level ``~/.claude/skills/`` of the worker container that
|
||||
runs the agent. Each managed skill dir carries a ``.handler-managed`` marker so the
|
||||
sync can delete skills removed in the UI without ever touching skills someone
|
||||
installed by hand (or the forge role skills committed into repos by ``skills_gen``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from ..db import repository as repo
|
||||
from ..db.engine import connection
|
||||
|
||||
MCP_CONFIG_RELPATH = os.path.join(".claude", "mcp-servers.json")
|
||||
_MANAGED_MARKER = ".handler-managed"
|
||||
|
||||
|
||||
def mcp_config_path(working_dir: str) -> str:
|
||||
return os.path.join(working_dir, MCP_CONFIG_RELPATH)
|
||||
|
||||
|
||||
def _server_entry(connector: dict) -> dict:
|
||||
"""One ``mcpServers`` value in claude's mcp-config shape."""
|
||||
if connector["transport"] == "stdio":
|
||||
entry: dict = {"command": connector["command"]}
|
||||
if connector.get("args"):
|
||||
entry["args"] = list(connector["args"])
|
||||
if connector.get("env"):
|
||||
entry["env"] = dict(connector["env"])
|
||||
return entry
|
||||
entry = {"type": connector["transport"], "url": connector["url"]}
|
||||
if connector.get("headers"):
|
||||
entry["headers"] = dict(connector["headers"])
|
||||
return entry
|
||||
|
||||
|
||||
def write_mcp_config(working_dir: str, connectors: list[dict]) -> str | None:
|
||||
"""Write the launch's ``--mcp-config`` file from the enabled connectors; return its
|
||||
path, or None (removing any previous one) when no connectors are enabled. The file
|
||||
lives under ``.claude/`` next to the generated ``settings.json``, and is regenerated
|
||||
every launch — deleting a connector in the UI deletes it here too."""
|
||||
path = mcp_config_path(working_dir)
|
||||
if not connectors:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return None
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
config = {"mcpServers": {c["name"]: _server_entry(c) for c in connectors}}
|
||||
with open(path, "w") as fh:
|
||||
json.dump(config, fh, indent=2)
|
||||
return path
|
||||
|
||||
|
||||
def _skills_root(home: str | None = None) -> str:
|
||||
return os.path.join(home or os.path.expanduser("~"), ".claude", "skills")
|
||||
|
||||
|
||||
def sync_user_skills(skills: list[dict], home: str | None = None) -> list[str]:
|
||||
"""Sync the enabled web-managed skills into the user-level skills dir; return the
|
||||
written SKILL.md paths.
|
||||
|
||||
Only dirs carrying the ``.handler-managed`` marker are ever deleted, so hand-installed
|
||||
skills survive; a managed skill disabled or deleted in the UI disappears on the next
|
||||
sync. Front-matter ``name``/``description`` come from the row; the body is the
|
||||
operator's markdown verbatim."""
|
||||
root = _skills_root(home)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
|
||||
keep = {s["name"] for s in skills}
|
||||
for entry in os.listdir(root):
|
||||
dir_path = os.path.join(root, entry)
|
||||
if entry not in keep and os.path.isfile(os.path.join(dir_path, _MANAGED_MARKER)):
|
||||
shutil.rmtree(dir_path, ignore_errors=True)
|
||||
|
||||
written: list[str] = []
|
||||
for skill in skills:
|
||||
skill_dir = os.path.join(root, skill["name"])
|
||||
os.makedirs(skill_dir, exist_ok=True)
|
||||
description = (skill.get("description") or skill["name"]).replace("\n", " ")
|
||||
front = f"---\nname: {skill['name']}\ndescription: {description}\n---\n\n"
|
||||
body = skill["content"]
|
||||
if not body.endswith("\n"):
|
||||
body += "\n"
|
||||
path = os.path.join(skill_dir, "SKILL.md")
|
||||
with open(path, "w") as fh:
|
||||
fh.write(front + body)
|
||||
with open(os.path.join(skill_dir, _MANAGED_MARKER), "w") as fh:
|
||||
fh.write("managed by handler — edits here are overwritten at every launch\n")
|
||||
written.append(path)
|
||||
return written
|
||||
|
||||
|
||||
def apply(working_dir: str, conn: Connection | None = None) -> dict:
|
||||
"""Apply the whole web-managed config for one launch; returns a small summary."""
|
||||
if conn is None:
|
||||
with connection() as c:
|
||||
connectors = repo.list_claude_connectors(c, enabled_only=True)
|
||||
skills = repo.list_claude_skills(c, enabled_only=True)
|
||||
else:
|
||||
connectors = repo.list_claude_connectors(conn, enabled_only=True)
|
||||
skills = repo.list_claude_skills(conn, enabled_only=True)
|
||||
mcp_path = write_mcp_config(working_dir, connectors)
|
||||
written = sync_user_skills(skills)
|
||||
return {"mcp_config": mcp_path, "skills_written": len(written)}
|
||||
@@ -34,6 +34,7 @@ from pathlib import Path
|
||||
from ..config import get_settings
|
||||
from ..db import repository as repo
|
||||
from ..db.engine import connection
|
||||
from . import claude_gen
|
||||
|
||||
# Stream types we recognize from ``--output-format stream-json``; anything else (or an
|
||||
# unparseable line) is stored as-is so no output is ever dropped. ``worker`` is our own:
|
||||
@@ -59,10 +60,13 @@ def session_dir(working_dir: str) -> Path:
|
||||
return Path(os.path.expanduser("~")) / ".claude" / "projects" / munged_project_dir(working_dir)
|
||||
|
||||
|
||||
def build_spawn_argv(task: str, settings_path: str, session_id: str) -> list[str]:
|
||||
def build_spawn_argv(
|
||||
task: str, settings_path: str, session_id: str, mcp_config: str | None = None
|
||||
) -> list[str]:
|
||||
"""The headless spawn invocation. ``--verbose`` is required with stream-json in
|
||||
print mode; ``--session-id`` pre-assigns the UUID so the session is addressable
|
||||
(and archivable) from the first event."""
|
||||
(and archivable) from the first event. ``mcp_config`` is the generated connectors
|
||||
file (``control.claude_gen``), when the operator has any enabled."""
|
||||
s = get_settings()
|
||||
argv = [
|
||||
s.claude_bin, "-p", "--verbose",
|
||||
@@ -70,13 +74,17 @@ def build_spawn_argv(task: str, settings_path: str, session_id: str) -> list[str
|
||||
"--session-id", session_id,
|
||||
"--settings", settings_path,
|
||||
]
|
||||
if mcp_config:
|
||||
argv += ["--mcp-config", mcp_config]
|
||||
if s.run_budget_usd > 0:
|
||||
argv += ["--max-budget-usd", str(s.run_budget_usd)]
|
||||
argv += ["--", task]
|
||||
return argv
|
||||
|
||||
|
||||
def build_resume_argv(session_id: str, answer: str, settings_path: str) -> list[str]:
|
||||
def build_resume_argv(
|
||||
session_id: str, answer: str, settings_path: str, mcp_config: str | None = None
|
||||
) -> list[str]:
|
||||
"""The headless resume invocation — a brand-new process continuing ``session_id``."""
|
||||
s = get_settings()
|
||||
argv = [
|
||||
@@ -85,6 +93,8 @@ def build_resume_argv(session_id: str, answer: str, settings_path: str) -> list[
|
||||
"--resume", session_id,
|
||||
"--settings", settings_path,
|
||||
]
|
||||
if mcp_config:
|
||||
argv += ["--mcp-config", mcp_config]
|
||||
if s.run_budget_usd > 0:
|
||||
argv += ["--max-budget-usd", str(s.run_budget_usd)]
|
||||
argv += ["--", answer]
|
||||
@@ -380,14 +390,19 @@ def launch(
|
||||
owns the process from here.
|
||||
"""
|
||||
working_dir = agent["working_dir"]
|
||||
# The generated connectors file (control.claude_gen) rides along when present; its
|
||||
# presence on disk is the contract, so the launch seam's signature stays stable.
|
||||
mcp_config = claude_gen.mcp_config_path(working_dir)
|
||||
if not os.path.exists(mcp_config):
|
||||
mcp_config = None
|
||||
if kind == "spawn":
|
||||
session_id = str(uuid.uuid4())
|
||||
argv = build_spawn_argv(prompt, settings_path, session_id)
|
||||
argv = build_spawn_argv(prompt, settings_path, session_id, mcp_config)
|
||||
else:
|
||||
session_id = agent.get("session_id")
|
||||
if not session_id:
|
||||
raise ValueError(f"agent '{agent['name']}' has no session to resume")
|
||||
argv = build_resume_argv(session_id, prompt, settings_path)
|
||||
argv = build_resume_argv(session_id, prompt, settings_path, mcp_config)
|
||||
with connection() as conn:
|
||||
run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
|
||||
repo.set_agent_session(conn, agent["id"], session_id, worker_id)
|
||||
|
||||
@@ -4,15 +4,27 @@
|
||||
This is the declarative half of hook integration; the imperative half — the agent
|
||||
identity and ``DATABASE_URL`` — is injected as environment via tmux (see
|
||||
``control.spawn``), because hook stdin does not carry our identity.
|
||||
|
||||
The dashboard's Claude page feeds in here too: the operator's permission overrides and
|
||||
plugins (``claude_config`` / ``claude_plugins`` rows) are merged over the env-configured
|
||||
baseline on every generation, so a change in the web UI applies to the next launch of
|
||||
every agent without a redeploy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import repository as repo
|
||||
from ..db.engine import connection
|
||||
|
||||
_OWNER_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
|
||||
|
||||
|
||||
def _hook_command(event: str) -> str:
|
||||
@@ -21,7 +33,15 @@ def _hook_command(event: str) -> str:
|
||||
return f"{sys.executable} -m handler.hooks {event}"
|
||||
|
||||
|
||||
def build_settings() -> dict:
|
||||
def _marketplace_source(repo_ref: str) -> dict:
|
||||
"""The settings-shaped source for a marketplace: ``owner/repo`` is a GitHub source,
|
||||
anything else is a git URL (the API validates it as one)."""
|
||||
if _OWNER_REPO_RE.match(repo_ref):
|
||||
return {"source": "github", "repo": repo_ref}
|
||||
return {"source": "git", "url": repo_ref}
|
||||
|
||||
|
||||
def build_settings(conn: Connection | None = None) -> dict:
|
||||
settings = {
|
||||
"hooks": {
|
||||
"Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}],
|
||||
@@ -44,18 +64,53 @@ def build_settings() -> dict:
|
||||
# project's own tooling) proceed; the PreToolUse/Stop hooks above remain the hard
|
||||
# gate either way, since a hook deny overrides any allow.
|
||||
s = get_settings()
|
||||
settings["permissions"] = {
|
||||
"defaultMode": s.headless_permission_mode,
|
||||
"allow": s.headless_allowed_tools_list,
|
||||
}
|
||||
mode = s.headless_permission_mode
|
||||
allow = list(s.headless_allowed_tools_list)
|
||||
deny: list[str] = []
|
||||
ask: list[str] = []
|
||||
plugins: list[dict] = []
|
||||
if conn is not None:
|
||||
stored = repo.get_claude_config(conn, "permissions") or {}
|
||||
if stored.get("default_mode"):
|
||||
mode = stored["default_mode"]
|
||||
allow += [r for r in stored.get("allow", []) if r not in allow]
|
||||
deny = list(stored.get("deny", []))
|
||||
ask = list(stored.get("ask", []))
|
||||
plugins = repo.list_claude_plugins(conn, enabled_only=True)
|
||||
permissions: dict = {"defaultMode": mode, "allow": allow}
|
||||
if deny:
|
||||
permissions["deny"] = deny
|
||||
if ask:
|
||||
permissions["ask"] = ask
|
||||
settings["permissions"] = permissions
|
||||
|
||||
# Web-managed plugins: declaring the marketplace + the enabled plugin makes a
|
||||
# headless run install both on boot, no interactive `/plugin` flow needed.
|
||||
if plugins:
|
||||
settings["extraKnownMarketplaces"] = {
|
||||
p["marketplace"]: {"source": _marketplace_source(p["marketplace_repo"])}
|
||||
for p in plugins
|
||||
}
|
||||
settings["enabledPlugins"] = {
|
||||
f"{p['name']}@{p['marketplace']}": True for p in plugins
|
||||
}
|
||||
return settings
|
||||
|
||||
|
||||
def write_settings(working_dir: str) -> str:
|
||||
"""Write ``.claude/settings.json`` under the agent's working dir; return its path."""
|
||||
def write_settings(working_dir: str, conn: Connection | None = None) -> str:
|
||||
"""Write ``.claude/settings.json`` under the agent's working dir; return its path.
|
||||
|
||||
Opens a short read connection when the caller doesn't hold one — the web-managed
|
||||
permission overrides and plugins live in the database.
|
||||
"""
|
||||
claude_dir = os.path.join(working_dir, ".claude")
|
||||
os.makedirs(claude_dir, exist_ok=True)
|
||||
path = os.path.join(claude_dir, "settings.json")
|
||||
if conn is None:
|
||||
with connection() as c:
|
||||
settings = build_settings(c)
|
||||
else:
|
||||
settings = build_settings(conn)
|
||||
with open(path, "w") as fh:
|
||||
json.dump(build_settings(), fh, indent=2)
|
||||
json.dump(settings, fh, indent=2)
|
||||
return path
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..config import get_settings
|
||||
from ..db import repository as repo
|
||||
from ..db.engine import connection
|
||||
from . import (
|
||||
claude_gen,
|
||||
credentials,
|
||||
forge,
|
||||
gitops,
|
||||
@@ -142,6 +143,9 @@ def spawn(
|
||||
)
|
||||
|
||||
settings_path = settings_gen.write_settings(working_dir)
|
||||
# Materialize the web-managed Claude config (MCP connectors + user-level skills)
|
||||
# so this launch picks up what the operator configured in the dashboard.
|
||||
claude_gen.apply(working_dir)
|
||||
env = _agent_env(project, agent, token, role=role, mise_init=mise_init)
|
||||
|
||||
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift
|
||||
@@ -249,6 +253,7 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
|
||||
|
||||
working_dir = agent["working_dir"]
|
||||
settings_path = settings_gen.write_settings(working_dir)
|
||||
claude_gen.apply(working_dir)
|
||||
try:
|
||||
token = None
|
||||
with connection() as conn:
|
||||
|
||||
@@ -27,6 +27,10 @@ from .tables import (
|
||||
agents,
|
||||
approvals,
|
||||
checkmarks,
|
||||
claude_config,
|
||||
claude_connectors,
|
||||
claude_plugins,
|
||||
claude_skills,
|
||||
commands,
|
||||
forge_hosts,
|
||||
log_entries,
|
||||
@@ -927,6 +931,205 @@ def get_runtime_secret(conn: Connection, key: str) -> dict | None:
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
# --------------------------------------------------- claude management (web-managed)
|
||||
|
||||
|
||||
def list_claude_skills(conn: Connection, enabled_only: bool = False) -> list[dict]:
|
||||
stmt = select(claude_skills)
|
||||
if enabled_only:
|
||||
stmt = stmt.where(claude_skills.c.enabled.is_(True))
|
||||
rows = conn.execute(stmt.order_by(claude_skills.c.name)).all()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
def get_claude_skill(conn: Connection, skill_id: int) -> dict | None:
|
||||
row = conn.execute(select(claude_skills).where(claude_skills.c.id == skill_id)).first()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def get_claude_skill_by_name(conn: Connection, name: str) -> dict | None:
|
||||
row = conn.execute(select(claude_skills).where(claude_skills.c.name == name)).first()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def create_claude_skill(
|
||||
conn: Connection,
|
||||
name: str,
|
||||
content: str,
|
||||
description: str | None = None,
|
||||
enabled: bool = True,
|
||||
) -> dict:
|
||||
now = _now()
|
||||
result = conn.execute(
|
||||
claude_skills.insert().values(
|
||||
name=name,
|
||||
description=description,
|
||||
content=content,
|
||||
enabled=enabled,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
return get_claude_skill(conn, result.inserted_primary_key[0])
|
||||
|
||||
|
||||
def update_claude_skill(conn: Connection, skill_id: int, **fields: Any) -> dict | None:
|
||||
allowed = {"name", "description", "content", "enabled"}
|
||||
values = {k: v for k, v in fields.items() if k in allowed}
|
||||
if values:
|
||||
values["updated_at"] = _now()
|
||||
conn.execute(
|
||||
claude_skills.update().where(claude_skills.c.id == skill_id).values(**values)
|
||||
)
|
||||
return get_claude_skill(conn, skill_id)
|
||||
|
||||
|
||||
def delete_claude_skill(conn: Connection, skill_id: int) -> bool:
|
||||
result = conn.execute(claude_skills.delete().where(claude_skills.c.id == skill_id))
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
def list_claude_connectors(conn: Connection, enabled_only: bool = False) -> list[dict]:
|
||||
stmt = select(claude_connectors)
|
||||
if enabled_only:
|
||||
stmt = stmt.where(claude_connectors.c.enabled.is_(True))
|
||||
rows = conn.execute(stmt.order_by(claude_connectors.c.name)).all()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
def get_claude_connector(conn: Connection, connector_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
select(claude_connectors).where(claude_connectors.c.id == connector_id)
|
||||
).first()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def get_claude_connector_by_name(conn: Connection, name: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
select(claude_connectors).where(claude_connectors.c.name == name)
|
||||
).first()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def create_claude_connector(
|
||||
conn: Connection,
|
||||
name: str,
|
||||
transport: str,
|
||||
command: str | None = None,
|
||||
args: list | None = None,
|
||||
env: dict | None = None,
|
||||
url: str | None = None,
|
||||
headers: dict | None = None,
|
||||
enabled: bool = True,
|
||||
) -> dict:
|
||||
result = conn.execute(
|
||||
claude_connectors.insert().values(
|
||||
name=name,
|
||||
transport=transport,
|
||||
command=command,
|
||||
args=args,
|
||||
env=env,
|
||||
url=url,
|
||||
headers=headers,
|
||||
enabled=enabled,
|
||||
created_at=_now(),
|
||||
)
|
||||
)
|
||||
return get_claude_connector(conn, result.inserted_primary_key[0])
|
||||
|
||||
|
||||
def update_claude_connector(conn: Connection, connector_id: int, **fields: Any) -> dict | None:
|
||||
allowed = {"name", "transport", "command", "args", "env", "url", "headers", "enabled"}
|
||||
values = {k: v for k, v in fields.items() if k in allowed}
|
||||
if values:
|
||||
conn.execute(
|
||||
claude_connectors.update()
|
||||
.where(claude_connectors.c.id == connector_id)
|
||||
.values(**values)
|
||||
)
|
||||
return get_claude_connector(conn, connector_id)
|
||||
|
||||
|
||||
def delete_claude_connector(conn: Connection, connector_id: int) -> bool:
|
||||
result = conn.execute(
|
||||
claude_connectors.delete().where(claude_connectors.c.id == connector_id)
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
def list_claude_plugins(conn: Connection, enabled_only: bool = False) -> list[dict]:
|
||||
stmt = select(claude_plugins)
|
||||
if enabled_only:
|
||||
stmt = stmt.where(claude_plugins.c.enabled.is_(True))
|
||||
rows = conn.execute(
|
||||
stmt.order_by(claude_plugins.c.marketplace, claude_plugins.c.name)
|
||||
).all()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
def get_claude_plugin(conn: Connection, plugin_id: int) -> dict | None:
|
||||
row = conn.execute(select(claude_plugins).where(claude_plugins.c.id == plugin_id)).first()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def get_claude_plugin_by_key(conn: Connection, name: str, marketplace: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
select(claude_plugins).where(
|
||||
claude_plugins.c.name == name, claude_plugins.c.marketplace == marketplace
|
||||
)
|
||||
).first()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def create_claude_plugin(
|
||||
conn: Connection,
|
||||
name: str,
|
||||
marketplace: str,
|
||||
marketplace_repo: str,
|
||||
enabled: bool = True,
|
||||
) -> dict:
|
||||
result = conn.execute(
|
||||
claude_plugins.insert().values(
|
||||
name=name,
|
||||
marketplace=marketplace,
|
||||
marketplace_repo=marketplace_repo,
|
||||
enabled=enabled,
|
||||
created_at=_now(),
|
||||
)
|
||||
)
|
||||
return get_claude_plugin(conn, result.inserted_primary_key[0])
|
||||
|
||||
|
||||
def update_claude_plugin(conn: Connection, plugin_id: int, **fields: Any) -> dict | None:
|
||||
allowed = {"name", "marketplace", "marketplace_repo", "enabled"}
|
||||
values = {k: v for k, v in fields.items() if k in allowed}
|
||||
if values:
|
||||
conn.execute(
|
||||
claude_plugins.update().where(claude_plugins.c.id == plugin_id).values(**values)
|
||||
)
|
||||
return get_claude_plugin(conn, plugin_id)
|
||||
|
||||
|
||||
def delete_claude_plugin(conn: Connection, plugin_id: int) -> bool:
|
||||
result = conn.execute(claude_plugins.delete().where(claude_plugins.c.id == plugin_id))
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
def get_claude_config(conn: Connection, key: str) -> Any | None:
|
||||
"""The stored JSON value for a claude_config key, or None when unset."""
|
||||
row = conn.execute(select(claude_config).where(claude_config.c.key == key)).first()
|
||||
return row._mapping["value"] if row is not None else None
|
||||
|
||||
|
||||
def set_claude_config(conn: Connection, key: str, value: Any) -> None:
|
||||
now = _now()
|
||||
result = conn.execute(
|
||||
claude_config.update().where(claude_config.c.key == key).values(value=value, updated_at=now)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
conn.execute(claude_config.insert().values(key=key, value=value, updated_at=now))
|
||||
|
||||
|
||||
def get_latest_claimed_command(conn: Connection, type: str) -> dict | None:
|
||||
"""The most recent command of a type that some worker has claimed (running or
|
||||
finished). Login pinning reads login_start's ``claimed_by`` to route login_submit to
|
||||
|
||||
@@ -328,6 +328,71 @@ session_archives = Table(
|
||||
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# ---- Claude management (web-managed). What the operator configures in the dashboard's
|
||||
# Claude page; the control container applies it to every launch — skills sync to the
|
||||
# worker's user-level ~/.claude/skills, connectors become the --mcp-config file, and
|
||||
# plugins/permissions fold into the generated per-agent settings.json (settings_gen).
|
||||
# Transports an MCP connector can use, mirroring claude's .mcp.json server types.
|
||||
MCP_TRANSPORTS = ("stdio", "http", "sse")
|
||||
|
||||
# Operator-authored Claude Code skills (SKILL.md bodies). Distinct from the forge role
|
||||
# skills (skills_gen), which are committed into managed repos; these are user-level and
|
||||
# synced to every worker at launch.
|
||||
claude_skills = Table(
|
||||
"claude_skills",
|
||||
metadata,
|
||||
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
Column("name", String, nullable=False, unique=True), # slug; becomes the skill dirname
|
||||
Column("description", String),
|
||||
Column("content", String, nullable=False), # markdown body below the front-matter
|
||||
Column("enabled", Boolean, nullable=False, server_default="1"),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# MCP servers ("connectors") agents may reach. Written per-launch as an --mcp-config
|
||||
# file, so nothing lands in the managed repo's tree.
|
||||
claude_connectors = Table(
|
||||
"claude_connectors",
|
||||
metadata,
|
||||
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
Column("name", String, nullable=False, unique=True), # the mcpServers key
|
||||
Column("transport", String, nullable=False),
|
||||
Column("command", String), # stdio: the executable
|
||||
Column("args", PortableJSON), # stdio: argv list
|
||||
Column("env", PortableJSON), # stdio: environment map
|
||||
Column("url", String), # http/sse: the endpoint
|
||||
Column("headers", PortableJSON), # http/sse: header map (may carry auth)
|
||||
Column("enabled", Boolean, nullable=False, server_default="1"),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
CheckConstraint(_in("transport", MCP_TRANSPORTS), name="ck_claude_connectors_transport"),
|
||||
)
|
||||
|
||||
# Claude Code plugins, pinned to the marketplace that serves them. Folded into generated
|
||||
# settings as extraKnownMarketplaces + enabledPlugins so headless runs auto-install them.
|
||||
claude_plugins = Table(
|
||||
"claude_plugins",
|
||||
metadata,
|
||||
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
Column("name", String, nullable=False), # the plugin's name within its marketplace
|
||||
Column("marketplace", String, nullable=False), # marketplace key, e.g. "acme-tools"
|
||||
Column("marketplace_repo", String, nullable=False), # "owner/repo" or a git URL
|
||||
Column("enabled", Boolean, nullable=False, server_default="1"),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"),
|
||||
)
|
||||
|
||||
# Small JSON key/value store for the remaining Claude management state; first key is
|
||||
# "permissions" — the operator's defaultMode override and extra allow/deny/ask rules,
|
||||
# merged over the env-configured baseline by settings_gen at launch.
|
||||
claude_config = Table(
|
||||
"claude_config",
|
||||
metadata,
|
||||
Column("key", String, primary_key=True),
|
||||
Column("value", PortableJSON, nullable=False),
|
||||
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# Recurring agent spawns. The worker checks for due rows on every loop pass and enqueues
|
||||
# an ordinary ``spawn`` command per firing (so scheduled runs show up in the Activity
|
||||
# audit trail like any other control action). Agent names must be unique per project, so
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""claude management: skills, connectors, plugins, and permission overrides
|
||||
|
||||
Revision ID: 0010_claude_management
|
||||
Revises: 0009_runtime_secrets
|
||||
Create Date: 2026-07-23
|
||||
|
||||
The dashboard's Claude page becomes a management surface, not just the login flow. The
|
||||
operator's skills, MCP connectors, plugins, and permission overrides live here; the
|
||||
control container reads them at every launch — skills sync to the worker's user-level
|
||||
``~/.claude/skills``, connectors become the run's ``--mcp-config`` file, and plugins/
|
||||
permissions fold into the generated per-agent ``settings.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from handler.db.types import PortableBigInt, PortableJSON, PortableTimestamp
|
||||
|
||||
revision: str = "0010_claude_management"
|
||||
down_revision: str | None = "0009_runtime_secrets"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"claude_skills",
|
||||
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("description", sa.String()),
|
||||
sa.Column("content", sa.String(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"claude_connectors",
|
||||
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("transport", sa.String(), nullable=False),
|
||||
sa.Column("command", sa.String()),
|
||||
sa.Column("args", PortableJSON),
|
||||
sa.Column("env", PortableJSON),
|
||||
sa.Column("url", sa.String()),
|
||||
sa.Column("headers", PortableJSON),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
sa.CheckConstraint(
|
||||
"transport IN ('stdio', 'http', 'sse')", name="ck_claude_connectors_transport"
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"claude_plugins",
|
||||
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("marketplace", sa.String(), nullable=False),
|
||||
sa.Column("marketplace_repo", sa.String(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"),
|
||||
)
|
||||
op.create_table(
|
||||
"claude_config",
|
||||
sa.Column("key", sa.String(), primary_key=True),
|
||||
sa.Column("value", PortableJSON, nullable=False),
|
||||
sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("claude_config")
|
||||
op.drop_table("claude_plugins")
|
||||
op.drop_table("claude_connectors")
|
||||
op.drop_table("claude_skills")
|
||||
@@ -0,0 +1,303 @@
|
||||
"""The Claude management surface: /claude API CRUD + admin gating, the generation that
|
||||
applies it (settings permissions/plugins, the --mcp-config file, user-level skills), and
|
||||
the spawn integration that ties them together.
|
||||
|
||||
Spawns go through the ``fake_launch`` seam; the conftest ``env`` fixture points $HOME at
|
||||
the per-test tmp dir, so skill syncs never touch a real ``~/.claude``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from handler.control import claude_gen, headless, settings_gen, spawn
|
||||
from handler.db import repository as repo
|
||||
from handler.db.engine import get_engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lowpriv(env):
|
||||
"""A valid bearer that is NOT the admin token (the shared-context write token)."""
|
||||
return {"Authorization": f"Bearer {env['shared_token']}"}
|
||||
|
||||
|
||||
# --- repository ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_repository_skill_crud_and_config_kv(conn):
|
||||
skill = repo.create_claude_skill(conn, "deploy-notes", "# body", description="d")
|
||||
assert skill["enabled"] is True
|
||||
assert repo.get_claude_skill_by_name(conn, "deploy-notes")["id"] == skill["id"]
|
||||
|
||||
updated = repo.update_claude_skill(conn, skill["id"], content="# new", enabled=False)
|
||||
assert updated["content"] == "# new" and updated["enabled"] is False
|
||||
assert repo.list_claude_skills(conn, enabled_only=True) == []
|
||||
assert len(repo.list_claude_skills(conn)) == 1
|
||||
|
||||
assert repo.delete_claude_skill(conn, skill["id"]) is True
|
||||
assert repo.get_claude_skill(conn, skill["id"]) is None
|
||||
|
||||
assert repo.get_claude_config(conn, "permissions") is None
|
||||
repo.set_claude_config(conn, "permissions", {"allow": ["Bash(npm *)"]})
|
||||
repo.set_claude_config(conn, "permissions", {"allow": ["Bash(go *)"]}) # upsert
|
||||
assert repo.get_claude_config(conn, "permissions") == {"allow": ["Bash(go *)"]}
|
||||
|
||||
|
||||
# --- API CRUD + gating -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_skill_api_crud(client, auth):
|
||||
r = client.post(
|
||||
"/claude/skills",
|
||||
json={"name": "deploy-notes", "description": "when deploying", "content": "# steps"},
|
||||
headers=auth,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
sid = r.json()["id"]
|
||||
|
||||
# Duplicate name refused.
|
||||
dup = client.post(
|
||||
"/claude/skills", json={"name": "deploy-notes", "content": "x"}, headers=auth
|
||||
)
|
||||
assert dup.status_code == 409
|
||||
|
||||
# Unsafe names (path separators, dot-prefix) never become dirnames.
|
||||
bad = client.post(
|
||||
"/claude/skills", json={"name": "../escape", "content": "x"}, headers=auth
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
|
||||
patched = client.patch(f"/claude/skills/{sid}", json={"enabled": False}, headers=auth)
|
||||
assert patched.status_code == 200 and patched.json()["enabled"] is False
|
||||
|
||||
assert client.get("/claude/skills", headers=auth).json()[0]["name"] == "deploy-notes"
|
||||
assert client.delete(f"/claude/skills/{sid}", headers=auth).status_code == 200
|
||||
assert client.get("/claude/skills", headers=auth).json() == []
|
||||
|
||||
|
||||
def test_connector_api_crud_and_validation(client, auth):
|
||||
# stdio without a command is refused.
|
||||
bad = client.post(
|
||||
"/claude/connectors", json={"name": "gh", "transport": "stdio"}, headers=auth
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
# http without an http(s) url is refused.
|
||||
bad = client.post(
|
||||
"/claude/connectors",
|
||||
json={"name": "gh", "transport": "http", "url": "ftp://x"},
|
||||
headers=auth,
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
|
||||
r = client.post(
|
||||
"/claude/connectors",
|
||||
json={
|
||||
"name": "github",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "tok"},
|
||||
},
|
||||
headers=auth,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
cid = r.json()["id"]
|
||||
|
||||
# A PATCH can't strand the row in an invalid pairing (http with no url).
|
||||
bad = client.patch(f"/claude/connectors/{cid}", json={"transport": "http"}, headers=auth)
|
||||
assert bad.status_code == 422
|
||||
|
||||
ok = client.patch(
|
||||
f"/claude/connectors/{cid}",
|
||||
json={"transport": "http", "url": "https://mcp.example.com/mcp"},
|
||||
headers=auth,
|
||||
)
|
||||
assert ok.status_code == 200 and ok.json()["url"] == "https://mcp.example.com/mcp"
|
||||
|
||||
assert client.delete(f"/claude/connectors/{cid}", headers=auth).status_code == 200
|
||||
|
||||
|
||||
def test_plugin_api_crud(client, auth):
|
||||
r = client.post(
|
||||
"/claude/plugins",
|
||||
json={"name": "reviewer", "marketplace": "acme", "marketplace_repo": "acme/market"},
|
||||
headers=auth,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
pid = r.json()["id"]
|
||||
|
||||
dup = client.post(
|
||||
"/claude/plugins",
|
||||
json={"name": "reviewer", "marketplace": "acme", "marketplace_repo": "acme/market"},
|
||||
headers=auth,
|
||||
)
|
||||
assert dup.status_code == 409
|
||||
|
||||
bad = client.post(
|
||||
"/claude/plugins",
|
||||
json={"name": "x", "marketplace": "m", "marketplace_repo": "not a repo"},
|
||||
headers=auth,
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
|
||||
assert client.delete(f"/claude/plugins/{pid}", headers=auth).status_code == 200
|
||||
|
||||
|
||||
def test_permissions_roundtrip_carries_baseline(client, auth):
|
||||
base = client.get("/claude/permissions", headers=auth).json()
|
||||
assert base["default_mode"] is None
|
||||
assert base["base_mode"] == "acceptEdits"
|
||||
assert "Bash(git *)" in base["base_allow"]
|
||||
|
||||
r = client.put(
|
||||
"/claude/permissions",
|
||||
json={"default_mode": "plan", "allow": ["Bash(npm *)", " "], "deny": ["WebFetch"]},
|
||||
headers=auth,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
saved = client.get("/claude/permissions", headers=auth).json()
|
||||
assert saved["default_mode"] == "plan"
|
||||
assert saved["allow"] == ["Bash(npm *)"] # blanks dropped
|
||||
assert saved["deny"] == ["WebFetch"]
|
||||
|
||||
bad = client.put("/claude/permissions", json={"default_mode": "yolo"}, headers=auth)
|
||||
assert bad.status_code == 422
|
||||
|
||||
|
||||
def test_claude_writes_need_admin(client, auth, lowpriv):
|
||||
# Reads pass with any valid bearer...
|
||||
assert client.get("/claude/skills", headers=lowpriv).status_code == 200
|
||||
assert client.get("/claude/permissions", headers=lowpriv).status_code == 200
|
||||
# ...writes need the admin token.
|
||||
r = client.post("/claude/skills", json={"name": "s", "content": "x"}, headers=lowpriv)
|
||||
assert r.status_code == 403
|
||||
assert client.put("/claude/permissions", json={}, headers=lowpriv).status_code == 403
|
||||
r = client.post(
|
||||
"/claude/connectors",
|
||||
json={"name": "c", "transport": "stdio", "command": "x"},
|
||||
headers=lowpriv,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# --- generation ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_merge_db_permissions_and_plugins(env):
|
||||
with get_engine().begin() as conn:
|
||||
repo.set_claude_config(
|
||||
conn,
|
||||
"permissions",
|
||||
{
|
||||
"default_mode": "plan",
|
||||
"allow": ["Bash(npm *)", "Bash(git *)"], # git already in the baseline
|
||||
"deny": ["WebFetch"],
|
||||
"ask": ["Bash(rm *)"],
|
||||
},
|
||||
)
|
||||
repo.create_claude_plugin(conn, "reviewer", "acme", "acme/market")
|
||||
repo.create_claude_plugin(conn, "linter", "tools", "https://git.corp/m.git")
|
||||
repo.create_claude_plugin(conn, "off", "acme", "acme/market", enabled=False)
|
||||
settings = settings_gen.build_settings(conn)
|
||||
|
||||
perms = settings["permissions"]
|
||||
assert perms["defaultMode"] == "plan"
|
||||
assert perms["allow"].count("Bash(git *)") == 1 # no duplicate from the merge
|
||||
assert "Bash(npm *)" in perms["allow"]
|
||||
assert perms["deny"] == ["WebFetch"]
|
||||
assert perms["ask"] == ["Bash(rm *)"]
|
||||
|
||||
markets = settings["extraKnownMarketplaces"]
|
||||
assert markets["acme"] == {"source": {"source": "github", "repo": "acme/market"}}
|
||||
assert markets["tools"] == {"source": {"source": "git", "url": "https://git.corp/m.git"}}
|
||||
assert settings["enabledPlugins"] == {"reviewer@acme": True, "linter@tools": True}
|
||||
assert "hooks" in settings # the hard gate is untouched
|
||||
|
||||
|
||||
def test_mcp_config_written_and_removed(env, tmp_path):
|
||||
wd = str(tmp_path / "wd")
|
||||
connectors = [
|
||||
{
|
||||
"name": "github",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "server-github"],
|
||||
"env": {"GITHUB_TOKEN": "tok"},
|
||||
},
|
||||
{"name": "docs", "transport": "http", "url": "https://mcp.x/mcp", "headers": {"A": "b"}},
|
||||
]
|
||||
path = claude_gen.write_mcp_config(wd, connectors)
|
||||
data = json.loads(open(path).read())
|
||||
assert data["mcpServers"]["github"] == {
|
||||
"command": "npx",
|
||||
"args": ["-y", "server-github"],
|
||||
"env": {"GITHUB_TOKEN": "tok"},
|
||||
}
|
||||
assert data["mcpServers"]["docs"] == {
|
||||
"type": "http",
|
||||
"url": "https://mcp.x/mcp",
|
||||
"headers": {"A": "b"},
|
||||
}
|
||||
# No connectors left => the previously generated file is removed.
|
||||
assert claude_gen.write_mcp_config(wd, []) is None
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
def test_skills_sync_and_managed_cleanup(env, tmp_path):
|
||||
home = str(tmp_path / "home")
|
||||
# A hand-installed skill (no marker) must survive every sync.
|
||||
manual = tmp_path / "home" / ".claude" / "skills" / "hand-made"
|
||||
manual.mkdir(parents=True)
|
||||
(manual / "SKILL.md").write_text("mine")
|
||||
|
||||
written = claude_gen.sync_user_skills(
|
||||
[{"name": "deploy", "description": "when deploying", "content": "# steps"}], home=home
|
||||
)
|
||||
text = open(written[0]).read()
|
||||
assert text.startswith("---\nname: deploy\ndescription: when deploying\n---\n")
|
||||
assert "# steps" in text
|
||||
|
||||
# The managed skill disappears when no longer enabled; the manual one stays.
|
||||
claude_gen.sync_user_skills([], home=home)
|
||||
assert not os.path.exists(os.path.dirname(written[0]))
|
||||
assert (manual / "SKILL.md").read_text() == "mine"
|
||||
|
||||
|
||||
def test_argv_carries_mcp_config():
|
||||
argv = headless.build_spawn_argv("task", "/s.json", "sid", "/wd/.claude/mcp-servers.json")
|
||||
i = argv.index("--mcp-config")
|
||||
assert argv[i + 1] == "/wd/.claude/mcp-servers.json"
|
||||
assert "--mcp-config" not in headless.build_spawn_argv("task", "/s.json", "sid")
|
||||
argv = headless.build_resume_argv("sid", "answer", "/s.json", "/m.json")
|
||||
assert argv[argv.index("--mcp-config") + 1] == "/m.json"
|
||||
|
||||
|
||||
# --- spawn integration -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spawn_applies_managed_config(env, fake_launch):
|
||||
root = env["tmp"] / "proj"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "proj", str(root))
|
||||
repo.create_claude_connector(conn, "github", "stdio", command="npx")
|
||||
repo.create_claude_connector(conn, "off", "stdio", command="x", enabled=False)
|
||||
repo.create_claude_skill(conn, "deploy", "# steps")
|
||||
repo.set_claude_config(conn, "permissions", {"default_mode": "plan"})
|
||||
|
||||
agent = spawn.spawn("proj", "api", task="do it")
|
||||
wd = agent["working_dir"]
|
||||
|
||||
# Disabled connectors are excluded from the generated --mcp-config file.
|
||||
mcp = json.loads(open(claude_gen.mcp_config_path(wd)).read())
|
||||
assert list(mcp["mcpServers"]) == ["github"]
|
||||
# Skills synced to the (test-scoped) user-level dir.
|
||||
skill = os.path.join(str(env["tmp"]), ".claude", "skills", "deploy", "SKILL.md")
|
||||
assert os.path.exists(skill)
|
||||
# The generated settings carry the DB permission override.
|
||||
settings = json.loads(open(os.path.join(wd, ".claude", "settings.json")).read())
|
||||
assert settings["permissions"]["defaultMode"] == "plan"
|
||||
Reference in New Issue
Block a user