mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 07:16:13 +00:00
feat(cli): add guided local onboarding
This commit is contained in:
1 parent
f32e718cc7
commit
8ca3db829f
11 files changed
+1140
-1
No files matched your search
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user