fix(cli): require explicit setup selections

This commit is contained in:
morluto 2026-07-18 14:02:28 +08:00
parent 1b90e7f992
commit 992ec505b2
5 changed files with 234 additions and 143 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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");
}

View File

@ -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<readonly string[] | undefined> => {
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<readonly string[] | undefined> => {
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<readonly string[] | undefined> => {
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<readonly string[] | undefined> => {
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<readonly string[] | undefined> => {
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<SetupMode | undefined> => {
if (accessible) return selectSetupModeAccessibly();
const mode = await select<SetupMode>({
...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<SetupMode | undefined> => {
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: [],
};
};

View File

@ -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<JourneyResult> => {
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");