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