| 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 | */ |
| 6 | import { ArrowLeft, FileUp, GitBranch } from "lucide-react"; |
| 7 | import { useId, useState } from "react"; |
| 8 | import { Form, Link, data, redirect, useActionData, useNavigation } from "react-router"; |
| 9 | |
| 10 | import type { SkillImport } from "@g1t/contracts"; |
| 11 | |
| 12 | import type { Route } from "./+types/skill-import"; |
| 13 | import { agentsAction } from "../../../components/agents/actions.server"; |
| 14 | import { type ActionResult, BUTTONS } from "../../../components/agents/dialogs"; |
| 15 | import { skillsPath } from "../../../components/agents/skills"; |
| 16 | import { CheckboxOption } from "../../../components/ui/checkbox"; |
| 17 | import { Field, FieldDescription, FieldLabel } from "../../../components/ui/field"; |
| 18 | import { Input } from "../../../components/ui/input"; |
| 19 | import { cn } from "../../../lib/cn"; |
| 20 | import { page } from "../../../lib/meta"; |
| 21 | import { skillLibrary } from "../../../lib/services.server"; |
| 22 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 23 | import { SKILL_UPLOAD_MAX_BYTES, base64Of, uploadedFiles } from "../../../lib/skill-files.server"; |
| 24 | |
| 25 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 26 | return page(args, { title: `Import a skill · Skills · ${params.owner} · g1t` }); |
| 27 | } |
| 28 | |
| 29 | export 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 | |
| 36 | export 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 | |
| 54 | export 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'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'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 | } |