| 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. |
| 16 | */ |
| 17 | |
| 18 | export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const; |
| 19 | |
| 20 | /** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */ |
| 21 | export 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. */ |
| 26 | export 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 | |
| 31 | function 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. */ |
| 40 | export 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 | |
| 49 | export 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 | */ |
| 60 | export 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 | } |