Skip to content
485 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 * Customize (routes/workspace/customize/): Skills, Connectors and Plugins,
3 * each with what the workspace has (Yours) and what it can add (Discover),
4 * read against what its people asked for. The wire shapes are
5 * @g1t/contracts customize.ts; this file is the site's reading of them:
6 * which view and search the address asks for, filters and grouping for
7 * Discover, where a request is answered, and what the forms post.
8 *
9 * No Workers or React imports, so it can be tested under Node.
10 */
11import type { ConnectorState, InstallRequest, InstallRequestStatus, ListingKind, PluginAgentTemplate, PluginAvailability, PluginComponents, PluginListing, SkillListing, SkillScope, WorkspaceConnector } from "@g1t/contracts";
12import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
13import { type ConnectorAuth, type CustomizeSection, customizePath } from "@g1t/contracts/customize";
14import { type FetchPlan, parsePluginRef } from "@g1t/contracts/claude-plugin";
15import { listingRef, parseListing } from "@g1t/contracts/marketplace";
16
17import { type AgentDraft, BLANK_DRAFT, cleanHandle } from "./agent-form.ts";
18import type { ConnectedState } from "./connectors.ts";
19
20// ── The address ──────────────────────────────────────────────────────────
21
22/** What a tab shows: what the workspace has, or what it can add. */
23export type CustomizeView = "yours" | "discover";
24
25/** The view the address asks for (`?view=discover`); Yours otherwise. */
26export function readView(params: URLSearchParams): CustomizeView {
27 return params.get("view") === "discover" ? "discover" : "yours";
28}
29
30/** What was typed into the search (`?q=`), trimmed. */
31export function readQuery(params: URLSearchParams): string {
32 return (params.get("q") ?? "").trim();
33}
34
35/**
36 * The same page with `changes` applied to its query: a value of null takes
37 * the key out, and so does the default view, so Yours has a clean address.
38 */
39export function withParams(pathname: string, params: URLSearchParams, changes: Record<string, string | null>): string {
40 const query = nextParams(params, changes).toString();
41 return query ? `${pathname}?${query}` : pathname;
42}
43
44/** The query with `changes` applied, as `withParams` applies them. */
45export function nextParams(params: URLSearchParams, changes: Record<string, string | null>): URLSearchParams {
46 const next = new URLSearchParams(params);
47 for (const [key, value] of Object.entries(changes)) {
48 if (value == null || value === "" || (key === "view" && value === "yours")) next.delete(key);
49 else next.set(key, value);
50 }
51 return next;
52}
53
54/** The tab a Customize address is on, when it is one of the three tabs' own pages. */
55export function tabOf(pathname: string): CustomizeSection | null {
56 const match = /^\/[^/]+\/-\/customize\/(skills|connectors|plugins)\/?$/.exec(pathname.replace(/\.data$/, ""));
57 return match ? (match[1] as CustomizeSection) : null;
58}
59
60/** One plugin's page: its ref (`name@marketplace`) in the address, encoded. */
61export function pluginPath(slug: string, ref: string): string {
62 return customizePath("plugins", slug, encodeURIComponent(ref));
63}
64
65/** One connector's page. */
66export function connectorPagePath(slug: string, id: string): string {
67 return customizePath("connectors", slug, encodeURIComponent(id));
68}
69
70/** Where a connector is added: from the directory entry `directory`, or by address. */
71export function connectorNewPath(slug: string, directory?: string | null): string {
72 return `${customizePath("connectors", slug, "new")}${directory ? `?directory=${encodeURIComponent(directory)}` : ""}`;
73}
74
75/** Where a connector's sign-in comes back to, as a path on this site. */
76export function connectorCallbackPath(slug: string): string {
77 return customizePath("connectors", slug, "callback");
78}
79
80// ── Listings and requests ────────────────────────────────────────────────
81
82/** The reference a request names for a skill in a marketplace: `skill:<plugin@marketplace>/<name>`. */
83export function skillListingRef(listing: Pick<SkillListing, "plugin" | "marketplace" | "name">): string {
84 return listingRef("skill", `${listing.plugin}@${listing.marketplace}/${listing.name}`);
85}
86
87/** The reference a request names for a plugin. */
88export function pluginListingRef(listing: Pick<PluginListing, "ref">): string {
89 return listingRef("plugin", listing.ref);
90}
91
92/** The reference a request names for a connector from the directory. */
93export function connectorListingRef(directoryId: string): string {
94 return listingRef("connector", directoryId);
95}
96
97/** The requests still waiting on an owner. */
98export function openRequests(requests: readonly InstallRequest[]): InstallRequest[] {
99 return requests.filter((request) => request.status === "open");
100}
101
102/** Whether `username` has asked for `ref` and is still waiting. */
103export function requestedBy(requests: readonly InstallRequest[], ref: string, username: string): boolean {
104 return requests.some((request) => request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase());
105}
106
107/** How many people are waiting for `ref`. */
108export function waitingFor(requests: readonly InstallRequest[], ref: string): number {
109 return openRequests(requests).filter((request) => request.listing === ref).length;
110}
111
112/** What a request asked for, in a word. */
113export function kindWord(kind: ListingKind): string {
114 switch (kind) {
115 case "plugin":
116 return "Plugin";
117 case "skill":
118 return "Skill";
119 case "connector":
120 return "Connector";
121 case "integration":
122 return "Integration";
123 case "extension":
124 return "Extension";
125 }
126}
127
128/**
129 * Where an owner goes to add what a request asks for: the plugin's page, the
130 * page of the plugin that brings a skill, the connector's connect page, or
131 * an integration's setup page from the directory `views`. Null when there
132 * is nowhere, or the request names something that isn't added here.
133 */
134export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: readonly ConnectorView[]): string | null {
135 const parsed = parseListing(request.listing);
136 if (!parsed) return null;
137 if (parsed.kind === "plugin") return parsePluginRef(parsed.id) ? pluginPath(slug, parsed.id) : null;
138 if (parsed.kind === "skill") {
139 const slash = parsed.id.indexOf("/");
140 const plugin = slash > 0 ? parsed.id.slice(0, slash) : null;
141 return plugin && parsePluginRef(plugin) ? pluginPath(slug, plugin) : null;
142 }
143 if (parsed.kind === "connector") return connectorNewPath(slug, parsed.id);
144 if (parsed.kind === "integration") {
145 const view = views.find((v) => v.id === parsed.id);
146 return view?.href ? connectorPath(view.href, slug) : null;
147 }
148 return null;
149}
150
151/** What the requests page's forms ask for, read from one. */
152export type CustomizeForm = { intent: "request"; listing: string; note: string | null } | { intent: "resolve"; id: string; status: Exclude<InstallRequestStatus, "open"> };
153
154/** A requests form's request; null when it asks for nothing that makes sense. */
155export function customizeForm(form: FormData): CustomizeForm | null {
156 const intent = form.get("intent");
157 if (intent === "request") {
158 const listing = String(form.get("listing") ?? "");
159 if (!parseListing(listing)) return null;
160 const note = String(form.get("note") ?? "").trim();
161 return { intent, listing, note: note || null };
162 }
163 if (intent === "resolve") {
164 const id = String(form.get("id") ?? "");
165 const status = form.get("status");
166 if (!id || (status !== "done" && status !== "declined")) return null;
167 return { intent, id, status };
168 }
169 return null;
170}
171
172// ── Search and filters ───────────────────────────────────────────────────
173
174/** Lowercased, without accents or punctuation, for matching. */
175function fold(text: string): string {
176 return text
177 .normalize("NFKD")
178 .replace(/[̀-ͯ]/g, "")
179 .toLowerCase()
180 .replace(/[^a-z0-9]+/g, " ")
181 .trim();
182}
183
184/** Whether every word typed appears somewhere in `fields`. */
185export function matches(fields: readonly (string | null | undefined)[], query: string): boolean {
186 const words = fold(query).split(" ").filter(Boolean);
187 if (words.length === 0) return true;
188 const haystack = fold(fields.filter((field): field is string => !!field).join(" "));
189 const compact = haystack.replace(/ /g, "");
190 return words.every((word) => haystack.includes(word) || compact.includes(word));
191}
192
193export type PluginSort = "marketplace" | "name";
194
195/** What Discover's plugins are narrowed and ordered by, from the address. */
196export type PluginFilters = { q: string; category: string | null; marketplace: string | null; sort: PluginSort };
197
198export function readPluginFilters(params: URLSearchParams): PluginFilters {
199 return {
200 q: readQuery(params),
201 category: params.get("category") || null,
202 marketplace: params.get("marketplace") || null,
203 sort: params.get("sort") === "name" ? "name" : "marketplace",
204 };
205}
206
207/** The categories the listings name, once each, in order of how many name them. */
208export function categoriesOf(listings: readonly { category: string | null }[]): string[] {
209 const counts = new Map<string, number>();
210 for (const listing of listings) if (listing.category) counts.set(listing.category, (counts.get(listing.category) ?? 0) + 1);
211 return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([category]) => category);
212}
213
214const byName = <T extends { name: string }>(a: T, b: T) => a.name.localeCompare(b.name);
215
216/** The plugins that pass `filters`, by marketplace then name, or by name alone. */
217export function filterPlugins(listings: readonly PluginListing[], filters: PluginFilters): PluginListing[] {
218 const kept = listings.filter(
219 (listing) =>
220 (!filters.category || listing.category === filters.category) &&
221 (!filters.marketplace || listing.marketplace === filters.marketplace) &&
222 matches([listing.name, listing.display_name, listing.description, listing.author, listing.category, listing.marketplace, ...listing.tags, ...listing.keywords, ...listing.skills], filters.q),
223 );
224 return filters.sort === "name" ? kept.sort(byName) : kept.sort((a, b) => a.marketplace.localeCompare(b.marketplace) || byName(a, b));
225}
226
227/** The skills that match what was typed and the category chosen, by name. */
228export function filterSkills(listings: readonly SkillListing[], filters: { q: string; category: string | null }): SkillListing[] {
229 return listings
230 .filter((listing) => (!filters.category || listing.category === filters.category) && matches([listing.name, listing.description, listing.plugin, listing.author, listing.category, listing.marketplace], filters.q))
231 .sort(byName);
232}
233
234/** Listings grouped under their marketplace, in the order they come. */
235export function groupByMarketplace<T extends { marketplace: string }>(items: readonly T[]): { marketplace: string; items: T[] }[] {
236 const groups = new Map<string, T[]>();
237 for (const item of items) {
238 const group = groups.get(item.marketplace);
239 if (group) group.push(item);
240 else groups.set(item.marketplace, [item]);
241 }
242 return [...groups.entries()].map(([marketplace, items]) => ({ marketplace, items }));
243}
244
245// ── What a listing says about itself ─────────────────────────────────────
246
247/** A plugin's availability in a word, with a tone for its badge. */
248export function availabilityWord(availability: PluginAvailability): { label: string; tone: "success" | "neutral" | "warn" | "accent" } {
249 switch (availability.kind) {
250 case "available":
251 return { label: "Available", tone: "neutral" };
252 case "installed":
253 return availability.enabled ? { label: "Installed", tone: "success" } : { label: "Switched off", tone: "warn" };
254 case "unavailable":
255 return { label: "Not available here", tone: "neutral" };
256 case "soon":
257 return { label: "Soon", tone: "accent" };
258 }
259}
260
261function count(n: number, one: string, many = `${one}s`): string {
262 return `${n} ${n === 1 ? one : many}`;
263}
264
265/** What a plugin brings g1t's agents, as short chips: "3 skills", "1 agent", "2 connectors". */
266export function componentChips(components: PluginComponents): string[] {
267 const chips: string[] = [];
268 const skills = components.skills.length + components.commands.length;
269 if (skills) chips.push(count(skills, "skill"));
270 if (components.agents.length) chips.push(count(components.agents.length, "agent"));
271 const remote = components.mcp_servers.filter((server) => server.remote).length;
272 if (remote) chips.push(count(remote, "connector"));
273 return chips;
274}
275
276/** What only Claude Code on a runner uses, in words: hooks, LSP, output styles, bin, and stdio servers. */
277export function claudeCodeOnly(components: PluginComponents): string[] {
278 const parts: string[] = [];
279 if (components.hooks) parts.push("hooks");
280 if (components.lsp) parts.push("LSP");
281 if (components.output_styles) parts.push("output styles");
282 if (components.bin) parts.push("bin");
283 const local = components.mcp_servers.filter((server) => !server.remote).length;
284 if (local) parts.push(count(local, "local MCP server"));
285 return parts;
286}
287
288/** Where a plugin is read from, in a few words; what keeps it from being read, when something does. */
289export function fetchWords(fetch: FetchPlan): string {
290 switch (fetch.kind) {
291 case "github":
292 return `github.com/${fetch.repo}${fetch.path ? `/${fetch.path}` : ""}${fetch.ref ? ` at ${fetch.ref}` : ""}`;
293 case "g1t":
294 return `${fetch.repo}${fetch.path ? `/${fetch.path}` : ""} on this g1t${fetch.ref ? ` at ${fetch.ref}` : ""}`;
295 case "archive":
296 return fetch.url;
297 case "unsupported":
298 return fetch.why;
299 }
300}
301
302/** Each connector state in words, with a tone for its badge. */
303export const CONNECTOR_STATES: Record<ConnectorState, { label: string; tone: "success" | "warn" | "danger" | "neutral" }> = {
304 connected: { label: "Connected", tone: "success" },
305 needs_sign_in: { label: "Needs sign-in", tone: "warn" },
306 needs_key: { label: "Needs a key", tone: "warn" },
307 problem: { label: "Problem", tone: "danger" },
308};
309
310/** How a connector signs in, in words. */
311export const AUTH_WORDS: Record<ConnectorAuth, string> = {
312 none: "No sign-in",
313 header: "A key",
314 oauth: "Signs in with the browser",
315};
316
317/** The auth a form names, or none. */
318export function readAuth(value: unknown): ConnectorAuth {
319 return value === "header" || value === "oauth" ? value : "none";
320}
321
322/** Where a connector came from, in a few words. */
323export function fromWords(from: WorkspaceConnector["from"]): string {
324 switch (from.kind) {
325 case "directory":
326 return "From the directory";
327 case "plugin":
328 return `Brought by ${from.ref}`;
329 case "address":
330 return "Added by address";
331 case "agent":
332 return `Moved from @${from.handle}`;
333 }
334}
335
336// ── The connectors directory ─────────────────────────────────────────────
337
338/** An MCP-backed entry of the directory, with the workspace's connector from it, if any. */
339export type McpDirectoryEntry = { view: ConnectorView; connector: WorkspaceConnector | null };
340
341/** One of g1t's own integrations in the directory: connected, connectable (with where), or not available here (with why). */
342export type IntegrationEntry = { view: ConnectorView; state: ConnectedState | null; why: string | null; href: string | null };
343
344export type ConnectorsDirectory = {
345 /** Remote MCP servers, connected from Customize: the ones not yet connected first. */
346 mcp: McpDirectoryEntry[];
347 /** g1t's own integrations, set up on their own pages: connected ones first. */
348 integrations: IntegrationEntry[];
349 /** Planned, not built. */
350 soon: ConnectorView[];
351};
352
353/**
354 * The directory for a workspace: its MCP-backed entries against the
355 * workspace's connectors (by directory id), g1t's integrations against what
356 * is connected (lib/connected.server.ts) and what this g1t can't connect,
357 * and what is coming. `query` narrows every part.
358 */
359export function connectorsDirectory(
360 views: readonly ConnectorView[],
361 connectors: readonly WorkspaceConnector[],
362 connected: Record<string, ConnectedState>,
363 unavailable: Record<string, string>,
364 slug: string,
365 query = "",
366): ConnectorsDirectory {
367 const shown = views.filter((view) => matches([view.name, view.description, view.category, ...view.capabilities, ...view.keywords], query));
368 const fromDirectory = new Map(connectors.filter((c) => c.from.kind === "directory").map((c) => [(c.from as { id: string }).id, c]));
369 const mcp = shown.filter((view) => view.mcp && view.status === "available").map((view) => ({ view, connector: fromDirectory.get(view.id) ?? null }));
370 mcp.sort((a, b) => Number(!!a.connector) - Number(!!b.connector));
371 const integrations = shown
372 .filter((view) => !view.mcp && view.status === "available")
373 .map((view) => {
374 const state = connected[view.id] ?? null;
375 const why = state ? null : (unavailable[view.id] ?? null);
376 return { view, state, why, href: state?.manage ?? (view.href && !why ? connectorPath(view.href, slug) : null) };
377 });
378 integrations.sort((a, b) => Number(!!b.state) - Number(!!a.state));
379 return { mcp, integrations, soon: shown.filter((view) => view.status === "soon") };
380}
381
382/** What each person connects for themselves, available today. */
383export function personalDirectory(views: readonly ConnectorView[], query = ""): ConnectorView[] {
384 return views.filter((view) => view.status === "available" && view.href != null && matches([view.name, view.description, ...view.keywords], query));
385}
386
387// ── Where something reaches ──────────────────────────────────────────────
388
389/** Where a skill or a connector reaches: every agent, a team's, or one agent. */
390export type Reach = { id: string; scope: SkillScope; target: string | null; label: string };
391
392/** Who may be given something: owners every agent and any agent, team maintainers their teams. */
393export type ReachTargets = { can_manage: boolean; teams: readonly { slug: string; name: string }[]; agents: readonly { handle: string; display_name: string }[] };
394
395export type ReachOption = { value: string; label: string; checked: boolean; reach: Reach | null };
396
397/** `scope:target` as the menu carries it, split. */
398export function splitTarget(value: string): { scope: SkillScope; target: string | null } {
399 const colon = value.indexOf(":");
400 const scope = (colon > 0 ? value.slice(0, colon) : value) as SkillScope;
401 const target = colon > 0 ? value.slice(colon + 1) : "";
402 return { scope: scope === "team" || scope === "agent" ? scope : "workspace", target: target || null };
403}
404
405/** Every place the viewer may give something, each saying whether it already reaches there. */
406export function reachOptions(reach: readonly Reach[], targets: ReachTargets): ReachOption[] {
407 const has = (scope: SkillScope, target: string | null) => reach.find((r) => r.scope === scope && (r.target ?? "").toLowerCase() === (target ?? "").toLowerCase()) ?? null;
408 const options: ReachOption[] = [];
409 const add = (scope: SkillScope, target: string | null, label: string) => {
410 const found = has(scope, target);
411 options.push({ value: `${scope}:${target ?? ""}`, label, checked: !!found, reach: found });
412 };
413 if (targets.can_manage) add("workspace", null, "Every agent");
414 for (const team of targets.teams) add("team", team.slug, `Team ${team.name}`);
415 if (targets.can_manage) for (const agent of targets.agents) add("agent", agent.handle, `@${agent.handle}`);
416 return options;
417}
418
419/** Where something reaches, in one line: "Every agent", "QA and @margo", "no agent yet". */
420export function reachWords(reach: readonly Reach[]): string {
421 if (reach.length === 0) return "no agent yet";
422 if (reach.some((r) => r.scope === "workspace")) return "Every agent";
423 const labels = reach.map((r) => r.label);
424 if (labels.length <= 2) return labels.join(" and ");
425 return `${labels.slice(0, 2).join(", ")} and ${labels.length - 2} more`;
426}
427
428// ── Hiring a plugin's agent ──────────────────────────────────────────────
429
430/** "code-reviewer" as a name: "Code Reviewer". */
431export function titleOf(name: string): string {
432 return name
433 .split(/[-_\s]+/)
434 .filter(Boolean)
435 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
436 .join(" ");
437}
438
439/**
440 * A plugin's subagent as the new-agent form starts: its name, its
441 * description as what it is responsible for, its system prompt as the
442 * instructions, and its model (haiku, sonnet, opus) as a routing limit.
443 */
444export function pluginAgentDraft(template: PluginAgentTemplate): AgentDraft {
445 const model = (template.model ?? "").toLowerCase();
446 const routing = { ...BLANK_DRAFT.routing };
447 if (model.includes("haiku")) routing.ceiling = "small";
448 else if (model.includes("opus")) routing.floor = "frontier";
449 else if (model.includes("sonnet")) routing.floor = "large";
450 const display = titleOf(template.name);
451 return {
452 ...BLANK_DRAFT,
453 handle: cleanHandle(template.name),
454 display_name: display,
455 title: display,
456 responsibilities: template.description ? [template.description] : [],
457 instructions: template.prompt,
458 routing,
459 };
460}
461
462// ── Reloading ────────────────────────────────────────────────────────────
463
464/**
465 * Whether a Customize page's data is read again for a move from `current`
466 * to `next`: typing a search or picking a filter changes only the address,
467 * so nothing is reloaded; another page, the other view, or a form that
468 * posted is read again.
469 */
470export function needsReload(current: URL, next: URL, formMethod: string | undefined): boolean {
471 if (formMethod && formMethod.toUpperCase() !== "GET") return true;
472 if (current.pathname !== next.pathname) return true;
473 return readView(current.searchParams) !== readView(next.searchParams);
474}
475
476// ── A page's flash ───────────────────────────────────────────────────────
477
478/** What a page says it just did, from its address (`?signed_in=1`, `?added=1`, `?problem=…`). */
479export function flashOf(params: URLSearchParams): { tone: "success" | "danger"; text: string } | null {
480 const problem = params.get("problem");
481 if (problem) return { tone: "danger", text: problem };
482 if (params.get("signed_in") === "1") return { tone: "success", text: "Signed in. Its tools are listed below; give it to agents with Available to." };
483 if (params.get("added") === "1") return { tone: "success", text: "Connected. Give it to agents with Available to." };
484 return null;
485}

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