Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
474 linesCodeBlameRaw
1/**
2 * Customize's RPC methods (@g1t/contracts workspace-agents.ts, the
3 * `marketplaces`, `plugins` and `connectors` calls): marketplaces a
4 * workspace reads, what Discover lists, plugins installed and what they
5 * brought, and connectors with their sign-ins and grants. The rules are
6 * in marketplaces.ts, plugins.ts and connectors.ts; this file reads what
7 * they need from identity and repos, checks who may do what (members see,
8 * owners change), and writes the audit log.
9 */
10import { type RepoPath, type Result, type User, fail, identityClient, ok, reposClient } from "@g1t/contracts";
11
12import { type McpServer } from "../../../packages/contracts/src/abilities.ts";
13import { parseMarketplaceAddress, parsePluginRef } from "../../../packages/contracts/src/claude-plugin.ts";
14import type { AgentConnector, ConnectorInput, CustomizeBundle, Marketplace, PluginAgentTemplate, PluginDetail, PluginInstall, PluginListing, SkillListing, WorkspaceConnector } from "../../../packages/contracts/src/customize.ts";
15import type { SkillScope } from "../../../packages/contracts/src/skill-library.ts";
16import type { SkillFile } from "../../../packages/contracts/src/skill-format.ts";
17import * as connectors from "./connectors.ts";
18import type { FetchDeps } from "./customize-fetch.ts";
19import { definitionOf } from "./store.ts";
20import * as marketplaces from "./marketplaces.ts";
21import * as plugins from "./plugins.ts";
22import { answerListing } from "./installs.ts";
23import type { SessionEnv } from "./sessions.ts";
24import { listingRef } from "../../../packages/contracts/src/marketplace.ts";
25import type { ViewContext } from "./views.ts";
26
27type CustomizeEnv = SessionEnv & { CONNECTORS_KEY?: string; GITHUB_TOKEN?: string; SITE_URL?: string };
28
29const env = (ctx: ViewContext): CustomizeEnv => ctx.env as CustomizeEnv;
30
31/** This g1t's host, for reading its own repositories by address. */
32function hostOf(ctx: ViewContext): string | null {
33 try {
34 return new URL(env(ctx).SITE_URL ?? "https://g1t.sh").hostname;
35 } catch {
36 return null;
37 }
38}
39
40/** Where plugins and marketplaces are read from: GitHub with the token if set, and repositories here the viewer can read. */
41function fetchDeps(ctx: ViewContext): FetchDeps {
42 const repos = reposClient(env(ctx).REPOS);
43 return {
44 githubToken: env(ctx).GITHUB_TOKEN ?? null,
45 repos: {
46 repo: async (full) => {
47 const [namespace, name] = full.split("/") as [string, string];
48 const found = await repos.get({ namespace, name } as RepoPath, ctx.viewer).catch(() => null);
49 return found?.ok ? { id: found.value.id, default_branch: found.value.defaultBranch } : null;
50 },
51 listFiles: (repoId, ref) => repos.listFiles(repoId, ref, 10_000),
52 blobs: async (repoId, hashes) => (await repos.rawBlobs(repoId, hashes, 2 * 1024 * 1024)).map((blob) => ({ hash: blob.hash, data: blob.data })),
53 },
54 };
55}
56
57async function connectorDeps(ctx: ViewContext): Promise<connectors.Deps> {
58 return { key: await connectors.sealingKey(env(ctx).CONNECTORS_KEY), site: (env(ctx).SITE_URL ?? "https://g1t.sh").replace(/\/+$/, "") };
59}
60
61const owners = (ctx: ViewContext, what: string): Result<null> => (ctx.owner ? ok(null) : fail("forbidden", `Only the workspace's owners ${what}.`));
62
63type Labels = { agents: Map<string, { handle: string; display_name: string }>; teams: Map<string, string> };
64
65async function labels(ctx: ViewContext): Promise<Labels> {
66 const [agents, teams] = await Promise.all([
67 ctx.db.prepare("SELECT id, handle, display_name FROM agents WHERE workspace_id = ? AND archived_at IS NULL").bind(ctx.workspaceId).all<{ id: string; handle: string; display_name: string }>(),
68 identityClient(env(ctx).IDENTITY)
69 .listTeams(ctx.viewer, ctx.slug)
70 .catch(() => null),
71 ]);
72 return { agents: new Map(agents.results.map((a) => [a.id, a])), teams: new Map(teams?.ok ? teams.value.map((t) => [t.slug, t.name]) : []) };
73}
74
75// ── Marketplaces and Discover ────────────────────────────────────────────
76
77async function loaded(ctx: ViewContext) {
78 return marketplaces.loadAll(ctx.db, ctx.workspaceId, fetchDeps(ctx), hostOf(ctx));
79}
80
81async function marks(ctx: ViewContext): Promise<marketplaces.InstallMarks> {
82 return new Map((await plugins.listInstalls(ctx.db, ctx.workspaceId)).map((row) => [row.ref, { enabled: row.enabled === 1 }]));
83}
84
85async function listMarketplaces(ctx: ViewContext): Promise<Result<Marketplace[]>> {
86 return ok(marketplaces.marketplacesOut(await loaded(ctx), ctx.owner));
87}
88
89async function addMarketplace(ctx: ViewContext, address: unknown, name: unknown): Promise<Result<Marketplace>> {
90 const may = owners(ctx, "add marketplaces");
91 if (!may.ok) return may;
92 const deps = fetchDeps(ctx);
93 const parsed = parseMarketplaceAddress(String(address ?? ""), { host: hostOf(ctx) ?? undefined, here: () => false });
94 if (!parsed.ok) return fail("invalid", parsed.message);
95 // `workspace/name` that is a repository here is read here; otherwise GitHub.
96 let source = parsed.value;
97 if (source.source === "github" && !String(address).includes("://") && (await deps.repos!.repo(source.repo))) source = { source: "g1t", repo: source.repo, ...(source.ref ? { ref: source.ref } : {}) };
98 const added = await marketplaces.addMarketplace(ctx.db, ctx.workspaceId, typeof name === "string" ? name : null, source, ctx.viewer.username, deps, hostOf(ctx));
99 if (added.ok) ctx.audit?.("add_marketplace", `customize/marketplaces/${added.value.name}`, `Added the marketplace ${added.value.name} (${added.value.plugins} plugins)`);
100 return added;
101}
102
103async function removeMarketplace(ctx: ViewContext, name: unknown): Promise<Result<null>> {
104 const may = owners(ctx, "remove marketplaces");
105 if (!may.ok) return may;
106 const removed = await marketplaces.removeMarketplace(ctx.db, ctx.workspaceId, String(name ?? ""));
107 if (removed.ok) ctx.audit?.("remove_marketplace", `customize/marketplaces/${String(name)}`, `Removed the marketplace ${String(name)}`);
108 return removed;
109}
110
111async function refreshMarketplace(ctx: ViewContext, name: unknown): Promise<Result<Marketplace>> {
112 const may = owners(ctx, "read marketplaces again");
113 if (!may.ok) return may;
114 const entry = await marketplaces.marketplaceByName(ctx.db, ctx.workspaceId, String(name ?? ""));
115 if (!entry) return fail("not_found", "The workspace reads no marketplace by that name.");
116 const row = await marketplaces.refreshRow(ctx.db, entry, fetchDeps(ctx), hostOf(ctx));
117 return ok(marketplaces.marketplacesOut([{ row, builtin: !!entry.builtin, manifest: marketplaces.cachedManifest(row) }], ctx.owner)[0]!);
118}
119
120export type Discover = { plugins: PluginListing[]; skills: SkillListing[]; marketplaces: Marketplace[] };
121
122async function discover(ctx: ViewContext): Promise<Result<Discover>> {
123 const all = await loaded(ctx);
124 const listings = marketplaces.discoverPlugins(all, await marks(ctx), hostOf(ctx));
125 const { results } = await ctx.db.prepare("SELECT plugin, name FROM skills WHERE workspace_id = ? AND plugin IS NOT NULL AND archived_at IS NULL").bind(ctx.workspaceId).all<{ plugin: string; name: string }>();
126 const added = new Set(results.map((r) => `${r.plugin}/${r.name}`));
127 return ok({ plugins: listings, skills: marketplaces.discoverSkills(listings, added), marketplaces: marketplaces.marketplacesOut(all, ctx.owner) });
128}
129
130// ── Plugins ──────────────────────────────────────────────────────────────
131
132async function listingFor(ctx: ViewContext, ref: unknown) {
133 const parsed = parsePluginRef(ref);
134 if (!parsed) return fail("invalid", "Name a plugin as name@marketplace.");
135 const all = await loaded(ctx);
136 const listings = marketplaces.discoverPlugins(all, await marks(ctx), hostOf(ctx));
137 const listing = marketplaces.findListing(listings, ref as string);
138 const entry = marketplaces.entryOf(all, parsed);
139 if (!listing || !entry) return fail("not_found", `No marketplace the workspace reads lists ${String(ref)}.`);
140 const manifest = all.find((l) => l.row.name === parsed.marketplace)?.manifest ?? null;
141 return ok({ listing, entry, owner: manifest?.owner.name ?? (parsed.marketplace === "g1t" ? "g1t" : null), listings });
142}
143
144async function pluginDetail(ctx: ViewContext, ref: unknown): Promise<Result<PluginDetail>> {
145 const found = await listingFor(ctx, ref);
146 if (!found.ok) return found;
147 const { listing, entry, owner } = found.value;
148 return ok(await plugins.detail(ctx.db, ctx.workspaceId, listing, entry.entry, { source: entry.source, pluginRoot: entry.pluginRoot, owner }, hostOf(ctx), fetchDeps(ctx)));
149}
150
151async function pluginInstalls(ctx: ViewContext): Promise<Result<PluginInstall[]>> {
152 const rows = await plugins.listInstalls(ctx.db, ctx.workspaceId);
153 const listings = rows.length ? marketplaces.discoverPlugins(await loaded(ctx), await marks(ctx), hostOf(ctx)) : [];
154 return ok(plugins.installsOut(rows, listings));
155}
156
157async function installPlugin(ctx: ViewContext, ref: unknown): Promise<Result<PluginInstall>> {
158 const may = owners(ctx, "install plugins");
159 if (!may.ok) return may;
160 const found = await listingFor(ctx, ref);
161 if (!found.ok) return found;
162 const { listing, entry } = found.value;
163 const done = await plugins.install(ctx.db, { workspaceId: ctx.workspaceId, listing, entry: entry.entry, marketplace: { name: listing.marketplace, source: entry.source, pluginRoot: entry.pluginRoot }, host: hostOf(ctx), by: ctx.viewer.username, deps: fetchDeps(ctx) });
164 if (!done.ok) return done;
165 // Its remote servers become connectors, for an owner to sign in or give a key.
166 const deps = await connectorDeps(ctx);
167 for (const server of done.value.plugin.servers) {
168 if ((server.type !== "http" && server.type !== "sse") || !server.url) continue;
169 const auth = server.variables.length ? "header" : "oauth";
170 const headerName = Object.keys(server.headers)[0] ?? "Authorization";
171 const added = await connectors.addConnector(ctx.db, deps, ctx.workspaceId, { from: { kind: "plugin", ref: listing.ref, server: server.name }, auth, header: auth === "header" ? { name: headerName, value: "" } : null }, ctx.viewer.username, { title: `${listing.display_name ?? listing.name}: ${server.name}`, url: server.url });
172 if (!added.ok) await ctx.db.prepare("UPDATE plugin_installs SET problems = json_insert(problems, '$[#]', ?) WHERE id = ?").bind(`${server.name}: ${added.error.message}`, done.value.install.id).run();
173 }
174 ctx.audit?.("install_plugin", `customize/plugins/${listing.ref}`, `Installed the plugin ${listing.ref}${done.value.install.version ? ` ${done.value.install.version}` : ""} (${done.value.skills.filter((s) => !s.problem).length} skills, ${done.value.plugin.agents.length} agents)`);
175 await answerListing(ctx, listingRef("plugin", listing.ref));
176 const fresh = await plugins.installByRef(ctx.db, ctx.workspaceId, listing.ref);
177 return ok(fresh ? plugins.installOut(fresh) : done.value.install);
178}
179
180async function setPluginEnabled(ctx: ViewContext, ref: unknown, enabled: unknown): Promise<Result<PluginInstall>> {
181 const may = owners(ctx, "switch plugins on and off");
182 if (!may.ok) return may;
183 const changed = await plugins.setEnabled(ctx.db, ctx.workspaceId, String(ref ?? ""), enabled === true);
184 if (changed.ok) ctx.audit?.(enabled === true ? "enable_plugin" : "disable_plugin", `customize/plugins/${String(ref)}`, `${enabled === true ? "Switched on" : "Switched off"} the plugin ${String(ref)}`);
185 return changed;
186}
187
188async function uninstallPlugin(ctx: ViewContext, ref: unknown): Promise<Result<null>> {
189 const may = owners(ctx, "uninstall plugins");
190 if (!may.ok) return may;
191 const removed = await plugins.uninstall(ctx.db, ctx.workspaceId, String(ref ?? ""), ctx.viewer.username);
192 if (!removed.ok) return removed;
193 const gone = await connectors.removePluginConnectors(ctx.db, await connectorDeps(ctx), ctx.workspaceId, String(ref));
194 ctx.audit?.("uninstall_plugin", `customize/plugins/${String(ref)}`, `Uninstalled the plugin ${String(ref)} (${removed.value.skills.length} skills, ${gone} connectors)`);
195 return ok(null);
196}
197
198async function pluginAgents(ctx: ViewContext): Promise<Result<PluginAgentTemplate[]>> {
199 return ok(await plugins.pluginAgents(ctx.db, ctx.workspaceId));
200}
201
202// ── Connectors ───────────────────────────────────────────────────────────
203
204async function listConnectors(ctx: ViewContext): Promise<Result<WorkspaceConnector[]>> {
205 return ok(await connectors.connectorsOut(ctx.db, ctx.workspaceId, await labels(ctx)));
206}
207
208async function oneConnector(ctx: ViewContext, id: unknown): Promise<Result<WorkspaceConnector>> {
209 const all = await connectors.connectorsOut(ctx.db, ctx.workspaceId, await labels(ctx));
210 const found = all.find((c) => c.id === id);
211 return found ? ok(found) : fail("not_found", "There is no such connector.");
212}
213
214async function addConnector(ctx: ViewContext, input: unknown): Promise<Result<WorkspaceConnector>> {
215 const may = owners(ctx, "add connectors");
216 if (!may.ok) return may;
217 const given = (input ?? {}) as Partial<ConnectorInput>;
218 if (!given.from || typeof given.from !== "object") return fail("invalid", "Say where the connector comes from.");
219 const auth = given.auth === "header" || given.auth === "oauth" ? given.auth : "none";
220 let pluginServer: { title: string; url: string } | null = null;
221 if (given.from.kind === "plugin") {
222 const install = await plugins.installByRef(ctx.db, ctx.workspaceId, given.from.ref);
223 const server = install ? plugins.installOut(install).components.mcp_servers.find((s) => s.name === (given.from as { server: string }).server) : null;
224 if (!install || !server?.url) return fail("not_found", "That plugin isn't installed here, or names no such server.");
225 pluginServer = { title: `${install.display_name ?? install.name}: ${server.name}`, url: server.url };
226 }
227 const added = await connectors.addConnector(ctx.db, await connectorDeps(ctx), ctx.workspaceId, { from: given.from, auth, header: given.header ?? null }, ctx.viewer.username, pluginServer);
228 if (!added.ok) return added;
229 ctx.audit?.("add_connector", `customize/connectors/${added.value.name}`, `Added the connector ${added.value.title} (${added.value.url})`);
230 return oneConnector(ctx, added.value.id);
231}
232
233async function rowFor(ctx: ViewContext, id: unknown): Promise<Result<connectors.ConnectorRow>> {
234 const row = await connectors.connectorById(ctx.db, ctx.workspaceId, String(id ?? ""));
235 return row ? ok(row) : fail("not_found", "There is no such connector.");
236}
237
238async function setConnectorKey(ctx: ViewContext, id: unknown, headerName: unknown, value: unknown): Promise<Result<WorkspaceConnector>> {
239 const may = owners(ctx, "change connectors");
240 if (!may.ok) return may;
241 const row = await rowFor(ctx, id);
242 if (!row.ok) return row;
243 const done = await connectors.setKey(ctx.db, await connectorDeps(ctx), row.value, typeof headerName === "string" ? headerName : null, String(value ?? ""));
244 if (!done.ok) return done;
245 ctx.audit?.("set_connector_key", `customize/connectors/${row.value.name}`, `Put a key on the connector ${row.value.title}`);
246 return oneConnector(ctx, row.value.id);
247}
248
249async function removeConnector(ctx: ViewContext, id: unknown): Promise<Result<null>> {
250 const may = owners(ctx, "remove connectors");
251 if (!may.ok) return may;
252 const row = await rowFor(ctx, id);
253 if (!row.ok) return row;
254 const done = await connectors.removeConnector(ctx.db, await connectorDeps(ctx), ctx.workspaceId, row.value.id);
255 if (done.ok) ctx.audit?.("remove_connector", `customize/connectors/${row.value.name}`, `Removed the connector ${row.value.title}`);
256 return done;
257}
258
259async function refreshConnector(ctx: ViewContext, id: unknown): Promise<Result<WorkspaceConnector>> {
260 const may = owners(ctx, "change connectors");
261 if (!may.ok) return may;
262 const row = await rowFor(ctx, id);
263 if (!row.ok) return row;
264 await connectors.refreshTools(ctx.db, await connectorDeps(ctx), row.value);
265 return oneConnector(ctx, row.value.id);
266}
267
268async function startSignIn(ctx: ViewContext, id: unknown, redirectUri: unknown): Promise<Result<{ url: string }>> {
269 const may = owners(ctx, "sign connectors in");
270 if (!may.ok) return may;
271 const row = await rowFor(ctx, id);
272 if (!row.ok) return row;
273 const redirect = String(redirectUri ?? "");
274 const site = (env(ctx).SITE_URL ?? "https://g1t.sh").replace(/\/+$/, "");
275 if (!redirect.startsWith(`${site}/`)) return fail("invalid", "The sign-in must come back to this g1t.");
276 return connectors.startSignIn(ctx.db, await connectorDeps(ctx), row.value, redirect);
277}
278
279async function finishSignIn(ctx: ViewContext, state: unknown, code: unknown): Promise<Result<WorkspaceConnector>> {
280 const may = owners(ctx, "sign connectors in");
281 if (!may.ok) return may;
282 const id = connectors.connectorOfState(String(state ?? ""));
283 if (!id) return fail("invalid", "This sign-in isn't one that was started here.");
284 const row = await rowFor(ctx, id);
285 if (!row.ok) return row;
286 const done = await connectors.finishSignIn(ctx.db, await connectorDeps(ctx), row.value, String(state), String(code ?? ""), ctx.viewer.username);
287 if (!done.ok) return done;
288 ctx.audit?.("sign_in_connector", `customize/connectors/${row.value.name}`, `Signed the connector ${row.value.title} in (${JSON.parse(done.value.tools || "[]").length} tools)`);
289 await answerListing(ctx, listingRef("connector", row.value.name));
290 return oneConnector(ctx, row.value.id);
291}
292
293async function signOut(ctx: ViewContext, id: unknown): Promise<Result<WorkspaceConnector>> {
294 const may = owners(ctx, "change connectors");
295 if (!may.ok) return may;
296 const row = await rowFor(ctx, id);
297 if (!row.ok) return row;
298 const done = await connectors.signOut(ctx.db, await connectorDeps(ctx), row.value);
299 if (!done.ok) return done;
300 ctx.audit?.("sign_out_connector", `customize/connectors/${row.value.name}`, `Signed the connector ${row.value.title} out`);
301 return oneConnector(ctx, row.value.id);
302}
303
304/** `target`: an agent's handle or a team's slug, resolved to what grants keep (the agent's id, the team's slug). */
305async function grantTarget(ctx: ViewContext, scope: SkillScope, target: unknown): Promise<Result<string>> {
306 if (scope === "workspace") return ok("");
307 const key = String(target ?? "").trim().replace(/^@/, "").toLowerCase();
308 if (!key) return fail("invalid", "Say which team or agent.");
309 if (scope === "agent") {
310 const agent = await ctx.db.prepare("SELECT id FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(ctx.workspaceId, key).first<{ id: string }>();
311 return agent ? ok(agent.id) : fail("not_found", `There is no agent called @${key}.`);
312 }
313 const teams = await identityClient(env(ctx).IDENTITY)
314 .listTeams(ctx.viewer, ctx.slug)
315 .catch(() => null);
316 return teams?.ok && teams.value.some((t) => t.slug === key) ? ok(key) : fail("not_found", `There is no team called ${key}.`);
317}
318
319async function grantConnector(ctx: ViewContext, id: unknown, scope: unknown, target: unknown): Promise<Result<WorkspaceConnector>> {
320 const may = owners(ctx, "give connectors to agents");
321 if (!may.ok) return may;
322 const row = await rowFor(ctx, id);
323 if (!row.ok) return row;
324 const where = scope === "team" || scope === "agent" ? scope : "workspace";
325 const resolved = await grantTarget(ctx, where, target);
326 if (!resolved.ok) return resolved;
327 const done = await connectors.grant(ctx.db, await connectorDeps(ctx), ctx.workspaceId, row.value.id, where, resolved.value, ctx.viewer.username);
328 if (!done.ok) return done;
329 ctx.audit?.("grant_connector", `customize/connectors/${row.value.name}`, `Gave the connector ${row.value.title} to ${where === "workspace" ? "every agent" : where === "team" ? `the team ${resolved.value}` : `@${String(target).replace(/^@/, "")}`}`);
330 return oneConnector(ctx, row.value.id);
331}
332
333async function revokeConnector(ctx: ViewContext, id: unknown, grantId: unknown): Promise<Result<WorkspaceConnector>> {
334 const may = owners(ctx, "change where connectors reach");
335 if (!may.ok) return may;
336 const row = await rowFor(ctx, id);
337 if (!row.ok) return row;
338 const done = await connectors.revoke(ctx.db, ctx.workspaceId, String(grantId ?? ""));
339 if (!done.ok) return done;
340 ctx.audit?.("revoke_connector", `customize/connectors/${row.value.name}`, `Took the connector ${row.value.title} back from somewhere it reached`);
341 return oneConnector(ctx, row.value.id);
342}
343
344async function agentConnectors(ctx: ViewContext, handle: unknown): Promise<Result<AgentConnector[]>> {
345 const key = String(handle ?? "").trim().replace(/^@/, "").toLowerCase();
346 const agent = await ctx.db.prepare("SELECT * FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(ctx.workspaceId, key).first<Parameters<typeof definitionOf>[0]>();
347 if (!agent) return fail("not_found", `There is no agent called @${key}.`);
348 const teams = await identityClient(env(ctx).IDENTITY)
349 .agentTeams(ctx.slug, agent.id)
350 .catch(() => []);
351 const reach = await connectors.reaching(ctx.db, ctx.workspaceId, agent.id, teams.map((t) => t.slug));
352 const names = new Map(teams.map((t) => [t.slug, t.name]));
353 return ok(connectors.agentConnectorsOut(reach, definitionOf(agent).abilities.mcp_servers ?? [], { teamName: (slug) => names.get(slug) ?? slug }));
354}
355
356// ── The bundle a runner gets ─────────────────────────────────────────────
357
358/**
359 * What a job's Claude Code receives: read with the job's agent token (or a
360 * workspace token), so what reaches it is what reaches that agent. Secrets
361 * are in it: the API hands it to the runner only.
362 */
363async function bundle(ctx: ViewContext): Promise<Result<CustomizeBundle>> {
364 const kind = ctx.viewer.kind ?? "user";
365 if (kind !== "agent" && kind !== "workspace" && !ctx.owner) return fail("forbidden", "The bundle is read by a job's agent token.");
366 const agent = kind === "agent" ? await ctx.db.prepare("SELECT id FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(ctx.workspaceId, ctx.viewer.username.toLowerCase()).first<{ id: string }>() : null;
367 const teams = agent
368 ? await identityClient(env(ctx).IDENTITY)
369 .agentTeams(ctx.slug, agent.id)
370 .then((t) => t.map((x) => x.slug))
371 .catch(() => [])
372 : [];
373 const installs = (await plugins.listInstalls(ctx.db, ctx.workspaceId)).filter((row) => row.enabled === 1);
374 const skillRows = await ctx.db
375 .prepare(
376 `SELECT DISTINCT s.id, s.name, s.plugin, a.version FROM skill_attachments a
377 JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL AND s.status = 'published'
378 WHERE a.workspace_id = ?1 AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?2) OR (a.scope = 'team' AND a.target IN (SELECT value FROM json_each(?3))))
379 LIMIT 200`,
380 )
381 .bind(ctx.workspaceId, agent?.id ?? "", JSON.stringify(teams))
382 .all<{ id: string; name: string; plugin: string | null; version: number }>();
383 const skills: CustomizeBundle["skills"] = [];
384 for (const row of skillRows.results) {
385 // A plugin's skills reach Claude Code with the plugin itself.
386 if (row.plugin) continue;
387 const version = await ctx.db.prepare("SELECT skill_md, files FROM skill_versions WHERE skill_id = ? AND version = ?").bind(row.id, row.version).first<{ skill_md: string; files: string }>();
388 if (!version) continue;
389 let files: SkillFile[] = [];
390 try {
391 files = JSON.parse(version.files) as SkillFile[];
392 } catch {
393 files = [];
394 }
395 skills.push({ name: row.name, plugin: null, files: [{ path: "SKILL.md", content: version.skill_md, encoding: "utf8" }, ...files.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding ?? "utf8" }))] });
396 }
397 const deps = await connectorDeps(ctx);
398 const reach = agent ? await connectors.reaching(ctx.db, ctx.workspaceId, agent.id, teams) : (await connectors.listConnectors(ctx.db, ctx.workspaceId)).map((row) => ({ row, via: "workspace" as const }));
399 const out: CustomizeBundle["connectors"] = [];
400 for (const { row } of reach) {
401 if (connectors.stateOf(row) !== "connected") continue;
402 const headers = await connectors.headersFor(ctx.db, deps, row);
403 if (headers) out.push({ name: row.name, url: row.url, headers });
404 }
405 return ok({
406 plugins: installs.map((row) => ({ ref: row.ref, name: row.name, fetch: plugins.installOut(row).fetch, version: row.version })),
407 skills,
408 connectors: out,
409 });
410}
411
412// ── Dispatch ─────────────────────────────────────────────────────────────
413
414/** Answers a Customize method, or null when `method` isn't one. */
415export async function customizeRpc(
416 method: string,
417 args: any,
418 view: <T>(a: { workspace: string; viewer: User | null }, run: (ctx: ViewContext) => Promise<Result<T>>) => Promise<Result<T>>,
419): Promise<Result<unknown> | null> {
420 switch (method) {
421 case "marketplaces":
422 return view(args, listMarketplaces);
423 case "add_marketplace":
424 return view(args, (ctx) => addMarketplace(ctx, args.address, args.name));
425 case "remove_marketplace":
426 return view(args, (ctx) => removeMarketplace(ctx, args.name));
427 case "refresh_marketplace":
428 return view(args, (ctx) => refreshMarketplace(ctx, args.name));
429 case "discover":
430 return view(args, discover);
431 case "plugin":
432 return view(args, (ctx) => pluginDetail(ctx, args.ref));
433 case "plugin_installs":
434 return view(args, pluginInstalls);
435 case "install_plugin":
436 return view(args, (ctx) => installPlugin(ctx, args.ref));
437 case "set_plugin_enabled":
438 return view(args, (ctx) => setPluginEnabled(ctx, args.ref, args.enabled));
439 case "uninstall_plugin":
440 return view(args, (ctx) => uninstallPlugin(ctx, args.ref));
441 case "plugin_agents":
442 return view(args, pluginAgents);
443 case "connectors":
444 return view(args, listConnectors);
445 case "connector":
446 return view(args, (ctx) => oneConnector(ctx, args.id));
447 case "add_connector":
448 return view(args, (ctx) => addConnector(ctx, args.input));
449 case "set_connector_key":
450 return view(args, (ctx) => setConnectorKey(ctx, args.id, args.header_name, args.value));
451 case "remove_connector":
452 return view(args, (ctx) => removeConnector(ctx, args.id));
453 case "refresh_connector":
454 return view(args, (ctx) => refreshConnector(ctx, args.id));
455 case "start_connector_sign_in":
456 return view(args, (ctx) => startSignIn(ctx, args.id, args.redirect_uri));
457 case "finish_connector_sign_in":
458 return view(args, (ctx) => finishSignIn(ctx, args.state, args.code));
459 case "sign_out_connector":
460 return view(args, (ctx) => signOut(ctx, args.id));
461 case "grant_connector":
462 return view(args, (ctx) => grantConnector(ctx, args.id, args.scope, args.target));
463 case "revoke_connector":
464 return view(args, (ctx) => revokeConnector(ctx, args.id, args.grant));
465 case "agent_connectors":
466 return view(args, (ctx) => agentConnectors(ctx, args.handle));
467 case "customize_bundle":
468 return view(args, bundle);
469 default:
470 return null;
471 }
472}
473
474export type { McpServer };