import { access, copyFile, mkdir, readFile, rm, writeFile, } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; 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 { supportsNodeVersion } from "../domain/runtimeVersion.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { runDoctor, systemDoctorHost, type DoctorHost, type DoctorProviderInspection, } from "./Doctor.js"; import { installLinuxHopper, readLinuxDistribution, type LinuxDistribution, } from "./LinuxHopper.js"; import { installMacHopper } from "./MacHopper.js"; import { configureDetectedClients } from "./SetupClients.js"; import { supportedClients, type SetupClient } from "./SupportedClients.js"; export type { SetupClient } from "./SupportedClients.js"; import { canonicalSkillNeedsInstall, installCanonicalSkill, } from "./SetupSkill.js"; import { setupPlan } from "./SetupPlan.js"; import { setupInstallFailure, type SetupFailureCode, type SetupHopperInstallResult, } from "./SetupInstallFailure.js"; export type { SetupHopperInstallResult } from "./SetupInstallFailure.js"; /** Exact non-secret provider variables propagated into managed registrations. */ export type SetupProviderEnvironment = Readonly>; export { canonicalSkillNeedsInstall, installCanonicalSkill, } from "./SetupSkill.js"; const registrationCommand = (): readonly string[] => process.env.npm_command === "exec" ? ["npx", "-y", PRODUCT_IDENTITY.packageSpecifier, "mcp"] : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"]; /** Result of one backup/write/readback transaction. */ export type ClientConfigurationResult = | { readonly status: "unchanged" | "configured" | "skipped"; readonly backupPath?: string; } | { readonly status: "failed"; readonly reason: "path" | "backup" | "write" | "readback"; }; /** Read-only state used to block unsafe setup plans before any mutation. */ export type ClientConfigurationInspection = | { readonly status: "already_current" } | { readonly status: "create" | "update"; readonly backupPath?: string; } | { readonly status: "invalid"; readonly remediation: string; }; /** * 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; linuxDistribution(): Promise; hopperPath(): Promise; installHopper(replaceExisting: boolean): Promise; providerEnvironment?(): Promise; detectedClients(): Promise; configureClient( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], ): Promise; clientNeedsConfigure( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], ): Promise; inspectClientConfiguration?( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], ): Promise; skillNeedsInstall(): Promise; installSkill(): Promise<"installed" | "unchanged" | "failed">; doctor(): Promise>>; } /** * 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: "planned" | "needs_confirmation" | "ready" | "needs_human"; readonly plannedActions: readonly SetupAction[]; readonly appliedActions: readonly string[]; readonly clients: Readonly>; readonly doctor: Awaited>; readonly remediation?: string; readonly code?: SetupFailureCode; } /** One concrete setup mutation disclosed before approval. */ export interface SetupAction { readonly id: string; readonly kind: "install_hopper" | "configure_client" | "install_skill"; readonly label: string; readonly target: string; readonly detail: string; readonly external: boolean; readonly operation: "create" | "update" | "install"; readonly backupPath?: string; readonly networkOrigins?: readonly string[]; readonly commands?: readonly string[]; readonly integrity?: string; } /** Explicit authorization supplied by an interactive or unattended CLI adapter. */ export interface SetupOptions { readonly approved: boolean; readonly installHopper: boolean; readonly structured: boolean; readonly proposeHopper?: boolean; readonly clientIds?: readonly string[]; readonly installSkill?: boolean; readonly onProgress?: (event: SetupProgressEvent) => void; } /** One settled or active setup operation projected to the interactive adapter. */ export interface SetupProgressEvent { readonly actionId: string; readonly label: string; readonly state: "started" | "completed" | "warning" | "failed"; readonly detail?: string; } /** Interactive selection and final consent returned by the CLI adapter. */ export interface SetupConfirmationDecision { readonly approved: boolean; readonly selectedActionIds: readonly string[]; } /** Adapter-owned interactive confirmation for a fully discovered setup plan. */ export type SetupConfirmation = ( actions: readonly SetupAction[], ) => Promise; /** * Install prerequisites and configure detected clients idempotently. * Discovery always precedes mutation. Interactive confirmation or explicit * unattended options authorize only the actions represented in the result. */ export const runSetup = async ( options: SetupOptions, host: SetupHost = systemSetupHost(), confirm?: SetupConfirmation, ): Promise => { const appliedActions: string[] = []; let plannedActions: readonly SetupAction[] = []; const clients: Record = {}; const fail = async (remediation: string): Promise => ({ status: "needs_human", plannedActions, appliedActions, clients, doctor: await host.doctor(), remediation, }); const unsupported = await hostRemediation(host, options.installHopper); if (unsupported !== undefined) return fail(unsupported); let hopperPath = await host.hopperPath(); let providerEnvironment = await initialProviderEnvironment(host, hopperPath); const discovery = await discoverSetupActions({ host, providerEnvironment, forceHopperInstall: options.installHopper, proposeHopper: options.proposeHopper ?? true, selectedClientIds: options.clientIds, installSkillSelection: options.installSkill, }); if (discovery.blocker !== undefined) return fail(discovery.blocker); let { installHopper, installSkill } = discovery; let detectedClients: readonly SetupClient[] = discovery.detectedClients; plannedActions = discovery.plannedActions; let approved = options.approved || plannedActions.length === 0; let interactiveApproval = false; if (!approved && confirm !== undefined && !options.structured) { const decision = await confirm(plannedActions); if (typeof decision === "boolean") { interactiveApproval = decision; approved = decision; } else { const selected = new Set(decision.selectedActionIds); plannedActions = plannedActions.filter(({ id }) => selected.has(id)); installHopper = plannedActions.some( ({ kind }) => kind === "install_hopper", ); installSkill = plannedActions.some( ({ kind }) => kind === "install_skill", ); detectedClients = detectedClients.filter((client) => selected.has(`configure_client:${client.name}`), ); interactiveApproval = decision.approved; approved = decision.approved || plannedActions.length === 0; } } if (!approved) return { status: confirm === undefined ? "needs_confirmation" : "planned", plannedActions, appliedActions, clients, doctor: await host.doctor(), remediation: "Review the setup plan, then rerun interactively or with --yes.", }; if (installHopper && (interactiveApproval || options.installHopper)) { emitProgress( options, "install_hopper", "Hopper deep-analysis provider", "started", ); const installed = await host.installHopper(options.installHopper); if (installed.status === "failed") { emitProgress( options, "install_hopper", "Hopper deep-analysis provider", "failed", installed.remediation, ); return { status: "needs_human", plannedActions, appliedActions, clients, doctor: await host.doctor(), code: installed.code, remediation: installed.remediation, }; } hopperPath = installed.launcherPath; providerEnvironment = { ...providerEnvironment, HOPPER_LAUNCHER_PATH: installed.launcherPath, }; appliedActions.push("installed_hopper"); emitProgress( options, "install_hopper", "Hopper deep-analysis provider", "completed", installed.launcherPath, ); detectedClients = await filterClientsNeedingConfigure( host, detectedClients, providerEnvironment, registrationCommand(), ); } const clientFailure = await configureDetectedClients({ host, detectedClients, providerEnvironment, command: registrationCommand(), clients, appliedActions, ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }), }); if (clientFailure !== undefined) return fail(clientFailure); if (installSkill) { emitProgress( options, "install_skill", "REA reverse-engineering skill", "started", ); const skill = await host.installSkill(); if (skill === "failed") { emitProgress( options, "install_skill", "REA reverse-engineering skill", "failed", ); return fail( "REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.", ); } if (skill === "installed") appliedActions.push("installed_skill"); emitProgress( options, "install_skill", "REA reverse-engineering skill", skill === "installed" ? "completed" : "warning", skill === "unchanged" ? "Already current" : undefined, ); } const doctor = await host.doctor(); const remediation = finalSetupRemediation( host.platform, appliedActions.includes("installed_hopper"), doctor.healthy, hopperPath, ); return { status: remediation === undefined ? "ready" : "needs_human", plannedActions, appliedActions, clients, doctor, ...(remediation === undefined ? {} : { remediation }), }; }; const discoverSetupActions = async (input: { readonly host: SetupHost; readonly providerEnvironment: SetupProviderEnvironment; readonly forceHopperInstall: boolean; readonly proposeHopper: boolean; readonly selectedClientIds: readonly string[] | undefined; readonly installSkillSelection: boolean | undefined; }) => { const { host, providerEnvironment } = input; const initialDoctor = await host.doctor(); const linuxHopperRepairNeeded = initialDoctor.checks.some( ({ name, ok, detail }) => !ok && (name === "hopper-demo-runtime" || (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")), ); const installHopper = input.forceHopperInstall || (input.proposeHopper && (Object.keys(providerEnvironment).length === 0 || linuxHopperRepairNeeded)); const [allDetectedClients, skillNeedsInstall] = await Promise.all([ host.detectedClients(), host.skillNeedsInstall(), ]); const selectedClientIdSet = input.selectedClientIds === undefined ? undefined : new Set(input.selectedClientIds); const detectedClients = allDetectedClients.filter( ({ name }) => selectedClientIdSet === undefined || selectedClientIdSet.has(name), ); const installSkill = input.installSkillSelection === false ? false : skillNeedsInstall; const command = registrationCommand(); const clients = installHopper ? detectedClients : await filterClientsNeedingConfigure( host, detectedClients, providerEnvironment, command, ); const inspections = await inspectClients( host, clients, providerEnvironment, command, ); const invalid = inspections.find( ({ inspection }) => inspection.status === "invalid", ); const blocker = invalid?.inspection.status === "invalid" ? `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}` : undefined; const clientPlans = inspections.flatMap(({ client, inspection }) => inspection.status === "create" || inspection.status === "update" || (installHopper && inspection.status === "already_current") ? [ { client, operation: inspection.status === "already_current" ? ("update" as const) : inspection.status, ...(inspection.status !== "already_current" && inspection.backupPath !== undefined ? { backupPath: inspection.backupPath } : {}), }, ] : [], ); const linuxDistribution = host.platform === "linux" ? await host.linuxDistribution() : undefined; const plannedActions = setupPlan({ platform: host.platform, installHopper, installSkill, clients: clientPlans, providerEnvironment, ...(linuxDistribution?.packageFamily === undefined ? {} : { linuxPackageFamily: linuxDistribution.packageFamily }), }); return { installHopper, installSkill, detectedClients: clientPlans.map(({ client }) => client), plannedActions, blocker, }; }; const inspectClients = async ( host: SetupHost, clients: readonly SetupClient[], providerEnvironment: SetupProviderEnvironment, command: readonly string[], ) => Promise.all( clients.map(async (client) => ({ client, inspection: (await host.inspectClientConfiguration?.( client, providerEnvironment, command, )) ?? ({ status: "update" } as const), })), ); const emitProgress = ( options: SetupOptions, actionId: string, label: string, state: SetupProgressEvent["state"], detail?: string, ): void => options.onProgress?.({ actionId, label, state, ...(detail === undefined ? {} : { detail }), }); const filterClientsNeedingConfigure = async ( host: SetupHost, detectedClients: readonly SetupClient[], providerEnvironment: SetupProviderEnvironment, command: readonly string[], ): Promise => { const needs = await Promise.all( detectedClients.map((client) => host.clientNeedsConfigure(client, providerEnvironment, command), ), ); return detectedClients.filter((_, index) => needs[index]); }; const initialProviderEnvironment = async ( host: SetupHost, hopperPath: string | undefined, ): Promise => ({ ...(await host.providerEnvironment?.()), ...(hopperPath === undefined ? {} : { HOPPER_LAUNCHER_PATH: hopperPath }), }); const finalSetupRemediation = ( platform: NodeJS.Platform, installedHopper: boolean, healthy: boolean, hopperPath: string | undefined, ): string | undefined => { if (platform === "darwin" && installedHopper) return "Open Hopper, choose its demo mode or activate a license, then rerun rea doctor --json."; if (healthy) return undefined; return hopperPath === undefined ? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis." : "Run rea doctor and apply each reported remediation."; }; const hostRemediation = async ( host: SetupHost, forceHopperInstall: boolean, ): Promise => { if (host.platform !== "darwin" && host.platform !== "linux") return "REA supports Hopper on macOS and selected 64-bit Linux distributions."; if (!supportsNodeVersion(host.nodeVersion)) return "Install Node.js 22.19+ or 24.11+ and rerun setup."; if (host.platform === "darwin") { const version = await host.macosVersion(); return version === undefined || major(version) < 12 ? "Upgrade to macOS 12 or newer." : undefined; } if ((await host.linuxDistribution())?.supported === true) return undefined; const providerEnvironment = await host.providerEnvironment?.(); if ( !forceHopperInstall && providerEnvironment !== undefined && Object.keys(providerEnvironment).length > 0 ) return undefined; return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead."; }; /** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */ export const systemSetupHost = ( doctorHost: DoctorHost = systemDoctorHost(), ): SetupHost => { return { platform: process.platform, nodeVersion: process.versions.node, macosVersion: () => doctorHost.macosVersion(), linuxDistribution: readLinuxDistribution, hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath, providerEnvironment: async () => { const diagnosis = await runDoctor(undefined, doctorHost); return { ...providerRegistrationEnvironment(diagnosis.providerInspections ?? []), ...(diagnosis.hopperPath === undefined ? {} : { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }), }; }, installHopper: async (replaceExisting) => { const result = process.platform === "linux" ? await installLinuxHopper() : await installMacHopper({ replaceExisting }); if (result.status === "installed") return result; return setupInstallFailure(result.reason); }, detectedClients: () => detectClients(homedir()), configureClient: (client, providerEnvironment, command) => client.format === "unsupported" ? Promise.resolve({ status: "skipped" }) : client.format === "toml" ? configureTomlClient(client, providerEnvironment, command) : configureJsonClient(client, providerEnvironment, command), clientNeedsConfigure: (client, providerEnvironment, command) => clientConfigurationAligned(client, providerEnvironment, command).then( (aligned) => !aligned, ), inspectClientConfiguration: inspectClientConfiguration, skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()), installSkill: () => installCanonicalSkill(homedir()), doctor: () => runDoctor(undefined, doctorHost), }; }; /** Detect supported agents from stable per-user installation markers. */ export const detectClients = async ( home: string, ): Promise => { const detected: SetupClient[] = []; for (const candidate of supportedClients(home)) if (await exists(candidate.markerPath ?? candidate.configPath)) detected.push(candidate); return detected; }; /** Back up, atomically update, and semantically read back one JSON MCP configuration. */ export const configureJsonClient = async ( client: SetupClient, environment: SetupProviderEnvironment | string = {}, command: readonly string[] = [ "npx", "-y", PRODUCT_IDENTITY.packageSpecifier, "mcp", ], ): Promise => { const transactionPath = await resolveClientConfigTransactionPath( client.configPath, ); if (transactionPath === undefined) return { status: "failed", reason: "path" }; let document: Record = {}; let original: string | undefined; try { original = await readFile(transactionPath, "utf8"); document = parseObject(original); } catch (cause: unknown) { if (!isMissing(cause)) return { status: "failed", reason: "readback" }; } let servers: Record; try { servers = parseOptionalObject(document.mcpServers); } catch { return { status: "failed", reason: "readback" }; } const desired = clientConfigurationDesired( normalizeProviderEnvironment(environment), command, ); 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`; if (!(await preserveConfigBackup(transactionPath, backupPath))) return { status: "failed", reason: "backup" }; } document.mcpServers = { ...servers, [PRODUCT_IDENTITY.mcpServerKey]: desired, }; const encoded = `${JSON.stringify(document, null, 2)}\n`; try { await mkdir(dirname(client.configPath), { recursive: true }); await writeFileAtomic(transactionPath, encoded, { encoding: "utf8", mode: 0o600, }); } catch { return { status: "failed", reason: "write" }; } try { const readback = parseObject(await readFile(transactionPath, "utf8")); const value = parseOptionalObject(readback.mcpServers)[ PRODUCT_IDENTITY.mcpServerKey ]; if (JSON.stringify(value) !== JSON.stringify(desired)) { await restoreConfig(transactionPath, original); return { status: "failed", reason: "readback" }; } } catch { await restoreConfig(transactionPath, 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, environment: SetupProviderEnvironment | string = {}, command: readonly string[] = [ "npx", "-y", PRODUCT_IDENTITY.packageSpecifier, "mcp", ], ): Promise => { const transactionPath = await resolveClientConfigTransactionPath( client.configPath, ); if (transactionPath === undefined) return { status: "failed", reason: "path" }; let document: Record = {}; let original: string | undefined; try { original = await readFile(transactionPath, "utf8"); document = objectSchema.parse(parseToml(original)); } catch (cause: unknown) { if (!isMissing(cause)) return { status: "failed", reason: "readback" }; } let servers: Record; try { servers = parseOptionalObject(document.mcp_servers); } catch { return { status: "failed", reason: "readback" }; } const desired = clientConfigurationDesired( normalizeProviderEnvironment(environment), command, ); if ( JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === JSON.stringify(desired) ) return { status: "unchanged" }; const backupPath = original === undefined ? undefined : `${client.configPath}.rea.backup`; if ( backupPath !== undefined && !(await preserveConfigBackup(transactionPath, backupPath)) ) return { status: "failed", reason: "backup" }; document.mcp_servers = { ...servers, [PRODUCT_IDENTITY.mcpServerKey]: desired, }; try { await mkdir(dirname(client.configPath), { recursive: true }); await writeFileAtomic(transactionPath, stringifyToml(document), { encoding: "utf8", mode: 0o600, }); const readback = objectSchema.parse( parseToml(await readFile(transactionPath, "utf8")), ); if ( JSON.stringify( parseOptionalObject(readback.mcp_servers)[ PRODUCT_IDENTITY.mcpServerKey ], ) !== JSON.stringify(desired) ) throw new Error("TOML readback mismatch"); } catch { await restoreConfig(transactionPath, original); return { status: "failed", reason: "readback" }; } return { status: "configured", ...(backupPath === undefined ? {} : { backupPath }), }; }; 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 preserveConfigBackup = async ( source: string, destination: string, ): Promise => { try { await copyFile(source, destination, fsConstants.COPYFILE_EXCL); return true; } catch (cause: unknown) { return cause instanceof Error && "code" in cause && cause.code === "EEXIST"; } }; const objectSchema = z.record(z.string(), z.unknown()); const parseOptionalObject = (value: unknown): Record => value === undefined ? {} : objectSchema.parse(value); const parseObject = (text: string): Record => 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 => { 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. } }; const clientConfigurationDesired = ( providerEnvironment: SetupProviderEnvironment, command: readonly string[], ) => { const environment = Object.fromEntries( Object.entries(providerEnvironment).sort(([left], [right]) => left.localeCompare(right), ), ); return { command: command[0] ?? PRODUCT_IDENTITY.cliBinary, args: command.slice(1), ...(Object.keys(environment).length === 0 ? {} : { env: environment }), }; }; const clientConfigurationAligned = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], ): Promise => { const desired = clientConfigurationDesired(providerEnvironment, command); try { const original = await readFile(client.configPath, "utf8"); if (client.format === "toml") { const document = objectSchema.parse(parseToml(original)); const servers = parseOptionalObject(document.mcp_servers); return ( JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === JSON.stringify(desired) ); } const document = parseObject(original); const servers = parseOptionalObject(document.mcpServers); return ( JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === JSON.stringify(desired) ); } catch { return false; } }; const inspectClientConfiguration = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], ): Promise => { if (client.format === "unsupported") return { status: "already_current" }; const transactionPath = await resolveClientConfigTransactionPath( client.configPath, ); if (transactionPath === undefined) return { status: "invalid", remediation: "The configuration path is unsafe or unresolved. Check ownership and symbolic links before rerunning setup.", }; let original: string; try { original = await readFile(transactionPath, "utf8"); } catch (cause: unknown) { if (isMissing(cause)) return { status: "create" }; return { status: "invalid", remediation: "The configuration file could not be read. Check its permissions before rerunning setup.", }; } const desired = clientConfigurationDesired(providerEnvironment, command); try { const document = client.format === "toml" ? objectSchema.parse(parseToml(original)) : parseObject(original); const servers = parseOptionalObject( client.format === "toml" ? document.mcp_servers : document.mcpServers, ); if ( JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === JSON.stringify(desired) ) return { status: "already_current" }; } catch { return { status: "invalid", remediation: "The existing configuration is malformed. Repair it before setup applies any other changes.", }; } return { status: "update", backupPath: `${client.configPath}.rea.backup`, }; }; const normalizeProviderEnvironment = ( environment: SetupProviderEnvironment | string, ): SetupProviderEnvironment => typeof environment === "string" ? { HOPPER_LAUNCHER_PATH: environment } : environment; const providerRegistrationEnvironment = ( inspections: readonly DoctorProviderInspection[], ): SetupProviderEnvironment => Object.fromEntries( inspections .filter(({ available }) => available) .flatMap(({ registrationEnvironment }) => Object.entries(registrationEnvironment), ) .sort(([left], [right]) => left.localeCompare(right)), );