Skip to content
434 linesCodeBlameRaw

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.

Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.1/**
2 * One plugin's page, read from its source as Claude Code would read it: its
3 * README, what it brings g1t's agents (skills, each given to agents from
4 * here once installed; subagents, each hireable as a starting point;
5 * remote MCP servers, connectors once installed) and what only Claude Code
6 * on a runner uses; and for owners Install, Update, the switch, and
7 * Uninstall. Members ask with Request.
8 */
9import { ArrowLeft, ArrowUpCircle, Bot, ExternalLink, Plus, Power, Trash2, UserPlus } from "lucide-react";
10import { Link, data, useFetcher } from "react-router";
11
12import type { PluginDetail, SkillLibrary, WorkspaceConnector } from "@g1t/contracts";
13import { customizePath } from "@g1t/contracts/customize";
14
15import type { Route } from "./+types/plugin";
16import { useCustomize } from "./layout";
17import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
18import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
19import { skillsPath } from "../../../components/agents/skills";
20import { AvailabilityBadge, AvailableTo, Chips, ListingMark, NoAnswer, RequestButton, SectionHead, StateBadge } from "../../../components/customize";
21import { Markdown } from "../../../components/markdown";
22import { ButtonLink, SubmitButton, TimeAgo } from "../../../components/ui";
23import { Alert } from "../../../components/ui/alert";
24import { Badge } from "../../../components/ui/badge";
25import { Button } from "../../../components/ui/button";
26import { Card } from "../../../components/ui/card";
27import { type Reach, type ReachOption, type ReachTargets, claudeCodeOnly, connectorPagePath, fetchWords, pluginListingRef, requestedBy, splitTarget } from "../../../lib/customize";
28import { addConnectorThen } from "../../../lib/customize.server";
29import { page } from "../../../lib/meta";
30import { skillLibrary, workspaceAgents } from "../../../lib/services.server";
31import { requireUser, roleIn } from "../../../lib/session.server";
32
33export function meta({ params, ...args }: Route.MetaArgs) {
34 const listing = args.loaderData?.detail?.listing;
35 return page(args, { title: `${listing?.display_name ?? listing?.name ?? "Plugin"} · Customize · ${params.owner} · g1t` });
36}
37
38type Loaded = { detail: PluginDetail | null; library: SkillLibrary | null; connectors: WorkspaceConnector[] };
39
40export async function loader({ params, context, request }: Route.LoaderArgs): Promise<Loaded> {
41 const viewer = requireUser(context, request);
42 const slug = params.owner.toLowerCase();
43 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
44 const [found, library, connectors] = await Promise.all([
45 workspaceAgents.plugin(slug, viewer, params.ref).catch(() => null),
46 readOrNull(skillLibrary.library(slug, viewer)),
47 readOrNull(workspaceAgents.connectors(slug, viewer)),
48 ]);
49 if (found && !found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 502 });
50 return { detail: found?.value ?? null, library, connectors: connectors ?? [] };
51}
52
53/** Install, update, switch or uninstall the plugin; connect one of its servers; give one of its skills to agents. */
54export async function action({ params, context, request }: Route.ActionArgs): Promise<Response | ActionResult> {
55 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
56 const intent = String(form.get("intent") ?? "");
57 const ref = params.ref;
58 switch (intent) {
59 case "install":
60 return answer(intent, workspaceAgents.installPlugin(slug, viewer, ref));
61 case "update":
62 return answer(intent, workspaceAgents.updatePlugin(slug, viewer, ref));
63 case "switch":
64 return answer(intent, workspaceAgents.setPluginEnabled(slug, viewer, ref, form.get("enabled") === "on"));
65 case "uninstall":
66 return answer(intent, workspaceAgents.uninstallPlugin(slug, viewer, ref));
67 case "connect":
68 return addConnectorThen(slug, viewer, { from: { kind: "plugin", ref, server: String(form.get("server") ?? "") }, auth: form.get("auth") === "header" ? "header" : form.get("auth") === "none" ? "none" : "oauth", header: null });
69 case "attach": {
70 const { scope, target } = splitTarget(String(form.get("where") ?? ""));
71 return answer(intent, skillLibrary.attachSkill(slug, viewer, String(form.get("name") ?? ""), scope, target));
72 }
73 case "detach":
74 return answer(intent, skillLibrary.detachSkill(slug, viewer, String(form.get("name") ?? ""), String(form.get("attachment") ?? "")));
75 default:
76 return { ok: false, intent, error: "Unknown request." };
77 }
78}
79
80export default function PluginPage({ loaderData, params }: Route.ComponentProps) {
81 const { slug, owner, username, requests } = useCustomize();
82 const { detail, library, connectors } = loaderData;
83 const back = (
84 <Link to={`${customizePath("plugins", slug)}?view=discover`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
85 <ArrowLeft size={14} />
86 Plugins
87 </Link>
88 );
89 if (!detail) {
90 return (
91 <div className="space-y-6">
92 {back}
93 <NoAnswer what={params.ref} />
94 </div>
95 );
96 }
97 const { listing, install } = detail;
98 const components = install?.components ?? detail.components;
99 const name = listing.display_name ?? listing.name;
100 const enabled = install?.enabled ?? false;
101 const targets: ReachTargets | null = library && (library.can_manage || library.teams.length > 0) ? { can_manage: library.can_manage, teams: library.teams, agents: library.agents } : null;
102 const runner = components ? claudeCodeOnly(components) : [];
103 const problems = [...detail.problems, ...(install?.problems ?? [])];
104 return (
105 <div className="space-y-8">
106 {back}
107 <header className="flex flex-wrap items-start gap-4">
108 <ListingMark id={listing.ref} name={name} size={56} />
109 <div className="min-w-0 grow basis-60">
110 <div className="flex flex-wrap items-center gap-2">
111 <h2 className="text-xl font-semibold tracking-tight">{name}</h2>
112 <AvailabilityBadge availability={listing.availability} />
113 {install?.update && <Badge tone="warn">Update available{install.update.version ? `: ${install.update.version}` : ""}</Badge>}
114 </div>
115 <p className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
116 {listing.author && <span>by {listing.author}</span>}
117 {listing.author && (
118 <span aria-hidden="true" className="text-faint">
119 ·
120 </span>
121 )}
122 <Link to={`${customizePath("plugins", slug)}?view=discover&marketplace=${encodeURIComponent(listing.marketplace)}`} className="hover:text-fg hover:underline">
123 {listing.marketplace}
124 </Link>
125 {listing.version && (
126 <>
127 <span aria-hidden="true" className="text-faint">
128 ·
129 </span>
130 <span>v{listing.version}</span>
131 </>
132 )}
133 {listing.category && (
134 <>
135 <span aria-hidden="true" className="text-faint">
136 ·
137 </span>
138 <span>{listing.category}</span>
139 </>
140 )}
141 </p>
142 <p className="mt-2 max-w-3xl text-sm text-fg-soft">{listing.description}</p>
143 </div>
144 <Actions detail={detail} slug={slug} owner={owner} requested={requestedBy(requests?.requests ?? [], pluginListingRef(listing), username)} />
145 </header>
146 {install && !enabled && (
147 <Alert tone="warn" asChild>
148 <p>Switched off: its skills, agents and connectors are kept but reach no agent until an owner switches it on.</p>
149 </Alert>
150 )}
151 {problems.length > 0 && (
152 <Alert tone="warn" asChild>
153 <div>
154 <p className="font-medium">What couldn&apos;t be read</p>
155 <ul className="mt-1 list-disc space-y-0.5 pl-5 text-muted">
156 {problems.map((problem) => (
157 <li key={problem}>{problem}</li>
158 ))}
159 </ul>
160 </div>
161 </Alert>
162 )}
163
164 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
165 <div className="min-w-0 space-y-8">
166 {components && (
167 <section aria-labelledby="components" className="space-y-6">
168 <SectionHead id="components" title="What it brings">
169 Skills, agents and connectors reach g1t&apos;s agents once it is installed; the rest reaches Claude Code when it runs on a g1t runner.
170 </SectionHead>
171 <Group title="Skills" count={components.skills.length + components.commands.length} empty="None.">
172 {[...components.skills, ...components.commands.map((command) => ({ name: command.name, description: "A command: the older one-file form of a skill.", path: command.path }))].map((skill) => {
173 const added = library?.skills.find((s) => s.plugin === listing.ref && s.name.toLowerCase() === skill.name.toLowerCase()) ?? null;
174 return (
175 <li key={skill.path} className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
176 <div className="min-w-0 grow">
177 {added ? (
178 <Link to={skillsPath(slug, added.qualified_name)} className="font-mono text-sm font-medium hover:underline">
179 {added.qualified_name}
180 </Link>
181 ) : (
182 <span className="font-mono text-sm font-medium">
183 {listing.name}:{skill.name}
184 </span>
185 )}
186 {skill.description && <p className="mt-0.5 text-sm text-muted">{skill.description}</p>}
187 </div>
188 {added && <SkillReach skill={added} targets={targets} />}
189 </li>
190 );
191 })}
192 </Group>
193 <Group title="Agents" count={components.agents.length} empty="None.">
194 {components.agents.map((agent) => {
195 const file = detail.agents.find((a) => a.name === agent.name);
196 return (
197 <li key={agent.path} className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
198 <Bot size={16} className="mt-0.5 shrink-0 text-faint" aria-hidden />
199 <div className="min-w-0 grow">
200 <p className="flex flex-wrap items-center gap-2">
201 <span className="font-mono text-sm font-medium">{agent.name}</span>
202 {file?.model && <Badge>{file.model}</Badge>}
203 </p>
204 {(agent.description || file?.description) && <p className="mt-0.5 text-sm text-muted">{agent.description ?? file?.description}</p>}
205 </div>
206 {install && enabled && owner ? (
207 <ButtonLink to={`/${slug}/-/agents/new?plugin=${encodeURIComponent(listing.ref)}&agent=${encodeURIComponent(agent.name)}`} variant="outline" size="xs">
208 <UserPlus size={12} />
209 Hire
210 </ButtonLink>
211 ) : (
212 <span className="text-xs text-faint">{install ? (enabled ? "Owners hire agents" : "Switched off") : "Hire it once installed"}</span>
213 )}
214 </li>
215 );
216 })}
217 </Group>
218 <Group title="Connectors" count={components.mcp_servers.length} empty="None.">
219 {components.mcp_servers.map((server) => {
220 const connector = connectors.find((c) => c.from.kind === "plugin" && c.from.ref === listing.ref && c.from.server === server.name) ?? null;
221 return (
222 <li key={server.name} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
223 <div className="min-w-0 grow">
224 <p className="flex flex-wrap items-center gap-2">
225 {connector ? (
226 <Link to={connectorPagePath(slug, connector.id)} className="font-mono text-sm font-medium hover:underline">
227 {server.name}
228 </Link>
229 ) : (
230 <span className="font-mono text-sm font-medium">{server.name}</span>
231 )}
232 {!server.remote ? <Badge>Claude Code only</Badge> : connector ? <StateBadge state={connector.state} problem={connector.problem} /> : null}
233 {connector && install && !enabled && <span className="text-xs text-faint">Stopped while the plugin is off</span>}
234 </p>
235 <p className="mt-0.5 truncate font-mono text-xs text-muted">{server.url ?? `${server.type} server, run on the machine Claude Code runs on`}</p>
236 </div>
237 {server.remote && install && !connector && owner && <ConnectServer server={server.name} />}
238 </li>
239 );
240 })}
241 </Group>
242 {runner.length > 0 && (
243 <div>
244 <h3 className="text-sm font-semibold">Claude Code only</h3>
245 <p className="mt-0.5 mb-2 text-sm text-muted">Read by Claude Code on a g1t runner, with the plugin fetched at its pinned commit; g1t&apos;s own agents don&apos;t use these.</p>
246 <Chips items={runner} />
247 </div>
248 )}
249 </section>
250 )}
251 <section aria-labelledby="readme">
252 <SectionHead id="readme" title="README" />
253 {detail.readme ? (
254 <Card className="p-5 sm:p-6">
255 <Markdown source={detail.readme} />
256 </Card>
257 ) : (
258 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
259 <p>{components ? "It has no README." : "Its files couldn't be read, so there is nothing more to show than its listing."}</p>
260 </Card>
261 )}
262 </section>
263 </div>
264
265 <aside className="text-sm">
266 <Card asChild className="space-y-3 p-4">
267 <dl>
268 <div>
269 <dt className="text-xs text-faint">Source</dt>
270 <dd className="mt-0.5 break-words">{fetchWords(install?.fetch ?? listing.fetch)}</dd>
271 </div>
272 {(install?.sha ?? detail.manifest?.version) && (
273 <div>
274 <dt className="text-xs text-faint">{install ? "Installed at" : "Version"}</dt>
275 <dd className="mt-0.5 font-mono text-xs">{install ? install.sha?.slice(0, 7) : detail.manifest?.version}</dd>
276 </div>
277 )}
278 {install && (
279 <div>
280 <dt className="text-xs text-faint">Installed</dt>
281 <dd className="mt-0.5">
282 By @{install.installed_by} · <TimeAgo at={install.installed_at} />
283 </dd>
284 </div>
285 )}
286 {detail.manifest?.license && (
287 <div>
288 <dt className="text-xs text-faint">License</dt>
289 <dd className="mt-0.5">{detail.manifest.license}</dd>
290 </div>
291 )}
292 {(listing.homepage ?? detail.manifest?.repository) && (
293 <div>
294 <dt className="text-xs text-faint">More</dt>
295 <dd className="mt-0.5">
296 <a href={listing.homepage ?? detail.manifest!.repository!} target="_blank" rel="noreferrer" className="flex items-center gap-1 hover:underline">
297 <span className="truncate">{listing.homepage ?? detail.manifest!.repository}</span>
298 <ExternalLink size={11} className="shrink-0" aria-hidden />
299 </a>
300 </dd>
301 </div>
302 )}
303 {listing.tags.length > 0 && (
304 <div>
305 <dt className="text-xs text-faint">Tags</dt>
306 <dd className="mt-1">
307 <Chips items={listing.tags} />
308 </dd>
309 </div>
310 )}
311 </dl>
312 </Card>
313 </aside>
314 </div>
315 </div>
316 );
317}
318
319/** A list of what a plugin brings, under its heading, or a word when there is nothing. */
320function Group({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
321 return (
322 <div>
323 <h3 className="mb-2 flex items-baseline gap-2 text-sm font-semibold">
324 {title}
325 <span className="text-xs font-normal text-faint tabular-nums">{count}</span>
326 </h3>
327 {count === 0 ? (
328 <Card asChild tone="plain" className="border-dashed px-4 py-3 text-sm text-muted">
329 <p>{empty}</p>
330 </Card>
331 ) : (
332 <Card asChild divided className="overflow-hidden">
333 <ul>{children}</ul>
334 </Card>
335 )}
336 </div>
337 );
338}
339
340/** Which agents one of the plugin's skills reaches, with the control that changes it. */
341function SkillReach({ skill, targets }: { skill: SkillLibrary["skills"][number]; targets: ReachTargets | null }) {
342 const fetcher = useFetcher<ActionResult>({ key: `plugin-skill-${skill.id}` });
343 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
344 const reach: Reach[] = skill.attachments.map((a) => ({ id: a.id, scope: a.scope, target: a.target, label: a.label }));
345 const change = (option: ReachOption, checked: boolean) => {
346 if (checked) fetcher.submit({ intent: "attach", name: skill.qualified_name, where: option.value }, { method: "post" });
347 else if (option.reach) fetcher.submit({ intent: "detach", name: skill.qualified_name, attachment: option.reach.id }, { method: "post" });
348 };
349 return (
350 <div className="flex shrink-0 flex-col items-end gap-1">
351 <AvailableTo reach={reach} targets={targets} busy={fetcher.state !== "idle"} onChange={change} />
352 {error && <p className="text-xs text-danger">{error}</p>}
353 </div>
354 );
355}
356
357/** Connect one of the plugin's remote servers that has no connector yet. */
358function ConnectServer({ server }: { server: string }) {
359 const fetcher = useFetcher<ActionResult>({ key: `plugin-connect-${server}` });
360 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
361 return (
362 <fetcher.Form method="post" className="flex flex-col items-end gap-1">
363 <input type="hidden" name="intent" value="connect" />
364 <input type="hidden" name="server" value={server} />
365 <SubmitButton variant="outline" size="xs" fetcher={fetcher} match={{ intent: "connect", server }} pending="Connecting…">
366 <Plus size={12} />
367 Connect
368 </SubmitButton>
369 {error && <p className="text-xs text-danger">{error}</p>}
370 </fetcher.Form>
371 );
372}
373
374/** What the viewer can do with the plugin: owners install, update, switch and uninstall; members ask. */
375function Actions({ detail, slug, owner, requested }: { detail: PluginDetail; slug: string; owner: boolean; requested: boolean }) {
376 const { listing, install } = detail;
377 const fetcher = useFetcher<ActionResult>({ key: `plugin-${listing.ref}` });
378 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
379 const name = listing.display_name ?? listing.name;
380 if (listing.availability.kind === "soon" || listing.availability.kind === "unavailable") return null;
381 if (!owner) {
382 return install ? null : <RequestButton slug={slug} listing={pluginListingRef(listing)} name={name} requested={requested} />;
383 }
384 return (
385 <div className="flex shrink-0 flex-col items-end gap-2">
386 <div className="flex flex-wrap items-center gap-2">
387 {!install ? (
388 <fetcher.Form method="post">
389 <input type="hidden" name="intent" value="install" />
390 <SubmitButton variant="accent" fetcher={fetcher} match={{ intent: "install" }} pending="Installing…">
391 <Plus size={14} />
392 Install
393 </SubmitButton>
394 </fetcher.Form>
395 ) : (
396 <>
397 {install.update && (
398 <fetcher.Form method="post">
399 <input type="hidden" name="intent" value="update" />
400 <SubmitButton variant="accent" size="sm" fetcher={fetcher} match={{ intent: "update" }} pending="Updating…">
401 <ArrowUpCircle size={14} />
402 Update{install.update.version ? ` to ${install.update.version}` : ""}
403 </SubmitButton>
404 </fetcher.Form>
405 )}
406 <fetcher.Form method="post">
407 <input type="hidden" name="intent" value="switch" />
408 <input type="hidden" name="enabled" value={install.enabled ? "off" : "on"} />
409 <SubmitButton variant="outline" size="sm" fetcher={fetcher} match={{ intent: "switch" }} pending="Saving…">
410 <Power size={14} />
411 {install.enabled ? "Switch off" : "Switch on"}
412 </SubmitButton>
413 </fetcher.Form>
414 <Confirm
415 title={`Uninstall ${name}?`}
416 confirm="Uninstall"
417 fields={{ intent: "uninstall" }}
418 fetcherKey={`uninstall-${listing.ref}`}
419 trigger={
420 <Button type="button" variant="outline" size="sm" className="hover:border-danger/50 hover:text-danger">
421 <Trash2 size={14} />
422 Uninstall
423 </Button>
424 }
425 >
426 Its skills leave the library, its agents leave Templates and its connectors are removed, at once. Agents hired from it stay as they are.
427 </Confirm>
428 </>
429 )}
430 </div>
431 {error && <p className="text-xs text-danger">{error}</p>}
432 </div>
433 );
434}

This file's history is long; its oldest lines are credited to the oldest commit read.