rea/src/application/SetupPlan.ts

257 lines
8.2 KiB
TypeScript

import { homedir } from "node:os";
import { join } from "node:path";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
linuxHopperInstallDisclosure,
type LinuxPackageFamily,
} from "./LinuxHopper.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
import type {
SetupAction,
SetupClient,
SetupHost,
SetupProviderEnvironment,
} from "./Setup.js";
/** Preflighted client mutation admitted into the setup plan. */
export type SetupClientPlan = Readonly<{
client: SetupClient;
operation: "create" | "update";
backupPath?: string;
}>;
/** Inputs used to build the setup mutation plan. */
export interface SetupPlanOptions {
readonly platform: NodeJS.Platform;
readonly installHopper: boolean;
readonly installSkill: boolean;
readonly clients: readonly SetupClientPlan[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
}
/** Build the complete setup mutation plan before approval. */
export const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [
...(input.installHopper
? [
{
id: "install_hopper",
kind: "install_hopper" as const,
label: "Hopper deep-analysis provider",
target:
input.platform === "darwin"
? join(homedir(), "Applications/Hopper Disassembler.app")
: "system package manager",
detail:
input.platform === "linux"
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
operation: "install" as const,
...(input.platform === "linux"
? input.linuxPackageFamily === undefined
? {}
: linuxDisclosure(input.linuxPackageFamily)
: {
networkOrigins: macHopperInstallDisclosure.networkOrigins,
commands: macHopperInstallDisclosure.commands,
}),
},
]
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
.map(
({ client, operation, backupPath }): SetupAction => ({
id: `configure_client:${client.name}`,
kind: "configure_client",
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
client.displayName ?? client.name,
input.providerEnvironment,
),
external: false,
operation,
...(backupPath === undefined ? {} : { backupPath }),
}),
),
...(input.installSkill
? [
{
id: "install_skill",
kind: "install_skill" as const,
label: "REA reverse-engineering skill",
target: join(
homedir(),
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
),
detail:
"Install or update the bundled REA reverse-engineering skill.",
external: false,
operation: "install" as const,
},
]
: []),
];
/** Discover and preflight the complete set of selectable setup actions. */
export const discoverSetupActions = async (input: {
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly command: readonly string[];
readonly forceHopperInstall: boolean;
readonly proposeHopper: boolean;
readonly selectedClientIds: readonly string[] | undefined;
readonly installSkillSelection: boolean | undefined;
}) => {
const { host, providerEnvironment, command } = 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 clients = installHopper
? detectedClients
: await filterClientsNeedingConfigure({
host,
clients: 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;
return {
installHopper,
installSkill,
detectedClients: clientPlans.map(({ client }) => client),
plannedActions: setupPlan({
platform: host.platform,
installHopper,
installSkill,
clients: clientPlans,
providerEnvironment,
...(linuxDistribution?.packageFamily === undefined
? {}
: { linuxPackageFamily: linuxDistribution.packageFamily }),
}),
blocker,
};
};
const inspectClients = async (input: {
readonly host: SetupHost;
readonly clients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly command: readonly string[];
}) =>
Promise.all(
input.clients.map(async (client) => ({
client,
inspection:
(await input.host.inspectClientConfiguration?.(
client,
input.providerEnvironment,
input.command,
)) ?? ({ status: "update" } as const),
})),
);
const filterClientsNeedingConfigure = async (input: {
readonly host: SetupHost;
readonly clients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly command: readonly string[];
}): Promise<readonly SetupClient[]> => {
const needs = await Promise.all(
input.clients.map((client) =>
input.host.clientNeedsConfigure(
client,
input.providerEnvironment,
input.command,
),
),
);
return input.clients.filter((_, index) => needs[index]);
};
const linuxDisclosure = (family: LinuxPackageFamily) => {
const disclosure = linuxHopperInstallDisclosure(
family,
process.getuid?.() === 0,
);
return {
networkOrigins: [disclosure.downloadUrl],
commands: disclosure.commands,
integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`,
};
};
const clientConfigurationDetail = (
client: string,
environment: SetupProviderEnvironment,
): string => {
const entries = Object.entries(environment)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => `${name}=${value}`);
const suffix =
entries.length === 0 ? "" : ` Environment: ${entries.join(", ")}.`;
return `Add the REA MCP registration for ${client}; preserve unrelated configuration.${suffix}`;
};