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