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("/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(null); const [expandedId, setExpandedId] = useState(null); const [installPrompt, setInstallPrompt] = useState(""); const [installing, setInstalling] = useState(false); const [installError, setInstallError] = useState(null); const [installSummary, setInstallSummary] = useState(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("/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 : "Couldn’t 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 can’t 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("/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 ( {loading ? "Loading…" : `${skills.length} skill${skills.length === 1 ? "" : "s"}`} {showForm ? ( ) : null} Install from a marketplace prompt {installSummary ? ( {installSummary} ) : null} {skills.length === 0 && !loading ? ( No custom skills yet. ) : ( {skills.map((sk, i) => { const expanded = expandedId === sk.id; return ( {i > 0 && } setExpandedId(expanded ? null : sk.id)} > {sk.name} {sk.owner_user_id != null ? ( private ) : null} {sk.description ? ( {sk.description} ) : null} {sk.files.length > 0 ? ( ships with {sk.files.length} file {sk.files.length === 1 ? "" : "s"} ) : null} toggle(sk, v)} /> {expanded ? ( {sk.content} {sk.files.map((f) => ( {f} ))} ) : null} ); })} )} ); } 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, }, });