Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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