From 07e1b5ac5f6a1724b1064c47ea05f04ccffcb4f3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:20:40 +0800 Subject: [PATCH] refactor(setup): split setup and CLI registration --- src/application/Setup.ts | 167 +++++++++++++++------------------- src/application/SetupSkill.ts | 55 +++++++++++ src/cli.ts | 103 +++++++++++---------- 3 files changed, 181 insertions(+), 144 deletions(-) create mode 100644 src/application/SetupSkill.ts diff --git a/src/application/Setup.ts b/src/application/Setup.ts index e60052b7b..f6cf299d9 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -22,6 +22,9 @@ import { type LinuxDistribution, } from "./LinuxHopper.js"; import { installMacHopper } from "./MacHopper.js"; +import { installCanonicalSkill } from "./SetupSkill.js"; + +export { installCanonicalSkill } from "./SetupSkill.js"; const registrationCommand = (): readonly string[] => process.env.npm_command === "exec" @@ -100,6 +103,65 @@ export type SetupConfirmation = ( actions: readonly SetupAction[], ) => Promise; +const setupPlan = ( + platform: NodeJS.Platform, + hopperPath: string | undefined, + clients: readonly SetupClient[], +): readonly SetupAction[] => [ + ...(hopperPath === undefined + ? [ + { + kind: "install_hopper" as const, + target: + platform === "darwin" + ? "~/Applications/Hopper Disassembler.app" + : "system package manager", + detail: + "Download the official Hopper package, verify it, install it, and open Hopper for activation.", + external: true, + }, + ] + : []), + ...clients + .filter(({ format }) => format !== "unsupported") + .map( + (client): SetupAction => ({ + kind: "configure_client", + target: client.configPath, + detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`, + external: false, + }), + ), + { + kind: "install_skill", + target: "~/.agents/skills/rea-analysis/SKILL.md", + detail: "Install or update the bundled REA analysis skill.", + external: false, + }, +]; + +const configureDetectedClients = async (options: { + readonly host: SetupHost; + readonly detectedClients: readonly SetupClient[]; + readonly hopperPath: string | undefined; + readonly clients: Record; + readonly appliedActions: string[]; +}): Promise => { + for (const client of options.detectedClients) { + const result = await options.host.configureClient( + client, + options.hopperPath, + registrationCommand(), + ); + options.clients[client.name] = result; + if (result.status === "failed") + return `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`; + if (result.status === "configured") + options.appliedActions.push(`configured_${client.name}`); + } + return undefined; +}; + /** * Install prerequisites and configure detected clients idempotently. * Discovery always precedes mutation. Interactive confirmation or explicit @@ -125,38 +187,7 @@ export const runSetup = async ( if (unsupported !== undefined) return fail(unsupported); let hopperPath = await host.hopperPath(); const detectedClients = await host.detectedClients(); - plannedActions = [ - ...(hopperPath === undefined - ? [ - { - kind: "install_hopper" as const, - target: - host.platform === "darwin" - ? "~/Applications/Hopper Disassembler.app" - : "system package manager", - detail: - "Download the official Hopper package, verify it, install it, and open Hopper for activation.", - external: true, - }, - ] - : []), - ...detectedClients - .filter(({ format }) => format !== "unsupported") - .map( - (client): SetupAction => ({ - kind: "configure_client", - target: client.configPath, - detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`, - external: false, - }), - ), - { - kind: "install_skill", - target: "~/.agents/skills/rea-analysis/SKILL.md", - detail: "Install or update the bundled REA analysis skill.", - external: false, - }, - ]; + plannedActions = setupPlan(host.platform, hopperPath, detectedClients); let approved = options.approved; let interactiveApproval = false; if (!approved && confirm !== undefined && !options.structured) { @@ -191,20 +222,14 @@ export const runSetup = async ( }; appliedActions.push("installed_hopper"); } - for (const client of detectedClients) { - const result = await host.configureClient( - client, - hopperPath, - registrationCommand(), - ); - clients[client.name] = result; - if (result.status === "failed") - return fail( - `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`, - ); - if (result.status === "configured") - appliedActions.push(`configured_${client.name}`); - } + const clientFailure = await configureDetectedClients({ + host, + detectedClients, + hopperPath, + clients, + appliedActions, + }); + if (clientFailure !== undefined) return fail(clientFailure); const skill = await host.installSkill(); if (skill === "failed") return fail("Agent skill installation or readback failed."); @@ -485,56 +510,6 @@ export const configureTomlClient = async ( }; }; -/** Transactionally install or upgrade the versioned canonical REA skill. */ -export const installCanonicalSkill = async ( - home: string, -): Promise<"installed" | "unchanged" | "failed"> => { - const destination = join( - home, - ".agents/skills", - PRODUCT_IDENTITY.skillName, - "SKILL.md", - ); - const backup = `${destination}.rea.backup`; - let original: string | undefined; - try { - const content = await readFile( - new URL( - `../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, - import.meta.url, - ), - "utf8", - ); - original = await readFile(destination, "utf8").catch(() => undefined); - if (original === content) return "unchanged"; - await mkdir(dirname(destination), { recursive: true }); - if (original !== undefined) - await writeFileAtomic(backup, original, { - encoding: "utf8", - mode: 0o600, - }); - await writeFileAtomic(destination, content, { - encoding: "utf8", - mode: 0o600, - }); - if ((await readFile(destination, "utf8")) !== content) - throw new Error("skill readback mismatch"); - return "installed"; - } catch { - try { - if (original === undefined) await rm(destination, { force: true }); - else - await writeFileAtomic(destination, original, { - encoding: "utf8", - mode: 0o600, - }); - } catch { - // The backup remains beside the skill for explicit operator recovery. - } - return "failed"; - } -}; - const major = (version: string): number => Number.parseInt(version.split(".")[0] ?? "0", 10); const exists = async (path: string): Promise => { diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts new file mode 100644 index 000000000..5df937780 --- /dev/null +++ b/src/application/SetupSkill.ts @@ -0,0 +1,55 @@ +import { mkdir, readFile, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import writeFileAtomic from "write-file-atomic"; + +import { PRODUCT_IDENTITY } from "../identity.js"; + +/** Transactionally install or upgrade the versioned canonical REA skill. */ +export const installCanonicalSkill = async ( + home: string, +): Promise<"installed" | "unchanged" | "failed"> => { + const destination = join( + home, + ".agents/skills", + PRODUCT_IDENTITY.skillName, + "SKILL.md", + ); + const backup = `${destination}.rea.backup`; + let original: string | undefined; + try { + const content = await readFile( + new URL( + `../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, + import.meta.url, + ), + "utf8", + ); + original = await readFile(destination, "utf8").catch(() => undefined); + if (original === content) return "unchanged"; + await mkdir(dirname(destination), { recursive: true }); + if (original !== undefined) + await writeFileAtomic(backup, original, { + encoding: "utf8", + mode: 0o600, + }); + await writeFileAtomic(destination, content, { + encoding: "utf8", + mode: 0o600, + }); + if ((await readFile(destination, "utf8")) !== content) + throw new Error("skill readback mismatch"); + return "installed"; + } catch { + try { + if (original === undefined) await rm(destination, { force: true }); + else + await writeFileAtomic(destination, original, { + encoding: "utf8", + mode: 0o600, + }); + } catch { + // The backup remains beside the skill for explicit operator recovery. + } + return "failed"; + } +}; diff --git a/src/cli.ts b/src/cli.ts index 209ddef1c..2b027e9aa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -51,6 +51,7 @@ export const createCli = (): ReturnType => { }, }); + registerSetupCommands(cli, logger); registerCoreCommands(cli, logger); registerFunctionCommand(cli, logger); registerSearchCommand(cli, logger); @@ -73,6 +74,60 @@ const registerCoreCommands = ( detail: z.enum(["concise", "detailed"]).default("concise"), limit: z.number().int().min(1).max(50).default(10), }); + cli.command("analyze", { + description: "Get an overview of an app", + args: z.object({ + path: z.string().describe("App, program, or Hopper database path"), + }), + options: overviewOptions, + run: ({ args, options }) => + logCliCommand(logger, "analyze", () => + runDirectAnalysis( + args.path, + "binary_overview", + { detail: options.detail, limit: options.limit }, + logger, + ), + ), + }); + cli.command("inspect", { + description: "Inspect an app overview with evidence", + args: z.object({ + path: z.string().describe("App, program, or Hopper database path"), + }), + options: overviewOptions, + run: ({ args, options }) => + logCliCommand(logger, "inspect", () => + runDirectAnalysis( + args.path, + "binary_overview", + { detail: options.detail, limit: options.limit }, + logger, + ), + ), + }); + cli.command("decompile", { + description: "Read one part of an app as code", + args: z.object({ + path: z.string().describe("App or program path"), + address: z.string().describe("Procedure address"), + }), + run: ({ args }) => + logCliCommand(logger, "decompile", () => + runDirectAnalysis( + args.path, + "procedure_pseudo_code", + { procedure: args.address }, + logger, + ), + ), + }); +}; + +const registerSetupCommands = ( + cli: ReturnType, + logger: Logger, +): void => { cli.command("setup", { description: "Install requirements and configure coding agents", options: z.object({ @@ -132,54 +187,6 @@ const registerCoreCommands = ( ), ), }); - cli.command("analyze", { - description: "Get an overview of an app", - args: z.object({ - path: z.string().describe("App, program, or Hopper database path"), - }), - options: overviewOptions, - run: ({ args, options }) => - logCliCommand(logger, "analyze", () => - runDirectAnalysis( - args.path, - "binary_overview", - { detail: options.detail, limit: options.limit }, - logger, - ), - ), - }); - cli.command("inspect", { - description: "Inspect an app overview with evidence", - args: z.object({ - path: z.string().describe("App, program, or Hopper database path"), - }), - options: overviewOptions, - run: ({ args, options }) => - logCliCommand(logger, "inspect", () => - runDirectAnalysis( - args.path, - "binary_overview", - { detail: options.detail, limit: options.limit }, - logger, - ), - ), - }); - cli.command("decompile", { - description: "Read one part of an app as code", - args: z.object({ - path: z.string().describe("App or program path"), - address: z.string().describe("Procedure address"), - }), - run: ({ args }) => - logCliCommand(logger, "decompile", () => - runDirectAnalysis( - args.path, - "procedure_pseudo_code", - { procedure: args.address }, - logger, - ), - ), - }); }; const confirmSetup = async (