Skip to content

Commit cbd3c12

Browse files
committed
refactor(runtime): unify validation into UsageError; bare command shows help
- drop IncompleteCommandError: missing-required, failed validate, and bad/unknown flags are all UsageError now - error boundary keys on bareness — bare command that fails → help (exit 0); non-bare invalid → error + message (exit 2) - login: drop config.apiKey fallback, --api-key required-unless-console via validate - speech: drop empty --text-file guard (empty content is the API's concern)
1 parent 91e6c6f commit cbd3c12

9 files changed

Lines changed: 31 additions & 72 deletions

File tree

packages/commands/src/commands/auth/login.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
type Config,
66
type GlobalFlags,
77
} from "bailian-cli-core";
8-
import { IncompleteCommandError } from "bailian-cli-core";
98
import { printQuickStart } from "bailian-cli-runtime";
109
import { emitBare } from "bailian-cli-runtime";
1110
import {
@@ -31,6 +30,7 @@ export default defineCommand({
3130
},
3231
],
3332
exampleArgs: ["--api-key sk-xxxxx", "--console"],
33+
validate: (f) => (!f.console && !f.apiKey ? "Provide --api-key or --console" : undefined),
3434
async run(config: Config, flags: GlobalFlags) {
3535
if (flags.console) {
3636
if (config.dryRun) {
@@ -51,10 +51,7 @@ export default defineCommand({
5151
process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`);
5252
}
5353

54-
const key = (flags.apiKey as string) || config.apiKey;
55-
if (!key) {
56-
throw new IncompleteCommandError("Missing required argument: --api-key");
57-
}
54+
const key = flags.apiKey as string;
5855

5956
const baseUrl = (flags.baseUrl as string) || undefined;
6057
const effectiveConfig = baseUrl ? { ...config, baseUrl } : config;

packages/commands/src/commands/speech/synthesize.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
type OutputFormat,
1515
speechSynthesizeEndpoint,
1616
parseSSE,
17-
IncompleteCommandError,
1817
resolveOutputDir,
1918
request,
2019
DOCS_HOSTS,
@@ -207,9 +206,8 @@ export default defineCommand({
207206
return;
208207
}
209208

210-
let text = flags.text as string | undefined;
211-
212-
// --text-file takes precedence if provided and --text is empty
209+
// --text / --text-file presence enforced by validate; empty file content → API rejects.
210+
let text = (flags.text as string) || "";
213211
if (!text && flags.textFile) {
214212
const filePath = flags.textFile as string;
215213
try {
@@ -218,10 +216,6 @@ export default defineCommand({
218216
throw new BailianError(`Cannot read text file: ${filePath}`, ExitCode.USAGE);
219217
}
220218
}
221-
222-
if (!text) {
223-
throw new IncompleteCommandError("Provide --text or --text-file.");
224-
}
225219
const voice = flags.voice as string;
226220

227221
const language = (flags.language as string) || undefined;

packages/core/src/errors/base.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,34 +45,14 @@ export class BailianError extends Error {
4545
}
4646
}
4747

48-
/**
49-
* The user invoked the CLI in a structurally invalid way: unknown command path,
50-
* unknown/badly-typed flag, or a missing required argument. Always carries
51-
* {@link ExitCode.USAGE}. The runtime's error boundary recognises this type and
52-
* renders the relevant command's help before printing the message — so command
53-
* code never builds usage strings or prints help itself; it just throws this.
54-
*/
48+
/** Invalid usage: unknown command, bad/unknown flag, missing required, failed validation. */
5549
export class UsageError extends BailianError {
5650
constructor(message: string, hint?: string) {
5751
super(message, ExitCode.USAGE, hint);
5852
this.name = "UsageError";
5953
}
6054
}
6155

62-
/**
63-
* The command is *incomplete* — a valid prefix that stopped short (a missing
64-
* required flag / positional). Distinct from {@link UsageError} ("you typed
65-
* something wrong"): an incomplete command is not an error. The runtime's error
66-
* boundary renders that command's help and exits 0 — exactly like landing on a
67-
* command group with no subcommand. Carries {@link ExitCode.SUCCESS}.
68-
*/
69-
export class IncompleteCommandError extends BailianError {
70-
constructor(message: string) {
71-
super(message, ExitCode.SUCCESS);
72-
this.name = "IncompleteCommandError";
73-
}
74-
}
75-
7656
function serializeCause(cause: unknown): Record<string, unknown> | undefined {
7757
if (cause == null) return undefined;
7858
if (cause instanceof Error) {

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { BailianError, UsageError, IncompleteCommandError } from "./errors/base.ts";
1+
export { BailianError, UsageError } from "./errors/base.ts";
22
export { mapApiError, type ApiErrorBody } from "./errors/api.ts";
33
export { ExitCode } from "./errors/codes.ts";
44

packages/core/src/types/command.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,10 @@ export interface Command {
4747
auth: AuthRequirement;
4848
notes?: string[];
4949
/**
50-
* Cross-flag validation, run after parsing and before execute. Return an
51-
* error message when the flag combination is incomplete (one-of, 3-of-N,
52-
* value-conditional, dependency, …); the runtime throws IncompleteCommandError
53-
* and renders this command's help. Return undefined to pass. Single-flag
54-
* `required: true` is enforced by the parser — use this only for rules that
55-
* span multiple flags or depend on a flag's *value*.
50+
* Cross-flag validation, run after parsing and before execute (one-of, 3-of-N,
51+
* value-conditional, dependency, …). Return an error message → UsageError;
52+
* undefined to pass. Single-flag `required: true` is enforced by the parser —
53+
* use this only for rules spanning multiple flags or depending on a flag's *value*.
5654
*/
5755
validate?: (flags: GlobalFlags) => string | undefined;
5856
execute: (config: Config, flags: GlobalFlags) => Promise<void>;

packages/runtime/src/args.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { GlobalFlags } from "bailian-cli-core";
22
import type { OptionDef } from "bailian-cli-core";
3-
import { UsageError, IncompleteCommandError } from "bailian-cli-core";
3+
import { UsageError } from "bailian-cli-core";
44

55
function kebabToCamel(str: string): string {
66
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
@@ -92,10 +92,8 @@ export function parsePath(argv: string[]): ParsePathResult {
9292

9393
/**
9494
* Second pass — parse the flag region into typed values, driven entirely by the
95-
* OptionDef schema. Pure: returns typed flags or throws — never prints/exits.
96-
* The runtime's error boundary decides rendering. Throws IncompleteCommandError
97-
* for missing required flags (incomplete → help) and UsageError for malformed
98-
* input (unknown/short flag, bad value, unexpected token, duplicate).
95+
* OptionDef schema. Pure: returns typed flags or throws UsageError — never
96+
* prints/exits. The runtime's error boundary decides rendering.
9997
*/
10098
export function parseFlags(rest: string[], options: OptionDef[]): GlobalFlags {
10199
const allowedKeys = buildAllowedFlagKeys(options);
@@ -189,7 +187,7 @@ export function parseFlags(rest: string[], options: OptionDef[]): GlobalFlags {
189187
});
190188
if (missing.length > 0) {
191189
const names = missing.map((opt) => opt.flag.match(/^(--[a-z][a-z0-9-]*)/i)?.[1] ?? opt.flag);
192-
throw new IncompleteCommandError(
190+
throw new UsageError(
193191
`Missing required ${names.length > 1 ? "flags" : "flag"}: ${names.join(", ")}`,
194192
);
195193
}

packages/runtime/src/create-cli.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,7 @@ import {
1010
type RunContext,
1111
} from "./middleware.ts";
1212
import type { Command, Config, GlobalFlags } from "bailian-cli-core";
13-
import {
14-
GLOBAL_OPTIONS,
15-
IncompleteCommandError,
16-
loadConfig,
17-
flushTelemetry,
18-
} from "bailian-cli-core";
13+
import { GLOBAL_OPTIONS, UsageError, loadConfig, flushTelemetry } from "bailian-cli-core";
1914
import { setupProxyFromEnv } from "./proxy.ts";
2015
import { handleError } from "./error-handler.ts";
2116
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
@@ -37,12 +32,9 @@ export interface Cli {
3732
}
3833

3934
/**
40-
* Build a CLI from an injected command set. The kernel is agnostic to *which*
41-
* commands exist — each product (bailian-cli, rag-cli, …) passes its own map and
42-
* identity. `run` is a thin orchestrator: resolve argv into a {@link Resolution},
43-
* then dispatch — terminal kinds render and return; `run` enters the middleware
44-
* stack guarded by a single error boundary. No business `if`s, no scattered
45-
* `process.exit`.
35+
* Build a CLI from an injected command set — each product (bl / rag / …) passes
36+
* its own commands + identity. `run` resolves argv into a {@link Resolution},
37+
* then dispatches it.
4638
*/
4739
export function createCli(commands: Record<string, Command>, opts: CliOptions): Cli {
4840
const registry = new CommandRegistry(commands, opts.binName);
@@ -117,9 +109,12 @@ export function createCli(commands: Record<string, Command>, opts: CliOptions):
117109

118110
case "run": {
119111
try {
112+
// 解析 flag + 跨 flag 校验:任何用法问题都抛 UsageError
120113
const flags = parseFlags(res.rest, [...GLOBAL_OPTIONS, ...(res.command.options ?? [])]);
121114
const invalid = res.command.validate?.(flags);
122-
if (invalid) throw new IncompleteCommandError(invalid);
115+
if (invalid) throw new UsageError(invalid);
116+
117+
// 校验通过 → 准备配置、进中间件执行命令
123118
const config = buildConfig(flags);
124119
const ctx: RunContext = {
125120
binName,
@@ -133,11 +128,13 @@ export function createCli(commands: Record<string, Command>, opts: CliOptions):
133128
await runMiddleware(ctx);
134129
await flushTelemetry(1000);
135130
} catch (err) {
136-
await flushTelemetry(1000);
137-
if (err instanceof IncompleteCommandError) {
131+
// 裸调用(命令后什么都没写)下的 UsageError → 当"还没写完",打 help、exit 0;
132+
// 写了 flag 却无效、或执行时报错 → 报错、exit 2。
133+
if (err instanceof UsageError && res.rest.length === 0) {
138134
registry.printHelp(res.path, process.stderr);
139135
return;
140136
}
137+
await flushTelemetry(1000);
141138
handleError(err, binName);
142139
}
143140
return;

packages/runtime/src/output/output.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,8 @@ import { formatOutput, type OutputFormat } from "bailian-cli-core";
22

33
/**
44
* Emit the primary result of a command.
5-
*
6-
* Design principle:
7-
* stdout → structured data only (JSON when piped, text when TTY)
8-
* stderr → human info (progress, logs, tips) — handled elsewhere
9-
*
10-
* This ensures `bl cmd ... | jq .` always receives clean JSON,
11-
* while interactive users see human-readable text.
5+
* stdout → result (text by default; JSON with --output json)
6+
* stderr → human info (progress, logs, tips) — handled elsewhere
127
*/
138
export function emitResult(data: unknown, format: OutputFormat): void {
149
process.stdout.write(formatOutput(data, format) + "\n");

packages/runtime/tests/args.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,11 @@ test("parseFlags validates number flags", () => {
129129
);
130130
});
131131

132-
test("parseFlags throws IncompleteCommandError when a required flag is missing", () => {
132+
test("parseFlags throws UsageError when a required flag is missing", () => {
133133
expect(() => parseFlags(["--model", "qwen-image-2.0"], OPTS)).toThrowError(
134134
expect.objectContaining({
135-
name: "IncompleteCommandError",
136-
exitCode: ExitCode.SUCCESS,
135+
name: "UsageError",
136+
exitCode: ExitCode.USAGE,
137137
message: expect.stringContaining("Missing required flag: --prompt"),
138138
}),
139139
);

0 commit comments

Comments
 (0)