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