Skip to content
306 linesCodeBlameRaw
1/**
2 * Agents → Skills: the workspace's skill library (docs.g1t.sh/guides/agent-skills/).
3 * Every skill it wrote, imported, saved from a session or follows from a
4 * repository, where each is attached, and g1t's foundational skills; the
5 * drafts waiting for review first. Owners link the repository it follows.
6 */
7import { ArrowLeft, BookOpen, ChevronRight, FileUp, GitBranch, PenLine, RefreshCw, Store } from "lucide-react";
8import { Link, data, useFetcher } from "react-router";
9
10import { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILLS_VERSION, type LibrarySkill, type SkillLibrary, type SkillMirror } from "@g1t/contracts";
11
12import type { Route } from "./+types/skill-library";
13import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
14import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
15import { AttachmentChip, FromRepository, NeedsComputer, originText, skillsPath } from "../../../components/agents/skills";
16import { ButtonLink, EmptyState, TimeAgo } from "../../../components/ui";
17import { Badge } from "../../../components/ui/badge";
18import { Button } from "../../../components/ui/button";
19import { Card } from "../../../components/ui/card";
20import { Input } from "../../../components/ui/input";
21import { page } from "../../../lib/meta";
22import { skillLibrary } from "../../../lib/services.server";
23import { requireUser, roleIn } from "../../../lib/session.server";
24
25export function meta({ params, ...args }: Route.MetaArgs) {
26 return page(args, { title: `Skills · Agents · ${params.owner} · g1t` });
27}
28
29export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ slug: string; library: SkillLibrary | null }> {
30 const viewer = requireUser(context, request);
31 const slug = params.owner.toLowerCase();
32 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
33 return { slug, library: await readOrNull(skillLibrary.library(slug, viewer)) };
34}
35
36/** Link the repository the library follows, read it again, or stop following it. */
37export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
38 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
39 const intent = String(form.get("intent") ?? "");
40 if (intent === "link") {
41 const repo = String(form.get("repo") ?? "").trim();
42 if (!repo) return { ok: false, intent, error: "Name the repository as workspace/name." };
43 return answer(intent, skillLibrary.setMirror(slug, viewer, repo));
44 }
45 if (intent === "unlink") return answer(intent, skillLibrary.setMirror(slug, viewer, null));
46 if (intent === "sync") return answer(intent, skillLibrary.syncMirror(slug, viewer));
47 return { ok: false, intent, error: "Unknown request." };
48}
49
50export default function SkillLibraryPage({ loaderData }: Route.ComponentProps) {
51 const { slug, library } = loaderData;
52 const drafts = library?.skills.filter((s) => s.status === "draft") ?? [];
53 const published = library?.skills.filter((s) => s.status === "published") ?? [];
54 return (
55 <div className="pb-4">
56 <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
57 <ArrowLeft size={14} />
58 Agents
59 </Link>
60 <header className="mt-4 mb-8 flex flex-wrap items-end justify-between gap-4">
61 <div className="min-w-0 grow basis-lg">
62 <h1 className="text-2xl font-semibold tracking-tight">Skills</h1>
63 <p className="mt-1.5 max-w-2xl text-sm text-muted">
64 How your agents do a kind of work, in the open SKILL.md format: a name, when to use it, and instructions, with files if it needs them. Attach a skill to an agent,
65 a team or every agent. Agents see each skill&apos;s name and when to use it, and read the rest when a request matches. A skill never gives an agent a tool or
66 access it doesn&apos;t have.
67 </p>
68 </div>
69 {library?.can_write && (
70 <div className="flex shrink-0 flex-wrap gap-2">
71 <ButtonLink to={skillsPath(slug, undefined, "/import")} variant="outline">
72 <FileUp size={15} />
73 Import
74 </ButtonLink>
75 <ButtonLink to={skillsPath(slug, undefined, "/new")} variant="accent">
76 <PenLine size={15} />
77 Write a skill
78 </ButtonLink>
79 </div>
80 )}
81 </header>
82
83 {!library ? (
84 <EmptyState title="The library can't be shown right now">The agents service didn&apos;t answer. Reload in a minute.</EmptyState>
85 ) : (
86 <div className="space-y-10">
87 {drafts.length > 0 && (
88 <section aria-labelledby="drafts">
89 <h2 id="drafts" className="text-sm font-medium">
90 Drafts to review <span className="text-faint">{drafts.length}</span>
91 </h2>
92 <p className="mt-1 text-xs text-faint">Saved from finished sessions. No agent uses a draft until someone who writes skills reviews and publishes it.</p>
93 <ul className="mt-3 divide-y divide-line/60 overflow-hidden rounded-xl border border-warn/30 bg-surface">
94 {drafts.map((skill) => (
95 <SkillRow key={skill.id} slug={slug} skill={skill} />
96 ))}
97 </ul>
98 </section>
99 )}
100
101 <section aria-labelledby="library">
102 <h2 id="library" className="text-sm font-medium">
103 Your workspace&apos;s skills <span className="text-faint">{published.length}</span>
104 </h2>
105 {published.length === 0 ? (
106 <Card tone="plain" className="mt-3 border-dashed px-6 py-10 text-center">
107 <p className="font-medium">No skills yet</p>
108 <p className="mx-auto mt-1.5 max-w-md text-sm text-muted">
109 {library.can_write
110 ? "Write one, such as how you cut a release or your brand voice, import a SKILL.md or a zip, or save a finished session as a skill."
111 : "Owners and team maintainers write skills. You can save a finished session as a skill for them to review."}
112 </p>
113 </Card>
114 ) : (
115 <Card asChild className="mt-3 divide-y divide-line/60 overflow-hidden">
116 <ul>
117 {published.map((skill) => (
118 <SkillRow key={skill.id} slug={slug} skill={skill} mirror={library.mirror} />
119 ))}
120 </ul>
121 </Card>
122 )}
123 </section>
124
125 <section aria-labelledby="foundational">
126 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
127 <h2 id="foundational" className="text-sm font-medium">
128 From g1t <span className="text-faint">{FOUNDATIONAL_SKILLS.length}</span>
129 </h2>
130 <p className="text-xs text-faint">Version {FOUNDATIONAL_SKILLS_VERSION}, updated with every release</p>
131 </div>
132 <p className="mt-1 text-xs text-faint">Every agent has these. Owners turn one off for an agent on its Skills tab.</p>
133 <ul className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
134 {FOUNDATIONAL_SKILLS.map((skill) => (
135 <li key={skill.id}>
136 <Link
137 to={skillsPath(slug, skill.id)}
138 className="flex h-full items-start gap-3 rounded-xl border border-line bg-surface px-4 py-3 transition-colors hover:border-line-strong hover:bg-raised/40"
139 >
140 <BookOpen size={15} className="mt-0.5 shrink-0 text-faint" aria-hidden />
141 <span className="min-w-0">
142 <span className="block font-mono text-[0.8125rem] font-medium text-fg">{skill.id}</span>
143 <span className="mt-0.5 block text-xs text-muted">{skill.description}</span>
144 </span>
145 </Link>
146 </li>
147 ))}
148 </ul>
149 </section>
150
151 <MirrorPanel slug={slug} mirror={library.mirror} canManage={library.can_manage} canWrite={library.can_write} />
152
153 <Card asChild className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
154 <section aria-labelledby="marketplace">
155 <div className="flex min-w-0 items-start gap-3">
156 <Store size={16} className="mt-0.5 shrink-0 text-muted" aria-hidden />
157 <div className="min-w-0">
158 <h2 id="marketplace" className="text-sm font-medium">
159 From the Marketplace
160 </h2>
161 <p className="mt-0.5 text-sm text-muted">Skills that extensions bring, added to the library in one step.</p>
162 </div>
163 </div>
164 <Badge tone="neutral">Coming</Badge>
165 </section>
166 </Card>
167 </div>
168 )}
169 </div>
170 );
171}
172
173function SkillRow({ slug, skill, mirror }: { slug: string; skill: LibrarySkill; mirror?: SkillMirror | null }) {
174 const behind = skill.attachments.filter((a) => a.version < skill.version).length;
175 return (
176 <li>
177 <Link to={skillsPath(slug, skill.name, skill.status === "draft" && skill.can_edit ? "/edit" : "")} className="group flex items-start gap-3 px-4 py-3.5 transition-colors hover:bg-raised/40">
178 <div className="min-w-0 grow">
179 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
180 <span className="font-mono text-sm font-medium text-fg">{skill.name}</span>
181 {skill.status === "draft" ? <Badge tone="warn">Draft</Badge> : <span className="text-xs text-faint">v{skill.version}</span>}
182 {skill.requires_computer && <NeedsComputer plain />}
183 {skill.mirrored && <FromRepository plain repo={mirror?.repo ?? null} />}
184 </div>
185 <p className="mt-0.5 line-clamp-2 text-sm text-muted">{skill.description}</p>
186 <div className="mt-2 flex flex-wrap items-center gap-1.5">
187 {skill.status === "draft" ? (
188 <span className="text-xs text-faint">{originText(skill.origin)}</span>
189 ) : skill.attachments.length ? (
190 skill.attachments.slice(0, 4).map((a) => <AttachmentChip key={a.id} attachment={a} latest={skill.version} />)
191 ) : (
192 <span className="text-xs text-faint">Not attached yet</span>
193 )}
194 {skill.attachments.length > 4 && <span className="text-xs text-faint">and {skill.attachments.length - 4} more</span>}
195 {behind > 0 && <span className="text-xs text-warn">Update available on {behind}</span>}
196 </div>
197 </div>
198 <div className="hidden shrink-0 text-right text-xs text-faint sm:block">
199 <p>
200 @{skill.updated_by} · <TimeAgo at={skill.updated_at} />
201 </p>
202 </div>
203 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint group-hover:text-fg" aria-hidden />
204 </Link>
205 </li>
206 );
207}
208
209/** The repository the library follows, read after every push there; writing back to it is coming. */
210function MirrorPanel({ slug, mirror, canManage, canWrite }: { slug: string; mirror: SkillMirror | null; canManage: boolean; canWrite: boolean }) {
211 const link = useFetcher<ActionResult>({ key: "skills-link" });
212 const sync = useFetcher<ActionResult>({ key: "skills-sync" });
213 const linkError = link.state === "idle" && link.data && !link.data.ok ? link.data.error : null;
214 const syncError = sync.state === "idle" && sync.data && !sync.data.ok ? sync.data.error : null;
215 const problems = mirror?.error?.split("\n").filter(Boolean) ?? [];
216 return (
217 <Card asChild>
218 <section aria-labelledby="repository">
219 <div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3.5">
220 <div className="flex min-w-0 items-start gap-3">
221 <GitBranch size={16} className="mt-0.5 shrink-0 text-muted" aria-hidden />
222 <div className="min-w-0">
223 <h2 id="repository" className="text-sm font-medium">
224 Keep skills in a repository
225 </h2>
226 {mirror ? (
227 <p className="mt-0.5 text-sm text-muted">
228 Follows{" "}
229 <Link to={`/${mirror.repo}/tree/${mirror.branch}/.g1t/skills`} className="font-mono text-fg hover:underline">
230 {mirror.repo}
231 </Link>
232 : each folder in <code className="font-mono text-[0.8125rem]">.g1t/skills/</code> on {mirror.branch} is a skill, and a push there publishes a new version.
233 {mirror.synced_at && (
234 <span className="text-faint">
235 {" "}
236 Read <TimeAgo at={mirror.synced_at} />
237 {mirror.commit ? ` at ${mirror.commit.slice(0, 7)}` : ""}.
238 </span>
239 )}
240 </p>
241 ) : (
242 <p className="mt-0.5 max-w-2xl text-sm text-muted">
243 Optional. Link a repository and each <code className="font-mono text-[0.8125rem]">.g1t/skills/&lt;name&gt;/</code> folder on its default branch becomes a skill
244 here, updated by every push, so skills go through the same pull requests and reviews as code.
245 </p>
246 )}
247 </div>
248 </div>
249 {mirror && (
250 <div className="flex shrink-0 flex-wrap gap-2">
251 {canWrite && (
252 <sync.Form method="post">
253 <input type="hidden" name="intent" value="sync" />
254 <Button type="submit" variant="outline" size="sm" disabled={sync.state !== "idle"}>
255 <RefreshCw size={13} className={sync.state !== "idle" ? "animate-spin" : undefined} />
256 {sync.state !== "idle" ? "Reading…" : "Read it again"}
257 </Button>
258 </sync.Form>
259 )}
260 {canManage && (
261 <Confirm
262 title="Stop following the repository?"
263 confirm="Stop following"
264 fields={{ intent: "unlink" }}
265 fetcherKey="skills-unlink"
266 trigger={
267 <Button type="button" variant="outline" size="sm" className="hover:border-danger/50 hover:text-danger">
268 Stop following
269 </Button>
270 }
271 >
272 Its skills stay in the library as they are, and can be edited here again. Pushes to {mirror.repo} no longer change them.
273 </Confirm>
274 )}
275 </div>
276 )}
277 </div>
278 {(problems.length > 0 || syncError) && (
279 <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">
280 {syncError && <li>{syncError}</li>}
281 {problems.map((p) => (
282 <li key={p}>{p}</li>
283 ))}
284 </ul>
285 )}
286 {!mirror && canManage && (
287 <link.Form method="post" className="flex flex-wrap items-start gap-2 border-t border-line/60 px-4 py-3">
288 <input type="hidden" name="intent" value="link" />
289 <label htmlFor="skills-repo" className="sr-only">
290 Repository
291 </label>
292 <Input id="skills-repo" name="repo" placeholder={`${slug}/agents`} autoComplete="off" spellCheck={false} className="h-9 max-w-xs grow font-mono" />
293 <Button type="submit" variant="outline" disabled={link.state !== "idle"}>
294 {link.state !== "idle" ? "Linking…" : "Link repository"}
295 </Button>
296 {linkError && <p className="w-full text-sm text-danger">{linkError}</p>}
297 </link.Form>
298 )}
299 <div className="flex flex-wrap items-center justify-between gap-2 border-t border-line/60 px-4 py-2.5">
300 <p className="text-xs text-faint">Writing edits made here back to the repository as a commit</p>
301 <Badge tone="neutral">Coming</Badge>
302 </div>
303 </section>
304 </Card>
305 );
306}