Skip to content

Commit 9812914

Browse files
committed
fix(runtime): tighten flag validation and clarify global flag placement
- Parse flags once during resolution and reuse them for help and execution - Validate supplied flags before root/group help and version output - Preserve bare-command help and skip required flags for help/version - Only accept explicitly defined flags and localize parser errors by profile language - Reuse the same positive finite timeout rule for CLI flags and resolved settings - Cover leading/trailing --config selection without changing active profiles - Add resolver, timeout, profile, and bilingual credential-domain regression tests - Update bl/kscli smoke tests and command E2E assertions for localization - Document flag placement, usage exit codes, and bilingual E2E requirements
1 parent d10cfdb commit 9812914

22 files changed

Lines changed: 634 additions & 178 deletions

‎docs/agents/cli-e2e-tests.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
- kscli:`runKscli` from `packages/kscli/tests/e2e/helpers.ts`
4141
- 全量 leaf/group help 使用产品 `commands` 创建 `CommandRegistry`,先通过 `resolve([...path, "--help"])` 检查 help 路由,再用 `captureRegistryHelp` 检查完整 Usage;禁止在 `test.each(commandPaths/groupPaths)` 中逐条启动 `tsx` 子进程
4242
- 真实子进程只保留根 help/version、产品身份、代表性叶子 help/鉴权域和缺参退出码等 shell/stdio/env 契约
43+
- 子进程的 parser 错误和 help 文案遵循 Profile 语言;语义断言应兼容中英文,同时检查参数名和退出码,并使用独立的中英文配置复跑,避免依赖本机 Profile
4344

4445
### 共享
4546

‎docs/agents/command-flag-change.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
### B. 鉴权 / 全局选项
2525

2626
- [ ] 如果是**全局 flag**(所有命令通用),改 `packages/core/src/types/command.ts` 的 `GLOBAL_FLAGS`
27+
- [ ] 全局参数支持完整命令路径前置或后置;命令专属参数和凭证域参数只支持后置,路径各级之间不插入参数。改路由解析时,覆盖根/分组/显式 help 的无效参数校验、前后重复参数、Profile 选择和激活状态不变;纯裸命令与合法分组帮助仍返回 0,参数错误返回 2
2728
- [ ] 如果是凭证域 flag,优先确认是否属于 `MODEL_AUTH_FLAGS` 或 `CONSOLE_AUTH_FLAGS`;不要在单个命令里重复声明
2829
- [ ] 如果新 flag 影响有效配置面,改 `packages/core/src/config/schema.ts` 的 `Settings` 接口
2930
- [ ] 如果对应 env var 或 config 文件字段,改 `packages/core/src/config/loader.ts` 的 `buildSettings`

‎packages/cli/tests/e2e/config-profile.e2e.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,112 @@ describe("e2e: named config", () => {
111111
});
112112
});
113113

