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 ホットパス:
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 の 5 種 help fast path。gateway hot path + tryRouteCli:L905-L928—tryRunGatewayRunFastPathがヒットすれば return,さもなくば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+registerGatewayRunOnlyホットパス第三層。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 ホットパスは振り分け漏斗の重要ノードです(run-main.ts:L905):
if (!bootstrapProxyBeforeFastPath &&
(await tryRunGatewayRunFastPath(normalizedArgv, startupTrace))) {
return;
}
// bootstrap 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 は proxy 起動前にまず一度走ります,gateway run 自身が proxy を管理するからです(installGatewayRunRuntimeHooks 経由)。ホットパスがヒットせず,proxy が必要なら,先に proxy を bootstrap してからもう一度ホットパスを走らせます。それでもヒットしなければ tryRouteCli へ。tryRunGatewayRunFastPath 内部では Promise.all で 8 個のモジュールを並列 dynamic import し(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)——Commander 全量パスにフォールバックしても,openclaw gateway run は依然として「run サブコマンドだけを取り付ける」精簡登録を行い,removeCommandByName で前に取り付けた可能性のある gateway コマンドを外してから再取り付けします。これがホットパスの第三層兜底です。
境界と失敗
- 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 を管理するからです。他のコマンドは先に proxy を bootstrap してから走ります——順序を間違えると 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)はホットパスの第三層——前の fast path がヒットせず Commander 全量に進んでも,gateway runは依然として run サブコマンドだけを取り付け,完全 gateway コマンドツリーを読み込みません。
まとめ
CLI 振り分けは三段階漏斗に一层兜底を加えたもの:help fast path は事前コンパイルテキストを読み,route-first は事前解析ルーティング表を走り,Commander 全量は primary-only 登録を行います。gateway run はホットパスのため,3 箇所すべてに専用の最適化(tryRunGatewayRunFastPath、shouldRegisterGatewayRunOnly、registerGatewayRunOnly)があります。コマンドが実際に実行開始した後の agent メインループは Agent メインループ,gateway 起動シーケンスは ゲートウェイコア,エントリ層がどう argv を runCli に渡すかは エントリと起動 を参照。
公式資料:OpenClaw ドキュメント · README。