feat(runtime): support colocated localized CLI help text

This commit is contained in:
若麒
2026-08-13 16:12:09 +08:00
parent ea7b0f016f
commit 6b685964f3
19 changed files with 438 additions and 89 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ export default defineCommand({
type: "string",
valueHint: "<key>",
description:
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"Config key (language, base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
required: true,
},
value: {
@@ -22,6 +22,7 @@ export default defineCommand({
},
},
exampleArgs: [
"--key language --value zh-CN",
"--key output --value json",
"--key timeout --value 600",
"--key base_url --value https://dashscope.aliyuncs.com",
@@ -1,7 +1,13 @@
import { BailianError, ExitCode, normalizeModelBaseUrl } from "bailian-cli-core";
import {
BailianError,
ExitCode,
normalizeModelBaseUrl,
SUPPORTED_LANGUAGES,
} from "bailian-cli-core";
/** Config keys that `config set` / `config ui` accept for read/write. */
export const VALID_KEYS = [
"language",
"base_url",
"output",
"output_dir",
@@ -47,6 +53,7 @@ export const UI_VALID_KEYS = [...VALID_KEYS, ...UI_EXTRA_KEYS] as const;
// Keys the UI renders as a fixed-choice dropdown instead of a free-text input.
export const UI_ENUM_KEYS: Record<string, string[]> = {
language: [...SUPPORTED_LANGUAGES],
output: ["text", "json"],
console_site: ["domestic", "international"],
};
@@ -144,6 +151,13 @@ export function validateAndCoerce(key: string, value: string): string | number {
);
}
if (resolvedKey === "language" && !(SUPPORTED_LANGUAGES as readonly string[]).includes(value)) {
throw new BailianError(
`Invalid language "${value}". Valid values: ${SUPPORTED_LANGUAGES.join(", ")}`,
ExitCode.USAGE,
);
}
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
@@ -1,4 +1,4 @@
import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
import { DEFAULT_LANGUAGE, defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { SECRET_KEYS } from "./shared.ts";
@@ -14,6 +14,7 @@ export default defineCommand({
const result: Record<string, unknown> = {
...file,
language: file.language ?? DEFAULT_LANGUAGE,
base_url: client.baseUrl,
output: settings.output,
timeout: settings.timeout,
@@ -189,6 +189,19 @@ describe("e2e: config", () => {
}
});
test("config set language 拒绝不支持的值", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
"language",
"--value",
"fr-FR",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid language|en-US, zh-CN/i);
});
test("config set 非法 key 时退出为用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
+10 -2
View File
@@ -1,5 +1,13 @@
export type { ConfigFile, Region, Identity, Settings } from "./schema.ts";
export { BAILIAN_HOST, CONFIG_FILE_KEYS, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts";
export type { ConfigFile, Language, Region, Identity, Settings } from "./schema.ts";
export {
BAILIAN_HOST,
CONFIG_FILE_KEYS,
DEFAULT_LANGUAGE,
DOCS_HOSTS,
REGIONS,
SUPPORTED_LANGUAGES,
parseConfigFile,
} from "./schema.ts";
export { normalizeConfigName, readConfigFile, writeConfigFile } from "./loader.ts";
export {
activateConfigProfile,
+11
View File
@@ -16,7 +16,12 @@ export const BAILIAN_HOST = "https://bailian.cn-beijing.aliyuncs.com";
export type Region = keyof typeof REGIONS;
export const SUPPORTED_LANGUAGES = ["en-US", "zh-CN"] as const;
export type Language = (typeof SUPPORTED_LANGUAGES)[number];
export const DEFAULT_LANGUAGE: Language = "en-US";
export interface ConfigFile {
language?: Language;
api_key?: string;
/** OAuth-style token from `bl auth login --console` callback; sent as `Authorization: Bearer …` */
access_token?: string;
@@ -45,6 +50,7 @@ export interface ConfigFile {
}
export const CONFIG_FILE_KEYS = [
"language",
"api_key",
"access_token",
"access_key_id",
@@ -90,6 +96,11 @@ export function parseConfigFile(raw: unknown): ConfigFile {
const obj = raw as Record<string, unknown>;
const out: ConfigFile = {};
if (
typeof obj.language === "string" &&
(SUPPORTED_LANGUAGES as readonly string[]).includes(obj.language)
)
out.language = obj.language as Language;
if (typeof obj.api_key === "string") out.api_key = obj.api_key;
if (typeof obj.access_token === "string" && obj.access_token.length > 0)
out.access_token = obj.access_token;
+69 -20
View File
@@ -4,6 +4,8 @@ import type { AuthStore } from "../auth/store.ts";
import type { Client } from "../client/client.ts";
import type { CommandPackManager } from "./command-pack-manager.ts";
export type LocalizedText = string | { readonly "en-US": string; readonly "zh-CN": string };
// ── Flag definitions ─────────────────────────────────────────────────────────
// Flags are keyed by camelCase name (the key IS the parsed flag name, e.g.
// `maxTokens` ↔ `--max-tokens`). The flag's type drives both runtime parsing
@@ -12,12 +14,12 @@ import type { CommandPackManager } from "./command-pack-manager.ts";
/** A presence flag: `--quiet`. No value; absent → false. */
export interface SwitchFlag {
type: "switch";
description: string;
description: LocalizedText;
}
/** A value flag: `--prompt <text>`, `--n <count>`, `--watermark true|false`. */
export interface ValueFlag {
type: "string" | "number" | "boolean" | "array";
description: string;
description: LocalizedText;
valueHint: string;
required?: boolean;
/**
@@ -70,26 +72,44 @@ export const GLOBAL_FLAGS = {
output: {
type: "string",
valueHint: "<format>",
description: "Output format: text, json",
description: { "en-US": "Output format: text, json", "zh-CN": "输出格式:text、json" },
},
timeout: {
type: "number",
valueHint: "<seconds>",
description: "Request timeout",
description: { "en-US": "Request timeout", "zh-CN": "请求超时时间" },
},
quiet: {
type: "switch",
description: { "en-US": "Suppress non-essential output", "zh-CN": "隐藏非必要输出" },
},
quiet: { type: "switch", description: "Suppress non-essential output" },
verbose: {
type: "switch",
description: "Print HTTP request/response details",
description: {
"en-US": "Print HTTP request/response details",
"zh-CN": "打印 HTTP 请求和响应详情",
},
},
dryRun: {
type: "switch",
description: { "en-US": "Dry run mode", "zh-CN": "仅预览,不实际执行" },
},
dryRun: { type: "switch", description: "Dry run mode" },
config: {
type: "string",
valueHint: "<name>",
description: "Use a config profile for this command",
description: {
"en-US": "Use a config profile for this command",
"zh-CN": "为当前命令使用指定配置 Profile",
},
},
help: {
type: "switch",
description: { "en-US": "Show help", "zh-CN": "显示帮助信息" },
},
version: {
type: "switch",
description: { "en-US": "Print version", "zh-CN": "显示版本信息" },
},
help: { type: "switch", description: "Show help" },
version: { type: "switch", description: "Print version" },
} satisfies FlagsDef;
/** Command-scoped flag for commands that support parallel API calls. */
@@ -111,8 +131,16 @@ export const ASYNC_FLAG = {
/** Model 域凭证/连接 flag,`auth: "apiKey"` 命令可见。 */
export const MODEL_AUTH_FLAGS = {
apiKey: { type: "string", valueHint: "<key>", description: "API key" },
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL" },
apiKey: {
type: "string",
valueHint: "<key>",
description: { "en-US": "API key", "zh-CN": "API Key" },
},
baseUrl: {
type: "string",
valueHint: "<url>",
description: { "en-US": "API base URL", "zh-CN": "API Base URL" },
},
} satisfies FlagsDef;
/** Console 域目标/作用域 flag,`auth: "console"` 命令可见。 */
@@ -120,22 +148,34 @@ export const CONSOLE_AUTH_FLAGS = {
consoleRegion: {
type: "string",
valueHint: "<region>",
description: "Console gateway region (e.g. cn-beijing, ap-southeast-1)",
description: {
"en-US": "Console gateway region (e.g. cn-beijing, ap-southeast-1)",
"zh-CN": "控制台网关地域(例如 cn-beijing、ap-southeast-1)",
},
},
consoleSite: {
type: "string",
valueHint: "<site>",
description: "Console site: domestic, international",
description: {
"en-US": "Console site: domestic, international",
"zh-CN": "控制台站点:domestic、international",
},
},
consoleSwitchAgent: {
type: "number",
valueHint: "<uid>",
description: "Switch agent UID for delegated access",
description: {
"en-US": "Switch agent UID for delegated access",
"zh-CN": "切换代理访问的 UID",
},
},
workspaceId: {
type: "string",
valueHint: "<id>",
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID)",
description: {
"en-US": "Workspace ID (env: BAILIAN_WORKSPACE_ID)",
"zh-CN": "Workspace ID(环境变量:BAILIAN_WORKSPACE_ID)",
},
},
} satisfies FlagsDef;
@@ -144,17 +184,26 @@ export const OPENAPI_AUTH_FLAGS = {
accessKeyId: {
type: "string",
valueHint: "<key>",
description: "Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID)",
description: {
"en-US": "Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID)",
"zh-CN": "阿里云 Access Key ID(环境变量:ALIBABA_CLOUD_ACCESS_KEY_ID)",
},
},
accessKeySecret: {
type: "string",
valueHint: "<key>",
description: "Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET)",
description: {
"en-US": "Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET)",
"zh-CN": "阿里云 Access Key Secret(环境变量:ALIBABA_CLOUD_ACCESS_KEY_SECRET)",
},
},
securityToken: {
type: "string",
valueHint: "<token>",
description: "Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN)",
description: {
"en-US": "Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN)",
"zh-CN": "阿里云 STS Security Token(环境变量:ALIBABA_CLOUD_SECURITY_TOKEN)",
},
},
} satisfies FlagsDef;
@@ -204,7 +253,7 @@ export interface CommandContext<F extends FlagsDef = FlagsDef> {
* {@link AnyCommand}; the precise typing lives at the `defineCommand` call site.
*/
export interface Command<F extends FlagsDef = FlagsDef> {
description: string;
description: LocalizedText;
/** Credential this command requires. See {@link AuthRequirement}. */
auth: AuthRequirement;
/** Usage line arg portion, e.g. "--prompt <text> [flags]". Manually written. */
+1
View File
@@ -2,6 +2,7 @@ export type {
Command,
AnyCommand,
CommandContext,
LocalizedText,
FlagDef,
FlagsDef,
ParsedFlags,
+5
View File
@@ -311,6 +311,11 @@ test("parseConfigFile ignores obsolete region field", () => {
expect("region" in f).toBe(false);
});
test("parseConfigFile accepts only supported languages", () => {
expect(parseConfigFile({ language: "zh-CN" }).language).toBe("zh-CN");
expect(parseConfigFile({ language: "fr-FR" }).language).toBeUndefined();
});
test("parseConfigFile accepts only well-formed http(s) base_url", () => {
expect(parseConfigFile({ base_url: "https://dashscope.aliyuncs.com" }).base_url).toBe(
"https://dashscope.aliyuncs.com",
+1
View File
@@ -43,6 +43,7 @@
"bailian-cli-core": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:",
"i18next": "catalog:",
"undici": "catalog:"
},
"devDependencies": {
+34 -10
View File
@@ -12,6 +12,7 @@ import {
import type { AnyCommand, FlagsDef, Identity, ParsedFlags, SourceFlags } from "bailian-cli-core";
import {
CONSOLE_AUTH_FLAGS,
DEFAULT_LANGUAGE,
GLOBAL_FLAGS,
MODEL_AUTH_FLAGS,
OPENAPI_AUTH_FLAGS,
@@ -28,10 +29,11 @@ import {
} from "bailian-cli-core";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { printQuickStart } from "./output/banner.ts";
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";
/** Per-product identity injected by each CLI entrypoint (bl / rag / …). */
export interface CliOptions {
@@ -60,6 +62,20 @@ function pick(obj: Record<string, unknown>, keys: string[]): Record<string, unkn
return out;
}
/** Read only the existing `--config` flag so locale follows the selected Profile before dispatch. */
function pickConfigFlag(argv: string[]): Partial<SourceFlags> {
for (let argumentIndex = 0; argumentIndex < argv.length; argumentIndex++) {
const argument = argv[argumentIndex];
if (argument === "--config") {
return { config: argv[argumentIndex + 1] };
}
if (argument?.startsWith("--config=")) {
return { config: argument.slice("--config=".length) };
}
}
return {};
}
/**
* 进程级一次性设置:代理初始化、Ctrl+C、stdout EPIPE。
* 属进程生命周期行为,装一次即可,不进 per-command 中间件。
@@ -94,19 +110,26 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
const identity: Identity = { binName, version, npmPackage, clientName };
const commandPackPolicy = opts.commandPacks ?? { supported: {} };
const commandPackManager = createCommandPackManager(identity, commandPackPolicy);
let registryPromise: Promise<CommandRegistry> | undefined;
let loadedCommandPacksPromise: ReturnType<typeof loadCommandPacks> | undefined;
installProcessHandlers(binName);
const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]);
function getRegistry(): Promise<CommandRegistry> {
if (!registryPromise) {
registryPromise = loadCommandPacks(commands, identity, commandPackPolicy).then(
(loaded) => new CommandRegistry(loaded.commands, binName),
);
function getLoadedCommandPacks(): ReturnType<typeof loadCommandPacks> {
if (!loadedCommandPacksPromise) {
loadedCommandPacksPromise = loadCommandPacks(commands, identity, commandPackPolicy);
}
return registryPromise;
return loadedCommandPacksPromise;
}
async function getRegistry(argv: string[]): Promise<CommandRegistry> {
const localeSources = buildSources(pickConfigFlag(argv));
const [translator, loaded] = await Promise.all([
createTranslator(localeSources.file.language ?? DEFAULT_LANGUAGE),
getLoadedCommandPacks(),
]);
return new CommandRegistry(loaded.commands, binName, translator);
}
/** Render help for `path`; root ([]) doubles as the onboarding / login guide. */
@@ -133,7 +156,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
if (hasKey) {
if (opts.quickStartTasks?.length) printQuickStart(opts.quickStartTasks);
} else {
printWelcomeBanner(binName);
registry.printWelcome();
}
}
@@ -207,7 +230,8 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
return {
run(argv: string[] = process.argv.slice(2)) {
return getRegistry()
return Promise.resolve()
.then(() => getRegistry(argv))
.then((registry) => dispatch(registry, argv))
.catch(
(err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void,
+56
View File
@@ -0,0 +1,56 @@
import { createInstance, type ResourceLanguage, type TOptions } from "i18next";
import {
DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGES,
type Language,
type LocalizedText,
} from "bailian-cli-core";
export type Translate = (key: string, options?: Record<string, unknown>) => string;
/** A colocated namespace that a product or feature can register with the CLI runtime. */
export interface CliMessageBundle {
namespace: string;
resources: Partial<Record<Language, ResourceLanguage>>;
}
/** Per-CLI translation surface. The mutable i18next instance stays private to runtime. */
export interface Translator {
language: Language;
translate: Translate;
localize(text: LocalizedText): string;
}
export async function createTranslator(
language: Language,
bundles: readonly CliMessageBundle[] = [],
): Promise<Translator> {
const instance = createInstance();
await instance.init({
lng: language,
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: [...SUPPORTED_LANGUAGES],
initAsync: false,
interpolation: { escapeValue: false },
});
for (const bundle of bundles) {
for (const supportedLanguage of SUPPORTED_LANGUAGES) {
const resource = bundle.resources[supportedLanguage];
if (resource) {
instance.addResourceBundle(supportedLanguage, bundle.namespace, resource, true, false);
}
}
}
return {
language,
localize(text) {
return typeof text === "string" ? text : text[language];
},
translate(key, options = {}) {
return String(instance.t(key, options as TOptions));
},
};
}
+30 -8
View File
@@ -1,16 +1,38 @@
import { API_KEY_PAGE, TOKEN_PLAN_PAGE } from "../urls.ts";
import type { LocalizedText } from "bailian-cli-core";
import type { Translator } from "../i18n.ts";
import { ansi } from "./color.ts";
export function printWelcomeBanner(cliName: string): void {
const WELCOME_TEXT = {
title: { "en-US": "Welcome to Bailian CLI!", "zh-CN": "欢迎使用 Bailian CLI!" },
getStarted: { "en-US": "Get started in 2 steps:", "zh-CN": "只需两步即可开始使用:" },
getApiKey: { "en-US": "Get your API Key:", "zh-CN": "获取 API Key:" },
login: { "en-US": "Login:", "zh-CN": "登录:" },
tokenPlan: { "en-US": "Token Plan:", "zh-CN": "Token Plan:" },
} satisfies Record<string, LocalizedText>;
function localize(translator: Translator | undefined, text: LocalizedText): string {
return translator?.localize(text) ?? (typeof text === "string" ? text : text["en-US"]);
}
export function printWelcomeBanner(cliName: string, translator?: Translator): void {
const color = ansi(process.stderr);
process.stderr.write(`\n Welcome to ${color.purple("Bailian")} CLI!\n\n`);
process.stderr.write(" Get started in 2 steps:\n");
process.stderr.write(` 1. Get your API Key: ${API_KEY_PAGE}\n`);
process.stderr.write(` 2. Login: ${cliName} auth login --api-key <your-key>\n\n`);
process.stderr.write(" Token Plan:\n");
process.stderr.write(` 1. Get your API Key: ${TOKEN_PLAN_PAGE}\n`);
const title = localize(translator, WELCOME_TEXT.title).replace(
"Bailian",
color.purple("Bailian"),
);
process.stderr.write(`\n ${title}\n\n`);
process.stderr.write(` ${localize(translator, WELCOME_TEXT.getStarted)}\n`);
process.stderr.write(` 1. ${localize(translator, WELCOME_TEXT.getApiKey)} ${API_KEY_PAGE}\n`);
process.stderr.write(
` 2. Login: ${cliName} auth login --config token-plan --api-key <your-key>\n\n`,
` 2. ${localize(translator, WELCOME_TEXT.login)} ${cliName} auth login --api-key <your-key>\n\n`,
);
process.stderr.write(` ${localize(translator, WELCOME_TEXT.tokenPlan)}\n`);
process.stderr.write(
` 1. ${localize(translator, WELCOME_TEXT.getApiKey)} ${TOKEN_PLAN_PAGE}\n`,
);
process.stderr.write(
` 2. ${localize(translator, WELCOME_TEXT.login)} ${cliName} auth login --config token-plan --api-key <your-key>\n\n`,
);
}
+86 -35
View File
@@ -1,4 +1,10 @@
import type { AnyCommand, AuthRequirement, FlagDef, FlagsDef } from "bailian-cli-core";
import type {
AnyCommand,
AuthRequirement,
FlagDef,
FlagsDef,
LocalizedText,
} from "bailian-cli-core";
import { UsageError } from "bailian-cli-core";
import {
CONSOLE_AUTH_FLAGS,
@@ -8,6 +14,8 @@ import {
credentialFlagDefs,
} from "bailian-cli-core";
import { camelToKebab } from "./args.ts";
import type { Translator } from "./i18n.ts";
import { printWelcomeBanner } from "./output/banner.ts";
import { ansi } from "./output/color.ts";
export type { Command, AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core";
@@ -25,6 +33,32 @@ interface CommandNode {
children: Map<string, CommandNode>;
}
const HELP_TEXT = {
usage: { "en-US": "Usage:", "zh-CN": "用法:" },
commands: { "en-US": "Commands:", "zh-CN": "命令:" },
flags: { "en-US": "Flags:", "zh-CN": "选项:" },
globalFlags: { "en-US": "Global Flags:", "zh-CN": "全局选项:" },
modelAuthFlags: { "en-US": "Model Auth Flags:", "zh-CN": "模型鉴权选项:" },
modelAuthScope: { "en-US": "(model-domain commands)", "zh-CN": "(模型域命令)" },
consoleAuthFlags: { "en-US": "Console Auth Flags:", "zh-CN": "控制台鉴权选项:" },
consoleAuthScope: { "en-US": "(console-domain commands)", "zh-CN": "(控制台域命令)" },
openApiAuthFlags: { "en-US": "OpenAPI Auth Flags:", "zh-CN": "OpenAPI 鉴权选项:" },
openApiAuthScope: { "en-US": "(openapi-domain commands)", "zh-CN": "(OpenAPI 域命令)" },
gettingHelp: { "en-US": "Getting Help:", "zh-CN": "获取帮助:" },
gettingHelpDescription1: {
"en-US": "Add --help after any command to see its full list of flags, defaults,",
"zh-CN": "在任意命令后添加 --help,查看完整的选项和默认值,",
},
gettingHelpDescription2: {
"en-US": "and usage examples. For example:",
"zh-CN": "以及用法示例。例如:",
},
notes: { "en-US": "Notes:", "zh-CN": "说明:" },
examples: { "en-US": "Examples:", "zh-CN": "示例:" },
minimalWorkflow: { "en-US": "Minimal workflow.yaml:", "zh-CN": "最小 workflow.yaml:" },
tryIt: { "en-US": "Try it:", "zh-CN": "试一试:" },
} satisfies Record<string, LocalizedText>;
/**
* What a command path resolves to in the registry. The single judgement that
* feeds `resolve()` — no scattered `isGroupPath` + throwing `resolve`.
@@ -42,15 +76,21 @@ export class CommandRegistry {
private root: CommandNode = { children: new Map() };
/** Binary name shown in usage/help/error strings (e.g. "bl", "rag"). */
private readonly cliName: string;
private readonly translator?: Translator;
private readonly authRequirements = new Set<AuthRequirement>();
constructor(commands: Record<string, AnyCommand>, cliName: string) {
constructor(commands: Record<string, AnyCommand>, cliName: string, translator?: Translator) {
this.cliName = cliName;
this.translator = translator;
for (const [path, cmd] of Object.entries(commands)) {
this.register(path, cmd);
}
}
private localize(text: LocalizedText): string {
return this.translator?.localize(text) ?? (typeof text === "string" ? text : text["en-US"]);
}
private register(path: string, command: AnyCommand): void {
// 同名守卫:命令自有 flag 不得与全局或其可见凭证域 flag 同名。
const reserved = { ...GLOBAL_FLAGS, ...credentialFlagDefs(command) };
@@ -134,7 +174,8 @@ export class CommandRegistry {
if (matched.length > 0 && node.children.size > 0) {
const subcommands = Array.from(node.children.entries())
.map(([name, n]) => {
if (n.command) return ` ${matched.join(" ")} ${name} ${n.command.description}`;
if (n.command)
return ` ${matched.join(" ")} ${name} ${this.localize(n.command.description)}`;
const subs = Array.from(n.children.keys()).join(", ");
return ` ${matched.join(" ")} ${name} [${subs}]`;
})
@@ -164,7 +205,7 @@ export class CommandRegistry {
for (const [name, child] of node.children) {
const fullPath = prefix ? `${prefix} ${name}` : name;
if (child.command) {
entries.push({ path: fullPath, desc: child.command.description });
entries.push({ path: fullPath, desc: this.localize(child.command.description) });
}
if (child.children.size > 0) {
collect(child, fullPath);
@@ -184,7 +225,7 @@ export class CommandRegistry {
): string {
const lines = Object.entries(defs).map(([k, def]) => ({
flag: flagDisplay(k, def),
desc: def.description,
desc: this.localize(def.description),
}));
const maxLen = Math.max(...lines.map((l) => l.flag.length));
return lines.map((l) => ` ${a(l.flag.padEnd(maxLen + 2))} ${d(l.desc)}`).join("\n");
@@ -231,8 +272,10 @@ export class CommandRegistry {
// Group help (e.g. `bl auth --help`)
const prefix = commandPath.join(" ");
out.write(`\n${this.bold("Usage:", out)} ${this.cliName} ${prefix} <command> [flags]\n\n`);
out.write(`${this.bold("Commands:", out)}\n`);
out.write(
`\n${this.bold(this.localize(HELP_TEXT.usage), out)} ${this.cliName} ${prefix} <command> [flags]\n\n`,
);
out.write(`${this.bold(this.localize(HELP_TEXT.commands), out)}\n`);
this.printChildren(node, prefix, out);
if (prefix === "pipeline") {
this.printPipelineQuickStart(out);
@@ -240,12 +283,16 @@ export class CommandRegistry {
out.write("\n");
}
printWelcome(): void {
printWelcomeBanner(this.cliName, this.translator);
}
private printPipelineQuickStart(out: NodeJS.WriteStream): void {
const b = (s: string) => this.bold(s, out);
const d = (s: string) => this.dim(s, out);
out.write(`
${b("Minimal workflow.yaml:")}
${b(this.localize(HELP_TEXT.minimalWorkflow))}
${d(" version: workflow/v1")}
${d(" steps:")}
${d(" - id: chat")}
@@ -254,7 +301,7 @@ ${d(" input:")}
${d(' message: "Who are you?"')}
${d(' system: "You are a concise assistant."')}
${b("Try it:")}
${b(this.localize(HELP_TEXT.tryIt))}
${d(` ${this.cliName} pipeline validate workflow.yaml`)}
${d(` ${this.cliName} pipeline run workflow.yaml --dry-run --output json`)}
`);
@@ -286,8 +333,8 @@ ${d(` ${this.cliName} pipeline run workflow.yaml --dry-run --output json`)}
const authFlagSections = [
this.buildAuthFlagSection(
"apiKey",
"Model Auth Flags:",
"(model-domain commands)",
this.localize(HELP_TEXT.modelAuthFlags),
this.localize(HELP_TEXT.modelAuthScope),
MODEL_AUTH_FLAGS,
b,
a,
@@ -295,8 +342,8 @@ ${d(` ${this.cliName} pipeline run workflow.yaml --dry-run --output json`)}
),
this.buildAuthFlagSection(
"console",
"Console Auth Flags:",
"(console-domain commands)",
this.localize(HELP_TEXT.consoleAuthFlags),
this.localize(HELP_TEXT.consoleAuthScope),
CONSOLE_AUTH_FLAGS,
b,
a,
@@ -304,8 +351,8 @@ ${d(` ${this.cliName} pipeline run workflow.yaml --dry-run --output json`)}
),
this.buildAuthFlagSection(
"openapi",
"OpenAPI Auth Flags:",
"(openapi-domain commands)",
this.localize(HELP_TEXT.openApiAuthFlags),
this.localize(HELP_TEXT.openApiAuthScope),
OPENAPI_AUTH_FLAGS,
b,
a,
@@ -316,17 +363,17 @@ ${d(` ${this.cliName} pipeline run workflow.yaml --dry-run --output json`)}
.join("\n\n");
out.write(`
${b("Usage:")} ${this.cliName} <resource> <command> [flags]
${b(this.localize(HELP_TEXT.usage))} ${this.cliName} <resource> <command> [flags]
${b("Commands:")}
${b(this.localize(HELP_TEXT.commands))}
${commandLines}
${b("Global Flags:")}
${b(this.localize(HELP_TEXT.globalFlags))}
${globalFlagLines}
${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
${d("Add --help after any command to see its full list of flags, defaults,")}
${d("and usage examples. For example:")} ${this.cliName} ${this.helpExample()} --help
${authFlagSections ? `${authFlagSections}\n\n` : ""}${b(this.localize(HELP_TEXT.gettingHelp))}
${d(this.localize(HELP_TEXT.gettingHelpDescription1))}
${d(this.localize(HELP_TEXT.gettingHelpDescription2))} ${this.cliName} ${this.helpExample()} --help
`);
}
@@ -339,33 +386,34 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
// same command shows the right invocation under any product (bl / rag / …).
const prefix = [this.cliName, ...commandPath].join(" ");
out.write(`\n${cmd.description}\n`);
out.write(`${b("Usage:")} ${prefix}${cmd.usageArgs ? ` ${cmd.usageArgs}` : ""}\n`);
const flagEntries = [
...Object.entries(cmd.flags ?? {}),
...Object.entries(credentialFlagDefs(cmd)),
] as [string, FlagDef][];
out.write(`\n${this.localize(cmd.description)}\n`);
out.write(
`${b(this.localize(HELP_TEXT.usage))} ${prefix}${cmd.usageArgs ? ` ${cmd.usageArgs}` : ""}\n`,
);
const ownFlagEntries = Object.entries(cmd.flags ?? {}) as [string, FlagDef][];
const credentialFlagEntries = Object.entries(credentialFlagDefs(cmd)) as [string, FlagDef][];
const flagEntries = [...ownFlagEntries, ...credentialFlagEntries];
if (flagEntries.length > 0) {
const lines = flagEntries.map(([k, def]) => ({
flag: flagDisplay(k, def),
desc: def.description,
const lines = flagEntries.map(([key, def]) => ({
flag: flagDisplay(key, def),
desc: this.localize(def.description),
}));
const maxLen = Math.max(...lines.map((l) => l.flag.length));
out.write(`\n${b("Flags:")}\n`);
out.write(`\n${b(this.localize(HELP_TEXT.flags))}\n`);
for (const l of lines) {
out.write(` ${a(l.flag.padEnd(maxLen + 2))} ${d(l.desc)}\n`);
}
}
out.write(`\n${b("Global Flags:")}\n`);
out.write(`\n${b(this.localize(HELP_TEXT.globalFlags))}\n`);
out.write(this.buildFlagLines(GLOBAL_FLAGS, a, d) + "\n");
if (cmd.notes && cmd.notes.length > 0) {
out.write(`\n${b("Notes:")}\n`);
out.write(`\n${b(this.localize(HELP_TEXT.notes))}\n`);
for (const note of cmd.notes) {
out.write(` ${note}\n`);
}
}
if (cmd.exampleArgs && cmd.exampleArgs.length > 0) {
out.write(`\n${b("Examples:")}\n`);
out.write(`\n${b(this.localize(HELP_TEXT.examples))}\n`);
for (const ex of cmd.exampleArgs) {
out.write(` ${d(ex ? `${prefix} ${ex}` : prefix)}\n`);
}
@@ -377,7 +425,10 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
const collect = (n: CommandNode, p: string) => {
for (const [name, child] of n.children) {
if (child.command)
entries.push({ fullName: `${p} ${name}`, description: child.command.description });
entries.push({
fullName: `${p} ${name}`,
description: this.localize(child.command.description),
});
if (child.children.size > 0) collect(child, `${p} ${name}`);
}
};
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "vite-plus/test";
import { defineCommand } from "bailian-cli-core";
import { createTranslator, type CliMessageBundle } from "../src/i18n.ts";
import { CommandRegistry } from "../src/registry.ts";
const messages = {
namespace: "test",
resources: {
"en-US": { greeting: "Hello, {{name}}!", englishOnly: "English fallback" },
"zh-CN": { greeting: "你好,{{name}}!" },
},
} satisfies CliMessageBundle;
test("translator uses the selected language, interpolation and English fallback", async () => {
const translator = await createTranslator("zh-CN", [messages]);
expect(translator.language).toBe("zh-CN");
expect(translator.translate("test:greeting", { name: "百炼" })).toBe("你好,百炼!");
expect(translator.translate("test:englishOnly")).toBe("English fallback");
});
test("translator resolves colocated text while preserving plain strings", async () => {
const translator = await createTranslator("zh-CN");
expect(translator.localize("Already localized")).toBe("Already localized");
expect(
translator.localize({
"en-US": "Show help",
"zh-CN": "显示帮助信息",
}),
).toBe("显示帮助信息");
});
test("registry renders runtime help copy with the selected language", async () => {
const translator = await createTranslator("zh-CN");
const command = defineCommand({
description: {
"en-US": "Test command",
"zh-CN": "测试命令",
},
auth: "none",
run: async () => {},
});
const registry = new CommandRegistry({ test: command }, "bl", translator);
let output = "";
const stream = {
isTTY: false,
write(chunk: string) {
output += chunk;
return true;
},
} as NodeJS.WriteStream;
registry.printHelp([], stream);
expect(output).toContain("用法: bl <resource> <command> [flags]");
expect(output).toContain("测试命令");
expect(output).toContain("全局选项:");
expect(output).toContain("显示帮助信息");
expect(output).toContain("获取帮助:");
});
+21 -3
View File
@@ -24,12 +24,15 @@ catalogs:
chalk:
specifier: ^5.6.2
version: 5.6.2
tar-stream:
specifier: ^3.2.0
version: 3.2.0
i18next:
specifier: ^26.3.6
version: 26.3.6
smol-toml:
specifier: ^1.4.2
version: 1.7.0
tar-stream:
specifier: ^3.2.0
version: 3.2.0
tsx:
specifier: ^4.23.0
version: 4.23.0
@@ -254,6 +257,9 @@ importers:
chalk:
specifier: 'catalog:'
version: 5.6.2
i18next:
specifier: 'catalog:'
version: 26.3.6(typescript@6.0.3)
undici:
specifier: 'catalog:'
version: 6.27.0
@@ -1200,6 +1206,14 @@ packages:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
i18next@26.3.6:
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
peerDependencies:
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
typescript:
optional: true
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
@@ -2250,6 +2264,10 @@ snapshots:
get-east-asian-width@1.6.0: {}
i18next@26.3.6(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
immediate@3.0.6: {}
inherits@2.0.4: {}
+1
View File
@@ -9,6 +9,7 @@ catalog:
ajv: ^8.20.0
boxen: ^8.0.1
chalk: ^5.6.2
i18next: ^26.3.6
tar-stream: ^3.2.0
smol-toml: ^1.4.2
tsx: ^4.23.0
+8 -4
View File
@@ -85,13 +85,17 @@ bl config list --output json
#### Flags
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--key <key>` | string | yes | Config key (base*url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) |
| `--value <value>` | string | yes | Value to set |
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <key>` | string | yes | Config key (language, base*url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) |
| `--value <value>` | string | yes | Value to set |
#### Examples
```bash
bl config set --key language --value zh-CN
```
```bash
bl config set --key output --value json
```
+12 -4
View File
@@ -25,6 +25,7 @@ import {
type AnyCommand,
type FlagDef,
type FlagsDef,
type LocalizedText,
} from "../packages/core/src/index.ts";
import { commands } from "../packages/cli/src/commands.ts";
@@ -62,6 +63,11 @@ function escCell(s: string): string {
return s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
}
/** Skill references are English artifacts; localized CLI text keeps English here. */
function referenceText(text: LocalizedText): string {
return typeof text === "string" ? text : text["en-US"];
}
function topLevel(path: string): string {
return path.split(" ")[0]!;
}
@@ -88,7 +94,7 @@ function formatFlagsTable(flags: FlagsDef | undefined): string {
if (!entries.length) return "_No command-specific flags._\n";
const rows = entries.map(([key, def]) => {
const req = def.type !== "switch" && def.required ? "yes" : "no";
return `| \`${escCell(flagDisplay(key, def))}\` | ${escCell(flagType(def))} | ${req} | ${escCell(def.description)} |`;
return `| \`${escCell(flagDisplay(key, def))}\` | ${escCell(flagType(def))} | ${req} | ${escCell(referenceText(def.description))} |`;
});
return [
"| Flag | Type | Required | Description |",
@@ -118,7 +124,7 @@ function commandSection(path: string, cmd: AnyCommand): string {
lines.push(`### \`bl ${path}\``, "");
lines.push(`| Field | Value |`, `| --- | --- |`);
lines.push(`| **Name** | \`${escCell(path)}\` |`);
lines.push(`| **Description** | ${escCell(cmd.description)} |`);
lines.push(`| **Description** | ${escCell(referenceText(cmd.description))} |`);
// Commands store argument-only usage; the `bl <path>` prefix is added here.
const usage = `bl ${path}${cmd.usageArgs ? ` ${cmd.usageArgs}` : ""}`;
lines.push(`| **Usage** | \`${escCell(usage)}\` |`);
@@ -172,7 +178,7 @@ function buildGroupFile(group: string, groupEntries: [string, AnyCommand][]): st
];
for (const [path, cmd] of groupEntries) {
lines.push(`| \`bl ${path}\` | ${escCell(cmd.description)} |`);
lines.push(`| \`bl ${path}\` | ${escCell(referenceText(cmd.description))} |`);
}
lines.push("", "## Command details", "");
@@ -205,7 +211,9 @@ function buildIndex(
for (const [path, cmd] of entries) {
const group = topLevel(path);
lines.push(`| \`bl ${path}\` | ${escCell(cmd.description)} | [${group}.md](${group}.md) |`);
lines.push(
`| \`bl ${path}\` | ${escCell(referenceText(cmd.description))} | [${group}.md](${group}.md) |`,
);
}
lines.push("", "## By group", "", "| Group | Commands | Reference |", "| --- | --- | --- |");