522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
import {
|
|
access,
|
|
copyFile,
|
|
mkdir,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { z } from "zod";
|
|
import writeFileAtomic from "write-file-atomic";
|
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import { runDoctor, systemDoctorHost } from "./Doctor.js";
|
|
import { probeHomebrew } from "./homebrew.js";
|
|
import {
|
|
installLinuxHopper,
|
|
readLinuxDistribution,
|
|
type LinuxDistribution,
|
|
} from "./LinuxHopper.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const registrationCommand = (): readonly string[] =>
|
|
process.env.npm_command === "exec"
|
|
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
|
|
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
|
|
|
/** A detected agent configuration owned by setup. */
|
|
export interface SetupClient {
|
|
readonly name: string;
|
|
readonly configPath: string;
|
|
readonly markerPath?: string;
|
|
readonly format?: "json" | "toml" | "unsupported";
|
|
}
|
|
/** Result of one backup/write/readback transaction. */
|
|
export type ClientConfigurationResult =
|
|
| {
|
|
readonly status: "unchanged" | "configured" | "skipped";
|
|
readonly backupPath?: string;
|
|
}
|
|
| {
|
|
readonly status: "failed";
|
|
readonly reason: "backup" | "write" | "readback";
|
|
};
|
|
/**
|
|
* Effects required by the idempotent setup workflow.
|
|
* Implementations report interrupted installers as values: setup must not wait
|
|
* indefinitely for stdin or a GUI response in an unattended agent invocation.
|
|
*/
|
|
export interface SetupHost {
|
|
readonly platform: NodeJS.Platform;
|
|
readonly nodeVersion: string;
|
|
macosVersion(): Promise<string | undefined>;
|
|
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
|
hasHomebrew(): Promise<boolean>;
|
|
installHomebrew(): Promise<boolean>;
|
|
hopperPath(): Promise<string | undefined>;
|
|
installHopper(): Promise<boolean>;
|
|
detectedClients(): Promise<readonly SetupClient[]>;
|
|
configureClient(
|
|
client: SetupClient,
|
|
hopperPath: string,
|
|
command: readonly string[],
|
|
): Promise<ClientConfigurationResult>;
|
|
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
|
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
|
}
|
|
/**
|
|
* Structured setup outcome intended to travel back through an agent to a human.
|
|
* `needs_human` carries remediation text instead of initiating an interactive
|
|
* prompt, avoiding deadlocks in stdio and other unattended environments.
|
|
*/
|
|
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 detected clients idempotently.
|
|
* With `yes` false, mutations requiring consent are reported as `needs_human`;
|
|
* with `yes` true, external installers still fail closed if they require UI.
|
|
*/
|
|
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,
|
|
});
|
|
const unsupported = await hostRemediation(host);
|
|
if (unsupported !== undefined) return fail(unsupported);
|
|
if (host.platform === "darwin" && !(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");
|
|
}
|
|
let hopperPath = await host.hopperPath();
|
|
if (hopperPath === undefined) {
|
|
if (!yes) return fail("Re-run with --yes to install Hopper.");
|
|
if (!(await host.installHopper()))
|
|
return fail(
|
|
"Hopper installation was interrupted; resolve the system prompt and re-run setup.",
|
|
);
|
|
actions.push("installed_hopper");
|
|
hopperPath = await host.hopperPath();
|
|
if (hopperPath === undefined)
|
|
return fail(
|
|
"Hopper installation completed but its launcher was not found.",
|
|
);
|
|
}
|
|
for (const client of await host.detectedClients()) {
|
|
const result = await host.configureClient(
|
|
client,
|
|
hopperPath,
|
|
registrationCommand(),
|
|
);
|
|
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();
|
|
const activationRequired = actions.includes("installed_hopper");
|
|
const ready = doctor.healthy && !activationRequired;
|
|
return {
|
|
status: ready ? "ready" : "needs_human",
|
|
actions,
|
|
clients,
|
|
doctor,
|
|
...(ready
|
|
? {}
|
|
: {
|
|
remediation: activationRequired
|
|
? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
|
|
: "Run rea doctor and apply each reported remediation.",
|
|
}),
|
|
};
|
|
};
|
|
|
|
const hostRemediation = async (
|
|
host: SetupHost,
|
|
): Promise<string | undefined> => {
|
|
if (host.platform !== "darwin" && host.platform !== "linux")
|
|
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
|
|
if (major(host.nodeVersion) < 24) return "Install Node.js 24.18 or newer.";
|
|
if (host.platform === "darwin") {
|
|
const version = await host.macosVersion();
|
|
return version === undefined || major(version) < 12
|
|
? "Upgrade to macOS 12 or newer."
|
|
: undefined;
|
|
}
|
|
return (await host.linuxDistribution())?.supported === true
|
|
? undefined
|
|
: "REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.";
|
|
};
|
|
|
|
/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
|
|
const systemSetupHost = (): SetupHost => {
|
|
const doctorHost = systemDoctorHost();
|
|
return {
|
|
platform: process.platform,
|
|
nodeVersion: process.versions.node,
|
|
macosVersion: () => doctorHost.macosVersion(),
|
|
linuxDistribution: readLinuxDistribution,
|
|
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: () =>
|
|
process.platform === "linux"
|
|
? installLinuxHopper().then(({ status }) => status === "installed")
|
|
: brewSucceeds(["install", "--cask", "hopper-disassembler"]),
|
|
detectedClients: () => detectClients(homedir()),
|
|
configureClient: (client, hopperPath, command) =>
|
|
client.format === "unsupported"
|
|
? Promise.resolve({ status: "skipped" })
|
|
: client.format === "toml"
|
|
? configureTomlClient(client, hopperPath, command)
|
|
: configureJsonClient(client, hopperPath, command),
|
|
installSkill: () => installCanonicalSkill(homedir()),
|
|
doctor: () => runDoctor(undefined, doctorHost),
|
|
};
|
|
};
|
|
|
|
/** Detect supported coding agents from stable per-user installation markers. */
|
|
export const detectClients = async (
|
|
home: string,
|
|
): Promise<readonly SetupClient[]> => {
|
|
const detected: SetupClient[] = [];
|
|
for (const candidate of supportedClients(home))
|
|
if (await exists(candidate.markerPath ?? candidate.configPath))
|
|
detected.push(candidate);
|
|
return detected;
|
|
};
|
|
|
|
/** Describe every client location that setup or uninstall may own. */
|
|
export const supportedClients = (home: string): readonly SetupClient[] => [
|
|
{
|
|
name: "claude_code",
|
|
configPath: join(home, ".claude.json"),
|
|
markerPath: join(home, ".claude"),
|
|
},
|
|
{
|
|
name: "claude_desktop",
|
|
configPath: join(
|
|
home,
|
|
"Library/Application Support/Claude/claude_desktop_config.json",
|
|
),
|
|
markerPath: join(home, "Library/Application Support/Claude"),
|
|
},
|
|
{
|
|
name: "codex",
|
|
configPath: join(home, ".codex/config.toml"),
|
|
markerPath: join(home, ".codex"),
|
|
format: "toml" as const,
|
|
},
|
|
{
|
|
name: "cursor",
|
|
configPath: join(home, ".cursor/mcp.json"),
|
|
markerPath: join(home, ".cursor"),
|
|
},
|
|
{
|
|
name: "gemini_cli",
|
|
configPath: join(home, ".gemini/settings.json"),
|
|
markerPath: join(home, ".gemini"),
|
|
},
|
|
{
|
|
name: "windsurf",
|
|
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
|
|
markerPath: join(home, ".codeium/windsurf"),
|
|
},
|
|
{
|
|
name: "devin",
|
|
configPath: join(home, ".devin"),
|
|
markerPath: join(home, ".devin"),
|
|
format: "unsupported" as const,
|
|
},
|
|
];
|
|
|
|
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
|
|
export const configureJsonClient = async (
|
|
client: SetupClient,
|
|
hopperPath?: string,
|
|
command: readonly string[] = [
|
|
"npx",
|
|
"-y",
|
|
PRODUCT_IDENTITY.packageName,
|
|
"mcp",
|
|
],
|
|
): 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" };
|
|
}
|
|
let servers: Record<string, unknown>;
|
|
try {
|
|
servers = parseOptionalObject(document.mcpServers);
|
|
} catch {
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
const desired = {
|
|
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
|
args: command.slice(1),
|
|
...(hopperPath === undefined
|
|
? {}
|
|
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
|
|
};
|
|
if (
|
|
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
|
|
JSON.stringify(desired)
|
|
)
|
|
return { status: "unchanged" };
|
|
let backupPath: string | undefined;
|
|
if (original !== undefined) {
|
|
backupPath = `${client.configPath}.rea.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}.rea.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 = parseOptionalObject(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 }),
|
|
};
|
|
};
|
|
|
|
/** Back up, atomically update, and semantically read back Codex TOML configuration. */
|
|
export const configureTomlClient = async (
|
|
client: SetupClient,
|
|
hopperPath?: string,
|
|
command: readonly string[] = [
|
|
"npx",
|
|
"-y",
|
|
PRODUCT_IDENTITY.packageName,
|
|
"mcp",
|
|
],
|
|
): Promise<ClientConfigurationResult> => {
|
|
let document: Record<string, unknown> = {};
|
|
let original: string | undefined;
|
|
try {
|
|
original = await readFile(client.configPath, "utf8");
|
|
document = objectSchema.parse(parseToml(original));
|
|
} catch (cause: unknown) {
|
|
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
|
|
}
|
|
let servers: Record<string, unknown>;
|
|
try {
|
|
servers = parseOptionalObject(document.mcp_servers);
|
|
} catch {
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
const desired = {
|
|
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
|
args: command.slice(1),
|
|
...(hopperPath === undefined
|
|
? {}
|
|
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
|
|
};
|
|
if (
|
|
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
|
|
JSON.stringify(desired)
|
|
)
|
|
return { status: "unchanged" };
|
|
const backupPath =
|
|
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
|
try {
|
|
if (backupPath !== undefined) await copyFile(client.configPath, backupPath);
|
|
} catch {
|
|
return { status: "failed", reason: "backup" };
|
|
}
|
|
document.mcp_servers = {
|
|
...servers,
|
|
[PRODUCT_IDENTITY.mcpServerKey]: desired,
|
|
};
|
|
try {
|
|
await mkdir(dirname(client.configPath), { recursive: true });
|
|
await writeFileAtomic(client.configPath, stringifyToml(document), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
const readback = objectSchema.parse(
|
|
parseToml(await readFile(client.configPath, "utf8")),
|
|
);
|
|
if (
|
|
JSON.stringify(
|
|
parseOptionalObject(readback.mcp_servers)[
|
|
PRODUCT_IDENTITY.mcpServerKey
|
|
],
|
|
) !== JSON.stringify(desired)
|
|
)
|
|
throw new Error("TOML readback mismatch");
|
|
} catch {
|
|
await restoreConfig(client.configPath, original);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
return {
|
|
status: "configured",
|
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
};
|
|
};
|
|
|
|
/** Transactionally install or upgrade the versioned canonical REA skill. */
|
|
export const installCanonicalSkill = async (
|
|
home: string,
|
|
): Promise<"installed" | "unchanged" | "failed"> => {
|
|
const destination = join(
|
|
home,
|
|
".agents/skills",
|
|
PRODUCT_IDENTITY.skillName,
|
|
"SKILL.md",
|
|
);
|
|
const backup = `${destination}.rea.backup`;
|
|
let original: string | undefined;
|
|
try {
|
|
const content = await readFile(
|
|
new URL(
|
|
`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`,
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
);
|
|
original = await readFile(destination, "utf8").catch(() => undefined);
|
|
if (original === content) return "unchanged";
|
|
await mkdir(dirname(destination), { recursive: true });
|
|
if (original !== undefined)
|
|
await writeFileAtomic(backup, original, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
await writeFileAtomic(destination, content, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
if ((await readFile(destination, "utf8")) !== content)
|
|
throw new Error("skill readback mismatch");
|
|
return "installed";
|
|
} catch {
|
|
try {
|
|
if (original === undefined) await rm(destination, { force: true });
|
|
else
|
|
await writeFileAtomic(destination, original, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
} catch {
|
|
// The backup remains beside the skill for explicit operator recovery.
|
|
}
|
|
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> => {
|
|
const result = await probeHomebrew(async (command) =>
|
|
(await commandSucceeds(command, args)) ? true : undefined,
|
|
);
|
|
return result === true;
|
|
};
|
|
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 parseOptionalObject = (value: unknown): Record<string, unknown> =>
|
|
value === undefined ? {} : objectSchema.parse(value);
|
|
const parseObject = (text: string): Record<string, unknown> =>
|
|
objectSchema.parse(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.
|
|
}
|
|
};
|