diff --git a/docs/architecture/architecture.mermaid b/docs/architecture/architecture.mermaid
index 09621773f2..825b0c9b4f 100644
--- a/docs/architecture/architecture.mermaid
+++ b/docs/architecture/architecture.mermaid
@@ -2,7 +2,7 @@ graph TD
%% Selected layer owners and dependency/wiring paths, not a directory or import inventory.
%% Current deep-provider candidates are wired in src/composition/binary.ts.
subgraph "Entry Points"
- CLI["src/cli.ts + src/cli/
One-shot CLI adapter
setup, diagnostics, analysis, decompilation"]
+ CLI["src/cli.ts + src/cli/
One-shot CLI adapter
setup, diagnostics, analysis, decompilation
cliLoader: recorded registrar per command, complete CLI otherwise"]
MAIN["src/main.ts + src/main/
MCP server adapter: stdio default, streamable HTTP opt-in
config, session, process lifetime"]
DISPATCH["scripts/rea.mjs
Executable dispatcher
routes mcp / mcp --http / one-shot"]
end
diff --git a/docs/development/tool-design.md b/docs/development/tool-design.md
index f8677892ee..b4fbb589d9 100644
--- a/docs/development/tool-design.md
+++ b/docs/development/tool-design.md
@@ -479,7 +479,18 @@ through the producing owner and affected CLI/MCP consumers.
## Implement and evaluate
Put provider wire-protocol parsing in adapters and shared CLI/MCP workflows in
-the application layer. Reusable format declarations may live in pure domain
+the application layer.
+
+CLI commands register through
+[`cliCommandRegistrations.ts`](https://github.com/morluto/rea/blob/main/src/cli/cliCommandRegistrations.ts):
+add the registrar to its family there and its module to `src/cli.ts`. The build
+records which registrar defines each top-level command, and a one-shot command
+loads only that registrar's module graph; help, completions, `--llms` and unknown
+names load the complete CLI. A registrar's command definitions must not depend
+on other registrars, and `tests/boundary/cli/cliLazyRegistration.test.ts` checks
+that each one registers alone with the definitions the complete CLI gives it.
+Modules that every registrar imports set the startup cost of every command; load
+optional heavy services where a command needs them. Reusable format declarations may live in pure domain
owners: [`peOptionalHeader.ts`](https://github.com/morluto/rea/blob/main/src/domain/peOptionalHeader.ts)
decodes PE structure, while consumers retain machine admission, directory-address
interpretation, and file/mapping bounds. Share Evidence provenance, unknown
diff --git a/package.json b/package.json
index d1256a70e9..d885dca380 100644
--- a/package.json
+++ b/package.json
@@ -344,8 +344,8 @@
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
- "compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
- "compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
+ "compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/record-cli-commands.mjs && node scripts/bundle-runtime.mjs",
+ "compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/record-cli-commands.mjs && node scripts/bundle-runtime.mjs",
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs
index c135343c59..1d0499c801 100644
--- a/scripts/bundle-runtime.mjs
+++ b/scripts/bundle-runtime.mjs
@@ -11,9 +11,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
export const bundleRuntime = async (root) => {
const compiledRoot = resolve(root, "dist");
// CLI, MCP, workers and updater fixtures share one release-identity owner.
- const metadataModule = join(compiledRoot, "generatedPackageMetadata.js");
+ // The recorded CLI command registrars stay one replaceable build fact too.
+ const recordedModules = new Set(
+ ["generatedPackageMetadata.js", "recordedCliCommandRegistrations.js"].map(
+ (name) => join(compiledRoot, name),
+ ),
+ );
const isMetadataModule = (id) =>
- isAbsolute(id) && resolve(id) === metadataModule;
+ isAbsolute(id) && recordedModules.has(resolve(id));
const { rolldown, RUNTIME_MODULE_ID } = await import("rolldown");
const { imports = {} } = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
@@ -122,7 +127,7 @@ export const bundleRuntime = async (root) => {
return `${path.replace(/\.js$/u, "").split(sep).join("__")}-[hash].js`;
},
paths: (id) =>
- isMetadataModule(id) ? "../generatedPackageMetadata.js" : id,
+ isMetadataModule(id) ? `../${relative(compiledRoot, id)}` : id,
minifyInternalExports: directory === "mcp",
codeSplitting: true,
strictExecutionOrder: true,
diff --git a/scripts/record-cli-commands.mjs b/scripts/record-cli-commands.mjs
new file mode 100644
index 0000000000..7ebdd59189
--- /dev/null
+++ b/scripts/record-cli-commands.mjs
@@ -0,0 +1,67 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { Cli } from "incur";
+
+// Record which registrar defines each top-level CLI command. Run after `tsc`
+// and before bundling, so the record comes from the same compiled registration
+// table that one-shot commands load from.
+const compiled = resolve(dirname(fileURLToPath(import.meta.url)), "../dist");
+const read = (name) => import(pathToFileURL(join(compiled, name)).href);
+const [
+ { CLI_COMMAND_REGISTRATIONS, CLI_REGISTRATION_ORDER },
+ { createCliShell },
+ { createDirectAnalysis },
+] = await Promise.all([
+ read("cli/cliCommandRegistrations.js"),
+ read("cli/cliShell.js"),
+ read("composition/directAnalysis.js"),
+]);
+
+// Registration is independent of the host account and user configuration.
+const home = await mkdtemp(join(tmpdir(), "rea-cli-commands-"));
+const recorded = {};
+try {
+ const registrations = await Promise.all(
+ CLI_REGISTRATION_ORDER.map(async (id) => {
+ const registration = CLI_COMMAND_REGISTRATIONS[id];
+ return {
+ module: await registration.load(),
+ registration: {
+ ...registration,
+ register(module, context) {
+ const before = new Set(Cli.toCommands.get(context.cli)?.keys());
+ registration.register(module, context);
+ for (const name of Cli.toCommands.get(context.cli)?.keys() ?? [])
+ if (!before.has(name)) recorded[name] = id;
+ },
+ },
+ };
+ }),
+ );
+ createCliShell({
+ selectedEnvironment: {
+ PATH: process.env.PATH,
+ HOME: home,
+ USERPROFILE: home,
+ XDG_CONFIG_HOME: join(home, "config"),
+ XDG_CACHE_HOME: join(home, "cache"),
+ XDG_STATE_HOME: join(home, "state"),
+ },
+ registrations,
+ createAnalysis: createDirectAnalysis,
+ });
+} finally {
+ await rm(home, { recursive: true, force: true });
+}
+if (Object.keys(recorded).length === 0)
+ throw new Error("No CLI commands were registered");
+const sorted = Object.fromEntries(
+ Object.entries(recorded).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
+);
+await writeFile(
+ join(compiled, "recordedCliCommandRegistrations.js"),
+ `/** Recorded from the compiled CLI registration table by scripts/record-cli-commands.mjs; do not edit. */\nexport const RECORDED_CLI_COMMAND_REGISTRATIONS = Object.freeze(${JSON.stringify(sorted)});\n`,
+ "utf8",
+);
diff --git a/src/cli.ts b/src/cli.ts
index b288cefc65..7b66580b55 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,112 +1,128 @@
-import { parseClientRegistrationCommand } from "./domain/clientRegistrationCommand.js";
-import {
- serveMcpRegistration,
- closeCliMcpRegistrationResources,
-} from "./cli/mcpRegistration.js";
-import { registerToolFamilies } from "./contracts/toolFamilyIds.js";
-import { registerGpuCommands } from "./cli/gpuCommands.js";
-import { registerNativeFunctionMatchingCommands } from "./cli/nativeFunctionMatchingCommands.js";
-import { registerCaptureCommands } from "./cli/captureCommands.js";
-import { registerSignalCommands } from "./cli/signalCommands.js";
-import { registerWasmCommands } from "./cli/wasmCommands.js";
-import { registerApkSignatureCommands } from "./cli/apkSignatureCommands.js";
-import { snapshotEnvironment } from "./process/snapshotEnvironment.js";
-import { createDirectAnalysis } from "./composition/directAnalysis.js";
-import { registerEvmCommands } from "./cli/evmCommands.js";
-import { registerDesktopApplicationCommands } from "./cli/desktopApplicationCommands.js";
-import { registerReleaseExposureCommands } from "./cli/releaseExposureCommands.js";
-import { registerGoCommands } from "./cli/goCommands.js";
-import { registerModelCommands } from "./cli/modelCommands.js";
-import { registerIl2cppCommands } from "./cli/il2cppCommands.js";
-import { registerRustCommands } from "./cli/rustCommands.js";
-import { registerPythonPycCommands } from "./cli/pythonPycCommands.js";
-import { registerErlangCommands } from "./cli/erlangCommands.js";
-import { registerV8CodeCacheCommands } from "./cli/v8CodeCacheCommands.js";
-import { registerSqliteCommands } from "./cli/sqliteCommands.js";
-import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js";
-import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js";
-import { registerFridaCommands } from "./cli/fridaCommands.js";
import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js";
-import { Cli, z } from "incur";
import {
- adaptCliOutputArgumentsForIncur,
- normalizeCliArguments,
-} from "./cliBuiltinArguments.js";
-
-import { createLogger } from "./logger.js";
-import { parseLogLevel } from "./config/processStart.js";
-import { PRODUCT_IDENTITY } from "./identity.js";
-import { currentClientRegistrationCommand } from "./application/ClientRegistrationIdentity.js";
-import { registerSetupCommands } from "./cli/setupCommands.js";
-import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js";
-import { registerUtilityCommands } from "./cli/utilityCommands.js";
-import { registerArtifactCommands } from "./cli/artifactCommands.js";
-import { registerManagedCommands } from "./cli/managedCommands.js";
-import { registerFirmwareCommands } from "./cli/firmwareCommands.js";
-import { registerAndroidCommands } from "./cli/androidCommands.js";
-import { registerApktoolCommands } from "./cli/apktoolCommands.js";
-import { registerPcapCommands } from "./cli/pcapCommands.js";
-import { registerFlutterCommands } from "./cli/flutterCommands.js";
-import { registerJebCommands } from "./cli/jebCommands.js";
-import { registerAdbCommands } from "./cli/adbCommands.js";
-import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
-import { registerEnvironmentCommands } from "./cli/environmentCommands.js";
-import { registerProcessCommands } from "./cli/processCommands.js";
-import { registerDesktopCaptureCommands } from "./cli/desktopCaptureCommands.js";
-import { registerBrowserCommands } from "./cli/browserCommands.js";
-import { registerWebScriptCommands } from "./cli/webScriptCommands.js";
-import { registerWebModuleCommands } from "./cli/webModuleCommands.js";
-import { registerWebSourceLocationCommands } from "./cli/webSourceLocationCommands.js";
-import { registerWebRuntimeCommands } from "./cli/webRuntimeCommands.js";
-import { registerWebNetworkCaptureCommands } from "./cli/webNetworkCaptureCommands.js";
-import { registerJavaScriptRecoveryCommands } from "./cli/javascriptRecoveryCommands.js";
-import { registerAdvancedBrowserCommands } from "./cli/browserAdvancedCommands.js";
-import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js";
-import { registerElectronCommands } from "./cli/electronCommands.js";
-import { registerJavaScriptRuntimeObservationCommands } from "./cli/javascriptRuntimeCommands.js";
-import { registerApplicationCommands } from "./cli/applicationCommands.js";
-import { presentOmittableDefaults } from "./cli/omittableDefaults.js";
+ CLI_COMMAND_REGISTRATIONS,
+ CLI_REGISTRATION_ORDER,
+ type CliCommandModules,
+} from "./cli/cliCommandRegistrations.js";
+import type { CliResultOutput } from "./cli/cliResultOutput.js";
+import { createCliShell } from "./cli/cliShell.js";
import type { CliInstance } from "./cli/types.js";
-import {
- streamCliCommandResults,
- type CliResultOutput,
-} from "./cli/cliResultOutput.js";
-import { registerReverseEngineeringCommands } from "./cli/reverseEngineeringCommands.js";
-import { registerCutterCommands } from "./cli/cutterCommands.js";
-import { excludeDeclaredSensitiveArguments } from "./cli/explicitSensitiveArguments.js";
-import { validateCliArguments } from "./cli/strictPositionals.js";
-import {
- encodeCliArgumentErrors,
- presentCliArgumentErrors,
-} from "./cli/argumentErrors.js";
-import { completionHelpNote, createCliCompletions } from "./cli/completions.js";
-import { reportUnknownMcpSubcommand } from "./cli/mcpBuiltinCommands.js";
-import {
- closeCliGuidanceResources,
- registerGuidanceCommands,
-} from "./cli/guidanceCommands.js";
-import { closeCliJavaScriptResources } from "./cli/javascriptApplicationAnalysis.js";
-import { registerGuidedWorkflowCommands } from "./cli/guidedWorkflowCommands.js";
+import { createDirectAnalysis } from "./composition/directAnalysis.js";
+import * as setupModule from "./cli/setupCommands.js";
+import * as coreAnalysisModule from "./cli/coreAnalysisCommands.js";
+import * as reverseEngineeringModule from "./cli/reverseEngineeringCommands.js";
+import * as cutterModule from "./cli/cutterCommands.js";
+import * as utilityModule from "./cli/utilityCommands.js";
+import * as binaryDiagnosticsModule from "./cli/binaryDiagnosticsCommands.js";
+import * as wasmModule from "./cli/wasmCommands.js";
+import * as apkSignatureModule from "./cli/apkSignatureCommands.js";
+import * as captureModule from "./cli/captureCommands.js";
+import * as signalModule from "./cli/signalCommands.js";
+import * as evmModule from "./cli/evmCommands.js";
+import * as desktopApplicationModule from "./cli/desktopApplicationCommands.js";
+import * as releaseExposureModule from "./cli/releaseExposureCommands.js";
+import * as goModule from "./cli/goCommands.js";
+import * as il2cppModule from "./cli/il2cppCommands.js";
+import * as modelModule from "./cli/modelCommands.js";
+import * as gpuModule from "./cli/gpuCommands.js";
+import * as rustModule from "./cli/rustCommands.js";
+import * as pythonPycModule from "./cli/pythonPycCommands.js";
+import * as erlangModule from "./cli/erlangCommands.js";
+import * as v8CodeCacheModule from "./cli/v8CodeCacheCommands.js";
+import * as sqliteModule from "./cli/sqliteCommands.js";
+import * as artifactModule from "./cli/artifactCommands.js";
+import * as managedModule from "./cli/managedCommands.js";
+import * as firmwareModule from "./cli/firmwareCommands.js";
+import * as androidModule from "./cli/androidCommands.js";
+import * as pcapModule from "./cli/pcapCommands.js";
+import * as flutterModule from "./cli/flutterCommands.js";
+import * as fridaModule from "./cli/fridaCommands.js";
+import * as apktoolModule from "./cli/apktoolCommands.js";
+import * as jebModule from "./cli/jebCommands.js";
+import * as adbModule from "./cli/adbCommands.js";
+import * as browserModule from "./cli/browserCommands.js";
+import * as webRuntimeModule from "./cli/webRuntimeCommands.js";
+import * as webNetworkCaptureModule from "./cli/webNetworkCaptureCommands.js";
+import * as advancedBrowserModule from "./cli/browserAdvancedCommands.js";
+import * as browserScenarioModule from "./cliBrowserScenarioCommands.js";
+import * as electronModule from "./cli/electronCommands.js";
+import * as javaScriptRuntimeObservationModule from "./cli/javascriptRuntimeCommands.js";
+import * as guidanceModule from "./cli/guidanceCommands.js";
+import * as guidedWorkflowModule from "./cli/guidedWorkflowCommands.js";
+import * as analysisViewModule from "./cli/analysisViewCommands.js";
+import * as evidenceModule from "./cliEvidenceCommands.js";
+import * as environmentModule from "./cli/environmentCommands.js";
+import * as processModule from "./cli/processCommands.js";
+import * as desktopCaptureModule from "./cli/desktopCaptureCommands.js";
+import * as javaScriptRecoveryModule from "./cli/javascriptRecoveryCommands.js";
+import * as webScriptModule from "./cli/webScriptCommands.js";
+import * as webModuleModule from "./cli/webModuleCommands.js";
+import * as webSourceLocationModule from "./cli/webSourceLocationCommands.js";
+import * as applicationModule from "./cli/applicationCommands.js";
+import * as nativeFunctionMatchingModule from "./cli/nativeFunctionMatchingCommands.js";
-/** Close every process-owned resource used by CLI commands. */
-export const closeCliResources = async (): Promise => {
- const results = await Promise.allSettled([
- closeCliJavaScriptResources(),
- closeCliGuidanceResources(),
- closeCliMcpRegistrationResources(),
- ]);
- const failures = results.flatMap((result) =>
- result.status === "rejected" ? [result.reason] : [],
- );
- if (failures.length === 1) throw failures[0];
- if (failures.length > 1)
- throw new AggregateError(failures, "CLI resource cleanup failed");
+export { closeCliResources } from "./cli/cliResources.js";
+
+const CLI_COMMAND_MODULES: CliCommandModules = {
+ setup: setupModule,
+ coreAnalysis: coreAnalysisModule,
+ reverseEngineering: reverseEngineeringModule,
+ cutter: cutterModule,
+ utility: utilityModule,
+ binaryDiagnostics: binaryDiagnosticsModule,
+ wasm: wasmModule,
+ apkSignature: apkSignatureModule,
+ capture: captureModule,
+ signal: signalModule,
+ evm: evmModule,
+ desktopApplication: desktopApplicationModule,
+ releaseExposure: releaseExposureModule,
+ go: goModule,
+ il2cpp: il2cppModule,
+ model: modelModule,
+ gpu: gpuModule,
+ rust: rustModule,
+ pythonPyc: pythonPycModule,
+ erlang: erlangModule,
+ v8CodeCache: v8CodeCacheModule,
+ sqlite: sqliteModule,
+ artifact: artifactModule,
+ managed: managedModule,
+ firmware: firmwareModule,
+ android: androidModule,
+ pcap: pcapModule,
+ flutter: flutterModule,
+ frida: fridaModule,
+ apktool: apktoolModule,
+ jeb: jebModule,
+ adb: adbModule,
+ browser: browserModule,
+ webRuntime: webRuntimeModule,
+ webNetworkCapture: webNetworkCaptureModule,
+ advancedBrowser: advancedBrowserModule,
+ browserScenario: browserScenarioModule,
+ electron: electronModule,
+ javaScriptRuntimeObservation: javaScriptRuntimeObservationModule,
+ guidance: guidanceModule,
+ guidedWorkflow: guidedWorkflowModule,
+ analysisView: analysisViewModule,
+ evidence: evidenceModule,
+ environment: environmentModule,
+ process: processModule,
+ desktopCapture: desktopCaptureModule,
+ javaScriptRecovery: javaScriptRecoveryModule,
+ webScript: webScriptModule,
+ webModule: webModuleModule,
+ webSourceLocation: webSourceLocationModule,
+ application: applicationModule,
+ nativeFunctionMatching: nativeFunctionMatchingModule,
};
/**
- * Build the one-shot Incur CLI without starting Hopper at import time.
+ * Build the complete 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.
+ * One-shot commands load only their own registrars; see `cli/cliLoader.ts`.
*/
export const createCli = (
selectedEnvironment: Readonly<
@@ -114,172 +130,14 @@ export const createCli = (
> = process.env,
resultOutput?: CliResultOutput,
fridaInstrumentation?: FridaInstrumentationPort,
-): CliInstance => {
- const environment = snapshotEnvironment(selectedEnvironment);
- const analysis = createDirectAnalysis(environment);
- const defaultRegistration = parseClientRegistrationCommand(
- currentClientRegistrationCommand({ environment }),
- );
- // The executable reports an invalid level before every command except doctor.
- // Doctor keeps going so its readiness report can name REA_LOG_LEVEL.
- const level =
- environment.REA_LOG_LEVEL === undefined
- ? undefined
- : parseLogLevel(environment.REA_LOG_LEVEL);
- const logger = createLogger(
- "cli",
- level?.ok === true ? level.value : "silent",
- );
- const timedCli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
- version: PRODUCT_IDENTITY.packageVersion,
- description: "Reverse engineer anything from your terminal or agent.",
- globals: z.object({
- timings: z
- .boolean()
- .default(false)
- .describe("Write measured command timing to stderr."),
- }),
- mcp: {
- instructions:
- "Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.",
- },
- sync: false,
- update: {
- // Incur's update:false only suppresses notices. Reject its explicit
- // installer so ambiguous flags cannot bypass REA's verified workflow.
- install: () => {
- throw new Error(
- "Use 'rea update' to update the identified installation. The '--update' alias accepts only global output flags.",
- );
- },
- },
+): CliInstance =>
+ createCliShell({
+ selectedEnvironment,
+ resultOutput,
+ fridaInstrumentation,
+ createAnalysis: createDirectAnalysis,
+ registrations: CLI_REGISTRATION_ORDER.map((id) => ({
+ registration: CLI_COMMAND_REGISTRATIONS[id],
+ module: CLI_COMMAND_MODULES[id],
+ })),
});
-
- timedCli.use(async (context, next) => {
- if (!context.globals.timings) return next();
- const started = performance.now();
- try {
- await next();
- } finally {
- // This interval covers CLI command dispatch, including result delivery.
- // It is not a provider-only or analysis-only measurement.
- process.stderr.write(
- `${JSON.stringify({ rea_timings: { version: 1, command_ms: Math.round(performance.now() - started) } })}\n`,
- );
- }
- });
- // Command registrars do not inspect global options; preserve their existing
- // type while Incur owns parsing and the middleware owns this added flag.
- const cli = timedCli as unknown as CliInstance;
-
- registerSetupCommands(cli, logger, environment);
- registerToolFamilies({
- direct: () =>
- registerCoreAnalysisCommands(
- cli,
- logger,
- analysis.runDirectAnalysis,
- environment,
- ),
- // Enhanced workflows are MCP tools. Their CLI entry points live with core analysis.
- enhanced: () => undefined,
- native: () => {
- registerReverseEngineeringCommands(cli, logger, environment);
- registerCutterCommands(cli, logger, environment);
- registerUtilityCommands(cli, logger, environment, analysis);
- registerBinaryDiagnosticsCommands(cli, logger, environment);
- },
- artifact: () => {
- registerWasmCommands(cli, logger, environment);
- registerApkSignatureCommands(cli, logger, environment);
- registerCaptureCommands(cli, logger, environment);
- registerSignalCommands(cli, logger, environment);
- registerEvmCommands(cli, logger, environment);
- registerDesktopApplicationCommands(cli, logger);
- registerReleaseExposureCommands(cli, logger, environment);
- registerGoCommands(cli, logger);
- registerIl2cppCommands(cli, logger);
- registerModelCommands(cli, logger);
- registerGpuCommands(cli, logger);
- registerRustCommands(cli, logger);
- registerPythonPycCommands(cli, logger);
- registerErlangCommands(cli, logger);
- registerV8CodeCacheCommands(cli, logger);
- registerSqliteCommands(cli, logger, environment);
- registerArtifactCommands(cli, logger, analysis.runProviderAnalysis);
- },
- managed: () =>
- registerManagedCommands(cli, logger, analysis.runProviderAnalysis),
- firmware: () => registerFirmwareCommands(cli, logger, environment),
- android: () => registerAndroidCommands(cli, logger, environment),
- pcap: () => registerPcapCommands(cli, logger, environment),
- flutter: () => registerFlutterCommands(cli, logger),
- frida: () => registerFridaCommands(cli, logger, fridaInstrumentation),
- apktool: () => registerApktoolCommands(cli, logger, environment),
- jeb: () => registerJebCommands(cli, logger, environment),
- adb: () => registerAdbCommands(cli, logger, environment),
- browser: () => {
- registerBrowserCommands(cli, logger);
- registerWebRuntimeCommands(cli, logger);
- registerWebNetworkCaptureCommands(cli, logger, environment);
- registerAdvancedBrowserCommands(cli, logger);
- registerBrowserScenarioCommands(cli, logger, environment);
- },
- electron: () => registerElectronCommands(cli, logger, environment),
- "javascript-runtime": () =>
- registerJavaScriptRuntimeObservationCommands(cli, logger, environment),
- application: () => {
- registerGuidanceCommands(cli, logger);
- registerGuidedWorkflowCommands(cli, logger);
- registerAnalysisViewCommands(cli, logger);
- registerEvidenceCommands(cli, logger);
- registerEnvironmentCommands(cli, logger, environment);
- registerProcessCommands(cli, logger, environment);
- registerDesktopCaptureCommands(cli, logger, environment);
- registerJavaScriptRecoveryCommands(cli, logger, environment);
- registerWebScriptCommands(cli, logger);
- registerWebModuleCommands(cli, logger, environment);
- registerWebSourceLocationCommands(cli, logger, environment);
- registerApplicationCommands(cli, logger);
- },
- session: () =>
- registerNativeFunctionMatchingCommands(cli, logger, environment),
- });
- presentOmittableDefaults(cli);
- if (resultOutput !== undefined) streamCliCommandResults(cli, resultOutput);
- const serveStrict = validateCliArguments(cli);
- presentCliArgumentErrors(cli);
- const serveDeclared = excludeDeclaredSensitiveArguments(cli);
- encodeCliArgumentErrors(cli);
- const serve = cli.serve.bind(cli);
- const complete = createCliCompletions(
- cli as unknown as Parameters[0],
- environment,
- );
- cli.serve = (arguments_ = process.argv.slice(2), options) => {
- if (complete(arguments_, options ?? {})) return Promise.resolve();
- const normalized = normalizeCliArguments(arguments_);
- const registration = serveMcpRegistration(
- normalized,
- environment,
- defaultRegistration,
- options ?? {},
- );
- if (registration !== undefined) return registration;
- const result = serveStrict(normalized, options ?? {}, (tokens, selected) =>
- serveDeclared(tokens, selected, (declared, declaredOptions) =>
- reportUnknownMcpSubcommand(cli.name, declared, declaredOptions)
- ? Promise.resolve()
- : serve(adaptCliOutputArgumentsForIncur(declared), declaredOptions),
- ),
- );
- const note = completionHelpNote(normalized);
- if (note === undefined) return result;
- return result.then(() => {
- const write =
- options?.stdout ?? ((text: string) => process.stdout.write(text));
- write(`\n${note}`);
- });
- };
- return cli;
-};
diff --git a/src/cli/cliCommandRegistration.ts b/src/cli/cliCommandRegistration.ts
new file mode 100644
index 0000000000..fc5c25796f
--- /dev/null
+++ b/src/cli/cliCommandRegistration.ts
@@ -0,0 +1,41 @@
+import type { Logger } from "pino";
+
+import type { FridaInstrumentationPort } from "../application/frida/FridaInstrumentationPort.js";
+import type { DirectAnalysis } from "../composition/directAnalysis.js";
+import type { CliInstance } from "./types.js";
+
+/** What a command registrar receives. */
+export interface CliRegistrationContext {
+ readonly cli: CliInstance;
+ readonly logger: Logger;
+ readonly environment: Readonly>;
+ readonly fridaInstrumentation: FridaInstrumentationPort | undefined;
+}
+
+/** Context for registrars that run direct or provider analysis. */
+export interface CliAnalysisRegistrationContext extends CliRegistrationContext {
+ readonly analysis: DirectAnalysis;
+}
+
+/** One command registrar: the module that defines it and how to call it. */
+export type CliCommandRegistration =
+ | {
+ readonly analysis: false;
+ load(): Promise;
+ register(module: Module, context: CliRegistrationContext): void;
+ }
+ | {
+ readonly analysis: true;
+ load(): Promise;
+ register(module: Module, context: CliAnalysisRegistrationContext): void;
+ };
+
+export const plain = (
+ load: () => Promise,
+ register: (module: Module, context: CliRegistrationContext) => void,
+) => ({ analysis: false, load, register }) as const;
+
+export const withAnalysis = (
+ load: () => Promise,
+ register: (module: Module, context: CliAnalysisRegistrationContext) => void,
+) => ({ analysis: true, load, register }) as const;
diff --git a/src/cli/cliCommandRegistrations.ts b/src/cli/cliCommandRegistrations.ts
new file mode 100644
index 0000000000..a8039b2dbe
--- /dev/null
+++ b/src/cli/cliCommandRegistrations.ts
@@ -0,0 +1,485 @@
+import {
+ TOOL_FAMILY_IDS,
+ type ToolFamilyId,
+} from "../contracts/toolFamilyIds.js";
+import { plain, withAnalysis } from "./cliCommandRegistration.js";
+
+/** Every CLI command registrar; see `cliLoader.ts` for one-shot loading. */
+export const CLI_COMMAND_REGISTRATIONS = {
+ setup: plain(
+ () => import("./setupCommands.js"),
+ (module, context) =>
+ module.registerSetupCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ coreAnalysis: withAnalysis(
+ () => import("./coreAnalysisCommands.js"),
+ (module, context) =>
+ module.registerCoreAnalysisCommands(
+ context.cli,
+ context.logger,
+ context.analysis.runDirectAnalysis,
+ context.environment,
+ ),
+ ),
+ reverseEngineering: plain(
+ () => import("./reverseEngineeringCommands.js"),
+ (module, context) =>
+ module.registerReverseEngineeringCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ cutter: plain(
+ () => import("./cutterCommands.js"),
+ (module, context) =>
+ module.registerCutterCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ utility: withAnalysis(
+ () => import("./utilityCommands.js"),
+ (module, context) =>
+ module.registerUtilityCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ context.analysis,
+ ),
+ ),
+ binaryDiagnostics: plain(
+ () => import("./binaryDiagnosticsCommands.js"),
+ (module, context) =>
+ module.registerBinaryDiagnosticsCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ wasm: plain(
+ () => import("./wasmCommands.js"),
+ (module, context) =>
+ module.registerWasmCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ apkSignature: plain(
+ () => import("./apkSignatureCommands.js"),
+ (module, context) =>
+ module.registerApkSignatureCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ capture: plain(
+ () => import("./captureCommands.js"),
+ (module, context) =>
+ module.registerCaptureCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ signal: plain(
+ () => import("./signalCommands.js"),
+ (module, context) =>
+ module.registerSignalCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ evm: plain(
+ () => import("./evmCommands.js"),
+ (module, context) =>
+ module.registerEvmCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ desktopApplication: plain(
+ () => import("./desktopApplicationCommands.js"),
+ (module, context) =>
+ module.registerDesktopApplicationCommands(context.cli, context.logger),
+ ),
+ releaseExposure: plain(
+ () => import("./releaseExposureCommands.js"),
+ (module, context) =>
+ module.registerReleaseExposureCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ go: plain(
+ () => import("./goCommands.js"),
+ (module, context) => module.registerGoCommands(context.cli, context.logger),
+ ),
+ il2cpp: plain(
+ () => import("./il2cppCommands.js"),
+ (module, context) =>
+ module.registerIl2cppCommands(context.cli, context.logger),
+ ),
+ model: plain(
+ () => import("./modelCommands.js"),
+ (module, context) =>
+ module.registerModelCommands(context.cli, context.logger),
+ ),
+ gpu: plain(
+ () => import("./gpuCommands.js"),
+ (module, context) =>
+ module.registerGpuCommands(context.cli, context.logger),
+ ),
+ rust: plain(
+ () => import("./rustCommands.js"),
+ (module, context) =>
+ module.registerRustCommands(context.cli, context.logger),
+ ),
+ pythonPyc: plain(
+ () => import("./pythonPycCommands.js"),
+ (module, context) =>
+ module.registerPythonPycCommands(context.cli, context.logger),
+ ),
+ erlang: plain(
+ () => import("./erlangCommands.js"),
+ (module, context) =>
+ module.registerErlangCommands(context.cli, context.logger),
+ ),
+ v8CodeCache: plain(
+ () => import("./v8CodeCacheCommands.js"),
+ (module, context) =>
+ module.registerV8CodeCacheCommands(context.cli, context.logger),
+ ),
+ sqlite: plain(
+ () => import("./sqliteCommands.js"),
+ (module, context) =>
+ module.registerSqliteCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ artifact: withAnalysis(
+ () => import("./artifactCommands.js"),
+ (module, context) =>
+ module.registerArtifactCommands(
+ context.cli,
+ context.logger,
+ context.analysis.runProviderAnalysis,
+ ),
+ ),
+ managed: withAnalysis(
+ () => import("./managedCommands.js"),
+ (module, context) =>
+ module.registerManagedCommands(
+ context.cli,
+ context.logger,
+ context.analysis.runProviderAnalysis,
+ ),
+ ),
+ firmware: plain(
+ () => import("./firmwareCommands.js"),
+ (module, context) =>
+ module.registerFirmwareCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ android: plain(
+ () => import("./androidCommands.js"),
+ (module, context) =>
+ module.registerAndroidCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ pcap: plain(
+ () => import("./pcapCommands.js"),
+ (module, context) =>
+ module.registerPcapCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ flutter: plain(
+ () => import("./flutterCommands.js"),
+ (module, context) =>
+ module.registerFlutterCommands(context.cli, context.logger),
+ ),
+ frida: plain(
+ () => import("./fridaCommands.js"),
+ (module, context) =>
+ module.registerFridaCommands(
+ context.cli,
+ context.logger,
+ context.fridaInstrumentation,
+ ),
+ ),
+ apktool: plain(
+ () => import("./apktoolCommands.js"),
+ (module, context) =>
+ module.registerApktoolCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ jeb: plain(
+ () => import("./jebCommands.js"),
+ (module, context) =>
+ module.registerJebCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ adb: plain(
+ () => import("./adbCommands.js"),
+ (module, context) =>
+ module.registerAdbCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ browser: plain(
+ () => import("./browserCommands.js"),
+ (module, context) =>
+ module.registerBrowserCommands(context.cli, context.logger),
+ ),
+ webRuntime: plain(
+ () => import("./webRuntimeCommands.js"),
+ (module, context) =>
+ module.registerWebRuntimeCommands(context.cli, context.logger),
+ ),
+ webNetworkCapture: plain(
+ () => import("./webNetworkCaptureCommands.js"),
+ (module, context) =>
+ module.registerWebNetworkCaptureCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ advancedBrowser: plain(
+ () => import("./browserAdvancedCommands.js"),
+ (module, context) =>
+ module.registerAdvancedBrowserCommands(context.cli, context.logger),
+ ),
+ browserScenario: plain(
+ () => import("../cliBrowserScenarioCommands.js"),
+ (module, context) =>
+ module.registerBrowserScenarioCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ electron: plain(
+ () => import("./electronCommands.js"),
+ (module, context) =>
+ module.registerElectronCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ javaScriptRuntimeObservation: plain(
+ () => import("./javascriptRuntimeCommands.js"),
+ (module, context) =>
+ module.registerJavaScriptRuntimeObservationCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ guidance: plain(
+ () => import("./guidanceCommands.js"),
+ (module, context) =>
+ module.registerGuidanceCommands(context.cli, context.logger),
+ ),
+ guidedWorkflow: plain(
+ () => import("./guidedWorkflowCommands.js"),
+ (module, context) =>
+ module.registerGuidedWorkflowCommands(context.cli, context.logger),
+ ),
+ analysisView: plain(
+ () => import("./analysisViewCommands.js"),
+ (module, context) =>
+ module.registerAnalysisViewCommands(context.cli, context.logger),
+ ),
+ evidence: plain(
+ () => import("../cliEvidenceCommands.js"),
+ (module, context) =>
+ module.registerEvidenceCommands(context.cli, context.logger),
+ ),
+ environment: plain(
+ () => import("./environmentCommands.js"),
+ (module, context) =>
+ module.registerEnvironmentCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ process: plain(
+ () => import("./processCommands.js"),
+ (module, context) =>
+ module.registerProcessCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ desktopCapture: plain(
+ () => import("./desktopCaptureCommands.js"),
+ (module, context) =>
+ module.registerDesktopCaptureCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ javaScriptRecovery: plain(
+ () => import("./javascriptRecoveryCommands.js"),
+ (module, context) =>
+ module.registerJavaScriptRecoveryCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ webScript: plain(
+ () => import("./webScriptCommands.js"),
+ (module, context) =>
+ module.registerWebScriptCommands(context.cli, context.logger),
+ ),
+ webModule: plain(
+ () => import("./webModuleCommands.js"),
+ (module, context) =>
+ module.registerWebModuleCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ webSourceLocation: plain(
+ () => import("./webSourceLocationCommands.js"),
+ (module, context) =>
+ module.registerWebSourceLocationCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+ application: plain(
+ () => import("./applicationCommands.js"),
+ (module, context) =>
+ module.registerApplicationCommands(context.cli, context.logger),
+ ),
+ nativeFunctionMatching: plain(
+ () => import("./nativeFunctionMatchingCommands.js"),
+ (module, context) =>
+ module.registerNativeFunctionMatchingCommands(
+ context.cli,
+ context.logger,
+ context.environment,
+ ),
+ ),
+};
+
+export type CliCommandRegistrationId = keyof typeof CLI_COMMAND_REGISTRATIONS;
+
+/** Each registrar's module, as `load` resolves it. */
+export type CliCommandModules = {
+ readonly [Id in CliCommandRegistrationId]: Awaited<
+ ReturnType<(typeof CLI_COMMAND_REGISTRATIONS)[Id]["load"]>
+ >;
+};
+
+/**
+ * Registrars of setup and of each published tool family. Every family id must
+ * appear, so a new family decides its CLI commands; enhanced workflows are
+ * MCP tools whose CLI entry points live with core analysis.
+ */
+const CLI_FAMILY_REGISTRATIONS: {
+ readonly [
+ Family in "setup" | ToolFamilyId
+ ]: readonly CliCommandRegistrationId[];
+} = {
+ setup: ["setup"],
+ direct: ["coreAnalysis"],
+ enhanced: [],
+ native: ["reverseEngineering", "cutter", "utility", "binaryDiagnostics"],
+ artifact: [
+ "wasm",
+ "apkSignature",
+ "capture",
+ "signal",
+ "evm",
+ "desktopApplication",
+ "releaseExposure",
+ "go",
+ "il2cpp",
+ "model",
+ "gpu",
+ "rust",
+ "pythonPyc",
+ "erlang",
+ "v8CodeCache",
+ "sqlite",
+ "artifact",
+ ],
+ managed: ["managed"],
+ firmware: ["firmware"],
+ android: ["android"],
+ pcap: ["pcap"],
+ flutter: ["flutter"],
+ frida: ["frida"],
+ apktool: ["apktool"],
+ jeb: ["jeb"],
+ adb: ["adb"],
+ browser: [
+ "browser",
+ "webRuntime",
+ "webNetworkCapture",
+ "advancedBrowser",
+ "browserScenario",
+ ],
+ electron: ["electron"],
+ "javascript-runtime": ["javaScriptRuntimeObservation"],
+ application: [
+ "guidance",
+ "guidedWorkflow",
+ "analysisView",
+ "evidence",
+ "environment",
+ "process",
+ "desktopCapture",
+ "javaScriptRecovery",
+ "webScript",
+ "webModule",
+ "webSourceLocation",
+ "application",
+ ],
+ session: ["nativeFunctionMatching"],
+};
+
+/** Registration order: setup, then each family in catalog order. */
+export const CLI_REGISTRATION_ORDER: readonly CliCommandRegistrationId[] = [
+ "setup" as const,
+ ...TOOL_FAMILY_IDS,
+].flatMap((family) => CLI_FAMILY_REGISTRATIONS[family]);
diff --git a/src/cli/cliLoader.ts b/src/cli/cliLoader.ts
new file mode 100644
index 0000000000..42cc3d09d3
--- /dev/null
+++ b/src/cli/cliLoader.ts
@@ -0,0 +1,98 @@
+import {
+ cliCompletionShell,
+ splitIncurBuiltinArguments,
+} from "../cliBuiltinArguments.js";
+import { RECORDED_CLI_COMMAND_REGISTRATIONS } from "../recordedCliCommandRegistrations.js";
+import {
+ CLI_COMMAND_REGISTRATIONS,
+ type CliCommandRegistrationId,
+} from "./cliCommandRegistrations.js";
+import type { CliResultOutput } from "./cliResultOutput.js";
+import type { CliInstance } from "./types.js";
+
+/** Global switches that change only how a command's result is written. */
+const COMMAND_OUTPUT_FLAGS: ReadonlySet = new Set([
+ "--json",
+ "--full-output",
+ "--token-count",
+ "--timings",
+ "--no-timings",
+]);
+
+const isRegistrationId = (id: string): id is CliCommandRegistrationId =>
+ Object.hasOwn(CLI_COMMAND_REGISTRATIONS, id);
+
+/**
+ * The registrar that defines the command these arguments run, or undefined
+ * when the complete CLI must answer. Help, schemas, `--llms`, completions,
+ * Incur builtins, unknown names ("did you mean"), and a missing or unusable
+ * recording all describe or search every command.
+ */
+export const selectCliRegistration = (
+ arguments_: readonly string[],
+ environment: Readonly,
+ recorded: Readonly> | undefined,
+): CliCommandRegistrationId | undefined => {
+ if (recorded === undefined || cliCompletionShell(environment) !== undefined)
+ return undefined;
+ const { command, flags } = splitIncurBuiltinArguments(arguments_);
+ for (const flag of flags)
+ if (!COMMAND_OUTPUT_FLAGS.has(flag) && !flag.startsWith("--timings="))
+ return undefined;
+ const name = command[0];
+ if (name === undefined || !Object.hasOwn(recorded, name)) return undefined;
+ const id = recorded[name]!;
+ return isRegistrationId(id) ? id : undefined;
+};
+
+/** The command runtime one invocation loaded, before its CLI is built. */
+export interface CliRuntime {
+ createCli(resultOutput: CliResultOutput | undefined): CliInstance;
+ /** Closes the process-owned resources of this runtime's commands. */
+ closeCliResources(): Promise;
+}
+
+/**
+ * Load the command runtime for one invocation. A recorded top-level command
+ * loads only its registrar's module graph; anything else loads the complete
+ * CLI.
+ */
+export const loadCliRuntime = async (
+ arguments_: readonly string[],
+ environment: NodeJS.ProcessEnv,
+): Promise => {
+ const id = selectCliRegistration(
+ arguments_,
+ environment,
+ RECORDED_CLI_COMMAND_REGISTRATIONS,
+ );
+ if (id === undefined) {
+ const { createCli, closeCliResources } = await import("../cli.js");
+ return {
+ createCli: (resultOutput) => createCli(environment, resultOutput),
+ closeCliResources,
+ };
+ }
+ const registration = CLI_COMMAND_REGISTRATIONS[id];
+ const [module, { createCliShell }, { closeCliResources }, analysis] =
+ await Promise.all([
+ registration.load(),
+ import("./cliShell.js"),
+ import("./cliResources.js"),
+ registration.analysis
+ ? import("../composition/directAnalysis.js")
+ : undefined,
+ ]);
+ return {
+ createCli: (resultOutput) =>
+ createCliShell({
+ selectedEnvironment: environment,
+ resultOutput,
+ registrations: [{ registration, module }],
+ ...(analysis === undefined
+ ? {}
+ : { createAnalysis: analysis.createDirectAnalysis }),
+ }),
+ closeCliResources,
+ };
+};
diff --git a/src/cli/cliResources.ts b/src/cli/cliResources.ts
new file mode 100644
index 0000000000..10b1a5f6f0
--- /dev/null
+++ b/src/cli/cliResources.ts
@@ -0,0 +1,45 @@
+/** Closers of process-owned CLI resources, by owner name. */
+export class CliResourceOwners {
+ readonly #closers = new Map Promise>();
+
+ /** Register an owner's closer; a repeated name keeps its first closer. */
+ own(name: string, close: () => Promise): void {
+ if (!this.#closers.has(name)) this.#closers.set(name, close);
+ }
+
+ names(): readonly string[] {
+ return [...this.#closers.keys()];
+ }
+
+ /** Settle every owner, then report each failure. */
+ async close(): Promise {
+ const results = await Promise.allSettled(
+ [...this.#closers.values()].map((close) => close()),
+ );
+ const failures = results.flatMap((result) =>
+ result.status === "rejected" ? [result.reason] : [],
+ );
+ if (failures.length === 1) throw failures[0];
+ if (failures.length > 1)
+ throw new AggregateError(failures, "CLI resource cleanup failed");
+ }
+}
+
+/**
+ * Process-owned CLI resources. Each owning module registers its closer when it
+ * loads, so a CLI that loads only some command families closes exactly the
+ * owners it loaded without importing the others.
+ */
+const processOwners = new CliResourceOwners();
+
+export const ownCliProcessResource = (
+ name: string,
+ close: () => Promise,
+): void => processOwners.own(name, close);
+
+/** Owners registered in this process, in registration order. */
+export const cliProcessResourceOwners = (): readonly string[] =>
+ processOwners.names();
+
+/** Close every process-owned resource used by CLI commands. */
+export const closeCliResources = (): Promise => processOwners.close();
diff --git a/src/cli/cliShell.ts b/src/cli/cliShell.ts
new file mode 100644
index 0000000000..60f3351c80
--- /dev/null
+++ b/src/cli/cliShell.ts
@@ -0,0 +1,184 @@
+import { Cli, z } from "incur";
+
+import type { FridaInstrumentationPort } from "../application/frida/FridaInstrumentationPort.js";
+import { currentClientRegistrationCommand } from "../application/ClientRegistrationIdentity.js";
+import {
+ adaptCliOutputArgumentsForIncur,
+ normalizeCliArguments,
+} from "../cliBuiltinArguments.js";
+import type { DirectAnalysis } from "../composition/directAnalysis.js";
+import { parseLogLevel } from "../config/processStart.js";
+import { parseClientRegistrationCommand } from "../domain/clientRegistrationCommand.js";
+import { PRODUCT_IDENTITY } from "../identity.js";
+import { createLogger } from "../logger.js";
+import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
+import {
+ encodeCliArgumentErrors,
+ presentCliArgumentErrors,
+} from "./argumentErrors.js";
+import type {
+ CliCommandRegistration,
+ CliRegistrationContext,
+} from "./cliCommandRegistration.js";
+import {
+ streamCliCommandResults,
+ type CliResultOutput,
+} from "./cliResultOutput.js";
+import { completionHelpNote, createCliCompletions } from "./completions.js";
+import { excludeDeclaredSensitiveArguments } from "./explicitSensitiveArguments.js";
+import { reportUnknownMcpSubcommand } from "./mcpBuiltinCommands.js";
+import { serveMcpRegistration } from "./mcpRegistration.js";
+import { presentOmittableDefaults } from "./omittableDefaults.js";
+import { validateCliArguments } from "./strictPositionals.js";
+import type { CliInstance } from "./types.js";
+
+/** A registrar and the module it was loaded from. */
+export interface LoadedCliCommandRegistration {
+ readonly registration: CliCommandRegistration;
+ readonly module: unknown;
+}
+
+export interface CliShellOptions {
+ readonly selectedEnvironment: Readonly>;
+ /** Registrars to run, in registration order. */
+ readonly registrations: readonly LoadedCliCommandRegistration[];
+ /** Required when any registrar declares analysis. */
+ readonly createAnalysis?: (
+ environment: Readonly>,
+ ) => DirectAnalysis;
+ readonly resultOutput?: CliResultOutput | undefined;
+ readonly fridaInstrumentation?: FridaInstrumentationPort | undefined;
+}
+
+/** Run each registrar, creating analysis once for those that declare it. */
+const registerCommands = (
+ context: CliRegistrationContext,
+ options: CliShellOptions,
+): void => {
+ let analysis: DirectAnalysis | undefined;
+ for (const { registration, module } of options.registrations) {
+ if (!registration.analysis) {
+ registration.register(module, context);
+ continue;
+ }
+ if (options.createAnalysis === undefined)
+ throw new Error("A CLI registrar needs analysis that was not provided");
+ analysis ??= options.createAnalysis(context.environment);
+ registration.register(module, { ...context, analysis });
+ }
+};
+
+/**
+ * Build the Incur CLI around the given registrars. Identity, global options,
+ * argument policy, completions, and registration builtins are the same for
+ * every set of registrars; they apply to whichever commands were registered.
+ */
+export const createCliShell = (options: CliShellOptions): CliInstance => {
+ const environment = snapshotEnvironment(options.selectedEnvironment);
+ const defaultRegistration = parseClientRegistrationCommand(
+ currentClientRegistrationCommand({ environment }),
+ );
+ // The executable reports an invalid level before every command except doctor.
+ // Doctor keeps going so its readiness report can name REA_LOG_LEVEL.
+ const level =
+ environment.REA_LOG_LEVEL === undefined
+ ? undefined
+ : parseLogLevel(environment.REA_LOG_LEVEL);
+ const logger = createLogger(
+ "cli",
+ level?.ok === true ? level.value : "silent",
+ );
+ const timedCli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
+ version: PRODUCT_IDENTITY.packageVersion,
+ description: "Reverse engineer anything from your terminal or agent.",
+ globals: z.object({
+ timings: z
+ .boolean()
+ .default(false)
+ .describe("Write measured command timing to stderr."),
+ }),
+ mcp: {
+ instructions:
+ "Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.",
+ },
+ sync: false,
+ update: {
+ // Incur's update:false only suppresses notices. Reject its explicit
+ // installer so ambiguous flags cannot bypass REA's verified workflow.
+ install: () => {
+ throw new Error(
+ "Use 'rea update' to update the identified installation. The '--update' alias accepts only global output flags.",
+ );
+ },
+ },
+ });
+
+ timedCli.use(async (context, next) => {
+ if (!context.globals.timings) return next();
+ const started = performance.now();
+ try {
+ await next();
+ } finally {
+ // This interval covers CLI command dispatch, including result delivery.
+ // It is not a provider-only or analysis-only measurement.
+ process.stderr.write(
+ `${JSON.stringify({ rea_timings: { version: 1, command_ms: Math.round(performance.now() - started) } })}\n`,
+ );
+ }
+ });
+ // Command registrars do not inspect global options; preserve their existing
+ // type while Incur owns parsing and the middleware owns this added flag.
+ const cli = timedCli as unknown as CliInstance;
+
+ registerCommands(
+ {
+ cli,
+ logger,
+ environment,
+ fridaInstrumentation: options.fridaInstrumentation,
+ },
+ options,
+ );
+
+ presentOmittableDefaults(cli);
+ if (options.resultOutput !== undefined)
+ streamCliCommandResults(cli, options.resultOutput);
+ const serveStrict = validateCliArguments(cli);
+ presentCliArgumentErrors(cli);
+ const serveDeclared = excludeDeclaredSensitiveArguments(cli);
+ encodeCliArgumentErrors(cli);
+ const serve = cli.serve.bind(cli);
+ const complete = createCliCompletions(
+ cli as unknown as Parameters[0],
+ environment,
+ );
+ cli.serve = (arguments_ = process.argv.slice(2), serveOptions) => {
+ if (complete(arguments_, serveOptions ?? {})) return Promise.resolve();
+ const normalized = normalizeCliArguments(arguments_);
+ const registration = serveMcpRegistration(
+ normalized,
+ environment,
+ defaultRegistration,
+ serveOptions ?? {},
+ );
+ if (registration !== undefined) return registration;
+ const result = serveStrict(
+ normalized,
+ serveOptions ?? {},
+ (tokens, selected) =>
+ serveDeclared(tokens, selected, (declared, declaredOptions) =>
+ reportUnknownMcpSubcommand(cli.name, declared, declaredOptions)
+ ? Promise.resolve()
+ : serve(adaptCliOutputArgumentsForIncur(declared), declaredOptions),
+ ),
+ );
+ const note = completionHelpNote(normalized);
+ if (note === undefined) return result;
+ return result.then(() => {
+ const write =
+ serveOptions?.stdout ?? ((text: string) => process.stdout.write(text));
+ write(`\n${note}`);
+ });
+ };
+ return cli;
+};
diff --git a/src/cli/guidanceCommands.ts b/src/cli/guidanceCommands.ts
index 6758d93bf7..7f0c0c5f12 100644
--- a/src/cli/guidanceCommands.ts
+++ b/src/cli/guidanceCommands.ts
@@ -15,6 +15,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliDecimalField } from "./options.js";
+import { ownCliProcessResource } from "./cliResources.js";
/** Guidance descriptors owned by one CLI host. */
export class CliGuidanceResources {
@@ -42,6 +43,8 @@ export const closeCliGuidanceResources = (
resources: CliGuidanceResources = processGuidanceResources,
): Promise => resources.close();
+ownCliProcessResource("guidance", () => closeCliGuidanceResources());
+
/** Register offline search and retrieval for the installed REA guide bundle. */
export const registerGuidanceCommands = (
cli: CliInstance,
diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts
index 5f444855c6..bc2522a757 100644
--- a/src/cli/javascriptApplicationAnalysis.ts
+++ b/src/cli/javascriptApplicationAnalysis.ts
@@ -12,6 +12,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { javaScriptSourcePathsSchema } from "../domain/javascript/javascriptSourceSelection.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
+import { ownCliProcessResource } from "./cliResources.js";
const OPERATION = "analyze_javascript_application";
@@ -94,6 +95,10 @@ export const closeCliJavaScriptResources = (
host: CliJavaScriptAnalysisHost = processJavaScriptAnalysis,
): Promise => host.close();
+ownCliProcessResource("javascript-analysis", () =>
+ closeCliJavaScriptResources(),
+);
+
/** Execute the shared CLI boundary for static JavaScript analysis. */
export const runCliJavaScriptApplicationAnalysis = async (
input: unknown,
diff --git a/src/cli/mcpRegistration.ts b/src/cli/mcpRegistration.ts
index f8204508f8..00cf8fe807 100644
--- a/src/cli/mcpRegistration.ts
+++ b/src/cli/mcpRegistration.ts
@@ -41,6 +41,7 @@ import { PRODUCT_IDENTITY } from "../identity.js";
import { createCliResultOutput } from "./cliResultOutput.js";
import { withCommandCancellation } from "./commandCancellation.js";
import type { CliInstance } from "./types.js";
+import { ownCliProcessResource } from "./cliResources.js";
let retainedResources: ArtifactResourceScope | undefined;
@@ -52,6 +53,8 @@ export const closeCliMcpRegistrationResources = async (): Promise => {
if (retainedResources === resources) retainedResources = undefined;
};
+ownCliProcessResource("mcp-registration", closeCliMcpRegistrationResources);
+
type ServeOptions = NonNullable[1]>;
interface RegistrationRequest {
readonly agents: readonly string[];
diff --git a/src/cliOneShot.ts b/src/cliOneShot.ts
index 49c6148f9d..41580c7fc9 100644
--- a/src/cliOneShot.ts
+++ b/src/cliOneShot.ts
@@ -122,8 +122,9 @@ export const runOneShotCli = async (
}
// Loaded only now: the documents above answer without the command catalog.
- const { closeCliResources, createCli } = await import("./cli.js");
- host.ownCliResources(closeCliResources);
+ const { loadCliRuntime } = await import("./cli/cliLoader.js");
+ const runtime = await loadCliRuntime(cliArguments, host.environment);
+ host.ownCliResources(runtime.closeCliResources);
const { createCliResultOutput } = await import("./cli/cliResultOutput.js");
let wroteOutput = false;
const resultOutput = createCliResultOutput(
@@ -131,7 +132,7 @@ export const runOneShotCli = async (
host.resultDestination,
);
try {
- await createCli(host.environment, resultOutput).serve(cliArguments, {
+ await runtime.createCli(resultOutput).serve(cliArguments, {
stdout: (output) => {
if (resultOutput?.handled) {
if (resultOutput.failed && !host.stdoutClosed())
diff --git a/src/recordedCliCommandRegistrations.ts b/src/recordedCliCommandRegistrations.ts
new file mode 100644
index 0000000000..09def76262
--- /dev/null
+++ b/src/recordedCliCommandRegistrations.ts
@@ -0,0 +1,8 @@
+/**
+ * The registrar of each top-level CLI command name, recorded from the compiled
+ * registration table by `scripts/record-cli-commands.mjs` after compilation.
+ * Source runs leave this undefined, so every command loads the complete CLI.
+ */
+export const RECORDED_CLI_COMMAND_REGISTRATIONS:
+ | Readonly>
+ | undefined = undefined;
diff --git a/tests/boundary/cli/cliLazyRegistration.test.ts b/tests/boundary/cli/cliLazyRegistration.test.ts
new file mode 100644
index 0000000000..9d46a7f439
--- /dev/null
+++ b/tests/boundary/cli/cliLazyRegistration.test.ts
@@ -0,0 +1,157 @@
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { Cli } from "incur";
+import { describe, expect, it } from "vitest";
+
+import { createCli } from "../../../src/cli.js";
+import {
+ CLI_COMMAND_REGISTRATIONS,
+ CLI_REGISTRATION_ORDER,
+ type CliCommandRegistrationId,
+} from "../../../src/cli/cliCommandRegistrations.js";
+import { selectCliRegistration } from "../../../src/cli/cliLoader.js";
+import { createCliShell } from "../../../src/cli/cliShell.js";
+import type { CliInstance } from "../../../src/cli/types.js";
+import { visitRegisteredCommands } from "../../../src/cli/visitRegisteredCommands.js";
+import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { processTest } from "../../support/process/processFixture.js";
+import { serveInProcessCli } from "../../support/cli/serveInProcessCli.js";
+
+const ENVIRONMENT = { PATH: process.env.PATH };
+
+// Incur types its router key without vars, env, or globals; accept any CLI.
+const registeredCommands: WeakMap<
+ object,
+ Map
+> = Cli.toCommands;
+
+const topLevelNames = (cli: CliInstance): readonly string[] => [
+ ...(registeredCommands.get(cli)?.keys() ?? []),
+];
+
+const commandPaths = (cli: CliInstance): readonly string[] => {
+ const paths: string[] = [];
+ visitRegisteredCommands(cli, (_command, path) => paths.push(path));
+ return paths;
+};
+
+const createRegistrarCli = async (
+ id: CliCommandRegistrationId,
+): Promise => {
+ const registration = CLI_COMMAND_REGISTRATIONS[id];
+ return createCliShell({
+ selectedEnvironment: ENVIRONMENT,
+ registrations: [{ registration, module: await registration.load() }],
+ createAnalysis: createDirectAnalysis,
+ });
+};
+
+const help = async (cli: CliInstance, path: string) =>
+ serveInProcessCli(cli, [...path.split(" "), "--help"]);
+
+const recordedRegistrations = async (): Promise> => {
+ const module = (await import(
+ pathToFileURL(resolve("dist/recordedCliCommandRegistrations.js")).href
+ )) as { RECORDED_CLI_COMMAND_REGISTRATIONS: Record };
+ return module.RECORDED_CLI_COMMAND_REGISTRATIONS;
+};
+
+describe("lazy CLI registration", () => {
+ it("records every top-level command under the one registrar that defines it", async () => {
+ const recorded = await recordedRegistrations();
+ const complete = createCli(ENVIRONMENT);
+ expect(Object.keys(recorded).sort()).toEqual(
+ [...topLevelNames(complete)].sort(),
+ );
+ for (const id of CLI_REGISTRATION_ORDER) {
+ const alone = await createRegistrarCli(id);
+ expect(
+ [...topLevelNames(alone)].sort(),
+ `${id} registers what the recording assigns it`,
+ ).toEqual(
+ Object.entries(recorded)
+ .filter(([, owner]) => owner === id)
+ .map(([name]) => name)
+ .sort(),
+ );
+ }
+ });
+
+ it("gives each command the same definition alone as in the complete CLI", async () => {
+ const complete = createCli(ENVIRONMENT);
+ const completePaths = new Set(commandPaths(complete));
+ const covered = new Set();
+ for (const id of CLI_REGISTRATION_ORDER) {
+ const alone = await createRegistrarCli(id);
+ for (const path of commandPaths(alone)) {
+ expect(completePaths.has(path), path).toBe(true);
+ covered.add(path);
+ expect(await help(alone, path), path).toEqual(
+ await help(complete, path),
+ );
+ }
+ }
+ expect(covered).toEqual(completePaths);
+ });
+
+ it("loads one registrar only for a recorded command that runs", () => {
+ const recorded = { "inspect-go-binary": "go", setup: "setup" };
+ const select = (
+ arguments_: readonly string[],
+ environment: NodeJS.ProcessEnv = {},
+ record: Readonly> | null = recorded,
+ ) => selectCliRegistration(arguments_, environment, record ?? undefined);
+
+ expect(select(["inspect-go-binary", "a.out"])).toBe("go");
+ expect(select(["inspect-go-binary", "a.out", "--json"])).toBe("go");
+ expect(select(["--format", "json", "inspect-go-binary", "a.out"])).toBe(
+ "go",
+ );
+ expect(select(["--timings", "setup", "--dry-run"])).toBe("setup");
+ // Help, schemas, LLM manifests and builtins describe the whole catalog.
+ for (const flag of ["--help", "-h", "--schema", "--llms", "--llms-full"])
+ expect(select(["inspect-go-binary", flag]), flag).toBeUndefined();
+ expect(select(["inspect-go-binary", "--mcp"])).toBeUndefined();
+ expect(select([])).toBeUndefined();
+ expect(select(["skills", "add"])).toBeUndefined();
+ // An unknown name needs every command for "did you mean".
+ expect(select(["inspect-go-binar"])).toBeUndefined();
+ expect(select(["constructor"])).toBeUndefined();
+ // Shell completion and a missing or stale recording use the complete CLI.
+ expect(select(["inspect-go-binary"], { COMPLETE: "bash" })).toBeUndefined();
+ expect(select(["inspect-go-binary"], {}, null)).toBeUndefined();
+ expect(
+ select(["inspect-go-binary"], {}, { "inspect-go-binary": "removed" }),
+ ).toBeUndefined();
+ });
+
+ processTest(
+ "runs a recorded command without loading the complete CLI",
+ async ({ processes }) => {
+ const marker = "REA_TEST_CLI_ENTRY_RESOLVED";
+ // Report a resolution of the complete CLI entry without changing it.
+ const hook = `data:text/javascript,${encodeURIComponent(`
+import { registerHooks } from "node:module";
+registerHooks({ resolve(specifier, context, next) {
+ const resolved = next(specifier, context);
+ if (resolved.url.endsWith("/dist/cli-runtime/cli.js")) process.stderr.write(${JSON.stringify(`${marker}\n`)});
+ return resolved;
+} });`)}`;
+ const home = await createTestTempDirectory("rea-cli-lazy-home-");
+ const run = (arguments_: readonly string[]) =>
+ processes.run(
+ process.execPath,
+ [`--import=${hook}`, join("scripts", "rea.mjs"), ...arguments_],
+ { env: { ...ENVIRONMENT, HOME: home } },
+ );
+ const lazy = await run(["list-cutter-sessions", "--json"]);
+ expect(lazy.exitCode, lazy.stderr).toBe(0);
+ expect(JSON.parse(lazy.stdout)).toMatchObject({ sessions: [] });
+ expect(lazy.stderr).not.toContain(marker);
+ const complete = await run(["list-cutter-sessions", "--help"]);
+ expect(complete.exitCode, complete.stderr).toBe(0);
+ expect(complete.stderr).toContain(marker);
+ },
+ );
+});
diff --git a/tests/boundary/cli/cliResourceClose.test.ts b/tests/boundary/cli/cliResourceClose.test.ts
index 4a1a9005c7..1c35d824e5 100644
--- a/tests/boundary/cli/cliResourceClose.test.ts
+++ b/tests/boundary/cli/cliResourceClose.test.ts
@@ -1,9 +1,9 @@
-import { afterEach, expect, it, vi } from "vitest";
-import { closeCliResources } from "../../../src/cli.js";
-import * as javascript from "../../../src/cli/javascriptApplicationAnalysis.js";
-import * as guidance from "../../../src/cli/guidanceCommands.js";
-
-afterEach(() => vi.restoreAllMocks());
+import { expect, it, vi } from "vitest";
+import "../../../src/cli.js";
+import {
+ CliResourceOwners,
+ cliProcessResourceOwners,
+} from "../../../src/cli/cliResources.js";
it.each([
[false, false],
@@ -15,17 +15,16 @@ it.each([
async (jsFails, guideFails) => {
const jsError = new Error("JavaScript resources retained");
const guideError = new Error("Guidance descriptor retained");
- const js = vi
- .spyOn(javascript, "closeCliJavaScriptResources")
- .mockImplementation(async () => {
- if (jsFails) throw jsError;
- });
- const guides = vi
- .spyOn(guidance, "closeCliGuidanceResources")
- .mockImplementation(async () => {
- if (guideFails) throw guideError;
- });
- const closing = closeCliResources();
+ const js = vi.fn(async () => {
+ if (jsFails) throw jsError;
+ });
+ const guides = vi.fn(async () => {
+ if (guideFails) throw guideError;
+ });
+ const owners = new CliResourceOwners();
+ owners.own("javascript-analysis", js);
+ owners.own("guidance", guides);
+ const closing = owners.close();
if (jsFails && guideFails)
await expect(closing).rejects.toMatchObject({
errors: [jsError, guideError],
@@ -37,3 +36,13 @@ it.each([
expect(guides).toHaveBeenCalledOnce();
},
);
+
+it("registers every process-owned CLI resource the complete CLI loads", () => {
+ expect(cliProcessResourceOwners()).toEqual(
+ expect.arrayContaining([
+ "javascript-analysis",
+ "guidance",
+ "mcp-registration",
+ ]),
+ );
+});
diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts
index 928de7b9f0..ca8e36a1d4 100644
--- a/tests/boundary/cli/dispatcher.test.ts
+++ b/tests/boundary/cli/dispatcher.test.ts
@@ -17,7 +17,7 @@ const installCliRuntime = async (
await workspace.mkdir("dist");
// The bundle's entrypoints and shared chunks form one emitted runtime. The
// one-shot owner and its output admission stay production code; only the
- // command runtime and result output are replaced below.
+ // command runtime, its recorded registrars, and result output are replaced.
await cp("dist/cli-runtime", workspace.path("dist/cli-runtime"), {
recursive: true,
});
@@ -43,6 +43,11 @@ const installCliRuntime = async (
),
workspace.write("dist/cli-runtime/cli.js", cliSource),
workspace.write("dist/cli-runtime/cliResultOutput.js", resultOutput),
+ // Without a recording, every command loads the replaced complete CLI.
+ workspace.write(
+ "dist/recordedCliCommandRegistrations.js",
+ "export const RECORDED_CLI_COMMAND_REGISTRATIONS = undefined;\n",
+ ),
]);
};
diff --git a/turbo.json b/turbo.json
index 653a253846..216f441745 100644
--- a/turbo.json
+++ b/turbo.json
@@ -22,6 +22,7 @@
"scripts/bundle-runtime.mjs",
"scripts/record-catalog-digests.mjs",
"scripts/record-mcp-catalog.mjs",
+ "scripts/record-cli-commands.mjs",
"scripts/generate-package-metadata.mjs",
"src/**/*.{cts,mts,ts,tsx}",
"src/**/package.json",