Diffinery

codician/diffinerypublic

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

145 lines · 7.8 KBRaw History
1import { createHash } from "node:crypto";
2import { existsSync, statSync } from "node:fs";
3import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4import { homedir } from "node:os";
5import path from "node:path";
6import { VERSION } from "../version.js";
7
8/**
9 * Claude Code's Diffinery plugins, set up by `diffinery setup` with nothing else to do: the `diffinery`
10 * plugin (skill, /diffinery:pr-status, MCP server) and `diffinery-ci` (live CI band and pane, and a note
11 * to Claude when CI on a commit it pushed settles). The server serves them at <server>/-/claude/<name>.zip;
12 * they're saved in Claude Code's settings directory and named in the `env` block of its user settings as
13 * CLAUDE_CODE_PLUGIN_DIRS, which Claude Code reads at the start of every session: the terminal, the
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. 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.
18 */
19
20/** What the plugins' Diffinery URL setting defaults to; `pluginConfigs` is written only to change it. */
21const PLUGIN_DEFAULT_URL = "https://diffinery.com";
22
23export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const;
24
25/** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */
26export function claudeDir(env: NodeJS.ProcessEnv): string {
27 return env.CLAUDE_CONFIG_DIR && path.isAbsolute(env.CLAUDE_CONFIG_DIR) ? env.CLAUDE_CONFIG_DIR : path.join(env.HOME || homedir(), ".claude");
28}
29
30/** Whether Claude Code is on this machine: its settings directory exists, or `claude` is on PATH. */
31export function hasClaude(env: NodeJS.ProcessEnv): boolean {
32 if (isDir(claudeDir(env))) return true;
33 return (env.PATH ?? "").split(path.delimiter).some((d) => d && existsSync(path.join(d, "claude")));
34}
35
36function isDir(p: string): boolean {
37 try {
38 return statSync(p).isDirectory();
39 } catch {
40 return false;
41 }
42}
43
44/** CLAUDE_CODE_PLUGIN_DIRS with ours in place of any earlier copy of them, others kept in their order. */
45export function withOurs(current: string | undefined, ours: string[]): string {
46 const names = new Set(ours.map((p) => path.basename(p)));
47 const kept = (current ?? "")
48 .split(path.delimiter)
49 .map((p) => p.trim())
50 .filter((p) => p && !(path.basename(path.dirname(p)) === "diffinery-plugins" && names.has(path.basename(p))) && !ours.includes(p));
51 return [...kept, ...ours].join(path.delimiter);
52}
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 */
58export 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
71export interface PluginsResult {
72 ok: boolean;
73 message: string;
74}
75
76/**
77 * Downloads the plugins and names them in Claude Code's user settings. Returns the check line's
78 * outcome, or null when there's nothing to do here (no Claude Code, or DIFFINERY_NO_CLAUDE_PLUGINS).
79 * `claudeCloud`: a Claude Code cloud environment, whose setup script may run before Claude Code has
80 * made its settings directory.
81 */
82export async function setupClaudePlugins(env: NodeJS.ProcessEnv, url: string, claudeCloud = false): Promise<PluginsResult | null> {
83 if (env.DIFFINERY_NO_CLAUDE_PLUGINS) return null;
84 if (!claudeCloud && !hasClaude(env)) return null;
85 const dir = claudeDir(env);
86 const settingsFile = path.join(dir, "settings.json");
87
88 // Read the settings first: a file we can't read as JSON is left as it is.
89 let settings: Record<string, unknown> = {};
90 try {
91 const text = await readFile(settingsFile, "utf8");
92 const parsed: unknown = text.trim() ? JSON.parse(text) : {};
93 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
94 settings = parsed as Record<string, unknown>;
95 } catch (e) {
96 if ((e as NodeJS.ErrnoException).code !== "ENOENT") return { ok: false, message: `${settingsFile} isn't valid JSON, so it was left alone: fix it, then run this again` };
97 }
98
99 // A plugin already installed from the Diffinery marketplace (`claude plugin install`) stays as it is.
100 const enabled = settings.enabledPlugins && typeof settings.enabledPlugins === "object" ? Object.keys(settings.enabledPlugins as object) : [];
101 const wanted = CLAUDE_PLUGINS.filter((n) => !enabled.some((k) => k.startsWith(`${n}@`) && (settings.enabledPlugins as Record<string, unknown>)[k] !== false));
102 const fromMarketplace = CLAUDE_PLUGINS.filter((n) => !wanted.includes(n));
103
104 const target = path.join(dir, "diffinery-plugins");
105 await mkdir(target, { recursive: true });
106 const got: string[] = [];
107 const files: string[] = [];
108 for (const name of wanted) {
109 let res: Response;
110 try {
111 res = await fetch(`${url}/-/claude/${name}.zip`, { headers: { "user-agent": `diffinery-cli/${VERSION}` } });
112 } catch (e) {
113 return { ok: false, message: `couldn't download the ${name} plugin: ${(e as Error).message}` };
114 }
115 if (!res.ok) return { ok: false, message: `couldn't download the ${name} plugin: ${url} answered ${res.status}` };
116 const body = Buffer.from(await res.arrayBuffer());
117 const sha = res.headers.get("x-diffinery-plugin-sha256");
118 if (!sha || createHash("sha256").update(body).digest("hex") !== sha) return { ok: false, message: `the ${name} plugin download didn't match its checksum: run this again` };
119 const file = path.join(target, `${name}.zip`);
120 await writeFile(`${file}.tmp`, body);
121 await rename(`${file}.tmp`, file);
122 files.push(file);
123 got.push(`${name} ${res.headers.get("x-diffinery-plugin-version") ?? ""}`.trim());
124 }
125
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 };
137 await writeFile(`${settingsFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
138 await rename(`${settingsFile}.tmp`, settingsFile);
139 }
140 const parts = [];
141 if (got.length) parts.push(`${got.join(" and ")} load in new Claude Code sessions (named in ${settingsFile})`);
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}`);
144 return { ok: true, message: parts.join("; ") };
145}