114+
test.each(["config show", "auth status"])(
115+
"前置和后置 --config 为 %s 选择同一份非激活 Profile,且不修改配置",
116+
async (commandPath) => {
117+
await withTempConfigDir(async (configDir) => {
118+
const originalConfig = {
119+
active_config: "default",
120+
api_key: "sk-default-review",
121+
base_url: "https://default.example.test",
122+
work: {
123+
api_key: "sk-work-review",
124+
base_url: "https://work.example.test",
125+
access_token: "console-work-review",
126+
console_site: "international",
127+
console_region: "ap-southeast-1",
128+
workspace_id: "work-space-review",
129+
output: "json",
130+
},
131+
};
132+
writeConfig(configDir, originalConfig);
133+
const env = {
134+
BAILIAN_CONFIG_DIR: configDir,
135+
DASHSCOPE_API_KEY: "",
136+
DASHSCOPE_BASE_URL: "",
137+
DASHSCOPE_OUTPUT: "",
138+
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
139+
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
140+
};
141+
const leading = await runCli(["--config", "work", ...commandPath.split(" ")], env);
142+
const trailing = await runCli([...commandPath.split(" "), "--config=work"], env);
143+
expect(leading.exitCode, leading.stderr).toBe(0);
144+
expect(trailing.exitCode, trailing.stderr).toBe(0);
145+
const leadingData = parseStdoutJson<Record<string, unknown>>(leading.stdout);
146+
expect(leadingData).toEqual(parseStdoutJson(trailing.stdout));
147+
expect(leadingData.config).toBe("work");
148+
if (commandPath === "auth status") {
149+
expect(leadingData).toMatchObject({
150+
authenticated: true,
151+
api_key: { source: "config", base_url: "https://work.example.test" },
152+
console: { source: "config", site: "international", region: "ap-southeast-1" },
153+
});
154+
} else {
155+
expect(leadingData.base_url).toBe("https://work.example.test");
156+
expect(leadingData.workspace_id).toBe("work-space-review");
157+
}
158+
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))).toEqual(
159+
originalConfig,
160+
);
161+
});
162+
},
163+
);
164+
165+
test.each([
166+
{ language: "en-US", expectedMessage: /before the command path/, expectedHint: /after/ },
167+
{ language: "zh-CN", expectedMessage: /命令路径之前/, expectedHint: /之后/ },
168+
])(
169+
"前置非全局参数的 JSON 错误遵循选中 Profile 的语言 $language",
170+
async ({ language, expectedMessage, expectedHint }) => {
171+
await withTempConfigDir(async (configDir) => {
172+
writeConfig(configDir, { language: "en-US", work: { language } });
173+
const result = await runCli(
174+
["--config=work", "--output=json", "--prompt", "hi", "text", "chat"],
175+
{ BAILIAN_CONFIG_DIR: configDir },
176+
);
177+
expect(result.exitCode).toBe(2);
178+
expect(result.stdout).toBe("");
179+
const { error } = JSON.parse(result.stderr) as {
180+
error: { code: number; message: string; hint: string };
181+
};
182+
expect(error.code).toBe(2);
183+
expect(error.message).toMatch(expectedMessage);
184+
expect(error.hint).toMatch(expectedHint);
185+
});
186+
},
187+
);
188+
189+
test.each([
190+
{ argv: ["--config=work", "auth"], help: /Usage: bl auth <command>/ },
191+
{ argv: ["--config=work"], help: /Usage: bl <resource> <command>/ },
192+
])("合法前置参数保留相应帮助页: $argv", async ({ argv, help }) => {
193+
await withTempConfigDir(async (configDir) => {
194+
writeConfig(configDir, { work: { language: "en-US" } });
195+
const result = await runCli(argv, { BAILIAN_CONFIG_DIR: configDir });
196+
expect(result.exitCode, result.stderr).toBe(0);
197+
expect(result.stderr).toMatch(help);
198+
expect(result.stdout).toBe("");
199+
});
200+
});
201+
202+
test.each([
203+
["auth", "--output=json", "--timeout"],
204+
["--output=json", "--timeout", "--help"],
205+
["--config=work", "--output=json", "--timeout", "--version"],
206+
])("中文帮助分支缺少参数值时返回结构化用法错误: %j", async (...argv) => {
207+
await withTempConfigDir(async (configDir) => {
208+
writeConfig(configDir, { language: "zh-CN", work: { language: "zh-CN" } });
209+
const result = await runCli(argv, {
210+
BAILIAN_CONFIG_DIR: configDir,
211+
});
212+
expect(result.exitCode).toBe(2);
213+
expect(result.stdout).toBe("");
214+
expect(JSON.parse(result.stderr)).toMatchObject({
215+
error: { code: 2, message: expect.stringMatching(/--timeout.*需要.*值/) },
216+
});
217+
});
218+
});
219+
114220
test("--config default 等价默认配置", async () => {
115221
await withTempConfigDir(async (dir) => {
116222
writeConfig(dir, {

‎packages/cli/tests/e2e/registry.smoke.e2e.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe("e2e: bl registry smoke", () => {
6767
test.each(commandPaths)("已注册命令 %s --help 成功", (path) => {
6868
const commandPath = path.split(" ");
6969

70-
expect(resolve([...commandPath, "--help"], registry)).toEqual({
70+
expect(resolve([...commandPath, "--help"], registry)).toMatchObject({
7171
kind: "help",
7272
path: commandPath,
7373
});
@@ -77,7 +77,7 @@ describe("e2e: bl registry smoke", () => {
7777
test.each(groupPaths)("命令分组 %s --help 成功", (path) => {
7878
const commandPath = path.split(" ");
7979

80-
expect(resolve([...commandPath, "--help"], registry)).toEqual({
80+
expect(resolve([...commandPath, "--help"], registry)).toMatchObject({
8181
kind: "help",
8282
path: commandPath,
8383
});

‎packages/commands/tests/e2e/auth.e2e.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -764,8 +764,8 @@ describe("e2e: auth", () => {
764764
"--base-url",
765765
"https://x.test",
766766
]);
767-
expect(exitCode).not.toBe(0);
768-
expect(stderr).toMatch(/Unknown flag.*--base-url/);
767+
expect(exitCode).toBe(2);
768+
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--base-url/);
769769
});
770770

771771
test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => {
@@ -788,8 +788,8 @@ describe("e2e: auth", () => {
788788
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder");
789789

790790
const denied = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--access-key-id", "ak"]);
791-
expect(denied.exitCode).not.toBe(0);
792-
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
791+
expect(denied.exitCode).toBe(2);
792+
expect(denied.stderr).toMatch(/(?:Unknown flag|未知参数).*--access-key-id/);
793793
});
794794

795795
test.skipIf(!isOpenApiE2EReady())(

‎packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ describe("e2e: console global flags (dry-run)", () => {
9292
"--region",
9393
"cn",
9494
]);
95-
expect(exitCode).not.toBe(0);
96-
expect(stderr).toMatch(/Unknown flag.*--region/);
95+
expect(exitCode).toBe(2);
96+
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--region/);
9797
});
9898

9999
test("mcp list --dry-run --console-region 透传", async () => {

‎packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ describe("e2e: managed-agent project init directory defaults", () => {
163163
const directory = await temporaryDirectory();
164164
const removedFlag = await runProject(directory, "build", ["--yes"]);
165165
expect(removedFlag.exitCode).toBe(2);
166-
expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/);
166+
expect(removedFlag.stderr).toMatch(/(?:Unknown flag|未知参数).*--yes/);
167167
});
168168

169169
test("nested build and validate explain the project root without changing directories", async () => {

‎packages/commands/tests/e2e/managed-agent.e2e.test.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe("e2e: managed-agent", () => {
245245
"--refresh-only",
246246
]);
247247
expect(exitCode).toBe(2);
248-
expect(stderr).toMatch(/Unknown flag.*--refresh-only/i);
248+
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--refresh-only/i);
249249
});
250250

251251
test("managed-agent init 不再暴露 Git 仓库脚手架", async () => {
@@ -538,8 +538,8 @@ agents:
538538
]);
539539
expect(exitCode, stderr).toBe(0);
540540
expect(stderr).toMatch(/--name|--model|--instructions|--skill|--skill-dir|--type|--yes/i);
541-
expect(stderr).toMatch(/remote Skill ID/i);
542-
expect(stderr).toMatch(/local Skill directory/i);
541+
expect(stderr).toMatch(/remote Skill ID|远端 Skill ID/i);
542+
expect(stderr).toMatch(/local Skill directory|本地 Skill 目录/i);
543543
expect(stderr).not.toMatch(/--key|--environment|--vault/i);
544544
});
545545

@@ -577,7 +577,7 @@ agents:
577577
"--quiet",
578578
]);
579579
expect(exitCode).toBe(2);
580-
expect(stderr).toMatch(/Unknown option|unknown flag|unexpected/i);
580+
expect(stderr).toMatch(new RegExp(`(?:Unknown flag|未知参数).*${flag}`, "i"));
581581
});
582582

583583
test("managed-agent agent create 将 official Skill ID 写为外部引用", async () => {
@@ -1355,7 +1355,9 @@ vaults:
13551355
"--quiet",
13561356
]);
13571357
expect(exitCode).toBe(2);
1358-
expect(stderr).toMatch(/--source must be one of: custom, official/i);
1358+
expect(stderr).toMatch(
1359+
/--source (?:must be one of: |必须为以下值之一:)custom, official, all/i,
1360+
);
13591361
});
13601362

13611363
test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => {
@@ -1376,7 +1378,7 @@ vaults:
13761378
);
13771379
// all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出
13781380
expect(exitCode).toBe(2);
1379-
expect(stderr).not.toMatch(/--source must be one of/i);
1381+
expect(stderr).not.toMatch(/--source (?:must be one of|必须为以下值之一)/i);
13801382
expect(stderr).toMatch(/File not found.*agents\.e2e-missing\.yaml/i);
13811383
});
13821384

@@ -1419,7 +1421,7 @@ vaults:
14191421
"bailian",
14201422
]);
14211423
expect(providerResult.exitCode).toBe(2);
1422-
expect(providerResult.stderr).toMatch(/Unknown flag.*--provider/i);
1424+
expect(providerResult.stderr).toMatch(/(?:Unknown flag|未知参数).*--provider/i);
14231425

14241426
const purposeResult = await runCommandE2e(MANAGED_AGENT_ROUTES, [
14251427
"managed-agent",
@@ -1432,7 +1434,7 @@ vaults:
14321434
"agent",
14331435
]);
14341436
expect(purposeResult.exitCode).toBe(2);
1435-
expect(purposeResult.stderr).toMatch(/Unknown flag.*--purpose/i);
1437+
expect(purposeResult.stderr).toMatch(/(?:Unknown flag|未知参数).*--purpose/i);
14361438
});
14371439
});
14381440

