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 が必要で,buildProgramregisterProgramCommands を呼んですべてのコアコマンドと 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 は使用頻度が最も高く,専用の tryRunGatewayRunFastPathbuildProgram をバイパスして極簡 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 ホットパスは振り分け漏斗の重要ノードです(run-main.ts:L905):

typescript
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):

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)——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.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)はホットパスの第三層——前の fast path がヒットせず Commander 全量に進んでも,gateway run は依然として run サブコマンドだけを取り付け,完全 gateway コマンドツリーを読み込みません。

まとめ

CLI 振り分けは三段階漏斗に一层兜底を加えたもの:help fast path は事前コンパイルテキストを読み,route-first は事前解析ルーティング表を走り,Commander 全量は primary-only 登録を行います。gateway run はホットパスのため,3 箇所すべてに専用の最適化(tryRunGatewayRunFastPathshouldRegisterGatewayRunOnlyregisterGatewayRunOnly)があります。コマンドが実際に実行開始した後の agent メインループは Agent メインループ,gateway 起動シーケンスは ゲートウェイコア,エントリ層がどう argv を runCli に渡すかは エントリと起動 を参照。

公式資料:OpenClaw ドキュメント · README