Compare changes
Choose two branches to see what changed or to start a new pull request.
3 commits
65 files changed+1216−133
Files (65)
- .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+71−0
- apps/cli/test/helpers.ts+2−1
- apps/cli/test/setup.test.ts+92−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/repositories/clone-in-jetbrains-ides.md+15−2
- apps/diffinery/docs/repositories/clone-in-your-editor.md+22−2
- 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
- apps/jetbrains/CHANGELOG.md+9−0
- apps/jetbrains/README.md+9−0
- apps/jetbrains/dist/diffinery-jetbrains-0.1.0.zip+0−0
- apps/jetbrains/dist/diffinery-jetbrains-0.1.1.zip+0−0
- apps/jetbrains/gradle.properties+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/ShareAction.java+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/Util.java+40−0
- apps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0
- apps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0
- apps/vscode/CHANGELOG.md+6−0
- apps/vscode/README.md+21−3
- apps/vscode/install/install.ps1+24−0
- apps/vscode/install/install.sh+20−0
- apps/vscode/package.json+7−2
- apps/vscode/src/auth.ts+10−0
- apps/vscode/src/extension.ts+3−0
- apps/vscode/src/git.ts+5−3
- apps/vscode/src/servers.ts+122−0
- apps/vscode/src/util.ts+46−6
- apps/vscode/test/signin.test.ts+36−1
- 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+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 @@ | ||
| 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+92−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,78 @@ | ||
| 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 | + /** | |
| 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 @@ | ||
| 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/repositories/clone-in-jetbrains-ides.md+15−2View file
| @@ -38,7 +38,7 @@ | ||
| 38 | 38 | |
| 39 | 39 | 1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen. |
| 40 | 40 | 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.) | |
| 42 | 42 | 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. |
| 43 | 43 | 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. |
| 44 | 44 | 6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it. |
| @@ -79,8 +79,20 @@ | ||
| 79 | 79 | |
| 80 | 80 | ## Settings |
| 81 | 81 | |
| 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. | |
| 83 | 83 | |
| 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 | + | |
| 84 | 96 | ## Troubleshooting |
| 85 | 97 | |
| 86 | 98 | | What you see | What to do | |
| @@ -98,4 +110,5 @@ | ||
| 98 | 110 | - 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. |
| 99 | 111 | - 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. |
| 100 | 112 | - 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. | |
| 101 | 114 | - 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 @@ | ||
| 38 | 38 | |
| 39 | 39 | 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. |
| 40 | 40 | 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. | |
| 42 | 42 | 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. |
| 43 | 43 | 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. |
| 44 | 44 | 6. Choose a folder. The editor clones the repository and opens it. |
| @@ -83,10 +83,29 @@ | ||
| 83 | 83 | |
| 84 | 84 | | Setting | Default | What it does | |
| 85 | 85 | | --- | --- | --- | |
| 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. | | |
| 87 | 87 | | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account, and keep Git's saved copy current. | |
| 88 | 88 | | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. | |
| 89 | 89 | |
| 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 | + | |
| 90 | 109 | ## JetBrains IDEs |
| 91 | 110 | |
| 92 | 111 | 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 @@ | ||
| 110 | 129 | |
| 111 | 130 | - 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. |
| 112 | 131 | - 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. | |
| 113 | 133 | - 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 @@ | ||
| 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 | +}); |
modifiedapps/jetbrains/CHANGELOG.md+9−0View file
| @@ -1,5 +1,14 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 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 | + | |
| 3 | 12 | ## 0.1.0 |
| 4 | 13 | |
| 5 | 14 | - **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 @@ | ||
| 9 | 9 | It's plain Java (no Kotlin), against the platform's stable API only: `./gradlew verifyPlugin` runs JetBrains' Plugin |
| 10 | 10 | Verifier against IntelliJ IDEA 2024.3, 2025.2 and the version the plugin builds with, and fails on internal API use. |
| 11 | 11 | |
| 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 | + | |
| 12 | 21 | ## Build |
| 13 | 22 | |
| 14 | 23 | 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 | |
| 2 | 2 | # The IDE the plugin is built against. -PideaHome=/path/to/idea uses one already on disk instead of downloading it. |
| 3 | 3 | platformVersion=2026.2.3 |
| 4 | 4 | sinceBuild=243 |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2View file
| @@ -5,7 +5,6 @@ | ||
| 5 | 5 | import com.intellij.openapi.application.ApplicationManager; |
| 6 | 6 | import com.intellij.openapi.application.ModalityState; |
| 7 | 7 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; |
| 8 | -import com.intellij.openapi.options.ShowSettingsUtil; | |
| 9 | 8 | import com.intellij.openapi.project.Project; |
| 10 | 9 | import com.intellij.openapi.ui.TextBrowseFolderListener; |
| 11 | 10 | import com.intellij.openapi.ui.TextFieldWithBrowseButton; |
| @@ -21,6 +20,7 @@ | ||
| 21 | 20 | import com.intellij.ui.ScrollPaneFactory; |
| 22 | 21 | import com.intellij.ui.SearchTextField; |
| 23 | 22 | import com.intellij.ui.SimpleTextAttributes; |
| 23 | +import com.intellij.ui.components.ActionLink; | |
| 24 | 24 | import com.intellij.ui.components.JBLabel; |
| 25 | 25 | import com.intellij.ui.components.JBList; |
| 26 | 26 | import com.intellij.util.ui.JBUI; |
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | import git4idea.commands.Git; |
| 30 | 30 | import java.awt.BorderLayout; |
| 31 | 31 | import java.awt.CardLayout; |
| 32 | +import java.awt.FlowLayout; | |
| 32 | 33 | import java.awt.GridBagConstraints; |
| 33 | 34 | import java.awt.GridBagLayout; |
| 34 | 35 | import java.awt.event.KeyAdapter; |
| @@ -72,6 +73,8 @@ | ||
| 72 | 73 | private final TextFieldWithBrowseButton directory = new TextFieldWithBrowseButton(); |
| 73 | 74 | private final JBLabel serverLabel = new JBLabel(); |
| 74 | 75 | private final JButton signInButton = new JButton("Sign In with Browser…"); |
| 76 | + private final ActionLink suggestedLink = Plugin.link("", this::useSuggested); | |
| 77 | + private String suggested; | |
| 75 | 78 | |
| 76 | 79 | private List<Repo> mine = List.of(); |
| 77 | 80 | private List<Repo> found = List.of(); |
| @@ -117,8 +120,13 @@ | ||
| 117 | 120 | serverLabel.setForeground(UIUtil.getContextHelpForeground()); |
| 118 | 121 | c.insets = JBUI.insetsTop(16); |
| 119 | 122 | 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))); | |
| 120 | 128 | c.insets = JBUI.emptyInsets(); |
| 121 | - p.add(Plugin.link("Change server…", () -> ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsPage.class)), c); | |
| 129 | + p.add(servers, c); | |
| 122 | 130 | return p; |
| 123 | 131 | } |
| 124 | 132 | |
| @@ -213,6 +221,11 @@ | ||
| 213 | 221 | getDialogStateListener().onListItemChanged(); |
| 214 | 222 | if (a == null) { |
| 215 | 223 | 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); | |
| 216 | 229 | cards.show(root, SIGNED_OUT); |
| 217 | 230 | getDialogStateListener().onOkActionEnabled(false); |
| 218 | 231 | return; |
| @@ -222,6 +235,12 @@ | ||
| 222 | 235 | load(); |
| 223 | 236 | } |
| 224 | 237 | |
| 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 | + | |
| 225 | 244 | private void load() { |
| 226 | 245 | int gen = loads.incrementAndGet(); |
| 227 | 246 | mine = List.of(); |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1View file
| @@ -47,7 +47,7 @@ | ||
| 47 | 47 | buttons.add(signIn); |
| 48 | 48 | buttons.add(useToken); |
| 49 | 49 | 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."); | |
| 51 | 51 | serverHelp.setForeground(UIUtil.getContextHelpForeground()); |
| 52 | 52 | 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>"); |
| 53 | 53 | gitHelp.setForeground(UIUtil.getContextHelpForeground()); |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0View file
| @@ -2,19 +2,123 @@ | ||
| 2 | 2 | |
| 3 | 3 | import com.intellij.openapi.application.ApplicationManager; |
| 4 | 4 | import com.intellij.openapi.application.ModalityState; |
| 5 | +import com.intellij.openapi.diagnostic.Logger; | |
| 5 | 6 | import com.intellij.openapi.progress.ProgressIndicator; |
| 6 | 7 | import com.intellij.openapi.progress.ProgressManager; |
| 7 | 8 | import com.intellij.openapi.progress.Task; |
| 8 | 9 | import com.intellij.openapi.project.Project; |
| 10 | +import com.intellij.openapi.ui.InputValidatorEx; | |
| 9 | 11 | import com.intellij.openapi.ui.Messages; |
| 12 | +import com.intellij.openapi.updateSettings.impl.UpdateSettings; | |
| 13 | +import java.io.IOException; | |
| 14 | +import java.util.List; | |
| 10 | 15 | import java.util.function.Consumer; |
| 11 | 16 | import org.jetbrains.annotations.NotNull; |
| 12 | 17 | import org.jetbrains.annotations.Nullable; |
| 13 | 18 | |
| 14 | 19 | /** Signing in from anywhere in the IDE: with a progress dialog that can be cancelled, and errors shown as sentences. */ |
| 15 | 20 | final class SignInDialogs { |
| 21 | + private static final Logger LOG = Logger.getInstance(SignInDialogs.class); | |
| 22 | + | |
| 16 | 23 | private SignInDialogs() {} |
| 17 | 24 | |
| 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 | + | |
| 18 | 122 | /** Opens the browser to sign in; `done` runs on the UI thread with the account once signed in. */ |
| 19 | 123 | static void signInWithBrowser(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) { |
| 20 | 124 | 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 @@ | ||
| 7 | 7 | import java.security.MessageDigest; |
| 8 | 8 | import java.security.NoSuchAlgorithmException; |
| 9 | 9 | import java.security.SecureRandom; |
| 10 | +import java.util.ArrayList; | |
| 10 | 11 | import java.util.Base64; |
| 11 | 12 | import java.util.LinkedHashMap; |
| 12 | 13 | import java.util.List; |
| @@ -43,6 +44,45 @@ | ||
| 43 | 44 | return scheme + "://" + u.getRawAuthority() + path; |
| 44 | 45 | } |
| 45 | 46 | |
| 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 | + | |
| 46 | 86 | /** The OAuth client_id of this plugin on a server: the URL of the metadata document the server publishes about it. */ |
| 47 | 87 | static String clientId(String server) { |
| 48 | 88 | return server + "/oauth/clients/jetbrains.json"; |
modifiedapps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0View file
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | <li><b>Push and pull without passwords.</b> When Git asks for a password for Diffinery, the plugin answers with |
| 14 | 14 | your signed-in account, and renews its token before it runs out.</li> |
| 15 | 15 | <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> | |
| 16 | 17 | </ul> |
| 17 | 18 | <p>Guide: <a href="https://diffinery.com/docs/repositories/clone-in-jetbrains-ides">Clone in JetBrains IDEs</a>.</p> |
| 18 | 19 | ]]></description> |
modifiedapps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0View file
| @@ -31,6 +31,34 @@ | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | @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 | |
| 34 | 62 | public void authorizeUrl() { |
| 35 | 63 | String url = Util.authorizeUrl(S, "http://127.0.0.1:5000/callback", "ch", "st"); |
| 36 | 64 | assertTrue(url.startsWith(S + "/oauth/authorize?")); |
modifiedapps/vscode/CHANGELOG.md+6−0View file
| @@ -1,5 +1,11 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 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 | + | |
| 3 | 9 | ## 0.1.1 |
| 4 | 10 | |
| 5 | 11 | - "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 @@ | ||
| 6 | 6 | - **Clone from Diffinery.** Run **Git: Clone** (or **Clone Repository** on the welcome page) and pick |
| 7 | 7 | **Clone from Diffinery**. You see your repositories and your organizations', most recently pushed first; |
| 8 | 8 | 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. | |
| 11 | 12 | - **Push and pull without passwords.** Git uses your signed-in account for diffinery.com. The extension renews |
| 12 | 13 | its token before it expires and keeps the copy Git saved in your keychain current. |
| 13 | 14 | - **Publish to Diffinery.** Publishing a local repository that has no remote offers **Publish to Diffinery**, |
| @@ -29,6 +30,22 @@ | ||
| 29 | 30 | The full guide, including JetBrains IDEs and the "Open in VS Code / Cursor" buttons on every repository, is at |
| 30 | 31 | [diffinery.com/docs/repositories/clone-in-your-editor](https://diffinery.com/docs/repositories/clone-in-your-editor). |
| 31 | 32 | |
| 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 | + | |
| 32 | 49 | ## Commands |
| 33 | 50 | |
| 34 | 51 | | Command | What it does | |
| @@ -36,6 +53,7 @@ | ||
| 36 | 53 | | Diffinery: Clone Repository | Opens the Diffinery repository picker directly. | |
| 37 | 54 | | Diffinery: Sign In | Signs in through your browser. | |
| 38 | 55 | | 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). | | |
| 39 | 57 | | Diffinery: Sign Out | Signs out and disconnects the editor from your account. | |
| 40 | 58 | | Diffinery: Check for Extension Updates | Installs a newer build from the server, if there is one. | |
| 41 | 59 | |
| @@ -43,6 +61,6 @@ | ||
| 43 | 61 | |
| 44 | 62 | | Setting | Default | | |
| 45 | 63 | |---|---|---| |
| 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. | | |
| 47 | 65 | | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account. | |
| 48 | 66 | | `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 @@ | ||
| 27 | 27 | $vsix = Join-Path $tmp "diffinery-$($meta.version).vsix" |
| 28 | 28 | Invoke-WebRequest "$server/-/vscode/diffinery-$($meta.version).vsix" -OutFile $vsix -UseBasicParsing |
| 29 | 29 | 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 | + | |
| 30 | 46 | $installed = 0 |
| 31 | 47 | foreach ($e in $known) { |
| 32 | 48 | if ($wanted.Count -gt 0 -and $wanted -notcontains $e.Name) { continue } |
| @@ -46,6 +62,14 @@ | ||
| 46 | 62 | exit 1 |
| 47 | 63 | } |
| 48 | 64 | 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 | + } | |
| 49 | 73 | Write-Host "Next: in the editor, run Git: Clone and pick Clone from Diffinery. ($server/docs/repositories/clone-in-your-editor)" |
| 50 | 74 | } finally { |
| 51 | 75 | Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue |
modifiedapps/vscode/install/install.sh+20−0View file
| @@ -65,6 +65,18 @@ | ||
| 65 | 65 | if command -v sha256sum >/dev/null; then got=$(sha256sum "$vsix" | cut -d' ' -f1); else got=$(shasum -a 256 "$vsix" | cut -d' ' -f1); fi |
| 66 | 66 | [ "$got" = "$sha" ] || die "the download didn't match the published SHA-256; nothing was installed" |
| 67 | 67 | |
| 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 | + | |
| 68 | 80 | installed=0 |
| 69 | 81 | while IFS='|' read -r name label app; do |
| 70 | 82 | if [ "$wanted" != " " ] && [[ $wanted != *" $name "* ]]; then continue; fi |
| @@ -86,4 +98,12 @@ | ||
| 86 | 98 | exit 1 |
| 87 | 99 | fi |
| 88 | 100 | 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 | |
| 89 | 109 | 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 @@ | ||
| 2 | 2 | "name": "@diffinery/vscode", |
| 3 | 3 | "displayName": "Diffinery", |
| 4 | 4 | "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", | |
| 6 | 6 | "publisher": "codician", |
| 7 | 7 | "private": true, |
| 8 | 8 | "license": "Apache-2.0", |
| @@ -86,6 +86,11 @@ | ||
| 86 | 86 | "category": "Diffinery" |
| 87 | 87 | }, |
| 88 | 88 | { |
| 89 | + "command": "diffinery.useServer", | |
| 90 | + "title": "Use Another Server…", | |
| 91 | + "category": "Diffinery" | |
| 92 | + }, | |
| 93 | + { | |
| 89 | 94 | "command": "diffinery.signOut", |
| 90 | 95 | "title": "Sign Out", |
| 91 | 96 | "category": "Diffinery" |
| @@ -103,7 +108,7 @@ | ||
| 103 | 108 | "type": "string", |
| 104 | 109 | "default": "https://diffinery.com", |
| 105 | 110 | "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." | |
| 107 | 112 | }, |
| 108 | 113 | "diffinery.gitAuthentication": { |
| 109 | 114 | "type": "boolean", |
modifiedapps/vscode/src/auth.ts+10−0View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { randomUUID } from "node:crypto"; |
| 3 | 3 | import * as vscode from "vscode"; |
| 4 | 4 | import { Api } from "./api"; |
| 5 | +import { chooseServer } from "./servers"; | |
| 5 | 6 | import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util"; |
| 6 | 7 | |
| 7 | 8 | export const AUTH_ID = "diffinery"; |
| @@ -184,6 +185,10 @@ | ||
| 184 | 185 | } |
| 185 | 186 | |
| 186 | 187 | 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); | |
| 187 | 192 | const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser(); |
| 188 | 193 | // Signing in again replaces the old sign-in, and ends its connection on the server. |
| 189 | 194 | const old = await this.load(); |
| @@ -320,6 +325,11 @@ | ||
| 320 | 325 | return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token }; |
| 321 | 326 | } |
| 322 | 327 | |
| 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 | + | |
| 323 | 333 | async signOut(): Promise<void> { |
| 324 | 334 | const s = await this.load(); |
| 325 | 335 | if (!s) { |
modifiedapps/vscode/src/extension.ts+3−0View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { AUTH_ID, DiffineryAuth, SCOPES } from "./auth"; |
| 3 | 3 | import { GitCredentials, Publisher, syncCredentialStore } from "./git"; |
| 4 | 4 | import { RemoteSources } from "./remoteSources"; |
| 5 | +import { offerInstallerServer, switchServer } from "./servers"; | |
| 5 | 6 | import type { GitAPI, GitBaseAPI, GitBaseExtension, GitExtension } from "./typings/git"; |
| 6 | 7 | import { checkForUpdates } from "./updates"; |
| 7 | 8 | |
| @@ -26,6 +27,7 @@ | ||
| 26 | 27 | command("diffinery.signIn", async () => vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }), "Signing in to Diffinery failed"); |
| 27 | 28 | command("diffinery.signInWithToken", () => auth.signInWithToken(), "Signing in to Diffinery failed"); |
| 28 | 29 | command("diffinery.signOut", () => auth.signOut(), "Signing out of Diffinery failed"); |
| 30 | + command("diffinery.useServer", () => switchServer(ctx.globalState, () => auth.login()), "Changing the Diffinery server failed"); | |
| 29 | 31 | command("diffinery.checkForUpdates", () => checkForUpdates(ctx, auth.server(), version, log, true), "Checking for updates failed"); |
| 30 | 32 | |
| 31 | 33 | // ---------------------------------------------------------------- the built-in Git extensions |
| @@ -86,6 +88,7 @@ | ||
| 86 | 88 | "Cloning from Diffinery failed", |
| 87 | 89 | ); |
| 88 | 90 | |
| 91 | + void offerInstallerServer(ctx.globalState, () => auth.login(), log).catch(report("Changing the Diffinery server failed")); | |
| 89 | 92 | void checkForUpdates(ctx, auth.server(), version, log, false).catch((e) => log.warn("update check failed", e)); |
| 90 | 93 | } |
| 91 | 94 |
modifiedapps/vscode/src/git.ts+5−3View file
| @@ -10,14 +10,16 @@ | ||
| 10 | 10 | constructor(private readonly auth: DiffineryAuth) {} |
| 11 | 11 | |
| 12 | 12 | async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> { |
| 13 | - let server: URL; | |
| 13 | + let server: string; | |
| 14 | 14 | try { |
| 15 | - server = new URL(this.auth.server()); | |
| 15 | + server = this.auth.server(); | |
| 16 | 16 | } catch { |
| 17 | 17 | return undefined; |
| 18 | 18 | } |
| 19 | - if (host.authority.toLowerCase() !== server.host.toLowerCase()) return undefined; | |
| 19 | + if (host.authority.toLowerCase() !== new URL(server).host.toLowerCase()) return undefined; | |
| 20 | 20 | 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; | |
| 21 | 23 | return { username: session.account.label, password: session.accessToken }; |
| 22 | 24 | } |
| 23 | 25 | } |
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 @@ | ||
| 1 | 1 | // Pure helpers, without the vscode module, so the unit tests can run them under Node. |
| 2 | 2 | import { createHash, randomBytes } from "node:crypto"; |
| 3 | +import * as path from "node:path"; | |
| 3 | 4 | |
| 4 | 5 | export const EXTENSION_ID = "codician.diffinery"; |
| 5 | 6 | /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */ |
| 6 | 7 | export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"]; |
| 7 | 8 | export const DEFAULT_SERVER = "https://diffinery.com"; |
| 8 | 9 | |
| 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" } { | |
| 12 | 11 | let u: URL; |
| 13 | 12 | try { |
| 14 | 13 | u = new URL(s.includes("://") ? s : `https://${s}`); |
| 15 | 14 | } 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" }; | |
| 17 | 16 | } |
| 18 | 17 | 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(/\/+$/, "")}` }; | |
| 21 | 20 | } |
| 22 | 21 | |
| 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 | + | |
| 23 | 63 | /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */ |
| 24 | 64 | export const clientId = (server: string) => `${server}/oauth/clients/editor.json`; |
| 25 | 65 |
modifiedapps/vscode/test/signin.test.ts+36−1View file
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { createHash } from "node:crypto"; |
| 2 | 2 | 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"; | |
| 4 | 6 | |
| 5 | 7 | const S = "https://diffinery.com"; |
| 6 | 8 | |
| @@ -14,6 +16,39 @@ | ||
| 14 | 16 | expect(() => normalizeServer("https://")).toThrow(/valid URL/); |
| 15 | 17 | }); |
| 16 | 18 | |
| 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 | + | |
| 17 | 52 | it("returns to the editor by its own scheme when the server knows it", () => { |
| 18 | 53 | expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth"); |
| 19 | 54 | expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth"); |
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"): |