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.

455 linesCodeBlameRaw
1/**
2 * Customize → Skills (docs.g1t.sh/guides/customize/, "Skills"). Yours: the
3 * workspace's library, every skill it wrote, imported, saved from a session,
4 * follows from a repository or got from a plugin, each saying which agents
5 * it reaches with the control that changes that; the drafts waiting for
6 * review first; g1t's foundational skills, which every agent has; and the
7 * repository the library follows. Discover: every skill in every
8 * marketplace's plugins, grouped by marketplace; Add installs the plugin
9 * that brings it, then the skill is given to agents like any other.
10 */
11import { BookOpen, ChevronRight, GitBranch, Plus, RefreshCw } from "lucide-react";
12import { Link, data, useFetcher, useSearchParams } from "react-router";
13
14import { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILLS_VERSION, type LibrarySkill, type SkillLibrary, type SkillListing, type SkillMirror } from "@g1t/contracts";
15import { customizePath } from "@g1t/contracts/customize";
16
17import type { Route } from "./+types/skills";
18import { useCustomize } from "./layout";
19import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
20import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
21import { FromRepository, NeedsComputer, originText, skillsPath } from "../../../components/agents/skills";
22import { AvailabilityBadge, AvailableTo, FeaturedBanner, FilterMenu, ListingCard, ListingGrid, ListingMark, NoAnswer, RequestButton, SectionHead, Waiting } from "../../../components/customize";
23import { ButtonLink, EmptyState, SubmitButton, TimeAgo } from "../../../components/ui";
24import { Badge } from "../../../components/ui/badge";
25import { Button } from "../../../components/ui/button";
26import { Card } from "../../../components/ui/card";
27import { Input } from "../../../components/ui/input";
28import { type Reach, type ReachOption, categoriesOf, filterSkills, groupByMarketplace, nextParams, pluginPath, readQuery, readView, requestedBy, skillListingRef, splitTarget, waitingFor, withParams } from "../../../lib/customize";
29import { page } from "../../../lib/meta";
30import { skillLibrary, workspaceAgents } from "../../../lib/services.server";
31import { requireUser, roleIn } from "../../../lib/session.server";
32
33export { shouldRevalidate } from "../../../lib/customize-revalidate";
34
35export function meta({ params, ...args }: Route.MetaArgs) {
36 return page(args, { title: `Skills · Customize · ${params.owner} · g1t` });
37}
38
39export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ library: SkillLibrary | null; discover: SkillListing[] | null | undefined }> {
40 const viewer = requireUser(context, request);
41 const slug = params.owner.toLowerCase();
42 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
43 const discovering = readView(new URL(request.url).searchParams) === "discover";
44 const [library, discover] = await Promise.all([
45 readOrNull(skillLibrary.library(slug, viewer)),
46 // Only Discover reads the marketplaces; undefined says it wasn't asked for.
47 discovering ? readOrNull(workspaceAgents.discover(slug, viewer)).then((found) => found?.skills ?? null) : Promise.resolve(undefined),
48 ]);
49 return { library, discover };
50}
51
52/**
53 * Give a skill to every agent, a team or one agent, or take it back; move
54 * an attachment to the newest version; install the plugin that brings a
55 * skill from Discover; link, read again or unlink the repository the
56 * library follows.
57 */
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 = String(form.get("name") ?? "");
62 switch (intent) {
63 case "attach": {
64 const { scope, target } = splitTarget(String(form.get("where") ?? ""));
65 return answer(intent, skillLibrary.attachSkill(slug, viewer, name, scope, target));
66 }
67 case "detach":
68 return answer(intent, skillLibrary.detachSkill(slug, viewer, name, String(form.get("attachment") ?? "")));
69 case "pin":
70 return answer(intent, skillLibrary.pinSkill(slug, viewer, name, String(form.get("attachment") ?? ""), null));
71 case "add":
72 return answer(intent, workspaceAgents.installPlugin(slug, viewer, String(form.get("plugin") ?? "")));
73 case "link": {
74 const repo = String(form.get("repo") ?? "").trim();
75 if (!repo) return { ok: false, intent, error: "Name the repository as workspace/name." };
76 return answer(intent, skillLibrary.setMirror(slug, viewer, repo));
77 }
78 case "unlink":
79 return answer(intent, skillLibrary.setMirror(slug, viewer, null));
80 case "sync":
81 return answer(intent, skillLibrary.syncMirror(slug, viewer));
82 default:
83 return { ok: false, intent, error: "Unknown request." };
84 }
85}
86
87/** Where a skill reaches, as the control reads it. */
88function reachOf(skill: LibrarySkill): Reach[] {
89 return skill.attachments.map((a) => ({ id: a.id, scope: a.scope, target: a.target, label: a.label }));
90}
91
92/** Who the viewer may give skills to: owners anyone, team maintainers their teams; null for everyone else. */
93function targetsOf(library: SkillLibrary) {
94 return library.can_manage || library.teams.length > 0 ? { can_manage: library.can_manage, teams: library.teams, agents: library.agents } : null;
95}
96
97export default function CustomizeSkills({ loaderData }: Route.ComponentProps) {
98 const [params] = useSearchParams();
99 return readView(params) === "discover" ? <Discover library={loaderData.library} listings={loaderData.discover ?? null} /> : <Yours library={loaderData.library} />;
100}
101
102// ── Yours ────────────────────────────────────────────────────────────────
103
104function Yours({ library }: { library: SkillLibrary | null }) {
105 const { slug } = useCustomize();
106 const [params] = useSearchParams();
107 const query = readQuery(params).toLowerCase();
108 if (!library) return <NoAnswer what="The library" />;
109 const matching = (skill: LibrarySkill) => !query || [skill.name, skill.qualified_name, skill.description, skill.plugin ?? ""].some((text) => text.toLowerCase().includes(query));
110 const drafts = library.skills.filter((s) => s.status === "draft" && matching(s));
111 const published = library.skills.filter((s) => s.status === "published" && matching(s));
112 const foundational = FOUNDATIONAL_SKILLS.filter((skill) => !query || skill.name.toLowerCase().includes(query) || skill.description.toLowerCase().includes(query));
113 const targets = targetsOf(library);
114 return (
115 <div className="space-y-10">
116 {drafts.length > 0 && (
117 <section aria-labelledby="drafts">
118 <SectionHead id="drafts" title="Drafts to review" aside={`${drafts.length}`}>
119 Saved from finished sessions. No agent uses a draft until someone who writes skills reviews and publishes it.
120 </SectionHead>
121 <Card asChild divided className="overflow-hidden border-warn/30">
122 <ul>
123 {drafts.map((skill) => (
124 <SkillRow key={skill.id} slug={slug} skill={skill} targets={null} mirror={library.mirror} />
125 ))}
126 </ul>
127 </Card>
128 </section>
129 )}
130
131 <section aria-labelledby="library">
132 <SectionHead id="library" title="Your workspace's skills" aside={`${published.length}`}>
133 How your agents do a kind of work, in the open SKILL.md format. A skill reaches the agents it is available to; they see its name and when to use it, and read the rest when a request matches.
134 </SectionHead>
135 {published.length === 0 ? (
136 <EmptyState title={query ? "No skill matches" : "No skills yet"}>
137 {query ? (
138 "Try another word, or look in Discover."
139 ) : library.can_write ? (
140 <>
141 Write one, such as how you cut a release or your brand voice, import a SKILL.md or a zip, save a finished session as a skill, or add public skills from{" "}
142 <Link to={withParams(customizePath("skills", slug), params, { view: "discover" })} className="text-accent hover:underline">
143 Discover
144 </Link>
145 .
146 </>
147 ) : (
148 "Owners and team maintainers write skills. You can save a finished session as a skill for them to review, or ask for a public skill in Discover."
149 )}
150 </EmptyState>
151 ) : (
152 <Card asChild divided className="overflow-hidden">
153 <ul>
154 {published.map((skill) => (
155 <SkillRow key={skill.id} slug={slug} skill={skill} targets={targets} mirror={library.mirror} />
156 ))}
157 </ul>
158 </Card>
159 )}
160 </section>
161
162 <section aria-labelledby="foundational">
163 <SectionHead id="foundational" title="From g1t" aside={`Version ${FOUNDATIONAL_SKILLS_VERSION}, updated with every release`}>
164 Every agent has these. Owners turn one off for an agent on its Skills tab.
165 </SectionHead>
166 <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
167 {foundational.map((skill) => (
168 <li key={skill.id}>
169 <Card asChild className="flex h-full items-start gap-3 px-4 py-3 transition-colors hover:border-line-strong">
170 <Link to={skillsPath(slug, skill.id)} prefetch="intent">
171 <BookOpen size={15} className="mt-0.5 shrink-0 text-faint" aria-hidden />
172 <span className="min-w-0">
173 <span className="block font-mono text-[0.8125rem] font-medium text-fg">{skill.id}</span>
174 <span className="mt-0.5 block text-xs text-muted">{skill.description}</span>
175 </span>
176 </Link>
177 </Card>
178 </li>
179 ))}
180 </ul>
181 </section>
182
183 <MirrorPanel slug={slug} mirror={library.mirror} canManage={library.can_manage} canWrite={library.can_write} />
184 </div>
185 );
186}
187
188/** A library skill as a row: its name, what it is, where it came from, and the agents it is available to. */
189function SkillRow({ slug, skill, targets, mirror }: { slug: string; skill: LibrarySkill; targets: ReturnType<typeof targetsOf>; mirror: SkillMirror | null }) {
190 const fetcher = useFetcher<ActionResult>({ key: `skill-reach-${skill.id}` });
191 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
192 const behind = skill.attachments.filter((a) => a.version < skill.version);
193 const draft = skill.status === "draft";
194 const to = skillsPath(slug, skill.qualified_name, draft && skill.can_edit ? "/edit" : "");
195 const change = (option: ReachOption, checked: boolean) => {
196 if (checked) fetcher.submit({ intent: "attach", name: skill.qualified_name, where: option.value }, { method: "post" });
197 else if (option.reach) fetcher.submit({ intent: "detach", name: skill.qualified_name, attachment: option.reach.id }, { method: "post" });
198 };
199 return (
200 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3.5 sm:flex-nowrap">
201 <div className="min-w-0 grow">
202 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
203 <Link to={to} prefetch="intent" className="font-mono text-sm font-medium text-fg hover:underline">
204 {skill.qualified_name}
205 </Link>
206 {draft ? <Badge tone="warn">Draft</Badge> : <span className="text-xs text-faint">v{skill.version}</span>}
207 {skill.plugin && (
208 <Link to={pluginPath(slug, skill.plugin)} className="inline-flex" prefetch="intent">
209 <Badge tone="accent">from {skill.plugin}</Badge>
210 </Link>
211 )}
212 {skill.requires_computer && <NeedsComputer />}
213 {skill.mirrored && <FromRepository repo={mirror?.repo ?? null} />}
214 </div>
215 <p className="mt-0.5 line-clamp-2 text-sm text-muted">{skill.description}</p>
216 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
217 {draft ? <span className="text-xs text-faint">{originText(skill.origin)}</span> : <AvailableTo reach={reachOf(skill)} targets={targets} busy={fetcher.state !== "idle"} onChange={change} />}
218 {behind.length > 0 && (
219 <fetcher.Form method="post" className="inline-flex items-center gap-1.5">
220 <input type="hidden" name="intent" value="pin" />
221 <input type="hidden" name="name" value={skill.qualified_name} />
222 <input type="hidden" name="attachment" value={behind[0]!.id} />
223 <SubmitButton variant="link" size="inline" fetcher={fetcher} match={{ intent: "pin", attachment: behind[0]!.id }} pending="Updating…" className="text-xs text-warn">
224 Update available on {behind.length === 1 ? behind[0]!.label : `${behind.length} attachments`}
225 </SubmitButton>
226 </fetcher.Form>
227 )}
228 </div>
229 {error && <p className="mt-1.5 text-sm text-danger">{error}</p>}
230 </div>
231 <div className="flex shrink-0 items-center gap-3 text-xs text-faint sm:flex-col sm:items-end">
232 <p>
233 @{skill.updated_by} · <TimeAgo at={skill.updated_at} />
234 </p>
235 <Link to={to} aria-label={`Open ${skill.qualified_name}`} className="hidden text-faint hover:text-fg sm:block">
236 <ChevronRight size={16} aria-hidden />
237 </Link>
238 </div>
239 </li>
240 );
241}
242
243/** The repository the library follows: `.claude/skills/<name>/` on its default branch, read after every push. */
244function MirrorPanel({ slug, mirror, canManage, canWrite }: { slug: string; mirror: SkillMirror | null; canManage: boolean; canWrite: boolean }) {
245 const link = useFetcher<ActionResult>({ key: "skills-link" });
246 const sync = useFetcher<ActionResult>({ key: "skills-sync" });
247 const linkError = link.state === "idle" && link.data && !link.data.ok ? link.data.error : null;
248 const syncError = sync.state === "idle" && sync.data && !sync.data.ok ? sync.data.error : null;
249 const problems = mirror?.error?.split("\n").filter(Boolean) ?? [];
250 return (
251 <Card asChild>
252 <section aria-labelledby="repository">
253 <div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3.5">
254 <div className="flex min-w-0 items-start gap-3">
255 <GitBranch size={16} className="mt-0.5 shrink-0 text-muted" aria-hidden />
256 <div className="min-w-0">
257 <h2 id="repository" className="text-sm font-medium">
258 Keep skills in a repository
259 </h2>
260 {mirror ? (
261 <p className="mt-0.5 text-sm text-muted">
262 Follows{" "}
263 <Link to={`/${mirror.repo}/tree/${mirror.branch}/.claude/skills`} className="font-mono text-fg hover:underline">
264 {mirror.repo}
265 </Link>
266 : each folder in <code className="font-mono text-[0.8125rem]">.claude/skills/</code> on {mirror.branch} is a skill, and a push there publishes a new version.
267 {mirror.synced_at && (
268 <span className="text-faint">
269 {" "}
270 Read <TimeAgo at={mirror.synced_at} />
271 {mirror.commit ? ` at ${mirror.commit.slice(0, 7)}` : ""}.
272 </span>
273 )}
274 </p>
275 ) : (
276 <p className="mt-0.5 max-w-2xl text-sm text-muted">
277 Optional. Link a repository and each <code className="font-mono text-[0.8125rem]">.claude/skills/&lt;name&gt;/</code> folder on its default branch becomes a skill here, updated by every push, so skills go
278 through the same pull requests and reviews as code.
279 </p>
280 )}
281 </div>
282 </div>
283 {mirror && (
284 <div className="flex shrink-0 flex-wrap gap-2">
285 {canWrite && (
286 <sync.Form method="post">
287 <input type="hidden" name="intent" value="sync" />
288 <SubmitButton variant="outline" size="sm" fetcher={sync} match={{ intent: "sync" }} pending="Reading…">
289 <RefreshCw size={13} />
290 Read it again
291 </SubmitButton>
292 </sync.Form>
293 )}
294 {canManage && (
295 <Confirm
296 title="Stop following the repository?"
297 confirm="Stop following"
298 fields={{ intent: "unlink" }}
299 fetcherKey="skills-unlink"
300 trigger={
301 <Button type="button" variant="outline" size="sm" className="hover:border-danger/50 hover:text-danger">
302 Stop following
303 </Button>
304 }
305 >
306 Its skills stay in the library as they are, and can be edited here again. Pushes to {mirror.repo} no longer change them.
307 </Confirm>
308 )}
309 </div>
310 )}
311 </div>
312 {(problems.length > 0 || syncError) && (
313 <ul className="space-y-1 border-t border-line/60 px-4 py-3 text-sm text-warn" aria-label="What couldn't be read">
314 {syncError && <li>{syncError}</li>}
315 {problems.map((p) => (
316 <li key={p}>{p}</li>
317 ))}
318 </ul>
319 )}
320 {!mirror && canManage && (
321 <link.Form method="post" className="flex flex-wrap items-start gap-2 border-t border-line/60 px-4 py-3">
322 <input type="hidden" name="intent" value="link" />
323 <label htmlFor="skills-repo" className="sr-only">
324 Repository
325 </label>
326 <Input id="skills-repo" name="repo" placeholder={`${slug}/agents`} autoComplete="off" spellCheck={false} className="max-w-xs grow font-mono" />
327 <SubmitButton variant="outline" fetcher={link} match={{ intent: "link" }} pending="Linking…">
328 Link repository
329 </SubmitButton>
330 {linkError && <p className="w-full text-sm text-danger">{linkError}</p>}
331 </link.Form>
332 )}
333 </section>
334 </Card>
335 );
336}
337
338// ── Discover ─────────────────────────────────────────────────────────────
339
340function Discover({ library, listings }: { library: SkillLibrary | null; listings: SkillListing[] | null }) {
341 const { slug, owner, username, requests } = useCustomize();
342 const [params, setParams] = useSearchParams();
343 if (!listings) return <NoAnswer what="Public skills" />;
344 const category = params.get("category") || null;
345 const shown = filterSkills(listings, { q: readQuery(params), category });
346 const categories = categoriesOf(listings);
347 const marketplaces = new Set(listings.map((listing) => listing.marketplace)).size;
348 const grouped = groupByMarketplace(shown);
349 const targets = library ? targetsOf(library) : null;
350 return (
351 <div className="space-y-8">
352 <FeaturedBanner
353 title="Public skills, as they are"
354 action={
355 <ButtonLink to={customizePath("marketplaces", slug)} variant="outline" size="sm">
356 {marketplaces} {marketplaces === 1 ? "marketplace" : "marketplaces"}
357 </ButtonLink>
358 }
359 >
360 Every skill published in the marketplaces this workspace reads, in the open SKILL.md format. Adding one installs the plugin that brings it; then you say which agents it is available to.
361 </FeaturedBanner>
362 <div className="flex flex-wrap items-center gap-2">
363 {categories.length > 0 && (
364 <FilterMenu label="Category" all="All" value={category} options={categories.map((c) => ({ value: c, label: c }))} onChange={(next) => setParams(nextParams(params, { category: next }), { replace: true, preventScrollReset: true })} />
365 )}
366 <span className="text-xs text-faint">
367 {shown.length} of {listings.length}
368 </span>
369 </div>
370 {shown.length === 0 ? (
371 <EmptyState title="No skill matches">Try another word or category, or add a marketplace that lists it.</EmptyState>
372 ) : (
373 grouped.map((group) => (
374 <section key={group.marketplace} aria-labelledby={`from-${group.marketplace}`}>
375 <SectionHead id={`from-${group.marketplace}`} title={`From ${group.marketplace}`} aside={`${group.items.length}`} />
376 <ListingGrid>
377 {group.items.map((listing) => (
378 <SkillCard key={listing.ref} listing={listing} slug={slug} owner={owner} requested={requestedBy(requests?.requests ?? [], skillListingRef(listing), username)} waiting={owner ? waitingFor(requests?.requests ?? [], skillListingRef(listing)) : 0} library={library} targets={targets} />
379 ))}
380 </ListingGrid>
381 </section>
382 ))
383 )}
384 </div>
385 );
386}
387
388/** A skill in Discover: what it is, the plugin that brings it, and Add, Request, or where it is available once added. */
389function SkillCard({
390 listing,
391 slug,
392 owner,
393 requested,
394 waiting,
395 library,
396 targets,
397}: {
398 listing: SkillListing;
399 slug: string;
400 owner: boolean;
401 requested: boolean;
402 waiting: number;
403 library: SkillLibrary | null;
404 targets: ReturnType<typeof targetsOf>;
405}) {
406 const fetcher = useFetcher<ActionResult>({ key: `discover-skill-${listing.ref}` });
407 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
408 const pluginRef = `${listing.plugin}@${listing.marketplace}`;
409 const added = library?.skills.find((skill) => skill.plugin === pluginRef && skill.name.toLowerCase() === listing.name.toLowerCase()) ?? null;
410 const available = listing.availability.kind === "available" || listing.availability.kind === "installed";
411 const change = (option: ReachOption, checked: boolean) => {
412 if (!added) return;
413 if (checked) fetcher.submit({ intent: "attach", name: added.qualified_name, where: option.value }, { method: "post" });
414 else if (option.reach) fetcher.submit({ intent: "detach", name: added.qualified_name, attachment: option.reach.id }, { method: "post" });
415 };
416 const action = added ? (
417 <AvailableTo reach={reachOf(added)} targets={targets} busy={fetcher.state !== "idle"} onChange={change} />
418 ) : !available ? null : owner ? (
419 <fetcher.Form method="post">
420 <input type="hidden" name="intent" value="add" />
421 <input type="hidden" name="plugin" value={pluginRef} />
422 <SubmitButton size="xs" fetcher={fetcher} match={{ intent: "add", plugin: pluginRef }} pending="Adding…">
423 <Plus size={13} />
424 Add
425 </SubmitButton>
426 </fetcher.Form>
427 ) : (
428 <RequestButton slug={slug} listing={skillListingRef(listing)} name={listing.name} requested={requested} size="xs" />
429 );
430 return (
431 <ListingCard
432 mark={<ListingMark id={listing.ref} name={listing.name} />}
433 title={<span className="font-mono">{listing.name}</span>}
434 to={pluginPath(slug, pluginRef)}
435 description={listing.description}
436 badge={added || listing.added ? <Badge tone="success">Added</Badge> : !available ? <AvailabilityBadge availability={listing.availability} /> : null}
437 muted={!available}
438 meta={
439 <>
440 From {listing.plugin}
441 {listing.author ? ` · by ${listing.author}` : ""}
442 {waiting > 0 && (
443 <>
444 {" · "}
445 <Waiting slug={slug} count={waiting} />
446 </>
447 )}
448 </>
449 }
450 action={action}
451 >
452 {error && <p className="text-xs text-danger">{error}</p>}
453 </ListingCard>
454 );
455}