Skip to content

Commit 361f9c0

Browse files
committed
fix(cli): accept subcommand no-color
1 parent 6607916 commit 361f9c0

3 files changed

Lines changed: 199 additions & 3 deletions

File tree

src/cli/argv.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
isRootVersionInvocation,
1717
normalizeGeneratedHelpCommandArgv,
1818
normalizeRootHelpTargetArgv,
19+
normalizeRootNoColorArgv,
1920
shouldMigrateState,
2021
shouldMigrateStateFromPath,
2122
} from "./argv.js";
@@ -171,6 +172,61 @@ describe("argv helpers", () => {
171172
expect(normalizeRootHelpTargetArgv(argv)).toEqual(expected);
172173
});
173174

175+
it.each([
176+
{
177+
name: "subcommand trailing no-color",
178+
argv: ["node", "openclaw", "doctor", "--no-color", "--post-upgrade", "--json"],
179+
expected: ["node", "openclaw", "--no-color", "doctor", "--post-upgrade", "--json"],
180+
},
181+
{
182+
name: "keeps existing root options first",
183+
argv: ["node", "openclaw", "--profile", "work", "doctor", "--no-color", "--lint", "--json"],
184+
expected: [
185+
"node",
186+
"openclaw",
187+
"--profile",
188+
"work",
189+
"--no-color",
190+
"doctor",
191+
"--lint",
192+
"--json",
193+
],
194+
},
195+
{
196+
name: "keeps no-color after possible command option value",
197+
argv: ["node", "openclaw", "doctor", "--lint", "--json", "--no-color"],
198+
expected: ["node", "openclaw", "doctor", "--lint", "--json", "--no-color"],
199+
},
200+
{
201+
name: "flag terminator leaves no-color positional",
202+
argv: ["node", "openclaw", "doctor", "--", "--no-color"],
203+
expected: ["node", "openclaw", "doctor", "--", "--no-color"],
204+
},
205+
{
206+
name: "command option value remains literal",
207+
argv: ["node", "openclaw", "agent", "--message", "--no-color"],
208+
expected: ["node", "openclaw", "agent", "--message", "--no-color"],
209+
},
210+
{
211+
name: "assigned command option value does not block no-color",
212+
argv: ["node", "openclaw", "agent", "--message=hello", "--no-color"],
213+
expected: ["node", "openclaw", "--no-color", "agent", "--message=hello"],
214+
},
215+
])("normalizes root --no-color before command parsing: $name", ({ argv, expected }) => {
216+
expect(normalizeRootNoColorArgv(argv)).toEqual(expected);
217+
});
218+
219+
it("allows final command metadata to lift no-color after boolean command flags", () => {
220+
const argv = ["node", "openclaw", "doctor", "--lint", "--json", "--no-color"];
221+
222+
expect(
223+
normalizeRootNoColorArgv(argv, {
224+
shouldPreserveNoColor: ({ remainingArgs, noColorIndex }) =>
225+
remainingArgs[noColorIndex - 1] === "--message",
226+
}),
227+
).toEqual(["node", "openclaw", "--no-color", "doctor", "--lint", "--json"]);
228+
});
229+
174230
it.each([
175231
{
176232
name: "root help command",

src/cli/argv.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,76 @@ export function normalizeRootHelpTargetArgv(argv: string[]): string[] {
253253
return [argv[0], argv[1], ...rootOptions, ...targetPath, "--help"];
254254
}
255255

256+
export type NormalizeRootNoColorArgvOptions = {
257+
shouldPreserveNoColor?: (params: {
258+
remainingArgs: readonly string[];
259+
noColorIndex: number;
260+
}) => boolean;
261+
};
262+
263+
function isPossibleCommandOptionValue(
264+
remainingArgs: readonly string[],
265+
noColorIndex: number,
266+
): boolean {
267+
const previous = remainingArgs[noColorIndex - 1];
268+
if (!previous?.startsWith("-") || previous === FLAG_TERMINATOR) {
269+
return false;
270+
}
271+
return !previous.includes("=");
272+
}
273+
274+
export function normalizeRootNoColorArgv(
275+
argv: string[],
276+
options: NormalizeRootNoColorArgvOptions = {},
277+
): string[] {
278+
const prefix = argv.slice(0, 2);
279+
const args = argv.slice(2);
280+
let rootPrefixEnd = 0;
281+
for (let index = 0; index < args.length; index += 1) {
282+
const arg = args[index];
283+
if (!arg || arg === FLAG_TERMINATOR) {
284+
break;
285+
}
286+
const consumed = consumeRootOptionToken(args, index);
287+
if (consumed <= 0) {
288+
break;
289+
}
290+
rootPrefixEnd = index + consumed;
291+
index += consumed - 1;
292+
}
293+
294+
const rootPrefix = args.slice(0, rootPrefixEnd);
295+
const remainingArgs = args.slice(rootPrefixEnd);
296+
const movedNoColorArgs: string[] = [];
297+
const nextArgs: string[] = [];
298+
for (let index = 0; index < remainingArgs.length; index += 1) {
299+
const arg = remainingArgs[index];
300+
if (arg === FLAG_TERMINATOR) {
301+
nextArgs.push(...remainingArgs.slice(index));
302+
break;
303+
}
304+
if (arg === "--no-color") {
305+
// Commander can treat dash-prefixed tokens as command option values.
306+
// Early callers stay conservative; final Commander parse can pass metadata.
307+
const shouldPreserve =
308+
options.shouldPreserveNoColor?.({ remainingArgs, noColorIndex: index }) ??
309+
isPossibleCommandOptionValue(remainingArgs, index);
310+
if (shouldPreserve) {
311+
nextArgs.push(arg);
312+
continue;
313+
}
314+
movedNoColorArgs.push(arg);
315+
continue;
316+
}
317+
nextArgs.push(arg);
318+
}
319+
320+
if (movedNoColorArgs.length === 0) {
321+
return argv;
322+
}
323+
return [...prefix, ...rootPrefix, ...movedNoColorArgs, ...nextArgs];
324+
}
325+
256326
export function getFlagValue(argv: string[], name: string): string | null | undefined {
257327
const args = argv.slice(2);
258328
for (let i = 0; i < args.length; i += 1) {

src/cli/run-main.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ import path from "node:path";
44
import process from "node:process";
55
import { fileURLToPath } from "node:url";
66
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
7+
import type { Command as CommanderCommand, Option as CommanderOption } from "commander";
78
import { resolveStateDir } from "../config/paths.js";
89
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
10+
import { FLAG_TERMINATOR, isValueToken } from "../infra/cli-root-options.js";
911
import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
1012
import { isMainModule } from "../infra/is-main.js";
1113
import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js";
1214
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
1315
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
1416
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
1517
import { resolveCliArgvInvocation } from "./argv-invocation.js";
16-
import { normalizeGeneratedHelpCommandArgv, normalizeRootHelpTargetArgv } from "./argv.js";
18+
import {
19+
normalizeGeneratedHelpCommandArgv,
20+
normalizeRootHelpTargetArgv,
21+
normalizeRootNoColorArgv,
22+
} from "./argv.js";
1723
import {
1824
isReservedNonPluginCommandRoot,
1925
shouldRegisterPrimaryCommandOnly,
@@ -315,6 +321,69 @@ function isCommanderParseExit(error: unknown): error is { exitCode: number } {
315321
);
316322
}
317323

324+
function findCommandOption(command: CommanderCommand, token: string): CommanderOption | undefined {
325+
const equalsIndex = token.indexOf("=");
326+
const flag = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
327+
return command.options.find((option) => option.long === flag || option.short === flag);
328+
}
329+
330+
function findSubcommand(command: CommanderCommand, name: string): CommanderCommand | undefined {
331+
return command.commands.find(
332+
(subcommand) => subcommand.name() === name || subcommand.aliases().includes(name),
333+
);
334+
}
335+
336+
function shouldOptionConsumeFollowingToken(
337+
option: CommanderOption | undefined,
338+
token: string,
339+
next: string | undefined,
340+
): boolean {
341+
if (!option || token.includes("=")) {
342+
return false;
343+
}
344+
if (option.required) {
345+
return true;
346+
}
347+
return option.optional && isValueToken(next);
348+
}
349+
350+
function isNoColorConsumedAsCommandOptionValue(
351+
program: CommanderCommand,
352+
remainingArgs: readonly string[],
353+
noColorIndex: number,
354+
): boolean {
355+
let command = program;
356+
let pendingValue = false;
357+
for (let index = 0; index < noColorIndex; index += 1) {
358+
const arg = remainingArgs[index];
359+
if (!arg || arg === FLAG_TERMINATOR) {
360+
return false;
361+
}
362+
if (pendingValue) {
363+
pendingValue = false;
364+
continue;
365+
}
366+
if (arg.startsWith("-")) {
367+
const option = findCommandOption(command, arg);
368+
if (!option && index === noColorIndex - 1 && !arg.includes("=")) {
369+
// Unknown option surfaces may allow arbitrary flags; keep the value-safe behavior there.
370+
return true;
371+
}
372+
pendingValue = shouldOptionConsumeFollowingToken(option, arg, remainingArgs[index + 1]);
373+
continue;
374+
}
375+
command = findSubcommand(command, arg) ?? command;
376+
}
377+
return pendingValue;
378+
}
379+
380+
function normalizeRootNoColorArgvForProgram(argv: string[], program: CommanderCommand): string[] {
381+
return normalizeRootNoColorArgv(argv, {
382+
shouldPreserveNoColor: ({ remainingArgs, noColorIndex }) =>
383+
isNoColorConsumedAsCommandOptionValue(program, remainingArgs, noColorIndex),
384+
});
385+
}
386+
318387
async function ensureCliEnvProxyDispatcher(): Promise<void> {
319388
try {
320389
const { hasEnvHttpProxyAgentConfigured } = await import("../infra/net/proxy-env.js");
@@ -499,7 +568,7 @@ export async function runCli(argv: string[] = process.argv) {
499568
}
500569
return;
501570
}
502-
const normalizedArgv = normalizeRootHelpTargetArgv(parsedProfile.argv);
571+
const normalizedArgv = normalizeRootHelpTargetArgv(normalizeRootNoColorArgv(parsedProfile.argv));
503572
const normalizedInvocation = resolveCliArgvInvocation(normalizedArgv);
504573
const isHelpOrVersionInvocation = normalizedInvocation.hasHelpOrVersion;
505574
startupTrace.mark("argv");
@@ -753,7 +822,7 @@ export async function runCli(argv: string[] = process.argv) {
753822
return;
754823
}
755824

756-
const parseArgv = normalizeGeneratedHelpCommandArgv(rewriteUpdateFlagArgv(normalizedArgv));
825+
let parseArgv = normalizeGeneratedHelpCommandArgv(rewriteUpdateFlagArgv(normalizedArgv));
757826
const suppressStartupProgress = hasJsonOutputFlag(parseArgv);
758827
const { createCliProgress } = await loadProgressModule();
759828
const startupProgress = createCliProgress({
@@ -895,6 +964,7 @@ export async function runCli(argv: string[] = process.argv) {
895964
}
896965
}
897966

967+
parseArgv = normalizeRootNoColorArgvForProgram(parseArgv, program);
898968
stopStartupProgress();
899969

900970
try {

0 commit comments

Comments
 (0)