| 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 | */ |
| 17 | import type { FailureCode, Result } from "@g1t/contracts"; |
| 18 | import { |
| 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"; |
| 29 | import { MAX_MARKETPLACES, type Marketplace, type PluginAvailability, type PluginListing, type SkillListing } from "../../../packages/contracts/src/customize.ts"; |
| 30 | import { newId } from "../../../packages/contracts/src/ids.ts"; |
| 31 | import { FIRST_PARTY_EXTENSIONS } from "../../../packages/contracts/src/marketplace.ts"; |
| 32 | import { type FetchDeps, fetchMarketplace } from "./customize-fetch.ts"; |
| 33 | |
| 34 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 35 | const 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. */ |
| 38 | export const STALE_MS = 6 * 60 * 60 * 1000; |
| 39 | |
| 40 | export 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. */ |
| 53 | export 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 | |
| 85 | export type ManifestCache = { manifest: MarketplaceManifest; sha: string | null; fetched_at: string }; |
| 86 | |
| 87 | function 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. */ |
| 97 | export 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). */ |
| 102 | export 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. */ |
| 110 | export 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). */ |
| 118 | export 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). */ |
| 150 | export 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. */ |
| 169 | export 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. */ |
| 200 | export 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. */ |
| 235 | export 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. */ |
| 242 | export 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. */ |
| 250 | export type InstallMarks = Map<string, { enabled: boolean }>; |
| 251 | |
| 252 | /** A listing for one plugin entry of one marketplace. */ |
| 253 | export 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. */ |
| 278 | export 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 | */ |
| 294 | export 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. */ |
| 315 | export 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. */ |
| 320 | export 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 | } |