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.
| Customize replaces the Marketplace on the site and the phone: three tabs, Skills, Connectors and Plugins, each with Yours and Discover, a search and the owners' Add menu, all kept in the address. Skills' Yours is the library (drafts, each skill with an inline Available to control that attaches it to every agent, a team or one, g1t's foundational skills, and the repository it follows); its Discover lists every skill in every marketplace's plugins, grouped by marketplace, and Add installs the plugin that brings one. Connectors' Yours is the workspace's MCP servers with their state, tools, where they reach and what an owner does with them (sign in or out, a key, list tools again, remove), beside the integrations the workspace has connected; its Discover is the directory, MCP servers first and connected in a step (the browser signs in, a key is pasted, or neither), then g1t's integrations, each person's own, and what is coming; a connector has a page, a connect page by directory entry or address, and the sign-in's return lands on it. Plugins' Yours is what the workspace installed with what each brought, its switch, Update when the marketplace moved on, and Uninstall; its Discover is every plugin, narrowed by category and marketplace and sorted by marketplace or name, with g1t's announced ones muted as Soon; a plugin's page renders its README and lists its skills (given to agents from there), its subagents (Hire opens the new-agent form filled from one), its servers and what only Claude Code uses. Marketplaces have a page; requests moved under Customize and name plugins, skills and connectors. Apps lists installed plugins and connectors beside integrations, ⌘K, the Apps page, the phone's More and the breadcrumbs say Customize, and the Marketplace's and the skill library's old addresses redirect. An agent's Abilities tab shows the connectors that reach it as Connectors, with per-tool levels, and keeps Add an MCP server under Only this agent; Templates lists the agents installed plugins bring; an agent's Skills tab says which plugin a skill came from. The phone's deep links open Customize's tabs. | 1 | /** |
| 2 | * What Customize's pages do on the server besides reading: the connector | |
| 3 | * actions every page with a connector shares (signing in and out, a key, | |
| 4 | * listing tools again, where it reaches, removing it), and what follows | |
| 5 | * adding one: the browser goes to sign in, or to the connector's page. | |
| 6 | */ | |
| 7 | import { type ShouldRevalidateFunctionArgs, redirect } from "react-router"; | |
| 8 | ||
| 9 | import type { ConnectorInput, User, WorkspaceConnector } from "@g1t/contracts"; | |
| 10 | ||
| 11 | import { addresses } from "./addresses.server"; | |
| 12 | import { connectorCallbackPath, connectorPagePath, needsReload, splitTarget } from "./customize"; | |
| 13 | import { workspaceAgents } from "./services.server"; | |
| 14 | import type { ActionResult } from "../components/agents/dialogs"; | |
| 15 | import { answer } from "../components/agents/actions.server"; | |
| 16 | ||
| 17 | /** Where a connector's sign-in comes back to: this g1t's own page. */ | |
| 18 | export function callbackUri(slug: string): string { | |
| 19 | return `${addresses().site}${connectorCallbackPath(slug)}`; | |
| 20 | } | |
| 21 | ||
| 22 | /** A Customize page reads its data again only for another page, the other view, or a posted form (lib/customize.ts). */ | |
| 23 | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs): boolean { | |
| 24 | return needsReload(currentUrl, nextUrl, formMethod) ? defaultShouldRevalidate : false; | |
| 25 | } | |
| 26 | ||
| 27 | /** The browser leaves for the connector's sign-in page, or lands on the connector's own page with what happened. */ | |
| 28 | async function toSignIn(slug: string, viewer: User, id: string): Promise<Response> { | |
| 29 | const started = await workspaceAgents.startConnectorSignIn(slug, viewer, id, callbackUri(slug)).catch(() => null); | |
| 30 | if (started?.ok) return redirect(started.value.url); | |
| 31 | const problem = started ? started.error.message : "The agents service didn't answer. Try again in a moment."; | |
| 32 | return redirect(`${connectorPagePath(slug, id)}?problem=${encodeURIComponent(problem)}`); | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * Adds a connector and takes the browser where it goes next: to sign in | |
| 37 | * when the server asks for that, or to the connector's page otherwise. An | |
| 38 | * answer (not a redirect) says why it couldn't be added. | |
| 39 | */ | |
| 40 | export async function addConnectorThen(slug: string, viewer: User, input: ConnectorInput): Promise<Response | ActionResult> { | |
| 41 | const added = await workspaceAgents.addConnector(slug, viewer, input).catch(() => null); | |
| 42 | if (!added) return { ok: false, intent: "connect", error: "The agents service didn't answer. Try again in a moment." }; | |
| 43 | if (!added.ok) return { ok: false, intent: "connect", error: added.error.message, code: added.error.code }; | |
| 44 | return afterAdd(slug, viewer, added.value); | |
| 45 | } | |
| 46 | ||
| 47 | /** After a connector is added: the sign-in when it needs one, its page otherwise. */ | |
| 48 | export function afterAdd(slug: string, viewer: User, connector: WorkspaceConnector): Promise<Response> | Response { | |
| 49 | if (connector.state === "needs_sign_in") return toSignIn(slug, viewer, connector.id); | |
| 50 | return redirect(`${connectorPagePath(slug, connector.id)}?added=1`); | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * The actions a connector has wherever it is shown. `intent` names one; | |
| 55 | * null says it isn't one of them, so the page's own action goes on. | |
| 56 | * `afterRemove` is where the browser goes once it is removed. | |
| 57 | */ | |
| 58 | export async function connectorAction(intent: string, form: FormData, slug: string, viewer: User, afterRemove: string | null): Promise<Response | ActionResult | null> { | |
| 59 | const id = String(form.get("connector") ?? ""); | |
| 60 | switch (intent) { | |
| 61 | case "sign_in": | |
| 62 | return toSignIn(slug, viewer, id); | |
| 63 | case "sign_out": | |
| 64 | return answer(intent, workspaceAgents.signOutConnector(slug, viewer, id)); | |
| 65 | case "key": | |
| 66 | return answer(intent, workspaceAgents.setConnectorKey(slug, viewer, id, String(form.get("header_name") ?? "") || null, String(form.get("value") ?? ""))); | |
| 67 | case "refresh": | |
| 68 | return answer(intent, workspaceAgents.refreshConnector(slug, viewer, id)); | |
| 69 | case "grant": { | |
| 70 | const { scope, target } = splitTarget(String(form.get("where") ?? "")); | |
| 71 | return answer(intent, workspaceAgents.grantConnector(slug, viewer, id, scope, target)); | |
| 72 | } | |
| 73 | case "revoke": | |
| 74 | return answer(intent, workspaceAgents.revokeConnector(slug, viewer, id, String(form.get("grant") ?? ""))); | |
| 75 | case "remove": { | |
| 76 | const result = await answer(intent, workspaceAgents.removeConnector(slug, viewer, id)); | |
| 77 | return result.ok && afterRemove ? redirect(afterRemove) : result; | |
| 78 | } | |
| 79 | default: | |
| 80 | return null; | |
| 81 | } | |
| 82 | } |