Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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