Files
handler/app/src/screens/manage/SkillsScreen.tsx
T
Claude c3e3d1627e feat(app): full management surface + email sign-in
Settings → Manage now opens the entire admin control surface on the
phone, mirroring the web dashboard page for page:

- Models: full CRUD for model backends incl. write-only API keys
  (set/clear), harness selection (claude/pi), enable toggles.
- Skills: create, toggle, delete, expand to read SKILL.md, and
  install-from-prompt driven through the command queue.
- Connectors: stdio/http/sse MCP servers with args/env/header parsing.
- Plugins: marketplace plugins pinned to their repo.
- Permissions: default mode + allow/deny/ask rules over the read-only
  env baseline.
- Repositories: register in git-server or manual mode (incl. the
  mise-init bootstrap), sync, delete.
- Git servers: forge hosts with encrypted tokens and generated deploy
  keys (public half selectable for copying).
- Approvals: record operator approve/reject verdicts per branch.
- Shared context: browse and set the cross-agent key/value store.
- Users: invite (with shareable invite links), promote/disable, mint
  reset links, delete — the user-accounts feature that just landed.
- Claude login: drive the worker's claude /login flow from the phone.
- Account (Settings): who you're signed in as, change password,
  sign out with server-side session revocation.

The connect screen gains the matching gate: email sign-in via
/auth/login (session token stored like the legacy env token),
first-run setup when the server has zero accounts, forgot-password,
and the API-token method as fallback (auto-selected for pre-accounts
servers). Memory gains note authoring + deletion via a new
reloadMemory hook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
2026-08-13 13:55:18 +00:00

