diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 25f04e80ac5..ef42e0579ac 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -8,6 +8,7 @@ import { getVerboseFlag, hasHelpOrVersion, hasFlag, + isRootVersionRequest, shouldMigrateState, shouldMigrateStateFromPath, } from "./argv.js"; @@ -284,4 +285,51 @@ describe("argv helpers", () => { ])("reuses command path for migrate state decisions: $path", ({ path, expected }) => { expect(shouldMigrateStateFromPath(path)).toBe(expected); }); + + // isRootVersionRequest: guards the entry.ts fast-path exit. + // Each case documents a semantic boundary that the fast path must respect. + it.each([ + { + name: "--version flag", + argv: ["node", "openclaw", "--version"], + expected: true, + }, + { + name: "-V flag", + argv: ["node", "openclaw", "-V"], + expected: true, + }, + { + name: "-v root alias (no subcommand)", + argv: ["node", "openclaw", "-v"], + expected: true, + }, + { + name: "-v root alias with a root-level flag before it", + argv: ["node", "openclaw", "--dev", "-v"], + expected: true, + }, + { + name: "--version after -- terminator must NOT trigger (forwarded arg)", + argv: ["node", "openclaw", "nodes", "run", "--", "git", "--version"], + expected: false, + }, + { + name: "-V after -- terminator must NOT trigger", + argv: ["node", "openclaw", "--", "-V"], + expected: false, + }, + { + name: "-v with subcommand must NOT trigger (subcommand-scoped flag)", + argv: ["node", "openclaw", "acp", "-v"], + expected: false, + }, + { + name: "normal command without version flag", + argv: ["node", "openclaw", "status"], + expected: false, + }, + ])("isRootVersionRequest: $name", ({ argv, expected }) => { + expect(isRootVersionRequest(argv)).toBe(expected); + }); }); diff --git a/src/cli/argv.ts b/src/cli/argv.ts index c996fab4bad..2953d2572dc 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -13,6 +13,17 @@ export function hasHelpOrVersion(argv: string[]): boolean { ); } +/** + * Returns true only when the process is a root-level version request: + * - `--version` or `-V` appear before any `--` terminator (so forwarded args like + * `nodes run -- git --version` are excluded), and + * - `-v` is only matched at root scope (no subcommand before it). + * Used by the entry.ts fast path to exit before loading heavy CLI modules. + */ +export function isRootVersionRequest(argv: string[]): boolean { + return hasFlag(argv, "--version") || hasFlag(argv, "-V") || hasRootVersionAlias(argv); +} + function isValueToken(arg: string | undefined): boolean { if (!arg) { return false; diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index e4b96db0607..1b2a98d25ac 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -23,9 +23,14 @@ 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)) { - // 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.) + // CHANGED SEMANTIC: ensurePluginRegistryLoaded() is intentionally NOT called + // here to avoid pulling in plugins/loader.ts → jiti at startup (slow on + // low-powered devices). As a result, OPENCLAW_EAGER_CHANNEL_OPTIONS is + // effectively a no-op for its original purpose of force-loading all plugins + // into the option list before Commander parses args. Plugin IDs are only + // included here if the registry was already populated by some other means + // (e.g. the preaction hook for a real command). If this env var behaviour is + // needed, restore the ensurePluginRegistryLoaded() call explicitly. const pluginIds = listChannelPlugins().map((plugin) => plugin.id); return dedupe([...base, ...pluginIds]); } diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 54d84ebe628..3e2f058441b 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -106,6 +106,9 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { outputError: (str, write) => write(theme.error(str)), }); + // Defense-in-depth: entry.ts already exits early for version flags before + // loading this module. This block handles the case where help is configured + // outside the normal entry path (e.g. tests, programmatic use). if ( hasFlag(process.argv, "-V") || hasFlag(process.argv, "--version") || diff --git a/src/entry.ts b/src/entry.ts index 36d4879c106..633f378eece 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { isRootVersionRequest } from "./cli/argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; @@ -132,9 +133,12 @@ if ( } // 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). + // isRootVersionRequest handles --version/-V (stops at -- terminator so forwarded + // args like `nodes run -- git --version` are excluded) and -v (only at root scope, + // not when a subcommand is present). Avoids the full plugin/config startup on + // low-powered devices (e.g. Pi4b). const argv = process.argv; - if (argv.includes("--version") || argv.includes("-V")) { + if (isRootVersionRequest(argv)) { console.log(VERSION); process.exit(0); }