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.

Compare changes

Choose two branches to see what changed or to start a new pull request.

base:←compare:3 commits ahead, 10 behind main
65 files changed+1216−133
Files (65)
modified.claude-plugin/marketplace.json+6−6View file
@@ -2,21 +2,21 @@
22 "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
33 "name": "diffinery",
44 "owner": {
5- "name": "Codician",
5+ "name": "Codician Team",
66 "url": "https://diffinery.com"
77 },
88 "metadata": {
99 "description": "Plugins that make coding agents work with Diffinery, the git forge built for AI agents.",
10- "version": "0.2.3"
10+ "version": "0.2.4"
1111 },
1212 "plugins": [
1313 {
1414 "name": "diffinery",
1515 "source": "./plugins/diffinery",
1616 "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.",
17- "version": "0.1.3",
17+ "version": "0.1.4",
1818 "author": {
19- "name": "Codician"
19+ "name": "Codician Team"
2020 },
2121 "homepage": "https://diffinery.com/docs/connect/claude-code",
2222 "repository": "https://diffinery.com/codician/diffinery",
@@ -35,9 +35,9 @@
3535 "name": "diffinery-ci",
3636 "source": "./plugins/diffinery-ci",
3737 "description": "Live CI for Diffinery pull requests: the branch's PR and its checks above the prompt and in the status line, a checks pane, and a note to Claude when CI on a commit it pushed settles (also in cloud sessions).",
38- "version": "0.2.3",
38+ "version": "0.2.4",
3939 "author": {
40- "name": "Codician"
40+ "name": "Codician Team"
4141 },
4242 "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude",
4343 "repository": "https://diffinery.com/codician/diffinery",
modifiedapps/cli/src/cli.ts+2−1View file
@@ -87,7 +87,8 @@
8787 if (r.code === 0) {
8888 const parsed = parseRemote(r.stdout, host);
8989 if (parsed) return parsed;
90- throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}; pass -R OWNER/NAME`);
90+ // A remote on another host is often another Diffinery instance (a self-hosted one): say how to switch.
91+ throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}, the Diffinery this CLI uses; pass -R OWNER/NAME, or if it's another Diffinery, sign in there with diffinery login --url https://HOST (or set DIFFINERY_URL)`);
9192 }
9293 throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository");
9394 }
modifiedapps/cli/src/commands/auth.ts+12−1View file
@@ -8,6 +8,7 @@
88 import { json } from "../output.js";
99 import { agentSetup, ghEnvToken, setupRepo } from "./agent-setup.js";
1010 import { setupClaudePlugins } from "./claude-plugins.js";
11+import { setupCodexMcp } from "./codex.js";
1112
1213 interface User {
1314 login: string;
@@ -257,7 +258,10 @@
257258 sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Where Claude Code is
258259 installed, it also adds Claude Code's Diffinery plugins (diffinery and diffinery-ci): they're saved in
259260 ~/.claude/diffinery-plugins and named in ~/.claude/settings.json, so every new Claude Code session loads
260-them. Skip that with --no-claude-plugins (or DIFFINERY_NO_CLAUDE_PLUGINS=1). Last, on a terminal, it asks
261+them, with their Diffinery URL setting pointed at this server. Skip that with --no-claude-plugins (or
262+DIFFINERY_NO_CLAUDE_PLUGINS=1). For a server other than diffinery.com (--url, a self-hosted Diffinery), it
263+also points Codex's diffinery MCP server there when Codex is installed (codex mcp add; skip with
264+DIFFINERY_NO_CODEX=1). Last, on a terminal, it asks
261265 whether to turn on auto-merge for the pull requests you open (--auto-merge says yes without asking,
262266 --no-auto-merge skips the question). Safe to run again; it brings the plugins up to date.
263267
@@ -333,6 +337,13 @@
333337 }
334338 }
335339 try {
340+ const p = await setupCodexMcp(ctx.io.env, url);
341+ if (p?.ok) ctx.out(`${ctx.c.green("✓")} Codex: ${p.message}`);
342+ else if (p) ctx.err(`${ctx.c.yellow("warning:")} Codex: ${p.message}`);
343+ } catch (e) {
344+ ctx.err(`${ctx.c.yellow("warning:")} Codex: ${(e as Error).message}`);
345+ }
346+ try {
336347 await offerAutoMerge(new Ctx(ctx.io, {}), url, { yes: o["auto-merge"] === true, skip: o["no-auto-merge"] === true });
337348 } catch (e) {
338349 ctx.err(`${ctx.c.yellow("warning:")} auto-merge: ${(e as Error).message}`);
modifiedapps/cli/src/commands/claude-plugins.ts+35−5View file
@@ -12,9 +12,14 @@
1212 * they're saved in Claude Code's settings directory and named in the `env` block of its user settings as
1313 * CLAUDE_CODE_PLUGIN_DIRS, which Claude Code reads at the start of every session: the terminal, the
1414 * desktop app, VS Code, and cloud sessions and project threads, whose setup script runs this. A cloud
15- * environment keeps them in its snapshot. Running it again brings them up to date.
15+ * environment keeps them in its snapshot. Running it again brings them up to date. Their "Diffinery URL"
16+ * setting is set to the server this runs against (pluginConfigs in the same settings file), for these
17+ * copies and for copies installed from the Diffinery marketplace alike.
1618 */
1719
20+/** What the plugins' Diffinery URL setting defaults to; `pluginConfigs` is written only to change it. */
21+const PLUGIN_DEFAULT_URL = "https://diffinery.com";
22+
1823 export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const;
1924
2025 /** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */
@@ -46,6 +51,23 @@
4651 return [...kept, ...ours].join(path.delimiter);
4752 }
4853
54+/**
55+ * `pluginConfigs` with each plugin id's `url` option set to `url`, other options and plugins kept; null when
56+ * nothing would change. A plugin with no saved URL is left alone for diffinery.com, its default.
57+ */
58+export function withUrlOption(current: unknown, ids: string[], url: string): Record<string, unknown> | null {
59+ const all = current && typeof current === "object" && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {};
60+ let changed = false;
61+ for (const id of ids) {
62+ const entry = all[id] && typeof all[id] === "object" && !Array.isArray(all[id]) ? (all[id] as Record<string, unknown>) : {};
63+ const options = entry.options && typeof entry.options === "object" && !Array.isArray(entry.options) ? (entry.options as Record<string, unknown>) : {};
64+ if (options.url === url || (options.url === undefined && url === PLUGIN_DEFAULT_URL)) continue;
65+ all[id] = { ...entry, options: { ...options, url } };
66+ changed = true;
67+ }
68+ return changed ? all : null;
69+}
70+
4971 export interface PluginsResult {
5072 ok: boolean;
5173 message: string;
@@ -101,15 +123,23 @@
101123 got.push(`${name} ${res.headers.get("x-diffinery-plugin-version") ?? ""}`.trim());
102124 }
103125
104- if (files.length) {
105- const block = settings.env && typeof settings.env === "object" && !Array.isArray(settings.env) ? (settings.env as Record<string, unknown>) : {};
106- const current = typeof block.CLAUDE_CODE_PLUGIN_DIRS === "string" ? block.CLAUDE_CODE_PLUGIN_DIRS : undefined;
107- const next = { ...settings, env: { ...block, CLAUDE_CODE_PLUGIN_DIRS: withOurs(current, files) } };
126+ // Plugins loaded from a directory or zip are <name>@inline; marketplace ones keep their enabledPlugins id.
127+ const ids = [...wanted.map((n) => `${n}@inline`), ...enabled.filter((k) => fromMarketplace.some((n) => k.startsWith(`${n}@`)))];
128+ const configs = withUrlOption(settings.pluginConfigs, ids, url);
129+ if (files.length || configs) {
130+ let next = settings;
131+ if (files.length) {
132+ const block = settings.env && typeof settings.env === "object" && !Array.isArray(settings.env) ? (settings.env as Record<string, unknown>) : {};
133+ const current = typeof block.CLAUDE_CODE_PLUGIN_DIRS === "string" ? block.CLAUDE_CODE_PLUGIN_DIRS : undefined;
134+ next = { ...next, env: { ...block, CLAUDE_CODE_PLUGIN_DIRS: withOurs(current, files) } };
135+ }
136+ if (configs) next = { ...next, pluginConfigs: configs };
108137 await writeFile(`${settingsFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
109138 await rename(`${settingsFile}.tmp`, settingsFile);
110139 }
111140 const parts = [];
112141 if (got.length) parts.push(`${got.join(" and ")} load in new Claude Code sessions (named in ${settingsFile})`);
113142 if (fromMarketplace.length) parts.push(`${fromMarketplace.join(" and ")} already installed from the Diffinery marketplace`);
143+ if (configs) parts.push(`their Diffinery URL is ${url}`);
114144 return { ok: true, message: parts.join("; ") };
115145 }
addedapps/cli/src/commands/codex.ts+71−0View file
@@ -1,0 +1,71 @@
1+import { spawn } from "node:child_process";
2+import { appendFile, mkdir, readFile } from "node:fs/promises";
3+import { homedir } from "node:os";
4+import path from "node:path";
5+
6+/**
7+ * Codex's `diffinery` MCP server, pointed at a self-hosted Diffinery by `diffinery setup`. The Codex plugin's
8+ * server is https://diffinery.com/mcp (Codex has no plugin settings to change it), and a server of the same
9+ * name in Codex's own config replaces it, so for another instance this adds that server to config.toml.
10+ * Not with `codex mcp add`, which starts a browser sign-in for a server that offers OAuth and waits for it.
11+ * Nothing happens for diffinery.com, or where Codex isn't installed.
12+ */
13+
14+const DEFAULT_URL = "https://diffinery.com";
15+
16+function codex(env: NodeJS.ProcessEnv, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
17+ return new Promise((resolve) => {
18+ let child;
19+ try {
20+ child = spawn("codex", args, { env, stdio: ["ignore", "pipe", "pipe"] });
21+ } catch (e) {
22+ return resolve({ code: 127, stdout: "", stderr: (e as Error).message });
23+ }
24+ let stdout = "";
25+ let stderr = "";
26+ child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
27+ child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
28+ child.on("error", (e) => resolve({ code: 127, stdout, stderr: e.message }));
29+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
30+ });
31+}
32+
33+export interface CodexResult {
34+ ok: boolean;
35+ message: string;
36+}
37+
38+/** Null when there's nothing to do: diffinery.com, DIFFINERY_NO_CODEX, or no `codex` on PATH. */
39+export async function setupCodexMcp(env: NodeJS.ProcessEnv, url: string): Promise<CodexResult | null> {
40+ if (url === DEFAULT_URL || env.DIFFINERY_NO_CODEX) return null;
41+ const want = `${url}/mcp`;
42+ // The server Codex uses now: the user's own, else the plugin's, else none (exit status 1).
43+ const got = await codex(env, ["mcp", "get", "diffinery", "--json"]);
44+ if (got.code === 127) return null;
45+ let bearer: string | null = null;
46+ if (got.code === 0) {
47+ try {
48+ const t = (JSON.parse(got.stdout) as { transport?: { url?: unknown; bearer_token_env_var?: unknown } }).transport ?? {};
49+ if (t.url === want) return { ok: true, message: `the diffinery MCP server is ${want}` };
50+ // A headless setup that sends DIFFINERY_TOKEN keeps doing so.
51+ if (typeof t.bearer_token_env_var === "string") bearer = t.bearer_token_env_var;
52+ } catch {
53+ // An answer we can't read: add the server all the same.
54+ }
55+ }
56+ const home = env.CODEX_HOME && path.isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : path.join(env.HOME || homedir(), ".codex");
57+ const file = path.join(home, "config.toml");
58+ const text = await readFile(file, "utf8").catch(() => "");
59+ // A server of the user's own goes first (codex removes it however it's written); the plugin's stays.
60+ if (/^\s*\[\s*mcp_servers\s*\.\s*"?diffinery"?\s*\]/m.test(text) || /^\s*mcp_servers\s*\.\s*"?diffinery"?\s*[.=]/m.test(text)) {
61+ const rm = await codex(env, ["mcp", "remove", "diffinery"]);
62+ if (rm.code !== 0) return { ok: false, message: `codex mcp remove diffinery failed: ${(rm.stderr.trim().split("\n")[0] || `exit status ${rm.code}`).slice(0, 200)}` };
63+ }
64+ const now = await readFile(file, "utf8").catch(() => "");
65+ const table = `[mcp_servers.diffinery]\nurl = ${JSON.stringify(want)}\n${bearer ? `bearer_token_env_var = ${JSON.stringify(bearer)}\n` : ""}`;
66+ await mkdir(home, { recursive: true });
67+ await appendFile(file, `${now && !now.endsWith("\n") ? "\n" : ""}${now ? "\n" : ""}${table}`);
68+ const check = await codex(env, ["mcp", "get", "diffinery", "--json"]);
69+ if (!check.stdout.includes(JSON.stringify(want).slice(1, -1))) return { ok: false, message: `Codex doesn't read the diffinery server added to ${file}: check that file` };
70+ return { ok: true, message: `the diffinery MCP server is now ${want} (sign in with: codex mcp login diffinery)` };
71+}
modifiedapps/cli/test/helpers.ts+2−1View file
@@ -129,8 +129,9 @@
129129 GIT_AUTHOR_EMAIL: "test@example.com",
130130 GIT_COMMITTER_NAME: "Test",
131131 GIT_COMMITTER_EMAIL: "test@example.com",
132- // This machine's Claude Code stays out of it; the tests that want it turn it back on.
132+ // This machine's Claude Code and Codex stay out of it; the tests that want them turn them back on.
133133 DIFFINERY_NO_CLAUDE_PLUGINS: "1",
134+ DIFFINERY_NO_CODEX: "1",
134135 ...extra,
135136 };
136137 }
modifiedapps/cli/test/setup.test.ts+92−0View file
@@ -3,6 +3,7 @@
33 import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
44 import path from "node:path";
55 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+import { withUrlOption } from "../src/commands/claude-plugins.js";
67 import { configPath, writeConfig } from "../src/config.js";
78 import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
89
@@ -303,6 +304,22 @@
303304 expect(dirsOf(market)).toEqual([path.join(market.HOME!, ".claude", "diffinery-plugins", "diffinery.zip")]);
304305 });
305306
307+ it("points the plugins' Diffinery URL setting at the server, for its own copies and marketplace ones", async () => {
308+ const env = await withClaude({ enabledPlugins: { "diffinery-ci@diffinery": true }, pluginConfigs: { "diffinery-ci@diffinery": { options: { notes: "all", url: "https://old.example" } }, "other@x": { options: { a: 1 } } } });
309+ const r = await run(["setup"], { env });
310+ expect(r.code).toBe(0);
311+ expect(r.stdout).toContain(`their Diffinery URL is ${srv.url}`);
312+ expect(settingsOf(env).pluginConfigs).toEqual({
313+ "diffinery@inline": { options: { url: srv.url } },
314+ "diffinery-ci@diffinery": { options: { notes: "all", url: srv.url } },
315+ "other@x": { options: { a: 1 } },
316+ });
317+ // diffinery.com is the setting's default: nothing to write unless a URL was saved before.
318+ expect(withUrlOption(undefined, ["diffinery@inline"], "https://diffinery.com")).toBe(null);
319+ expect(withUrlOption({ "diffinery@inline": { options: { url: srv.url } } }, ["diffinery@inline"], "https://diffinery.com")).toEqual({ "diffinery@inline": { options: { url: "https://diffinery.com" } } });
320+ expect(withUrlOption({ "diffinery@inline": { options: { url: srv.url } } }, ["diffinery@inline"], srv.url)).toBe(null);
321+ });
322+
306323 it("leaves settings it can't read alone, and refuses a download that doesn't match its checksum", async () => {
307324 const broken = await withClaude("{ not json");
308325 const r = await run(["setup"], { env: broken });
@@ -318,3 +335,78 @@
318335 expect(existsSync(path.join(bad.HOME!, ".claude", "settings.json"))).toBe(false);
319336 });
320337 });
338+
339+describe("Codex in diffinery setup", () => {
340+ let srv: FakeServer;
341+ beforeEach(async () => {
342+ srv = await fakeServer();
343+ srv.on("GET", "/api/v3/user", { body: { login: "alice", type: "User" } });
344+ });
345+ afterEach(() => srv.close());
346+
347+ /**
348+ * A codex that records its calls. `mcp get` answers `server` (none: exit status 1, as codex does) until
349+ * config.toml names a URL, then that URL; `mcp remove` drops the config's diffinery table.
350+ */
351+ function fakeCodex(server: unknown): { dir: string; calls: () => string[] } {
352+ const dir = tempDir("diffinery-fake-codex-");
353+ const log = path.join(dir, "calls");
354+ writeFileSync(path.join(dir, "answer.json"), JSON.stringify(server ?? null));
355+ writeFileSync(
356+ path.join(dir, "codex"),
357+ [
358+ "#!/bin/sh",
359+ `printf '%s\\n' "$*" >> "${log}"`,
360+ 'cfg="$CODEX_HOME/config.toml"',
361+ 'if [ "$1 $2" = "mcp remove" ]; then sed -i.bak "/mcp_servers.diffinery/,\\$d" "$cfg"; exit 0; fi',
362+ 'if [ "$1 $2" = "mcp get" ]; then',
363+ ' u=$(sed -n \'s/^url = "\\(.*\\)"$/\\1/p\' "$cfg" 2>/dev/null | tail -n 1)',
364+ ' b=$(sed -n \'s/^bearer_token_env_var = "\\(.*\\)"$/\\1/p\' "$cfg" 2>/dev/null | tail -n 1)',
365+ ' if [ -n "$u" ]; then printf \'{"transport":{"url":"%s","bearer_token_env_var":"%s"}}\' "$u" "$b"; exit 0; fi',
366+ server ? ` cat "${dir}/answer.json"` : " echo 'Error: No MCP server named' >&2; exit 1",
367+ "fi",
368+ "exit 0",
369+ "",
370+ ].join("\n"),
371+ );
372+ chmodSync(path.join(dir, "codex"), 0o755);
373+ return { dir, calls: () => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []) };
374+ }
375+ const machine = async (codexDir: string, config?: string) => {
376+ const env = isolatedEnv({ DIFFINERY_NO_CODEX: "", PATH: `${codexDir}${path.delimiter}${process.env.PATH}` });
377+ env.CODEX_HOME = path.join(env.HOME!, ".codex");
378+ if (config !== undefined) {
379+ mkdirSync(env.CODEX_HOME);
380+ writeFileSync(path.join(env.CODEX_HOME, "config.toml"), config);
381+ }
382+ await writeConfig(env, { url: srv.url, token: "dfy_good" });
383+ return env;
384+ };
385+ const tomlOf = (env: NodeJS.ProcessEnv) => readFileSync(path.join(env.CODEX_HOME!, "config.toml"), "utf8");
386+
387+ it("points Codex's diffinery MCP server at a self-hosted server, keeping a headless token setting", async () => {
388+ const plugin = fakeCodex({ name: "diffinery", transport: { type: "streamable_http", url: "https://diffinery.com/mcp", bearer_token_env_var: null } });
389+ const env = await machine(plugin.dir, 'model = "o3"');
390+ const r = await run(["setup", "--no-auto-merge"], { env });
391+ expect(r.code).toBe(0);
392+ expect(r.stdout).toContain(`✓ Codex: the diffinery MCP server is now ${srv.url}/mcp`);
393+ expect(plugin.calls()).toEqual(["mcp get diffinery --json", "mcp get diffinery --json"]);
394+ expect(tomlOf(env)).toBe(`model = "o3"\n\n[mcp_servers.diffinery]\nurl = "${srv.url}/mcp"\n`);
395+ // Again: nothing to change.
396+ expect((await run(["setup", "--no-auto-merge"], { env })).stdout).toContain(`✓ Codex: the diffinery MCP server is ${srv.url}/mcp`);
397+
398+ // A server of the user's own, sending DIFFINERY_TOKEN: replaced, and still sending it.
399+ const headless = fakeCodex({ name: "diffinery", transport: { url: "https://old.example/mcp", bearer_token_env_var: "DIFFINERY_TOKEN" } });
400+ const henv = await machine(headless.dir, '[mcp_servers.diffinery]\nurl = "https://old.example/mcp"\nbearer_token_env_var = "DIFFINERY_TOKEN"\n');
401+ await run(["setup", "--no-auto-merge"], { env: henv });
402+ expect(headless.calls()).toContain("mcp remove diffinery");
403+ expect(tomlOf(henv)).toBe(`[mcp_servers.diffinery]\nurl = "${srv.url}/mcp"\nbearer_token_env_var = "DIFFINERY_TOKEN"\n`);
404+ });
405+
406+ it("leaves Codex alone for diffinery.com", async () => {
407+ const codex = fakeCodex(null);
408+ const { setupCodexMcp } = await import("../src/commands/codex.js");
409+ expect(await setupCodexMcp({ ...(await machine(codex.dir)) }, "https://diffinery.com")).toBe(null);
410+ expect(codex.calls()).toEqual([]);
411+ });
412+});
deletedapps/diffinery/claude-plugins/diffinery-0.1.3.zip+0−0
Binary file not shown.
addedapps/diffinery/claude-plugins/diffinery-0.1.4.zip+0−0View file
Binary file not shown.
deletedapps/diffinery/claude-plugins/diffinery-ci-0.2.3.zip+0−0
Binary file not shown.
addedapps/diffinery/claude-plugins/diffinery-ci-0.2.4.zip+0−0View file
Binary file not shown.
modifiedapps/diffinery/docs/agents/mcp.md+1−1View file
@@ -9,7 +9,7 @@
99
1010 | Property | Value |
1111 | --- | --- |
12-| URL | `https://diffinery.com/mcp` |
12+| URL | `https://diffinery.com/mcp`; on a [self-hosted Diffinery](/docs/self-hosting), `/mcp` on your instance, such as `https://git.example.com/mcp` |
1313 | Transport | Streamable HTTP. Send JSON-RPC 2.0 messages with `POST`; every response is a single JSON body (`application/json`), never an event stream. |
1414 | Sessions | None. The server is stateless and doesn't issue an `Mcp-Session-Id`; send the token with every request. |
1515 | Batches | A JSON array of requests gets a JSON array of responses. |
modifiedapps/diffinery/docs/connect.md+2−0View file
@@ -9,6 +9,8 @@
99 https://diffinery.com/mcp
1010 ```
1111
12+On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`.
13+
1214 There is no token to copy. The app sends you to Diffinery in your browser, you choose whom it acts as and what it may do, and it's connected. You can see and disconnect every app under **Settings → Access tokens → Connected apps**, which also shows this URL with a copy button.
1315
1416 ![Settings → Access tokens on Diffinery: the MCP server URL with a Copy button, links to the guides for Claude, Claude Code, Codex and other apps, and two connected apps, Claude Code and Codex, each acting as the agent ada-bot with write access.](/docs/images/connected-apps.webp)
modifiedapps/diffinery/docs/connect/ci-status-in-claude.md+5−2View file
@@ -27,7 +27,7 @@
2727 ```text
2828 ✓ git will use diffinery for https://diffinery.com
2929 ✓ gh is signed in to diffinery.com
30- ✓ Claude Code plugins: diffinery 0.1.3 and diffinery-ci 0.2.3 load in new Claude Code sessions (named in ~/.claude/settings.json)
30+ ✓ Claude Code plugins: diffinery 0.1.4 and diffinery-ci 0.2.4 load in new Claude Code sessions (named in ~/.claude/settings.json)
3131 ```
3232
3333 3. Start a new Claude Code session in a clone of a Diffinery repository (or run `/reload-plugins` in an open one). On a branch with a pull request, the band appears above the prompt.
@@ -37,6 +37,9 @@
3737 > [!NOTE]
3838 > `diffinery setup` saves the plugins in `~/.claude/diffinery-plugins/` and names them in `CLAUDE_CODE_PLUGIN_DIRS`, in the `env` block of `~/.claude/settings.json`, which Claude Code reads at the start of every session in the terminal, the desktop app and the editors. It keeps everything else in that file. To skip it, run `diffinery setup --no-claude-plugins`. A plugin you already installed from the Diffinery marketplace (`claude plugin install diffinery-ci@diffinery`) is left as it is.
3939
40+> [!NOTE]
41+> On a [self-hosted Diffinery](/docs/self-hosting), install the CLI from your instance and give `diffinery setup` its URL: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. The band then reads that instance: it follows `DIFFINERY_URL`, else the plugin's **Diffinery URL** setting (which `diffinery setup` sets, and `/config` changes), else the URL `diffinery login` saved. It sends a token only to the instance the token belongs to. See [Claude Code: Self-hosted Diffinery](/docs/connect/claude-code#self-hosted-diffinery).
42+
4043 ## In cloud sessions and project threads
4144
4245 A Claude Code cloud environment gets the plugins from the setup script of the repository's **Settings → Agent setup**, the same three boxes that give it git, gh and the CLI. Follow [Cloud agent environments](/docs/connect/cloud-agents#set-up-a-claude-code-cloud-environment). In short:
@@ -94,7 +97,7 @@
9497 | Problem | What to do |
9598 | --- | --- |
9699 | 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. |
97-| 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. |
100+| No band, although the branch has a pull request | The folder's remote isn't on the Diffinery the band reads (`git remote -v`; for your own instance, see the note under [On your computer](#on-your-computer)), the band is hidden (run `/diffinery-ci`), or mods are off: `/plugin` lists the mods a session loaded. |
98101 | `/diffinery-ci` says there's no pull request | The current branch has none yet. Open one with `diffinery pr create` or `gh pr create`. |
99102 | 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. |
100103 | `diffinery setup` says `~/.claude/settings.json isn't valid JSON` | Claude Code's settings file has a typo. Fix it (or move it away), then run `diffinery setup` again. |
modifiedapps/diffinery/docs/connect/claude-code.md+19−1View file
@@ -53,7 +53,7 @@
5353
5454 | Part | What it does |
5555 | --- | --- |
56-| MCP server | `https://diffinery.com/mcp` with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
56+| MCP server | `https://diffinery.com/mcp` (or your [own instance's](#self-hosted-diffinery)) with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
5757 | `diffinery` skill | Teaches Claude the loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, read failing logs with `diffinery run logs`, auto-merge, and what agents can and can't do. |
5858 | `/diffinery:pr-status` | This branch's pull request, its checks, and the log tail of failing jobs. Changes nothing. |
5959 | Session hook | In a Diffinery clone, tells Claude the repository is hosted on Diffinery and whether the CLI is installed and signed in. Elsewhere it stays silent. It never uses the network. |
@@ -109,12 +109,30 @@
109109
110110 Keep the single quotes: Claude Code reads `${DIFFINERY_TOKEN}` from the environment each time it connects, so the token is never written to its settings. This entry takes the place of the plugin's own server. The CLI and `diffinery auth setup-git` read `DIFFINERY_TOKEN` too. For Claude Code on the web, set up the environment from the repository's [Agent setup](/docs/connect/cloud-agents), which also brings the CLI and both plugins. More in [Headless use](/docs/connect/how-sign-in-works#headless-use).
111111
112+## Self-hosted Diffinery
113+
114+The plugins work with any Diffinery instance, such as one you [deploy on your own Cloudflare account](/docs/self-hosting). Their **Diffinery URL** setting, `https://diffinery.com` unless you change it, decides the MCP server's address (`<URL>/mcp`), the instance the session hook recognises in a clone, and the instance the [CI band](/docs/connect/ci-status-in-claude) reads. Set it in one of these ways:
115+
116+- **With the CLI.** Install the CLI from your instance and run `diffinery setup` with its URL:
117+
118+ ```sh
119+ curl -fsSL https://git.example.com/install | bash
120+ diffinery setup --url https://git.example.com
121+ ```
122+
123+ It signs you in there, adds both plugins, and sets their Diffinery URL in `~/.claude/settings.json` (under `pluginConfigs`), for plugins installed from the marketplace too. It ends with `✓ Claude Code plugins: … their Diffinery URL is https://git.example.com`.
124+- **From the marketplace.** `claude plugin install diffinery@diffinery` asks for the Diffinery URL when it enables the plugin. Change it later in `/config`, where each plugin option has its own row.
125+- **As files.** The plugin zips your instance serves at `https://git.example.com/-/claude/diffinery.zip` and `https://git.example.com/-/claude/diffinery-ci.zip` already default to that instance, for claude.ai's **Upload plugin** too.
126+
127+`DIFFINERY_URL` in the environment takes precedence for the session hook and the CI band, as it does for the CLI. The MCP server always follows the setting. For a server without the plugin, use your instance's address: `claude mcp add --transport http diffinery https://git.example.com/mcp`.
128+
112129 ## Troubleshooting
113130
114131 | Problem | What to do |
115132 | --- | --- |
116133 | `/plugin` doesn't list `diffinery` | Run `diffinery setup` (step 1) and start a new session. Its `Claude Code plugins` line says what went wrong, if anything. |
117134 | `claude plugin marketplace add` fails with an authentication error | Git can't read codician/diffinery with your sign-in. Run `diffinery auth setup-git`, or use `diffinery setup` instead (step 1). |
135+| `/mcp` shows `https://diffinery.com/mcp` but your repositories are on your own instance | Set the plugin's Diffinery URL: run `diffinery setup --url https://<your instance>`, or change it in `/config`. See [Self-hosted Diffinery](#self-hosted-diffinery). |
118136 | `/mcp` shows the server as needing authentication | Choose **Authenticate**, or run `claude mcp login plugin:diffinery:diffinery`. |
119137 | The browser says "Can't connect this app" | Remove the server and add it again, then sign in. |
120138 | A tool says it needs the write scope | You connected with **Read**. Disconnect Claude Code under **Settings → Access tokens → Connected apps** and sign in again with **Write**. |
modifiedapps/diffinery/docs/connect/claude.md+1−1View file
@@ -17,7 +17,7 @@
1717 | Name | `Diffinery` |
1818 | Remote MCP server URL | `https://diffinery.com/mcp` |
1919
20- Leave **Advanced settings** (OAuth client ID and secret) empty: Claude registers itself with Diffinery.
20+ Leave **Advanced settings** (OAuth client ID and secret) empty: Claude registers itself with Diffinery. On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`.
2121 3. Choose **Connect** next to Diffinery. A Diffinery tab opens. Sign in with your passkey if you aren't signed in.
2222 4. On the consent page, choose whom Claude acts as and its access, then choose **Connect**. The tab returns to Claude and the connector shows as connected.
2323
modifiedapps/diffinery/docs/connect/cloud-agents.md+2−0View file
@@ -15,6 +15,8 @@
1515
1616 ## Before you start
1717
18+- On a [self-hosted Diffinery](/docs/self-hosting), everything below works the same with your instance's domain in place of `diffinery.com`: its **Agent setup** page shows the setup script and environment variables with its own URLs, and the network setting must allow its domain.
19+
1820 - You need write access to the repository. Admins find **Agent setup** among the repository's settings; people with write access get a **Settings** tab that opens it.
1921 - Generating a token needs a passkey sign-in from the last 2 hours, as for any token. If yours is older, Diffinery asks you to confirm with your passkey first.
2022
modifiedapps/diffinery/docs/connect/codex.md+13−1View file
@@ -50,7 +50,7 @@
5050
5151 | Part | What it does |
5252 | --- | --- |
53-| MCP server | `https://diffinery.com/mcp` with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
53+| MCP server | `https://diffinery.com/mcp` (see [Self-hosted Diffinery](#self-hosted-diffinery) for your own instance) with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
5454 | `diffinery` skill | The loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, `diffinery run logs`, auto-merge, and the agent rules. |
5555 | `pr-status` skill | This branch's pull request, its checks, and the log tail of failing jobs. (Codex plugins have no slash commands, so this is a skill.) |
5656 | Session hook | In a Diffinery clone, tells Codex the repository is hosted on Diffinery and whether the CLI is installed and signed in. Codex runs plugin hooks only after you trust them: open `/hooks` in Codex and trust it. The skills work without it. |
@@ -92,6 +92,18 @@
9292
9393 `codex mcp list` then shows **Bearer token** under **Auth**. More in [Headless use](/docs/connect/how-sign-in-works#headless-use).
9494
95+## Self-hosted Diffinery
96+
97+The plugin's skills and session hook work with any Diffinery instance, such as one you [deploy on your own Cloudflare account](/docs/self-hosting). The hook recognises a clone of your instance when its URL is in `DIFFINERY_URL` or saved by `diffinery login`. Codex plugins have no settings, so the plugin's MCP server stays `https://diffinery.com/mcp`; a server of your own named `diffinery` takes its place. `diffinery setup` adds it for you when Codex is installed:
98+
99+```sh
100+curl -fsSL https://git.example.com/install | bash
101+diffinery setup --url https://git.example.com # ✓ Codex: the diffinery MCP server is now https://git.example.com/mcp
102+codex mcp login diffinery
103+```
104+
105+Or add it yourself: `codex mcp add diffinery --url https://git.example.com/mcp`. `codex mcp list` then shows your instance's URL for `diffinery`. For headless use, add `--bearer-token-env-var DIFFINERY_TOKEN` and set `DIFFINERY_URL=https://git.example.com` for the CLI.
106+
95107 ## Troubleshooting
96108
97109 | Problem | What to do |
modifiedapps/diffinery/docs/connect/other-apps.md+2−0View file
@@ -9,6 +9,8 @@
99 https://diffinery.com/mcp
1010 ```
1111
12+On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`. Use it in place of `https://diffinery.com/mcp` in the examples below.
13+
1214 ## Cursor
1315
1416 Add Diffinery to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project to share it with everyone who opens the project:
modifiedapps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+15−2View file
@@ -38,7 +38,7 @@
3838
3939 1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen.
4040 2. Choose **Diffinery** in the list on the left.
41-3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page.
41+3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page. (For a [self-hosted Diffinery](#self-hosted-diffinery), choose **Use another server…** first.)
4242 4. On the consent page, check that it says **Diffinery plugin for JetBrains IDEs**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the IDE.
4343 5. Pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link.
4444 6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it.
@@ -79,8 +79,20 @@
7979
8080 ## Settings
8181
82-**Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a self-hosted Diffinery. Changing the server signs you out. For a self-hosted Diffinery, the plugin repository is the same address on your server, such as `https://git.example.com/-/jetbrains/updatePlugins.xml`.
82+**Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a [self-hosted Diffinery](#self-hosted-diffinery). Changing the server signs you out.
8383
84+## Self-hosted Diffinery
85+
86+The plugin works with any Diffinery, such as one your company runs at `https://git.example.com`.
87+
88+1. Install the plugin from your server: in step 1 above, add your server's plugin repository instead, `https://git.example.com/-/jetbrains/updatePlugins.xml`. The IDE then installs updates from your server. From a terminal: `idea installPlugins com.diffinery https://git.example.com/-/jetbrains/updatePlugins.xml`.
89+2. Open **Get from Version Control → Diffinery**. Under **Sign In with Browser…**, the page shows **Server: diffinery.com** and:
90+ - **Use git.example.com**, when the IDE installs plugins from that server's repository. One click switches to it.
91+ - **Use another server…**, which asks for the address (such as `git.example.com`), checks that a Diffinery answers there, and switches to it. Enter `diffinery.com` to go back.
92+3. The page now shows **Server: git.example.com**. Choose **Sign In with Browser…**; your server's consent page opens.
93+
94+**Git → Share Project on Diffinery…** offers **Use Another Server…** too when you aren't signed in, and **Settings → Version Control → Diffinery → Server** sets the address by hand. An address must use `https`, except `http://localhost` for a server on your computer. Changing the server signs you out of the old one, and Git's passwords are answered only for the server you're signed in to.
95+
8496 ## Troubleshooting
8597
8698 | What you see | What to do |
@@ -98,4 +110,5 @@
98110 - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](/docs/agents/mcp)'s tools. The plugin is for people using a JetBrains IDE.
99111 - When a person asks how to open a Diffinery repository in IntelliJ IDEA, PyCharm or another JetBrains IDE, point them to this page: they add `https://diffinery.com/-/jetbrains/updatePlugins.xml` under **Settings → Plugins → ⚙ → Manage Plugin Repositories…**, install **Diffinery**, then use **Get from Version Control → Diffinery**. Don't try to complete the browser sign-in for them.
100112 - The plugin repository at `https://diffinery.com/-/jetbrains/updatePlugins.xml` names the current version and its download; the plugin's ID is `com.diffinery`.
113+- For a self-hosted Diffinery, the plugin repository is `https://SERVER/-/jetbrains/updatePlugins.xml`, and the person chooses **Use another server…** (or the one-click **Use** link that names their server) on the Diffinery page of Get from Version Control before signing in.
101114 - For VS Code, Cursor and Windsurf, see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
modifiedapps/diffinery/docs/repositories/clone-in-your-editor.md+22−2View file
@@ -38,7 +38,7 @@
3838
3939 1. Open the Command Palette (`Ctrl+Shift+P`, or `Cmd+Shift+P` on a Mac) and run **Git: Clone**, or choose **Clone Repository** on the editor's welcome page.
4040 2. Choose **Clone from Diffinery**.
41-3. The first time, the editor asks to sign in with Diffinery. Choose **Allow**: your browser opens Diffinery's consent page.
41+3. The first time, the editor asks to sign in with Diffinery. Choose **Allow**, then **Sign in to diffinery.com** (or **Use another Diffinery server…** for a [self-hosted Diffinery](#self-hosted-diffinery)): your browser opens Diffinery's consent page.
4242 4. On the consent page, check that it says **Diffinery extension for VS Code and Cursor**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the editor.
4343 5. Back in the editor, pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link.
4444 6. Choose a folder. The editor clones the repository and opens it.
@@ -83,10 +83,29 @@
8383
8484 | Setting | Default | What it does |
8585 | --- | --- | --- |
86-| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. Change it only for a self-hosted Diffinery. |
86+| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. For a self-hosted Diffinery, its address; see below. |
8787 | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account, and keep Git's saved copy current. |
8888 | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. |
8989
90+## Self-hosted Diffinery
91+
92+The extension works with any Diffinery, such as one your company runs at `https://git.example.com`. Install it from your server, so it also updates itself from there:
93+
94+```sh
95+curl -fsSL https://git.example.com/install-editor | bash
96+```
97+
98+On Windows: `powershell -c "irm https://git.example.com/install-editor.ps1 | iex"`.
99+
100+Then point the extension at your server, in any of these ways:
101+
102+- **Installed with your server's script:** when the extension starts, it asks **The Diffinery extension was installed from https://git.example.com. Sign in, clone and push there?** Choose **Use git.example.com**. (Editors can't set a setting from their command line, so the script leaves the address in `~/.config/diffinery/editor-server`, or `%USERPROFILE%\.config\diffinery\editor-server` on Windows, and the extension asks once.)
103+- **The first sign-in:** the extension asks **Which Diffinery do you use?** Choose **Use another Diffinery server…** and enter the address, such as `git.example.com`.
104+- **Any time:** run **Diffinery: Use Another Server…** from the Command Palette and enter the address. Enter `diffinery.com` to go back.
105+- **By hand:** set `diffinery.serverUrl` to `https://git.example.com` in your user settings.
106+
107+The extension checks that a Diffinery answers at the address before it uses it (an address must use `https`, except `http://localhost` for a server on your computer). Each server keeps its own sign-in, so switching back doesn't sign you out. Git's passwords are answered for the server in `diffinery.serverUrl` only.
108+
90109 ## JetBrains IDEs
91110
92111 IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs have their own Diffinery plugin, which adds **Diffinery** to **Get from Version Control** with the same passkey sign-in: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
@@ -110,4 +129,5 @@
110129
111130 - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](/docs/agents/mcp)'s tools. The extension is for people using an editor.
112131 - When a person asks how to open a Diffinery repository in VS Code, Cursor or Windsurf, point them to this page: they run `curl -fsSL https://diffinery.com/install-editor | bash`, then **Git: Clone → Clone from Diffinery**. Don't try to complete the browser sign-in for them.
132+- For a self-hosted Diffinery, they run the same script from their server (`curl -fsSL https://git.example.com/install-editor | bash`) and choose **Use git.example.com** when the editor asks, or run **Diffinery: Use Another Server…**. Don't edit their `settings.json` for them.
113133 - For connecting an editor's AI (Cursor's agent, VS Code's Copilot Chat) to Diffinery's MCP server, see [Other apps](/docs/connect/other-apps).
modifiedapps/diffinery/docs/self-hosting.md+1−0View file
@@ -54,6 +54,7 @@
5454 - **Who can join.** An instance is invite-only by default. Set `SIGNUP=open` to let anyone create an account; see [SIGNUP](/docs/self-hosting/configuration#signup) and [Invitations](/docs/accounts/invitations).
5555 - **Limits.** Change the per-account quotas and the instance's hosted job limit with the `QUOTA_*` and `HOSTED_JOBS_LIMIT` variables; see [Configuration](/docs/self-hosting/configuration#worker-variables).
5656 - **Running it.** [Site administration](/docs/admin/site-administration) covers accounts, early access requests, email and audits, and the [status page](/docs/self-hosting/status-page) shows whether each part of your instance works.
57+- **Your team's tools.** The CLI, the Claude Code and Codex plugins, the MCP server and the editor extensions all work with your instance. People install the CLI from it and run `diffinery setup` with its URL, which signs them in and points git, gh, the Claude Code plugins and Codex at it: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. AI apps connect to `https://git.example.com/mcp`. See [Claude Code](/docs/connect/claude-code#self-hosted-diffinery), [Codex](/docs/connect/codex#self-hosted-diffinery), [Clone in your editor](/docs/repositories/clone-in-your-editor) and [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
5758 - **How it fits together.** [Architecture](/docs/self-hosting/architecture) maps each part of Diffinery to the Cloudflare product that runs it.
5859
5960 ## Get help and contribute
addedapps/diffinery/src/docs/claude-plugins.ts+32−0View file
@@ -1,0 +1,32 @@
1+import { unzip, zip } from "../lib/archive.js";
2+
3+/** The URL the plugins' "Diffinery URL" setting (userConfig `url`) defaults to in their sources. */
4+export const PLUGIN_DEFAULT_URL = "https://diffinery.com";
5+const MANIFEST = ".claude-plugin/plugin.json";
6+// The fixed timestamp scripts/pack-plugins.py gives every entry, so a rewrite of the same zip is the same bytes.
7+const STAMP = Date.UTC(2026, 0, 1) / 1000;
8+
9+const hex = (b: ArrayBuffer) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
10+
11+/**
12+ * A Claude Code plugin zip as `origin` serves it. A self-hosted instance's copy defaults the plugin's
13+ * Diffinery URL setting to that instance, so a plugin uploaded to claude.ai or set up by `diffinery setup`
14+ * from there reaches it with nothing to configure; diffinery.com serves the zip as packed.
15+ */
16+export async function pluginZipFor(origin: string, packed: Uint8Array, sha256: string): Promise<{ bytes: Uint8Array; sha256: string }> {
17+ if (origin === PLUGIN_DEFAULT_URL) return { bytes: packed, sha256 };
18+ const entries = await unzip(packed);
19+ let changed = false;
20+ const out = entries.map((e) => {
21+ if ("dir" in e || e.path !== MANIFEST) return e;
22+ const manifest = JSON.parse(new TextDecoder().decode(e.content)) as { userConfig?: { url?: { default?: unknown } } };
23+ const url = manifest.userConfig?.url;
24+ if (!url || url.default !== PLUGIN_DEFAULT_URL) return e;
25+ url.default = origin;
26+ changed = true;
27+ return { ...e, content: new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`) };
28+ });
29+ if (!changed) return { bytes: packed, sha256 };
30+ const bytes = await zip(out, STAMP);
31+ return { bytes, sha256: hex(await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>)) };
32+}
modifiedapps/diffinery/src/docs/web.tsx+13−6View file
@@ -22,6 +22,7 @@
2222 import { fenceHighlighter } from "../web/highlight.js";
2323 import { ICONS } from "../web/assets.js";
2424 import { render, type WebApp } from "../web/ui.js";
25+import { pluginZipFor } from "./claude-plugins.js";
2526 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";
2627 import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, sectionLlmsFullTxt, sectionLlmsTxt, SLUGS, type Page } from "./pages.js";
2728 import { docsJsonLd } from "../web/site.js";
@@ -296,20 +297,26 @@
296297
297298 // The Claude Code plugins, for `diffinery setup` (which checks the SHA-256 header) and claude.ai's Upload
298299 // plugin, neither of which can read the plugin repository. /-/claude/<name>.zip is always the current version; the versioned name stays the same bytes.
299- const claudeZips = new Map<string, Uint8Array<ArrayBuffer>>();
300- web.get("/-/claude/:file", (c) => {
300+ // A self-hosted instance's copies point the plugins at itself (claude-plugins.ts).
301+ const claudeZips = new Map<string, Promise<{ bytes: Uint8Array; sha256: string }>>();
302+ web.get("/-/claude/:file", async (c) => {
301303 const file = c.req.param("file");
302304 const m = /^([a-z][a-z0-9-]*?)(?:-(\d+\.\d+\.\d+))?\.zip$/.exec(file);
303305 const plugin = m && Object.hasOwn(CLAUDE_PLUGINS, m[1]!) ? CLAUDE_PLUGINS[m[1]!]! : null;
304306 if (!m || !plugin || (m[2] !== undefined && m[2] !== plugin.version)) return c.text("Not found", 404);
305- let bytes = claudeZips.get(m[1]!);
306- if (!bytes) claudeZips.set(m[1]!, (bytes = Uint8Array.from(atob(plugin.zip), (ch) => ch.charCodeAt(0))));
307- return c.body(bytes, 200, {
307+ const key = `${origin(c)} ${m[1]}`;
308+ let built = claudeZips.get(key);
309+ if (!built) {
310+ claudeZips.set(key, (built = pluginZipFor(origin(c), Uint8Array.from(atob(plugin.zip), (ch) => ch.charCodeAt(0)), plugin.sha256)));
311+ built.catch(() => claudeZips.delete(key));
312+ }
313+ const { bytes, sha256 } = await built;
314+ return c.body(bytes as Uint8Array<ArrayBuffer>, 200, {
308315 ...PUBLIC,
309316 "content-type": "application/zip",
310317 "content-disposition": `attachment; filename="${m[1]}-${plugin.version}.zip"`,
311318 "x-diffinery-plugin-version": plugin.version,
312- "x-diffinery-plugin-sha256": plugin.sha256,
319+ "x-diffinery-plugin-sha256": sha256,
313320 });
314321 });
315322
modifiedapps/diffinery/src/lib/archive.ts+48−1View file
@@ -1,6 +1,7 @@
11 /**
22 * Minimal tar and zip writers for source archives (zipball/tarball) and run
3- * log bundles. Entries are buffered, which is fine for the size limits used.
3+ * log bundles, and a zip reader for the small zips the server ships itself.
4+ * Entries are buffered, which is fine for the size limits used.
45 */
56
67 export type ArchiveEntry = { path: string; mode: string; content: Uint8Array } | { path: string; dir: true };
@@ -208,6 +209,52 @@
208209 return concat(parts);
209210 }
210211
212+async function inflateRaw(data: Uint8Array): Promise<Uint8Array> {
213+ const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
214+ return new Uint8Array(await new Response(stream).arrayBuffer());
215+}
216+
217+/**
218+ * The files of a classic zip (stored or deflated entries, read through the central directory), with
219+ * their unix modes as git modes. For zips the server ships itself, such as the Claude Code plugins:
220+ * it checks each entry's CRC and refuses anything else rather than guess.
221+ */
222+export async function unzip(data: Uint8Array): Promise<ArchiveEntry[]> {
223+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
224+ let end = data.length - 22;
225+ while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end--;
226+ if (end < 0) throw new Error("not a zip archive");
227+ const count = view.getUint16(end + 10, true);
228+ let at = view.getUint32(end + 16, true);
229+ const out: ArchiveEntry[] = [];
230+ for (let i = 0; i < count; i++) {
231+ if (view.getUint32(at, true) !== 0x02014b50) throw new Error("corrupt zip central directory");
232+ const method = view.getUint16(at + 10, true);
233+ const crc = view.getUint32(at + 16, true);
234+ const size = view.getUint32(at + 20, true);
235+ const nameLen = view.getUint16(at + 28, true);
236+ const extraLen = view.getUint16(at + 30, true);
237+ const commentLen = view.getUint16(at + 32, true);
238+ const unixMode = view.getUint32(at + 38, true) >>> 16;
239+ const local = view.getUint32(at + 42, true);
240+ const path = new TextDecoder().decode(data.subarray(at + 46, at + 46 + nameLen));
241+ at += 46 + nameLen + extraLen + commentLen;
242+ if (path.endsWith("/")) {
243+ out.push({ path, dir: true });
244+ continue;
245+ }
246+ if (view.getUint32(local, true) !== 0x04034b50) throw new Error(`corrupt zip entry ${path}`);
247+ const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true);
248+ const raw = data.subarray(start, start + size);
249+ if (method !== 0 && method !== 8) throw new Error(`zip entry ${path} uses compression method ${method}`);
250+ const content = method === 8 ? await inflateRaw(raw) : raw.slice();
251+ if (crc32(content) !== crc) throw new Error(`zip entry ${path} fails its CRC check`);
252+ const kind = unixMode >> 12;
253+ out.push({ path, mode: kind === 0o12 ? "120000" : unixMode & 0o111 ? "100755" : "100644", content });
254+ }
255+ return out;
256+}
257+
211258 export function concat(parts: Uint8Array[]): Uint8Array {
212259 let n = 0;
213260 for (const p of parts) n += p.length;
addedapps/diffinery/test/unit/claude-plugins.test.ts+49−0View file
@@ -1,0 +1,49 @@
1+import { createHash } from "node:crypto";
2+import { readdirSync, readFileSync } from "node:fs";
3+import { describe, expect, it } from "vitest";
4+import { pluginZipFor } from "../../src/docs/claude-plugins.js";
5+import { unzip } from "../../src/lib/archive.js";
6+
7+const dec = new TextDecoder();
8+const dir = new URL("../../claude-plugins/", import.meta.url);
9+
10+/** The checked-in zip of one plugin (claude-plugins/<name>-<version>.zip), as the build embeds it. */
11+function packed(name: string): { bytes: Uint8Array; sha256: string } {
12+ const file = readdirSync(dir).find((f) => new RegExp(`^${name}-\\d+\\.\\d+\\.\\d+\\.zip$`).test(f))!;
13+ const bytes = new Uint8Array(readFileSync(new URL(file, dir)));
14+ return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
15+}
16+
17+const files = async (bytes: Uint8Array) =>
18+ Object.fromEntries((await unzip(bytes)).map((e) => ["dir" in e ? e.path : e.path, "dir" in e ? "" : dec.decode(e.content)]));
19+
20+describe("Claude Code plugins served by an instance", () => {
21+ for (const name of ["diffinery", "diffinery-ci"]) {
22+ it(`${name}: diffinery.com serves the packed zip as it is`, async () => {
23+ const p = packed(name);
24+ const out = await pluginZipFor("https://diffinery.com", p.bytes, p.sha256);
25+ expect(out.bytes).toBe(p.bytes);
26+ expect(out.sha256).toBe(p.sha256);
27+ });
28+
29+ it(`${name}: a self-hosted instance defaults the Diffinery URL setting to itself and changes nothing else`, async () => {
30+ const p = packed(name);
31+ const before = await files(p.bytes);
32+ const out = await pluginZipFor("https://git.example.com", p.bytes, p.sha256);
33+ expect(out.sha256).toBe(createHash("sha256").update(out.bytes).digest("hex"));
34+ const after = await files(out.bytes);
35+ expect(Object.keys(after)).toEqual(Object.keys(before));
36+ const manifest = JSON.parse(after[".claude-plugin/plugin.json"]!);
37+ expect(manifest.userConfig.url.default).toBe("https://git.example.com");
38+ expect({ ...manifest, userConfig: undefined }).toEqual({ ...JSON.parse(before[".claude-plugin/plugin.json"]!), userConfig: undefined });
39+ for (const f of Object.keys(before)) if (f !== ".claude-plugin/plugin.json") expect(after[f]).toBe(before[f]);
40+ // The same instance gets the same bytes every time (a fixed timestamp, as pack-plugins.py writes).
41+ expect((await pluginZipFor("https://git.example.com", p.bytes, p.sha256)).sha256).toBe(out.sha256);
42+ });
43+ }
44+
45+ it("the diffinery plugin's MCP server follows the setting", async () => {
46+ const mcp = JSON.parse((await files(packed("diffinery").bytes))[".mcp.json"]!);
47+ expect(mcp.mcpServers.diffinery.url).toBe("${user_config.url}/mcp");
48+ });
49+});
modifiedapps/jetbrains/CHANGELOG.md+9−0View file
@@ -1,5 +1,14 @@
11 # Changelog
22
3+## 0.1.1
4+
5+- Works with any Diffinery, not only diffinery.com: **Use another server…** under **Sign In with Browser…** on the
6+ Diffinery page of **Get from Version Control** asks for your server's address, checks that a Diffinery answers
7+ there, and switches to it.
8+- Installed from your own Diffinery's plugin repository, the sign-in page offers that server in one click
9+ (**Use git.example.com**).
10+- **Share Project on Diffinery…** asks which server to sign in to when you aren't signed in yet.
11+
312 ## 0.1.0
413
514 - **Diffinery** in **Get from Version Control**: your repositories and your organizations', search across every
modifiedapps/jetbrains/README.md+9−0View file
@@ -9,6 +9,15 @@
99 It's plain Java (no Kotlin), against the platform's stable API only: `./gradlew verifyPlugin` runs JetBrains' Plugin
1010 Verifier against IntelliJ IDEA 2024.3, 2025.2 and the version the plugin builds with, and fails on internal API use.
1111
12+## Self-hosted Diffinery
13+
14+The server is `server` in the IDE's `diffinery.xml` (`Accounts.setServer`), `https://diffinery.com` by default. People
15+change it from the Diffinery page of **Get from Version Control**: **Use another server…** under the sign-in button
16+(`SignInDialogs.chooseServer`, which checks that `/-/jetbrains/updatePlugins.xml` answers there first), or one click on
17+**Use git.example.com** when the IDE's plugin repositories include a Diffinery's `updatePlugins.xml`
18+(`Util.pluginRepositoryServers`). **Share Project on Diffinery…** offers the same choice before signing in, and
19+**Settings → Version Control → Diffinery → Server** remains. Changing the server signs out of the old one.
20+
1221 ## Build
1322
1423 Gradle 9 through the wrapper, with a JDK 21 or later (the IDE's own JetBrains Runtime works):
deletedapps/jetbrains/dist/diffinery-jetbrains-0.1.0.zip+0−0
Binary file not shown.
addedapps/jetbrains/dist/diffinery-jetbrains-0.1.1.zip+0−0View file
Binary file not shown.
modifiedapps/jetbrains/gradle.properties+1−1View file
@@ -1,4 +1,4 @@
1-pluginVersion=0.1.0
1+pluginVersion=0.1.1
22 # The IDE the plugin is built against. -PideaHome=/path/to/idea uses one already on disk instead of downloading it.
33 platformVersion=2026.2.3
44 sinceBuild=243
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2View file
@@ -5,7 +5,6 @@
55 import com.intellij.openapi.application.ApplicationManager;
66 import com.intellij.openapi.application.ModalityState;
77 import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory;
8-import com.intellij.openapi.options.ShowSettingsUtil;
98 import com.intellij.openapi.project.Project;
109 import com.intellij.openapi.ui.TextBrowseFolderListener;
1110 import com.intellij.openapi.ui.TextFieldWithBrowseButton;
@@ -21,6 +20,7 @@
2120 import com.intellij.ui.ScrollPaneFactory;
2221 import com.intellij.ui.SearchTextField;
2322 import com.intellij.ui.SimpleTextAttributes;
23+import com.intellij.ui.components.ActionLink;
2424 import com.intellij.ui.components.JBLabel;
2525 import com.intellij.ui.components.JBList;
2626 import com.intellij.util.ui.JBUI;
@@ -29,6 +29,7 @@
2929 import git4idea.commands.Git;
3030 import java.awt.BorderLayout;
3131 import java.awt.CardLayout;
32+import java.awt.FlowLayout;
3233 import java.awt.GridBagConstraints;
3334 import java.awt.GridBagLayout;
3435 import java.awt.event.KeyAdapter;
@@ -72,6 +73,8 @@
7273 private final TextFieldWithBrowseButton directory = new TextFieldWithBrowseButton();
7374 private final JBLabel serverLabel = new JBLabel();
7475 private final JButton signInButton = new JButton("Sign In with Browser…");
76+ private final ActionLink suggestedLink = Plugin.link("", this::useSuggested);
77+ private String suggested;
7578
7679 private List<Repo> mine = List.of();
7780 private List<Repo> found = List.of();
@@ -117,8 +120,13 @@
117120 serverLabel.setForeground(UIUtil.getContextHelpForeground());
118121 c.insets = JBUI.insetsTop(16);
119122 p.add(serverLabel, c);
123+ // A self-hosted Diffinery is one click away: the one this plugin was installed from, when the IDE's plugin
124+ // repositories name it, or any other by its address.
125+ JPanel servers = new JPanel(new FlowLayout(FlowLayout.CENTER, JBUI.scale(12), 0));
126+ servers.add(suggestedLink);
127+ servers.add(Plugin.link("Use another server…", () -> SignInDialogs.chooseServer(project, this::refresh)));
120128 c.insets = JBUI.emptyInsets();
121- p.add(Plugin.link("Change server…", () -> ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsPage.class)), c);
129+ p.add(servers, c);
122130 return p;
123131 }
124132
@@ -213,6 +221,11 @@
213221 getDialogStateListener().onListItemChanged();
214222 if (a == null) {
215223 serverLabel.setText("Server: " + Util.host(Accounts.get().server()));
224+ List<String> others = SignInDialogs.pluginRepositoryServers();
225+ suggested = others.isEmpty() ? null : others.get(0);
226+ suggestedLink.setText(suggested == null ? "" : "Use " + Util.host(suggested));
227+ suggestedLink.setToolTipText(suggested == null ? null : "This IDE installs the Diffinery plugin from " + suggested);
228+ suggestedLink.setVisible(suggested != null);
216229 cards.show(root, SIGNED_OUT);
217230 getDialogStateListener().onOkActionEnabled(false);
218231 return;
@@ -222,6 +235,12 @@
222235 load();
223236 }
224237
238+ /** Switches to the Diffinery this plugin came from: it served the plugin, so there's nothing to check. */
239+ private void useSuggested() {
240+ String s = suggested;
241+ if (s != null) ApplicationManager.getApplication().executeOnPooledThread(() -> Accounts.get().setServer(s));
242+ }
243+
225244 private void load() {
226245 int gen = loads.incrementAndGet();
227246 mine = List.of();
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1View file
@@ -47,7 +47,7 @@
4747 buttons.add(signIn);
4848 buttons.add(useToken);
4949 buttons.add(signOut);
50- JBLabel serverHelp = new JBLabel("Change it only for a self-hosted Diffinery. Changing it signs you out.");
50+ JBLabel serverHelp = new JBLabel("https://diffinery.com, or the address of your own Diffinery. Changing it signs you out.");
5151 serverHelp.setForeground(UIUtil.getContextHelpForeground());
5252 JBLabel gitHelp = new JBLabel("<html>When you're signed in, Git uses this account for the server: clone, pull and push need no password.</html>");
5353 gitHelp.setForeground(UIUtil.getContextHelpForeground());
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/ShareAction.java+1−1View file
@@ -62,7 +62,7 @@
6262 Messages.showInfoMessage(project, "Commit your files on a branch first, then share the project.", "Share Project on Diffinery");
6363 return;
6464 }
65- if (Accounts.get().account() == null) SignInDialogs.signInWithBrowser(project, a -> chooseAndShare(project, repo));
65+ if (Accounts.get().account() == null) SignInDialogs.signIn(project, a -> chooseAndShare(project, repo));
6666 else chooseAndShare(project, repo);
6767 }
6868
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0View file
@@ -2,19 +2,123 @@
22
33 import com.intellij.openapi.application.ApplicationManager;
44 import com.intellij.openapi.application.ModalityState;
5+import com.intellij.openapi.diagnostic.Logger;
56 import com.intellij.openapi.progress.ProgressIndicator;
67 import com.intellij.openapi.progress.ProgressManager;
78 import com.intellij.openapi.progress.Task;
89 import com.intellij.openapi.project.Project;
10+import com.intellij.openapi.ui.InputValidatorEx;
911 import com.intellij.openapi.ui.Messages;
12+import com.intellij.openapi.updateSettings.impl.UpdateSettings;
13+import java.io.IOException;
14+import java.util.List;
1015 import java.util.function.Consumer;
1116 import org.jetbrains.annotations.NotNull;
1217 import org.jetbrains.annotations.Nullable;
1318
1419 /** Signing in from anywhere in the IDE: with a progress dialog that can be cancelled, and errors shown as sentences. */
1520 final class SignInDialogs {
21+ private static final Logger LOG = Logger.getInstance(SignInDialogs.class);
22+
1623 private SignInDialogs() {}
1724
25+ /**
26+ * Signing in from a place that doesn't show the server (Share Project): says which Diffinery, with a way to pick
27+ * another one first. `done` runs on the UI thread with the account once signed in.
28+ */
29+ static void signIn(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) {
30+ String host = Util.host(Accounts.get().server());
31+ int choice = Messages.showDialog(project, "Sign in to Diffinery at " + host + " with your passkey in the browser, or use another Diffinery server, such as your own.", "Sign In to Diffinery", new String[] {"Sign In to " + host + "…", "Use Another Server…", Messages.getCancelButton()}, 0, Plugin.ICON);
32+ if (choice == 0) signInWithBrowser(project, done);
33+ else if (choice == 1) chooseServer(project, () -> signInWithBrowser(project, done));
34+ }
35+
36+ /**
37+ * The self-hosted Diffinery servers this IDE installs plugins from (Settings → Plugins → Manage Plugin
38+ * Repositories), other than the current one.
39+ */
40+ static List<String> pluginRepositoryServers() {
41+ try {
42+ return Util.pluginRepositoryServers(UpdateSettings.getInstance().getStoredPluginHosts(), Accounts.get().server());
43+ } catch (RuntimeException | LinkageError e) {
44+ // Only a suggestion: an IDE that keeps its plugin repositories elsewhere just doesn't get one.
45+ LOG.info("Couldn't read the IDE's plugin repositories: " + e);
46+ return List.of();
47+ }
48+ }
49+
50+ /**
51+ * "Use another server…": asks for a self-hosted Diffinery's address (offering the one this plugin came from),
52+ * checks that a Diffinery answers there, and switches to it, which signs out of the current server. `done` runs on
53+ * the UI thread once switched; not when cancelled.
54+ */
55+ static void chooseServer(@Nullable Project project, @Nullable Runnable done) {
56+ String current = Accounts.get().server();
57+ List<String> suggested = pluginRepositoryServers();
58+ String initial = !suggested.isEmpty() ? suggested.get(0) : current.equals(Util.DEFAULT_SERVER) ? "" : current;
59+ Accounts.Account signedIn = Accounts.get().account();
60+ String raw = Messages.showInputDialog(
61+ project,
62+ "The address of your Diffinery, as in your browser's address bar, such as git.example.com. Enter diffinery.com to go back."
63+ + (signedIn != null ? "\nChanging the server signs you out of " + Util.host(current) + "." : ""),
64+ "Use Another Diffinery Server",
65+ Plugin.ICON,
66+ initial,
67+ new InputValidatorEx() {
68+ @Override
69+ public String getErrorText(String input) {
70+ return Util.serverInputError(input);
71+ }
72+
73+ @Override
74+ public boolean checkInput(String input) {
75+ return Util.serverInputError(input) == null;
76+ }
77+
78+ @Override
79+ public boolean canClose(String input) {
80+ return checkInput(input);
81+ }
82+ });
83+ if (raw == null) return;
84+ String server = Util.normalizeServer(raw);
85+ if (server.equals(current)) {
86+ if (done != null) done.run();
87+ return;
88+ }
89+ ProgressManager.getInstance().run(new Task.Modal(project, "Looking for Diffinery at " + Util.host(server), true) {
90+ private String problem;
91+
92+ @Override
93+ public void run(@NotNull ProgressIndicator indicator) {
94+ indicator.setIndeterminate(true);
95+ problem = probe(server);
96+ }
97+
98+ @Override
99+ public void onSuccess() {
100+ if (problem != null
101+ && Messages.showYesNoDialog(project, server + " doesn't look like a Diffinery server: " + problem + "\nCheck the address, and use it anyway only if you're sure.", "Use Another Diffinery Server", "Use Anyway", Messages.getCancelButton(), Messages.getWarningIcon())
102+ != Messages.YES) return;
103+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
104+ Accounts.get().setServer(server);
105+ if (done != null) ApplicationManager.getApplication().invokeLater(done, ModalityState.any());
106+ });
107+ }
108+ });
109+ }
110+
111+ /** Null when a Diffinery answers at the server (every instance serves this plugin's repository), else what went wrong. */
112+ private static String probe(String server) {
113+ try {
114+ Api.Response r = Api.send("GET", server + "/-/jetbrains/updatePlugins.xml", null, null, null);
115+ if (r.status() != 200) return "its plugin repository answered " + r.status() + ".";
116+ return r.body().contains("\"" + Plugin.ID + "\"") ? null : "it doesn't serve the Diffinery plugin.";
117+ } catch (IOException e) {
118+ return "it couldn't be reached (" + e.getMessage() + ").";
119+ }
120+ }
121+
18122 /** Opens the browser to sign in; `done` runs on the UI thread with the account once signed in. */
19123 static void signInWithBrowser(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) {
20124 ProgressManager.getInstance().run(new Task.Modal(project, "Sign In to Diffinery", true) {
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Util.java+40−0View file
@@ -7,6 +7,7 @@
77 import java.security.MessageDigest;
88 import java.security.NoSuchAlgorithmException;
99 import java.security.SecureRandom;
10+import java.util.ArrayList;
1011 import java.util.Base64;
1112 import java.util.LinkedHashMap;
1213 import java.util.List;
@@ -43,6 +44,45 @@
4344 return scheme + "://" + u.getRawAuthority() + path;
4445 }
4546
47+ /** Why what someone typed as a server address can't be used, for the dialog; null when it can. */
48+ static String serverInputError(String raw) {
49+ String s = raw == null ? "" : raw.trim();
50+ if (s.isEmpty()) return "Enter your Diffinery's address, such as git.example.com.";
51+ if (s.chars().anyMatch(Character::isWhitespace)) return "An address has no spaces.";
52+ try {
53+ normalizeServer(s);
54+ return null;
55+ } catch (IllegalArgumentException e) {
56+ return e.getMessage();
57+ }
58+ }
59+
60+ /** The Diffinery that serves a plugin repository (https://git.example.com/-/jetbrains/updatePlugins.xml), or null. */
61+ static String serverOfPluginRepository(String url) {
62+ if (url == null) return null;
63+ String u = url.trim().replaceAll("[?#].*$", "");
64+ String suffix = "/-/jetbrains/updatePlugins.xml";
65+ if (!u.endsWith(suffix) || !SCHEME.matcher(u).find()) return null;
66+ try {
67+ return normalizeServer(u.substring(0, u.length() - suffix.length()));
68+ } catch (IllegalArgumentException e) {
69+ return null;
70+ }
71+ }
72+
73+ /**
74+ * The Diffinery servers among the IDE's plugin repositories, other than `current`, in order: someone who installed
75+ * this plugin from their own Diffinery has its updatePlugins.xml there, so it's the server they most likely mean.
76+ */
77+ static List<String> pluginRepositoryServers(List<String> repositories, String current) {
78+ List<String> out = new ArrayList<>();
79+ for (String r : repositories) {
80+ String s = serverOfPluginRepository(r);
81+ if (s != null && !s.equals(current) && !out.contains(s)) out.add(s);
82+ }
83+ return out;
84+ }
85+
4686 /** The OAuth client_id of this plugin on a server: the URL of the metadata document the server publishes about it. */
4787 static String clientId(String server) {
4888 return server + "/oauth/clients/jetbrains.json";
modifiedapps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0View file
@@ -13,6 +13,7 @@
1313 <li><b>Push and pull without passwords.</b> When Git asks for a password for Diffinery, the plugin answers with
1414 your signed-in account, and renews its token before it runs out.</li>
1515 <li><b>Share Project on Diffinery.</b> Publish a local Git repository as a new Diffinery repository.</li>
16+ <li><b>Any Diffinery.</b> diffinery.com or your own: choose <b>Use another server…</b> under the sign-in button.</li>
1617 </ul>
1718 <p>Guide: <a href="https://diffinery.com/docs/repositories/clone-in-jetbrains-ides">Clone in JetBrains IDEs</a>.</p>
1819 ]]></description>
modifiedapps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0View file
@@ -31,6 +31,34 @@
3131 }
3232
3333 @Test
34+ public void serverInput() {
35+ assertNull(Util.serverInputError("git.example.com"));
36+ assertNull(Util.serverInputError(" https://git.example.com/ "));
37+ assertNull(Util.serverInputError("http://localhost:8787"));
38+ assertTrue(Util.serverInputError("").startsWith("Enter"));
39+ assertTrue(Util.serverInputError("git example").contains("spaces"));
40+ assertTrue(Util.serverInputError("http://git.example.com").contains("https"));
41+ assertTrue(Util.serverInputError("https://").contains("URL"));
42+ }
43+
44+ @Test
45+ public void pluginRepositoryServers() {
46+ assertEquals("https://git.example.com", Util.serverOfPluginRepository("https://git.example.com/-/jetbrains/updatePlugins.xml"));
47+ assertEquals("https://git.example.com:8443/forge", Util.serverOfPluginRepository(" https://git.example.com:8443/forge/-/jetbrains/updatePlugins.xml?build=IU-262 "));
48+ assertNull(Util.serverOfPluginRepository("https://plugins.example.com/updatePlugins.xml"));
49+ assertNull(Util.serverOfPluginRepository("/-/jetbrains/updatePlugins.xml"));
50+ assertNull(Util.serverOfPluginRepository("http://git.example.com/-/jetbrains/updatePlugins.xml"));
51+ assertNull(Util.serverOfPluginRepository(null));
52+ List<String> hosts = List.of(
53+ "https://diffinery.com/-/jetbrains/updatePlugins.xml",
54+ "https://plugins.example.com/updatePlugins.xml",
55+ "https://git.example.com/-/jetbrains/updatePlugins.xml",
56+ "https://git.example.com/-/jetbrains/updatePlugins.xml");
57+ assertEquals(List.of("https://git.example.com"), Util.pluginRepositoryServers(hosts, S));
58+ assertEquals(List.of(S), Util.pluginRepositoryServers(hosts, "https://git.example.com"));
59+ }
60+
61+ @Test
3462 public void authorizeUrl() {
3563 String url = Util.authorizeUrl(S, "http://127.0.0.1:5000/callback", "ch", "st");
3664 assertTrue(url.startsWith(S + "/oauth/authorize?"));
modifiedapps/vscode/CHANGELOG.md+6−0View file
@@ -1,5 +1,11 @@
11 # Changelog
22
3+## 0.1.2
4+
5+- Works with any Diffinery, not only diffinery.com: the first sign-in asks which Diffinery you use, with **Use another Diffinery server…** for a self-hosted one.
6+- New command **Diffinery: Use Another Server…**. Each server keeps its own sign-in.
7+- Installed with a self-hosted Diffinery's `install-editor` script, the extension asks on its next start whether to use that server.
8+
39 ## 0.1.1
410
511 - "Sign In with an Access Token" goes through the editor's own sign-in, so the first clone afterwards no longer asks to allow the extension to sign in.
modifiedapps/vscode/README.md+21−3View file
@@ -6,8 +6,9 @@
66 - **Clone from Diffinery.** Run **Git: Clone** (or **Clone Repository** on the welcome page) and pick
77 **Clone from Diffinery**. You see your repositories and your organizations', most recently pushed first;
88 type to search everything you can read, or paste `owner/name`.
9-- **Sign in with your passkey.** The first time, the editor opens diffinery.com in your browser, you approve
10- the connection, and you're back in the editor. Diffinery shows up in the Accounts menu. Nothing to copy.
9+- **Sign in with your passkey.** The first time, choose your Diffinery (diffinery.com or your own server), the
10+ editor opens it in your browser, you approve the connection, and you're back in the editor. Diffinery shows up
11+ in the Accounts menu. Nothing to copy.
1112 - **Push and pull without passwords.** Git uses your signed-in account for diffinery.com. The extension renews
1213 its token before it expires and keeps the copy Git saved in your keychain current.
1314 - **Publish to Diffinery.** Publishing a local repository that has no remote offers **Publish to Diffinery**,
@@ -29,6 +30,22 @@
2930 The full guide, including JetBrains IDEs and the "Open in VS Code / Cursor" buttons on every repository, is at
3031 [diffinery.com/docs/repositories/clone-in-your-editor](https://diffinery.com/docs/repositories/clone-in-your-editor).
3132
33+## Self-hosted Diffinery
34+
35+The extension works with any Diffinery, not only diffinery.com:
36+
37+- **The first time you sign in** (from **Clone from Diffinery**, the Accounts menu or **Diffinery: Sign In**), the
38+ extension asks which Diffinery you use. Choose **Use another Diffinery server…** and enter its address, such as
39+ `git.example.com`. The extension checks that a Diffinery answers there and saves it in `diffinery.serverUrl`.
40+- **Diffinery: Use Another Server…** changes the server at any time. Each server keeps its own sign-in, so you can
41+ switch back without signing in again.
42+- **Installed from your server** with `curl -fsSL https://git.example.com/install-editor | bash` (or its
43+ `install-editor.ps1`), the extension asks on its next start whether to use that server: choose
44+ **Use git.example.com**. The installer leaves the address in `~/.config/diffinery/editor-server` for this; it
45+ doesn't edit the editor's settings.
46+
47+The extension then updates itself from your server too.
48+
3249 ## Commands
3350
3451 | Command | What it does |
@@ -36,6 +53,7 @@
3653 | Diffinery: Clone Repository | Opens the Diffinery repository picker directly. |
3754 | Diffinery: Sign In | Signs in through your browser. |
3855 | Diffinery: Sign In with an Access Token | Uses a token from Settings → Access tokens instead (agents, browser-based editors). |
56+| Diffinery: Use Another Server… | Switches to a self-hosted Diffinery (or back to diffinery.com). |
3957 | Diffinery: Sign Out | Signs out and disconnects the editor from your account. |
4058 | Diffinery: Check for Extension Updates | Installs a newer build from the server, if there is one. |
4159
@@ -43,6 +61,6 @@
4361
4462 | Setting | Default | |
4563 |---|---|---|
46-| `diffinery.serverUrl` | `https://diffinery.com` | Change only for a self-hosted Diffinery. |
64+| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to use: set it to your server's address for a self-hosted Diffinery. |
4765 | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account. |
4866 | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build. |
modifiedapps/vscode/install/install.ps1+24−0View file
@@ -27,6 +27,22 @@
2727 $vsix = Join-Path $tmp "diffinery-$($meta.version).vsix"
2828 Invoke-WebRequest "$server/-/vscode/diffinery-$($meta.version).vsix" -OutFile $vsix -UseBasicParsing
2929 if ((Get-FileHash $vsix -Algorithm SHA256).Hash.ToLower() -ne $meta.sha256) { throw "The download didn't match the published SHA-256; nothing was installed." }
30+ # A self-hosted Diffinery: leave its address where the extension looks when it starts (src/servers.ts), and it asks
31+ # whether to use it. Editors' command-line tools can't change a setting, and their settings files are JSON with
32+ # comments, so this doesn't edit them. Written before installing, since a running editor starts a new extension at once.
33+ $conf = if ($env:XDG_CONFIG_HOME -and [IO.Path]::IsPathRooted($env:XDG_CONFIG_HOME)) { $env:XDG_CONFIG_HOME } else { Join-Path $(if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }) ".config" }
34+ $hintFile = Join-Path $conf "diffinery\editor-server"
35+ $hint = $false
36+ if ($server -ne "https://diffinery.com") {
37+ try {
38+ New-Item -ItemType Directory -Force (Split-Path $hintFile) | Out-Null
39+ [IO.File]::WriteAllText($hintFile, "$server`n")
40+ $hint = $true
41+ } catch {}
42+ } else {
43+ Remove-Item -Force $hintFile -ErrorAction SilentlyContinue
44+ }
45+
3046 $installed = 0
3147 foreach ($e in $known) {
3248 if ($wanted.Count -gt 0 -and $wanted -notcontains $e.Name) { continue }
@@ -46,6 +62,14 @@
4662 exit 1
4763 }
4864 Write-Host ""
65+ if ($server -ne "https://diffinery.com") {
66+ if ($hint) {
67+ Write-Host "This Diffinery is $server. When the editor starts, the extension asks to use it: choose Use $($server -replace '^[a-z]+://', '')."
68+ Write-Host "If it doesn't ask, run Diffinery: Use Another Server... from the Command Palette and enter $server."
69+ } else {
70+ Write-Host "This Diffinery is $server`: in the editor, run Diffinery: Use Another Server... from the Command Palette and enter it."
71+ }
72+ }
4973 Write-Host "Next: in the editor, run Git: Clone and pick Clone from Diffinery. ($server/docs/repositories/clone-in-your-editor)"
5074 } finally {
5175 Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
modifiedapps/vscode/install/install.sh+20−0View file
@@ -65,6 +65,18 @@
6565 if command -v sha256sum >/dev/null; then got=$(sha256sum "$vsix" | cut -d' ' -f1); else got=$(shasum -a 256 "$vsix" | cut -d' ' -f1); fi
6666 [ "$got" = "$sha" ] || die "the download didn't match the published SHA-256; nothing was installed"
6767
68+# A self-hosted Diffinery: leave its address where the extension looks when it starts (src/servers.ts), and it asks
69+# whether to use it. Editors' command-line tools can't change a setting, and their settings files are JSON with
70+# comments, so this doesn't edit them. Written before installing, since a running editor starts a new extension at once.
71+conf=${XDG_CONFIG_HOME:-}
72+case $conf in /*) ;; *) conf=$HOME/.config ;; esac
73+hint=""
74+if [ "$server" != "https://diffinery.com" ]; then
75+ if mkdir -p "$conf/diffinery" 2>/dev/null && printf '%s\n' "$server" >"$conf/diffinery/editor-server" 2>/dev/null; then hint=1; fi
76+else
77+ rm -f "$conf/diffinery/editor-server" 2>/dev/null || true
78+fi
79+
6880 installed=0
6981 while IFS='|' read -r name label app; do
7082 if [ "$wanted" != " " ] && [[ $wanted != *" $name "* ]]; then continue; fi
@@ -86,4 +98,12 @@
8698 exit 1
8799 fi
88100 say ""
101+if [ "$server" != "https://diffinery.com" ]; then
102+ if [ -n "$hint" ]; then
103+ say "This Diffinery is ${bold}$server${reset}. When the editor starts, the extension asks to use it: choose ${bold}Use ${server#*://}${reset}."
104+ say "If it doesn't ask, run ${bold}Diffinery: Use Another Server…${reset} from the Command Palette and enter $server."
105+ else
106+ say "This Diffinery is ${bold}$server${reset}: in the editor, run ${bold}Diffinery: Use Another Server…${reset} from the Command Palette and enter it."
107+ fi
108+fi
89109 say "Next: in the editor, run ${bold}Git: Clone${reset} and pick ${bold}Clone from Diffinery${reset}. ${dim}($server/docs/repositories/clone-in-your-editor)${reset}"
modifiedapps/vscode/package.json+7−2View file
@@ -2,7 +2,7 @@
22 "name": "@diffinery/vscode",
33 "displayName": "Diffinery",
44 "description": "Clone, publish and push Diffinery repositories from VS Code, Cursor, Windsurf and other editors built on VS Code.",
5- "version": "0.1.1",
5+ "version": "0.1.2",
66 "publisher": "codician",
77 "private": true,
88 "license": "Apache-2.0",
@@ -86,6 +86,11 @@
8686 "category": "Diffinery"
8787 },
8888 {
89+ "command": "diffinery.useServer",
90+ "title": "Use Another Server…",
91+ "category": "Diffinery"
92+ },
93+ {
8994 "command": "diffinery.signOut",
9095 "title": "Sign Out",
9196 "category": "Diffinery"
@@ -103,7 +108,7 @@
103108 "type": "string",
104109 "default": "https://diffinery.com",
105110 "scope": "application",
106- "markdownDescription": "The Diffinery server to sign in to. Change it only for a self-hosted Diffinery."
111+ "markdownDescription": "The Diffinery server to sign in to: `https://diffinery.com`, or the address of a self-hosted Diffinery. The first sign-in asks which one, and **Diffinery: Use Another Server…** changes it."
107112 },
108113 "diffinery.gitAuthentication": {
109114 "type": "boolean",
modifiedapps/vscode/src/auth.ts+10−0View file
@@ -2,6 +2,7 @@
22 import { randomUUID } from "node:crypto";
33 import * as vscode from "vscode";
44 import { Api } from "./api";
5+import { chooseServer } from "./servers";
56 import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util";
67
78 export const AUTH_ID = "diffinery";
@@ -184,6 +185,10 @@
184185 }
185186
186187 async createSession(): Promise<vscode.AuthenticationSession> {
188+ // Every way in (the Accounts menu, the clone picker, Publish, the commands) first asks which Diffinery, until
189+ // someone has chosen, so a self-hosted one needs no trip to the settings. It may change diffinery.serverUrl, and
190+ // with it this.key().
191+ await chooseServer(this.ctx.globalState);
187192 const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser();
188193 // Signing in again replaces the old sign-in, and ends its connection on the server.
189194 const old = await this.load();
@@ -320,6 +325,11 @@
320325 return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token };
321326 }
322327
328+ /** Who is signed in to the server in the settings, if anyone. */
329+ async login(): Promise<string | undefined> {
330+ return (await this.load())?.login;
331+ }
332+
323333 async signOut(): Promise<void> {
324334 const s = await this.load();
325335 if (!s) {
modifiedapps/vscode/src/extension.ts+3−0View file
@@ -2,6 +2,7 @@
22 import { AUTH_ID, DiffineryAuth, SCOPES } from "./auth";
33 import { GitCredentials, Publisher, syncCredentialStore } from "./git";
44 import { RemoteSources } from "./remoteSources";
5+import { offerInstallerServer, switchServer } from "./servers";
56 import type { GitAPI, GitBaseAPI, GitBaseExtension, GitExtension } from "./typings/git";
67 import { checkForUpdates } from "./updates";
78
@@ -26,6 +27,7 @@
2627 command("diffinery.signIn", async () => vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }), "Signing in to Diffinery failed");
2728 command("diffinery.signInWithToken", () => auth.signInWithToken(), "Signing in to Diffinery failed");
2829 command("diffinery.signOut", () => auth.signOut(), "Signing out of Diffinery failed");
30+ command("diffinery.useServer", () => switchServer(ctx.globalState, () => auth.login()), "Changing the Diffinery server failed");
2931 command("diffinery.checkForUpdates", () => checkForUpdates(ctx, auth.server(), version, log, true), "Checking for updates failed");
3032
3133 // ---------------------------------------------------------------- the built-in Git extensions
@@ -86,6 +88,7 @@
8688 "Cloning from Diffinery failed",
8789 );
8890
91+ void offerInstallerServer(ctx.globalState, () => auth.login(), log).catch(report("Changing the Diffinery server failed"));
8992 void checkForUpdates(ctx, auth.server(), version, log, false).catch((e) => log.warn("update check failed", e));
9093 }
9194
modifiedapps/vscode/src/git.ts+5−3View file
@@ -10,14 +10,16 @@
1010 constructor(private readonly auth: DiffineryAuth) {}
1111
1212 async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> {
13- let server: URL;
13+ let server: string;
1414 try {
15- server = new URL(this.auth.server());
15+ server = this.auth.server();
1616 } catch {
1717 return undefined;
1818 }
19- if (host.authority.toLowerCase() !== server.host.toLowerCase()) return undefined;
19+ if (host.authority.toLowerCase() !== new URL(server).host.toLowerCase()) return undefined;
2020 const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true });
21+ // Signing in starts by choosing the server: if another one was chosen, its token isn't for this host.
22+ if (this.auth.server() !== server) return undefined;
2123 return { username: session.account.label, password: session.accessToken };
2224 }
2325 }
addedapps/vscode/src/servers.ts+122−0View file
@@ -1,0 +1,122 @@
1+import { readFile } from "node:fs/promises";
2+import { homedir } from "node:os";
3+import * as vscode from "vscode";
4+import { DEFAULT_SERVER, installHintPath, normalizeServer, readInstallHint, serverInputError, serverLabel } from "./util";
5+
6+/** Set (globalState) once someone has chosen a server, so signing in stops asking which one. */
7+const CHOSEN = "diffinery.serverChosen";
8+/** The installer's server this editor already asked about (globalState), so it asks once per address. */
9+const HINT_ANSWERED = "diffinery.installHintAnswered";
10+
11+/** The server in diffinery.serverUrl, or undefined when the setting isn't a usable address. */
12+export function configuredServer(): string | undefined {
13+ try {
14+ return normalizeServer(vscode.workspace.getConfiguration("diffinery").get<string>("serverUrl"));
15+ } catch {
16+ return undefined;
17+ }
18+}
19+
20+/** Saves the server in the user settings; diffinery.com clears the setting, so it follows the default. */
21+export async function useServer(state: vscode.Memento, server: string): Promise<void> {
22+ await vscode.workspace.getConfiguration("diffinery").update("serverUrl", server === DEFAULT_SERVER ? undefined : server, vscode.ConfigurationTarget.Global);
23+ await state.update(CHOSEN, true);
24+}
25+
26+/**
27+ * Asks for a self-hosted Diffinery's address, and checks that a Diffinery answers there before it's used, so a typo
28+ * ends here rather than at an error page in the browser. Undefined when cancelled.
29+ */
30+export async function askForServer(value?: string): Promise<string | undefined> {
31+ const raw = await vscode.window.showInputBox({
32+ title: "Use Another Diffinery Server",
33+ prompt: "The address of your Diffinery, as in your browser's address bar. The extension signs in, clones and pushes there.",
34+ placeHolder: "git.example.com",
35+ value,
36+ ignoreFocusOut: true,
37+ validateInput: serverInputError,
38+ });
39+ if (raw === undefined) return undefined;
40+ const server = normalizeServer(raw);
41+ const found = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Looking for Diffinery at ${serverLabel(server)}…` }, () => isDiffinery(server));
42+ if (found !== true) {
43+ const go = await vscode.window.showWarningMessage(`${server} doesn't look like a Diffinery server`, { modal: true, detail: `${found} Check the address, and use it anyway only if you're sure.` }, "Use Anyway");
44+ if (!go) return undefined;
45+ }
46+ return server;
47+}
48+
49+/** True when the server answers like Diffinery (every instance serves this extension at /-/vscode/), else what went wrong. */
50+async function isDiffinery(server: string): Promise<true | string> {
51+ try {
52+ const res = await fetch(`${server}/-/vscode/version.json`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
53+ if (!res.ok) return `${server}/-/vscode/version.json answered ${res.status}.`;
54+ const body = (await res.json()) as { version?: unknown };
55+ return typeof body.version === "string" ? true : `${server}/-/vscode/version.json isn't what Diffinery serves.`;
56+ } catch (e) {
57+ return `It couldn't be reached (${e instanceof Error ? e.message : String(e)}).`;
58+ }
59+}
60+
61+/**
62+ * The first sign-in asks which Diffinery: diffinery.com, or a self-hosted one by its address, so nobody has to find
63+ * the setting first. Once someone has chosen, or set diffinery.serverUrl themselves, it doesn't ask again
64+ * ("Diffinery: Use Another Server…" changes it later).
65+ */
66+export async function chooseServer(state: vscode.Memento): Promise<void> {
67+ if (state.get<boolean>(CHOSEN) || vscode.workspace.getConfiguration("diffinery").inspect<string>("serverUrl")?.globalValue !== undefined) return;
68+ const items: Array<vscode.QuickPickItem & { server?: string }> = [
69+ { label: `$(diffinery-logo) Sign in to ${serverLabel(DEFAULT_SERVER)}`, server: DEFAULT_SERVER },
70+ { label: "$(server) Use another Diffinery server…", detail: "A self-hosted Diffinery on your own domain" },
71+ ];
72+ const pick = await vscode.window.showQuickPick(items, { title: "Sign in to Diffinery", placeHolder: "Which Diffinery do you use?", ignoreFocusOut: true });
73+ if (!pick) throw new vscode.CancellationError();
74+ const server = pick.server ?? (await askForServer());
75+ if (!server) throw new vscode.CancellationError();
76+ await useServer(state, server);
77+}
78+
79+/** "Diffinery: Use Another Server…": switches the server. Sign-ins on other servers stay, for switching back. */
80+export async function switchServer(state: vscode.Memento, signedInAs: () => Promise<string | undefined>): Promise<void> {
81+ const server = await askForServer(configuredServer());
82+ if (!server) return;
83+ await useServer(state, server);
84+ await announce(server, await signedInAs());
85+}
86+
87+async function announce(server: string, login: string | undefined): Promise<void> {
88+ if (login) {
89+ void vscode.window.showInformationMessage(`Diffinery now uses ${serverLabel(server)}, where you're signed in as ${login}.`);
90+ return;
91+ }
92+ const pick = await vscode.window.showInformationMessage(`Diffinery now uses ${serverLabel(server)}. Sign in to clone and push there.`, "Sign In");
93+ if (pick) void vscode.commands.executeCommand("diffinery.signIn");
94+}
95+
96+/**
97+ * The installer that a self-hosted Diffinery serves leaves its address in a file (util.ts installHintPath), since an
98+ * editor's command line can't change a setting. The file only suggests: the person is asked, once per address.
99+ */
100+export async function offerInstallerServer(state: vscode.Memento, signedInAs: () => Promise<string | undefined>, log: vscode.LogOutputChannel): Promise<void> {
101+ let server: string | null;
102+ try {
103+ server = readInstallHint(await readFile(installHintPath(process.env, homedir()), "utf8"));
104+ } catch {
105+ return; // no file: installed from diffinery.com, or by hand
106+ }
107+ if (!server || state.get<string>(HINT_ANSWERED) === server) return;
108+ const current = configuredServer();
109+ if (server === current) {
110+ await state.update(HINT_ANSWERED, server);
111+ return;
112+ }
113+ log.info(`installed from ${server}; asking whether to use it`);
114+ const use = `Use ${serverLabel(server)}`;
115+ const keep = current ? `Keep ${serverLabel(current)}` : "Not Now";
116+ const pick = await vscode.window.showInformationMessage(`The Diffinery extension was installed from ${server}. Sign in, clone and push there?`, use, keep);
117+ if (!pick) return; // closed without answering: ask again next time
118+ await state.update(HINT_ANSWERED, server);
119+ if (pick !== use) return;
120+ await useServer(state, server);
121+ await announce(server, await signedInAs());
122+}
modifiedapps/vscode/src/util.ts+46−6View file
@@ -1,25 +1,65 @@
11 // Pure helpers, without the vscode module, so the unit tests can run them under Node.
22 import { createHash, randomBytes } from "node:crypto";
3+import * as path from "node:path";
34
45 export const EXTENSION_ID = "codician.diffinery";
56 /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */
67 export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"];
78 export const DEFAULT_SERVER = "https://diffinery.com";
89
9-/** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */
10-export function normalizeServer(raw: string | undefined): string {
11- const s = (raw ?? "").trim() || DEFAULT_SERVER;
10+function checkServer(s: string): { server: string } | { invalid: "url" | "https" } {
1211 let u: URL;
1312 try {
1413 u = new URL(s.includes("://") ? s : `https://${s}`);
1514 } catch {
16- throw new Error(`The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.`);
15+ return { invalid: "url" };
1716 }
1817 const loopback = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(u.hostname);
19- if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) throw new Error(`The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`);
20- return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}`;
18+ if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return { invalid: "https" };
19+ return { server: `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}` };
2120 }
2221
22+/** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */
23+export function normalizeServer(raw: string | undefined): string {
24+ const s = (raw ?? "").trim() || DEFAULT_SERVER;
25+ const r = checkServer(s);
26+ if ("server" in r) return r.server;
27+ throw new Error(r.invalid === "url" ? `The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.` : `The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`);
28+}
29+
30+/** Why what someone typed as a server address can't be used, for the input box; null when it can. */
31+export function serverInputError(raw: string): string | null {
32+ const s = raw.trim();
33+ if (!s) return "Enter your Diffinery's address, such as git.example.com.";
34+ if (/\s/.test(s)) return "An address has no spaces.";
35+ const r = checkServer(s);
36+ return "server" in r ? null : r.invalid === "url" ? `"${s}" isn't a valid address.` : "The address must use https (http only for a server on this computer).";
37+}
38+
39+/** A server as people read it: diffinery.com, git.example.com:8443. */
40+export function serverLabel(server: string): string {
41+ const u = new URL(server);
42+ return `${u.host}${u.pathname.replace(/\/+$/, "")}`;
43+}
44+
45+/**
46+ * Where the editor installer (install/install.sh, install.ps1) leaves the address of the self-hosted Diffinery that
47+ * served it, for the extension to offer on its next start: the CLI's config folder (~/.config/diffinery, or under
48+ * XDG_CONFIG_HOME), on every system.
49+ */
50+export function installHintPath(env: NodeJS.ProcessEnv, home: string): string {
51+ const xdg = env.XDG_CONFIG_HOME;
52+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || home, ".config");
53+ return path.join(base, "diffinery", "editor-server");
54+}
55+
56+/** The server named in the installer's hint file, or null when the file holds anything else. */
57+export function readInstallHint(text: string): string | null {
58+ const line = text.split(/\r?\n/, 1)[0]!.trim();
59+ if (!line || line.length > 2000 || serverInputError(line)) return null;
60+ return normalizeServer(line);
61+}
62+
2363 /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */
2464 export const clientId = (server: string) => `${server}/oauth/clients/editor.json`;
2565
modifiedapps/vscode/test/signin.test.ts+36−1View file
@@ -1,6 +1,8 @@
11 import { createHash } from "node:crypto";
22 import { describe, expect, it } from "vitest";
3-import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "../src/util";
3+import { readFileSync } from "node:fs";
4+import path from "node:path";
5+import { authorizeUrl, clientId, editorRedirect, installHintPath, normalizeServer, pkce, readCallback, readInstallHint, serverInputError, serverLabel } from "../src/util";
46
57 const S = "https://diffinery.com";
68
@@ -14,6 +16,39 @@
1416 expect(() => normalizeServer("https://")).toThrow(/valid URL/);
1517 });
1618
19+ it("checks a server address someone types, and shows it by its host", () => {
20+ expect(serverInputError("git.example.com")).toBeNull();
21+ expect(serverInputError(" https://git.example.com/ ")).toBeNull();
22+ expect(serverInputError("http://localhost:8787")).toBeNull();
23+ expect(serverInputError("")).toMatch(/Enter/);
24+ expect(serverInputError("git example")).toMatch(/spaces/);
25+ expect(serverInputError("http://git.example.com")).toMatch(/https/);
26+ expect(serverInputError("https://")).toMatch(/valid/);
27+ expect(serverLabel(S)).toBe("diffinery.com");
28+ expect(serverLabel("https://git.example.com:8443")).toBe("git.example.com:8443");
29+ });
30+
31+ it("reads the server the installer left, where the installers write it", () => {
32+ expect(installHintPath({ HOME: "/home/ada" }, "/ignored")).toBe(path.join("/home/ada", ".config", "diffinery", "editor-server"));
33+ expect(installHintPath({ HOME: "/home/ada", XDG_CONFIG_HOME: "/cfg" }, "/ignored")).toBe(path.join("/cfg", "diffinery", "editor-server"));
34+ expect(installHintPath({ XDG_CONFIG_HOME: "relative" }, "/home/bo")).toBe(path.join("/home/bo", ".config", "diffinery", "editor-server"));
35+ expect(readInstallHint("https://git.example.com/\n")).toBe("https://git.example.com");
36+ expect(readInstallHint("git.example.com\r\n")).toBe("https://git.example.com");
37+ expect(readInstallHint("http://git.example.com\n")).toBeNull();
38+ expect(readInstallHint("")).toBeNull();
39+ expect(readInstallHint("{\"server\": 1}")).toBeNull();
40+ const sh = readFileSync(path.join(__dirname, "../install/install.sh"), "utf8");
41+ const ps1 = readFileSync(path.join(__dirname, "../install/install.ps1"), "utf8");
42+ expect(sh).toContain('"$conf/diffinery/editor-server"');
43+ expect(ps1).toContain('"diffinery\\editor-server"');
44+ });
45+
46+ it("registers every command it contributes", () => {
47+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "../package.json"), "utf8"));
48+ const src = readFileSync(path.join(__dirname, "../src/extension.ts"), "utf8");
49+ for (const c of pkg.contributes.commands as { command: string }[]) expect(src).toContain(`"${c.command}"`);
50+ });
51+
1752 it("returns to the editor by its own scheme when the server knows it", () => {
1853 expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth");
1954 expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth");
modifiedplugins/README.md+38−8View file
@@ -2,7 +2,8 @@
22
33 A plugin marketplace, kept in the Diffinery repository itself, that makes Claude Code and
44 OpenAI's Codex CLI work smoothly with repositories hosted on [Diffinery](https://diffinery.com),
5-the git forge built for AI agents.
5+the open-source git forge built for AI agents: on diffinery.com or on any instance you deploy
6+yourself (see [Any Diffinery instance](#any-diffinery-instance)).
67 It has two plugins: `diffinery` (Claude Code and Codex) and `diffinery-ci` (Claude Code),
78 described below.
89
@@ -10,7 +11,7 @@
1011
1112 | Piece | Claude Code | Codex |
1213 | --- | --- | --- |
13-| `diffinery` MCP server (`https://diffinery.com/mcp`, OAuth sign-in) | yes | yes |
14+| `diffinery` MCP server (`<Diffinery URL>/mcp`, OAuth sign-in) | yes | yes (diffinery.com; see below for others) |
1415 | `diffinery` skill: the CLI, git auth, the branch → PR → CI → merge loop, Diffinery's agent rules | yes | yes |
1516 | `pr-status`: this branch's PR, its checks, and the log tail of failing jobs | `/diffinery:pr-status` command | `pr-status` skill |
1617 | SessionStart hook: in a Diffinery clone, tells the agent the repo is on Diffinery and whether the `diffinery` CLI is installed and signed in | yes | yes, once you trust it (`/hooks`) |
@@ -25,8 +26,10 @@
2526 | Status line entry (`PR #7 ✓ 10/10`) and a toast when CI passes or fails | Where Claude Code draws |
2627 | After the session pushes a branch or opens its PR, a note to Claude once that commit's checks settle (failures where the band shows the rest, passes too where nothing draws, so a cloud thread hears either way; `/diffinery-ci notes auto|failures|all|off` changes it, nothing to set up) | Everywhere hooks run, cloud sessions and project threads included |
2728
28-It reads Diffinery's GraphQL API with the token from `DIFFINERY_TOKEN`, `diffinery login`,
29-or gh signed in to the host (`diffinery auth setup-gh`), and sends it nowhere else. It needs
29+It reads the GraphQL API of the Diffinery instance in `DIFFINERY_URL`, the plugin's Diffinery
30+URL setting or the CLI's saved URL (in that order, else diffinery.com), with the token from
31+`DIFFINERY_TOKEN`, `diffinery login`, or gh signed in to the host (`diffinery auth setup-gh`),
32+and sends a token only to the instance it belongs to. It needs
3033 Claude Code 2.1.287 or later (the desktop app: 2.1.286). Cloud sessions run its hooks but
3134 draw no mod interface, so there it's the note to Claude and the `/diffinery-ci` text.
3235
@@ -77,6 +80,30 @@
7780 you review and trust them: open `/hooks` in the Codex TUI and trust the `diffinery`
7881 SessionStart hook (optional; the skill works without it).
7982
83+## Any Diffinery instance
84+
85+Diffinery is open source, and every plugin here works with any instance, not only
86+diffinery.com. Both Claude Code plugins have a **Diffinery URL** setting (`userConfig.url`,
87+default `https://diffinery.com`). It sets the MCP server's address (`.mcp.json` uses
88+`${user_config.url}/mcp`), the instance the SessionStart hook recognises in a clone (it gets
89+the value as `CLAUDE_PLUGIN_OPTION_URL`), and the instance `diffinery-ci` reads.
90+
91+- `diffinery setup --url https://git.example.com` sets it for you, in `pluginConfigs` in
92+ `~/.claude/settings.json`, for the copies it installs (`diffinery@inline`) and for
93+ marketplace installs alike. Install the CLI from the instance itself:
94+ `curl -fsSL https://git.example.com/install | bash`.
95+- Claude Code asks for it when you enable a marketplace install; change it later in `/config`.
96+- The zips an instance serves at `/-/claude/<name>.zip` default the setting to that instance
97+ (`apps/diffinery/src/docs/claude-plugins.ts`), so uploads to claude.ai need nothing else.
98+- The hook also knows `DIFFINERY_URL` and the URL `diffinery login` saved; `DIFFINERY_URL`
99+ takes precedence for the hook and for `diffinery-ci`, as it does for the CLI.
100+
101+Codex substitutes no plugin settings, so the Codex manifest reads its own
102+`codex.mcp.json`, which names `https://diffinery.com/mcp`. A server of your own named
103+`diffinery` replaces it, and `diffinery setup --url` adds one when Codex is installed:
104+`codex mcp add diffinery --url https://git.example.com/mcp`. The skills and the hook work
105+with any instance in Codex too.
106+
80107 ## Sign in
81108
82109 The MCP server signs you in with OAuth in the browser:
@@ -124,14 +151,15 @@
124151
125152 ## claude.ai and the Claude apps
126153
127-Settings → Connectors → Add custom connector, with the URL `https://diffinery.com/mcp`.
128-You sign in with your Diffinery account when you connect it.
154+Settings → Connectors → Add custom connector, with the URL `https://diffinery.com/mcp`
155+(or your own instance's `https://git.example.com/mcp`). You sign in with your Diffinery
156+account when you connect it.
129157
130158 ## gh
131159
132160 Prefer the `diffinery` CLI: it knows Diffinery's extras (`--intent`, auto-merge,
133161 `run logs`). `gh` works too once you run `diffinery auth setup-gh`, which signs gh in to
134-diffinery.com as a GitHub Enterprise host. That is also what lets Claude Code show the
162+the Diffinery host (diffinery.com or yours) as a GitHub Enterprise host. That is also what lets Claude Code show the
135163 branch's pull request with its CI status. See https://diffinery.com/docs/agents/github-cli.
136164
137165 ## Layout
@@ -144,7 +172,9 @@
144172 plugins/diffinery/
145173 .claude-plugin/plugin.json Claude Code manifest
146174 .codex-plugin/plugin.json Codex manifest
147- .mcp.json the diffinery MCP server (both clients)
175+ .claude-plugin/icon.png the listing icon (also in diffinery-ci)
176+ .mcp.json the diffinery MCP server (Claude Code: ${user_config.url}/mcp)
177+ codex.mcp.json the diffinery MCP server (Codex: diffinery.com)
148178 skills/diffinery/SKILL.md the skill (both clients)
149179 commands/pr-status.md /pr-status (Claude Code)
150180 codex-skills/pr-status/SKILL.md pr-status skill (Codex has no plugin commands)
addedplugins/diffinery-ci/.claude-plugin/icon.png+0−0View file
Binary file not shown.
modifiedplugins/diffinery-ci/.claude-plugin/plugin.json+14−3View file
@@ -1,12 +1,23 @@
11 {
22 "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
33 "name": "diffinery-ci",
4- "version": "0.2.3",
4+ "version": "0.2.4",
55 "description": "Live CI for Diffinery pull requests in Claude Code: the branch's PR and its checks above the prompt and in the status line, a checks pane, and a note to Claude when CI on a commit it pushed settles.",
6- "author": { "name": "Codician" },
6+ "author": { "name": "Codician Team" },
77 "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude",
88 "repository": "https://diffinery.com/codician/diffinery",
99 "license": "Apache-2.0",
10+ "icon": "./.claude-plugin/icon.png",
11+ "documentationUrl": "https://diffinery.com/docs/connect/ci-status-in-claude",
12+ "supportUrl": "https://diffinery.com/codician/diffinery/issues",
1013 "keywords": ["diffinery", "ci", "pull-requests", "status"],
11- "types": "./types/index.d.ts"
14+ "types": "./types/index.d.ts",
15+ "userConfig": {
16+ "url": {
17+ "type": "string",
18+ "title": "Diffinery URL",
19+ "description": "The Diffinery instance your repositories are on: https://diffinery.com, or your own, such as https://git.example.com.",
20+ "default": "https://diffinery.com"
21+ }
22+ }
1223 }
modifiedplugins/diffinery-ci/hooks/lib.ts+36−0View file
@@ -15,6 +15,8 @@
1515 */
1616 export function remoteRepo(url: string, host: string): string | null {
1717 const u = url.trim()
18+ // Remotes name the host with or without the port the Diffinery URL has (localhost:8787).
19+ host = host.replace(/:\d+$/, '')
1820 let rest: string | null = null
1921 const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/.exec(u)
2022 if (withScheme) {
@@ -39,6 +41,40 @@
3941 }
4042 }
4143
44+export const DEFAULT_URL = 'https://diffinery.com'
45+
46+/** What readAuth found: the environment, the plugin's Diffinery URL setting and the CLI's config file. */
47+export type AuthInputs = {
48+ envUrl: string | null
49+ envToken: string | null
50+ option: string | null
51+ savedUrl: string | null
52+ savedToken: string | null
53+}
54+
55+const clean = (url: string | null): string | null => {
56+ const u = url?.trim().replace(/\/+$/, '')
57+ return u && hostOf(u) ? u : null
58+}
59+
60+/**
61+ * Which Diffinery instance to read, and the token that belongs to it. The instance is
62+ * DIFFINERY_URL, else the plugin's Diffinery URL setting when it isn't the default, else the
63+ * URL `diffinery login` saved, else diffinery.com. A token only goes to its own instance, as
64+ * the CLI uses it: DIFFINERY_TOKEN to DIFFINERY_URL (else the saved URL, else diffinery.com),
65+ * the saved token to the saved URL (else diffinery.com). Null token: try gh for the host.
66+ */
67+export function chooseAuth(i: AuthInputs): { url: string; host: string; token: string | null; source: string } {
68+ const envUrl = clean(i.envUrl)
69+ const saved = clean(i.savedUrl)
70+ const option = clean(i.option)
71+ const url = envUrl ?? (option && option !== DEFAULT_URL ? option : null) ?? saved ?? DEFAULT_URL
72+ const host = hostOf(url)!
73+ if (i.envToken && hostOf(envUrl ?? saved ?? DEFAULT_URL) === host) return { url, host, token: i.envToken, source: 'DIFFINERY_TOKEN' }
74+ if (i.savedToken && hostOf(saved ?? DEFAULT_URL) === host) return { url, host, token: i.savedToken, source: 'diffinery login' }
75+ return { url, host, token: null, source: '' }
76+}
77+
4278 /** Picks the first Diffinery remote from `git remote -v`-style lines, origin first. */
4379 export function pickRemote(remotes: readonly { name: string; url: string }[], host: string): string | null {
4480 const ordered = [...remotes].sort((a, b) => Number(b.name === 'origin') - Number(a.name === 'origin'))
modifiedplugins/diffinery-ci/hooks/register.tsx+27−25View file
@@ -8,8 +8,10 @@
88 // where the band shows the rest, passes too where nothing draws, so a cloud thread hears either
99 // way. /diffinery-ci answers in text where nothing draws. Nothing to set up.
1010 //
11-// It reads Diffinery's GraphQL API with the token of the diffinery CLI (DIFFINERY_TOKEN or
12-// `diffinery login`) or of gh signed in to the Diffinery host, and sends it only there.
11+// It reads the GraphQL API of the Diffinery instance the session uses (DIFFINERY_URL, the
12+// plugin's Diffinery URL setting, the CLI's saved URL, else diffinery.com) with the token of
13+// the diffinery CLI (DIFFINERY_TOKEN or `diffinery login`) or of gh signed in to that host,
14+// and sends a token only to the instance it belongs to.
1315
1416 import { atom, read, update } from 'claude-code'
1517 import type { Register } from 'claude-code'
@@ -19,9 +21,9 @@
1921 QUERY,
2022 SYMBOL,
2123 checksLine,
24+ chooseAuth,
2225 duration,
2326 factsLine,
24- hostOf,
2527 ordered,
2628 parsePr,
2729 parseRemotes,
@@ -39,7 +41,6 @@
3941
4042 const PLUGIN = 'diffinery-ci'
4143 const PANE = 'diffinery-ci'
42-const DEFAULT_URL = 'https://diffinery.com'
4344
4445 // Change a tag when the stored value's fields change: it declines what an earlier version
4546 // stored, which outlives a reload.
@@ -60,7 +61,7 @@
6061 off: 'never',
6162 }
6263
63-export const register: Register = on => {
64+export const register: Register = (on, options) => {
6465 // What the person chose with `/diffinery-ci notes`, kept across sessions.
6566 let choice: WakeChoice = 'auto'
6667
@@ -86,28 +87,29 @@
8687 const seen = new Map<string, string>()
8788
8889 const readAuth = async (): Promise<Auth> => {
89- let url = (await $.env.get('DIFFINERY_URL')) ?? null
90- let token = (await $.env.get('DIFFINERY_TOKEN')) ?? null
91- let source = token ? 'DIFFINERY_TOKEN' : ''
92- if (!url || !token) {
93- const xdg = await $.env.get('XDG_CONFIG_HOME')
94- const home = await $.env.get('HOME')
95- const base = xdg && xdg.startsWith('/') ? xdg : home ? `${home}/.config` : null
96- if (base) {
97- try {
98- const cfg = JSON.parse(await $.fs.read(`${base}/diffinery/config.json`)) as Record<string, unknown>
99- if (!url && typeof cfg.url === 'string') url = cfg.url
100- if (!token && typeof cfg.token === 'string' && cfg.token) {
101- token = cfg.token
102- source = 'diffinery login'
103- }
104- } catch {
105- // No config file, or not JSON: the CLI isn't signed in here.
106- }
90+ let savedUrl: string | null = null
91+ let savedToken: string | null = null
92+ const xdg = await $.env.get('XDG_CONFIG_HOME')
93+ const home = await $.env.get('HOME')
94+ const base = xdg && xdg.startsWith('/') ? xdg : home ? `${home}/.config` : null
95+ if (base) {
96+ try {
97+ const cfg = JSON.parse(await $.fs.read(`${base}/diffinery/config.json`)) as Record<string, unknown>
98+ if (typeof cfg.url === 'string') savedUrl = cfg.url
99+ if (typeof cfg.token === 'string' && cfg.token) savedToken = cfg.token
100+ } catch {
101+ // No config file, or not JSON: the CLI isn't signed in here.
107102 }
108103 }
109- url = (url ?? DEFAULT_URL).replace(/\/+$/, '')
110- const host = hostOf(url) ?? 'diffinery.com'
104+ const picked = chooseAuth({
105+ envUrl: (await $.env.get('DIFFINERY_URL')) ?? null,
106+ envToken: (await $.env.get('DIFFINERY_TOKEN')) ?? null,
107+ option: typeof options?.url === 'string' ? options.url : null,
108+ savedUrl,
109+ savedToken,
110+ })
111+ const { url, host } = picked
112+ let { token, source } = picked
111113 if (!token) {
112114 try {
113115 const gh = await $.process.run(['gh', 'auth', 'token', '--hostname', host], { timeoutMs: 5000 })
modifiedplugins/diffinery-ci/tests/ci.test.tsx+23−0View file
@@ -3,6 +3,7 @@
33
44 import {
55 checksLine,
6+ chooseAuth,
67 parsePr,
78 pollDelay,
89 publishes,
@@ -50,6 +51,28 @@
5051 },
5152 })
5253
54+test('the instance comes from DIFFINERY_URL, the setting, then the CLI, and tokens stay with theirs', async () => {
55+ const none = { envUrl: null, envToken: null, option: null, savedUrl: null, savedToken: null }
56+ expect(chooseAuth(none)).toEqual({ url: 'https://diffinery.com', host: 'diffinery.com', token: null, source: '' })
57+ expect(chooseAuth({ ...none, option: 'https://diffinery.com', savedUrl: 'https://git.example.com/', savedToken: 't' })).toEqual({
58+ url: 'https://git.example.com',
59+ host: 'git.example.com',
60+ token: 't',
61+ source: 'diffinery login',
62+ })
63+ // The setting picks the instance, but the CLI's diffinery.com token doesn't go there.
64+ expect(chooseAuth({ ...none, option: 'https://git.example.com', savedToken: 't', envToken: 'e' })).toEqual({
65+ url: 'https://git.example.com',
66+ host: 'git.example.com',
67+ token: null,
68+ source: '',
69+ })
70+ expect(chooseAuth({ ...none, envUrl: 'http://localhost:8787', envToken: 'e', option: 'https://git.example.com' }).token).toBe('e')
71+ expect(chooseAuth({ ...none, envUrl: 'http://localhost:8787', envToken: 'e' }).host).toBe('localhost:8787')
72+ expect(chooseAuth({ ...none, option: 'not a url', savedToken: 't' })).toEqual({ url: 'https://diffinery.com', host: 'diffinery.com', token: 't', source: 'diffinery login' })
73+ expect(remoteRepo('http://localhost:8787/dev/repo.git', 'localhost:8787')).toBe('dev/repo')
74+})
75+
5376 test('remotes on the Diffinery host are read, lookalikes are not', async () => {
5477 expect(remoteRepo('https://diffinery.com/codician/demo.git', 'diffinery.com')).toBe('codician/demo')
5578 expect(remoteRepo('https://x-token@diffinery.com/codician/demo', 'diffinery.com')).toBe('codician/demo')
addedplugins/diffinery/.claude-plugin/icon.png+0−0View file
Binary file not shown.
modifiedplugins/diffinery/.claude-plugin/plugin.json+14−3View file
@@ -1,11 +1,22 @@
11 {
22 "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
33 "name": "diffinery",
4- "version": "0.1.3",
4+ "version": "0.1.4",
55 "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.",
6- "author": { "name": "Codician" },
6+ "author": { "name": "Codician Team" },
77 "homepage": "https://diffinery.com/docs/connect/claude-code",
88 "repository": "https://diffinery.com/codician/diffinery",
99 "license": "Apache-2.0",
10- "keywords": ["diffinery", "git", "pull-requests", "ci", "code-review", "mcp"]
10+ "icon": "./.claude-plugin/icon.png",
11+ "documentationUrl": "https://diffinery.com/docs/connect/claude-code",
12+ "supportUrl": "https://diffinery.com/codician/diffinery/issues",
13+ "keywords": ["diffinery", "git", "pull-requests", "ci", "code-review", "mcp"],
14+ "userConfig": {
15+ "url": {
16+ "type": "string",
17+ "title": "Diffinery URL",
18+ "description": "The Diffinery instance your repositories are on: https://diffinery.com, or your own, such as https://git.example.com.",
19+ "default": "https://diffinery.com"
20+ }
21+ }
1122 }
modifiedplugins/diffinery/.codex-plugin/plugin.json+4−4View file
@@ -1,9 +1,9 @@
11 {
22 "name": "diffinery",
3- "version": "0.1.3",
3+ "version": "0.1.4",
44 "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.",
55 "author": {
6- "name": "Codician"
6+ "name": "Codician Team"
77 },
88 "homepage": "https://diffinery.com/docs/connect/claude-code",
99 "repository": "https://diffinery.com/codician/diffinery",
@@ -20,12 +20,12 @@
2020 "./skills/",
2121 "./codex-skills/"
2222 ],
23- "mcpServers": "./.mcp.json",
23+ "mcpServers": "./codex.mcp.json",
2424 "interface": {
2525 "displayName": "Diffinery",
2626 "shortDescription": "Pull requests, CI and issues on Diffinery",
2727 "longDescription": "Lets Codex open pull requests, watch CI checks, read failing job logs and manage issues on repositories hosted on Diffinery, through the diffinery CLI and the Diffinery MCP server.",
28- "developerName": "Codician",
28+ "developerName": "Codician Team",
2929 "category": "Coding",
3030 "capabilities": [
3131 "Read",
modifiedplugins/diffinery/.mcp.json+1−1View file
@@ -2,7 +2,7 @@
22 "mcpServers": {
33 "diffinery": {
44 "type": "http",
5- "url": "https://diffinery.com/mcp"
5+ "url": "${user_config.url}/mcp"
66 }
77 }
88 }
modifiedplugins/diffinery/codex-skills/pr-status/SKILL.md+5−4View file
@@ -1,6 +1,6 @@
11 ---
22 name: pr-status
3-description: Show the Diffinery pull request for the current branch (or a given PR number), its CI checks, and the log tail of any failing job. Use when the user asks for PR status, CI status, or why checks failed in a repository hosted on diffinery.com. Codex counterpart of the Claude Code /pr-status command.
3+description: Show the Diffinery pull request for the current branch (or a given PR number), its CI checks, and the log tail of any failing job. Use when the user asks for PR status, CI status, or why checks failed in a repository hosted on Diffinery (diffinery.com or a self-hosted instance). Codex counterpart of the Claude Code /pr-status command.
44 ---
55
66 # PR status
@@ -11,9 +11,10 @@
1111 comments.
1212
1313 1. Run `git remote -v` and `git branch --show-current`. If no remote is on
14- diffinery.com, say this repository is not hosted on Diffinery and stop. If
15- `diffinery` is not installed, say how to install it
16- (`curl -fsSL https://diffinery.com/install | bash`) and stop.
14+ diffinery.com or another Diffinery instance (the one `diffinery whoami` names, or one
15+ the session's Diffinery note names), say this repository is not hosted on Diffinery
16+ and stop. If `diffinery` is not installed, say how to install it
17+ (`curl -fsSL https://HOST/install | bash`, with the repository's Diffinery host) and stop.
1718 2. Run `diffinery pr view [N]`. If there is no open pull request for the branch, say so
1819 (suggest `diffinery pr create ... --intent ...`) and stop.
1920 3. Run `diffinery pr checks [N]` (exit status 0 = passed, 1 = failed, 8 = pending; a
addedplugins/diffinery/codex.mcp.json+8−0View file
@@ -1,0 +1,8 @@
1+{
2+ "mcpServers": {
3+ "diffinery": {
4+ "type": "http",
5+ "url": "https://diffinery.com/mcp"
6+ }
7+ }
8+}
modifiedplugins/diffinery/commands/pr-status.md+4−3View file
@@ -9,9 +9,10 @@
99 change anything: no pushes, merges, re-runs or comments.
1010
1111 1. Run `git remote -v` and `git branch --show-current`. If no remote is on
12- diffinery.com, say this repository is not hosted on Diffinery and stop. If
13- `diffinery` is not installed, say how to install it
14- (`curl -fsSL https://diffinery.com/install | bash`) and stop.
12+ diffinery.com or another Diffinery instance (the one `diffinery whoami` names, or one
13+ the session's Diffinery note names), say this repository is not hosted on Diffinery
14+ and stop. If `diffinery` is not installed, say how to install it
15+ (`curl -fsSL https://HOST/install | bash`, with the repository's Diffinery host) and stop.
1516 2. Run `diffinery pr view $ARGUMENTS`. If there is no open pull request for the branch,
1617 say so (suggest `diffinery pr create ... --intent ...`) and stop.
1718 3. Run `diffinery pr checks $ARGUMENTS` (exit status 0 = passed, 1 = failed,
modifiedplugins/diffinery/hooks/session-start.sh+55−19View file
@@ -1,25 +1,56 @@
11 #!/bin/sh
22 # SessionStart hook for the diffinery plugin (Claude Code and Codex).
33 #
4-# When the session's working directory is a git repository with a remote on diffinery.com,
4+# When the session's working directory is a git repository with a remote on a Diffinery instance,
55 # prints JSON that adds a short note to the agent's context: where the repository is hosted,
6-# whether the diffinery CLI is installed and signed in, and that it is preferred over gh there.
6+# whether the diffinery CLI is installed and signed in there, and that it is preferred over gh.
77 # Elsewhere it prints nothing. It never touches the network and always exits 0.
8+#
9+# The instances it knows, in this order: DIFFINERY_URL, the plugin's Diffinery URL setting
10+# (CLAUDE_PLUGIN_OPTION_URL, Claude Code only), the URL `diffinery login` saved, and diffinery.com.
811
12+# The origin of a URL as scheme://host[:port], lower-cased; nothing unless it is plain http(s) with a
13+# host of letters, digits, dots and dashes, so it is safe to put in a pattern and in JSON.
14+origin_of() {
15+ printf '%s\n' "$1" | tr 'A-Z' 'a-z' |
16+ sed -n -E 's#^[[:space:]]*(https?://)?([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(:[0-9]{1,5})?(/.*)?[[:space:]]*$#\1\2\4#p' |
17+ sed '/:\/\//!s#^#https://#' | head -n 1
18+}
19+
920 main() {
1021 dir=${CLAUDE_PROJECT_DIR:-$PWD}
1122 command -v git >/dev/null 2>&1 || return 0
1223
13- # origin first, then any other remote. Only diffinery.com itself counts (not lookalike
14- # hosts), and owner/repo are limited to characters that are safe to put in JSON.
15- slug=$(
24+ case ${XDG_CONFIG_HOME:-} in
25+ /*) cfg=$XDG_CONFIG_HOME/diffinery/config.json ;;
26+ *) cfg=$HOME/.config/diffinery/config.json ;;
27+ esac
28+ saved=
29+ [ -f "$cfg" ] && saved=$(sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$cfg" 2>/dev/null | head -n 1)
30+
31+ # origin first, then any other remote.
32+ remotes=$(
1633 {
1734 git -C "$dir" remote get-url origin
1835 git -C "$dir" config --get-regexp '^remote\..*\.url$' | sed 's/^[^ ]* //'
19- } 2>/dev/null |
20- sed -n -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)?([^@/]+@)?diffinery\.com(:[0-9]+)?[:/]+([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/*$#\4/\5#p' |
21- head -n 1
36+ } 2>/dev/null
2237 )
38+ [ -n "$remotes" ] || return 0
39+
40+ # Only the instance's host itself counts (not lookalike hosts), and owner/repo are limited to
41+ # characters that are safe to put in JSON.
42+ slug= base=
43+ for u in "${DIFFINERY_URL:-}" "${CLAUDE_PLUGIN_OPTION_URL:-}" "$saved" https://diffinery.com; do
44+ base=$(origin_of "$u")
45+ [ -n "$base" ] || continue
46+ host=${base#*://}
47+ host=${host%%:*}
48+ pat=$(printf '%s' "$host" | sed 's/[.]/[.]/g')
49+ slug=$(printf '%s\n' "$remotes" |
50+ sed -n -E "s#^([A-Za-z][A-Za-z0-9+.-]*://)?([^@/]+@)?$pat(:[0-9]+)?[:/]+([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/*\$#\\4/\\5#p" |
51+ head -n 1)
52+ [ -n "$slug" ] && break
53+ done
2354 [ -n "$slug" ] || return 0
2455 slug=${slug%.git}
2556
@@ -30,22 +61,27 @@
3061 cli="yes, at ${DIFFINERY_INSTALL:-$HOME/.diffinery}/bin/diffinery (not on PATH)"
3162 fi
3263
64+ # The CLI's token belongs to the instance the CLI points at: DIFFINERY_URL, else the saved URL.
65+ cli_url=$(origin_of "${DIFFINERY_URL:-${saved:-https://diffinery.com}}")
3366 signed=no
34- case ${XDG_CONFIG_HOME:-} in
35- /*) cfg=$XDG_CONFIG_HOME/diffinery/config.json ;;
36- *) cfg=$HOME/.config/diffinery/config.json ;;
37- esac
38- if [ -n "${DIFFINERY_TOKEN:-}" ]; then
39- signed="yes (DIFFINERY_TOKEN)"
40- elif [ -f "$cfg" ] && grep -q '"token"' "$cfg" 2>/dev/null; then
41- signed=yes
67+ if [ "${cli_url#*://}" != "${base#*://}" ]; then
68+ other=yes
69+ else
70+ other=no
71+ if [ -n "${DIFFINERY_TOKEN:-}" ]; then
72+ signed="yes (DIFFINERY_TOKEN)"
73+ elif [ -f "$cfg" ] && grep -q '"token"' "$cfg" 2>/dev/null; then
74+ signed=yes
75+ fi
4276 fi
4377
44- msg="This repository is hosted on Diffinery ($slug). Use the diffinery CLI (installed: $cli, signed in: $signed) or the diffinery MCP tools for pull requests, CI and issues (they support Diffinery's --intent, auto-merge and run logs); gh works once \`diffinery auth setup-gh\` has signed it in to diffinery.com. The diffinery skill has the workflow."
78+ msg="This repository is hosted on the Diffinery instance at $base ($slug). Use the diffinery CLI (installed: $cli, signed in there: $signed) or the diffinery MCP tools for pull requests, CI and issues (they support Diffinery's --intent, auto-merge and run logs); gh works once \`diffinery auth setup-gh\` has signed it in to $host. The diffinery skill has the workflow."
4579 case $cli in
46- no) msg="$msg Install the CLI with \`curl -fsSL https://diffinery.com/install | bash\`." ;;
80+ no) msg="$msg Install the CLI with \`curl -fsSL $base/install | bash\`." ;;
4781 esac
48- if [ "$signed" = no ]; then
82+ if [ "$other" = yes ]; then
83+ msg="$msg The CLI points at ${cli_url:-another instance}: to use it here, ask the user to run \`diffinery setup --url $base\` (or \`diffinery login --url $base\`), or set DIFFINERY_URL=$base and DIFFINERY_TOKEN."
84+ elif [ "$signed" = no ]; then
4985 msg="$msg To sign in, ask the user to run \`diffinery login\` (or \`diffinery login --device\`), or set DIFFINERY_TOKEN."
5086 fi
5187
modifiedplugins/diffinery/skills/diffinery/SKILL.md+24−11View file
@@ -1,13 +1,16 @@
11 ---
22 name: diffinery
3-description: Work with repositories hosted on Diffinery (diffinery.com), the git forge built for AI agents. Use whenever the current repository has a git remote on diffinery.com, or the user mentions Diffinery, or asks about pull requests, CI, checks, workflow runs, job logs, merges or issues in a Diffinery-hosted repository. Covers the diffinery CLI (preferred over gh here), the diffinery MCP tools, git auth and the agent rules.
3+description: Work with repositories hosted on Diffinery (diffinery.com or a self-hosted instance), the open-source git forge built for AI agents. Use whenever the current repository has a git remote on diffinery.com or another Diffinery instance, or the user mentions Diffinery, or asks about pull requests, CI, checks, workflow runs, job logs, merges or issues in a Diffinery-hosted repository. Covers the diffinery CLI (preferred over gh here), the diffinery MCP tools, git auth and the agent rules.
44 ---
55
66 # Diffinery
77
8-Diffinery is a git forge built for AI agents. It speaks git over HTTPS and has a
9-GitHub-compatible API. Prefer the `diffinery` CLI or the `diffinery` MCP tools for pull
10-requests, CI and issues: they know Diffinery's extras (`--intent`, auto-merge, run logs).
8+Diffinery is an open-source git forge built for AI agents. It speaks git over HTTPS and
9+has a GitHub-compatible API. It runs at https://diffinery.com and on instances people deploy
10+on their own Cloudflare accounts; everything here works the same on each, with that
11+instance's URL in place of `https://diffinery.com` (written `$URL` below). Prefer the
12+`diffinery` CLI or the `diffinery` MCP tools for pull requests, CI and issues: they know
13+Diffinery's extras (`--intent`, auto-merge, run logs).
1114
1215 ## Is this repository on Diffinery?
1316
@@ -16,15 +19,19 @@
1619 ```
1720
1821 A remote like `https://diffinery.com/OWNER/REPO.git` means yes, and everything below
19-applies. `gh` works here once `diffinery auth setup-gh` has signed it in to diffinery.com
22+applies. A remote on another host may be a self-hosted Diffinery: the SessionStart note
23+says so when the host is the plugin's Diffinery URL setting, `DIFFINERY_URL` or the URL
24+`diffinery login` saved, and `curl -fsS https://HOST/api/v3/meta` answers
25+`{"name":"Diffinery",...}` on any Diffinery. `diffinery whoami` prints the instance the
26+CLI uses. `gh` works here once `diffinery auth setup-gh` has signed it in to the instance
2027 (it treats Diffinery as a GitHub Enterprise host); until then `gh` has no token for it.
2128 Prefer `diffinery`: it knows Diffinery's extras (`--intent`, `run logs`).
2229
2330 ## Set up (once per machine)
2431
2532 ```sh
26-command -v diffinery || curl -fsSL https://diffinery.com/install | bash # Windows: irm https://diffinery.com/install.ps1 | iex
27-diffinery whoami # who you are signed in as
33+command -v diffinery || curl -fsSL $URL/install | bash # Windows: irm $URL/install.ps1 | iex
34+diffinery whoami # who you are signed in as, and on which instance
2835 ```
2936
3037 The installer puts the CLI in `~/.diffinery/bin`; if `diffinery` is not found right
@@ -36,12 +43,15 @@
3643 - Interactive machine: ask the user to run `diffinery login` (opens a browser) or
3744 `diffinery login --device` (prints a code to enter elsewhere). Do not try to complete
3845 a browser sign-in yourself.
46+- Another instance than diffinery.com: `diffinery setup --url $URL` (or
47+ `diffinery login --url $URL`) points the CLI there and saves it; for one command or a
48+ headless machine, set `DIFFINERY_URL=$URL` with `DIFFINERY_TOKEN`.
3949
40-Git auth: `diffinery auth setup-git` installs a credential helper for diffinery.com
50+Git auth: `diffinery auth setup-git` installs a credential helper for the instance
4151 that uses the CLI's token (including `DIFFINERY_TOKEN`). Without the CLI, git accepts
4252 the token as the HTTPS password. Plain git (clone, fetch, push) needs nothing else.
4353
44-gh: `diffinery auth setup-gh` signs `gh` in to diffinery.com with the same token, so
54+gh: `diffinery auth setup-gh` signs `gh` in to the instance with the same token, so
4555 `gh pr ...` works in Diffinery clones (needs `gh` installed).
4656
4757 ## The loop
@@ -109,8 +119,11 @@
109119 `search_docs`, `read_docs`, ...): use them when there is no shell or CLI, for a repo
110120 you have not cloned, or when the user prefers them. If they say authentication is
111121 needed, ask the user to sign in (`/mcp` in Claude Code, `codex mcp login diffinery`
112- in Codex), or use the CLI instead.
113-- Diffinery's own documentation: `search_docs` / `read_docs`, or https://diffinery.com/docs.
122+ in Codex), or use the CLI instead. The plugin's server is `https://diffinery.com/mcp`
123+ unless its Diffinery URL setting (Claude Code: `/plugin`, then the plugin's options) says
124+ otherwise; Codex users on another instance add their own server named `diffinery` with
125+ `codex mcp add diffinery --url $URL/mcp`.
126+- Diffinery's own documentation: `search_docs` / `read_docs`, or `$URL/docs`.
114127
115128 ## Rules Diffinery enforces (work with them, not around them)
116129
modifiedscripts/pack-plugins.py+3−1View file
@@ -17,6 +17,8 @@
1717
1818 ROOT = pathlib.Path(__file__).resolve().parent.parent
1919 SKIP_DIRS = {"tests", ".codex-plugin", "codex-skills", "node_modules"}
20+# Codex reads its own MCP config: Codex substitutes no ${user_config.*} values.
21+SKIP_FILES = {"codex.mcp.json"}
2022 STAMP = (2026, 1, 1, 0, 0, 0)
2123
2224
@@ -24,7 +26,7 @@
2426 out = []
2527 for p in plugin.rglob("*"):
2628 rel = p.relative_to(plugin)
27- if not p.is_file() or any(part in SKIP_DIRS for part in rel.parts):
29+ if not p.is_file() or any(part in SKIP_DIRS for part in rel.parts) or rel.as_posix() in SKIP_FILES:
2830 continue
2931 # The engine lays generated type files beside a mod it loads from a working folder.
3032 if rel.parts[:2] == (".claude-plugin", "types"):