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.

115 lines · 5.9 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.
16 */
17
18export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const;
19
20/** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */
21export function claudeDir(env: NodeJS.ProcessEnv): string {
22 return env.CLAUDE_CONFIG_DIR && path.isAbsolute(env.CLAUDE_CONFIG_DIR) ? env.CLAUDE_CONFIG_DIR : path.join(env.HOME || homedir(), ".claude");
23}
24
25/** Whether Claude Code is on this machine: its settings directory exists, or `claude` is on PATH. */
26export function hasClaude(env: NodeJS.ProcessEnv): boolean {
27 if (isDir(claudeDir(env))) return true;
28 return (env.PATH ?? "").split(path.delimiter).some((d) => d && existsSync(path.join(d, "claude")));
29}
30
31function isDir(p: string): boolean {
32 try {
33 return statSync(p).isDirectory();
34 } catch {
35 return false;
36 }
37}
38
39/** CLAUDE_CODE_PLUGIN_DIRS with ours in place of any earlier copy of them, others kept in their order. */
40export function withOurs(current: string | undefined, ours: string[]): string {
41 const names = new Set(ours.map((p) => path.basename(p)));
42 const kept = (current ?? "")
43 .split(path.delimiter)
44 .map((p) => p.trim())
45 .filter((p) => p && !(path.basename(path.dirname(p)) === "diffinery-plugins" && names.has(path.basename(p))) && !ours.includes(p));
46 return [...kept, ...ours].join(path.delimiter);
47}
48
49export interface PluginsResult {
50 ok: boolean;
51 message: string;
52}
53
54/**
55 * Downloads the plugins and names them in Claude Code's user settings. Returns the check line's
56 * outcome, or null when there's nothing to do here (no Claude Code, or DIFFINERY_NO_CLAUDE_PLUGINS).
57 * `claudeCloud`: a Claude Code cloud environment, whose setup script may run before Claude Code has
58 * made its settings directory.
59 */
60export async function setupClaudePlugins(env: NodeJS.ProcessEnv, url: string, claudeCloud = false): Promise<PluginsResult | null> {
61 if (env.DIFFINERY_NO_CLAUDE_PLUGINS) return null;
62 if (!claudeCloud && !hasClaude(env)) return null;
63 const dir = claudeDir(env);
64 const settingsFile = path.join(dir, "settings.json");
65
66 // Read the settings first: a file we can't read as JSON is left as it is.
67 let settings: Record<string, unknown> = {};
68 try {
69 const text = await readFile(settingsFile, "utf8");
70 const parsed: unknown = text.trim() ? JSON.parse(text) : {};
71 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
72 settings = parsed as Record<string, unknown>;
73 } catch (e) {
74 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` };
75 }
76
77 // A plugin already installed from the Diffinery marketplace (`claude plugin install`) stays as it is.
78 const enabled = settings.enabledPlugins && typeof settings.enabledPlugins === "object" ? Object.keys(settings.enabledPlugins as object) : [];
79 const wanted = CLAUDE_PLUGINS.filter((n) => !enabled.some((k) => k.startsWith(`${n}@`) && (settings.enabledPlugins as Record<string, unknown>)[k] !== false));
80 const fromMarketplace = CLAUDE_PLUGINS.filter((n) => !wanted.includes(n));
81
82 const target = path.join(dir, "diffinery-plugins");
83 await mkdir(target, { recursive: true });
84 const got: string[] = [];
85 const files: string[] = [];
86 for (const name of wanted) {
87 let res: Response;
88 try {
89 res = await fetch(`${url}/-/claude/${name}.zip`, { headers: { "user-agent": `diffinery-cli/${VERSION}` } });
90 } catch (e) {
91 return { ok: false, message: `couldn't download the ${name} plugin: ${(e as Error).message}` };
92 }
93 if (!res.ok) return { ok: false, message: `couldn't download the ${name} plugin: ${url} answered ${res.status}` };
94 const body = Buffer.from(await res.arrayBuffer());
95 const sha = res.headers.get("x-diffinery-plugin-sha256");
96 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` };
97 const file = path.join(target, `${name}.zip`);
98 await writeFile(`${file}.tmp`, body);
99 await rename(`${file}.tmp`, file);
100 files.push(file);
101 got.push(`${name} ${res.headers.get("x-diffinery-plugin-version") ?? ""}`.trim());
102 }
103
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) } };
108 await writeFile(`${settingsFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
109 await rename(`${settingsFile}.tmp`, settingsFile);
110 }
111 const parts = [];
112 if (got.length) parts.push(`${got.join(" and ")} load in new Claude Code sessions (named in ${settingsFile})`);
113 if (fromMarketplace.length) parts.push(`${fromMarketplace.join(" and ")} already installed from the Diffinery marketplace`);
114 return { ok: true, message: parts.join("; ") };
115}