/** * @file ArtifactAddPanels.tsx * @description The Artifacts page's Add dialogs for a skill, a plugin and an * uploaded file (server/lib/artifacts.js): * - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown * file that becomes SKILL.md, several files, a whole folder — picked or * dropped — or a .zip/.skill; an upload holding several skills makes one * artifact each), **Write** (name, description, instructions) and * **Existing** (a folder in the user's ~/.claude/skills or a workspace's * .claude/skills). The user names the skill; the name is previewed as the * folder and `/command` it installs as (`skillSlug`, the server's rule). * - `PluginAddPanel` — paste a marketplace link (a git https link, a forge * web link, `owner/repo` or a marketplace.json link), read its plugin list * and tick the ones to add; or type `name@marketplace` by hand, which is * also where a link the server cannot read (ssh, a private repo) ends up. * - `FileUploadPanel` — one file from the user's computer (≤ 25 MB). * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { useTranslation } from "react-i18next"; import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react"; import { api, type MarketplaceInfo } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { toast } from "../../lib/toast"; const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary"; const label = "block text-[11px] font-medium text-fg-secondary"; const hint = "text-[11px] text-fg-muted"; /** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */ export function skillSlug(text: string): string { return text .normalize("NFD") .replace(/[̀-ͯ]/g, "") .replace(/đ/g, "d") .replace(/Đ/g, "D") .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^[-._]+|-+$/g, "") .slice(0, 100); } /** `field:` of a Markdown file's frontmatter, or "". */ export function frontmatterField(text: string, name: string): string { const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text); if (!fm) return ""; const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? ""); return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2"); } type Picked = { file: File; path: string }; /** A small file's text ("" on failure), only to prefill the name/description. */ function readText(file: File): Promise { if (file.size > 1024 * 1024) return Promise.resolve(""); return new Promise((ok) => { const r = new FileReader(); r.onload = () => ok(typeof r.result === "string" ? r.result : ""); r.onerror = () => ok(""); r.readAsText(file); }); } const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/; const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p); const isArchive = (p: string) => /\.(zip|skill)$/i.test(p); const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p); /** Every file under what was dropped, folders walked (webkitGetAsEntry). */ async function droppedFiles(e: DragEvent): Promise { const items = Array.from(e.dataTransfer.items || []); const entries = items .map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null)) .filter((x): x is FileSystemEntry => !!x); if (!entries.length) { return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name })); } const out: Picked[] = []; const walk = async (entry: FileSystemEntry, prefix: string): Promise => { if (entry.isFile) { const file = await new Promise((ok, fail) => (entry as FileSystemFileEntry).file(ok, fail) ); out.push({ file, path: prefix + entry.name }); return; } const reader = (entry as FileSystemDirectoryEntry).createReader(); // readEntries answers in batches until an empty one. for (;;) { const batch = await new Promise((ok, fail) => reader.readEntries(ok, fail) ); if (!batch.length) break; for (const child of batch) await walk(child, `${prefix}${entry.name}/`); } }; for (const entry of entries) await walk(entry, ""); return out; } /** What an upload will make, guessed the way the server decides it. */ function describeUpload(files: Picked[]): { kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md"; count: number; folder: string; } { const real = files.filter((f) => !JUNK.test(f.path)); if (!real.length) return { kind: "none", count: 0, folder: "" }; if (real.length === 1 && isArchive(real[0]!.path)) return { kind: "archive", count: 0, folder: "" }; const roots = [ ...new Set( real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/")) ), ].sort((a, b) => a.length - b.length); const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`); const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r))); if (!top.length) { return real.length === 1 && isText(real[0]!.path) ? { kind: "single-md", count: 1, folder: "" } : { kind: "no-skill-md", count: 0, folder: "" }; } if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" }; return { kind: "many", count: top.length, folder: "" }; } function Tabs({ value, options, onChange, }: { value: T; options: { id: T; label: string }[]; onChange: (v: T) => void; }) { return (
{options.map((o) => ( ))}
); } /** The name field with the folder/command it becomes. */ function SkillNameField({ value, onChange, disabled, required, }: { value: string; onChange: (v: string) => void; disabled?: boolean; required?: boolean; }) { const { t } = useTranslation("artifacts"); const slug = skillSlug(value); return (
{value.trim() && (

{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}

)}
); } // ── Skill ───────────────────────────────────────────────────────────────── export function SkillAddPanel({ lanes, onAdded, onClose, }: { lanes: Lane[]; onAdded: () => void; onClose: () => void; }) { const { t } = useTranslation("artifacts"); const [mode, setMode] = useState<"upload" | "write" | "existing">("upload"); const [busy, setBusy] = useState(false); const [name, setName] = useState(""); const [nameTouched, setNameTouched] = useState(false); const [description, setDescription] = useState(""); const [descTouched, setDescTouched] = useState(false); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [files, setFiles] = useState([]); const [dragging, setDragging] = useState(false); const [laneId, setLaneId] = useState(null); const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null); const fileInput = useRef(null); const folderInput = useRef(null); // `webkitdirectory` is not in React's typings. useEffect(() => { folderInput.current?.setAttribute("webkitdirectory", ""); }, [mode]); useEffect(() => { if (mode !== "existing") return; api.artifacts .skills(laneId ?? undefined) .then((r) => setSkills(r.items)) .catch(() => setSkills([])); }, [mode, laneId]); const shape = useMemo(() => describeUpload(files), [files]); // Fill the name and description from what was picked, unless typed. useEffect(() => { if (!files.length) return; const md = files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ?? (shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined); const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, ""); let live = true; void (async () => { const text = md ? await readText(md.file) : ""; if (!live) return; const fmName = frontmatterField(text, "name"); const fmDesc = frontmatterField(text, "description"); if (!nameTouched) { const guess = shape.kind === "single-md" ? fmName || stem(md?.path ?? "") : shape.kind === "one" ? fmName || shape.folder : shape.kind === "archive" ? stem(files[0]!.path) : ""; setName(guess); } if (!descTouched && fmDesc) setDescription(fmDesc); })(); return () => { live = false; }; // Refill only when the pick changes, never on a typed name. }, [files, shape]); const pick = (list: FileList | null) => { if (!list?.length) return; setFiles( Array.from(list).map((file) => ({ file, path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name, })) ); }; const run = async (fn: () => Promise) => { setBusy(true); try { const n = await fn(); toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added")); onAdded(); onClose(); } catch (err) { toast.error(err); } finally { setBusy(false); } }; const many = shape.kind === "many"; const total = files.reduce((n, f) => n + f.file.size, 0); const meta = ( <>