From e0956a08534835ad1044c265a2b65f2977ca976e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 27 Apr 2026 00:24:00 -0400 Subject: [PATCH] fix(cli): skip startup work for positional help --- CHANGELOG.md | 1 + src/agents/context.lookup.test.ts | 4 ++ src/agents/context.ts | 15 +------ src/cli/argv-invocation.ts | 4 +- src/cli/argv.test.ts | 66 +++++++++++++++++++++++++++++++ src/cli/argv.ts | 60 ++++++++++++++++++++++++++++ src/cli/program/preaction.ts | 4 +- 7 files changed, 137 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 512f50b8cfd..f2ad8e25678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc. +- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras. - Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras. - Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui. - Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9. diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index d6668378bcd..c80b547fb98 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -202,6 +202,10 @@ describe("lookupContextTokens", () => { expect(shouldEagerWarmContextWindowCache(["node", "openclaw", "chat"])).toBe(true); expect(shouldEagerWarmContextWindowCache(["node", "openclaw", "chat", "--help"])).toBe(false); + expect( + shouldEagerWarmContextWindowCache(["node", "openclaw", "matrix", "encryption", "help"]), + ).toBe(false); + expect(shouldEagerWarmContextWindowCache(["node", "openclaw", "help", "matrix"])).toBe(false); expect( shouldEagerWarmContextWindowCache(["node", "openclaw", "browser", "status", "--help"]), ).toBe(false); diff --git a/src/agents/context.ts b/src/agents/context.ts index 7ffbf92b3f3..aabc43650b4 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -2,6 +2,7 @@ // the agent reports a model id. This includes custom models.json entries. import path from "node:path"; +import { isHelpOrVersionInvocation } from "../cli/argv.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js"; @@ -130,18 +131,6 @@ function getCommandPathFromArgv(argv: string[]): string[] { return tokens; } -function hasHelpOrVersionFlag(argv: string[]): boolean { - for (const arg of argv.slice(2)) { - if (arg === FLAG_TERMINATOR) { - return false; - } - if (arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version") { - return true; - } - } - return false; -} - const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([ "agent", "backup", @@ -175,7 +164,7 @@ export function shouldEagerWarmContextWindowCache(argv: string[] = process.argv) if (!isLikelyOpenClawCliProcess(argv)) { return false; } - if (hasHelpOrVersionFlag(argv)) { + if (isHelpOrVersionInvocation(argv)) { return false; } const [primary] = getCommandPathFromArgv(argv); diff --git a/src/cli/argv-invocation.ts b/src/cli/argv-invocation.ts index ab05807fc58..d27115bd40b 100644 --- a/src/cli/argv-invocation.ts +++ b/src/cli/argv-invocation.ts @@ -1,7 +1,7 @@ import { getCommandPathWithRootOptions, getPrimaryCommand, - hasHelpOrVersion, + isHelpOrVersionInvocation, isRootHelpInvocation, } from "./argv.js"; @@ -18,7 +18,7 @@ export function resolveCliArgvInvocation(argv: string[]): CliArgvInvocation { argv, commandPath: getCommandPathWithRootOptions(argv, 2), primary: getPrimaryCommand(argv), - hasHelpOrVersion: hasHelpOrVersion(argv), + hasHelpOrVersion: isHelpOrVersionInvocation(argv), isRootHelpInvocation: isRootHelpInvocation(argv), }; } diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index a7ec2b529c4..909596f62ee 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -10,6 +10,7 @@ import { getVerboseFlag, hasHelpOrVersion, hasFlag, + isHelpOrVersionInvocation, isRootHelpInvocation, isRootVersionInvocation, shouldMigrateState, @@ -67,6 +68,71 @@ describe("argv helpers", () => { expect(hasHelpOrVersion(argv)).toBe(expected); }); + it.each([ + { + name: "root help command", + argv: ["node", "openclaw", "help"], + expected: true, + }, + { + name: "root help command with target", + argv: ["node", "openclaw", "help", "matrix"], + expected: true, + }, + { + name: "nested help command", + argv: ["node", "openclaw", "matrix", "encryption", "help"], + expected: true, + }, + { + name: "known subcommand root help command", + argv: ["node", "openclaw", "config", "help"], + expected: true, + }, + { + name: "known leaf command positional help", + argv: ["node", "openclaw", "docs", "help"], + expected: false, + }, + { + name: "known subcommand leaf positional help", + argv: ["node", "openclaw", "config", "set", "some.path", "help"], + expected: false, + }, + { + name: "unknown plugin command help", + argv: ["node", "openclaw", "external-plugin", "tools", "help"], + expected: true, + }, + { + name: "help flag", + argv: ["node", "openclaw", "matrix", "encryption", "--help"], + expected: true, + }, + { + name: "help as option value", + argv: ["node", "openclaw", "agent", "--message", "help"], + expected: false, + }, + { + name: "help after terminator", + argv: ["node", "openclaw", "nodes", "invoke", "--", "help"], + expected: false, + }, + { + name: "help flag after terminator", + argv: ["node", "openclaw", "nodes", "invoke", "--", "--help"], + expected: false, + }, + { + name: "version flag after terminator", + argv: ["node", "openclaw", "nodes", "invoke", "--", "--version"], + expected: false, + }, + ])("detects help/version invocations: $name", ({ argv, expected }) => { + expect(isHelpOrVersionInvocation(argv)).toBe(expected); + }); + it.each([ { name: "root --version", diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 02d40656222..816ff7b52fe 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -4,10 +4,21 @@ import { FLAG_TERMINATOR, isValueToken, } from "../infra/cli-root-options.js"; +import { CORE_CLI_COMMAND_DESCRIPTORS } from "./program/core-command-descriptors.js"; +import { SUB_CLI_DESCRIPTORS } from "./program/subcli-descriptors.js"; const HELP_FLAGS = new Set(["-h", "--help"]); const VERSION_FLAGS = new Set(["-V", "--version"]); const ROOT_VERSION_ALIAS_FLAG = "-v"; +const ROOT_COMMAND_DESCRIPTORS = [...CORE_CLI_COMMAND_DESCRIPTORS, ...SUB_CLI_DESCRIPTORS]; +const KNOWN_ROOT_COMMANDS: ReadonlySet = new Set( + ROOT_COMMAND_DESCRIPTORS.map((descriptor) => descriptor.name), +); +const ROOT_COMMANDS_WITH_SUBCOMMANDS: ReadonlySet = new Set( + ROOT_COMMAND_DESCRIPTORS.filter((descriptor) => descriptor.hasSubcommands).map( + (descriptor) => descriptor.name, + ), +); export function hasHelpOrVersion(argv: string[]): boolean { return ( @@ -15,6 +26,55 @@ export function hasHelpOrVersion(argv: string[]): boolean { ); } +export function isHelpOrVersionInvocation(argv: string[]): boolean { + if (hasRootVersionAlias(argv)) { + return true; + } + + const args = argv.slice(2); + let sawCommandOption = false; + const positionals: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg || arg === FLAG_TERMINATOR) { + break; + } + const rootConsumed = consumeRootOptionToken(args, i); + if (rootConsumed > 0) { + i += rootConsumed - 1; + continue; + } + if (HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg)) { + return true; + } + if (arg.startsWith("-")) { + sawCommandOption = true; + continue; + } + positionals.push(arg); + if (arg !== "help") { + continue; + } + if (sawCommandOption) { + return false; + } + if (positionals.length === 1) { + return true; + } + const [primary] = positionals; + // Positional `help` may be a command argument for known leaf commands. + // Unknown roots are treated as plugin command namespaces. + if (!primary || !KNOWN_ROOT_COMMANDS.has(primary)) { + return true; + } + if (positionals.length === 2 && ROOT_COMMANDS_WITH_SUBCOMMANDS.has(primary)) { + return true; + } + return false; + } + return false; +} + function parsePositiveInt(value: string): number | undefined { const parsed = Number.parseInt(value, 10); if (Number.isNaN(parsed) || parsed <= 0) { diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 867c172aa76..0b733e4f18a 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import { setVerbose } from "../../globals.js"; import type { LogLevel } from "../../logging/levels.js"; import { defaultRuntime } from "../../runtime.js"; -import { getVerboseFlag, hasHelpOrVersion } from "../argv.js"; +import { getVerboseFlag, isHelpOrVersionInvocation } from "../argv.js"; import { resolveCliName } from "../cli-name.js"; import { applyCliExecutionStartupPresentation, @@ -65,7 +65,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) program.hook("preAction", async (_thisCommand, actionCommand) => { setProcessTitleForCommand(actionCommand); const argv = process.argv; - if (hasHelpOrVersion(argv)) { + if (isHelpOrVersionInvocation(argv)) { return; } const jsonOutputMode = isCommandJsonOutputMode(actionCommand, argv);