fix(cli): add early-exit fast paths for --version/--help before heavy imports (#5871)

This commit is contained in:
Catalin Lupuleti
2026-02-26 21:59:27 +00:00
committed by Vincent Koc
parent b297bae027
commit 83d609926e
5 changed files with 74 additions and 11 deletions

View File

@@ -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<string>();
@@ -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]);
}

View File

@@ -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();
});
});

View File

@@ -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("|");
},
};
}

View File

@@ -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 <command> --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());
},
});

View File

@@ -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) => {