Skip to content

CLI 命令分发

源码版本v2026.6.11

职责

runCli(argv) 是 OpenClaw 命令真正开始分发的地方。从 入口 拿到 argv 后,它要做的事:解析 profile/container 选项、加载 .env、决定要不要启动代理、走 help fast path 兜底、尝试 route-first 快速路径、然后才落到完整 Commander 程序——动态注册主命令 (primary command)、注册插件命令、调 program.parseAsync(argv) 让 Commander 派活。

分发 (dispatch) 在 OpenClaw 里不是一个单一动作,而是三层漏斗:

  1. 快速路径 (fast path):--help/--version/browser --help/secrets --help/nodes --help 等纯 help 请求,直接读预编译文本返回,不加载 Commander。
  2. route-first:tryRouteCli 通过预解析的 routedCommands 表把 argv 直接路由到命令实现,绕开完整 Commander 注册。
  3. Commander 全量:buildProgram() 构建 Commander 根程序,按需 lazy 注册主命令和插件命令,最后 program.parseAsync

每一层命中就 return,不命中下沉。这样 openclaw gateway run 这种热路径(tryRunGatewayRunFastPath:L143)能跳过大部分注册开销,而 openclaw plugins install foo 这种冷路径才走完整 Commander。

设计动机

为什么搞三层漏斗而不是直接 program.parseAsync(argv)?因为 Commander 的 parseAsync 之前要先 buildProgram,而 buildProgram 会调 registerProgramCommands 把所有核心命令和 subcli 都注册一遍。OpenClaw 有 40+ 个 subcli(gateway/agent/cron/models/devices/plugins/channels/security/secrets/skills/...),每个 subcli 又有自己的子命令树,全部 eager 注册的话冷启动要好几百毫秒。

设计上有几个关键选择:

  • primary-only 注册:argv 里第一个非选项 token 是 primary command(openclaw gateway rungateway 就是 primary)。shouldRegisterPrimaryCommandOnly(argv) 为真时只注册这一个,其他 subcli 全跳过。
  • lazy import:每个 subcli 的注册器都是 () => import("../xxx-cli.js") 动态加载,只有真正命中时才执行。
  • gateway hot path:openclaw gateway run 使用频率最高,有专门的 tryRunGatewayRunFastPath,绕过 buildProgram 直接构造极简 Commander 程序。
  • route-first:有些命令甚至不需要 Commander 的解析能力,直接用预解析的 argv 表 dispatch。

代价是代码路径多、理解成本高;收益是 openclaw gateway run 的冷启动从秒级压到几百毫秒。

关键文件

数据流

runCli 顶部做 argv 归一化和环境加载(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;
  }

注意 maybeRunCliInContainer——如果 argv 指定 --container <name>,这里会把这个进程替换成容器内执行并 return,真正的分发逻辑不会往下走。

gateway hot path 是分发漏斗的关键节点(run-main.ts:L905):

typescript
if (!bootstrapProxyBeforeFastPath &&
    (await tryRunGatewayRunFastPath(normalizedArgv, startupTrace))) {
  return;
}
// bootstrap proxy 后再跑一次 hot path
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 在 proxy 启动先跑一次,因为 gateway run 自己会管理 proxy(通过 installGatewayRunRuntimeHooks)。如果 hot path 没命中、又需要 proxy,就先 bootstrap proxy 再跑一次 hot path。再不命中才走 tryRouteClitryRunGatewayRunFastPath 内部用 Promise.all 并行 dynamic import 8 个模块(run-main.ts:L159),然后构造一个只有 gateway run 命令的极简 Commander 程序。gateway run 的完整启动序列看 网关核心

route-first 快速路径(route.ts:L82)在 OPENCLAW_DISABLE_ROUTE_FIRST 未设、且 argv 无 help/version 时,用 resolveCliArgvInvocation 解析出 commandPath,调 findRoutedCommand(routes.ts:L8)线性扫描 routedCommands 表找第一个 matches(path)canRun(argv) 通过的路由,命中就 prepareRoutedCommand(加载插件按 route.loadPlugins 策略——text-only 表示只有非 --json 调用才预加载)再 route.run(argv),绕开完整 Commander。

Commander 全量路径做 primary-only 注册(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);
  });
}

subcli 注册表是声明式的(register.subclis-core.ts:L92):用 defineImportedProgramCommandGroupSpecs 处理"模块导出固定函数名"的简单情况(纯声明式——commandNames + loadModule: () => import(...) + exportName),需要额外参数的情况(比如 plugins 要在前后挂插件命令)用内联对象配 registerSubCliWithPluginCommands 包装,通过 pluginCliPosition: "before" | "after" 控制插件命令挂在 subcli 之前还是之后。

registerSubCliByName 还有 gateway 特例(register.subclis-core.ts:L45)——即使 fallback 到 Commander 全量路径,openclaw gateway run 仍然走"只挂 run 子命令"的精简注册,removeCommandByName 把之前可能已经挂上的 gateway 命令摘掉再重新挂。这是 hot path 的第三层兜底。

边界与失败

  • unowned primary 拦截:openclaw <typo> 在 help fast path 之后、route 之前会先做 unowned primary 检查(run-main.ts:L826),抛 Unknown command 错误而不是静默显示顶级 help。这是修 #81077 的设计——否则 openclaw gatway --help 会假装成功。
  • 插件命令缺失诊断:primary-only 注册后,如果 primary 既不是内置命令也不在 subcli 表(run-main.ts:L1043),会调 resolveMissingPluginCommandMessageFromPolicy 给出"是不是要装 X 插件"的提示,而不是空 help。
  • proxy 启动顺序:tryRunGatewayRunFastPath 在 proxy 启动先跑一次(run-main.ts:L905),因为 gateway run 自己管理 proxy。其他命令先 bootstrap proxy 再跑——顺序错了会导致 gateway 进程被外部 proxy 抢占。
  • Commander 解析退出:program.parseAsynccommander.* 错误时(run-main.ts:L324),isCommanderParseExit 识别后只设 process.exitCode 不重抛——Commander 的 exitOverride 设计就是把 help/version 当 exit 处理的。
  • 资源清理:runClifinally 块(run-main.ts:L1089)调 closeCliMemoryManagersdisposeCliAgentHarnessespauseNonTtyStdinForCliExit——短命 CLI 进程退出前要清理内存运行时和 agent harness,否则子进程会泄漏。
  • gateway run 三层兜底:shouldRegisterGatewayRunOnly(register.subclis-core.ts:L45)是 hot path 的第三层——即使前面 fast path 没命中走到 Commander 全量,gateway run 仍然只挂 run 子命令,不加载完整 gateway 命令树。

小结

CLI 分发是三层漏斗加一层兜底:help fast path 读预编译文本,route-first 走预解析路由表,Commander 全量做 primary-only 注册。gateway run 因为是热路径,在三处都有专门优化(tryRunGatewayRunFastPathshouldRegisterGatewayRunOnlyregisterGatewayRunOnly)。命令真正开始执行后,agent 主循环怎么跑看 Agent 主循环,gateway 启动序列看 网关核心,入口层怎么把 argv 交到 runCli入口与启动

对照官方资料:OpenClaw 文档 · README