393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import {
|
|
cancel,
|
|
confirm,
|
|
intro,
|
|
isCancel,
|
|
multiselect,
|
|
outro,
|
|
} from "@clack/prompts";
|
|
|
|
import type {
|
|
SetupAction,
|
|
SetupConfirmationDecision,
|
|
SetupProgressEvent,
|
|
SetupResult,
|
|
} from "./application/Setup.js";
|
|
import { projectDoctorReport } from "./application/DoctorProjection.js";
|
|
import { SUPPORTED_CLIENT_DEFINITIONS } from "./application/SupportedClients.js";
|
|
|
|
const clientDisplayNames: ReadonlyMap<string, string> = new Map(
|
|
SUPPORTED_CLIENT_DEFINITIONS.map(({ name, displayName }) => [
|
|
name,
|
|
displayName,
|
|
]),
|
|
);
|
|
|
|
const promptStreams = {
|
|
input: process.stdin,
|
|
output: process.stderr,
|
|
withGuide: true,
|
|
} as const;
|
|
|
|
const agentIntegrationCapabilityId = "agent_integration";
|
|
|
|
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 (
|
|
actions: readonly SetupAction[],
|
|
accessible: boolean,
|
|
): Promise<SetupConfirmationDecision> => {
|
|
intro("REA setup", promptStreams);
|
|
renderValueIntroduction();
|
|
renderDetectedSummary(actions);
|
|
renderKeyboardHelp(accessible);
|
|
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 };
|
|
}
|
|
const selected = new Set(selectedActionIds);
|
|
const selectedActions = actions.filter(({ id }) => selected.has(id));
|
|
renderPreflight(selectedActions);
|
|
const approved = await confirm({
|
|
...promptStreams,
|
|
message:
|
|
selectedActions.length === 1
|
|
? "Apply this change?"
|
|
: `Apply these ${String(selectedActions.length)} changes?`,
|
|
initialValue: false,
|
|
active: "Yes, apply",
|
|
inactive: "No, cancel",
|
|
});
|
|
if (isCancel(approved) || !approved) {
|
|
cancel("Setup cancelled. No changes were made.", promptStreams);
|
|
return { approved: false, selectedActionIds };
|
|
}
|
|
return { approved: true, selectedActionIds };
|
|
};
|
|
|
|
/** Render stable, append-only progress for real setup operations. */
|
|
export const renderSetupProgress = (event: SetupProgressEvent): void => {
|
|
const symbol =
|
|
event.state === "started"
|
|
? "◇"
|
|
: event.state === "completed"
|
|
? "◆"
|
|
: event.state === "warning"
|
|
? "!"
|
|
: "✗";
|
|
const suffix = event.detail === undefined ? "" : ` · ${event.detail}`;
|
|
writeLine(`${symbol} ${event.label}${suffix}`);
|
|
};
|
|
|
|
/** Finish an interactive setup journey without exposing the full doctor catalog. */
|
|
export const renderInteractiveSetupResult = (result: SetupResult): void => {
|
|
if (result.status === "ready") {
|
|
const changedClients = Object.entries(result.clients)
|
|
.filter(([, client]) => client.status === "configured")
|
|
.map(([client]) => clientDisplayNames.get(client) ?? client);
|
|
const readyClients = readyAgentClients(result, changedClients);
|
|
renderReadyCapabilities(result, readyClients);
|
|
outro(
|
|
changedClients.length === 0
|
|
? "REA is ready for local app analysis."
|
|
: `Restart ${changedClients.join(", ")} to load REA.`,
|
|
promptStreams,
|
|
);
|
|
return;
|
|
}
|
|
if (result.status === "planned") return;
|
|
writeLine(`! ${result.remediation ?? "Setup needs attention."}`);
|
|
outro("Run `rea doctor` for the remaining checks.", promptStreams);
|
|
};
|
|
|
|
/** Keep default non-TTY output actionable without emitting the full catalog. */
|
|
export const conciseSetupResult = (result: SetupResult) => ({
|
|
status: result.status,
|
|
plannedActions: result.plannedActions,
|
|
appliedActions: result.appliedActions,
|
|
clients: result.clients,
|
|
doctor: projectDoctorReport(result.doctor, "summary"),
|
|
...(result.code === undefined ? {} : { code: result.code }),
|
|
...(result.remediation === undefined
|
|
? {}
|
|
: { remediation: result.remediation }),
|
|
});
|
|
|
|
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 bundledSkillAction = componentActions.find(
|
|
({ kind }) => kind === "install_skill",
|
|
);
|
|
const selectedActionIds = componentActions
|
|
.filter(
|
|
({ id }) =>
|
|
id !== bundledSkillAction?.id && selectedCapabilityIds.has(id),
|
|
)
|
|
.map(({ id }) => id);
|
|
if (!selectedCapabilityIds.has(agentIntegrationCapabilityId))
|
|
return selectedActionIds;
|
|
if (clientActions.length === 0)
|
|
return [
|
|
...(bundledSkillAction === undefined ? [] : [bundledSkillAction.id]),
|
|
...selectedActionIds,
|
|
];
|
|
const selectedClients = accessible
|
|
? await selectClientsAccessibly(clientActions)
|
|
: await selectClients(clientActions);
|
|
if (selectedClients === undefined) return undefined;
|
|
if (selectedClients.length === 0) return selectedActionIds;
|
|
return [
|
|
...selectedClients,
|
|
...(bundledSkillAction === undefined ? [] : [bundledSkillAction.id]),
|
|
...selectedActionIds,
|
|
];
|
|
};
|
|
|
|
const setupCapabilities = (
|
|
clientActions: readonly SetupAction[],
|
|
componentActions: readonly SetupAction[],
|
|
): readonly SetupCapability[] => [
|
|
...(clientActions.length === 0 &&
|
|
!componentActions.some(({ kind }) => kind === "install_skill")
|
|
? []
|
|
: [
|
|
{
|
|
id: agentIntegrationCapabilityId,
|
|
label: "Agent integration (MCP + guided workflow)",
|
|
hint:
|
|
clientActions.length === 0
|
|
? "MCP registrations are aligned; installs the matching bundled skill"
|
|
: `${String(clientActions.length)} detected ${clientActions.length === 1 ? "agent" : "agents"}; installs the bundled skill when needed`,
|
|
},
|
|
]),
|
|
...componentActions
|
|
.filter(({ kind }) => kind !== "install_skill")
|
|
.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: "Which agents should use REA?",
|
|
options: actions.map((action) => ({
|
|
value: action.id,
|
|
label: `${action.label} (detected)`,
|
|
hint: `${action.operation} · ${action.target}`,
|
|
})),
|
|
required: false,
|
|
maxItems: Math.max(
|
|
3,
|
|
Math.min(actions.length, (process.stderr.rows ?? 24) - 8),
|
|
),
|
|
showInstructions: true,
|
|
});
|
|
return isCancel(selection) ? undefined : selection;
|
|
};
|
|
|
|
const selectClientsAccessibly = async (
|
|
actions: readonly SetupAction[],
|
|
): Promise<readonly string[] | undefined> => {
|
|
const selected: string[] = [];
|
|
for (const action of actions) {
|
|
const included = await confirm({
|
|
...promptStreams,
|
|
message: `Configure ${action.label}? ${action.target}`,
|
|
initialValue: false,
|
|
vertical: true,
|
|
});
|
|
if (isCancel(included)) return undefined;
|
|
if (included) selected.push(action.id);
|
|
}
|
|
return selected;
|
|
};
|
|
|
|
const renderValueIntroduction = (): void => {
|
|
writeLine("│ Understand local apps and binaries from your coding agent.");
|
|
writeLine("│");
|
|
writeLine("│ • Trace how a feature works with local evidence.");
|
|
writeLine(
|
|
"│ • Use the same analysis capabilities from REA's CLI and supported agents.",
|
|
);
|
|
writeLine(
|
|
"│ • Keep analyzed targets on this machine instead of uploading them to a hosted service.",
|
|
);
|
|
};
|
|
|
|
const renderDetectedSummary = (actions: readonly SetupAction[]): void => {
|
|
const clients = actions.filter(({ kind }) => kind === "configure_client");
|
|
writeLine("│");
|
|
if (clients.length === 0) {
|
|
writeLine("◆ No agent integrations need configuration");
|
|
} else {
|
|
writeLine(
|
|
`◆ Found ${String(clients.length)} supported ${clients.length === 1 ? "agent" : "agents"}`,
|
|
);
|
|
writeLine(`│ ${humanList(clients.map(({ label }) => label))}`);
|
|
}
|
|
};
|
|
|
|
const renderKeyboardHelp = (accessible: boolean): void => {
|
|
writeLine("│");
|
|
writeLine(
|
|
accessible
|
|
? "│ Keys: Enter answer · Ctrl-C cancel"
|
|
: "│ Keys: ↑/↓ navigate · Space toggle · Enter confirm · Ctrl-C cancel",
|
|
);
|
|
};
|
|
|
|
const renderReadyCapabilities = (
|
|
result: SetupResult,
|
|
readyClients: readonly string[],
|
|
): void => {
|
|
writeLine("◆ What you can do now");
|
|
const providers = readyProviders(result);
|
|
if (providers.length > 0)
|
|
writeLine(`│ Deep analysis: ${humanList(providers)}`);
|
|
if (readyClients.length > 0)
|
|
writeLine(`│ Agent access: ${humanList(readyClients)}`);
|
|
if (result.doctor.identity?.skill.state === "aligned")
|
|
writeLine("│ Guided reverse-engineering workflows: installed");
|
|
if (providers.length > 0) writeLine("│ CLI: rea analyze /path/to/app");
|
|
const firstClient = readyClients[0];
|
|
if (firstClient !== undefined)
|
|
writeLine(
|
|
`│ Try in ${firstClient}: "Explain how a feature works in /path/to/app and show the evidence."`,
|
|
);
|
|
};
|
|
|
|
const readyAgentClients = (
|
|
result: SetupResult,
|
|
changedClients: readonly string[],
|
|
): readonly string[] => {
|
|
const alignedClients = (result.doctor.identity?.registrations ?? [])
|
|
.filter(({ state }) => state === "aligned")
|
|
.map(({ client }) => clientDisplayNames.get(client) ?? client);
|
|
return [...new Set([...alignedClients, ...changedClients])];
|
|
};
|
|
|
|
const readyProviders = (result: SetupResult): readonly string[] => {
|
|
const providers = [
|
|
...(result.doctor.hopperPath === undefined ? [] : ["Hopper"]),
|
|
...(result.doctor.providerInspections ?? [])
|
|
.filter(({ available }) => available)
|
|
.map(({ id }) => displayProviderId(id)),
|
|
];
|
|
return [...new Set(providers)];
|
|
};
|
|
|
|
const displayProviderId = (id: string): string =>
|
|
id.length === 0 ? id : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
|
|
|
|
const actionModality = (action: SetupAction): string => {
|
|
if (action.kind === "configure_client") return "MCP";
|
|
if (action.kind === "install_hopper") return "provider";
|
|
return "skill";
|
|
};
|
|
|
|
const humanList = (values: readonly string[]): string => {
|
|
if (values.length < 2) return values[0] ?? "";
|
|
if (values.length === 2) return `${values[0]} and ${values[1]}`;
|
|
return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
|
|
};
|
|
|
|
const renderPreflight = (actions: readonly SetupAction[]): void => {
|
|
writeLine("│");
|
|
writeLine("◆ Ready to review");
|
|
for (const action of actions) {
|
|
writeLine("│");
|
|
writeLine(`│ ${action.operation.toUpperCase()} ${action.label}`);
|
|
writeLine(`│ ${action.target}`);
|
|
if (action.backupPath !== undefined)
|
|
writeLine(`│ backup: ${action.backupPath}`);
|
|
for (const origin of action.networkOrigins ?? [])
|
|
writeLine(`│ network: ${origin}`);
|
|
if (action.integrity !== undefined)
|
|
writeLine(`│ integrity: ${action.integrity}`);
|
|
for (const command of action.commands ?? [])
|
|
writeLine(`│ command: ${command}`);
|
|
writeLine(`│ ${action.detail}`);
|
|
if (action.external) writeLine("│ external software");
|
|
}
|
|
writeLine("│");
|
|
writeLine(
|
|
"│ REA will preserve unrelated configuration and will not install or upgrade Node.js, npm, Homebrew, Java, or Ghidra.",
|
|
);
|
|
};
|
|
|
|
const cancelledDecision = (): SetupConfirmationDecision => {
|
|
cancel("Setup cancelled. No changes were made.", promptStreams);
|
|
return {
|
|
approved: false,
|
|
selectedActionIds: [],
|
|
};
|
|
};
|
|
|
|
const writeLine = (line: string): void => {
|
|
process.stderr.write(`${line}\n`);
|
|
};
|