CLI command dispatch
Responsibilities
runCli(argv) is where OpenClaw commands actually start dispatching. After receiving argv from the entry, it parses profile / container options, loads .env, decides whether to start a proxy, falls back through help fast paths, attempts the route-first fast path, and only then lands in the full Commander program — dynamically registering the primary command, plugin commands, and calling program.parseAsync(argv) to let Commander dispatch.
Dispatch in OpenClaw is not one action but a three-layer funnel:
- Fast path: pure help requests like
--help/--version/browser --help/secrets --help/nodes --helpreturn pre-compiled text without loading Commander. - Route-first:
tryRouteCliroutes argv directly to command implementations via a pre-parsedroutedCommandstable, bypassing full Commander registration. - Full Commander:
buildProgram()builds the root Commander program, lazily registers primary commands and plugin commands on demand, and finallyprogram.parseAsync.
Each layer returns on hit and falls through on miss. This way the hot path like openclaw gateway run (tryRunGatewayRunFastPath:L143) skips most registration overhead, while cold paths like openclaw plugins install foo go through full Commander.
Design motivation
Why a three-layer funnel instead of just program.parseAsync(argv)? Because Commander requires buildProgram before parseAsync, and buildProgram calls registerProgramCommands to register every core command and subcli. OpenClaw has 40+ subclis (gateway / agent / cron / models / devices / plugins / channels / security / secrets / skills / ...), each with its own subcommand tree. Eager registration of everything adds several hundred milliseconds to cold start.
Several design choices make this work:
- primary-only registration: the first non-option token in argv is the primary command (in
openclaw gateway run,gatewayis primary). WhenshouldRegisterPrimaryCommandOnly(argv)is true, only that one is registered; every other subcli is skipped. - lazy import: each subcli's registrar is
() => import("../xxx-cli.js")— dynamically loaded only when actually hit. - gateway hot path:
openclaw gateway runis the most frequent command and has a dedicatedtryRunGatewayRunFastPaththat bypassesbuildProgramand constructs a minimal Commander program. - route-first: some commands don't even need Commander's parsing — they dispatch directly via the pre-parsed argv table.
The cost is more code paths and higher understanding complexity; the payoff is that openclaw gateway run cold start drops from seconds to a few hundred milliseconds.
Key files
runCli top:L610-L667— argv normalization, container / profile parsing, .env loading.runMain help fast path:L760-L820— root / browser / secrets / nodes / subcommand help fast paths.gateway hot path + tryRouteCli:L905-L928—tryRunGatewayRunFastPathreturns on hit, otherwisetryRouteCli.tryRunGatewayRunFastPath:L143-L238— builds a minimal Commander with only thegateway runcommand attached.primary-only + plugin command registration:L1005-L1070—registerCoreCliByName+registerSubCliByName+ lazy plugin command registration.coreEntrySpecs:L50-L143— core command registry (crestodian / setup / onboard / configure / config / backup / migrate / maintenance / message / mcp / transcripts / agent / agents / status-health-sessions).entrySpecs subcli registry:L92-L278— subcli registry (acp / gateway / daemon / logs / system / models / devices / node / sandbox / tui / cron / dns / docs / qa / proxy / hooks / webhooks / qr / clawbot / pairing / plugins / channels / directory / security / secrets / skills / update).gateway run fallback:L45-L66—shouldRegisterGatewayRunOnly+registerGatewayRunOnlyis the third layer of the hot path.tryRouteCli:L82-L115— route-first fast path.createParsedRoute:L26-L59— compiles a catalog entry into aRouteSpec.
Data flow
The top of runCli does argv normalization and environment loading (run-main.ts:L610):
export async function runCli(argv: string[] = process.argv) {
const originalArgv = normalizeWindowsArgv(argv);
const startupTrace = createGatewayStartupTrace(originalArgv, "cli.main");
const parsedContainer = parseCliContainerArgs(originalArgv);
if (!parsedContainer.ok) {
throw new Error(parsedContainer.error);
}
// ...
const containerTarget = maybeRunCliInContainer(originalArgv);
if (containerTarget.handled) {
if (containerTarget.exitCode !== 0) {
process.exitCode = containerTarget.exitCode;
}
return;
}Note maybeRunCliInContainer — if argv specifies --container <name>, this replaces the current process with an in-container execution and returns; the real dispatch logic never runs.
The gateway hot path is the funnel's key node (run-main.ts:L905):
if (!bootstrapProxyBeforeFastPath &&
(await tryRunGatewayRunFastPath(normalizedArgv, startupTrace))) {
return;
}
// Re-run the hot path after bootstrapping the proxy
if (bootstrapProxyBeforeFastPath &&
(await tryRunGatewayRunFastPath(normalizedArgv, startupTrace))) {
return;
}
const { tryRouteCli } = await startupTrace.measure("route-import", () => import("./route.js"));
if (await startupTrace.measure("route", () => tryRouteCli(normalizedArgv))) {
return;
}tryRunGatewayRunFastPath runs once before the proxy starts, because gateway run manages its own proxy (via installGatewayRunRuntimeHooks). If the hot path misses and a proxy is needed, it bootstraps the proxy and re-runs the hot path. Only then does it fall through to tryRouteCli. tryRunGatewayRunFastPath internally uses Promise.all to dynamic import 8 modules in parallel (run-main.ts:L159) and then builds a minimal Commander program with only the gateway run command. The full gateway run startup sequence is in Gateway core.
The route-first fast path (route.ts:L82) — when OPENCLAW_DISABLE_ROUTE_FIRST is unset and argv has no help / version flags — uses resolveCliArgvInvocation to parse the commandPath and calls findRoutedCommand (routes.ts:L8) to linearly scan routedCommands for the first route whose matches(path) and canRun(argv) pass. On hit, prepareRoutedCommand loads plugins per the route.loadPlugins policy (text-only means preload plugins only for non---json calls), then route.run(argv) runs, bypassing full Commander.
The full Commander path does primary-only registration (run-main.ts:L1009):
const { primary } = invocation;
if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) {
await startupTrace.measure("register-primary", async () => {
const ctx = getProgramContext(program);
if (ctx) {
const { registerCoreCliByName } = await import("./program/command-registry.js");
await registerCoreCliByName(program, ctx, primary, parseArgv);
}
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary, parseArgv);
});
}The subcli registry is declarative (register.subclis-core.ts:L92): defineImportedProgramCommandGroupSpecs handles the simple case where modules export fixed function names (purely declarative — commandNames + loadModule: () => import(...) + exportName). Cases needing extra parameters (like plugins attaching plugin commands before / after) use inline objects wrapped with registerSubCliWithPluginCommands, controlled by pluginCliPosition: "before" | "after".
registerSubCliByName has a gateway special case (register.subclis-core.ts:L45) — even when falling back to full Commander, openclaw gateway run still uses a minimal "only the run subcommand" registration; removeCommandByName detaches any previously-attached gateway command and re-attaches. This is the third layer of the hot path.
Boundaries and failures
- unowned primary interception:
openclaw <typo>runs the unowned-primary check after the help fast path but before routing (run-main.ts:L826) and throwsUnknown commandinstead of silently showing top-level help. This fixes #81077 — otherwiseopenclaw gatway --helpwould pretend to succeed. - plugin command missing diagnostics: after primary-only registration, if the primary is neither a built-in command nor in the subcli table (
run-main.ts:L1043), it callsresolveMissingPluginCommandMessageFromPolicyto suggest "did you mean to install plugin X?" rather than empty help. - proxy startup order:
tryRunGatewayRunFastPathruns once before the proxy starts (run-main.ts:L905) because gateway run manages its own proxy. Other commands bootstrap the proxy first — wrong order would let an external proxy hijack the gateway process. - Commander parse exit: when
program.parseAsyncthrows acommander.*error (run-main.ts:L324),isCommanderParseExitrecognizes it and only setsprocess.exitCodewithout re-throwing — Commander'sexitOverridedesign treats help / version as exits. - resource cleanup: the
finallyblock ofrunCli(run-main.ts:L1089) callscloseCliMemoryManagers,disposeCliAgentHarnesses,pauseNonTtyStdinForCliExit— short-lived CLI processes must clean up memory runtimes and agent harnesses before exit, otherwise child processes leak. - gateway run triple fallback:
shouldRegisterGatewayRunOnly(register.subclis-core.ts:L45) is the third layer of the hot path — even if earlier fast paths miss and it reaches full Commander,gateway runstill attaches only the run subcommand and doesn't load the full gateway command tree.
Summary
CLI dispatch is a three-layer funnel plus a fallback: help fast path reads pre-compiled text, route-first uses a pre-parsed route table, and full Commander does primary-only registration. Because gateway run is the hot path, it gets dedicated optimizations in all three places (tryRunGatewayRunFastPath, shouldRegisterGatewayRunOnly, registerGatewayRunOnly). After a command actually starts, see Agent main loop for how the agent loop runs, Gateway core for the gateway startup sequence, and Entry and startup for how the entry layer hands argv to runCli.
Official references: OpenClaw docs · README.