refactor(setup): split setup and CLI registration
This commit is contained in:
parent
c8d762e6ff
commit
07e1b5ac5f
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
};
|
||||
103
src/cli.ts
103
src/cli.ts
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue