CLI: unify routed config positional parsing

This commit is contained in:
Gustavo Madeira Santana
2026-03-02 21:11:16 -05:00
parent d3c637d193
commit 15a0455d04
4 changed files with 176 additions and 31 deletions

View File

@@ -3,6 +3,7 @@ import {
buildParseArgv,
getFlagValue,
getCommandPath,
getCommandPositionalsWithRootOptions,
getCommandPathWithRootOptions,
getPrimaryCommand,
getPositiveIntFlagValue,
@@ -170,6 +171,41 @@ describe("argv helpers", () => {
).toEqual(["config", "validate"]);
});
it("extracts routed config get positionals with interleaved root options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "get", "--log-level", "debug", "update.channel", "--json"],
{
commandPath: ["config", "get"],
booleanFlags: ["--json"],
},
),
).toEqual(["update.channel"]);
});
it("extracts routed config unset positionals with interleaved root options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"],
{
commandPath: ["config", "unset"],
},
),
).toEqual(["update.channel"]);
});
it("returns null when routed command sees unknown options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "get", "--mystery", "value", "update.channel"],
{
commandPath: ["config", "get"],
booleanFlags: ["--json"],
},
),
).toBeNull();
});
it.each([
{
name: "returns first command token",

View File

@@ -188,6 +188,91 @@ export function getPrimaryCommand(argv: string[]): string | null {
return primary ?? null;
}
type CommandPositionalsParseOptions = {
commandPath: ReadonlyArray<string>;
booleanFlags?: ReadonlyArray<string>;
valueFlags?: ReadonlyArray<string>;
};
function consumeKnownOptionToken(
args: ReadonlyArray<string>,
index: number,
booleanFlags: ReadonlySet<string>,
valueFlags: ReadonlySet<string>,
): number {
const arg = args[index];
if (!arg || arg === FLAG_TERMINATOR || !arg.startsWith("-")) {
return 0;
}
const equalsIndex = arg.indexOf("=");
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
if (booleanFlags.has(flag)) {
return equalsIndex === -1 ? 1 : 0;
}
if (!valueFlags.has(flag)) {
return 0;
}
if (equalsIndex !== -1) {
const value = arg.slice(equalsIndex + 1).trim();
return value ? 1 : 0;
}
return isValueToken(args[index + 1]) ? 2 : 0;
}
export function getCommandPositionalsWithRootOptions(
argv: string[],
options: CommandPositionalsParseOptions,
): string[] | null {
const args = argv.slice(2);
const commandPath = options.commandPath;
const booleanFlags = new Set(options.booleanFlags ?? []);
const valueFlags = new Set(options.valueFlags ?? []);
const positionals: string[] = [];
let commandIndex = 0;
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 (arg.startsWith("-")) {
const optionConsumed = consumeKnownOptionToken(args, i, booleanFlags, valueFlags);
if (optionConsumed === 0) {
return null;
}
i += optionConsumed - 1;
continue;
}
if (commandIndex < commandPath.length) {
if (arg !== commandPath[commandIndex]) {
return null;
}
commandIndex += 1;
continue;
}
positionals.push(arg);
}
if (commandIndex < commandPath.length) {
return null;
}
return positionals;
}
export function buildParseArgv(params: {
programName?: string;
rawArgs?: string[];

View File

@@ -102,6 +102,38 @@ describe("program routes", () => {
expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" });
});
it("passes config get path when root value options appear after subcommand", async () => {
const route = expectRoute(["config", "get"]);
await expect(
route?.run([
"node",
"openclaw",
"config",
"get",
"--log-level",
"debug",
"update.channel",
"--json",
]),
).resolves.toBe(true);
expect(runConfigGetMock).toHaveBeenCalledWith({ path: "update.channel", json: true });
});
it("passes config unset path when root value options appear after subcommand", async () => {
const route = expectRoute(["config", "unset"]);
await expect(
route?.run(["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"]),
).resolves.toBe(true);
expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" });
});
it("returns false for config get route when unknown option appears", async () => {
await expectRunFalse(
["config", "get"],
["node", "openclaw", "config", "get", "--mystery", "value", "update.channel"],
);
});
it("returns false for memory status route when --agent value is missing", async () => {
await expectRunFalse(["memory", "status"], ["node", "openclaw", "memory", "status", "--agent"]);
});

View File

@@ -1,6 +1,12 @@
import { consumeRootOptionToken, isValueToken } from "../../infra/cli-root-options.js";
import { isValueToken } from "../../infra/cli-root-options.js";
import { defaultRuntime } from "../../runtime.js";
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
import {
getCommandPositionalsWithRootOptions,
getFlagValue,
getPositiveIntFlagValue,
getVerboseFlag,
hasFlag,
} from "../argv.js";
export type RouteSpec = {
match: (path: string[]) => boolean;
@@ -100,31 +106,6 @@ const routeMemoryStatus: RouteSpec = {
},
};
function getCommandPositionals(argv: string[]): string[] {
const out: string[] = [];
const args = argv.slice(2);
let commandStarted = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg || arg === "--") {
break;
}
if (!commandStarted) {
const consumed = consumeRootOptionToken(args, i);
if (consumed > 0) {
i += consumed - 1;
continue;
}
}
if (arg.startsWith("-")) {
continue;
}
commandStarted = true;
out.push(arg);
}
return out;
}
function getFlagValues(argv: string[], name: string): string[] | null {
const values: string[] = [];
const args = argv.slice(2);
@@ -156,8 +137,14 @@ function getFlagValues(argv: string[], name: string): string[] | null {
const routeConfigGet: RouteSpec = {
match: (path) => path[0] === "config" && path[1] === "get",
run: async (argv) => {
const positionals = getCommandPositionals(argv);
const pathArg = positionals[2];
const positionals = getCommandPositionalsWithRootOptions(argv, {
commandPath: ["config", "get"],
booleanFlags: ["--json"],
});
if (!positionals || positionals.length !== 1) {
return false;
}
const pathArg = positionals[0];
if (!pathArg) {
return false;
}
@@ -171,8 +158,13 @@ const routeConfigGet: RouteSpec = {
const routeConfigUnset: RouteSpec = {
match: (path) => path[0] === "config" && path[1] === "unset",
run: async (argv) => {
const positionals = getCommandPositionals(argv);
const pathArg = positionals[2];
const positionals = getCommandPositionalsWithRootOptions(argv, {
commandPath: ["config", "unset"],
});
if (!positionals || positionals.length !== 1) {
return false;
}
const pathArg = positionals[0];
if (!pathArg) {
return false;
}