Skip to content
302 linesCodeBlameRaw
1/**
2 * Customize → Plugins (docs.g1t.sh/guides/customize/, "Plugins"). Yours:
3 * the plugins the workspace installed, each saying what it brought (skills,
4 * agents, connectors, and what only Claude Code on a runner uses), whether
5 * it is on, and whether its marketplace has moved on. Discover: every
6 * plugin in every marketplace the workspace reads, searched and narrowed by
7 * category and marketplace, grouped by marketplace or sorted by name, each
8 * saying whether it can be installed here and why not when it can't.
9 */
10import { ArrowUpCircle, EllipsisVertical, Plus, Store, Trash2 } from "lucide-react";
11import { Link, data, useFetcher, useSearchParams } from "react-router";
12
13import type { PluginInstall, PluginListing } from "@g1t/contracts";
14import { customizePath } from "@g1t/contracts/customize";
15
16import type { Route } from "./+types/plugins";
17import { useCustomize } from "./layout";
18import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
19import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
20import { AvailabilityBadge, Chips, FeaturedBanner, FilterMenu, ListingCard, ListingGrid, ListingMark, NoAnswer, RequestButton, SectionHead, Waiting } from "../../../components/customize";
21import { ButtonLink, EmptyState, SubmitButton } from "../../../components/ui";
22import { Badge } from "../../../components/ui/badge";
23import { Button } from "../../../components/ui/button";
24import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu";
25import { Hint } from "../../../components/ui/hint";
26import { Switch } from "../../../components/ui/switch";
27import { type PluginSort, categoriesOf, claudeCodeOnly, componentChips, filterPlugins, groupByMarketplace, matches, nextParams, pluginListingRef, pluginPath, readPluginFilters, readQuery, readView, requestedBy, waitingFor, withParams } from "../../../lib/customize";
28import { page } from "../../../lib/meta";
29import { workspaceAgents } from "../../../lib/services.server";
30import { requireUser, roleIn } from "../../../lib/session.server";
31
32export { shouldRevalidate } from "../../../lib/customize.server";
33
34export function meta({ params, ...args }: Route.MetaArgs) {
35 return page(args, { title: `Plugins · Customize · ${params.owner} · g1t` });
36}
37
38export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ installs: PluginInstall[] | null; discover: PluginListing[] | null | undefined }> {
39 const viewer = requireUser(context, request);
40 const slug = params.owner.toLowerCase();
41 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
42 const discovering = readView(new URL(request.url).searchParams) === "discover";
43 const [installs, discover] = await Promise.all([
44 readOrNull(workspaceAgents.pluginInstalls(slug, viewer)),
45 // Only Discover reads the marketplaces; undefined says it wasn't asked for.
46 discovering ? readOrNull(workspaceAgents.discover(slug, viewer)).then((found) => found?.plugins ?? null) : Promise.resolve(undefined),
47 ]);
48 return { installs, discover };
49}
50
51/** Install a plugin, update it to its marketplace's current version, switch it on or off, or uninstall it. */
52export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
53 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
54 const intent = String(form.get("intent") ?? "");
55 const ref = String(form.get("plugin") ?? "");
56 switch (intent) {
57 case "install":
58 return answer(intent, workspaceAgents.installPlugin(slug, viewer, ref));
59 case "update":
60 return answer(intent, workspaceAgents.updatePlugin(slug, viewer, ref));
61 case "switch":
62 return answer(intent, workspaceAgents.setPluginEnabled(slug, viewer, ref, form.get("enabled") === "on"));
63 case "uninstall":
64 return answer(intent, workspaceAgents.uninstallPlugin(slug, viewer, ref));
65 default:
66 return { ok: false, intent, error: "Unknown request." };
67 }
68}
69
70export default function CustomizePlugins({ loaderData }: Route.ComponentProps) {
71 const [params] = useSearchParams();
72 return readView(params) === "discover" ? <Discover listings={loaderData.discover ?? null} /> : <Yours installs={loaderData.installs} />;
73}
74
75// ── Yours ────────────────────────────────────────────────────────────────
76
77function Yours({ installs }: { installs: PluginInstall[] | null }) {
78 const { slug, owner } = useCustomize();
79 const [params] = useSearchParams();
80 const query = readQuery(params);
81 if (!installs) return <NoAnswer what="Installed plugins" />;
82 const shown = installs.filter((install) => matches([install.name, install.display_name, install.description, install.marketplace, ...install.components.skills.map((s) => s.name), ...install.components.agents.map((a) => a.name)], query));
83 const updates = installs.filter((install) => install.update).length;
84 return (
85 <section aria-labelledby="installed">
86 <SectionHead id="installed" title="Installed" aside={updates > 0 ? <span className="text-warn">{updates === 1 ? "1 update available" : `${updates} updates available`}</span> : `${installs.length}`}>
87 Each brought its skills to the library, its agents to Templates and its remote servers to Connectors, pinned to the commit it was read at. Everything else in it reaches Claude Code when it runs on a runner.
88 </SectionHead>
89 {shown.length === 0 ? (
90 <EmptyState title={query ? "No plugin matches" : "No plugins yet"}>
91 {query ? (
92 "Try another word, or look in Discover."
93 ) : (
94 <>
95 Find one in{" "}
96 <Link to={withParams(customizePath("plugins", slug), params, { view: "discover" })} className="text-accent hover:underline">
97 Discover
98 </Link>
99 : public skills, agents and connectors in the open plugin format, from any marketplace.
100 </>
101 )}
102 </EmptyState>
103 ) : (
104 <ListingGrid>
105 {shown.map((install) => (
106 <InstallCard key={install.id} install={install} slug={slug} owner={owner} />
107 ))}
108 </ListingGrid>
109 )}
110 </section>
111 );
112}
113
114/** An installed plugin: what it brought, whether it is on, and the update, when there is one. */
115function InstallCard({ install, slug, owner }: { install: PluginInstall; slug: string; owner: boolean }) {
116 const fetcher = useFetcher<ActionResult>({ key: `install-${install.id}` });
117 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
118 const pending = fetcher.formData?.get("intent") === "switch" ? fetcher.formData.get("enabled") === "on" : null;
119 const on = pending ?? install.enabled;
120 const name = install.display_name ?? install.name;
121 const runner = claudeCodeOnly(install.components);
122 return (
123 <ListingCard
124 mark={<ListingMark id={install.ref} name={name} />}
125 title={name}
126 to={pluginPath(slug, install.ref)}
127 description={install.description}
128 muted={!on}
129 badge={
130 <>
131 {install.update && (
132 <Hint label={`The marketplace lists ${install.update.version ?? "a newer commit"}; this workspace has ${install.version ?? install.sha?.slice(0, 7) ?? "an older one"}.`}>
133 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
134 <Badge tone="warn">Update available{install.update.version ? `: ${install.update.version}` : ""}</Badge>
135 </span>
136 </Hint>
137 )}
138 {!on && !install.update && <Badge>Switched off</Badge>}
139 {owner && <InstallMenu install={install} fetcher={fetcher} />}
140 </>
141 }
142 meta={`${install.version ? `v${install.version}` : (install.sha?.slice(0, 7) ?? "")} · ${install.marketplace} · by @${install.installed_by}`}
143 action={
144 owner ? (
145 <Hint label={on ? `Switch ${name} off: its skills, agents and connectors stop reaching agents` : `Switch ${name} on`}>
146 <span className="inline-flex">
147 <Switch checked={on} disabled={fetcher.state !== "idle"} aria-label={`${name} on`} onCheckedChange={(next) => fetcher.submit({ intent: "switch", plugin: install.ref, enabled: next ? "on" : "off" }, { method: "post" })} />
148 </span>
149 </Hint>
150 ) : (
151 <Badge tone={on ? "success" : "neutral"}>{on ? "On" : "Off"}</Badge>
152 )
153 }
154 >
155 <div className="space-y-1.5">
156 <Chips items={componentChips(install.components)} label="What it brought" />
157 {runner.length > 0 && <Chips items={[`Claude Code only: ${runner.join(" · ")}`]} muted label="For Claude Code on a runner" />}
158 {install.problems.length > 0 && <p className="text-xs text-warn">{install.problems.length === 1 ? "1 problem" : `${install.problems.length} problems`} when it was read</p>}
159 {error && <p className="text-xs text-danger">{error}</p>}
160 </div>
161 </ListingCard>
162 );
163}
164
165/** Update an install, or uninstall it (asked twice). */
166function InstallMenu({ install, fetcher }: { install: PluginInstall; fetcher: ReturnType<typeof useFetcher<ActionResult>> }) {
167 return (
168 <DropdownMenu>
169 <DropdownMenuTrigger asChild>
170 <Button type="button" variant="ghost" size="icon-xs" aria-label={`What to do with ${install.display_name ?? install.name}`} disabled={fetcher.state !== "idle"}>
171 <EllipsisVertical size={14} />
172 </Button>
173 </DropdownMenuTrigger>
174 <DropdownMenuContent align="end">
175 {install.update && (
176 <>
177 <DropdownMenuItem onSelect={() => fetcher.submit({ intent: "update", plugin: install.ref }, { method: "post" })}>
178 <ArrowUpCircle />
179 Update{install.update.version ? ` to ${install.update.version}` : ""}
180 </DropdownMenuItem>
181 <DropdownMenuSeparator />
182 </>
183 )}
184 <Confirm
185 title={`Uninstall ${install.display_name ?? install.name}?`}
186 confirm="Uninstall"
187 fields={{ intent: "uninstall", plugin: install.ref }}
188 fetcherKey={`uninstall-${install.id}`}
189 trigger={
190 <DropdownMenuItem onSelect={(event) => event.preventDefault()} className="text-danger data-highlighted:text-danger [&_svg]:text-danger">
191 <Trash2 />
192 Uninstall
193 </DropdownMenuItem>
194 }
195 >
196 Its skills leave the library, its agents leave Templates and its connectors are removed, at once. Agents hired from it stay as they are.
197 </Confirm>
198 </DropdownMenuContent>
199 </DropdownMenu>
200 );
201}
202
203// ── Discover ─────────────────────────────────────────────────────────────
204
205function Discover({ listings }: { listings: PluginListing[] | null }) {
206 const { slug, owner, username, requests } = useCustomize();
207 const [params, setParams] = useSearchParams();
208 if (!listings) return <NoAnswer what="Public plugins" />;
209 const filters = readPluginFilters(params);
210 const shown = filterPlugins(listings, filters);
211 const categories = categoriesOf(listings);
212 const marketplaces = [...new Set(listings.map((listing) => listing.marketplace))].sort();
213 const asked = requests?.requests ?? [];
214 const set = (changes: Record<string, string | null>) => setParams(nextParams(params, changes), { replace: true, preventScrollReset: true });
215 const card = (listing: PluginListing) => (
216 <PluginCard key={listing.ref} listing={listing} slug={slug} owner={owner} requested={requestedBy(asked, pluginListingRef(listing), username)} waiting={owner ? waitingFor(asked, pluginListingRef(listing)) : 0} />
217 );
218 return (
219 <div className="space-y-8">
220 <FeaturedBanner
221 title="Install public plugins as they are"
222 action={
223 <ButtonLink to={customizePath("marketplaces", slug)} variant="outline" size="sm">
224 <Store size={14} />
225 {marketplaces.length} {marketplaces.length === 1 ? "marketplace" : "marketplaces"}
226 </ButtonLink>
227 }
228 >
229 Every plugin in the marketplaces this workspace reads, in the open plugin format: skills, agents and connectors for g1t&apos;s agents, and everything else for Claude Code on a runner. Owners add marketplaces by address.
230 </FeaturedBanner>
231 <div className="flex flex-wrap items-center gap-2">
232 {categories.length > 0 && <FilterMenu label="Category" all="All" value={filters.category} options={categories.map((c) => ({ value: c, label: c }))} onChange={(next) => set({ category: next })} />}
233 <FilterMenu label="Marketplace" all="All" value={filters.marketplace} options={marketplaces.map((m) => ({ value: m, label: m }))} onChange={(next) => set({ marketplace: next })} />
234 <FilterMenu label="Sort" all="By marketplace" value={filters.sort === "name" ? "name" : null} options={[{ value: "name", label: "By name" }]} onChange={(next) => set({ sort: (next as PluginSort | null) ?? null })} />
235 <span className="text-xs text-faint">
236 {shown.length} of {listings.length}
237 </span>
238 </div>
239 {shown.length === 0 ? (
240 <EmptyState title="No plugin matches">Try another word, category or marketplace, or add a marketplace that lists it.</EmptyState>
241 ) : filters.sort === "name" ? (
242 <ListingGrid>{shown.map(card)}</ListingGrid>
243 ) : (
244 groupByMarketplace(shown).map((group) => (
245 <section key={group.marketplace} aria-labelledby={`from-${group.marketplace}`}>
246 <SectionHead id={`from-${group.marketplace}`} title={`From ${group.marketplace}`} aside={`${group.items.length}`} />
247 <ListingGrid>{group.items.map(card)}</ListingGrid>
248 </section>
249 ))
250 )}
251 </div>
252 );
253}
254
255/** A plugin in Discover: what it is and who made it, whether it can be installed here, and Install or Request. */
256function PluginCard({ listing, slug, owner, requested, waiting }: { listing: PluginListing; slug: string; owner: boolean; requested: boolean; waiting: number }) {
257 const fetcher = useFetcher<ActionResult>({ key: `discover-plugin-${listing.ref}` });
258 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
259 const name = listing.display_name ?? listing.name;
260 const available = listing.availability.kind === "available";
261 const action = !available ? null : owner ? (
262 <fetcher.Form method="post">
263 <input type="hidden" name="intent" value="install" />
264 <input type="hidden" name="plugin" value={listing.ref} />
265 <SubmitButton size="xs" fetcher={fetcher} match={{ intent: "install", plugin: listing.ref }} pending="Installing…">
266 <Plus size={13} />
267 Install
268 </SubmitButton>
269 </fetcher.Form>
270 ) : (
271 <RequestButton slug={slug} listing={pluginListingRef(listing)} name={name} requested={requested} size="xs" />
272 );
273 return (
274 <ListingCard
275 mark={<ListingMark id={listing.ref} name={name} />}
276 title={name}
277 to={pluginPath(slug, listing.ref)}
278 description={listing.description}
279 muted={listing.availability.kind === "soon" || listing.availability.kind === "unavailable"}
280 badge={available ? null : <AvailabilityBadge availability={listing.availability} />}
281 meta={
282 <>
283 {listing.author ? `by ${listing.author} · ` : ""}
284 {listing.marketplace}
285 {listing.version ? ` · v${listing.version}` : ""}
286 {waiting > 0 && (
287 <>
288 {" · "}
289 <Waiting slug={slug} count={waiting} />
290 </>
291 )}
292 </>
293 }
294 action={action}
295 >
296 {(listing.skills.length > 0 || listing.category) && (
297 <Chips items={[...(listing.category ? [listing.category] : []), ...listing.skills.slice(0, 4), ...(listing.skills.length > 4 ? [`+${listing.skills.length - 4} more`] : [])]} muted={!available} label="What it brings" />
298 )}
299 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
300 </ListingCard>
301 );
302}