Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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