fix(cli): skip startup work for positional help

This commit is contained in:
Gustavo Madeira Santana
2026-04-27 00:24:00 -04:00
parent 9c07579a95
commit e0956a0853
7 changed files with 137 additions and 17 deletions

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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<string> = new Set(
ROOT_COMMAND_DESCRIPTORS.map((descriptor) => descriptor.name),
);
const ROOT_COMMANDS_WITH_SUBCOMMANDS: ReadonlySet<string> = 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) {

View File

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