| 1 | /** |
| 2 | * The skill editor (docs.g1t.sh/guides/agent-skills/, "Write a skill"): |
| 3 | * a name, when to use it, the instructions, the tools it uses and its |
| 4 | * files. Saving writes SKILL.md and a new version; a draft saved from a |
| 5 | * session is reviewed and published here. |
| 6 | */ |
| 7 | import { ArrowLeft, Eye, FileCode, FileText, PenLine, Undo2, X } from "lucide-react"; |
| 8 | import { useId, useState } from "react"; |
| 9 | import { Form, Link, data, redirect, useActionData, useNavigation } from "react-router"; |
| 10 | |
| 11 | import { AGENT_TOOL_GROUPS, SKILL_DESCRIPTION_MAX, type SkillDetail, type SkillInput, skillSize } from "@g1t/contracts"; |
| 12 | |
| 13 | import type { Route } from "./+types/skill-edit"; |
| 14 | import { agentsAction } from "../../../components/agents/actions.server"; |
| 15 | import { type ActionResult } from "../../../components/agents/dialogs"; |
| 16 | import { originText, skillsPath } from "../../../components/agents/skills"; |
| 17 | import { Markdown } from "../../../components/markdown"; |
| 18 | import { ButtonLink } from "../../../components/ui"; |
| 19 | import { Alert } from "../../../components/ui/alert"; |
| 20 | import { Badge } from "../../../components/ui/badge"; |
| 21 | import { Button } from "../../../components/ui/button"; |
| 22 | import { Card } from "../../../components/ui/card"; |
| 23 | import { CheckboxOption } from "../../../components/ui/checkbox"; |
| 24 | import { Field, FieldDescription, FieldError, FieldLabel } from "../../../components/ui/field"; |
| 25 | import { FileDrop } from "../../../components/ui/file-drop"; |
| 26 | import { Input } from "../../../components/ui/input"; |
| 27 | import { SelectField } from "../../../components/ui/select"; |
| 28 | import { Textarea } from "../../../components/ui/textarea"; |
| 29 | import { cn } from "../../../lib/cn"; |
| 30 | import { page } from "../../../lib/meta"; |
| 31 | import { skillLibrary } from "../../../lib/services.server"; |
| 32 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 33 | import { skillFileOf, uploadedFiles } from "../../../lib/skill-files.server"; |
| 34 | |
| 35 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 36 | return page(args, { title: `${params.name ? `Edit ${params.name}` : "Write a skill"} · Skills · ${params.owner} · g1t` }); |
| 37 | } |
| 38 | |
| 39 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ slug: string; detail: SkillDetail | null; unavailable: boolean }> { |
| 40 | const viewer = requireUser(context, request); |
| 41 | const slug = params.owner.toLowerCase(); |
| 42 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); |
| 43 | if (!params.name) return { slug, detail: null, unavailable: false }; |
| 44 | const found = await skillLibrary.skill(slug, viewer, params.name).catch(() => null); |
| 45 | if (found && !found.ok && found.error.code === "not_found") throw data(null, { status: 404 }); |
| 46 | if (found?.ok && !found.value.skill.can_edit) throw redirect(skillsPath(slug, params.name)); |
| 47 | return { slug, detail: found?.ok ? found.value : null, unavailable: !found?.ok }; |
| 48 | } |
| 49 | |
| 50 | /** Saves the skill: a new one, a new version, or a draft published. */ |
| 51 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { |
| 52 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); |
| 53 | const intent = "save"; |
| 54 | const folder = form.get("folder") === "scripts" ? "scripts" : "resources"; |
| 55 | const added = []; |
| 56 | for (const file of uploadedFiles(form, "files").slice(0, 50)) { |
| 57 | const one = await skillFileOf(`${folder}/${file.name.replace(/[\\/]/g, "_")}`, file); |
| 58 | if ("error" in one) return { ok: false, intent, error: one.error, field: "files" }; |
| 59 | added.push(one); |
| 60 | } |
| 61 | const input: SkillInput = { |
| 62 | name: String(form.get("name") ?? "").trim(), |
| 63 | description: String(form.get("description") ?? ""), |
| 64 | instructions: String(form.get("instructions") ?? ""), |
| 65 | tools: form.getAll("tools").map(String), |
| 66 | requires_computer: form.get("requires_computer") === "on", |
| 67 | add_files: added, |
| 68 | remove_files: form.getAll("remove").map(String), |
| 69 | note: String(form.get("note") ?? "") || null, |
| 70 | update_attachments: form.get("update_attachments") !== "off", |
| 71 | }; |
| 72 | const saved = await skillLibrary.saveSkill(slug, viewer, params.name ?? null, input).catch(() => null); |
| 73 | if (!saved) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." }; |
| 74 | if (!saved.ok) return { ok: false, intent, error: saved.error.message }; |
| 75 | throw redirect(skillsPath(slug, saved.value.skill.name)); |
| 76 | } |
| 77 | |
| 78 | export default function SkillEditor({ loaderData, params }: Route.ComponentProps) { |
| 79 | const { slug, detail, unavailable } = loaderData; |
| 80 | const result = useActionData<ActionResult>(); |
| 81 | const navigation = useNavigation(); |
| 82 | const busy = navigation.state !== "idle" && navigation.formMethod === "POST"; |
| 83 | const id = useId(); |
| 84 | const editing = !!params.name; |
| 85 | const draft = detail?.skill.status === "draft"; |
| 86 | const [description, setDescription] = useState(detail?.skill.description ?? ""); |
| 87 | const [instructions, setInstructions] = useState(detail?.instructions ?? ""); |
| 88 | const [preview, setPreview] = useState(false); |
| 89 | const [removed, setRemoved] = useState<string[]>([]); |
| 90 | const [folder, setFolder] = useState("resources"); |
| 91 | const [updateAll, setUpdateAll] = useState(true); |
| 92 | const back = editing ? skillsPath(slug, params.name) : skillsPath(slug); |
| 93 | |
| 94 | if (editing && unavailable) { |
| 95 | return ( |
| 96 | <div className="space-y-4"> |
| 97 | <Link to={back} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 98 | <ArrowLeft size={14} /> |
| 99 | {params.name} |
| 100 | </Link> |
| 101 | <Card tone="plain" className="border-dashed px-6 py-14 text-center"> |
| 102 | <p className="font-medium">{params.name} can't be edited right now</p> |
| 103 | <p className="mt-1.5 text-sm text-muted">The agents service didn't answer. Reload in a moment.</p> |
| 104 | </Card> |
| 105 | </div> |
| 106 | ); |
| 107 | } |
| 108 | const title = !editing ? "Write a skill" : draft ? "Review the draft" : `Edit ${params.name}`; |
| 109 | const attached = detail?.skill.attachments.length ?? 0; |
| 110 | return ( |
| 111 | <div className="pb-4"> |
| 112 | <Link to={back} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 113 | <ArrowLeft size={14} /> |
| 114 | {editing ? params.name : "Skills"} |
| 115 | </Link> |
| 116 | <header className="mt-4 mb-6"> |
| 117 | <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> |
| 118 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> |
| 119 | {draft |
| 120 | ? `${originText(detail!.skill.origin)}. Read it as you would a pull request: change what's wrong, take out anything private, then publish it. No agent uses it before then.` |
| 121 | : "Agents see the name and when to use it on every reply, and read the instructions when a request matches, so keep the first short and the second complete."} |
| 122 | </p> |
| 123 | </header> |
| 124 | <Form method="post" encType="multipart/form-data" className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_17rem]"> |
| 125 | <div className="grid min-w-0 gap-6"> |
| 126 | <Field> |
| 127 | <FieldLabel htmlFor={`${id}-name`}>Name</FieldLabel> |
| 128 | <Input id={`${id}-name`} name="name" defaultValue={detail?.skill.name ?? ""} required maxLength={64} placeholder="release-notes" autoComplete="off" spellCheck={false} className="font-mono" /> |
| 129 | <FieldDescription>Lowercase letters, digits and hyphens. Agents ask for it by this name.</FieldDescription> |
| 130 | </Field> |
| 131 | <Field> |
| 132 | <div className="flex items-baseline justify-between gap-2"> |
| 133 | <FieldLabel htmlFor={`${id}-description`}>When to use it</FieldLabel> |
| 134 | <span className={cn("text-xs tabular-nums", description.length > SKILL_DESCRIPTION_MAX ? "text-danger" : "text-faint")}> |
| 135 | {description.length} / {SKILL_DESCRIPTION_MAX} |
| 136 | </span> |
| 137 | </div> |
| 138 | <Textarea |
| 139 | id={`${id}-description`} |
| 140 | name="description" |
| 141 | rows={3} |
| 142 | required |
| 143 | value={description} |
| 144 | onChange={(e) => setDescription(e.target.value)} |
| 145 | placeholder="Use when someone asks for release notes or a changelog for a version." |
| 146 | /> |
| 147 | <FieldDescription>The SKILL.md description. Start with “Use when” and name the requests it is for.</FieldDescription> |
| 148 | </Field> |
| 149 | <Field> |
| 150 | <div className="flex items-center justify-between gap-2"> |
| 151 | <FieldLabel htmlFor={`${id}-instructions`}>Instructions</FieldLabel> |
| 152 | <div role="tablist" aria-label="Instructions" className="flex rounded-md bg-surface p-0.5 ring-1 ring-line"> |
| 153 | <button type="button" role="tab" aria-selected={!preview} onClick={() => setPreview(false)} className={cn("inline-flex items-center gap-1 rounded px-2 py-1 text-xs", !preview ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")}> |
| 154 | <PenLine size={12} /> |
| 155 | Write |
| 156 | </button> |
| 157 | <button type="button" role="tab" aria-selected={preview} onClick={() => setPreview(true)} className={cn("inline-flex items-center gap-1 rounded px-2 py-1 text-xs", preview ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")}> |
| 158 | <Eye size={12} /> |
| 159 | Preview |
| 160 | </button> |
| 161 | </div> |
| 162 | </div> |
| 163 | <Textarea |
| 164 | id={`${id}-instructions`} |
| 165 | name="instructions" |
| 166 | rows={18} |
| 167 | required |
| 168 | value={instructions} |
| 169 | onChange={(e) => setInstructions(e.target.value)} |
| 170 | placeholder={"# Release notes\n\n1. Read the pull requests merged since the last tag (recent_activity).\n2. Group them by area: Added, Changed, Fixed.\n3. Write the notes as a doc, linking each pull request."} |
| 171 | className={cn("font-mono text-[0.8125rem]", preview && "hidden")} |
| 172 | /> |
| 173 | {preview && ( |
| 174 | <div className="min-h-40 rounded-md border border-line bg-surface px-4 py-3"> |
| 175 | {instructions.trim() ? <Markdown source={instructions} /> : <p className="text-sm text-faint">Nothing to preview yet.</p>} |
| 176 | </div> |
| 177 | )} |
| 178 | <FieldDescription>Markdown, in the second person: the steps, what to read first, the checks before it's done. Agents get it word for word.</FieldDescription> |
| 179 | </Field> |
| 180 | |
| 181 | <fieldset className="min-w-0"> |
| 182 | <legend className="text-sm font-medium text-fg-soft">Tools it uses</legend> |
| 183 | <p className="mt-1 text-xs text-faint">Naming a tool never gives it to an agent. An agent without one is told that part doesn't work where it's asked.</p> |
| 184 | <div className="mt-3 grid gap-4 sm:grid-cols-2"> |
| 185 | {AGENT_TOOL_GROUPS.map((group) => ( |
| 186 | <Card key={group.group} radius="lg" className="px-3 py-2.5"> |
| 187 | <p className="mb-2 text-xs font-medium text-muted">{group.group}</p> |
| 188 | <div className="grid gap-1.5"> |
| 189 | {group.tools.map((tool) => ( |
| 190 | <CheckboxOption key={tool} name="tools" value={tool} defaultChecked={detail?.tools.includes(tool)} label={<span className="font-mono text-[0.8125rem]">{tool}</span>} /> |
| 191 | ))} |
| 192 | </div> |
| 193 | </Card> |
| 194 | ))} |
| 195 | </div> |
| 196 | </fieldset> |
| 197 | |
| 198 | <fieldset className="min-w-0"> |
| 199 | <legend className="text-sm font-medium text-fg-soft">Files</legend> |
| 200 | <p className="mt-1 text-xs text-faint"> |
| 201 | Templates and references go in resources/, and agents read them when the instructions point to them. Scripts go in scripts/: they run only on an agent's own |
| 202 | computer, which is coming, so for now they're kept with the skill and never run. At most 1 MB for everything. |
| 203 | </p> |
| 204 | {detail && detail.files.length > 0 && ( |
| 205 | <Card asChild radius="lg" className="mt-3 divide-y divide-line/60 overflow-hidden"> |
| 206 | <ul> |
| 207 | {detail.files.map((file) => { |
| 208 | const gone = removed.includes(file.path); |
| 209 | return ( |
| 210 | <li key={file.path} className="flex items-center gap-2.5 px-3 py-2 text-sm"> |
| 211 | {file.script ? <FileCode size={14} className="shrink-0 text-faint" aria-hidden /> : <FileText size={14} className="shrink-0 text-faint" aria-hidden />} |
| 212 | <span className={cn("min-w-0 grow truncate font-mono text-[0.8125rem]", gone && "text-faint line-through")}>{file.path}</span> |
| 213 | <span className="shrink-0 text-xs text-faint">{skillSize(file.bytes)}</span> |
| 214 | {gone && <input type="hidden" name="remove" value={file.path} />} |
| 215 | <Button |
| 216 | type="button" |
| 217 | onClick={() => setRemoved((now) => (gone ? now.filter((p) => p !== file.path) : [...now, file.path]))} |
| 218 | aria-label={gone ? `Keep ${file.path}` : `Remove ${file.path}`} |
| 219 | variant="ghost" |
| 220 | size="icon-xs" |
| 221 | className="text-faint" |
| 222 | > |
| 223 | {gone ? <Undo2 size={14} /> : <X size={14} />} |
| 224 | </Button> |
| 225 | </li> |
| 226 | ); |
| 227 | })} |
| 228 | </ul> |
| 229 | </Card> |
| 230 | )} |
| 231 | <div className="mt-3 flex flex-wrap items-start gap-2"> |
| 232 | <input type="hidden" name="folder" value={folder} /> |
| 233 | <SelectField |
| 234 | aria-label="Folder" |
| 235 | options={[ |
| 236 | { value: "resources", label: "resources/" }, |
| 237 | { value: "scripts", label: "scripts/" }, |
| 238 | ]} |
| 239 | value={folder} |
| 240 | onValueChange={setFolder} |
| 241 | className="w-36 font-mono" |
| 242 | /> |
| 243 | <FileDrop name="files" multiple compact label="Add files" className="min-w-0 grow basis-56" /> |
| 244 | </div> |
| 245 | <FieldError>{result && !result.ok && result.field === "files" ? result.error : null}</FieldError> |
| 246 | </fieldset> |
| 247 | |
| 248 | <CheckboxOption |
| 249 | name="requires_computer" |
| 250 | defaultChecked={detail?.requires_computer && !detail.files.some((f) => f.script)} |
| 251 | label="Needs a computer of its own" |
| 252 | description="For a skill that only works with a shell, a browser or code it runs. Agents don't have their own computer yet, so it is marked Coming and agents follow only the parts they can. A skill with scripts is marked anyway." |
| 253 | /> |
| 254 | |
| 255 | {editing && !draft && ( |
| 256 | <Card radius="lg" className="grid gap-4 px-4 py-3.5"> |
| 257 | <Field> |
| 258 | <FieldLabel htmlFor={`${id}-note`}>What changed</FieldLabel> |
| 259 | <Input id={`${id}-note`} name="note" maxLength={200} placeholder="Optional, shown in its history" /> |
| 260 | </Field> |
| 261 | {attached > 0 && ( |
| 262 | <> |
| 263 | <input type="hidden" name="update_attachments" value={updateAll ? "on" : "off"} /> |
| 264 | <CheckboxOption |
| 265 | checked={updateAll} |
| 266 | onCheckedChange={(checked) => setUpdateAll(checked === true)} |
| 267 | label="Use the new version wherever I can change it" |
| 268 | description={`It is attached in ${attached} ${attached === 1 ? "place" : "places"}. Left unticked, they keep their version and show an update available.`} |
| 269 | /> |
| 270 | </> |
| 271 | )} |
| 272 | </Card> |
| 273 | )} |
| 274 | |
| 275 | {result && !result.ok && result.field !== "files" && ( |
| 276 | <Alert asChild> |
| 277 | <p role="alert"> |
| 278 | {result.error} |
| 279 | </p> |
| 280 | </Alert> |
| 281 | )} |
| 282 | <div className="flex flex-wrap justify-end gap-2 border-t border-line pt-4"> |
| 283 | <ButtonLink to={back} variant="outline"> |
| 284 | Cancel |
| 285 | </ButtonLink> |
| 286 | <Button type="submit" variant="accent" disabled={busy}> |
| 287 | {busy ? "Saving…" : draft ? "Publish" : editing ? "Save a new version" : "Add to the library"} |
| 288 | </Button> |
| 289 | </div> |
| 290 | </div> |
| 291 | |
| 292 | <aside className="space-y-4 text-sm text-muted lg:pt-7"> |
| 293 | <Card className="px-4 py-3.5"> |
| 294 | <p className="font-medium text-fg">A good skill</p> |
| 295 | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-xs leading-relaxed"> |
| 296 | <li>Covers one kind of work, the way your team does it.</li> |
| 297 | <li>Says when to use it in one sentence, so agents pick it at the right time.</li> |
| 298 | <li>Lists the steps, where to look first, and the checks before it's done.</li> |
| 299 | <li>Points to its files by path, such as resources/template.md.</li> |
| 300 | </ul> |
| 301 | </Card> |
| 302 | <Card className="px-4 py-3.5 text-xs leading-relaxed"> |
| 303 | <p className="text-sm font-medium text-fg">Saved as SKILL.md</p> |
| 304 | <p className="mt-1.5">The open format other tools read, so a skill moves between them and can live in a repository. Every save is a new version; attachments pin the one they use.</p> |
| 305 | {detail && !draft && ( |
| 306 | <p className="mt-2"> |
| 307 | <Badge tone="neutral">Now version {detail.skill.version}</Badge> |
| 308 | </p> |
| 309 | )} |
| 310 | </Card> |
| 311 | </aside> |
| 312 | </Form> |
| 313 | </div> |
| 314 | ); |
| 315 | } |