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 | * One skill (docs.g1t.sh/guides/agent-skills/): its instructions and files, | |
| 3 | * where it is attached and at which version, and every version. A draft | |
| 4 | * shows what the agent wrote, for someone to review and publish. g1t's | |
| 5 | * foundational skills show here too, as the SKILL.md they are. | |
| 6 | */ | |
| 7 | import { ArrowLeft, ArrowUpCircle, FileCode, FileText, History, PenLine, Plus, Trash2, X } from "lucide-react"; | |
| 8 | import type { ReactNode } from "react"; | |
| 9 | import { Link, data, redirect, useFetcher } from "react-router"; | |
| 10 | ||
| 11 | import { | |
| 12 | type AgentSkill, | |
| 13 | FOUNDATIONAL_SKILLS, | |
| 14 | type SkillDetail, | |
| 15 | type SkillLibrary, | |
| 16 | foundationalSkillMd, | |
| 17 | skillSize, | |
| 18 | splitFrontMatter, | |
| 19 | } from "@g1t/contracts"; | |
| 20 | ||
| 21 | import type { Route } from "./+types/skill"; | |
| 22 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; | |
| 23 | import { type ActionResult, BUTTONS, Confirm } from "../../../components/agents/dialogs"; | |
| 24 | import { AttachDialog, AttachmentChip, FromRepository, NeedsComputer, originText, skillsPath } from "../../../components/agents/skills"; | |
| 25 | import { Markdown } from "../../../components/markdown"; | |
| 26 | import { TimeAgo } from "../../../components/ui"; | |
| 27 | import { Badge } from "../../../components/ui/badge"; | |
| 28 | import { Hint } from "../../../components/ui/hint"; | |
| 29 | import { cn } from "../../../lib/cn"; | |
| 30 | import { page } from "../../../lib/meta"; | |
| 31 | import { skillLibrary } from "../../../lib/services.server"; | |
| 32 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 33 | ||
| 34 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 35 | return page(args, { title: `${params.name} · Skills · ${params.owner} · g1t` }); | |
| 36 | } | |
| 37 | ||
| 38 | type Loaded = | |
| 39 | | { kind: "foundational"; slug: string; skill: AgentSkill; skillMd: string } | |
| 40 | | { kind: "library"; slug: string; detail: SkillDetail | null; library: SkillLibrary | null }; | |
| 41 | ||
| 42 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<Loaded> { | |
| 43 | const viewer = requireUser(context, request); | |
| 44 | const slug = params.owner.toLowerCase(); | |
| 45 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 46 | const foundational = FOUNDATIONAL_SKILLS.find((s) => s.id === params.name); | |
| 47 | if (foundational) return { kind: "foundational", slug, skill: foundational, skillMd: foundationalSkillMd(foundational) }; | |
| 48 | const version = new URL(request.url).searchParams.get("version"); | |
| 49 | const [found, library] = await Promise.all([ | |
| 50 | skillLibrary.skill(slug, viewer, params.name, version ? Number(version) : null).catch(() => null), | |
| 51 | readOrNull(skillLibrary.library(slug, viewer)), | |
| 52 | ]); | |
| 53 | if (found && !found.ok && found.error.code === "not_found") throw data(null, { status: 404 }); | |
| 54 | return { kind: "library", slug, detail: found?.ok ? found.value : null, library }; | |
| 55 | } | |
| 56 | ||
| 57 | /** Attach, detach, move a pin, or delete the skill. */ | |
| 58 | export 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 = params.name; | |
| 62 | if (intent === "attach") { | |
| 63 | const scope = String(form.get("scope") ?? "") as "agent" | "team" | "workspace"; | |
| 64 | const target = String(form.get("target") ?? "") || null; | |
| 65 | return answer(intent, skillLibrary.attachSkill(slug, viewer, name, scope, target)); | |
| 66 | } | |
| 67 | if (intent === "detach") return answer(intent, skillLibrary.detachSkill(slug, viewer, name, String(form.get("attachment") ?? ""))); | |
| 68 | if (intent === "pin") { | |
| 69 | const version = form.get("version"); | |
| 70 | return answer(intent, skillLibrary.pinSkill(slug, viewer, name, String(form.get("attachment") ?? ""), version ? Number(version) : null)); | |
| 71 | } | |
| 72 | if (intent === "delete") { | |
| 73 | const done = await skillLibrary.deleteSkill(slug, viewer, name).catch(() => null); | |
| 74 | if (!done) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." }; | |
| 75 | if (!done.ok) return { ok: false, intent, error: done.error.message }; | |
| 76 | throw redirect(skillsPath(slug)); | |
| 77 | } | |
| 78 | return { ok: false, intent, error: "Unknown request." }; | |
| 79 | } | |
| 80 | ||
| 81 | export default function SkillPage({ loaderData, params }: Route.ComponentProps) { | |
| 82 | const back = ( | |
| 83 | <Link to={skillsPath(params.owner)} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 84 | <ArrowLeft size={14} /> | |
| 85 | Skills | |
| 86 | </Link> | |
| 87 | ); | |
| 88 | if (loaderData.kind === "foundational") return <Foundational back={back} skill={loaderData.skill} skillMd={loaderData.skillMd} />; | |
| 89 | const { slug, detail, library } = loaderData; | |
| 90 | if (!detail) { | |
| 91 | return ( | |
| 92 | <div className="space-y-4"> | |
| 93 | {back} | |
| 94 | <div className="rounded-xl border border-dashed border-line px-6 py-14 text-center"> | |
| 95 | <p className="font-medium">{params.name} can't be shown right now</p> | |
| 96 | <p className="mt-1.5 text-sm text-muted">The agents service didn't answer. Reload in a moment.</p> | |
| 97 | </div> | |
| 98 | </div> | |
| 99 | ); | |
| 100 | } | |
| 101 | const { skill } = detail; | |
| 102 | const latest = detail.shown === skill.version; | |
| 103 | const draft = skill.status === "draft"; | |
| 104 | return ( | |
| 105 | <div className="space-y-8 pb-4"> | |
| 106 | {back} | |
| 107 | {!latest && ( | |
| 108 | <p className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-info/40 bg-info/10 px-3 py-2 text-sm text-fg"> | |
| 109 | <History size={15} className="shrink-0 text-info" aria-hidden /> | |
| 110 | You're reading version {detail.shown} of {skill.version}. | |
| 111 | <Link to={skillsPath(slug, skill.name)} className="font-medium text-info hover:underline"> | |
| 112 | Read the newest | |
| 113 | </Link> | |
| 114 | </p> | |
| 115 | )} | |
| 116 | {draft && ( | |
| 117 | <p className="rounded-lg border border-warn/40 bg-warn/10 px-3 py-2 text-sm text-fg"> | |
| 118 | {originText(skill.origin)}, as a draft. No agent uses it until {skill.can_edit ? "you review and publish it" : "an owner or team maintainer publishes it"}. | |
| 119 | </p> | |
| 120 | )} | |
| 121 | <header className="flex flex-wrap items-start justify-between gap-4"> | |
| 122 | <div className="min-w-0 grow basis-md"> | |
| 123 | <div className="flex flex-wrap items-center gap-2"> | |
| 124 | <h1 className="font-mono text-xl font-semibold tracking-tight break-all sm:text-2xl">{skill.name}</h1> | |
| 125 | {draft ? <Badge tone="warn">Draft</Badge> : <Badge tone="neutral">Version {detail.shown}</Badge>} | |
| 126 | {detail.requires_computer && <NeedsComputer />} | |
| 127 | {skill.mirrored && <FromRepository repo={library?.mirror?.repo ?? null} />} | |
| 128 | </div> | |
| 129 | <p className="mt-2 max-w-3xl text-sm text-fg-soft">{detail.versions.find((v) => v.version === detail.shown)?.description || skill.description}</p> | |
| 130 | <p className="mt-2 text-xs text-faint"> | |
| 131 | {originText(detail.versions.find((v) => v.version === detail.shown)?.origin ?? skill.origin)} · @{skill.updated_by} · <TimeAgo at={skill.updated_at} /> | |
| 132 | </p> | |
| 133 | </div> | |
| 134 | <div className="flex shrink-0 flex-wrap gap-2"> | |
| 135 | {skill.can_edit && ( | |
| 136 | <Link to={skillsPath(slug, skill.name, "/edit")} className={`${draft ? BUTTONS.PRIMARY : BUTTONS.QUIET} h-9 py-0`}> | |
| 137 | <PenLine size={15} /> | |
| 138 | {draft ? "Review and publish" : "Edit"} | |
| 139 | </Link> | |
| 140 | )} | |
| 141 | {skill.can_delete && ( | |
| 142 | <Confirm | |
| 143 | title={draft ? `Discard the draft ${skill.name}?` : `Delete ${skill.name}?`} | |
| 144 | confirm={draft ? "Discard draft" : "Delete skill"} | |
| 145 | fields={{ intent: "delete" }} | |
| 146 | fetcherKey={`delete-${skill.id}`} | |
| 147 | trigger={ | |
| 148 | <button type="button" className={`${BUTTONS.QUIET} h-9 py-0 hover:border-danger/50 hover:text-danger`}> | |
| 149 | <Trash2 size={14} /> | |
| 150 | {draft ? "Discard" : "Delete"} | |
| 151 | </button> | |
| 152 | } | |
| 153 | > | |
| 154 | {draft | |
| 155 | ? "It is gone for good; the session it came from stays." | |
| 156 | : `It is detached from every agent, team and the workspace (${skill.attachments.length} ${skill.attachments.length === 1 ? "place" : "places"}), and its history goes with it. Agents stop seeing it on their next reply.`} | |
| 157 | </Confirm> | |
| 158 | )} | |
| 159 | </div> | |
| 160 | </header> | |
| 161 | ||
| 162 | <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_19rem]"> | |
| 163 | <div className="min-w-0 space-y-6"> | |
| 164 | <section aria-labelledby="instructions" className="rounded-xl border border-line bg-surface"> | |
| 165 | <h2 id="instructions" className="border-b border-line/60 px-4 py-2.5 text-xs font-medium text-muted"> | |
| 166 | Instructions | |
| 167 | </h2> | |
| 168 | <div className="px-4 py-4 sm:px-5"> | |
| 169 | <Markdown source={detail.instructions} /> | |
| 170 | </div> | |
| 171 | </section> | |
| 172 | {detail.files.length > 0 && ( | |
| 173 | <section aria-labelledby="files"> | |
| 174 | <h2 id="files" className="text-sm font-medium"> | |
| 175 | Files <span className="text-faint">{detail.files.length}</span> | |
| 176 | </h2> | |
| 177 | <ul className="mt-3 divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface"> | |
| 178 | {detail.files.map((file) => ( | |
| 179 | <li key={file.path}> | |
| 180 | <details className="group"> | |
| 181 | <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2.5 text-sm select-none hover:bg-raised/40 [&::-webkit-details-marker]:hidden"> | |
| 182 | {file.script ? <FileCode size={14} className="shrink-0 text-faint" aria-hidden /> : <FileText size={14} className="shrink-0 text-faint" aria-hidden />} | |
| 183 | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]">{file.path}</span> | |
| 184 | {file.script && <Badge tone="warn">Script · not run</Badge>} | |
| 185 | <span className="shrink-0 text-xs text-faint">{skillSize(file.bytes)}</span> | |
| 186 | </summary> | |
| 187 | <div className="border-t border-line/60 bg-bg/40 px-4 py-3"> | |
| 188 | {file.content != null ? ( | |
| 189 | <pre className="max-h-96 overflow-auto text-xs leading-relaxed whitespace-pre-wrap text-fg-soft">{file.content}</pre> | |
| 190 | ) : ( | |
| 191 | <p className="text-xs text-faint">{file.encoding === "base64" ? "Not text, so it isn't shown here." : "Too large to show here."}</p> | |
| 192 | )} | |
| 193 | </div> | |
| 194 | </details> | |
| 195 | </li> | |
| 196 | ))} | |
| 197 | </ul> | |
| 198 | </section> | |
| 199 | )} | |
| 200 | <details className="group rounded-xl border border-line bg-surface"> | |
| 201 | <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden"> | |
| 202 | <FileText size={13} aria-hidden /> | |
| 203 | <span className="group-open:hidden">Show SKILL.md as written</span> | |
| 204 | <span className="hidden group-open:inline">Hide SKILL.md</span> | |
| 205 | </summary> | |
| 206 | <pre className="overflow-x-auto border-t border-line/60 px-4 py-3 text-xs leading-relaxed whitespace-pre-wrap text-fg-soft">{detail.skill_md}</pre> | |
| 207 | </details> | |
| 208 | </div> | |
| 209 | ||
| 210 | <aside className="space-y-6"> | |
| 211 | <Attached detail={detail} library={library} /> | |
| 212 | <section aria-labelledby="tools"> | |
| 213 | <h2 id="tools" className="text-sm font-medium"> | |
| 214 | Tools it uses | |
| 215 | </h2> | |
| 216 | {detail.tools.length ? ( | |
| 217 | <ul className="mt-2 flex flex-wrap gap-1" aria-label="Tools it uses"> | |
| 218 | {detail.tools.map((tool) => ( | |
| 219 | <li key={tool} className="rounded-[5px] bg-raised px-1.5 py-px font-mono text-[0.6875rem] text-muted ring-1 ring-line ring-inset"> | |
| 220 | {tool} | |
| 221 | </li> | |
| 222 | ))} | |
| 223 | </ul> | |
| 224 | ) : ( | |
| 225 | <p className="mt-1 text-sm text-muted">None named.</p> | |
| 226 | )} | |
| 227 | <p className="mt-2 text-xs text-faint">A skill never adds a tool. An agent without one of these is told that part doesn't work where it is asked.</p> | |
| 228 | </section> | |
| 229 | <Versions detail={detail} slug={slug} /> | |
| 230 | </aside> | |
| 231 | </div> | |
| 232 | </div> | |
| 233 | ); | |
| 234 | } | |
| 235 | ||
| 236 | function Attached({ detail, library }: { detail: SkillDetail; library: SkillLibrary | null }) { | |
| 237 | const { skill } = detail; | |
| 238 | const pin = useFetcher<ActionResult>({ key: `pin-${skill.id}` }); | |
| 239 | const detach = useFetcher<ActionResult>({ key: `detach-${skill.id}` }); | |
| 240 | const error = [pin, detach].map((f) => (f.state === "idle" && f.data && !f.data.ok ? f.data.error : null)).find(Boolean); | |
| 241 | const canAttach = !!library && skill.status === "published" && (library.can_manage || library.teams.length > 0); | |
| 242 | return ( | |
| 243 | <section aria-labelledby="attached"> | |
| 244 | <div className="flex items-center justify-between gap-2"> | |
| 245 | <h2 id="attached" className="text-sm font-medium"> | |
| 246 | Attached to | |
| 247 | </h2> | |
| 248 | {canAttach && ( | |
| 249 | <AttachDialog | |
| 250 | skill={skill} | |
| 251 | library={library} | |
| 252 | trigger={ | |
| 253 | <button type="button" className={`${BUTTONS.QUIET} h-8 px-2.5 py-0 text-xs`}> | |
| 254 | <Plus size={13} /> | |
| 255 | Attach | |
| 256 | </button> | |
| 257 | } | |
| 258 | /> | |
| 259 | )} | |
| 260 | </div> | |
| 261 | {skill.status === "draft" ? ( | |
| 262 | <p className="mt-1 text-sm text-muted">Nowhere: publish it first.</p> | |
| 263 | ) : skill.attachments.length === 0 ? ( | |
| 264 | <p className="mt-1 text-sm text-muted">Nowhere yet. No agent sees it until it is attached.</p> | |
| 265 | ) : ( | |
| 266 | <ul className="mt-2 space-y-1.5"> | |
| 267 | {skill.attachments.map((a) => ( | |
| 268 | <li key={a.id} className="flex flex-wrap items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2"> | |
| 269 | <span className="min-w-0 grow"> | |
| 270 | <AttachmentChip attachment={a} /> | |
| 271 | <span className={cn("ml-2 text-xs", a.version < skill.version ? "text-warn" : "text-faint")}>v{a.version}</span> | |
| 272 | </span> | |
| 273 | {a.can_change && a.version < skill.version && ( | |
| 274 | <pin.Form method="post"> | |
| 275 | <input type="hidden" name="intent" value="pin" /> | |
| 276 | <input type="hidden" name="attachment" value={a.id} /> | |
| 277 | <Hint label={`Move ${a.label} to version ${skill.version}`}> | |
| 278 | <button type="submit" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-accent hover:bg-accent/10" disabled={pin.state !== "idle"}> | |
| 279 | <ArrowUpCircle size={13} /> | |
| 280 | Update to v{skill.version} | |
| 281 | </button> | |
| 282 | </Hint> | |
| 283 | </pin.Form> | |
| 284 | )} | |
| 285 | {a.can_change && ( | |
| 286 | <detach.Form method="post"> | |
| 287 | <input type="hidden" name="intent" value="detach" /> | |
| 288 | <input type="hidden" name="attachment" value={a.id} /> | |
| 289 | <Hint label={`Detach from ${a.label}`}> | |
| 290 | <button type="submit" aria-label={`Detach from ${a.label}`} className="flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-danger" disabled={detach.state !== "idle"}> | |
| 291 | <X size={14} /> | |
| 292 | </button> | |
| 293 | </Hint> | |
| 294 | </detach.Form> | |
| 295 | )} | |
| 296 | </li> | |
| 297 | ))} | |
| 298 | </ul> | |
| 299 | )} | |
| 300 | {error && ( | |
| 301 | <p role="alert" className="mt-2 text-sm text-danger"> | |
| 302 | {error} | |
| 303 | </p> | |
| 304 | )} | |
| 305 | </section> | |
| 306 | ); | |
| 307 | } | |
| 308 | ||
| 309 | function Versions({ detail, slug }: { detail: SkillDetail; slug: string }) { | |
| 310 | if (detail.skill.status === "draft") return null; | |
| 311 | return ( | |
| 312 | <section aria-labelledby="versions"> | |
| 313 | <h2 id="versions" className="text-sm font-medium"> | |
| 314 | Versions <span className="text-faint">{detail.versions.length}</span> | |
| 315 | </h2> | |
| 316 | <ol className="mt-2 space-y-px"> | |
| 317 | {detail.versions.map((v) => { | |
| 318 | const shown = v.version === detail.shown; | |
| 319 | return ( | |
| 320 | <li key={v.version}> | |
| 321 | <Link | |
| 322 | to={v.version === detail.skill.version ? skillsPath(slug, detail.skill.name) : `${skillsPath(slug, detail.skill.name)}?version=${v.version}`} | |
| 323 | aria-current={shown ? "page" : undefined} | |
| 324 | className={cn("block rounded-md px-2.5 py-2 text-sm transition-colors", shown ? "bg-raised" : "hover:bg-raised/60")} | |
| 325 | > | |
| 326 | <span className="flex items-baseline justify-between gap-2"> | |
| 327 | <span className="font-medium text-fg">Version {v.version}</span> | |
| 328 | <span className="shrink-0 text-xs text-faint"> | |
| 329 | <TimeAgo at={v.created_at} /> | |
| 330 | </span> | |
| 331 | </span> | |
| 332 | <span className="mt-0.5 block truncate text-xs text-muted">{v.note ?? originText(v.origin)}</span> | |
| 333 | <span className="block text-xs text-faint"> | |
| 334 | @{v.created_by} · {skillSize(v.bytes)} | |
| 335 | {v.files ? ` · ${v.files} ${v.files === 1 ? "file" : "files"}` : ""} | |
| 336 | </span> | |
| 337 | </Link> | |
| 338 | </li> | |
| 339 | ); | |
| 340 | })} | |
| 341 | </ol> | |
| 342 | </section> | |
| 343 | ); | |
| 344 | } | |
| 345 | ||
| 346 | function Foundational({ back, skill, skillMd }: { back: ReactNode; skill: AgentSkill; skillMd: string }) { | |
| 347 | const split = splitFrontMatter(skillMd); | |
| 348 | return ( | |
| 349 | <div className="space-y-8 pb-4"> | |
| 350 | {back} | |
| 351 | <header> | |
| 352 | <div className="flex flex-wrap items-center gap-2"> | |
| 353 | <h1 className="font-mono text-xl font-semibold tracking-tight sm:text-2xl">{skill.id}</h1> | |
| 354 | <Badge tone="accent">From g1t</Badge> | |
| 355 | <Badge tone="neutral">Version {skill.version}</Badge> | |
| 356 | </div> | |
| 357 | <p className="mt-2 max-w-3xl text-sm text-fg-soft">{skill.when}</p> | |
| 358 | <p className="mt-2 text-xs text-faint">Every agent has it, updated with g1t's releases. Owners turn it off for one agent on that agent's Skills tab.</p> | |
| 359 | </header> | |
| 360 | <section aria-labelledby="instructions" className="rounded-xl border border-line bg-surface"> | |
| 361 | <h2 id="instructions" className="border-b border-line/60 px-4 py-2.5 text-xs font-medium text-muted"> | |
| 362 | Instructions | |
| 363 | </h2> | |
| 364 | <div className="px-4 py-4 sm:px-5"> | |
| 365 | <Markdown source={split.ok ? split.body : skillMd} /> | |
| 366 | </div> | |
| 367 | </section> | |
| 368 | <details className="group rounded-xl border border-line bg-surface"> | |
| 369 | <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden"> | |
| 370 | <FileText size={13} aria-hidden /> | |
| 371 | <span className="group-open:hidden">Show SKILL.md</span> | |
| 372 | <span className="hidden group-open:inline">Hide SKILL.md</span> | |
| 373 | </summary> | |
| 374 | <pre className="overflow-x-auto border-t border-line/60 px-4 py-3 text-xs leading-relaxed whitespace-pre-wrap text-fg-soft">{skillMd}</pre> | |
| 375 | </details> | |
| 376 | </div> | |
| 377 | ); | |
| 378 | } |