Skip to content
203 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
13import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
14import { Hint } from "../ui/hint";
15import { SelectField } from "../ui/select";
16import { BUTTONS, useDialogFetcher } from "./dialogs";
17
18/** `/acme/-/agents/skills`, or one skill's page. */
19export 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. */
24export 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
39const SCOPE_ICONS: Record<SkillScope, typeof Bot> = { agent: Bot, team: Users, workspace: Building2 };
40
41/** Where a skill is attached, as a small chip. */
42export 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. */
55export 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. */
77export 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. */
99function 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. */
109export 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&apos;s skills reach every agent on it. A skill never gives an agent a tool or access it doesn&apos;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. */
165export 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&apos;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}