Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

12 files changed+178−6
Files (12)
addedapps/diffinery/claude-plugins/README.md+13−0View file
@@ -1,0 +1,13 @@
1+# Claude Code plugins, as served for upload
2+
3+Copies of the plugins from codician/diffinery-plugins, packed by that repository's
4+`scripts/pack.py` (reproducible: the same sources give the same bytes). Diffinery serves them at
5+`/-/claude/<name>.zip` (and `/-/claude/<name>-<version>.zip`) so people can upload them to
6+claude.ai under Customize → Plugins → Add → Upload plugin, which can't fetch the private plugin
7+repository. See docs/connect/ci-status-in-claude.md.
8+
9+To update one: change it in codician/diffinery-plugins, bump its version, run
10+`python3 scripts/pack.py <name>` there, replace the zip here (one file per plugin,
11+`<name>-<version>.zip`), and check that `sha256sum` matches a fresh pack of the merged commit.
12+scripts/build-docs.mjs embeds them and refuses a name it doesn't know, two versions of one
13+plugin, or a zip without `.claude-plugin/plugin.json` at its root.
addedapps/diffinery/claude-plugins/diffinery-0.1.1.zip+0−0View file
Binary file not shown.
addedapps/diffinery/claude-plugins/diffinery-ci-0.1.0.zip+0−0View file
Binary file not shown.
modifiedapps/diffinery/docs/agents/github-cli.md+1−1View file
@@ -60,7 +60,7 @@
6060
6161 There's nothing to set up beyond signing gh in. Claude Code treats any host other than github.com as GitHub Enterprise: it reads the remote of the folder it's in, gets the token from `GH_ENTERPRISE_TOKEN` (when `GH_HOST` names the host) or from `gh auth token --hostname diffinery.com`, and asks Diffinery's REST and GraphQL APIs for the pull request, its review decision and its CI counts. In the desktop app, the folder has to be one you've trusted.
6262
63-Sessions in Claude Code on the web (claude.ai/code) run in Anthropic's cloud and get their pull request status from Anthropic's GitHub integration, which only reaches github.com. Their badge doesn't show Diffinery pull requests. Those sessions can still use gh and the [MCP server](/docs/agents/mcp) to open pull requests and follow CI on Diffinery.
63+Sessions in Claude Code on the web (claude.ai/code) and project threads run in Anthropic's cloud and get their pull request cards from Anthropic's GitHub integration, which doesn't reach Diffinery. Those sessions can still use gh and the [MCP server](/docs/agents/mcp) to open pull requests and follow CI, and the `diffinery-ci` plugin tells Claude when CI on its pushed commit settles. For a live view of every check in the terminal, the desktop app and VS Code, see [CI status in Claude](/docs/connect/ci-status-in-claude).
6464
6565 ## Troubleshooting
6666
modifiedapps/diffinery/docs/connect.md+1−0View file
@@ -19,6 +19,7 @@
1919 | --- | --- | --- |
2020 | **Claude** on claude.ai, Claude Desktop or the Claude mobile apps | Add a custom connector with the URL, then approve it on Diffinery. | [Claude](/docs/connect/claude) |
2121 | **Claude Code** | Install the Diffinery plugin, then sign in from `/mcp`. | [Claude Code](/docs/connect/claude-code) |
22+| **Live CI in Claude Code**, cloud sessions included | Install or upload the `diffinery-ci` plugin. | [CI status in Claude](/docs/connect/ci-status-in-claude) |
2223 | **Codex** (OpenAI's coding agent) | Install the Diffinery plugin, then run `codex mcp login diffinery`. | [Codex](/docs/connect/codex) |
2324 | **Cursor, VS Code or another MCP app** | Add the URL as a remote (HTTP) MCP server. | [Other apps](/docs/connect/other-apps) |
2425 | **Claude Code cloud environments** and other agent sandboxes | Paste three boxes from the repository's **Settings → Agent setup**. | [Cloud agent environments](/docs/connect/cloud-agents) |
addedapps/diffinery/docs/connect/ci-status-in-claude.md+86−0View file
@@ -1,0 +1,86 @@
1+---
2+title: CI status in Claude
3+description: See a Diffinery pull request and its CI checks live in Claude Code, and have Claude told when CI on its pushed commit passes or fails, cloud sessions included.
4+---
5+
6+Claude Code shows the pull request of the branch you're on, with its CI. On Diffinery you get that in three layers:
7+
8+| Where you run Claude Code | What you see | Set up |
9+| --- | --- | --- |
10+| Terminal, the desktop app's Code tab, VS Code | Claude Code's own pull request badge, and with the `diffinery-ci` plugin a live band of the PR's checks, a checks pane and a status line entry | [Sign gh in](#1-sign-gh-in-to-diffinery), [install the plugin](#2-install-the-diffinery-ci-plugin) |
11+| Cloud sessions on claude.ai/code and project threads | No badge or card (see [what Claude's cloud doesn't show](#what-claudes-cloud-doesnt-show)); with the plugin, Claude is told when CI on a commit it pushed settles, and `/diffinery-ci` answers in text | [Upload the plugin](#in-claudeai-cloud-sessions-and-projects) |
12+| Anywhere | Claude can follow CI itself with `diffinery pr checks --watch`, `gh pr checks` or the MCP tools | [Claude Code](/docs/connect/claude-code) |
13+
14+## 1. Sign gh in to Diffinery
15+
16+Claude Code finds a branch's pull request through gh's sign-in and treats any host other than github.com as GitHub Enterprise, which Diffinery answers. With the [Diffinery CLI](/docs/accounts/cli-sign-in):
17+
18+```sh
19+diffinery auth setup-gh
20+```
21+
22+The terminal footer then shows `PR #12` for the branch's open pull request, and the desktop app's session chip shows the pull request and its CI. Details in [GitHub CLI and Claude Code](/docs/agents/github-cli#claude-codes-pull-request-badge).
23+
24+## 2. Install the diffinery-ci plugin
25+
26+`diffinery-ci` is a Claude Code [mod](https://code.claude.com/docs/en/plugins/mods/overview): a plugin whose code runs inside Claude Code. It needs Claude Code 2.1.287 or later in the terminal (2.1.286 in the desktop app).
27+
28+```sh
29+claude plugin marketplace add https://diffinery.com/codician/diffinery-plugins.git
30+claude plugin install diffinery-ci@diffinery
31+```
32+
33+Inside Claude Code the same is `/plugin marketplace add https://diffinery.com/codician/diffinery-plugins.git`, then `/plugin install diffinery-ci@diffinery`, then `/reload-plugins`. While the plugin repository is open only to Diffinery members, git fetches it with your sign-in: run `diffinery setup` (or `diffinery auth setup-git`) first. In the desktop app, use **+** next to the prompt → **Plugins** → **Add marketplace** with the same URL.
34+
35+The plugin reads your token from `DIFFINERY_TOKEN`, from `diffinery login`, or from gh signed in to Diffinery, and sends it only to Diffinery.
36+
37+### What it shows
38+
39+- **A band above the prompt** for the current branch's pull request: its number and title, the checks (`✗ 1 failed · ● 3 running · ✓ 6`), and the review and merge state (approved, changes requested, merge conflict, auto-merge on). **Checks** opens the pane, **Open** opens the pull request, **Hide** folds the band away until you run `/diffinery-ci`.
40+- **`/diffinery-ci`** opens a pane with every check, how long it took and a link to its log, plus **Refresh**, **Fix CI** (asks Claude to read the failing logs and fix what's clear) and a switch for the note below.
41+- **The status line** reads `PR #12 ✓ 10/10`, `PR #12 ● 4/10` or `PR #12 ✗ 1 failed`, and a toast says when CI passes or fails.
42+
43+It looks again every 15 seconds while checks run, every minute otherwise, and right after Claude pushes, switches branch or runs a `pr` command. In a folder with several clones it follows the one Claude last worked in.
44+
45+### Claude hears when CI settles
46+
47+When Claude pushes the branch or opens its pull request in the session, the plugin follows that exact commit. Once its checks finish, it sends Claude a note: by default only when something failed, naming each failed check with its log link and asking Claude to fix what's clear and say so when the failure isn't caused by the branch. This is what GitHub repositories get from Anthropic's GitHub App as auto-fix.
48+
49+| To | Do |
50+| --- | --- |
51+| Stop the notes for this session | `/diffinery-ci off` (`on` turns them back on), or the switch in the pane |
52+| Also get a note when CI passes, or never | Set the plugin's **wake** option to `all` or `off` (`/config`, or under the plugin in `/plugin`) |
53+
54+The note comes from the plugin, and Claude reads it as one: the transcript names `diffinery-ci` as its sender.
55+
56+### In claude.ai, cloud sessions and projects
57+
58+Claude Code cloud sessions and project threads load the plugins on your claude.ai account. claude.ai can't read the plugin repository, so upload the plugin as a file:
59+
60+1. Download [diffinery-ci.zip](https://diffinery.com/-/claude/diffinery-ci.zip). The [diffinery plugin](https://diffinery.com/-/claude/diffinery.zip) (skill, `/diffinery:pr-status`, session hook, MCP server) is there too.
61+2. On claude.ai, open **Customize → Plugins → Add → Upload plugin** and pick the zip.
62+3. For a project, also add it under **Project settings → Plugins**. New sessions and new threads load it when they start.
63+
64+The session needs a token: give the cloud environment `DIFFINERY_TOKEN` (an [agent's token](/docs/agents/overview), or the repository's [Agent setup](/docs/connect/cloud-agents) token) and allow `diffinery.com` in its network settings.
65+
66+Cloud sessions run a mod's code but don't draw what it draws, so there the plugin works through the note to Claude and the text answer of `/diffinery-ci`. Notes need the session to be running when CI finishes: they don't wake a session whose machine has stopped.
67+
68+## What Claude's cloud doesn't show
69+
70+The floating pull request and CI cards in claude.ai/code cloud sessions and in projects come from Anthropic's GitHub integration, the Claude GitHub App, and GitHub's own webhooks. As of October 2026:
71+
72+- **Projects** work with repositories on github.com only.
73+- **Cloud sessions** reach GitHub Enterprise Server instances that an Owner connected under **Organization settings → Git providers**, on Team and Enterprise plans only. Projects aren't offered on those plans.
74+- Claude Code reports a pull request on any other host (`code_change_published` with provider `github-enterprise`), but the cloud only draws cards for hosts its GitHub integration reaches.
75+
76+So a Diffinery pull request gets no card there today, and nothing a repository or plugin does can add one. That's Anthropic's to change; the plugin covers what it can in the meantime.
77+
78+## Troubleshooting
79+
80+| Problem | What to do |
81+| --- | --- |
82+| The band says `not signed in to Diffinery` | Set `DIFFINERY_TOKEN`, run `diffinery login`, or `diffinery auth setup-gh`. The plugin reads the token again on its next look. |
83+| No band, although the branch has a pull request | The folder's remote isn't on diffinery.com (`git remote -v`), the band is hidden (run `/diffinery-ci`), or mods are off: `/plugin` lists the mods a session loaded. |
84+| `/diffinery-ci` says there's no pull request | The current branch has none yet. Open one with `diffinery pr create` or `gh pr create`. |
85+| Claude wasn't told when CI failed | Notes follow commits the session itself pushed, and stop after `/diffinery-ci off`. A push from outside the session shows in the band only. |
86+| Upload plugin refuses the file | Upload the `.zip` as downloaded, without unpacking or renaming it. |
modifiedapps/diffinery/docs/connect/claude-code.md+2−0View file
@@ -102,6 +102,8 @@
102102
103103 See [GitHub CLI](/docs/agents/github-cli) for what works with `gh`.
104104
105+For a live band of the pull request's checks above the prompt, and a note to Claude when CI on its pushed commit passes or fails, add the `diffinery-ci` plugin: `claude plugin install diffinery-ci@diffinery`. See [CI status in Claude](/docs/connect/ci-status-in-claude).
106+
105107 ## Headless: CI and cloud sessions
106108
107109 Where nobody can approve a browser sign-in, give Claude Code an [agent's token](/docs/agents/overview) in `DIFFINERY_TOKEN` and add a server that sends it:
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -14,6 +14,7 @@
1414 "connect",
1515 "connect/claude",
1616 "connect/claude-code",
17+ "connect/ci-status-in-claude",
1718 "connect/codex",
1819 "connect/cloud-agents",
1920 "connect/other-apps",
modifiedapps/diffinery/scripts/build-docs.mjs+31−2View file
@@ -10,6 +10,7 @@
1010 // and /install-editor.ps1
1111 // - the plugin for JetBrains IDEs (apps/jetbrains), built and checked in at apps/jetbrains/dist (the build here has no
1212 // JDK; scripts/plugin.mjs there refuses a zip built from older sources), served at /-/jetbrains/
13+// - the Claude Code plugins packed by codician/diffinery-plugins, checked in at claude-plugins/, served at /-/claude/
1314 // The REST API and MCP tool references are generated at runtime from the live
1415 // router and tool list (src/docs/reference.ts), so none of the references can drift.
1516 //
@@ -31,6 +32,9 @@
3132 const editorInstall = path.resolve(app, "../vscode/install");
3233 const jetbrainsReader = path.resolve(app, "../jetbrains/scripts/plugin.mjs");
3334 const siteDir = path.join(app, "site");
35+const claudePluginsDir = path.join(app, "claude-plugins");
36+/** The Claude Code plugins Diffinery serves for upload to claude.ai, by name. */
37+const CLAUDE_PLUGIN_NAMES = new Set(["diffinery", "diffinery-ci"]);
3438 const out = path.join(app, "src/docs/content.generated.ts");
3539
3640 /** Pages produced in code rather than from a Markdown file. */
@@ -244,6 +248,29 @@
244248 } catch (e) {
245249 problems.push(e.message);
246250 }
251+// claude-plugins/<name>-<version>.zip: one per plugin, each a zip with the plugin's manifest at its root.
252+const claudePlugins = {};
253+for (const e of await readdir(claudePluginsDir, { withFileTypes: true }).catch(() => [])) {
254+ if (!e.isFile() || e.name === "README.md") continue;
255+ const m = /^([a-z][a-z0-9-]*?)-(\d+\.\d+\.\d+)\.zip$/.exec(e.name);
256+ if (!m || !CLAUDE_PLUGIN_NAMES.has(m[1])) {
257+ problems.push(`claude-plugins/${e.name}: expected <name>-<version>.zip for one of ${[...CLAUDE_PLUGIN_NAMES].join(", ")}`);
258+ continue;
259+ }
260+ if (claudePlugins[m[1]]) {
261+ problems.push(`claude-plugins/ holds two versions of ${m[1]}: keep only the newest`);
262+ continue;
263+ }
264+ const zip = await readFile(path.join(claudePluginsDir, e.name));
265+ // Zip entry names are stored as written: the manifest's path shows in the bytes of a zip that has one.
266+ if (zip.readUInt32LE(0) !== 0x04034b50 || !zip.includes(Buffer.from(".claude-plugin/plugin.json"))) {
267+ problems.push(`claude-plugins/${e.name} isn't a zip with .claude-plugin/plugin.json at its root`);
268+ continue;
269+ }
270+ claudePlugins[m[1]] = { version: m[2], sha256: createHash("sha256").update(zip).digest("hex"), zip: zip.toString("base64") };
271+}
272+for (const name of CLAUDE_PLUGIN_NAMES) if (!claudePlugins[name]) problems.push(`claude-plugins/ has no ${name}-<version>.zip`);
273+
247274 pages["reference/cli"] = {
248275 title: "CLI reference",
249276 description: "Every command and option of the diffinery command-line client, generated from its source.",
@@ -278,7 +305,7 @@
278305 }
279306
280307 const ordered = Object.fromEntries(navSlugs.filter((s) => pages[s]).map((s) => [s, pages[s]]));
281-const src = `// Generated by scripts/build-docs.mjs from docs/, site/, apps/cli, apps/vscode and apps/jetbrains/dist. Do not edit.
308+const src = `// Generated by scripts/build-docs.mjs from docs/, site/, claude-plugins/, apps/cli, apps/vscode and apps/jetbrains/dist. Do not edit.
282309 /* eslint-disable */
283310 export interface DocPage { title: string; description: string; markdown: string }
284311 export interface DocSection { title: string; pages: string[] }
@@ -303,6 +330,8 @@
303330 export const JETBRAINS_PLUGIN_DESCRIPTION = ${JSON.stringify(jetbrains.description)};
304331 export const JETBRAINS_PLUGIN_SHA256 = ${JSON.stringify(createHash("sha256").update(jetbrains.zip).digest("hex"))};
305332 export const JETBRAINS_PLUGIN_ZIP_B64 = ${JSON.stringify(jetbrains.zip.toString("base64"))};
333+/** claude-plugins/: the Claude Code plugins served at /-/claude/<name>.zip, base64. */
334+export const CLAUDE_PLUGINS: Record<string, { version: string; sha256: string; zip: string }> = ${JSON.stringify(claudePlugins)};
306335 export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))};
307336 export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))};
308337 export const CLI_SHA256 = ${JSON.stringify(createHash("sha256").update(cli.bin).digest("hex"))};
@@ -310,4 +339,4 @@
310339 `;
311340 await mkdir(path.dirname(out), { recursive: true });
312341 await writeFile(out, src);
313-console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), ${Object.keys(site).length} site pages, ${Object.keys(images).length} images, CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB), editor extension ${editor.version} (${Math.round(editor.vsix.length / 1024)} KiB), JetBrains plugin ${jetbrains.version} (${Math.round(jetbrains.zip.length / 1024)} KiB)`);
342+console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), ${Object.keys(site).length} site pages, ${Object.keys(images).length} images, CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB), editor extension ${editor.version} (${Math.round(editor.vsix.length / 1024)} KiB), JetBrains plugin ${jetbrains.version} (${Math.round(jetbrains.zip.length / 1024)} KiB), Claude Code plugins ${Object.entries(claudePlugins).map(([n, p]) => `${n} ${p.version}`).join(", ")}`);
modifiedapps/diffinery/src/docs/web.tsx+22−2View file
@@ -9,7 +9,8 @@
99 * /-/vscode/diffinery.vsix, with /-/vscode/version.json (which it updates itself from) and its
1010 * install scripts at /install-editor and /install-editor.ps1; and the plugin for JetBrains IDEs (apps/jetbrains) at
1111 * /-/jetbrains/diffinery-jetbrains.zip, with /-/jetbrains/updatePlugins.xml, the custom plugin repository the IDEs
12- * install and update it from.
12+ * install and update it from; and the Claude Code plugins (claude-plugins/) at /-/claude/<name>.zip, for claude.ai's
13+ * Upload plugin.
1314 */
1415 import { raw } from "hono/html";
1516 import type { AppContext } from "../auth/principal.js";
@@ -20,7 +21,7 @@
2021 import { fenceHighlighter } from "../web/highlight.js";
2122 import { ICONS } from "../web/assets.js";
2223 import { render, type WebApp } from "../web/ui.js";
23-import { AGENT_SETUP_SH, CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_SHA256, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js";
24+import { AGENT_SETUP_SH, CLAUDE_PLUGINS, CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_SHA256, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js";
2425 import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js";
2526 import { openApiDocument } from "./reference.js";
2627
@@ -276,6 +277,25 @@
276277 c.body(jetbrainsRepository(origin(c), pluginName), 200, { ...PUBLIC, "content-type": "application/xml; charset=utf-8" }),
277278 );
278279
280+ // The Claude Code plugins, for claude.ai's Customize → Plugins → Add → Upload plugin, which can't read the plugin
281+ // repository. /-/claude/<name>.zip is always the current version; the versioned name stays the same bytes.
282+ const claudeZips = new Map<string, Uint8Array<ArrayBuffer>>();
283+ web.get("/-/claude/:file", (c) => {
284+ const file = c.req.param("file");
285+ const m = /^([a-z][a-z0-9-]*?)(?:-(\d+\.\d+\.\d+))?\.zip$/.exec(file);
286+ const plugin = m && Object.hasOwn(CLAUDE_PLUGINS, m[1]!) ? CLAUDE_PLUGINS[m[1]!]! : null;
287+ if (!m || !plugin || (m[2] !== undefined && m[2] !== plugin.version)) return c.text("Not found", 404);
288+ let bytes = claudeZips.get(m[1]!);
289+ if (!bytes) claudeZips.set(m[1]!, (bytes = Uint8Array.from(atob(plugin.zip), (ch) => ch.charCodeAt(0))));
290+ return c.body(bytes, 200, {
291+ ...PUBLIC,
292+ "content-type": "application/zip",
293+ "content-disposition": `attachment; filename="${m[1]}-${plugin.version}.zip"`,
294+ "x-diffinery-plugin-version": plugin.version,
295+ "x-diffinery-plugin-sha256": plugin.sha256,
296+ });
297+ });
298+
279299 // Pictures for the docs pages (docs/images/), decoded once per isolate.
280300 const images = new Map<string, Uint8Array<ArrayBuffer>>();
281301 web.get("/docs/images/:file", (c) => {
modifiedapps/diffinery/src/repo/actions.ts+2−1View file
@@ -2115,9 +2115,10 @@
21152115 const run = this.runRow(runId);
21162116 if (run.status === "completed") return;
21172117 this.sql.exec("UPDATE runs SET conclusion = 'cancelled', updated_at = ? WHERE id = ?", now(), run.id);
2118+ // One cancel, one deadline: the jobs it lets run their after-cancel steps all end together.
2119+ const at = Date.now();
21182120 for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status != 'completed'", run.id).toArray()) {
21192121 if (graceful && !j.cancel_at && j.status === "in_progress" && !j.labels && j.container && !j.container.startsWith("self-hosted:")) {
2120- const at = Date.now();
21212122 this.sql.exec("UPDATE jobs SET cancel_at = ?, cancel_reason = ? WHERE id = ?", at, reason, j.id);
21222123 await this.stopContainer(j, true);
21232124 this.repo.alarmBy(at + CANCEL_STOP_MS);
modifiedapps/diffinery/test/unit/docs.test.ts+19−0View file
@@ -250,6 +250,25 @@
250250 }
251251 });
252252
253+ it("serves the Claude Code plugins for claude.ai's Upload plugin", async () => {
254+ for (const name of ["diffinery", "diffinery-ci"]) {
255+ const latest = await get(`/-/claude/${name}.zip`);
256+ expect(latest.status, name).toBe(200);
257+ expect(latest.headers.get("content-type")).toBe("application/zip");
258+ const version = latest.headers.get("x-diffinery-plugin-version")!;
259+ expect(version).toMatch(/^\d+\.\d+\.\d+$/);
260+ expect(latest.headers.get("content-disposition")).toBe(`attachment; filename="${name}-${version}.zip"`);
261+ const bytes = new Uint8Array(await latest.arrayBuffer());
262+ expect(bytes.slice(0, 4)).toEqual(new Uint8Array([0x50, 0x4b, 0x03, 0x04]));
263+ const sha = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (b) => b.toString(16).padStart(2, "0")).join("");
264+ expect(latest.headers.get("x-diffinery-plugin-sha256")).toBe(sha);
265+ const pinned = await get(`/-/claude/${name}-${version}.zip`);
266+ expect(new Uint8Array(await pinned.arrayBuffer())).toEqual(bytes);
267+ expect((await get(`/-/claude/${name}-0.0.0.zip`)).status).toBe(404);
268+ }
269+ for (const path of ["/-/claude/other.zip", "/-/claude/diffinery", "/-/claude/constructor.zip"]) expect((await get(path)).status, path).toBe(404);
270+ });
271+
253272 it("serves the screenshots in docs/images", async () => {
254273 const res = await get("/docs/images/connected-apps.webp");
255274 expect(res.status).toBe(200);