diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 665b47c4f60..de7c26cd01e 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -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", diff --git a/src/cli/argv.ts b/src/cli/argv.ts index ca989dc4a4b..7f8e5423b03 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -188,6 +188,91 @@ export function getPrimaryCommand(argv: string[]): string | null { return primary ?? null; } +type CommandPositionalsParseOptions = { + commandPath: ReadonlyArray; + booleanFlags?: ReadonlyArray; + valueFlags?: ReadonlyArray; +}; + +function consumeKnownOptionToken( + args: ReadonlyArray, + index: number, + booleanFlags: ReadonlySet, + valueFlags: ReadonlySet, +): 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[]; diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 0ce9dde4310..61be251097e 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -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"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 943d2aecad4..cea5fcb8138 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -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; }