feat(cli): add guided local onboarding

This commit is contained in:
morluto 2026-07-12 06:34:27 +08:00
parent f32e718cc7
commit 8ca3db829f
11 changed files with 1140 additions and 1 deletions

65
package-lock.json generated
View File

@ -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",

View File

@ -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": {

64
scripts/better-binary.mjs Normal file
View File

@ -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();

178
scripts/verify-package.mjs Normal file
View File

@ -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);
}

View File

@ -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.

View File

@ -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<Record<string, JsonValue>>,
): Promise<JsonValue> => {
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();
}
};

171
src/application/Doctor.ts Normal file
View File

@ -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<string | undefined>;
readable(path: string): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
}
/** 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 }),
});

311
src/application/Setup.ts Normal file
View File

@ -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<string | undefined>;
hasHomebrew(): Promise<boolean>;
installHomebrew(): Promise<boolean>;
hopperPath(): Promise<string | undefined>;
installHopper(): Promise<boolean>;
detectedClients(): Promise<readonly SetupClient[]>;
configureClient(client: SetupClient): Promise<ClientConfigurationResult>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
}
/** Structured setup outcome with actionable partial progress. */
export interface SetupResult {
readonly status: "ready" | "needs_human";
readonly actions: readonly string[];
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
readonly remediation?: string;
}
/** Install prerequisites and configure every detected supported client idempotently. */
export const runSetup = async (
yes: boolean,
host: SetupHost = systemSetupHost(),
): Promise<SetupResult> => {
const actions: string[] = [];
const clients: Record<string, ClientConfigurationResult> = {};
const fail = async (remediation: string): Promise<SetupResult> => ({
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<readonly SetupClient[]> => {
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<ClientConfigurationResult> => {
let document: Record<string, unknown> = {};
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<boolean> => {
try {
await execFileAsync(command, args, env === undefined ? {} : { env });
return true;
} catch {
return false;
}
};
const brewSucceeds = async (args: readonly string[]): Promise<boolean> => {
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<boolean> => {
try {
await access(path);
return true;
} catch {
return false;
}
};
const objectSchema = z.record(z.string(), z.unknown());
const objectValue = (value: unknown): Record<string, unknown> => {
const parsed = objectSchema.safeParse(value);
return parsed.success ? parsed.data : {};
};
const parseObject = (text: string): Record<string, unknown> =>
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<void> => {
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.
}
};

62
tests/doctor.test.ts Normal file
View File

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
const host = (overrides: Partial<DoctorHost> = {}): 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);
});
});

161
tests/setup.test.ts Normal file
View File

@ -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<string, ClientConfigurationResult>();
homebrewInstalls = 0;
hopperInstalls = 0;
configurations = 0;
macosVersion = (): Promise<string | undefined> =>
Promise.resolve(this.version);
hasHomebrew = (): Promise<boolean> => Promise.resolve(this.brew);
installHomebrew = (): Promise<boolean> => {
this.homebrewInstalls += 1;
if (this.homebrewInstallSucceeds) this.brew = true;
return Promise.resolve(this.homebrewInstallSucceeds);
};
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
installHopper = (): Promise<boolean> => {
this.hopperInstalls += 1;
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
return Promise.resolve(this.hopperInstallSucceeds);
};
detectedClients = (): Promise<readonly SetupClient[]> =>
Promise.resolve(this.clients);
configureClient = (
client: SetupClient,
): Promise<ClientConfigurationResult> => {
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);
},
);
});

61
tests/setupConfig.test.ts Normal file
View File

@ -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");
});
});