From 83d609926e6578d52fe745d0080d75a4e0bdc04c Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti <105351510+lupuletic@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:59:27 +0000 Subject: [PATCH] fix(cli): add early-exit fast paths for --version/--help before heavy imports (#5871) --- src/cli/channel-options.ts | 8 ++++++-- src/cli/program/context.test.ts | 32 ++++++++++++++++++++++++++++++++ src/cli/program/context.ts | 24 ++++++++++++++++++++---- src/cli/program/help.ts | 12 +++++++----- src/entry.ts | 9 +++++++++ 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index 357133f1d65..e4b96db0607 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -2,7 +2,9 @@ import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js" import { listChannelPlugins } from "../channels/plugins/index.js"; import { CHAT_CHANNEL_ORDER } from "../channels/registry.js"; import { isTruthyEnvValue } from "../infra/env.js"; -import { ensurePluginRegistryLoaded } from "./plugin-registry.js"; +// NOTE: plugin-registry.ts is NOT imported here to avoid pulling in +// plugins/loader.ts → jiti at startup (which is slow on low-powered devices). +// The OPENCLAW_EAGER_CHANNEL_OPTIONS path reads from the registry without force-loading. function dedupe(values: string[]): string[] { const seen = new Set(); @@ -21,7 +23,9 @@ export function resolveCliChannelOptions(): string[] { const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id); const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]); if (isTruthyEnvValue(process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS)) { - ensurePluginRegistryLoaded(); + // Reads plugin channel IDs from the registry if already populated. + // (ensurePluginRegistryLoaded is intentionally not called here to avoid + // loading jiti; plugins are loaded by the preaction hook for real commands.) const pluginIds = listChannelPlugins().map((plugin) => plugin.id); return dedupe([...base, ...pluginIds]); } diff --git a/src/cli/program/context.test.ts b/src/cli/program/context.test.ts index 18fc90deba7..bbdf6b17fe3 100644 --- a/src/cli/program/context.test.ts +++ b/src/cli/program/context.test.ts @@ -34,4 +34,36 @@ describe("createProgramContext", () => { agentChannelOptions: "last", }); }); + + // Fast-path correctness: channel options must NOT be resolved until a command + // actually needs them. This ensures --help/--version never trigger catalog discovery. + it("does not call resolveCliChannelOptions before channelOptions is accessed", () => { + resolveCliChannelOptionsMock.mockClear(); + createProgramContext(); // create context without accessing any channel option getter + expect(resolveCliChannelOptionsMock).not.toHaveBeenCalled(); + }); + + it("calls resolveCliChannelOptions lazily when channelOptions getter is first accessed", () => { + resolveCliChannelOptionsMock.mockClear().mockReturnValue(["discord"]); + const ctx = createProgramContext(); + const _ = ctx.channelOptions; + expect(resolveCliChannelOptionsMock).toHaveBeenCalledOnce(); + }); + + it("caches channel options so resolveCliChannelOptions is called only once per context", () => { + resolveCliChannelOptionsMock.mockClear().mockReturnValue(["telegram"]); + const ctx = createProgramContext(); + const _a = ctx.channelOptions; + const _b = ctx.messageChannelOptions; + const _c = ctx.agentChannelOptions; + // All three getters share the same underlying cache. + expect(resolveCliChannelOptionsMock).toHaveBeenCalledOnce(); + }); + + it("programVersion is accessible without triggering channel option resolution", () => { + resolveCliChannelOptionsMock.mockClear(); + const ctx = createProgramContext(); + expect(ctx.programVersion).toBe("9.9.9-test"); + expect(resolveCliChannelOptionsMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/program/context.ts b/src/cli/program/context.ts index dc38eb41f4d..4e907ae33f7 100644 --- a/src/cli/program/context.ts +++ b/src/cli/program/context.ts @@ -9,11 +9,27 @@ export type ProgramContext = { }; export function createProgramContext(): ProgramContext { - const channelOptions = resolveCliChannelOptions(); + // Defer resolveCliChannelOptions() until a command actually needs channel option strings. + // This avoids the catalog discovery (discoverOpenClawPlugins) and module loading + // during --help, --version, and other fast-path invocations. + let _channelOptions: string[] | undefined; + function getChannelOptions(): string[] { + if (_channelOptions === undefined) { + _channelOptions = resolveCliChannelOptions(); + } + return _channelOptions; + } + return { programVersion: VERSION, - channelOptions, - messageChannelOptions: channelOptions.join("|"), - agentChannelOptions: ["last", ...channelOptions].join("|"), + get channelOptions() { + return getChannelOptions(); + }, + get messageChannelOptions() { + return getChannelOptions().join("|"); + }, + get agentChannelOptions() { + return ["last", ...getChannelOptions()].join("|"); + }, }; } diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 87ef63d8d2e..54d84ebe628 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -12,10 +12,6 @@ import { getSubCliCommandsWithSubcommands } from "./register.subclis.js"; const CLI_NAME = resolveCliName(); const CLI_NAME_PATTERN = escapeRegExp(CLI_NAME); -const ROOT_COMMANDS_WITH_SUBCOMMANDS = new Set([ - ...getCoreCliCommandsWithSubcommands(), - ...getSubCliCommandsWithSubcommands(), -]); const ROOT_COMMANDS_HINT = "Hint: commands suffixed with * have subcommands. Run --help for details."; @@ -44,6 +40,12 @@ const EXAMPLES = [ ] as const; export function configureProgramHelp(program: Command, ctx: ProgramContext) { + // Built here (not at module level) to defer the work until help is actually configured. + const rootCommandsWithSubcommands = new Set([ + ...getCoreCliCommandsWithSubcommands(), + ...getSubCliCommandsWithSubcommands(), + ]); + program .name(CLI_NAME) .description("") @@ -73,7 +75,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { optionTerm: (option) => theme.option(option.flags), subcommandTerm: (cmd) => { const isRootCommand = cmd.parent === program; - const hasSubcommands = isRootCommand && ROOT_COMMANDS_WITH_SUBCOMMANDS.has(cmd.name()); + const hasSubcommands = isRootCommand && rootCommandsWithSubcommands.has(cmd.name()); return theme.command(hasSubcommands ? `${cmd.name()} *` : cmd.name()); }, }); diff --git a/src/entry.ts b/src/entry.ts index 6f664edced0..36d4879c106 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -9,6 +9,7 @@ import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { installProcessWarningFilter } from "./infra/warning-filter.js"; import { attachChildProcessBridge } from "./process/child-process-bridge.js"; +import { VERSION } from "./version.js"; const ENTRY_WRAPPER_PAIRS = [ { wrapperBasename: "openclaw.mjs", entryBasename: "entry.js" }, @@ -130,6 +131,14 @@ if ( process.argv = parsed.argv; } + // Fast path: print version and exit before loading heavy CLI modules. + // This avoids the full plugin/config startup on low-powered devices (e.g. Pi4b). + const argv = process.argv; + if (argv.includes("--version") || argv.includes("-V")) { + console.log(VERSION); + process.exit(0); + } + import("./cli/run-main.js") .then(({ runCli }) => runCli(process.argv)) .catch((error) => {