fix(setup): preserve onboarding after refactor

This commit is contained in:
morluto 2026-07-18 05:18:05 +00:00
parent db1efef9d2
commit da48033c19
4 changed files with 474 additions and 267 deletions

View File

@ -23,16 +23,14 @@ import {
canonicalSkillNeedsInstall, canonicalSkillNeedsInstall,
installCanonicalSkill, installCanonicalSkill,
} from "./SetupSkill.js"; } from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js"; import { discoverSetupActions } from "./SetupPlan.js";
import { import {
clientConfigurationAligned, clientConfigurationAligned,
configureJsonClient, configureJsonClient,
configureTomlClient, configureTomlClient,
inspectClientConfiguration,
} from "./SetupClientConfiguration.js"; } from "./SetupClientConfiguration.js";
export { export { configureJsonClient, configureTomlClient };
configureJsonClient,
configureTomlClient,
} from "./SetupClientConfiguration.js";
import { import {
setupInstallFailure, setupInstallFailure,
type SetupFailureCode, type SetupFailureCode,
@ -43,10 +41,7 @@ export type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
/** Exact non-secret provider variables propagated into managed registrations. */ /** Exact non-secret provider variables propagated into managed registrations. */
export type SetupProviderEnvironment = Readonly<Record<string, string>>; export type SetupProviderEnvironment = Readonly<Record<string, string>>;
export { export { canonicalSkillNeedsInstall, installCanonicalSkill };
canonicalSkillNeedsInstall,
installCanonicalSkill,
} from "./SetupSkill.js";
const registrationCommand = (): readonly string[] => const registrationCommand = (): readonly string[] =>
process.env.npm_command === "exec" process.env.npm_command === "exec"
@ -75,11 +70,7 @@ export type ClientConfigurationInspection =
readonly status: "invalid"; readonly status: "invalid";
readonly remediation: string; readonly remediation: string;
}; };
/** /** Effects required by the idempotent, non-blocking setup workflow. */
* 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 { export interface SetupHost {
readonly platform: NodeJS.Platform; readonly platform: NodeJS.Platform;
readonly nodeVersion: string; readonly nodeVersion: string;
@ -108,11 +99,7 @@ export interface SetupHost {
installSkill(): Promise<"installed" | "unchanged" | "failed">; installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>; doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
} }
/** /** Structured setup outcome carrying remediation instead of prompting. */
* 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 { export interface SetupResult {
readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human"; readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human";
readonly plannedActions: readonly SetupAction[]; readonly plannedActions: readonly SetupAction[];
@ -168,11 +155,7 @@ export type SetupConfirmation = (
actions: readonly SetupAction[], actions: readonly SetupAction[],
) => Promise<boolean | SetupConfirmationDecision>; ) => Promise<boolean | SetupConfirmationDecision>;
/** /** Discover, approve, and apply setup actions idempotently. */
* 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 ( export const runSetup = async (
options: SetupOptions, options: SetupOptions,
host: SetupHost = systemSetupHost(), host: SetupHost = systemSetupHost(),
@ -190,15 +173,86 @@ export const runSetup = async (
const discovery = await discoverSetupActions({ const discovery = await discoverSetupActions({
host, host,
providerEnvironment, providerEnvironment,
command: registrationCommand(),
forceHopperInstall: options.installHopper, forceHopperInstall: options.installHopper,
proposeHopper: options.proposeHopper ?? true, proposeHopper: options.proposeHopper ?? true,
selectedClientIds: options.clientIds, selectedClientIds: options.clientIds,
installSkillSelection: options.installSkill, installSkillSelection: options.installSkill,
}); });
if (discovery.blocker !== undefined) return fail(discovery.blocker); if (discovery.blocker !== undefined) return fail(discovery.blocker);
let { installHopper, installSkill } = discovery; const selection = await resolveSetupSelection(options, discovery, confirm);
let detectedClients: readonly SetupClient[] = discovery.detectedClients; plannedActions = selection.plannedActions;
plannedActions = discovery.plannedActions; if (!selection.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.",
};
let detectedClients: readonly SetupClient[] = selection.detectedClients;
if (
selection.installHopper &&
(selection.interactiveApproval || options.installHopper)
) {
const install = await installHopperAction({
host,
options,
plannedActions,
appliedActions,
clients,
detectedClients,
providerEnvironment,
});
if ("failure" in install) return install.failure;
({ hopperPath, providerEnvironment, detectedClients } = install);
}
const clientFailure = await configureDetectedClients({
host,
detectedClients,
providerEnvironment,
command: registrationCommand(),
clients,
appliedActions,
...(options.onProgress === undefined
? {}
: { onProgress: options.onProgress }),
});
if (clientFailure !== undefined) return fail(clientFailure);
if (
selection.installSkill &&
!(await installSkillAction(host, options, appliedActions))
)
return fail(
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
);
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 resolveSetupSelection = async (
options: SetupOptions,
discovery: Awaited<ReturnType<typeof discoverSetupActions>>,
confirm: SetupConfirmation | undefined,
) => {
let { installHopper, installSkill, detectedClients, plannedActions } =
discovery;
let approved = options.approved || plannedActions.length === 0; let approved = options.approved || plannedActions.length === 0;
let interactiveApproval = false; let interactiveApproval = false;
if (!approved && confirm !== undefined && !options.structured) { if (!approved && confirm !== undefined && !options.structured) {
@ -222,120 +276,104 @@ export const runSetup = async (
approved = decision.approved || plannedActions.length === 0; approved = decision.approved || plannedActions.length === 0;
} }
} }
if (!approved) return {
return { approved,
status: confirm === undefined ? "needs_confirmation" : "planned", interactiveApproval,
plannedActions, installHopper,
appliedActions, installSkill,
clients, detectedClients,
doctor: await host.doctor(), plannedActions,
remediation: };
"Review the setup plan, then rerun interactively or with --yes.", };
};
if (installHopper && (interactiveApproval || options.installHopper)) { const installHopperAction = async (input: {
emitProgress( readonly host: SetupHost;
options, readonly options: SetupOptions;
"install_hopper", readonly plannedActions: readonly SetupAction[];
"Hopper deep-analysis provider", readonly appliedActions: string[];
"started", readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
); readonly detectedClients: readonly SetupClient[];
const installed = await host.installHopper(options.installHopper); readonly providerEnvironment: SetupProviderEnvironment;
if (installed.status === "failed") { }) => {
emitProgress( const label = "Hopper deep-analysis provider";
options, emitProgress(input.options, {
"install_hopper", actionId: "install_hopper",
"Hopper deep-analysis provider", label,
"failed", state: "started",
installed.remediation, });
); const installed = await input.host.installHopper(input.options.installHopper);
return { if (installed.status === "failed") {
status: "needs_human", emitProgress(input.options, {
plannedActions, actionId: "install_hopper",
appliedActions, label,
clients, state: "failed",
doctor: await host.doctor(), detail: installed.remediation,
});
return {
failure: {
status: "needs_human" as const,
plannedActions: input.plannedActions,
appliedActions: input.appliedActions,
clients: input.clients,
doctor: await input.host.doctor(),
code: installed.code, code: installed.code,
remediation: installed.remediation, remediation: installed.remediation,
}; },
}
hopperPath = installed.launcherPath;
providerEnvironment = {
...providerEnvironment,
HOPPER_LAUNCHER_PATH: installed.launcherPath,
}; };
appliedActions.push("installed_hopper"); }
emitProgress( input.appliedActions.push("installed_hopper");
options, emitProgress(input.options, {
"install_hopper", actionId: "install_hopper",
"Hopper deep-analysis provider", label,
"completed", state: "completed",
installed.launcherPath, detail: installed.launcherPath,
); });
detectedClients = await filterClientsNeedingConfigure( const providerEnvironment = {
host, ...input.providerEnvironment,
detectedClients, HOPPER_LAUNCHER_PATH: installed.launcherPath,
};
return {
hopperPath: installed.launcherPath,
providerEnvironment,
detectedClients: await filterClientsNeedingConfigure(
input.host,
input.detectedClients,
providerEnvironment, providerEnvironment,
registrationCommand(), 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 installSkillAction = async (
host: SetupHost,
options: SetupOptions,
appliedActions: string[],
): Promise<boolean> => {
const label = "REA reverse-engineering skill";
emitProgress(options, {
actionId: "install_skill",
label,
state: "started",
});
const skill = await host.installSkill();
if (skill === "failed") {
emitProgress(options, {
actionId: "install_skill",
label,
state: "failed",
});
return false;
}
if (skill === "installed") appliedActions.push("installed_skill");
emitProgress(options, {
actionId: "install_skill",
label,
state: skill === "installed" ? "completed" : "warning",
...(skill === "unchanged" ? { detail: "Already current" } : {}),
});
return true;
};
const setupFailure = async ( const setupFailure = async (
remediation: string, remediation: string,
[host, plannedActions, appliedActions, clients]: readonly [ [host, plannedActions, appliedActions, clients]: readonly [
@ -353,92 +391,8 @@ const setupFailure = async (
remediation, remediation,
}); });
const discoverSetupActions = async ( const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void =>
host: SetupHost, options.onProgress?.(event);
providerEnvironment: SetupProviderEnvironment,
forceHopperInstall: boolean,
) => {
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 plannedActions = setupPlan(host.platform, {
installHopper,
installSkill,
clients,
providerEnvironment,
});
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 ( const filterClientsNeedingConfigure = async (
host: SetupHost, host: SetupHost,

View File

@ -8,6 +8,7 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { PRODUCT_IDENTITY } from "../identity.js"; import { PRODUCT_IDENTITY } from "../identity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import type { import type {
ClientConfigurationInspection,
ClientConfigurationResult, ClientConfigurationResult,
SetupProviderEnvironment, SetupProviderEnvironment,
} from "./Setup.js"; } from "./Setup.js";
@ -187,6 +188,57 @@ export const clientConfigurationAligned = async (
} }
}; };
/** Preflight one client configuration before setup presents any mutations. */
export const inspectClientConfiguration = async (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
): Promise<ClientConfigurationInspection> => {
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 (sameConfiguration(servers[PRODUCT_IDENTITY.mcpServerKey], 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 objectSchema = z.record(z.string(), z.unknown()); const objectSchema = z.record(z.string(), z.unknown());
const parseOptionalObject = (value: unknown): Record<string, unknown> => const parseOptionalObject = (value: unknown): Record<string, unknown> =>
value === undefined ? {} : objectSchema.parse(value); value === undefined ? {} : objectSchema.parse(value);

View File

@ -1,24 +1,39 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { PRODUCT_IDENTITY } from "../identity.js"; import { PRODUCT_IDENTITY } from "../identity.js";
import {
linuxHopperInstallDisclosure,
type LinuxPackageFamily,
} from "./LinuxHopper.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
import type { import type {
SetupAction, SetupAction,
SetupClient, SetupClient,
SetupHost,
SetupProviderEnvironment, SetupProviderEnvironment,
} from "./Setup.js"; } 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. */ /** Inputs used to build the setup mutation plan. */
export interface SetupPlanOptions { export interface SetupPlanOptions {
readonly platform: NodeJS.Platform;
readonly installHopper: boolean; readonly installHopper: boolean;
readonly installSkill: boolean; readonly installSkill: boolean;
readonly clients: readonly SetupClient[]; readonly clients: readonly SetupClientPlan[];
readonly providerEnvironment: SetupProviderEnvironment; readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
} }
/** Build the complete setup mutation plan before approval. */ /** Build the complete setup mutation plan before approval. */
export const setupPlan = ( export const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [
platform: NodeJS.Platform, ...(input.installHopper
options: SetupPlanOptions,
): readonly SetupAction[] => [
...(options.installHopper
? [ ? [
{ {
id: "install_hopper", id: "install_hopper",
@ -45,8 +60,8 @@ export const setupPlan = (
}, },
] ]
: []), : []),
...options.clients ...input.clients
.filter(({ format }) => format !== "unsupported") .filter(({ client }) => client.format !== "unsupported")
.map( .map(
({ client, operation, backupPath }): SetupAction => ({ ({ client, operation, backupPath }): SetupAction => ({
id: `configure_client:${client.name}`, id: `configure_client:${client.name}`,
@ -54,15 +69,15 @@ export const setupPlan = (
label: client.displayName ?? client.name, label: client.displayName ?? client.name,
target: client.configPath, target: client.configPath,
detail: clientConfigurationDetail( detail: clientConfigurationDetail(
client.name, client.displayName ?? client.name,
options.providerEnvironment, input.providerEnvironment,
), ),
external: false, external: false,
operation, operation,
...(backupPath === undefined ? {} : { backupPath }), ...(backupPath === undefined ? {} : { backupPath }),
}), }),
), ),
...(options.installSkill ...(input.installSkill
? [ ? [
{ {
id: "install_skill", id: "install_skill",
@ -83,6 +98,139 @@ export const setupPlan = (
: []), : []),
]; ];
/** 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 linuxDisclosure = (family: LinuxPackageFamily) => {
const disclosure = linuxHopperInstallDisclosure( const disclosure = linuxHopperInstallDisclosure(
family, family,

View File

@ -1,14 +1,15 @@
import { z } from "incur"; import { z } from "incur";
import { createInterface } from "node:readline/promises";
import { runDoctor } from "../application/Doctor.js"; import { runDoctor } from "../application/Doctor.js";
import { import { runSetup, systemSetupHost } from "../application/Setup.js";
runSetup,
systemSetupHost,
type SetupAction,
} from "../application/Setup.js";
import { runUninstall } from "../application/Uninstall.js"; import { runUninstall } from "../application/Uninstall.js";
import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js"; import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js";
import {
conciseSetupResult,
confirmInteractiveSetup,
renderInteractiveSetupResult,
renderSetupProgress,
} from "../cliSetup.js";
import { PRODUCT_IDENTITY } from "../identity.js"; import { PRODUCT_IDENTITY } from "../identity.js";
import { logCliCommand } from "../cliLogging.js"; import { logCliCommand } from "../cliLogging.js";
import { createSystemDoctorHost } from "../doctorRuntime.js"; import { createSystemDoctorHost } from "../doctorRuntime.js";
@ -22,6 +23,7 @@ export const registerSetupCommands = (
): void => { ): void => {
cli.command(CLI_COMMANDS.setup, { cli.command(CLI_COMMANDS.setup, {
description: "Install requirements and configure agents", description: "Install requirements and configure agents",
outputPolicy: "agent-only",
options: z.object({ options: z.object({
yes: z yes: z
.boolean() .boolean()
@ -31,21 +33,40 @@ export const registerSetupCommands = (
.boolean() .boolean()
.default(false) .default(false)
.describe("Also approve Hopper installation with --yes"), .describe("Also approve Hopper installation with --yes"),
client: z
.array(
z.enum([
"claude_code",
"claude_desktop",
"codex",
"cursor",
"gemini_cli",
"windsurf",
]),
)
.default([])
.describe("Agent integration to configure; repeat for multiple agents"),
allDetected: z
.boolean()
.default(false)
.describe("Configure every detected supported agent"),
skill: z
.boolean()
.optional()
.describe("Install or skip the bundled REA skill"),
dryRun: z
.boolean()
.default(false)
.describe("Print the resolved plan without applying it"),
accessible: z
.boolean()
.default(false)
.describe("Use sequential accessible setup prompts"),
}), }),
alias: { yes: "y", installHopper: "install-hopper" }, alias: { yes: "y" },
run: ({ options, formatExplicit }) => run: ({ options, formatExplicit, agent }) =>
logCliCommand(logger, "setup", () => logCliCommand(logger, "setup", () =>
runSetup( runSetupCommand({ options, formatExplicit, agent }),
{
approved: options.yes,
installHopper: options.installHopper,
structured: formatExplicit,
},
systemSetupHost(createSystemDoctorHost()),
options.yes || formatExplicit || process.stdin.isTTY !== true
? undefined
: confirmSetup,
),
), ),
}); });
cli.command(CLI_COMMANDS.doctor, { cli.command(CLI_COMMANDS.doctor, {
@ -83,24 +104,56 @@ export const registerSetupCommands = (
}); });
}; };
const confirmSetup = async ( interface SetupCommandOptions {
actions: readonly SetupAction[], readonly yes: boolean;
): Promise<boolean> => { readonly installHopper: boolean;
process.stdout.write("\nREA setup plan\n"); readonly client: readonly string[];
for (const action of actions) readonly allDetected: boolean;
process.stdout.write( readonly skill?: boolean | undefined;
` - ${action.detail}\n ${action.target}${action.external ? " (external software)" : ""}\n`, readonly dryRun: boolean;
readonly accessible: boolean;
}
const runSetupCommand = async (input: {
readonly options: SetupCommandOptions;
readonly formatExplicit: boolean;
readonly agent: boolean;
}) => {
const { options } = input;
const interactive =
!options.yes &&
!options.dryRun &&
!input.formatExplicit &&
process.stdin.isTTY === true &&
process.stderr.isTTY === true;
const hasExplicitScope = setupHasExplicitScope(options);
if (options.yes && !hasExplicitScope)
process.stderr.write(
"! Implicit `rea setup --yes` scope is deprecated; use `--all-detected --skill` to retain it.\n",
); );
const prompt = createInterface({ const result = await runSetup(
input: process.stdin, {
output: process.stdout, approved: options.yes && !options.dryRun,
}); installHopper: options.installHopper,
try { structured: input.formatExplicit || options.dryRun,
const answer = (await prompt.question("Continue? [Y/n] ")) proposeHopper: !hasExplicitScope || options.installHopper,
.trim() ...(!hasExplicitScope || options.allDetected
.toLowerCase(); ? {}
return answer === "" || answer === "y" || answer === "yes"; : { clientIds: options.client }),
} finally { ...(!hasExplicitScope ? {} : { installSkill: options.skill ?? false }),
prompt.close(); ...(interactive ? { onProgress: renderSetupProgress } : {}),
} },
systemSetupHost(createSystemDoctorHost()),
interactive
? (actions) => confirmInteractiveSetup(actions, options.accessible)
: undefined,
);
if (interactive) renderInteractiveSetupResult(result);
return input.formatExplicit ? result : conciseSetupResult(result);
}; };
const setupHasExplicitScope = (options: SetupCommandOptions): boolean =>
options.allDetected ||
options.client.length > 0 ||
options.skill !== undefined ||
options.installHopper;