mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
CLI: unify routed config positional parsing
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user