Docs: CI status in Claude, and the Claude Code plugins at /-/claude/ for claude.ai's Upload plugin
A guide to seeing Diffinery CI in Claude Code (gh badge, the diffinery-ci plugin's live band and pane, the note to Claude when CI settles, uploading to claude.ai for cloud sessions and projects) and what Claude's cloud cards can't show for non-github.com hosts. The plugin zips packed by codician/diffinery-plugins are checked in at claude-plugins/ and served at /-/claude/<name>.zip. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEy3wQ4DbP2VQUmrcNhTKn
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)Failed
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
11 files changed+176−5
Files (11)
- apps/diffinery/claude-plugins/README.md+13−0
- apps/diffinery/claude-plugins/diffinery-0.1.1.zip+0−0
- apps/diffinery/claude-plugins/diffinery-ci-0.1.0.zip+0−0
- apps/diffinery/docs/agents/github-cli.md+1−1
- apps/diffinery/docs/connect.md+1−0
- apps/diffinery/docs/connect/ci-status-in-claude.md+86−0
- apps/diffinery/docs/connect/claude-code.md+2−0
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/scripts/build-docs.mjs+31−2
- apps/diffinery/src/docs/web.tsx+22−2
- apps/diffinery/test/unit/docs.test.ts+19−0
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 @@ | ||
| 60 | 60 | |
| 61 | 61 | 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. |
| 62 | 62 | |
| 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). | |
| 64 | 64 | |
| 65 | 65 | ## Troubleshooting |
| 66 | 66 |
modifiedapps/diffinery/docs/connect.md+1−0View file
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | | --- | --- | --- | |
| 20 | 20 | | **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) | |
| 21 | 21 | | **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) | | |
| 22 | 23 | | **Codex** (OpenAI's coding agent) | Install the Diffinery plugin, then run `codex mcp login diffinery`. | [Codex](/docs/connect/codex) | |
| 23 | 24 | | **Cursor, VS Code or another MCP app** | Add the URL as a remote (HTTP) MCP server. | [Other apps](/docs/connect/other-apps) | |
| 24 | 25 | | **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 @@ | ||
| 102 | 102 | |
| 103 | 103 | See [GitHub CLI](/docs/agents/github-cli) for what works with `gh`. |
| 104 | 104 | |
| 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 | + | |
| 105 | 107 | ## Headless: CI and cloud sessions |
| 106 | 108 | |
| 107 | 109 | 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/scripts/build-docs.mjs+31−2View file
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | // and /install-editor.ps1 |
| 11 | 11 | // - the plugin for JetBrains IDEs (apps/jetbrains), built and checked in at apps/jetbrains/dist (the build here has no |
| 12 | 12 | // 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/ | |
| 13 | 14 | // The REST API and MCP tool references are generated at runtime from the live |
| 14 | 15 | // router and tool list (src/docs/reference.ts), so none of the references can drift. |
| 15 | 16 | // |
| @@ -31,6 +32,9 @@ | ||
| 31 | 32 | const editorInstall = path.resolve(app, "../vscode/install"); |
| 32 | 33 | const jetbrainsReader = path.resolve(app, "../jetbrains/scripts/plugin.mjs"); |
| 33 | 34 | 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"]); | |
| 34 | 38 | const out = path.join(app, "src/docs/content.generated.ts"); |
| 35 | 39 | |
| 36 | 40 | /** Pages produced in code rather than from a Markdown file. */ |
| @@ -244,6 +248,29 @@ | ||
| 244 | 248 | } catch (e) { |
| 245 | 249 | problems.push(e.message); |
| 246 | 250 | } |
| 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 | + | |
| 247 | 274 | pages["reference/cli"] = { |
| 248 | 275 | title: "CLI reference", |
| 249 | 276 | description: "Every command and option of the diffinery command-line client, generated from its source.", |
| @@ -278,7 +305,7 @@ | ||
| 278 | 305 | } |
| 279 | 306 | |
| 280 | 307 | 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. | |
| 282 | 309 | /* eslint-disable */ |
| 283 | 310 | export interface DocPage { title: string; description: string; markdown: string } |
| 284 | 311 | export interface DocSection { title: string; pages: string[] } |
| @@ -303,6 +330,8 @@ | ||
| 303 | 330 | export const JETBRAINS_PLUGIN_DESCRIPTION = ${JSON.stringify(jetbrains.description)}; |
| 304 | 331 | export const JETBRAINS_PLUGIN_SHA256 = ${JSON.stringify(createHash("sha256").update(jetbrains.zip).digest("hex"))}; |
| 305 | 332 | 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)}; | |
| 306 | 335 | export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))}; |
| 307 | 336 | export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))}; |
| 308 | 337 | export const CLI_SHA256 = ${JSON.stringify(createHash("sha256").update(cli.bin).digest("hex"))}; |
| @@ -310,4 +339,4 @@ | ||
| 310 | 339 | `; |
| 311 | 340 | await mkdir(path.dirname(out), { recursive: true }); |
| 312 | 341 | 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 @@ | ||
| 9 | 9 | * /-/vscode/diffinery.vsix, with /-/vscode/version.json (which it updates itself from) and its |
| 10 | 10 | * install scripts at /install-editor and /install-editor.ps1; and the plugin for JetBrains IDEs (apps/jetbrains) at |
| 11 | 11 | * /-/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. | |
| 13 | 14 | */ |
| 14 | 15 | import { raw } from "hono/html"; |
| 15 | 16 | import type { AppContext } from "../auth/principal.js"; |
| @@ -20,7 +21,7 @@ | ||
| 20 | 21 | import { fenceHighlighter } from "../web/highlight.js"; |
| 21 | 22 | import { ICONS } from "../web/assets.js"; |
| 22 | 23 | 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"; | |
| 24 | 25 | import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js"; |
| 25 | 26 | import { openApiDocument } from "./reference.js"; |
| 26 | 27 | |
| @@ -276,6 +277,25 @@ | ||
| 276 | 277 | c.body(jetbrainsRepository(origin(c), pluginName), 200, { ...PUBLIC, "content-type": "application/xml; charset=utf-8" }), |
| 277 | 278 | ); |
| 278 | 279 | |
| 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 | + | |
| 279 | 299 | // Pictures for the docs pages (docs/images/), decoded once per isolate. |
| 280 | 300 | const images = new Map<string, Uint8Array<ArrayBuffer>>(); |
| 281 | 301 | web.get("/docs/images/:file", (c) => { |
modifiedapps/diffinery/test/unit/docs.test.ts+19−0View file
| @@ -250,6 +250,25 @@ | ||
| 250 | 250 | } |
| 251 | 251 | }); |
| 252 | 252 | |
| 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 | + | |
| 253 | 272 | it("serves the screenshots in docs/images", async () => { |
| 254 | 273 | const res = await get("/docs/images/connected-apps.webp"); |
| 255 | 274 | expect(res.status).toBe(200); |