| 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, Confirm } from "../../../components/agents/dialogs"; |
| 15 | import { AttachmentChip, FromRepository, NeedsComputer, originText, skillsPath } from "../../../components/agents/skills"; |
| 16 | import { ButtonLink, EmptyState, TimeAgo } from "../../../components/ui"; |
| 17 | import { Badge } from "../../../components/ui/badge"; |
| 18 | import { Button } from "../../../components/ui/button"; |
| 19 | import { Card } from "../../../components/ui/card"; |
| 20 | import { Input } from "../../../components/ui/input"; |
| 21 | import { page } from "../../../lib/meta"; |
| 22 | import { skillLibrary } from "../../../lib/services.server"; |
| 23 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 24 | |
| 25 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 26 | return page(args, { title: `Skills · Agents · ${params.owner} · g1t` }); |
| 27 | } |
| 28 | |
| 29 | export 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. */ |
| 37 | export 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 | |
| 50 | export 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'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'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'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'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 | |
| 173 | function 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. */ |
| 210 | function 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/<name>/</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 | } |