diff --git a/README.md b/README.md index a3368ce..079740a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/app/claude/page.tsx b/frontend/app/claude/page.tsx new file mode 100644 index 0000000..bf45af7 --- /dev/null +++ b/frontend/app/claude/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index a8bf3dd..e4797c5 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -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 ( -
- -
- ); +export default function LoginRedirect() { + const router = useRouter(); + useEffect(() => { + router.replace("/claude"); + }, [router]); + return null; } diff --git a/frontend/components/Shell.tsx b/frontend/components/Shell.tsx index 680a1d2..d0399d3 100644 --- a/frontend/components/Shell.tsx +++ b/frontend/components/Shell.tsx @@ -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 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", }, diff --git a/frontend/components/sections/ClaudeSection.tsx b/frontend/components/sections/ClaudeSection.tsx new file mode 100644 index 0000000..ea4c94a --- /dev/null +++ b/frontend/components/sections/ClaudeSection.tsx @@ -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 { + const out: Record = {}; + 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 | 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(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 ( + <> +
+ Custom Claude Code skills, synced to every worker's{" "} + ~/.claude/skills at each launch. The description is + what makes Claude pick the skill up — say when to use it. +
+ +
+ + {editingId != null ? `Edit skill · ${form.name}` : "Add a skill"} + +
+
+ setForm({ ...form, name: v })} + placeholder="deploy-checklist" + /> + setForm({ ...form, description: v })} + placeholder="Use when preparing or reviewing a deploy." + /> +
+
+