rea/src/cli.ts

323 lines
10 KiB
TypeScript

import { Cli, z } from "incur";
import { fileURLToPath } from "node:url";
import { runDoctor } from "./application/Doctor.js";
import {
runDirectAnalysis,
runProviderAnalysis,
} from "./application/DirectAnalysis.js";
import { runSetup } from "./application/Setup.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import { createLogger, parseLogLevel, type Logger } from "./logger.js";
import { parseConfig } from "./config.js";
import {
exportEvidenceBundleCommand,
importEvidenceBundleCommand,
} from "./application/EvidenceBundleCommands.js";
import { importReferenceSource } from "./application/ReferenceSourceImport.js";
/**
* Build the one-shot Incur CLI without starting Hopper at import time.
* Analysis commands acquire and close their own sessions; bare `mcp` and
* `--mcp` are intercepted by the executable dispatcher before this module loads.
*/
export const createCli = (): ReturnType<typeof Cli.create> => {
const logger = createLogger(
"cli",
process.env.REA_LOG_LEVEL === undefined
? "silent"
: parseLogLevel(process.env.REA_LOG_LEVEL),
);
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
version: "0.1.0",
description:
"Reverse engineer anything from your terminal or coding agent.",
mcp: {
command: PRODUCT_IDENTITY.mcpCommand,
instructions:
"Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.",
},
sync: {
cwd: fileURLToPath(new URL("..", import.meta.url)),
include: ["skills/*"],
suggestions: [
"understand how a software feature works",
"investigate an artifact or observed behavior",
"check my REA setup",
],
},
});
cli.command("setup", {
description: "Install requirements and configure coding agents",
options: z.object({
yes: z
.boolean()
.default(false)
.describe("Approve prerequisite installation"),
}),
alias: { yes: "y" },
run: ({ options }) =>
logCliCommand(logger, "setup", () => runSetup(options.yes)),
});
cli.command("doctor", {
description: "Check whether REA is ready",
options: z.object({
target: z.string().optional().describe("Optional app path to check"),
}),
run: ({ options }) =>
logCliCommand(logger, "doctor", () => runDoctor(options.target)),
});
cli.command("analyze", {
description: "Get an overview of an app",
args: z.object({
path: z.string().describe("App, program, or Hopper database path"),
}),
run: ({ args }) =>
logCliCommand(logger, "analyze", () =>
runDirectAnalysis(args.path, "binary_overview", {}, 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,
),
),
});
registerNativeCommands(cli, logger);
registerArtifactCommands(cli, logger);
registerEvidenceCommands(cli, logger);
registerReferenceSourceCommand(cli, logger);
return cli;
};
const registerReferenceSourceCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("import-reference-source", {
description: "Import a bounded source tree as historical reference only",
args: z.object({
root: z
.string()
.describe("Source root allowed by REA_REFERENCE_ROOTS_JSON"),
}),
run: ({ args }) =>
logCliCommand(logger, "import-reference-source", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const imported = await importReferenceSource({
root: args.root,
caller: "rea-cli",
policy: config.value.referenceSourcePolicy,
importer: PRODUCT_IDENTITY.packageName,
importerVersion: null,
});
return imported.ok
? imported.value
: { error: imported.error.code, message: imported.error.message };
}),
});
};
const registerArtifactCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("inventory-artifact", {
description: "Build a bounded deterministic artifact graph",
args: z.object({
path: z.string().describe("Application or package path"),
}),
options: z.object({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(500).default(100),
}),
run: ({ args, options }) =>
logCliCommand(logger, "inventory-artifact", () =>
runProviderAnalysis(
args.path,
"inventory_artifact",
{
node_offset: options.offset,
node_limit: options.limit,
occurrence_offset: options.offset,
occurrence_limit: options.limit,
edge_offset: options.offset,
edge_limit: options.limit,
},
logger,
),
),
});
cli.command("extract-artifact", {
description: "Extract explicitly selected artifact occurrences safely",
args: z.object({
path: z.string().describe("Application or package path"),
outputRoot: z.string().describe("Absent absolute output root"),
occurrenceIds: z
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
.min(1)
.max(500),
}),
alias: { outputRoot: "output-root", occurrenceIds: "occurrence-ids" },
run: ({ args }) =>
logCliCommand(logger, "extract-artifact", () =>
runProviderAnalysis(
args.path,
"extract_artifact",
{
approved: true,
output_root: args.outputRoot,
occurrence_ids: args.occurrenceIds,
},
logger,
),
),
});
};
const registerNativeCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
for (const [command, tool] of [
["inspect-macho", "inspect_macho"],
["inspect-signature", "inspect_signature"],
["list-architectures", "list_architectures"],
] as const) {
cli.command(command, {
description: `Run ${tool} without launching Hopper`,
args: z.object({ path: z.string().describe("Mach-O or app path") }),
run: ({ args }) =>
logCliCommand(logger, command, () =>
runProviderAnalysis(args.path, tool, {}, logger),
),
});
}
cli.command("inspect-plist", {
description: "Parse app plist metadata without launching Hopper",
args: z.object({ path: z.string().describe("App or Mach-O path") }),
options: z.object({
relativePath: z.string().default("Contents/Info.plist"),
}),
alias: { relativePath: "relative-path" },
run: ({ args, options }) =>
logCliCommand(logger, "inspect-plist", () =>
runProviderAnalysis(
args.path,
"inspect_plist",
{ relative_path: options.relativePath },
logger,
),
),
});
cli.command("demangle-swift", {
description: "Demangle a bounded Swift symbol batch without Hopper",
args: z.object({
path: z.string().describe("Artifact path used for evidence identity"),
symbols: z.array(z.string().min(1)).min(1).max(500),
}),
run: ({ args }) =>
logCliCommand(logger, "demangle-swift", () =>
runProviderAnalysis(
args.path,
"demangle_swift",
{ symbols: args.symbols },
logger,
),
),
});
};
const registerEvidenceCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("evidence-import", {
description: "Validate and import a bounded local Evidence v2 bundle",
args: z.object({
path: z.string().describe("Evidence bundle JSON path"),
}),
run: ({ args }) =>
logCliCommand(logger, "evidence-import", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const imported = await importEvidenceBundleCommand(
args.path,
config.value.evidenceFilePolicy,
);
return imported.ok
? imported.value
: { error: imported.error._tag, message: imported.error.message };
}),
});
cli.command("evidence-export", {
description: "Validate and atomically export canonical Evidence v2 JSON",
args: z.object({
source: z.string().describe("Existing evidence bundle JSON path"),
output: z.string().describe("Canonical output JSON path"),
}),
options: z.object({
overwrite: z.boolean().default(false).describe("Replace output file"),
}),
run: ({ args, options }) =>
logCliCommand(logger, "evidence-export", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const exported = await exportEvidenceBundleCommand(
args.source,
args.output,
options.overwrite,
config.value.evidenceFilePolicy,
);
return exported.ok
? exported.value
: { error: exported.error._tag, message: exported.error.message };
}),
});
};
const logCliCommand = async <Value>(
logger: Logger,
command: string,
execute: () => Promise<Value>,
): Promise<Value> => {
const startedAt = performance.now();
try {
const value = await execute();
logger.info(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "ok",
},
"CLI command completed",
);
return value;
} catch (cause: unknown) {
logger.error(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "error",
},
"CLI command failed",
);
throw cause;
}
};