| 1 | /** |
| 2 | * Reading marketplaces and plugins from where they live |
| 3 | * (docs.g1t.sh/guides/customize/, "Where plugins come from"): a GitHub |
| 4 | * repository (public, by its API for the tree and raw files for content), |
| 5 | * a repository on this g1t (the repos service), a direct link to a |
| 6 | * `marketplace.json`, or a zip archive. Every read is bounded: at most |
| 7 | * `MAX_PLUGIN_FILES` files and `MAX_PLUGIN_BYTES` together, and a file over |
| 8 | * `MAX_FILE_BYTES` is left out and named, since Claude Code on a runner |
| 9 | * fetches the plugin whole for itself anyway. |
| 10 | * |
| 11 | * Pure apart from `fetch` and the repos ports, so it is tested with fakes. |
| 12 | */ |
| 13 | import type { FetchPlan, MarketplaceSource } from "../../../packages/contracts/src/claude-plugin.ts"; |
| 14 | import type { SkillFile } from "../../../packages/contracts/src/skill-format.ts"; |
| 15 | import { asSkillFile, unzip } from "./skill-zip.ts"; |
| 16 | |
| 17 | /** The most a plugin read here holds. */ |
| 18 | export const MAX_PLUGIN_FILES = 2000; |
| 19 | export const MAX_PLUGIN_BYTES = 24 * 1024 * 1024; |
| 20 | /** A file larger than this is listed but not read. */ |
| 21 | export const MAX_FILE_BYTES = 2 * 1024 * 1024; |
| 22 | /** How long one fetch may take. */ |
| 23 | const TIMEOUT_MS = 20_000; |
| 24 | /** Raw files fetched at once. */ |
| 25 | const PARALLEL = 16; |
| 26 | |
| 27 | export type Fetch = (url: string, init?: RequestInit) => Promise<Response>; |
| 28 | |
| 29 | /** The repos service, as the fetcher needs it: a repository the viewer may read, its files at a ref, and blobs. */ |
| 30 | export type RepoPorts = { |
| 31 | repo(full: string): Promise<{ id: string; default_branch: string } | null>; |
| 32 | listFiles(repoId: string, ref: string | null): Promise<{ commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean }>; |
| 33 | blobs(repoId: string, hashes: string[]): Promise<{ hash: string; data: string | null }[]>; |
| 34 | }; |
| 35 | |
| 36 | export type FetchDeps = { |
| 37 | fetchFn?: Fetch; |
| 38 | /** A GitHub token for the API (optional: the API allows a few reads an hour without one). */ |
| 39 | githubToken?: string | null; |
| 40 | repos?: RepoPorts | null; |
| 41 | }; |
| 42 | |
| 43 | export type Fetched = { ok: true; files: SkillFile[]; sha: string | null; omitted: string[] } | { ok: false; message: string }; |
| 44 | |
| 45 | const fail = (message: string): { ok: false; message: string } => ({ ok: false, message }); |
| 46 | |
| 47 | async function get(fetchFn: Fetch, url: string, headers: Record<string, string> = {}): Promise<Response | null> { |
| 48 | try { |
| 49 | return await fetchFn(url, { headers: { "user-agent": "g1t-customize", ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: "follow" }); |
| 50 | } catch { |
| 51 | return null; |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | function githubHeaders(token: string | null | undefined): Record<string, string> { |
| 56 | return { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", ...(token ? { authorization: `Bearer ${token}` } : {}) }; |
| 57 | } |
| 58 | |
| 59 | // ── GitHub ─────────────────────────────────────────────────────────────── |
| 60 | |
| 61 | /** The commit `ref` names (or the default branch's head), as a 40-character sha. */ |
| 62 | async function githubCommit(fetchFn: Fetch, token: string | null | undefined, repo: string, ref: string | null): Promise<{ ok: true; sha: string } | { ok: false; message: string }> { |
| 63 | const response = await get(fetchFn, `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref ?? "HEAD")}`, githubHeaders(token)); |
| 64 | if (!response) return fail(`GitHub couldn't be reached for ${repo}.`); |
| 65 | if (response.status === 404) return fail(`${repo}${ref ? ` at ${ref}` : ""} isn't on GitHub, or isn't public.`); |
| 66 | if (response.status === 403 || response.status === 429) return fail("GitHub is limiting how often g1t may read right now. Try again in a little while."); |
| 67 | if (!response.ok) return fail(`GitHub answered ${response.status} for ${repo}.`); |
| 68 | const body = (await response.json().catch(() => null)) as { sha?: string } | null; |
| 69 | return body?.sha && /^[0-9a-f]{40}$/.test(body.sha) ? { ok: true, sha: body.sha } : fail(`GitHub didn't say which commit ${repo} is at.`); |
| 70 | } |
| 71 | |
| 72 | /** Every file under `path` at `sha`, from the repository's tree. */ |
| 73 | async function githubTree(fetchFn: Fetch, token: string | null | undefined, repo: string, sha: string, path: string): Promise<{ ok: true; files: { path: string; size: number }[]; truncated: boolean } | { ok: false; message: string }> { |
| 74 | const response = await get(fetchFn, `https://api.github.com/repos/${repo}/git/trees/${sha}?recursive=1`, githubHeaders(token)); |
| 75 | if (!response) return fail(`GitHub couldn't be reached for ${repo}.`); |
| 76 | if (response.status === 403 || response.status === 429) return fail("GitHub is limiting how often g1t may read right now. Try again in a little while."); |
| 77 | if (!response.ok) return fail(`GitHub answered ${response.status} for ${repo}'s files.`); |
| 78 | const body = (await response.json().catch(() => null)) as { tree?: { path: string; type: string; size?: number }[]; truncated?: boolean } | null; |
| 79 | if (!body?.tree) return fail(`GitHub didn't list ${repo}'s files.`); |
| 80 | const prefix = path ? `${path.replace(/\/+$/, "")}/` : ""; |
| 81 | const files = body.tree.filter((e) => e.type === "blob" && e.path.startsWith(prefix)).map((e) => ({ path: e.path.slice(prefix.length), size: e.size ?? 0 })); |
| 82 | return { ok: true, files, truncated: !!body.truncated }; |
| 83 | } |
| 84 | |
| 85 | /** Raw files from GitHub, in parallel, each as text or base64. */ |
| 86 | async function githubFiles(fetchFn: Fetch, repo: string, sha: string, base: string, files: { path: string; size: number }[]): Promise<{ ok: true; files: SkillFile[]; omitted: string[] } | { ok: false; message: string }> { |
| 87 | const out: SkillFile[] = []; |
| 88 | const omitted: string[] = []; |
| 89 | const prefix = base ? `${base}/` : ""; |
| 90 | const wanted = files.filter((f) => (f.size <= MAX_FILE_BYTES ? true : (omitted.push(f.path), false))); |
| 91 | for (let i = 0; i < wanted.length; i += PARALLEL) { |
| 92 | const batch = wanted.slice(i, i + PARALLEL); |
| 93 | const results = await Promise.all( |
| 94 | batch.map(async (file) => { |
| 95 | const response = await get(fetchFn, `https://raw.githubusercontent.com/${repo}/${sha}/${prefix}${file.path.split("/").map(encodeURIComponent).join("/")}`); |
| 96 | if (!response?.ok) return { file, bytes: null as Uint8Array | null }; |
| 97 | return { file, bytes: new Uint8Array(await response.arrayBuffer()) }; |
| 98 | }), |
| 99 | ); |
| 100 | for (const { file, bytes } of results) { |
| 101 | if (!bytes) return fail(`${file.path} in ${repo} couldn't be read from GitHub.`); |
| 102 | out.push(asSkillFile(file.path, bytes)); |
| 103 | } |
| 104 | } |
| 105 | return { ok: true, files: out, omitted }; |
| 106 | } |
| 107 | |
| 108 | // ── This g1t ───────────────────────────────────────────────────────────── |
| 109 | |
| 110 | function blobFile(path: string, data: string): SkillFile { |
| 111 | const binary = atob(data); |
| 112 | const bytes = new Uint8Array(binary.length); |
| 113 | for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| 114 | return asSkillFile(path, bytes); |
| 115 | } |
| 116 | |
| 117 | async function g1tFiles(repos: RepoPorts, repo: string, ref: string | null, path: string): Promise<Fetched> { |
| 118 | const found = await repos.repo(repo).catch(() => null); |
| 119 | if (!found) return fail(`${repo} isn't a repository here that you can read.`); |
| 120 | const listing = await repos.listFiles(found.id, ref).catch(() => null); |
| 121 | if (!listing) return fail(`${repo}${ref ? ` at ${ref}` : ""} couldn't be read.`); |
| 122 | const prefix = path ? `${path.replace(/\/+$/, "")}/` : ""; |
| 123 | const wanted = listing.files.filter((f): f is { path: string; hash: string } => !!f.hash && f.path.startsWith(prefix)); |
| 124 | if (!wanted.length) return fail(`There are no files in ${path || "the top folder"} of ${repo}.`); |
| 125 | if (wanted.length > MAX_PLUGIN_FILES) return fail(`${path || repo} has ${wanted.length} files; a plugin holds at most ${MAX_PLUGIN_FILES}.`); |
| 126 | const data = new Map<string, string | null>(); |
| 127 | const hashes = [...new Set(wanted.map((f) => f.hash))]; |
| 128 | for (let i = 0; i < hashes.length; i += 100) for (const blob of await repos.blobs(found.id, hashes.slice(i, i + 100))) data.set(blob.hash, blob.data); |
| 129 | const files: SkillFile[] = []; |
| 130 | const omitted: string[] = []; |
| 131 | let bytes = 0; |
| 132 | for (const file of wanted) { |
| 133 | const content = data.get(file.hash); |
| 134 | const relative = file.path.slice(prefix.length); |
| 135 | if (content == null) { |
| 136 | omitted.push(relative); |
| 137 | continue; |
| 138 | } |
| 139 | const one = blobFile(relative, content); |
| 140 | bytes += one.encoding === "base64" ? Math.floor((one.content.length * 3) / 4) : new TextEncoder().encode(one.content).length; |
| 141 | if (bytes > MAX_PLUGIN_BYTES) return fail(`${path || repo} is larger than ${Math.round(MAX_PLUGIN_BYTES / 1024 / 1024)} MB, the most a plugin read here holds.`); |
| 142 | files.push(one); |
| 143 | } |
| 144 | return { ok: true, files, sha: listing.commit, omitted }; |
| 145 | } |
| 146 | |
| 147 | // ── Archives ───────────────────────────────────────────────────────────── |
| 148 | |
| 149 | async function archiveFiles(fetchFn: Fetch, url: string, sha256: string | null): Promise<Fetched> { |
| 150 | const response = await get(fetchFn, url); |
| 151 | if (!response?.ok) return fail(`The archive at ${url} couldn't be fetched${response ? ` (${response.status})` : ""}.`); |
| 152 | const bytes = new Uint8Array(await response.arrayBuffer()); |
| 153 | if (bytes.length > MAX_PLUGIN_BYTES) return fail(`The archive is larger than ${Math.round(MAX_PLUGIN_BYTES / 1024 / 1024)} MB, the most a plugin read here holds.`); |
| 154 | if (sha256) { |
| 155 | const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| 156 | if (digest !== sha256) return fail("The archive isn't the one the marketplace lists: its checksum differs."); |
| 157 | } |
| 158 | const unpacked = await unzip(bytes, { files: MAX_PLUGIN_FILES, bytes: MAX_PLUGIN_BYTES }); |
| 159 | if (!unpacked.ok) return fail(unpacked.message); |
| 160 | return { ok: true, files: unpacked.files, sha: null, omitted: [] }; |
| 161 | } |
| 162 | |
| 163 | // ── Plugins ────────────────────────────────────────────────────────────── |
| 164 | |
| 165 | /** A plugin's files, read from where its plan says, with the commit they were read at when there is one. */ |
| 166 | export async function fetchPlugin(plan: FetchPlan, deps: FetchDeps = {}): Promise<Fetched> { |
| 167 | const fetchFn = deps.fetchFn ?? ((url, init) => fetch(url, init)); |
| 168 | switch (plan.kind) { |
| 169 | case "unsupported": |
| 170 | return fail(plan.why); |
| 171 | case "archive": |
| 172 | return archiveFiles(fetchFn, plan.url, plan.sha256); |
| 173 | case "g1t": |
| 174 | if (!deps.repos) return fail("Repositories here can't be read just now."); |
| 175 | return g1tFiles(deps.repos, plan.repo, plan.ref, plan.path); |
| 176 | case "github": { |
| 177 | const sha = plan.sha ? { ok: true as const, sha: plan.sha } : await githubCommit(fetchFn, deps.githubToken, plan.repo, plan.ref); |
| 178 | if (!sha.ok) return sha; |
| 179 | const tree = await githubTree(fetchFn, deps.githubToken, plan.repo, sha.sha, plan.path); |
| 180 | if (!tree.ok) return tree; |
| 181 | if (!tree.files.length) return fail(`There are no files in ${plan.path || "the top folder"} of ${plan.repo}.`); |
| 182 | if (tree.files.length > MAX_PLUGIN_FILES) return fail(`${plan.path || plan.repo} has ${tree.files.length} files; a plugin holds at most ${MAX_PLUGIN_FILES}.`); |
| 183 | const total = tree.files.reduce((n, f) => n + Math.min(f.size, MAX_FILE_BYTES), 0); |
| 184 | if (total > MAX_PLUGIN_BYTES) return fail(`${plan.path || plan.repo} is larger than ${Math.round(MAX_PLUGIN_BYTES / 1024 / 1024)} MB, the most a plugin read here holds.`); |
| 185 | const files = await githubFiles(fetchFn, plan.repo, sha.sha, plan.path, tree.files); |
| 186 | if (!files.ok) return files; |
| 187 | return { ok: true, files: files.files, sha: sha.sha, omitted: [...files.omitted, ...(tree.truncated ? ["(GitHub listed only part of a very large repository)"] : [])] }; |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // ── Marketplaces ───────────────────────────────────────────────────────── |
| 193 | |
| 194 | export type FetchedManifest = { ok: true; json: unknown; sha: string | null } | { ok: false; message: string }; |
| 195 | |
| 196 | const MANIFEST_PATH = ".claude-plugin/marketplace.json"; |
| 197 | |
| 198 | /** A marketplace's `marketplace.json`, read from where it is. */ |
| 199 | export async function fetchMarketplace(source: MarketplaceSource, deps: FetchDeps = {}, host: string | null = null): Promise<FetchedManifest> { |
| 200 | const fetchFn = deps.fetchFn ?? ((url, init) => fetch(url, init)); |
| 201 | const parse = async (response: Response | null, where: string): Promise<FetchedManifest> => { |
| 202 | if (!response) return fail(`${where} couldn't be reached.`); |
| 203 | if (response.status === 404) return fail(`There is no ${MANIFEST_PATH} at ${where}.`); |
| 204 | if (!response.ok) return fail(`${where} answered ${response.status}.`); |
| 205 | const text = await response.text().catch(() => ""); |
| 206 | if (text.length > 4 * 1024 * 1024) return fail("That marketplace.json is larger than 4 MB."); |
| 207 | try { |
| 208 | return { ok: true, json: JSON.parse(text), sha: null }; |
| 209 | } catch { |
| 210 | return fail(`${where} didn't give JSON.`); |
| 211 | } |
| 212 | }; |
| 213 | switch (source.source) { |
| 214 | case "builtin": |
| 215 | return fail("A built-in marketplace is read by its own source."); |
| 216 | case "url": |
| 217 | return parse(await get(fetchFn, source.url), source.url); |
| 218 | case "github": { |
| 219 | const read = await parse(await get(fetchFn, `https://raw.githubusercontent.com/${source.repo}/${encodeURIComponent(source.ref ?? "HEAD")}/${MANIFEST_PATH}`), `${source.repo} on GitHub`); |
| 220 | if (!read.ok) return read; |
| 221 | const sha = await githubCommit(fetchFn, deps.githubToken, source.repo, source.ref ?? null); |
| 222 | return { ...read, sha: sha.ok ? sha.sha : null }; |
| 223 | } |
| 224 | case "git": { |
| 225 | const github = /github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(source.url); |
| 226 | if (github) return fetchMarketplace({ source: "github", repo: github[1]!, ...(source.ref ? { ref: source.ref } : {}) }, deps, host); |
| 227 | const own = host ? new RegExp(`^https?://(?:www\\.)?${host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([^/]+/[^/]+?)(?:\\.git)?/?$`, "i").exec(source.url) : null; |
| 228 | if (own) return fetchMarketplace({ source: "g1t", repo: own[1]!, ...(source.ref ? { ref: source.ref } : {}) }, deps, host); |
| 229 | return fail("g1t reads marketplaces from GitHub, from repositories here, and from a link to their marketplace.json. That git host isn't one of them yet."); |
| 230 | } |
| 231 | case "g1t": { |
| 232 | if (!deps.repos) return fail("Repositories here can't be read just now."); |
| 233 | const found = await deps.repos.repo(source.repo).catch(() => null); |
| 234 | if (!found) return fail(`${source.repo} isn't a repository here that you can read.`); |
| 235 | const listing = await deps.repos.listFiles(found.id, source.ref ?? null).catch(() => null); |
| 236 | const entry = listing?.files.find((f) => f.path === MANIFEST_PATH); |
| 237 | if (!listing || !entry?.hash) return fail(`There is no ${MANIFEST_PATH} in ${source.repo}.`); |
| 238 | const [blob] = await deps.repos.blobs(found.id, [entry.hash]); |
| 239 | if (!blob?.data) return fail(`${MANIFEST_PATH} in ${source.repo} couldn't be read.`); |
| 240 | try { |
| 241 | return { ok: true, json: JSON.parse(atob(blob.data)), sha: listing.commit }; |
| 242 | } catch { |
| 243 | return fail(`${MANIFEST_PATH} in ${source.repo} isn't JSON.`); |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | } |