Compare commits

...

8 Commits

Author SHA1 Message Date
Vincent Koc
0054bac90b test(run-main): cover route-first and unknown-command plugin skip 2026-02-27 23:56:54 -08:00
Vincent Koc
8a88b22e4d test(argv): cover root no-command detection 2026-02-27 23:56:48 -08:00
Vincent Koc
e9ee68f9c5 cli(run-main): lazy route import and skip plugin bootstrap for unknown commands 2026-02-27 23:56:43 -08:00
Vincent Koc
af19c5dd60 cli(argv): add root no-command fast-path detection 2026-02-27 23:56:37 -08:00
Vincent Koc
31c639f351 cli(entry): fast-path root invocations to help 2026-02-27 23:56:16 -08:00
Catalin Lupuleti
a8f0557c9d fix: address review feedback (#5871) 2026-02-27 23:47:48 -08:00
Catalin Lupuleti
c5e6209310 fix: address review feedback (#5871) 2026-02-27 23:47:48 -08:00
Catalin Lupuleti
83d609926e fix(cli): add early-exit fast paths for --version/--help before heavy imports (#5871) 2026-02-27 23:47:48 -08:00
9 changed files with 401 additions and 30 deletions

View File

@@ -8,6 +8,9 @@ import {
getVerboseFlag,
hasHelpOrVersion,
hasFlag,
isRootHelpRequest,
isRootNoCommandRequest,
isRootVersionRequest,
shouldMigrateState,
shouldMigrateStateFromPath,
} from "./argv.js";
@@ -284,4 +287,127 @@ 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);
});
// isRootHelpRequest: guards the entry.ts fast-path help exit.
it.each([
{
name: "--help flag at root",
argv: ["node", "openclaw", "--help"],
expected: true,
},
{
name: "-h flag at root",
argv: ["node", "openclaw", "-h"],
expected: true,
},
{
name: "--help after a root boolean flag",
argv: ["node", "openclaw", "--dev", "--help"],
expected: true,
},
{
name: "--help after a root value flag",
argv: ["node", "openclaw", "--profile", "dev", "--help"],
expected: true,
},
{
name: "--help after -- terminator must NOT trigger (forwarded arg)",
argv: ["node", "openclaw", "nodes", "run", "--", "git", "--help"],
expected: false,
},
{
name: "-h after -- terminator must NOT trigger",
argv: ["node", "openclaw", "--", "-h"],
expected: false,
},
{
name: "--help with subcommand must NOT trigger (subcommand-scoped)",
argv: ["node", "openclaw", "status", "--help"],
expected: false,
},
{
name: "-h with subcommand must NOT trigger",
argv: ["node", "openclaw", "models", "-h"],
expected: false,
},
{
name: "normal command without help flag",
argv: ["node", "openclaw", "status"],
expected: false,
},
])("isRootHelpRequest: $name", ({ argv, expected }) => {
expect(isRootHelpRequest(argv)).toBe(expected);
});
it.each([
{
name: "no args",
argv: ["node", "openclaw"],
expected: true,
},
{
name: "only root flags",
argv: ["node", "openclaw", "--dev", "--profile", "work"],
expected: true,
},
{
name: "subcommand present",
argv: ["node", "openclaw", "status"],
expected: false,
},
{
name: "non-flag token after terminator is ignored",
argv: ["node", "openclaw", "--", "status"],
expected: true,
},
])("isRootNoCommandRequest: $name", ({ argv, expected }) => {
expect(isRootNoCommandRequest(argv)).toBe(expected);
});
});

View File

@@ -7,12 +7,108 @@ const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]);
const FLAG_TERMINATOR = "--";
/**
* Returns true if argv contains any help or version flag.
* NOTE: uses `argv.some()` without `--` terminator awareness, so forwarded args
* like `nodes run -- git --help` will produce a false positive. Use
* `isRootHelpRequest`/`isRootVersionRequest` for fast-path guards where
* correctness around forwarded args matters.
*/
export function hasHelpOrVersion(argv: string[]): boolean {
return (
argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg)) || hasRootVersionAlias(argv)
);
}
/**
* 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);
}
/**
* Returns true only when the process is a root-level help request:
* - `-h` or `--help` appears before any `--` terminator, and
* - no subcommand (non-flag token) precedes the help flag.
* `openclaw status --help` returns false (subcommand-scoped); `openclaw --help` returns true.
* Used by the entry.ts fast path to display help without loading route.ts or run-main.js.
*/
export function isRootHelpRequest(argv: string[]): boolean {
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg) {
continue;
}
if (arg === FLAG_TERMINATOR) {
break;
}
if (arg === "-h" || arg === "--help") {
return true;
}
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (arg.startsWith("--profile=")) {
continue;
}
if (ROOT_VALUE_FLAGS.has(arg)) {
const next = args[i + 1];
if (isValueToken(next)) {
i += 1;
}
continue;
}
if (arg.startsWith("-")) {
continue;
}
// Non-flag token is a subcommand — this is a subcommand-scoped help request.
return false;
}
return false;
}
/**
* Returns true when argv contains no primary subcommand token.
* Root flags are ignored; scanning stops at `--`.
* Used by entry.ts to render root help without loading the full CLI path.
*/
export function isRootNoCommandRequest(argv: string[]): boolean {
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg) {
continue;
}
if (arg === FLAG_TERMINATOR) {
break;
}
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
continue;
}
if (ROOT_VALUE_FLAGS.has(arg)) {
const next = args[i + 1];
if (isValueToken(next)) {
i += 1;
}
continue;
}
if (arg.startsWith("-")) {
continue;
}
return false;
}
return true;
}
function isValueToken(arg: string | undefined): boolean {
if (!arg) {
return false;

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,16 @@ 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();
// Emit a deprecation warning: ensurePluginRegistryLoaded() was removed to avoid
// pulling plugins/loader.ts → jiti at startup (slow on low-powered devices).
// OPENCLAW_EAGER_CHANNEL_OPTIONS no longer force-loads plugin channels; plugin IDs
// are only included if the registry was already populated by another code path.
process.emitWarning(
"OPENCLAW_EAGER_CHANNEL_OPTIONS no longer force-loads plugin channels at startup. " +
"Plugin IDs are only included if the registry was pre-loaded by another means. " +
"Remove this env var to silence this warning.",
{ code: "OPENCLAW_EAGER_CHANNEL_OPTIONS_DEPRECATED" },
);
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());
},
});
@@ -104,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") ||

View File

@@ -4,6 +4,7 @@ import {
shouldEnsureCliPath,
shouldRegisterPrimarySubcommand,
shouldSkipPluginCommandRegistration,
shouldTryRouteFirst,
} from "./run-main.js";
describe("rewriteUpdateFlagArgv", () => {
@@ -54,7 +55,7 @@ describe("shouldRegisterPrimarySubcommand", () => {
});
describe("shouldSkipPluginCommandRegistration", () => {
it("skips plugin registration for root help/version", () => {
it("skips plugin registration when no primary command is present", () => {
expect(
shouldSkipPluginCommandRegistration({
argv: ["node", "openclaw", "--help"],
@@ -84,23 +85,44 @@ describe("shouldSkipPluginCommandRegistration", () => {
).toBe(true);
});
it("keeps plugin registration for non-builtin help", () => {
it("skips plugin registration for non-builtin help by default", () => {
expect(
shouldSkipPluginCommandRegistration({
argv: ["node", "openclaw", "voicecall", "--help"],
primary: "voicecall",
hasBuiltinPrimary: false,
}),
).toBe(false);
).toBe(true);
});
it("keeps plugin registration for non-builtin command runs", () => {
it("skips plugin registration for non-builtin command runs by default", () => {
expect(
shouldSkipPluginCommandRegistration({
argv: ["node", "openclaw", "voicecall", "status"],
primary: "voicecall",
hasBuiltinPrimary: false,
}),
).toBe(true);
});
it("skips plugin registration for unknown commands", () => {
expect(
shouldSkipPluginCommandRegistration({
argv: ["node", "openclaw", "definitely-not-a-command"],
primary: "definitely-not-a-command",
hasBuiltinPrimary: false,
}),
).toBe(true);
});
it("can force plugin registration via env guard", () => {
expect(
shouldSkipPluginCommandRegistration({
argv: ["node", "openclaw", "unknown-cmd"],
primary: "unknown-cmd",
hasBuiltinPrimary: false,
forcePluginRegistration: true,
}),
).toBe(false);
});
});
@@ -125,3 +147,14 @@ describe("shouldEnsureCliPath", () => {
expect(shouldEnsureCliPath(["node", "openclaw", "acp", "-v"])).toBe(true);
});
});
describe("shouldTryRouteFirst", () => {
it("enables route-first only for routed commands", () => {
expect(shouldTryRouteFirst(["node", "openclaw", "status"])).toBe(true);
expect(shouldTryRouteFirst(["node", "openclaw", "health"])).toBe(true);
expect(shouldTryRouteFirst(["node", "openclaw", "sessions"])).toBe(true);
expect(shouldTryRouteFirst(["node", "openclaw", "models", "status"])).toBe(false);
expect(shouldTryRouteFirst(["node", "openclaw", "definitely-not-a-command"])).toBe(false);
expect(shouldTryRouteFirst(["node", "openclaw"])).toBe(false);
});
});

View File

@@ -1,7 +1,7 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import { loadDotEnv } from "../infra/dotenv.js";
import { normalizeEnv } from "../infra/env.js";
import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
import { formatUncaughtError } from "../infra/errors.js";
import { isMainModule } from "../infra/is-main.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
@@ -9,7 +9,6 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getCommandPath, getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -31,14 +30,21 @@ export function shouldSkipPluginCommandRegistration(params: {
argv: string[];
primary: string | null;
hasBuiltinPrimary: boolean;
forcePluginRegistration?: boolean;
}): boolean {
if (params.forcePluginRegistration) {
return false;
}
if (params.hasBuiltinPrimary) {
return true;
}
if (!params.primary) {
return hasHelpOrVersion(params.argv);
// No primary command can never map to a plugin top-level command.
return true;
}
return false;
// Unknown primary commands fast-fail without loading plugins/config.
// Set OPENCLAW_FORCE_PLUGIN_COMMAND_REGISTRATION=1 to preserve legacy behavior.
return true;
}
export function shouldEnsureCliPath(argv: string[]): boolean {
@@ -61,6 +67,13 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
return true;
}
const ROUTE_FIRST_COMMANDS = new Set(["status", "health", "sessions"]);
export function shouldTryRouteFirst(argv: string[]): boolean {
const primary = getPrimaryCommand(argv);
return primary !== null && ROUTE_FIRST_COMMANDS.has(primary);
}
export async function runCli(argv: string[] = process.argv) {
const normalizedArgv = normalizeWindowsArgv(argv);
loadDotEnv({ quiet: true });
@@ -72,8 +85,11 @@ export async function runCli(argv: string[] = process.argv) {
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
if (await tryRouteCli(normalizedArgv)) {
return;
if (shouldTryRouteFirst(normalizedArgv)) {
const { tryRouteCli } = await import("./route.js");
if (await tryRouteCli(normalizedArgv)) {
return;
}
}
// Capture all console output into structured logs while keeping stdout/stderr behavior.
@@ -108,10 +124,14 @@ export async function runCli(argv: string[] = process.argv) {
const hasBuiltinPrimary =
primary !== null && program.commands.some((command) => command.name() === primary);
const forcePluginRegistration = isTruthyEnvValue(
process.env.OPENCLAW_FORCE_PLUGIN_COMMAND_REGISTRATION,
);
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
argv: parseArgv,
primary,
hasBuiltinPrimary,
forcePluginRegistration,
});
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing

View File

@@ -2,6 +2,7 @@
import { spawn } from "node:child_process";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { isRootHelpRequest, isRootNoCommandRequest, 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";
@@ -9,6 +10,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,14 +132,44 @@ if (
process.argv = parsed.argv;
}
import("./cli/run-main.js")
.then(({ runCli }) => runCli(process.argv))
.catch((error) => {
console.error(
"[openclaw] Failed to start CLI:",
error instanceof Error ? (error.stack ?? error.message) : error,
);
process.exitCode = 1;
});
// Fast path: print version and exit before loading heavy CLI modules.
// 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 (isRootVersionRequest(argv)) {
console.log(VERSION);
process.exit(0);
}
// Fast path: display root help without loading run-main.js or route.ts.
// isRootHelpRequest stops at -- and requires no subcommand before -h/--help,
// so `openclaw status --help` still falls through to the full CLI path.
// Importing program.js directly avoids the route.ts static import in run-main.ts.
if (isRootHelpRequest(argv) || isRootNoCommandRequest(argv)) {
import("./cli/program.js")
.then(({ buildProgram }) => {
buildProgram().outputHelp();
process.exit(0);
})
.catch((error) => {
console.error(
"[openclaw] Failed to display help:",
error instanceof Error ? (error.stack ?? error.message) : error,
);
process.exit(1);
});
} else {
import("./cli/run-main.js")
.then(({ runCli }) => runCli(process.argv))
.catch((error) => {
console.error(
"[openclaw] Failed to start CLI:",
error instanceof Error ? (error.stack ?? error.message) : error,
);
process.exitCode = 1;
});
}
}
}