From 8ca3db829f90f99dcaa57bbfc44fb3fe6a6b60c0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:34:27 +0800 Subject: [PATCH] feat(cli): add guided local onboarding --- package-lock.json | 65 ++++++ package.json | 13 +- scripts/better-binary.mjs | 64 +++++ scripts/verify-package.mjs | 178 ++++++++++++++ skills/better-binary-analysis/SKILL.md | 8 + src/application/DirectAnalysis.ts | 47 ++++ src/application/Doctor.ts | 171 ++++++++++++++ src/application/Setup.ts | 311 +++++++++++++++++++++++++ tests/doctor.test.ts | 62 +++++ tests/setup.test.ts | 161 +++++++++++++ tests/setupConfig.test.ts | 61 +++++ 11 files changed, 1140 insertions(+), 1 deletion(-) create mode 100644 scripts/better-binary.mjs create mode 100644 scripts/verify-package.mjs create mode 100644 skills/better-binary-analysis/SKILL.md create mode 100644 src/application/DirectAnalysis.ts create mode 100644 src/application/Doctor.ts create mode 100644 src/application/Setup.ts create mode 100644 tests/doctor.test.ts create mode 100644 tests/setup.test.ts create mode 100644 tests/setupConfig.test.ts diff --git a/package-lock.json b/package-lock.json index 71647255b..d9f718a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@modelcontextprotocol/server": "2.0.0-beta.3", + "incur": "0.4.13", "zod": "4.4.3" }, "bin": { @@ -28,6 +29,12 @@ "node": ">=20" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -602,6 +609,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@scalar/openapi-types": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.8.0.tgz", + "integrity": "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -609,6 +625,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@toon-format/toon": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz", + "integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1646,6 +1668,28 @@ "node": ">=0.8.19" } }, + "node_modules/incur": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/incur/-/incur-0.4.13.tgz", + "integrity": "sha512-BeKlYFLIsRCgC8IxsUd3S2/4kPeZaNf1Rbz+Kz41jQII4jOgr7j5w4KYe00aAEQa3V0Ur57BVxE5JDTx8L2s5Q==", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@modelcontextprotocol/server": "^2.0.0-alpha.2", + "@scalar/openapi-types": "^0.8.0", + "@toon-format/toon": "^2.1.0", + "tokenx": "^1.3.0", + "yaml": "^2.8.2", + "zod": "^4.3.6" + }, + "bin": { + "incur": "dist/bin.js", + "incur.src": "src/bin.ts" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2474,6 +2518,12 @@ "node": ">=14.0.0" } }, + "node_modules/tokenx": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz", + "integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2774,6 +2824,21 @@ "node": ">=0.10.0" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0ca1203fa..b6accb4ac 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,20 @@ "private": true, "type": "module", "bin": { + "better-binary": "./scripts/better-binary.mjs", "better-binary-mcp": "./dist/main.js" }, "engines": { - "node": ">=20" + "node": ">=22" }, + "files": [ + "dist", + "bridge", + "scripts/better-binary.mjs", + "skills", + "README.md", + "LICENSE" + ], "scripts": { "build": "tsc -p tsconfig.build.json", "check": "npm run typecheck && npm run lint && npm run format:check && npm test", @@ -21,10 +30,12 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit", "verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs", + "verify:package": "npm run build && node scripts/verify-package.mjs", "config:print": "node scripts/print-mcp-config.mjs" }, "dependencies": { "@modelcontextprotocol/server": "2.0.0-beta.3", + "incur": "0.4.13", "zod": "4.4.3" }, "devDependencies": { diff --git a/scripts/better-binary.mjs b/scripts/better-binary.mjs new file mode 100644 index 000000000..f621b18c6 --- /dev/null +++ b/scripts/better-binary.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { Cli, z } from "incur"; +import { runDoctor } from "../dist/application/Doctor.js"; +import { runDirectAnalysis } from "../dist/application/DirectAnalysis.js"; +import { runSetup } from "../dist/application/Setup.js"; +import { PRODUCT_IDENTITY } from "../dist/identity.js"; + +const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, { + version: "0.1.0", + description: "Install, diagnose, and automate Hopper binary analysis.", + mcp: { + command: PRODUCT_IDENTITY.mcpBinary, + instructions: + "Open a local target with open_binary, analyze it, and close the session when finished.", + }, + sync: { + cwd: import.meta.dirname, + include: ["../skills/*"], + suggestions: [ + "open a binary and summarize it", + "decompile a procedure", + "diagnose my Hopper setup", + ], + }, +}); + +cli.command("setup", { + description: "Install prerequisites and configure binary analysis", + options: z.object({ + yes: z + .boolean() + .default(false) + .describe("Approve prerequisite installation"), + }), + alias: { yes: "y" }, + run: ({ options }) => runSetup(options.yes), +}); +cli.command("doctor", { + description: "Diagnose the local Hopper integration", + options: z.object({ + target: z.string().optional().describe("Optional binary path to check"), + }), + run: ({ options }) => runDoctor(options.target), +}); +cli.command("analyze", { + description: "Analyze a binary through the beta.3 MCP server", + args: z.object({ + path: z.string().describe("Binary or Hopper database path"), + }), + run: ({ args }) => runDirectAnalysis(args.path, "binary_overview", {}), +}); +cli.command("decompile", { + description: "Decompile an address through the beta.3 MCP server", + args: z.object({ + path: z.string().describe("Binary path"), + address: z.string().describe("Procedure address"), + }), + run: ({ args }) => + runDirectAnalysis(args.path, "procedure_pseudo_code", { + procedure: args.address, + }), +}); +cli.serve(); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 000000000..c410ec009 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,178 @@ +import { execFile } from "node:child_process"; +import { + chmod, + mkdtemp, + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +const exec = promisify(execFile); +const root = process.cwd(); +const workspace = await mkdtemp(join(tmpdir(), "better-binary-package-")); +let tarball; + +try { + tarball = ( + await exec("npm", ["pack", "--silent"], { cwd: root }) + ).stdout.trim(); + const prefix = join(workspace, "prefix"); + const home = join(workspace, "home"); + const fakeBin = join(workspace, "bin"); + await mkdir(fakeBin, { recursive: true }); + const brew = join(fakeBin, "brew"); + const swVers = join(fakeBin, "sw_vers"); + const npx = join(fakeBin, "npx"); + const npxLog = join(workspace, "npx.log"); + const hopper = join(workspace, "hopper"); + await writeFile( + brew, + '#!/bin/sh\nif [ "$1" = "--prefix" ]; then echo /fake/cask; fi\nexit 0\n', + ); + await writeFile(swVers, "#!/bin/sh\necho 14.5\n"); + await writeFile( + npx, + '#!/bin/sh\nprintf "%s\\n" "$*" > "$BETTER_BINARY_NPX_LOG"\necho "│ ✓ Cursor: isolated │"\n', + ); + await writeFile(hopper, "#!/bin/sh\nexit 0\n"); + await Promise.all([ + chmod(brew, 0o755), + chmod(swVers, 0o755), + chmod(hopper, 0o755), + chmod(npx, 0o755), + ]); + const claudeDir = join(home, "Library/Application Support/Claude"); + const cursorDir = join(home, ".cursor"); + await Promise.all([ + mkdir(claudeDir, { recursive: true }), + mkdir(cursorDir, { recursive: true }), + ]); + const claudeConfig = join(claudeDir, "claude_desktop_config.json"); + const cursorConfig = join(cursorDir, "mcp.json"); + await writeFile(claudeConfig, '{"existing":true}\n'); + await writeFile(cursorConfig, '{"existing":true}\n'); + const environment = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + HOPPER_LAUNCHER_PATH: hopper, + BETTER_BINARY_NPX_LOG: npxLog, + }; + await exec( + "npm", + ["install", "--global", "--prefix", prefix, join(root, tarball)], + { env: environment }, + ); + const cli = join(prefix, "bin", "better-binary"); + const mcp = join(prefix, "bin", "better-binary-mcp"); + const help = await run(cli, ["--help"], environment); + const llms = await run(cli, ["--llms"], environment); + const doctor = json(await run(cli, ["doctor", "--json"], environment)); + if ( + !help.includes("setup") || + !llms.includes("decompile") || + doctor.healthy !== true + ) + throw new Error("packaged CLI discovery or doctor failed"); + await run(cli, ["mcp", "add"], environment); + const mcpRegistration = await readFile(npxLog, "utf8"); + if ( + !mcpRegistration.includes("add-mcp better-binary-mcp --name better-binary") + ) + throw new Error("Incur mcp add did not register the beta.3 executable"); + const first = json(await run(cli, ["setup", "--yes", "--json"], environment)); + const second = json( + await run(cli, ["setup", "--yes", "--json"], environment), + ); + if ( + first.status !== "ready" || + second.status !== "ready" || + second.actions.length !== 0 + ) + throw new Error("packaged setup was not ready and idempotent"); + for (const configPath of [claudeConfig, cursorConfig]) { + const config = json(await readFile(configPath, "utf8")); + if ( + config.existing !== true || + config.mcpServers?.["better-binary"]?.command !== "better-binary-mcp" + ) + throw new Error("packaged client readback failed"); + if ( + !(await readFile(`${configPath}.better-binary.backup`, "utf8")).includes( + "existing", + ) + ) + throw new Error("packaged client backup failed"); + } + const skill = await readFile( + join(home, ".agents/skills/better-binary-analysis/SKILL.md"), + "utf8", + ); + if (!skill.includes("open_binary")) + throw new Error("packaged skill readback failed"); + + await writeFile(cursorConfig, "malformed"); + const failed = json( + await run(cli, ["setup", "--yes", "--json"], environment), + ); + if ( + failed.status !== "needs_human" || + (await readFile(cursorConfig, "utf8")) !== "malformed" + ) + throw new Error("packaged setup failure recovery did not preserve input"); + await writeFile(cursorConfig, "{}\n"); + const recovered = json( + await run(cli, ["setup", "--yes", "--json"], environment), + ); + if (recovered.status !== "ready") + throw new Error("packaged setup did not recover"); + + const transport = new StdioClientTransport({ + command: mcp, + env: environment, + stderr: "pipe", + }); + let mcpStderr = ""; + transport.stderr?.on("data", (chunk) => { + mcpStderr += chunk.toString(); + }); + const client = new Client({ name: "package-smoke", version: "1.0.0" }); + try { + await client.connect(transport); + if ((await client.listTools()).tools.length !== 42) + throw new Error("packaged MCP did not expose 42 tools"); + const result = await client.callTool({ + name: "current_document", + arguments: {}, + }); + if (result.isError !== true) + throw new Error("packaged target-free MCP omitted no-target error"); + } catch (cause) { + throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause }); + } finally { + await client.close(); + await transport.close(); + } + + process.stdout.write( + `${JSON.stringify({ cli: true, incurMcpCommand: "better-binary-mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 42, targetFree: true })}\n`, + ); +} finally { + if (tarball) await rm(join(root, tarball), { force: true }); + await rm(workspace, { recursive: true, force: true }); +} + +async function run(command, args, env) { + return (await exec(command, args, { env })).stdout; +} +function json(text) { + return JSON.parse(text); +} diff --git a/skills/better-binary-analysis/SKILL.md b/skills/better-binary-analysis/SKILL.md new file mode 100644 index 000000000..fa6c675ec --- /dev/null +++ b/skills/better-binary-analysis/SKILL.md @@ -0,0 +1,8 @@ +--- +name: better-binary-analysis +description: Analyze local binaries with Better Binary and Hopper through its MCP session workflow. +--- + +# Better Binary Analysis + +Call `open_binary` with a readable local path before analysis. Begin with `binary_overview`, use strings and symbol searches to identify relevant procedures, then use `procedure_pseudo_code`, callers, callees, and xrefs to build evidence. Call `open_binary` again to switch targets and `close_binary` when finished. Never claim real-Hopper behavior from mock-only evidence. diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts new file mode 100644 index 000000000..c8d23c00e --- /dev/null +++ b/src/application/DirectAnalysis.ts @@ -0,0 +1,47 @@ +import { fileURLToPath } from "node:url"; + +import { parseConfig } from "../config.js"; +import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js"; +import { HopperClient } from "../hopper/HopperClient.js"; +import type { JsonValue } from "../hopper/protocol.js"; +import { BinarySession } from "./BinarySession.js"; + +/** Open one binary, execute one tool, and always release the target session. */ +export const runDirectAnalysis = async ( + path: string, + tool: "binary_overview" | "procedure_pseudo_code", + arguments_: Readonly>, +): Promise => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const bridgeScriptPath = fileURLToPath( + new URL("../../bridge/hopper_bridge.py", import.meta.url), + ); + const session = new BinarySession( + (target) => + new HopperClient({ + launcher: new HopperApplicationLauncher({ + launcherPath: config.value.hopperLauncherPath, + targetPath: target.path, + targetKind: target.kind, + loaderArgs: + config.value.hopperLoaderArgs.length > 0 + ? config.value.hopperLoaderArgs + : target.loaderArgs, + bridgeScriptPath, + }), + }), + ); + try { + const opened = await session.open(path); + if (!opened.ok) + return { error: opened.error._tag, message: opened.error.message }; + const result = await session.callTool(tool, arguments_); + return result.ok + ? result.value + : { error: result.error._tag, message: result.error.message }; + } finally { + await session.close(); + } +}; diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts new file mode 100644 index 000000000..d83411553 --- /dev/null +++ b/src/application/Doctor.ts @@ -0,0 +1,171 @@ +import { constants } from "node:fs"; +import { access } from "node:fs/promises"; +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const DEFAULT_HOPPER = + "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; + +/** One actionable environment diagnostic. */ +export interface DoctorCheck { + readonly name: string; + readonly ok: boolean; + readonly detail?: string; + readonly remediation?: string; +} +/** Host facts and effects required by diagnostics. */ +export interface DoctorHost { + readonly platform: NodeJS.Platform; + readonly nodeVersion: string; + readonly configuredHopperPath?: string; + macosVersion(): Promise; + readable(path: string): Promise; + brewHopperPath(): Promise; + manualHopperPaths(): Promise; +} + +/** Check requirements and an optional target without mutating the host. */ +export const runDoctor = async ( + target?: string, + host: DoctorHost = systemDoctorHost(), +): Promise<{ + readonly healthy: boolean; + readonly hopperPath?: string; + readonly checks: readonly DoctorCheck[]; +}> => { + const checks: DoctorCheck[] = []; + const nodeMajor = parseMajor(host.nodeVersion); + checks.push( + check( + "node", + nodeMajor >= 22, + host.nodeVersion, + "Install Node.js 22 or newer.", + ), + ); + const macosVersion = + host.platform === "darwin" ? await host.macosVersion() : undefined; + const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion); + checks.push( + check( + "macos", + host.platform === "darwin" && macosMajor >= 12, + macosVersion ?? host.platform, + "Hopper requires macOS 12 or newer.", + ), + ); + const candidates = [ + host.configuredHopperPath, + DEFAULT_HOPPER, + ...(await host.manualHopperPaths()), + await host.brewHopperPath(), + ].filter((value): value is string => value !== undefined); + let hopperPath: string | undefined; + for (const candidate of new Set(candidates)) + if (await host.readable(candidate)) { + hopperPath = candidate; + break; + } + checks.push( + check( + "hopper", + hopperPath !== undefined, + hopperPath, + "Install Hopper with: brew install --cask hopper-disassembler, or set HOPPER_LAUNCHER_PATH.", + ), + ); + if (target !== undefined) + checks.push( + check( + "target", + await host.readable(target), + target, + "Supply a readable local binary path.", + ), + ); + return { + healthy: checks.every(({ ok }) => ok), + ...(hopperPath === undefined ? {} : { hopperPath }), + checks, + }; +}; + +/** Create diagnostics backed by the current process and host commands. */ +export const systemDoctorHost = (): DoctorHost => ({ + platform: process.platform, + nodeVersion: process.versions.node, + ...(process.env.HOPPER_LAUNCHER_PATH === undefined + ? {} + : { configuredHopperPath: process.env.HOPPER_LAUNCHER_PATH }), + async macosVersion() { + try { + return ( + await execFileAsync("sw_vers", ["-productVersion"]) + ).stdout.trim(); + } catch { + return undefined; + } + }, + async readable(path) { + try { + await access(path, constants.R_OK); + return true; + } catch { + return false; + } + }, + async brewHopperPath() { + for (const command of [ + "brew", + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + ]) { + try { + const prefix = ( + await execFileAsync(command, [ + "--prefix", + "--cask", + "hopper-disassembler", + ]) + ).stdout.trim(); + return `${prefix}/Hopper Disassembler.app/Contents/MacOS/hopper`; + } catch { + // Try the next standard Homebrew executable location. + } + } + return undefined; + }, + async manualHopperPaths() { + const roots = ["/Applications", join(homedir(), "Applications")]; + const paths: string[] = []; + for (const root of roots) { + try { + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name)) + paths.push(join(root, entry.name, "Contents/MacOS/hopper")); + } + } catch { + // A missing or unreadable optional application directory is not fatal. + } + } + return paths; + }, +}); + +const parseMajor = (version: string): number => + Number.parseInt(version.split(".")[0] ?? "0", 10); +const check = ( + name: string, + ok: boolean, + detail: string | undefined, + remediation: string, +): DoctorCheck => ({ + name, + ok, + ...(detail === undefined ? {} : { detail }), + ...(ok ? {} : { remediation }), +}); diff --git a/src/application/Setup.ts b/src/application/Setup.ts new file mode 100644 index 000000000..282b25adc --- /dev/null +++ b/src/application/Setup.ts @@ -0,0 +1,311 @@ +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. + } +}; diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 000000000..bda405f74 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { runDoctor, type DoctorHost } from "../src/application/Doctor.js"; + +const host = (overrides: Partial = {}): DoctorHost => ({ + platform: "darwin", + nodeVersion: "22.1.0", + macosVersion: () => Promise.resolve("12.0"), + readable: (path) => Promise.resolve(path.includes("Hopper")), + brewHopperPath: () => Promise.resolve(undefined), + manualHopperPaths: () => Promise.resolve([]), + ...overrides, +}); + +describe("doctor", () => { + it.each(["11.7", undefined])( + "rejects unsupported macOS version %s", + async (version) => { + const result = await runDoctor( + undefined, + host({ macosVersion: () => Promise.resolve(version) }), + ); + expect(result.checks.find(({ name }) => name === "macos")?.ok).toBe( + false, + ); + }, + ); + it("detects a manual configured Hopper before Homebrew", async () => { + const result = await runDoctor( + undefined, + host({ + configuredHopperPath: "/manual/Hopper", + readable: (path) => Promise.resolve(path === "/manual/Hopper"), + }), + ); + expect(result.hopperPath).toBe("/manual/Hopper"); + expect(result.healthy).toBe(true); + }); + it("detects a Homebrew cask installed outside /Applications", async () => { + const path = + "/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper"; + const result = await runDoctor( + undefined, + host({ + readable: (candidate) => Promise.resolve(candidate === path), + brewHopperPath: () => Promise.resolve(path), + }), + ); + expect(result.hopperPath).toBe(path); + }); + + it("detects a manually installed Hopper application", async () => { + const path = "/Applications/Hopper v6.app/Contents/MacOS/hopper"; + const result = await runDoctor( + undefined, + host({ + readable: (candidate) => Promise.resolve(candidate === path), + manualHopperPaths: () => Promise.resolve([path]), + }), + ); + expect(result.hopperPath).toBe(path); + }); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts new file mode 100644 index 000000000..e1df79cc5 --- /dev/null +++ b/tests/setup.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { + runSetup, + type ClientConfigurationResult, + type SetupClient, + type SetupHost, +} from "../src/application/Setup.js"; +import type { DoctorCheck } from "../src/application/Doctor.js"; + +class FakeSetupHost implements SetupHost { + readonly platform = "darwin" as const; + readonly nodeVersion = "22.4.0"; + version: string | undefined = "14.5"; + brew = false; + hopper: string | undefined; + homebrewInstallSucceeds = true; + hopperInstallSucceeds = true; + skill: "installed" | "unchanged" | "failed" = "installed"; + clients: readonly SetupClient[] = []; + clientResults = new Map(); + homebrewInstalls = 0; + hopperInstalls = 0; + configurations = 0; + + macosVersion = (): Promise => + Promise.resolve(this.version); + hasHomebrew = (): Promise => Promise.resolve(this.brew); + installHomebrew = (): Promise => { + this.homebrewInstalls += 1; + if (this.homebrewInstallSucceeds) this.brew = true; + return Promise.resolve(this.homebrewInstallSucceeds); + }; + hopperPath = (): Promise => Promise.resolve(this.hopper); + installHopper = (): Promise => { + this.hopperInstalls += 1; + if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper"; + return Promise.resolve(this.hopperInstallSucceeds); + }; + detectedClients = (): Promise => + Promise.resolve(this.clients); + configureClient = ( + client: SetupClient, + ): Promise => { + this.configurations += 1; + return Promise.resolve( + this.clientResults.get(client.name) ?? { + status: "configured", + backupPath: `${client.configPath}.backup`, + }, + ); + }; + installSkill = (): Promise<"installed" | "unchanged" | "failed"> => + Promise.resolve(this.skill); + doctor = (): Promise<{ + healthy: boolean; + hopperPath?: string; + checks: readonly DoctorCheck[]; + }> => + Promise.resolve({ + healthy: this.brew && this.hopper !== undefined, + ...(this.hopper === undefined ? {} : { hopperPath: this.hopper }), + checks: [], + }); +} + +describe("setup workflow", () => { + it("installs Homebrew and Hopper on a clean approved machine", async () => { + const host = new FakeSetupHost(); + const result = await runSetup(true, host); + expect(result.status).toBe("ready"); + expect(result.actions).toEqual([ + "installed_homebrew", + "installed_hopper", + "installed_skill", + ]); + }); + + it("requires --yes before installing Homebrew", async () => { + const host = new FakeSetupHost(); + const result = await runSetup(false, host); + expect(result.status).toBe("needs_human"); + expect(host.homebrewInstalls).toBe(0); + expect(result.remediation).toContain("--yes"); + }); + + it("accepts existing Homebrew and manually installed Hopper", async () => { + const host = new FakeSetupHost(); + host.brew = true; + host.hopper = "/Applications/Manual Hopper"; + expect((await runSetup(true, host)).actions).toEqual(["installed_skill"]); + expect(host.hopperInstalls).toBe(0); + }); + + it.each(["homebrew", "hopper"] as const)( + "returns needs_human when %s installation is interrupted", + async (stage) => { + const host = new FakeSetupHost(); + if (stage === "homebrew") host.homebrewInstallSucceeds = false; + else { + host.brew = true; + host.hopperInstallSucceeds = false; + } + const result = await runSetup(true, host); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toContain("interrupted"); + }, + ); + + it("configures every detected client and reports backup paths", async () => { + const host = new FakeSetupHost(); + host.brew = true; + host.hopper = "/Hopper"; + host.clients = [ + { name: "claude", configPath: "/claude.json" }, + { name: "cursor", configPath: "/cursor.json" }, + ]; + const result = await runSetup(true, host); + expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]); + expect(host.configurations).toBe(2); + }); + + it.each(["backup", "write", "readback"] as const)( + "stops with precise remediation after a %s failure", + async (reason) => { + const host = new FakeSetupHost(); + host.brew = true; + host.hopper = "/Hopper"; + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clientResults.set("cursor", { status: "failed", reason }); + const result = await runSetup(true, host); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toContain(reason); + }, + ); + + it("is idempotent on repeated setup", async () => { + const host = new FakeSetupHost(); + host.brew = true; + host.hopper = "/Hopper"; + host.skill = "unchanged"; + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clientResults.set("cursor", { status: "unchanged" }); + const first = await runSetup(true, host); + const second = await runSetup(true, host); + expect(first.actions).toEqual([]); + expect(second.actions).toEqual([]); + expect(host.homebrewInstalls).toBe(0); + expect(host.hopperInstalls).toBe(0); + }); + + it.each(["11.7", undefined])( + "rejects unsupported macOS %s before mutation", + async (version) => { + const host = new FakeSetupHost(); + host.version = version; + expect((await runSetup(true, host)).status).toBe("needs_human"); + expect(host.homebrewInstalls).toBe(0); + }, + ); +}); diff --git a/tests/setupConfig.test.ts b/tests/setupConfig.test.ts new file mode 100644 index 000000000..1154f74cd --- /dev/null +++ b/tests/setupConfig.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { configureJsonClient } from "../src/application/Setup.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe("JSON client configuration transaction", () => { + it("preserves existing keys, creates a backup, and reads back the MCP entry", async () => { + directory = await mkdtemp(join(tmpdir(), "bb-setup-")); + const configPath = join(directory, "mcp.json"); + const original = + '{"theme":"dark","mcpServers":{"other":{"command":"other"}}}\n'; + await writeFile(configPath, original); + const result = await configureJsonClient({ name: "cursor", configPath }); + expect(result.status).toBe("configured"); + expect(await readFile(`${configPath}.better-binary.backup`, "utf8")).toBe( + original, + ); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + theme: "dark", + mcpServers: { + other: { command: "other" }, + "better-binary": { command: "better-binary-mcp" }, + }, + }); + }); + + it("performs no write or second backup when configuration already matches", async () => { + directory = await mkdtemp(join(tmpdir(), "bb-setup-")); + const configPath = join(directory, "mcp.json"); + await writeFile( + configPath, + '{"mcpServers":{"better-binary":{"command":"better-binary-mcp"}}}\n', + ); + expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({ + status: "unchanged", + }); + await expect( + readFile(`${configPath}.better-binary.backup`, "utf8"), + ).rejects.toThrow(); + }); + + it("refuses malformed existing JSON without overwriting it", async () => { + directory = await mkdtemp(join(tmpdir(), "bb-setup-")); + const configPath = join(directory, "mcp.json"); + await writeFile(configPath, "not-json"); + expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({ + status: "failed", + reason: "readback", + }); + expect(await readFile(configPath, "utf8")).toBe("not-json"); + }); +});