diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index ca6752c..f7709a5 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -5,7 +5,6 @@ import { type Config, type GlobalFlags, } from "bailian-cli-core"; -import { IncompleteCommandError } from "bailian-cli-core"; import { printQuickStart } from "bailian-cli-runtime"; import { emitBare } from "bailian-cli-runtime"; import { @@ -31,6 +30,7 @@ export default defineCommand({ }, ], exampleArgs: ["--api-key sk-xxxxx", "--console"], + validate: (f) => (!f.console && !f.apiKey ? "Provide --api-key or --console" : undefined), async run(config: Config, flags: GlobalFlags) { if (flags.console) { if (config.dryRun) { @@ -51,10 +51,7 @@ export default defineCommand({ process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`); } - const key = (flags.apiKey as string) || config.apiKey; - if (!key) { - throw new IncompleteCommandError("Missing required argument: --api-key"); - } + const key = flags.apiKey as string; const baseUrl = (flags.baseUrl as string) || undefined; const effectiveConfig = baseUrl ? { ...config, baseUrl } : config; diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index ae9a07a..4da0ac2 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -14,7 +14,6 @@ import { type OutputFormat, speechSynthesizeEndpoint, parseSSE, - IncompleteCommandError, resolveOutputDir, request, DOCS_HOSTS, @@ -207,9 +206,8 @@ export default defineCommand({ return; } - let text = flags.text as string | undefined; - - // --text-file takes precedence if provided and --text is empty + // --text / --text-file presence enforced by validate; empty file content → API rejects. + let text = (flags.text as string) || ""; if (!text && flags.textFile) { const filePath = flags.textFile as string; try { @@ -218,10 +216,6 @@ export default defineCommand({ throw new BailianError(`Cannot read text file: ${filePath}`, ExitCode.USAGE); } } - - if (!text) { - throw new IncompleteCommandError("Provide --text or --text-file."); - } const voice = flags.voice as string; const language = (flags.language as string) || undefined; diff --git a/packages/core/src/errors/base.ts b/packages/core/src/errors/base.ts index 04fb116..69d9abb 100644 --- a/packages/core/src/errors/base.ts +++ b/packages/core/src/errors/base.ts @@ -45,13 +45,7 @@ export class BailianError extends Error { } } -/** - * The user invoked the CLI in a structurally invalid way: unknown command path, - * unknown/badly-typed flag, or a missing required argument. Always carries - * {@link ExitCode.USAGE}. The runtime's error boundary recognises this type and - * renders the relevant command's help before printing the message — so command - * code never builds usage strings or prints help itself; it just throws this. - */ +/** Invalid usage: unknown command, bad/unknown flag, missing required, failed validation. */ export class UsageError extends BailianError { constructor(message: string, hint?: string) { super(message, ExitCode.USAGE, hint); @@ -59,20 +53,6 @@ export class UsageError extends BailianError { } } -/** - * The command is *incomplete* — a valid prefix that stopped short (a missing - * required flag / positional). Distinct from {@link UsageError} ("you typed - * something wrong"): an incomplete command is not an error. The runtime's error - * boundary renders that command's help and exits 0 — exactly like landing on a - * command group with no subcommand. Carries {@link ExitCode.SUCCESS}. - */ -export class IncompleteCommandError extends BailianError { - constructor(message: string) { - super(message, ExitCode.SUCCESS); - this.name = "IncompleteCommandError"; - } -} - function serializeCause(cause: unknown): Record | undefined { if (cause == null) return undefined; if (cause instanceof Error) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d48357d..a6cc28b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { BailianError, UsageError, IncompleteCommandError } from "./errors/base.ts"; +export { BailianError, UsageError } from "./errors/base.ts"; export { mapApiError, type ApiErrorBody } from "./errors/api.ts"; export { ExitCode } from "./errors/codes.ts"; diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 5443766..68da64d 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -47,12 +47,10 @@ export interface Command { auth: AuthRequirement; notes?: string[]; /** - * Cross-flag validation, run after parsing and before execute. Return an - * error message when the flag combination is incomplete (one-of, 3-of-N, - * value-conditional, dependency, …); the runtime throws IncompleteCommandError - * and renders this command's help. Return undefined to pass. Single-flag - * `required: true` is enforced by the parser — use this only for rules that - * span multiple flags or depend on a flag's *value*. + * Cross-flag validation, run after parsing and before execute (one-of, 3-of-N, + * value-conditional, dependency, …). Return an error message → UsageError; + * undefined to pass. Single-flag `required: true` is enforced by the parser — + * use this only for rules spanning multiple flags or depending on a flag's *value*. */ validate?: (flags: GlobalFlags) => string | undefined; execute: (config: Config, flags: GlobalFlags) => Promise; diff --git a/packages/runtime/src/args.ts b/packages/runtime/src/args.ts index 7e47a7f..6b1d229 100644 --- a/packages/runtime/src/args.ts +++ b/packages/runtime/src/args.ts @@ -1,6 +1,6 @@ import type { GlobalFlags } from "bailian-cli-core"; import type { OptionDef } from "bailian-cli-core"; -import { UsageError, IncompleteCommandError } from "bailian-cli-core"; +import { UsageError } from "bailian-cli-core"; function kebabToCamel(str: string): string { return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); @@ -92,10 +92,8 @@ export function parsePath(argv: string[]): ParsePathResult { /** * Second pass — parse the flag region into typed values, driven entirely by the - * OptionDef schema. Pure: returns typed flags or throws — never prints/exits. - * The runtime's error boundary decides rendering. Throws IncompleteCommandError - * for missing required flags (incomplete → help) and UsageError for malformed - * input (unknown/short flag, bad value, unexpected token, duplicate). + * OptionDef schema. Pure: returns typed flags or throws UsageError — never + * prints/exits. The runtime's error boundary decides rendering. */ export function parseFlags(rest: string[], options: OptionDef[]): GlobalFlags { const allowedKeys = buildAllowedFlagKeys(options); @@ -189,7 +187,7 @@ export function parseFlags(rest: string[], options: OptionDef[]): GlobalFlags { }); if (missing.length > 0) { const names = missing.map((opt) => opt.flag.match(/^(--[a-z][a-z0-9-]*)/i)?.[1] ?? opt.flag); - throw new IncompleteCommandError( + throw new UsageError( `Missing required ${names.length > 1 ? "flags" : "flag"}: ${names.join(", ")}`, ); } diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 6502cd3..bf9b259 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -10,12 +10,7 @@ import { type RunContext, } from "./middleware.ts"; import type { Command, Config, GlobalFlags } from "bailian-cli-core"; -import { - GLOBAL_OPTIONS, - IncompleteCommandError, - loadConfig, - flushTelemetry, -} from "bailian-cli-core"; +import { GLOBAL_OPTIONS, UsageError, loadConfig, flushTelemetry } from "bailian-cli-core"; import { setupProxyFromEnv } from "./proxy.ts"; import { handleError } from "./error-handler.ts"; import { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; @@ -37,12 +32,9 @@ export interface Cli { } /** - * Build a CLI from an injected command set. The kernel is agnostic to *which* - * commands exist — each product (bailian-cli, rag-cli, …) passes its own map and - * identity. `run` is a thin orchestrator: resolve argv into a {@link Resolution}, - * then dispatch — terminal kinds render and return; `run` enters the middleware - * stack guarded by a single error boundary. No business `if`s, no scattered - * `process.exit`. + * Build a CLI from an injected command set — each product (bl / rag / …) passes + * its own commands + identity. `run` resolves argv into a {@link Resolution}, + * then dispatches it. */ export function createCli(commands: Record, opts: CliOptions): Cli { const registry = new CommandRegistry(commands, opts.binName); @@ -117,9 +109,12 @@ export function createCli(commands: Record, opts: CliOptions): case "run": { try { + // 解析 flag + 跨 flag 校验:任何用法问题都抛 UsageError const flags = parseFlags(res.rest, [...GLOBAL_OPTIONS, ...(res.command.options ?? [])]); const invalid = res.command.validate?.(flags); - if (invalid) throw new IncompleteCommandError(invalid); + if (invalid) throw new UsageError(invalid); + + // 校验通过 → 准备配置、进中间件执行命令 const config = buildConfig(flags); const ctx: RunContext = { binName, @@ -133,11 +128,13 @@ export function createCli(commands: Record, opts: CliOptions): await runMiddleware(ctx); await flushTelemetry(1000); } catch (err) { - await flushTelemetry(1000); - if (err instanceof IncompleteCommandError) { + // 裸调用(命令后什么都没写)下的 UsageError → 当"还没写完",打 help、exit 0; + // 写了 flag 却无效、或执行时报错 → 报错、exit 2。 + if (err instanceof UsageError && res.rest.length === 0) { registry.printHelp(res.path, process.stderr); return; } + await flushTelemetry(1000); handleError(err, binName); } return; diff --git a/packages/runtime/src/output/output.ts b/packages/runtime/src/output/output.ts index 54de51a..04c3037 100644 --- a/packages/runtime/src/output/output.ts +++ b/packages/runtime/src/output/output.ts @@ -2,13 +2,8 @@ import { formatOutput, type OutputFormat } from "bailian-cli-core"; /** * Emit the primary result of a command. - * - * Design principle: - * stdout → structured data only (JSON when piped, text when TTY) - * stderr → human info (progress, logs, tips) — handled elsewhere - * - * This ensures `bl cmd ... | jq .` always receives clean JSON, - * while interactive users see human-readable text. + * stdout → result (text by default; JSON with --output json) + * stderr → human info (progress, logs, tips) — handled elsewhere */ export function emitResult(data: unknown, format: OutputFormat): void { process.stdout.write(formatOutput(data, format) + "\n"); diff --git a/packages/runtime/tests/args.test.ts b/packages/runtime/tests/args.test.ts index 5b78bcd..938e24c 100644 --- a/packages/runtime/tests/args.test.ts +++ b/packages/runtime/tests/args.test.ts @@ -129,11 +129,11 @@ test("parseFlags validates number flags", () => { ); }); -test("parseFlags throws IncompleteCommandError when a required flag is missing", () => { +test("parseFlags throws UsageError when a required flag is missing", () => { expect(() => parseFlags(["--model", "qwen-image-2.0"], OPTS)).toThrowError( expect.objectContaining({ - name: "IncompleteCommandError", - exitCode: ExitCode.SUCCESS, + name: "UsageError", + exitCode: ExitCode.USAGE, message: expect.stringContaining("Missing required flag: --prompt"), }), );