diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index ea75a4a..67c718a 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -11,7 +11,7 @@ export default defineCommand({ type: "string", valueHint: "", 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", diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 2473f80..8cd5446 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -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 = { + 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`, diff --git a/packages/commands/src/commands/config/show.ts b/packages/commands/src/commands/config/show.ts index ee6e3fa..ca35803 100644 --- a/packages/commands/src/commands/config/show.ts +++ b/packages/commands/src/commands/config/show.ts @@ -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 = { ...file, + language: file.language ?? DEFAULT_LANGUAGE, base_url: client.baseUrl, output: settings.output, timeout: settings.timeout, diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index e9b1bdb..877170f 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -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", diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 48c89a5..029905b 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -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, diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 56f4ff1..2196bb7 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -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; 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; diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 4bd14be..fb31428 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -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 `, `--n `, `--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: "", - description: "Output format: text, json", + description: { "en-US": "Output format: text, json", "zh-CN": "输出格式:text、json" }, }, timeout: { type: "number", valueHint: "", - 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: "", - 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: "", description: "API key" }, - baseUrl: { type: "string", valueHint: "", description: "API base URL" }, + apiKey: { + type: "string", + valueHint: "", + description: { "en-US": "API key", "zh-CN": "API Key" }, + }, + baseUrl: { + type: "string", + valueHint: "", + 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: "", - 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: "", - description: "Console site: domestic, international", + description: { + "en-US": "Console site: domestic, international", + "zh-CN": "控制台站点:domestic、international", + }, }, consoleSwitchAgent: { type: "number", valueHint: "", - description: "Switch agent UID for delegated access", + description: { + "en-US": "Switch agent UID for delegated access", + "zh-CN": "切换代理访问的 UID", + }, }, workspaceId: { type: "string", valueHint: "", - 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: "", - 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: "", - 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: "", - 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 { * {@link AnyCommand}; the precise typing lives at the `defineCommand` call site. */ export interface Command { - description: string; + description: LocalizedText; /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; /** Usage line arg portion, e.g. "--prompt [flags]". Manually written. */ diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index bafd242..b1f5a4e 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -2,6 +2,7 @@ export type { Command, AnyCommand, CommandContext, + LocalizedText, FlagDef, FlagsDef, ParsedFlags, diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index b8aaf7f..44eb765 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -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", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 79cc59d..abceb73 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -43,6 +43,7 @@ "bailian-cli-core": "workspace:*", "boxen": "catalog:", "chalk": "catalog:", + "i18next": "catalog:", "undici": "catalog:" }, "devDependencies": { diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 6eb03a1..38da2e1 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -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, keys: string[]): Record { + 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, opts: CliOptions const identity: Identity = { binName, version, npmPackage, clientName }; const commandPackPolicy = opts.commandPacks ?? { supported: {} }; const commandPackManager = createCommandPackManager(identity, commandPackPolicy); - let registryPromise: Promise | undefined; + let loadedCommandPacksPromise: ReturnType | undefined; installProcessHandlers(binName); const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]); - function getRegistry(): Promise { - if (!registryPromise) { - registryPromise = loadCommandPacks(commands, identity, commandPackPolicy).then( - (loaded) => new CommandRegistry(loaded.commands, binName), - ); + function getLoadedCommandPacks(): ReturnType { + if (!loadedCommandPacksPromise) { + loadedCommandPacksPromise = loadCommandPacks(commands, identity, commandPackPolicy); } - return registryPromise; + return loadedCommandPacksPromise; + } + + async function getRegistry(argv: string[]): Promise { + 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, 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, 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, diff --git a/packages/runtime/src/i18n.ts b/packages/runtime/src/i18n.ts new file mode 100644 index 0000000..8326333 --- /dev/null +++ b/packages/runtime/src/i18n.ts @@ -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; + +/** A colocated namespace that a product or feature can register with the CLI runtime. */ +export interface CliMessageBundle { + namespace: string; + resources: Partial>; +} + +/** 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 { + 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)); + }, + }; +} diff --git a/packages/runtime/src/output/banner.ts b/packages/runtime/src/output/banner.ts index cb88e0c..d3c3e6e 100644 --- a/packages/runtime/src/output/banner.ts +++ b/packages/runtime/src/output/banner.ts @@ -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; + +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 \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 \n\n`, + ` 2. ${localize(translator, WELCOME_TEXT.login)} ${cliName} auth login --api-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 \n\n`, ); } diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index 22ebd98..3b84480 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -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; } +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; + /** * 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(); - constructor(commands: Record, cliName: string) { + constructor(commands: Record, 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} [flags]\n\n`); - out.write(`${this.bold("Commands:", out)}\n`); + out.write( + `\n${this.bold(this.localize(HELP_TEXT.usage), out)} ${this.cliName} ${prefix} [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} [flags] +${b(this.localize(HELP_TEXT.usage))} ${this.cliName} [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}`); } }; diff --git a/packages/runtime/tests/i18n.test.ts b/packages/runtime/tests/i18n.test.ts new file mode 100644 index 0000000..d49e83b --- /dev/null +++ b/packages/runtime/tests/i18n.test.ts @@ -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 [flags]"); + expect(output).toContain("测试命令"); + expect(output).toContain("全局选项:"); + expect(output).toContain("显示帮助信息"); + expect(output).toContain("获取帮助:"); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb698a3..815a16f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 611df31..3ce8416 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 50403b9..649eb6f 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -85,13 +85,17 @@ bl config list --output json #### Flags -| Flag | Type | Required | Description | -| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--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 ` | string | yes | Value to set | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--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 ` | string | yes | Value to set | #### Examples +```bash +bl config set --key language --value zh-CN +``` + ```bash bl config set --key output --value json ``` diff --git a/tools/generate-reference.ts b/tools/generate-reference.ts index 976d80a..a9f4b46 100644 --- a/tools/generate-reference.ts +++ b/tools/generate-reference.ts @@ -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 ` 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 |", "| --- | --- | --- |");