From 992ec505b279114dcb13230fcd008db8668578af Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:02:28 +0800 Subject: [PATCH] fix(cli): require explicit setup selections --- README.md | 18 ++-- docs/installation.md | 31 +++--- scripts/verify-package.mjs | 2 +- src/cliSetup.ts | 199 ++++++++++++++++++++----------------- tests/cliSetup.test.ts | 127 ++++++++++++++++++----- 5 files changed, 234 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index d7289bddc..7a7081a47 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,17 @@ shows its complete plan and asks before applying it. Setup does not update Homebrew, Node.js, or npm. `npx rea-agents setup` opens with the work it enables: investigate local apps from an agent, recover evidence through a deep-analysis provider, and reuse REA's guided workflow. It summarizes the -detected agents, then offers **Set up all available capabilities -(recommended)**, **Customize**, or **No thanks**. Customize shows every -concrete target with an `MCP`, `provider`, or `skill` label. +detected agents, then asks which independent capabilities to set up: coding-agent +access through MCP, the shared investigation skill, and—when needed—the Hopper +provider. Nothing is preselected. Choosing MCP access opens a second empty +checklist for the specific detected agents that should receive a registration. -REA keeps the journey inline so its history remains in the terminal. Before -anything changes, it validates existing configuration, prints exact paths and -external effects, and asks for final approval with **No** as the default. The -screen keeps the available keys visible while you choose; Ctrl-C and declining -leave the system unchanged. +REA keeps the journey inline so its history remains in the terminal. Selecting +a capability does not select every detected target or authorize a change. +Before anything changes, REA validates existing configuration, prints exact +paths and external effects, and asks for final approval with **No** as the +default. The screen keeps the available keys visible while you choose; Ctrl-C +and declining leave the system unchanged. REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It configures the first six when detected; Devin is reported but left unchanged because it has no documented local MCP configuration boundary. Registrations are additive, backup-first, and read back after writing. You can safely rerun setup. diff --git a/docs/installation.md b/docs/installation.md index a035fc707..4d8822501 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -50,24 +50,23 @@ mechanics: - recover evidence through an available deep-analysis provider; - use the bundled skill for a repeatable investigation workflow. -REA then summarizes the detected clients and presents one gateway: +REA then summarizes the detected clients and asks which independent +capabilities to set up. The capability picker starts empty and labels each +choice by its role: `MCP` for coding-agent access, `provider` for Hopper, and +`skill` for the shared REA investigation workflow. Selecting MCP access opens a +second empty checklist for the exact detected agents that should receive a +registration. Detection provides context; it does not preselect or authorize a +configuration write. -- **Set up all available capabilities (recommended)** selects the complete - resolved plan; -- **Customize** opens the concrete action picker; -- **No thanks** exits without making changes. +Choosing no capabilities exits without changes. Selecting the skill or provider +does not implicitly select an agent, and selecting agent access does not include +every detected agent. The picker keeps its navigation, selection, confirmation, +and cancellation keys visible instead of relying on a transient hint. -The custom picker labels each target by its role: `MCP` for an agent -registration, `provider` for Hopper or a validated bring-your-own provider, and -`skill` for the REA investigation skill. These labels describe different setup -modalities rather than suggesting that every target is an MCP registration. -The picker keeps its navigation, selection, confirmation, and cancellation keys -visible instead of relying on a transient hint. - -Both recommended and custom paths converge on the same exact preflight. REA -validates the current state, prints the proposed effects, and asks for final -approval with **No** as the default. Selection alone never authorizes a -mutation. The plan identifies: +Every selected path converges on the same exact preflight. REA validates the +current state, prints the proposed effects, and asks for final approval with +**No** as the default. Selection alone never authorizes a mutation. The plan +identifies: - an existing Hopper installation, a validated bring-your-own Ghidra environment, or the official Hopper package it proposes to install; - each detected agent configuration path; diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 0e67076a9..584cb8831 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1192,7 +1192,7 @@ async function verifyInteractiveSetup(command, env) { terminal.onData((data) => { output += data; - if (!cancelled && output.includes("Choose a setup")) { + if (!cancelled && output.includes("What should REA set up?")) { cancelled = true; terminal.write("\u0003"); } diff --git a/src/cliSetup.ts b/src/cliSetup.ts index 94ebe6b3b..0be6b5631 100644 --- a/src/cliSetup.ts +++ b/src/cliSetup.ts @@ -5,7 +5,6 @@ import { isCancel, multiselect, outro, - select, } from "@clack/prompts"; import type { @@ -29,7 +28,13 @@ const promptStreams = { withGuide: true, } as const; -type SetupMode = "recommended" | "custom" | "skip"; +const agentAccessCapabilityId = "agent_access"; + +interface SetupCapability { + readonly id: string; + readonly label: string; + readonly hint: string; +} /** Run the inline setup picker, exact preflight, and default-No consent. */ export const confirmInteractiveSetup = async ( @@ -40,22 +45,8 @@ export const confirmInteractiveSetup = async ( renderValueIntroduction(); renderDetectedSummary(actions); renderKeyboardHelp(accessible); - const mode = await selectSetupMode(actions, accessible); - if (mode === undefined) return cancelledDecision(actions); - if (mode === "skip") { - cancel("No changes were made.", promptStreams); - return { - approved: false, - selectedActionIds: actions.map(({ id }) => id), - }; - } - const selectedActionIds = - mode === "recommended" - ? actions.map(({ id }) => id) - : accessible - ? await selectActionsAccessibly(actions) - : await selectActions(actions); - if (selectedActionIds === undefined) return cancelledDecision(actions); + const selectedActionIds = await selectSetupActions(actions, accessible); + if (selectedActionIds === undefined) return cancelledDecision(); if (selectedActionIds.length === 0) { cancel("Nothing selected. No changes were made.", promptStreams); return { approved: false, selectedActionIds }; @@ -131,18 +122,104 @@ export const conciseSetupResult = (result: SetupResult) => ({ : { remediation: result.remediation }), }); -const selectActions = async ( +const selectSetupActions = async ( + actions: readonly SetupAction[], + accessible: boolean, +): Promise => { + const clientActions = actions.filter( + ({ kind }) => kind === "configure_client", + ); + const componentActions = actions.filter( + ({ kind }) => kind !== "configure_client", + ); + const capabilities = setupCapabilities(clientActions, componentActions); + const selectedCapabilities = accessible + ? await selectCapabilitiesAccessibly(capabilities) + : await selectCapabilities(capabilities); + if (selectedCapabilities === undefined) return undefined; + const selectedCapabilityIds = new Set(selectedCapabilities); + const selectedActionIds = componentActions + .filter(({ id }) => selectedCapabilityIds.has(id)) + .map(({ id }) => id); + if (!selectedCapabilityIds.has(agentAccessCapabilityId)) + return selectedActionIds; + const selectedClients = accessible + ? await selectClientsAccessibly(clientActions) + : await selectClients(clientActions); + return selectedClients === undefined + ? undefined + : [...selectedClients, ...selectedActionIds]; +}; + +const setupCapabilities = ( + clientActions: readonly SetupAction[], + componentActions: readonly SetupAction[], +): readonly SetupCapability[] => [ + ...(clientActions.length === 0 + ? [] + : [ + { + id: agentAccessCapabilityId, + label: "Coding-agent access (MCP)", + hint: `${String(clientActions.length)} detected ${clientActions.length === 1 ? "agent" : "agents"}`, + }, + ]), + ...componentActions.map((action) => ({ + id: action.id, + label: `${action.label} (${actionModality(action)})`, + hint: `${action.operation} · ${action.target}`, + })), +]; + +const selectCapabilities = async ( + capabilities: readonly SetupCapability[], +): Promise => { + const selection = await multiselect({ + ...promptStreams, + message: "What should REA set up?", + options: capabilities.map((capability) => ({ + value: capability.id, + label: capability.label, + hint: capability.hint, + })), + required: false, + maxItems: Math.max( + 3, + Math.min(capabilities.length, (process.stderr.rows ?? 24) - 8), + ), + showInstructions: true, + }); + return isCancel(selection) ? undefined : selection; +}; + +const selectCapabilitiesAccessibly = async ( + capabilities: readonly SetupCapability[], +): Promise => { + const selected: string[] = []; + for (const capability of capabilities) { + const included = await confirm({ + ...promptStreams, + message: `Set up ${capability.label}? ${capability.hint}`, + initialValue: false, + vertical: true, + }); + if (isCancel(included)) return undefined; + if (included) selected.push(capability.id); + } + return selected; +}; + +const selectClients = async ( actions: readonly SetupAction[], ): Promise => { const selection = await multiselect({ ...promptStreams, - message: "Choose what REA should configure", + message: "Which agents should use REA?", options: actions.map((action) => ({ value: action.id, - label: `${action.label} (${actionModality(action)})`, + label: `${action.label} (detected)`, hint: `${action.operation} · ${action.target}`, })), - initialValues: actions.map(({ id }) => id), required: false, maxItems: Math.max( 3, @@ -153,15 +230,15 @@ const selectActions = async ( return isCancel(selection) ? undefined : selection; }; -const selectActionsAccessibly = async ( +const selectClientsAccessibly = async ( actions: readonly SetupAction[], ): Promise => { const selected: string[] = []; for (const action of actions) { const included = await confirm({ ...promptStreams, - message: `Include ${action.label} (${actionModality(action)})? ${action.target}`, - initialValue: true, + message: `Configure ${action.label}? ${action.target}`, + initialValue: false, vertical: true, }); if (isCancel(included)) return undefined; @@ -170,55 +247,6 @@ const selectActionsAccessibly = async ( return selected; }; -const selectSetupMode = async ( - actions: readonly SetupAction[], - accessible: boolean, -): Promise => { - if (accessible) return selectSetupModeAccessibly(); - const mode = await select({ - ...promptStreams, - message: "Choose a setup", - options: [ - { - value: "recommended", - label: "Set up all available capabilities", - hint: `recommended · ${actionCount(actions.length)}`, - }, - { - value: "custom", - label: "Customize setup", - hint: "choose integrations and components", - }, - { - value: "skip", - label: "No thanks", - hint: "make no changes", - }, - ], - initialValue: "recommended", - }); - return isCancel(mode) ? undefined : mode; -}; - -const selectSetupModeAccessibly = async (): Promise => { - const recommended = await confirm({ - ...promptStreams, - message: "Use the recommended complete setup?", - initialValue: true, - vertical: true, - }); - if (isCancel(recommended)) return undefined; - if (recommended) return "recommended"; - const customize = await confirm({ - ...promptStreams, - message: "Customize integrations and components?", - initialValue: true, - vertical: true, - }); - if (isCancel(customize)) return undefined; - return customize ? "custom" : "skip"; -}; - const renderValueIntroduction = (): void => { writeLine("│ Understand local apps and binaries from your coding agent."); writeLine("│"); @@ -237,20 +265,10 @@ const renderDetectedSummary = (actions: readonly SetupAction[]): void => { if (clients.length === 0) { writeLine("◆ No agent integrations need configuration"); } else { - writeLine(`◆ Detected ${humanList(clients.map(({ label }) => label))}`); - for (const client of clients) { - writeLine( - `│ ${client.label} · ${actionModality(client)} · ${client.operation}`, - ); - writeLine(`│ ${client.target}`); - } - } - const components = actions.filter(({ kind }) => kind !== "configure_client"); - if (components.length > 0) { - writeLine("│"); writeLine( - `│ Also available: ${components.map((action) => `${action.label} (${actionModality(action)})`).join(", ")}`, + `◆ Found ${String(clients.length)} supported ${clients.length === 1 ? "agent" : "agents"}`, ); + writeLine(`│ ${humanList(clients.map(({ label }) => label))}`); } }; @@ -312,9 +330,6 @@ const actionModality = (action: SetupAction): string => { return "skill"; }; -const actionCount = (count: number): string => - `${String(count)} ${count === 1 ? "change" : "changes"}`; - const humanList = (values: readonly string[]): string => { if (values.length < 2) return values[0] ?? ""; if (values.length === 2) return `${values[0]} and ${values[1]}`; @@ -345,13 +360,11 @@ const renderPreflight = (actions: readonly SetupAction[]): void => { ); }; -const cancelledDecision = ( - actions: readonly SetupAction[], -): SetupConfirmationDecision => { +const cancelledDecision = (): SetupConfirmationDecision => { cancel("Setup cancelled. No changes were made.", promptStreams); return { approved: false, - selectedActionIds: actions.map(({ id }) => id), + selectedActionIds: [], }; }; diff --git a/tests/cliSetup.test.ts b/tests/cliSetup.test.ts index d3f61b73a..1dc12a450 100644 --- a/tests/cliSetup.test.ts +++ b/tests/cliSetup.test.ts @@ -41,8 +41,10 @@ const actions = [ ] as const; describe("interactive setup journey", () => { - it("leads with value, detected targets, modality labels, and durable key help", async () => { - const result = await runJourney([step("Choose a setup", "\u0003")]); + it("leads with value, a compact detected-target summary, and durable key help", async () => { + const result = await runJourney([ + step("What should REA set up?", "\u0003"), + ]); expect(result.output).toContain( "Understand local apps and binaries from your coding agent.", @@ -50,8 +52,8 @@ describe("interactive setup journey", () => { expect(result.output).toContain( "Trace how a feature works with local evidence.", ); - expect(result.output).toContain("Detected Codex"); - expect(result.output).toContain("Codex · MCP · create"); + expect(result.output).toContain("Found 1 supported agent"); + expect(result.output).toContain("Codex"); expect(result.output).toContain("Hopper deep-analysis provider (provider)"); expect(result.output).toContain("REA reverse-engineering skill (skill)"); expect(result.output).toContain( @@ -60,49 +62,122 @@ describe("interactive setup journey", () => { expect(result.decision.approved).toBe(false); }); - it("offers recommended, custom, and no-thanks intent before choosing targets", async () => { - const result = await runJourney([ - step("Choose a setup", "\u001b[B\u001b[B\r"), - ]); + it("does not turn available capabilities into selected setup actions", async () => { + const result = await runJourney([step("What should REA set up?", "\r")]); - expect(result.output).toContain("Set up all available capabilities"); - expect(result.output).toContain("recommended · 3 changes"); - expect(result.output).toContain("Customize setup"); - expect(result.output).toContain("No thanks"); + expect(result.output).toContain("Coding-agent access (MCP)"); + expect(result.output).toContain("REA reverse-engineering skill (skill)"); + expect(result.output).toContain("Hopper deep-analysis provider (provider)"); expect(result.output).not.toContain("Ready to review"); - expect(result.decision).toMatchObject({ approved: false }); + expect(result.output).toContain("Nothing selected. No changes were made."); + expect(result.decision).toEqual({ + approved: false, + selectedActionIds: [], + }); }); - it("lets custom mode reach the modality-labelled target picker and cancel safely", async () => { + it("omits MCP access when no agent integration needs configuration", async () => { + const result = await runJourney( + [step("What should REA set up?", "\r")], + false, + actions.filter(({ kind }) => kind !== "configure_client"), + ); + + expect(result.output).toContain("No agent integrations need configuration"); + expect(result.output).not.toContain("Coding-agent access (MCP)"); + expect(result.decision.selectedActionIds).toEqual([]); + }); + + it("shows multiple detected agents without preselecting either one", async () => { + const result = await runJourney( + [ + step("What should REA set up?", " \r"), + step("Which agents should use REA?", "\u0003"), + ], + false, + [ + actions[0], + { + ...actions[0], + id: "configure_client:cursor", + label: "Cursor", + target: "/isolated/.cursor/mcp.json", + }, + ...actions.slice(1), + ], + ); + + expect(result.output).toContain("Codex (detected)"); + expect(result.output).toContain("Cursor (detected)"); + expect(result.decision.approved).toBe(false); + }); + + it("asks for agent targets only after MCP access is selected", async () => { const result = await runJourney([ - step("Choose a setup", "\u001b[B\r"), - step("Choose what REA should configure", "\u0003"), + step("What should REA set up?", " \r"), + step("Which agents should use REA?", "\u0003"), ]); - expect(result.output).toContain("Choose what REA should configure"); - expect(result.output).toContain("Codex (MCP)"); - expect(result.output).toContain("Hopper deep-analysis provider (provider)"); + expect(result.output).toContain("Codex (detected)"); expect(result.output).toContain("Setup cancelled. No changes were made."); expect(result.decision.approved).toBe(false); }); - it("keeps the recommended path subject to a default-No exact preflight", async () => { + it("reviews only explicitly selected actions with default-No consent", async () => { const result = await runJourney([ - step("Choose a setup", "\r"), - step("Apply these 3 changes?", "\r"), + step("What should REA set up?", " \r"), + step("Which agents should use REA?", " \r"), + step("Apply this change?", "\r"), ]); expect(result.output).toContain("Ready to review"); expect(result.output).toContain("CREATE Codex"); expect(result.output).toContain("/isolated/.codex/config.toml"); + expect(result.output).not.toContain( + "INSTALL REA reverse-engineering skill", + ); + expect(result.output).not.toContain( + "INSTALL Hopper deep-analysis provider", + ); expect(result.output).toContain("No, cancel"); expect(result.output).toContain("Setup cancelled. No changes were made."); expect(result.decision).toEqual({ approved: false, - selectedActionIds: actions.map(({ id }) => id), + selectedActionIds: ["configure_client:codex"], }); }); + it("can select the shared skill without selecting an agent integration", async () => { + const result = await runJourney([ + step("What should REA set up?", "\u001b[B \r"), + step("Apply this change?", "\r"), + ]); + + expect(result.output).toContain("INSTALL REA reverse-engineering skill"); + expect(result.output).not.toContain("CREATE Codex"); + expect(result.output).not.toContain( + "INSTALL Hopper deep-analysis provider", + ); + expect(result.decision).toEqual({ + approved: false, + selectedActionIds: ["install_skill"], + }); + }); + + it("keeps every accessible capability prompt defaulted to skip", async () => { + const result = await runJourney( + [ + step("Set up Coding-agent access (MCP)?", "\r"), + step("Set up REA reverse-engineering skill (skill)?", "\r"), + step("Set up Hopper deep-analysis provider (provider)?", "\r"), + ], + true, + ); + + expect(result.output).not.toContain("Ready to review"); + expect(result.decision.selectedActionIds).toEqual([]); + }); + it("frames completion around the capabilities that are now ready", async () => { const { stderr } = await execute( process.execPath, @@ -163,12 +238,14 @@ const step = (prompt: string, input: string): JourneyStep => ({ const runJourney = async ( steps: readonly JourneyStep[], + accessible = false, + journeyActions: readonly object[] = actions, ): Promise => { const isolatedHome = await mkdtemp(join(tmpdir(), "rea-cli-setup-test-")); const script = [ 'import { confirmInteractiveSetup } from "./dist/cliSetup.js";', - `const actions = ${JSON.stringify(actions)};`, - "const decision = await confirmInteractiveSetup(actions, false);", + `const actions = ${JSON.stringify(journeyActions)};`, + `const decision = await confirmInteractiveSetup(actions, ${JSON.stringify(accessible)});`, `process.stdout.write(${JSON.stringify(`\n${decisionMarker}`)} + JSON.stringify(decision) + "\\n");`, ].join("\n");