Skip to content
315 linesCodeBlameRaw
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 */
7import { ArrowLeft, Eye, FileCode, FileText, PenLine, Undo2, X } from "lucide-react";
8import { useId, useState } from "react";
9import { Form, Link, data, redirect, useActionData, useNavigation } from "react-router";
10
11import { AGENT_TOOL_GROUPS, SKILL_DESCRIPTION_MAX, type SkillDetail, type SkillInput, skillSize } from "@g1t/contracts";
12
13import type { Route } from "./+types/skill-edit";
14import { agentsAction } from "../../../components/agents/actions.server";
15import { type ActionResult } from "../../../components/agents/dialogs";
16import { originText, skillsPath } from "../../../components/agents/skills";
17import { Markdown } from "../../../components/markdown";
18import { ButtonLink } from "../../../components/ui";
19import { Alert } from "../../../components/ui/alert";
20import { Badge } from "../../../components/ui/badge";
21import { Button } from "../../../components/ui/button";
22import { Card } from "../../../components/ui/card";
23import { CheckboxOption } from "../../../components/ui/checkbox";
24import { Field, FieldDescription, FieldError, FieldLabel } from "../../../components/ui/field";
25import { FileDrop } from "../../../components/ui/file-drop";
26import { Input } from "../../../components/ui/input";
27import { SelectField } from "../../../components/ui/select";
28import { Textarea } from "../../../components/ui/textarea";
29import { cn } from "../../../lib/cn";
30import { page } from "../../../lib/meta";
31import { skillLibrary } from "../../../lib/services.server";
32import { requireUser, roleIn } from "../../../lib/session.server";
33import { skillFileOf, uploadedFiles } from "../../../lib/skill-files.server";
34
35export 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
39export 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. */
51export 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
78export 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&apos;t be edited right now</p>
103 <p className="mt-1.5 text-sm text-muted">The agents service didn&apos;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 &ldquo;Use when&rdquo; 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&apos;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&apos;t work where it&apos;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&apos;s own
202 computer, which is coming, so for now they&apos;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&apos;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}