346 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
import { radius, text } from "../../theme/tokens";
import { useTheme } from "../../theme/useTheme";
import { Badge } from "../../components/Badge";
import { Button } from "../../components/Button";
import { Switch } from "../../components/Switch";
import { TextField } from "../../components/TextField";
import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell";
import { Card, Divider, Mono, SectionLabel } from "../../components/primitives";
import { useAppState } from "../../state/AppState";
import { useResource } from "../../state/useResource";
import type { ClaudeSkill, Command } from "../../api/client";
/**
* Skills — managed Claude Code skills, the mobile counterpart of the web
* dashboard's Claude → Skills panel. Rows are plain DB writes the control
* container syncs to every worker's ~/.claude/skills at the next agent launch.
* Everyone sees the shared rows (owner NULL) plus their own; mutating a row the
* session doesn't own 403s server-side and surfaces inline, never fatally.
*/
export function SkillsScreen() {
const { colors } = useTheme();
const { client } = useAppState();
const {
data,
error: loadError,
loading,
reload,
} = useResource<ClaudeSkill[]>("/claude/skills");
const skills = data ?? [];
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [installPrompt, setInstallPrompt] = useState("");
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [installSummary, setInstallSummary] = useState<string | null>(null);
async function create() {
if (!client) return;
if (!name.trim() || !content.trim()) {
setError("Name and content are both required.");
return;
}
setError(null);
setBusy(true);
try {
await client.api<ClaudeSkill>("/claude/skills", {
body: {
name: name.trim(),
description: description.trim() || null,
content,
enabled: true,
},
});
setName("");
setDescription("");
setContent("");
setShowForm(false);
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Couldnt create the skill.");
} finally {
setBusy(false);
}
}
function toggle(sk: ClaudeSkill, enabled: boolean) {
if (!client) return;
client
.api(`/claude/skills/${sk.id}`, { method: "PATCH", body: { enabled } })
.then(reload)
.catch((e) => setError(e instanceof Error ? e.message : "Update failed."));
}
function confirmDelete(sk: ClaudeSkill) {
Alert.alert(
"Delete skill?",
`Remove ${sk.name} from every worker at its next launch. This cant be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
client
?.api(`/claude/skills/${sk.id}`, { method: "DELETE" })
.then(reload)
.catch((e) =>
setError(e instanceof Error ? e.message : "Delete failed."),
);
},
},
],
);
}
/* Install-from-prompt runs headlessly on the worker — the 202 hands back a
* command we poll to completion (installs can be slow: network + a full
* claude run, hence the 4-minute budget). */
async function install() {
if (!client || !installPrompt.trim()) return;
setInstallError(null);
setInstallSummary(null);
setInstalling(true);
try {
const cmd = await client.api<Command>("/claude/skills/install", {
body: { prompt: installPrompt.trim() },
});
const done = await client.trackCommand(cmd.id, {
attempts: 120,
intervalMs: 2000,
});
if (done === null) {
setInstallError(
"Still installing after 4 minutes — pull to reload later to see what landed.",
);
} else if (done.status === "failed") {
setInstallError(done.error ?? "Install failed.");
} else {
setInstallSummary(
done.result ? JSON.stringify(done.result).trim() : "Installed.",
);
setInstallPrompt("");
reload();
}
} catch (e) {
setInstallError(e instanceof Error ? e.message : "Install failed.");
} finally {
setInstalling(false);
}
}
return (
<ManageShell
title="Skills"
subtitle="Managed skills, synced to every workers ~/.claude/skills at each agent launch. The description is what makes Claude pick a skill up."
>
<View style={styles.headRow}>
<SectionLabel>
{loading
? "Loading…"
: `${skills.length} skill${skills.length === 1 ? "" : "s"}`}
</SectionLabel>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => setShowForm((v) => !v)}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field label="Name" hint="A slug — becomes the skill directory.">
<TextField
value={name}
onChangeText={setName}
placeholder="deploy-checklist"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Description" hint="When should Claude use it? (optional)">
<TextField
value={description}
onChangeText={setDescription}
placeholder="Use when preparing or reviewing a deploy."
/>
</Field>
<Field label="SKILL.md body">
<TextField
value={content}
onChangeText={setContent}
placeholder={"# Deploy checklist\n\n1. ..."}
multiline
height={160}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : create}>
{busy ? "Creating…" : "Add skill"}
</Button>
</Card>
) : null}
<SectionLabel style={{ marginBottom: 8 }}>Install from a marketplace prompt</SectionLabel>
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field
label="Install prompt"
hint="Runs headlessly on the worker — when the instructions offer choices, Claude picks the defaults (always user scope). Review the imported skill below afterwards."
>
<TextField
value={installPrompt}
onChangeText={setInstallPrompt}
placeholder="Paste the whole prompt the marketplace page tells you to give Claude."
multiline
height={100}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Button
size="lg"
style={{ width: "100%" }}
onPress={installing || !installPrompt.trim() ? undefined : install}
>
{installing ? "Installing…" : "Run install"}
</Button>
{installSummary ? (
<Mono style={{ fontSize: 12, color: colors.positive }} numberOfLines={4}>
{installSummary}
</Mono>
) : null}
<ErrorNotice message={installError} />
</Card>
<ErrorNotice message={error ?? loadError} />
{skills.length === 0 && !loading ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
No custom skills yet.
</Text>
) : (
<Card>
{skills.map((sk, i) => {
const expanded = expandedId === sk.id;
return (
<View key={sk.id}>
{i > 0 && <Divider />}
<Pressable
style={styles.row}
onPress={() => setExpandedId(expanded ? null : sk.id)}
>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{sk.name}
</Mono>
{sk.owner_user_id != null ? (
<Badge tone="neutral">private</Badge>
) : null}
</View>
{sk.description ? (
<Text
numberOfLines={expanded ? undefined : 2}
style={[text.caption, { color: colors.textMuted }]}
>
{sk.description}
</Text>
) : null}
{sk.files.length > 0 ? (
<Text style={[text.caption, { color: colors.textMuted }]}>
ships with {sk.files.length} file
{sk.files.length === 1 ? "" : "s"}
</Text>
) : null}
</View>
<View style={styles.rowActions}>
<Switch value={sk.enabled} onValueChange={(v) => toggle(sk, v)} />
<Button size="sm" variant="danger" onPress={() => confirmDelete(sk)}>
Delete
</Button>
</View>
</Pressable>
{expanded ? (
<View style={styles.detail}>
<View
style={[
styles.monoBlock,
{
backgroundColor: colors.surfaceSunken,
borderColor: colors.borderSubtle,
},
]}
>
<Mono style={{ fontSize: 12, color: colors.textBody }}>
{sk.content}
</Mono>
</View>
{sk.files.map((f) => (
<Mono
key={f}
style={{ fontSize: 12, color: colors.textMuted }}
numberOfLines={1}
>
{f}
</Mono>
))}
</View>
) : null}
</View>
);
})}
</Card>
)}
</ManageShell>
);
}
const styles = StyleSheet.create({
headRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
row: {
flexDirection: "row",
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
},
rowActions: {
alignItems: "flex-end",
justifyContent: "space-between",
gap: 10,
},
detail: {
paddingHorizontal: 16,
paddingBottom: 12,
gap: 6,
},
monoBlock: {
borderWidth: 1,
borderRadius: radius.md,
padding: 12,
},
});