Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how. | 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 | */ | |
| 7 | import { ArrowLeft, BookOpen, ChevronRight, FileUp, GitBranch, PenLine, RefreshCw, Store } from "lucide-react"; | |
| 8 | import { Link, data, useFetcher } from "react-router"; | |
| 9 | ||
| 10 | import { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILLS_VERSION, type LibrarySkill, type SkillLibrary, type SkillMirror } from "@g1t/contracts"; | |
| 11 | ||
| 12 | import type { Route } from "./+types/skill-library"; | |
| 13 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; | |
| 14 | import { type ActionResult, BUTTONS, Confirm } from "../../../components/agents/dialogs"; | |
| 15 | import { AttachmentChip, FromRepository, NeedsComputer, originText, skillsPath } from "../../../components/agents/skills"; | |
| 16 | import { EmptyState, TimeAgo } from "../../../components/ui"; | |
| 17 | import { Badge } from "../../../components/ui/badge"; | |
| 18 | import { Input } from "../../../components/ui/input"; | |
| 19 | import { page } from "../../../lib/meta"; | |
| 20 | import { skillLibrary } from "../../../lib/services.server"; | |
| 21 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 22 | ||
| 23 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 24 | return page(args, { title: `Skills · Agents · ${params.owner} · g1t` }); | |
| 25 | } | |
| 26 | ||
| 27 | export 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. */ | |
| 35 | export 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 | ||
| 48 | export 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'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'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'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'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 | ||
| 167 | function 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. */ | |
| 204 | function 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/<name>/</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 | } |