Skip to content

CLI command dispatch

源码版本v2026.6.11

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:

  1. Fast path: pure help requests like --help / --version / browser --help / secrets --help / nodes --help return pre-compiled text without loading Commander.
  2. Route-first: tryRouteCli routes argv directly to command implementations via a pre-parsed routedCommands table, bypassing full Commander registration.
  3. Full Commander: buildProgram() builds the root Commander program, lazily registers primary commands and plugin commands on demand, and finally program.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, gateway is primary). When shouldRegisterPrimaryCommandOnly(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 run is the most frequent command and has a dedicated tryRunGatewayRunFastPath that bypasses buildProgram and 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

Data flow

The top of runCli does argv normalization and environment loading (run-main.ts:L610):

typescript
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):

typescript
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):

typescript
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 throws Unknown command instead of silently showing top-level help. This fixes #81077 — otherwise openclaw gatway --help would 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 calls resolveMissingPluginCommandMessageFromPolicy to suggest "did you mean to install plugin X?" rather than empty help.
  • proxy startup order: tryRunGatewayRunFastPath runs 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.parseAsync throws a commander.* error (run-main.ts:L324), isCommanderParseExit recognizes it and only sets process.exitCode without re-throwing — Commander's exitOverride design treats help / version as exits.
  • resource cleanup: the finally block of runCli (run-main.ts:L1089) calls closeCliMemoryManagers, 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 run still 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.