Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 67 additions & 11 deletions packages/runtime/src/args.ts
Original file line number Diff line number Diff line change
@@ -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());
Expand All @@ -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"),
Expand Down
6 changes: 4 additions & 2 deletions packages/runtime/src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 46 additions & 2 deletions packages/runtime/tests/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ 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"]);
expect(r.path).toEqual(["image", "generate"]);
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([]);
Expand All @@ -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 ----
Expand Down
Loading