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:
Claude
2026-07-23 13:32:41 +00:00
parent 301a697e74
commit 07d8c3aa19
65 changed files with 2240 additions and 99 deletions
+223 -3
View File
@@ -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>;