Répartition des commandes CLI
Responsabilités
runCli(argv) est le point où OpenClaw commence réellement à répartir les commandes. Recevant argv depuis l'entrée, il doit: parser les options profile/container, charger le .env, décider de lancer un proxy, faire un repli sur le fast path help, tenter la route rapide (route-first), puis seulement tomber sur le programme Commander complet — enregistrement dynamique des commandes primaires (primary command), enregistrement des commandes de plugin, et program.parseAsync(argv) pour que Commander prenne le relais.
La répartition (dispatch) dans OpenClaw n'est pas un acte unique, mais un entonnoir à trois étages:
- Fast path:
--help/--version/browser --help/secrets --help/nodes --helpet autres requêtes de pur help lisent un texte précompilé sans charger Commander. - Route-first:
tryRouteCliroute argv directement vers l'implémentation via une tableroutedCommandspré-parsée, court-circuitant l'enregistrement Commander complet. - Commander complet:
buildProgram()construit le programme racine Commander, enregistre en lazy les primary command et les commandes de plugin, puisprogram.parseAsync.
Chaque étage return si hit, sinon on descend. Ainsi openclaw gateway run (tryRunGatewayRunFastPath:L143) saute l'essentiel des enregistrements, tandis que openclaw plugins install foo (chemin froid) tombe dans Commander complet.
Motivation de conception
Pourquoi un entonnoir à trois étages plutôt qu'un simple program.parseAsync(argv)? Parce qu'avant parseAsync, il faut buildProgram, qui appelle registerProgramCommands et enregistre toutes les commandes cœur et subclis. OpenClaw a 40+ subclis (gateway/agent/cron/models/devices/plugins/channels/security/secrets/skills/...), chacun avec son sous-arbre de commandes; tout enregistrer eagerly coûterait plusieurs centaines de ms de démarrage à froid.
Plusieurs choix de conception sont clés:
- Enregistrement primary-only: le premier token non-option dans argv est la commande primaire (
gatewaydansopenclaw gateway run). SishouldRegisterPrimaryCommandOnly(argv)est vrai, on n'enregistre que celle-là, tous les autres subclis sont ignorés. - Lazy import: chaque registreur de subcli est
() => import("../xxx-cli.js"), chargé dynamiquement uniquement si hit. - Hot path de la passerelle:
openclaw gateway runest la commande la plus fréquente;tryRunGatewayRunFastPathdédié contournebuildProgramet construit un programme Commander minimaliste. - Route-first: certaines commandes n'ont même pas besoin du parseur Commander; elles sont dispatchées directement depuis une table argv pré-parsée.
Le coût: plus de chemins de code, plus de complexité à comprendre. Le gain: le démarrage à froid de openclaw gateway run passe de la seconde à quelques centaines de ms.
Fichiers clés
runCli top:L610-L667— normalisation argv, parsing container/profile, chargement .env.runMain fast path help:L760-L820— cinq types de fast path help: root/browser/secrets/nodes/subcommand.hot path passerelle + tryRouteCli:L905-L928—tryRunGatewayRunFastPathhit puis return, sinontryRouteCli.tryRunGatewayRunFastPath:L143-L238— construit un Commander minimaliste ne montant quegateway run.primary-only + enregistrement plugins:L1005-L1070—registerCoreCliByName+registerSubCliByName+ lazy enregistrement des commandes de plugin.coreEntrySpecs:L50-L143— registre des commandes cœur (crestodian/setup/onboard/configure/config/backup/migrate/maintenance/message/mcp/transcripts/agent/agents/status-health-sessions).entrySpecs registre subclis:L92-L278— registre subclis (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).repli gateway run:L45-L66—shouldRegisterGatewayRunOnly+registerGatewayRunOnly, troisième étage du hot path.tryRouteCli:L82-L115— fast path route-first.createParsedRoute:L26-L59— compile une entrée de catalogue enRouteSpec.
Flux de données
En haut de runCli on normalise argv et on charge l'environnement (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;
}Notez maybeRunCliInContainer — si argv spécifie --container <name>, ce code remplace ce processus par une exécution dans le conteneur et return; la logique de répartition réelle n'est jamais atteinte.
Le hot path passerelle est le nœud clé de l'entonnoir (run-main.ts:L905):
if (!bootstrapProxyBeforeFastPath &&
(await tryRunGatewayRunFastPath(normalizedArgv, startupTrace))) {
return;
}
// bootstrap proxy puis on retente le 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 est joué avant le bootstrap du proxy, car gateway run gère lui-même le proxy (via installGatewayRunRuntimeHooks). Si le hot path miss et que le proxy est requis, on le boot puis on rejoue le hot path. Si toujours miss, on tente tryRouteCli. tryRunGatewayRunFastPath fait en interne un Promise.all de 8 dynamic imports (run-main.ts:L159), puis construit un programme Commander minimaliste ne contenant que la commande gateway run. La séquence de démarrage complète de gateway run se trouve dans Cœur de passerelle.
Le fast path route-first (route.ts:L82), si OPENCLAW_DISABLE_ROUTE_FIRST n'est pas posé et qu'argv ne contient pas help/version, utilise resolveCliArgvInvocation pour extraire commandPath, appelle findRoutedCommand (routes.ts:L8) qui scanne linéairement routedCommands pour le premier matches(path) avec canRun(argv) OK, puis prepareRoutedCommand (qui charge les plugins selon la stratégie route.loadPlugins — text-only signifie que seuls les appels non --json pré-chargent) et enfin route.run(argv), en court-circuitant Commander complet.
Le chemin Commander complet fait l'enregistrement 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);
});
}Le registre des subclis est déclaratif (register.subclis-core.ts:L92): defineImportedProgramCommandGroupSpecs traite le cas simple « module qui exporte un nom de fonction fixe » (purement déclaratif — commandNames + loadModule: () => import(...) + exportName); les cas nécessitant des paramètres supplémentaires (par exemple plugins qui attache des commandes de plugin avant/après) utilisent un objet inline qui enveloppe via registerSubCliWithPluginCommands, contrôlé par pluginCliPosition: "before" | "after".
registerSubCliByName a aussi un cas spécial gateway (register.subclis-core.ts:L45) — même en repli sur Commander complet, openclaw gateway run passe par un enregistrement minimaliste « monter uniquement run », via removeCommandByName qui démonte un éventuel gateway précédent avant de le remonter. C'est le troisième étage de repli du hot path.
Limites et modes d'échec
- Interception unowned primary:
openclaw <typo>passe un check unowned primary après le fast path help et avant la route (run-main.ts:L826), lèveUnknown commandplutôt que d'afficher silencieusement l'aide racine. C'est le fix de #81077 — sinonopenclaw gatway --helpfeindrait le succès. - Diagnostic commande de plugin manquante: après l'enregistrement primary-only, si primary n'est ni commande builtin ni dans la table subclis (
run-main.ts:L1043), on appelleresolveMissingPluginCommandMessageFromPolicypour suggérer « peut-être installer le plugin X » plutôt qu'un help vide. - Ordre de bootstrap du proxy:
tryRunGatewayRunFastPathest joué avant le bootstrap du proxy (run-main.ts:L905), cargateway rungère lui-même le proxy. Les autres commandes boot le proxy d'abord — un ordre inverse laisserait le processus gateway se faire voler le proxy par une autre commande. - Sortie du parseur Commander: quand
program.parseAsyncjette une erreurcommander.*(run-main.ts:L324),isCommanderParseExitla reconnaît et se contente de poserprocess.exitCodesans rethrow — le designexitOverridede Commander traite help/version comme des exits. - Nettoyage de ressources: le bloc
finallyderunCli(run-main.ts:L1089) appellecloseCliMemoryManagers,disposeCliAgentHarnesses,pauseNonTtyStdinForCliExit— un processus CLI court doit nettoyer son runtime mémoire et ses agent harness avant de sortir, sinon les sous-processus fuient. - Trois étages de repli pour gateway run:
shouldRegisterGatewayRunOnly(register.subclis-core.ts:L45) est le troisième étage du hot path — même si les fast paths précédents miss et que l'on tombe dans Commander complet,gateway runne monte toujours que la sous-commande run, sans charger tout l'arbre gateway.
Résumé
La répartition CLI est un entonnoir à trois étages avec un repli: fast path help lit du texte précompilé, route-first passe par une table de routes pré-parsée, Commander complet fait l'enregistrement primary-only. gateway run étant un hot path, il a des optimisations dédiées à trois endroits (tryRunGatewayRunFastPath, shouldRegisterGatewayRunOnly, registerGatewayRunOnly). Une fois la commande en exécution, voir Boucle principale de l'agent pour la boucle; Cœur de passerelle pour la séquence de démarrage de la passerelle; Entrée et démarrage pour la manière dont l'entrée transmet argv à runCli.
Pour comparer avec la documentation officielle: OpenClaw docs · README.