fix(cli): accept --log-level after subcommands

This commit is contained in:
luyifan
2026-06-16 15:39:37 +08:00
committed by Vincent Koc
parent 1ae0eacf4b
commit b6d3aa5719
3 changed files with 185 additions and 3 deletions
+58
View File
@@ -16,6 +16,7 @@ import {
isRootVersionInvocation,
normalizeGeneratedHelpCommandArgv,
normalizeRootHelpTargetArgv,
normalizeRootLogLevelArgv,
normalizeRootNoColorArgv,
shouldMigrateState,
shouldMigrateStateFromPath,
@@ -227,6 +228,63 @@ describe("argv helpers", () => {
).toEqual(["node", "openclaw", "--no-color", "doctor", "--lint", "--json"]);
});
it.each([
{
name: "subcommand trailing log-level",
argv: ["node", "openclaw", "doctor", "--log-level", "debug", "--json"],
expected: ["node", "openclaw", "--log-level", "debug", "doctor", "--json"],
},
{
name: "subcommand trailing log-level equals form",
argv: ["node", "openclaw", "doctor", "--log-level=trace", "--json"],
expected: ["node", "openclaw", "--log-level=trace", "doctor", "--json"],
},
{
name: "keeps existing root options first",
argv: ["node", "openclaw", "--profile", "work", "doctor", "--log-level", "debug"],
expected: ["node", "openclaw", "--profile", "work", "--log-level", "debug", "doctor"],
},
{
name: "keeps log-level after possible command option value",
argv: ["node", "openclaw", "agent", "--message", "--log-level", "debug"],
expected: ["node", "openclaw", "agent", "--message", "--log-level", "debug"],
},
{
name: "flag terminator leaves log-level positional",
argv: ["node", "openclaw", "nodes", "run", "--", "--log-level", "debug"],
expected: ["node", "openclaw", "nodes", "run", "--", "--log-level", "debug"],
},
{
name: "missing value remains command scoped",
argv: ["node", "openclaw", "doctor", "--log-level", "--json"],
expected: ["node", "openclaw", "doctor", "--log-level", "--json"],
},
])("normalizes root --log-level before command parsing: $name", ({ argv, expected }) => {
expect(normalizeRootLogLevelArgv(argv)).toEqual(expected);
});
it("allows final command metadata to lift log-level after boolean command flags", () => {
const argv = ["node", "openclaw", "doctor", "--lint", "--json", "--log-level", "debug"];
expect(
normalizeRootLogLevelArgv(argv, {
shouldPreserveLogLevel: ({ remainingArgs, logLevelIndex }) =>
remainingArgs[logLevelIndex - 1] === "--message",
}),
).toEqual(["node", "openclaw", "--log-level", "debug", "doctor", "--lint", "--json"]);
});
it("preserves log-level when final command metadata owns the option", () => {
const argv = ["node", "openclaw", "plugin-cmd", "--log-level", "debug"];
expect(
normalizeRootLogLevelArgv(argv, {
shouldPreserveLogLevel: ({ remainingArgs, logLevelIndex }) =>
remainingArgs[logLevelIndex] === "--log-level",
}),
).toEqual(argv);
});
it.each([
{
name: "root help command",
+80 -2
View File
@@ -260,17 +260,39 @@ export type NormalizeRootNoColorArgvOptions = {
}) => boolean;
};
export type NormalizeRootLogLevelArgvOptions = {
shouldPreserveLogLevel?: (params: {
remainingArgs: readonly string[];
logLevelIndex: number;
consumed: number;
}) => boolean;
};
function isPossibleCommandOptionValue(
remainingArgs: readonly string[],
noColorIndex: number,
optionIndex: number,
): boolean {
const previous = remainingArgs[noColorIndex - 1];
const previous = remainingArgs[optionIndex - 1];
if (!previous?.startsWith("-") || previous === FLAG_TERMINATOR) {
return false;
}
return !previous.includes("=");
}
function consumeRootLogLevelToken(args: readonly string[], index: number): number {
const arg = args[index];
if (!arg || arg === FLAG_TERMINATOR) {
return 0;
}
if (arg.startsWith("--log-level=")) {
return arg.slice("--log-level=".length).trim() ? 1 : 0;
}
if (arg === "--log-level") {
return isValueToken(args[index + 1]) ? 2 : 0;
}
return 0;
}
export function normalizeRootNoColorArgv(
argv: string[],
options: NormalizeRootNoColorArgvOptions = {},
@@ -323,6 +345,62 @@ export function normalizeRootNoColorArgv(
return [...prefix, ...rootPrefix, ...movedNoColorArgs, ...nextArgs];
}
export function normalizeRootLogLevelArgv(
argv: string[],
options: NormalizeRootLogLevelArgvOptions = {},
): string[] {
const prefix = argv.slice(0, 2);
const args = argv.slice(2);
let rootPrefixEnd = 0;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === FLAG_TERMINATOR) {
break;
}
const consumed = consumeRootOptionToken(args, index);
if (consumed <= 0) {
break;
}
rootPrefixEnd = index + consumed;
index += consumed - 1;
}
const rootPrefix = args.slice(0, rootPrefixEnd);
const remainingArgs = args.slice(rootPrefixEnd);
const movedLogLevelArgs: string[] = [];
const nextArgs: string[] = [];
for (let index = 0; index < remainingArgs.length; index += 1) {
const arg = remainingArgs[index];
if (arg === FLAG_TERMINATOR) {
nextArgs.push(...remainingArgs.slice(index));
break;
}
const consumed = consumeRootLogLevelToken(remainingArgs, index);
if (consumed > 0) {
const shouldPreserve =
options.shouldPreserveLogLevel?.({
remainingArgs,
logLevelIndex: index,
consumed,
}) ?? isPossibleCommandOptionValue(remainingArgs, index);
const tokens = remainingArgs.slice(index, index + consumed);
if (shouldPreserve) {
nextArgs.push(...tokens);
} else {
movedLogLevelArgs.push(...tokens);
}
index += consumed - 1;
continue;
}
nextArgs.push(arg);
}
if (movedLogLevelArgs.length === 0) {
return argv;
}
return [...prefix, ...rootPrefix, ...movedLogLevelArgs, ...nextArgs];
}
export function getFlagValue(argv: string[], name: string): string | null | undefined {
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
+47 -1
View File
@@ -18,6 +18,7 @@ import { resolveCliArgvInvocation } from "./argv-invocation.js";
import {
normalizeGeneratedHelpCommandArgv,
normalizeRootHelpTargetArgv,
normalizeRootLogLevelArgv,
normalizeRootNoColorArgv,
} from "./argv.js";
import {
@@ -425,6 +426,41 @@ function isNoColorConsumedAsCommandOptionValue(
return pendingValue;
}
function isLogLevelConsumedAsCommandOption(
program: CommanderCommand,
remainingArgs: readonly string[],
logLevelIndex: number,
): boolean {
let command = program;
let pendingValue = false;
for (let index = 0; index < logLevelIndex; index += 1) {
const arg = remainingArgs[index];
if (!arg || arg === FLAG_TERMINATOR) {
return false;
}
if (pendingValue) {
pendingValue = false;
continue;
}
if (arg.startsWith("-")) {
const option = findCommandOption(command, arg);
if (!option && index === logLevelIndex - 1 && !arg.includes("=")) {
return true;
}
pendingValue = shouldOptionConsumeFollowingToken(option, arg, remainingArgs[index + 1]);
continue;
}
command = findSubcommand(command, arg) ?? command;
}
if (pendingValue) {
return true;
}
const arg = remainingArgs[logLevelIndex];
return command !== program && arg !== undefined && findCommandOption(command, arg) !== undefined;
}
function normalizeRootNoColorArgvForProgram(argv: string[], program: CommanderCommand): string[] {
return normalizeRootNoColorArgv(argv, {
shouldPreserveNoColor: ({ remainingArgs, noColorIndex }) =>
@@ -432,6 +468,13 @@ function normalizeRootNoColorArgvForProgram(argv: string[], program: CommanderCo
});
}
function normalizeRootLogLevelArgvForProgram(argv: string[], program: CommanderCommand): string[] {
return normalizeRootLogLevelArgv(argv, {
shouldPreserveLogLevel: ({ remainingArgs, logLevelIndex }) =>
isLogLevelConsumedAsCommandOption(program, remainingArgs, logLevelIndex),
});
}
async function ensureCliEnvProxyDispatcher(): Promise<void> {
try {
const { hasEnvHttpProxyAgentConfigured } = await import("../infra/net/proxy-env.js");
@@ -1051,7 +1094,10 @@ export async function runCli(argv: string[] = process.argv) {
}
}
parseArgv = normalizeRootNoColorArgvForProgram(parseArgv, program);
parseArgv = normalizeRootLogLevelArgvForProgram(
normalizeRootNoColorArgvForProgram(parseArgv, program),
program,
);
stopStartupProgress();
try {