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