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