refactor(setup): split setup and CLI registration

This commit is contained in:
morluto 2026-07-13 18:20:40 +08:00
parent c8d762e6ff
commit 07e1b5ac5f
3 changed files with 181 additions and 144 deletions

View File

@ -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<boolean>;
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<string, ClientConfigurationResult>;
readonly appliedActions: string[];
}): Promise<string | undefined> => {
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<boolean> => {

View File

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

View File

@ -51,6 +51,7 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
},
});
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<typeof Cli.create>,
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 (