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