diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 0bbbf3e35..d4e4c804e 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -23,16 +23,14 @@ import { canonicalSkillNeedsInstall, installCanonicalSkill, } from "./SetupSkill.js"; -import { setupPlan } from "./SetupPlan.js"; +import { discoverSetupActions } from "./SetupPlan.js"; import { clientConfigurationAligned, configureJsonClient, configureTomlClient, + inspectClientConfiguration, } from "./SetupClientConfiguration.js"; -export { - configureJsonClient, - configureTomlClient, -} from "./SetupClientConfiguration.js"; +export { configureJsonClient, configureTomlClient }; import { setupInstallFailure, type SetupFailureCode, @@ -43,10 +41,7 @@ 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"; +export { canonicalSkillNeedsInstall, installCanonicalSkill }; const registrationCommand = (): readonly string[] => process.env.npm_command === "exec" @@ -75,11 +70,7 @@ export type ClientConfigurationInspection = 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. - */ +/** Effects required by the idempotent, non-blocking setup workflow. */ export interface SetupHost { readonly platform: NodeJS.Platform; readonly nodeVersion: string; @@ -108,11 +99,7 @@ export interface SetupHost { 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. - */ +/** Structured setup outcome carrying remediation instead of prompting. */ export interface SetupResult { readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human"; readonly plannedActions: readonly SetupAction[]; @@ -168,11 +155,7 @@ 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. - */ +/** Discover, approve, and apply setup actions idempotently. */ export const runSetup = async ( options: SetupOptions, host: SetupHost = systemSetupHost(), @@ -190,15 +173,86 @@ export const runSetup = async ( const discovery = await discoverSetupActions({ host, providerEnvironment, + command: registrationCommand(), 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; + const selection = await resolveSetupSelection(options, discovery, confirm); + plannedActions = selection.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>, + confirm: SetupConfirmation | undefined, +) => { + let { installHopper, installSkill, detectedClients, plannedActions } = + discovery; let approved = options.approved || plannedActions.length === 0; let interactiveApproval = false; if (!approved && confirm !== undefined && !options.structured) { @@ -222,120 +276,104 @@ export const runSetup = async ( 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.", - }; + return { + approved, + interactiveApproval, + installHopper, + installSkill, + detectedClients, + plannedActions, + }; +}; - 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(), +const installHopperAction = async (input: { + readonly host: SetupHost; + readonly options: SetupOptions; + readonly plannedActions: readonly SetupAction[]; + readonly appliedActions: string[]; + readonly clients: Readonly>; + readonly detectedClients: readonly SetupClient[]; + readonly providerEnvironment: SetupProviderEnvironment; +}) => { + const label = "Hopper deep-analysis provider"; + emitProgress(input.options, { + actionId: "install_hopper", + label, + state: "started", + }); + const installed = await input.host.installHopper(input.options.installHopper); + if (installed.status === "failed") { + emitProgress(input.options, { + actionId: "install_hopper", + label, + state: "failed", + 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, 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, + } + input.appliedActions.push("installed_hopper"); + emitProgress(input.options, { + actionId: "install_hopper", + label, + state: "completed", + detail: installed.launcherPath, + }); + const providerEnvironment = { + ...input.providerEnvironment, + HOPPER_LAUNCHER_PATH: installed.launcherPath, + }; + return { + hopperPath: installed.launcherPath, + providerEnvironment, + detectedClients: await filterClientsNeedingConfigure( + input.host, + input.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 installSkillAction = async ( + host: SetupHost, + options: SetupOptions, + appliedActions: string[], +): Promise => { + 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 ( remediation: string, [host, plannedActions, appliedActions, clients]: readonly [ @@ -353,92 +391,8 @@ const setupFailure = async ( remediation, }); -const discoverSetupActions = async ( - host: SetupHost, - 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 emitProgress = (options: SetupOptions, event: SetupProgressEvent): void => + options.onProgress?.(event); const filterClientsNeedingConfigure = async ( host: SetupHost, diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 1646d23cc..dbf953d1f 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -8,6 +8,7 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { PRODUCT_IDENTITY } from "../identity.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import type { + ClientConfigurationInspection, ClientConfigurationResult, SetupProviderEnvironment, } 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 => { + 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 parseOptionalObject = (value: unknown): Record => value === undefined ? {} : objectSchema.parse(value); diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 1dbf9258b..810ab6616 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -1,24 +1,39 @@ +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 SetupClient[]; + readonly clients: readonly SetupClientPlan[]; readonly providerEnvironment: SetupProviderEnvironment; + readonly linuxPackageFamily?: LinuxPackageFamily; } /** Build the complete setup mutation plan before approval. */ -export const setupPlan = ( - platform: NodeJS.Platform, - options: SetupPlanOptions, -): readonly SetupAction[] => [ - ...(options.installHopper +export const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [ + ...(input.installHopper ? [ { id: "install_hopper", @@ -45,8 +60,8 @@ export const setupPlan = ( }, ] : []), - ...options.clients - .filter(({ format }) => format !== "unsupported") + ...input.clients + .filter(({ client }) => client.format !== "unsupported") .map( ({ client, operation, backupPath }): SetupAction => ({ id: `configure_client:${client.name}`, @@ -54,15 +69,15 @@ export const setupPlan = ( label: client.displayName ?? client.name, target: client.configPath, detail: clientConfigurationDetail( - client.name, - options.providerEnvironment, + client.displayName ?? client.name, + input.providerEnvironment, ), external: false, operation, ...(backupPath === undefined ? {} : { backupPath }), }), ), - ...(options.installSkill + ...(input.installSkill ? [ { 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 => { + 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, diff --git a/src/cli/setupCommands.ts b/src/cli/setupCommands.ts index da33c52ab..e47db7e2c 100644 --- a/src/cli/setupCommands.ts +++ b/src/cli/setupCommands.ts @@ -1,14 +1,15 @@ import { z } from "incur"; -import { createInterface } from "node:readline/promises"; import { runDoctor } from "../application/Doctor.js"; -import { - runSetup, - systemSetupHost, - type SetupAction, -} from "../application/Setup.js"; +import { runSetup, systemSetupHost } from "../application/Setup.js"; import { runUninstall } from "../application/Uninstall.js"; import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js"; +import { + conciseSetupResult, + confirmInteractiveSetup, + renderInteractiveSetupResult, + renderSetupProgress, +} from "../cliSetup.js"; import { PRODUCT_IDENTITY } from "../identity.js"; import { logCliCommand } from "../cliLogging.js"; import { createSystemDoctorHost } from "../doctorRuntime.js"; @@ -22,6 +23,7 @@ export const registerSetupCommands = ( ): void => { cli.command(CLI_COMMANDS.setup, { description: "Install requirements and configure agents", + outputPolicy: "agent-only", options: z.object({ yes: z .boolean() @@ -31,21 +33,40 @@ export const registerSetupCommands = ( .boolean() .default(false) .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" }, - run: ({ options, formatExplicit }) => + alias: { yes: "y" }, + run: ({ options, formatExplicit, agent }) => logCliCommand(logger, "setup", () => - runSetup( - { - approved: options.yes, - installHopper: options.installHopper, - structured: formatExplicit, - }, - systemSetupHost(createSystemDoctorHost()), - options.yes || formatExplicit || process.stdin.isTTY !== true - ? undefined - : confirmSetup, - ), + runSetupCommand({ options, formatExplicit, agent }), ), }); cli.command(CLI_COMMANDS.doctor, { @@ -83,24 +104,56 @@ export const registerSetupCommands = ( }); }; -const confirmSetup = async ( - actions: readonly SetupAction[], -): Promise => { - process.stdout.write("\nREA setup plan\n"); - for (const action of actions) - process.stdout.write( - ` - ${action.detail}\n ${action.target}${action.external ? " (external software)" : ""}\n`, +interface SetupCommandOptions { + readonly yes: boolean; + readonly installHopper: boolean; + readonly client: readonly string[]; + readonly allDetected: boolean; + readonly skill?: boolean | undefined; + 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({ - input: process.stdin, - output: process.stdout, - }); - try { - const answer = (await prompt.question("Continue? [Y/n] ")) - .trim() - .toLowerCase(); - return answer === "" || answer === "y" || answer === "yes"; - } finally { - prompt.close(); - } + const result = await runSetup( + { + approved: options.yes && !options.dryRun, + installHopper: options.installHopper, + structured: input.formatExplicit || options.dryRun, + proposeHopper: !hasExplicitScope || options.installHopper, + ...(!hasExplicitScope || options.allDetected + ? {} + : { clientIds: options.client }), + ...(!hasExplicitScope ? {} : { installSkill: options.skill ?? false }), + ...(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;