diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index b4462926..3307b8b7 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -53,7 +53,8 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - 显式 `auth login --config ` 在凭证落盘成功后自动激活目标 Profile;未传 `--config` 时继续写当前激活项,失败和 dry-run 不切换 - `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认 -- `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段 +- `resolveOpenApi()` — AK/SK 统一解析链:显式参数 > `BAILIAN_ACCESS_KEY_ID` / `BAILIAN_ACCESS_KEY_SECRET` > 当前 config `access_key_*` > `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`。各层使用同组 `security_token` / `BAILIAN_SECURITY_TOKEN` / `ALIBABA_CLOUD_SECURITY_TOKEN`,缺少整组时继续查找,部分配置则报错,不跨来源拼接。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段。Aliyun 宿主凭证按标准环境变量处理,不读取或切换 Aliyun config。 +- 上述 AK/SK 参数优先级仅适用于开放这些参数的入口(如 `auth: "openapi"` 命令)。Console 业务命令不开放临时 AK/SK 参数,只使用提前准备好的凭证:百炼环境变量 > 当前百炼配置 > 阿里云环境变量。 - `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照 命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。 @@ -67,9 +68,14 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx ### Console token 签发与刷新 +- `packages/core/src/client/console-session.ts` 集中管理 Console 凭证、换票、重试、并发协调及落盘;`Client.console()` 委托给该内部模块,对外调用方式不变。 - `resolveCLIAccessTokenTarget()` 优先采用有效 `consoleRegion`;地域缺省时识别国内/新加坡 DashScope 和对应 `*.maas.aliyuncs.com` 域名(含 Token Plan、Workspace)。未知或不支持的地域报错,不静默回退北京;`consoleSite` 与地域独立,不能把新加坡自动等同国际站。 - `Client.console()` 收到 `NotLogined` 后,以本次实际 Console credential 的 region/site/switchAgent 签发并重试一次。只有原请求重试成功才保存新 token,并更新 Client 内存凭证;失败保留磁盘旧 token。 -- `refreshAccessToken()` 独立调用时默认用只读 `loginInfo` 验证;调用方提供 `validateToken` 时由其完成验证或原请求重试,回调成功后才落盘。缺少当前 Profile 的 AK/SK 返回 `null`,不跨 Profile 借用凭证。 +- Console token 为空时,authStage 先通过统一 AK/SK 链校验凭证是否可用;首次 `Client.console()` 调用时换票,并直接执行原请求。token 失效时也复用该 AK/SK 链与签发接口;每次请求至多换票一次,新 token 再次失效不循环刷新。有效 token 不要求额外提供 AK/SK。 +- 同一 Client 的并发请求共享在途换票,但每个业务请求独立执行和报错;任一业务请求成功后才更新 Client 凭证,符合落盘条件时只保存一次。旧 token 的失效响应晚到时,直接用该 Client 已刷新的 token 重试,不重复签发。换票失败或所有等待请求结束后清理在途状态,后续调用可重新尝试;不同 Client(包括 `withConsoleSwitchAgent()` 创建的实例)不共享换票状态。 +- 新增自动获取 token,以及使用环境变量凭证的刷新,只更新 Client 内存;不保存 AK/SK、STS 或 token。已有本地 token 使用本地 config AK/SK 刷新时,保留“原请求重试成功才保存 token”的规则。 +- `auth status` / AuthStore 快照仅展示当前凭证,不换票、不在线验证、不增加“可换票”等推断状态。静态 dry-run 不换票;需要 Console 只读请求的 prepare/dry-run 按原规则鉴权。模型 API Key 链、登录/退出、根帮助与配置 UI 不随此次换票接入改写。 +- `refreshAccessToken()` 独立调用时默认用只读 `loginInfo` 验证;调用方提供 `validateToken` 时由其完成验证,允许持久化时在回调成功后落盘。Client 的共享换票使用 `persist: false`,由各业务请求验证,并在符合条件的首次成功后保存。未传入已解析凭证且当前 Profile 缺少 AK/SK 时返回 `null`,不跨 Profile 借用凭证。 - `auth generate-access-token` 只签发并输出,不做 Console 验证、不持久化;首次登录的校验直接走 `callConsoleGateway`,不得递归进入刷新流程。 ### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入 @@ -159,7 +165,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - [ ] `packages/commands/tests/e2e/auth.e2e.test.ts` 增加新方式的 happy / failure 路径;浏览器回调契约在 `packages/commands/tests/login-console.test.ts` 覆盖 - [ ] mask token 的输出格式不变(避免泄漏) -- [ ] 如调整 resolver 优先级,补 core/runtime 单测覆盖 flag > env > file +- [ ] 如调整 resolver 优先级,补 core/runtime 单测覆盖对应凭证链;AK/SK 为 flag > 百炼 env > file > 阿里云 env,并验证不跨来源拼接 ## 完成后自查 diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index dfd423eb..56352b18 100644 --- a/docs/agents/error-hint-change.md +++ b/docs/agents/error-hint-change.md @@ -73,11 +73,23 @@ process.exit(err.exitCode) - ❌ 新增/改动时不写控制台 URL 或 region - ❌ 新增/改动时不写渠道追踪参数(`source_channel=xxx`) - ✅ 只描述抽象做法(如 `"Set DASHSCOPE_API_KEY environment variable, or pass --api-key."`) -- 当前遗留:`packages/core/src/auth/resolver.ts` 仍含 `bl auth login` hint;触碰鉴权错误时迁到 runtime `errorHint` +- 本地凭证错误用 `CredentialError.reason` 表达失败事实;resolver 的纯提示不含产品命令,runtime `errorHint` 保留原有登录指引。 ### 4. runtime / 产品层可以使用入口名 + URL -- `packages/runtime/src/error-handler.ts` 通过本次调用的 `binName` 渲染 `bl` / `kscli` 等入口名,不要硬编码 +- 用 `formatCommand(identity, " [args]")` 生成用户操作提示;`identity.binName` + 保持产品身份,用于渠道、安装包及独立入口默认名;宿主可执行信息另行声明。不要全局替换输出或服务端文本。 +- 可执行命令引用用 `buildCommandInvocation(identity, path, args)` 生成 `{ bin, path, args }`; + `identity.commandInvocation` 显式声明宿主 executable/固定子命令,不拆分展示字符串。 + 消费者使用 `spawn(bin, [...path, ...args], { shell: false })`,不隐式添加确认参数。 +- 静态 CLI 元数据使用 `commandRef(" [args]")` 声明命令引用,由 help、introspect + 和 reference 生成器调用 `renderCommandText` 渲染;固定模板不可用于服务端文本或用户输入。 +- 动态错误在构造时生成完整字符串;helper 只接收必要的格式化函数或恢复提示。 + `BailianError.message/hint/toJSON()` 不携带模板,也不要求调用方理解渲染协议。 +- Console 失效恢复提示由 runtime 提供给 client/gateway,在业务包装前完成;普通包装无需传播模板或凭证域状态。 +- 前缀适配只改变命令引用,不增删原有操作步骤、不重组鉴权提示、不改写周围文案。 + +- `packages/runtime/src/error-handler.ts` 通过本次调用的 `commandPrefix` 渲染 `bl` / `kscli` 等入口名,不要硬编码 - 产品入口 / README / E2E 可以写具体入口命令 - shared command 实现不写 `bl` / `kscli` 前缀;`usageArgs` / `exampleArgs` 只写参数片段 - URL 必须从 `packages/runtime/src/urls.ts` import,不能硬编码 @@ -93,13 +105,13 @@ process.exit(err.exitCode) ### B. runtime 增强(`errorHint`) - [ ] `packages/runtime/src/error-handler.ts:errorHint`:**当前只为 internal AUTH 增强**(因为 resolver / authStage 等内部位置会发 AUTH) -- [ ] 命令名使用 `binName`,不要硬编码 `bl` +- [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl` - [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"` ### C. command / runtime 直接抛错 -- [ ] runtime 层抛 BailianError 时,hint 里可以放 `binName` 渲染的入口命令,但 **URL 一律走 `urls.ts` import** -- [ ] `packages/commands` 作为 shared command 库,默认不硬编码产品 bin;如果确需用户操作提示,优先依赖 runtime error handler 或 `ctx.identity.binName` +- [ ] runtime 层抛 BailianError 时,hint 里可以放 `commandPrefix` 渲染的入口命令,但 **URL 一律走 `urls.ts` import** +- [ ] `packages/commands` 作为 shared command 库,默认不硬编码产品 bin;如果确需用户操作提示,优先依赖 runtime error handler 或 `formatCommand(ctx.identity, args)` - [ ] 抛错位置如果**已经在调用服务端**,catch 时不要替换 message——重新评估是否需要 catch ### D. 文案一致性 diff --git a/packages/cli/src/integrations/aliyun.ts b/packages/cli/src/integrations/aliyun.ts new file mode 100644 index 00000000..f76ecf56 --- /dev/null +++ b/packages/cli/src/integrations/aliyun.ts @@ -0,0 +1,11 @@ +import type { CliOptions } from "bailian-cli-runtime"; + +/** Decode the existing Aliyun subprocess protocol at the product boundary. */ +export function resolveAliyunIntegration( + environment: NodeJS.ProcessEnv, +): Pick { + if (environment.ALIBABA_CLOUD_BL_COMPAT_MODE?.trim() !== "aliyun bl") return {}; + return { + commandInvocation: { bin: "aliyun", path: ["bl"] }, + }; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 219e0122..ea836fc2 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -2,6 +2,7 @@ import { createCli } from "bailian-cli-runtime"; import { commandAliases, commands } from "./commands.ts"; import { commandPackPolicy } from "./command-pack-policy.ts"; import pkg from "../package.json" with { type: "json" }; +import { resolveAliyunIntegration } from "./integrations/aliyun.ts"; const quickStartTasks = [ "帮我创建一个能够生成短片分镜和视频的 Managed Agent。\n Help me create a Managed Agent that can generate short-film storyboards and videos.", @@ -15,6 +16,7 @@ void createCli( { ...commands, ...commandAliases }, { binName: "bl", + ...resolveAliyunIntegration(process.env), version: pkg.version, clientName: "bailian-cli", npmPackage: "bailian-cli", diff --git a/packages/cli/tests/e2e/aliyun-compat.e2e.test.ts b/packages/cli/tests/e2e/aliyun-compat.e2e.test.ts new file mode 100644 index 00000000..9020c565 --- /dev/null +++ b/packages/cli/tests/e2e/aliyun-compat.e2e.test.ts @@ -0,0 +1,226 @@ +import { captureRegistryHelp } from "e2e/registry-smoke"; +import { buildIsolatedCliEnv } from "e2e/isolation"; +import { runInProcess } from "e2e/in-process"; +import { defineCommand, buildCommandInvocation, maskToken } from "bailian-cli-core"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, expectTypeOf, test } from "vite-plus/test"; +import { runCli } from "./helpers.ts"; +import { + buildCommandSchema, + CommandRegistry, + createCli, + type CliOptions, +} from "bailian-cli-runtime"; +import { commands } from "../../src/commands.ts"; +import { resolveAliyunIntegration } from "../../src/integrations/aliyun.ts"; + +const configDirectory = mkdtempSync(join(tmpdir(), "bl-aliyun-compat-")); +afterAll(() => rmSync(configDirectory, { recursive: true, force: true })); + +test("host adapter propagates executable argv through the runtime context", async () => { + expectTypeOf().not.toHaveProperty("commandPrefix"); + const isolated = buildIsolatedCliEnv({ + overrides: { NO_UPDATE_NOTIFIER: "1", DO_NOT_TRACK: "1" }, + }); + const arguments_ = ["probe", "--output", "json"]; + const probe = defineCommand({ + auth: "none", + description: "fixture", + async run(context) { + context.output.result({ + product: context.identity.binName, + command: buildCommandInvocation( + context.identity, + ["knowledge", "delete"], + ["--index-id", "example"], + ), + }); + }, + }); + try { + const result = await runInProcess( + () => + createCli( + { probe }, + { + binName: "bl", + clientName: "test", + npmPackage: "test", + version: "test", + ...resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: "aliyun bl" }), + }, + ).run(arguments_), + arguments_, + { env: isolated.env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + product: "bl", + command: { + bin: "aliyun", + path: ["bl", "knowledge", "delete"], + args: ["--index-id", "example"], + }, + }); + } finally { + isolated.dispose(); + } +}); + +test("all command metadata preserves standalone wording and resolves hosted references", () => { + const identity = { + binName: "bl", + version: "test", + clientName: "test", + npmPackage: "bailian-cli", + }; + const standalone = buildCommandSchema(new CommandRegistry(commands, "bl"), identity); + const hosted = buildCommandSchema(new CommandRegistry(commands, "aliyun bl"), { + ...identity, + commandInvocation: { bin: "aliyun", path: ["bl"] }, + }); + const serialized = JSON.stringify(hosted); + expect(serialized).not.toContain("{{commandPrefix}}"); + expect(hosted.bin).toBe("aliyun"); + expect(hosted.scope).toEqual(["bl"]); + const qualifiedStandalone = { + ...standalone, + bin: "aliyun", + scope: ["bl"], + commands: standalone.commands.map((command) => ({ ...command, path: ["bl", ...command.path] })), + }; + expect(JSON.parse(serialized.replaceAll("aliyun bl", "bl"))).toEqual(qualifiedStandalone); + const skill = hosted.commands.find((command) => command.path.join(" ") === "bl skill init")!; + expect(JSON.stringify(skill.notes)).toContain("Equivalent to: `aliyun bl skill add --all`"); +}); + +// Real subprocesses below verify the product entrypoint reads the inherited host marker. +function runCompatible(arguments_: string[], language = "en-US", compatibility = "aliyun bl") { + writeFileSync(join(configDirectory, "config.json"), JSON.stringify({ language })); + return runCli(arguments_, { + BAILIAN_CONFIG_DIR: configDirectory, + ALIBABA_CLOUD_BL_COMPAT_MODE: compatibility, + DASHSCOPE_API_KEY: "", + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", + ALIBABA_CLOUD_ACCESS_KEY_ID: "", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", + ALIBABA_CLOUD_SECURITY_TOKEN: "", + NO_UPDATE_NOTIFIER: "1", + DO_NOT_TRACK: "1", + }); +} + +test.each(["en-US", "zh-CN"])( + "host help and onboarding use the invocation prefix (%s)", + async (language) => { + const root = await runCompatible(["--help"], language); + expect(root.exitCode, root.stderr).toBe(0); + expect(root.stderr).toContain("aliyun bl [flags]"); + expect(root.stderr).toContain("aliyun bl auth login --console"); + }, +); + +test.each( + ["en-US", "zh-CN"].flatMap((language) => + ["app", "app list", "skill init", "managed-agent plan"].map((path) => ({ language, path })), + ), +)("host nested help uses the invocation prefix ($language, $path)", ({ language, path }) => { + const registry = new CommandRegistry(commands, "aliyun bl", { + language: language as "en-US" | "zh-CN", + localize: (value) => (typeof value === "string" ? value : value[language as "en-US" | "zh-CN"]), + }); + const help = captureRegistryHelp(registry, path.split(" ")); + expect(help).toContain(`aliyun bl ${path}`); + expect(help).not.toMatch(/(? { + writeFileSync( + join(configDirectory, "config.json"), + JSON.stringify({ + language: "en-US", + access_key_id: "config-ak", + access_key_secret: "config-sk", + }), + ); + const result = await runCli(["auth", "status", "--output", "json"], { + BAILIAN_CONFIG_DIR: configDirectory, + ALIBABA_CLOUD_BL_COMPAT_MODE: "aliyun bl", + BAILIAN_ACCESS_KEY_ID: "bailian-env-access-key", + BAILIAN_ACCESS_KEY_SECRET: "bailian-env-secret-key", + BAILIAN_SECURITY_TOKEN: "bailian-env-sts", + ALIBABA_CLOUD_ACCESS_KEY_ID: "host-ak", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "host-sk", + ALIBABA_CLOUD_SECURITY_TOKEN: "host-sts", + DASHSCOPE_API_KEY: "", + NO_UPDATE_NOTIFIER: "1", + DO_NOT_TRACK: "1", + }); + expect(result.exitCode, result.stderr).toBe(0); + const state = JSON.parse(result.stdout); + expect(state.openapi).toEqual({ + source: "env", + access_key_id: maskToken("bailian-env-access-key"), + access_key_secret: maskToken("bailian-env-secret-key"), + }); + expect(state.console).toBeUndefined(); + expect(result.stdout + result.stderr).not.toContain("bailian-env-secret-key"); + expect(result.stdout + result.stderr).not.toContain("bailian-env-sts"); +}); + +test("host introspection exports executable paths and examples from the real entrypoint", async () => { + const result = await runCompatible(["auth", "status", "--introspect"]); + expect(result.exitCode, result.stderr).toBe(0); + const schema = JSON.parse(result.stdout); + expect(schema.bin).toBe("aliyun"); + expect(schema.scope).toEqual(["bl", "auth", "status"]); + expect(schema.commands[0].path).toEqual(["bl", "auth", "status"]); + expect(schema.commands[0].usage).toMatch(/^aliyun bl auth status/); + expect(schema.commands[0].examples[0]).toBe("aliyun bl auth status"); +}); + +test("host version is display-only and absent or unknown COMPAT keeps standalone", async () => { + const hosted = await runCompatible(["--version"]); + expect(hosted.stdout).toMatch(/^aliyun bl \d+\.\d+\.\d+\n$/); + for (const compatibility of [""]) { + const standalone = await runCompatible(["--version"], "en-US", compatibility); + expect(standalone.stdout).toMatch(/^bl \d+\.\d+\.\d+\n$/); + } +}); + +test.each(["text", "json"])( + "missing Console credentials use host instructions (%s)", + async (output) => { + const result = await runCompatible(["app", "list", "--output", output]); + expect(result.exitCode, result.stderr).toBe(3); + const diagnostic = output === "json" ? JSON.parse(result.stderr).error.hint : result.stderr; + expect(diagnostic).toContain("aliyun bl auth login --console"); + expect(diagnostic).not.toMatch(/(? { + const result = await runCompatible(["auth", "status", "--output", "json"]); + expect(result.exitCode, result.stderr).toBe(0); + const status = JSON.parse(result.stdout); + expect(status.authenticated).toBe(false); + expect(status.hint).toContain("aliyun bl auth login --api-key"); +}); + +// Marker decoding is pure; unknown/whitespace cases need no additional CLI processes. +test.each(["", " ", "unrecognized-host"])( + "unknown host marker keeps the default invocation (%j)", + (marker) => { + expect(resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: marker })).toEqual({}); + }, +); +test("recognized host marker permits surrounding whitespace", () => { + expect(resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: " aliyun bl " })).toEqual({ + commandInvocation: { bin: "aliyun", path: ["bl"] }, + }); +}); diff --git a/packages/cli/tests/e2e/config-profile.e2e.test.ts b/packages/cli/tests/e2e/config-profile.e2e.test.ts index 32ea12ae..6bda9258 100644 --- a/packages/cli/tests/e2e/config-profile.e2e.test.ts +++ b/packages/cli/tests/e2e/config-profile.e2e.test.ts @@ -90,6 +90,9 @@ describe("e2e: named config", () => { const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], { BAILIAN_CONFIG_DIR: dir, DASHSCOPE_API_KEY: "", + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }); @@ -101,6 +104,9 @@ describe("e2e: named config", () => { const defaultStatus = await runCli(["auth", "status", "--output", "json"], { BAILIAN_CONFIG_DIR: dir, DASHSCOPE_API_KEY: "", + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }); @@ -135,6 +141,9 @@ describe("e2e: named config", () => { DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "", DASHSCOPE_OUTPUT: "", + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }; @@ -268,6 +277,9 @@ describe("e2e: named config", () => { const result = await runCli(["auth", "status", "--output", "text"], { BAILIAN_CONFIG_DIR: dir, DASHSCOPE_API_KEY: "", + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }); diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index 3515f44a..d9598cf0 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -7,6 +7,7 @@ import { DEFAULT_LLM_MODEL, defineCommand, ExitCode, + formatCommand, type EngineRecommendResult, type Degradation, type CommandOutput, @@ -49,7 +50,7 @@ function emitDegradations(degradations: Degradation[], output: CommandOutput): v } /** Map a classified engine LLM failure onto a CLI-native BailianError with an actionable hint. */ -function toBailianError(err: LLMError): { message: string; hint?: string } { +function toBailianError(err: LLMError, commandPrefix: string): { message: string; hint?: string } { const stage = STAGE_LABEL[err.stage ?? ""] ?? "LLM call"; const requestId = err.requestId ? ` (request_id: ${err.requestId})` : ""; const message = `Recommendation aborted: ${stage} failed — ${err.message}${requestId}`; @@ -68,7 +69,7 @@ function toBailianError(err: LLMError): { message: string; hint?: string } { case "auth": return { message, - hint: "The API key is invalid or lacks access to the model. Check DASHSCOPE_API_KEY / --api-key, or run `bl auth login`. / API Key 无效或无该模型访问权限,请检查 DASHSCOPE_API_KEY / --api-key 或执行 `bl auth login`。", + hint: `The API key is invalid or lacks access to the model. Check DASHSCOPE_API_KEY / --api-key, or run \`${commandPrefix} auth login\`. / API Key 无效或无该模型访问权限,请检查 DASHSCOPE_API_KEY / --api-key 或执行 \`${commandPrefix} auth login\`。`, }; case "timeout": return { @@ -276,7 +277,7 @@ export default defineCommand({ } } catch (err) { if (err instanceof LLMError) { - const { message: errMessage, hint } = toBailianError(err); + const { message: errMessage, hint } = toBailianError(err, formatCommand(ctx.identity)); throw new BailianError(errMessage, ExitCode.GENERAL, hint); } throw err; diff --git a/packages/commands/src/commands/alert/create.ts b/packages/commands/src/commands/alert/create.ts index 40c6a057..62aa0c99 100644 --- a/packages/commands/src/commands/alert/create.ts +++ b/packages/commands/src/commands/alert/create.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { unwrapResponse } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { @@ -50,7 +51,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); const raw = await ctx.client.console(CREATE_RULE_API, { reqDTO }); const resp = unwrapResponse(raw as Record); diff --git a/packages/commands/src/commands/alert/delete.ts b/packages/commands/src/commands/alert/delete.ts index 6922807f..1a89cebb 100644 --- a/packages/commands/src/commands/alert/delete.ts +++ b/packages/commands/src/commands/alert/delete.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { parseCommaList } from "../shared/params.ts"; import { ensureAlertReady } from "./shared.ts"; @@ -51,7 +52,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); await ctx.client.console(DELETE_RULES_API, { reqDTO }); diff --git a/packages/commands/src/commands/alert/disable.ts b/packages/commands/src/commands/alert/disable.ts index 9863d02f..f1e08982 100644 --- a/packages/commands/src/commands/alert/disable.ts +++ b/packages/commands/src/commands/alert/disable.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ensureAlertReady } from "./shared.ts"; @@ -41,7 +42,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); await ctx.client.console(DISABLE_RULE_API, { reqDTO }); diff --git a/packages/commands/src/commands/alert/enable.ts b/packages/commands/src/commands/alert/enable.ts index f57ff2e3..bf0b4660 100644 --- a/packages/commands/src/commands/alert/enable.ts +++ b/packages/commands/src/commands/alert/enable.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ensureAlertReady } from "./shared.ts"; @@ -39,7 +40,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); await ctx.client.console("zeldaEasy.bailian-telemetry.alertRule.enableAlertRule", { reqDTO }); diff --git a/packages/commands/src/commands/alert/history.ts b/packages/commands/src/commands/alert/history.ts index cb8480a2..9d9d358d 100644 --- a/packages/commands/src/commands/alert/history.ts +++ b/packages/commands/src/commands/alert/history.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ansi, renderBoxTable } from "bailian-cli-runtime"; import { formatDateTime } from "../shared/format.ts"; @@ -130,7 +131,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); const raw = await ctx.client.console(LIST_HISTORIES_API, { reqDTO }); const page = extractAlertPage(raw); diff --git a/packages/commands/src/commands/alert/list.ts b/packages/commands/src/commands/alert/list.ts index b22c413d..c01b8763 100644 --- a/packages/commands/src/commands/alert/list.ts +++ b/packages/commands/src/commands/alert/list.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import type { LocalizedText } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ansi, renderBoxTable } from "bailian-cli-runtime"; @@ -143,7 +144,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); const raw = await ctx.client.console(LIST_RULES_API, { reqDTO }); const page = extractAlertPage(raw); diff --git a/packages/commands/src/commands/alert/metrics.ts b/packages/commands/src/commands/alert/metrics.ts index edef48f1..fb241fd9 100644 --- a/packages/commands/src/commands/alert/metrics.ts +++ b/packages/commands/src/commands/alert/metrics.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { unwrapResponse } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { renderBoxTable } from "bailian-cli-runtime"; @@ -34,7 +35,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); const raw = await ctx.client.console(LIST_METRICS_API, { reqDTO: { workspaceId: settings.workspaceId, resourceType: "model" }, diff --git a/packages/commands/src/commands/alert/shared.ts b/packages/commands/src/commands/alert/shared.ts index ddcce2a9..935190c3 100644 --- a/packages/commands/src/commands/alert/shared.ts +++ b/packages/commands/src/commands/alert/shared.ts @@ -1,4 +1,11 @@ -import { UsageError, unwrapResponse, type Client, type FlagsDef } from "bailian-cli-core"; +import { + formatCommand, + UsageError, + unwrapResponse, + type Client, + type FlagsDef, +} from "bailian-cli-core"; + import { parseCommaList } from "../shared/params.ts"; import { ensureTelemetryReady, ensureTelemetryRegionSupported } from "../shared/telemetry.ts"; @@ -206,14 +213,14 @@ export function buildAlertRuleReqDTO( export async function ensureAlertReady( client: Client, workspaceId: string | undefined, - binName: string, + commandPrefix: string, settings: Parameters[0], ): Promise { ensureTelemetryRegionSupported(settings); await ensureTelemetryReady(client, { serviceType: "ModelMonitor", workspaceId, - enableCommand: `${binName} monitor delivery enable`, + enableCommand: `${formatCommand(commandPrefix, "monitor delivery enable")}`, requireInstance: false, }); } diff --git a/packages/commands/src/commands/alert/template-create.ts b/packages/commands/src/commands/alert/template-create.ts index 467eb55e..e665b387 100644 --- a/packages/commands/src/commands/alert/template-create.ts +++ b/packages/commands/src/commands/alert/template-create.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { BailianError, ExitCode, unwrapResponse } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { @@ -121,7 +122,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); const reqDTO = { templateName: flags.name, diff --git a/packages/commands/src/commands/alert/template-delete.ts b/packages/commands/src/commands/alert/template-delete.ts index 4ab0efc7..d466daa9 100644 --- a/packages/commands/src/commands/alert/template-delete.ts +++ b/packages/commands/src/commands/alert/template-delete.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { unwrapResponse } from "bailian-cli-core"; import { parseCommaList } from "../shared/params.ts"; import { ensureAlertReady } from "./shared.ts"; @@ -52,7 +53,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); const raw = await ctx.client.console(DELETE_TEMPLATES_API, { reqDTO }); const resp = unwrapResponse(raw as Record); diff --git a/packages/commands/src/commands/alert/template-list.ts b/packages/commands/src/commands/alert/template-list.ts index fbe98a61..e936a2d9 100644 --- a/packages/commands/src/commands/alert/template-list.ts +++ b/packages/commands/src/commands/alert/template-list.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { renderBoxTable } from "bailian-cli-runtime"; import { ensureAlertReady, extractAlertPage } from "./shared.ts"; @@ -101,7 +102,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); const raw = await ctx.client.console(LIST_TEMPLATES_API, { reqDTO }); const page = extractAlertPage(raw); diff --git a/packages/commands/src/commands/alert/template-update.ts b/packages/commands/src/commands/alert/template-update.ts index 0ff7cc39..d1106674 100644 --- a/packages/commands/src/commands/alert/template-update.ts +++ b/packages/commands/src/commands/alert/template-update.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ensureAlertReady, parseCondition, validateTemplateConditions } from "./shared.ts"; @@ -76,7 +77,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); await ctx.client.console(UPDATE_TEMPLATE_API, { reqDTO }); diff --git a/packages/commands/src/commands/alert/update.ts b/packages/commands/src/commands/alert/update.ts index cfe5af36..a398b0b0 100644 --- a/packages/commands/src/commands/alert/update.ts +++ b/packages/commands/src/commands/alert/update.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ALERT_RULE_WRITE_FLAGS, @@ -52,7 +53,7 @@ export default defineTelemetryCommand({ return; } - await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + await ensureAlertReady(ctx.client, settings.workspaceId, formatCommand(ctx.identity), settings); await ctx.client.console(UPDATE_RULE_API, { reqDTO }); diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 788ff935..7a13219a 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import http from "node:http"; import { + formatCommand, BailianError, ExitCode, validateConsoleAccessToken, @@ -642,11 +643,10 @@ export async function runConsoleLogin( } if (opts?.needApiKey && savedSessionWithoutApiKey) { - const binName = deps.identity.binName; deps.output.warning({ message: { - "en-US": `Saved the console session only. Model commands still need an API key: ${binName} auth login --console, or ${binName} auth login --api-key `, - "zh-CN": `仅保存了控制台会话。模型命令仍需要 API Key:${binName} auth login --console,或 ${binName} auth login --api-key `, + "en-US": `Saved the console session only. Model commands still need an API key: ${formatCommand(deps.identity, "auth login --console")}, or ${formatCommand(deps.identity, "auth login --api-key ")}`, + "zh-CN": `仅保存了控制台会话。模型命令仍需要 API Key:${formatCommand(deps.identity, "auth login --console")},或 ${formatCommand(deps.identity, "auth login --api-key ")}`, }, code: "MODEL_API_KEY_MISSING", }); diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts index 73aa7600..e03d2f02 100644 --- a/packages/commands/src/commands/auth/status.ts +++ b/packages/commands/src/commands/auth/status.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core"; import { API_KEY_PAGE } from "bailian-cli-runtime"; @@ -42,8 +43,8 @@ export default defineCommand({ const modelKeyHint = apiKey ? undefined : localize({ - "en-US": `No model API key is configured. Model commands need: ${identity.binName} auth login --console, or ${identity.binName} auth login --api-key `, - "zh-CN": `尚未配置模型 API Key。模型命令需要:${identity.binName} auth login --console,或 ${identity.binName} auth login --api-key `, + "en-US": `No model API key is configured. Model commands need: ${formatCommand(identity, "auth login --console")}, or ${formatCommand(identity, "auth login --api-key ")}`, + "zh-CN": `尚未配置模型 API Key。模型命令需要:${formatCommand(identity, "auth login --console")},或 ${formatCommand(identity, "auth login --api-key ")}`, }); if (!authenticated) { @@ -53,9 +54,9 @@ export default defineCommand({ config_file: configFile, message: "Not authenticated.", hint: [ - `API key (model): ${identity.binName} auth login --api-key or DASHSCOPE_API_KEY`, - `Console gateway: ${identity.binName} auth login --console`, - `OpenAPI (AK/SK): ${identity.binName} auth login --open-api --access-key-id --access-key-secret `, + `API key (model): ${formatCommand(identity, "auth login --api-key ")} or DASHSCOPE_API_KEY`, + `Console gateway: ${formatCommand(identity, "auth login --console")}`, + `OpenAPI (AK/SK): ${formatCommand(identity, "auth login --open-api --access-key-id --access-key-secret ")}`, `Get API Key: ${API_KEY_PAGE}`, ].join("\n"), }); @@ -90,14 +91,14 @@ export default defineCommand({ ); } else { lines.push( - ` Console gateway: not configured (run ${identity.binName} auth login --console)`, + ` Console gateway: not configured (run ${formatCommand(identity, "auth login --console")})`, ); } if (openapi) { lines.push(` OpenAPI (AK/SK): ${openapi.source} ${openapi.access_key_id}`); } else { lines.push( - ` OpenAPI (AK/SK): not configured (run ${identity.binName} auth login --open-api)`, + ` OpenAPI (AK/SK): not configured (run ${formatCommand(identity, "auth login --open-api")})`, ); } ctx.output.result( diff --git a/packages/commands/src/commands/config/scenarios.ts b/packages/commands/src/commands/config/scenarios.ts index 8f2cb9ab..fd2f0f3d 100644 --- a/packages/commands/src/commands/config/scenarios.ts +++ b/packages/commands/src/commands/config/scenarios.ts @@ -1,4 +1,4 @@ -import type { Language } from "bailian-cli-core"; +import { commandRef, renderCommandText, type Language } from "bailian-cli-core"; /** * Curated "Playground" scenarios surfaced in the config UI. @@ -32,8 +32,7 @@ export const SCENARIOS = [ title: "文生图", description: "一键生成一张示例图片并保存到输出目录。", category: "图像", - prompt: - "请使用 bl 的图像生成能力(如 `bl image generate` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。", + prompt: `请使用 bl 的图像生成能力(如 \`${commandRef("image generate")}\` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。`, }, { id: "image-describe", @@ -65,8 +64,7 @@ export const SCENARIOS = [ title: "文字转语音", description: "把一句示例文字合成为自然语音。", category: "音频", - prompt: - "请使用 bl 的语音合成能力(如 `bl speech` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。", + prompt: `请使用 bl 的语音合成能力(如 \`${commandRef("speech")}\` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。`, }, { id: "audio-summarize", @@ -82,8 +80,7 @@ export const SCENARIOS = [ title: "文生视频", description: "一键生成一段示例短视频。", category: "视频", - prompt: - "请使用 bl 的视频生成能力(如 `bl video generate` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。", + prompt: `请使用 bl 的视频生成能力(如 \`${commandRef("video generate")}\` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。`, }, { id: "video-storyboard", @@ -162,8 +159,7 @@ const EN_US_SCENARIOS = { title: "Text to image", description: "Generate a sample image and save it to the output directory.", category: "Image", - prompt: - "Use bl's image generation capability (such as `bl image generate`) to create a sample image: a corgi holding an umbrella in the rain, watercolor style, with soft lighting. Save it to the output directory and tell me the file path.", + prompt: `Use bl's image generation capability (such as \`${commandRef("image generate")}\`) to create a sample image: a corgi holding an umbrella in the rain, watercolor style, with soft lighting. Save it to the output directory and tell me the file path.`, }, "image-describe": { title: "Image understanding", @@ -190,8 +186,7 @@ const EN_US_SCENARIOS = { title: "Text to speech", description: "Turn a sample sentence into natural speech.", category: "Audio", - prompt: - "Use bl's speech synthesis capability (such as a `bl speech` command) to turn this sentence into natural speech: Welcome to Alibaba Cloud Model Studio CLI, making multimodal creation easier. Save the audio to the output directory and tell me the file path.", + prompt: `Use bl's speech synthesis capability (such as a \`${commandRef("speech")}\` command) to turn this sentence into natural speech: Welcome to Alibaba Cloud Model Studio CLI, making multimodal creation easier. Save the audio to the output directory and tell me the file path.`, }, "audio-summarize": { title: "Transcribe and summarize audio", @@ -204,8 +199,7 @@ const EN_US_SCENARIOS = { title: "Text to video", description: "Generate a sample short video.", category: "Video", - prompt: - "Use bl's video generation capability (such as `bl video generate`) to create a sample short video: a teenager running along the beach at sunset, cinematic, in slow motion. Save it to the output directory and tell me the file path.", + prompt: `Use bl's video generation capability (such as \`${commandRef("video generate")}\`) to create a sample short video: a teenager running along the beach at sunset, cinematic, in slow motion. Save it to the output directory and tell me the file path.`, }, "video-storyboard": { title: "Video storyboard", @@ -266,18 +260,23 @@ const EN_US_SCENARIOS = { }, } satisfies Record; -export function localizeScenarios(language: Language): Scenario[] { - if (language === "zh-CN") return SCENARIOS.map((scenario) => ({ ...scenario })); - - return SCENARIOS.map((scenario) => ({ - ...scenario, - ...EN_US_SCENARIOS[scenario.id], - })); +export function localizeScenarios(language: Language, invocation = "bl"): Scenario[] { + return SCENARIOS.map((scenario) => { + const localized = { + ...scenario, + ...(language === "en-US" ? EN_US_SCENARIOS[scenario.id] : {}), + }; + return { ...localized, prompt: renderCommandText(localized.prompt, invocation) }; + }); } /** Look up a scenario by id, or undefined when unknown. */ -export function getScenario(id: string, language: Language): Scenario | undefined { - return localizeScenarios(language).find((scenario) => scenario.id === id); +export function getScenario( + id: string, + language: Language, + invocation = "bl", +): Scenario | undefined { + return localizeScenarios(language, invocation).find((scenario) => scenario.id === id); } /** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */ diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index 154040e5..7b887752 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -4,7 +4,7 @@ // fetches carry the session token from the page URL. Visual language mirrors // the bailian landing design system (Inter / Geist Mono, gradient accents, // lift-on-hover cards). -import type { Language } from "bailian-cli-core"; +import { commandRef, renderCommandText, type Language } from "bailian-cli-core"; import { renderConfigUiShell } from "./ui-i18n.ts"; const PAGE_HTML = ` @@ -533,7 +533,7 @@ const PAGE_HTML = `

Playground

-

Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where bl config ui was started.

+

Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where ${commandRef("config ui")} was started.

@@ -552,7 +552,7 @@ const PAGE_HTML = `

Installed Skills

-

Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via bl skill add.

+

Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via ${commandRef("skill add")}.

Loading…
@@ -1253,8 +1253,8 @@ const PAGE_HTML = ` } function modelCatNote(key) { if (key === 'default_video_model') { - return 'Applies to bl video generate only (text/image-to-video). ' - + 'bl video ref (multi-image) and bl video edit keep their own ' + return 'Applies to ${commandRef("video generate")} only (text/image-to-video). ' + + '${commandRef("video ref")} (multi-image) and ${commandRef("video edit")} keep their own ' + 'fixed models — pass --model to override those per run.'; } return ''; @@ -1522,7 +1522,7 @@ const PAGE_HTML = ` function renderSkills() { var body = document.getElementById('skillsBody'); var pager = document.getElementById('skillsPager'); - if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with bl skill init'); return; } + if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with ${commandRef("skill init")}'); return; } var list = SKILLS.filter(function (s) { return skillMatches(s, SKILL_Q); }); if (!list.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills match "' + SKILL_Q + '".', ''); return; } var info = pageSlice(list, SKILL_PAGE, getPageSize('skills')); SKILL_PAGE = info.page; @@ -2349,7 +2349,7 @@ const PAGE_HTML = ` var pager = document.getElementById('assetsPager'); if (!ASSETS.length) { pager.innerHTML = ''; - renderEmpty(body, 'No generated assets yet.', 'Assets from bl image, bl video, bl speech and bl omni will appear here.'); + renderEmpty(body, 'No generated assets yet.', 'Assets from ${commandRef("image")}, ${commandRef("video")}, ${commandRef("speech")} and ${commandRef("omni")} will appear here.'); return; } var list = ASSET_FILTER === 'all' ? ASSETS : ASSETS.filter(function (a) { return a.kind === ASSET_FILTER; }); @@ -2697,6 +2697,6 @@ const PAGE_HTML = ` `; -export function renderConfigUiHtml(language: Language): string { - return renderConfigUiShell(PAGE_HTML, language); +export function renderConfigUiHtml(language: Language, invocation = "bl"): string { + return renderCommandText(renderConfigUiShell(PAGE_HTML, language), invocation); } diff --git a/packages/commands/src/commands/config/ui-i18n.ts b/packages/commands/src/commands/config/ui-i18n.ts index d0907d88..04a32fb1 100644 --- a/packages/commands/src/commands/config/ui-i18n.ts +++ b/packages/commands/src/commands/config/ui-i18n.ts @@ -1,4 +1,4 @@ -import type { Language } from "bailian-cli-core"; +import { commandRef, type Language } from "bailian-cli-core"; /** * Config UI is a self-contained HTML document, so this catalog is embedded in @@ -21,8 +21,8 @@ const ZH_CN_REPLACEMENTS: ReadonlyArray = [ "完成以下步骤以检查本地环境并完成首次配置:登录百炼、连接本地编程 Agent,并运行第一个任务。完成后,每个圆点会由灰色变为绿色。", ], [ - 'Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where bl config ui was started.', - '选择一个场景,将预设任务发送给已连接的本地编程 Agent,并在新终端中运行。任务会在启动 bl config ui 的目录中执行。', + `Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where ${commandRef("config ui")} was started.`, + `选择一个场景,将预设任务发送给已连接的本地编程 Agent,并在新终端中运行。任务会在启动 ${commandRef("config ui")} 的目录中执行。`, ], [ "Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where ", @@ -78,8 +78,8 @@ const ZH_CN_REPLACEMENTS: ReadonlyArray = [ "在 Claude Code、Codex、Qwen Code 或 OpenCode 中配置的 MCP 服务器会显示在这里。", ], [ - "Assets from bl image, bl video, bl speech and bl omni will appear here.", - "通过 bl image、bl video、bl speech 和 bl omni 生成的资产会显示在这里。", + `Assets from ${commandRef("image")}, ${commandRef("video")}, ${commandRef("speech")} and ${commandRef("omni")} will appear here.`, + `通过 ${commandRef("image")}、${commandRef("video")}、${commandRef("speech")} 和 ${commandRef("omni")} 生成的资产会显示在这里。`, ], ["Remote agents loaded from a URL will appear here.", "通过 URL 加载的远程 Agent 会显示在这里。"], [ @@ -156,12 +156,12 @@ const ZH_CN_REPLACEMENTS: ReadonlyArray = [ ["Available ", "可用的 "], [" models · click to use", " 模型 · 点击使用"], [ - "Applies to bl video generate only (text/image-to-video). ", - "仅适用于 bl video generate(文生视频/图生视频)。", + `Applies to ${commandRef("video generate")} only (text/image-to-video). `, + `仅适用于 ${commandRef("video generate")}(文生视频/图生视频)。`, ], [ - "bl video ref (multi-image) and bl video edit keep their own ", - "bl video ref(多图)和 bl video edit 仍使用各自的", + `${commandRef("video ref")} (multi-image) and ${commandRef("video edit")} keep their own `, + `${commandRef("video ref")}(多图)和 ${commandRef("video edit")} 仍使用各自的`, ], [ "fixed models — pass --model to override those per run.", diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index 63e5e93f..de4bf542 100644 --- a/packages/commands/src/commands/config/ui.ts +++ b/packages/commands/src/commands/config/ui.ts @@ -4,6 +4,7 @@ import { createReadStream, existsSync, statSync, unlinkSync } from "node:fs"; import { extname } from "node:path"; import { + formatCommand, defineCommand, BailianError, ExitCode, @@ -176,6 +177,7 @@ export function createConfigUiServer( outputBase: string = defaultOutputBase(), authBridge?: AuthUiBridge, onError?: (error: unknown) => void, + invocation = "bl", ): http.Server { let activatedUiProfile: string | null = null; const uiLanguage = (): Language => { @@ -214,7 +216,7 @@ export function createConfigUiServer( "img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " + "media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", }); - res.end(renderConfigUiHtml(uiLanguage())); + res.end(renderConfigUiHtml(uiLanguage(), invocation)); return; } @@ -400,7 +402,7 @@ export function createConfigUiServer( })) .filter((a) => a.dispatchable); sendJson(res, 200, { - scenarios: localizeScenarios(uiLanguage()), + scenarios: localizeScenarios(uiLanguage(), invocation), agents: targets, }); return; @@ -567,7 +569,7 @@ export function createConfigUiServer( } else { scenario = typeof body.scenario === "string" - ? getScenario(body.scenario, uiLanguage()) + ? getScenario(body.scenario, uiLanguage(), invocation) : undefined; } if (!scenario) { @@ -746,6 +748,7 @@ export default defineCommand({ code: "CONFIG_UI_REQUEST_FAILED", details: { error }, }), + formatCommand(ctx.identity), ); let port: number; diff --git a/packages/commands/src/commands/dataset/upload.ts b/packages/commands/src/commands/dataset/upload.ts index e9730c58..dc45dea6 100644 --- a/packages/commands/src/commands/dataset/upload.ts +++ b/packages/commands/src/commands/dataset/upload.ts @@ -1,4 +1,5 @@ import { + formatCommand, defineCommand, uploadDataset, validateDataset, @@ -130,7 +131,7 @@ export default defineCommand({ } lines.push( "", - `Hint: re-run \`${identity.binName} dataset validate --file \` for the full report,`, + `Hint: re-run \`${formatCommand(identity, "dataset validate --file ")}\` for the full report,`, " or pass --no-validate to skip this check at your own risk.", ); throw new BailianError(lines.join("\n"), ExitCode.GENERAL); diff --git a/packages/commands/src/commands/decision/predict.ts b/packages/commands/src/commands/decision/predict.ts index 32aec5e4..26c515fc 100644 --- a/packages/commands/src/commands/decision/predict.ts +++ b/packages/commands/src/commands/decision/predict.ts @@ -1,4 +1,5 @@ import { + formatCommand, defineCommand, DECISION_REGIONS, DEFAULT_DECISION_MODEL, @@ -146,7 +147,7 @@ export default defineCommand({ const endpoint = resolveDecisionEndpoint({ workspaceId: flags.workspaceId || settings.workspaceId, region: flags.region ?? DEFAULT_DECISION_REGION, - binName: ctx.identity.binName, + commandPrefix: formatCommand(ctx.identity), client: ctx.client, localize: ctx.localize, }); diff --git a/packages/commands/src/commands/decision/shared.ts b/packages/commands/src/commands/decision/shared.ts index c2d4a83f..aa7e0151 100644 --- a/packages/commands/src/commands/decision/shared.ts +++ b/packages/commands/src/commands/decision/shared.ts @@ -1,5 +1,6 @@ import { extname } from "path"; import { + formatCommand, BailianError, ExitCode, decisionPath, @@ -180,7 +181,7 @@ export function buildDecisionInput(flags: DecisionInputFlags, localize: Localize export function resolveDecisionEndpoint(ctx: { workspaceId?: string; region: string; - binName: string; + commandPrefix: string; client: Pick; localize: Localize; }): string { @@ -193,8 +194,8 @@ export function resolveDecisionEndpoint(ctx: { "zh-CN": "调用决策模型需要 Workspace ID。", }, { - "en-US": `Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.binName} config set workspace_id . You can also override the endpoint with --base-url.`, - "zh-CN": `请传入 --workspace-id、设置环境变量 BAILIAN_WORKSPACE_ID,或执行:${ctx.binName} config set workspace_id 。也可以用 --base-url 覆盖请求地址。`, + "en-US": `Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${formatCommand(ctx.commandPrefix, "config set workspace_id ")}. You can also override the endpoint with --base-url.`, + "zh-CN": `请传入 --workspace-id、设置环境变量 BAILIAN_WORKSPACE_ID,或执行:${formatCommand(ctx.commandPrefix, "config set workspace_id ")}。也可以用 --base-url 覆盖请求地址。`, }, ); } diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index 9f81b17b..f078cb6f 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -1,4 +1,5 @@ import { + formatCommand, defineCommand, createDeployment, pickPlanStrategy, @@ -211,7 +212,7 @@ async function runCreate( const resolved = await strategy.resolve({ client: ctx.client, dryRun, - binName: identity.binName, + modelUnavailableHint: `Run \`${formatCommand(identity, "deploy models --source base")}\` to inspect available models, or pass --deploy-spec explicitly.`, flags, model: flags.modelName, name: flags.displayName, diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index 1146bf8a..013b32fb 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -1,4 +1,5 @@ import { + formatCommand, defineCommand, createFineTune, getDataset, @@ -82,7 +83,6 @@ async function analyzeDatasetTokens( knownModality?: { path: string; modality: DataModality }, ): Promise { const { settings } = context; - const binName = context.identity.binName; const tokens = raw .split(",") .map((token) => token.trim()) @@ -135,8 +135,8 @@ async function analyzeDatasetTokens( } lines.push( "", - `Hint: re-run \`${binName} dataset validate --file \` for the full report,`, - ` or upload manually with \`${binName} dataset upload --no-validate\` and`, + `Hint: re-run \`${formatCommand(context.identity, "dataset validate --file ")}\` for the full report,`, + ` or upload manually with \`${formatCommand(context.identity, "dataset upload --no-validate")}\` and`, " pass the resulting file-id here.", ); throw new BailianError(lines.join("\n"), ExitCode.GENERAL); diff --git a/packages/commands/src/commands/knowledge/billing.ts b/packages/commands/src/commands/knowledge/billing.ts index d5587a84..7494a6bc 100644 --- a/packages/commands/src/commands/knowledge/billing.ts +++ b/packages/commands/src/commands/knowledge/billing.ts @@ -1,3 +1,4 @@ +import { buildCommandInvocation } from "bailian-cli-core"; import type { CommandContext, CommandNotice, CommandRisk } from "bailian-cli-core"; import { KNOWLEDGE_BILLING_PAGE } from "bailian-cli-runtime"; import deleteIndex from "./kb-delete.ts"; @@ -48,11 +49,12 @@ export function reportCreatedKnowledgeBase( const path = context.commandPath?.(deleteIndex); const command = path && context.identity - ? { - bin: context.identity.binName, - path, - args: ["--index-id", indexId, "--workspace-id", workspaceId], - } + ? buildCommandInvocation(context.identity, path, [ + "--index-id", + indexId, + "--workspace-id", + workspaceId, + ]) : undefined; const resource = { indexId, diff --git a/packages/commands/src/commands/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts index 87d76bda..358975fd 100644 --- a/packages/commands/src/commands/knowledge/chunk-delete.ts +++ b/packages/commands/src/commands/knowledge/chunk-delete.ts @@ -1,5 +1,6 @@ import { validateNonemptyFlags } from "./nonempty-flags.ts"; import { + formatCommand, defineCommand, ragEndpoint, RAG_PATHS, @@ -102,7 +103,7 @@ export default defineCommand({ deletedCount += batchIds.length; completedBatches.push(batchIds); } catch (error) { - const original = normalizeError(error, ctx.identity.binName, ctx.localize); + const original = normalizeError(error, formatCommand(ctx.identity), ctx.localize); const failedBatch = completedBatches.length + 1; const progress = [ ctx.localize({ diff --git a/packages/commands/src/commands/knowledge/init.ts b/packages/commands/src/commands/knowledge/init.ts index 50a040be..cece0459 100644 --- a/packages/commands/src/commands/knowledge/init.ts +++ b/packages/commands/src/commands/knowledge/init.ts @@ -1,9 +1,11 @@ import { resolve } from "node:path"; import { + buildCommandInvocation, BailianError, ExitCode, defineCommand, type CommandContext, + type ExecutableCommand, type FlagsDef, } from "bailian-cli-core"; import { knowledgeCreationRisk, localizedKnowledgeBillingNotice } from "./billing.ts"; @@ -46,16 +48,11 @@ const INIT_FLAGS = { ...WORKSPACE_FLAG, } satisfies FlagsDef; -interface CommandAction { - path: string[]; - args: string[]; -} - function cleanupAction( - context: Pick, + context: Pick, resource: InitResource, workspaceId: string, -): CommandAction[] { +): ExecutableCommand[] { if (!resource.created) return []; const command = resource.kind === "knowledge-base" @@ -70,11 +67,20 @@ function cleanupAction( ? "--agent-id" : "--file-id"; const path = context.commandPath?.(command); - return path ? [{ path, args: [flag, resource.id, "--workspace-id", workspaceId] }] : []; + return path + ? [ + buildCommandInvocation(context.identity, path, [ + flag, + resource.id, + "--workspace-id", + workspaceId, + ]), + ] + : []; } -function renderAction(binName: string, action: CommandAction): string { - return [binName, ...action.path, ...action.args] +function renderAction(command: ExecutableCommand): string { + return [command.bin, ...command.path, ...command.args] .map((word) => /^[a-zA-Z0-9_./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`, ) @@ -172,13 +178,7 @@ export default defineCommand({ }); context.output.warning({ code: "KNOWLEDGE_INIT_RESOURCE", - message: - format === "text" - ? [ - message, - ...cleanup.map((action) => renderAction(context.identity.binName, action)), - ].join("\n") - : message, + message: format === "text" ? [message, ...cleanup.map(renderAction)].join("\n") : message, ...(format === "json" ? { details: { resource, cleanup } } : {}), }); }, @@ -192,19 +192,16 @@ export default defineCommand({ .flatMap((resource) => cleanupAction(context, resource, workspaceId)); const searchPath = context.commandPath?.(search); const nextSearch = searchPath - ? { - path: searchPath, - args: [ - "--agent-id", - result.agentId, - "--agent-version", - "beta", - "--workspace-id", - workspaceId, - "--query", - context.localize({ "en-US": "What is RAG?", "zh-CN": "什么是 RAG?" }), - ], - } + ? buildCommandInvocation(context.identity, searchPath, [ + "--agent-id", + result.agentId, + "--agent-version", + "beta", + "--workspace-id", + workspaceId, + "--query", + context.localize({ "en-US": "What is RAG?", "zh-CN": "什么是 RAG?" }), + ]) : undefined; const notices = [localizedKnowledgeBillingNotice(context)]; let text: string | undefined; @@ -239,7 +236,7 @@ export default defineCommand({ }), ); lines.push(context.localize({ "en-US": `Content: ${excerpt}`, "zh-CN": `内容:${excerpt}` })); - if (nextSearch) lines.push(renderAction(context.identity.binName, nextSearch)); + if (nextSearch) lines.push(renderAction(nextSearch)); lines.push(notices[0]!.message); if (cleanup.length) lines.push( @@ -249,7 +246,7 @@ export default defineCommand({ "zh-CN": "本次新建资源的清理命令(删除需另行确认):", }), ); - for (const action of cleanup) lines.push(renderAction(context.identity.binName, action)); + for (const action of cleanup) lines.push(renderAction(action)); text = lines.join("\n"); } context.output.result( diff --git a/packages/commands/src/commands/log/audit-disable.ts b/packages/commands/src/commands/log/audit-disable.ts index 54e0ef20..ef2d0017 100644 --- a/packages/commands/src/commands/log/audit-disable.ts +++ b/packages/commands/src/commands/log/audit-disable.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand } from "../shared/telemetry.ts"; import { disableLogDelivery } from "./shared.ts"; @@ -22,7 +23,7 @@ export default defineTelemetryCommand({ client: ctx.client, output: ctx.output, settings: ctx.settings, - binName: ctx.identity.binName, + commandPrefix: formatCommand(ctx.identity), }, "audit", true, diff --git a/packages/commands/src/commands/log/audit-enable.ts b/packages/commands/src/commands/log/audit-enable.ts index b261dabd..a34857d2 100644 --- a/packages/commands/src/commands/log/audit-enable.ts +++ b/packages/commands/src/commands/log/audit-enable.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand } from "../shared/telemetry.ts"; import { LOG_NO_WAIT_FLAG, enableLogDelivery } from "./shared.ts"; import { LOG_SERVICE_TYPES } from "./status.ts"; @@ -30,7 +31,7 @@ export default defineTelemetryCommand({ client: ctx.client, output: ctx.output, settings: ctx.settings, - binName: ctx.identity.binName, + commandPrefix: formatCommand(ctx.identity), noWait: ctx.flags.noWait, }, "audit", diff --git a/packages/commands/src/commands/log/inference-disable.ts b/packages/commands/src/commands/log/inference-disable.ts index 8e0feea8..8f3cc61f 100644 --- a/packages/commands/src/commands/log/inference-disable.ts +++ b/packages/commands/src/commands/log/inference-disable.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand } from "../shared/telemetry.ts"; import { disableLogDelivery } from "./shared.ts"; @@ -22,7 +23,7 @@ export default defineTelemetryCommand({ client: ctx.client, output: ctx.output, settings: ctx.settings, - binName: ctx.identity.binName, + commandPrefix: formatCommand(ctx.identity), }, "inference", ); diff --git a/packages/commands/src/commands/log/inference-enable.ts b/packages/commands/src/commands/log/inference-enable.ts index 2a0b4776..3dabcefe 100644 --- a/packages/commands/src/commands/log/inference-enable.ts +++ b/packages/commands/src/commands/log/inference-enable.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand } from "../shared/telemetry.ts"; import { LOG_NO_WAIT_FLAG, enableLogDelivery, requireAuditLogEnabled } from "./shared.ts"; import { LOG_SERVICE_TYPES } from "./status.ts"; @@ -29,12 +30,16 @@ export default defineTelemetryCommand({ client: ctx.client, output: ctx.output, settings: ctx.settings, - binName: ctx.identity.binName, + commandPrefix: formatCommand(ctx.identity), noWait: ctx.flags.noWait, }; if (!ctx.settings.dryRun) { - await requireAuditLogEnabled(ctx.client, ctx.settings.workspaceId, ctx.identity.binName); + await requireAuditLogEnabled( + ctx.client, + ctx.settings.workspaceId, + formatCommand(ctx.identity), + ); } await enableLogDelivery(enableContext, "inference", LOG_SERVICE_TYPES.inference); diff --git a/packages/commands/src/commands/log/shared.ts b/packages/commands/src/commands/log/shared.ts index e3ed20d4..84f97e42 100644 --- a/packages/commands/src/commands/log/shared.ts +++ b/packages/commands/src/commands/log/shared.ts @@ -1,4 +1,5 @@ import { + formatCommand, BailianError, ExitCode, type Client, @@ -266,7 +267,7 @@ export async function getLogSwitch( export async function requireAuditLogEnabled( client: Client, workspaceId: string | undefined, - binName: string, + commandPrefix: string, ): Promise { let auditSwitch = await getLogSwitch(client, "audit", workspaceId); for (let attempt = 0; attempt < 3 && auditSwitch !== true; attempt += 1) { @@ -277,7 +278,7 @@ export async function requireAuditLogEnabled( throw new BailianError( "Model audit log is not enabled yet.", ExitCode.USAGE, - `Inference log delivery requires the audit log. Run \`${binName} log audit enable\` first, then check \`${binName} log status\`.`, + `Inference log delivery requires the audit log. Run \`${formatCommand(commandPrefix, "log audit enable")}\` first, then check \`${formatCommand(commandPrefix, "log status")}\`.`, ); } @@ -293,7 +294,7 @@ export interface LogEnableContext { output: CommandOutput; client: Client; settings: Settings; - binName: string; + commandPrefix: string; noWait?: boolean; } @@ -363,7 +364,7 @@ export async function enableLogDelivery( textChunks.push(`${kind === "audit" ? "Audit" : "Inference"} log delivery enabled.\n`); if (ctx.noWait) { textChunks.push( - `The SLS instance may still be initializing; check \`${ctx.binName} log status\` later.\n`, + `The SLS instance may still be initializing; check \`${formatCommand(ctx.commandPrefix, "log status")}\` later.\n`, ); } @@ -404,7 +405,7 @@ export async function disableLogDelivery( throw new BailianError( "The audit log cannot be disabled while the inference log is enabled.", ExitCode.USAGE, - `Run \`${ctx.binName} log inference disable\` first, then disable the audit log.`, + `Run \`${formatCommand(ctx.commandPrefix, "log inference disable")}\` first, then disable the audit log.`, ); } } diff --git a/packages/commands/src/commands/log/status.ts b/packages/commands/src/commands/log/status.ts index f1c59a24..8c0dcc18 100644 --- a/packages/commands/src/commands/log/status.ts +++ b/packages/commands/src/commands/log/status.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { ansi, displayWidth, padEnd } from "bailian-cli-runtime"; import { defineTelemetryCommand, @@ -120,12 +121,14 @@ export default defineTelemetryCommand({ if (status.auditSwitch !== true) { textChunks.push( - color.dim(`\nRun \`${identity.binName} log audit enable\` to enable audit log delivery.`), + color.dim( + `\nRun \`${formatCommand(identity, "log audit enable")}\` to enable audit log delivery.`, + ), ); } else if (status.inferenceSwitch !== true) { textChunks.push( color.dim( - `\nRun \`${identity.binName} log inference enable\` to enable inference log delivery.`, + `\nRun \`${formatCommand(identity, "log inference enable")}\` to enable inference log delivery.`, ), ); } diff --git a/packages/commands/src/commands/managed-agent/_engine/config-loader.ts b/packages/commands/src/commands/managed-agent/_engine/config-loader.ts index 7085bb45..2329710c 100644 --- a/packages/commands/src/commands/managed-agent/_engine/config-loader.ts +++ b/packages/commands/src/commands/managed-agent/_engine/config-loader.ts @@ -4,7 +4,8 @@ import { type ProjectRuntimeContext, resolveProjectConfig, } from "@openagentpack/sdk"; -import { BailianError, ExitCode } from "bailian-cli-core"; +import { formatCommand, type CommandFormatter, BailianError, ExitCode } from "bailian-cli-core"; + import { assertProviderCredentials, type CredentialHost, @@ -64,9 +65,13 @@ export async function resolveAgentProjectConfig( overrideBaseUrl: options.overrideBailianBaseUrl, }); scrubCredentialEnv(); - assertBailianOnlyProviders(resolved.config.providers); + assertBailianOnlyProviders(resolved.config.providers, (args) => + formatCommand(host.identity, args), + ); if ((options.credentials ?? "all") !== "none") { - assertProviderCredentials(resolved.config.providers); + assertProviderCredentials(resolved.config.providers, (args) => + formatCommand(host.identity, args), + ); } return resolved; }); @@ -102,7 +107,10 @@ export async function buildAgentRuntime( } /** Enforce the bl product boundary while the shared SDK remains multi-provider capable. */ -export function assertBailianOnlyProviders(providers: Record): void { +export function assertBailianOnlyProviders( + providers: Record, + command: CommandFormatter, +): void { const configuredProviders = Object.keys(providers); const unsupportedProviders = configuredProviders.filter( (provider) => provider !== BAILIAN_PROVIDER, @@ -110,13 +118,13 @@ export function assertBailianOnlyProviders(providers: Record): if (unsupportedProviders.length > 0) { const names = unsupportedProviders.join(", "); throw new BailianError( - `bl managed-agent only supports provider '${BAILIAN_PROVIDER}'; remove unsupported providers: ${names}. / bl managed-agent 仅支持 Provider '${BAILIAN_PROVIDER}';请移除不支持的 Provider:${names}。`, + `${command("managed-agent")} only supports provider '${BAILIAN_PROVIDER}'; remove unsupported providers: ${names}. / ${command("managed-agent")} 仅支持 Provider '${BAILIAN_PROVIDER}';请移除不支持的 Provider:${names}。`, ExitCode.USAGE, ); } if (!(BAILIAN_PROVIDER in providers)) { throw new BailianError( - `bl managed-agent requires a '${BAILIAN_PROVIDER}' provider configuration. / bl managed-agent 需要配置 Provider '${BAILIAN_PROVIDER}'。`, + `${command("managed-agent")} requires a '${BAILIAN_PROVIDER}' provider configuration. / ${command("managed-agent")} 需要配置 Provider '${BAILIAN_PROVIDER}'。`, ExitCode.USAGE, ); } diff --git a/packages/commands/src/commands/managed-agent/_engine/credentials.ts b/packages/commands/src/commands/managed-agent/_engine/credentials.ts index 1ad42ebe..897469c3 100644 --- a/packages/commands/src/commands/managed-agent/_engine/credentials.ts +++ b/packages/commands/src/commands/managed-agent/_engine/credentials.ts @@ -1,5 +1,12 @@ import { AGENTS_PROVIDER_FIELDS, bootstrapRuntimeCredentialsSync } from "@openagentpack/sdk"; -import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core"; +import { + commandRef, + type CommandFormatter, + BailianError, + type Client, + ExitCode, + type Settings, +} from "bailian-cli-core"; /** * AgentStudio API path the SDK's BailianClient serves resources under. bl's @@ -28,16 +35,14 @@ const CREDENTIAL_ENV_KEYS = [ /** How to obtain each provider's key, surfaced in the CLI's own AUTH error when it is missing. */ const CREDENTIAL_HINTS: Record = { - bailian: "Run `bl auth login --api-key `, pass --api-key, or set DASHSCOPE_API_KEY.", claude: "Set ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in your shell or .env.", ark: "Set ARK_API_KEY in your shell or .env.", qoder: "Set QODER_PAT (or QODER_API_KEY) in your shell or .env.", }; const BAILIAN_ONLY_NOTE = { - "en-US": - "bl managed-agent supports the Bailian provider only; configurations containing other providers are rejected.", - "zh-CN": "bl managed-agent 仅支持百炼 Provider;包含其他 Provider 的配置会被拒绝。", + "en-US": `${commandRef("managed-agent")} supports the Bailian provider only; configurations containing other providers are rejected.`, + "zh-CN": `${commandRef("managed-agent")} 仅支持百炼 Provider;包含其他 Provider 的配置会被拒绝。`, } as const; /** The slice of CommandContext the credential pipeline needs: authStage-resolved client + settings. */ @@ -51,15 +56,13 @@ export interface CredentialHost { * credentials. Bailian goes through bl's own auth chain (commands declare * `auth: "apiKey"`). The resolved credential is injected into the SDK in-memory * and scrubbed from the environment. Attach to every command that loads - * agents.yaml. `bl` prefix is safe: agent commands ship on `bl` only. + * agents.yaml. Command references are rendered by the current CLI host. */ export const CREDENTIALS_NOTE = [ BAILIAN_ONLY_NOTE, { - "en-US": - "Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).", - "zh-CN": - "百炼凭证来自 bl 鉴权链:--api-key > DASHSCOPE_API_KEY > `bl auth login`(当前激活的配置 Profile)。", + "en-US": `Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > \`${commandRef("auth login")}\` (active config profile).`, + "zh-CN": `百炼凭证来自 bl 鉴权链:--api-key > DASHSCOPE_API_KEY > \`${commandRef("auth login")}\`(当前激活的配置 Profile)。`, }, { "en-US": @@ -175,6 +178,7 @@ export function normalizeInterpolatedProviderBlocks(providers: Record, + command: CommandFormatter, targetProviders?: readonly string[], ): void { for (const [name, raw] of Object.entries(providers)) { @@ -187,7 +191,9 @@ export function assertProviderCredentials( throw new BailianError( `Provider '${name}' is configured but its API key is empty.`, ExitCode.AUTH, - CREDENTIAL_HINTS[name] ?? `Provide credentials for provider '${name}'.`, + name === "bailian" + ? `Run \`${command("auth login --api-key ")}\`, pass --api-key, or set DASHSCOPE_API_KEY.` + : (CREDENTIAL_HINTS[name] ?? `Provide credentials for provider '${name}'.`), ); } } diff --git a/packages/commands/src/commands/managed-agent/_engine/errors.ts b/packages/commands/src/commands/managed-agent/_engine/errors.ts index bd531903..d41f75a0 100644 --- a/packages/commands/src/commands/managed-agent/_engine/errors.ts +++ b/packages/commands/src/commands/managed-agent/_engine/errors.ts @@ -100,19 +100,14 @@ export function retainAgentError(error: unknown, hint: string, fallback: string) * GENERAL (message passed through, per bl's "don't translate server errors" * boundary). */ -export async function withAgentErrors(fn: () => Promise): Promise { +export async function withAgentErrors(fn: () => Promise, timeoutHint?: string): Promise { try { return await fn(); } catch (error) { if (error instanceof BailianError) throw error; if (error instanceof UserError) { if (isSdkPollingTimeout(error)) { - throw new BailianError( - error.message, - ExitCode.TIMEOUT, - // `bl` prefix is safe: agent commands ship on `bl` only. - "The session may still be running — check `bl managed-agent session get --session-id ` or `session events`.", - ); + throw new BailianError(error.message, ExitCode.TIMEOUT, timeoutHint); } throw new BailianError(error.message, ExitCode.USAGE); } diff --git a/packages/commands/src/commands/managed-agent/_engine/scoped-create.ts b/packages/commands/src/commands/managed-agent/_engine/scoped-create.ts index 4cd06f45..88aeb3ca 100644 --- a/packages/commands/src/commands/managed-agent/_engine/scoped-create.ts +++ b/packages/commands/src/commands/managed-agent/_engine/scoped-create.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { randomUUID } from "node:crypto"; import { readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; @@ -145,7 +146,7 @@ export async function loadScopedCreateProject( throw new BailianError( `Config file not found: ${file}`, ExitCode.USAGE, - "Run `bl managed-agent init` first.", + `Run \`${formatCommand(host.identity, "managed-agent init")}\` first.`, ); } throw error; @@ -162,7 +163,12 @@ export async function loadScopedCreateProject( ); } const provider = "bailian"; - if (!host.settings.dryRun) assertProviderCredentials(loaded.config.providers, [provider]); + if (!host.settings.dryRun) + assertProviderCredentials( + loaded.config.providers, + (args) => formatCommand(host.identity, args), + [provider], + ); installSdkTransport(); const stateBackend = new LocalFileStateBackend({ configPath: loaded.configPath }); return { diff --git a/packages/commands/src/commands/managed-agent/agent/create.ts b/packages/commands/src/commands/managed-agent/agent/create.ts index 7bea7041..877a9431 100644 --- a/packages/commands/src/commands/managed-agent/agent/create.ts +++ b/packages/commands/src/commands/managed-agent/agent/create.ts @@ -1,3 +1,4 @@ +import { formatCommand, type CommandFormatter } from "bailian-cli-core"; import { stat } from "node:fs/promises"; import { dirname, extname, relative, sep } from "node:path"; import { @@ -252,12 +253,14 @@ function canonicalJson(value: unknown): string { } export function selectLocalSkillKey(options: { + command: CommandFormatter; displayName: string; provider: string; declarations: Record>; candidate: Record; state: IStateManager; }): LocalSkillKeySelection { + const { command } = options; const trackedKeys = new Set( options.state .listResources() @@ -288,7 +291,7 @@ export function selectLocalSkillKey(options: { throw new BailianError( `Skill '${options.displayName}' is already tracked with a different declaration.`, ExitCode.USAGE, - "Modify its existing YAML declaration and run full `bl managed-agent apply`, or bind its remote ID with --skill.", + `Modify its existing YAML declaration and run full \`${command("managed-agent apply")}\`, or bind its remote ID with --skill.`, ); } const selected = selectResourceKey({ @@ -312,6 +315,7 @@ export function selectLocalSkillKey(options: { async function prepareLocalSkillSources( project: LoadedScopedCreateProject, sources: string[] | undefined, + command: CommandFormatter, ): Promise { const declarations = { ...((project.config.skills ?? {}) as unknown as Record>), @@ -352,6 +356,7 @@ async function prepareLocalSkillSources( })) as unknown as SkillDecl; const keySelection = await project.stateBackend.read(project.stateScope, (state) => selectLocalSkillKey({ + command, displayName: inspected.name, provider: project.provider, declarations, @@ -468,7 +473,9 @@ export default defineCommand({ mcp: mcpBindings.map((binding) => binding.serverName), }; const externalSkillRefs = buildAgentSkillRefs(flags.skill, agentSkillType(flags.type)); - const localSkills = await prepareLocalSkillSources(project, flags.skillDir); + const localSkills = await prepareLocalSkillSources(project, flags.skillDir, (args) => + formatCommand(ctx.identity, args), + ); const skillRefs: AgentSkillDecl[] = [ ...externalSkillRefs, ...localSkills.map((skill) => skill.key), diff --git a/packages/commands/src/commands/managed-agent/init.ts b/packages/commands/src/commands/managed-agent/init.ts index f34d94e0..cee97718 100644 --- a/packages/commands/src/commands/managed-agent/init.ts +++ b/packages/commands/src/commands/managed-agent/init.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -10,12 +11,12 @@ agents.state.json .env `; -function buildTemplate(options: { agentName: string }): string { +function buildTemplate(options: { agentName: string; invocation: string }): string { return `version: "1" providers: bailian: - # bl auth login --api-key sets DASHSCOPE_API_KEY; --base-url sets BAILIAN_BASE_URL + # ${formatCommand(options.invocation, "auth login --api-key ")} sets DASHSCOPE_API_KEY; --base-url sets BAILIAN_BASE_URL api_key: \${DASHSCOPE_API_KEY} base_url: \${BAILIAN_BASE_URL} @@ -104,7 +105,7 @@ export default defineCommand({ return; } - const template = buildTemplate({ agentName }); + const template = buildTemplate({ agentName, invocation: formatCommand(ctx.identity) }); await writeFile(file, template, "utf8"); if (existsSync(gitignorePath)) { @@ -126,9 +127,11 @@ export default defineCommand({ { lines.push(`Created ${resolve(file)}`); lines.push( - "Credentials: run `bl auth login --api-key --base-url `, or set DASHSCOPE_API_KEY / BAILIAN_BASE_URL.", + `Credentials: run \`${formatCommand(ctx.identity, "auth login --api-key --base-url ")}\`, or set DASHSCOPE_API_KEY / BAILIAN_BASE_URL.`, + ); + lines.push( + `Next: edit agents.yaml, then run \`${formatCommand(ctx.identity, "managed-agent plan")}\`.`, ); - lines.push("Next: edit agents.yaml, then run `bl managed-agent plan`."); } return lines.join("\n"); })() diff --git a/packages/commands/src/commands/managed-agent/session-run.ts b/packages/commands/src/commands/managed-agent/session-run.ts index b133236e..9d065d84 100644 --- a/packages/commands/src/commands/managed-agent/session-run.ts +++ b/packages/commands/src/commands/managed-agent/session-run.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, type FlagsDef } from "bailian-cli-core"; import { startSessionRun, startSessionRunPolling } from "@openagentpack/sdk"; import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; @@ -154,33 +155,35 @@ export default defineCommand({ return; } - await withAgentErrors(() => - withStdoutProtected(ctx, async () => { - const runtime = await buildAgentRuntime(ctx, file); - if (flags.noStream) { - const run = await startSessionRunPolling(runtime, flags.prompt, runOptions); - ctx.output.info({ - "en-US": `Session created: ${run.session.id}`, - "zh-CN": `Session 已创建:${run.session.id}`, - }); - renderCollectedEvents(ctx, run, { - session_id: run.session.id, - provider: run.provider, - agent: run.agentName, - }); - } else { - const run = await startSessionRun(runtime, flags.prompt, runOptions); - ctx.output.info({ - "en-US": `Session created: ${run.session.id}`, - "zh-CN": `Session 已创建:${run.session.id}`, - }); - await streamAndRenderEvents(ctx, run.events, { - session_id: run.session.id, - provider: run.provider, - agent: run.agentName, - }); - } - }), + await withAgentErrors( + () => + withStdoutProtected(ctx, async () => { + const runtime = await buildAgentRuntime(ctx, file); + if (flags.noStream) { + const run = await startSessionRunPolling(runtime, flags.prompt, runOptions); + ctx.output.info({ + "en-US": `Session created: ${run.session.id}`, + "zh-CN": `Session 已创建:${run.session.id}`, + }); + renderCollectedEvents(ctx, run, { + session_id: run.session.id, + provider: run.provider, + agent: run.agentName, + }); + } else { + const run = await startSessionRun(runtime, flags.prompt, runOptions); + ctx.output.info({ + "en-US": `Session created: ${run.session.id}`, + "zh-CN": `Session 已创建:${run.session.id}`, + }); + await streamAndRenderEvents(ctx, run.events, { + session_id: run.session.id, + provider: run.provider, + agent: run.agentName, + }); + } + }), + `The session may still be running — check \`${formatCommand(ctx.identity, "managed-agent session get --session-id ")}\` or \`session events\`.`, ); }, }); diff --git a/packages/commands/src/commands/managed-agent/session-send.ts b/packages/commands/src/commands/managed-agent/session-send.ts index 868d6af9..f9e78c3a 100644 --- a/packages/commands/src/commands/managed-agent/session-send.ts +++ b/packages/commands/src/commands/managed-agent/session-send.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, type FlagsDef } from "bailian-cli-core"; import { sendSessionMessagePolling, sendSessionMessageStreaming } from "@openagentpack/sdk"; import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; @@ -68,30 +69,37 @@ export default defineCommand({ return; } - await withAgentErrors(() => - withStdoutProtected(ctx, async () => { - const runtime = await buildAgentRuntime(ctx, file); - if (flags.noStream) { - const result = await sendSessionMessagePolling(runtime, flags.sessionId, flags.message, { - provider: "bailian", - }); - renderCollectedEvents(ctx, result, { - session_id: flags.sessionId, - }); - } else { - const events = await sendSessionMessageStreaming( - runtime, - flags.sessionId, - flags.message, - { - provider: "bailian", - }, - ); - await streamAndRenderEvents(ctx, events, { - session_id: flags.sessionId, - }); - } - }), + await withAgentErrors( + () => + withStdoutProtected(ctx, async () => { + const runtime = await buildAgentRuntime(ctx, file); + if (flags.noStream) { + const result = await sendSessionMessagePolling( + runtime, + flags.sessionId, + flags.message, + { + provider: "bailian", + }, + ); + renderCollectedEvents(ctx, result, { + session_id: flags.sessionId, + }); + } else { + const events = await sendSessionMessageStreaming( + runtime, + flags.sessionId, + flags.message, + { + provider: "bailian", + }, + ); + await streamAndRenderEvents(ctx, events, { + session_id: flags.sessionId, + }); + } + }), + `The session may still be running — check \`${formatCommand(ctx.identity, "managed-agent session get --session-id ")}\` or \`session events\`.`, ); }, }); diff --git a/packages/commands/src/commands/managed-agent/skill/create.ts b/packages/commands/src/commands/managed-agent/skill/create.ts index 02b352aa..10e0fea9 100644 --- a/packages/commands/src/commands/managed-agent/skill/create.ts +++ b/packages/commands/src/commands/managed-agent/skill/create.ts @@ -1,6 +1,13 @@ import { dirname, relative, sep } from "node:path"; import { inspectSkillSource } from "@openagentpack/sdk"; -import { BailianError, defineCommand, ExitCode, type FlagsDef } from "bailian-cli-core"; +import { + formatCommand, + BailianError, + defineCommand, + ExitCode, + type FlagsDef, +} from "bailian-cli-core"; + import { CREDENTIALS_NOTE } from "../_engine/config-loader.ts"; import { loadScopedCreateProject, @@ -80,7 +87,7 @@ export default defineCommand({ throw new BailianError( `Skill '${inspected.name}' is already tracked.`, ExitCode.USAGE, - "Modify its existing YAML declaration and run full `bl managed-agent apply` to create a new version.", + `Modify its existing YAML declaration and run full \`${formatCommand(ctx.identity, "managed-agent apply")}\` to create a new version.`, ); } const rawDeclaration: Record = { diff --git a/packages/commands/src/commands/managed-agent/state-import.ts b/packages/commands/src/commands/managed-agent/state-import.ts index 9ba8860a..46d97fab 100644 --- a/packages/commands/src/commands/managed-agent/state-import.ts +++ b/packages/commands/src/commands/managed-agent/state-import.ts @@ -1,3 +1,4 @@ +import { formatCommand, type CommandFormatter } from "bailian-cli-core"; import { BailianError, defineCommand, ExitCode, type FlagsDef } from "bailian-cli-core"; import { importResource, parseStateAddress } from "@openagentpack/sdk"; import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; @@ -41,11 +42,11 @@ const STATE_IMPORT_FLAGS = { }, } satisfies FlagsDef; -function parseBailianStateAddress(address: string) { +function parseBailianStateAddress(address: string, command: CommandFormatter) { const parsed = parseStateAddress(address, { requireProvider: true }); if (parsed.provider !== "bailian") { throw new BailianError( - `bl managed-agent can import only Bailian resources; address provider was '${parsed.provider}'. / bl managed-agent 只能导入百炼资源;地址中的 Provider 为 '${parsed.provider}'。`, + `${command("managed-agent")} can import only Bailian resources; address provider was '${parsed.provider}'. / ${command("managed-agent")} 只能导入百炼资源;地址中的 Provider 为 '${parsed.provider}'。`, ExitCode.USAGE, ); } @@ -71,7 +72,7 @@ export default defineCommand({ if (settings.dryRun) { // Validate the address shape locally so dry-run still catches usage errors. await withAgentErrors(async () => { - parseBailianStateAddress(flags.address); + parseBailianStateAddress(flags.address, (args) => formatCommand(ctx.identity, args)); }); ctx.output.result({ would_import: flags.address, @@ -85,7 +86,9 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(ctx, async () => { // Parse first so a malformed address fails fast, before any config I/O. - const parsed = parseBailianStateAddress(flags.address); + const parsed = parseBailianStateAddress(flags.address, (args) => + formatCommand(ctx.identity, args), + ); const runtime = await buildAgentRuntime(ctx, file); await importResource(runtime, parsed, flags.remoteId, { resourceVersion: flags.resourceVersion, diff --git a/packages/commands/src/commands/managed-agent/vault/credential/create.ts b/packages/commands/src/commands/managed-agent/vault/credential/create.ts index 3635f880..7f90f8ab 100644 --- a/packages/commands/src/commands/managed-agent/vault/credential/create.ts +++ b/packages/commands/src/commands/managed-agent/vault/credential/create.ts @@ -7,6 +7,7 @@ import { resolveProjectConfigFromObject, } from "@openagentpack/sdk"; import { + formatCommand, BailianError, defineCommand, detectOutputFormat, @@ -192,7 +193,7 @@ export default defineCommand({ throw new BailianError( `Vault '${ctx.flags.vault}' already declares a different credential named '${rawCredential.name}'.`, ExitCode.USAGE, - "Modify the existing YAML declaration and run full `bl managed-agent apply`; create never overwrites it.", + `Modify the existing YAML declaration and run full \`${formatCommand(ctx.identity, "managed-agent apply")}\`; create never overwrites it.`, ); } reusedPending = true; diff --git a/packages/commands/src/commands/mcp/list.ts b/packages/commands/src/commands/mcp/list.ts index 4d42b6c1..86fc200d 100644 --- a/packages/commands/src/commands/mcp/list.ts +++ b/packages/commands/src/commands/mcp/list.ts @@ -1,4 +1,5 @@ import { + formatCommand, defineCommand, effectiveConsoleGatewayConfig, BailianError, @@ -88,7 +89,7 @@ export default defineCommand({ const msg = (dataField.errorMsg as string | undefined) ?? code; const hint = code === "BailianGateway.Login.NotLogined" - ? `Run \`${identity.binName} auth login --console\` to refresh your console session.` + ? `Run \`${formatCommand(identity, "auth login --console")}\` to refresh your console session.` : undefined; throw new BailianError(`Console gateway: ${msg}`, ExitCode.AUTH, hint); } diff --git a/packages/commands/src/commands/monitor/delivery-enable.ts b/packages/commands/src/commands/monitor/delivery-enable.ts index 57baedcf..1b2812b8 100644 --- a/packages/commands/src/commands/monitor/delivery-enable.ts +++ b/packages/commands/src/commands/monitor/delivery-enable.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { buildGroupSwitchReqDTO, @@ -150,8 +151,8 @@ export default defineTelemetryCommand({ const result = { activated: false, submitted: true }; ctx.output.result(result, { text: ctx.localize({ - "en-US": `Activation submitted. Run \`${ctx.identity.binName} monitor delivery status\` to check progress.`, - "zh-CN": `开通请求已提交。运行 \`${ctx.identity.binName} monitor delivery status\` 查看进度。`, + "en-US": `Activation submitted. Run \`${formatCommand(ctx.identity, "monitor delivery status")}\` to check progress.`, + "zh-CN": `开通请求已提交。运行 \`${formatCommand(ctx.identity, "monitor delivery status")}\` 查看进度。`, }), }); return; @@ -187,8 +188,8 @@ export default defineTelemetryCommand({ "zh-CN": `监控数据投递已开启。${after.instanceInfo ? `\n实例:${after.instanceInfo.instanceId}\nURL:${after.instanceInfo.instanceUrl}` : ""}`, }) : ctx.localize({ - "en-US": `Activation is still in progress. Run \`${ctx.identity.binName} monitor delivery status\` to check later.`, - "zh-CN": `服务仍在开通中。稍后运行 \`${ctx.identity.binName} monitor delivery status\` 查看状态。`, + "en-US": `Activation is still in progress. Run \`${formatCommand(ctx.identity, "monitor delivery status")}\` to check later.`, + "zh-CN": `服务仍在开通中。稍后运行 \`${formatCommand(ctx.identity, "monitor delivery status")}\` 查看状态。`, }); } ctx.output.result({ activated: ready, ...after }, { text }); diff --git a/packages/commands/src/commands/monitor/delivery-status.ts b/packages/commands/src/commands/monitor/delivery-status.ts index 0e207362..47475c25 100644 --- a/packages/commands/src/commands/monitor/delivery-status.ts +++ b/packages/commands/src/commands/monitor/delivery-status.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { ansi, displayWidth, padEnd } from "bailian-cli-runtime"; import { formatDateTime } from "../shared/format.ts"; import { @@ -19,7 +20,7 @@ function renderStatus( cmsSlrAuthorized: boolean, status: TelemetryServiceStatus, deliverySwitch: boolean | null, - binName: string, + commandPrefix: string, ): string { const textChunks: string[] = []; @@ -51,7 +52,9 @@ function renderStatus( if (!status.openStatus || deliverySwitch !== true) { textChunks.push( - color.dim(`\nRun \`${binName} monitor delivery enable\` to activate monitoring delivery.`), + color.dim( + `\nRun \`${formatCommand(commandPrefix, "monitor delivery enable")}\` to activate monitoring delivery.`, + ), ); } @@ -102,7 +105,7 @@ export default defineTelemetryCommand({ const textChunks: string[] = []; textChunks.push( - renderStatus(cmsSlrAuthorized, status, deliverySwitch, identity.binName) + "\n", + renderStatus(cmsSlrAuthorized, status, deliverySwitch, formatCommand(identity)) + "\n", ); text = textChunks.join("").replace(/\n$/, ""); diff --git a/packages/commands/src/commands/monitor/metrics.ts b/packages/commands/src/commands/monitor/metrics.ts index 83094b5b..c9a7231a 100644 --- a/packages/commands/src/commands/monitor/metrics.ts +++ b/packages/commands/src/commands/monitor/metrics.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ansi } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; @@ -212,7 +213,7 @@ export default defineTelemetryCommand({ if (parseCommaList(flags.metric).some((metricName) => FAILURE_METRICS.has(metricName))) { textChunks.push( ansi(process.stdout).dim( - `\nFor the error-code breakdown, run \`${ctx.identity.binName} monitor errors\`.`, + `\nFor the error-code breakdown, run \`${formatCommand(ctx.identity, "monitor errors")}\`.`, ), ); } diff --git a/packages/commands/src/commands/permission/list.ts b/packages/commands/src/commands/permission/list.ts index 1206261d..346fe1eb 100644 --- a/packages/commands/src/commands/permission/list.ts +++ b/packages/commands/src/commands/permission/list.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, modelsPermissionsPath, type LocalizedText } from "bailian-cli-core"; import { renderBoxTable } from "bailian-cli-runtime"; import { buildQuery } from "../shared/params.ts"; @@ -180,12 +181,11 @@ export default defineCommand({ let text: string | undefined; if (format === "text") { - const binName = ctx.identity.binName; const emptyHint = ctx.localize( scope === "authorized" ? { - "en-US": `Nothing granted yet in this workspace. Browse grantable models with \`${binName} permission list --scope authorizable\`, then grant with \`${binName} permission grant --model \`.`, - "zh-CN": `此工作空间尚无授权记录。使用 \`${binName} permission list --scope authorizable\` 查看可授权模型,再通过 \`${binName} permission grant --model \` 授权。`, + "en-US": `Nothing granted yet in this workspace. Browse grantable models with \`${formatCommand(ctx.identity, "permission list --scope authorizable")}\`, then grant with \`${formatCommand(ctx.identity, "permission grant --model ")}\`.`, + "zh-CN": `此工作空间尚无授权记录。使用 \`${formatCommand(ctx.identity, "permission list --scope authorizable")}\` 查看可授权模型,再通过 \`${formatCommand(ctx.identity, "permission grant --model ")}\` 授权。`, } : { "en-US": diff --git a/packages/commands/src/commands/quota/history.ts b/packages/commands/src/commands/quota/history.ts index 301eec63..c7debcaf 100644 --- a/packages/commands/src/commands/quota/history.ts +++ b/packages/commands/src/commands/quota/history.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, BailianError, ExitCode } from "bailian-cli-core"; import { ansi } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; @@ -122,7 +123,7 @@ export default defineCommand({ throw new BailianError( "session expired.", ExitCode.AUTH, - `Run \`${identity.binName} auth login --console\` to re-authenticate.`, + `Run \`${formatCommand(identity, "auth login --console")}\` to re-authenticate.`, ); } throw err; diff --git a/packages/commands/src/commands/shared/prime.ts b/packages/commands/src/commands/shared/prime.ts index a5d5c5f7..b1a4e235 100644 --- a/packages/commands/src/commands/shared/prime.ts +++ b/packages/commands/src/commands/shared/prime.ts @@ -1,4 +1,5 @@ import { + formatCommand, BailianError, ExitCode, workspaceMaaSBaseUrl, @@ -55,7 +56,7 @@ export function resolvePrimeEndpoint( throw new BailianError( "Workspace ID is required for the default Prime endpoint.", ExitCode.USAGE, - `Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id . You can also override the endpoint with --base-url.`, + `Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${formatCommand(ctx.identity, "config set workspace_id ")}. You can also override the endpoint with --base-url.`, ); } return workspaceMaaSBaseUrl(workspaceId); diff --git a/packages/commands/src/commands/shared/workspace.ts b/packages/commands/src/commands/shared/workspace.ts index 36cf0bda..e8f85149 100644 --- a/packages/commands/src/commands/shared/workspace.ts +++ b/packages/commands/src/commands/shared/workspace.ts @@ -3,6 +3,7 @@ // credential scope does not apply to these, so the workspace is a per-command // flag instead. import { + formatCommand, BailianError, ExitCode, unwrapResponse, @@ -34,7 +35,7 @@ export function resolveWorkspaceId(ctx: { throw new BailianError( "Workspace ID is required.", ExitCode.USAGE, - `Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id `, + `Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${formatCommand(ctx.identity, "config set workspace_id ")}`, ); } return workspaceId; diff --git a/packages/commands/src/commands/skill/add.ts b/packages/commands/src/commands/skill/add.ts index ca626bec..d8106cf4 100644 --- a/packages/commands/src/commands/skill/add.ts +++ b/packages/commands/src/commands/skill/add.ts @@ -1,4 +1,5 @@ import { + formatCommand, BailianError, ExitCode, defineCommand, @@ -94,7 +95,7 @@ export default defineCommand({ throw new BailianError( `${failed.length}/${skills.length} skill(s) failed to install`, ExitCode.GENERAL, - "Check the reason for failed skills in the output; network failures can be retried with bl skill add", + `Check the reason for failed skills in the output; network failures can be retried with ${formatCommand(ctx.identity, "skill add")}`, ); } return; @@ -160,7 +161,7 @@ export default defineCommand({ throw new BailianError( `${failed.length}/${results.length} skill(s) failed to install`, ExitCode.GENERAL, - "Check the reason for failed skills in the output; network failures can be retried with bl skill add", + `Check the reason for failed skills in the output; network failures can be retried with ${formatCommand(ctx.identity, "skill add")}`, ); } }, diff --git a/packages/commands/src/commands/skill/init.ts b/packages/commands/src/commands/skill/init.ts index c8adad85..1236b01a 100644 --- a/packages/commands/src/commands/skill/init.ts +++ b/packages/commands/src/commands/skill/init.ts @@ -1,4 +1,6 @@ import { + commandRef, + formatCommand, BailianError, ExitCode, defineCommand, @@ -46,8 +48,8 @@ export default defineCommand({ "zh-CN": "获取 Registry 索引并安装名称以 `bailian-` 开头的所有 Skill。", }, { - "en-US": "Equivalent to: `bl skill add --all` (filtered to `bailian-*` skills).", - "zh-CN": "等价于:`bl skill add --all`(筛选为 `bailian-*` Skill)。", + "en-US": `Equivalent to: \`${commandRef("skill add --all")}\` (filtered to \`bailian-*\` skills).`, + "zh-CN": `等价于:\`${commandRef("skill add --all")}\`(筛选为 \`bailian-*\` Skill)。`, }, ], async run(ctx) { @@ -146,7 +148,7 @@ export default defineCommand({ throw new BailianError( `${failed.length}/${results.length} skill(s) failed to install`, ExitCode.GENERAL, - "Check the reason for failed skills in the output; network failures can be retried with bl skill init", + `Check the reason for failed skills in the output; network failures can be retried with ${formatCommand(ctx.identity, "skill init")}`, ); } }, diff --git a/packages/commands/src/commands/skill/remove.ts b/packages/commands/src/commands/skill/remove.ts index 1d23f7d9..70868c2b 100644 --- a/packages/commands/src/commands/skill/remove.ts +++ b/packages/commands/src/commands/skill/remove.ts @@ -1,4 +1,5 @@ import { + formatCommand, BailianError, ExitCode, defineCommand, @@ -99,7 +100,7 @@ export default defineCommand({ throw new BailianError( `${failed.length}/${results.length} skill(s) failed to remove`, ExitCode.GENERAL, - "Check the reason for failed skills in the output; use bl skill list to verify local install status", + `Check the reason for failed skills in the output; use ${formatCommand(ctx.identity, "skill list")} to verify local install status`, ); } return; @@ -154,7 +155,7 @@ export default defineCommand({ throw new BailianError( `${failed.length}/${results.length} skill(s) failed to remove`, ExitCode.GENERAL, - "Check the reason for failed skills in the output; use bl skill list to verify local install status", + `Check the reason for failed skills in the output; use ${formatCommand(ctx.identity, "skill list")} to verify local install status`, ); } }, diff --git a/packages/commands/src/commands/skill/update.ts b/packages/commands/src/commands/skill/update.ts index ea6ca276..795ee99b 100644 --- a/packages/commands/src/commands/skill/update.ts +++ b/packages/commands/src/commands/skill/update.ts @@ -1,4 +1,5 @@ import { + formatCommand, BailianError, ExitCode, defineCommand, @@ -110,7 +111,10 @@ export default defineCommand({ results.push({ name, status: "failed", - reason: "not installed; run bl skill add --name " + name + " first", + reason: + `not installed; run ${formatCommand(ctx.identity, "skill add")} --name ` + + name + + " first", }); continue; } @@ -170,7 +174,7 @@ export default defineCommand({ throw new BailianError( `${failed.length} skill(s) failed to update`, ExitCode.GENERAL, - "Check the reason for failed skills in the output; network failures can be retried with bl skill update", + `Check the reason for failed skills in the output; network failures can be retried with ${formatCommand(ctx.identity, "skill update")}`, ); } return; @@ -211,8 +215,8 @@ export default defineCommand({ results.length === 0 ? [ ctx.localize({ - "en-US": "No skills installed locally; run bl skill add first.", - "zh-CN": "本地未安装 Skill,请先运行 bl skill add。", + "en-US": `No skills installed locally; run ${formatCommand(ctx.identity, "skill add")} first.`, + "zh-CN": `本地未安装 Skill,请先运行 ${formatCommand(ctx.identity, "skill add")}。`, }), ] : formatTable(["NAME", "STATUS", "PUBLISHED"], rows); @@ -231,7 +235,7 @@ export default defineCommand({ throw new BailianError( `${failed.length} skill(s) failed to update`, ExitCode.GENERAL, - "Check the reason for failed skills in the output; network failures can be retried with bl skill update", + `Check the reason for failed skills in the output; network failures can be retried with ${formatCommand(ctx.identity, "skill update")}`, ); } }, diff --git a/packages/commands/src/commands/token-plan/assign-seats.ts b/packages/commands/src/commands/token-plan/assign-seats.ts index 3eb33233..edeec01f 100644 --- a/packages/commands/src/commands/token-plan/assign-seats.ts +++ b/packages/commands/src/commands/token-plan/assign-seats.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, type FlagsDef, type ParsedFlags } from "bailian-cli-core"; import type { BatchAssignSeatsResponse } from "./types.ts"; import { @@ -58,7 +59,7 @@ export default defineCommand({ async run(ctx) { const { identity, settings, flags } = ctx; - const workspaceId = requireWorkspaceId(settings, flags, identity.binName); + const workspaceId = requireWorkspaceId(settings, flags, formatCommand(identity)); const queryParams = buildQueryParams(flags, workspaceId); if (settings.dryRun) { diff --git a/packages/commands/src/commands/token-plan/create-key.ts b/packages/commands/src/commands/token-plan/create-key.ts index a4a845e5..2c178a0c 100644 --- a/packages/commands/src/commands/token-plan/create-key.ts +++ b/packages/commands/src/commands/token-plan/create-key.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, type FlagsDef, type ParsedFlags } from "bailian-cli-core"; import { padEnd } from "bailian-cli-runtime"; import type { CreateTokenPlanKeyResponse } from "./types.ts"; @@ -50,7 +51,7 @@ export default defineCommand({ const { identity, settings, flags } = ctx; const format = settings.output; - const workspaceId = requireWorkspaceId(settings, flags, identity.binName); + const workspaceId = requireWorkspaceId(settings, flags, formatCommand(identity)); const queryParams = buildQueryParams(flags, { accountId: flags.accountId, workspaceId }); if (settings.dryRun) { diff --git a/packages/commands/src/commands/token-plan/utils.ts b/packages/commands/src/commands/token-plan/utils.ts index 2ca1a251..f866b9c3 100644 --- a/packages/commands/src/commands/token-plan/utils.ts +++ b/packages/commands/src/commands/token-plan/utils.ts @@ -1,4 +1,5 @@ import { + formatCommand, REGIONS, buildAcsCanonicalQuery, type Client, @@ -71,13 +72,13 @@ export type TokenPlanQueryParams = AcsQueryParams; export function requireWorkspaceId( settings: Settings, flags: { workspaceId?: string }, - binName: string, + commandPrefix: string, ): string { const workspaceId = flags.workspaceId || settings.workspaceId; if (!workspaceId) { throw new BailianError( "Missing workspace ID.\n" + - `Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: ${binName} config set workspace_id `, + `Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: ${formatCommand(commandPrefix, "config set workspace_id ")}`, ExitCode.USAGE, ); } diff --git a/packages/commands/src/commands/update.ts b/packages/commands/src/commands/update.ts index ed46f3f2..732032e3 100644 --- a/packages/commands/src/commands/update.ts +++ b/packages/commands/src/commands/update.ts @@ -2,6 +2,7 @@ import { execSync, spawnSync } from "child_process"; import { writeFileSync } from "fs"; import { join } from "path"; import { + formatCommand, BailianError, DEFAULT_INSTALL_PS1_URL, DEFAULT_INSTALL_SCRIPT_URL, @@ -12,6 +13,7 @@ import { type InstallMethod, type CommandOutput, } from "bailian-cli-core"; + import { fetchLatestVersion, fetchBinaryChannelVersion, @@ -90,7 +92,10 @@ function skillUpdateResult(value: unknown): unknown { } } -function updateAgentSkill(output: CommandOutput): { +function updateAgentSkill( + output: CommandOutput, + invocation: string, +): { status: "updated" | "skipped"; result?: unknown; exit_code?: number | null; @@ -113,8 +118,8 @@ function updateAgentSkill(output: CommandOutput): { : undefined; output.warning({ message: { - "en-US": `Agent skill update skipped. Run manually: ${SKILL_UPDATE_CMD}`, - "zh-CN": `已跳过 Agent Skill 更新。请手动运行:${SKILL_UPDATE_CMD}`, + "en-US": `Agent skill update skipped. Run manually: ${formatCommand(invocation, "skill update")}`, + "zh-CN": `已跳过 Agent Skill 更新。请手动运行:${formatCommand(invocation, "skill update")}`, }, code: "SKILL_UPDATE_SKIPPED", details: { exit_code: failure?.status, signal: failure?.signal, error: failure?.diagnostic }, @@ -183,6 +188,7 @@ export default defineCommand({ }, async run(ctx) { const { identity } = ctx; + const skillCommandPrefix = identity.commandInvocation ? formatCommand(identity) : "bl"; const npmPackage = identity.npmPackage; const binName = identity.binName; const currentVersion = identity.version; @@ -251,7 +257,8 @@ export default defineCommand({ "zh-CN": `✓ 当前已是最新版本(${currentVersion})。`, }, ); - const skillUpdate = method === "npm" ? updateAgentSkill(ctx.output) : undefined; + const skillUpdate = + method === "npm" ? updateAgentSkill(ctx.output, skillCommandPrefix) : undefined; ctx.output.result( { status: "up-to-date", @@ -307,7 +314,10 @@ export default defineCommand({ const rawVersion = execSync(`${binName} --version 2>/dev/null`, { encoding: "utf-8", }).trim(); - installedVersion = rawVersion.replace(new RegExp(`^${binName}\\s+`), ""); + const parsedVersion = rawVersion.split(/\s+/).pop()!; + if (!isValidUpdateTargetVersion(parsedVersion)) + throw new Error("Invalid installed version."); + installedVersion = parsedVersion; writeUpdateState(installedVersion); } catch { /* Version lookup has always been best effort. */ @@ -324,7 +334,7 @@ export default defineCommand({ ); } } - const skillUpdate = updateAgentSkill(ctx.output); + const skillUpdate = updateAgentSkill(ctx.output, skillCommandPrefix); ctx.output.result( { status: "updated", diff --git a/packages/commands/src/commands/usage/free.ts b/packages/commands/src/commands/usage/free.ts index 893f25aa..31796b52 100644 --- a/packages/commands/src/commands/usage/free.ts +++ b/packages/commands/src/commands/usage/free.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, findModelByName } from "bailian-cli-core"; import { FREE_TIER_API, @@ -202,7 +203,7 @@ export default defineCommand({ title: `Free Tier Quota · ${quotas.length} models${titleSuffix}`, moreHint: remaining > 0 - ? `+ ${remaining} more · ${identity.binName} usage free --all to browse all` + ? `+ ${remaining} more · ${formatCommand(identity, "usage free --all")} to browse all` : undefined, }) : ctx.localize({ "en-US": "No free-tier quota found.", "zh-CN": "未找到免费额度。" }), diff --git a/packages/commands/src/commands/usage/shared.ts b/packages/commands/src/commands/usage/shared.ts index 3b1cc6df..caa7113d 100644 --- a/packages/commands/src/commands/usage/shared.ts +++ b/packages/commands/src/commands/usage/shared.ts @@ -1,4 +1,5 @@ import { + formatCommand, fetchModelListAll, BailianError, ExitCode, @@ -6,6 +7,7 @@ import { type Client, type Settings, } from "bailian-cli-core"; + import { ansi, renderBoxTable, displayWidth, padEnd } from "bailian-cli-runtime"; import { formatNumber } from "../shared/format.ts"; @@ -29,13 +31,13 @@ export function formatDateTime(ts: number): string { return `${formatDate(ts)} ${hour}:${minute}:${second}`; } -export function requireWorkspaceId(settings: Settings, binName: string): string { +export function requireWorkspaceId(settings: Settings, commandPrefix: string): string { if (settings.workspaceId) return settings.workspaceId; throw new BailianError( - `workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id \`.`, + `workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${formatCommand(commandPrefix, "config set workspace_id ")}\`.`, ExitCode.GENERAL, - `Run \`${binName} workspace list\` to view available workspaces.`, + `Run \`${formatCommand(commandPrefix, "workspace list")}\` to view available workspaces.`, ); } diff --git a/packages/commands/src/commands/usage/stats.ts b/packages/commands/src/commands/usage/stats.ts index 5f34b5f8..c7e0e909 100644 --- a/packages/commands/src/commands/usage/stats.ts +++ b/packages/commands/src/commands/usage/stats.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, BailianError, ExitCode } from "bailian-cli-core"; import { ansi } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; @@ -174,7 +175,7 @@ export default defineCommand({ const typeFlag = flags.type || undefined; const format = settings.output; - const workspaceId = requireWorkspaceId(settings, identity.binName); + const workspaceId = requireWorkspaceId(settings, formatCommand(identity)); const endTime = Date.now(); const startTime = endTime - daysFlag * 24 * 60 * 60 * 1000; diff --git a/packages/commands/src/commands/usage/summary.ts b/packages/commands/src/commands/usage/summary.ts index a730d5a0..0c7d007b 100644 --- a/packages/commands/src/commands/usage/summary.ts +++ b/packages/commands/src/commands/usage/summary.ts @@ -1,3 +1,4 @@ +import { formatCommand } from "bailian-cli-core"; import { defineCommand, BailianError, ExitCode } from "bailian-cli-core"; import { ansi } from "bailian-cli-runtime"; import { @@ -152,7 +153,7 @@ export default defineCommand({ title: `Free Tier Quota · ${quotas.length} models · sorted by urgency`, moreHint: remaining > 0 - ? `+ ${remaining} more · ${identity.binName} usage free to browse all` + ? `+ ${remaining} more · ${formatCommand(identity, "usage free")} to browse all` : undefined, }) + "\n", ); @@ -163,7 +164,7 @@ export default defineCommand({ if (!workspaceId) { textChunks.push( color.dim( - `Usage overview skipped · set --workspace-id or \`${identity.binName} config set workspace_id \` to include it.`, + `Usage overview skipped · set --workspace-id or \`${formatCommand(identity, "config set workspace_id ")}\` to include it.`, ) + "\n", ); } else if (!overview) { diff --git a/packages/commands/tests/command-rendering.test.ts b/packages/commands/tests/command-rendering.test.ts new file mode 100644 index 00000000..c169cc2e --- /dev/null +++ b/packages/commands/tests/command-rendering.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vite-plus/test"; +import { renderConfigUiHtml } from "../src/commands/config/ui-html.ts"; +import { getScenario, renderScenarioPrompt } from "../src/commands/config/scenarios.ts"; +import { BailianError, formatCommand } from "bailian-cli-core"; +import { withPartialSuccessHint } from "../src/commands/knowledge/shared.ts"; + +test.each(["en-US", "zh-CN"] as const)( + "config UI command examples follow the invocation (%s)", + (language) => { + const standalone = renderConfigUiHtml(language); + const hosted = renderConfigUiHtml(language, "aliyun bl"); + expect(hosted).toContain("aliyun bl config ui"); + expect(hosted).toContain("aliyun bl skill init"); + expect(hosted).not.toContain("{{commandPrefix}}"); + expect(hosted.replaceAll("aliyun bl", "bl")).toBe(standalone); + }, +); + +test("wrapped local errors preserve complete strings and literal user data", () => { + const userValue = "{{commandPrefix}}"; + const original = new BailianError( + `Run ${formatCommand("aliyun bl", "managed-agent init")} for '${userValue}'.`, + undefined, + `Run ${formatCommand("aliyun bl", "auth login --console")}.`, + ); + const wrapped = withPartialSuccessHint(original, "indexId: example") as BailianError; + expect(wrapped.message).toBe("Run aliyun bl managed-agent init for '{{commandPrefix}}'."); + expect(wrapped.hint).toBe("Run aliyun bl auth login --console.\nindexId: example"); +}); + +test.each(["en-US", "zh-CN"] as const)( + "curated prompts render commands before user input (%s)", + (language) => { + const scenario = getScenario("image-generate", language, "aliyun bl")!; + expect(scenario.prompt).toContain("aliyun bl image generate"); + expect(scenario.prompt.replaceAll("aliyun bl", "bl")).toBe( + getScenario("image-generate", language)!.prompt, + ); + expect( + renderScenarioPrompt( + { ...scenario, prompt: "{{user}}" }, + { user: "bl user data {{commandPrefix}}" }, + ), + ).toBe("bl user data {{commandPrefix}}"); + }, +); diff --git a/packages/commands/tests/credentials-bridge.test.ts b/packages/commands/tests/credentials-bridge.test.ts index 92cad27a..67c4c863 100644 --- a/packages/commands/tests/credentials-bridge.test.ts +++ b/packages/commands/tests/credentials-bridge.test.ts @@ -1,11 +1,13 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; -import { afterEach, beforeEach, expect, test } from "vite-plus/test"; +import { afterEach, beforeEach, expect, expectTypeOf, test } from "vite-plus/test"; +import { formatCommand } from "bailian-cli-core"; import { type ApiKeyCredential, Client, ExitCode, type Identity, + type CommandFormatter, type Settings, } from "bailian-cli-core"; import { @@ -16,6 +18,8 @@ import { scrubCredentialEnv, } from "../src/commands/managed-agent/_engine/credentials.ts"; +const standaloneCommand = (args = "") => formatCommand("bl", args); + /** * 凭证内存注入管道:injectProviderCredentials 把 authStage 解析进 Client 的凭证 * 权威覆写 bailian 配置块(不落 env),scrubCredentialEnv 清空所有凭证 env, @@ -172,11 +176,15 @@ test("inject:非 bailian provider 块不被触碰", () => { }); test("assert:所有已声明 provider 的 key 非空时通过", () => { + expectTypeOf[1]>().toEqualTypeOf(); expect(() => - assertProviderCredentials({ - bailian: { api_key: "x" }, - claude: { api_key: "y" }, - }), + assertProviderCredentials( + { + bailian: { api_key: "x" }, + claude: { api_key: "y" }, + }, + standaloneCommand, + ), ).not.toThrow(); }); @@ -187,6 +195,7 @@ test("assert:定向检查不会被无关 provider 的空 key 阻塞", () => { bailian: { api_key: "x" }, claude: { api_key: "" }, }, + standaloneCommand, ["bailian"], ), ).not.toThrow(); @@ -195,7 +204,7 @@ test("assert:定向检查不会被无关 provider 的空 key 阻塞", () => { test("assert:claude key 为空抛 AUTH 且 hint 指向 ANTHROPIC_API_KEY", () => { let thrown: unknown; try { - assertProviderCredentials({ claude: { api_key: "" } }); + assertProviderCredentials({ claude: { api_key: "" } }, standaloneCommand); } catch (error) { thrown = error; } @@ -205,17 +214,24 @@ test("assert:claude key 为空抛 AUTH 且 hint 指向 ANTHROPIC_API_KEY", () => expect(err.message).toContain("claude"); }); -test("assert:bailian key 为空(dry-run/未登录)抛 AUTH 且 hint 指向 bl auth login", () => { - let thrown: unknown; - try { - assertProviderCredentials({ bailian: { api_key: "" } }); - } catch (error) { - thrown = error; - } - const err = thrown as { exitCode?: number; hint?: string }; - expect(err.exitCode).toBe(ExitCode.AUTH); - expect(err.hint).toContain("bl auth login"); -}); +test.each(["bl", "aliyun bl"])( + "empty Bailian API key uses the explicit formatter: %s", + (invocation) => { + let thrown: unknown; + try { + assertProviderCredentials({ bailian: { api_key: "" } }, (args) => + formatCommand(invocation, args), + ); + } catch (error) { + thrown = error; + } + const err = thrown as { exitCode?: number; hint?: string }; + expect(err.exitCode).toBe(ExitCode.AUTH); + expect(err.hint).toBe( + `Run \`${invocation} auth login --api-key \`, pass --api-key, or set DASHSCOPE_API_KEY.`, + ); + }, +); test("scrub:所有凭证 env 变量被删除", () => { process.env.DASHSCOPE_API_KEY = "x"; diff --git a/packages/commands/tests/decision.test.ts b/packages/commands/tests/decision.test.ts index c2d5c082..53454900 100644 --- a/packages/commands/tests/decision.test.ts +++ b/packages/commands/tests/decision.test.ts @@ -171,7 +171,7 @@ describe("decision command helpers", () => { }); test("Endpoint:默认按 workspace + region 拼接,显式 Base URL 优先", () => { - const base = { binName: "bl", localize }; + const base = { commandPrefix: "bl", localize }; expect( resolveDecisionEndpoint({ ...base, diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 5c3023e9..3543ab7d 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -148,6 +148,9 @@ describe("e2e: auth", () => { ], { BAILIAN_CONFIG_DIR: configDir, + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }, @@ -1109,6 +1112,9 @@ describe("e2e: auth", () => { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: undefined, DASHSCOPE_BASE_URL: undefined, + BAILIAN_ACCESS_KEY_ID: undefined, + BAILIAN_ACCESS_KEY_SECRET: undefined, + BAILIAN_SECURITY_TOKEN: undefined, ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, ALIBABA_CLOUD_SECURITY_TOKEN: undefined, @@ -1258,6 +1264,9 @@ describe("e2e: auth", () => { const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-login-")); const env = { BAILIAN_CONFIG_DIR: configDir, + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }; diff --git a/packages/commands/tests/e2e/knowledge/knowledge-init-runtime.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-init-runtime.e2e.test.ts index 89e709cc..93cb4bab 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-init-runtime.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-init-runtime.e2e.test.ts @@ -108,6 +108,7 @@ test("confirmed init uses the full runtime and reuses the same resources on a se }, }); expect(payload.cleanup).toContainEqual({ + bin: "bl", path: ["knowledge", "delete"], args: ["--index-id", "index-test", "--workspace-id", "ws-test"], }); diff --git a/packages/commands/tests/e2e/token-plan.e2e.test.ts b/packages/commands/tests/e2e/token-plan.e2e.test.ts index af41f9e1..5f37aa1e 100644 --- a/packages/commands/tests/e2e/token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/token-plan.e2e.test.ts @@ -27,6 +27,9 @@ describe("e2e: token-plan", () => { TOKEN_PLAN_ROUTES, ["token-plan", "list-seats", "--dry-run", "--output", "json"], { + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }, @@ -44,6 +47,9 @@ describe("e2e: token-plan", () => { ["token-plan", "list-seats"], { BAILIAN_CONFIG_DIR: configDir, + BAILIAN_ACCESS_KEY_ID: "", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "", ALIBABA_CLOUD_ACCESS_KEY_ID: "", ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }, diff --git a/packages/commands/tests/knowledge/knowledge-billing.test.ts b/packages/commands/tests/knowledge/knowledge-billing.test.ts index abe229b6..5f0e3ec5 100644 --- a/packages/commands/tests/knowledge/knowledge-billing.test.ts +++ b/packages/commands/tests/knowledge/knowledge-billing.test.ts @@ -106,13 +106,24 @@ test("failed wait retains created resource diagnostics even in quiet mode and pr }); test.each([ - { binName: "bl", path: ["knowledge", "delete"] }, - { binName: "kscli", path: ["kb", "delete"] }, + { binName: "bl", path: ["knowledge", "delete"], commandInvocation: undefined }, + { binName: "kscli", path: ["kb", "delete"], commandInvocation: undefined }, + { + binName: "bl", + path: ["knowledge", "delete"], + commandInvocation: { bin: "aliyun", path: ["bl"] }, + }, ])( "direct create reports the actual $binName cleanup command before a failed wait", - async ({ binName, path }) => { + async ({ binName, path, commandInvocation }) => { const { context, requestJson, stderr } = fixture({ wait: true, quiet: true }); - context.identity = { binName, version: "test", npmPackage: "test", clientName: "test" }; + context.identity = { + binName, + commandInvocation, + version: "test", + npmPackage: "test", + clientName: "test", + }; context.commandPath = (command) => (command === deleteIndex ? path : undefined); const original = new BailianError("original polling failure", ExitCode.GENERAL); requestJson @@ -126,8 +137,8 @@ test.each([ .map((line) => JSON.parse(line)) .find((value) => value.warning?.code === "KNOWLEDGE_RESOURCE_CREATED"); expect(record.warning.details.resource.cleanup.command).toEqual({ - bin: binName, - path, + bin: commandInvocation?.bin ?? binName, + path: [...(commandInvocation?.path ?? []), ...path], args: ["--index-id", "idx-billed", "--workspace-id", "ws-test"], }); expect(JSON.stringify(record.warning.details.resource.cleanup.command)).not.toContain("--yes"); @@ -156,3 +167,25 @@ test("human cleanup instructions quote IDs safely and do not guess a missing rou context.commandPath = () => undefined; expect(reportCreatedKnowledgeBase(context, "ws", "idx").cleanup.command).toBeUndefined(); }); + +test("host cleanup text and structured command use the same executable invocation", () => { + const { context, stderr } = fixture({ format: "text" }); + context.identity = { + binName: "bl", + commandInvocation: { bin: "aliyun", path: ["bl"] }, + version: "test", + npmPackage: "test", + clientName: "test", + }; + context.commandPath = () => ["knowledge", "delete"]; + const resource = reportCreatedKnowledgeBase(context, "ws space", "idx'quoted"); + expect(resource.cleanup.command).toEqual({ + bin: "aliyun", + path: ["bl", "knowledge", "delete"], + args: ["--index-id", "idx'quoted", "--workspace-id", "ws space"], + }); + expect(resource.cleanup.requiresConfirmation).toBe(true); + expect(stderr.join("")).toContain( + "aliyun bl knowledge delete --index-id 'idx'\\''quoted' --workspace-id 'ws space'", + ); +}); diff --git a/packages/commands/tests/knowledge/knowledge-init-command.test.ts b/packages/commands/tests/knowledge/knowledge-init-command.test.ts index 7af1f0d8..8572cbb5 100644 --- a/packages/commands/tests/knowledge/knowledge-init-command.test.ts +++ b/packages/commands/tests/knowledge/knowledge-init-command.test.ts @@ -64,10 +64,22 @@ function runCommand(context: Parameters[0]) { return command.run({ ...context, output }); } -test.each(["kscli", "bl"])( - "success includes product-correct cleanup only for newly created resources: %s", - async (binName) => { +const invocations = [ + { binName: "kscli", commandInvocation: undefined, bin: "kscli", prefix: [] }, + { binName: "bl", commandInvocation: undefined, bin: "bl", prefix: [] }, + { + binName: "bl", + commandInvocation: { bin: "aliyun", path: ["bl"] }, + bin: "aliyun", + prefix: ["bl"], + }, +]; + +test.each(invocations)( + "success includes executable cleanup only for newly created resources: $bin", + async ({ binName, commandInvocation, bin, prefix }) => { const { context, stdout } = fixture(binName); + context.identity.commandInvocation = commandInvocation; const resources = [ { kind: "knowledge-base", id: "index-1", created: true }, { kind: "service", id: "agent-1", created: false }, @@ -88,36 +100,98 @@ test.each(["kscli", "bl"])( expect(output.sampleSearch).toEqual(sampleSearch); expect(output.cleanup).toEqual([ { - path: binName === "kscli" ? ["kb", "delete"] : ["knowledge", "delete"], + bin, + path: [...prefix, ...(binName === "kscli" ? ["kb", "delete"] : ["knowledge", "delete"])], args: ["--index-id", "index-1", "--workspace-id", "ws-test"], }, ]); - expect(output.nextSearch.path).toEqual( - binName === "kscli" ? ["search"] : ["knowledge", "search"], - ); + expect(output.nextSearch.bin).toBe(bin); + expect(output.nextSearch.path).toEqual([ + ...prefix, + ...(binName === "kscli" ? ["search"] : ["knowledge", "search"]), + ]); expect(output.nextSearch.args).toContain("beta"); + expect(output.cleanup[0].args).not.toContain("--yes"); expect(output.notices[0].message).toContain("720"); }, ); -test("failure after creation prints the billable ID and cleanup even in quiet mode", async () => { - const { context, stderr, stdout } = fixture(); - const failure = new Error("raw failure"); - vi.spyOn(workflow, "runKnowledgeInit").mockImplementation(async (options) => { - options.report({ kind: "knowledge-base", id: "billable-id", created: true }); - throw failure; +test.each(invocations)( + "failure preserves executable cleanup in quiet mode: $bin", + async ({ binName, commandInvocation, bin, prefix }) => { + const { context, stderr, stdout } = fixture(binName); + context.identity.commandInvocation = commandInvocation; + const failure = new Error("raw failure"); + vi.spyOn(workflow, "runKnowledgeInit").mockImplementation(async (options) => { + options.report({ kind: "knowledge-base", id: "billable-id", created: true }); + throw failure; + }); + await expect(runCommand(context)).rejects.toBe(failure); + const warning = JSON.parse( + stderr.mock.calls + .map(([text]) => text) + .join("") + .trim(), + ); + expect(warning.warning.details.resource).toMatchObject({ id: "billable-id", created: true }); + expect(warning.warning.details.cleanup[0].args).toContain("billable-id"); + expect(warning.warning.details.cleanup[0]).toMatchObject({ + bin, + path: [...prefix, ...(binName === "kscli" ? ["kb", "delete"] : ["knowledge", "delete"])], + }); + expect(warning.warning.details.cleanup[0].args).not.toContain("--yes"); + expect(warning.warning.message).toContain("charges"); + expect(stdout).not.toHaveBeenCalled(); + }, +); + +test("hosted text renders the same executable cleanup and search as JSON, with quoted values", async () => { + const { context, stdout } = fixture("bl"); + context.identity.commandInvocation = { bin: "aliyun", path: ["bl"] }; + const indexId = "index space;$(literal)"; + vi.spyOn(workflow, "runKnowledgeInit").mockResolvedValue({ + indexId, + agentId: "agent-1", + fileId: "file-1", + agentVersion: "beta", + sampleMatched: true, + sampleSearch, + resources: [ + { kind: "knowledge-base", id: indexId, created: true }, + { kind: "file", id: "file-1", created: true }, + { kind: "service", id: "agent-1", created: true }, + ], + stateFile: "state.json", + timeToFirstValueMs: 1, }); - await expect(runCommand(context)).rejects.toBe(failure); - const warning = JSON.parse( - stderr.mock.calls - .map(([text]) => text) - .join("") - .trim(), + await runCommand(context); + const json = JSON.parse(stdout.mock.calls.map(([text]) => text).join("")); + expect( + [...json.cleanup, json.nextSearch].every((action: { bin: string }) => action.bin === "aliyun"), + ).toBe(true); + expect(json.cleanup.map((action: { args: string[] }) => action.args[0])).toEqual([ + "--agent-id", + "--index-id", + "--file-id", + ]); + expect(json.cleanup[1].args[1]).toBe(indexId); + stdout.mockClear(); + context.settings.output = "text"; + await runCommand(context); + const text = stdout.mock.calls.map(([chunk]) => chunk).join(""); + expect(text.split("\n")).toContain( + "aliyun bl knowledge service delete --agent-id agent-1 --workspace-id ws-test", + ); + expect(text.split("\n")).toContain( + "aliyun bl knowledge delete --index-id 'index space;$(literal)' --workspace-id ws-test", + ); + expect(text.split("\n")).toContain( + "aliyun bl knowledge file delete --file-id file-1 --workspace-id ws-test", + ); + expect(text.split("\n")).toContain( + "aliyun bl knowledge search --agent-id agent-1 --agent-version beta --workspace-id ws-test --query 'What is RAG?'", ); - expect(warning.warning.details.resource).toMatchObject({ id: "billable-id", created: true }); - expect(warning.warning.details.cleanup[0].args).toContain("billable-id"); - expect(warning.warning.message).toContain("charges"); - expect(stdout).not.toHaveBeenCalled(); + expect(text).not.toContain("aliyun bl bl"); }); test("missing product route omits the command instead of inventing one", async () => { diff --git a/packages/commands/tests/managed-agent-create.test.ts b/packages/commands/tests/managed-agent-create.test.ts index 247459f0..48c4c193 100644 --- a/packages/commands/tests/managed-agent-create.test.ts +++ b/packages/commands/tests/managed-agent-create.test.ts @@ -2,7 +2,8 @@ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildAgentDecl, StateManager } from "@openagentpack/sdk"; -import { expect, test } from "vite-plus/test"; +import { expect, expectTypeOf, test } from "vite-plus/test"; +import { formatCommand, type CommandFormatter } from "bailian-cli-core"; import { buildAgentMcpBindings, buildAgentSkillRefs, @@ -116,6 +117,9 @@ test("MCP server code 去重并默认启用全部工具", () => { }); test("本地 Skill 来源复用同声明的待创建或已跟踪 key", () => { + expectTypeOf< + Parameters[0]["command"] + >().toEqualTypeOf(); const declaration = { name: "local-report", source: "skills/local-report.zip", @@ -125,6 +129,7 @@ test("本地 Skill 来源复用同声明的待创建或已跟踪 key", () => { const pendingState = StateManager.initialize("/tmp/bailian-cli-local-skill-pending.json"); expect( selectLocalSkillKey({ + command: (args) => formatCommand("bl", args), displayName: "local-report", provider: "bailian", declarations: { "local-report": declaration }, @@ -146,6 +151,7 @@ test("本地 Skill 来源复用同声明的待创建或已跟踪 key", () => { }); expect( selectLocalSkillKey({ + command: (args) => formatCommand("bl", args), displayName: "local-report", provider: "bailian", declarations: { "local-report": declaration }, @@ -160,6 +166,7 @@ test("本地 Skill 来源复用同声明的待创建或已跟踪 key", () => { }); expect(() => selectLocalSkillKey({ + command: (args) => formatCommand("bl", args), displayName: "local-report", provider: "bailian", declarations: { "local-report": declaration }, @@ -169,6 +176,34 @@ test("本地 Skill 来源复用同声明的待创建或已跟踪 key", () => { ).toThrow(/already tracked with a different declaration/); }); +test.each(["bl", "aliyun bl"])( + "local Skill conflict uses the explicit formatter: %s", + (invocation) => { + const state = StateManager.initialize("/tmp/bailian-cli-local-skill-conflict.json"); + state.setResource({ + address: { type: "skill", name: "local-report", provider: "bailian" }, + remote_id: "skill_local_report", + content_hash: "hash", + }); + let failure: unknown; + try { + selectLocalSkillKey({ + command: (args) => formatCommand(invocation, args), + displayName: "local-report", + provider: "bailian", + declarations: { "local-report": { name: "local-report", source: "old.zip" } }, + candidate: { name: "local-report", source: "new.zip" }, + state, + }); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + hint: `Modify its existing YAML declaration and run full \`${invocation} managed-agent apply\`, or bind its remote ID with --skill.`, + }); + }, +); + test("YAML 替换校验原内容并保留文件权限", async () => { const directory = await mkdtemp(join(tmpdir(), "bailian-agent-create-")); const configPath = join(directory, "agents.yaml"); diff --git a/packages/commands/tests/managed-agent-errors.test.ts b/packages/commands/tests/managed-agent-errors.test.ts index c9ed4d4c..62427b55 100644 --- a/packages/commands/tests/managed-agent-errors.test.ts +++ b/packages/commands/tests/managed-agent-errors.test.ts @@ -1,5 +1,5 @@ import { UserError } from "@openagentpack/sdk"; -import { BailianError, ExitCode } from "bailian-cli-core"; +import { BailianError, ExitCode, formatCommand } from "bailian-cli-core"; import { expect, test } from "vite-plus/test"; import { formatAgentDiagnosticFailure, @@ -51,7 +51,7 @@ test("SDK polling-timeout UserError maps to TIMEOUT (5), not USAGE", async () => ); expect(mapped.exitCode).toBe(ExitCode.TIMEOUT); expect(mapped.message).toBe("Session did not complete within the timeout (600 seconds)."); - expect(mapped.hint).toMatch(/session get/); + expect(mapped.hint).toBeUndefined(); }); test("提及 timeout 但非轮询超时句式的 UserError 仍归 USAGE", async () => { @@ -162,3 +162,39 @@ test("retaining an Agent error adds a hint without losing structured context", ( expect(retained.rawResponse).toBe("raw provider response"); expect(retained.cause).toBe(cause); }); + +test.each(["bl", "aliyun bl"])( + "SDK timeout hints are complete strings (%s)", + async (invocation) => { + const timeoutHint = `The session may still be running — check \`${formatCommand(invocation, "managed-agent session get --session-id ")}\` or \`session events\`.`; + let failure: BailianError | undefined; + try { + await withAgentErrors( + () => + Promise.reject( + new UserError("Session did not complete within the timeout (600 seconds)."), + ), + timeoutHint, + ); + } catch (error) { + expect(error).toBeInstanceOf(BailianError); + failure = error as BailianError; + } + expect(failure).toBeDefined(); + expect(failure!.hint).toBe( + `The session may still be running — check \`${invocation} managed-agent session get --session-id \` or \`session events\`.`, + ); + expect(JSON.stringify(failure)).not.toContain("{{commandPrefix}}"); + }, +); + +test("polling timeout preserves a supplied complete hint verbatim", async () => { + const hint = "User-selected {{commandPrefix}} text, without a rendering protocol."; + await expect( + withAgentErrors( + () => + Promise.reject(new UserError("Session did not complete within the timeout (600 seconds).")), + hint, + ), + ).rejects.toMatchObject({ exitCode: ExitCode.TIMEOUT, hint }); +}); diff --git a/packages/commands/tests/output/knowledge-workflow-output.test.ts b/packages/commands/tests/output/knowledge-workflow-output.test.ts index 034fcf67..8fabcbff 100644 --- a/packages/commands/tests/output/knowledge-workflow-output.test.ts +++ b/packages/commands/tests/output/knowledge-workflow-output.test.ts @@ -338,6 +338,8 @@ test.each(["bl", "kscli"] as const)( const result = await invoke("init", { format: "json", quiet: true, binName }); expect(result.exitCode, result.stderr).toBe(0); const data = JSON.parse(result.stdout); + expect(data.cleanup.every((action: { bin: string }) => action.bin === binName)).toBe(true); + expect(data.nextSearch.bin).toBe(binName); expect(data.cleanup.map((action: { path: string[] }) => action.path)).toEqual( binName === "bl" ? [ @@ -372,6 +374,7 @@ test.each([false, true])( resource: { kind: "knowledge-base", id: "index-one", created: true }, cleanup: [ { + bin: "bl", path: ["knowledge", "delete"], args: ["--index-id", "index-one", "--workspace-id", "ws-test"], }, diff --git a/packages/commands/tests/update-binary-skill-sync.test.ts b/packages/commands/tests/update-binary-skill-sync.test.ts index 07442b00..f3dcb79b 100644 --- a/packages/commands/tests/update-binary-skill-sync.test.ts +++ b/packages/commands/tests/update-binary-skill-sync.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; @@ -67,6 +67,70 @@ const identity = { version: "1.14.3", }; +test.each([undefined, "aliyun bl"])( + "manual skill recovery uses its real entrypoint or host override %j", + async (commandPrefix) => { + childProcessMocks.spawnSync.mockReturnValueOnce({ + status: 1, + signal: null, + stdout: Buffer.alloc(0), + stderr: Buffer.from('{"error":{"message":"fixture failure"}}\n'), + }); + const context = withCommandOutput({ + identity: { + ...identity, + binName: "kscli", + ...(commandPrefix ? { commandInvocation: { bin: "aliyun", path: ["bl"] } } : {}), + }, + flags: { to: "1.15.0" }, + settings: buildSettings({ flags: { output: "json" }, file: {}, env: {} }), + }); + vi.spyOn(context.output, "result").mockImplementation(() => {}); + const warnings = vi.spyOn(context.output, "warning").mockImplementation(() => {}); + await updateCommand.run(context as never); + const skipped = warnings.mock.calls + .map(([warning]) => warning) + .find( + (warning) => + typeof warning === "object" && + "code" in warning && + warning.code === "SKILL_UPDATE_SKIPPED", + ); + expect(JSON.stringify(skipped)).toContain( + `Run manually: ${commandPrefix ?? "bl"} skill update`, + ); + expectSkillUpdateNotInit(); + }, +); + +test.each([ + ["aliyun bl 1.15.0\n", "1.15.0"], + ["bl 1.15.0\n", "1.15.0"], + ["aliyun bl invalid\n", undefined], +] as const)( + "npm version lookup caches only a valid version from %j", + async (versionOutput, expected) => { + process.env.BAILIAN_INSTALL_METHOD = "npm"; + childProcessMocks.execSync.mockReturnValueOnce(versionOutput); + const context = withCommandOutput({ + identity: { ...identity, commandInvocation: { bin: "aliyun", path: ["bl"] } }, + flags: { to: "1.15.0" }, + settings: buildSettings({ flags: { output: "json" }, file: {}, env: {} }), + }); + const result = vi.spyOn(context.output, "result").mockImplementation(() => {}); + await updateCommand.run(context as never); + expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl --version 2>/dev/null", { + encoding: "utf-8", + }); + expect(result.mock.calls[0]?.[0]).toHaveProperty("status", "updated"); + const payload = result.mock.calls[0]?.[0] as { version?: string } | undefined; + expect(payload?.version).toBe(expected); + const state = join(configDir, "update-state.json"); + if (expected) expect(JSON.parse(readFileSync(state, "utf8")).latestVersion).toBe(expected); + else expect(existsSync(state)).toBe(false); + }, +); + function expectSkillUpdateNotInit(): void { expect(childProcessMocks.spawnSync).toHaveBeenCalledWith("bl skill update --output json", { shell: true, diff --git a/packages/core/src/auth/refresh-token.ts b/packages/core/src/auth/refresh-token.ts index 2626d779..2f30239b 100644 --- a/packages/core/src/auth/refresh-token.ts +++ b/packages/core/src/auth/refresh-token.ts @@ -5,6 +5,7 @@ import { ExitCode } from "../errors/codes.ts"; import { callConsoleGateway, type ConsoleGatewayTarget } from "../console/gateway.ts"; import { readConfigFile, writeConfigFile } from "../config/loader.ts"; import { Client } from "../client/client.ts"; +import type { OpenApiCredential } from "./types.ts"; const API_VERSION = "2026-02-10"; const API_ACTION = "GenerateCLIAccessToken"; @@ -111,7 +112,7 @@ export async function generateCLIAccessToken(opts: { } /** - * Try to refresh the console access_token using stored AK/SK. + * Obtain a Console token using resolved AK/SK, or selected-config AK/SK for legacy callers. * Returns the new token on success, or null if AK/SK are not available. */ export async function refreshAccessToken(opts: { @@ -121,11 +122,15 @@ export async function refreshAccessToken(opts: { baseUrl: string; /** 重试原请求作为验证;独立调用时默认通过只读 loginInfo 校验。 */ validateToken?: (token: string) => Promise; + /** Already selected by the shared credential chain; legacy callers use the selected config. */ + credential?: OpenApiCredential; + /** Defaults to persistence only for local config credentials; temporary credentials stay in memory. */ + persist?: boolean; }): Promise { const configName = opts.settings.configName; - const config = readConfigFile(configName); - const accessKeyId = config.access_key_id; - const accessKeySecret = config.access_key_secret; + const config = opts.credential ? undefined : readConfigFile(configName); + const accessKeyId = opts.credential?.accessKeyId ?? config?.access_key_id; + const accessKeySecret = opts.credential?.accessKeySecret ?? config?.access_key_secret; if (!accessKeyId || !accessKeySecret) return null; opts.debug?.({ message: { "en-US": "Refreshing access token", "zh-CN": "正在刷新访问令牌" } }); @@ -137,7 +142,7 @@ export async function refreshAccessToken(opts: { baseUrl: opts.baseUrl, accessKeyId, accessKeySecret, - securityToken: config.security_token, + securityToken: opts.credential ? opts.credential.securityToken : config?.security_token, }); const token = resp.cliAccessToken; @@ -151,9 +156,15 @@ export async function refreshAccessToken(opts: { ); } + const persist = opts.persist ?? (!opts.credential || opts.credential.source === "config"); + if (persist) await persistConsoleAccessToken(token, configName); + + return token; +} + +/** Called only after a successful Console validation or business request. */ +export async function persistConsoleAccessToken(token: string, configName?: string): Promise { const existing = readConfigFile(configName) as Record; existing.access_token = token; await writeConfigFile(existing, configName); - - return token; } diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts index 110fa773..caccf138 100644 --- a/packages/core/src/auth/resolver.ts +++ b/packages/core/src/auth/resolver.ts @@ -9,8 +9,7 @@ import type { OpenApiCredential, AuthState, } from "./types.ts"; -import { BailianError } from "../errors/base.ts"; -import { ExitCode } from "../errors/codes.ts"; +import { CredentialError } from "../errors/credential.ts"; // Resolve the credential for a command's declared domain (model = api-key, // console = access-token), by priority, or throw. Read only from sources. @@ -80,10 +79,10 @@ export function resolveApiKey(s: ResolutionSources): ApiKeyCredential { const envKey = s.env.DASHSCOPE_API_KEY?.trim(); if (envKey) return { token: envKey, baseUrl, source: "env" }; if (s.file.api_key) return { token: s.file.api_key, baseUrl, source: "config" }; - throw new BailianError( + throw new CredentialError( + "missing-api-key", "No API key found.", - ExitCode.AUTH, - "Set DASHSCOPE_API_KEY, pass --api-key, or run `bl auth login`.", + "Set DASHSCOPE_API_KEY or pass --api-key.", ); } @@ -91,11 +90,7 @@ export function resolveApiKey(s: ResolutionSources): ApiKeyCredential { export function resolveConsole(s: ResolutionSources): ConsoleCredential { const token = s.file.access_token?.trim(); if (!token) { - throw new BailianError( - "No console access token found.", - ExitCode.AUTH, - "Run `bl auth login --console`.", - ); + throw new CredentialError("missing-console-token", "No console access token found."); } return { token, @@ -106,7 +101,7 @@ export function resolveConsole(s: ResolutionSources): ConsoleCredential { }; } -/** Alibaba Cloud OpenAPI AK/SK credential. Priority: flags > env > config. */ +/** AK/SK priority: flags > Bailian env > selected config > Alibaba Cloud env. */ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential { const flagCred = resolveOpenApiPair( "flag", @@ -119,13 +114,13 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential { const envCred = resolveOpenApiPair( "env", - s.env.ALIBABA_CLOUD_ACCESS_KEY_ID, - s.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET, + s.env.BAILIAN_ACCESS_KEY_ID, + s.env.BAILIAN_ACCESS_KEY_SECRET, Boolean( - trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_ID) || - trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET), + trimNonEmpty(s.env.BAILIAN_ACCESS_KEY_ID) || trimNonEmpty(s.env.BAILIAN_ACCESS_KEY_SECRET), ), - s.env.ALIBABA_CLOUD_SECURITY_TOKEN, + s.env.BAILIAN_SECURITY_TOKEN, + "BAILIAN", ); if (envCred) return envCred; @@ -138,10 +133,22 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential { ); if (configCred) return configCred; - throw new BailianError( + const aliyunCred = resolveOpenApiPair( + "env", + s.env.ALIBABA_CLOUD_ACCESS_KEY_ID, + s.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET, + Boolean( + trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_ID) || + trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET), + ), + s.env.ALIBABA_CLOUD_SECURITY_TOKEN, + ); + if (aliyunCred) return aliyunCred; + + throw new CredentialError( + "missing-openapi-pair", "No OpenAPI AK/SK credentials found.", - ExitCode.AUTH, - "Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET, pass --access-key-id and --access-key-secret, or run `bl auth login --open-api`.", + "Set BAILIAN_ACCESS_KEY_ID and BAILIAN_ACCESS_KEY_SECRET (or ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET), or pass --access-key-id and --access-key-secret.", ); } @@ -151,6 +158,7 @@ function resolveOpenApiPair( rawAccessKeySecret: string | undefined, provided: boolean, rawSecurityToken?: string, + envPrefix = "ALIBABA_CLOUD", ): OpenApiCredential | undefined { if (!provided) return undefined; @@ -158,10 +166,10 @@ function resolveOpenApiPair( const accessKeySecret = trimNonEmpty(rawAccessKeySecret); if (!accessKeyId || !accessKeySecret) { - throw new BailianError( + throw new CredentialError( + `incomplete-openapi-${source}`, "Incomplete OpenAPI AK/SK credentials found.", - ExitCode.AUTH, - openApiPairHint(source), + openApiPairHint(source, envPrefix), ); } @@ -174,14 +182,14 @@ function trimNonEmpty(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } -function openApiPairHint(source: OpenApiCredential["source"]): string { +function openApiPairHint(source: OpenApiCredential["source"], envPrefix: string): string { if (source === "flag") { return "Pass both --access-key-id and --access-key-secret, or remove the partial flags to use env/config credentials."; } if (source === "env") { - return "Set both ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET, or unset the partial env vars to use config credentials."; + return `Set both ${envPrefix}_ACCESS_KEY_ID and ${envPrefix}_ACCESS_KEY_SECRET, or unset the partial env vars.`; } - return "Run `bl auth login --open-api --access-key-id --access-key-secret ` again to save a complete pair."; + return "Save a complete AccessKey ID and secret pair in the selected configuration."; } /** Full auth snapshot from sources — what would resolve per domain (or undefined). */ diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index f87003ba..8e4ca153 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -13,8 +13,7 @@ import { type McpConnectedClient, } from "./mcp.ts"; import { SdkMcpAdapter } from "./mcp-sdk.ts"; -import { callConsoleGateway } from "../console/gateway.ts"; -import { refreshAccessToken } from "../auth/refresh-token.ts"; +import { ConsoleSession } from "./console-session.ts"; import { debugRequest, debugResponse } from "./diagnostics.ts"; import { getApiKeyLoginKind } from "../config/profile-presets.ts"; import { trackingHeaders } from "./headers.ts"; @@ -30,6 +29,10 @@ export interface ClientDeps { baseUrlIsDefault?: boolean; apiCred?: ApiKeyCredential; consoleCred?: ConsoleCredential; + /** Lazily resolve AK/SK only when the Console token is missing or expired. */ + resolveConsoleOpenApiCredential?: () => OpenApiCredential; + /** Optional host-provided recovery text; core does not construct CLI instructions. */ + consoleRecoveryHint?: string; openApiCred?: OpenApiCredential; } @@ -69,11 +72,15 @@ export interface OpenApiResponse { * credential and base URL are already baked in, so commands never handle tokens * or baseUrl. Model methods (`request`/`requestJson`/`mcp`) need an api key; * `uploadFile` only needs one for local files, and passes URLs through without - * credentials. `console` needs a console token; calling one without its - * credential throws. + * credentials. `console` uses a Console token, obtaining one lazily from + * resolved AK/SK when needed. Concurrent calls share the exchange, not results. */ export class Client { - constructor(private readonly deps: ClientDeps) {} + private consoleSession: ConsoleSession; + + constructor(private readonly deps: ClientDeps) { + this.consoleSession = new ConsoleSession(deps); + } private get http(): HttpDeps { return { identity: this.deps.identity, settings: this.deps.settings, debug: this.deps.debug }; @@ -242,53 +249,16 @@ export class Client { /** 创建仅本次调用链使用的 Console 身份;不修改原 Client、Settings 或持久化配置。 */ withConsoleSwitchAgent(switchAgent: number): Client { - return new Client({ + const client = new Client({ ...this.deps, settings: { ...this.deps.settings, consoleSwitchAgent: switchAgent }, - consoleCred: this.deps.consoleCred ? { ...this.deps.consoleCred, switchAgent } : undefined, }); + client.consoleSession = this.consoleSession.withSwitchAgent(switchAgent); + return client; } - async console(api: string, data: Record): Promise { - if (!this.deps.consoleCred) { - throw new BailianError("This command needs a console access token.", ExitCode.AUTH); - } - const gwOpts = { api, data }; - const { timeout } = this.deps.settings; - try { - return (await callConsoleGateway(this.deps.consoleCred, timeout, gwOpts, { - debug: this.deps.debug, - })) as T; - } catch (err) { - if ( - !(err instanceof BailianError) || - err.exitCode !== ExitCode.AUTH || - !err.message.includes("not logged in") - ) { - throw err; - } - const credential = this.deps.consoleCred; - let retryResult: unknown; - const newToken = await refreshAccessToken({ - identity: this.deps.identity, - settings: { - ...this.deps.settings, - consoleRegion: credential.region, - consoleSite: credential.site, - consoleSwitchAgent: credential.switchAgent, - }, - debug: this.deps.debug, - baseUrl: this.deps.baseUrl, - validateToken: async (token) => { - retryResult = await callConsoleGateway({ ...credential, token }, timeout, gwOpts, { - debug: this.deps.debug, - }); - }, - }); - if (!newToken) throw err; - this.deps.consoleCred = { ...credential, token: newToken }; - return retryResult as T; - } + console(api: string, data: Record): Promise { + return this.consoleSession.request(api, data); } openApiQueryJson(opts: ClientOpenApiQueryOpts): Promise { diff --git a/packages/core/src/client/console-session.ts b/packages/core/src/client/console-session.ts new file mode 100644 index 00000000..1ab6e85e --- /dev/null +++ b/packages/core/src/client/console-session.ts @@ -0,0 +1,161 @@ +import type { Identity, Settings } from "../config/schema.ts"; +import type { ConsoleCredential, OpenApiCredential } from "../auth/types.ts"; +import type { HttpDeps } from "./http.ts"; +import { BailianError } from "../errors/base.ts"; +import { CredentialError } from "../errors/credential.ts"; +import { ExitCode } from "../errors/codes.ts"; +import { callConsoleGateway } from "../console/gateway.ts"; +import { + persistConsoleAccessToken, + refreshAccessToken, + resolveCLIAccessTokenTarget, +} from "../auth/refresh-token.ts"; + +interface ConsoleSessionOptions { + identity: Identity; + settings: Settings; + baseUrl: string; + debug?: HttpDeps["debug"]; + consoleCred?: ConsoleCredential; + resolveConsoleOpenApiCredential?: () => OpenApiCredential; + consoleRecoveryHint?: string; +} + +interface ConsoleTokenExchange { + credential: ConsoleCredential; + persist: boolean; +} + +interface ConsoleTokenRefresh { + previous?: ConsoleCredential; + promise: Promise; + users: number; + commit?: Promise; +} + +/** Internal Console credential lifecycle; each instance owns its cache and in-flight exchange. */ +export class ConsoleSession { + private consoleTokenPersisted: boolean; + private consoleRefresh?: ConsoleTokenRefresh; + + constructor(private readonly deps: ConsoleSessionOptions) { + this.consoleTokenPersisted = deps.consoleCred?.source === "config"; + } + + /** 创建仅本次调用链使用的 Console 身份;不修改原 Client、Settings 或持久化配置。 */ + withSwitchAgent(switchAgent: number): ConsoleSession { + const session = new ConsoleSession({ + ...this.deps, + settings: { ...this.deps.settings, consoleSwitchAgent: switchAgent }, + consoleCred: this.deps.consoleCred ? { ...this.deps.consoleCred, switchAgent } : undefined, + }); + session.consoleTokenPersisted = this.consoleTokenPersisted; + return session; + } + + async request(api: string, data: Record): Promise { + const credential = this.deps.consoleCred; + if (!credential) { + const failure = new BailianError("This command needs a console access token.", ExitCode.AUTH); + if (!this.deps.resolveConsoleOpenApiCredential) throw failure; + return this.refreshConsole(api, data, failure); + } + try { + return await this.requestConsole(credential, api, data); + } catch (error) { + if (!(error instanceof CredentialError) || error.reason !== "expired-console-session") + throw error; + return this.refreshConsole(api, data, error, credential); + } + } + + private async requestConsole( + credential: ConsoleCredential, + api: string, + data: Record, + ): Promise { + return (await callConsoleGateway( + credential, + this.deps.settings.timeout, + { api, data }, + { + debug: this.deps.debug, + recoveryHint: this.deps.consoleRecoveryHint, + }, + )) as T; + } + + /** Share issuance only; every caller retries and validates its own business request. */ + private async refreshConsole( + api: string, + data: Record, + failure: BailianError, + previous?: ConsoleCredential, + ): Promise { + if (this.deps.consoleCred && this.deps.consoleCred !== previous) + return this.requestConsole(this.deps.consoleCred, api, data); + + let refresh = this.consoleRefresh; + if (!refresh || refresh.previous !== previous) { + refresh = { previous, promise: this.exchangeConsoleToken(previous), users: 0 }; + this.consoleRefresh = refresh; + } + refresh.users++; + try { + const exchange = await refresh.promise.catch((error: unknown) => { + if (error instanceof CredentialError && error.reason === "missing-openapi-pair") + throw failure; + throw error; + }); + if (!exchange) throw failure; + const result = await this.requestConsole(exchange.credential, api, data); + refresh.commit ??= (async () => { + if (this.deps.consoleCred !== previous) return; + if (exchange.persist) + await persistConsoleAccessToken(exchange.credential.token, this.deps.settings.configName); + this.consoleTokenPersisted = exchange.persist; + this.deps.consoleCred = exchange.credential; + })(); + await refresh.commit; + return result; + } finally { + refresh.users--; + if (refresh.users === 0 && this.consoleRefresh === refresh) this.consoleRefresh = undefined; + } + } + + private async exchangeConsoleToken( + previous?: ConsoleCredential, + ): Promise { + const credential = this.deps.resolveConsoleOpenApiCredential?.(); + const target = previous ?? resolveCLIAccessTokenTarget(this.deps.settings, this.deps.baseUrl); + const persist = + this.consoleTokenPersisted && + previous?.source === "config" && + (!credential || credential.source === "config"); + const token = await refreshAccessToken({ + identity: this.deps.identity, + settings: { + ...this.deps.settings, + consoleRegion: target.region, + consoleSite: target.site, + consoleSwitchAgent: target.switchAgent, + }, + debug: this.deps.debug, + baseUrl: this.deps.baseUrl, + credential, + persist: false, + // Each waiting caller validates by executing its own business request. + validateToken: async () => {}, + }); + if (!token) return null; + return { + credential: { + ...target, + token, + source: credential?.source ?? previous?.source ?? "config", + }, + persist, + }; + } +} diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 94cbf622..1fe8362b 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -203,6 +203,8 @@ export function parseConfigFile(raw: unknown): ConfigFile { export interface Identity { /** Product binary name, e.g. "bl", "rag". */ binName: string; + /** Executable and fixed host subcommands; display text is derived from this structure. */ + commandInvocation?: { readonly bin: string; readonly path: readonly string[] }; version: string; /** npm package name for self-update, e.g. "bailian-cli". */ npmPackage: string; diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index 1954400b..7a7e0bdc 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -1,3 +1,4 @@ +import { CredentialError } from "../errors/credential.ts"; import { debugRequest, debugResponse } from "../client/diagnostics.ts"; import type { HttpDeps } from "../client/http.ts"; import { httpTransport } from "../client/transport.ts"; @@ -75,7 +76,7 @@ export type ConsoleCall = (api: string, data: Record) => Promis */ export function anonymousConsoleCall( config: Pick, - diagnostics: Pick = {}, + options: Pick & { recoveryHint?: string } = {}, ): ConsoleCall { const eff = effectiveConsoleGatewayConfig(config); return (api, data) => @@ -83,7 +84,7 @@ export function anonymousConsoleCall( { region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent }, config.timeout, { api, data }, - diagnostics, + options, ); } @@ -128,7 +129,7 @@ export async function callConsoleGateway( target: ConsoleGatewayTarget, timeoutSec: number, { api, data }: ConsoleGatewayRequest, - diagnostics: Pick = {}, + options: Pick & { recoveryHint?: string } = {}, ): Promise { const resolved = resolveGateway(target.region, target.site); const gatewayBase = `https://${resolved.csGateway}`; @@ -145,7 +146,7 @@ export async function callConsoleGateway( if (target.token) headers.Authorization = `Bearer ${target.token}`; const endpoint = `${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`; - debugRequest(diagnostics, "POST", endpoint, { api, region: target.region }); + debugRequest(options, "POST", endpoint, { api, region: target.region }); const res = await httpTransport.fetch(endpoint, { method: "POST", @@ -154,7 +155,7 @@ export async function callConsoleGateway( signal: AbortSignal.timeout(timeoutMs), }); - debugResponse(diagnostics, res); + debugResponse(options, res); if (!res.ok) { const t = await res.text().catch(() => ""); @@ -174,16 +175,17 @@ export async function callConsoleGateway( const errorCode = typeof rawErrorCode === "string" ? rawErrorCode : JSON.stringify(rawErrorCode); const notLogined = errorCode.includes("NotLogined"); - throw new BailianError( - notLogined - ? "Console session is not logged in or has expired." - : `Console gateway error: ${errorCode}`, - notLogined ? ExitCode.AUTH : ExitCode.GENERAL, - notLogined - ? "Run `bl auth login --console` to sign in or refresh your console session." - : undefined, - { rawResponse }, - ); + if (notLogined) { + throw new CredentialError( + "expired-console-session", + "Console session is not logged in or has expired.", + options.recoveryHint, + { rawResponse }, + ); + } + throw new BailianError(`Console gateway error: ${errorCode}`, ExitCode.GENERAL, undefined, { + rawResponse, + }); } return json; diff --git a/packages/core/src/deploy/plans.ts b/packages/core/src/deploy/plans.ts index 7565d03a..afce76bf 100644 --- a/packages/core/src/deploy/plans.ts +++ b/packages/core/src/deploy/plans.ts @@ -45,7 +45,8 @@ export interface PlanContext { /** True in --dry-run: strategies must skip side-effecting catalog lookups. */ dryRun: boolean; /** CLI bin name, for usage hints in error messages. */ - binName: string; + /** Optional recovery text supplied by the calling product. */ + modelUnavailableHint?: string; flags: CreatePlanFlags; /** Underlying model identifier (`--model`). */ model: string; @@ -164,8 +165,7 @@ const muStrategy: PlanStrategy = { const noTemplateError = () => new BailianError( `No mu-plan template found for model "${ctx.model}". ` + - `Run \`${ctx.binName} deploy models --source base\` to inspect available models, ` + - `or pass --deploy-spec explicitly.`, + (ctx.modelUnavailableHint ?? "Pass --deploy-spec explicitly."), ExitCode.USAGE, ); try { diff --git a/packages/core/src/errors/credential.ts b/packages/core/src/errors/credential.ts new file mode 100644 index 00000000..d40ff527 --- /dev/null +++ b/packages/core/src/errors/credential.ts @@ -0,0 +1,23 @@ +import { BailianError, type BailianErrorOptions } from "./base.ts"; +import { ExitCode } from "./codes.ts"; + +export type CredentialFailure = + | "missing-api-key" + | "missing-console-token" + | "expired-console-session" + | "missing-openapi-pair" + | "incomplete-openapi-flag" + | "incomplete-openapi-env" + | "incomplete-openapi-config"; + +/** Local credential failure facts; CLI recovery instructions belong to the host. */ +export class CredentialError extends BailianError { + constructor( + readonly reason: CredentialFailure, + message: string, + hint?: string, + options?: BailianErrorOptions, + ) { + super(message, ExitCode.AUTH, hint, options); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df16203d..78caa3bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export { BailianError, UsageError } from "./errors/base.ts"; +export { CredentialError, type CredentialFailure } from "./errors/credential.ts"; export { mapApiError, type ApiErrorBody } from "./errors/api.ts"; export { ExitCode } from "./errors/codes.ts"; diff --git a/packages/core/src/output/command.ts b/packages/core/src/output/command.ts new file mode 100644 index 00000000..e113194b --- /dev/null +++ b/packages/core/src/output/command.ts @@ -0,0 +1,46 @@ +import type { Identity } from "../config/schema.ts"; + +type Invocation = Pick | string; +export type CommandFormatter = (arguments_?: string) => string; +const COMMAND_PREFIX = "{{commandPrefix}}"; + +export interface ExecutableCommand { + bin: string; + path: string[]; + args: string[]; +} + +/** Build argv for shell:false consumers; never interpret a human-facing prefix. */ +export function buildCommandInvocation( + identity: Pick, + path: readonly string[] = [], + args: readonly string[] = [], +): ExecutableCommand { + return { + bin: identity.commandInvocation?.bin ?? identity.binName, + path: [...(identity.commandInvocation?.path ?? []), ...path], + args: [...args], + }; +} + +/** Format a complete instruction at the point where the invocation is known. */ +export function formatCommand(invocation: Invocation, arguments_ = ""): string { + const prefix = + typeof invocation === "string" + ? invocation + : [ + invocation.commandInvocation?.bin ?? invocation.binName, + ...(invocation.commandInvocation?.path ?? []), + ].join(" "); + return arguments_ ? `${prefix} ${arguments_}` : prefix; +} + +/** Reference used only in fixed CLI metadata, before the invocation is known. */ +export function commandRef(arguments_ = ""): string { + return formatCommand(COMMAND_PREFIX, arguments_); +} + +/** Resolve fixed help/reference templates; never pass service or user-generated text. */ +export function renderCommandText(text: string, invocation: Invocation): string { + return text.split(COMMAND_PREFIX).join(formatCommand(invocation)); +} diff --git a/packages/core/src/output/index.ts b/packages/core/src/output/index.ts index e2f3915b..deddf407 100644 --- a/packages/core/src/output/index.ts +++ b/packages/core/src/output/index.ts @@ -4,3 +4,12 @@ export { formatErrorJson, formatJson } from "./json.ts"; export { formatText } from "./text.ts"; export { sanitizeDiagnosticDetails } from "./diagnostics.ts"; + +export { + commandRef, + formatCommand, + type CommandFormatter, + renderCommandText, + buildCommandInvocation, + type ExecutableCommand, +} from "./command.ts"; diff --git a/packages/core/tests/command-invocation.test.ts b/packages/core/tests/command-invocation.test.ts new file mode 100644 index 00000000..d673a91b --- /dev/null +++ b/packages/core/tests/command-invocation.test.ts @@ -0,0 +1,120 @@ +import { expect, expectTypeOf, test } from "vite-plus/test"; +import { spawnSync } from "node:child_process"; +import { + BailianError, + commandRef, + formatCommand, + renderCommandText, + resolveApiKey, + resolveConsole, + resolveOpenApi, + buildCommandInvocation, + type Identity, +} from "../src/index.ts"; + +test("display and executable references share structured invocation, ignoring obsolete overrides", () => { + expectTypeOf().not.toHaveProperty("commandPrefix"); + const identity = { + binName: "bl", + commandPrefix: "presentation only", + commandInvocation: { bin: "aliyun", path: ["bl"] }, + }; + const path = ["knowledge", "delete"]; + const args = ["--index-id", "idx space;$(literal)"]; + const command = buildCommandInvocation(identity, path, args); + expect(formatCommand(identity, path.join(" "))).toBe("aliyun bl knowledge delete"); + expect(command).toEqual({ bin: "aliyun", path: ["bl", "knowledge", "delete"], args }); + expect(buildCommandInvocation({ binName: "bl" }, path, args)).toEqual({ bin: "bl", path, args }); + expect(buildCommandInvocation({ binName: "kscli" }, ["kb", "delete"], args).bin).toBe("kscli"); + command.path.push("changed"); + command.args.push("changed"); + expect(identity.commandInvocation.path).toEqual(["bl"]); + expect(path).toEqual(["knowledge", "delete"]); + expect(args).toEqual(["--index-id", "idx space;$(literal)"]); +}); + +// This one child process verifies the shell:false execution/argv boundary, not CLI behavior. +test("structured commands preserve argument boundaries when spawned without a shell", () => { + const args = ["--index-id", "space ' quote; $(literal)", "--workspace-id", "工作空间"]; + const command = buildCommandInvocation( + { + binName: "product", + commandInvocation: { + bin: process.execPath, + path: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))"], + }, + }, + ["knowledge", "delete"], + args, + ); + const result = spawnSync(command.bin, [...command.path, ...command.args], { + shell: false, + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(["knowledge", "delete", ...args]); +}); + +test.each([resolveApiKey, resolveConsole, resolveOpenApi])( + "credential errors serialize without CLI templates or product commands (%s)", + (resolve) => { + let failure: BailianError | undefined; + try { + resolve({ flags: {}, env: {}, file: {} } as Parameters[0]); + } catch (error) { + failure = error as BailianError; + } + expect(failure).toBeInstanceOf(BailianError); + for (const value of [ + failure!.message, + failure!.hint ?? "", + JSON.stringify(failure), + JSON.stringify({ failure }), + ]) { + expect(value).not.toContain("{{commandPrefix}}"); + expect(value).not.toContain("auth login"); + } + expect(JSON.parse(JSON.stringify({ failure })).failure).toEqual(failure!.toJSON()); + }, +); + +test("command formatting uses the invocation without changing product identity", () => { + const identity = { binName: "bl", commandInvocation: { bin: "aliyun", path: ["bl"] } }; + expect(formatCommand(identity, "auth login --console")).toBe("aliyun bl auth login --console"); + expect(formatCommand({ binName: "bl" }, "auth status")).toBe("bl auth status"); + expect(formatCommand({ binName: "kscli" }, "search")).toBe("kscli search"); + expect(formatCommand(identity)).toBe("aliyun bl"); + expect(identity.binName).toBe("bl"); + expect(formatCommand(identity, "file --name '{{commandPrefix}}'")).toBe( + "aliyun bl file --name '{{commandPrefix}}'", + ); +}); + +test("only explicit command references are expanded, preserving surrounding text", () => { + const template = `Run \`${commandRef("auth login --console")}\`. bl is ordinary text.`; + expect(renderCommandText(template, "aliyun bl")).toBe( + "Run `aliyun bl auth login --console`. bl is ordinary text.", + ); + expect( + renderCommandText(`First ${commandRef("auth login")}; then ${commandRef("auth status")}`, "bl"), + ).toBe("First bl auth login; then bl auth status"); +}); + +test("plain errors preserve literal text without a rendering protocol", () => { + const literal = "Server {{commandPrefix}} data"; + const failure = new BailianError(literal, undefined, literal); + expect(failure.message).toBe(literal); + expect(failure.hint).toBe(literal); + expect(failure.toJSON().error).toMatchObject({ message: literal, hint: literal }); +}); + +test("JSON serialization property names never become command prefixes", () => { + const failure = new BailianError( + "Missing token", + undefined, + `Run ${formatCommand("aliyun bl", "auth login")}`, + ); + const nested = JSON.parse(JSON.stringify({ failure })); + expect(nested.failure).toEqual(failure.toJSON()); + expect(nested.failure.error.hint).not.toContain("failure auth login"); +}); diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index cb30ec90..481bbd1e 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -13,8 +13,8 @@ import { } from "../src/auth/resolver.ts"; import { getModelProfilePreset } from "../src/config/profile-presets.ts"; -// 行为锁定:所有配置字段统一保持 flag>env>selected file>默认。`--config` 只选择 -// file block,不提升该 block 的字段优先级。Profile 预设只在登录写入阶段使用。 +// Settings 与模型凭证保持 flag>env>selected file>默认;OpenAPI 的专属来源顺序见下方用例。 +// `--config` 只选择 file block。Profile 预设只在登录写入阶段使用。 // buildSettings 与 resolver 都是纯函数,sources 直接构造,无需环境隔离。 function src(s: { @@ -371,7 +371,7 @@ test("console 凭证:token 仅 file 源;目标 flag > file > 默认;无 token expect(() => resolveConsole(src({}))).toThrow(/console access token/); }); -test("openapi 凭证:按来源成对解析,flag > env > file;缺 id 或 secret 抛 AUTH", () => { +test("OpenAPI credentials resolve complete pairs; partial sources fail", () => { const all = src({ flags: { accessKeyId: "ak-flag", accessKeySecret: "secret-flag" }, env: { @@ -440,7 +440,7 @@ test("openapi 凭证:按来源成对解析,flag > env > file;缺 id 或 secret expect(() => resolveOpenApi( src({ - env: { ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-env" }, + env: { BAILIAN_ACCESS_KEY_SECRET: "secret-env" }, file: { access_key_id: "ak-file", access_key_secret: "secret-file", @@ -478,8 +478,8 @@ test("openapi 凭证:低优先级来源缺字段时不影响更高优先级成 const completeEnvCred = src({ env: { - ALIBABA_CLOUD_ACCESS_KEY_ID: "ak-env", - ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-env", + BAILIAN_ACCESS_KEY_ID: "ak-env", + BAILIAN_ACCESS_KEY_SECRET: "secret-env", }, file: { access_key_id: "ak-file" }, }); @@ -490,6 +490,87 @@ test("openapi 凭证:低优先级来源缺字段时不影响更高优先级成 }); }); +test.each(["flag", "bailian-env", "config", "aliyun-env"] as const)( + "OpenAPI priority is flags > Bailian env > config > Aliyun env: %s", + (winner) => { + const sources = src({ + flags: + winner === "flag" + ? { accessKeyId: "flag-ak", accessKeySecret: "flag-sk", securityToken: "flag-sts" } + : {}, + env: { + ...(["flag", "bailian-env"].includes(winner) + ? { + BAILIAN_ACCESS_KEY_ID: "bailian-ak", + BAILIAN_ACCESS_KEY_SECRET: "bailian-sk", + BAILIAN_SECURITY_TOKEN: "bailian-sts", + } + : {}), + ALIBABA_CLOUD_ACCESS_KEY_ID: "aliyun-ak", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "aliyun-sk", + ALIBABA_CLOUD_SECURITY_TOKEN: "aliyun-sts", + }, + file: + winner === "aliyun-env" + ? {} + : { + access_key_id: "config-ak", + access_key_secret: "config-sk", + security_token: "config-sts", + }, + }); + const prefix = + winner === "bailian-env" ? "bailian" : winner === "aliyun-env" ? "aliyun" : winner; + expect(resolveOpenApi(sources)).toEqual({ + accessKeyId: `${prefix}-ak`, + accessKeySecret: `${prefix}-sk`, + securityToken: `${prefix}-sts`, + source: winner.endsWith("env") ? "env" : winner, + }); + }, +); + +test("OpenAPI skips blank Bailian env and never borrows a lower-priority STS token", () => { + expect( + resolveOpenApi( + src({ + env: { + BAILIAN_ACCESS_KEY_ID: " ", + BAILIAN_ACCESS_KEY_SECRET: "", + BAILIAN_SECURITY_TOKEN: "unused", + ALIBABA_CLOUD_ACCESS_KEY_ID: "host-ak", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "host-sk", + ALIBABA_CLOUD_SECURITY_TOKEN: "host-sts", + }, + file: { access_key_id: "local-ak", access_key_secret: "local-sk" }, + }), + ), + ).toEqual({ + accessKeyId: "local-ak", + accessKeySecret: "local-sk", + securityToken: undefined, + source: "config", + }); +}); + +test("partial Bailian env reports its own variable names without falling back", () => { + let failure: unknown; + try { + resolveOpenApi( + src({ + env: { BAILIAN_ACCESS_KEY_ID: "partial" }, + file: { access_key_id: "local-ak", access_key_secret: "local-sk" }, + }), + ); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + exitCode: 3, + hint: "Set both BAILIAN_ACCESS_KEY_ID and BAILIAN_ACCESS_KEY_SECRET, or unset the partial env vars.", + }); +}); + test("default*Model / outputDir:仅 file 源", () => { const c = resolve({ file: parseConfigFile({ diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index ceb7dbfb..d2f2194b 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -286,6 +286,36 @@ const tokenTestIdentity = { clientName: "bailian-cli-test", }; +test("explicit environment credentials refresh in memory unless persistence is requested", async () => { + await withIsolatedHome(async () => { + await writeConfigFile({ access_token: "saved-token" }, "selected"); + const originalFetch = httpTransport.fetch; + let requests = 0; + httpTransport.fetch = async (input) => { + requests++; + const url = new URL(input instanceof Request ? input.url : input); + return Response.json( + url.pathname === "/modelstudio/cli/generateAccessToken" + ? { cliAccessToken: "temporary-token" } + : { data: { success: true } }, + ); + }; + try { + const token = await refreshAccessToken({ + identity: tokenTestIdentity, + settings: buildSettings({ flags: {}, env: {}, file: {}, configName: "selected" }), + baseUrl: "https://dashscope.aliyuncs.com", + credential: { source: "env", accessKeyId: "env-ak", accessKeySecret: "env-sk" }, + }); + expect(token).toBe("temporary-token"); + expect(requests).toBe(2); + expect(readConfigFile("selected")).toEqual({ access_token: "saved-token" }); + } finally { + httpTransport.fetch = originalFetch; + } + }); +}); + test.each([ { baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com", expected: "ap-southeast-1" }, { baseUrl: "https://workspace.ap-southeast-1.maas.aliyuncs.com", expected: "ap-southeast-1" }, diff --git a/packages/core/tests/deploy-write-api.test.ts b/packages/core/tests/deploy-write-api.test.ts index fedb0b17..76fad8ee 100644 --- a/packages/core/tests/deploy-write-api.test.ts +++ b/packages/core/tests/deploy-write-api.test.ts @@ -501,7 +501,14 @@ describe("shared prepaid information", () => { }); function planContext(client: Client, flags: CreatePlanFlags = {}, dryRun = false): PlanContext { - return { client, flags, dryRun, model: "base-model", binName: "test-cli" }; + return { + client, + flags, + dryRun, + model: "base-model", + modelUnavailableHint: + "Run `test-cli deploy models --source base` to inspect available models, or pass --deploy-spec explicitly.", + }; } describe("PTU creation strategy", () => { diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 838bfd2a..cbaa26a6 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -22,6 +22,7 @@ import type { OutputDiagnostic, } from "bailian-cli-core"; import { + formatCommand, DEFAULT_LANGUAGE, BailianError, ExitCode, @@ -48,8 +49,10 @@ import { createTranslator } from "./i18n.ts"; /** Per-product identity injected by each CLI entrypoint (bl / rag / …). */ export interface CliOptions { - /** Binary name shown in help/usage/version output (e.g. "bl", "rag"). */ + /** Product binary name and default invocation (e.g. "bl", "rag"). */ binName: string; + /** Explicit executable invocation for structured command references. */ + commandInvocation?: Identity["commandInvocation"]; /** Product version for `--version` output, telemetry and update checks. */ version: string; /** User-Agent / telemetry client name (e.g. "bailian-cli", "rag-cli")。必填,无默认。 */ @@ -163,7 +166,14 @@ function installProcessHandlers( */ export function createCli(commands: Record, opts: CliOptions): Cli { const { binName, version, npmPackage, clientName } = opts; - const identity: Identity = { binName, version, npmPackage, clientName }; + const identity: Identity = { + binName, + version, + npmPackage, + clientName, + ...(opts.commandInvocation ? { commandInvocation: opts.commandInvocation } : {}), + }; + const commandPrefix = formatCommand(identity); const commandPackPolicy = opts.commandPacks ?? { supported: {} }; let loadedCommandPacksPromise: ReturnType | undefined; @@ -186,7 +196,7 @@ export function createCli(commands: Record, opts: CliOptions translator: ReturnType, ): Promise { const loaded = await getLoadedCommandPacks(); - return new CommandRegistry(loaded.commands, binName, translator); + return new CommandRegistry(loaded.commands, commandPrefix, translator); } /** Render help for `path`; root ([]) doubles as the onboarding / login guide. */ @@ -249,7 +259,7 @@ export function createCli(commands: Record, opts: CliOptions ); return; case "version": - process.stdout.write(`${binName} ${version}\n`); + process.stdout.write(`${commandPrefix} ${version}\n`); return; case "help": @@ -324,7 +334,11 @@ export function createCli(commands: Record, opts: CliOptions let translator = createTranslator(DEFAULT_LANGUAGE); const localize = (text: LocalizedText) => translator.localize(text); const initialSettings = buildOutputSettings({ flags, env: process.env }); - let reporter = createOutputReporter({ settings: initialSettings, localize, binName }); + let reporter = createOutputReporter({ + settings: initialSettings, + localize, + commandPrefix, + }); const warnings: OutputDiagnostic[] = []; const cleanup = installProcessHandlers(() => reporter, localize); try { @@ -333,12 +347,12 @@ export function createCli(commands: Record, opts: CliOptions reporter = createOutputReporter({ settings: buildOutputSettings(sources), localize, - binName, + commandPrefix, }); setupProxyFromEnv(); const registry = await getRegistry(translator); await dispatch(registry, argv, localize, sources, (settings) => { - reporter = createOutputReporter({ settings, localize, binName }); + reporter = createOutputReporter({ settings, localize, commandPrefix }); for (const warning of warnings.splice(0)) reporter.output.warning(warning); return reporter; }); diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index 6dcb2e21..275ef34b 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -1,3 +1,5 @@ +import { CredentialError } from "bailian-cli-core"; +import { formatCommand } from "bailian-cli-core"; import { BailianError, ExitCode, type LocalizedText } from "bailian-cli-core"; import type { OutputReporter } from "./output/reporter.ts"; import { sanitizeDiagnosticDetails } from "./output/diagnostics.ts"; @@ -8,11 +10,15 @@ export function handleError(error: unknown, reporter: Pick --access-key-secret ")}\` again to save a complete pair.`; + case "incomplete-openapi-flag": + case "incomplete-openapi-env": + return hint; + } + } + if (error.exitCode !== ExitCode.AUTH) return hint; if ( hint && [ @@ -43,8 +72,8 @@ export function errorHint( return hint; return [ hint, - `${binName} auth login --api-key `, - `${binName} auth status`, + `${formatCommand(commandPrefix, "auth login --api-key ")}`, + `${formatCommand(commandPrefix, "auth status")}`, localize({ "en-US": `Get API Key: ${API_KEY_PAGE}`, "zh-CN": `获取 API Key:${API_KEY_PAGE}` }), ] .filter((line) => line !== undefined) @@ -57,13 +86,13 @@ const english: Localize = (text) => (typeof text === "string" ? text : text["en- /** Shared local-error classification; service errors are never reclassified. */ export function classifyRuntimeError( error: Error, - binName: string, + commandPrefix: string, localize: Localize = english, ): BailianError | undefined { if (error instanceof BailianError) return error; const baseUrlHint = localize({ - "en-US": `If the DashScope host is wrong, check baseUrl (--base-url, ${binName} config show, or DASHSCOPE_BASE_URL).`, - "zh-CN": `如果 DashScope 主机地址有误,请检查 baseUrl(--base-url、${binName} config show 或 DASHSCOPE_BASE_URL)。`, + "en-US": `If the DashScope host is wrong, check baseUrl (--base-url, ${formatCommand(commandPrefix, "config show")}, or DASHSCOPE_BASE_URL).`, + "zh-CN": `如果 DashScope 主机地址有误,请检查 baseUrl(--base-url、${formatCommand(commandPrefix, "config show")} 或 DASHSCOPE_BASE_URL)。`, }); if ( error.name === "AbortError" || @@ -74,8 +103,8 @@ export function classifyRuntimeError( localize({ "en-US": "Request timed out.", "zh-CN": "请求超时。" }), ExitCode.TIMEOUT, localize({ - "en-US": `Try increasing --timeout (e.g. --timeout 60).\n${baseUrlHint}\nRun: ${binName} auth status — to check credentials.`, - "zh-CN": `请尝试增大 --timeout(例如 --timeout 60)。\n${baseUrlHint}\n运行 ${binName} auth status 检查凭证。`, + "en-US": `Try increasing --timeout (e.g. --timeout 60).\n${baseUrlHint}\nRun: ${formatCommand(commandPrefix, "auth status")} — to check credentials.`, + "zh-CN": `请尝试增大 --timeout(例如 --timeout 60)。\n${baseUrlHint}\n运行 ${formatCommand(commandPrefix, "auth status")} 检查凭证。`, }), { cause: error }, ); @@ -95,7 +124,7 @@ export function classifyRuntimeError( "zh-CN": `网络请求失败:${detail}`, }), ExitCode.NETWORK, - networkHint(cause?.code, binName, baseUrlHint, localize), + networkHint(cause?.code, commandPrefix, baseUrlHint, localize), { cause: cause ?? error }, ); } @@ -132,7 +161,7 @@ export function classifyRuntimeError( function networkHint( code: string | undefined, - binName: string, + commandPrefix: string, baseUrlHint: string, localize: Localize, ): string { @@ -150,8 +179,8 @@ function networkHint( }); case "ECONNRESET": return localize({ - "en-US": `Connection reset by peer. Retry, or check proxy / firewall.\nIf you are behind a VPN or corporate proxy, route ${binName} through it:\nexport HTTPS_PROXY=http://127.0.0.1:`, - "zh-CN": `连接被对端重置,请重试或检查代理和防火墙。\n如果使用 VPN 或企业代理,请为 ${binName} 配置代理:\nexport HTTPS_PROXY=http://127.0.0.1:`, + "en-US": `Connection reset by peer. Retry, or check proxy / firewall.\nIf you are behind a VPN or corporate proxy, route ${commandPrefix} through it:\nexport HTTPS_PROXY=http://127.0.0.1:`, + "zh-CN": `连接被对端重置,请重试或检查代理和防火墙。\n如果使用 VPN 或企业代理,请为 ${commandPrefix} 配置代理:\nexport HTTPS_PROXY=http://127.0.0.1:`, }); case "ETIMEDOUT": return localize({ diff --git a/packages/runtime/src/introspect.ts b/packages/runtime/src/introspect.ts index e9ae5830..6caa27e9 100644 --- a/packages/runtime/src/introspect.ts +++ b/packages/runtime/src/introspect.ts @@ -1,4 +1,7 @@ import { + formatCommand, + buildCommandInvocation, + renderCommandText, GLOBAL_FLAGS, MODEL_AUTH_FLAGS, CONSOLE_AUTH_FLAGS, @@ -16,6 +19,7 @@ import { camelToKebab } from "./args.ts"; import { confirmationFlagDefs } from "./confirm.ts"; export interface CommandSchemaNode { + /** Complete subcommand chain after CommandSchema.bin, including fixed host subcommands. */ path: readonly string[]; description: LocalizedText; auth: AuthRequirement; @@ -30,8 +34,10 @@ export interface CommandSchemaNode { export interface CommandSchema { schema_version: 1; + /** Actual executable; combine with a command's path without splitting display text. */ bin: string; version: string; + /** Executable path of the inspected root, group, or leaf. */ scope: readonly string[]; exitCodes: typeof ExitCode; globalFlags: Record; @@ -39,15 +45,27 @@ export interface CommandSchema { commands: readonly CommandSchemaNode[]; } -function flagsSchema(definitions: FlagsDef): Record { +function localizedCommandText(text: LocalizedText, identity: Identity): LocalizedText { + return typeof text === "string" + ? renderCommandText(text, identity) + : { + "en-US": renderCommandText(text["en-US"], identity), + "zh-CN": renderCommandText(text["zh-CN"], identity), + }; +} + +function flagsSchema(definitions: FlagsDef, identity: Identity): Record { return Object.fromEntries( Object.entries(definitions).map(([name, definition]) => [ `--${camelToKebab(name)}`, definition.type === "switch" - ? { type: definition.type, description: definition.description } + ? { + type: definition.type, + description: localizedCommandText(definition.description, identity), + } : { type: definition.type, - description: definition.description, + description: localizedCommandText(definition.description, identity), valueHint: definition.valueHint, ...(definition.required !== undefined ? { required: definition.required } : {}), ...(definition.choices ? { choices: [...definition.choices] } : {}), @@ -73,38 +91,39 @@ export function buildCommandSchema( identity: Identity, path: readonly string[] = [], ): CommandSchema { + const invocation = buildCommandInvocation(identity, path); return { schema_version: 1, - bin: identity.binName, + bin: invocation.bin, version: identity.version, - scope: [...path], + scope: invocation.path, exitCodes: ExitCode, - globalFlags: flagsSchema(GLOBAL_FLAGS), + globalFlags: flagsSchema(GLOBAL_FLAGS, identity), credentialFlags: { - apiKey: flagsSchema(MODEL_AUTH_FLAGS), - console: flagsSchema(CONSOLE_AUTH_FLAGS), - openapi: flagsSchema(OPENAPI_AUTH_FLAGS), + apiKey: flagsSchema(MODEL_AUTH_FLAGS, identity), + console: flagsSchema(CONSOLE_AUTH_FLAGS, identity), + openapi: flagsSchema(OPENAPI_AUTH_FLAGS, identity), none: {}, }, commands: registry .entries(path) .map(({ path: commandPath, command }): CommandSchemaNode => { - const prefix = [identity.binName, ...commandPath].join(" "); + const prefix = formatCommand(identity, commandPath.join(" ")); return { - path: commandPath, - description: command.description, + path: buildCommandInvocation(identity, commandPath).path, + description: localizedCommandText(command.description, identity), auth: command.auth, usage: `${prefix}${command.usageArgs ? ` ${command.usageArgs}` : ""}`, - flags: flagsSchema({ ...command.flags, ...confirmationFlagDefs(command) }), - notes: command.notes ?? [], + flags: flagsSchema({ ...command.flags, ...confirmationFlagDefs(command) }, identity), + notes: (command.notes ?? []).map((note) => localizedCommandText(note, identity)), examples: (command.exampleArgs ?? []).map((example) => - exampleWithPrefix(example, prefix), + localizedCommandText(exampleWithPrefix(example, prefix), identity), ), ...(command.risk ? { risk: { level: command.risk.level, - message: command.risk.message, + message: localizedCommandText(command.risk.message, identity), ...(command.risk.reason ? { reason: command.risk.reason } : {}), }, } diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 0e34e15d..2f5941a4 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -1,3 +1,4 @@ +import { consoleRecoveryHint } from "./error-handler.ts"; import type { AnyCommand, CommandOutput, @@ -16,6 +17,8 @@ import type { LocalizedText, } from "bailian-cli-core"; import { + CredentialError, + formatCommand, Client, DEFAULT_LANGUAGE, isModelConnectionSourceMismatch, @@ -140,14 +143,14 @@ function formatModelConnectionMismatchNotice( `api_key ← ${apiKeyLabel}`, `base_url ← ${baseUrlLabel}`, "These may not match. The request will continue.", - `Set both from the same source, or run ${ctx.identity.binName} config show to view effective configuration.`, + `Set both from the same source, or run ${formatCommand(ctx.identity, "config show")} to view effective configuration.`, ].join("\n"), "zh-CN": [ "api_key 和 base_url 来自不同配置源。", `api_key ← ${apiKeyLabel}`, `base_url ← ${baseUrlLabel}`, "两者可能不匹配,请求仍会继续。", - `建议设为同一来源,或运行 ${ctx.identity.binName} config show 查看生效配置。`, + `建议设为同一来源,或运行 ${formatCommand(ctx.identity, "config show")} 查看生效配置。`, ].join("\n"), }); } @@ -187,6 +190,7 @@ export const authStage: Middleware = async (ctx, next) => { const base = { identity: ctx.identity, settings, + consoleRecoveryHint: consoleRecoveryHint(formatCommand(ctx.identity)), debug: (diagnostic: Parameters[0]) => ctx.output.debug(diagnostic), ...resolveModelBaseUrlState(sources), }; @@ -219,12 +223,26 @@ export const authStage: Middleware = async (ctx, next) => { if (cred && !command.prepare) maybeShowStatusBar(settings, cred.token, cred, ctx.output); } else if (command.auth === "console") { let cred: ConsoleCredential | undefined; + let exchangeCredential: OpenApiCredential | undefined; try { cred = resolveConsole(sources); - } catch (err) { - if (!settings.dryRun || command.prepare) throw err; + } catch (error) { + try { + exchangeCredential = resolveOpenApi(sources); + } catch (credentialError) { + if (!settings.dryRun || command.prepare) { + throw credentialError instanceof CredentialError && + credentialError.reason === "missing-openapi-pair" + ? error + : credentialError; + } + } } - if (cred) ctx.client = new Client({ ...base, consoleCred: cred }); + ctx.client = new Client({ + ...base, + consoleCred: cred, + resolveConsoleOpenApiCredential: () => exchangeCredential ?? resolveOpenApi(sources), + }); } else if (command.auth === "openapi") { let cred: OpenApiCredential | undefined; try { @@ -281,19 +299,23 @@ export const versionCheckStage: Middleware = async (ctx, next) => { newVersion, ctx.identity.npmPackage, ctx.identity.clientName, - { output: ctx.output, binName: ctx.identity.binName }, + { + output: ctx.output, + binName: ctx.identity.binName, + ...(ctx.identity.commandInvocation ? { commandPrefix: formatCommand(ctx.identity) } : {}), + }, ); } else { ctx.output.warning({ code: "UPDATE_AVAILABLE", message: { - "en-US": `Update available: ${ctx.identity.version} → ${newVersion}. Run ${ctx.identity.binName} update to upgrade.`, - "zh-CN": `发现新版本:${ctx.identity.version} → ${newVersion}。运行 ${ctx.identity.binName} update 升级。`, + "en-US": `Update available: ${ctx.identity.version} → ${newVersion}. Run ${formatCommand(ctx.identity, "update")} to upgrade.`, + "zh-CN": `发现新版本:${ctx.identity.version} → ${newVersion}。运行 ${formatCommand(ctx.identity, "update")} 升级。`, }, details: { current_version: ctx.identity.version, latest_version: newVersion, - upgrade_command: `${ctx.identity.binName} update`, + upgrade_command: `${formatCommand(ctx.identity, "update")}`, }, }); } diff --git a/packages/runtime/src/output/reporter.ts b/packages/runtime/src/output/reporter.ts index fcb1fcfb..262b5b39 100644 --- a/packages/runtime/src/output/reporter.ts +++ b/packages/runtime/src/output/reporter.ts @@ -22,7 +22,7 @@ export interface OutputReporterOptions { stdout?: OutputWriter; stderr?: OutputWriter; localize?: (text: LocalizedText) => string; - binName?: string; + commandPrefix?: string; } export interface OutputReporter { @@ -95,8 +95,8 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo }, error(error) { if (errorCode !== undefined) return errorCode; - const failure = normalizeError(error, options.binName ?? "cli", localize); - const hint = errorHint(failure, options.binName ?? "cli", localize); + const failure = normalizeError(error, options.commandPrefix ?? "cli", localize); + const hint = errorHint(failure, options.commandPrefix ?? "cli", localize); if (verbose && error instanceof Error && !(error instanceof BailianError)) { diagnostic("debug", { code: "ERROR_STACK", @@ -137,9 +137,10 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo const repeatsNormalizedMessage = failure !== error && cause === error && cause.message === failure.message && !code; if (!repeatsNormalizedMessage) { + const message = cause.message; field( { "en-US": "Caused by", "zh-CN": "原因" }, - code ? `${code}: ${cause.message}` : cause.message, + code ? `${code}: ${message}` : message, ); } cause = cause.cause; diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index 4edd39f3..7674986d 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -6,14 +6,17 @@ import type { FlagsDef, LocalizedText, } from "bailian-cli-core"; -import { UsageError } from "bailian-cli-core"; import { + renderCommandText, + formatCommand, + UsageError, CONSOLE_AUTH_FLAGS, GLOBAL_FLAGS, MODEL_AUTH_FLAGS, OPENAPI_AUTH_FLAGS, credentialFlagDefs, } from "bailian-cli-core"; + import { camelToKebab } from "./args.ts"; import type { Translator } from "./i18n.ts"; import { printQuickStart, printWelcomeBanner } from "./output/banner.ts"; @@ -108,7 +111,10 @@ export class CommandRegistry { } private localize(text: LocalizedText): string { - return this.translator?.localize(text) ?? (typeof text === "string" ? text : text["en-US"]); + return renderCommandText( + this.translator?.localize(text) ?? (typeof text === "string" ? text : text["en-US"]), + this.cliName, + ); } private register(path: string, command: AnyCommand): void { @@ -459,7 +465,7 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b(this.localize(HELP_TEXT. // ` ` prefix is rendered here, not stored in the command, so the // same command shows the right invocation under any product (bl / rag / …). - const prefix = [this.cliName, ...commandPath].join(" "); + const prefix = formatCommand(this.cliName, commandPath.join(" ")); out.write(`\n${this.localize(cmd.description)}\n`); out.write( diff --git a/packages/runtime/src/utils/update-checker.ts b/packages/runtime/src/utils/update-checker.ts index 739669e4..c64da3f2 100644 --- a/packages/runtime/src/utils/update-checker.ts +++ b/packages/runtime/src/utils/update-checker.ts @@ -1,7 +1,6 @@ -import { httpTransport } from "bailian-cli-core"; -import { join } from "path"; -import { readFileSync, writeFileSync } from "fs"; import { + formatCommand, + httpTransport, BailianError, DEFAULT_INSTALL_PS1_URL, DEFAULT_INSTALL_SCRIPT_URL, @@ -10,6 +9,9 @@ import { type CommandOutput, } from "bailian-cli-core"; +import { join } from "path"; +import { readFileSync, writeFileSync } from "fs"; + export const NPM_REGISTRY = "https://registry.npmjs.org"; /** Default npm package; products override per-call via the `npmPackage` argument. */ export const NPM_PACKAGE = "bailian-cli"; @@ -213,7 +215,7 @@ function errorMessage(err: unknown): string { type UpdateOutput = Pick; -async function syncAgentSkillsAfterUpdate(output: UpdateOutput): Promise { +async function syncAgentSkillsAfterUpdate(output: UpdateOutput, invocation: string): Promise { try { output.info({ "en-US": "Syncing agent skills...", "zh-CN": "正在同步 Agent 技能……" }); const { execSync } = await import("child_process"); @@ -223,8 +225,8 @@ async function syncAgentSkillsAfterUpdate(output: UpdateOutput): Promise { output.warning({ code: "SKILL_SYNC_FAILED", message: { - "en-US": `Agent skill sync failed: ${errorMessage(error)}. Run bl skill update manually.`, - "zh-CN": `Agent 技能同步失败:${errorMessage(error)}。请手动运行 bl skill update。`, + "en-US": `Agent skill sync failed: ${errorMessage(error)}. Run ${formatCommand(invocation, "skill update")} manually.`, + "zh-CN": `Agent 技能同步失败:${errorMessage(error)}。请手动运行 ${formatCommand(invocation, "skill update")}。`, }, }); } @@ -236,9 +238,10 @@ export async function performAutoUpdate( latestVersion: string, npmPackage: string = NPM_PACKAGE, clientName: string = NPM_PACKAGE, - options: { output: UpdateOutput; binName: string }, + options: { output: UpdateOutput; binName: string; commandPrefix?: string }, ): Promise { - const { output, binName } = options; + const { output, binName, commandPrefix } = options; + const invocation = commandPrefix ?? binName; const method = getUpdateInstallMethod({ clientName, npmPackage }); if (method === "brew" || method === "winget") return false; output.info({ @@ -272,10 +275,10 @@ export async function performAutoUpdate( } writeState({ lastChecked: Date.now(), latestVersion: newVersion ?? latestVersion }); output.info({ - "en-US": `Update complete: ${currentVersion} → ${newVersion ?? latestVersion}. Run ${binName} --version to verify.`, - "zh-CN": `更新完成:${currentVersion} → ${newVersion ?? latestVersion}。运行 ${binName} --version 验证。`, + "en-US": `Update complete: ${currentVersion} → ${newVersion ?? latestVersion}. Run ${formatCommand(invocation, "--version")} to verify.`, + "zh-CN": `更新完成:${currentVersion} → ${newVersion ?? latestVersion}。运行 ${formatCommand(invocation, "--version")} 验证。`, }); - await syncAgentSkillsAfterUpdate(output); + await syncAgentSkillsAfterUpdate(output, commandPrefix ?? "bl"); pendingNotification = null; return true; } catch (error) { diff --git a/packages/runtime/tests/command-reference-output.test.ts b/packages/runtime/tests/command-reference-output.test.ts new file mode 100644 index 00000000..ce80fa8c --- /dev/null +++ b/packages/runtime/tests/command-reference-output.test.ts @@ -0,0 +1,186 @@ +import { withIsolatedHome } from "e2e/isolation"; +import { authStage, type RunContext } from "../src/middleware.ts"; +import { expect, test, vi } from "vite-plus/test"; +import { + BailianError, + CredentialError, + resolveApiKey, + resolveOpenApi, + httpTransport, + ExitCode, + commandRef, + formatCommand, + resolveConsole, +} from "bailian-cli-core"; +import { createOutputReporter } from "../src/output/reporter.ts"; + +function render(error: unknown, output: "text" | "json") { + let stderr = ""; + const reporter = createOutputReporter({ + settings: { output, quiet: true, verbose: false }, + commandPrefix: "aliyun bl", + stderr: { + write(chunk) { + stderr += chunk; + }, + }, + }); + const exitCode = reporter.error(error); + return { stderr, exitCode }; +} + +test.each(["text", "json"] as const)( + "Console references render through the reporter (%s)", + (format) => { + let error: unknown; + try { + resolveConsole({ flags: {}, env: {}, file: {} } as Parameters[0]); + } catch (failure) { + error = failure; + } + const result = render(error, format); + expect(result.exitCode).toBe(ExitCode.AUTH); + const hint = format === "json" ? JSON.parse(result.stderr).error.hint : result.stderr; + expect(hint).toContain("Run `aliyun bl auth login --console`."); + expect(hint).not.toContain("auth status"); + expect(hint).not.toContain("--api-key"); + if (format === "json") expect(result.stderr.trim().split("\n")).toHaveLength(1); + }, +); + +test.each(["text", "json"] as const)( + "complete message and cause preserve literal user data (%s)", + (format) => { + const literal = "{{commandPrefix}}"; + const cause = new BailianError( + `Run ${formatCommand("aliyun bl", "managed-agent init")} for ${literal}`, + ); + const failure = new BailianError( + `Failed ${formatCommand("aliyun bl", "managed-agent apply")}`, + undefined, + undefined, + { cause }, + ); + const result = render(failure, format); + expect(result.stderr).toContain("Failed aliyun bl managed-agent apply"); + expect(result.stderr).toContain("Run aliyun bl managed-agent init for {{commandPrefix}}"); + }, +); + +test("unmarked service text is never interpreted as a command reference", () => { + const text = `Server bl data ${commandRef("auth login")}`; + const result = render(new BailianError(text, undefined, text), "json"); + expect(JSON.parse(result.stderr).error).toMatchObject({ message: text, hint: text }); +}); + +test.each(["bl", "aliyun bl"])( + "credential recovery retains original steps under %s", + (commandPrefix) => { + const sources = { flags: {}, env: {}, file: {} } as Parameters[0]; + const failures: unknown[] = []; + for (const resolve of [resolveApiKey, resolveOpenApi]) { + try { + resolve(sources); + } catch (error) { + failures.push(error); + } + } + for (const source of ["flags", "env", "file"] as const) { + const partial = { + ...sources, + [source]: + source === "flags" + ? { accessKeyId: "id" } + : source === "env" + ? { ALIBABA_CLOUD_ACCESS_KEY_ID: "id" } + : { access_key_id: "id" }, + }; + try { + resolveOpenApi(partial); + } catch (error) { + failures.push(error); + } + } + const hints = failures.map((error) => { + let stderr = ""; + createOutputReporter({ + settings: { output: "json", quiet: true, verbose: false }, + commandPrefix, + stderr: { + write(chunk) { + stderr += chunk; + }, + }, + }).error(error); + return JSON.parse(stderr).error.hint as string; + }); + expect(hints).toHaveLength(5); + expect(hints[0]).toContain(`or run \`${commandPrefix} auth login\`.`); + expect(hints[0]).toContain(`${commandPrefix} auth status`); + expect(hints[0]).toContain("Get API Key:"); + expect(hints[1]).toContain(`or run \`${commandPrefix} auth login --open-api\`.`); + expect(hints[1]).not.toMatch(/[\u4e00-\u9fff]/u); + expect(hints[2]).toBe( + "Pass both --access-key-id and --access-key-secret, or remove the partial flags to use env/config credentials.", + ); + expect(hints[3]).toBe( + "Set both ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET, or unset the partial env vars.", + ); + expect(hints[4]).toBe( + `Run \`${commandPrefix} auth login --open-api --access-key-id --access-key-secret \` again to save a complete pair.`, + ); + expect(hints.slice(1).join("\n")).not.toContain("auth status"); + }, +); + +test("Console errors carry complete hints before ordinary business wrapping", async () => { + await withIsolatedHome(async () => { + const response = { data: { success: false, errorCode: "BailianGateway.Login.NotLogined" } }; + const fetching = vi + .spyOn(httpTransport, "fetch") + .mockImplementation(async () => new Response(JSON.stringify(response))); + const context = { + identity: { + binName: "bl", + commandInvocation: { bin: "aliyun", path: ["bl"] }, + version: "test", + npmPackage: "test", + clientName: "test", + }, + settings: { timeout: 30, dryRun: false, output: "json", quiet: true, verbose: false }, + sources: { flags: {}, env: {}, file: { access_token: "expired" } }, + command: { auth: "console" }, + output: createOutputReporter({ settings: { output: "json", quiet: true, verbose: false } }) + .output, + } as unknown as RunContext; + try { + await authStage(context, async () => { + let failure: BailianError | undefined; + try { + await context.client.console("test.api", {}); + } catch (error) { + expect(error).toBeInstanceOf(CredentialError); + failure = error as BailianError; + } + expect(failure).toBeDefined(); + expect(failure!.hint).toBe( + "Run `aliyun bl auth login --console` to sign in or refresh your console session.", + ); + const wrapped = new BailianError( + failure!.message, + failure!.exitCode, + `${failure!.hint}\nPartial result retained.`, + { rawResponse: failure!.rawResponse }, + ); + const result = render(wrapped, "json"); + const hint = JSON.parse(result.stderr).error.hint; + expect(hint).toContain("aliyun bl auth login --console"); + expect(hint).toContain("Partial result retained."); + expect(hint).not.toContain("auth status"); + expect(JSON.stringify(wrapped)).not.toContain("{{commandPrefix}}"); + }); + } finally { + fetching.mockRestore(); + } + }); +}); diff --git a/packages/runtime/tests/console-aksk-auth.test.ts b/packages/runtime/tests/console-aksk-auth.test.ts new file mode 100644 index 00000000..de76bf61 --- /dev/null +++ b/packages/runtime/tests/console-aksk-auth.test.ts @@ -0,0 +1,554 @@ +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { buildIsolatedCliEnv, withIsolatedHome } from "e2e/isolation"; +import { runInProcess } from "e2e/in-process"; +import { + Client, + defineCommand, + CredentialError, + buildSettings, + describeAuthState, + httpTransport, + readConfigFile, + writeConfigFile, + type ConfigFile, + type ResolutionSources, +} from "bailian-cli-core"; +import { authStage, type RunContext } from "../src/middleware.ts"; +import { createOutputReporter } from "../src/output/reporter.ts"; +import { createCli } from "../src/create-cli.ts"; + +const identity = { binName: "bl", version: "test", npmPackage: "test", clientName: "test" }; +const envCredentials = { + BAILIAN_ACCESS_KEY_ID: "bailian-ak", + BAILIAN_ACCESS_KEY_SECRET: "bailian-sk", + BAILIAN_SECURITY_TOKEN: "bailian-sts", +}; +const hostCredentials = { + ALIBABA_CLOUD_ACCESS_KEY_ID: "host-ak", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "host-sk", + ALIBABA_CLOUD_SECURITY_TOKEN: "host-sts", +}; +afterEach(() => vi.restoreAllMocks()); + +async function context( + file: ConfigFile = {}, + env: NodeJS.ProcessEnv = {}, + flags: ResolutionSources["flags"] = {}, + prepared = false, +) { + await writeConfigFile({ ...file }, "selected"); + const sources: ResolutionSources = { file, env, flags, configName: "selected" }; + const settings = buildSettings(sources); + return { + identity, + settings, + sources, + command: { + auth: "console", + ...(prepared + ? { prepare: async () => ({ data: undefined, preview: {}, risk: null, notices: [] }) } + : {}), + }, + client: new Client({ identity, settings, baseUrl: "https://dashscope.aliyuncs.com" }), + output: createOutputReporter({ settings: { output: "json", quiet: true, verbose: false } }) + .output, + } as unknown as RunContext; +} + +function backend( + options: { + rejectNew?: boolean; + permission?: boolean; + generationFailure?: boolean; + generation?: () => Promise; + gateway?: (url: URL, headers: Headers) => Promise; + } = {}, +) { + const calls: Array<{ url: URL; headers: Headers; body: string }> = []; + vi.spyOn(httpTransport, "fetch").mockImplementation(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + const headers = new Headers(init?.headers); + calls.push({ url, headers, body: typeof init?.body === "string" ? init.body : "" }); + if (url.pathname === "/modelstudio/cli/generateAccessToken") { + if (options.generation) return options.generation(); + return options.generationFailure + ? Response.json( + { + Message: "original generation denial", + Code: "Denied", + RequestId: "generation-request", + }, + { status: 403 }, + ) + : Response.json({ cliAccessToken: "fresh-token" }); + } + if (url.pathname !== "/cli/api.json") throw new Error(`Unexpected request: ${url.pathname}`); + if (options.gateway) return options.gateway(url, headers); + if (options.permission) + return Response.json({ data: { success: false, errorCode: "Original.Permission.Denied" } }); + if (headers.get("authorization") === "Bearer old-token" || options.rejectNew) { + return Response.json({ + data: { success: false, errorCode: "BailianGateway.Login.NotLogined" }, + }); + } + return Response.json({ data: { success: true, value: "result" } }); + }); + return { + calls, + generations: () => + calls.filter((call) => call.url.pathname === "/modelstudio/cli/generateAccessToken"), + gateways: () => calls.filter((call) => call.url.pathname === "/cli/api.json"), + }; +} + +test.each([ + { + name: "bailian-env", + env: { ...envCredentials, ...hostCredentials }, + file: {}, + key: "bailian-ak", + sts: "bailian-sts", + }, + { + name: "config", + env: hostCredentials, + file: { access_key_id: "local-ak", access_key_secret: "local-sk" }, + key: "local-ak", + sts: null, + }, + { name: "host-env", env: hostCredentials, file: {}, key: "host-ak", sts: "host-sts" }, +])( + "empty token exchanges selected credentials once and stays in memory: $name", + async ({ env, file, key, sts }) => { + await withIsolatedHome(async () => { + const ctx = await context(file, env); + const before = readConfigFile("selected"); + const requests = backend(); + await authStage(ctx, async () => { + expect(requests.calls).toHaveLength(0); + await expect(ctx.client.console("test.api", {})).resolves.toMatchObject({ + data: { value: "result" }, + }); + await ctx.client.console("test.other", {}); + }); + expect(requests.generations()).toHaveLength(1); + expect(requests.generations()[0]!.headers.get("authorization")).toContain( + `Credential=${key}`, + ); + expect(requests.generations()[0]!.headers.get("x-acs-security-token")).toBe(sts); + expect(requests.gateways().map((call) => call.headers.get("authorization"))).toEqual([ + "Bearer fresh-token", + "Bearer fresh-token", + ]); + expect(readConfigFile("selected")).toEqual(before); + expect(readConfigFile().access_token).toBeUndefined(); + }); + }, +); + +test("existing token is used without resolving unused partial AK/SK", async () => { + await withIsolatedHome(async () => { + const ctx = await context( + { access_token: "valid-token" }, + { BAILIAN_ACCESS_KEY_ID: "partial" }, + ); + const requests = backend(); + await authStage(ctx, () => ctx.client.console("test.api", {}).then(() => {})); + expect(requests.generations()).toHaveLength(0); + expect(requests.gateways()).toHaveLength(1); + }); +}); + +test.each([false, true])( + "expired local token uses environment credentials without persisting them, rejectNew=%s", + async (rejectNew) => { + await withIsolatedHome(async () => { + const file = { + access_token: "old-token", + console_region: "ap-southeast-1", + console_site: "international", + console_switch_agent: 77, + } as const; + const ctx = await context(file, envCredentials); + const requests = backend({ rejectNew }); + const run = () => authStage(ctx, () => ctx.client.console("test.api", {}).then(() => {})); + if (rejectNew) await expect(run()).rejects.toBeInstanceOf(CredentialError); + else await run(); + expect(requests.generations()).toHaveLength(1); + expect(requests.generations()[0]!.url.hostname).toBe( + "modelstudio.ap-southeast-1.aliyuncs.com", + ); + expect(requests.gateways()).toHaveLength(2); + for (const call of requests.gateways()) { + expect(call.url.hostname).toBe("bailian-singapore-cs.alibabacloud.com"); + expect( + JSON.parse(new URLSearchParams(call.body).get("params")!).Data.cornerstoneParam + .switchAgent, + ).toBe(77); + } + expect(readConfigFile("selected")).toEqual(file); + }); + }, +); + +test("a rejected newly acquired token does not trigger another exchange", async () => { + await withIsolatedHome(async () => { + const ctx = await context({}, hostCredentials); + const requests = backend({ rejectNew: true }); + await expect( + authStage(ctx, () => ctx.client.console("test.api", {}).then(() => {})), + ).rejects.toBeInstanceOf(CredentialError); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(1); + expect(readConfigFile("selected").access_token).toBeUndefined(); + }); +}); + +test("no credentials preserves the Console login error before command execution", async () => { + await withIsolatedHome(async () => { + const ctx = await context(); + const requests = backend(); + const next = vi.fn(async () => {}); + await expect(authStage(ctx, next)).rejects.toMatchObject({ + reason: "missing-console-token", + exitCode: 3, + }); + expect(next).not.toHaveBeenCalled(); + expect(requests.calls).toHaveLength(0); + }); +}); + +test("permission errors are not treated as expired tokens", async () => { + await withIsolatedHome(async () => { + const ctx = await context({ access_token: "valid-token" }, hostCredentials); + const requests = backend({ permission: true }); + await expect( + authStage(ctx, () => ctx.client.console("test.api", {}).then(() => {})), + ).rejects.toMatchObject({ + exitCode: 1, + message: "Console gateway error: Original.Permission.Denied", + }); + expect(requests.generations()).toHaveLength(0); + }); +}); + +test("token generation denial passes through without a business request or config write", async () => { + await withIsolatedHome(async () => { + const ctx = await context({}, hostCredentials); + const requests = backend({ generationFailure: true }); + await expect( + authStage(ctx, () => ctx.client.console("test.api", {}).then(() => {})), + ).rejects.toMatchObject({ exitCode: 1, message: "Denied - original generation denial" }); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(0); + expect(readConfigFile("selected").access_token).toBeUndefined(); + }); +}); + +test("expired token with partial Bailian credentials fails without falling back to host credentials", async () => { + await withIsolatedHome(async () => { + const ctx = await context( + { access_token: "old-token" }, + { ...hostCredentials, BAILIAN_ACCESS_KEY_ID: "partial" }, + ); + const requests = backend(); + await expect( + authStage(ctx, () => ctx.client.console("test.api", {}).then(() => {})), + ).rejects.toMatchObject({ + reason: "incomplete-openapi-env", + hint: expect.stringContaining("BAILIAN_ACCESS_KEY_SECRET"), + }); + expect(requests.generations()).toHaveLength(0); + expect(requests.gateways()).toHaveLength(1); + expect(readConfigFile("selected").access_token).toBe("old-token"); + }); +}); + +test("prepared dry-run can acquire a token for its required read request", async () => { + await withIsolatedHome(async () => { + const ctx = await context({}, hostCredentials, { dryRun: true }, true); + const requests = backend(); + await authStage(ctx, () => ctx.client.console("test.read", {}).then(() => {})); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(1); + expect(readConfigFile("selected").access_token).toBeUndefined(); + }); +}); + +test("static dry-run and read-only auth snapshot never exchange tokens", async () => { + await withIsolatedHome(async () => { + const ctx = await context({}, envCredentials, { dryRun: true }); + const requests = backend(); + await authStage(ctx, async () => {}); + expect(describeAuthState(ctx.sources)).toMatchObject({ openapi: { source: "env" } }); + expect(describeAuthState(ctx.sources).console).toBeUndefined(); + expect(requests.calls).toHaveLength(0); + }); +}); + +test("CLI output remains the business result with no extra auth diagnostics", async () => { + const isolated = buildIsolatedCliEnv({ + overrides: { ...envCredentials, NO_UPDATE_NOTIFIER: "1", DO_NOT_TRACK: "1" }, + }); + const requests = backend(); + const probe = defineCommand({ + auth: "console", + description: "fixture", + async run(ctx) { + ctx.output.result(await ctx.client.console("test.api", {})); + }, + }); + const args = ["probe", "--output", "json", "--quiet"]; + try { + const result = await runInProcess(() => createCli({ probe }, identity).run(args), args, { + env: isolated.env, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ data: { success: true, value: "result" } }); + expect(result.stderr).toBe(""); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(1); + } finally { + isolated.dispose(); + } +}); + +test.each([false, true])( + "automatically acquired config token remains temporary after refresh, clone=%s", + async (clone) => { + await withIsolatedHome(async () => { + const file = { access_key_id: "local-ak", access_key_secret: "local-sk" }; + const ctx = await context(file); + let generations = 0; + let gateways = 0; + vi.spyOn(httpTransport, "fetch").mockImplementation(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.pathname === "/modelstudio/cli/generateAccessToken") { + generations++; + return Response.json({ cliAccessToken: `token-${generations}` }); + } + gateways++; + if (gateways > 1 && new Headers(init?.headers).get("authorization") === "Bearer token-1") { + return Response.json({ + data: { success: false, errorCode: "BailianGateway.Login.NotLogined" }, + }); + } + return Response.json({ data: { success: true } }); + }); + await authStage(ctx, async () => { + await ctx.client.console("test.first", {}); + const client = clone ? ctx.client.withConsoleSwitchAgent(9) : ctx.client; + await client.console("test.second", {}); + }); + expect(generations).toBe(2); + expect(gateways).toBe(3); + expect(readConfigFile("selected")).toEqual(file); + }); + }, +); + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +test.each([false, true])( + "concurrent Console requests share one exchange, expired=%s", + async (expired) => { + await withIsolatedHome(async () => { + const file = expired ? { access_token: "old-token" } : {}; + const ctx = await context(file, hostCredentials); + const started = deferred(); + const release = deferred(); + const requests = backend({ + generation: async () => { + started.resolve(); + await release.promise; + return Response.json({ cliAccessToken: "fresh-token" }); + }, + }); + await authStage(ctx, async () => { + const results = Promise.all([ + ctx.client.console("test.first", {}), + ctx.client.console("test.second", {}), + ]); + await started.promise; + release.resolve(); + expect(await results).toHaveLength(2); + await ctx.client.console("test.cached", {}); + }); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(expired ? 5 : 3); + expect(readConfigFile("selected")).toEqual(file); + }); + }, +); + +test("a late expired response reuses the token refreshed by another request", async () => { + await withIsolatedHome(async () => { + const ctx = await context({ access_token: "old-token" }, hostCredentials); + const lateStarted = deferred(); + const releaseLate = deferred(); + const requests = backend({ + gateway: async (url, headers) => { + if (headers.get("authorization") === "Bearer old-token") { + if (url.searchParams.get("api") === "test.late") { + lateStarted.resolve(); + await releaseLate.promise; + } + return Response.json({ + data: { success: false, errorCode: "BailianGateway.Login.NotLogined" }, + }); + } + return Response.json({ data: { success: true } }); + }, + }); + await authStage(ctx, async () => { + const late = ctx.client.console("test.late", {}); + await lateStarted.promise; + await ctx.client.console("test.fast", {}); + releaseLate.resolve(); + await late; + }); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(4); + }); +}); + +test("shared exchange does not couple business errors or return another request's result", async () => { + await withIsolatedHome(async () => { + const ctx = await context({}, hostCredentials); + const requests = backend({ + gateway: async (url) => + url.searchParams.get("api") === "test.denied" + ? Response.json({ data: { success: false, errorCode: "Original.Permission.Denied" } }) + : Response.json({ data: { success: true, value: url.searchParams.get("api") } }), + }); + await authStage(ctx, async () => { + const results = await Promise.allSettled([ + ctx.client.console("test.denied", {}), + ctx.client.console("test.allowed", {}), + ]); + expect(results[0]).toMatchObject({ status: "rejected" }); + expect(results[1]).toMatchObject({ + status: "fulfilled", + value: { data: { value: "test.allowed" } }, + }); + await ctx.client.console("test.cached", {}); + }); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(3); + expect(readConfigFile("selected").access_token).toBeUndefined(); + }); +}); + +test("failed shared exchange is released so a later call can retry", async () => { + await withIsolatedHome(async () => { + const ctx = await context({}, hostCredentials); + let attempts = 0; + const requests = backend({ + generation: async () => { + attempts++; + return attempts === 1 + ? Response.json({ Code: "Denied", Message: "exchange denied" }, { status: 403 }) + : Response.json({ cliAccessToken: "fresh-token" }); + }, + }); + await authStage(ctx, async () => { + const results = await Promise.allSettled([ + ctx.client.console("test.first", {}), + ctx.client.console("test.second", {}), + ]); + expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(0); + await ctx.client.console("test.retry", {}); + }); + expect(requests.generations()).toHaveLength(2); + expect(requests.gateways()).toHaveLength(1); + }); +}); + +test("concurrent local refresh saves only after a successful business request", async () => { + await withIsolatedHome(async () => { + const file = { + access_token: "old-token", + access_key_id: "local-ak", + access_key_secret: "local-sk", + }; + const ctx = await context(file); + const requests = backend({ + gateway: async (url, headers) => { + expect(readConfigFile("selected").access_token).toBe("old-token"); + if (headers.get("authorization") === "Bearer old-token") + return Response.json({ + data: { success: false, errorCode: "BailianGateway.Login.NotLogined" }, + }); + return url.searchParams.get("api") === "test.denied" + ? Response.json({ data: { success: false, errorCode: "Original.Permission.Denied" } }) + : Response.json({ data: { success: true } }); + }, + }); + await authStage(ctx, async () => { + const results = await Promise.allSettled([ + ctx.client.console("test.denied", {}), + ctx.client.console("test.allowed", {}), + ]); + expect(results.map((result) => result.status)).toEqual(["rejected", "fulfilled"]); + }); + expect(requests.generations()).toHaveLength(1); + expect(readConfigFile("selected")).toEqual({ ...file, access_token: "fresh-token" }); + }); +}); + +test("rejected shared token is not retried again and leaves no cached credential", async () => { + await withIsolatedHome(async () => { + const file = { + access_token: "old-token", + access_key_id: "local-ak", + access_key_secret: "local-sk", + }; + const ctx = await context(file); + const requests = backend({ rejectNew: true }); + await authStage(ctx, async () => { + const results = await Promise.allSettled([ + ctx.client.console("test.first", {}), + ctx.client.console("test.second", {}), + ]); + expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]); + expect(requests.generations()).toHaveLength(1); + expect(requests.gateways()).toHaveLength(4); + await expect(ctx.client.console("test.retry", {})).rejects.toBeInstanceOf(CredentialError); + }); + expect(requests.generations()).toHaveLength(2); + expect(requests.gateways()).toHaveLength(6); + expect(readConfigFile("selected")).toEqual(file); + }); +}); + +test("different switch-agent Clients keep their exchanges and target scopes separate", async () => { + await withIsolatedHome(async () => { + const ctx = await context({}, hostCredentials); + const requests = backend(); + await authStage(ctx, async () => { + const switched = ctx.client.withConsoleSwitchAgent(9); + await Promise.all([ + ctx.client.console("test.original", {}), + switched.console("test.switched", {}), + ]); + }); + expect(requests.generations()).toHaveLength(2); + expect(requests.gateways()).toHaveLength(2); + const switched = requests + .gateways() + .find((call) => call.url.searchParams.get("api") === "test.switched")!; + expect( + JSON.parse(new URLSearchParams(switched.body).get("params")!).Data.cornerstoneParam + .switchAgent, + ).toBe(9); + expect(readConfigFile("selected").access_token).toBeUndefined(); + }); +}); diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index 0c7ffcf4..4aa75ba5 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -8,7 +8,7 @@ function render(error: unknown, output: "text" | "json" = "json") { let stderr = ""; const reporter = createOutputReporter({ settings: { output, quiet: true, verbose: false }, - binName: "kscli", + commandPrefix: "kscli", stderr: { write: (chunk) => { stderr += chunk; diff --git a/packages/runtime/tests/introspect.test.ts b/packages/runtime/tests/introspect.test.ts index 67cc389a..a3dca2f0 100644 --- a/packages/runtime/tests/introspect.test.ts +++ b/packages/runtime/tests/introspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vite-plus/test"; -import { defineCommand, type Identity } from "bailian-cli-core"; +import { defineCommand, sourceConfig, type Identity } from "bailian-cli-core"; import * as runtime from "../src/index.ts"; const identity: Identity = { @@ -30,6 +30,43 @@ const command = defineCommand({ const other = defineCommand({ auth: "none", description: "Other", async run() {} }); const registry = new runtime.CommandRegistry({ "doc sync": command, "kb list": other }, "kscli"); +test.each([[], ["doc"], ["doc", "sync"]])( + "exported invocation qualifies scope %j without changing product identity", + (...path) => { + const hostedIdentity = { + ...identity, + binName: "bl", + commandInvocation: { bin: "aliyun", path: ["bl"] }, + }; + const hostedRegistry = new runtime.CommandRegistry({ "doc sync": command }, "aliyun bl"); + const schema = runtime.buildCommandSchema(hostedRegistry, hostedIdentity, path); + expect(schema.bin).toBe("aliyun"); + expect(schema.scope).toEqual(["bl", ...path]); + expect(schema.commands[0]?.path).toEqual(["bl", "doc", "sync"]); + expect(schema.commands[0]?.usage).toBe("aliyun bl doc sync --index-id "); + expect(schema.commands[0]?.examples[0]).toBe("aliyun bl doc sync --index-id example"); + expect(hostedRegistry.entries()[0]?.path).toEqual(["doc", "sync"]); + expect(JSON.parse(sourceConfig(hostedIdentity)).tags.t2).toBe("bl"); + expect(hostedIdentity.clientName).toBe("fixture"); + }, +); + +test("generic host paths work with an empty registry and do not mutate their inputs", () => { + const hostPath = ["plugins", "example"]; + const scope = ["missing"]; + const schema = runtime.buildCommandSchema( + new runtime.CommandRegistry({}, "host"), + { + ...identity, + commandInvocation: { bin: "host", path: hostPath }, + }, + scope, + ); + expect(schema).toMatchObject({ bin: "host", scope: [...hostPath, ...scope], commands: [] }); + expect(hostPath).toEqual(["plugins", "example"]); + expect(scope).toEqual(["missing"]); +}); + describe("introspection routing", () => { test.each([[], ["doc"], ["doc", "sync"]])( "resolves scope %j without required flags", diff --git a/packages/runtime/tests/output-reporter.test.ts b/packages/runtime/tests/output-reporter.test.ts index a53f3a76..89981f02 100644 --- a/packages/runtime/tests/output-reporter.test.ts +++ b/packages/runtime/tests/output-reporter.test.ts @@ -381,7 +381,7 @@ test("local error hints use the invocation language and binary name", () => { let stderr = ""; const reporter = createOutputReporter({ settings: { output: "json", quiet: true, verbose: false }, - binName: "kscli", + commandPrefix: "kscli", localize: (text) => createTranslator("zh-CN").localize(text), stderr: { write: (chunk) => { diff --git a/packages/runtime/tests/preparation.test.ts b/packages/runtime/tests/preparation.test.ts index 1a226126..05c6aaab 100644 --- a/packages/runtime/tests/preparation.test.ts +++ b/packages/runtime/tests/preparation.test.ts @@ -161,7 +161,7 @@ test("human confirmation output includes the actual plan and notices", async () error, createOutputReporter({ settings: { output: "text", quiet: true, verbose: false }, - binName: "fixture", + commandPrefix: "fixture", }), ), ).toThrow("exit-confirmation"); diff --git a/packages/runtime/tests/update-checker.test.ts b/packages/runtime/tests/update-checker.test.ts index 49caadea..357a0001 100644 --- a/packages/runtime/tests/update-checker.test.ts +++ b/packages/runtime/tests/update-checker.test.ts @@ -58,6 +58,25 @@ afterEach(() => { vi.clearAllMocks(); }); +test("hosted update instructions use the prefix while skill execution uses the binary", async () => { + const output = { info: vi.fn(), warning: vi.fn() }; + childProcessMocks.execSync.mockImplementationOnce(() => { + throw new Error("skill sync failed"); + }); + expect( + await performAutoUpdate("1.14.3", "2.0.0", "bailian-cli", "bailian-cli", { + output, + binName: "bl", + commandPrefix: "aliyun bl", + }), + ).toBe(true); + expect(JSON.stringify(output.info.mock.calls)).toContain("Run aliyun bl --version"); + expect(JSON.stringify(output.warning.mock.calls)).toContain( + "Run aliyun bl skill update manually.", + ); + expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill update", { stdio: "pipe" }); +}); + test("parseVersion strips pre-release and build metadata", () => { expect(parseVersion("1.4.2")).toEqual([1, 4, 2]); expect(parseVersion("2.0.0-beta.1")).toEqual([2, 0, 0]); diff --git a/packages/runtime/tests/version-check-stage.test.ts b/packages/runtime/tests/version-check-stage.test.ts index 774a37c1..e52d71be 100644 --- a/packages/runtime/tests/version-check-stage.test.ts +++ b/packages/runtime/tests/version-check-stage.test.ts @@ -28,6 +28,19 @@ function makeContext(): RunContext { } as unknown as RunContext; } +test("hosted auto-update keeps product identity and derives the display prefix", async () => { + const context = makeContext(); + context.identity.commandInvocation = { bin: "aliyun", path: ["bl"] }; + await versionCheckStage(context, async () => {}); + expect(updateMocks.performAutoUpdate).toHaveBeenCalledWith( + "1.24.0", + "2.2.1", + "bailian-cli", + "bailian-cli-test", + { output: context.output, binName: "bl", commandPrefix: "aliyun bl" }, + ); +}); + beforeEach(() => { vi.stubEnv("NO_UPDATE_NOTIFIER", undefined); updateMocks.checkForUpdate.mockResolvedValue(undefined); diff --git a/skills/bailian-knowledge/reference/knowledge.md b/skills/bailian-knowledge/reference/knowledge.md index 586c7b6c..ae3f622c 100644 --- a/skills/bailian-knowledge/reference/knowledge.md +++ b/skills/bailian-knowledge/reference/knowledge.md @@ -2,7 +2,7 @@ > Generated from the same schema as `--introspect`. Do not edit by hand. > Schema version: 1. -> Query the installed CLI version with `bl --version`. +> Query the installed CLI version with `bl knowledge --version`. Cross-flag constraints are validated at execution and are not exported by this schema. diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 57f79ca6..37dfaff1 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -196,6 +196,28 @@ After a successful console login, the callback may persist `console_site` in `~/ Use the same `--console-site` on console-gateway commands when it differs from the saved default, e.g. `bl app list --console-site international`. +### AK/SK for OpenAPI and Console tokens + +OpenAPI commands resolve AK/SK as a complete group in this order: command parameters, +`BAILIAN_ACCESS_KEY_ID` / `BAILIAN_ACCESS_KEY_SECRET`, the selected Bailian config, +then `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET` (including +credentials supplied by `aliyun bl`). Optional STS tokens come from the same +source: `BAILIAN_SECURITY_TOKEN`, config `security_token`, or +`ALIBABA_CLOUD_SECURITY_TOKEN`. An absent pair is skipped; a partial pair fails +without borrowing fields from another source. `--config` selects only Bailian +configuration; Aliyun configuration is handled by the host. + +Console business commands do not accept temporary AK/SK flags. Prepare credentials +in advance: Bailian environment variables, the selected Bailian config, then +Alibaba Cloud environment variables. Console commands continue using an existing access token. If it is missing or +the gateway reports it expired, available AK/SK can obtain a Console token +internally. Each request exchanges at most once; concurrent calls on the same +Client share token issuance and execute their business requests independently. +Late failures for an old token reuse an already refreshed token. Automatically acquired tokens +and environment credentials stay in memory; refreshing an existing local token +with local config AK/SK retains the existing save-after-success behavior. +`auth status` remains read-only. Model commands still require a separate API Key. + --- ## DashScope endpoint @@ -211,7 +233,7 @@ Default: `https://dashscope.aliyuncs.com` (China). Override with any of: ## Configuration - **Config file:** `~/.bailian/config.json` -- **Env:** `DASHSCOPE_API_KEY`, `DASHSCOPE_BASE_URL`, `DASHSCOPE_OUTPUT`, `ALIBABA_CLOUD_ACCESS_KEY_ID`, `ALIBABA_CLOUD_ACCESS_KEY_SECRET`, `BAILIAN_WORKSPACE_ID` +- **Env:** `DASHSCOPE_API_KEY`, `DASHSCOPE_BASE_URL`, `DASHSCOPE_OUTPUT`, `BAILIAN_ACCESS_KEY_ID`, `BAILIAN_ACCESS_KEY_SECRET`, `BAILIAN_SECURITY_TOKEN`, `ALIBABA_CLOUD_ACCESS_KEY_ID`, `ALIBABA_CLOUD_ACCESS_KEY_SECRET`, `ALIBABA_CLOUD_SECURITY_TOKEN`, `BAILIAN_WORKSPACE_ID` ```bash bl config show diff --git a/tools/generate-reference.ts b/tools/generate-reference.ts index ce9f63c2..bcb6e373 100644 --- a/tools/generate-reference.ts +++ b/tools/generate-reference.ts @@ -22,6 +22,7 @@ import { MODEL_AUTH_FLAGS, OPENAPI_AUTH_FLAGS, credentialFlagDefs, + renderCommandText, type AnyCommand, type AuthRequirement, type FlagDef, @@ -95,7 +96,7 @@ function escCell(s: string): string { /** Skill references are English artifacts; localized CLI text keeps English here. */ function referenceText(text: LocalizedText): string { - return typeof text === "string" ? text : text["en-US"]; + return renderCommandText(typeof text === "string" ? text : text["en-US"], "bl"); } function topLevel(path: string): string { diff --git a/tools/knowledge-reference-renderer.test.ts b/tools/knowledge-reference-renderer.test.ts index a624f2bd..e83054d6 100644 --- a/tools/knowledge-reference-renderer.test.ts +++ b/tools/knowledge-reference-renderer.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "vite-plus/test"; import { defineCommand } from "../packages/core/src/index.ts"; import { CommandRegistry } from "../packages/runtime/src/registry.ts"; import { buildCommandSchema } from "../packages/runtime/src/introspect.ts"; +import { resolve } from "../packages/runtime/src/resolve.ts"; import { renderKnowledgeReference, renderKnowledgeReferenceIndex, @@ -48,6 +49,32 @@ test("actual product paths are preserved with no prefix substitution", () => { expect(kscli).toContain("kscli init --mode"); expect(kscli).not.toContain("kscli knowledge"); }); +test.each([[], ["knowledge"], ["knowledge", "init"]])( + "hosted references use executable scope %j for version queries", + (...scope) => { + const registry = new CommandRegistry({ "knowledge init": command }, "aliyun bl"); + const input = buildCommandSchema( + registry, + { + binName: "bl", + commandInvocation: { bin: "aliyun", path: ["bl"] }, + version: "2.0.1", + npmPackage: "fixture", + clientName: "fixture", + }, + scope, + ); + // --version is supported at root, group and leaf; it skips required business flags. + expect(resolve([...scope, "--version"], registry)).toEqual({ kind: "version" }); + for (const locale of ["en-US", "zh-CN"] as const) { + const output = renderKnowledgeReference(input, locale); + expect(output).toContain(`\`${["aliyun", "bl", ...scope].join(" ")} --version\``); + expect(output).toContain("## aliyun bl knowledge init"); + expect(output).not.toContain("`aliyun --version`"); + expect(output).not.toContain("aliyun bl bl"); + } + }, +); test("required flags, choices, billing risk and runtime confirmation survive rendering", () => { const output = renderKnowledgeReference(schema("bl", "knowledge init")); expect(output).toContain("--mode"); diff --git a/tools/knowledge-reference-renderer.ts b/tools/knowledge-reference-renderer.ts index ee51501f..3a179c4d 100644 --- a/tools/knowledge-reference-renderer.ts +++ b/tools/knowledge-reference-renderer.ts @@ -28,6 +28,8 @@ function flagRows(flags: Record, locale: Locale): string[] { /** Render declarative schema only; do not re-read command definitions or infer defaults. */ export function renderKnowledgeReference(schema: CommandSchema, locale: Locale = "en-US"): string { const pick = (english: string, chinese: string) => (locale === "en-US" ? english : chinese); + // Global --version works at the inspected scope, including fixed host subcommands. + const versionCommand = [schema.bin, ...schema.scope, "--version"].join(" "); const lines = [ `# ${schema.bin} ${pick("command reference", "命令参考")}`, "", @@ -38,8 +40,8 @@ export function renderKnowledgeReference(schema: CommandSchema, locale: Locale = // Channel releases temporarily bump package versions before checking these tracked assets. `> ${pick("Schema version", "Schema 版本")}: ${schema.schema_version}.`, pick( - `> Query the installed CLI version with \`${schema.bin} --version\`.`, - `> 使用 \`${schema.bin} --version\` 查询已安装的 CLI 版本。`, + `> Query the installed CLI version with \`${versionCommand}\`.`, + `> 使用 \`${versionCommand}\` 查询已安装的 CLI 版本。`, ), "", pick(