@@ -1537,7 +1539,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () =>
15371539
"claude",
15381540
]);
15391541
expect(exitCode).toBe(2);
1540-
expect(stderr).toMatch(/Unknown flag.*--provider/i);
1542+
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--provider/i);
15411543
});
15421544

15431545
test("project init --dry-run 不需要密钥且不创建目录", async () => {

‎packages/commands/tests/e2e/pipeline.e2e.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ describe("e2e: pipeline", () => {
238238
]);
239239
expect(exitCode).toBe(2);
240240
expect(stdout).toBe("");
241-
expect(stderr).toMatch(/--events must be one of: jsonl/i);
241+
expect(stderr).toMatch(/--events (?:must be one of: |必须为以下值之一:)jsonl/i);
242242
});
243243

244244
test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => {

‎packages/commands/tests/e2e/quota.e2e.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@ describe("e2e: quota", () => {
9393
"--delete",
9494
]);
9595
expect(exitCode).toBe(2);
96-
expect(stderr).toContain("Unknown flag");
96+
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--delete/);
9797
});
9898

9999
test("quota delete 缺少 --model 报用法错误", async () => {
100100
// 裸 `quota delete`(无任何 flag)会渲染 help 并正常退出,需带 flag 触发必填校验
101101
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "delete", "--yes"]);
102102
expect(exitCode).toBe(2);
103-
expect(stderr).toContain("Missing required flag: --model");
103+
expect(stderr).toMatch(/(?:Missing required flag: |缺少必填参数:)--model/);
104104
});
105105

106106
test("quota delete 无 --yes 返回确认请求 (7)", async () => {

0 commit comments

Comments
 (0)