diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index ecd5c10a9..8e2f301aa 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -40,6 +40,7 @@ - kscli:`runKscli` from `packages/kscli/tests/e2e/helpers.ts` - 全量 leaf/group help 使用产品 `commands` 创建 `CommandRegistry`,先通过 `resolve([...path, "--help"])` 检查 help 路由,再用 `captureRegistryHelp` 检查完整 Usage;禁止在 `test.each(commandPaths/groupPaths)` 中逐条启动 `tsx` 子进程 - 真实子进程只保留根 help/version、产品身份、代表性叶子 help/鉴权域和缺参退出码等 shell/stdio/env 契约 +- 子进程的 parser 错误和 help 文案遵循 Profile 语言;语义断言应兼容中英文,同时检查参数名和退出码,并使用独立的中英文配置复跑,避免依赖本机 Profile ### 共享 diff --git a/docs/agents/command-flag-change.md b/docs/agents/command-flag-change.md index a8ff6531e..f332e4a3f 100644 --- a/docs/agents/command-flag-change.md +++ b/docs/agents/command-flag-change.md @@ -24,6 +24,7 @@ ### B. 鉴权 / 全局选项 - [ ] 如果是**全局 flag**(所有命令通用),改 `packages/core/src/types/command.ts` 的 `GLOBAL_FLAGS` +- [ ] 全局参数支持完整命令路径前置或后置;命令专属参数和凭证域参数只支持后置,路径各级之间不插入参数。改路由解析时,覆盖根/分组/显式 help 的无效参数校验、前后重复参数、Profile 选择和激活状态不变;纯裸命令与合法分组帮助仍返回 0,参数错误返回 2 - [ ] 如果是凭证域 flag,优先确认是否属于 `MODEL_AUTH_FLAGS` 或 `CONSOLE_AUTH_FLAGS`;不要在单个命令里重复声明 - [ ] 如果新 flag 影响有效配置面,改 `packages/core/src/config/schema.ts` 的 `Settings` 接口 - [ ] 如果对应 env var 或 config 文件字段,改 `packages/core/src/config/loader.ts` 的 `buildSettings` diff --git a/packages/cli/tests/e2e/config-profile.e2e.test.ts b/packages/cli/tests/e2e/config-profile.e2e.test.ts index 8dec96eb5..f4f9e1a56 100644 --- a/packages/cli/tests/e2e/config-profile.e2e.test.ts +++ b/packages/cli/tests/e2e/config-profile.e2e.test.ts @@ -111,6 +111,112 @@ describe("e2e: named config", () => { }); }); + test.each(["config show", "auth status"])( + "前置和后置 --config 为 %s 选择同一份非激活 Profile,且不修改配置", + async (commandPath) => { + await withTempConfigDir(async (configDir) => { + const originalConfig = { + active_config: "default", + api_key: "sk-default-review", + base_url: "https://default.example.test", + work: { + api_key: "sk-work-review", + base_url: "https://work.example.test", + access_token: "console-work-review", + console_site: "international", + console_region: "ap-southeast-1", + workspace_id: "work-space-review", + output: "json", + }, + }; + writeConfig(configDir, originalConfig); + const env = { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + DASHSCOPE_OUTPUT: "", + ALIBABA_CLOUD_ACCESS_KEY_ID: "", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", + }; + const leading = await runCli(["--config", "work", ...commandPath.split(" ")], env); + const trailing = await runCli([...commandPath.split(" "), "--config=work"], env); + expect(leading.exitCode, leading.stderr).toBe(0); + expect(trailing.exitCode, trailing.stderr).toBe(0); + const leadingData = parseStdoutJson>(leading.stdout); + expect(leadingData).toEqual(parseStdoutJson(trailing.stdout)); + expect(leadingData.config).toBe("work"); + if (commandPath === "auth status") { + expect(leadingData).toMatchObject({ + authenticated: true, + api_key: { source: "config", base_url: "https://work.example.test" }, + console: { source: "config", site: "international", region: "ap-southeast-1" }, + }); + } else { + expect(leadingData.base_url).toBe("https://work.example.test"); + expect(leadingData.workspace_id).toBe("work-space-review"); + } + expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))).toEqual( + originalConfig, + ); + }); + }, + ); + + test.each([ + { language: "en-US", expectedMessage: /before the command path/, expectedHint: /after/ }, + { language: "zh-CN", expectedMessage: /命令路径之前/, expectedHint: /之后/ }, + ])( + "前置非全局参数的 JSON 错误遵循选中 Profile 的语言 $language", + async ({ language, expectedMessage, expectedHint }) => { + await withTempConfigDir(async (configDir) => { + writeConfig(configDir, { language: "en-US", work: { language } }); + const result = await runCli( + ["--config=work", "--output=json", "--prompt", "hi", "text", "chat"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + const { error } = JSON.parse(result.stderr) as { + error: { code: number; message: string; hint: string }; + }; + expect(error.code).toBe(2); + expect(error.message).toMatch(expectedMessage); + expect(error.hint).toMatch(expectedHint); + }); + }, + ); + + test.each([ + { argv: ["--config=work", "auth"], help: /Usage: bl auth / }, + { argv: ["--config=work"], help: /Usage: bl / }, + ])("合法前置参数保留相应帮助页: $argv", async ({ argv, help }) => { + await withTempConfigDir(async (configDir) => { + writeConfig(configDir, { work: { language: "en-US" } }); + const result = await runCli(argv, { BAILIAN_CONFIG_DIR: configDir }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toMatch(help); + expect(result.stdout).toBe(""); + }); + }); + + test.each([ + ["auth", "--output=json", "--timeout"], + ["--output=json", "--timeout", "--help"], + ["--config=work", "--output=json", "--timeout", "--version"], + ])("中文帮助分支缺少参数值时返回结构化用法错误: %j", async (...argv) => { + await withTempConfigDir(async (configDir) => { + writeConfig(configDir, { language: "zh-CN", work: { language: "zh-CN" } }); + const result = await runCli(argv, { + BAILIAN_CONFIG_DIR: configDir, + }); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { code: 2, message: expect.stringMatching(/--timeout.*需要.*值/) }, + }); + }); + }); + test("--config default 等价默认配置", async () => { await withTempConfigDir(async (dir) => { writeConfig(dir, { diff --git a/packages/cli/tests/e2e/registry.smoke.e2e.test.ts b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts index b94b9eecf..561f374b3 100644 --- a/packages/cli/tests/e2e/registry.smoke.e2e.test.ts +++ b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts @@ -67,7 +67,7 @@ describe("e2e: bl registry smoke", () => { test.each(commandPaths)("已注册命令 %s --help 成功", (path) => { const commandPath = path.split(" "); - expect(resolve([...commandPath, "--help"], registry)).toEqual({ + expect(resolve([...commandPath, "--help"], registry)).toMatchObject({ kind: "help", path: commandPath, }); @@ -77,7 +77,7 @@ describe("e2e: bl registry smoke", () => { test.each(groupPaths)("命令分组 %s --help 成功", (path) => { const commandPath = path.split(" "); - expect(resolve([...commandPath, "--help"], registry)).toEqual({ + expect(resolve([...commandPath, "--help"], registry)).toMatchObject({ kind: "help", path: commandPath, }); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 2c0cd22ee..b9f6dadb9 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -764,8 +764,8 @@ describe("e2e: auth", () => { "--base-url", "https://x.test", ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/Unknown flag.*--base-url/); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--base-url/); }); test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => { @@ -788,8 +788,8 @@ describe("e2e: auth", () => { expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder"); const denied = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--access-key-id", "ak"]); - expect(denied.exitCode).not.toBe(0); - expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/); + expect(denied.exitCode).toBe(2); + expect(denied.stderr).toMatch(/(?:Unknown flag|未知参数).*--access-key-id/); }); test.skipIf(!isOpenApiE2EReady())( diff --git a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts index 71e83b508..d817b7944 100644 --- a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts +++ b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts @@ -92,8 +92,8 @@ describe("e2e: console global flags (dry-run)", () => { "--region", "cn", ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/Unknown flag.*--region/); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--region/); }); test("mcp list --dry-run --console-region 透传", async () => { diff --git a/packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts b/packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts index 855e3df51..5fcb7c00d 100644 --- a/packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts @@ -163,7 +163,7 @@ describe("e2e: managed-agent project init directory defaults", () => { const directory = await temporaryDirectory(); const removedFlag = await runProject(directory, "build", ["--yes"]); expect(removedFlag.exitCode).toBe(2); - expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/); + expect(removedFlag.stderr).toMatch(/(?:Unknown flag|未知参数).*--yes/); }); test("nested build and validate explain the project root without changing directories", async () => { diff --git a/packages/commands/tests/e2e/managed-agent.e2e.test.ts b/packages/commands/tests/e2e/managed-agent.e2e.test.ts index 6d6dd4073..bae03e916 100644 --- a/packages/commands/tests/e2e/managed-agent.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent.e2e.test.ts @@ -245,7 +245,7 @@ describe("e2e: managed-agent", () => { "--refresh-only", ]); expect(exitCode).toBe(2); - expect(stderr).toMatch(/Unknown flag.*--refresh-only/i); + expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--refresh-only/i); }); test("managed-agent init 不再暴露 Git 仓库脚手架", async () => { @@ -538,8 +538,8 @@ agents: ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--name|--model|--instructions|--skill|--skill-dir|--type|--yes/i); - expect(stderr).toMatch(/remote Skill ID/i); - expect(stderr).toMatch(/local Skill directory/i); + expect(stderr).toMatch(/remote Skill ID|远端 Skill ID/i); + expect(stderr).toMatch(/local Skill directory|本地 Skill 目录/i); expect(stderr).not.toMatch(/--key|--environment|--vault/i); }); @@ -577,7 +577,7 @@ agents: "--quiet", ]); expect(exitCode).toBe(2); - expect(stderr).toMatch(/Unknown option|unknown flag|unexpected/i); + expect(stderr).toMatch(new RegExp(`(?:Unknown flag|未知参数).*${flag}`, "i")); }); test("managed-agent agent create 将 official Skill ID 写为外部引用", async () => { @@ -1355,7 +1355,9 @@ vaults: "--quiet", ]); expect(exitCode).toBe(2); - expect(stderr).toMatch(/--source must be one of: custom, official/i); + expect(stderr).toMatch( + /--source (?:must be one of: |必须为以下值之一:)custom, official, all/i, + ); }); test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => { @@ -1376,7 +1378,7 @@ vaults: ); // all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出 expect(exitCode).toBe(2); - expect(stderr).not.toMatch(/--source must be one of/i); + expect(stderr).not.toMatch(/--source (?:must be one of|必须为以下值之一)/i); expect(stderr).toMatch(/File not found.*agents\.e2e-missing\.yaml/i); }); @@ -1419,7 +1421,7 @@ vaults: "bailian", ]); expect(providerResult.exitCode).toBe(2); - expect(providerResult.stderr).toMatch(/Unknown flag.*--provider/i); + expect(providerResult.stderr).toMatch(/(?:Unknown flag|未知参数).*--provider/i); const purposeResult = await runCommandE2e(MANAGED_AGENT_ROUTES, [ "managed-agent", @@ -1432,7 +1434,7 @@ vaults: "agent", ]); expect(purposeResult.exitCode).toBe(2); - expect(purposeResult.stderr).toMatch(/Unknown flag.*--purpose/i); + expect(purposeResult.stderr).toMatch(/(?:Unknown flag|未知参数).*--purpose/i); }); }); @@ -1537,7 +1539,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => "claude", ]); expect(exitCode).toBe(2); - expect(stderr).toMatch(/Unknown flag.*--provider/i); + expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--provider/i); }); test("project init --dry-run 不需要密钥且不创建目录", async () => { diff --git a/packages/commands/tests/e2e/pipeline.e2e.test.ts b/packages/commands/tests/e2e/pipeline.e2e.test.ts index 722168973..1eaa5b969 100644 --- a/packages/commands/tests/e2e/pipeline.e2e.test.ts +++ b/packages/commands/tests/e2e/pipeline.e2e.test.ts @@ -238,7 +238,7 @@ describe("e2e: pipeline", () => { ]); expect(exitCode).toBe(2); expect(stdout).toBe(""); - expect(stderr).toMatch(/--events must be one of: jsonl/i); + expect(stderr).toMatch(/--events (?:must be one of: |必须为以下值之一:)jsonl/i); }); test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => { diff --git a/packages/commands/tests/e2e/quota.e2e.test.ts b/packages/commands/tests/e2e/quota.e2e.test.ts index b0ed9764b..52a45a016 100644 --- a/packages/commands/tests/e2e/quota.e2e.test.ts +++ b/packages/commands/tests/e2e/quota.e2e.test.ts @@ -93,14 +93,14 @@ describe("e2e: quota", () => { "--delete", ]); expect(exitCode).toBe(2); - expect(stderr).toContain("Unknown flag"); + expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--delete/); }); test("quota delete 缺少 --model 报用法错误", async () => { // 裸 `quota delete`(无任何 flag)会渲染 help 并正常退出,需带 flag 触发必填校验 const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "delete", "--yes"]); expect(exitCode).toBe(2); - expect(stderr).toContain("Missing required flag: --model"); + expect(stderr).toMatch(/(?:Missing required flag: |缺少必填参数:)--model/); }); test("quota delete 无 --yes 返回确认请求 (7)", async () => { diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 54bcd0c48..fce85ac6f 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -34,3 +34,4 @@ export { type ApiKeyLoginKind, } from "./profile-presets.ts"; export { normalizeModelBaseUrl } from "./model-base-url.ts"; +export { validateTimeout } from "./validation.ts"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index faec8390e..aa0c7137d 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -5,6 +5,7 @@ import { detectOutputFormat } from "../output/formatter.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import type { SourceFlags } from "../types/command.ts"; +import { validateTimeout } from "./validation.ts"; const CONFIG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; const ACTIVE_CONFIG_KEY = "active_config"; @@ -256,13 +257,11 @@ export function buildSettings(s: ResolutionSources): Settings { const { flags, file, env } = s; const envTimeout = env.DASHSCOPE_TIMEOUT ? Number(env.DASHSCOPE_TIMEOUT) : undefined; - const validEnvTimeout = - envTimeout !== undefined && Number.isFinite(envTimeout) && envTimeout > 0 - ? envTimeout - : undefined; + const validEnvTimeout = validateTimeout(envTimeout) === undefined ? envTimeout : undefined; const timeout = flags.timeout ?? validEnvTimeout ?? file.timeout ?? 300; - if (!Number.isFinite(timeout) || timeout <= 0) { - throw new BailianError("Timeout must be a positive finite number.", ExitCode.USAGE); + const timeoutError = validateTimeout(timeout); + if (timeoutError) { + throw new BailianError(timeoutError["en-US"], ExitCode.USAGE); } return { diff --git a/packages/core/src/config/validation.ts b/packages/core/src/config/validation.ts new file mode 100644 index 000000000..c0ba1b3e1 --- /dev/null +++ b/packages/core/src/config/validation.ts @@ -0,0 +1,14 @@ +import type { LocalizedText } from "../types/command.ts"; + +const TIMEOUT_ERROR_MESSAGE = { + "en-US": "Timeout must be a positive finite number.", + "zh-CN": "请求超时必须是一个有限的正数。", +} as const satisfies LocalizedText; + +/** Shared timeout constraint for effective settings and CLI flag validation. */ +export function validateTimeout(timeout: unknown): typeof TIMEOUT_ERROR_MESSAGE | undefined { + if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) { + return TIMEOUT_ERROR_MESSAGE; + } + return undefined; +} diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 5974df906..901cd3e1a 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -187,6 +187,32 @@ test("timeout:flag > 合法 env > file > 300;非法 env 被跳过;非法 flag expect(() => resolve({ flags: { timeout: -1 } })).toThrow(/Timeout/); }); +test.each([0, -1, NaN, Infinity, -Infinity])( + "invalid explicit timeout %s is rejected instead of falling back to env or config", + (timeout) => { + expect(() => + resolve({ + flags: { timeout }, + env: { DASHSCOPE_TIMEOUT: "20" }, + file: { timeout: 30 }, + }), + ).toThrow(expect.objectContaining({ exitCode: 2 })); + }, +); + +test.each(["0", "-1", "NaN", "Infinity", "-Infinity"])( + "invalid environment timeout %s falls back to the selected config", + (environmentTimeout) => { + expect( + resolve({ env: { DASHSCOPE_TIMEOUT: environmentTimeout }, file: { timeout: 30 } }).timeout, + ).toBe(30); + }, +); + +test("positive fractional timeouts remain valid", () => { + expect(resolve({ flags: { timeout: 0.1 } }).timeout).toBe(0.1); +}); + test("workspaceId:flag > env > file(console 域 flag)", () => { const file: ConfigFile = { workspace_id: "ws-file" }; const env = { BAILIAN_WORKSPACE_ID: "ws-env" }; diff --git a/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts index 8a0f8080d..a483ac138 100644 --- a/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts +++ b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts @@ -43,7 +43,7 @@ describe("e2e: kscli registry smoke", () => { test.each(commandPaths)("已注册命令 %s --help 成功", (path) => { const commandPath = path.split(" "); - expect(resolve([...commandPath, "--help"], registry)).toEqual({ + expect(resolve([...commandPath, "--help"], registry)).toMatchObject({ kind: "help", path: commandPath, }); @@ -53,7 +53,7 @@ describe("e2e: kscli registry smoke", () => { test.each(groupPaths)("命令分组 %s --help 成功", (path) => { const commandPath = path.split(" "); - expect(resolve([...commandPath, "--help"], registry)).toEqual({ + expect(resolve([...commandPath, "--help"], registry)).toMatchObject({ kind: "help", path: commandPath, }); diff --git a/packages/runtime/src/args.ts b/packages/runtime/src/args.ts index bca2fb701..69ff48337 100644 --- a/packages/runtime/src/args.ts +++ b/packages/runtime/src/args.ts @@ -1,36 +1,113 @@ -import type { FlagsDef, ParsedFlags } from "bailian-cli-core"; -import { UsageError } from "bailian-cli-core"; +import type { FlagsDef, LocalizedText, ParsedFlags } from "bailian-cli-core"; +import { DEFAULT_LANGUAGE, GLOBAL_FLAGS, UsageError } from "bailian-cli-core"; +import { createTranslator, type Translator } from "./i18n.ts"; -function kebabToCamel(str: string): string { - return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +const defaultLocalize: Translator["localize"] = (text) => + createTranslator(DEFAULT_LANGUAGE).localize(text); + +function usageError( + localize: Translator["localize"], + message: LocalizedText, + hint?: LocalizedText, +): UsageError { + return new UsageError(localize(message), hint === undefined ? undefined : localize(hint)); +} + +function kebabToCamel(value: string): string { + return value.replace(/-([a-z])/g, (_match, character: string) => character.toUpperCase()); } /** maxTokens → max-tokens. For rendering flags in help / error messages. */ -export function camelToKebab(str: string): string { - return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +export function camelToKebab(value: string): string { + return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`); } 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); +export function parsePath( + argv: string[], + localize: Translator["localize"] = defaultLocalize, +): ParsePathResult { + const leadingRest: string[] = []; + let index = 0; + + while (index < argv.length) { + const argument = argv[index]!; + if (!argument.startsWith("-")) break; + + if (!argument.startsWith("--")) { + throw usageError(localize, { + "en-US": `Unknown flag "${argument}". Use the --long form.`, + "zh-CN": `未知参数 "${argument}"。请使用 -- 开头的完整参数名。`, + }); + } + + const equalsIndex = argument.indexOf("="); + const rawKey = equalsIndex !== -1 ? argument.slice(2, equalsIndex) : argument.slice(2); + if (rawKey === "") { + throw usageError(localize, { + "en-US": `Unknown flag "${argument}".`, + "zh-CN": `未知参数 "${argument}"。`, + }); + } + + const flagKey = kebabToCamel(rawKey); + const flagDef = Object.hasOwn(GLOBAL_FLAGS, flagKey) + ? GLOBAL_FLAGS[flagKey as keyof typeof GLOBAL_FLAGS] + : undefined; + if (!flagDef) { + throw usageError( + localize, + { + "en-US": `Unknown flag "--${rawKey}" before the command path.`, + "zh-CN": `参数 "--${rawKey}" 不能放在命令路径之前。`, + }, + { + "en-US": + "Only global flags may appear before the command path. Put command-specific and credential flags after the complete command path; use --help to see available flags.", + "zh-CN": + "命令路径之前只能使用全局参数。请将命令专属参数和鉴权参数放在完整命令路径之后;使用 --help 查看可用参数。", + }, + ); + } + + if (flagDef.type === "switch" || equalsIndex !== -1) { + leadingRest.push(argument); + index += 1; + continue; + } + + const value = argv[index + 1]; + // Preserve an incomplete flag for the unified parser to report its error. + if (value === undefined || value.startsWith("--")) break; + + 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"), @@ -42,84 +119,121 @@ export function parsePath(argv: string[]): ParsePathResult { * keyed FlagsDef (key = camelCase flag name). Pure: returns typed flags or * throws UsageError — never prints/exits. The error boundary decides rendering. */ -export function parseFlags(rest: string[], defs: F): ParsedFlags { +export function parseFlags( + rest: string[], + defs: F, + localize: Translator["localize"] = defaultLocalize, + options: { enforceRequired?: boolean } = {}, +): ParsedFlags { const flags: Record = {}; const seen = new Set(); - for (const [key, def] of Object.entries(defs)) { - if (def.type === "switch") flags[key] = false; + for (const [key, definition] of Object.entries(defs)) { + if (definition.type === "switch") flags[key] = false; } - let i = 0; - while (i < rest.length) { - const arg = rest[i]!; + let index = 0; + while (index < rest.length) { + const argument = rest[index]!; - if (!arg.startsWith("-")) { - throw new UsageError(`Unexpected argument: ${arg}`); + if (!argument.startsWith("-")) { + throw usageError(localize, { + "en-US": `Unexpected argument: ${argument}`, + "zh-CN": `非预期参数:${argument}`, + }); } - if (!arg.startsWith("--")) { - throw new UsageError(`Unknown flag "${arg}". Use the --long form.`); + if (!argument.startsWith("--")) { + throw usageError(localize, { + "en-US": `Unknown flag "${argument}". Use the --long form.`, + "zh-CN": `未知参数 "${argument}"。请使用 -- 开头的完整参数名。`, + }); } - const eqIdx = arg.indexOf("="); - const rawKey = eqIdx !== -1 ? arg.slice(2, eqIdx) : arg.slice(2); - let value: string | undefined = eqIdx !== -1 ? arg.slice(eqIdx + 1) : undefined; + const equalsIndex = argument.indexOf("="); + const rawKey = equalsIndex !== -1 ? argument.slice(2, equalsIndex) : argument.slice(2); + let value: string | undefined = + equalsIndex !== -1 ? argument.slice(equalsIndex + 1) : undefined; if (rawKey === "") { - throw new UsageError(`Unknown flag "${arg}".`); + throw usageError(localize, { + "en-US": `Unknown flag "${argument}".`, + "zh-CN": `未知参数 "${argument}"。`, + }); } const key = kebabToCamel(rawKey); - const def = defs[key]; - if (!def) { - throw new UsageError(`Unknown flag "--${rawKey}". Run with --help to see available options.`); + const definition = Object.hasOwn(defs, key) ? defs[key] : undefined; + if (!definition) { + throw usageError(localize, { + "en-US": `Unknown flag "--${rawKey}". Run with --help to see available options.`, + "zh-CN": `未知参数 "--${rawKey}"。请使用 --help 查看可用参数。`, + }); } - if (def.type === "switch") { + if (definition.type === "switch") { if (value !== undefined) { - throw new UsageError(`Flag --${rawKey} is a switch and takes no value.`); + throw usageError(localize, { + "en-US": `Flag --${rawKey} is a switch and takes no value.`, + "zh-CN": `参数 --${rawKey} 是开关,不接受值。`, + }); } flags[key] = true; - i++; + index += 1; continue; } if (value === undefined) { - const next = rest[i + 1]; + const next = rest[index + 1]; if (next === undefined || next.startsWith("--")) { - throw new UsageError(`Flag --${rawKey} requires a value.`); + throw usageError(localize, { + "en-US": `Flag --${rawKey} requires a value.`, + "zh-CN": `参数 --${rawKey} 需要一个值。`, + }); } value = next; - i += 2; + index += 2; } else { - i += 1; + index += 1; } - if (def.choices && !def.choices.includes(value)) { - throw new UsageError(`Flag --${rawKey} must be one of: ${def.choices.join(", ")}.`); + if (definition.choices && !definition.choices.includes(value)) { + throw usageError(localize, { + "en-US": `Flag --${rawKey} must be one of: ${definition.choices.join(", ")}.`, + "zh-CN": `参数 --${rawKey} 必须为以下值之一:${definition.choices.join(", ")}。`, + }); } - if (def.type === "array") { - const arr = flags[key] as string[] | undefined; - if (arr) arr.push(value); + if (definition.type === "array") { + const values = flags[key] as string[] | undefined; + if (values) values.push(value); else flags[key] = [value]; continue; } if (seen.has(key)) { - throw new UsageError(`Flag --${rawKey} given more than once.`); + throw usageError(localize, { + "en-US": `Flag --${rawKey} given more than once.`, + "zh-CN": `参数 --${rawKey} 不能重复指定。`, + }); } seen.add(key); - if (def.type === "number") { - const n = Number(value); - if (!Number.isFinite(n)) { - throw new UsageError(`Flag --${rawKey} requires a finite number.`); + if (definition.type === "number") { + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + throw usageError(localize, { + "en-US": `Flag --${rawKey} requires a finite number.`, + "zh-CN": `参数 --${rawKey} 需要一个有限数值。`, + }); } - flags[key] = n; - } else if (def.type === "boolean") { - const v = value.trim().toLowerCase(); - if (v === "true") flags[key] = true; - else if (v === "false") flags[key] = false; - else throw new UsageError(`Flag --${rawKey} requires true or false.`); + flags[key] = numberValue; + } else if (definition.type === "boolean") { + const booleanValue = value.trim().toLowerCase(); + if (booleanValue === "true") flags[key] = true; + else if (booleanValue === "false") flags[key] = false; + else + throw usageError(localize, { + "en-US": `Flag --${rawKey} requires true or false.`, + "zh-CN": `参数 --${rawKey} 需要 true 或 false。`, + }); } else { flags[key] = value; } @@ -128,13 +242,18 @@ export function parseFlags(rest: string[], defs: F): ParsedF // Required enforcement — declarative, driven by the schema. const missing = Object.entries(defs) .filter( - ([key, def]) => def.type !== "switch" && def.required === true && flags[key] === undefined, + ([key, definition]) => + options.enforceRequired !== false && + definition.type !== "switch" && + definition.required === true && + flags[key] === undefined, ) .map(([key]) => `--${camelToKebab(key)}`); if (missing.length > 0) { - throw new UsageError( - `Missing required ${missing.length > 1 ? "flags" : "flag"}: ${missing.join(", ")}`, - ); + throw usageError(localize, { + "en-US": `Missing required ${missing.length > 1 ? "flags" : "flag"}: ${missing.join(", ")}`, + "zh-CN": `缺少必填参数:${missing.join(", ")}`, + }); } return flags as unknown as ParsedFlags; diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index ea04f3f41..97db3582e 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -1,4 +1,3 @@ -import { parseFlags } from "./args.ts"; import { CommandRegistry } from "./registry.ts"; import { resolve } from "./resolve.ts"; import { @@ -19,11 +18,8 @@ import type { SourceFlags, } from "bailian-cli-core"; import { - CONSOLE_AUTH_FLAGS, DEFAULT_LANGUAGE, GLOBAL_FLAGS, - MODEL_AUTH_FLAGS, - OPENAPI_AUTH_FLAGS, UsageError, credentialFlagDefs, buildSources, @@ -41,7 +37,6 @@ import { loadCommandPacks } from "./command-packs/load.ts"; import { createCommandPackManager } from "./command-packs/manager.ts"; import type { CommandPackPolicy } from "./command-packs/types.ts"; import { createTranslator } from "./i18n.ts"; -import { confirmationFlagDefs } from "./confirm.ts"; /** Per-product identity injected by each CLI entrypoint (bl / rag / …). */ export interface CliOptions { @@ -75,7 +70,8 @@ function pickConfigFlag(argv: string[]): Partial { for (let argumentIndex = 0; argumentIndex < argv.length; argumentIndex++) { const argument = argv[argumentIndex]; if (argument === "--config") { - return { config: argv[argumentIndex + 1] }; + const value = argv[argumentIndex + 1]; + return value === undefined || value.startsWith("--") ? {} : { config: value }; } if (argument?.startsWith("--config=")) { return { config: argument.slice("--config=".length) }; @@ -151,25 +147,20 @@ export function createCli(commands: Record, opts: CliOptions } /** Render help for `path`; root ([]) doubles as the onboarding / login guide. */ - function renderHelp(registry: CommandRegistry, path: string[], argv: string[]): void { + function renderHelp( + registry: CommandRegistry, + path: string[], + parsedFlags: Record, + ): void { registry.printHelp(path, process.stderr); if (path.length > 0) return; let hasKey = false; try { - const auth = describeAuthState( - buildSources( - parseFlags(argv, { - ...GLOBAL_FLAGS, - ...MODEL_AUTH_FLAGS, - ...CONSOLE_AUTH_FLAGS, - ...OPENAPI_AUTH_FLAGS, - }) as Partial, - ), - ); + const auth = describeAuthState(buildSources(parsedFlags as Partial)); hasKey = !!(auth.apiKey || auth.console); } catch { - /* unparseable global flags on the bare invocation — fall through to welcome */ + /* Credential inspection failure — fall through to welcome. */ } if (hasKey) { if (opts.quickStartTasks?.length) registry.printQuickStart(opts.quickStartTasks); @@ -183,7 +174,7 @@ export function createCli(commands: Record, opts: CliOptions argv: string[], localize: (text: LocalizedText) => string, ): Promise { - const res = resolve(argv, registry); + const res = resolve(argv, registry, localize); switch (res.kind) { case "version": @@ -191,7 +182,7 @@ export function createCli(commands: Record, opts: CliOptions return; case "help": - renderHelp(registry, res.path, argv); + renderHelp(registry, res.path, res.parsedFlags); return; case "usageError": @@ -202,13 +193,7 @@ export function createCli(commands: Record, opts: CliOptions try { // 全局与凭证域 flag 进 sources,命令自有 flag 进 ctx.flags。 const credDefs = credentialFlagDefs(res.command); - const confirmationDefs = confirmationFlagDefs(res.command); - const parsedFlags = parseFlags(res.rest, { - ...GLOBAL_FLAGS, - ...credDefs, - ...confirmationDefs, - ...res.command.flags, - }) as Record; + const parsedFlags = res.parsedFlags; const globalFlags = pick(parsedFlags, [ ...Object.keys(GLOBAL_FLAGS), ...Object.keys(credDefs), diff --git a/packages/runtime/src/resolve.ts b/packages/runtime/src/resolve.ts index b04377313..be2a15b84 100644 --- a/packages/runtime/src/resolve.ts +++ b/packages/runtime/src/resolve.ts @@ -1,7 +1,15 @@ -import type { AnyCommand } from "bailian-cli-core"; -import { type UsageError } from "bailian-cli-core"; +import type { AnyCommand, FlagsDef } from "bailian-cli-core"; +import { + DEFAULT_LANGUAGE, + GLOBAL_FLAGS, + UsageError, + credentialFlagDefs, + validateTimeout, +} from "bailian-cli-core"; import type { CommandRegistry } from "./registry.ts"; -import { parsePath } from "./args.ts"; +import { parseFlags, parsePath } from "./args.ts"; +import { confirmationFlagDefs } from "./confirm.ts"; +import { createTranslator, type Translator } from "./i18n.ts"; /** * What an invocation resolves to — "what to do" expressed as data, not control @@ -9,33 +17,80 @@ import { parsePath } from "./args.ts"; * dispatch switches on it. No scattered `argv.includes(...)` + `process.exit`. * - version: print the version and stop. * - help: print help for `path` (root [] / a group / an explicit --help). Terminal. - * - run: execute `command`; `rest` is the flag region for parseFlags. + * - run: execute `command` with the already parsed and validated flags. * - usageError: the path doesn't exist (or has unexpected args); render and stop. */ export type Resolution = | { kind: "version" } - | { kind: "help"; path: string[] } - | { kind: "run"; path: string[]; command: AnyCommand; rest: string[] } + | { kind: "help"; path: string[]; parsedFlags: Record } + | { + kind: "run"; + path: string[]; + command: AnyCommand; + rest: string[]; + parsedFlags: Record; + } | { kind: "usageError"; error: UsageError }; /** - * 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. + * Classify argv into a {@link Resolution}. Pure over (argv, registry). + * Match the command, then parse and validate all flags once. Help/version skip + * required enforcement. Dispatch reuses the parsed flags instead of parsing again. */ -export function resolve(argv: string[], registry: CommandRegistry): Resolution { - const { path, rest, hasHelpFlag, hasVersionFlag } = parsePath(argv); - if (hasVersionFlag) return { kind: "version" }; +export function resolve( + argv: string[], + registry: CommandRegistry, + localize: Translator["localize"] = (text) => createTranslator(DEFAULT_LANGUAGE).localize(text), +): Resolution { + try { + const { path, rest, hasHelpFlag, hasVersionFlag } = parsePath(argv, localize); + const target = registry.locate(path); - const target = registry.locate(path); - switch (target.kind) { - case "leaf": - return hasHelpFlag - ? { kind: "help", path: target.matched } - : { kind: "run", path: target.matched, command: target.command, rest }; - case "group": - return { kind: "help", path: target.matched }; - case "unknown": + if (target.kind === "unknown" && !hasVersionFlag && !hasHelpFlag) { return { kind: "usageError", error: target.error }; + } + + const definitions: FlagsDef = + target.kind === "leaf" + ? { + ...GLOBAL_FLAGS, + ...credentialFlagDefs(target.command), + ...confirmationFlagDefs(target.command), + ...target.command.flags, + } + : GLOBAL_FLAGS; + let parsedFlags: Record; + try { + parsedFlags = parseFlags(rest, definitions, localize, { + enforceRequired: target.kind === "leaf" && !hasHelpFlag && !hasVersionFlag, + }); + } catch (error) { + // A bare leaf with missing required flags remains an incomplete invocation. + if (error instanceof UsageError && target.kind === "leaf" && rest.length === 0) { + return { kind: "help", path: target.matched, parsedFlags: {} }; + } + throw error; + } + + if (parsedFlags.timeout !== undefined) { + const timeoutError = validateTimeout(parsedFlags.timeout); + if (timeoutError) throw new UsageError(localize(timeoutError)); + } + + if (hasVersionFlag) return { kind: "version" }; + + switch (target.kind) { + case "leaf": + return hasHelpFlag + ? { kind: "help", path: target.matched, parsedFlags } + : { kind: "run", path: target.matched, command: target.command, rest, parsedFlags }; + case "group": + return { kind: "help", path: target.matched, parsedFlags }; + case "unknown": + return { kind: "usageError", error: target.error }; + } + } catch (error) { + if (error instanceof UsageError) return { kind: "usageError", error }; + throw error; } } diff --git a/packages/runtime/tests/args.test.ts b/packages/runtime/tests/args.test.ts index 845d98c04..63bbbcf9a 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,84 @@ 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"), + }), + ); +}); + +test.each(["constructor", "to-string", "__proto__"])( + "parsePath rejects inherited property --%s as a leading global flag", + (flagName) => { + expect(() => parsePath([`--${flagName}=ignored`, "config", "show"])).toThrowError( + expect.objectContaining({ name: "UsageError", exitCode: ExitCode.USAGE }), + ); + }, +); + +test.each([ + ["--config"], + ["--timeout", "--quiet", "auth"], + ["--timeout", "--help"], + ["--config", "work", "--timeout", "--version"], +])( + "parsePath leaves missing leading values for unified flag validation: %j", + (...argumentsList) => { + expect(parsePath(argumentsList)).toMatchObject({ path: [], rest: argumentsList }); + }, +); + +test.each([ + ["--timeout", "invalid"], + ["--timeout", "0"], + ["--timeout=-1"], + ["--quiet=true"], + ["--config", "first", "--config", "second"], +])("parsePath preserves raw leading values for later validation: %j", (...leadingFlags) => { + expect(parsePath([...leadingFlags, "auth"])).toMatchObject({ + path: ["auth"], + rest: leadingFlags, + }); }); // ---- parseFlags: typed parsing ---- @@ -42,6 +120,15 @@ test("parseFlags parses string / number / switch", () => { expect(flags.async).toBe(true); }); +test("help mode skips required enforcement while still parsing supplied values", () => { + expect(parseFlags(["--n", "2"], OPTS, undefined, { enforceRequired: false })).toMatchObject({ + n: 2, + }); + expect(() => + parseFlags(["--n", "invalid"], OPTS, undefined, { enforceRequired: false }), + ).toThrowError(expect.objectContaining({ exitCode: ExitCode.USAGE })); +}); + test("parseFlags supports the --flag=value form", () => { expect(parseFlags(["--prompt=cat"], OPTS).prompt).toBe("cat"); }); @@ -102,6 +189,15 @@ test("parseFlags rejects unknown long flags", () => { ); }); +test.each(["constructor", "to-string", "__proto__"])( + "parseFlags rejects inherited property --%s instead of ignoring it", + (flagName) => { + expect(() => parseFlags(["--prompt", "cat", `--${flagName}=ignored`], OPTS)).toThrowError( + expect.objectContaining({ name: "UsageError", exitCode: ExitCode.USAGE }), + ); + }, +); + test("parseFlags rejects short flags", () => { expect(() => parseFlags(["--prompt", "x", "-h"], OPTS)).toThrowError( expect.objectContaining({ name: "UsageError" }), diff --git a/packages/runtime/tests/console-flags.e2e.test.ts b/packages/runtime/tests/console-flags.e2e.test.ts index 66ee1e2ad..dd022f5b4 100644 --- a/packages/runtime/tests/console-flags.e2e.test.ts +++ b/packages/runtime/tests/console-flags.e2e.test.ts @@ -1,42 +1,54 @@ import { dirname, join } from "path"; import { fileURLToPath } from "url"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; import { describe, expect, test } from "vite-plus/test"; import { runNodeMain } from "e2e/runner"; +import type { Language } from "bailian-cli-core"; const runtimeRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const harnessMainTs = join(runtimeRoot, "tests/harness/cross-domain-main.ts"); -async function runRuntimeHarness(args: string[]) { - return runNodeMain(harnessMainTs, args, { cwd: runtimeRoot }); +async function runRuntimeHarness(args: string[], language: Language) { + const configDir = mkdtempSync(join(tmpdir(), "bl-cross-domain-e2e-")); + try { + writeFileSync(join(configDir, "config.json"), JSON.stringify({ language })); + return await runNodeMain(harnessMainTs, args, { + cwd: runtimeRoot, + env: { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_OUTPUT: "text" }, + }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } } /** * 跨域 flag 拒绝:runtime 凭证域与命令 flag 解析边界(synthetic command,不依赖 commands)。 */ -describe("e2e: console global flags (cross-domain rejection)", () => { - test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => { - const { stderr, exitCode } = await runRuntimeHarness([ - "text", - "chat", - "--message", - "hi", - "--console-region", - "cn-hangzhou", - "--dry-run", - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/Unknown flag.*--console-region/); - }); +describe.each([ + { language: "en-US", unknownFlag: "Unknown flag" }, + { language: "zh-CN", unknownFlag: "未知参数" }, +] as const)( + "e2e: console global flags (cross-domain rejection, $language)", + ({ language, unknownFlag }) => { + test("跨域 flag 拒绝:model 命令传 --console-region 报未知参数", async () => { + const { stdout, stderr, exitCode } = await runRuntimeHarness( + ["text", "chat", "--message", "hi", "--console-region", "cn-hangzhou", "--dry-run"], + language, + ); + expect(exitCode, stderr).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toMatch(new RegExp(`${unknownFlag}.*--console-region`)); + }); - test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => { - const { stderr, exitCode } = await runRuntimeHarness([ - "mcp", - "list", - "--api-key", - "sk-test", - "--dry-run", - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/Unknown flag.*--api-key/); - }); -}); + test("跨域 flag 拒绝:console 命令传 --api-key 报未知参数", async () => { + const { stdout, stderr, exitCode } = await runRuntimeHarness( + ["mcp", "list", "--api-key", "sk-test", "--dry-run"], + language, + ); + expect(exitCode, stderr).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toMatch(new RegExp(`${unknownFlag}.*--api-key`)); + }); + }, +); diff --git a/packages/runtime/tests/resolve.test.ts b/packages/runtime/tests/resolve.test.ts new file mode 100644 index 000000000..539127278 --- /dev/null +++ b/packages/runtime/tests/resolve.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from "vite-plus/test"; +import { defineCommand, ExitCode } from "bailian-cli-core"; +import { CommandRegistry } from "../src/registry.ts"; +import { resolve } from "../src/resolve.ts"; + +const command = defineCommand({ + description: "Test chat command", + auth: "apiKey", + flags: { + prompt: { type: "string", valueHint: "", description: "Prompt", required: true }, + }, + async run() {}, +}); +const registry = new CommandRegistry({ "text chat": command }, "bl"); + +test.each([ + { argv: [], path: [] }, + { argv: ["--config", "work"], path: [] }, + { argv: ["--config", "work", "text"], path: ["text"] }, + { argv: ["text", "--config=work"], path: ["text"] }, + { argv: ["--config=work", "text", "chat", "--help"], path: ["text", "chat"] }, + { argv: ["text", "chat", "--api-key", "fake-key", "--help"], path: ["text", "chat"] }, +])("valid flags preserve help routing for $argv", ({ argv, path }) => { + expect(resolve(argv, registry)).toMatchObject({ kind: "help", path }); +}); + +test.each([ + ["--config"], + ["--unknown", "text", "chat"], + ["--constructor=ignored", "text", "chat"], + ["text", "--timeout", "invalid"], + ["text", "--timeout", "0"], + ["text", "--timeout", "-1"], + ["text", "--config"], + ["text", "--quiet=true"], + ["--config=first", "text", "--config=second"], + ["text", "--unknown"], + ["text", "--timeout", "--help"], + ["text", "chat", "--unknown", "--help"], + ["text", "chat", "--timeout", "invalid", "--help"], + ["text", "chat", "--timeout=0", "--help"], + ["--timeout=-1", "--version"], + ["text", "chat", "--prompt", "--help"], + ["text", "chat", "--constructor=ignored", "--help"], + ["--quiet=true", "--version"], + ["--timeout", "invalid", "text", "chat", "--prompt", "hi"], + ["--timeout=0", "text", "chat", "--prompt", "hi"], + ["--quiet=true", "text", "chat", "--prompt", "hi"], + ["--config=first", "text", "chat", "--config=second", "--prompt", "hi"], + ["text", "chat", "--prompt", "hi", "--timeout=-1"], + ["text", "chat", "--unknown"], +])("invalid input returns usageError instead of help or version: %j", (...argv) => { + expect(resolve(argv, registry)).toMatchObject({ + kind: "usageError", + error: { name: "UsageError", exitCode: ExitCode.USAGE }, + }); +}); + +test("mixed leading and trailing flags reach the selected leaf command", () => { + expect( + resolve(["--config=work", "text", "chat", "--prompt", "hi", "--quiet"], registry), + ).toMatchObject({ + kind: "run", + path: ["text", "chat"], + command, + rest: ["--config=work", "--prompt", "hi", "--quiet"], + parsedFlags: { config: "work", prompt: "hi", quiet: true }, + }); +}); + +test("run resolution carries typed flags so dispatch can reuse them", () => { + expect(resolve(["--timeout=20", "text", "chat", "--prompt=hi"], registry)).toMatchObject({ + kind: "run", + parsedFlags: { timeout: 20, prompt: "hi", quiet: false }, + }); +}); + +test("help resolution carries the selected profile and parsed flags", () => { + expect(resolve(["--config=work", "text", "chat", "--help"], registry)).toMatchObject({ + kind: "help", + path: ["text", "chat"], + parsedFlags: { config: "work", help: true }, + }); +}); + +test("bare leaf commands with required flags show help, while supplied flags enforce requirements", () => { + expect(resolve(["text", "chat"], registry)).toMatchObject({ + kind: "help", + path: ["text", "chat"], + }); + expect(resolve(["text", "chat", "--quiet"], registry)).toMatchObject({ + kind: "usageError", + error: { exitCode: ExitCode.USAGE }, + }); +}); + +test("global flags between command path segments are rejected", () => { + expect(resolve(["text", "--config=work", "chat"], registry)).toMatchObject({ + kind: "usageError", + error: { exitCode: ExitCode.USAGE }, + }); +}); + +test("version remains available without required command arguments", () => { + expect(resolve(["text", "chat", "--version"], registry)).toEqual({ kind: "version" }); +}); + +test.each([ + { argv: ["--config"], message: "Flag --config requires a value." }, + { argv: ["--timeout", "--quiet", "text"], message: "Flag --timeout requires a value." }, + { argv: ["--timeout", "--help"], message: "Flag --timeout requires a value." }, + { + argv: ["--config", "work", "--timeout", "--version"], + message: "Flag --timeout requires a value.", + }, + { + argv: ["--quiet=true", "--timeout"], + message: "Flag --quiet is a switch and takes no value.", + }, + { + argv: ["--timeout", "invalid", "--config"], + message: "Flag --timeout requires a finite number.", + }, + { + argv: ["--config", "first", "--config", "second", "--timeout"], + message: "Flag --config given more than once.", + }, +])("unified validation reports the first flag error for $argv", ({ argv, message }) => { + expect(resolve(argv, registry)).toMatchObject({ + kind: "usageError", + error: { exitCode: ExitCode.USAGE, message }, + }); +}); diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 77c632d73..1a3290b49 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -129,6 +129,14 @@ API Key login selects and activates the matching built-in Profile only after the persisted `active_config` > `default`; credential and endpoint fields inside the selected Profile still follow flag > environment > config. +Global flags can precede or follow the complete command path: +`bl --config token-plan auth status` and `bl auth status --config token-plan` select +the same Profile for one invocation without changing `active_config`. Keep command +path segments together. Command-specific and credential flags must follow the path. +Unknown flags, missing flag values, and invalid supplied values return usage exit +code 2, including when the invocation would otherwise display help. A valid bare +root or command-group invocation displays the corresponding help and returns 0. + Activation selects the entire Config for every credential domain, not only model consumption. The only exception is the API Key capability fallback described above. After activating `token-plan`, Token Plan management and Console commands still read their OpenAPI or Console credentials from that Profile. If those credentials remain in `default`, invoke the command with `--config default` or log the corresponding credential domain into `token-plan`. The built-in `token-plan` preset contains the following defaults. The CLI materializes them only after