| 1 | import { createHash } from "node:crypto"; |
| 2 | import { existsSync, statSync } from "node:fs"; |
| 3 | import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; |
| 4 | import { homedir } from "node:os"; |
| 5 | import path from "node:path"; |
| 6 | import { 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. */ |
| 21 | const PLUGIN_DEFAULT_URL = "https://diffinery.com"; |
| 22 | |
| 23 | export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const; |
| 24 | |
| 25 | /** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */ |
| 26 | export 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. */ |
| 31 | export 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 | |
| 36 | function 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. */ |
| 45 | export 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 | */ |
| 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 | |
| 71 | export 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 | */ |
| 82 | export 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 | } |