| 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 | */ |
| 10 | import { ArrowUpCircle, EllipsisVertical, Plus, Store, Trash2 } from "lucide-react"; |
| 11 | import { Link, data, useFetcher, useSearchParams } from "react-router"; |
| 12 | |
| 13 | import type { PluginInstall, PluginListing } from "@g1t/contracts"; |
| 14 | import { customizePath } from "@g1t/contracts/customize"; |
| 15 | |
| 16 | import type { Route } from "./+types/plugins"; |
| 17 | import { useCustomize } from "./layout"; |
| 18 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; |
| 19 | import { type ActionResult, Confirm } from "../../../components/agents/dialogs"; |
| 20 | import { AvailabilityBadge, Chips, FeaturedBanner, FilterMenu, ListingCard, ListingGrid, ListingMark, NoAnswer, RequestButton, SectionHead, Waiting } from "../../../components/customize"; |
| 21 | import { ButtonLink, EmptyState, SubmitButton } from "../../../components/ui"; |
| 22 | import { Badge } from "../../../components/ui/badge"; |
| 23 | import { Button } from "../../../components/ui/button"; |
| 24 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu"; |
| 25 | import { Hint } from "../../../components/ui/hint"; |
| 26 | import { Switch } from "../../../components/ui/switch"; |
| 27 | import { type PluginSort, categoriesOf, claudeCodeOnly, componentChips, filterPlugins, groupByMarketplace, matches, nextParams, pluginListingRef, pluginPath, readPluginFilters, readQuery, readView, requestedBy, waitingFor, withParams } from "../../../lib/customize"; |
| 28 | import { page } from "../../../lib/meta"; |
| 29 | import { workspaceAgents } from "../../../lib/services.server"; |
| 30 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 31 | |
| 32 | export { shouldRevalidate } from "../../../lib/customize.server"; |
| 33 | |
| 34 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 35 | return page(args, { title: `Plugins · Customize · ${params.owner} · g1t` }); |
| 36 | } |
| 37 | |
| 38 | export 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. */ |
| 52 | export 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 | |
| 70 | export 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 | |
| 77 | function 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. */ |
| 115 | function 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). */ |
| 166 | function 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 | |
| 205 | function 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'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. */ |
| 256 | function 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 | } |