| 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 { FileDrop } from "../../../components/ui/file-drop"; |
| 19 | import { Input } from "../../../components/ui/input"; |
| 20 | import { cn } from "../../../lib/cn"; |
| 21 | import { page } from "../../../lib/meta"; |
| 22 | import { skillLibrary } from "../../../lib/services.server"; |
| 23 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 24 | import { SKILL_UPLOAD_MAX_BYTES, base64Of, uploadedFiles } from "../../../lib/skill-files.server"; |
| 25 | |
| 26 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 27 | return page(args, { title: `Import a skill · Skills · ${params.owner} · g1t` }); |
| 28 | } |
| 29 | |
| 30 | export 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 | |
| 37 | export 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 | |
| 55 | export 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'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'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 | } |