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。