Plugins, CLI and MCP config work with any Diffinery instance
The Claude Code plugins get a Diffinery URL setting (userConfig url, default https://diffinery.com) that drives the MCP server, the SessionStart hook and the diffinery-ci mod; the hook also knows DIFFINERY_URL and the CLI's saved URL, and the mod sends a token only to the instance it belongs to. Codex reads its own codex.mcp.json. A self-hosted instance serves the plugin zips with the setting defaulting to itself, and diffinery setup --url sets it in Claude Code's pluginConfigs and points Codex's diffinery MCP server there. Docs for self-hosted instances across the Connect pages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TdJysKcGD4ekSD5ZHbwBmJ
40 files changed+624−109
Files (40)
- .claude-plugin/marketplace.json+6−6
- apps/cli/src/cli.ts+2−1
- apps/cli/src/commands/auth.ts+12−1
- apps/cli/src/commands/claude-plugins.ts+35−5
- apps/cli/src/commands/codex.ts+55−0
- apps/cli/test/helpers.ts+2−1
- apps/cli/test/setup.test.ts+68−0
- apps/diffinery/claude-plugins/diffinery-0.1.3.zip+0−0
- apps/diffinery/claude-plugins/diffinery-0.1.4.zip+0−0
- apps/diffinery/claude-plugins/diffinery-ci-0.2.3.zip+0−0
- apps/diffinery/claude-plugins/diffinery-ci-0.2.4.zip+0−0
- apps/diffinery/docs/agents/mcp.md+1−1
- apps/diffinery/docs/connect.md+2−0
- apps/diffinery/docs/connect/ci-status-in-claude.md+5−2
- apps/diffinery/docs/connect/claude-code.md+19−1
- apps/diffinery/docs/connect/claude.md+1−1
- apps/diffinery/docs/connect/cloud-agents.md+2−0
- apps/diffinery/docs/connect/codex.md+13−1
- apps/diffinery/docs/connect/other-apps.md+2−0
- apps/diffinery/docs/self-hosting.md+1−0
- apps/diffinery/src/docs/claude-plugins.ts+32−0
- apps/diffinery/src/docs/web.tsx+13−6
- apps/diffinery/src/lib/archive.ts+48−1
- apps/diffinery/test/unit/claude-plugins.test.ts+49−0
- plugins/README.md+38−8
- plugins/diffinery-ci/.claude-plugin/icon.png+0−0
- plugins/diffinery-ci/.claude-plugin/plugin.json+14−3
- plugins/diffinery-ci/hooks/lib.ts+36−0
- plugins/diffinery-ci/hooks/register.tsx+27−25
- plugins/diffinery-ci/tests/ci.test.tsx+23−0
- plugins/diffinery/.claude-plugin/icon.png+0−0
- plugins/diffinery/.claude-plugin/plugin.json+14−3
- plugins/diffinery/.codex-plugin/plugin.json+4−4
- plugins/diffinery/.mcp.json+1−1
- plugins/diffinery/codex-skills/pr-status/SKILL.md+5−4
- plugins/diffinery/codex.mcp.json+8−0
- plugins/diffinery/commands/pr-status.md+4−3
- plugins/diffinery/hooks/session-start.sh+55−19
- plugins/diffinery/skills/diffinery/SKILL.md+24−11
- scripts/pack-plugins.py+3−1
modified.claude-plugin/marketplace.json+6−6View file
| @@ -2,21 +2,21 @@ | ||
| 2 | 2 | "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", |
| 3 | 3 | "name": "diffinery", |
| 4 | 4 | "owner": { |
| 5 | - "name": "Codician", | |
| 5 | + "name": "Codician Team", | |
| 6 | 6 | "url": "https://diffinery.com" |
| 7 | 7 | }, |
| 8 | 8 | "metadata": { |
| 9 | 9 | "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" | |
| 11 | 11 | }, |
| 12 | 12 | "plugins": [ |
| 13 | 13 | { |
| 14 | 14 | "name": "diffinery", |
| 15 | 15 | "source": "./plugins/diffinery", |
| 16 | 16 | "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", | |
| 18 | 18 | "author": { |
| 19 | - "name": "Codician" | |
| 19 | + "name": "Codician Team" | |
| 20 | 20 | }, |
| 21 | 21 | "homepage": "https://diffinery.com/docs/connect/claude-code", |
| 22 | 22 | "repository": "https://diffinery.com/codician/diffinery", |
| @@ -35,9 +35,9 @@ | ||
| 35 | 35 | "name": "diffinery-ci", |
| 36 | 36 | "source": "./plugins/diffinery-ci", |
| 37 | 37 | "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", | |
| 39 | 39 | "author": { |
| 40 | - "name": "Codician" | |
| 40 | + "name": "Codician Team" | |
| 41 | 41 | }, |
| 42 | 42 | "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude", |
| 43 | 43 | "repository": "https://diffinery.com/codician/diffinery", |
modifiedapps/cli/src/cli.ts+2−1View file
| @@ -87,7 +87,8 @@ | ||
| 87 | 87 | if (r.code === 0) { |
| 88 | 88 | const parsed = parseRemote(r.stdout, host); |
| 89 | 89 | 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)`); | |
| 91 | 92 | } |
| 92 | 93 | throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository"); |
| 93 | 94 | } |
modifiedapps/cli/src/commands/auth.ts+12−1View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { json } from "../output.js"; |
| 9 | 9 | import { agentSetup, ghEnvToken, setupRepo } from "./agent-setup.js"; |
| 10 | 10 | import { setupClaudePlugins } from "./claude-plugins.js"; |
| 11 | +import { setupCodexMcp } from "./codex.js"; | |
| 11 | 12 | |
| 12 | 13 | interface User { |
| 13 | 14 | login: string; |
| @@ -257,7 +258,10 @@ | ||
| 257 | 258 | sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Where Claude Code is |
| 258 | 259 | installed, it also adds Claude Code's Diffinery plugins (diffinery and diffinery-ci): they're saved in |
| 259 | 260 | ~/.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 | |
| 261 | 265 | whether to turn on auto-merge for the pull requests you open (--auto-merge says yes without asking, |
| 262 | 266 | --no-auto-merge skips the question). Safe to run again; it brings the plugins up to date. |
| 263 | 267 | |
| @@ -333,6 +337,13 @@ | ||
| 333 | 337 | } |
| 334 | 338 | } |
| 335 | 339 | 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 { | |
| 336 | 347 | await offerAutoMerge(new Ctx(ctx.io, {}), url, { yes: o["auto-merge"] === true, skip: o["no-auto-merge"] === true }); |
| 337 | 348 | } catch (e) { |
| 338 | 349 | 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 @@ | ||
| 12 | 12 | * they're saved in Claude Code's settings directory and named in the `env` block of its user settings as |
| 13 | 13 | * CLAUDE_CODE_PLUGIN_DIRS, which Claude Code reads at the start of every session: the terminal, the |
| 14 | 14 | * 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. | |
| 16 | 18 | */ |
| 17 | 19 | |
| 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 | + | |
| 18 | 23 | export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const; |
| 19 | 24 | |
| 20 | 25 | /** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */ |
| @@ -46,6 +51,23 @@ | ||
| 46 | 51 | return [...kept, ...ours].join(path.delimiter); |
| 47 | 52 | } |
| 48 | 53 | |
| 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 | + | |
| 49 | 71 | export interface PluginsResult { |
| 50 | 72 | ok: boolean; |
| 51 | 73 | message: string; |
| @@ -101,15 +123,23 @@ | ||
| 101 | 123 | got.push(`${name} ${res.headers.get("x-diffinery-plugin-version") ?? ""}`.trim()); |
| 102 | 124 | } |
| 103 | 125 | |
| 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 }; | |
| 108 | 137 | await writeFile(`${settingsFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`); |
| 109 | 138 | await rename(`${settingsFile}.tmp`, settingsFile); |
| 110 | 139 | } |
| 111 | 140 | const parts = []; |
| 112 | 141 | if (got.length) parts.push(`${got.join(" and ")} load in new Claude Code sessions (named in ${settingsFile})`); |
| 113 | 142 | if (fromMarketplace.length) parts.push(`${fromMarketplace.join(" and ")} already installed from the Diffinery marketplace`); |
| 143 | + if (configs) parts.push(`their Diffinery URL is ${url}`); | |
| 114 | 144 | return { ok: true, message: parts.join("; ") }; |
| 115 | 145 | } |
addedapps/cli/src/commands/codex.ts+55−0View file
| @@ -1,0 +1,55 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Codex's `diffinery` MCP server, pointed at a self-hosted Diffinery by `diffinery setup`. The Codex plugin's | |
| 5 | + * server is https://diffinery.com/mcp (Codex has no plugin settings to change it), and a server of the same | |
| 6 | + * name in Codex's own config replaces it, so for another instance this adds that server with | |
| 7 | + * `codex mcp add`. Nothing happens for diffinery.com, or where Codex isn't installed. | |
| 8 | + */ | |
| 9 | + | |
| 10 | +const DEFAULT_URL = "https://diffinery.com"; | |
| 11 | + | |
| 12 | +function codex(env: NodeJS.ProcessEnv, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { | |
| 13 | + return new Promise((resolve) => { | |
| 14 | + let child; | |
| 15 | + try { | |
| 16 | + child = spawn("codex", args, { env, stdio: ["ignore", "pipe", "pipe"] }); | |
| 17 | + } catch (e) { | |
| 18 | + return resolve({ code: 127, stdout: "", stderr: (e as Error).message }); | |
| 19 | + } | |
| 20 | + let stdout = ""; | |
| 21 | + let stderr = ""; | |
| 22 | + child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 23 | + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 24 | + child.on("error", (e) => resolve({ code: 127, stdout, stderr: e.message })); | |
| 25 | + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); | |
| 26 | + }); | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface CodexResult { | |
| 30 | + ok: boolean; | |
| 31 | + message: string; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** Null when there's nothing to do: diffinery.com, DIFFINERY_NO_CODEX, or no `codex` on PATH. */ | |
| 35 | +export async function setupCodexMcp(env: NodeJS.ProcessEnv, url: string): Promise<CodexResult | null> { | |
| 36 | + if (url === DEFAULT_URL || env.DIFFINERY_NO_CODEX) return null; | |
| 37 | + const want = `${url}/mcp`; | |
| 38 | + // The server Codex uses now: the user's own, else the plugin's, else none (exit status 1). | |
| 39 | + const got = await codex(env, ["mcp", "get", "diffinery", "--json"]); | |
| 40 | + if (got.code === 127) return null; | |
| 41 | + let bearer: string | null = null; | |
| 42 | + if (got.code === 0) { | |
| 43 | + try { | |
| 44 | + const t = (JSON.parse(got.stdout) as { transport?: { url?: unknown; bearer_token_env_var?: unknown } }).transport ?? {}; | |
| 45 | + if (t.url === want) return { ok: true, message: `the diffinery MCP server is ${want}` }; | |
| 46 | + // A headless setup that sends DIFFINERY_TOKEN keeps doing so. | |
| 47 | + if (typeof t.bearer_token_env_var === "string") bearer = t.bearer_token_env_var; | |
| 48 | + } catch { | |
| 49 | + // An answer we can't read: add the server all the same. | |
| 50 | + } | |
| 51 | + } | |
| 52 | + const add = await codex(env, ["mcp", "add", "diffinery", "--url", want, ...(bearer ? ["--bearer-token-env-var", bearer] : [])]); | |
| 53 | + if (add.code !== 0) return { ok: false, message: `codex mcp add failed: ${(add.stderr.trim().split("\n")[0] || `exit status ${add.code}`).slice(0, 200)}` }; | |
| 54 | + return { ok: true, message: `the diffinery MCP server is now ${want} (sign in with: codex mcp login diffinery)` }; | |
| 55 | +} |
modifiedapps/cli/test/helpers.ts+2−1View file
| @@ -129,8 +129,9 @@ | ||
| 129 | 129 | GIT_AUTHOR_EMAIL: "test@example.com", |
| 130 | 130 | GIT_COMMITTER_NAME: "Test", |
| 131 | 131 | 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. | |
| 133 | 133 | DIFFINERY_NO_CLAUDE_PLUGINS: "1", |
| 134 | + DIFFINERY_NO_CODEX: "1", | |
| 134 | 135 | ...extra, |
| 135 | 136 | }; |
| 136 | 137 | } |
modifiedapps/cli/test/setup.test.ts+68−0View file
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; |
| 4 | 4 | import path from "node:path"; |
| 5 | 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 6 | +import { withUrlOption } from "../src/commands/claude-plugins.js"; | |
| 6 | 7 | import { configPath, writeConfig } from "../src/config.js"; |
| 7 | 8 | import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; |
| 8 | 9 | |
| @@ -303,6 +304,22 @@ | ||
| 303 | 304 | expect(dirsOf(market)).toEqual([path.join(market.HOME!, ".claude", "diffinery-plugins", "diffinery.zip")]); |
| 304 | 305 | }); |
| 305 | 306 | |
| 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 | + | |
| 306 | 323 | it("leaves settings it can't read alone, and refuses a download that doesn't match its checksum", async () => { |
| 307 | 324 | const broken = await withClaude("{ not json"); |
| 308 | 325 | const r = await run(["setup"], { env: broken }); |
| @@ -318,3 +335,54 @@ | ||
| 318 | 335 | expect(existsSync(path.join(bad.HOME!, ".claude", "settings.json"))).toBe(false); |
| 319 | 336 | }); |
| 320 | 337 | }); |
| 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 | + /** A codex that records its calls and answers `mcp get` with `server` (none: exit status 1, as codex does). */ | |
| 348 | + function fakeCodex(server: unknown): { dir: string; calls: () => string[] } { | |
| 349 | + const dir = tempDir("diffinery-fake-codex-"); | |
| 350 | + const log = path.join(dir, "calls"); | |
| 351 | + writeFileSync(path.join(dir, "answer.json"), JSON.stringify(server ?? null)); | |
| 352 | + writeFileSync( | |
| 353 | + path.join(dir, "codex"), | |
| 354 | + `#!/bin/sh\nprintf '%s\\n' "$*" >> "${log}"\nif [ "$1 $2" = "mcp get" ]; then ${server ? `cat "${dir}/answer.json"` : "echo 'Error: No MCP server named' >&2; exit 1"}; fi\nexit 0\n`, | |
| 355 | + ); | |
| 356 | + chmodSync(path.join(dir, "codex"), 0o755); | |
| 357 | + return { dir, calls: () => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []) }; | |
| 358 | + } | |
| 359 | + const machine = async (codexDir: string) => { | |
| 360 | + const env = isolatedEnv({ DIFFINERY_NO_CODEX: "", PATH: `${codexDir}${path.delimiter}${process.env.PATH}` }); | |
| 361 | + await writeConfig(env, { url: srv.url, token: "dfy_good" }); | |
| 362 | + return env; | |
| 363 | + }; | |
| 364 | + | |
| 365 | + it("points Codex's diffinery MCP server at a self-hosted server, keeping a headless token setting", async () => { | |
| 366 | + const plugin = fakeCodex({ name: "diffinery", transport: { type: "streamable_http", url: "https://diffinery.com/mcp", bearer_token_env_var: null } }); | |
| 367 | + const r = await run(["setup", "--no-auto-merge"], { env: await machine(plugin.dir) }); | |
| 368 | + expect(r.code).toBe(0); | |
| 369 | + expect(r.stdout).toContain(`✓ Codex: the diffinery MCP server is now ${srv.url}/mcp`); | |
| 370 | + expect(plugin.calls()).toEqual(["mcp get diffinery --json", `mcp add diffinery --url ${srv.url}/mcp`]); | |
| 371 | + | |
| 372 | + const headless = fakeCodex({ name: "diffinery", transport: { url: "https://old.example/mcp", bearer_token_env_var: "DIFFINERY_TOKEN" } }); | |
| 373 | + await run(["setup", "--no-auto-merge"], { env: await machine(headless.dir) }); | |
| 374 | + expect(headless.calls()[1]).toBe(`mcp add diffinery --url ${srv.url}/mcp --bearer-token-env-var DIFFINERY_TOKEN`); | |
| 375 | + | |
| 376 | + const done = fakeCodex({ name: "diffinery", transport: { url: `${srv.url}/mcp` } }); | |
| 377 | + const again = await run(["setup", "--no-auto-merge"], { env: await machine(done.dir) }); | |
| 378 | + expect(again.stdout).toContain(`✓ Codex: the diffinery MCP server is ${srv.url}/mcp`); | |
| 379 | + expect(done.calls()).toEqual(["mcp get diffinery --json"]); | |
| 380 | + }); | |
| 381 | + | |
| 382 | + it("leaves Codex alone for diffinery.com", async () => { | |
| 383 | + const codex = fakeCodex(null); | |
| 384 | + const { setupCodexMcp } = await import("../src/commands/codex.js"); | |
| 385 | + expect(await setupCodexMcp({ ...(await machine(codex.dir)) }, "https://diffinery.com")).toBe(null); | |
| 386 | + expect(codex.calls()).toEqual([]); | |
| 387 | + }); | |
| 388 | +}); |
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 @@ | ||
| 9 | 9 | |
| 10 | 10 | | Property | Value | |
| 11 | 11 | | --- | --- | |
| 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` | | |
| 13 | 13 | | Transport | Streamable HTTP. Send JSON-RPC 2.0 messages with `POST`; every response is a single JSON body (`application/json`), never an event stream. | |
| 14 | 14 | | Sessions | None. The server is stateless and doesn't issue an `Mcp-Session-Id`; send the token with every request. | |
| 15 | 15 | | Batches | A JSON array of requests gets a JSON array of responses. | |
modifiedapps/diffinery/docs/connect.md+2−0View file
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | https://diffinery.com/mcp |
| 10 | 10 | ``` |
| 11 | 11 | |
| 12 | +On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`. | |
| 13 | + | |
| 12 | 14 | 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. |
| 13 | 15 | |
| 14 | 16 |  |
modifiedapps/diffinery/docs/connect/ci-status-in-claude.md+5−2View file
| @@ -27,7 +27,7 @@ | ||
| 27 | 27 | ```text |
| 28 | 28 | ✓ git will use diffinery for https://diffinery.com |
| 29 | 29 | ✓ 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) | |
| 31 | 31 | ``` |
| 32 | 32 | |
| 33 | 33 | 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 @@ | ||
| 37 | 37 | > [!NOTE] |
| 38 | 38 | > `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. |
| 39 | 39 | |
| 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 | + | |
| 40 | 43 | ## In cloud sessions and project threads |
| 41 | 44 | |
| 42 | 45 | 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 @@ | ||
| 94 | 97 | | Problem | What to do | |
| 95 | 98 | | --- | --- | |
| 96 | 99 | | 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. | | |
| 98 | 101 | | `/diffinery-ci` says there's no pull request | The current branch has none yet. Open one with `diffinery pr create` or `gh pr create`. | |
| 99 | 102 | | 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. | |
| 100 | 103 | | `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 @@ | ||
| 53 | 53 | |
| 54 | 54 | | Part | What it does | |
| 55 | 55 | | --- | --- | |
| 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). | | |
| 57 | 57 | | `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. | |
| 58 | 58 | | `/diffinery:pr-status` | This branch's pull request, its checks, and the log tail of failing jobs. Changes nothing. | |
| 59 | 59 | | 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 @@ | ||
| 109 | 109 | |
| 110 | 110 | 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). |
| 111 | 111 | |
| 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 | + | |
| 112 | 129 | ## Troubleshooting |
| 113 | 130 | |
| 114 | 131 | | Problem | What to do | |
| 115 | 132 | | --- | --- | |
| 116 | 133 | | `/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. | |
| 117 | 134 | | `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). | | |
| 118 | 136 | | `/mcp` shows the server as needing authentication | Choose **Authenticate**, or run `claude mcp login plugin:diffinery:diffinery`. | |
| 119 | 137 | | The browser says "Can't connect this app" | Remove the server and add it again, then sign in. | |
| 120 | 138 | | 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 @@ | ||
| 17 | 17 | | Name | `Diffinery` | |
| 18 | 18 | | Remote MCP server URL | `https://diffinery.com/mcp` | |
| 19 | 19 | |
| 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`. | |
| 21 | 21 | 3. Choose **Connect** next to Diffinery. A Diffinery tab opens. Sign in with your passkey if you aren't signed in. |
| 22 | 22 | 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. |
| 23 | 23 |
modifiedapps/diffinery/docs/connect/cloud-agents.md+2−0View file
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | |
| 16 | 16 | ## Before you start |
| 17 | 17 | |
| 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 | + | |
| 18 | 20 | - 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. |
| 19 | 21 | - 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. |
| 20 | 22 |
modifiedapps/diffinery/docs/connect/codex.md+13−1View file
| @@ -50,7 +50,7 @@ | ||
| 50 | 50 | |
| 51 | 51 | | Part | What it does | |
| 52 | 52 | | --- | --- | |
| 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). | | |
| 54 | 54 | | `diffinery` skill | The loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, `diffinery run logs`, auto-merge, and the agent rules. | |
| 55 | 55 | | `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.) | |
| 56 | 56 | | 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 @@ | ||
| 92 | 92 | |
| 93 | 93 | `codex mcp list` then shows **Bearer token** under **Auth**. More in [Headless use](/docs/connect/how-sign-in-works#headless-use). |
| 94 | 94 | |
| 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 | + | |
| 95 | 107 | ## Troubleshooting |
| 96 | 108 | |
| 97 | 109 | | Problem | What to do | |
modifiedapps/diffinery/docs/connect/other-apps.md+2−0View file
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | https://diffinery.com/mcp |
| 10 | 10 | ``` |
| 11 | 11 | |
| 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 | + | |
| 12 | 14 | ## Cursor |
| 13 | 15 | |
| 14 | 16 | 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/self-hosting.md+1−0View file
| @@ -54,6 +54,7 @@ | ||
| 54 | 54 | - **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). |
| 55 | 55 | - **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). |
| 56 | 56 | - **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). | |
| 57 | 58 | - **How it fits together.** [Architecture](/docs/self-hosting/architecture) maps each part of Diffinery to the Cloudflare product that runs it. |
| 58 | 59 | |
| 59 | 60 | ## 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 @@ | ||
| 22 | 22 | import { fenceHighlighter } from "../web/highlight.js"; |
| 23 | 23 | import { ICONS } from "../web/assets.js"; |
| 24 | 24 | import { render, type WebApp } from "../web/ui.js"; |
| 25 | +import { pluginZipFor } from "./claude-plugins.js"; | |
| 25 | 26 | 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"; |
| 26 | 27 | import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, sectionLlmsFullTxt, sectionLlmsTxt, SLUGS, type Page } from "./pages.js"; |
| 27 | 28 | import { docsJsonLd } from "../web/site.js"; |
| @@ -296,20 +297,26 @@ | ||
| 296 | 297 | |
| 297 | 298 | // The Claude Code plugins, for `diffinery setup` (which checks the SHA-256 header) and claude.ai's Upload |
| 298 | 299 | // 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) => { | |
| 301 | 303 | const file = c.req.param("file"); |
| 302 | 304 | const m = /^([a-z][a-z0-9-]*?)(?:-(\d+\.\d+\.\d+))?\.zip$/.exec(file); |
| 303 | 305 | const plugin = m && Object.hasOwn(CLAUDE_PLUGINS, m[1]!) ? CLAUDE_PLUGINS[m[1]!]! : null; |
| 304 | 306 | 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, { | |
| 308 | 315 | ...PUBLIC, |
| 309 | 316 | "content-type": "application/zip", |
| 310 | 317 | "content-disposition": `attachment; filename="${m[1]}-${plugin.version}.zip"`, |
| 311 | 318 | "x-diffinery-plugin-version": plugin.version, |
| 312 | - "x-diffinery-plugin-sha256": plugin.sha256, | |
| 319 | + "x-diffinery-plugin-sha256": sha256, | |
| 313 | 320 | }); |
| 314 | 321 | }); |
| 315 | 322 |
modifiedapps/diffinery/src/lib/archive.ts+48−1View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 4 | 5 | */ |
| 5 | 6 | |
| 6 | 7 | export type ArchiveEntry = { path: string; mode: string; content: Uint8Array } | { path: string; dir: true }; |
| @@ -208,6 +209,52 @@ | ||
| 208 | 209 | return concat(parts); |
| 209 | 210 | } |
| 210 | 211 | |
| 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 | + | |
| 211 | 258 | export function concat(parts: Uint8Array[]): Uint8Array { |
| 212 | 259 | let n = 0; |
| 213 | 260 | 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 | +}); |
modifiedplugins/README.md+38−8View file
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | A plugin marketplace, kept in the Diffinery repository itself, that makes Claude Code and |
| 4 | 4 | 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)). | |
| 6 | 7 | It has two plugins: `diffinery` (Claude Code and Codex) and `diffinery-ci` (Claude Code), |
| 7 | 8 | described below. |
| 8 | 9 | |
| @@ -10,7 +11,7 @@ | ||
| 10 | 11 | |
| 11 | 12 | | Piece | Claude Code | Codex | |
| 12 | 13 | | --- | --- | --- | |
| 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) | | |
| 14 | 15 | | `diffinery` skill: the CLI, git auth, the branch → PR → CI → merge loop, Diffinery's agent rules | yes | yes | |
| 15 | 16 | | `pr-status`: this branch's PR, its checks, and the log tail of failing jobs | `/diffinery:pr-status` command | `pr-status` skill | |
| 16 | 17 | | 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 @@ | ||
| 25 | 26 | | Status line entry (`PR #7 ✓ 10/10`) and a toast when CI passes or fails | Where Claude Code draws | |
| 26 | 27 | | 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 | |
| 27 | 28 | |
| 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 | |
| 30 | 33 | Claude Code 2.1.287 or later (the desktop app: 2.1.286). Cloud sessions run its hooks but |
| 31 | 34 | draw no mod interface, so there it's the note to Claude and the `/diffinery-ci` text. |
| 32 | 35 | |
| @@ -77,6 +80,30 @@ | ||
| 77 | 80 | you review and trust them: open `/hooks` in the Codex TUI and trust the `diffinery` |
| 78 | 81 | SessionStart hook (optional; the skill works without it). |
| 79 | 82 | |
| 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 | + | |
| 80 | 107 | ## Sign in |
| 81 | 108 | |
| 82 | 109 | The MCP server signs you in with OAuth in the browser: |
| @@ -124,14 +151,15 @@ | ||
| 124 | 151 | |
| 125 | 152 | ## claude.ai and the Claude apps |
| 126 | 153 | |
| 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. | |
| 129 | 157 | |
| 130 | 158 | ## gh |
| 131 | 159 | |
| 132 | 160 | Prefer the `diffinery` CLI: it knows Diffinery's extras (`--intent`, auto-merge, |
| 133 | 161 | `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 | |
| 135 | 163 | branch's pull request with its CI status. See https://diffinery.com/docs/agents/github-cli. |
| 136 | 164 | |
| 137 | 165 | ## Layout |
| @@ -144,7 +172,9 @@ | ||
| 144 | 172 | plugins/diffinery/ |
| 145 | 173 | .claude-plugin/plugin.json Claude Code manifest |
| 146 | 174 | .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) | |
| 148 | 178 | skills/diffinery/SKILL.md the skill (both clients) |
| 149 | 179 | commands/pr-status.md /pr-status (Claude Code) |
| 150 | 180 | 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://anthropic.com/claude-code/plugin.schema.json", |
| 3 | 3 | "name": "diffinery-ci", |
| 4 | - "version": "0.2.3", | |
| 4 | + "version": "0.2.4", | |
| 5 | 5 | "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" }, | |
| 7 | 7 | "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude", |
| 8 | 8 | "repository": "https://diffinery.com/codician/diffinery", |
| 9 | 9 | "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", | |
| 10 | 13 | "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 | + } | |
| 12 | 23 | } |
modifiedplugins/diffinery-ci/hooks/lib.ts+36−0View file
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | export function remoteRepo(url: string, host: string): string | null { |
| 17 | 17 | 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+$/, '') | |
| 18 | 20 | let rest: string | null = null |
| 19 | 21 | const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/.exec(u) |
| 20 | 22 | if (withScheme) { |
| @@ -39,6 +41,40 @@ | ||
| 39 | 41 | } |
| 40 | 42 | } |
| 41 | 43 | |
| 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 | + | |
| 42 | 78 | /** Picks the first Diffinery remote from `git remote -v`-style lines, origin first. */ |
| 43 | 79 | export function pickRemote(remotes: readonly { name: string; url: string }[], host: string): string | null { |
| 44 | 80 | 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 @@ | ||
| 8 | 8 | // where the band shows the rest, passes too where nothing draws, so a cloud thread hears either |
| 9 | 9 | // way. /diffinery-ci answers in text where nothing draws. Nothing to set up. |
| 10 | 10 | // |
| 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. | |
| 13 | 15 | |
| 14 | 16 | import { atom, read, update } from 'claude-code' |
| 15 | 17 | import type { Register } from 'claude-code' |
| @@ -19,9 +21,9 @@ | ||
| 19 | 21 | QUERY, |
| 20 | 22 | SYMBOL, |
| 21 | 23 | checksLine, |
| 24 | + chooseAuth, | |
| 22 | 25 | duration, |
| 23 | 26 | factsLine, |
| 24 | - hostOf, | |
| 25 | 27 | ordered, |
| 26 | 28 | parsePr, |
| 27 | 29 | parseRemotes, |
| @@ -39,7 +41,6 @@ | ||
| 39 | 41 | |
| 40 | 42 | const PLUGIN = 'diffinery-ci' |
| 41 | 43 | const PANE = 'diffinery-ci' |
| 42 | -const DEFAULT_URL = 'https://diffinery.com' | |
| 43 | 44 | |
| 44 | 45 | // Change a tag when the stored value's fields change: it declines what an earlier version |
| 45 | 46 | // stored, which outlives a reload. |
| @@ -60,7 +61,7 @@ | ||
| 60 | 61 | off: 'never', |
| 61 | 62 | } |
| 62 | 63 | |
| 63 | -export const register: Register = on => { | |
| 64 | +export const register: Register = (on, options) => { | |
| 64 | 65 | // What the person chose with `/diffinery-ci notes`, kept across sessions. |
| 65 | 66 | let choice: WakeChoice = 'auto' |
| 66 | 67 | |
| @@ -86,28 +87,29 @@ | ||
| 86 | 87 | const seen = new Map<string, string>() |
| 87 | 88 | |
| 88 | 89 | 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. | |
| 107 | 102 | } |
| 108 | 103 | } |
| 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 | |
| 111 | 113 | if (!token) { |
| 112 | 114 | try { |
| 113 | 115 | 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 @@ | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | checksLine, |
| 6 | + chooseAuth, | |
| 6 | 7 | parsePr, |
| 7 | 8 | pollDelay, |
| 8 | 9 | publishes, |
| @@ -50,6 +51,28 @@ | ||
| 50 | 51 | }, |
| 51 | 52 | }) |
| 52 | 53 | |
| 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 | + | |
| 53 | 76 | test('remotes on the Diffinery host are read, lookalikes are not', async () => { |
| 54 | 77 | expect(remoteRepo('https://diffinery.com/codician/demo.git', 'diffinery.com')).toBe('codician/demo') |
| 55 | 78 | 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://anthropic.com/claude-code/plugin.schema.json", |
| 3 | 3 | "name": "diffinery", |
| 4 | - "version": "0.1.3", | |
| 4 | + "version": "0.1.4", | |
| 5 | 5 | "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" }, | |
| 7 | 7 | "homepage": "https://diffinery.com/docs/connect/claude-code", |
| 8 | 8 | "repository": "https://diffinery.com/codician/diffinery", |
| 9 | 9 | "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 | + } | |
| 11 | 22 | } |
modifiedplugins/diffinery/.codex-plugin/plugin.json+4−4View file
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "diffinery", |
| 3 | - "version": "0.1.3", | |
| 3 | + "version": "0.1.4", | |
| 4 | 4 | "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.", |
| 5 | 5 | "author": { |
| 6 | - "name": "Codician" | |
| 6 | + "name": "Codician Team" | |
| 7 | 7 | }, |
| 8 | 8 | "homepage": "https://diffinery.com/docs/connect/claude-code", |
| 9 | 9 | "repository": "https://diffinery.com/codician/diffinery", |
| @@ -20,12 +20,12 @@ | ||
| 20 | 20 | "./skills/", |
| 21 | 21 | "./codex-skills/" |
| 22 | 22 | ], |
| 23 | - "mcpServers": "./.mcp.json", | |
| 23 | + "mcpServers": "./codex.mcp.json", | |
| 24 | 24 | "interface": { |
| 25 | 25 | "displayName": "Diffinery", |
| 26 | 26 | "shortDescription": "Pull requests, CI and issues on Diffinery", |
| 27 | 27 | "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", | |
| 29 | 29 | "category": "Coding", |
| 30 | 30 | "capabilities": [ |
| 31 | 31 | "Read", |
modifiedplugins/diffinery/.mcp.json+1−1View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "mcpServers": { |
| 3 | 3 | "diffinery": { |
| 4 | 4 | "type": "http", |
| 5 | - "url": "https://diffinery.com/mcp" | |
| 5 | + "url": "${user_config.url}/mcp" | |
| 6 | 6 | } |
| 7 | 7 | } |
| 8 | 8 | } |
modifiedplugins/diffinery/codex-skills/pr-status/SKILL.md+5−4View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | 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. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | # PR status |
| @@ -11,9 +11,10 @@ | ||
| 11 | 11 | comments. |
| 12 | 12 | |
| 13 | 13 | 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. | |
| 17 | 18 | 2. Run `diffinery pr view [N]`. If there is no open pull request for the branch, say so |
| 18 | 19 | (suggest `diffinery pr create ... --intent ...`) and stop. |
| 19 | 20 | 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 @@ | ||
| 9 | 9 | change anything: no pushes, merges, re-runs or comments. |
| 10 | 10 | |
| 11 | 11 | 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. | |
| 15 | 16 | 2. Run `diffinery pr view $ARGUMENTS`. If there is no open pull request for the branch, |
| 16 | 17 | say so (suggest `diffinery pr create ... --intent ...`) and stop. |
| 17 | 18 | 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 @@ | ||
| 1 | 1 | #!/bin/sh |
| 2 | 2 | # SessionStart hook for the diffinery plugin (Claude Code and Codex). |
| 3 | 3 | # |
| 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, | |
| 5 | 5 | # 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. | |
| 7 | 7 | # 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. | |
| 8 | 11 | |
| 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 | + | |
| 9 | 20 | main() { |
| 10 | 21 | dir=${CLAUDE_PROJECT_DIR:-$PWD} |
| 11 | 22 | command -v git >/dev/null 2>&1 || return 0 |
| 12 | 23 | |
| 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=$( | |
| 16 | 33 | { |
| 17 | 34 | git -C "$dir" remote get-url origin |
| 18 | 35 | 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 | |
| 22 | 37 | ) |
| 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 | |
| 23 | 54 | [ -n "$slug" ] || return 0 |
| 24 | 55 | slug=${slug%.git} |
| 25 | 56 | |
| @@ -30,22 +61,27 @@ | ||
| 30 | 61 | cli="yes, at ${DIFFINERY_INSTALL:-$HOME/.diffinery}/bin/diffinery (not on PATH)" |
| 31 | 62 | fi |
| 32 | 63 | |
| 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}}") | |
| 33 | 66 | 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 | |
| 42 | 76 | fi |
| 43 | 77 | |
| 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." | |
| 45 | 79 | 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\`." ;; | |
| 47 | 81 | 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 | |
| 49 | 85 | msg="$msg To sign in, ask the user to run \`diffinery login\` (or \`diffinery login --device\`), or set DIFFINERY_TOKEN." |
| 50 | 86 | fi |
| 51 | 87 |
modifiedplugins/diffinery/skills/diffinery/SKILL.md+24−11View file
| @@ -1,13 +1,16 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | 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. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | # Diffinery |
| 7 | 7 | |
| 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). | |
| 11 | 14 | |
| 12 | 15 | ## Is this repository on Diffinery? |
| 13 | 16 | |
| @@ -16,15 +19,19 @@ | ||
| 16 | 19 | ``` |
| 17 | 20 | |
| 18 | 21 | 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 | |
| 20 | 27 | (it treats Diffinery as a GitHub Enterprise host); until then `gh` has no token for it. |
| 21 | 28 | Prefer `diffinery`: it knows Diffinery's extras (`--intent`, `run logs`). |
| 22 | 29 | |
| 23 | 30 | ## Set up (once per machine) |
| 24 | 31 | |
| 25 | 32 | ```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 | |
| 28 | 35 | ``` |
| 29 | 36 | |
| 30 | 37 | The installer puts the CLI in `~/.diffinery/bin`; if `diffinery` is not found right |
| @@ -36,12 +43,15 @@ | ||
| 36 | 43 | - Interactive machine: ask the user to run `diffinery login` (opens a browser) or |
| 37 | 44 | `diffinery login --device` (prints a code to enter elsewhere). Do not try to complete |
| 38 | 45 | 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`. | |
| 39 | 49 | |
| 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 | |
| 41 | 51 | that uses the CLI's token (including `DIFFINERY_TOKEN`). Without the CLI, git accepts |
| 42 | 52 | the token as the HTTPS password. Plain git (clone, fetch, push) needs nothing else. |
| 43 | 53 | |
| 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 | |
| 45 | 55 | `gh pr ...` works in Diffinery clones (needs `gh` installed). |
| 46 | 56 | |
| 47 | 57 | ## The loop |
| @@ -109,8 +119,11 @@ | ||
| 109 | 119 | `search_docs`, `read_docs`, ...): use them when there is no shell or CLI, for a repo |
| 110 | 120 | you have not cloned, or when the user prefers them. If they say authentication is |
| 111 | 121 | 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`. | |
| 114 | 127 | |
| 115 | 128 | ## Rules Diffinery enforces (work with them, not around them) |
| 116 | 129 |
modifiedscripts/pack-plugins.py+3−1View file
| @@ -17,6 +17,8 @@ | ||
| 17 | 17 | |
| 18 | 18 | ROOT = pathlib.Path(__file__).resolve().parent.parent |
| 19 | 19 | 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"} | |
| 20 | 22 | STAMP = (2026, 1, 1, 0, 0, 0) |
| 21 | 23 | |
| 22 | 24 | |
| @@ -24,7 +26,7 @@ | ||
| 24 | 26 | out = [] |
| 25 | 27 | for p in plugin.rglob("*"): |
| 26 | 28 | 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: | |
| 28 | 30 | continue |
| 29 | 31 | # The engine lays generated type files beside a mod it loads from a working folder. |
| 30 | 32 | if rel.parts[:2] == (".claude-plugin", "types"): |