Skip to content
328 linesCodeBlameRaw
1/**
2 * Marketplaces (docs.g1t.sh/guides/customize/, "Marketplaces"): where a
3 * workspace reads plugins from. Every workspace starts with the built-in
4 * ones (@g1t/contracts claude-plugin.ts `BUILTIN_MARKETPLACES`), whose
5 * manifests are cached once for everyone (workspace_id ''), and owners add
6 * more by the forms Claude Code takes. A manifest is read when the
7 * marketplace is added, again on demand, and again when it is older than
8 * `STALE_MS` and someone opens Discover.
9 *
10 * Discover is built from the cached manifests: one listing per plugin
11 * (`PluginListing`) with where g1t would fetch it from and whether it can
12 * (`fetchPlan`), and one per skill a plugin names outright
13 * (`SkillListing`), so a skills-only plugin lists its skills without a
14 * fetch. The rules and the tables are here, tested on SQLite; who may do
15 * what, the audit log and telling people are index.ts's.
16 */
17import type { FailureCode, Result } from "@g1t/contracts";
18import {
19 BUILTIN_MARKETPLACES,
20 type MarketplaceManifest,
21 type MarketplacePluginEntry,
22 type MarketplaceSource,
23 fetchPlan,
24 isReservedMarketplaceName,
25 marketplaceNameProblem,
26 parseMarketplaceManifest,
27 pluginRef,
28} from "../../../packages/contracts/src/claude-plugin.ts";
29import { MAX_MARKETPLACES, type Marketplace, type PluginAvailability, type PluginListing, type SkillListing } from "../../../packages/contracts/src/customize.ts";
30import { newId } from "../../../packages/contracts/src/ids.ts";
31import { FIRST_PARTY_EXTENSIONS } from "../../../packages/contracts/src/marketplace.ts";
32import { type FetchDeps, fetchMarketplace } from "./customize-fetch.ts";
33
34const ok = <T>(value: T): Result<T> => ({ ok: true, value });
35const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
36
37/** A cached manifest older than this is read again when Discover opens. */
38export const STALE_MS = 6 * 60 * 60 * 1000;
39
40export type MarketplaceRow = {
41 id: string;
42 workspace_id: string;
43 name: string;
44 source: string;
45 manifest: string | null;
46 fetched_at: string | null;
47 problem: string | null;
48 added_by: string;
49 added_at: string;
50};
51
52/** g1t's own marketplace, in code: what g1t builds and will build, under its own name. */
53export const G1T_MARKETPLACE: MarketplaceManifest = {
54 name: "g1t",
55 owner: { name: "g1t" },
56 description: "g1t's own plugins: the pages, cards and roles g1t is building for what a team does besides code.",
57 version: null,
58 pluginRoot: null,
59 // What g1t is building, announced before release: each a plugin here once it ships.
60 plugins: FIRST_PARTY_EXTENSIONS.map(
61 (extension): MarketplacePluginEntry => ({
62 name: extension.id,
63 source: { source: "path", path: `./${extension.id}` },
64 description: extension.tagline,
65 version: null,
66 author: { name: "g1t" },
67 homepage: null,
68 repository: null,
69 license: null,
70 keywords: [],
71 category: extension.category.toLowerCase(),
72 tags: [],
73 skills: [],
74 commands: [],
75 agents: [],
76 mcpServers: null,
77 strict: true,
78 displayName: extension.name,
79 extra: { status: extension.status === "available" ? "available" : "soon" },
80 }),
81 ),
82 problems: [],
83};
84
85export type ManifestCache = { manifest: MarketplaceManifest; sha: string | null; fetched_at: string };
86
87function json<T>(text: string | null, fallback: T): T {
88 if (!text) return fallback;
89 try {
90 return JSON.parse(text) as T;
91 } catch {
92 return fallback;
93 }
94}
95
96/** The source a row keeps. */
97export function sourceOf(row: MarketplaceRow): MarketplaceSource {
98 return json<MarketplaceSource>(row.source, { source: "builtin", id: row.name });
99}
100
101/** The cached manifest a row keeps, parsed again (the cache holds the raw JSON, so parsing improvements apply). */
102export function cachedManifest(row: MarketplaceRow): MarketplaceManifest | null {
103 const cache = json<{ json: unknown; sha: string | null } | null>(row.manifest, null);
104 if (!cache) return null;
105 const parsed = parseMarketplaceManifest(cache.json);
106 return parsed.ok ? parsed.value : null;
107}
108
109/** The marketplaces a workspace reads: the built-in ones (from the shared cache rows), then its own, by name. */
110export async function listRows(db: D1Database, workspaceId: string): Promise<{ row: MarketplaceRow | null; builtin: (typeof BUILTIN_MARKETPLACES)[number] | null; name: string }[]> {
111 const { results } = await db.prepare("SELECT * FROM marketplaces WHERE workspace_id IN ('', ?) ORDER BY added_at").bind(workspaceId).all<MarketplaceRow>();
112 const shared = new Map(results.filter((r) => r.workspace_id === "").map((r) => [r.name, r]));
113 const own = results.filter((r) => r.workspace_id === workspaceId);
114 return [...BUILTIN_MARKETPLACES.map((b) => ({ row: shared.get(b.name) ?? null, builtin: b, name: b.name })), ...own.map((r) => ({ row: r, builtin: null, name: r.name }))];
115}
116
117/** Reads a marketplace's manifest and caches it on its row (a built-in's row is made if missing). */
118export async function refreshRow(
119 db: D1Database,
120 entry: { row: MarketplaceRow | null; builtin: (typeof BUILTIN_MARKETPLACES)[number] | null; name: string },
121 deps: FetchDeps,
122 host: string | null,
123 now = new Date(),
124): Promise<MarketplaceRow> {
125 const source = entry.builtin ? entry.builtin.source : sourceOf(entry.row!);
126 const read = await fetchMarketplace(source, deps, host);
127 const at = now.toISOString();
128 let manifest: string | null = entry.row?.manifest ?? null;
129 let problem: string | null = null;
130 if (read.ok) {
131 const parsed = parseMarketplaceManifest(read.json);
132 if (parsed.ok) {
133 if (!entry.builtin && parsed.value.name !== entry.name) problem = `Its marketplace.json calls itself ${parsed.value.name}, not ${entry.name}.`;
134 manifest = JSON.stringify({ json: read.json, sha: read.sha });
135 } else problem = parsed.message;
136 } else problem = read.message;
137 if (!entry.row) {
138 const row: MarketplaceRow = { id: newId("mkt", now.getTime()), workspace_id: "", name: entry.name, source: JSON.stringify(source), manifest, fetched_at: at, problem, added_by: "g1t", added_at: at };
139 await db
140 .prepare("INSERT INTO marketplaces (id, workspace_id, name, source, manifest, fetched_at, problem, added_by, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (workspace_id, name) DO UPDATE SET manifest = excluded.manifest, fetched_at = excluded.fetched_at, problem = excluded.problem")
141 .bind(row.id, row.workspace_id, row.name, row.source, row.manifest, row.fetched_at, row.problem, row.added_by, row.added_at)
142 .run();
143 return row;
144 }
145 await db.prepare("UPDATE marketplaces SET manifest = ?, fetched_at = ?, problem = ? WHERE id = ?").bind(manifest, at, problem, entry.row.id).run();
146 return { ...entry.row, manifest, fetched_at: at, problem };
147}
148
149/** Every marketplace with a fresh enough manifest, reading the stale ones (and the built-ins' first time). */
150export async function loadAll(db: D1Database, workspaceId: string, deps: FetchDeps, host: string | null, now = new Date()): Promise<{ row: MarketplaceRow; builtin: boolean; manifest: MarketplaceManifest | null }[]> {
151 const out: { row: MarketplaceRow; builtin: boolean; manifest: MarketplaceManifest | null }[] = [];
152 for (const entry of await listRows(db, workspaceId)) {
153 let row = entry.row;
154 const stale = !row?.fetched_at || now.getTime() - Date.parse(row.fetched_at) > STALE_MS;
155 if (stale) {
156 try {
157 row = await refreshRow(db, entry, deps, host, now);
158 } catch (error) {
159 console.error("agents: a marketplace wasn't read", entry.name, String(error));
160 }
161 }
162 if (!row) continue;
163 out.push({ row, builtin: !!entry.builtin, manifest: cachedManifest(row) });
164 }
165 return out;
166}
167
168/** The marketplaces as their page lists them. */
169export function marketplacesOut(loaded: { row: MarketplaceRow; builtin: boolean; manifest: MarketplaceManifest | null }[], owner: boolean): Marketplace[] {
170 const out = loaded.map(({ row, builtin, manifest }) => ({
171 name: row.name,
172 source: sourceOf(row),
173 builtin,
174 about: builtin ? (BUILTIN_MARKETPLACES.find((b) => b.name === row.name)?.about ?? null) : (manifest?.description ?? null),
175 owner: manifest?.owner.name ?? null,
176 plugins: manifest?.plugins.length ?? 0,
177 fetched_at: row.fetched_at,
178 problem: row.problem,
179 added_by: builtin ? null : row.added_by,
180 added_at: builtin ? null : row.added_at,
181 can_remove: owner && !builtin,
182 }));
183 out.push({
184 name: G1T_MARKETPLACE.name,
185 source: { source: "builtin", id: "g1t" },
186 builtin: true,
187 about: G1T_MARKETPLACE.description,
188 owner: "g1t",
189 plugins: G1T_MARKETPLACE.plugins.length,
190 fetched_at: null,
191 problem: null,
192 added_by: null,
193 added_at: null,
194 can_remove: false,
195 });
196 return out;
197}
198
199/** Adds a marketplace for the workspace and reads it; the row is kept even when the first read fails, so it can be read again. */
200export async function addMarketplace(
201 db: D1Database,
202 workspaceId: string,
203 name: string | null,
204 source: MarketplaceSource,
205 by: string,
206 deps: FetchDeps,
207 host: string | null,
208 now = new Date(),
209): Promise<Result<Marketplace>> {
210 const count = await db.prepare("SELECT COUNT(*) AS n FROM marketplaces WHERE workspace_id = ?").bind(workspaceId).first<{ n: number }>();
211 if ((count?.n ?? 0) >= MAX_MARKETPLACES) return fail("limit", `A workspace reads at most ${MAX_MARKETPLACES} marketplaces of its own.`);
212 const read = await fetchMarketplace(source, deps, host);
213 if (!read.ok) return fail("invalid", read.message);
214 const parsed = parseMarketplaceManifest(read.json);
215 if (!parsed.ok) return fail("invalid", `Its marketplace.json can't be read: ${parsed.message}`);
216 const wanted = name?.trim() || parsed.value.name;
217 const problem = marketplaceNameProblem(wanted);
218 if (problem) return fail("invalid", problem);
219 if (isReservedMarketplaceName(wanted) || wanted === G1T_MARKETPLACE.name) return fail("invalid", `${wanted} is a name kept for a built-in marketplace. Give it another name here.`);
220 const at = now.toISOString();
221 const row: MarketplaceRow = { id: newId("mkt", now.getTime()), workspace_id: workspaceId, name: wanted, source: JSON.stringify(source), manifest: JSON.stringify({ json: read.json, sha: read.sha }), fetched_at: at, problem: null, added_by: by, added_at: at };
222 try {
223 await db
224 .prepare("INSERT INTO marketplaces (id, workspace_id, name, source, manifest, fetched_at, problem, added_by, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
225 .bind(row.id, row.workspace_id, row.name, row.source, row.manifest, row.fetched_at, row.problem, row.added_by, row.added_at)
226 .run();
227 } catch (error) {
228 if (String(error).includes("UNIQUE")) return fail("conflict", `The workspace already reads a marketplace called ${wanted}.`);
229 throw error;
230 }
231 return ok(marketplacesOut([{ row, builtin: false, manifest: parsed.value }], true)[0]!);
232}
233
234/** Removes one of the workspace's own marketplaces. Plugins installed from it stay installed. */
235export async function removeMarketplace(db: D1Database, workspaceId: string, name: string): Promise<Result<null>> {
236 if (BUILTIN_MARKETPLACES.some((b) => b.name === name) || name === G1T_MARKETPLACE.name) return fail("invalid", `${name} is built in, so it can't be removed.`);
237 const done = await db.prepare("DELETE FROM marketplaces WHERE workspace_id = ? AND name = ?").bind(workspaceId, name).run();
238 return done.meta.changes ? ok(null) : fail("not_found", "The workspace reads no marketplace by that name.");
239}
240
241/** One marketplace's row by name, the workspace's own or a built-in's shared cache. */
242export async function marketplaceByName(db: D1Database, workspaceId: string, name: string): Promise<{ row: MarketplaceRow | null; builtin: (typeof BUILTIN_MARKETPLACES)[number] | null; name: string } | null> {
243 const entries = await listRows(db, workspaceId);
244 return entries.find((e) => e.name === name) ?? null;
245}
246
247// ── Discover ─────────────────────────────────────────────────────────────
248
249/** The installs Discover marks, by ref. */
250export type InstallMarks = Map<string, { enabled: boolean }>;
251
252/** A listing for one plugin entry of one marketplace. */
253export function listingOf(entry: MarketplacePluginEntry, marketplace: { name: string; source: MarketplaceSource; pluginRoot: string | null }, installs: InstallMarks, host: string | null): PluginListing {
254 const ref = pluginRef(entry.name, marketplace.name);
255 const plan = fetchPlan(entry.source, marketplace.source, { pluginRoot: marketplace.pluginRoot, host });
256 const installed = installs.get(ref);
257 const soon = entry.extra.status === "soon";
258 const availability: PluginAvailability = installed ? { kind: "installed", enabled: installed.enabled } : soon ? { kind: "soon" } : plan.kind === "unsupported" ? { kind: "unavailable", why: plan.why } : { kind: "available" };
259 return {
260 ref,
261 name: entry.name,
262 marketplace: marketplace.name,
263 display_name: entry.displayName,
264 description: entry.description,
265 version: entry.version,
266 author: entry.author?.name ?? null,
267 homepage: entry.homepage,
268 category: entry.category,
269 tags: entry.tags,
270 keywords: entry.keywords,
271 fetch: plan,
272 availability,
273 skills: entry.skills.filter((s) => s !== ".").map((s) => s.replace(/^.*\//, "")),
274 };
275}
276
277/** Every plugin every marketplace lists, g1t's own last, in listing order. */
278export function discoverPlugins(loaded: { row: MarketplaceRow; manifest: MarketplaceManifest | null }[], installs: InstallMarks, host: string | null): PluginListing[] {
279 const out: PluginListing[] = [];
280 for (const { row, manifest } of loaded) {
281 if (!manifest) continue;
282 const source = sourceOf(row);
283 for (const entry of manifest.plugins) out.push(listingOf(entry, { name: row.name, source, pluginRoot: manifest.pluginRoot }, installs, host));
284 }
285 for (const entry of G1T_MARKETPLACE.plugins) out.push(listingOf(entry, { name: G1T_MARKETPLACE.name, source: { source: "builtin", id: "g1t" }, pluginRoot: null }, installs, host));
286 return out;
287}
288
289/**
290 * Every skill a marketplace names outright in a plugin entry (skills-only
291 * plugins list theirs), one listing each, with whether the library has it
292 * from that plugin already (`added`: by `plugin:name`).
293 */
294export function discoverSkills(listings: PluginListing[], added: ReadonlySet<string>): SkillListing[] {
295 const out: SkillListing[] = [];
296 for (const plugin of listings) {
297 for (const name of plugin.skills) {
298 out.push({
299 ref: `${plugin.name}:${name}`,
300 name,
301 plugin: plugin.name,
302 marketplace: plugin.marketplace,
303 description: plugin.description,
304 category: plugin.category,
305 author: plugin.author,
306 added: added.has(`${plugin.ref}/${name}`),
307 availability: plugin.availability,
308 });
309 }
310 }
311 return out;
312}
313
314/** A plugin's listing by `name@marketplace`, from what is loaded. */
315export function findListing(listings: PluginListing[], ref: string): PluginListing | null {
316 return listings.find((l) => l.ref === ref) ?? null;
317}
318
319/** The entry behind a listing, for installing: the marketplace's parsed entry and source. */
320export function entryOf(loaded: { row: MarketplaceRow; manifest: MarketplaceManifest | null }[], ref: { name: string; marketplace: string }): { entry: MarketplacePluginEntry; source: MarketplaceSource; pluginRoot: string | null } | null {
321 if (ref.marketplace === G1T_MARKETPLACE.name) {
322 const entry = G1T_MARKETPLACE.plugins.find((p) => p.name === ref.name);
323 return entry ? { entry, source: { source: "builtin", id: "g1t" }, pluginRoot: null } : null;
324 }
325 const found = loaded.find((l) => l.row.name === ref.marketplace);
326 const entry = found?.manifest?.plugins.find((p) => p.name === ref.name);
327 return found && entry ? { entry, source: sourceOf(found.row), pluginRoot: found.manifest!.pluginRoot } : null;
328}