Skip to content
168 linesCodeBlameRaw
1/**
2 * Import a skill (docs.g1t.sh/guides/agent-skills/, "Import a skill"):
3 * upload a SKILL.md or a zip of a skill's folder, or read a folder from a
4 * repository, pinned to the commit it was read at.
5 */
6import { ArrowLeft, FileUp, GitBranch } from "lucide-react";
7import { useId, useState } from "react";
8import { Form, Link, data, redirect, useActionData, useNavigation } from "react-router";
9
10import type { SkillImport } from "@g1t/contracts";
11
12import type { Route } from "./+types/skill-import";
13import { agentsAction } from "../../../components/agents/actions.server";
14import { type ActionResult } from "../../../components/agents/dialogs";
15import { skillsPath } from "../../../components/agents/skills";
16import { Alert } from "../../../components/ui/alert";
17import { Button } from "../../../components/ui/button";
18import { Card } from "../../../components/ui/card";
19import { CheckboxOption } from "../../../components/ui/checkbox";
20import { Field, FieldDescription, FieldLabel } from "../../../components/ui/field";
21import { FileDrop } from "../../../components/ui/file-drop";
22import { Input } from "../../../components/ui/input";
23import { cn } from "../../../lib/cn";
24import { page } from "../../../lib/meta";
25import { skillLibrary } from "../../../lib/services.server";
26import { requireUser, roleIn } from "../../../lib/session.server";
27import { SKILL_UPLOAD_MAX_BYTES, base64Of, uploadedFiles } from "../../../lib/skill-files.server";
28
29export function meta({ params, ...args }: Route.MetaArgs) {
30 return page(args, { title: `Import a skill · Skills · ${params.owner} · g1t` });
31}
32
33export async function loader({ params, context, request }: Route.LoaderArgs) {
34 const viewer = requireUser(context, request);
35 const slug = params.owner.toLowerCase();
36 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
37 return { slug };
38}
39
40export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
41 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
42 const intent = String(form.get("intent") ?? "");
43 let source: SkillImport;
44 if (intent === "upload") {
45 const [file] = uploadedFiles(form, "file");
46 if (!file) return { ok: false, intent, error: "Choose a SKILL.md or a zip to upload." };
47 if (file.size > SKILL_UPLOAD_MAX_BYTES) return { ok: false, intent, error: "Upload at most 2 MB: a SKILL.md, or a zip of the skill's folder." };
48 source = { kind: "upload", filename: file.name, data_base64: base64Of(new Uint8Array(await file.arrayBuffer())) };
49 } else if (intent === "repository") {
50 source = { kind: "repository", repo: String(form.get("repo") ?? ""), path: String(form.get("path") ?? ""), ref: String(form.get("ref") ?? "") || null };
51 } else return { ok: false, intent, error: "Unknown request." };
52 const imported = await skillLibrary.importSkill(slug, viewer, source, form.get("replace") === "on").catch(() => null);
53 if (!imported) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
54 if (!imported.ok) return { ok: false, intent, error: imported.error.message };
55 throw redirect(skillsPath(slug, imported.value.skill.name));
56}
57
58export default function ImportSkill({ loaderData }: Route.ComponentProps) {
59 const { slug } = loaderData;
60 const result = useActionData<ActionResult>();
61 const navigation = useNavigation();
62 const busyWith = navigation.state !== "idle" ? String(navigation.formData?.get("intent") ?? "") : null;
63 const [tab, setTab] = useState<"upload" | "repository">(result && !result.ok && result.intent === "repository" ? "repository" : "upload");
64 const id = useId();
65 const error = (which: string) =>
66 result && !result.ok && result.intent === which ? (
67 <Alert asChild>
68 <p role="alert">
69 {result.error}
70 </p>
71 </Alert>
72 ) : null;
73 const replace = (
74 <CheckboxOption
75 name="replace"
76 label="If the library has a skill with its name, make this its new version"
77 description="Otherwise a skill with the same name is refused. Its attachments you can change move to the new version."
78 />
79 );
80 return (
81 <div className="max-w-3xl pb-4">
82 <Link to={skillsPath(slug)} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
83 <ArrowLeft size={14} />
84 Skills
85 </Link>
86 <header className="mt-4 mb-6">
87 <h1 className="text-2xl font-semibold tracking-tight">Import a skill</h1>
88 <p className="mt-1.5 text-sm text-muted">
89 Any skill in the open SKILL.md format: a folder with a SKILL.md whose front-matter has a name and a description, and optional scripts/ and resources/. Front-matter g1t
90 doesn&apos;t use is kept as it is.
91 </p>
92 </header>
93 <div role="tablist" aria-label="Import from" className="mb-5 grid grid-cols-2 gap-1 rounded-lg bg-surface p-1 ring-1 ring-line sm:inline-grid sm:w-auto">
94 {(
95 [
96 ["upload", "Upload", FileUp],
97 ["repository", "From a repository", GitBranch],
98 ] as const
99 ).map(([value, label, Icon]) => (
100 <button
101 key={value}
102 type="button"
103 role="tab"
104 aria-selected={tab === value}
105 onClick={() => setTab(value)}
106 className={cn("inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm", tab === value ? "bg-raised font-medium text-fg shadow-sm" : "text-muted hover:text-fg")}
107 >
108 <Icon size={14} />
109 {label}
110 </button>
111 ))}
112 </div>
113
114 {tab === "upload" ? (
115 <Card asChild className="grid gap-5 p-4 sm:p-5">
116 <Form method="post" encType="multipart/form-data">
117 <input type="hidden" name="intent" value="upload" />
118 <Field>
119 <FieldLabel htmlFor={`${id}-file`}>SKILL.md or zip</FieldLabel>
120 <FileDrop id={`${id}-file`} name="file" required accept=".md,.zip,text/markdown,application/zip" aria-describedby={`${id}-file-about`} />
121 <FieldDescription id={`${id}-file-about`}>A zip of the skill&apos;s folder, as zipping the folder makes it. At most 1 MB once unpacked and 200 files.</FieldDescription>
122 </Field>
123 {replace}
124 {error("upload")}
125 <div className="flex justify-end">
126 <Button type="submit" variant="accent" disabled={busyWith === "upload"}>
127 {busyWith === "upload" ? "Importing…" : "Import"}
128 </Button>
129 </div>
130 </Form>
131 </Card>
132 ) : (
133 <Card asChild className="grid gap-5 p-4 sm:p-5">
134 <Form method="post">
135 <input type="hidden" name="intent" value="repository" />
136 <Field>
137 <FieldLabel htmlFor={`${id}-repo`}>Repository</FieldLabel>
138 <Input id={`${id}-repo`} name="repo" required placeholder={`${slug}/handbook`} autoComplete="off" spellCheck={false} className="font-mono" />
139 <FieldDescription>One you can read, as workspace/name.</FieldDescription>
140 </Field>
141 <div className="grid gap-5 sm:grid-cols-[minmax(0,1fr)_12rem]">
142 <Field>
143 <FieldLabel htmlFor={`${id}-path`}>Folder</FieldLabel>
144 <Input id={`${id}-path`} name="path" placeholder="skills/release-notes" autoComplete="off" spellCheck={false} className="font-mono" />
145 <FieldDescription>The folder holding SKILL.md. Empty for the top of the repository.</FieldDescription>
146 </Field>
147 <Field>
148 <FieldLabel htmlFor={`${id}-ref`}>Branch, tag or commit</FieldLabel>
149 <Input id={`${id}-ref`} name="ref" placeholder="Default branch" autoComplete="off" spellCheck={false} className="font-mono placeholder:font-sans" />
150 </Field>
151 </div>
152 <p className="text-xs text-faint">
153 It is read once, at the commit the branch or tag points to now, and that commit is kept with the version. To have every push update a skill, link the repository on the
154 Skills page and keep the skill in .g1t/skills/.
155 </p>
156 {replace}
157 {error("repository")}
158 <div className="flex justify-end">
159 <Button type="submit" variant="accent" disabled={busyWith === "repository"}>
160 {busyWith === "repository" ? "Reading…" : "Import"}
161 </Button>
162 </div>
163 </Form>
164 </Card>
165 )}
166 </div>
167 );
168}