import { access, copyFile, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; import { runDoctor, systemDoctorHost } from "./Doctor.js"; const execFileAsync = promisify(execFile); /** A detected agent configuration owned by setup. */ export interface SetupClient { readonly name: string; readonly configPath: string; } /** Result of one backup/write/readback transaction. */ export type ClientConfigurationResult = | { readonly status: "unchanged" | "configured"; readonly backupPath?: string; } | { readonly status: "failed"; readonly reason: "backup" | "write" | "readback"; }; /** Effects required by the idempotent setup workflow. */ export interface SetupHost { readonly platform: NodeJS.Platform; readonly nodeVersion: string; macosVersion(): Promise; hasHomebrew(): Promise; installHomebrew(): Promise; hopperPath(): Promise; installHopper(): Promise; detectedClients(): Promise; configureClient(client: SetupClient): Promise; installSkill(): Promise<"installed" | "unchanged" | "failed">; doctor(): Promise>>; } /** Structured setup outcome with actionable partial progress. */ export interface SetupResult { readonly status: "ready" | "needs_human"; readonly actions: readonly string[]; readonly clients: Readonly>; readonly doctor: Awaited>; readonly remediation?: string; } /** Install prerequisites and configure every detected supported client idempotently. */ export const runSetup = async ( yes: boolean, host: SetupHost = systemSetupHost(), ): Promise => { const actions: string[] = []; const clients: Record = {}; const fail = async (remediation: string): Promise => ({ status: "needs_human", actions, clients, doctor: await host.doctor(), remediation, }); if (host.platform !== "darwin") return fail("Hopper requires macOS 12 or newer."); const macosVersion = await host.macosVersion(); if (macosVersion === undefined || major(macosVersion) < 12) return fail("Upgrade to macOS 12 or newer."); if (major(host.nodeVersion) < 22) return fail("Install Node.js 22 or newer."); if (!(await host.hasHomebrew())) { if (!yes) return fail("Re-run with --yes to install Homebrew."); if (!(await host.installHomebrew())) return fail( "Homebrew installation was interrupted; resolve the installer prompt and re-run setup.", ); actions.push("installed_homebrew"); } if ((await host.hopperPath()) === undefined) { if (!(await host.installHopper())) return fail( "Hopper installation was interrupted; resolve the system prompt and re-run setup.", ); actions.push("installed_hopper"); } for (const client of await host.detectedClients()) { const result = await host.configureClient(client); clients[client.name] = result; if (result.status === "failed") return fail( `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`, ); if (result.status === "configured") actions.push(`configured_${client.name}`); } const skill = await host.installSkill(); if (skill === "failed") return fail("Agent skill installation or readback failed."); if (skill === "installed") actions.push("installed_skill"); const doctor = await host.doctor(); return { status: doctor.healthy ? "ready" : "needs_human", actions, clients, doctor, ...(doctor.healthy ? {} : { remediation: "Run better-binary doctor and apply each reported remediation.", }), }; }; /** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */ export const systemSetupHost = (): SetupHost => { const doctorHost = systemDoctorHost(); return { platform: process.platform, nodeVersion: process.versions.node, macosVersion: () => doctorHost.macosVersion(), hasHomebrew: () => brewSucceeds(["--version"]), installHomebrew: () => commandSucceeds( "/bin/bash", [ "-c", "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)", ], { ...process.env, NONINTERACTIVE: "1" }, ), hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath, installHopper: () => brewSucceeds(["install", "--cask", "hopper-disassembler"]), detectedClients: () => detectJsonClients(homedir()), configureClient: configureJsonClient, installSkill: () => installCanonicalSkill(homedir()), doctor: () => runDoctor(undefined, doctorHost), }; }; const detectJsonClients = async ( home: string, ): Promise => { const candidates = [ { name: "claude_desktop", configPath: join( home, "Library/Application Support/Claude/claude_desktop_config.json", ), marker: join(home, "Library/Application Support/Claude"), }, { name: "cursor", configPath: join(home, ".cursor/mcp.json"), marker: join(home, ".cursor"), }, ]; const detected: SetupClient[] = []; for (const candidate of candidates) if (await exists(candidate.marker)) detected.push({ name: candidate.name, configPath: candidate.configPath }); return detected; }; /** Back up, atomically update, and semantically read back one JSON MCP configuration. */ export const configureJsonClient = async ( client: SetupClient, ): Promise => { let document: Record = {}; let original: string | undefined; try { original = await readFile(client.configPath, "utf8"); document = parseObject(original); } catch (cause: unknown) { if (!isMissing(cause)) return { status: "failed", reason: "readback" }; } const servers = objectValue(document.mcpServers); const desired = { command: PRODUCT_IDENTITY.mcpBinary }; if ( JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === JSON.stringify(desired) ) return { status: "unchanged" }; let backupPath: string | undefined; if (original !== undefined) { backupPath = `${client.configPath}.better-binary.backup`; try { await copyFile(client.configPath, backupPath); } catch { return { status: "failed", reason: "backup" }; } } document.mcpServers = { ...servers, [PRODUCT_IDENTITY.mcpServerKey]: desired, }; const encoded = `${JSON.stringify(document, null, 2)}\n`; const temporary = `${client.configPath}.better-binary.tmp`; try { await mkdir(dirname(client.configPath), { recursive: true }); await writeFile(temporary, encoded, { encoding: "utf8", mode: 0o600 }); await rename(temporary, client.configPath); } catch { await rm(temporary, { force: true }).catch(() => undefined); return { status: "failed", reason: "write" }; } try { const readback = parseObject(await readFile(client.configPath, "utf8")); const value = objectValue(readback.mcpServers)[ PRODUCT_IDENTITY.mcpServerKey ]; if (JSON.stringify(value) !== JSON.stringify(desired)) { await restoreConfig(client.configPath, original); return { status: "failed", reason: "readback" }; } } catch { await restoreConfig(client.configPath, original); return { status: "failed", reason: "readback" }; } return { status: "configured", ...(backupPath === undefined ? {} : { backupPath }), }; }; const installCanonicalSkill = async ( home: string, ): Promise<"installed" | "unchanged" | "failed"> => { const destination = join( home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md", ); const content = `---\nname: ${PRODUCT_IDENTITY.skillName}\ndescription: Analyze binaries with Better Binary and Hopper.\n---\n\nOpen a target with open_binary, begin with binary_overview, and close it when finished.\n`; try { if ( (await readFile(destination, "utf8").catch(() => undefined)) === content ) return "unchanged"; await mkdir(dirname(destination), { recursive: true }); await writeFile(destination, content, { encoding: "utf8", mode: 0o600 }); return (await readFile(destination, "utf8")) === content ? "installed" : "failed"; } catch { return "failed"; } }; const commandSucceeds = async ( command: string, args: readonly string[], env?: NodeJS.ProcessEnv, ): Promise => { try { await execFileAsync(command, args, env === undefined ? {} : { env }); return true; } catch { return false; } }; const brewSucceeds = async (args: readonly string[]): Promise => { for (const command of [ "brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew", ]) if (await commandSucceeds(command, args)) return true; return false; }; const major = (version: string): number => Number.parseInt(version.split(".")[0] ?? "0", 10); const exists = async (path: string): Promise => { try { await access(path); return true; } catch { return false; } }; const objectSchema = z.record(z.string(), z.unknown()); const objectValue = (value: unknown): Record => { const parsed = objectSchema.safeParse(value); return parsed.success ? parsed.data : {}; }; const parseObject = (text: string): Record => objectValue(JSON.parse(text)); const isMissing = (cause: unknown): boolean => cause instanceof Error && "code" in cause && cause.code === "ENOENT"; const restoreConfig = async ( path: string, original: string | undefined, ): Promise => { try { if (original === undefined) await rm(path, { force: true }); else await writeFile(path, original, { encoding: "utf8", mode: 0o600 }); } catch { // The backup remains available for the remediation reported by setup. } };