Skip to content
397 linesCodeBlameRaw
1/**
2 * One skill (docs.g1t.sh/guides/agent-skills/): its instructions and files,
3 * where it is attached and at which version, and every version. A draft
4 * shows what the agent wrote, for someone to review and publish. g1t's
5 * foundational skills show here too, as the SKILL.md they are.
6 */
7import { ArrowLeft, ArrowUpCircle, FileCode, FileText, History, PenLine, Plus, Trash2, X } from "lucide-react";
8import type { ReactNode } from "react";
9import { Link, data, redirect, useFetcher } from "react-router";
10
11import {
12 type AgentSkill,
13 FOUNDATIONAL_SKILLS,
14 type SkillDetail,
15 type SkillLibrary,
16 foundationalSkillMd,
17 skillSize,
18 splitFrontMatter,
19} from "@g1t/contracts";
20
21import type { Route } from "./+types/skill";
22import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
23import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
24import { AttachDialog, AttachmentChip, FromRepository, NeedsComputer, originText, skillsPath } from "../../../components/agents/skills";
25import { Markdown } from "../../../components/markdown";
26import { ButtonLink, TimeAgo } from "../../../components/ui";
27import { Alert } from "../../../components/ui/alert";
28import { Badge } from "../../../components/ui/badge";
29import { Button } from "../../../components/ui/button";
30import { Card } from "../../../components/ui/card";
31import { Hint } from "../../../components/ui/hint";
32import { cn } from "../../../lib/cn";
33import { page } from "../../../lib/meta";
34import { skillLibrary } from "../../../lib/services.server";
35import { requireUser, roleIn } from "../../../lib/session.server";
36
37export function meta({ params, ...args }: Route.MetaArgs) {
38 return page(args, { title: `${params.name} · Skills · ${params.owner} · g1t` });
39}
40
41type Loaded =
42 | { kind: "foundational"; slug: string; skill: AgentSkill; skillMd: string }
43 | { kind: "library"; slug: string; detail: SkillDetail | null; library: SkillLibrary | null };
44
45export async function loader({ params, context, request }: Route.LoaderArgs): Promise<Loaded> {
46 const viewer = requireUser(context, request);
47 const slug = params.owner.toLowerCase();
48 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
49 const foundational = FOUNDATIONAL_SKILLS.find((s) => s.id === params.name);
50 if (foundational) return { kind: "foundational", slug, skill: foundational, skillMd: foundationalSkillMd(foundational) };
51 const version = new URL(request.url).searchParams.get("version");
52 const [found, library] = await Promise.all([
53 skillLibrary.skill(slug, viewer, params.name, version ? Number(version) : null).catch(() => null),
54 readOrNull(skillLibrary.library(slug, viewer)),
55 ]);
56 if (found && !found.ok && found.error.code === "not_found") throw data(null, { status: 404 });
57 return { kind: "library", slug, detail: found?.ok ? found.value : null, library };
58}
59
60/** Attach, detach, move a pin, or delete the skill. */
61export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
62 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
63 const intent = String(form.get("intent") ?? "");
64 const name = params.name;
65 if (intent === "attach") {
66 const scope = String(form.get("scope") ?? "") as "agent" | "team" | "workspace";
67 const target = String(form.get("target") ?? "") || null;
68 return answer(intent, skillLibrary.attachSkill(slug, viewer, name, scope, target));
69 }
70 if (intent === "detach") return answer(intent, skillLibrary.detachSkill(slug, viewer, name, String(form.get("attachment") ?? "")));
71 if (intent === "pin") {
72 const version = form.get("version");
73 return answer(intent, skillLibrary.pinSkill(slug, viewer, name, String(form.get("attachment") ?? ""), version ? Number(version) : null));
74 }
75 if (intent === "delete") {
76 const done = await skillLibrary.deleteSkill(slug, viewer, name).catch(() => null);
77 if (!done) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
78 if (!done.ok) return { ok: false, intent, error: done.error.message };
79 throw redirect(skillsPath(slug));
80 }
81 return { ok: false, intent, error: "Unknown request." };
82}
83
84export default function SkillPage({ loaderData, params }: Route.ComponentProps) {
85 const back = (
86 <Link to={skillsPath(params.owner)} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
87 <ArrowLeft size={14} />
88 Skills
89 </Link>
90 );
91 if (loaderData.kind === "foundational") return <Foundational back={back} skill={loaderData.skill} skillMd={loaderData.skillMd} />;
92 const { slug, detail, library } = loaderData;
93 if (!detail) {
94 return (
95 <div className="space-y-4">
96 {back}
97 <Card tone="plain" className="border-dashed px-6 py-14 text-center">
98 <p className="font-medium">{params.name} can&apos;t be shown 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 </Card>
101 </div>
102 );
103 }
104 const { skill } = detail;
105 const latest = detail.shown === skill.version;
106 const draft = skill.status === "draft";
107 return (
108 <div className="space-y-8 pb-4">
109 {back}
110 {!latest && (
111 <Alert asChild tone="info" className="flex flex-wrap items-center gap-x-3 gap-y-1">
112 <p>
113 <History size={15} className="shrink-0 text-info" aria-hidden />
114 You&apos;re reading version {detail.shown} of {skill.version}.
115 <Link to={skillsPath(slug, skill.name)} className="font-medium text-info hover:underline">
116 Read the newest
117 </Link>
118 </p>
119 </Alert>
120 )}
121 {draft && (
122 <Alert asChild tone="warn">
123 <p>
124 {originText(skill.origin)}, as a draft. No agent uses it until {skill.can_edit ? "you review and publish it" : "an owner or team maintainer publishes it"}.
125 </p>
126 </Alert>
127 )}
128 <header className="flex flex-wrap items-start justify-between gap-4">
129 <div className="min-w-0 grow basis-md">
130 <div className="flex flex-wrap items-center gap-2">
131 <h1 className="font-mono text-xl font-semibold tracking-tight break-all sm:text-2xl">{skill.name}</h1>
132 {draft ? <Badge tone="warn">Draft</Badge> : <Badge tone="neutral">Version {detail.shown}</Badge>}
133 {detail.requires_computer && <NeedsComputer />}
134 {skill.mirrored && <FromRepository repo={library?.mirror?.repo ?? null} />}
135 </div>
136 <p className="mt-2 max-w-3xl text-sm text-fg-soft">{detail.versions.find((v) => v.version === detail.shown)?.description || skill.description}</p>
137 <p className="mt-2 text-xs text-faint">
138 {originText(detail.versions.find((v) => v.version === detail.shown)?.origin ?? skill.origin)} · @{skill.updated_by} · <TimeAgo at={skill.updated_at} />
139 </p>
140 </div>
141 <div className="flex shrink-0 flex-wrap gap-2">
142 {skill.can_edit && (
143 <ButtonLink to={skillsPath(slug, skill.name, "/edit")} variant={draft ? "accent" : "outline"}>
144 <PenLine size={15} />
145 {draft ? "Review and publish" : "Edit"}
146 </ButtonLink>
147 )}
148 {skill.can_delete && (
149 <Confirm
150 title={draft ? `Discard the draft ${skill.name}?` : `Delete ${skill.name}?`}
151 confirm={draft ? "Discard draft" : "Delete skill"}
152 fields={{ intent: "delete" }}
153 fetcherKey={`delete-${skill.id}`}
154 trigger={
155 <Button type="button" variant="outline" className="hover:border-danger/50 hover:text-danger">
156 <Trash2 size={14} />
157 {draft ? "Discard" : "Delete"}
158 </Button>
159 }
160 >
161 {draft
162 ? "It is gone for good; the session it came from stays."
163 : `It is detached from every agent, team and the workspace (${skill.attachments.length} ${skill.attachments.length === 1 ? "place" : "places"}), and its history goes with it. Agents stop seeing it on their next reply.`}
164 </Confirm>
165 )}
166 </div>
167 </header>
168
169 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_19rem]">
170 <div className="min-w-0 space-y-6">
171 <Card asChild>
172 <section aria-labelledby="instructions">
173 <h2 id="instructions" className="border-b border-line/60 px-4 py-2.5 text-xs font-medium text-muted">
174 Instructions
175 </h2>
176 <div className="px-4 py-4 sm:px-5">
177 <Markdown source={detail.instructions} />
178 </div>
179 </section>
180 </Card>
181 {detail.files.length > 0 && (
182 <section aria-labelledby="files">
183 <h2 id="files" className="text-sm font-medium">
184 Files <span className="text-faint">{detail.files.length}</span>
185 </h2>
186 <Card asChild className="mt-3 divide-y divide-line/60 overflow-hidden">
187 <ul>
188 {detail.files.map((file) => (
189 <li key={file.path}>
190 <details className="group">
191 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2.5 text-sm select-none hover:bg-raised/40 [&::-webkit-details-marker]:hidden">
192 {file.script ? <FileCode size={14} className="shrink-0 text-faint" aria-hidden /> : <FileText size={14} className="shrink-0 text-faint" aria-hidden />}
193 <span className="min-w-0 grow truncate font-mono text-[0.8125rem]">{file.path}</span>
194 {file.script && <Badge tone="warn">Script · not run</Badge>}
195 <span className="shrink-0 text-xs text-faint">{skillSize(file.bytes)}</span>
196 </summary>
197 <div className="border-t border-line/60 bg-bg/40 px-4 py-3">
198 {file.content != null ? (
199 <pre className="max-h-96 overflow-auto text-xs leading-relaxed whitespace-pre-wrap text-fg-soft">{file.content}</pre>
200 ) : (
201 <p className="text-xs text-faint">{file.encoding === "base64" ? "Not text, so it isn't shown here." : "Too large to show here."}</p>
202 )}
203 </div>
204 </details>
205 </li>
206 ))}
207 </ul>
208 </Card>
209 </section>
210 )}
211 <Card asChild className="group">
212 <details>
213 <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
214 <FileText size={13} aria-hidden />
215 <span className="group-open:hidden">Show SKILL.md as written</span>
216 <span className="hidden group-open:inline">Hide SKILL.md</span>
217 </summary>
218 <pre className="overflow-x-auto border-t border-line/60 px-4 py-3 text-xs leading-relaxed whitespace-pre-wrap text-fg-soft">{detail.skill_md}</pre>
219 </details>
220 </Card>
221 </div>
222
223 <aside className="space-y-6">
224 <Attached detail={detail} library={library} />
225 <section aria-labelledby="tools">
226 <h2 id="tools" className="text-sm font-medium">
227 Tools it uses
228 </h2>
229 {detail.tools.length ? (
230 <ul className="mt-2 flex flex-wrap gap-1" aria-label="Tools it uses">
231 {detail.tools.map((tool) => (
232 <li key={tool} className="rounded-[5px] bg-raised px-1.5 py-px font-mono text-[0.6875rem] text-muted ring-1 ring-line ring-inset">
233 {tool}
234 </li>
235 ))}
236 </ul>
237 ) : (
238 <p className="mt-1 text-sm text-muted">None named.</p>
239 )}
240 <p className="mt-2 text-xs text-faint">A skill never adds a tool. An agent without one of these is told that part doesn&apos;t work where it is asked.</p>
241 </section>
242 <Versions detail={detail} slug={slug} />
243 </aside>
244 </div>
245 </div>
246 );
247}
248
249function Attached({ detail, library }: { detail: SkillDetail; library: SkillLibrary | null }) {
250 const { skill } = detail;
251 const pin = useFetcher<ActionResult>({ key: `pin-${skill.id}` });
252 const detach = useFetcher<ActionResult>({ key: `detach-${skill.id}` });
253 const error = [pin, detach].map((f) => (f.state === "idle" && f.data && !f.data.ok ? f.data.error : null)).find(Boolean);
254 const canAttach = !!library && skill.status === "published" && (library.can_manage || library.teams.length > 0);
255 return (
256 <section aria-labelledby="attached">
257 <div className="flex items-center justify-between gap-2">
258 <h2 id="attached" className="text-sm font-medium">
259 Attached to
260 </h2>
261 {canAttach && (
262 <AttachDialog
263 skill={skill}
264 library={library}
265 trigger={
266 <Button type="button" variant="outline" size="sm">
267 <Plus size={13} />
268 Attach
269 </Button>
270 }
271 />
272 )}
273 </div>
274 {skill.status === "draft" ? (
275 <p className="mt-1 text-sm text-muted">Nowhere: publish it first.</p>
276 ) : skill.attachments.length === 0 ? (
277 <p className="mt-1 text-sm text-muted">Nowhere yet. No agent sees it until it is attached.</p>
278 ) : (
279 <ul className="mt-2 space-y-1.5">
280 {skill.attachments.map((a) => (
281 <Card asChild key={a.id} radius="lg" className="flex flex-wrap items-center gap-2 px-2.5 py-2">
282 <li>
283 <span className="min-w-0 grow">
284 <AttachmentChip attachment={a} />
285 <span className={cn("ml-2 text-xs", a.version < skill.version ? "text-warn" : "text-faint")}>v{a.version}</span>
286 </span>
287 {a.can_change && a.version < skill.version && (
288 <pin.Form method="post">
289 <input type="hidden" name="intent" value="pin" />
290 <input type="hidden" name="attachment" value={a.id} />
291 <Hint label={`Move ${a.label} to version ${skill.version}`}>
292 <Button type="submit" variant="link" size="xs" className="hover:bg-accent/10" disabled={pin.state !== "idle"}>
293 <ArrowUpCircle size={13} />
294 Update to v{skill.version}
295 </Button>
296 </Hint>
297 </pin.Form>
298 )}
299 {a.can_change && (
300 <detach.Form method="post">
301 <input type="hidden" name="intent" value="detach" />
302 <input type="hidden" name="attachment" value={a.id} />
303 <Hint label={`Detach from ${a.label}`}>
304 <Button type="submit" aria-label={`Detach from ${a.label}`} variant="ghost" size="icon-xs" className="text-faint hover:text-danger" disabled={detach.state !== "idle"}>
305 <X size={14} />
306 </Button>
307 </Hint>
308 </detach.Form>
309 )}
310 </li>
311 </Card>
312 ))}
313 </ul>
314 )}
315 {error && (
316 <p role="alert" className="mt-2 text-sm text-danger">
317 {error}
318 </p>
319 )}
320 </section>
321 );
322}
323
324function Versions({ detail, slug }: { detail: SkillDetail; slug: string }) {
325 if (detail.skill.status === "draft") return null;
326 return (
327 <section aria-labelledby="versions">
328 <h2 id="versions" className="text-sm font-medium">
329 Versions <span className="text-faint">{detail.versions.length}</span>
330 </h2>
331 <ol className="mt-2 space-y-px">
332 {detail.versions.map((v) => {
333 const shown = v.version === detail.shown;
334 return (
335 <li key={v.version}>
336 <Link
337 to={v.version === detail.skill.version ? skillsPath(slug, detail.skill.name) : `${skillsPath(slug, detail.skill.name)}?version=${v.version}`}
338 aria-current={shown ? "page" : undefined}
339 className={cn("block rounded-md px-2.5 py-2 text-sm transition-colors", shown ? "bg-raised" : "hover:bg-raised/60")}
340 >
341 <span className="flex items-baseline justify-between gap-2">
342 <span className="font-medium text-fg">Version {v.version}</span>
343 <span className="shrink-0 text-xs text-faint">
344 <TimeAgo at={v.created_at} />
345 </span>
346 </span>
347 <span className="mt-0.5 block truncate text-xs text-muted">{v.note ?? originText(v.origin)}</span>
348 <span className="block text-xs text-faint">
349 @{v.created_by} · {skillSize(v.bytes)}
350 {v.files ? ` · ${v.files} ${v.files === 1 ? "file" : "files"}` : ""}
351 </span>
352 </Link>
353 </li>
354 );
355 })}
356 </ol>
357 </section>
358 );
359}
360
361function Foundational({ back, skill, skillMd }: { back: ReactNode; skill: AgentSkill; skillMd: string }) {
362 const split = splitFrontMatter(skillMd);
363 return (
364 <div className="space-y-8 pb-4">
365 {back}
366 <header>
367 <div className="flex flex-wrap items-center gap-2">
368 <h1 className="font-mono text-xl font-semibold tracking-tight sm:text-2xl">{skill.id}</h1>
369 <Badge tone="accent">From g1t</Badge>
370 <Badge tone="neutral">Version {skill.version}</Badge>
371 </div>
372 <p className="mt-2 max-w-3xl text-sm text-fg-soft">{skill.when}</p>
373 <p className="mt-2 text-xs text-faint">Every agent has it, updated with g1t&apos;s releases. Owners turn it off for one agent on that agent&apos;s Skills tab.</p>
374 </header>
375 <Card asChild>
376 <section aria-labelledby="instructions">
377 <h2 id="instructions" className="border-b border-line/60 px-4 py-2.5 text-xs font-medium text-muted">
378 Instructions
379 </h2>
380 <div className="px-4 py-4 sm:px-5">
381 <Markdown source={split.ok ? split.body : skillMd} />
382 </div>
383 </section>
384 </Card>
385 <Card asChild className="group">
386 <details>
387 <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
388 <FileText size={13} aria-hidden />
389 <span className="group-open:hidden">Show SKILL.md</span>
390 <span className="hidden group-open:inline">Hide SKILL.md</span>
391 </summary>
392 <pre className="overflow-x-auto border-t border-line/60 px-4 py-3 text-xs leading-relaxed whitespace-pre-wrap text-fg-soft">{skillMd}</pre>
393 </details>
394 </Card>
395 </div>
396 );
397}