| 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 | */ |
| 11 | import type { ConnectorState, InstallRequest, InstallRequestStatus, ListingKind, PluginAgentTemplate, PluginAvailability, PluginComponents, PluginListing, SkillListing, SkillScope, WorkspaceConnector } from "@g1t/contracts"; |
| 12 | import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors"; |
| 13 | import { type ConnectorAuth, type CustomizeSection, customizePath } from "@g1t/contracts/customize"; |
| 14 | import { type FetchPlan, parsePluginRef } from "@g1t/contracts/claude-plugin"; |
| 15 | import { listingRef, parseListing } from "@g1t/contracts/marketplace"; |
| 16 | |
| 17 | import { type AgentDraft, BLANK_DRAFT, cleanHandle } from "./agent-form.ts"; |
| 18 | import type { ConnectedState } from "./connectors.ts"; |
| 19 | |
| 20 | // ── The address ────────────────────────────────────────────────────────── |
| 21 | |
| 22 | /** What a tab shows: what the workspace has, or what it can add. */ |
| 23 | export type CustomizeView = "yours" | "discover"; |
| 24 | |
| 25 | /** The view the address asks for (`?view=discover`); Yours otherwise. */ |
| 26 | export function readView(params: URLSearchParams): CustomizeView { |
| 27 | return params.get("view") === "discover" ? "discover" : "yours"; |
| 28 | } |
| 29 | |
| 30 | /** What was typed into the search (`?q=`), trimmed. */ |
| 31 | export 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 | */ |
| 39 | export 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. */ |
| 45 | export 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. */ |
| 55 | export 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. */ |
| 61 | export function pluginPath(slug: string, ref: string): string { |
| 62 | return customizePath("plugins", slug, encodeURIComponent(ref)); |
| 63 | } |
| 64 | |
| 65 | /** One connector's page. */ |
| 66 | export 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. */ |
| 71 | export 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. */ |
| 76 | export 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>`. */ |
| 83 | export 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. */ |
| 88 | export 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. */ |
| 93 | export function connectorListingRef(directoryId: string): string { |
| 94 | return listingRef("connector", directoryId); |
| 95 | } |
| 96 | |
| 97 | /** The requests still waiting on an owner. */ |
| 98 | export 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. */ |
| 103 | export 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`. */ |
| 108 | export 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. */ |
| 113 | export 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 | */ |
| 134 | export 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. */ |
| 152 | export 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. */ |
| 155 | export 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. */ |
| 175 | function 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`. */ |
| 185 | export 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 | |
| 193 | export type PluginSort = "marketplace" | "name"; |
| 194 | |
| 195 | /** What Discover's plugins are narrowed and ordered by, from the address. */ |
| 196 | export type PluginFilters = { q: string; category: string | null; marketplace: string | null; sort: PluginSort }; |
| 197 | |
| 198 | export 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. */ |
| 208 | export 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 | |
| 214 | const 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. */ |
| 217 | export 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. */ |
| 228 | export 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. */ |
| 235 | export 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. */ |
| 248 | export 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 | |
| 261 | function 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". */ |
| 266 | export 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. */ |
| 277 | export 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. */ |
| 289 | export 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. */ |
| 303 | export 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. */ |
| 311 | export 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. */ |
| 318 | export 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. */ |
| 323 | export 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. */ |
| 339 | export 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). */ |
| 342 | export type IntegrationEntry = { view: ConnectorView; state: ConnectedState | null; why: string | null; href: string | null }; |
| 343 | |
| 344 | export 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 | */ |
| 359 | export 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. */ |
| 383 | export 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. */ |
| 390 | export 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. */ |
| 393 | export type ReachTargets = { can_manage: boolean; teams: readonly { slug: string; name: string }[]; agents: readonly { handle: string; display_name: string }[] }; |
| 394 | |
| 395 | export type ReachOption = { value: string; label: string; checked: boolean; reach: Reach | null }; |
| 396 | |
| 397 | /** `scope:target` as the menu carries it, split. */ |
| 398 | export 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. */ |
| 406 | export 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". */ |
| 420 | export 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". */ |
| 431 | export 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 | */ |
| 444 | export 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 | */ |
| 470 | export 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=…`). */ |
| 479 | export 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 | } |