Skip to content
307 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how.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 { Input } from "../../../components/ui/input";
22import { SelectField } from "../../../components/ui/select";
23import { Textarea } from "../../../components/ui/textarea";
24import { cn } from "../../../lib/cn";
25import { page } from "../../../lib/meta";
26import { skillLibrary } from "../../../lib/services.server";
27import { requireUser, roleIn } from "../../../lib/session.server";
28import { skillFileOf, uploadedFiles } from "../../../lib/skill-files.server";
29
30export 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
34export 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. */
46export 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
73export 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&apos;t be edited right now</p>
98 <p className="mt-1.5 text-sm text-muted">The agents service didn&apos;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 &ldquo;Use when&rdquo; 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&apos;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&apos;t work where it&apos;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&apos;s own
197 computer, which is coming, so for now they&apos;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&apos;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}