diff --git a/packages/runtime/src/args.ts b/packages/runtime/src/args.ts index bca2fb701..2c391ca2a 100644 --- a/packages/runtime/src/args.ts +++ b/packages/runtime/src/args.ts @@ -1,5 +1,5 @@ import type { FlagsDef, ParsedFlags } from "bailian-cli-core"; -import { UsageError } from "bailian-cli-core"; +import { GLOBAL_FLAGS, UsageError } from "bailian-cli-core"; function kebabToCamel(str: string): string { return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); @@ -11,26 +11,82 @@ export function camelToKebab(str: string): string { } export interface ParsePathResult { - /** Command path: the leading run of bare tokens, e.g. ["speech", "recognize"]. */ + /** Command path: consecutive bare tokens after any leading global flags. */ path: string[]; - /** Everything from the first flag onward — handed to parseFlags later. */ + /** + * Flag region handed to parseFlags later. + * Includes peeled leading GLOBAL_FLAGS plus everything after the command path. + */ rest: string[]; hasHelpFlag: boolean; hasVersionFlag: boolean; } /** - * First pass — routing only. The command path is the leading run of bare - * (non-`-`) tokens; the first flag ends it ("command path first, then flags", - * oclif-style). There are no positionals, so nothing bare can legitimately - * follow a flag — and no flags precede the path, so this needs no schema. + * First-pass routing parse. + * - Known GLOBAL_FLAGS may appear before the command path (git/docker style). + * - Bare tokens form the command path; everything after the path goes into rest. + * - Unknown flags before the path throw UsageError (no silent root-help fallback). */ export function parsePath(argv: string[]): ParsePathResult { - let i = 0; - while (i < argv.length && !argv[i]!.startsWith("-")) i++; - const rest = argv.slice(i); + const leadingRest: string[] = []; + let index = 0; + + while (index < argv.length) { + const argument = argv[index]!; + if (!argument.startsWith("-")) break; + + if (!argument.startsWith("--")) { + throw new UsageError(`Unknown flag "${argument}". Use the --long form.`); + } + + const equalsIndex = argument.indexOf("="); + const rawKey = equalsIndex !== -1 ? argument.slice(2, equalsIndex) : argument.slice(2); + if (rawKey === "") { + throw new UsageError(`Unknown flag "${argument}".`); + } + + const flagKey = kebabToCamel(rawKey); + const flagDef = + flagKey in GLOBAL_FLAGS ? GLOBAL_FLAGS[flagKey as keyof typeof GLOBAL_FLAGS] : undefined; + if (!flagDef) { + throw new UsageError( + `Unknown flag "--${rawKey}" before the command path. Only global flags may appear before the command; put command-specific flags after it.`, + ); + } + + if (flagDef.type === "switch") { + leadingRest.push(argument); + index += 1; + continue; + } + + // Value-taking global flags: support --flag=value and --flag value. + if (equalsIndex !== -1) { + leadingRest.push(argument); + index += 1; + continue; + } + + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + // Leave a missing value for parseFlags so trailing form keeps the same error path. + leadingRest.push(argument); + index += 1; + continue; + } + + leadingRest.push(argument, value); + index += 2; + } + + const pathStart = index; + while (index < argv.length && !argv[index]!.startsWith("-")) index += 1; + const path = argv.slice(pathStart, index); + const rest = leadingRest.concat(argv.slice(index)); + return { - path: argv.slice(0, i), + path, rest, hasHelpFlag: rest.includes("--help"), hasVersionFlag: rest.includes("--version"), diff --git a/packages/runtime/src/resolve.ts b/packages/runtime/src/resolve.ts index b04377313..4416ee337 100644 --- a/packages/runtime/src/resolve.ts +++ b/packages/runtime/src/resolve.ts @@ -20,8 +20,10 @@ export type Resolution = /** * Classify argv into a {@link Resolution}. Pure over (argv, registry): one - * `parsePath` scan for the command path + flag region, one `registry.locate` - * for the routing decision. Never throws. Trivially unit-testable. + * `parsePath` scan for the command path + flag region (leading GLOBAL_FLAGS + * allowed), one `registry.locate` for the routing decision. `parsePath` may + * throw UsageError for unknown flags before the command path; otherwise this + * function itself does not throw. Trivially unit-testable. */ export function resolve(argv: string[], registry: CommandRegistry): Resolution { const { path, rest, hasHelpFlag, hasVersionFlag } = parsePath(argv); diff --git a/packages/runtime/tests/args.test.ts b/packages/runtime/tests/args.test.ts index 845d98c04..99850b69d 100644 --- a/packages/runtime/tests/args.test.ts +++ b/packages/runtime/tests/args.test.ts @@ -12,7 +12,7 @@ const IMAGE_GENERATE_FLAGS = { } satisfies FlagsDef; const OPTS = { ...GLOBAL_FLAGS, ...IMAGE_GENERATE_FLAGS }; -// ---- parsePath: routing only (command path first, then flags) ---- +// ---- parsePath: routing (leading GLOBAL_FLAGS + command path + trailing flags) ---- test("parsePath splits leading bare tokens as the command path", () => { const r = parsePath(["image", "generate", "--prompt", "cat"]); @@ -20,7 +20,7 @@ test("parsePath splits leading bare tokens as the command path", () => { expect(r.rest).toEqual(["--prompt", "cat"]); }); -test("parsePath stops the path at the first flag", () => { +test("parsePath stops the path at the first trailing flag", () => { const r = parsePath(["speech"]); expect(r.path).toEqual(["speech"]); expect(r.rest).toEqual([]); @@ -31,6 +31,50 @@ test("parsePath detects --help and --version in the flag region", () => { const v = parsePath(["--version"]); expect(v.hasVersionFlag).toBe(true); expect(v.path).toEqual([]); + expect(v.rest).toEqual(["--version"]); +}); + +test("parsePath peels leading --config before the command path", () => { + const r = parsePath(["--config", "token-plan", "config", "show"]); + expect(r.path).toEqual(["config", "show"]); + expect(r.rest).toEqual(["--config", "token-plan"]); +}); + +test("parsePath peels leading --config=value before the command path", () => { + const r = parsePath(["--config=token-plan", "auth", "status"]); + expect(r.path).toEqual(["auth", "status"]); + expect(r.rest).toEqual(["--config=token-plan"]); +}); + +test("parsePath peels multiple leading global flags and keeps trailing flags", () => { + const r = parsePath(["--verbose", "--config", "token-plan", "text", "chat", "--prompt", "hi"]); + expect(r.path).toEqual(["text", "chat"]); + expect(r.rest).toEqual(["--verbose", "--config", "token-plan", "--prompt", "hi"]); +}); + +test("parsePath still accepts global flags after the command path", () => { + const r = parsePath(["config", "show", "--config", "token-plan", "--quiet"]); + expect(r.path).toEqual(["config", "show"]); + expect(r.rest).toEqual(["--config", "token-plan", "--quiet"]); +}); + +test("parsePath rejects unknown flags before the command path", () => { + expect(() => parsePath(["--prompt", "hi", "text", "chat"])).toThrowError( + expect.objectContaining({ + name: "UsageError", + exitCode: ExitCode.USAGE, + message: expect.stringContaining('Unknown flag "--prompt" before the command path'), + }), + ); +}); + +test("parsePath rejects short flags before the command path", () => { + expect(() => parsePath(["-h", "config", "show"])).toThrowError( + expect.objectContaining({ + name: "UsageError", + message: expect.stringContaining("Use the --long form"), + }), + ); }); // ---- parseFlags: typed parsing ----