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 | * The skill library's shared parts (docs.g1t.sh/guides/agent-skills/): | |
| 3 | * where a skill came from, where it is attached, the "needs a computer" | |
| 4 | * mark, and the dialogs that attach one. | |
| 5 | */ | |
| 6 | import { Bot, Building2, Cpu, GitBranch, Users } from "lucide-react"; | |
| 7 | import { type ReactNode, useId, useState } from "react"; | |
| 8 | ||
| 9 | import type { LibrarySkill, SkillAttachment, SkillLibrary, SkillOrigin, SkillScope } from "@g1t/contracts"; | |
| 10 | ||
| 11 | import { Badge } from "../ui/badge"; | |
| 12 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"; | |
| 13 | import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field"; | |
| 14 | import { Hint } from "../ui/hint"; | |
| 15 | import { SelectField } from "../ui/select"; | |
| 16 | import { BUTTONS, useDialogFetcher } from "./dialogs"; | |
| 17 | ||
| 18 | /** `/acme/-/agents/skills`, or one skill's page. */ | |
| 19 | export function skillsPath(slug: string, name?: string, rest = ""): string { | |
| 20 | return `/${slug}/-/agents/skills${name ? `/${encodeURIComponent(name)}` : ""}${rest}`; | |
| 21 | } | |
| 22 | ||
| 23 | /** Where a version came from, in a few words. */ | |
| 24 | export function originText(origin: SkillOrigin): string { | |
| 25 | switch (origin.kind) { | |
| 26 | case "written": | |
| 27 | return "Written in g1t"; | |
| 28 | case "upload": | |
| 29 | return `Uploaded from ${origin.filename}`; | |
| 30 | case "repository": | |
| 31 | return `From ${origin.repo}${origin.path && origin.path !== "." ? `, ${origin.path}` : ""} at ${origin.commit.slice(0, 7)}`; | |
| 32 | case "session": | |
| 33 | return `Drafted by @${origin.agent} from “${origin.title}”`; | |
| 34 | case "mirror": | |
| 35 | return `From ${origin.repo} at ${origin.commit.slice(0, 7)}`; | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | const SCOPE_ICONS: Record<SkillScope, typeof Bot> = { agent: Bot, team: Users, workspace: Building2 }; | |
| 40 | ||
| 41 | /** Where a skill is attached, as a small chip. */ | |
| 42 | export function AttachmentChip({ attachment, latest }: { attachment: Pick<SkillAttachment, "scope" | "label" | "version">; latest?: number }) { | |
| 43 | const Icon = SCOPE_ICONS[attachment.scope]; | |
| 44 | const behind = latest != null && attachment.version < latest; | |
| 45 | return ( | |
| 46 | <span className="inline-flex max-w-full items-center gap-1.5 rounded-md bg-raised px-2 py-0.5 text-xs text-fg-soft ring-1 ring-line ring-inset"> | |
| 47 | <Icon size={12} className="shrink-0 text-faint" aria-hidden /> | |
| 48 | <span className="truncate">{attachment.label}</span> | |
| 49 | {behind && <span className="shrink-0 text-faint">v{attachment.version}</span>} | |
| 50 | </span> | |
| 51 | ); | |
| 52 | } | |
| 53 | ||
| 54 | /** Marked on a skill that needs the agent's own computer, which is coming. `plain` inside a link, where a hint can't be focused. */ | |
| 55 | export function NeedsComputer({ plain }: { plain?: boolean }) { | |
| 56 | if (plain) { | |
| 57 | return ( | |
| 58 | <Badge tone="warn"> | |
| 59 | <Cpu size={11} aria-hidden /> | |
| 60 | Needs a computer · Coming | |
| 61 | </Badge> | |
| 62 | ); | |
| 63 | } | |
| 64 | return ( | |
| 65 | <Hint label="Its scripts run only on an agent's own computer, which is coming. Until then agents follow its instructions and never run its scripts."> | |
| 66 | <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40"> | |
| 67 | <Badge tone="warn"> | |
| 68 | <Cpu size={11} aria-hidden /> | |
| 69 | Needs a computer · Coming | |
| 70 | </Badge> | |
| 71 | </span> | |
| 72 | </Hint> | |
| 73 | ); | |
| 74 | } | |
| 75 | ||
| 76 | /** Marked on a skill that follows the linked repository. */ | |
| 77 | export function FromRepository({ repo, plain }: { repo: string | null; plain?: boolean }) { | |
| 78 | if (plain) { | |
| 79 | return ( | |
| 80 | <Badge tone="info"> | |
| 81 | <GitBranch size={11} aria-hidden /> | |
| 82 | From the repository | |
| 83 | </Badge> | |
| 84 | ); | |
| 85 | } | |
| 86 | return ( | |
| 87 | <Hint label={repo ? `Changed in ${repo}, under .g1t/skills/: a push there publishes a new version.` : "Changed in the linked repository."}> | |
| 88 | <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40"> | |
| 89 | <Badge tone="info"> | |
| 90 | <GitBranch size={11} aria-hidden /> | |
| 91 | From the repository | |
| 92 | </Badge> | |
| 93 | </span> | |
| 94 | </Hint> | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 98 | /** Where a skill may be attached by this viewer: every agent and agents for owners, the teams they maintain. */ | |
| 99 | function targets(library: Pick<SkillLibrary, "can_manage" | "teams" | "agents">, attached: SkillAttachment[]) { | |
| 100 | const taken = new Set(attached.map((a) => `${a.scope}:${a.target ?? ""}`)); | |
| 101 | const options: { value: string; label: string; disabled?: boolean }[] = []; | |
| 102 | if (library.can_manage) options.push({ value: "workspace:", label: "Every agent in the workspace", disabled: taken.has("workspace:") }); | |
| 103 | for (const team of library.teams) options.push({ value: `team:${team.slug}`, label: `Team: ${team.name}`, disabled: taken.has(`team:${team.slug}`) }); | |
| 104 | if (library.can_manage) for (const agent of library.agents) options.push({ value: `agent:${agent.handle}`, label: `${agent.display_name} (@${agent.handle})`, disabled: taken.has(`agent:${agent.handle}`) }); | |
| 105 | return options; | |
| 106 | } | |
| 107 | ||
| 108 | /** Attach a skill to every agent, a team or one agent: the newest version is pinned. */ | |
| 109 | export function AttachDialog({ | |
| 110 | skill, | |
| 111 | library, | |
| 112 | action, | |
| 113 | trigger, | |
| 114 | }: { | |
| 115 | skill: LibrarySkill; | |
| 116 | library: Pick<SkillLibrary, "can_manage" | "teams" | "agents">; | |
| 117 | action?: string; | |
| 118 | trigger: ReactNode; | |
| 119 | }) { | |
| 120 | const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`attach-${skill.id}`); | |
| 121 | const id = useId(); | |
| 122 | const options = targets(library, skill.attachments); | |
| 123 | const [where, setWhere] = useState(options.find((o) => !o.disabled)?.value ?? ""); | |
| 124 | const [scope, target] = where ? [where.slice(0, where.indexOf(":")), where.slice(where.indexOf(":") + 1)] : ["", ""]; | |
| 125 | return ( | |
| 126 | <Dialog open={open} onOpenChange={setOpen}> | |
| 127 | <DialogTrigger asChild>{trigger}</DialogTrigger> | |
| 128 | <DialogContent> | |
| 129 | <DialogHeader> | |
| 130 | <DialogTitle>Attach {skill.name}</DialogTitle> | |
| 131 | <DialogDescription> | |
| 132 | Agents it reaches see its name and when to use it, and read it when a request matches. Version {skill.version} is pinned there; a newer version reaches them when someone | |
| 133 | moves the pin. | |
| 134 | </DialogDescription> | |
| 135 | </DialogHeader> | |
| 136 | <fetcher.Form method="post" action={action} className="grid gap-5"> | |
| 137 | <input type="hidden" name="intent" value="attach" /> | |
| 138 | <input type="hidden" name="scope" value={scope} /> | |
| 139 | <input type="hidden" name="target" value={target} /> | |
| 140 | <Field> | |
| 141 | <FieldLabel htmlFor={`${id}-where`}>Attach to</FieldLabel> | |
| 142 | {options.length ? ( | |
| 143 | <SelectField id={`${id}-where`} options={options} value={where} onValueChange={setWhere} placeholder="Choose where" /> | |
| 144 | ) : ( | |
| 145 | <p className="text-sm text-muted">There is nowhere you can attach it. Owners attach skills anywhere; team maintainers attach them to their teams.</p> | |
| 146 | )} | |
| 147 | <FieldDescription>A team's skills reach every agent on it. A skill never gives an agent a tool or access it doesn't have.</FieldDescription> | |
| 148 | </Field> | |
| 149 | <FieldError>{error}</FieldError> | |
| 150 | <DialogFooter> | |
| 151 | <button type="button" className={BUTTONS.QUIET} onClick={() => setOpen(false)}> | |
| 152 | Cancel | |
| 153 | </button> | |
| 154 | <button type="submit" className={BUTTONS.PRIMARY} disabled={busy || !where}> | |
| 155 | {busy ? "Attaching…" : "Attach"} | |
| 156 | </button> | |
| 157 | </DialogFooter> | |
| 158 | </fetcher.Form> | |
| 159 | </DialogContent> | |
| 160 | </Dialog> | |
| 161 | ); | |
| 162 | } | |
| 163 | ||
| 164 | /** From an agent's Skills tab: attach one of the library's skills to this agent. */ | |
| 165 | export function AttachToAgentDialog({ agentName, skills, trigger }: { agentName: string; skills: Pick<LibrarySkill, "name" | "description" | "version">[]; trigger: ReactNode }) { | |
| 166 | const { fetcher, open, setOpen, error, busy } = useDialogFetcher("attach-to-agent"); | |
| 167 | const id = useId(); | |
| 168 | const [name, setName] = useState(skills[0]?.name ?? ""); | |
| 169 | const chosen = skills.find((s) => s.name === name); | |
| 170 | return ( | |
| 171 | <Dialog open={open} onOpenChange={setOpen}> | |
| 172 | <DialogTrigger asChild>{trigger}</DialogTrigger> | |
| 173 | <DialogContent> | |
| 174 | <DialogHeader> | |
| 175 | <DialogTitle>Attach a skill to {agentName}</DialogTitle> | |
| 176 | <DialogDescription>From your workspace's library. Its newest version is pinned; {agentName} reads it when a request matches.</DialogDescription> | |
| 177 | </DialogHeader> | |
| 178 | <fetcher.Form method="post" className="grid gap-5"> | |
| 179 | <input type="hidden" name="intent" value="attach" /> | |
| 180 | <input type="hidden" name="name" value={name} /> | |
| 181 | <Field> | |
| 182 | <FieldLabel htmlFor={`${id}-skill`}>Skill</FieldLabel> | |
| 183 | {skills.length ? ( | |
| 184 | <SelectField id={`${id}-skill`} options={skills.map((s) => ({ value: s.name, label: s.name }))} value={name} onValueChange={setName} /> | |
| 185 | ) : ( | |
| 186 | <p className="text-sm text-muted">Every published skill in the library already reaches {agentName}.</p> | |
| 187 | )} | |
| 188 | {chosen && <FieldDescription>{chosen.description}</FieldDescription>} | |
| 189 | </Field> | |
| 190 | <FieldError>{error}</FieldError> | |
| 191 | <DialogFooter> | |
| 192 | <button type="button" className={BUTTONS.QUIET} onClick={() => setOpen(false)}> | |
| 193 | Cancel | |
| 194 | </button> | |
| 195 | <button type="submit" className={BUTTONS.PRIMARY} disabled={busy || !name}> | |
| 196 | {busy ? "Attaching…" : "Attach"} | |
| 197 | </button> | |
| 198 | </DialogFooter> | |
| 199 | </fetcher.Form> | |
| 200 | </DialogContent> | |
| 201 | </Dialog> | |
| 202 | ); | |
| 203 | } |