CLI 指令分派
職責
runCli(argv) 是 OpenClaw 指令真正開始分派的地方。從 入口 拿到 argv 後,它要做的事:解析 profile/container 選項、載入 .env、決定要不要啟動代理、走 help fast path 兜底、嘗試 route-first 快速路徑,然後才落到完整 Commander 程式——動態註冊主指令 (primary command)、註冊外掛指令、呼叫 program.parseAsync(argv) 讓 Commander 派活。
分派 (dispatch) 在 OpenClaw 裡不是一個單一動作,而是三層漏斗:
- 快速路徑 (fast path):
--help/--version/browser --help/secrets --help/nodes --help等純 help 請求,直接讀預編譯文本返回,不載入 Commander。 - route-first:
tryRouteCli透過預解析的routedCommands表把 argv 直接路由到指令實作,繞開完整 Commander 註冊。 - 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 run裡gateway就是 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 頂部:L610-L667— argv 歸一化、container/profile 解析、.env 載入。runMain help fast path:L760-L820— root/browser/secrets/nodes/subcommand 五類 help fast path。gateway hot path + tryRouteCli:L905-L928—tryRunGatewayRunFastPath命中就返回,否則tryRouteCli。tryRunGatewayRunFastPath:L143-L238— 建構極簡 Commander 只掛gateway run指令。primary-only + 外掛指令註冊:L1005-L1070—registerCoreCliByName+registerSubCliByName+ 外掛指令 lazy 註冊。coreEntrySpecs:L50-L143— 核心指令註冊表(crestodian/setup/onboard/configure/config/backup/migrate/maintenance/message/mcp/transcripts/agent/agents/status-health-sessions)。entrySpecs subcli 註冊表:L92-L278— subcli 註冊表(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 兜底:L45-L66—shouldRegisterGatewayRunOnly+registerGatewayRunOnlyhot path 第三層。tryRouteCli:L82-L115— route-first 快速路徑。createParsedRoute:L26-L59— 把 catalog 條目編譯成RouteSpec。
資料流
runCli 頂部做 argv 歸一化和環境載入(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;
}注意 maybeRunCliInContainer——如果 argv 指定 --container <name>,這裡會把這個進程替換成容器內執行並 return,真正的分派邏輯不會往下走。
gateway hot path 是分派漏斗的關鍵節點(run-main.ts:L905):
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。再不命中才走 tryRouteCli。tryRunGatewayRunFastPath 內部用 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):
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.parseAsync拋commander.*錯誤時(run-main.ts:L324),isCommanderParseExit識別後只設process.exitCode不重拋——Commander 的exitOverride設計就是把 help/version 當 exit 處理的。 - 資源清理:
runCli的finally區塊(run-main.ts:L1089)呼叫closeCliMemoryManagers、disposeCliAgentHarnesses、pauseNonTtyStdinForCliExit——短命 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 因為是熱路徑,在三處都有專門優化(tryRunGatewayRunFastPath、shouldRegisterGatewayRunOnly、registerGatewayRunOnly)。指令真正開始執行後,agent 主循環怎麼跑看 Agent 主循環,gateway 啟動序列看 閘道核心,入口層怎麼把 argv 交到 runCli 看 入口與啟動。
對照官方資料:OpenClaw 文件 · README。