From 096def288ed5fb8b911d563401837d9cba58faba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Mon, 19 Oct 2026 19:08:11 +0800 Subject: [PATCH 01/28] feat(runtime): add unified command output reporter - support DASHSCOPE_QUIET environment variable - expose result, progress, info, warning and debug via ctx.output - centralize output format, channel and quiet/verbose policies - share error classification and preserve diagnostic context - add output contract tests and maintenance guidance --- AGENTS.md | 1 + docs/agents/config-add.md | 1 + docs/agents/output-contract.md | 46 ++ packages/core/src/config/loader.ts | 2 +- packages/core/src/types/command.ts | 3 + packages/core/src/types/index.ts | 7 + packages/core/src/types/output.ts | 24 + packages/core/tests/config-priority.test.ts | 19 +- packages/runtime/src/create-cli.ts | 2 + packages/runtime/src/error-handler.ts | 216 +++++---- packages/runtime/src/index.ts | 3 + packages/runtime/src/middleware.ts | 2 + packages/runtime/src/output/diagnostics.ts | 74 +++ packages/runtime/src/output/reporter.ts | 149 ++++++ .../tests/auth-profile-fallback.test.ts | 2 + packages/runtime/tests/harness/output-main.ts | 23 + .../runtime/tests/output-reporter.e2e.test.ts | 42 ++ .../runtime/tests/output-reporter.test.ts | 447 ++++++++++++++++++ 18 files changed, 976 insertions(+), 87 deletions(-) create mode 100644 docs/agents/output-contract.md create mode 100644 packages/core/src/types/output.ts create mode 100644 packages/runtime/src/output/diagnostics.ts create mode 100644 packages/runtime/src/output/reporter.ts create mode 100644 packages/runtime/tests/harness/output-main.ts create mode 100644 packages/runtime/tests/output-reporter.e2e.test.ts create mode 100644 packages/runtime/tests/output-reporter.test.ts diff --git a/AGENTS.md b/AGENTS.md index 46e7c3a52..34162b819 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐 | 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) | | E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) | | 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) | +| 输出契约 | 新增输出、quiet/verbose、JSON 错误或流式输出 | [docs/agents/output-contract.md](docs/agents/output-contract.md) | | 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | | 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | | Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) | diff --git a/docs/agents/config-add.md b/docs/agents/config-add.md index c8d688705..1600b33d7 100644 --- a/docs/agents/config-add.md +++ b/docs/agents/config-add.md @@ -36,6 +36,7 @@ config 文件 ─┘ - [ ] `packages/core/src/config/loader.ts`: - `buildSources()` 如需新增来源,把 flag/file/env 纳入 sources - `buildSettings()` 加新字段的合并逻辑(`flags.x ?? process.env.XXX ?? file.x ?? default`) + - 开启型 switch(如 `quiet`)未传时解析为 `false`,环境变量回退用 `flags.quiet || env.DASHSCOPE_QUIET === "1"`,不要用 `??` 将默认 `false` 当作显式关闭 - 校验(数值范围、枚举合法性等) - 校验失败抛 `BailianError(USAGE)` diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md new file mode 100644 index 000000000..c65b6f387 --- /dev/null +++ b/docs/agents/output-contract.md @@ -0,0 +1,46 @@ +# 输出契约 + +## 触发条件 + +- 新增结果、进度、提示、警告或诊断输出 +- 修改 output / quiet / verbose 行为 +- 修改错误出口、流式或二进制输出 + +## 当前迁移阶段 + +第一波仅提供 DASHSCOPE_QUIET、ctx.output 和 reporter 组件。旧 emitResult/emitBare、底层 HTTP logger 和公共 handleError 尚未迁移;不能宣称任意命令的 JSON stderr 已经遵循新协议。 + +## 必查清单 + +### A. 新接口 + +- [ ] 用 ctx.output.result(data, { text? }) 提交一次最终结果,text 只控制文本呈现;JSON 始终序列化 data,false/0/null/空列表都合法 +- [ ] progress 与 info 写 stderr,仅 text 且非 quiet 时显示;progress 当前在非 TTY 下仍输出普通进度文本,不创建动画定时器;仅动画要求 TTY +- [ ] warning 始终保留;debug 仅 verbose 开启时显示;quiet 不关闭 verbose 明确请求的诊断 +- [ ] 消息支持 LocalizedText;warning/debug 可携带 code 与 details,details 的凭证键统一脱敏、循环/深层引用受控 +- [ ] 结果自身的凭证与敏感字段继续由命令在提交前按现有业务规则脱敏;不要把真实凭证拼进诊断 message + +### B. 错误与边界 + +- [ ] 业务不调用 error 或 process.exit;终止失败抛异常,由 runtime 统一处理 +- [ ] report 包含失败项时,先保留业务报告;若契约要求失败退出,再抛一次失败摘要。查询 FAILED 资源不自动等价于查询失败 +- [ ] text 错误保留 HTTP 状态、API 错误码、Request ID、cause 链及退出码;本地网络、超时、文件错误与旧出口共用分类,服务端错误原样透传 +- [ ] reporter.error 是后续 runtime 接入的内部渲染能力,只返回退出码,不自行退出进程;不把它添加到 CommandOutput +- [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析 +- [ ] 原始音频/字节流、既有事件流和 Command Pack API 1 仍走现有专用入口;不得绕过新接口承诺后混入 stdout +- [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr + +### C. 后续迁移 + +- [ ] 按命令完整调用链迁移:业务、middleware、传输日志、轮询/下载、错误出口必须一起核对 +- [ ] 移除 quiet 裁剪/吞结果分支,更新依赖 quiet 提取 ID/path 的示例 +- [ ] 迁移完后再为相应路径启用直接打印约束,保留协议/原始流等窄例外;不要先全仓禁用旧代码 + +## 完成后自查 + +```sh +pnpm exec vp test packages/runtime/tests/output-reporter.test.ts packages/runtime/tests/output-reporter.e2e.test.ts +pnpm exec vp check +``` + +本清单描述新输出组件的约定;存量命令完成整条调用链迁移后,才适用同样的输出保证。 diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index aa0c7137d..b2c7625da 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -285,7 +285,7 @@ export function buildSettings(s: ResolutionSources): Settings { consoleSite: (flags.consoleSite as Settings["consoleSite"]) || file.console_site || undefined, consoleSwitchAgent: flags.consoleSwitchAgent || file.console_switch_agent || undefined, verbose: flags.verbose || env.DASHSCOPE_VERBOSE === "1", - quiet: flags.quiet || false, + quiet: flags.quiet || env.DASHSCOPE_QUIET === "1", dryRun: flags.dryRun || false, telemetry: env.DO_NOT_TRACK === "1" ? false : (file.telemetry ?? true), }; diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index d090456a6..2db5f9a2d 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -1,3 +1,4 @@ +import type { CommandOutput } from "./output.ts"; import type { Identity, Settings } from "../config/schema.ts"; import type { ConfigStore } from "../config/store.ts"; import type { AuthStore } from "../auth/store.ts"; @@ -238,6 +239,8 @@ export function credentialFlagDefs(cmd: { auth: AuthRequirement }): FlagsDef { export interface CommandContext { /** 静态产品身份(binName/version/npmPackage/clientName)。 */ identity: Identity; + /** Unified per-invocation result and diagnostic output. */ + output: CommandOutput; /** * Locale selector for user-facing output: picks the `en-US` / `zh-CN` variant * of a {@link LocalizedText} per the resolved language. Supplied by the runtime diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 77fb30f67..44cbb7c87 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -86,3 +86,10 @@ export type { } from "./api.ts"; export type * from "./knowledge-admin.ts"; export type * from "./security.ts"; + +export type { + CommandOutput, + OutputResultOptions, + OutputDiagnostic, + OutputMessage, +} from "./output.ts"; diff --git a/packages/core/src/types/output.ts b/packages/core/src/types/output.ts new file mode 100644 index 000000000..8053290d6 --- /dev/null +++ b/packages/core/src/types/output.ts @@ -0,0 +1,24 @@ +import type { LocalizedText } from "./command.ts"; + +/** Custom text presentation never replaces the primary JSON data. */ +export interface OutputResultOptions { + text?: string; +} + +export interface OutputDiagnostic { + message: LocalizedText; + code?: string; + /** Structured diagnostic context; the runtime redacts credential keys. */ + details?: unknown; +} + +export type OutputMessage = LocalizedText | OutputDiagnostic; + +/** Per-invocation output surface. Throw terminal failures instead of printing them. */ +export interface CommandOutput { + result(data: unknown, options?: OutputResultOptions): void; + progress(message: LocalizedText): void; + info(message: LocalizedText): void; + warning(warning: OutputMessage): void; + debug(details: OutputMessage): void; +} diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 901cd3e1a..1753d0bd1 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -427,7 +427,7 @@ test("default*Model / outputDir:仅 file 源", () => { expect(resolve({}).defaultTextModel).toBeUndefined(); }); -test("quiet/dryRun:仅 flag 源,直通", () => { +test("quiet/dryRun:flag 开启、缺省关闭", () => { const on = resolve({ flags: { quiet: true, dryRun: true }, }); @@ -441,3 +441,20 @@ test("quiet/dryRun:仅 flag 源,直通", () => { dryRun: false, }); }); + +test.each([ + { envValue: undefined, expected: false }, + { envValue: "0", expected: false }, + { envValue: "1", expected: true }, + { envValue: "true", expected: false }, + { envValue: "", expected: false }, +])("DASHSCOPE_QUIET=$envValue resolves to $expected", ({ envValue, expected }) => { + const env: Record = envValue === undefined ? {} : { DASHSCOPE_QUIET: envValue }; + expect(resolve({ env }).quiet).toBe(expected); + // Switches absent from argv are materialized as false by parseFlags. + expect(resolve({ flags: { quiet: false }, env }).quiet).toBe(expected); +}); + +test("explicit --quiet overrides DASHSCOPE_QUIET=0", () => { + expect(resolve({ flags: { quiet: true }, env: { DASHSCOPE_QUIET: "0" } }).quiet).toBe(true); +}); diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 97db3582e..6f19f105d 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -33,6 +33,7 @@ import { } from "bailian-cli-core"; import { setupProxyFromEnv } from "./proxy.ts"; import { handleError } from "./error-handler.ts"; +import { createOutputReporter } from "./output/reporter.ts"; import { loadCommandPacks } from "./command-packs/load.ts"; import { createCommandPackManager } from "./command-packs/manager.ts"; import type { CommandPackPolicy } from "./command-packs/types.ts"; @@ -210,6 +211,7 @@ export function createCli(commands: Record, opts: CliOptions const settings = buildSettings(sources); const ctx: RunContext = { identity, + output: createOutputReporter({ settings, localize, binName: identity.binName }).output, path: res.path, command: res.command, flags: ownFlags, diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index b3d892041..8bf7b25f9 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -1,4 +1,10 @@ -import { BailianError, ExitCode, detectOutputFormat, type OutputFormat } from "bailian-cli-core"; +import { + BailianError, + ExitCode, + detectOutputFormat, + type LocalizedText, + type OutputFormat, +} from "bailian-cli-core"; import { API_KEY_PAGE } from "./urls.ts"; const LABEL_WIDTH = 13; @@ -6,11 +12,6 @@ const LABEL_WIDTH = 13; /** Binary name used in error hints; set by handleError() so its helpers can read it. */ let binName: string; -/** Short reminder; full resolution order matches `resolveApiKey` in bailian-cli-core. */ -function baseUrlHint(): string { - return `If the DashScope host is wrong, check baseUrl (--base-url, ${binName} config show, or DASHSCOPE_BASE_URL).`; -} - function pad(label: string): string { return label.padEnd(LABEL_WIDTH); } @@ -68,61 +69,6 @@ export function detectErrorOutputFormat( return detectOutputFormat(envOutput); } -function fromFetchFailed(err: TypeError): BailianError { - const cause = err.cause as NodeJS.ErrnoException | undefined; - const code = cause?.code; - const causeMsg = cause?.message; - - const detailParts: string[] = []; - if (code) detailParts.push(code); - if (causeMsg && causeMsg !== code) detailParts.push(causeMsg); - const detail = detailParts.length > 0 ? detailParts.join(": ") : "unknown cause"; - - // Prefer the errno (ENOTFOUND, …) so JSON toJSON() exposes cause.code for agents. - return new BailianError( - `Network request failed: ${detail}`, - ExitCode.NETWORK, - pickNetworkHint(code), - { cause: cause ?? err }, - ); -} - -function pickNetworkHint(code: string | undefined): string { - switch (code) { - case "ENOTFOUND": - case "EAI_AGAIN": - return `DNS resolution failed. Check DNS / network. ${baseUrlHint()}`; - case "ECONNREFUSED": - return "Connection refused. Check the target host/port and proxy settings."; - case "ECONNRESET": - return ( - "Connection reset by peer. Retry, or check proxy / firewall.\n" + - `If you are behind a VPN or corporate proxy, route ${binName} through it:\n` + - "export HTTPS_PROXY=http://127.0.0.1:" - ); - case "ETIMEDOUT": - return `Connection timed out. Check your network. ${baseUrlHint()}`; - case "CERT_HAS_EXPIRED": - case "UNABLE_TO_VERIFY_LEAF_SIGNATURE": - case "DEPTH_ZERO_SELF_SIGNED_CERT": - return "TLS certificate error. Check system clock and CA bundle."; - default: - return `Check network and proxy (HTTP_PROXY / HTTPS_PROXY). ${baseUrlHint()}`; - } -} - -function fromFsError(err: NodeJS.ErrnoException): BailianError { - const ecode = err.code; - let hint = "Check the file path and permissions."; - if (ecode === "ENOENT") hint = "File or directory not found."; - else if (ecode === "EACCES" || ecode === "EPERM") - hint = "Permission denied — check file or directory permissions."; - else if (ecode === "ENOSPC") hint = "Disk full — free up space and try again."; - return new BailianError(`File system error: ${err.message}`, ExitCode.GENERAL, hint, { - cause: err, - }); -} - function writeCauseChain(err: Error): void { let cur: unknown = (err as { cause?: unknown }).cause; let depth = 0; @@ -174,30 +120,8 @@ export function handleError(err: unknown, cliName: string): never { } if (err instanceof Error) { - if ( - err.name === "AbortError" || - err.name === "TimeoutError" || - err.message.includes("timed out") - ) { - const timeout = new BailianError( - "Request timed out.", - ExitCode.TIMEOUT, - "Try increasing --timeout (e.g. --timeout 60).\n" + - `${baseUrlHint()}\n` + - `Run: ${binName} auth status — to check credentials.`, - { cause: err }, - ); - return handleError(timeout, binName); - } - - if (err instanceof TypeError && err.message === "fetch failed") { - return handleError(fromFetchFailed(err), binName); - } - - const ecode = (err as NodeJS.ErrnoException).code; - if (typeof ecode === "string" && ecode.startsWith("E")) { - return handleError(fromFsError(err as NodeJS.ErrnoException), binName); - } + const classified = classifyRuntimeError(err, binName); + if (classified) return handleError(classified, binName); process.stderr.write(`\n${pad("Error:")}${err.message}\n`); writeCauseChain(err); @@ -210,3 +134,125 @@ export function handleError(err: unknown, cliName: string): never { process.exit(ExitCode.GENERAL); } + +type Localize = (text: LocalizedText) => string; +const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]); + +/** Shared local-error classification; service errors are never reclassified. */ +export function classifyRuntimeError( + error: Error, + binName: 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)。`, + }); + if ( + error.name === "AbortError" || + error.name === "TimeoutError" || + error.message.includes("timed out") + ) { + return new BailianError( + 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 检查凭证。`, + }), + { cause: error }, + ); + } + if (error instanceof TypeError && error.message === "fetch failed") { + const cause = error.cause as NodeJS.ErrnoException | undefined; + const detailParts = [ + cause?.code, + cause?.message !== cause?.code ? cause?.message : undefined, + ].filter(Boolean); + const detail = detailParts.length + ? detailParts.join(": ") + : localize({ "en-US": "unknown cause", "zh-CN": "未知原因" }); + return new BailianError( + localize({ + "en-US": `Network request failed: ${detail}`, + "zh-CN": `网络请求失败:${detail}`, + }), + ExitCode.NETWORK, + networkHint(cause?.code, binName, baseUrlHint, localize), + { cause: cause ?? error }, + ); + } + const errno = (error as NodeJS.ErrnoException).code; + if (typeof errno === "string" && errno.startsWith("E")) { + let hint: LocalizedText = { + "en-US": "Check the file path and permissions.", + "zh-CN": "请检查文件路径和权限。", + }; + if (errno === "ENOENT") + hint = { "en-US": "File or directory not found.", "zh-CN": "文件或目录不存在。" }; + else if (errno === "EACCES" || errno === "EPERM") + hint = { + "en-US": "Permission denied — check file or directory permissions.", + "zh-CN": "权限不足,请检查文件或目录权限。", + }; + else if (errno === "ENOSPC") + hint = { + "en-US": "Disk full — free up space and try again.", + "zh-CN": "磁盘已满,请释放空间后重试。", + }; + return new BailianError( + localize({ + "en-US": `File system error: ${error.message}`, + "zh-CN": `文件系统错误:${error.message}`, + }), + ExitCode.GENERAL, + localize(hint), + { cause: error }, + ); + } + return undefined; +} + +function networkHint( + code: string | undefined, + binName: string, + baseUrlHint: string, + localize: Localize, +): string { + switch (code) { + case "ENOTFOUND": + case "EAI_AGAIN": + return localize({ + "en-US": `DNS resolution failed. Check DNS / network. ${baseUrlHint}`, + "zh-CN": `DNS 解析失败,请检查 DNS 和网络。${baseUrlHint}`, + }); + case "ECONNREFUSED": + return localize({ + "en-US": "Connection refused. Check the target host/port and proxy settings.", + "zh-CN": "连接被拒绝,请检查目标主机、端口和代理配置。", + }); + 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:`, + }); + case "ETIMEDOUT": + return localize({ + "en-US": `Connection timed out. Check your network. ${baseUrlHint}`, + "zh-CN": `连接超时,请检查网络。${baseUrlHint}`, + }); + case "CERT_HAS_EXPIRED": + case "UNABLE_TO_VERIFY_LEAF_SIGNATURE": + case "DEPTH_ZERO_SELF_SIGNED_CERT": + return localize({ + "en-US": "TLS certificate error. Check system clock and CA bundle.", + "zh-CN": "TLS 证书错误,请检查系统时间和 CA 证书。", + }); + default: + return localize({ + "en-US": `Check network and proxy (HTTP_PROXY / HTTPS_PROXY). ${baseUrlHint}`, + "zh-CN": `请检查网络和代理(HTTP_PROXY / HTTPS_PROXY)。${baseUrlHint}`, + }); + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index e0844208f..a4bf7be66 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -102,3 +102,6 @@ export { initPipelineSteps } from "./pipeline/init.ts"; export { executePipeline, streamPipelineEvents } from "./pipeline/executor.ts"; export { collectPipelineIssues, collectPipelineHints } from "./pipeline/validation.ts"; export type { PipelineDefinition, PipelineLifecycleEvent } from "./pipeline/types.ts"; + +export { createOutputReporter } from "./output/reporter.ts"; +export type { OutputReporter, OutputReporterOptions, OutputWriter } from "./output/reporter.ts"; diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 660916912..b9c08c7e1 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -1,5 +1,6 @@ import type { AnyCommand, + CommandOutput, ApiKeyCredential, AuthStore, ConfigStore, @@ -42,6 +43,7 @@ import { ConfirmationRequiredError, confirmationHint } from "./confirm.ts"; export interface RunContext { /** 静态产品身份(binName/version/npmPackage/clientName)。 */ readonly identity: Identity; + readonly output: CommandOutput; /** The matched command path, e.g. ["speech","recognize"]. */ readonly path: string[]; readonly command: AnyCommand; diff --git a/packages/runtime/src/output/diagnostics.ts b/packages/runtime/src/output/diagnostics.ts new file mode 100644 index 000000000..1c704fb3a --- /dev/null +++ b/packages/runtime/src/output/diagnostics.ts @@ -0,0 +1,74 @@ +import type { LocalizedText, OutputMessage } from "bailian-cli-core"; + +const MAX_DIAGNOSTIC_DEPTH = 8; +const SECRET_KEY = + /(?:authorization|cookie|apikey|accesskey(?:id)?|privatekey|credentials?|token|password|secret)$/i; + +/** Convert diagnostics to inert data; never invoke accessors or serialization hooks. */ +export function sanitizeDiagnosticDetails( + value: unknown, + ancestors = new Set(), + depth = 0, +): unknown { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "function") return "[Function]"; + if (value === null || typeof value !== "object") return value; + if (ancestors.has(value)) return "[Circular]"; + if (depth >= MAX_DIAGNOSTIC_DEPTH) return "[Truncated]"; + ancestors.add(value); + try { + const descriptors = Object.getOwnPropertyDescriptors(value); + const readDescriptor = (descriptor: PropertyDescriptor | undefined): unknown => { + if (!descriptor) return undefined; + if (!("value" in descriptor)) return "[Accessor]"; + return sanitizeDiagnosticDetails(descriptor.value as unknown, ancestors, depth + 1); + }; + if (Array.isArray(value)) { + const length = descriptors.length?.value as number; + return Array.from({ length }, (_entry, index) => readDescriptor(descriptors[String(index)])); + } + return Object.fromEntries( + Object.entries(descriptors) + .filter(([, descriptor]) => descriptor.enumerable) + .map(([key, descriptor]) => [ + key, + SECRET_KEY.test(key.replaceAll(/[-_]/g, "")) ? "[REDACTED]" : readDescriptor(descriptor), + ]), + ); + } catch { + // Proxy traps and exotic objects must not make verbose change business behavior. + return "[Unserializable]"; + } finally { + ancestors.delete(value); + } +} + +export function diagnosticPayload( + input: OutputMessage, + localize: (text: LocalizedText) => string, +): { message: string; code?: string; details?: unknown } { + if (typeof input === "string") return { message: localize(input) }; + try { + const descriptors = Object.getOwnPropertyDescriptors(input); + const readField = (descriptor: PropertyDescriptor | undefined): unknown => { + if (!descriptor) return undefined; + return "value" in descriptor + ? sanitizeDiagnosticDetails(descriptor.value as unknown) + : "[Accessor]"; + }; + if (Object.hasOwn(descriptors, "message")) { + const message = readField(descriptors.message) as LocalizedText; + const code = readField(descriptors.code); + const details = readField(descriptors.details); + return { + message: localize(message), + ...(typeof code === "string" ? { code } : {}), + ...(details === undefined ? {} : { details }), + }; + } + return { message: localize(sanitizeDiagnosticDetails(input) as LocalizedText) }; + } catch { + // Top-level proxy traps and malformed diagnostics must not interrupt commands. + return { message: "[Unserializable]" }; + } +} diff --git a/packages/runtime/src/output/reporter.ts b/packages/runtime/src/output/reporter.ts new file mode 100644 index 000000000..0ab9247f2 --- /dev/null +++ b/packages/runtime/src/output/reporter.ts @@ -0,0 +1,149 @@ +import { stripVTControlCharacters } from "node:util"; +import { + BailianError, + ExitCode, + formatOutput, + type CommandOutput, + type LocalizedText, + type OutputMessage, + type Settings, +} from "bailian-cli-core"; +import { classifyRuntimeError } from "../error-handler.ts"; +import { createTranslator } from "../i18n.ts"; +import { diagnosticPayload, sanitizeDiagnosticDetails } from "./diagnostics.ts"; + +export interface OutputWriter { + write(chunk: string): unknown; +} + +export interface OutputReporterOptions { + settings: Pick; + stdout?: OutputWriter; + stderr?: OutputWriter; + localize?: (text: LocalizedText) => string; + binName?: string; +} + +export interface OutputReporter { + output: CommandOutput; + /** Runtime-only terminal error rendering. The lifecycle owns process exit. */ + error(error: unknown): ExitCode; +} + +/** No global interception: legacy emitters remain separate until they are migrated. */ +export function createOutputReporter(options: OutputReporterOptions): OutputReporter { + const { output: format, quiet, verbose } = options.settings; + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const translator = createTranslator("en-US"); + const localize = options.localize ?? ((text: LocalizedText) => translator.localize(text)); + let resultWritten = false; + let errorCode: ExitCode | undefined; + + function diagnostic(level: "warning" | "debug", input: OutputMessage): void { + const payload = diagnosticPayload(input, localize); + if (format === "json") { + stderr.write(JSON.stringify({ [level]: payload }) + "\n"); + } else { + const label = + level === "warning" ? localize({ "en-US": "Warning", "zh-CN": "警告" }) : "debug"; + const details = payload.details === undefined ? "" : ` ${JSON.stringify(payload.details)}`; + stderr.write(stripVTControlCharacters(`${label}: ${payload.message}${details}`) + "\n"); + } + } + + function information(message: LocalizedText): void { + if (format === "text" && !quiet) { + stderr.write(stripVTControlCharacters(diagnosticPayload(message, localize).message) + "\n"); + } + } + + return { + output: { + result(data, resultOptions) { + if (resultWritten) { + throw new BailianError( + localize({ + "en-US": "A final result has already been written.", + "zh-CN": "最终结果已经输出,不能重复输出。", + }), + ); + } + // Serialize before writing; invalid values must not reserve the result slot. + const serialized = + format === "text" && resultOptions?.text !== undefined + ? resultOptions.text + : formatOutput(data, format); + if (typeof serialized !== "string") { + throw new BailianError( + localize({ + "en-US": "The final result is not serializable.", + "zh-CN": "最终结果无法序列化。", + }), + ); + } + stdout.write(serialized + "\n"); + resultWritten = true; + }, + progress: information, + info: information, + warning: (warning) => diagnostic("warning", warning), + debug(details) { + if (verbose) diagnostic("debug", details); + }, + }, + error(error) { + if (errorCode !== undefined) return errorCode; + const failure = + error instanceof BailianError + ? error + : ((error instanceof Error + ? classifyRuntimeError(error, options.binName ?? "cli", localize) + : undefined) ?? + new BailianError( + error instanceof Error ? error.message : describeThrownValue(error), + ExitCode.GENERAL, + undefined, + error instanceof Error ? { cause: error } : undefined, + )); + if (format === "json") { + stderr.write(JSON.stringify(failure.toJSON()) + "\n"); + } else { + const lines: string[] = []; + const field = (label: LocalizedText, value: string | number) => + lines.push(`${localize(label)}: ${value}`); + field({ "en-US": "Error", "zh-CN": "错误" }, failure.message); + if (failure.hint) field({ "en-US": "Hint", "zh-CN": "提示" }, failure.hint); + if (failure.api?.httpStatus !== undefined) { + const suffix = failure.api.apiCode ? ` (${failure.api.apiCode})` : ""; + field({ "en-US": "Status", "zh-CN": "状态" }, `HTTP ${failure.api.httpStatus}${suffix}`); + } else if (failure.api?.apiCode) + field({ "en-US": "Code", "zh-CN": "错误码" }, failure.api.apiCode); + if (failure.api?.requestId) + field({ "en-US": "Request ID", "zh-CN": "请求 ID" }, failure.api.requestId); + let cause: unknown = failure.cause; + const seen = new Set(); + while (cause instanceof Error && seen.size < 5 && !seen.has(cause)) { + seen.add(cause); + const code = (cause as NodeJS.ErrnoException).code; + field( + { "en-US": "Caused by", "zh-CN": "原因" }, + code ? `${code}: ${cause.message}` : cause.message, + ); + cause = cause.cause; + } + field({ "en-US": "Exit code", "zh-CN": "退出码" }, failure.exitCode); + stderr.write(stripVTControlCharacters(lines.join("\n")) + "\n"); + } + errorCode = failure.exitCode; + return errorCode; + }, + }; +} + +function describeThrownValue(value: unknown): string { + if (value !== null && typeof value === "object") { + return JSON.stringify(sanitizeDiagnosticDetails(value)); + } + return String(value); +} diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index d102b3a15..96f3516ef 100644 --- a/packages/runtime/tests/auth-profile-fallback.test.ts +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -1,3 +1,4 @@ +import { createOutputReporter } from "../src/output/reporter.ts"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -54,6 +55,7 @@ function makeContext( npmPackage: "bailian-cli", clientName: "bailian-cli-test", }, + output: createOutputReporter({ settings }).output, path, command, flags: {}, diff --git a/packages/runtime/tests/harness/output-main.ts b/packages/runtime/tests/harness/output-main.ts new file mode 100644 index 000000000..acd9302e7 --- /dev/null +++ b/packages/runtime/tests/harness/output-main.ts @@ -0,0 +1,23 @@ +import { defineCommand } from "bailian-cli-core"; +import { createCli } from "../../src/create-cli.ts"; + +const probe = defineCommand({ + description: "Output reporter integration probe", + auth: "none", + async run(context) { + context.output.info({ "en-US": "Using cache", "zh-CN": "使用缓存" }); + context.output.warning({ code: "SKIPPED", message: { "en-US": "Skipped", "zh-CN": "已跳过" } }); + context.output.debug({ message: "HTTP response", details: { status: 200 } }); + context.output.result({ task_id: "task-demo" }, { text: "task-demo" }); + }, +}); + +await createCli( + { probe }, + { + binName: "test-cli", + version: "0.0.0", + clientName: "output-reporter-test", + npmPackage: "output-reporter-test", + }, +).run(); diff --git a/packages/runtime/tests/output-reporter.e2e.test.ts b/packages/runtime/tests/output-reporter.e2e.test.ts new file mode 100644 index 000000000..67c878044 --- /dev/null +++ b/packages/runtime/tests/output-reporter.e2e.test.ts @@ -0,0 +1,42 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { expect, test } from "vite-plus/test"; +import { runNodeMain } from "e2e/runner"; + +const runtimeRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const main = join(runtimeRoot, "tests/harness/output-main.ts"); + +test.each(["en-US", "zh-CN"])("createCli injects per-call JSON reporter (%s)", async (language) => { + const configDir = mkdtempSync(join(tmpdir(), "bl-output-reporter-")); + try { + writeFileSync(join(configDir, "config.json"), JSON.stringify({ language, output: "json" })); + writeFileSync( + join(configDir, "update-state.json"), + JSON.stringify({ lastChecked: Date.now(), latestVersion: "0.0.0" }), + ); + const result = await runNodeMain(main, ["probe", "--verbose"], { + cwd: runtimeRoot, + env: { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_QUIET: "1", + DASHSCOPE_OUTPUT: "", + DASHSCOPE_VERBOSE: "0", + CI: "1", + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ task_id: "task-demo" }); + const lines = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(lines).toEqual([ + { warning: { code: "SKIPPED", message: language === "zh-CN" ? "已跳过" : "Skipped" } }, + { debug: { message: "HTTP response", details: { status: 200 } } }, + ]); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/tests/output-reporter.test.ts b/packages/runtime/tests/output-reporter.test.ts new file mode 100644 index 000000000..59ff6daff --- /dev/null +++ b/packages/runtime/tests/output-reporter.test.ts @@ -0,0 +1,447 @@ +import { expect, test } from "vite-plus/test"; +import { BailianError, ExitCode, type Settings } from "bailian-cli-core"; +import { createOutputReporter } from "../src/output/reporter.ts"; +import { createTranslator } from "../src/i18n.ts"; + +function capture(settings: Pick) { + let stdout = ""; + let stderr = ""; + const reporter = createOutputReporter({ + settings, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + localize: (text) => createTranslator("en-US").localize(text), + }); + return { reporter, stdout: () => stdout, stderr: () => stderr }; +} + +const matrix = (["text", "json"] as const).flatMap((output) => + [false, true].flatMap((quiet) => [false, true].map((verbose) => ({ output, quiet, verbose }))), +); + +test.each(matrix)("output matrix $output quiet=$quiet verbose=$verbose", (settings) => { + const streams = capture(settings); + const { output } = streams.reporter; + const result = { task_id: "task-demo", saved: ["/tmp/demo.png"] }; + output.progress("Downloading 50%"); + output.info("Using cache"); + output.warning({ code: "SKIPPED", message: "One file skipped" }); + output.debug({ message: "HTTP response", details: { status: 200 } }); + output.result(result, { text: "task-demo" }); + expect(streams.stdout()).toBe( + settings.output === "json" ? JSON.stringify(result, null, 2) + "\n" : "task-demo\n", + ); + if (settings.output === "json") { + const lines = streams + .stderr() + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(lines).toEqual([ + { warning: { code: "SKIPPED", message: "One file skipped" } }, + ...(settings.verbose + ? [{ debug: { message: "HTTP response", details: { status: 200 } } }] + : []), + ]); + } else { + expect(streams.stderr().includes("Downloading 50%")).toBe(!settings.quiet); + expect(streams.stderr().includes("Using cache")).toBe(!settings.quiet); + expect(streams.stderr()).toContain("One file skipped"); + expect(streams.stderr().includes("HTTP response")).toBe(settings.verbose); + expect(streams.stderr()).not.toContain("\u001b"); + } + expect("error" in output).toBe(false); +}); + +test.each([false, 0, null, [], ""])("preserves valid JSON result %j", (value) => { + const streams = capture({ output: "json", quiet: true, verbose: true }); + streams.reporter.output.result(value); + expect(JSON.parse(streams.stdout())).toEqual(value); +}); + +test("rejects undefined before writing and allows a later valid result", () => { + const streams = capture({ output: "json", quiet: false, verbose: false }); + expect(() => streams.reporter.output.result(undefined)).toThrow(/serializ/i); + expect(streams.stdout()).toBe(""); + streams.reporter.output.result({ valid: true }); + expect(JSON.parse(streams.stdout())).toEqual({ valid: true }); +}); + +test("rejects a second final result without appending another JSON document", () => { + const streams = capture({ output: "json", quiet: false, verbose: false }); + streams.reporter.output.result({ first: true }); + const original = streams.stdout(); + expect(() => streams.reporter.output.result({ second: true })).toThrow(/already/i); + expect(streams.stdout()).toBe(original); +}); + +test("uses the existing text formatter when no text override is supplied", () => { + const streams = capture({ output: "text", quiet: true, verbose: false }); + streams.reporter.output.result({ task_id: "task-demo" }); + expect(streams.stdout()).toBe("task_id: task-demo\n"); +}); + +test("independent reporters retain their settings and output destinations", async () => { + const settings = { output: "json" as const, quiet: true, verbose: false }; + const first = capture(settings); + const second = capture({ output: "text", quiet: false, verbose: true }); + settings.quiet = false; + await Promise.all([ + Promise.resolve().then(() => { + first.reporter.output.info("hidden"); + first.reporter.output.result(0); + }), + Promise.resolve().then(() => { + second.reporter.output.info("shown"); + second.reporter.output.result(false, { text: "no" }); + }), + ]); + expect(first.stdout()).toBe("0\n"); + expect(first.stderr()).toBe(""); + expect(second.stdout()).toBe("no\n"); + expect(second.stderr()).toContain("shown"); +}); + +test("localizes messages without changing machine codes", () => { + let stderr = ""; + const reporter = createOutputReporter({ + settings: { output: "json", quiet: false, verbose: false }, + localize: (text) => createTranslator("zh-CN").localize(text), + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + reporter.output.warning({ code: "SKIPPED", message: { "en-US": "Skipped", "zh-CN": "已跳过" } }); + expect(JSON.parse(stderr)).toEqual({ warning: { code: "SKIPPED", message: "已跳过" } }); +}); + +test("writes warnings, debug and a terminal error as separate JSON lines only once", () => { + const streams = capture({ output: "json", quiet: true, verbose: true }); + streams.reporter.output.warning("warning\ncontinued"); + streams.reporter.output.debug("debug\ncontinued"); + const failure = new BailianError("Server original message", ExitCode.GENERAL, "Retry later", { + api: { httpStatus: 500, apiCode: "ServerFailure", requestId: "request-demo" }, + cause: new Error("root cause"), + }); + expect(streams.reporter.error(failure)).toBe(ExitCode.GENERAL); + expect(streams.reporter.error(failure)).toBe(ExitCode.GENERAL); + const lines = streams + .stderr() + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(lines).toEqual([ + { warning: { message: "warning\ncontinued" } }, + { debug: { message: "debug\ncontinued" } }, + failure.toJSON(), + ]); + expect(streams.stdout()).toBe(""); +}); + +test.each([new SyntaxError("Invalid JSON"), "thrown string", null])( + "normalizes unknown failure %j", + (failure) => { + const streams = capture({ output: "json", quiet: true, verbose: false }); + expect(streams.reporter.error(failure)).toBe(ExitCode.GENERAL); + const error = JSON.parse(streams.stderr()).error; + expect(error.code).toBe(ExitCode.GENERAL); + expect(error.message).toBe(failure instanceof Error ? failure.message : String(failure)); + }, +); + +test("keeps failure reports on stdout and terminal error on stderr", () => { + const streams = capture({ output: "json", quiet: true, verbose: false }); + streams.reporter.output.result({ valid: false, issues: ["bad row"] }); + expect(streams.reporter.error(new BailianError("Validation failed", ExitCode.GENERAL))).toBe(1); + expect(JSON.parse(streams.stdout())).toEqual({ valid: false, issues: ["bad row"] }); + expect(JSON.parse(streams.stderr()).error.message).toBe("Validation failed"); +}); + +test("redacts structured diagnostic credentials and handles cycles and BigInt", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + const details: Record = { + authorization: "example-authorization", + api_key: "example-key", + accessKeySecret: "example-secret", + nested: { token: "example-token", request_id: "request-demo" }, + count: 42n, + }; + details.self = details; + streams.reporter.output.debug({ message: "Request", details }); + const payload = JSON.parse(streams.stderr()).debug; + expect(payload.details).toMatchObject({ + authorization: "[REDACTED]", + api_key: "[REDACTED]", + accessKeySecret: "[REDACTED]", + nested: { token: "[REDACTED]", request_id: "request-demo" }, + count: "42", + self: "[Circular]", + }); + expect(details.api_key).toBe("example-key"); + expect(streams.stderr()).not.toContain("example-token"); +}); + +test("text diagnostics remove terminal escape sequences", () => { + const streams = capture({ output: "text", quiet: false, verbose: true }); + streams.reporter.output.warning("\u001b[31mwarning\u001b[0m"); + streams.reporter.output.debug({ message: "Details", details: { status: 200 } }); + streams.reporter.error(new Error("failure")); + expect(streams.stderr()).toContain("warning"); + expect(streams.stderr()).toContain("failure"); + expect(streams.stderr()).not.toContain("\u001b"); +}); + +test("redacts credential keys with environment and HTTP header prefixes", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + streams.reporter.output.debug({ + message: "Request context", + details: { + DASHSCOPE_API_KEY: "fake-dashscope-key", + ALIBABA_CLOUD_ACCESS_KEY_ID: "fake-access-key", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-access-secret", + headers: { "x-api-key": "fake-header-key", "Proxy-Authorization": "fake-proxy-auth" }, + request_id: "request-demo", + }, + }); + expect(JSON.parse(streams.stderr()).debug.details).toEqual({ + DASHSCOPE_API_KEY: "[REDACTED]", + ALIBABA_CLOUD_ACCESS_KEY_ID: "[REDACTED]", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "[REDACTED]", + headers: { "x-api-key": "[REDACTED]", "Proxy-Authorization": "[REDACTED]" }, + request_id: "request-demo", + }); +}); + +test("does not execute diagnostic toJSON hooks that bypass redaction", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + let calls = 0; + streams.reporter.output.debug({ + message: "Diagnostic", + details: { + api_key: "fake-secret", + toJSON() { + calls += 1; + return { api_key: "fake-secret" }; + }, + }, + }); + expect(calls).toBe(0); + expect(JSON.parse(streams.stderr()).debug.details.api_key).toBe("[REDACTED]"); + expect(streams.stderr()).not.toContain("fake-secret"); +}); + +test("diagnostic toJSON returning BigInt cannot break JSON line output", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + expect(() => + streams.reporter.output.warning({ + message: "Diagnostic", + details: { + toJSON() { + return 42n; + }, + }, + }), + ).not.toThrow(); + expect(JSON.parse(streams.stderr()).warning.message).toBe("Diagnostic"); +}); + +test("does not invoke diagnostic accessors on objects or arrays", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + let reads = 0; + const details = Object.defineProperty({}, "response", { + enumerable: true, + get() { + reads += 1; + throw new Error("accessor must not run"); + }, + }); + const entries = ["initial"]; + Object.defineProperty(entries, "0", { + get() { + reads += 1; + throw new Error("array accessor must not run"); + }, + }); + expect(() => + streams.reporter.output.debug({ message: "Details", details: { details, entries } }), + ).not.toThrow(); + expect(reads).toBe(0); + expect(JSON.parse(streams.stderr()).debug.details).toEqual({ + details: { response: "[Accessor]" }, + entries: ["[Accessor]"], + }); +}); + +test("a revoked proxy in diagnostic details cannot fail the business operation", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + const proxy = Proxy.revocable({}, {}); + proxy.revoke(); + expect(() => + streams.reporter.output.debug({ message: "Details", details: proxy.proxy }), + ).not.toThrow(); + streams.reporter.output.result({ completed: true }); + expect(JSON.parse(streams.stdout())).toEqual({ completed: true }); + expect(JSON.parse(streams.stderr()).debug.details).toBe("[Unserializable]"); +}); + +test.each([false, true])("text errors retain API context and causes with verbose=%s", (verbose) => { + const streams = capture({ output: "text", quiet: true, verbose }); + const failure = new BailianError("Original service message", ExitCode.GENERAL, "Retry later", { + api: { httpStatus: 503, apiCode: "Unavailable", requestId: "request-demo" }, + cause: new Error("transport cause", { cause: new Error("root cause") }), + }); + streams.reporter.error(failure); + expect(streams.stderr()).toContain("Original service message"); + expect(streams.stderr()).toContain("Retry later"); + expect(streams.stderr()).toContain("HTTP 503"); + expect(streams.stderr()).toContain("Unavailable"); + expect(streams.stderr()).toContain("request-demo"); + expect(streams.stderr()).toContain("transport cause"); + expect(streams.stderr()).toContain("root cause"); + expect(streams.stderr()).toContain("Exit code: 1"); +}); + +test("normalizes fetch failures without losing network exit code or errno", () => { + const streams = capture({ output: "json", quiet: true, verbose: false }); + const cause = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), { + code: "ENOTFOUND", + }); + expect(streams.reporter.error(new TypeError("fetch failed", { cause }))).toBe(ExitCode.NETWORK); + const failure = JSON.parse(streams.stderr()).error; + expect(failure.code).toBe(ExitCode.NETWORK); + expect(failure.message).toContain("ENOTFOUND"); + expect(failure.cause).toEqual({ message: cause.message, code: "ENOTFOUND" }); +}); + +test.each([ + { failure: Object.assign(new Error("aborted"), { name: "AbortError" }), code: ExitCode.TIMEOUT }, + { failure: Object.assign(new Error("missing file"), { code: "ENOENT" }), code: ExitCode.GENERAL }, +])("preserves local error classification $code", ({ failure, code }) => { + const streams = capture({ output: "json", quiet: false, verbose: false }); + expect(streams.reporter.error(failure)).toBe(code); + const payload = JSON.parse(streams.stderr()).error; + expect(payload.hint).toBeTruthy(); + expect(payload.cause.message).toBe(failure.message); +}); + +test("plain progress remains readable on non-TTY streams without animation", () => { + const streams = capture({ output: "text", quiet: false, verbose: false }); + streams.reporter.output.progress("Downloading 50%"); + expect(streams.stderr()).toBe("Downloading 50%\n"); +}); + +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", + localize: (text) => createTranslator("zh-CN").localize(text), + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + reporter.error(new TypeError("fetch failed")); + const payload = JSON.parse(stderr).error; + expect(payload.code).toBe(ExitCode.NETWORK); + expect(payload.hint).toContain("kscli config show"); + expect(payload.hint).toContain("请检查网络"); +}); + +test("service failures retain their message and exit code despite timeout keywords", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + const failure = new BailianError("Server request timed out", ExitCode.GENERAL, undefined, { + api: { httpStatus: 504, apiCode: "ServerTimeout", requestId: "request-timeout" }, + }); + expect(streams.reporter.error(failure)).toBe(ExitCode.GENERAL); + expect(JSON.parse(streams.stderr())).toEqual(failure.toJSON()); +}); + +test.each(["message", "code", "details"] as const)( + "outer diagnostic %s accessors cannot change business results", + (field) => { + for (const verbose of [false, true]) { + const streams = capture({ output: "json", quiet: true, verbose }); + let reads = 0; + const diagnostic = Object.defineProperty( + { message: "Diagnostic", code: "TEST", details: {} }, + field, + { + get() { + reads += 1; + throw new Error("outer accessor must not run"); + }, + }, + ); + expect(() => { + streams.reporter.output.debug(diagnostic); + streams.reporter.output.result({ completed: true }); + }).not.toThrow(); + expect(reads).toBe(0); + expect(JSON.parse(streams.stdout())).toEqual({ completed: true }); + if (verbose) expect(JSON.parse(streams.stderr()).debug[field]).toBe("[Accessor]"); + } + }, +); + +test("outer diagnostic proxy traps cannot interrupt a warning or result", () => { + const streams = capture({ output: "json", quiet: false, verbose: false }); + const diagnostic = Proxy.revocable({ message: "Diagnostic" }, {}); + diagnostic.revoke(); + expect(() => { + streams.reporter.output.warning(diagnostic.proxy); + streams.reporter.output.result({ completed: true }); + }).not.toThrow(); + expect(JSON.parse(streams.stdout())).toEqual({ completed: true }); + expect(JSON.parse(streams.stderr()).warning.message).toBe("[Unserializable]"); +}); + +test("localized diagnostic message accessors are not invoked", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + let reads = 0; + const message = { + get "en-US"(): string { + reads += 1; + throw new Error("localized accessor must not run"); + }, + "zh-CN": "诊断", + }; + expect(() => streams.reporter.output.debug({ message })).not.toThrow(); + expect(reads).toBe(0); + expect(JSON.parse(streams.stderr()).debug.message).toBe("[Accessor]"); +}); + +test.each(["info", "progress"] as const)( + "%s localized getters cannot change results when quiet is toggled", + (method) => { + for (const quiet of [false, true]) { + const streams = capture({ output: "text", quiet, verbose: false }); + let reads = 0; + const message = { + get "en-US"(): string { + reads += 1; + throw new Error("informational accessor must not run"); + }, + "zh-CN": "过程提示", + }; + expect(() => { + streams.reporter.output[method](message); + streams.reporter.output.result({ completed: true }, { text: "completed" }); + }).not.toThrow(); + expect(reads).toBe(0); + expect(streams.stdout()).toBe("completed\n"); + expect(streams.stderr()).toBe(quiet ? "" : "[Accessor]\n"); + } + }, +); From 78c7414f891da190c4a2ee774aba5aa2f7a9f1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 28 Oct 2026 11:13:48 +0800 Subject: [PATCH 02/28] refactor(runtime): unify CLI errors and diagnostic output - Unify error, warning, debug, and polling progress output - Capture update subprocess output and prevent duplicate process listeners - Preserve existing download progress behavior - Emit UPDATE_AVAILABLE warnings in JSON and quiet modes, including cached updates - Add output contract and process lifecycle regression tests --- docs/agents/error-hint-change.md | 17 +- docs/agents/output-contract.md | 10 +- .../cli/tests/e2e/command-packs.e2e.test.ts | 23 +- .../cli/tests/e2e/update-notice.e2e.test.ts | 79 ++++++ packages/commands/src/commands/image/edit.ts | 16 +- .../commands/src/commands/image/generate.ts | 16 +- .../src/commands/knowledge/doc-status.ts | 1 + .../src/commands/knowledge/doc-upload.ts | 1 + .../src/commands/knowledge/kb-create.ts | 1 + .../commands/src/commands/knowledge/shared.ts | 6 +- .../commands/src/commands/speech/recognize.ts | 15 +- .../commands/src/commands/video/download.ts | 4 +- packages/commands/src/commands/video/edit.ts | 11 +- .../commands/src/commands/video/generate.ts | 9 +- packages/commands/src/commands/video/ref.ts | 11 +- .../commands/src/commands/workspace/init.ts | 7 +- packages/core/src/auth/refresh-token.ts | 10 +- packages/core/src/client/client.ts | 33 +-- packages/core/src/client/diagnostics.ts | 38 +++ packages/core/src/client/http.ts | 22 +- .../core/src/client/instrumented-fetch.ts | 16 +- packages/core/src/client/mcp-sse.ts | 29 +- packages/core/src/client/mcp.ts | 19 +- packages/core/src/config/index.ts | 1 + packages/core/src/config/loader.ts | 43 ++- packages/core/src/console/gateway.ts | 15 +- packages/core/src/errors/base.ts | 3 +- packages/core/src/output/diagnostics.ts | 42 +++ packages/core/src/output/index.ts | 2 + packages/core/tests/index.test.ts | 34 +-- packages/runtime/src/create-cli.ts | 165 +++++++---- packages/runtime/src/error-handler.ts | 172 +++--------- packages/runtime/src/middleware.ts | 56 ++-- packages/runtime/src/output/diagnostics.ts | 44 +-- packages/runtime/src/output/reporter.ts | 38 +-- packages/runtime/src/output/status-bar.ts | 32 +-- packages/runtime/src/utils/polling.ts | 66 ++--- packages/runtime/src/utils/update-checker.ts | 175 +++++------- .../tests/auth-profile-fallback.test.ts | 25 +- packages/runtime/tests/error-handler.test.ts | 160 ++++------- packages/runtime/tests/harness/output-main.ts | 48 +++- .../tests/harness/process-handlers-main.ts | 68 +++++ .../runtime/tests/middleware-output.test.ts | 118 ++++++++ .../runtime/tests/output-reporter.test.ts | 55 ++++ .../runtime/tests/progress-output.test.ts | 93 ++++++ .../runtime/tests/runtime-output.e2e.test.ts | 264 ++++++++++++++++++ .../tests/transport-diagnostics.test.ts | 151 ++++++++++ packages/runtime/tests/update-checker.test.ts | 178 +++++++++++- 48 files changed, 1726 insertions(+), 716 deletions(-) create mode 100644 packages/cli/tests/e2e/update-notice.e2e.test.ts create mode 100644 packages/core/src/client/diagnostics.ts create mode 100644 packages/core/src/output/diagnostics.ts create mode 100644 packages/runtime/tests/harness/process-handlers-main.ts create mode 100644 packages/runtime/tests/middleware-output.test.ts create mode 100644 packages/runtime/tests/progress-output.test.ts create mode 100644 packages/runtime/tests/runtime-output.e2e.test.ts create mode 100644 packages/runtime/tests/transport-diagnostics.test.ts diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index c47d07310..fce67662c 100644 --- a/docs/agents/error-hint-change.md +++ b/docs/agents/error-hint-change.md @@ -3,7 +3,7 @@ ## 触发条件 - 修改 `BailianError` 的 message 或 hint -- 调整 runtime 的 hint 增强逻辑(`enhanceHint`) +- 调整 runtime 的 hint 增强逻辑(`errorHint`) - 改 auth stage / resolver 的鉴权失败文案 - 改任何抛错位置的分类(exitCode) @@ -39,14 +39,15 @@ ``` core 抛出 BailianError(message, exitCode, hint, cause?) ↓ 沿调用栈冒泡 -runtime/create-cli.ts: dispatch().catch(handleError) +runtime/create-cli.ts: 每次调用创建 reporter,统一捕获初始化/解析/业务异常 ↓ runtime/error-handler.ts: - 服务端错误(BailianError(GENERAL)) → text 直接打 message - - 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 enhanceHint(只 AUTH 还有增强) + - 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 errorHint(只 AUTH 还有增强) - TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK - Node fs errno → 翻成 GENERAL + errno hint - - `--output json` → stderr 输出两空格缩进的多行 error JSON;前面已有结构化 warning 时,两个 diagnostics 以空行分隔 + - reporter:JSON 错误、warning、debug 各占 stderr 一行,无空行;stdout 仅保留最终业务结果 + - 早期错误使用只读 CLI/env/Profile 格式来源,不在渲染时重新初始化配置 - 其它 Error → 默认走 cause 链 ↓ process.exit(err.exitCode) @@ -72,11 +73,11 @@ 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 `enhanceHint` +- 当前遗留:`packages/core/src/auth/resolver.ts` 仍含 `bl auth login` hint;触碰鉴权错误时迁到 runtime `errorHint` ### 4. runtime / 产品层可以使用入口名 + URL -- `packages/runtime/src/error-handler.ts` 通过 `binName` 渲染 `bl` / `kscli` 等入口名,不要硬编码 +- `packages/runtime/src/error-handler.ts` 通过本次调用的 `binName` 渲染 `bl` / `kscli` 等入口名,不要硬编码 - 产品入口 / README / E2E 可以写具体入口命令 - shared command 实现不写 `bl` / `kscli` 前缀;`usageArgs` / `exampleArgs` 只写参数片段 - URL 必须从 `packages/runtime/src/urls.ts` import,不能硬编码 @@ -89,9 +90,9 @@ process.exit(err.exitCode) - [ ] `packages/core/src/auth/resolver.ts` 新增/改 throw 语句时:hint 不含 cli 关切;已有 `bl auth login` 遗留点被触碰时要收敛 - [ ] 任何 core 文件新增/改 BailianError:同上 -### B. runtime 增强(`enhanceHint`) +### B. runtime 增强(`errorHint`) -- [ ] `packages/runtime/src/error-handler.ts:enhanceHint`:**当前只为 internal AUTH 增强**(因为 resolver / authStage 等内部位置会发 AUTH) +- [ ] `packages/runtime/src/error-handler.ts:errorHint`:**当前只为 internal AUTH 增强**(因为 resolver / authStage 等内部位置会发 AUTH) - [ ] 命令名使用 `binName`,不要硬编码 `bl` - [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"` diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index c65b6f387..f710b83ce 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -第一波仅提供 DASHSCOPE_QUIET、ctx.output 和 reporter 组件。旧 emitResult/emitBare、底层 HTTP logger 和公共 handleError 尚未迁移;不能宣称任意命令的 JSON stderr 已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 @@ -16,7 +16,9 @@ - [ ] 用 ctx.output.result(data, { text? }) 提交一次最终结果,text 只控制文本呈现;JSON 始终序列化 data,false/0/null/空列表都合法 - [ ] progress 与 info 写 stderr,仅 text 且非 quiet 时显示;progress 当前在非 TTY 下仍输出普通进度文本,不创建动画定时器;仅动画要求 TTY +- [ ] 下载进度沿用 TTY 原地刷新,非 TTY 不输出;quiet/JSON 时关闭,不把逐数据块更新转换为追加日志 - [ ] warning 始终保留;debug 仅 verbose 开启时显示;quiet 不关闭 verbose 明确请求的诊断 +- [ ] 可升级通知使用 UPDATE_AVAILABLE warning,JSON/quiet 下保留;details 提供 current_version、latest_version、upgrade_command,业务结果和退出码不变,联网检查仍按既有间隔执行,命中版本缓存时仍生成可升级通知;自动更新阈值不变 - [ ] 消息支持 LocalizedText;warning/debug 可携带 code 与 details,details 的凭证键统一脱敏、循环/深层引用受控 - [ ] 结果自身的凭证与敏感字段继续由命令在提交前按现有业务规则脱敏;不要把真实凭证拼进诊断 message @@ -25,10 +27,14 @@ - [ ] 业务不调用 error 或 process.exit;终止失败抛异常,由 runtime 统一处理 - [ ] report 包含失败项时,先保留业务报告;若契约要求失败退出,再抛一次失败摘要。查询 FAILED 资源不自动等价于查询失败 - [ ] text 错误保留 HTTP 状态、API 错误码、Request ID、cause 链及退出码;本地网络、超时、文件错误与旧出口共用分类,服务端错误原样透传 -- [ ] reporter.error 是后续 runtime 接入的内部渲染能力,只返回退出码,不自行退出进程;不把它添加到 CommandOutput +- [ ] 普通异常的调用栈通过 ERROR_STACK debug 记录输出,仅 verbose 开启时保留;quiet 不隐藏,JSON 模式仍逐条单行,终止 error 不重复输出 +- [ ] reporter.error 是 runtime 内部渲染能力,只返回退出码;handleError 接收本次 reporter 并负责退出,不把 error 添加到 CommandOutput +- [ ] core 通过可选 debug 回调上报传输诊断,无回调时保持安静;runtime 注入 ctx.output.debug。请求体、认证头和查询参数值不直接打印 +- [ ] 错误 cause 中的结构化对象复用安全序列化;服务端 message、api_code、request_id 不改写 - [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析 - [ ] 原始音频/字节流、既有事件流和 Command Pack API 1 仍走现有专用入口;不得绕过新接口承诺后混入 stdout - [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr +- [ ] 进程共用 SIGINT / stdout error 监听器,调用结束清理输出上下文;并发不触发 MaxListenersExceededWarning,单次中断只输出一条终止错误,EPIPE 保护覆盖 run 返回后的异步写入 ### C. 后续迁移 diff --git a/packages/cli/tests/e2e/command-packs.e2e.test.ts b/packages/cli/tests/e2e/command-packs.e2e.test.ts index e709ea2b9..d445ba131 100644 --- a/packages/cli/tests/e2e/command-packs.e2e.test.ts +++ b/packages/cli/tests/e2e/command-packs.e2e.test.ts @@ -14,19 +14,26 @@ function env(): NodeJS.ProcessEnv { DO_NOT_TRACK: "1", DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "", + DASHSCOPE_OUTPUT: "", + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: "0", }; } function parseStderrJsonDiagnostics(stderr: string): unknown[] { return stderr .trim() - .split(/\n\s*\n/) + .split("\n") .map((diagnostic) => JSON.parse(diagnostic) as unknown); } describe("e2e: Command Pack", () => { beforeAll(async () => { configDir = await mkdtemp(join(tmpdir(), "bl-command-pack-e2e-")); + await writeFile( + join(configDir, "update-state.json"), + JSON.stringify({ lastChecked: Date.now(), latestVersion: "0.0.0" }), + ); }); afterAll(async () => { @@ -102,18 +109,20 @@ describe("e2e: Command Pack", () => { ); const fallbackFailure = await runCli(["agent", "credential", "--output", "json"], env()); expect(fallbackFailure.exitCode).toBe(3); - expect(fallbackFailure.stderr).toMatch(/^\{\n {2}"warning": \{/); - expect(fallbackFailure.stderr).toContain("\n\n{\n"); + expect(fallbackFailure.stderr.trim().split("\n")).toHaveLength(2); + expect(fallbackFailure.stderr.endsWith("\n")).toBe(true); expect(parseStderrJsonDiagnostics(fallbackFailure.stderr)).toEqual([ { warning: { code: "PROFILE_API_KEY_FALLBACK", message: 'Profile "company-plan" does not support command "agent credential"; API Key settings will be read from Profile "default" for this run.', - profile: "company-plan", - command_path: ["agent", "credential"], - fallback_profile: "default", - credential_fields: ["api_key", "base_url"], + details: { + profile: "company-plan", + command_path: ["agent", "credential"], + fallback_profile: "default", + credential_fields: ["api_key", "base_url"], + }, }, }, { diff --git a/packages/cli/tests/e2e/update-notice.e2e.test.ts b/packages/cli/tests/e2e/update-notice.e2e.test.ts new file mode 100644 index 000000000..9ce8d8bca --- /dev/null +++ b/packages/cli/tests/e2e/update-notice.e2e.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vite-plus/test"; +import pkg from "../../package.json" with { type: "json" }; +import { runCli } from "./helpers.ts"; + +const [majorVersion, minorVersion] = pkg.version + .split(".") + .map((part) => Number.parseInt(part, 10)); +const latestVersion = `${majorVersion}.${minorVersion! + 1}.0`; + +test.each(["text", "json"] as const)( + "config list retains cached update warnings in %s, including the next process with quiet", + async (format) => { + const configDir = mkdtempSync(join(tmpdir(), "bl-update-notice-")); + try { + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ language: "en-US", telemetry: false }), + ); + writeFileSync( + join(configDir, "update-state.json"), + JSON.stringify({ + lastChecked: Date.now(), + latestVersion, + }), + ); + for (const quiet of [false, true]) { + const result = await runCli( + ["config", "list", "--output", format, ...(quiet ? ["--quiet"] : [])], + { + BAILIAN_CONFIG_DIR: configDir, + DO_NOT_TRACK: "1", + DASHSCOPE_OUTPUT: "", + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: "0", + // Do not permit real installations even if update thresholds change in the future. + BAILIAN_INSTALL_METHOD: "brew", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + if (format === "json") { + expect(JSON.parse(result.stdout)).toMatchObject({ + active_config: "default", + profiles: ["default"], + }); + const records = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records).toEqual([ + { + warning: { + code: "UPDATE_AVAILABLE", + message: expect.stringContaining("Update available"), + details: { + current_version: pkg.version, + latest_version: latestVersion, + upgrade_command: "bl update", + }, + }, + }, + ]); + } else { + expect(result.stdout).toContain("default"); + expect(result.stderr).toContain("Update available"); + expect(result.stderr).toContain(`${pkg.version} → ${latestVersion}`); + expect(result.stderr).toContain("bl update"); + } + } + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index e6af2c0ae..c73bec319 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -5,6 +5,7 @@ import { resolveOutputDir, generateFilename, stripUndefined, + type CommandOutput, type Client, type DashScopeImageRequest, type DashScopeImageSyncResponse, @@ -324,7 +325,16 @@ export default defineCommand({ if (route.useSync) { await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); } else { - await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); + await handleAsyncMode( + ctx.client, + ctx.output, + settings, + route.path, + body, + flags, + format, + concurrent, + ); } }, }); @@ -361,6 +371,7 @@ async function handleSyncMode( async function handleAsyncMode( client: Client, + commandOutput: CommandOutput, settings: Settings, path: string, body: DashScopeImageRequest, @@ -389,7 +400,8 @@ async function handleAsyncMode( const pollInterval = flags.pollInterval ?? 3; const pollPromises = taskIds.map((taskId) => - poll(client, settings, { + poll(client, { + output: commandOutput, url: client.url(taskPath(taskId)), intervalSec: pollInterval, timeoutSec: settings.timeout, diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 6c09a12b0..d79ee05bd 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -2,6 +2,7 @@ import { defineCommand, taskPath, detectOutputFormat, + type CommandOutput, type Client, type Settings, type FlagsDef, @@ -233,7 +234,16 @@ export default defineCommand({ if (route.useSync) { await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); } else { - await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); + await handleAsyncMode( + ctx.client, + ctx.output, + settings, + route.path, + body, + flags, + format, + concurrent, + ); } }, }); @@ -270,6 +280,7 @@ async function handleSyncMode( async function handleAsyncMode( client: Client, + commandOutput: CommandOutput, settings: Settings, path: string, body: DashScopeImageRequest, @@ -302,7 +313,8 @@ async function handleAsyncMode( const pollPromises = taskIds.map((taskId) => { const pollUrl = client.url(taskPath(taskId)); - return poll(client, settings, { + return poll(client, { + output: commandOutput, url: pollUrl, intervalSec: pollInterval, timeoutSec: settings.timeout, diff --git a/packages/commands/src/commands/knowledge/doc-status.ts b/packages/commands/src/commands/knowledge/doc-status.ts index 02c6286f7..4a7b250fb 100644 --- a/packages/commands/src/commands/knowledge/doc-status.ts +++ b/packages/commands/src/commands/knowledge/doc-status.ts @@ -120,6 +120,7 @@ export default defineCommand({ // Reuse the shared polling (timeout → TIMEOUT(5)); failure detection happens // uniformly after return, based on per-document status response = await pollImportJob(ctx.client, settings, { + output: ctx.output, statusUrl: endpoint, intervalSec: flags.pollInterval ?? 5, }); diff --git a/packages/commands/src/commands/knowledge/doc-upload.ts b/packages/commands/src/commands/knowledge/doc-upload.ts index 885c3b658..10b4aff11 100644 --- a/packages/commands/src/commands/knowledge/doc-upload.ts +++ b/packages/commands/src/commands/knowledge/doc-upload.ts @@ -305,6 +305,7 @@ export default defineCommand({ ingestionId = job.data?.ingestionId; if (flags.wait && ingestionId) { const statusResponse = await pollImportJob(ctx.client, settings, { + output: ctx.output, statusUrl: importJobStatusUrl(workspaceId, flags.indexId, ingestionId).toString(), intervalSec: flags.pollInterval ?? 5, }); diff --git a/packages/commands/src/commands/knowledge/kb-create.ts b/packages/commands/src/commands/knowledge/kb-create.ts index 0855ba3d2..20e621aab 100644 --- a/packages/commands/src/commands/knowledge/kb-create.ts +++ b/packages/commands/src/commands/knowledge/kb-create.ts @@ -193,6 +193,7 @@ export default defineCommand({ let finalStatus: string | undefined; if (flags.wait && pipelineId && ingestionId) { const statusResponse = await pollImportJob(ctx.client, settings, { + output: ctx.output, statusUrl: importJobStatusUrl(workspaceId, pipelineId, ingestionId).toString(), intervalSec: flags.pollInterval ?? 5, }); diff --git a/packages/commands/src/commands/knowledge/shared.ts b/packages/commands/src/commands/knowledge/shared.ts index f6049adc0..444b16957 100644 --- a/packages/commands/src/commands/knowledge/shared.ts +++ b/packages/commands/src/commands/knowledge/shared.ts @@ -4,6 +4,7 @@ import { ExitCode, ragEndpoint, RAG_PATHS, + type CommandOutput, type Client, type FlagsDef, type RagIndexJobDoc, @@ -143,9 +144,10 @@ export function importJobStatusUrl(workspaceId: string, indexId: string, jobId: export async function pollImportJob( client: Client, settings: Settings, - options: { statusUrl: string; intervalSec: number }, + options: { statusUrl: string; intervalSec: number; output: CommandOutput }, ): Promise { - return poll(client, settings, { + return poll(client, { + output: options.output, url: options.statusUrl, intervalSec: options.intervalSec, timeoutSec: settings.timeout, diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index 65b0cedd2..29b937df5 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -4,6 +4,7 @@ import { defineCommand, ExitCode, detectOutputFormat, + type CommandOutput, type Client, type Settings, type DashScopeASRRequest, @@ -305,7 +306,15 @@ export default defineCommand({ process.stderr.write(`[Model: ${model}] [Mode: async] [Files: ${resolvedUrls.length}]\n`); } - await handleAsyncMode(ctx.client, settings, body, flags, format, resolvedUrls.length); + await handleAsyncMode( + ctx.client, + ctx.output, + settings, + body, + flags, + format, + resolvedUrls.length, + ); }, }); @@ -363,6 +372,7 @@ async function handleSyncFlashMode( async function handleAsyncMode( client: Client, + commandOutput: CommandOutput, settings: Settings, body: DashScopeASRRequest, flags: RecognizeFlags, @@ -389,7 +399,8 @@ async function handleAsyncMode( const pollInterval = flags.pollInterval ?? 2; const pollUrl = client.url(taskPath(taskId)); - const result = await poll(client, settings, { + const result = await poll(client, { + output: commandOutput, url: pollUrl, intervalSec: pollInterval, timeoutSec: settings.timeout, diff --git a/packages/commands/src/commands/video/download.ts b/packages/commands/src/commands/video/download.ts index 099cf847a..8072bd99d 100644 --- a/packages/commands/src/commands/video/download.ts +++ b/packages/commands/src/commands/video/download.ts @@ -72,7 +72,9 @@ export default defineCommand({ throw new BailianError("No download URL available for this task.", ExitCode.GENERAL); } - const { size } = await downloadFile(downloadUrl, outPath, { quiet: settings.quiet }); + const { size } = await downloadFile(downloadUrl, outPath, { + quiet: settings.quiet || settings.output === "json", + }); if (settings.quiet) { emitBare(outPath); diff --git a/packages/commands/src/commands/video/edit.ts b/packages/commands/src/commands/video/edit.ts index 28ab29c11..661d1f55b 100644 --- a/packages/commands/src/commands/video/edit.ts +++ b/packages/commands/src/commands/video/edit.ts @@ -256,7 +256,8 @@ export default defineCommand({ const results = await Promise.all( taskIds.map((taskId) => - poll(ctx.client, settings, { + poll(ctx.client, { + output: ctx.output, url: ctx.client.url(taskPath(taskId)), intervalSec: pollInterval, timeoutSec: editTimeout, @@ -286,7 +287,9 @@ export default defineCommand({ // --download: save to file if (flags.download) { const destPath = flags.download; - const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { quiet: settings.quiet }); + const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { + quiet: settings.quiet || settings.output === "json", + }); if (settings.quiet) { emitBare(destPath); @@ -312,7 +315,9 @@ export default defineCommand({ await Promise.all( videos.map(async ({ taskId, videoUrl }) => { const destPath = path.join(destDir, `${taskId}.mp4`); - await downloadFile(videoUrl, destPath, { quiet: settings.quiet }); + await downloadFile(videoUrl, destPath, { + quiet: settings.quiet || settings.output === "json", + }); saved.push({ task_id: taskId, video_url: videoUrl, saved: destPath }); }), ); diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 1108ca98a..9fefcf54f 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -351,7 +351,8 @@ export default defineCommand({ const pollUrl = flags.prime ? resolvePrimeEndpoint(ctx, taskPath(taskId)) : ctx.client.url(taskPath(taskId)); - return poll(ctx.client, settings, { + return poll(ctx.client, { + output: ctx.output, url: pollUrl, intervalSec: pollInterval, timeoutSec: settings.timeout, @@ -385,7 +386,7 @@ export default defineCommand({ if (flags.download) { const destPath = flags.download; const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { - quiet: settings.quiet, + quiet: settings.quiet || settings.output === "json", }); if (settings.quiet) { @@ -414,7 +415,9 @@ export default defineCommand({ await Promise.all( videos.map(async ({ taskId, videoUrl }) => { const destPath = join(destDir, `${taskId}.mp4`); - await downloadFile(videoUrl, destPath, { quiet: settings.quiet }); + await downloadFile(videoUrl, destPath, { + quiet: settings.quiet || settings.output === "json", + }); saved.push({ task_id: taskId, video_url: videoUrl, saved: destPath }); }), ); diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index 67819fefe..5187e421d 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -318,7 +318,8 @@ export default defineCommand({ const results = await Promise.all( taskIds.map((taskId) => - poll(ctx.client, settings, { + poll(ctx.client, { + output: ctx.output, url: ctx.client.url(taskPath(taskId)), intervalSec: pollInterval, timeoutSec: refTimeout, @@ -348,7 +349,9 @@ export default defineCommand({ // --download: save to file if (flags.download) { const destPath = flags.download; - const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { quiet: settings.quiet }); + const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { + quiet: settings.quiet || settings.output === "json", + }); if (settings.quiet) { emitBare(destPath); @@ -375,7 +378,9 @@ export default defineCommand({ await Promise.all( videos.map(async ({ taskId, videoUrl }) => { const destPath = join(destDir, `${taskId}.mp4`); - await downloadFile(videoUrl, destPath, { quiet: settings.quiet }); + await downloadFile(videoUrl, destPath, { + quiet: settings.quiet || settings.output === "json", + }); saved.push({ task_id: taskId, video_url: videoUrl, saved: destPath }); }), ); diff --git a/packages/commands/src/commands/workspace/init.ts b/packages/commands/src/commands/workspace/init.ts index f773378c7..364f654cc 100644 --- a/packages/commands/src/commands/workspace/init.ts +++ b/packages/commands/src/commands/workspace/init.ts @@ -180,7 +180,12 @@ export default defineCommand({ const callApi = async (api: string, data: Record = {}) => { if (verbose) process.stderr.write(`> ${api}\n`); try { - const resp = await callConsoleGateway(target, settings.timeout, { api, data }, settings); + const resp = await callConsoleGateway( + target, + settings.timeout, + { api, data }, + { debug: (diagnostic) => ctx.output.debug(diagnostic) }, + ); if (verbose) process.stderr.write(`< ${JSON.stringify(resp)}\n`); return resp; } catch (err) { diff --git a/packages/core/src/auth/refresh-token.ts b/packages/core/src/auth/refresh-token.ts index 30644c5da..f2d21e9c1 100644 --- a/packages/core/src/auth/refresh-token.ts +++ b/packages/core/src/auth/refresh-token.ts @@ -1,3 +1,4 @@ +import type { HttpDeps } from "../client/http.ts"; import { REGIONS, type Region } from "../config/schema.ts"; import type { Identity, Settings } from "../config/schema.ts"; import { readConfigFile, writeConfigFile } from "../config/loader.ts"; @@ -27,6 +28,8 @@ function modelStudioHost(baseUrl: string): string { export async function generateCLIAccessToken(opts: { identity: Identity; settings: Settings; + // TODO(output-contract): Pass ctx.output.debug from auth generate-access-token, auth login and workspace init. + debug?: HttpDeps["debug"]; baseUrl: string; accessKeyId: string; accessKeySecret: string; @@ -37,6 +40,7 @@ export async function generateCLIAccessToken(opts: { const client = new Client({ identity, settings, + debug: opts.debug, baseUrl, openApiCred: { accessKeyId, accessKeySecret, securityToken, source: "flag" }, }); @@ -60,6 +64,7 @@ export async function generateCLIAccessToken(opts: { export async function refreshAccessToken(opts: { identity: Identity; settings: Settings; + debug?: HttpDeps["debug"]; baseUrl: string; }): Promise { const configName = opts.settings.configName; @@ -68,13 +73,12 @@ export async function refreshAccessToken(opts: { const accessKeySecret = config.access_key_secret; if (!accessKeyId || !accessKeySecret) return null; - if (opts.settings.verbose) { - process.stderr.write("Refreshing access token...\n"); - } + opts.debug?.({ message: { "en-US": "Refreshing access token", "zh-CN": "正在刷新访问令牌" } }); const resp = await generateCLIAccessToken({ identity: opts.identity, settings: opts.settings, + debug: opts.debug, baseUrl: opts.baseUrl, accessKeyId, accessKeySecret, diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 2a549c89f..0f11cd8ce 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -14,13 +14,14 @@ import { } from "./mcp.ts"; import { callConsoleGateway } from "../console/gateway.ts"; import { refreshAccessToken } from "../auth/refresh-token.ts"; -import { maskToken } from "../utils/token.ts"; +import { debugRequest, debugResponse } from "./diagnostics.ts"; import { trackingHeaders } from "./headers.ts"; /** Client 的结构化依赖:身份 + 有效配置 + 各域凭证(按命令的 auth 注入)。 */ export interface ClientDeps { identity: Identity; settings: Settings; + debug?: HttpDeps["debug"]; /** Model 域 base URL(凭证无关链解析,resolveModelBaseUrl;有 apiCred 时两者一致)。 */ baseUrl: string; /** True only when the shared URL chain used its default; explicit origins win over service defaults. */ @@ -73,7 +74,7 @@ export class Client { constructor(private readonly deps: ClientDeps) {} private get http(): HttpDeps { - return { identity: this.deps.identity, settings: this.deps.settings }; + return { identity: this.deps.identity, settings: this.deps.settings, debug: this.deps.debug }; } private requireApi(): ApiKeyCredential { @@ -215,12 +216,9 @@ export class Client { const gwOpts = { api, data }; const { timeout } = this.deps.settings; try { - return (await callConsoleGateway( - this.deps.consoleCred, - timeout, - gwOpts, - this.deps.settings, - )) as T; + return (await callConsoleGateway(this.deps.consoleCred, timeout, gwOpts, { + debug: this.deps.debug, + })) as T; } catch (err) { if ( !(err instanceof BailianError) || @@ -232,6 +230,7 @@ export class Client { const newToken = await refreshAccessToken({ identity: this.deps.identity, settings: this.deps.settings, + debug: this.deps.debug, baseUrl: this.deps.baseUrl, }); if (!newToken) throw err; @@ -239,7 +238,7 @@ export class Client { { ...this.deps.consoleCred, token: newToken }, timeout, gwOpts, - this.deps.settings, + { debug: this.deps.debug }, )) as T; } } @@ -266,16 +265,7 @@ export class Client { queryString, }); - if (this.deps.settings.verbose) { - process.stderr.write(`> ${opts.method} ${endpoint}\n`); - process.stderr.write(`> x-acs-action: ${opts.action} (version ${opts.version})\n`); - process.stderr.write(`> AK: ${maskToken(cred.accessKeyId)}\n`); - if (cred.securityToken) { - process.stderr.write(`> STS token: ${maskToken(cred.securityToken)}\n`); - } - if (queryString) process.stderr.write(`> query: ${queryString}\n`); - if (bodyStr) process.stderr.write(`> body: ${bodyStr}\n`); - } + debugRequest(this.http, opts.method, endpoint, { action: opts.action, version: opts.version }); const timeoutMs = this.deps.settings.timeout * 1000; const res = await fetch(endpoint, { @@ -286,10 +276,7 @@ export class Client { }); const rawText = await res.text(); - if (this.deps.settings.verbose) { - process.stderr.write(`< ${res.status} ${res.statusText}\n`); - process.stderr.write(`< ${rawText}\n`); - } + debugResponse(this.http, res); let data: T; try { diff --git a/packages/core/src/client/diagnostics.ts b/packages/core/src/client/diagnostics.ts new file mode 100644 index 000000000..9b1a90166 --- /dev/null +++ b/packages/core/src/client/diagnostics.ts @@ -0,0 +1,38 @@ +import type { HttpDeps } from "./http.ts"; + +function diagnosticUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + url.username = ""; + url.password = ""; + // Query values and fragments may carry credentials or user content. + for (const key of url.searchParams.keys()) url.searchParams.set(key, "[REDACTED]"); + url.hash = ""; + return url.toString(); + } catch { + return "[Invalid URL]"; + } +} + +export function debugRequest( + deps: Pick, + method: string, + url: string, + details?: Record, +): void { + deps.debug?.({ + message: { "en-US": "HTTP request", "zh-CN": "HTTP 请求" }, + details: { method, url: diagnosticUrl(url), ...details }, + }); +} + +export function debugResponse(deps: Pick, response: Response): void { + deps.debug?.({ + message: { "en-US": "HTTP response", "zh-CN": "HTTP 响应" }, + details: { + status: response.status, + status_text: response.statusText, + request_id: response.headers.get("x-request-id") ?? undefined, + }, + }); +} diff --git a/packages/core/src/client/http.ts b/packages/core/src/client/http.ts index 500d84dad..9931662c0 100644 --- a/packages/core/src/client/http.ts +++ b/packages/core/src/client/http.ts @@ -3,13 +3,16 @@ import type { ApiErrorBody } from "../errors/api.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { mapApiError } from "../errors/api.ts"; -import { maskToken } from "../utils/token.ts"; -import { sourceConfig, trackingHeaders } from "./headers.ts"; +import type { OutputMessage } from "../types/output.ts"; +import { debugRequest, debugResponse } from "./diagnostics.ts"; +import { trackingHeaders } from "./headers.ts"; /** 传输层依赖:UA 用 identity,timeout/verbose 用 settings。凭证由调用方(Client)注头。 */ export interface HttpDeps { identity: Identity; settings: Settings; + /** Optional diagnostic sink; core never writes to terminal streams. */ + debug?: (diagnostic: OutputMessage) => void; } export interface RequestOpts { @@ -70,12 +73,7 @@ async function performRequest( headers["X-DashScope-OssResourceResolve"] = "enable"; } - if (deps.settings.verbose) { - console.error(`> ${opts.method ?? "GET"} ${opts.url}`); - const auth = headers["Authorization"]; - if (auth) console.error(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}`); - console.error(`> x-dashscope-source-config: ${sourceConfig(deps.identity)}`); - } + debugRequest(deps, opts.method ?? "GET", opts.url); const res = await fetch(opts.url, { method: opts.method ?? "GET", @@ -88,13 +86,7 @@ async function performRequest( signal, }); - if (deps.settings.verbose) { - console.error(`< ${res.status} ${res.statusText}`); - const reqId = res.headers.get("x-request-id"); - if (reqId) { - console.error(`request_id: ${reqId}`); - } - } + debugResponse(deps, res); if (!res.ok) { let body: ApiErrorBody = {}; diff --git a/packages/core/src/client/instrumented-fetch.ts b/packages/core/src/client/instrumented-fetch.ts index d053de6fd..4720a5840 100644 --- a/packages/core/src/client/instrumented-fetch.ts +++ b/packages/core/src/client/instrumented-fetch.ts @@ -1,4 +1,4 @@ -import { maskToken } from "../utils/token.ts"; +import { debugRequest, debugResponse } from "./diagnostics.ts"; import type { HttpDeps } from "./http.ts"; import { trackingHeaders } from "./headers.ts"; @@ -55,21 +55,11 @@ export function createInstrumentedFetch(deps: HttpDeps): FetchImplementation { } } - if (deps.settings.verbose) { - console.error(`> ${init.method ?? "GET"} ${url}`); - const auth = headers.get("authorization"); - if (auth) console.error(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}`); - } + debugRequest(deps, init.method ?? (input instanceof Request ? input.method : "GET"), url); const res = await fetch(input, { ...init, headers }); - if (deps.settings.verbose) { - console.error(`< ${res.status} ${res.statusText}`); - const reqId = res.headers.get("x-request-id"); - if (reqId) { - console.error(`request_id: ${reqId}`); - } - } + debugResponse(deps, res); return res; }; diff --git a/packages/core/src/client/mcp-sse.ts b/packages/core/src/client/mcp-sse.ts index a156557a7..fb192113a 100644 --- a/packages/core/src/client/mcp-sse.ts +++ b/packages/core/src/client/mcp-sse.ts @@ -1,3 +1,4 @@ +import { debugRequest, debugResponse } from "./diagnostics.ts"; /** * MCP classic HTTP+SSE client (protocol 2024-11-05 transport). * @@ -62,7 +63,7 @@ export class McpSseClient { await this.openSse(); - const result = await this.rpc("initialize", { + await this.rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { @@ -71,10 +72,9 @@ export class McpSseClient { }, }); - if (this.deps.settings.verbose) { - console.error(`[MCP SSE] Session initialized`); - console.error(`[MCP SSE] Server: ${JSON.stringify(result)}`); - } + this.deps.debug?.({ + message: { "en-US": "MCP session initialized", "zh-CN": "MCP 会话已初始化" }, + }); await this.notify("notifications/initialized"); } @@ -132,9 +132,7 @@ export class McpSseClient { headers["Authorization"] = `Bearer ${this.authToken}`; } - if (this.deps.settings.verbose) { - console.error(`> GET ${this.sseUrl}`); - } + debugRequest(this.deps, "GET", this.sseUrl); let response: Response; try { @@ -157,9 +155,7 @@ export class McpSseClient { throw error; } - if (this.deps.settings.verbose) { - console.error(`< ${response.status} ${response.statusText}`); - } + debugResponse(this.deps, response); if (!response.ok) { // Keep headerTimer until error body is read (or times out). @@ -338,10 +334,9 @@ export class McpSseClient { headers["Authorization"] = `Bearer ${this.authToken}`; } - if (this.deps.settings.verbose) { - console.error(`> POST ${this.messageUrl}`); - console.error(`> Method: ${(body as { method?: string }).method}`); - } + debugRequest(this.deps, "POST", this.messageUrl, { + rpc_method: (body as { method?: string }).method, + }); const timeoutMs = this.deps.settings.timeout * 1000; // Combine per-RPC timeout with session abort so close() cancels in-flight POSTs. @@ -362,9 +357,7 @@ export class McpSseClient { throw error; } - if (this.deps.settings.verbose) { - console.error(`< ${res.status} ${res.statusText}`); - } + debugResponse(this.deps, res); if (!res.ok) { // Keep signal until error body is read (same class of bug as GET openSse). diff --git a/packages/core/src/client/mcp.ts b/packages/core/src/client/mcp.ts index c3ffe9e41..92a0e0f49 100644 --- a/packages/core/src/client/mcp.ts +++ b/packages/core/src/client/mcp.ts @@ -1,3 +1,4 @@ +import { debugRequest, debugResponse } from "./diagnostics.ts"; /** * MCP (Model Context Protocol) streamable HTTP client. * @@ -179,7 +180,7 @@ export class McpClient { throw new BailianError("This command needs a model-domain API key.", ExitCode.AUTH); } - const result = await this.rpc("initialize", { + await this.rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { @@ -188,10 +189,9 @@ export class McpClient { }, }); - if (this.deps.settings.verbose) { - console.error(`[MCP] Session initialized: ${this.sessionId ?? "no session"}`); - console.error(`[MCP] Server: ${JSON.stringify(result)}`); - } + this.deps.debug?.({ + message: { "en-US": "MCP session initialized", "zh-CN": "MCP 会话已初始化" }, + }); await this.notify("notifications/initialized"); } @@ -294,10 +294,7 @@ export class McpClient { headers["Mcp-Session-Id"] = this.sessionId; } - if (this.deps.settings.verbose) { - console.error(`> POST ${this.url}`); - console.error(`> Method: ${(body as { method?: string }).method}`); - } + debugRequest(this.deps, "POST", this.url, { rpc_method: (body as { method?: string }).method }); const timeoutMs = this.deps.settings.timeout * 1000; const res = await fetch(this.url, { @@ -307,9 +304,7 @@ export class McpClient { signal: AbortSignal.timeout(timeoutMs), }); - if (this.deps.settings.verbose) { - console.error(`< ${res.status} ${res.statusText}`); - } + debugResponse(this.deps, res); const sid = res.headers.get("Mcp-Session-Id") || res.headers.get("mcp-session-id"); if (sid) this.sessionId = sid; diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index fce85ac6f..293dc9f6e 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -22,6 +22,7 @@ export { export { buildSources, buildSettings, + buildOutputSettings, selectApiKeyResolutionSources, type ApiKeyResolutionSourceSelection, type ResolutionSources, diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index b2c7625da..6b67c61fa 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,3 +1,4 @@ +import type { OutputDiagnostic } from "../types/output.ts"; import { readFileSync, writeFileSync, renameSync, existsSync } from "fs"; import { CONFIG_FILE_KEYS, parseConfigFile, type ConfigFile, type Settings } from "./schema.ts"; import { ensureConfigDir, getConfigPath } from "./paths.ts"; @@ -38,7 +39,9 @@ export function normalizeConfigName(name?: unknown): string | undefined { } /** 读完整 config.json 原始对象(不经过 `parseConfigFile` 过滤),保留其他命名配置 block。 */ -function readRawConfigObject(): Record { +function readRawConfigObject( + onWarning?: (warning: OutputDiagnostic) => void, +): Record { const path = getConfigPath(); if (!existsSync(path)) return {}; try { @@ -49,7 +52,13 @@ function readRawConfigObject(): Record { } catch (err) { const e = err as Error; if (e instanceof SyntaxError || e.message.includes("JSON")) { - console.warn("Warning: config file is corrupted; using defaults."); + onWarning?.({ + code: "CONFIG_CORRUPTED", + message: { + "en-US": "Config file is corrupted; using defaults.", + "zh-CN": "配置文件已损坏,将使用默认配置。", + }, + }); } return {}; } @@ -235,8 +244,11 @@ export function selectApiKeyResolutionSources( }; } -export function buildSources(flags: Partial): ResolutionSources { - const raw = readRawConfigObject(); +export function buildSources( + flags: Partial, + onWarning?: (warning: OutputDiagnostic) => void, +): ResolutionSources { + const raw = readRawConfigObject(onWarning); const configExplicit = flags.config !== undefined; const activeConfigName = readStoredActiveConfigName(raw, !configExplicit); const configName = configExplicit ? normalizeConfigName(flags.config) : activeConfigName; @@ -249,6 +261,24 @@ export function buildSources(flags: Partial): ResolutionSources { }; } +/** Presentation-only resolution, also safe before config loading or unrelated validation. */ +export function buildOutputSettings({ + flags, + env, + file = {}, +}: Pick & { file?: ConfigFile }): Pick< + Settings, + "output" | "outputExplicit" | "quiet" | "verbose" +> { + const output = flags.output || env.DASHSCOPE_OUTPUT || file.output; + return { + output: detectOutputFormat(output), + outputExplicit: Boolean(output), + quiet: flags.quiet || env.DASHSCOPE_QUIET === "1", + verbose: flags.verbose || env.DASHSCOPE_VERBOSE === "1", + }; +} + /** * 纯解析 sources → Settings(命令唯一会读的配置面)。不含身份、baseUrl、鉴权。 * 各字段链序 flag > env > file > 默认;锁定表 tests/config-priority.test.ts。 @@ -267,8 +297,7 @@ export function buildSettings(s: ResolutionSources): Settings { return { configPath: s.configPath ?? getConfigPath(), configName: s.configName, - output: detectOutputFormat(flags.output || env.DASHSCOPE_OUTPUT || file.output), - outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output), + ...buildOutputSettings(s), outputDir: file.output_dir || undefined, timeout, watermark: file.watermark ?? true, @@ -284,8 +313,6 @@ export function buildSettings(s: ResolutionSources): Settings { consoleRegion: flags.consoleRegion || file.console_region || undefined, consoleSite: (flags.consoleSite as Settings["consoleSite"]) || file.console_site || undefined, consoleSwitchAgent: flags.consoleSwitchAgent || file.console_switch_agent || undefined, - verbose: flags.verbose || env.DASHSCOPE_VERBOSE === "1", - quiet: flags.quiet || env.DASHSCOPE_QUIET === "1", dryRun: flags.dryRun || false, telemetry: env.DO_NOT_TRACK === "1" ? false : (file.telemetry ?? true), }; diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index 3004c50c1..96646f3b0 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -1,3 +1,5 @@ +import { debugRequest, debugResponse } from "../client/diagnostics.ts"; +import type { HttpDeps } from "../client/http.ts"; import type { Settings } from "../config/schema.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; @@ -123,7 +125,7 @@ export async function callConsoleGateway( target: ConsoleGatewayTarget, timeoutSec: number, { api, data }: ConsoleGatewayRequest, - settings?: Pick, + diagnostics: Pick = {}, ): Promise { const resolved = resolveGateway(target.region, target.site); const gatewayBase = `https://${resolved.csGateway}`; @@ -140,12 +142,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)}`; - if (settings?.verbose) { - process.stderr.write(`> POST ${endpoint}\n`); - process.stderr.write( - `> payload ${JSON.stringify({ params: JSON.parse(params), region: target.region }, null, 2)}\n`, - ); - } + debugRequest(diagnostics, "POST", endpoint, { api }); const res = await fetch(endpoint, { method: "POST", @@ -154,9 +151,7 @@ export async function callConsoleGateway( signal: AbortSignal.timeout(timeoutMs), }); - if (settings?.verbose) { - process.stderr.write(`< ${res.status} ${res.statusText}\n`); - } + debugResponse(diagnostics, res); if (!res.ok) { const t = await res.text().catch(() => ""); diff --git a/packages/core/src/errors/base.ts b/packages/core/src/errors/base.ts index b3ed692b9..80feed825 100644 --- a/packages/core/src/errors/base.ts +++ b/packages/core/src/errors/base.ts @@ -1,3 +1,4 @@ +import { sanitizeDiagnosticDetails } from "../output/diagnostics.ts"; import { ExitCode } from "./codes.ts"; export interface ApiErrorContext { @@ -68,7 +69,7 @@ function serializeCause(cause: unknown): Record | undefined { return { message: String(cause) }; } try { - return { message: JSON.stringify(cause) }; + return { message: JSON.stringify(sanitizeDiagnosticDetails(cause)) }; } catch { return undefined; } diff --git a/packages/core/src/output/diagnostics.ts b/packages/core/src/output/diagnostics.ts new file mode 100644 index 000000000..7ea28c64a --- /dev/null +++ b/packages/core/src/output/diagnostics.ts @@ -0,0 +1,42 @@ +const MAX_DIAGNOSTIC_DEPTH = 8; +const SECRET_KEY = + /(?:authorization|cookie|apikey|accesskey(?:id)?|privatekey|credentials?|token|password|secret)$/i; + +/** Convert diagnostics to inert data; never invoke accessors or serialization hooks. */ +export function sanitizeDiagnosticDetails( + value: unknown, + ancestors = new Set(), + depth = 0, +): unknown { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "function") return "[Function]"; + if (value === null || typeof value !== "object") return value; + if (ancestors.has(value)) return "[Circular]"; + if (depth >= MAX_DIAGNOSTIC_DEPTH) return "[Truncated]"; + ancestors.add(value); + try { + const descriptors = Object.getOwnPropertyDescriptors(value); + const readDescriptor = (descriptor: PropertyDescriptor | undefined): unknown => { + if (!descriptor) return undefined; + if (!("value" in descriptor)) return "[Accessor]"; + return sanitizeDiagnosticDetails(descriptor.value as unknown, ancestors, depth + 1); + }; + if (Array.isArray(value)) { + const length = descriptors.length?.value as number; + return Array.from({ length }, (_entry, index) => readDescriptor(descriptors[String(index)])); + } + return Object.fromEntries( + Object.entries(descriptors) + .filter(([, descriptor]) => descriptor.enumerable) + .map(([key, descriptor]) => [ + key, + SECRET_KEY.test(key.replaceAll(/[-_]/g, "")) ? "[REDACTED]" : readDescriptor(descriptor), + ]), + ); + } catch { + // Proxy traps and exotic objects must not make verbose change business behavior. + return "[Unserializable]"; + } finally { + ancestors.delete(value); + } +} diff --git a/packages/core/src/output/index.ts b/packages/core/src/output/index.ts index 74880441a..e2f3915bc 100644 --- a/packages/core/src/output/index.ts +++ b/packages/core/src/output/index.ts @@ -2,3 +2,5 @@ export type { OutputFormat } from "./formatter.ts"; export { detectOutputFormat, formatOutput } from "./formatter.ts"; export { formatErrorJson, formatJson } from "./json.ts"; export { formatText } from "./text.ts"; + +export { sanitizeDiagnosticDetails } from "./diagnostics.ts"; diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 620504532..34b5e6155 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -203,11 +203,12 @@ test("toJSON splits service-error metadata into structured fields", () => { }); }); -test("callConsoleGateway verbose prints structured request payload", async () => { +test("callConsoleGateway emits injected diagnostics without printing request payload", async () => { const originalFetch = globalThis.fetch; const originalWrite = process.stderr.write.bind(process.stderr); let stderr = ""; let requestBody: string | undefined; + const diagnostics: unknown[] = []; globalThis.fetch = async (_url, init) => { requestBody = init?.body as string | undefined; @@ -235,7 +236,7 @@ test("callConsoleGateway verbose prints structured request payload", async () => api: "test.api", data: { workspaceId: "ws-1", cornerstoneParam: { custom: "value" } }, }, - { verbose: true }, + { debug: (diagnostic) => diagnostics.push(diagnostic) }, ); } finally { globalThis.fetch = originalFetch; @@ -243,15 +244,16 @@ test("callConsoleGateway verbose prints structured request payload", async () => } expect(requestBody).toBeDefined(); - expect(stderr).toContain('> payload {\n "params": {'); - expect(stderr).toContain(' "region": "ap-southeast-1"'); - expect(stderr).toContain(' "Api": "test.api"'); - expect(stderr).toContain(' "workspaceId": "ws-1"'); - expect(stderr).toContain(' "switchUserType": 3'); - expect(stderr).toContain(' "switchAgent": 123'); - expect(stderr).toContain(' "custom": "value"'); - expect(stderr).toContain("< 200 OK"); - expect(stderr).not.toContain("response-body"); + const params = new URLSearchParams(requestBody).get("params"); + expect(params).toContain('"workspaceId":"ws-1"'); + expect(params).toContain('"switchAgent":123'); + expect(params).toContain('"custom":"value"'); + expect(stderr).toBe(""); + expect(diagnostics).toMatchObject([ + { details: { method: "POST", api: "test.api" } }, + { details: { status: 200 } }, + ]); + expect(JSON.stringify(diagnostics)).not.toContain("response-body"); }); test("callConsoleGateway keeps readable message and raw gateway response separately", async () => { @@ -276,12 +278,10 @@ test("callConsoleGateway keeps readable message and raw gateway response separat try { await expect( - callConsoleGateway( - { region: "cn-beijing", site: "domestic", token: "token" }, - 30, - { api: "test.api", data: {} }, - { verbose: true }, - ), + callConsoleGateway({ region: "cn-beijing", site: "domestic", token: "token" }, 30, { + api: "test.api", + data: {}, + }), ).rejects.toMatchObject({ message: "Console gateway error: BailianGateway.Team.NotAuthorised", rawResponse: JSON.stringify(responseBody), diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 6f19f105d..f28a57356 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -16,14 +16,19 @@ import type { LocalizedText, ParsedFlags, SourceFlags, + ResolutionSources, + OutputDiagnostic, } from "bailian-cli-core"; import { DEFAULT_LANGUAGE, + BailianError, + ExitCode, GLOBAL_FLAGS, UsageError, credentialFlagDefs, buildSources, buildSettings, + buildOutputSettings, describeAuthState, resolveModelBaseUrlState, makeConfigStore, @@ -33,7 +38,7 @@ import { } from "bailian-cli-core"; import { setupProxyFromEnv } from "./proxy.ts"; import { handleError } from "./error-handler.ts"; -import { createOutputReporter } from "./output/reporter.ts"; +import { type OutputReporter, createOutputReporter } from "./output/reporter.ts"; import { loadCommandPacks } from "./command-packs/load.ts"; import { createCommandPackManager } from "./command-packs/manager.ts"; import type { CommandPackPolicy } from "./command-packs/types.ts"; @@ -66,43 +71,74 @@ function pick(obj: Record, keys: string[]): Record { - for (let argumentIndex = 0; argumentIndex < argv.length; argumentIndex++) { - const argument = argv[argumentIndex]; - if (argument === "--config") { - const value = argv[argumentIndex + 1]; - return value === undefined || value.startsWith("--") ? {} : { config: value }; - } - if (argument?.startsWith("--config=")) { - return { config: argument.slice("--config=".length) }; - } +/** Tolerant, read-only presentation flags for failures before full parsing. */ +function presentationFlags(argv: string[]): Partial { + const flags: Partial = {}; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]!; + if (argument === "--") break; + if (argument === "--quiet") flags.quiet = true; + else if (argument === "--verbose") flags.verbose = true; + else + for (const key of ["output", "config"] as const) { + if (argument.startsWith(`--${key}=`)) flags[key] = argument.slice(key.length + 3); + else if (argument === `--${key}`) { + const value = argv[index + 1]; + if (value !== undefined && !value.startsWith("--")) { + flags[key] = value; + index++; + } + } + } } - return {}; + return flags; } -/** - * 进程级一次性设置:代理初始化、Ctrl+C、stdout EPIPE。 - * 属进程生命周期行为,装一次即可,不进 per-command 中间件。 - */ -function installProcessHandlers(binName: string): void { - try { - setupProxyFromEnv(); - } catch (err) { - handleError(err, binName); - } +interface ProcessOutputContext { + getReporter: () => OutputReporter; + localize: (text: LocalizedText) => string; +} - // 处理 Ctrl+C - process.on("SIGINT", () => { - process.stderr.write("\nInterrupted. Exiting.\n"); - void flushTelemetry(500).finally(() => process.exit(130)); - }); +const activeProcessOutputs = new Set(); - // 处理 stdout EPIPE(例如管道到提前退出的 `mpv`) - process.stdout.on("error", (e: NodeJS.ErrnoException) => { - if (e.code === "EPIPE") process.exit(0); - else throw e; - }); +/** One process event produces one error, using the first still-active invocation. */ +function handleInterrupt(): void { + const active = activeProcessOutputs.values().next().value; + if (!active) return; + active + .getReporter() + .error( + new BailianError( + active.localize({ "en-US": "Interrupted. Exiting.", "zh-CN": "操作已中断,正在退出。" }), + 130 as ExitCode, + ), + ); + void flushTelemetry(500).finally(() => process.exit(130)); +} + +/** Keep EPIPE protection after run() returns, without retaining completed invocations. */ +function handleStdoutPipeError(error: NodeJS.ErrnoException): void { + if (error.code === "EPIPE") process.exit(0); + const active = activeProcessOutputs.values().next().value; + if (active) process.exit(active.getReporter().error(error)); + // Outside an invocation, let other listeners handle errors or retain Node's failure behavior. + if (process.stdout.listenerCount("error") === 1) throw error; +} + +function installProcessHandlers( + getReporter: () => OutputReporter, + localize: (text: LocalizedText) => string, +): () => void { + if (!process.stdout.listeners("error").includes(handleStdoutPipeError)) { + process.stdout.on("error", handleStdoutPipeError); + } + if (activeProcessOutputs.size === 0) process.once("SIGINT", handleInterrupt); + const context = { getReporter, localize }; + activeProcessOutputs.add(context); + return () => { + activeProcessOutputs.delete(context); + if (activeProcessOutputs.size === 0) process.removeListener("SIGINT", handleInterrupt); + }; } /** @@ -117,8 +153,6 @@ export function createCli(commands: Record, opts: CliOptions const commandPackManager = createCommandPackManager(identity, commandPackPolicy); let loadedCommandPacksPromise: ReturnType | undefined; - installProcessHandlers(binName); - const runMiddleware = compose([ telemetryStage, confirmationStage, @@ -134,17 +168,11 @@ export function createCli(commands: Record, opts: CliOptions return loadedCommandPacksPromise; } - async function getRegistry(argv: string[]): Promise<{ - registry: CommandRegistry; - localize: (text: LocalizedText) => string; - }> { - const localeSources = buildSources(pickConfigFlag(argv)); - const translator = createTranslator(localeSources.file.language ?? DEFAULT_LANGUAGE); + async function getRegistry( + translator: ReturnType, + ): Promise { const loaded = await getLoadedCommandPacks(); - return { - registry: new CommandRegistry(loaded.commands, binName, translator), - localize: (text) => translator.localize(text), - }; + return new CommandRegistry(loaded.commands, binName, translator); } /** Render help for `path`; root ([]) doubles as the onboarding / login guide. */ @@ -174,6 +202,8 @@ export function createCli(commands: Record, opts: CliOptions registry: CommandRegistry, argv: string[], localize: (text: LocalizedText) => string, + initialSources: ResolutionSources, + reporter: OutputReporter, ): Promise { const res = resolve(argv, registry, localize); @@ -187,8 +217,7 @@ export function createCli(commands: Record, opts: CliOptions return; case "usageError": - handleError(res.error, binName); - return; + throw res.error; case "run": { try { @@ -207,11 +236,11 @@ export function createCli(commands: Record, opts: CliOptions if (invalid) throw new UsageError(invalid); // 校验通过 → 建源、解析 settings、组 ctx,进中间件执行命令。 - const sources = buildSources(globalFlags); + const sources = { ...initialSources, flags: globalFlags }; const settings = buildSettings(sources); const ctx: RunContext = { identity, - output: createOutputReporter({ settings, localize, binName: identity.binName }).output, + output: reporter.output, path: res.path, command: res.command, flags: ownFlags, @@ -225,6 +254,7 @@ export function createCli(commands: Record, opts: CliOptions client: new Client({ identity, settings, + debug: (diagnostic) => reporter.output.debug(diagnostic), ...resolveModelBaseUrlState(sources), }), }; @@ -237,8 +267,7 @@ export function createCli(commands: Record, opts: CliOptions registry.printHelp(res.path, process.stderr); return; } - await flushTelemetry(1000); - handleError(err, binName); + throw err; } return; } @@ -246,13 +275,33 @@ export function createCli(commands: Record, opts: CliOptions } return { - run(argv: string[] = process.argv.slice(2)) { - return Promise.resolve() - .then(() => getRegistry(argv)) - .then(({ registry, localize }) => dispatch(registry, argv, localize)) - .catch( - (err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void, - ); + async run(argv: string[] = process.argv.slice(2)) { + const flags = presentationFlags(argv); + 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 }); + const warnings: OutputDiagnostic[] = []; + const cleanup = installProcessHandlers(() => reporter, localize); + try { + const sources = buildSources(flags, (warning) => warnings.push(warning)); + translator = createTranslator(sources.file.language ?? DEFAULT_LANGUAGE); + reporter = createOutputReporter({ + settings: buildOutputSettings(sources), + localize, + binName, + }); + for (const warning of warnings.splice(0)) reporter.output.warning(warning); + setupProxyFromEnv(); + const registry = await getRegistry(translator); + await dispatch(registry, argv, localize, sources, reporter); + } catch (error) { + for (const warning of warnings.splice(0)) reporter.output.warning(warning); + await flushTelemetry(1000); + handleError(error, reporter); + } finally { + cleanup(); + } }, }; } diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index 8bf7b25f9..6dcb2e21a 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -1,138 +1,54 @@ -import { - BailianError, - ExitCode, - detectOutputFormat, - type LocalizedText, - type OutputFormat, -} 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"; import { API_KEY_PAGE } from "./urls.ts"; -const LABEL_WIDTH = 13; - -/** Binary name used in error hints; set by handleError() so its helpers can read it. */ -let binName: string; - -function pad(label: string): string { - return label.padEnd(LABEL_WIDTH); -} - -function alignContinuation(text: string): string { - return text - .split("\n") - .map((line, i) => (i === 0 ? line : " ".repeat(LABEL_WIDTH) + line)) - .join("\n"); +/** The invocation owns rendering; this boundary owns process termination. */ +export function handleError(error: unknown, reporter: Pick): never { + process.exit(reporter.error(error)); } -function enhanceHint(err: BailianError): string | undefined { - if (err.exitCode === ExitCode.AUTH) { - // Non-model auth domains carry their own credential-specific hints; don't - // append model API key onboarding lines to Console/OpenAPI errors. - if (isNonModelAuthHint(err.hint)) { - return err.hint; - } - return [ - err.hint, - "", - `${binName} auth login --api-key `, - `${binName} auth status`, - `Get API Key: ${API_KEY_PAGE}`, - ] - .filter((s): s is string => s !== undefined) - .join("\n"); - } - return err.hint; -} - -function isNonModelAuthHint(hint: string | undefined): boolean { - if (!hint) return false; - return ( - hint.includes("auth login --console") || - hint.includes("auth login --open-api") || - hint.includes("--access-key-id") || - hint.includes("ALIBABA_CLOUD_ACCESS_KEY_") - ); -} - -export function detectErrorOutputFormat( - argv: string[] = process.argv.slice(2), - envOutput: string | undefined = process.env.DASHSCOPE_OUTPUT, -): OutputFormat { - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === "--output") { - return detectOutputFormat(argv[i + 1] || envOutput); - } - if (arg?.startsWith("--output=")) { - return detectOutputFormat(arg.slice("--output=".length)); - } - } - return detectOutputFormat(envOutput); -} - -function writeCauseChain(err: Error): void { - let cur: unknown = (err as { cause?: unknown }).cause; - let depth = 0; - while (cur instanceof Error && depth < 5) { - process.stderr.write(`${pad("Caused by:")}${cur.message}\n`); - cur = (cur as { cause?: unknown }).cause; - depth++; - } -} - -function writeBailianErrorText(err: BailianError): void { - process.stderr.write(`\n${pad("Error:")}${err.message}\n`); - - const hint = enhanceHint(err); - if (hint) { - process.stderr.write(`${pad("Hint:")}${alignContinuation(hint)}\n`); - } - - const api = err.api; - if (api) { - if (api.httpStatus !== undefined) { - const codeSuffix = api.apiCode ? ` (${api.apiCode})` : ""; - process.stderr.write(`${pad("Status:")}HTTP ${api.httpStatus}${codeSuffix}\n`); - } else if (api.apiCode) { - process.stderr.write(`${pad("Code:")}${api.apiCode}\n`); - } - if (api.requestId) { - process.stderr.write(`${pad("Request ID:")}${api.requestId}\n`); - } +export function normalizeError(error: unknown, binName: string, localize: Localize): BailianError { + if (error instanceof BailianError) return error; + if (error instanceof Error) { + return ( + classifyRuntimeError(error, binName, localize) ?? + new BailianError(error.message, ExitCode.GENERAL, undefined, { cause: error }) + ); } - - writeCauseChain(err); - - process.stderr.write(`${pad("Exit code:")}${err.exitCode}\n`); + const message = + error !== null && typeof error === "object" + ? JSON.stringify(sanitizeDiagnosticDetails(error)) + : String(error); + return new BailianError(message, ExitCode.GENERAL); } -export function handleError(err: unknown, cliName: string): never { - binName = cliName; - - if (err instanceof BailianError) { - const format = detectErrorOutputFormat(); - - if (format === "json") { - process.stderr.write(JSON.stringify(err.toJSON(), null, 2) + "\n"); - } else { - writeBailianErrorText(err); - } - process.exit(err.exitCode); - } - - if (err instanceof Error) { - const classified = classifyRuntimeError(err, binName); - if (classified) return handleError(classified, binName); - - process.stderr.write(`\n${pad("Error:")}${err.message}\n`); - writeCauseChain(err); - if (process.env.DASHSCOPE_VERBOSE === "1") { - process.stderr.write(`${err.stack}\n`); - } - } else { - process.stderr.write(`\n${pad("Error:")}${String(err)}\n`); - } - - process.exit(ExitCode.GENERAL); +/** Add runtime-owned model-auth guidance without modifying service errors. */ +export function errorHint( + error: BailianError, + binName: string, + localize: Localize, +): string | undefined { + if (error.exitCode !== ExitCode.AUTH) return error.hint; + const hint = error.hint; + if ( + hint && + [ + "auth login --console", + "auth login --open-api", + "--access-key-id", + "ALIBABA_CLOUD_ACCESS_KEY_", + ].some((marker) => hint.includes(marker)) + ) + return hint; + return [ + hint, + `${binName} auth login --api-key `, + `${binName} auth status`, + localize({ "en-US": `Get API Key: ${API_KEY_PAGE}`, "zh-CN": `获取 API Key:${API_KEY_PAGE}` }), + ] + .filter((line) => line !== undefined) + .join("\n"); } type Localize = (text: LocalizedText) => string; diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index b9c08c7e1..6da27418b 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -25,7 +25,6 @@ import { trackCommandExecution, } from "bailian-cli-core"; import { maybeShowStatusBar } from "./output/status-bar.ts"; -import { ansi } from "./output/color.ts"; import { createTranslator } from "./i18n.ts"; import { checkForUpdate, @@ -85,26 +84,16 @@ function formatApiKeyFallbackNotice( function writeApiKeyFallbackNotice(ctx: RunContext, fallbackFrom: string): void { const commandPath = ctx.path.join(" "); const message = formatApiKeyFallbackNotice(ctx, fallbackFrom, commandPath); - if (ctx.settings.output === "json") { - process.stderr.write( - JSON.stringify( - { - warning: { - code: "PROFILE_API_KEY_FALLBACK", - message, - profile: fallbackFrom, - command_path: ctx.path, - fallback_profile: "default", - credential_fields: ["api_key", "base_url"], - }, - }, - null, - 2, - ) + "\n\n", - ); - return; - } - process.stderr.write(`${message}\n`); + ctx.output.warning({ + code: "PROFILE_API_KEY_FALLBACK", + message, + details: { + profile: fallbackFrom, + command_path: ctx.path, + fallback_profile: "default", + credential_fields: ["api_key", "base_url"], + }, + }); } /** Fold a middleware list into a single runnable function. */ @@ -130,13 +119,14 @@ export const authStage: Middleware = async (ctx, next) => { const base = { identity: ctx.identity, settings, + debug: (diagnostic: Parameters[0]) => ctx.output.debug(diagnostic), ...resolveModelBaseUrlState(sources), }; if (command.auth === "apiKey") { const capability = ctx.path.join("."); const selection = selectApiKeyResolutionSources(sources, capability); const apiSources = selection.sources; - if (selection.fallbackFrom && !settings.quiet) { + if (selection.fallbackFrom) { writeApiKeyFallbackNotice(ctx, selection.fallbackFrom); } let cred: ApiKeyCredential | undefined; @@ -150,7 +140,7 @@ export const authStage: Middleware = async (ctx, next) => { ...resolveModelBaseUrlState(apiSources), apiCred: cred, }); - if (cred) maybeShowStatusBar(settings, cred.token, cred); + if (cred) maybeShowStatusBar(settings, cred.token, cred, ctx.output); } else if (command.auth === "console") { let cred: ConsoleCredential | undefined; try { @@ -201,7 +191,7 @@ export const versionCheckStage: Middleware = async (ctx, next) => { const isUpdateCommand = ctx.path.length === 1 && ctx.path[0] === "update"; const newVersion = getPendingUpdateNotification(); - if (newVersion && !ctx.settings.quiet && !isUpdateCommand) { + if (newVersion && !isUpdateCommand) { if (shouldAutoUpdate(newVersion, ctx.identity.version)) { // 大版本差距且目标为稳定版,自动更新 await performAutoUpdate( @@ -209,13 +199,21 @@ export const versionCheckStage: Middleware = async (ctx, next) => { newVersion, ctx.identity.npmPackage, ctx.identity.clientName, + { output: ctx.output, binName: ctx.identity.binName }, ); } else { - const color = ansi(process.stderr); - process.stderr.write( - `\n ${color.yellow(`Update available: ${ctx.identity.version} → ${newVersion}`)}\n`, - ); - process.stderr.write(` Run ${color.cyan(`${ctx.identity.binName} update`)} to upgrade\n\n`); + 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 升级。`, + }, + details: { + current_version: ctx.identity.version, + latest_version: newVersion, + upgrade_command: `${ctx.identity.binName} update`, + }, + }); } } }; diff --git a/packages/runtime/src/output/diagnostics.ts b/packages/runtime/src/output/diagnostics.ts index 1c704fb3a..6a3f58c82 100644 --- a/packages/runtime/src/output/diagnostics.ts +++ b/packages/runtime/src/output/diagnostics.ts @@ -1,47 +1,7 @@ import type { LocalizedText, OutputMessage } from "bailian-cli-core"; -const MAX_DIAGNOSTIC_DEPTH = 8; -const SECRET_KEY = - /(?:authorization|cookie|apikey|accesskey(?:id)?|privatekey|credentials?|token|password|secret)$/i; - -/** Convert diagnostics to inert data; never invoke accessors or serialization hooks. */ -export function sanitizeDiagnosticDetails( - value: unknown, - ancestors = new Set(), - depth = 0, -): unknown { - if (typeof value === "bigint") return value.toString(); - if (typeof value === "function") return "[Function]"; - if (value === null || typeof value !== "object") return value; - if (ancestors.has(value)) return "[Circular]"; - if (depth >= MAX_DIAGNOSTIC_DEPTH) return "[Truncated]"; - ancestors.add(value); - try { - const descriptors = Object.getOwnPropertyDescriptors(value); - const readDescriptor = (descriptor: PropertyDescriptor | undefined): unknown => { - if (!descriptor) return undefined; - if (!("value" in descriptor)) return "[Accessor]"; - return sanitizeDiagnosticDetails(descriptor.value as unknown, ancestors, depth + 1); - }; - if (Array.isArray(value)) { - const length = descriptors.length?.value as number; - return Array.from({ length }, (_entry, index) => readDescriptor(descriptors[String(index)])); - } - return Object.fromEntries( - Object.entries(descriptors) - .filter(([, descriptor]) => descriptor.enumerable) - .map(([key, descriptor]) => [ - key, - SECRET_KEY.test(key.replaceAll(/[-_]/g, "")) ? "[REDACTED]" : readDescriptor(descriptor), - ]), - ); - } catch { - // Proxy traps and exotic objects must not make verbose change business behavior. - return "[Unserializable]"; - } finally { - ancestors.delete(value); - } -} +import { sanitizeDiagnosticDetails } from "bailian-cli-core"; +export { sanitizeDiagnosticDetails } from "bailian-cli-core"; export function diagnosticPayload( input: OutputMessage, diff --git a/packages/runtime/src/output/reporter.ts b/packages/runtime/src/output/reporter.ts index 0ab9247f2..541e39761 100644 --- a/packages/runtime/src/output/reporter.ts +++ b/packages/runtime/src/output/reporter.ts @@ -8,9 +8,9 @@ import { type OutputMessage, type Settings, } from "bailian-cli-core"; -import { classifyRuntimeError } from "../error-handler.ts"; +import { errorHint, normalizeError } from "../error-handler.ts"; import { createTranslator } from "../i18n.ts"; -import { diagnosticPayload, sanitizeDiagnosticDetails } from "./diagnostics.ts"; +import { diagnosticPayload } from "./diagnostics.ts"; export interface OutputWriter { write(chunk: string): unknown; @@ -94,26 +94,25 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo }, error(error) { if (errorCode !== undefined) return errorCode; - const failure = - error instanceof BailianError - ? error - : ((error instanceof Error - ? classifyRuntimeError(error, options.binName ?? "cli", localize) - : undefined) ?? - new BailianError( - error instanceof Error ? error.message : describeThrownValue(error), - ExitCode.GENERAL, - undefined, - error instanceof Error ? { cause: error } : undefined, - )); + const failure = normalizeError(error, options.binName ?? "cli", localize); + const hint = errorHint(failure, options.binName ?? "cli", localize); + if (verbose && error instanceof Error && !(error instanceof BailianError)) { + diagnostic("debug", { + code: "ERROR_STACK", + message: { "en-US": "Error stack", "zh-CN": "错误调用栈" }, + details: { stack: error.stack }, + }); + } if (format === "json") { - stderr.write(JSON.stringify(failure.toJSON()) + "\n"); + const payload = failure.toJSON(); + if (hint !== undefined) payload.error.hint = hint; + stderr.write(JSON.stringify(payload) + "\n"); } else { const lines: string[] = []; const field = (label: LocalizedText, value: string | number) => lines.push(`${localize(label)}: ${value}`); field({ "en-US": "Error", "zh-CN": "错误" }, failure.message); - if (failure.hint) field({ "en-US": "Hint", "zh-CN": "提示" }, failure.hint); + if (hint) field({ "en-US": "Hint", "zh-CN": "提示" }, hint); if (failure.api?.httpStatus !== undefined) { const suffix = failure.api.apiCode ? ` (${failure.api.apiCode})` : ""; field({ "en-US": "Status", "zh-CN": "状态" }, `HTTP ${failure.api.httpStatus}${suffix}`); @@ -140,10 +139,3 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo }, }; } - -function describeThrownValue(value: unknown): string { - if (value !== null && typeof value === "object") { - return JSON.stringify(sanitizeDiagnosticDetails(value)); - } - return String(value); -} diff --git a/packages/runtime/src/output/status-bar.ts b/packages/runtime/src/output/status-bar.ts index 2ceb72c9c..d54d6d6e6 100644 --- a/packages/runtime/src/output/status-bar.ts +++ b/packages/runtime/src/output/status-bar.ts @@ -1,27 +1,23 @@ import { homedir } from "os"; -import { maskToken, type Settings, type ApiKeyCredential } from "bailian-cli-core"; -import { ansi, isTerminal } from "./color.ts"; - -function tildePath(p: string): string { - return p.startsWith(homedir()) ? p.replace(homedir(), "~") : p; -} +import { + maskToken, + type Settings, + type ApiKeyCredential, + type CommandOutput, +} from "bailian-cli-core"; +import { isTerminal } from "./color.ts"; export function maybeShowStatusBar( settings: Settings, token: string, resolved: ApiKeyCredential, + output: CommandOutput, ): void { - if (settings.quiet || !isTerminal(process.stderr)) return; - - const filePath = settings.configPath ? tildePath(settings.configPath) : "~/.bailian/config.json"; - const authTag = `${resolved.source} · api-key`; + if (!isTerminal(process.stderr)) return; + const filePath = settings.configPath?.replace(homedir(), "~") ?? "~/.bailian/config.json"; const maskedKey = maskToken(token); - const color = ansi(process.stderr); - - process.stderr.write( - `${color.brandBlue("BAILIAN")} ` + - `${color.dim(filePath)} ` + - `${color.dim("|")} ` + - `${color.dim("Auth:")} ${color.keyPink(maskedKey)} ${color.dim(authTag)}\n`, - ); + output.info({ + "en-US": `BAILIAN ${filePath} | Auth: ${maskedKey} ${resolved.source} · api-key`, + "zh-CN": `BAILIAN ${filePath} | 鉴权:${maskedKey} ${resolved.source} · api-key`, + }); } diff --git a/packages/runtime/src/utils/polling.ts b/packages/runtime/src/utils/polling.ts index 6a164e34b..7ea73b1c1 100644 --- a/packages/runtime/src/utils/polling.ts +++ b/packages/runtime/src/utils/polling.ts @@ -1,7 +1,7 @@ -import { BailianError, ExitCode, type Client, type Settings } from "bailian-cli-core"; -import { createSpinner } from "../output/progress.ts"; +import { BailianError, ExitCode, type Client, type CommandOutput } from "bailian-cli-core"; export interface PollOptions { + output: Pick; /** Absolute task URL (Client passes absolute URLs through as-is). */ url: string; intervalSec: number; @@ -12,42 +12,36 @@ export interface PollOptions { getErrorMessage?: (data: unknown) => string | undefined; } -export async function poll(client: Client, settings: Settings, opts: PollOptions): Promise { +export async function poll(client: Client, opts: PollOptions): Promise { const deadline = Date.now() + opts.timeoutSec * 1000; - const spinner = createSpinner("Polling..."); - - if (!settings.quiet) spinner.start(); - - try { - while (Date.now() < deadline) { - const data = await client.requestJson({ path: opts.url }); - - if (opts.getStatus && !settings.quiet) { - spinner.update(`Status: ${opts.getStatus(data)}`); - } - - if (opts.isComplete(data)) { - spinner.stop("Done."); - return data; - } - - if (opts.isFailed(data)) { - spinner.stop("Failed."); - if (settings.verbose) { - process.stderr.write(`[verbose] Task response: ${JSON.stringify(data, null, 2)}\n`); - } - const errMsg = opts.getErrorMessage?.(data); - throw new BailianError( - errMsg ? `Task failed: ${errMsg}` : "Task failed.", - ExitCode.GENERAL, - errMsg ? undefined : "Use --verbose to see full API response details.", - ); - } - - await new Promise((r) => setTimeout(r, opts.intervalSec * 1000)); + const { output } = opts; + output.progress({ "en-US": "Polling...", "zh-CN": "正在查询任务状态……" }); + + while (Date.now() < deadline) { + const data = await client.requestJson({ path: opts.url }); + + if (opts.getStatus) { + const status = opts.getStatus(data); + output.progress({ "en-US": `Status: ${status}`, "zh-CN": `状态:${status}` }); + } + + if (opts.isComplete(data)) { + output.progress({ "en-US": "Done.", "zh-CN": "已完成。" }); + return data; } - } finally { - spinner.stop(); + + if (opts.isFailed(data)) { + output.progress({ "en-US": "Failed.", "zh-CN": "任务失败。" }); + output.debug({ message: { "en-US": "Task response", "zh-CN": "任务响应" }, details: data }); + const errMsg = opts.getErrorMessage?.(data); + throw new BailianError( + errMsg ? `Task failed: ${errMsg}` : "Task failed.", + ExitCode.GENERAL, + errMsg ? undefined : "Use --verbose to see full API response details.", + ); + } + + await new Promise((resolve) => setTimeout(resolve, opts.intervalSec * 1000)); } throw new BailianError( diff --git a/packages/runtime/src/utils/update-checker.ts b/packages/runtime/src/utils/update-checker.ts index ed492a649..ab841b8b1 100644 --- a/packages/runtime/src/utils/update-checker.ts +++ b/packages/runtime/src/utils/update-checker.ts @@ -6,6 +6,7 @@ import { DEFAULT_INSTALL_SCRIPT_URL, getConfigDir, getUpdateInstallMethod, + type CommandOutput, } from "bailian-cli-core"; export const NPM_REGISTRY = "https://registry.npmjs.org"; @@ -209,124 +210,89 @@ function errorMessage(err: unknown): string { return String(err); } -async function syncAgentSkillsAfterUpdate( - dim: string, - green: string, - yellow: string, - reset: string, -): Promise { +type UpdateOutput = Pick; + +async function syncAgentSkillsAfterUpdate(output: UpdateOutput): Promise { try { - process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`); + output.info({ "en-US": "Syncing agent skills...", "zh-CN": "正在同步 Agent 技能……" }); const { execSync } = await import("child_process"); - execSync("bl skill update", { stdio: "inherit" }); - process.stderr.write(` ${green}\u2713 Agent skill updated.${reset}\n\n`); + execSync("bl skill update", { stdio: "pipe" }); + output.info({ "en-US": "Agent skills updated.", "zh-CN": "Agent 技能已更新。" }); } catch (error) { - process.stderr.write( - ` ${yellow}\u26a0 Agent skill sync failed: ${errorMessage(error)}${reset}\n`, - ); - process.stderr.write(` ${yellow} Run manually: bl skill update${reset}\n\n`); + 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。`, + }, + }); } } -/** - * Perform auto-update for npm or binary installs. - * Returns true if update succeeded, false otherwise. - */ +/** Updating is independent of presentation; subprocess output never inherits CLI channels. */ export async function performAutoUpdate( currentVersion: string, latestVersion: string, npmPackage: string = NPM_PACKAGE, clientName: string = NPM_PACKAGE, + options: { output: UpdateOutput; binName: string }, ): Promise { - const isTTY = process.stderr.isTTY; - const green = isTTY ? "\x1b[32m" : ""; - const yellow = isTTY ? "\x1b[33m" : ""; - const cyan = isTTY ? "\x1b[36m" : ""; - const dim = isTTY ? "\x1b[2m" : ""; - const reset = isTTY ? "\x1b[0m" : ""; - + const { output, binName } = options; const method = getUpdateInstallMethod({ clientName, npmPackage }); - if (method === "brew" || method === "winget") { - return false; - } - - const [latestMajor] = parseVersion(latestVersion); - const [currentMajor] = parseVersion(currentVersion); - const isMajorBump = latestMajor > currentMajor; - - process.stderr.write("\n"); - process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n`); - if (isMajorBump) { - process.stderr.write( - ` ${yellow}⚡ Major update detected: ${currentVersion} → ${latestVersion}${reset}\n`, - ); - } else { - process.stderr.write( - ` ${yellow}⚡ Significant update detected: ${currentVersion} → ${latestVersion}${reset}\n`, - ); - } - process.stderr.write(` ${dim}Auto-updating to keep your CLI up to date...${reset}\n`); - process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n\n`); - - if (method === "binary") { - try { - const { performBinaryUpdate } = await import("./binary-update.ts"); - const newVer = await performBinaryUpdate(latestVersion); - writeState({ lastChecked: Date.now(), latestVersion: newVer }); - process.stderr.write(` ${green}✓ Update complete: ${currentVersion} → ${newVer}${reset}\n`); - process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`); - await syncAgentSkillsAfterUpdate(dim, green, yellow, reset); - pendingNotification = null; - return true; - } catch (err) { - process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`); - const reinstall = - err instanceof BailianError && err.hint - ? err.hint.replace(/^Re-run:\s*/i, "") - : process.platform === "win32" - ? `irm ${DEFAULT_INSTALL_PS1_URL} | iex` - : `curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash`; - process.stderr.write(` ${yellow} Re-run:${reset} ${cyan}${reinstall}${reset}\n\n`); - return false; - } - } - - const cmd = `npm install -g ${npmPackage}@latest`; - + if (method === "brew" || method === "winget") return false; + output.info({ + "en-US": `Auto-updating: ${currentVersion} → ${latestVersion}`, + "zh-CN": `正在自动更新:${currentVersion} → ${latestVersion}`, + }); + const command = `npm install -g ${npmPackage}@latest`; try { - const { execSync } = await import("child_process"); - execSync(cmd, { stdio: "inherit" }); - - let newVer: string | null = null; - try { - const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); - const pkgPath = join(globalRoot, npmPackage, "package.json"); - const rawPkg = readFileSync(pkgPath, "utf-8"); - const pkg = JSON.parse(rawPkg) as { version?: string }; - newVer = pkg.version ?? null; - } catch (err) { - process.stderr.write( - ` ${yellow}⚠ Could not verify installed version: ${errorMessage(err)}${reset}\n`, - ); + let newVersion: string | null = null; + if (method === "binary") { + const { performBinaryUpdate } = await import("./binary-update.ts"); + newVersion = await performBinaryUpdate(latestVersion); + } else { + const { execSync } = await import("child_process"); + execSync(command, { stdio: "pipe" }); + try { + const globalRoot = execSync("npm root -g", { encoding: "utf-8", stdio: "pipe" }).trim(); + const pkg = JSON.parse( + readFileSync(join(globalRoot, npmPackage, "package.json"), "utf-8"), + ) as { version?: string }; + newVersion = pkg.version ?? null; + } catch (error) { + output.warning({ + code: "UPDATE_VERSION_UNVERIFIED", + message: { + "en-US": `Could not verify installed version: ${errorMessage(error)}`, + "zh-CN": `无法验证已安装版本:${errorMessage(error)}`, + }, + }); + } } - - writeState({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion }); - - process.stderr.write( - ` ${green}✓ Update complete: ${currentVersion} → ${newVer ?? latestVersion}${reset}\n`, - ); - process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`); - - await syncAgentSkillsAfterUpdate(dim, green, yellow, reset); - + 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 验证。`, + }); + await syncAgentSkillsAfterUpdate(output); pendingNotification = null; return true; - } catch (err) { - process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`); - process.stderr.write( - ` ${yellow} If this is a permissions error (EACCES), retry with sudo or fix npm perms.${reset}\n`, - ); - process.stderr.write(` ${yellow} Run manually:${reset} ${cyan}${cmd}${reset}\n\n`); + } catch (error) { + const retry = + method === "binary" + ? error instanceof BailianError && error.hint + ? error.hint.replace(/^Re-run:\s*/i, "") + : process.platform === "win32" + ? `irm ${DEFAULT_INSTALL_PS1_URL} | iex` + : `curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash` + : command; + output.warning({ + code: "AUTO_UPDATE_FAILED", + message: { + "en-US": `Auto-update failed: ${errorMessage(error)}. Run manually: ${retry}`, + "zh-CN": `自动更新失败:${errorMessage(error)}。请手动运行:${retry}`, + }, + }); return false; } } @@ -339,7 +305,12 @@ export async function checkForUpdate( const state = readState(); const now = Date.now(); - if (state && now - state.lastChecked < CHECK_INTERVAL_MS) return; + if (state && now - state.lastChecked < CHECK_INTERVAL_MS) { + pendingNotification = isNewerVersion(state.latestVersion, currentVersion) + ? state.latestVersion + : null; + return; + } const method = getUpdateInstallMethod({ clientName, npmPackage }); let latest: string | null = null; diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index 96f3516ef..daf230dcd 100644 --- a/packages/runtime/tests/auth-profile-fallback.test.ts +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -80,6 +80,13 @@ function makeContext( } async function runAuth(context: RunContext): Promise { + Object.assign(context, { + output: createOutputReporter({ + settings: context.settings, + localize: (text) => + typeof text === "string" ? text : text[context.sources.file.language ?? "en-US"], + }).output, + }); await authStage(context, async () => {}); } @@ -210,7 +217,7 @@ test("authStage 在 --api-key 覆盖 Profile API Key 时跳过 capability fallba }); }); -test("authStage fallback 准确描述 Profile 文件层降级,--quiet 可抑制提示", async () => { +test("authStage fallback 准确描述 Profile 文件层降级,--quiet 保留警告", async () => { useTempConfigDir(); await writeConfigFile({ api_key: "sk-default" }); await writeConfigFile( @@ -233,10 +240,10 @@ test("authStage fallback 准确描述 Profile 文件层降级,--quiet 可抑 const quietContext = makeContext(["text", "chat"]); const quiet = await captureStderr(() => runAuth(quietContext)); expect(quiet.error).toBeUndefined(); - expect(quiet.output).toBe(""); + expect(quiet.output).toContain("company-plan"); }); -test("authStage 在 JSON 模式输出多行结构化 fallback warning", async () => { +test("authStage 在 JSON 模式输出单行结构化 fallback warning", async () => { useTempConfigDir(); await writeConfigFile({ api_key: "sk-default" }); await writeConfigFile( @@ -254,16 +261,18 @@ test("authStage 在 JSON 模式输出多行结构化 fallback warning", async () const captured = await captureStderr(() => runAuth(context)); expect(captured.error).toBeUndefined(); - expect(captured.output).toMatch(/^\{\n {2}"warning": \{\n/); + expect(captured.output.trim().split("\n")).toHaveLength(1); expect(JSON.parse(captured.output.trim())).toEqual({ warning: { code: "PROFILE_API_KEY_FALLBACK", message: 'Profile "company-plan" 不支持命令 "text chat",本次将从 Profile "default" 读取 API Key 配置。', - profile: "company-plan", - command_path: ["text", "chat"], - fallback_profile: "default", - credential_fields: ["api_key", "base_url"], + details: { + profile: "company-plan", + command_path: ["text", "chat"], + fallback_profile: "default", + credential_fields: ["api_key", "base_url"], + }, }, }); }); diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index 1d023a43f..0c7ffcf45 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -1,126 +1,68 @@ -import { ExitCode } from "bailian-cli-core"; -import { expect, test } from "vite-plus/test"; +import { BailianError, ExitCode } from "bailian-cli-core"; +import { expect, test, vi } from "vite-plus/test"; import { handleError } from "../src/error-handler.ts"; +import { createOutputReporter } from "../src/output/reporter.ts"; import { ConfirmationRequiredError } from "../src/confirm.ts"; -test("handleError: fetch failed JSON includes cause.code from errno", () => { - const previousOutput = process.env.DASHSCOPE_OUTPUT; - process.env.DASHSCOPE_OUTPUT = "json"; - +function render(error: unknown, output: "text" | "json" = "json") { let stderr = ""; - const originalWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit.bind(process); - process.stderr.write = ((chunk: string | Uint8Array) => { - stderr += String(chunk); - return true; - }) as typeof process.stderr.write; - process.exit = ((code?: number) => { + const reporter = createOutputReporter({ + settings: { output, quiet: true, verbose: false }, + binName: "kscli", + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + const exit = vi.spyOn(process, "exit").mockImplementation((code) => { throw new Error(`process.exit:${code ?? 0}`); - }) as typeof process.exit; - - const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), { - code: "ENOTFOUND", }); - const fetchFailed = new TypeError("fetch failed", { cause: root }); - try { - expect(() => handleError(fetchFailed, "bl")).toThrow( - new RegExp(`process\\.exit:${ExitCode.NETWORK}`), - ); - expect(stderr).toMatch(/^\{\n {2}"error": \{\n/); - const payload = JSON.parse(stderr.trim()) as { - error: { code: number; message: string; cause?: { message: string; code?: string } }; - }; - expect(payload.error.code).toBe(ExitCode.NETWORK); - expect(payload.error.message).toMatch(/ENOTFOUND/); - expect(payload.error.cause).toEqual({ - message: root.message, - code: "ENOTFOUND", - }); + expect(() => handleError(error, reporter)).toThrow(/process.exit:/); } finally { - process.stderr.write = originalWrite; - process.exit = originalExit; - if (previousOutput === undefined) { - delete process.env.DASHSCOPE_OUTPUT; - } else { - process.env.DASHSCOPE_OUTPUT = previousOutput; - } + exit.mockRestore(); } -}); + return stderr; +} -test("handleError: fetch failed without nested cause still maps to NETWORK", () => { - const previousOutput = process.env.DASHSCOPE_OUTPUT; - process.env.DASHSCOPE_OUTPUT = "json"; - - let stderr = ""; - const originalWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit.bind(process); - process.stderr.write = ((chunk: string | Uint8Array) => { - stderr += String(chunk); - return true; - }) as typeof process.stderr.write; - process.exit = ((code?: number) => { - throw new Error(`process.exit:${code ?? 0}`); - }) as typeof process.exit; - - const fetchFailed = new TypeError("fetch failed"); - - try { - expect(() => handleError(fetchFailed, "bl")).toThrow( - new RegExp(`process\\.exit:${ExitCode.NETWORK}`), - ); - const payload = JSON.parse(stderr.trim()) as { - error: { code: number; message: string; cause?: { message: string; code?: string } }; - }; - expect(payload.error.code).toBe(ExitCode.NETWORK); - expect(payload.error.message).toMatch(/unknown cause/); - expect(payload.error.cause).toEqual({ message: "fetch failed" }); - } finally { - process.stderr.write = originalWrite; - process.exit = originalExit; - if (previousOutput === undefined) { - delete process.env.DASHSCOPE_OUTPUT; - } else { - process.env.DASHSCOPE_OUTPUT = previousOutput; - } - } +test("handleError: fetch failed JSON includes cause.code from errno", () => { + const cause = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), { + code: "ENOTFOUND", + }); + const stderr = render(new TypeError("fetch failed", { cause })); + expect(stderr.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(stderr).error).toMatchObject({ + code: ExitCode.NETWORK, + cause: { message: cause.message, code: "ENOTFOUND" }, + }); }); -test("handleError: confirmation text uses the standard message and hint layout", () => { - const previousOutput = process.env.DASHSCOPE_OUTPUT; - delete process.env.DASHSCOPE_OUTPUT; - - let stderr = ""; - const originalWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit.bind(process); - process.stderr.write = ((chunk: string | Uint8Array) => { - stderr += String(chunk); - return true; - }) as typeof process.stderr.write; - process.exit = ((code?: number) => { - throw new Error(`process.exit:${code ?? 0}`); - }) as typeof process.exit; +test("handleError: fetch without nested cause still maps to NETWORK", () => { + const payload = JSON.parse(render(new TypeError("fetch failed"))).error; + expect(payload.code).toBe(ExitCode.NETWORK); + expect(payload.message).toContain("unknown cause"); + expect(payload.cause).toEqual({ message: "fetch failed" }); +}); +test("handleError: confirmation preserves its structured subtype and guidance", () => { const confirmation = new ConfirmationRequiredError({ - message: "This permanently deletes the selected documents and all of their chunks.", - hint: "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", + message: "Permanent deletion.", + hint: "Add --yes to continue.", }); + expect(JSON.parse(render(confirmation))).toEqual(confirmation.toJSON()); + const text = render(confirmation, "text"); + expect(text).toContain("Add --yes to continue."); + expect(text).not.toContain("Risk:"); +}); - try { - expect(() => handleError(confirmation, "bl")).toThrow( - new RegExp(`process\\.exit:${ExitCode.CONFIRMATION_REQUIRED}`), - ); - expect(stderr).toContain( - "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", - ); - expect(stderr).not.toContain("bl knowledge doc delete"); - expect(stderr).not.toContain("Risk:"); - expect(stderr).not.toContain("Action:"); - expect(stderr).not.toContain("Note:"); - } finally { - process.stderr.write = originalWrite; - process.exit = originalExit; - if (previousOutput === undefined) delete process.env.DASHSCOPE_OUTPUT; - else process.env.DASHSCOPE_OUTPUT = previousOutput; - } +test("model-auth guidance uses the invocation binary and does not leak into console errors", () => { + const auth = JSON.parse(render(new BailianError("No key", ExitCode.AUTH))).error; + expect(auth.hint).toContain("kscli auth status"); + const consoleError = new BailianError( + "No console token", + ExitCode.AUTH, + "Run kscli auth login --console", + ); + expect(JSON.parse(render(consoleError))).toEqual(consoleError.toJSON()); }); diff --git a/packages/runtime/tests/harness/output-main.ts b/packages/runtime/tests/harness/output-main.ts index acd9302e7..05250ab99 100644 --- a/packages/runtime/tests/harness/output-main.ts +++ b/packages/runtime/tests/harness/output-main.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "bailian-cli-core"; +import { BailianError, ExitCode, defineCommand } from "bailian-cli-core"; import { createCli } from "../../src/create-cli.ts"; const probe = defineCommand({ @@ -12,8 +12,52 @@ const probe = defineCommand({ }, }); +const fail = defineCommand({ + description: "Failure probe", + auth: "none", + flags: { kind: { type: "string", valueHint: "kind", description: "Failure kind" } }, + async run(context) { + if (context.flags.kind === "network") { + throw new TypeError("fetch failed", { + cause: Object.assign(new Error("DNS failed"), { code: "ENOTFOUND" }), + }); + } + if (context.flags.kind === "report") { + context.output.warning("One item skipped"); + context.output.debug("Failure report ready"); + context.output.result({ successful: ["task-demo"], failed: ["row-2"] }); + throw new BailianError("Partial failure", ExitCode.GENERAL, "Retry row-2", { + api: { httpStatus: 500, apiCode: "ServerFailure", requestId: "request-demo" }, + }); + } + throw new SyntaxError("Invalid local JSON"); + }, +}); + +const transport = defineCommand({ + description: "HTTP output probe", + auth: "apiKey", + async run(context) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: "ServiceUnavailable", + message: "mock server rejected", + request_id: "request-transport", + }), + { status: 503 }, + ); + try { + await context.client.requestJson({ path: "/task?token=fake-query-secret" }); + } finally { + globalThis.fetch = originalFetch; + } + }, +}); + await createCli( - { probe }, + { probe, fail, transport }, { binName: "test-cli", version: "0.0.0", diff --git a/packages/runtime/tests/harness/process-handlers-main.ts b/packages/runtime/tests/harness/process-handlers-main.ts new file mode 100644 index 000000000..407d83405 --- /dev/null +++ b/packages/runtime/tests/harness/process-handlers-main.ts @@ -0,0 +1,68 @@ +import { defineCommand } from "bailian-cli-core"; +import { createCli } from "../../src/create-cli.ts"; + +const mode = process.argv[2]; +const parallelCalls = 12; +const before = { + interrupt: process.listenerCount("SIGINT"), + stdoutError: process.stdout.listenerCount("error"), +}; +let releaseCommands!: () => void; +const hold = new Promise((resolve) => { + releaseCommands = resolve; +}); +let reportReady!: () => void; +const ready = new Promise((resolve) => { + reportReady = resolve; +}); +let started = 0; +const command = defineCommand({ + description: "Process handler lifecycle probe", + auth: "none", + async run() { + started++; + if (started === parallelCalls) reportReady(); + await hold; + }, +}); +const options = { + binName: "test-cli", + version: "0.0.0", + clientName: "process-handler-test", + npmPackage: "process-handler-test", +}; +const cli = createCli({ hold: command }, options); +const pending = Promise.all( + Array.from({ length: parallelCalls }, () => + (mode === "separate" ? createCli({ hold: command }, options) : cli).run([ + "hold", + "--output=json", + "--quiet", + ]), + ), +); +await ready; +const during = { + interrupt: process.listenerCount("SIGINT"), + stdoutError: process.stdout.listenerCount("error"), +}; +if (mode === "interrupt") { + process.emit("SIGINT"); +} else if (mode === "stdout-error") { + process.stdout.emit("error", Object.assign(new Error("Mock stdout error"), { code: "EIO" })); +} else { + releaseCommands(); + await pending; + // Node warnings are delivered asynchronously; include them in the subprocess capture. + await new Promise((resolve) => setImmediate(resolve)); + process.stdout.write( + JSON.stringify({ + before, + during, + after: { + interrupt: process.listenerCount("SIGINT"), + stdoutError: process.stdout.listenerCount("error"), + }, + }) + "\n", + ); +} diff --git a/packages/runtime/tests/middleware-output.test.ts b/packages/runtime/tests/middleware-output.test.ts new file mode 100644 index 000000000..620be22c8 --- /dev/null +++ b/packages/runtime/tests/middleware-output.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, expect, test, vi } from "vite-plus/test"; +const updates = vi.hoisted(() => ({ + perform: vi.fn(async () => true), + pending: vi.fn(() => "2.0.0"), + automatic: vi.fn(() => true), +})); +vi.mock("../src/utils/update-checker.ts", () => ({ + checkForUpdate: async () => {}, + getPendingUpdateNotification: updates.pending, + shouldAutoUpdate: updates.automatic, + performAutoUpdate: updates.perform, +})); +import { versionCheckStage, type RunContext } from "../src/middleware.ts"; +import { createOutputReporter } from "../src/output/reporter.ts"; +import { createTranslator } from "../src/i18n.ts"; + +beforeEach(() => { + updates.perform.mockClear(); + updates.pending.mockReturnValue("2.0.0"); + updates.automatic.mockReturnValue(true); +}); + +test.each([false, true])("quiet=%s does not change auto-update execution", async (quiet) => { + const settings = { output: "json" as const, quiet, verbose: false }; + const output = createOutputReporter({ settings }).output; + const context = { + settings, + output, + path: ["probe"], + identity: { + version: "1.0.0", + npmPackage: "test-cli", + clientName: "test-cli", + binName: "test-cli", + }, + } as unknown as RunContext; + const next = vi.fn(async () => {}); + await versionCheckStage(context, next); + expect(next).toHaveBeenCalledOnce(); + expect(updates.perform).toHaveBeenCalledOnce(); + expect(updates.perform).toHaveBeenCalledWith("1.0.0", "2.0.0", "test-cli", "test-cli", { + output, + binName: "test-cli", + }); +}); + +const notificationCases = (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((quiet) => + (["en-US", "zh-CN"] as const).map((language) => ({ format, quiet, language })), + ), +); + +test.each(notificationCases)( + "update warning is visible with $format quiet=$quiet language=$language", + async ({ format, quiet, language }) => { + updates.pending.mockReturnValue("2.6.1"); + updates.automatic.mockReturnValue(false); + let stdout = ""; + let stderr = ""; + const settings = { output: format, quiet, verbose: false }; + const output = createOutputReporter({ + settings, + localize: (message) => createTranslator(language).localize(message), + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }).output; + const context = { + settings, + output, + path: ["probe"], + identity: { + version: "2.3.0", + npmPackage: "knowledge-studio-cli", + clientName: "knowledge-studio-cli", + binName: "kscli", + }, + } as unknown as RunContext; + const next = vi.fn(async () => output.result({ ok: true }, { text: "OK" })); + await versionCheckStage(context, next); + expect(next).toHaveBeenCalledOnce(); + expect(updates.perform).not.toHaveBeenCalled(); + if (format === "json") { + expect(JSON.parse(stdout)).toEqual({ ok: true }); + const records = stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records).toEqual([ + { + warning: { + code: "UPDATE_AVAILABLE", + message: expect.stringContaining( + language === "zh-CN" ? "发现新版本" : "Update available", + ), + details: { + current_version: "2.3.0", + latest_version: "2.6.1", + upgrade_command: "kscli update", + }, + }, + }, + ]); + } else { + expect(stdout).toBe("OK\n"); + expect(stderr).toContain(language === "zh-CN" ? "警告:" : "Warning:"); + expect(stderr).toContain("2.3.0 → 2.6.1"); + expect(stderr).toContain("kscli update"); + } + }, +); diff --git a/packages/runtime/tests/output-reporter.test.ts b/packages/runtime/tests/output-reporter.test.ts index 59ff6daff..3b47e2643 100644 --- a/packages/runtime/tests/output-reporter.test.ts +++ b/packages/runtime/tests/output-reporter.test.ts @@ -445,3 +445,58 @@ test.each(["info", "progress"] as const)( } }, ); + +test("structured error causes redact credentials without executing serialization hooks", () => { + const streams = capture({ output: "json", quiet: false, verbose: true }); + let calls = 0; + const cause = { + api_key: "fake-error-secret", + details: { password: "fake-error-password" }, + toJSON() { + calls++; + return { leaked: "fake-error-secret" }; + }, + }; + streams.reporter.error( + new BailianError("Original failure", ExitCode.GENERAL, undefined, { cause }), + ); + expect(calls).toBe(0); + expect(streams.stderr()).not.toContain("fake-error-secret"); + expect(streams.stderr()).not.toContain("fake-error-password"); + expect(JSON.parse(JSON.parse(streams.stderr()).error.cause.message).api_key).toBe("[REDACTED]"); +}); + +test.each(matrix)( + "ordinary error stack follows verbose with $output quiet=$quiet verbose=$verbose", + (settings) => { + const streams = capture(settings); + const failure = new SyntaxError("Invalid local JSON"); + failure.stack = "SyntaxError: Invalid local JSON\n at parseInput (/fixture/command.ts:42:7)"; + expect(streams.reporter.error(failure)).toBe(ExitCode.GENERAL); + expect(streams.reporter.error(failure)).toBe(ExitCode.GENERAL); + expect(streams.stdout()).toBe(""); + expect(streams.stderr().includes("/fixture/command.ts:42:7")).toBe(settings.verbose); + if (settings.output === "json") { + const records = streams + .stderr() + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.filter((record) => record.error)).toHaveLength(1); + expect(records.filter((record) => record.debug)).toEqual( + settings.verbose + ? [ + { + debug: { + code: "ERROR_STACK", + message: "Error stack", + details: { stack: failure.stack }, + }, + }, + ] + : [], + ); + expect(records.at(-1).error.code).toBe(ExitCode.GENERAL); + } + }, +); diff --git a/packages/runtime/tests/progress-output.test.ts b/packages/runtime/tests/progress-output.test.ts new file mode 100644 index 000000000..f5b3a956f --- /dev/null +++ b/packages/runtime/tests/progress-output.test.ts @@ -0,0 +1,93 @@ +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Client, Settings } from "bailian-cli-core"; +import { createOutputReporter } from "../src/output/reporter.ts"; +import { poll } from "../src/utils/polling.ts"; +import { downloadFile } from "../src/utils/download.ts"; + +afterEach(() => vi.restoreAllMocks()); + +test("failed polling emits only structured debug before throwing", async () => { + let stderr = ""; + const settings = { output: "json", quiet: true, verbose: true } as Settings; + const output = createOutputReporter({ + settings, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }).output; + const client = { + requestJson: async () => ({ status: "FAILED", api_key: "fake-secret" }), + } as unknown as Client; + await expect( + poll(client, { + url: "/task", + intervalSec: 0, + timeoutSec: 1, + isComplete: () => false, + isFailed: () => true, + output, + }), + ).rejects.toThrow("Task failed"); + const records = stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records).toEqual([ + { debug: { message: "Task response", details: { status: "FAILED", api_key: "[REDACTED]" } } }, + ]); +}); + +test.each( + (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((quiet) => [false, true].map((tty) => ({ format, quiet, tty }))), + ), +)( + "download progress keeps terminal behavior: $format quiet=$quiet tty=$tty", + async ({ format, quiet, tty }) => { + const directory = mkdtempSync(join(tmpdir(), "bl-download-output-")); + let stderr = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const ttyDescriptor = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { value: tty, configurable: true }); + try { + const chunks = [new TextEncoder().encode("pay"), new TextEncoder().encode("load")]; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }), + { headers: { "content-length": "7" } }, + ), + ); + const destination = join(directory, "file.bin"); + expect( + await downloadFile("https://example.invalid/file", destination, { + quiet: quiet || format === "json", + }), + ).toEqual({ size: 7 }); + expect(readFileSync(destination, "utf-8")).toBe("payload"); + if (tty && format === "text" && !quiet) { + expect(stderr).toContain("\r"); + expect(stderr).toContain("100%"); + expect(stderr.match(/\n/g)).toHaveLength(1); + } else { + expect(stderr).toBe(""); + } + } finally { + if (ttyDescriptor) Object.defineProperty(process.stderr, "isTTY", ttyDescriptor); + else Reflect.deleteProperty(process.stderr, "isTTY"); + rmSync(directory, { recursive: true, force: true }); + } + }, +); diff --git a/packages/runtime/tests/runtime-output.e2e.test.ts b/packages/runtime/tests/runtime-output.e2e.test.ts new file mode 100644 index 000000000..95b1fe314 --- /dev/null +++ b/packages/runtime/tests/runtime-output.e2e.test.ts @@ -0,0 +1,264 @@ +import { execFile } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { expect, test } from "vite-plus/test"; +import { runNodeMain } from "e2e/runner"; + +const runtimeRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const main = join(runtimeRoot, "tests/harness/output-main.ts"); + +async function invoke( + args: string[], + options: { output?: string; corrupt?: boolean; proxy?: string; verbose?: "0" | "1" } = {}, +) { + const configDir = mkdtempSync(join(tmpdir(), "bl-runtime-output-")); + try { + writeFileSync( + join(configDir, "config.json"), + options.corrupt + ? "{invalid" + : JSON.stringify({ language: "en-US", output: options.output ?? "text" }), + ); + writeFileSync( + join(configDir, "update-state.json"), + JSON.stringify({ lastChecked: Date.now(), latestVersion: "0.0.0" }), + ); + return await runNodeMain(main, args, { + cwd: runtimeRoot, + env: { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_OUTPUT: "", + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: options.verbose ?? "0", + CI: "1", + HTTPS_PROXY: options.proxy ?? "", + HTTP_PROXY: "", + ALL_PROXY: "", + }, + }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +} + +function diagnostics(stderr: string) { + return stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); +} + +test.each([[], ["--quiet"], ["--verbose"], ["--quiet", "--verbose"]].map((flags) => ({ flags })))( + "ordinary errors are JSON with flags $flags", + async ({ flags }) => { + const result = await invoke(["fail", "--output", "json", ...flags]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const records = diagnostics(result.stderr); + expect(records.filter((record) => record.error)).toEqual([ + { error: expect.objectContaining({ code: 1, message: "Invalid local JSON" }) }, + ]); + expect(records.filter((record) => record.debug)).toHaveLength( + flags.includes("--verbose") ? 1 : 0, + ); + }, +); + +test("profile-selected JSON also formats ordinary errors", async () => { + const result = await invoke(["fail"], { output: "json" }); + expect(result.exitCode).toBe(1); + expect(diagnostics(result.stderr)[0].error.message).toBe("Invalid local JSON"); +}); + +test.each( + [ + ["unknown", "--output=json"], + ["probe", "--bad", "--output", "json"], + ].map((args) => ({ args })), +)("early parser errors honor supplied argv $args", async ({ args }) => { + const result = await invoke(args); + expect(result.exitCode).toBe(2); + expect(diagnostics(result.stderr)[0].error.code).toBe(2); +}); + +test("corrupt configuration preserves defaults with a structured warning", async () => { + const result = await invoke(["probe", "--output", "json"], { corrupt: true }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ task_id: "task-demo" }); + expect( + diagnostics(result.stderr).filter((line) => line.warning?.code === "CONFIG_CORRUPTED"), + ).toHaveLength(1); +}); + +test("report, warning, debug and terminal error preserve separate channels", async () => { + const result = await invoke([ + "fail", + "--kind", + "report", + "--output=json", + "--quiet", + "--verbose", + ]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ successful: ["task-demo"], failed: ["row-2"] }); + const lines = diagnostics(result.stderr); + expect(lines.map((line) => Object.keys(line)[0])).toEqual(["warning", "debug", "error"]); + expect(lines[2].error.request_id).toBe("request-demo"); +}); + +test("network errors retain errno and network exit code", async () => { + const result = await invoke(["fail", "--kind", "network", "--output=json"]); + expect(result.exitCode).toBe(6); + expect(diagnostics(result.stderr)[0].error.cause.code).toBe("ENOTFOUND"); +}); + +test.each([false, true])( + "transport failures with verbose=%s keep stderr JSON-only", + async (verbose) => { + const result = await invoke([ + "transport", + "--api-key", + "fake-api-secret", + "--base-url", + "https://example.invalid", + "--output=json", + "--quiet", + ...(verbose ? ["--verbose"] : []), + ]); + expect(result.exitCode).toBe(1); + const records = diagnostics(result.stderr); + expect(records.map((record) => Object.keys(record)[0])).toEqual( + verbose ? ["debug", "debug", "error"] : ["error"], + ); + expect(records.at(-1).error.request_id).toBe("request-transport"); + expect(result.stderr).not.toContain("fake-api-secret"); + expect(result.stderr).not.toContain("fake-query-secret"); + }, +); + +test("invalid proxy initialization honors explicit JSON", async () => { + const result = await invoke(["probe", "--output=json"], { proxy: "invalid-proxy" }); + expect(result.exitCode).toBe(2); + expect(diagnostics(result.stderr)[0].error.message).toContain("proxy"); +}); + +test("invalid profile selection honors explicit JSON", async () => { + const result = await invoke(["probe", "--config", "api_key", "--output=json"]); + expect(result.exitCode).toBe(2); + expect(diagnostics(result.stderr)[0].error.code).toBe(2); +}); + +test("explicit text overrides profile-selected JSON on failure", async () => { + const result = await invoke(["fail", "--output=text"], { output: "json" }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: Invalid local JSON"); +}); + +test("a closed stdout pipe still exits successfully after version dispatch", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-output-pipe-")); + try { + writeFileSync(join(configDir, "config.json"), "{}"); + const result = await new Promise<{ code: number; stderr: string }>((resolve) => { + const child = execFile( + process.execPath, + ["--import", import.meta.resolve("tsx"), main, "--version"], + { + cwd: runtimeRoot, + env: { + ...process.env, + BAILIAN_CONFIG_DIR: configDir, + DO_NOT_TRACK: "1", + NODE_NO_WARNINGS: "1", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + }, + encoding: "utf8", + }, + (error, _stdout, stderr) => { + resolve({ code: error ? (typeof error.code === "number" ? error.code : 1) : 0, stderr }); + }, + ); + child.stdout?.destroy(); + }); + expect(result.code, result.stderr).toBe(0); + expect(result.stderr).not.toContain("EPIPE"); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +}); + +test.each(["0", "1"] as const)( + "DASHSCOPE_VERBOSE=%s controls error stack even with quiet", + async (verbose) => { + const result = await invoke(["fail", "--output=json", "--quiet"], { verbose }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const records = diagnostics(result.stderr); + const stack = records.find((record) => record.debug?.code === "ERROR_STACK"); + if (verbose === "1") { + expect(stack.debug.details.stack).toContain("SyntaxError: Invalid local JSON"); + expect(stack.debug.details.stack).toContain("output-main.ts"); + } else expect(stack).toBeUndefined(); + expect(records.at(-1).error.message).toBe("Invalid local JSON"); + }, +); + +test.each(["shared", "separate", "interrupt", "stdout-error"])( + "concurrent CLI process handlers preserve JSON and cleanup (%s)", + async (mode) => { + const configDir = mkdtempSync(join(tmpdir(), "bl-process-handlers-")); + try { + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ telemetry: false, language: "en-US" }), + ); + writeFileSync( + join(configDir, "update-state.json"), + JSON.stringify({ lastChecked: Date.now(), latestVersion: "0.0.0" }), + ); + const result = await runNodeMain( + join(runtimeRoot, "tests/harness/process-handlers-main.ts"), + [mode], + { + cwd: runtimeRoot, + env: { + BAILIAN_CONFIG_DIR: configDir, + DO_NOT_TRACK: "1", + DASHSCOPE_OUTPUT: "", + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: "0", + NODE_NO_WARNINGS: "", + NODE_OPTIONS: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + }, + }, + ); + if (mode === "shared" || mode === "separate") { + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + const { before, during, after } = JSON.parse(result.stdout); + expect(during.interrupt - before.interrupt).toBe(1); + expect(during.stdoutError - before.stdoutError).toBe(1); + expect(after.interrupt).toBe(before.interrupt); + // The stateless EPIPE guard must survive pending writes after run() returns. + expect(after.stdoutError - before.stdoutError).toBe(1); + } else { + expect(result.exitCode, result.stderr).toBe(mode === "interrupt" ? 130 : 1); + expect(result.stdout).toBe(""); + const records = diagnostics(result.stderr); + expect(records).toHaveLength(1); + expect(records[0].error.code).toBe(mode === "interrupt" ? 130 : 1); + expect(records[0].error.message).toContain( + mode === "interrupt" ? "Interrupted" : "Mock stdout error", + ); + } + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/runtime/tests/transport-diagnostics.test.ts b/packages/runtime/tests/transport-diagnostics.test.ts new file mode 100644 index 000000000..2e06b266f --- /dev/null +++ b/packages/runtime/tests/transport-diagnostics.test.ts @@ -0,0 +1,151 @@ +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { + Client, + McpClient, + createInstrumentedFetch, + requestJson, + type HttpDeps, + type OutputMessage, +} from "bailian-cli-core"; +import { createOutputReporter } from "../src/output/reporter.ts"; + +const identity = { + binName: "test-cli", + version: "0.0.0", + npmPackage: "test-cli", + clientName: "test-cli", +}; +const settings = { + output: "json" as const, + outputExplicit: true, + timeout: 5, + watermark: true, + verbose: true, + quiet: true, + dryRun: false, + telemetry: false, +}; + +afterEach(() => vi.restoreAllMocks()); + +test.each(["http", "instrumented", "console", "openapi"])( + "%s diagnostics use injected JSON writer without credentials", + async (transport) => { + let stderr = ""; + const reporter = createOutputReporter({ + settings, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + const debug = (diagnostic: OutputMessage) => reporter.output.debug(diagnostic); + const deps = { identity, settings, debug }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ Success: true, Data: {} }), { + headers: { "x-request-id": "request-demo" }, + }), + ); + const legacy = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = new Client({ + ...deps, + baseUrl: "https://example.invalid", + apiCred: { token: "fake-api-secret", baseUrl: "https://example.invalid", source: "flag" }, + consoleCred: { + token: "fake-console-secret", + region: "cn-beijing", + site: "domestic", + source: "flag", + }, + openApiCred: { + accessKeyId: "fake-ak-secret", + accessKeySecret: "fake-access-secret", + source: "flag", + }, + }); + if (transport === "http") + await requestJson(deps, { + url: "https://user:fake-password@example.invalid/task?api_key=fake-query-secret", + headers: { Authorization: "Bearer fake-api-secret" }, + }); + if (transport === "instrumented") + await createInstrumentedFetch(deps)("https://example.invalid/task?token=fake-query-secret", { + headers: { Authorization: "Bearer fake-api-secret" }, + }); + if (transport === "console") await client.console("test.api", { api_key: "fake-body-secret" }); + if (transport === "openapi") + await client.openApiJson({ + host: "example.invalid", + path: "/", + action: "List", + version: "2026-01-01", + method: "POST", + body: { password: "fake-body-secret" }, + }); + expect(legacy).not.toHaveBeenCalled(); + const records = stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.length).toBeGreaterThanOrEqual(2); + expect(records.every((record) => record.debug)).toBe(true); + expect(stderr).not.toMatch(/fake-(?:api|query|body|password|console|ak|access)-secret/); + }, +); + +test("core transport is silent without a diagnostic callback", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}")); + const legacy = vi.spyOn(console, "error").mockImplementation(() => {}); + await requestJson({ identity, settings } satisfies HttpDeps, { url: "https://example.invalid" }); + expect(legacy).not.toHaveBeenCalled(); +}); + +test("MCP session diagnostics also use the callback", async () => { + const records: OutputMessage[] = []; + let stderr = ""; + const output = createOutputReporter({ + settings, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }).output; + const legacy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { + if (typeof init?.body !== "string") throw new Error("Expected RPC JSON body"); + const body = JSON.parse(init.body); + return body.id === undefined + ? new Response(null, { status: 202 }) + : new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + result: { + serverInfo: { name: "test" }, + instructions: "https://example.invalid/?token=fake-mcp-secret", + customPayload: "private-user-content", + }, + }), + { headers: { "content-type": "application/json" } }, + ); + }); + const client = new McpClient( + { + identity, + settings, + debug: (diagnostic) => { + records.push(diagnostic); + output.debug(diagnostic); + }, + }, + "https://example.invalid/mcp", + "fake-api-secret", + ); + await client.initialize(); + expect(records.length).toBeGreaterThanOrEqual(3); + expect(legacy).not.toHaveBeenCalled(); + expect(stderr).not.toContain("fake-mcp-secret"); + expect(stderr).not.toContain("private-user-content"); +}); diff --git a/packages/runtime/tests/update-checker.test.ts b/packages/runtime/tests/update-checker.test.ts index 1f290c1fa..84cb85a8a 100644 --- a/packages/runtime/tests/update-checker.test.ts +++ b/packages/runtime/tests/update-checker.test.ts @@ -1,4 +1,6 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { createOutputReporter } from "../src/output/reporter.ts"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; @@ -22,6 +24,8 @@ vi.mock("child_process", async (importOriginal) => { }); import { + checkForUpdate, + getPendingUpdateNotification, compareVersion, isMajorUpgrade, isNewerVersion, @@ -171,14 +175,18 @@ test("shouldAutoUpdate only targets stable releases with a significant gap", () }); function expectSkillUpdateNotInit(): void { - expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill update", { stdio: "inherit" }); + expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill update", { stdio: "pipe" }); expect( childProcessMocks.execSync.mock.calls.some((call) => String(call[0]).includes("skill init")), ).toBe(false); } test("binary auto-update refreshes installed skills and does not run skill init", async () => { - const updated = await performAutoUpdate("1.14.3", "2.0.0"); + const updated = await performAutoUpdate("1.14.3", "2.0.0", "bailian-cli", "bailian-cli", { + output: createOutputReporter({ settings: { output: "text", quiet: true, verbose: false } }) + .output, + binName: "bl", + }); expect(updated).toBe(true); expect(binaryUpdateMocks.performBinaryUpdate).toHaveBeenCalledWith("2.0.0"); @@ -189,11 +197,171 @@ test("npm auto-update refreshes installed skills and does not run skill init", a process.env.BAILIAN_INSTALL_METHOD = "npm"; childProcessMocks.execSync.mockReturnValue(""); - const updated = await performAutoUpdate("1.14.3", "2.0.0"); + const updated = await performAutoUpdate("1.14.3", "2.0.0", "bailian-cli", "bailian-cli", { + output: createOutputReporter({ settings: { output: "text", quiet: true, verbose: false } }) + .output, + binName: "bl", + }); expect(updated).toBe(true); expect(childProcessMocks.execSync).toHaveBeenCalledWith("npm install -g bailian-cli@latest", { - stdio: "inherit", + stdio: "pipe", }); expectSkillUpdateNotInit(); }); + +test("auto-update uses the injected output and captures subprocess streams", async () => { + let stderr = ""; + const output = createOutputReporter({ + settings: { output: "json", quiet: true, verbose: false }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }).output; + const direct = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + expect( + await performAutoUpdate("1.14.3", "2.0.0", "bailian-cli", "bailian-cli", { + output, + binName: "bl", + }), + ).toBe(true); + expect(stderr).toBe(""); + expect(direct).not.toHaveBeenCalled(); + expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill update", { stdio: "pipe" }); + } finally { + direct.mockRestore(); + } +}); + +test.each([false, true])( + "kscli updates retain the existing bl skill sync with failure=%s", + async (fails) => { + const output = { info: vi.fn(), warning: vi.fn() }; + if (fails) { + childProcessMocks.execSync.mockImplementation((command) => { + if (command === "bl skill update" || command === "kscli skill update") { + throw new Error("mock skill sync failure"); + } + return ""; + }); + } + expect( + await performAutoUpdate("1.14.3", "2.0.0", "knowledge-studio-cli", "knowledge-studio-cli", { + output, + binName: "kscli", + }), + ).toBe(true); + expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill update", { stdio: "pipe" }); + expect(childProcessMocks.execSync).not.toHaveBeenCalledWith( + "kscli skill update", + expect.anything(), + ); + if (fails) { + expect(output.warning).toHaveBeenCalledWith( + expect.objectContaining({ + code: "SKILL_SYNC_FAILED", + message: { + "en-US": expect.stringContaining("Run bl skill update manually."), + "zh-CN": expect.stringContaining("请手动运行 bl skill update。"), + }, + }), + ); + } else { + expect(output.warning).not.toHaveBeenCalledWith( + expect.objectContaining({ code: "SKILL_SYNC_FAILED" }), + ); + } + }, +); + +test.each([false, true])( + "npm root diagnostics never bypass JSON output with failure=%s", + async (fails) => { + process.env.BAILIAN_INSTALL_METHOD = "npm"; + const globalRoot = join(configDir, "global"); + mkdirSync(join(globalRoot, "bailian-cli"), { recursive: true }); + writeFileSync( + join(globalRoot, "bailian-cli", "package.json"), + JSON.stringify({ version: "2.0.0" }), + ); + childProcessMocks.execSync.mockImplementation((command, options) => { + if (command !== "npm root -g") return ""; + // Exercise Node's actual child stderr forwarding, without running npm or an installation. + return execFileSync( + process.execPath, + [ + "-e", + [ + 'process.stderr.write("npm WARN mock-root-diagnostic\\n");', + `process.stdout.write(${JSON.stringify(globalRoot)});`, + `process.exit(${fails ? 1 : 0});`, + ].join("\n"), + ], + options as ExecFileSyncOptionsWithStringEncoding, + ); + }); + let diagnostics = ""; + const output = createOutputReporter({ + settings: { output: "json", quiet: true, verbose: false }, + stderr: { + write: (chunk) => { + diagnostics += chunk; + }, + }, + }).output; + let bypassed = ""; + const direct = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + bypassed += String(chunk); + return true; + }); + try { + expect( + await performAutoUpdate("1.0.0", "2.0.0", "bailian-cli", "bailian-cli", { + output, + binName: "bl", + }), + ).toBe(true); + expect(bypassed).toBe(""); + if (fails) { + const records = diagnostics + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records).toHaveLength(1); + expect(records[0].warning.code).toBe("UPDATE_VERSION_UNVERIFIED"); + expect(records[0].warning.message).toContain("npm WARN mock-root-diagnostic"); + } else { + expect(diagnostics).toBe(""); + } + } finally { + direct.mockRestore(); + } + }, +); + +test("cached newer versions notify without another network check and clear after upgrading", async () => { + writeFileSync( + join(configDir, "update-state.json"), + JSON.stringify({ + lastChecked: Date.now(), + latestVersion: "2.6.1", + }), + ); + const network = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("Network must not be used")); + try { + await checkForUpdate("2.3.0"); + expect(getPendingUpdateNotification()).toBe("2.6.1"); + await checkForUpdate("2.6.1"); + expect(getPendingUpdateNotification()).toBeNull(); + await checkForUpdate("2.7.0"); + expect(getPendingUpdateNotification()).toBeNull(); + expect(network).not.toHaveBeenCalled(); + } finally { + network.mockRestore(); + } +}); From eaf8f265bde49f290831b8b3e030bdfa4421252f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 28 Oct 2026 14:13:22 +0800 Subject: [PATCH 03/28] refactor(commands): unify video results and quiet handling - Route video command results through the unified output API - Preserve complete results in quiet mode and hide process messages in JSON mode - Use the shared output API for concurrent request notices - Preserve existing download progress and failure behavior - Add video output contract regression tests --- docs/agents/output-contract.md | 3 +- packages/commands/src/commands/image/edit.ts | 16 +- .../commands/src/commands/image/generate.ts | 16 +- .../src/commands/speech/synthesize.ts | 6 +- .../commands/src/commands/video/download.ts | 13 +- packages/commands/src/commands/video/edit.ts | 65 ++-- .../commands/src/commands/video/generate.ts | 39 +-- packages/commands/src/commands/video/ref.ts | 64 ++-- .../commands/src/commands/video/task-get.ts | 14 +- .../tests/output/video-output.test.ts | 284 ++++++++++++++++++ packages/runtime/src/utils/concurrent.ts | 25 +- 11 files changed, 401 insertions(+), 144 deletions(-) create mode 100644 packages/commands/tests/output/video-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index f710b83ce..8e3784254 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 @@ -16,6 +16,7 @@ - [ ] 用 ctx.output.result(data, { text? }) 提交一次最终结果,text 只控制文本呈现;JSON 始终序列化 data,false/0/null/空列表都合法 - [ ] progress 与 info 写 stderr,仅 text 且非 quiet 时显示;progress 当前在非 TTY 下仍输出普通进度文本,不创建动画定时器;仅动画要求 TTY +- [ ] 并发请求提示通过 runConcurrent 的 output.info 输出,数量、请求顺序和失败传播不受 quiet/verbose 影响 - [ ] 下载进度沿用 TTY 原地刷新,非 TTY 不输出;quiet/JSON 时关闭,不把逐数据块更新转换为追加日志 - [ ] warning 始终保留;debug 仅 verbose 开启时显示;quiet 不关闭 verbose 明确请求的诊断 - [ ] 可升级通知使用 UPDATE_AVAILABLE warning,JSON/quiet 下保留;details 提供 current_version、latest_version、upgrade_command,业务结果和退出码不变,联网检查仍按既有间隔执行,命中版本缓存时仍生成可升级通知;自动更新阈值不变 diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index c73bec319..0de8e310b 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -323,7 +323,16 @@ export default defineCommand({ const concurrent = getConcurrency(flags); if (route.useSync) { - await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); + await handleSyncMode( + ctx.client, + ctx.output, + settings, + route.path, + body, + flags, + format, + concurrent, + ); } else { await handleAsyncMode( ctx.client, @@ -341,6 +350,7 @@ export default defineCommand({ async function handleSyncMode( client: Client, + commandOutput: CommandOutput, settings: Settings, path: string, body: DashScopeImageRequest, @@ -348,7 +358,7 @@ async function handleSyncMode( format: OutputFormat, concurrent: number, ): Promise { - const results = await runConcurrent(concurrent, settings, () => + const results = await runConcurrent(concurrent, commandOutput, () => client.requestJson({ path, method: "POST", @@ -381,7 +391,7 @@ async function handleAsyncMode( ): Promise { const responses = await runConcurrent( concurrent, - settings, + commandOutput, () => client.requestJson({ path, diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index d79ee05bd..575a47923 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -232,7 +232,16 @@ export default defineCommand({ } if (route.useSync) { - await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); + await handleSyncMode( + ctx.client, + ctx.output, + settings, + route.path, + body, + flags, + format, + concurrent, + ); } else { await handleAsyncMode( ctx.client, @@ -252,6 +261,7 @@ export default defineCommand({ async function handleSyncMode( client: Client, + commandOutput: CommandOutput, settings: Settings, path: string, body: DashScopeImageRequest, @@ -259,7 +269,7 @@ async function handleSyncMode( format: string, concurrent: number, ): Promise { - const results = await runConcurrent(concurrent, settings, () => + const results = await runConcurrent(concurrent, commandOutput, () => client.requestJson({ path, method: "POST", body }), ); @@ -290,7 +300,7 @@ async function handleAsyncMode( ): Promise { const responses = await runConcurrent( concurrent, - settings, + commandOutput, () => client.requestJson({ path, diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index 023709ea3..cc5e3373d 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -4,6 +4,7 @@ import { defineCommand, ExitCode, detectOutputFormat, + type CommandOutput, type Client, type Settings, type DashScopeTTSRequest, @@ -440,13 +441,14 @@ export default defineCommand({ if (useStream) { await handleStreamMode(ctx.client, settings, body, flags, format); } else { - await handleNonStreamMode(ctx.client, settings, body, flags, format); + await handleNonStreamMode(ctx.client, ctx.output, settings, body, flags, format); } }, }); async function handleNonStreamMode( client: Client, + commandOutput: CommandOutput, settings: Settings, body: DashScopeTTSRequest, flags: SynthesizeFlags, @@ -454,7 +456,7 @@ async function handleNonStreamMode( ): Promise { const concurrent = getConcurrency(flags); - const results = await runConcurrent(concurrent, settings, () => + const results = await runConcurrent(concurrent, commandOutput, () => client.requestJson({ path: speechSynthesizePath(), method: "POST", diff --git a/packages/commands/src/commands/video/download.ts b/packages/commands/src/commands/video/download.ts index 8072bd99d..9ceaa316f 100644 --- a/packages/commands/src/commands/video/download.ts +++ b/packages/commands/src/commands/video/download.ts @@ -1,13 +1,11 @@ import { defineCommand, taskPath, - detectOutputFormat, type DashScopeTaskResponse, BailianError, ExitCode, } from "bailian-cli-core"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ description: { @@ -40,10 +38,8 @@ export default defineCommand({ const outPath = flags.out; - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { - emitResult({ task_id: taskId, action: "download", out: outPath }, format); + ctx.output.result({ task_id: taskId, action: "download", out: outPath }); return; } @@ -76,11 +72,6 @@ export default defineCommand({ quiet: settings.quiet || settings.output === "json", }); - if (settings.quiet) { - emitBare(outPath); - return; - } - - emitResult({ saved: outPath, size: formatBytes(size) }, format); + ctx.output.result({ saved: outPath, size: formatBytes(size) }); }, }); diff --git a/packages/commands/src/commands/video/edit.ts b/packages/commands/src/commands/video/edit.ts index 661d1f55b..87c022def 100644 --- a/packages/commands/src/commands/video/edit.ts +++ b/packages/commands/src/commands/video/edit.ts @@ -2,7 +2,6 @@ import { defineCommand, videoGeneratePath, taskPath, - detectOutputFormat, type DashScopeVideoEditRequest, type DashScopeAsyncResponse, type DashScopeTaskResponse, @@ -17,7 +16,6 @@ import { import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; import { runConcurrent, getConcurrency } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; export default defineCommand({ @@ -173,7 +171,6 @@ export default defineCommand({ const prompt = flags.prompt; const model = flags.model || "happyhorse-1.0-video-edit"; - const format = detectOutputFormat(settings.output); // Auto-upload local files const resolvedVideoUrl = await ctx.client.uploadFile(videoUrl, model); @@ -187,7 +184,7 @@ export default defineCommand({ if (refImageArg) { const images = refImageArg .split(",") - .map((s) => s.trim()) + .map((image) => image.trim()) .filter(Boolean); for (const imgUrl of images) { const resolved = await ctx.client.uploadFile(imgUrl, model); @@ -218,14 +215,14 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult({ request: body }, format); + ctx.output.result({ request: body }); return; } const concurrent = getConcurrency(flags); const responses = await runConcurrent( concurrent, - settings, + ctx.output, () => ctx.client.requestJson({ path: videoGeneratePath(), @@ -236,16 +233,17 @@ export default defineCommand({ "tasks", ); - const taskIds = responses.map((r) => r.output.task_id); + const taskIds = responses.map((response) => response.output.task_id); - if (!settings.quiet) { - process.stderr.write(`[Model: ${model}]\n`); - process.stderr.write("Note: Video editing typically takes 5-8 minutes. Please be patient.\n"); - } + ctx.output.info({ "en-US": `[Model: ${model}]`, "zh-CN": `[模型:${model}]` }); + ctx.output.info({ + "en-US": "Note: Video editing typically takes 5-8 minutes. Please be patient.", + "zh-CN": "提示:视频编辑通常需要 5–8 分钟,请耐心等待。", + }); // --async: return task ID(s) immediately if (flags.async) { - emitResult(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }, format); + ctx.output.result(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }); return; } @@ -261,23 +259,23 @@ export default defineCommand({ url: ctx.client.url(taskPath(taskId)), intervalSec: pollInterval, timeoutSec: editTimeout, - isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", - isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", - getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, - getErrorMessage: (d) => { - const o = (d as DashScopeTaskResponse).output; - return o.message || o.code || undefined; + isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (data) => (data as DashScopeTaskResponse).output.task_status, + getErrorMessage: (data) => { + const output = (data as DashScopeTaskResponse).output; + return output.message || output.code || undefined; }, }), ), ); const videos: Array<{ taskId: string; videoUrl: string }> = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]!; + for (let resultIndex = 0; resultIndex < results.length; resultIndex++) { + const result = results[resultIndex]!; const videoUrl = result.output.video_url || (result.output.results && result.output.results[0]?.url); - if (videoUrl) videos.push({ taskId: taskIds[i]!, videoUrl }); + if (videoUrl) videos.push({ taskId: taskIds[resultIndex]!, videoUrl }); } if (videos.length === 0) { @@ -291,20 +289,13 @@ export default defineCommand({ quiet: settings.quiet || settings.output === "json", }); - if (settings.quiet) { - emitBare(destPath); - } else { - emitResult( - { - task_id: videos[0]!.taskId, - video_url: videos[0]!.videoUrl, - status: "SUCCEEDED", - saved: destPath, - size: formatBytes(size), - }, - format, - ); - } + ctx.output.result({ + task_id: videos[0]!.taskId, + video_url: videos[0]!.videoUrl, + status: "SUCCEEDED", + saved: destPath, + size: formatBytes(size), + }); return; } @@ -323,9 +314,9 @@ export default defineCommand({ ); if (saved.length === 1) { - emitResult(saved[0]!, format); + ctx.output.result(saved[0]!); } else { - emitResult({ videos: saved, total: saved.length }, format); + ctx.output.result({ videos: saved, total: saved.length }); } }, }); diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 9fefcf54f..63575f8bf 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -3,7 +3,6 @@ import { videoGeneratePath, image2videoPath, taskPath, - detectOutputFormat, type DashScopeVideoRequest, type DashScopeAsyncResponse, type DashScopeTaskResponse, @@ -19,7 +18,6 @@ import { import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; import { runConcurrent, getConcurrency } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; import { PRIME_FLAGS, resolvePrimeEndpoint, validatePrimeFlags } from "../shared/prime.ts"; @@ -190,7 +188,6 @@ export default defineCommand({ (flags.image ? settings.defaultImageToVideoModel || "wan3.0-video" : settings.defaultVideoModel || "wan3.0-video"); - const format = detectOutputFormat(settings.output); const imageUrl = flags.image; const lastFrameUrl = flags.lastFrame as string | undefined; @@ -309,9 +306,8 @@ export default defineCommand({ }, }; } - emitResult( + ctx.output.result( flags.prime ? { endpoint: submitPath, request: previewBody } : { request: previewBody }, - format, ); return; } @@ -321,7 +317,7 @@ export default defineCommand({ const responses = await runConcurrent( concurrent, - settings, + ctx.output, () => ctx.client.requestJson({ path: submitPath, @@ -334,13 +330,11 @@ export default defineCommand({ const taskIds = responses.map((response) => response.output.task_id); - if (!settings.quiet) { - process.stderr.write(`[Model: ${model}]\n`); - } + ctx.output.info({ "en-US": `[Model: ${model}]`, "zh-CN": `[模型:${model}]` }); // --async: return task ID(s) immediately if (flags.async) { - emitResult(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }, format); + ctx.output.result(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }); return; } @@ -389,20 +383,13 @@ export default defineCommand({ quiet: settings.quiet || settings.output === "json", }); - if (settings.quiet) { - emitBare(destPath); - } else { - emitResult( - { - task_id: videos[0]!.taskId, - video_url: videos[0]!.videoUrl, - status: "SUCCEEDED", - saved: destPath, - size: formatBytes(size), - }, - format, - ); - } + ctx.output.result({ + task_id: videos[0]!.taskId, + video_url: videos[0]!.videoUrl, + status: "SUCCEEDED", + saved: destPath, + size: formatBytes(size), + }); return; } @@ -423,9 +410,9 @@ export default defineCommand({ ); if (saved.length === 1) { - emitResult(saved[0]!, format); + ctx.output.result(saved[0]!); } else { - emitResult({ videos: saved, total: saved.length }, format); + ctx.output.result({ videos: saved, total: saved.length }); } }, }); diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index 5187e421d..f52e88d31 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -2,7 +2,6 @@ import { defineCommand, videoGeneratePath, taskPath, - detectOutputFormat, type DashScopeVideoRefRequest, type DashScopeAsyncResponse, type DashScopeTaskResponse, @@ -18,7 +17,6 @@ import { import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; import { runConcurrent, getConcurrency } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; export default defineCommand({ @@ -173,8 +171,9 @@ export default defineCommand({ "zh-CN": '--prompt "图片 1 中的人物喝水" --image person.jpg --watermark false', }, ], - validate: (f) => - !(f.image as string[] | undefined)?.length && !(f.refVideo as string[] | undefined)?.length + validate: (flags) => + !(flags.image as string[] | undefined)?.length && + !(flags.refVideo as string[] | undefined)?.length ? "Provide at least one --image or --ref-video." : undefined, async run(ctx) { @@ -188,7 +187,6 @@ export default defineCommand({ const videoVoices = flags.videoVoice || []; const model = flags.model || settings.defaultReferenceToVideoModel || "wan3.0-video"; - const format = detectOutputFormat(settings.output); // wan3.0-video emits voice as standalone reference_audio entries (referenced as Audio 1, Audio 2 in prompt); // other models (wan2.7-r2v / happyhorse-1.1-r2v, etc.) keep the legacy reference_voice field attached to each asset. @@ -279,14 +277,14 @@ export default defineCommand({ })), }, }; - emitResult({ request: previewBody }, format); + ctx.output.result({ request: previewBody }); return; } const concurrent = getConcurrency(flags); const responses = await runConcurrent( concurrent, - settings, + ctx.output, () => ctx.client.requestJson({ path: videoGeneratePath(), @@ -297,18 +295,17 @@ export default defineCommand({ "tasks", ); - const taskIds = responses.map((r) => r.output.task_id); + const taskIds = responses.map((response) => response.output.task_id); - if (!settings.quiet) { - process.stderr.write(`[Model: ${model}]\n`); - process.stderr.write( - `Note: Reference-to-video typically takes 5-10 minutes. Please be patient.\n`, - ); - } + ctx.output.info({ "en-US": `[Model: ${model}]`, "zh-CN": `[模型:${model}]` }); + ctx.output.info({ + "en-US": "Note: Reference-to-video typically takes 5-10 minutes. Please be patient.", + "zh-CN": "提示:参考生视频通常需要 5–10 分钟,请耐心等待。", + }); // --async: return task ID(s) immediately if (flags.async) { - emitResult(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }, format); + ctx.output.result(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }); return; } @@ -323,12 +320,12 @@ export default defineCommand({ url: ctx.client.url(taskPath(taskId)), intervalSec: pollInterval, timeoutSec: refTimeout, - isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", - isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", - getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, - getErrorMessage: (d) => { - const o = (d as DashScopeTaskResponse).output; - return o.message || o.code || undefined; + isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (data) => (data as DashScopeTaskResponse).output.task_status, + getErrorMessage: (data) => { + const output = (data as DashScopeTaskResponse).output; + return output.message || output.code || undefined; }, }), ), @@ -353,20 +350,13 @@ export default defineCommand({ quiet: settings.quiet || settings.output === "json", }); - if (settings.quiet) { - emitBare(destPath); - } else { - emitResult( - { - task_id: videos[0]!.taskId, - video_url: videos[0]!.videoUrl, - status: "SUCCEEDED", - saved: destPath, - size: formatBytes(size), - }, - format, - ); - } + ctx.output.result({ + task_id: videos[0]!.taskId, + video_url: videos[0]!.videoUrl, + status: "SUCCEEDED", + saved: destPath, + size: formatBytes(size), + }); return; } @@ -386,9 +376,9 @@ export default defineCommand({ ); if (saved.length === 1) { - emitResult(saved[0]!, format); + ctx.output.result(saved[0]!); } else { - emitResult({ videos: saved, total: saved.length }, format); + ctx.output.result({ videos: saved, total: saved.length }); } }, }); diff --git a/packages/commands/src/commands/video/task-get.ts b/packages/commands/src/commands/video/task-get.ts index 502f60a8d..f367ce53f 100644 --- a/packages/commands/src/commands/video/task-get.ts +++ b/packages/commands/src/commands/video/task-get.ts @@ -1,11 +1,9 @@ import { defineCommand, taskPath, - detectOutputFormat, stripUndefined, type DashScopeTaskResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ description: { "en-US": "Query async task status", "zh-CN": "查询异步任务状态" }, @@ -27,10 +25,8 @@ export default defineCommand({ const { settings, flags } = ctx; const taskId = flags.taskId; - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { - emitResult({ task_id: taskId }, format); + ctx.output.result({ task_id: taskId }); return; } @@ -38,13 +34,8 @@ export default defineCommand({ path: taskPath(taskId), }); - if (settings.quiet) { - emitBare(response.output.task_status); - return; - } - // 透传服务端失败字段:FAILED 时 output.code / output.message 是排查依据;request_id 便于工单溯源。 - emitResult( + ctx.output.result( stripUndefined({ task_id: response.output.task_id, task_status: response.output.task_status, @@ -57,7 +48,6 @@ export default defineCommand({ message: response.output.message, request_id: response.request_id, }), - format, ); }, }); diff --git a/packages/commands/tests/output/video-output.test.ts b/packages/commands/tests/output/video-output.test.ts new file mode 100644 index 000000000..b53af80c2 --- /dev/null +++ b/packages/commands/tests/output/video-output.test.ts @@ -0,0 +1,284 @@ +import { 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"; +import { parse as parseYaml } from "yaml"; +import { buildSettings, type AnyCommand, type LocalizedText } from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import generate from "../../src/commands/video/generate.ts"; +import reference from "../../src/commands/video/ref.ts"; +import edit from "../../src/commands/video/edit.ts"; +import download from "../../src/commands/video/download.ts"; +import taskGet from "../../src/commands/video/task-get.ts"; + +let directory: string; +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "bl-video-output-")); +}); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(directory, { recursive: true, force: true }); +}); + +const generators = [ + { name: "generate", command: generate, flags: { prompt: "A quiet lake" } }, + { + name: "reference", + command: reference, + flags: { prompt: "Image 1 moves", image: ["https://example.invalid/image.png"] }, + }, + { + name: "edit", + command: edit, + flags: { prompt: "Change the background", video: "https://example.invalid/source.mp4" }, + }, +]; +const cases = [ + ...generators.flatMap((entry) => + (["explicit", "automatic", "async"] as const).map((mode) => ({ ...entry, mode })), + ), + { name: "download", command: download, flags: { taskId: "task-1", out: "" }, mode: "download" }, + { name: "task-get", command: taskGet, flags: { taskId: "task-1" }, mode: "query" }, +]; +const displayCases = (["text", "json"] as const).flatMap((format) => + [false, true].map((verbose) => ({ format, verbose })), +); + +async function invoke( + command: AnyCommand, + flags: Record, + display: { + format: "text" | "json"; + quiet: boolean; + verbose: boolean; + dryRun?: boolean; + failedTask?: boolean; + }, +) { + let stdout = ""; + let stderr = ""; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const ttyDescriptor = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + env: {}, + file: { output_dir: directory, timeout: 1 }, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); + const reporter = createOutputReporter({ settings, localize }); + let submissions = 0; + const requestJson = vi.fn( + async (request: { async?: boolean; path: string; method?: string; body?: unknown }) => { + if (request.async) return { output: { task_id: `task-${++submissions}` } }; + const taskId = new URL(request.path, "https://api.invalid").pathname.split("/").at(-1); + return { + request_id: "request-test", + output: { + task_id: taskId, + task_status: display.failedTask ? "FAILED" : "SUCCEEDED", + video_url: `https://video.invalid/${taskId}.mp4`, + ...(display.failedTask + ? { code: "ServerFailure", message: "Original service failure" } + : {}), + }, + }; + }, + ); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("pay")); + controller.enqueue(new TextEncoder().encode("load")); + controller.close(); + }, + }), + { headers: { "content-length": "7" } }, + ), + ); + const context = { + flags, + settings, + output: reporter.output, + localize, + identity: { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }, + client: { + requestJson, + url: (path: string) => new URL(path, "https://api.invalid").href, + uploadFile: async (url: string) => url, + resolveImageInput: async (url: string) => url, + }, + } as unknown as Parameters[0]; + let exitCode = 0; + try { + try { + await command.run(context); + } catch (error) { + exitCode = reporter.error(error); + } + return { + stdout, + stderr, + exitCode, + data: stdout + ? display.format === "json" + ? JSON.parse(stdout) + : parseYaml(stdout) + : undefined, + diagnostics: + display.format === "json" && stderr + ? stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : [], + requests: requestJson.mock.calls.map(([request]) => request), + downloads: fetchMock.mock.calls.length, + }; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + fetchMock.mockRestore(); + if (ttyDescriptor) Object.defineProperty(process.stderr, "isTTY", ttyDescriptor); + else Reflect.deleteProperty(process.stderr, "isTTY"); + } +} + +for (const entry of cases) { + test.each(displayCases)( + `${entry.name}/${entry.mode} preserves results and requests with $format verbose=$verbose`, + async ({ format, verbose }) => { + const destination = join(directory, "explicit.mp4"); + const flags: Record = { ...entry.flags, pollInterval: 0 }; + if (entry.mode === "explicit") flags.download = destination; + if (entry.mode === "download") flags.out = destination; + if (entry.mode === "automatic" || entry.mode === "async") flags.concurrent = 2; + if (entry.mode === "async") flags.async = true; + const runs = []; + for (const quiet of [false, true]) { + const result = await invoke(entry.command, flags, { format, quiet, verbose }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.diagnostics).toEqual([]); + if (quiet || format === "json") expect(result.stderr).toBe(""); + if (!quiet && format === "text" && entry.mode !== "download" && entry.mode !== "query") { + expect(result.stderr).toContain("[Model:"); + if (flags.concurrent === 2) expect(result.stderr).toContain("[Concurrent: 2 tasks]"); + if (entry.name === "reference") + expect(result.stderr).toContain("Reference-to-video typically takes"); + if (entry.name === "edit") + expect(result.stderr).toContain("Video editing typically takes"); + } + if (entry.mode === "explicit") { + expect(result.data).toEqual({ + task_id: "task-1", + video_url: "https://video.invalid/task-1.mp4", + status: "SUCCEEDED", + saved: destination, + size: "7 B", + }); + expect(readFileSync(destination, "utf8")).toBe("payload"); + } else if (entry.mode === "download") { + expect(result.data).toEqual({ saved: destination, size: "7 B" }); + expect(readFileSync(destination, "utf8")).toBe("payload"); + if (format === "text" && !quiet) { + expect(result.stderr).toContain("\r"); + expect(result.stderr.match(/\n/g)).toHaveLength(1); + } + } else if (entry.mode === "async") { + expect(result.data).toEqual({ task_ids: ["task-1", "task-2"] }); + expect(result.downloads).toBe(0); + expect(result.requests).toHaveLength(2); + } else if (entry.mode === "automatic") { + expect(result.data).toEqual({ + total: 2, + videos: expect.arrayContaining( + [1, 2].map((number) => ({ + task_id: `task-${number}`, + video_url: `https://video.invalid/task-${number}.mp4`, + saved: join(directory, "videos", `task-${number}.mp4`), + })), + ), + }); + expect(result.data.videos).toHaveLength(2); + expect(result.downloads).toBe(2); + } else { + expect(result.data).toEqual({ + task_id: "task-1", + task_status: "SUCCEEDED", + video_url: "https://video.invalid/task-1.mp4", + request_id: "request-test", + }); + } + runs.push(result); + } + expect(runs[1]!.requests).toEqual(runs[0]!.requests); + }, + ); +} + +test("querying a FAILED task preserves service fields and still succeeds with JSON quiet", async () => { + const result = await invoke( + taskGet, + { taskId: "task-1" }, + { format: "json", quiet: true, verbose: true, failedTask: true }, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.data).toMatchObject({ + task_status: "FAILED", + code: "ServerFailure", + message: "Original service failure", + request_id: "request-test", + }); +}); + +test.each(generators)( + "$name polling failure retains a single structured error", + async ({ command, flags }) => { + const result = await invoke( + command, + { ...flags, concurrent: 2, pollInterval: 0 }, + { format: "json", quiet: false, verbose: false, failedTask: true }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].error.message).toContain("Original service failure"); + }, +); + +test.each([ + ...generators, + { name: "download", command: download, flags: { taskId: "task-1", out: "preview.mp4" } }, + { name: "task-get", command: taskGet, flags: { taskId: "task-1" } }, +])( + "$name dry-run remains JSON with quiet and does not submit or download", + async ({ command, flags }) => { + const result = await invoke(command, flags, { + format: "json", + quiet: true, + verbose: true, + dryRun: true, + }); + expect(result.exitCode).toBe(0); + expect(result.data).toBeTypeOf("object"); + expect(result.requests).toEqual([]); + expect(result.downloads).toBe(0); + expect(result.stderr).toBe(""); + }, +); diff --git a/packages/runtime/src/utils/concurrent.ts b/packages/runtime/src/utils/concurrent.ts index 7e7c11ac6..aa3f80274 100644 --- a/packages/runtime/src/utils/concurrent.ts +++ b/packages/runtime/src/utils/concurrent.ts @@ -5,10 +5,10 @@ * Used by commands that declare `--concurrent `. * * @example - * const results = await runConcurrent(3, config, () => callApi()); + * const results = await runConcurrent(3, output, () => callApi()); */ -import type { Settings } from "bailian-cli-core"; +import type { CommandOutput } from "bailian-cli-core"; /** Resolve concurrency from parsed command flags(`--concurrent`,defaults to 1). */ export function getConcurrency(flags: { concurrent?: number }): number { @@ -20,27 +20,28 @@ export function getConcurrency(flags: { concurrent?: number }): number { * Returns all resolved results in order. * If any single task fails, the error propagates (Promise.all semantics). * - * @param n Number of concurrent executions - * @param settings Resolved settings (for logging) + * @param count Number of concurrent executions + * @param output Invocation output for process information * @param task Async factory to execute * @param label Optional label for status output (e.g. "requests", "tasks") */ export async function runConcurrent( - n: number, - settings: Settings, + count: number, + output: Pick, task: (index: number) => Promise, - label = "requests", + label: "requests" | "tasks" = "requests", ): Promise { - if (n <= 1) { + if (count <= 1) { const result = await task(0); return [result]; } - if (!settings.quiet) { - process.stderr.write(`[Concurrent: ${n} ${label}]\n`); - } + output.info({ + "en-US": `[Concurrent: ${count} ${label}]`, + "zh-CN": `[并发:${count} ${label === "tasks" ? "个任务" : "个请求"}]`, + }); - const tasks = Array.from({ length: n }, (_, i) => task(i)); + const tasks = Array.from({ length: count }, (_entry, index) => task(index)); return Promise.all(tasks); } From 28ab6c1f7db1c351b3995f205a18ac783c12d391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 28 Oct 2026 15:04:00 +0800 Subject: [PATCH 04/28] refactor(commands): unify image results and quiet handling - Route image generation and editing results through the unified output API - Preserve complete results and task metadata in quiet mode - Localize model notices and hide process information in JSON mode - Preserve text-mode download progress behavior - Add image output contract regression tests --- docs/agents/output-contract.md | 2 +- packages/commands/src/commands/image/edit.ts | 65 +-- .../commands/src/commands/image/generate.ts | 77 ++-- .../tests/output/image-output.test.ts | 383 ++++++++++++++++++ 4 files changed, 429 insertions(+), 98 deletions(-) create mode 100644 packages/commands/tests/output/image-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index 8e3784254..81d0aeadf 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get 和图片 generate / edit 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index 0de8e310b..0beb90116 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -1,7 +1,6 @@ import { defineCommand, taskPath, - detectOutputFormat, resolveOutputDir, generateFilename, stripUndefined, @@ -12,7 +11,6 @@ import { type DashScopeAsyncResponse, type DashScopeTaskResponse, type FlagsDef, - type OutputFormat, type ParsedFlags, type Settings, ExitCode, @@ -27,7 +25,6 @@ import { import { poll } from "bailian-cli-runtime"; import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveImageSize } from "bailian-cli-runtime"; import { join } from "path"; import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; @@ -271,8 +268,6 @@ export default defineCommand({ // Remove undefined parameters stripUndefined(body.parameters as Record); - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { let previewBody: DashScopeImageRequest = body; if ("messages" in body.input) { @@ -307,43 +302,25 @@ export default defineCommand({ }, }; } - emitResult( - { request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path }, - format, - ); + ctx.output.result({ + request: previewBody, + mode: route.useSync ? "sync" : "async", + path: route.path, + }); return; } - if (!settings.quiet) { - process.stderr.write( - `[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`, - ); - } + ctx.output.info({ + "en-US": `[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]`, + "zh-CN": `[模型:${model}] [模式:${route.useSync ? "同步" : "异步"}] [图片:${resolvedImages.length}]`, + }); const concurrent = getConcurrency(flags); if (route.useSync) { - await handleSyncMode( - ctx.client, - ctx.output, - settings, - route.path, - body, - flags, - format, - concurrent, - ); + await handleSyncMode(ctx.client, ctx.output, settings, route.path, body, flags, concurrent); } else { - await handleAsyncMode( - ctx.client, - ctx.output, - settings, - route.path, - body, - flags, - format, - concurrent, - ); + await handleAsyncMode(ctx.client, ctx.output, settings, route.path, body, flags, concurrent); } }, }); @@ -355,7 +332,6 @@ async function handleSyncMode( path: string, body: DashScopeImageRequest, flags: EditFlags, - format: OutputFormat, concurrent: number, ): Promise { const results = await runConcurrent(concurrent, commandOutput, () => @@ -376,7 +352,7 @@ async function handleSyncMode( throw new BailianError("Edit completed but no images returned.", ExitCode.GENERAL); } - await saveImages(imageUrls, flags, settings, format); + await saveImages(imageUrls, flags, settings, commandOutput); } async function handleAsyncMode( @@ -386,7 +362,6 @@ async function handleAsyncMode( path: string, body: DashScopeImageRequest, flags: EditFlags, - format: OutputFormat, concurrent: number, ): Promise { const responses = await runConcurrent( @@ -404,7 +379,7 @@ async function handleAsyncMode( const taskIds = responses.map((response) => response.output.task_id); if (flags.async) { - emitResult({ task_ids: taskIds }, format); + commandOutput.result({ task_ids: taskIds }); return; } @@ -448,14 +423,14 @@ async function handleAsyncMode( throw new BailianError("All tasks completed but no images returned.", ExitCode.GENERAL); } - await saveImages(imageUrls, flags, settings, format); + await saveImages(imageUrls, flags, settings, commandOutput); } async function saveImages( imageUrls: string[], flags: EditFlags, settings: Settings, - format: OutputFormat, + commandOutput: CommandOutput, ): Promise { const outDir = resolveOutputDir(settings, { flagDir: flags.outDir, @@ -473,11 +448,9 @@ async function saveImages( }) : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; - const saved = await downloadParallel(items, downloadFile, { quiet: settings.quiet }); + const saved = await downloadParallel(items, downloadFile, { + quiet: settings.quiet || settings.output === "json", + }); - if (settings.quiet) { - emitBare(saved.join("\n")); - } else { - emitResult({ urls: imageUrls, saved, total: imageUrls.length }, format); - } + commandOutput.result({ urls: imageUrls, saved, total: imageUrls.length }); } diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 575a47923..12456eed4 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -1,7 +1,6 @@ import { defineCommand, taskPath, - detectOutputFormat, type CommandOutput, type Client, type Settings, @@ -13,7 +12,6 @@ import { BailianError, ExitCode, type DashScopeAsyncResponse, - type OutputFormat, type DashScopeTaskResponse, generateFilename, resolveBooleanFlag, @@ -25,7 +23,6 @@ import { import { poll } from "bailian-cli-runtime"; import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveImageSize } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; @@ -217,42 +214,24 @@ export default defineCommand({ }, }; - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { - emitResult( - { request: body, mode: route.useSync ? "sync" : "async", path: route.path }, - format, - ); + ctx.output.result({ + request: body, + mode: route.useSync ? "sync" : "async", + path: route.path, + }); return; } - if (!settings.quiet) { - process.stderr.write(`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]\n`); - } + ctx.output.info({ + "en-US": `[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]`, + "zh-CN": `[模型:${model}] [模式:${route.useSync ? "同步" : "异步"}]`, + }); if (route.useSync) { - await handleSyncMode( - ctx.client, - ctx.output, - settings, - route.path, - body, - flags, - format, - concurrent, - ); + await handleSyncMode(ctx.client, ctx.output, settings, route.path, body, flags, concurrent); } else { - await handleAsyncMode( - ctx.client, - ctx.output, - settings, - route.path, - body, - flags, - format, - concurrent, - ); + await handleAsyncMode(ctx.client, ctx.output, settings, route.path, body, flags, concurrent); } }, }); @@ -266,7 +245,6 @@ async function handleSyncMode( path: string, body: DashScopeImageRequest, flags: GenerateFlags, - format: string, concurrent: number, ): Promise { const results = await runConcurrent(concurrent, commandOutput, () => @@ -283,7 +261,7 @@ async function handleSyncMode( throw new BailianError("Generation completed but no images returned.", ExitCode.GENERAL); } - await saveImages(imageUrls, flags, settings, format); + await saveImages(imageUrls, flags, settings, commandOutput); } // ---- Async mode: wan2.6-t2i / wan2.6-image / legacy text2image ---- @@ -295,7 +273,6 @@ async function handleAsyncMode( path: string, body: DashScopeImageRequest, flags: GenerateFlags, - format: string, concurrent: number, ): Promise { const responses = await runConcurrent( @@ -314,7 +291,7 @@ async function handleAsyncMode( // --async: return all task IDs immediately if (flags.async) { - emitResult({ task_ids: taskIds }, format as OutputFormat); + commandOutput.result({ task_ids: taskIds }); return; } @@ -365,7 +342,7 @@ async function handleAsyncMode( imageUrls, flags, settings, - format, + commandOutput, taskIds.length === 1 ? taskIds[0] : undefined, taskIds, ); @@ -377,7 +354,7 @@ async function saveImages( imageUrls: string[], flags: GenerateFlags, settings: Settings, - format: string, + commandOutput: CommandOutput, taskId?: string, taskIds?: string[], ): Promise { @@ -398,18 +375,16 @@ async function saveImages( }) : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; - const results = await downloadParallel(items, downloadFile, { quiet: settings.quiet }); + const results = await downloadParallel(items, downloadFile, { + quiet: settings.quiet || settings.output === "json", + }); - if (settings.quiet) { - emitBare(results.join("\n")); - } else { - const output: Record = { - urls: imageUrls, - saved: results, - total: imageUrls.length, - }; - if (taskId) output.task_id = taskId; - if (taskIds && taskIds.length > 1) output.task_ids = taskIds; - emitResult(output, format as OutputFormat); - } + const output: Record = { + urls: imageUrls, + saved: results, + total: imageUrls.length, + }; + if (taskId) output.task_id = taskId; + if (taskIds && taskIds.length > 1) output.task_ids = taskIds; + commandOutput.result(output); } diff --git a/packages/commands/tests/output/image-output.test.ts b/packages/commands/tests/output/image-output.test.ts new file mode 100644 index 000000000..b60ef1dfb --- /dev/null +++ b/packages/commands/tests/output/image-output.test.ts @@ -0,0 +1,383 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { parse as parseYaml } from "yaml"; +import { buildSettings, Client, type AnyCommand, type LocalizedText } from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import generate from "../../src/commands/image/generate.ts"; +import edit from "../../src/commands/image/edit.ts"; + +let directory: string; +let server: Server; +let origin: string; +let scenario: "success" | "submit-error" | "task-failed" | "empty" | "download-error"; +let requests: Array<{ method: string; path: string; body: unknown; async: boolean }>; +let legacyResults: boolean; +let submissions: number; +let downloads: number; + +beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), "bl-image-output-")); + scenario = "success"; + requests = []; + submissions = 0; + downloads = 0; + legacyResults = false; + server = createServer(async (request, response) => { + if (request.url?.startsWith("/files/")) { + downloads++; + if (scenario === "download-error") { + response.writeHead(503).end("unavailable"); + return; + } + response.writeHead(200, { "content-length": "7" }); + response.write("pay"); + response.end("load"); + return; + } + let rawBody = ""; + for await (const chunk of request) rawBody += String(chunk); + const asynchronous = request.headers["x-dashscope-async"] === "enable"; + requests.push({ + method: request.method!, + path: request.url!, + body: rawBody ? JSON.parse(rawBody) : undefined, + async: asynchronous, + }); + response.setHeader("content-type", "application/json"); + if (scenario === "submit-error") { + response.writeHead(429).end( + JSON.stringify({ + code: "ServiceLimit", + message: "Original service failure", + request_id: "request-test", + }), + ); + return; + } + if (request.method === "POST") { + const submission = ++submissions; + response.end( + JSON.stringify({ + output: asynchronous + ? { task_id: `task-${submission}` } + : { + choices: + scenario === "empty" + ? [] + : [{ message: { content: [{ image: `${origin}/files/${submission}.png` }] } }], + }, + }), + ); + return; + } + const taskId = request.url!.split("/").at(-1)!; + response.end( + JSON.stringify({ + output: { + task_id: taskId, + task_status: scenario === "task-failed" ? "FAILED" : "SUCCEEDED", + ...(scenario === "task-failed" + ? { code: "TaskFailure", message: "Original task failure" } + : legacyResults + ? { + results: + scenario === "empty" ? [] : [{ url: `${origin}/files/${taskId.slice(5)}.png` }], + } + : { + choices: + scenario === "empty" + ? [] + : [ + { + message: { + content: [{ image: `${origin}/files/${taskId.slice(5)}.png` }], + }, + }, + ], + }), + }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected loopback address"); + origin = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + rmSync(directory, { recursive: true, force: true }); +}); + +const commands = [ + { name: "generate", command: generate }, + { name: "edit", command: edit }, +]; +const modes = ["sync", "wait", "async"] as const; +const displays = (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((verbose) => [false, true].map((tty) => ({ format, verbose, tty }))), +); + +async function invoke( + command: AnyCommand, + flags: Record, + display: { + format: "text" | "json"; + quiet: boolean; + verbose?: boolean; + tty?: boolean; + dryRun?: boolean; + chinese?: boolean; + }, +) { + let stdout = ""; + let stderr = ""; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const ttyDescriptor = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { + value: display.tty ?? false, + configurable: true, + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + env: {}, + file: { output_dir: directory, timeout: 2 }, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => + typeof text === "string" ? text : text[display.chinese ? "zh-CN" : "en-US"]; + const reporter = createOutputReporter({ settings, localize }); + const identity = { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }; + const client = new Client({ + identity, + settings, + baseUrl: origin, + apiCred: { token: "test-placeholder", baseUrl: origin, source: "flag" }, + debug: (diagnostic) => reporter.output.debug(diagnostic), + }); + let exitCode = 0; + try { + try { + await command.run({ + flags, + settings, + localize, + identity, + client, + output: reporter.output, + } as Parameters[0]); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, exitCode }; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + if (ttyDescriptor) Object.defineProperty(process.stderr, "isTTY", ttyDescriptor); + else Reflect.deleteProperty(process.stderr, "isTTY"); + } +} + +function flagsFor(name: string, mode: (typeof modes)[number], concurrent = 2) { + return { + prompt: "A quiet lake", + image: [`${origin}/source.png`], + model: mode === "sync" ? "qwen-image-3.0" : "wan2.6-t2i", + async: mode === "async", + concurrent, + pollInterval: 0, + outPrefix: "fixed", + outDir: join(directory, "explicit"), + ...(name === "generate" ? { seed: 42, watermark: false } : {}), + }; +} + +for (const entry of commands) { + for (const mode of modes) { + test.each(displays)( + `${entry.name}/${mode} preserves results and requests: $format verbose=$verbose tty=$tty`, + async (display) => { + const runs = []; + for (const quiet of [false, true]) { + submissions = 0; + requests = []; + downloads = 0; + const result = await invoke(entry.command, flagsFor(entry.name, mode), { + ...display, + quiet, + }); + expect(result.exitCode, result.stderr).toBe(0); + const data = + display.format === "json" ? JSON.parse(result.stdout) : parseYaml(result.stdout); + if (mode === "async") { + expect(data).toEqual({ task_ids: ["task-1", "task-2"] }); + expect(downloads).toBe(0); + expect(requests).toHaveLength(2); + } else { + const saved = [1, 2].map((number) => + join(directory, "explicit", `fixed_00${number}.png`), + ); + expect(data).toEqual({ + urls: [1, 2].map((number) => `${origin}/files/${number}.png`), + saved, + total: 2, + ...(entry.name === "generate" && mode === "wait" + ? { task_ids: ["task-1", "task-2"] } + : {}), + }); + for (const path of saved) expect(readFileSync(path, "utf8")).toBe("payload"); + expect(downloads).toBe(2); + expect(requests).toHaveLength(mode === "wait" ? 4 : 2); + } + const submitted = requests.filter((request) => request.method === "POST"); + expect(submitted).toHaveLength(2); + expect(submitted.every((request) => request.async === (mode !== "sync"))).toBe(true); + expect(submitted[0]!.path).toBe( + mode === "sync" + ? "/api/v1/services/aigc/multimodal-generation/generation" + : "/api/v1/services/aigc/image-generation/generation", + ); + expect(submitted[0]!.body).toMatchObject({ + model: mode === "sync" ? "qwen-image-3.0" : "wan2.6-t2i", + input: { + messages: [ + { role: "user", content: expect.arrayContaining([{ text: "A quiet lake" }]) }, + ], + }, + }); + if (display.format === "json") { + const diagnostics = result.stderr.trim() + ? result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + expect(diagnostics.every((record) => record.debug)).toBe(true); + expect(diagnostics.length > 0).toBe(display.verbose); + } else { + expect(result.stderr.includes("[Model:")).toBe(!quiet); + expect(result.stderr.includes("[Concurrent: 2")).toBe(!quiet); + expect(result.stderr.includes("\rDownloading")).toBe( + !quiet && display.tty && mode !== "async", + ); + } + runs.push({ stdout: result.stdout, requests: [...requests] }); + } + expect(runs[1]).toEqual(runs[0]); + }, + ); + } +} + +for (const entry of commands) { + test.each(["sync", "wait"] as const)( + `${entry.name}/%s single image retains metadata and default output directory`, + async (mode) => { + const flags = flagsFor(entry.name, mode, 1); + const { outDir: _outDir, ...defaultFlags } = flags; + const result = await invoke(entry.command, defaultFlags, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + urls: [`${origin}/files/1.png`], + saved: [join(directory, "images", "fixed.png")], + total: 1, + ...(entry.name === "generate" && mode === "wait" ? { task_id: "task-1" } : {}), + }); + }, + ); + + test(`${entry.name} retains the legacy task results response`, async () => { + legacyResults = true; + const result = await invoke(entry.command, flagsFor(entry.name, "wait", 1), { + format: "json", + quiet: true, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ urls: [`${origin}/files/1.png`], total: 1 }); + expect(downloads).toBe(1); + }); + + test.each(["submit-error", "task-failed", "empty", "download-error"] as const)( + `${entry.name}/%s retains one terminal JSON error`, + async (failure) => { + scenario = failure; + const mode = failure === "task-failed" ? "wait" : "sync"; + const result = await invoke(entry.command, flagsFor(entry.name, mode, 1), { + format: "json", + quiet: false, + verbose: true, + tty: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const records = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const errors = records.filter((record) => record.error); + expect(errors).toHaveLength(1); + expect(records.every((record) => record.debug || record.error)).toBe(true); + if (failure === "submit-error") { + expect(errors[0].error).toMatchObject({ + message: "Original service failure", + api_code: "ServiceLimit", + request_id: "request-test", + http_status: 429, + }); + } + if (failure === "task-failed") + expect(errors[0].error.message).toContain("Original task failure"); + if (failure === "download-error") expect(errors[0].error.message).toContain("503"); + if (failure === "empty") expect(errors[0].error.message).toContain("no images"); + }, + ); + + test(`${entry.name} localizes process info and retains TTY progress`, async () => { + const result = await invoke(entry.command, flagsFor(entry.name, "sync", 1), { + format: "text", + quiet: false, + chinese: true, + tty: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("[模型:qwen-image-3.0]"); + expect(result.stderr).toContain("\rDownloading"); + }); + + test(`${entry.name} dry-run preserves JSON quiet and input redaction`, async () => { + const result = await invoke( + entry.command, + { ...flagsFor(entry.name, "sync"), image: ["data:image/png;base64,cGF5bG9hZA=="] }, + { format: "json", quiet: true, verbose: true, dryRun: true }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toEqual([]); + expect(downloads).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + mode: "sync", + request: { model: "qwen-image-3.0" }, + }); + expect(result.stdout).not.toContain("cGF5bG9hZA=="); + }); +} From 1cbdee840fb32dcd2e65962799d2a4406a45fe80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 28 Oct 2026 17:37:37 +0800 Subject: [PATCH 05/28] refactor(commands): unify speech vocabulary output - Route vocabulary command results through the unified output API - Preserve complete JSON responses in quiet mode and existing text layouts - Localize empty-list and undeployed vocabulary notes - Add CLI output regression tests and isolate inherited proxy settings --- docs/agents/output-contract.md | 6 +- .../src/commands/speech/vocabulary/create.ts | 22 +- .../src/commands/speech/vocabulary/delete.ts | 26 +- .../src/commands/speech/vocabulary/get.ts | 49 +-- .../src/commands/speech/vocabulary/list.ts | 40 +- .../src/commands/speech/vocabulary/update.ts | 22 +- .../output/speech-vocabulary-output.test.ts | 344 ++++++++++++++++++ 7 files changed, 415 insertions(+), 94 deletions(-) create mode 100644 packages/commands/tests/output/speech-vocabulary-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index 81d0aeadf..6c7e2cf41 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get 和图片 generate / edit 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit 和语音热词表 create / update / delete / get / list 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 @@ -43,6 +43,10 @@ - [ ] 移除 quiet 裁剪/吞结果分支,更新依赖 quiet 提取 ID/path 的示例 - [ ] 迁移完后再为相应路径启用直接打印约束,保留协议/原始流等窄例外;不要先全仓禁用旧代码 +### D. 回归测试隔离 + +- [ ] 本地假服务的子进程测试显式覆盖 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY 及小写变量,关闭 NODE_USE_ENV_PROXY;仅代理专项测试按场景注入代理,不继承开发机的代理设置 + ## 完成后自查 ```sh diff --git a/packages/commands/src/commands/speech/vocabulary/create.ts b/packages/commands/src/commands/speech/vocabulary/create.ts index 5c57888fa..972bc9873 100644 --- a/packages/commands/src/commands/speech/vocabulary/create.ts +++ b/packages/commands/src/commands/speech/vocabulary/create.ts @@ -1,13 +1,11 @@ import { defineCommand, - detectOutputFormat, speechVocabularyPath, buildVocabularyRequest, createVocabulary, type FlagsDef, type ParsedFlags, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { VOCABULARY_BODY_FLAGS, VOCABULARY_LIMIT_NOTES, @@ -83,7 +81,6 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const vocabulary = readVocabularyEntries(flags); - const format = detectOutputFormat(settings.output); const request = buildVocabularyRequest("create_vocabulary", { target_model: flags.model, @@ -92,13 +89,10 @@ export default defineCommand({ }); if (settings.dryRun) { - emitResult( - { - endpoint: ctx.client.url(speechVocabularyPath()), - request, - }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(speechVocabularyPath()), + request, + }); return; } @@ -108,10 +102,8 @@ export default defineCommand({ vocabulary, }); - if (settings.quiet || format === "text") { - emitBare(response.output?.vocabulary_id ?? ""); - } else { - emitResult(response, format); - } + ctx.output.result(response, { + text: settings.output === "text" ? (response.output?.vocabulary_id ?? "") : undefined, + }); }, }); diff --git a/packages/commands/src/commands/speech/vocabulary/delete.ts b/packages/commands/src/commands/speech/vocabulary/delete.ts index 54c26509a..c5f02c8cd 100644 --- a/packages/commands/src/commands/speech/vocabulary/delete.ts +++ b/packages/commands/src/commands/speech/vocabulary/delete.ts @@ -1,13 +1,11 @@ import { defineCommand, - detectOutputFormat, speechVocabularyPath, buildVocabularyRequest, deleteVocabulary, type FlagsDef, type ParsedFlags, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { VOCABULARY_ID_FLAG } from "./shared.ts"; const DELETE_FLAGS = { @@ -34,27 +32,21 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const vocabularyId = (flags as DeleteFlags).id; - const format = detectOutputFormat(settings.output); if (settings.dryRun) { - emitResult( - { - endpoint: ctx.client.url(speechVocabularyPath()), - request: buildVocabularyRequest("delete_vocabulary", { - vocabulary_id: vocabularyId, - }), - }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(speechVocabularyPath()), + request: buildVocabularyRequest("delete_vocabulary", { + vocabulary_id: vocabularyId, + }), + }); return; } const response = await deleteVocabulary(ctx.client, vocabularyId); - if (settings.quiet || format === "text") { - emitBare(vocabularyId); - } else { - emitResult(response, format); - } + ctx.output.result(response, { + text: vocabularyId, + }); }, }); diff --git a/packages/commands/src/commands/speech/vocabulary/get.ts b/packages/commands/src/commands/speech/vocabulary/get.ts index 89005e046..9ec5000c5 100644 --- a/packages/commands/src/commands/speech/vocabulary/get.ts +++ b/packages/commands/src/commands/speech/vocabulary/get.ts @@ -1,13 +1,11 @@ import { defineCommand, - detectOutputFormat, speechVocabularyPath, buildVocabularyRequest, queryVocabulary, type FlagsDef, type ParsedFlags, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { VOCABULARY_ID_FLAG } from "./shared.ts"; const GET_FLAGS = { @@ -35,45 +33,38 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const vocabularyId = (flags as GetFlags).id; - const format = detectOutputFormat(settings.output); if (settings.dryRun) { - emitResult( - { - endpoint: ctx.client.url(speechVocabularyPath()), - request: buildVocabularyRequest("query_vocabulary", { - vocabulary_id: vocabularyId, - }), - }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(speechVocabularyPath()), + request: buildVocabularyRequest("query_vocabulary", { + vocabulary_id: vocabularyId, + }), + }); return; } const response = await queryVocabulary(ctx.client, vocabularyId); - const output = response.output; - - if (settings.quiet) { - emitBare(output?.target_model ?? ""); - return; - } - - if (format === "text") { - emitBare(`vocabulary_id: ${vocabularyId}`); - emitBare(`status: ${output?.status ?? ""}`); - emitBare(`target_model: ${output?.target_model ?? ""}`); - emitBare(`gmt_create: ${output?.gmt_create ?? ""}`); - emitBare(`gmt_modified: ${output?.gmt_modified ?? ""}`); + let text: string | undefined; + if (settings.output === "text") { + const output = response.output; + const lines = [ + `vocabulary_id: ${vocabularyId}`, + `status: ${output?.status ?? ""}`, + `target_model: ${output?.target_model ?? ""}`, + `gmt_create: ${output?.gmt_create ?? ""}`, + `gmt_modified: ${output?.gmt_modified ?? ""}`, + ]; const entries = output?.vocabulary ?? []; if (entries.length > 0) { - emitBare("vocabulary:"); + lines.push("vocabulary:"); for (const entry of entries) { const langPart = entry.lang ? ` lang=${entry.lang}` : ""; - emitBare(` ${entry.text} weight=${entry.weight}${langPart}`); + lines.push(` ${entry.text} weight=${entry.weight}${langPart}`); } } - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, { text }); }, }); diff --git a/packages/commands/src/commands/speech/vocabulary/list.ts b/packages/commands/src/commands/speech/vocabulary/list.ts index 224ca5acf..a25b6e5df 100644 --- a/packages/commands/src/commands/speech/vocabulary/list.ts +++ b/packages/commands/src/commands/speech/vocabulary/list.ts @@ -1,13 +1,11 @@ import { defineCommand, - detectOutputFormat, speechVocabularyPath, buildVocabularyRequest, listVocabularies, type FlagsDef, type ParsedFlags, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; const LIST_FLAGS = { prefix: { @@ -65,7 +63,6 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); const pageIndex = flags.page !== undefined ? flags.page - 1 : undefined; const input: Record = {}; @@ -74,13 +71,10 @@ export default defineCommand({ if (flags.pageSize !== undefined) input.page_size = flags.pageSize; if (settings.dryRun) { - emitResult( - { - endpoint: ctx.client.url(speechVocabularyPath()), - request: buildVocabularyRequest("list_vocabulary", input), - }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(speechVocabularyPath()), + request: buildVocabularyRequest("list_vocabulary", input), + }); return; } @@ -90,10 +84,17 @@ export default defineCommand({ pageSize: flags.pageSize, }); - if (settings.quiet || format === "text") { + let text: string | undefined; + if (settings.output === "text") { const items = response.output?.vocabulary_list ?? []; + const lines: string[] = []; if (items.length === 0) { - emitBare("No vocabularies found."); + lines.push( + ctx.localize({ + "en-US": "No vocabularies found.", + "zh-CN": "未找到热词表。", + }), + ); } else { let hasUndeployed = false; for (const item of items) { @@ -101,16 +102,21 @@ export default defineCommand({ const status = item.status ?? ""; const modified = item.gmt_modified ?? ""; if (status === "UNDEPLOYED") hasUndeployed = true; - emitBare(`[${id}] ${status} ${modified}`.trimEnd()); + lines.push(`[${id}] ${status} ${modified}`.trimEnd()); } if (hasUndeployed) { - emitBare( - "Note: UNDEPLOYED vocabularies are silently ignored by ASR. Use `speech vocabulary get` to inspect them.", + lines.push( + ctx.localize({ + "en-US": + "Note: UNDEPLOYED vocabularies are silently ignored by ASR. Use `speech vocabulary get` to inspect them.", + "zh-CN": + "提示:ASR 会静默忽略 UNDEPLOYED 状态的热词表。请使用 `speech vocabulary get` 查看详情。", + }), ); } } - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, { text }); }, }); diff --git a/packages/commands/src/commands/speech/vocabulary/update.ts b/packages/commands/src/commands/speech/vocabulary/update.ts index 4612a9453..ffa11f81c 100644 --- a/packages/commands/src/commands/speech/vocabulary/update.ts +++ b/packages/commands/src/commands/speech/vocabulary/update.ts @@ -1,13 +1,11 @@ import { defineCommand, - detectOutputFormat, speechVocabularyPath, buildVocabularyRequest, updateVocabulary, type FlagsDef, type ParsedFlags, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { VOCABULARY_ID_FLAG, VOCABULARY_BODY_FLAGS, @@ -61,7 +59,6 @@ export default defineCommand({ const { settings, flags } = ctx; const vocabularyId = flags.id; const vocabulary = readVocabularyEntries(flags); - const format = detectOutputFormat(settings.output); const request = buildVocabularyRequest("update_vocabulary", { vocabulary_id: vocabularyId, @@ -69,22 +66,17 @@ export default defineCommand({ }); if (settings.dryRun) { - emitResult( - { - endpoint: ctx.client.url(speechVocabularyPath()), - request, - }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(speechVocabularyPath()), + request, + }); return; } const response = await updateVocabulary(ctx.client, vocabularyId, vocabulary); - if (settings.quiet || format === "text") { - emitBare(vocabularyId); - } else { - emitResult(response, format); - } + ctx.output.result(response, { + text: vocabularyId, + }); }, }); diff --git a/packages/commands/tests/output/speech-vocabulary-output.test.ts b/packages/commands/tests/output/speech-vocabulary-output.test.ts new file mode 100644 index 000000000..ba27c0c25 --- /dev/null +++ b/packages/commands/tests/output/speech-vocabulary-output.test.ts @@ -0,0 +1,344 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { runCommandE2e } from "../e2e/helpers.ts"; + +const routes = { + "speech vocabulary create": "speechVocabularyCreate", + "speech vocabulary update": "speechVocabularyUpdate", + "speech vocabulary delete": "speechVocabularyDelete", + "speech vocabulary get": "speechVocabularyGet", + "speech vocabulary list": "speechVocabularyList", +}; +const vocabularyId = "vocab-demo-test"; +const endpoint = "/api/v1/services/audio/asr/customization"; +const entries = [ + { + name: "create", + flags: ["--model", "fun-asr", "--prefix", "demo", "--words", '{"Word":4}', "--lang", "en"], + input: { + action: "create_vocabulary", + target_model: "fun-asr", + prefix: "demo", + vocabulary: [{ text: "Word", weight: 4, lang: "en" }], + }, + output: { vocabulary_id: vocabularyId }, + text: vocabularyId + "\n", + }, + { + name: "update", + flags: ["--id", vocabularyId, "--words", '[{"text":"Word","weight":4,"lang":"en"}]', "--yes"], + input: { + action: "update_vocabulary", + vocabulary_id: vocabularyId, + vocabulary: [{ text: "Word", weight: 4, lang: "en" }], + }, + output: {}, + text: vocabularyId + "\n", + }, + { + name: "delete", + flags: ["--id", vocabularyId, "--yes"], + input: { action: "delete_vocabulary", vocabulary_id: vocabularyId }, + output: {}, + text: vocabularyId + "\n", + }, + { + name: "get", + flags: ["--id", vocabularyId], + input: { action: "query_vocabulary", vocabulary_id: vocabularyId }, + output: { + status: "OK", + target_model: "fun-asr", + gmt_create: "created", + gmt_modified: "modified", + vocabulary: [ + { text: "Word", weight: 4, lang: "en" }, + { text: "Other", weight: 5 }, + ], + }, + text: `vocabulary_id: ${vocabularyId}\nstatus: OK\ntarget_model: fun-asr\ngmt_create: created\ngmt_modified: modified\nvocabulary:\n Word weight=4 lang=en\n Other weight=5\n`, + }, + { + name: "list", + flags: ["--prefix", "demo", "--page", "2", "--page-size", "3"], + input: { action: "list_vocabulary", prefix: "demo", page_index: 1, page_size: 3 }, + output: { + vocabulary_list: [ + { vocabulary_id: vocabularyId, status: "OK", gmt_modified: "modified" }, + { vocabulary_id: "vocab-unready", status: "UNDEPLOYED" }, + ], + }, + text: `[${vocabularyId}] OK modified\n[vocab-unready] UNDEPLOYED\nNote: UNDEPLOYED vocabularies are silently ignored by ASR. Use \`speech vocabulary get\` to inspect them.\n`, + }, +]; +let directory: string; +let server: Server; +let origin: string; +let responseBody: unknown; +let errorMode: "none" | "http" | "business"; +let requests: Array<{ path: string; method: string; body: unknown }>; + +beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), "bl-vocabulary-output-")); + writeFileSync(join(directory, "config.json"), JSON.stringify({ language: "en-US" })); + requests = []; + errorMode = "none"; + responseBody = {}; + server = createServer(async (request, response) => { + let raw = ""; + for await (const chunk of request) raw += String(chunk); + requests.push({ path: request.url!, method: request.method!, body: JSON.parse(raw) }); + response.setHeader("content-type", "application/json"); + if (errorMode !== "none") { + response.writeHead(errorMode === "http" ? 429 : 200).end( + JSON.stringify({ + code: "ServiceFailure", + message: "Original service failure", + request_id: "request-failure", + }), + ); + } else response.end(JSON.stringify(responseBody)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected loopback address"); + origin = `http://127.0.0.1:${address.port}`; +}); +afterEach(async () => { + vi.unstubAllEnvs(); + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + rmSync(directory, { recursive: true, force: true }); +}); + +function invoke( + name: string, + flags: string[], + display: { format: "text" | "json"; quiet?: boolean; verbose?: boolean; dryRun?: boolean }, +) { + const args = ["speech", "vocabulary", name, ...flags, "--output", display.format]; + if (display.quiet) args.push("--quiet"); + if (display.verbose) args.push("--verbose"); + if (display.dryRun) args.push("--dry-run"); + return runCommandE2e(routes, args, { + HOME: directory, + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_API_KEY: "test-vocabulary-placeholder", + DASHSCOPE_BASE_URL: origin, + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: "0", + BAILIAN_INSTALL_METHOD: "brew", + NODE_USE_ENV_PROXY: "0", + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: "", + https_proxy: "", + ALL_PROXY: "", + all_proxy: "", + NO_PROXY: "", + no_proxy: "", + }); +} +const displays = (["text", "json"] as const).flatMap((format) => + [false, true].map((verbose) => ({ format, verbose })), +); + +for (const entry of entries) { + test.each(displays)( + `${entry.name} preserves results and requests with $format verbose=$verbose`, + async (display) => { + responseBody = { + request_id: "request-success", + output: entry.output, + usage: { count: 2 }, + service_extra: "preserved", + }; + const runs = []; + for (const quiet of [false, true]) { + requests = []; + const result = await invoke(entry.name, entry.flags, { ...display, quiet }); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") { + expect(JSON.parse(result.stdout)).toEqual(responseBody); + const diagnostics = result.stderr.trim() + ? result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + expect(diagnostics.every((record) => record.debug)).toBe(true); + expect(diagnostics.length > 0).toBe(display.verbose); + } else { + expect(result.stdout).toBe(entry.text); + if (!display.verbose) expect(result.stderr).toBe(""); + } + expect(result.stderr).not.toContain("test-vocabulary-placeholder"); + expect(requests).toEqual([ + { path: endpoint, method: "POST", body: { model: "speech-biasing", input: entry.input } }, + ]); + runs.push(result.stdout); + } + expect(runs[1]).toBe(runs[0]); + }, + ); + + test(`${entry.name} dry-run keeps its request and makes no API call`, async () => { + const result = await invoke(entry.name, entry.flags, { + format: "json", + quiet: true, + verbose: true, + dryRun: true, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + endpoint: origin + endpoint, + request: { model: "speech-biasing", input: entry.input }, + }); + expect(result.stderr).toBe(""); + expect(requests).toEqual([]); + }); + + test.each(["http", "business"] as const)( + `${entry.name}/%s retains a single terminal error in JSON quiet verbose`, + async (failure) => { + errorMode = failure; + const result = await invoke(entry.name, entry.flags, { + format: "json", + quiet: true, + verbose: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const records = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const errors = records.filter((record) => record.error); + expect(errors).toHaveLength(1); + expect(errors[0].error).toMatchObject({ + message: "Original service failure", + api_code: "ServiceFailure", + }); + if (failure === "http") expect(errors[0].error.request_id).toBe("request-failure"); + expect(requests).toHaveLength(1); + }, + ); +} + +for (const name of ["update", "delete"]) { + test(`${name} still requires confirmation in JSON quiet mode`, async () => { + const entry = entries.find((candidate) => candidate.name === name)!; + const result = await invoke( + name, + entry.flags.filter((flag) => flag !== "--yes"), + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(7); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toMatchObject({ error: { type: "requires_confirmation" } }); + expect(requests).toEqual([]); + }); +} + +test.each(["get", "list"])( + "%s passes through unrecognized server fields in JSON without rendering text", + async (name) => { + responseBody = { + request_id: "request-raw", + output: + name === "get" + ? { vocabulary: null, status: "UNDEPLOYED", unexpected: 42 } + : { vocabulary_list: { unexpected: 42 } }, + }; + const entry = entries.find((candidate) => candidate.name === name)!; + const result = await invoke(name, entry.flags, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(responseBody); + expect(result.stderr).toBe(""); + }, +); + +test.each(["text", "json"] as const)("list preserves empty results in %s quiet", async (format) => { + responseBody = { request_id: "request-empty", output: { vocabulary_list: [] } }; + const result = await invoke("list", [], { format, quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + if (format === "json") expect(JSON.parse(result.stdout)).toEqual(responseBody); + else expect(result.stdout).toBe("No vocabularies found.\n"); +}); + +test.each(["create", "update"])( + "%s preserves words-file parsing and missing-file errors", + async (name) => { + const entry = entries.find((candidate) => candidate.name === name)!; + responseBody = { request_id: "request-file", output: entry.output }; + const wordsFile = join(directory, "words.json"); + writeFileSync(wordsFile, '[{"text":"FileWord","weight":4,"lang":"en"}]'); + const flags = + name === "create" + ? ["--model", "fun-asr", "--prefix", "demo"] + : ["--id", vocabularyId, "--yes"]; + const result = await invoke(name, [...flags, "--words-file", wordsFile], { + format: "json", + quiet: true, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(responseBody); + expect(requests[0]!.body).toMatchObject({ + input: { vocabulary: [{ text: "FileWord", weight: 4, lang: "en" }] }, + }); + requests = []; + const missing = await invoke( + name, + [...flags, "--words-file", join(directory, "missing.json")], + { format: "json", quiet: true }, + ); + expect(missing.exitCode).toBe(1); + expect(missing.stdout).toBe(""); + expect(JSON.parse(missing.stderr).error.message).toContain("missing.json"); + expect(requests).toEqual([]); + }, +); + +test.each([false, true])("list localizes human result text with empty=%s", async (empty) => { + writeFileSync(join(directory, "config.json"), JSON.stringify({ language: "zh-CN" })); + responseBody = { + output: { + vocabulary_list: empty ? [] : [{ vocabulary_id: vocabularyId, status: "UNDEPLOYED" }], + }, + }; + for (const quiet of [false, true]) { + const result = await invoke("list", [], { format: "text", quiet }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + if (empty) expect(result.stdout).toBe("未找到热词表。\n"); + else + expect(result.stdout).toBe( + `[${vocabularyId}] UNDEPLOYED\n提示:ASR 会静默忽略 UNDEPLOYED 状态的热词表。请使用 \`speech vocabulary get\` 查看详情。\n`, + ); + } +}); + +test("loopback requests ignore inherited proxy settings", async () => { + for (const key of [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ]) { + vi.stubEnv(key, "http://127.0.0.1:1"); + } + vi.stubEnv("NO_PROXY", ""); + vi.stubEnv("no_proxy", ""); + responseBody = { request_id: "request-direct", output: { vocabulary_list: [] } }; + const result = await invoke("list", [], { format: "json", quiet: true, verbose: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(responseBody); + expect(requests).toHaveLength(1); +}); From 2def84ce2d3ddd3008e78fabd61a2669d57d2097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 29 Oct 2026 00:09:52 +0800 Subject: [PATCH 06/28] refactor(commands): unify speech synthesis output - Route synthesis results and process messages through the unified output API - Preserve complete results in quiet mode and existing text audio streams - Require file output for JSON streaming and return structured voice lists - Add binary, JSON, progress and CLI regression coverage --- docs/agents/output-contract.md | 5 +- .../src/commands/speech/synthesize.ts | 213 +++++--- .../output/speech-synthesize-output.test.ts | 491 ++++++++++++++++++ skills/bailian-gen/reference/speech.md | 2 +- 4 files changed, 626 insertions(+), 85 deletions(-) create mode 100644 packages/commands/tests/output/speech-synthesize-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index 6c7e2cf41..2cf971c8d 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit 和语音热词表 create / update / delete / get / list 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list 和 speech synthesize 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 @@ -33,7 +33,8 @@ - [ ] core 通过可选 debug 回调上报传输诊断,无回调时保持安静;runtime 注入 ctx.output.debug。请求体、认证头和查询参数值不直接打印 - [ ] 错误 cause 中的结构化对象复用安全序列化;服务端 message、api_code、request_id 不改写 - [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析 -- [ ] 原始音频/字节流、既有事件流和 Command Pack API 1 仍走现有专用入口;不得绕过新接口承诺后混入 stdout +- [ ] speech synthesize 的原始音频仅在 text 流式模式下直接写 stdout;JSON 流式需 --out 文件并返回元数据,未指定文件时在请求和文件操作前报用法错误;dry-run 仍允许 JSON 预览。--list-voices 的 JSON 返回音色条目与已有文档链接,内置列表未知时 voices/total 为 null +- [ ] 其他原始字节流、既有事件流和 Command Pack API 1 仍走现有专用入口;不得绕过新接口承诺后混入 stdout - [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr - [ ] 进程共用 SIGINT / stdout error 监听器,调用结束清理输出上下文;并发不触发 MaxListenersExceededWarning,单次中断只输出一条终止错误,EPIPE 保护覆盖 run 返回后的异步写入 diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index cc5e3373d..ccfa56ad9 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -3,15 +3,14 @@ import { BailianError, defineCommand, ExitCode, - detectOutputFormat, type CommandOutput, + type LocalizedText, type Client, type Settings, type DashScopeTTSRequest, type DashScopeTTSResponse, type DashScopeTTSStreamChunk, stripUndefined, - type OutputFormat, speechSynthesizePath, parseSSE, resolveOutputDir, @@ -23,7 +22,6 @@ import { import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { VOICE_TTS_PAGE } from "bailian-cli-runtime"; const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`; @@ -154,34 +152,78 @@ function voiceDocsUrl(model: string): string { return VOICE_TTS_PAGE; } -function printVoiceList(model: string): void { +function outputVoiceList( + model: string, + output: CommandOutput, + localize: (text: LocalizedText) => string, +): void { const voices = MODEL_VOICES[model]; const docsUrl = voiceDocsUrl(model); + const data = { + model, + voices: voices ?? null, + total: voices?.length ?? null, + voice_list_url: docsUrl, + ...(voices?.length === 0 + ? { clone_design_docs_url: COSYVOICE_CLONE_DESIGN_DOC, console_url: VOICE_TTS_PAGE } + : {}), + }; if (!voices) { - process.stdout.write(`No built-in voice list available for model: ${model}\n`); - process.stdout.write(`See official voice list: ${docsUrl}\n`); + output.result(data, { + text: [ + localize({ + "en-US": `No built-in voice list available for model: ${model}`, + "zh-CN": `模型 ${model} 未提供内置音色列表`, + }), + localize({ + "en-US": `See official voice list: ${docsUrl}`, + "zh-CN": `请查看官方音色列表:${docsUrl}`, + }), + ].join("\n"), + }); return; } if (voices.length === 0) { - process.stdout.write(`Model ${model} has no system voices.\n`); - process.stdout.write("Use clone or design voices created via the CosyVoice API.\n"); - process.stdout.write(`See: ${COSYVOICE_CLONE_DESIGN_DOC}\n`); - process.stdout.write(`Browse voices in the console: ${VOICE_TTS_PAGE}\n`); + output.result(data, { + text: [ + localize({ + "en-US": `Model ${model} has no system voices.`, + "zh-CN": `模型 ${model} 没有系统音色。`, + }), + localize({ + "en-US": "Use clone or design voices created via the CosyVoice API.", + "zh-CN": "请使用通过 CosyVoice API 创建的复刻或设计音色。", + }), + localize({ + "en-US": `See: ${COSYVOICE_CLONE_DESIGN_DOC}`, + "zh-CN": `参考文档:${COSYVOICE_CLONE_DESIGN_DOC}`, + }), + localize({ + "en-US": `Browse voices in the console: ${VOICE_TTS_PAGE}`, + "zh-CN": `在控制台浏览音色:${VOICE_TTS_PAGE}`, + }), + ].join("\n"), + }); return; } - const col = (s: string, w: number) => s.padEnd(w); - process.stdout.write(`\nSystem voices for ${model}:\n`); - process.stdout.write( - `${col("VOICE ID", 26)} ${col("NAME", 10)} ${col("DESCRIPTION", 16)} LANGUAGE\n`, - ); - process.stdout.write(`${"-".repeat(26)} ${"-".repeat(10)} ${"-".repeat(16)} ${"-".repeat(12)}\n`); - for (const entry of voices) { - process.stdout.write( - `${col(entry.voice, 26)} ${col(entry.name, 10)} ${col(entry.desc, 16)} ${entry.lang}\n`, - ); - } - process.stdout.write(`\nTotal: ${voices.length} voices\n`); - process.stdout.write(`Preview and browse more voices: \n${docsUrl}\n`); + const col = (value: string, width: number) => value.padEnd(width); + const lines = [ + "", + localize({ "en-US": `System voices for ${model}:`, "zh-CN": `${model} 的系统音色:` }), + `${col(localize({ "en-US": "VOICE ID", "zh-CN": "音色 ID" }), 26)} ${col(localize({ "en-US": "NAME", "zh-CN": "名称" }), 10)} ${col(localize({ "en-US": "DESCRIPTION", "zh-CN": "描述" }), 16)} ${localize({ "en-US": "LANGUAGE", "zh-CN": "语言" })}`, + `${"-".repeat(26)} ${"-".repeat(10)} ${"-".repeat(16)} ${"-".repeat(12)}`, + ...voices.map( + (entry) => + `${col(entry.voice, 26)} ${col(entry.name, 10)} ${col(entry.desc, 16)} ${entry.lang}`, + ), + "", + localize({ "en-US": `Total: ${voices.length} voices`, "zh-CN": `共 ${voices.length} 个音色` }), + localize({ + "en-US": `Preview and browse more voices: \n${docsUrl}`, + "zh-CN": `试听并浏览更多音色:\n${docsUrl}`, + }), + ]; + output.result(data, { text: lines.join("\n") }); } const SYNTHESIZE_FLAGS = { @@ -312,8 +354,8 @@ const SYNTHESIZE_FLAGS = { stream: { type: "switch", description: { - "en-US": "Stream raw PCM audio to stdout (pipe to player)", - "zh-CN": "将原始 PCM 音频流式输出到 stdout(可通过管道传给播放器)", + "en-US": "Stream raw PCM audio to stdout (pipe to player); JSON output requires --out", + "zh-CN": "将原始 PCM 音频流式输出到 stdout(可通过管道传给播放器);JSON 输出需要 --out", }, }, ...CONCURRENT_FLAG, @@ -367,10 +409,10 @@ export default defineCommand({ '--text "你好" --voice --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 -', }, ], - validate: (f) => { - if (f.listVoices) return undefined; - if (!f.text && !f.textFile) return "Provide --text or --text-file."; - if (!f.voice) + validate: (flags) => { + if (flags.listVoices) return undefined; + if (!flags.text && !flags.textFile) return "Provide --text or --text-file."; + if (!flags.voice) return `Missing required flag: --voice (use --list-voices; browse more voices: ${VOICE_TTS_PAGE})`; return undefined; }, @@ -380,10 +422,27 @@ export default defineCommand({ // --list-voices: print voice list for the model and exit if (flags.listVoices) { - printVoiceList(model); + outputVoiceList(model, ctx.output, (text) => ctx.localize(text)); return; } + const useStream = flags.stream === true; + if (useStream && settings.output === "json" && !flags.out && !settings.dryRun) { + throw new BailianError( + ctx.localize({ + "en-US": "--stream with --output json requires --out .", + "zh-CN": "--stream 与 --output json 同用时需要指定 --out 。", + }), + ExitCode.USAGE, + ctx.localize({ + "en-US": + "Use --out to save audio and return JSON metadata, or --output text to stream audio to stdout.", + "zh-CN": + "使用 --out 保存音频并返回 JSON 元数据,或使用 --output text 将音频流输出到 stdout。", + }), + ); + } + // --text / --text-file presence enforced by validate; empty file content → API rejects. let text = flags.text || ""; if (!text && flags.textFile) { @@ -398,7 +457,6 @@ export default defineCommand({ const language = flags.language || undefined; const instruction = flags.instruction || undefined; - const useStream = flags.stream === true; const audioFormat = flags.format || (useStream ? "pcm" : "mp3"); const sampleRate = flags.sampleRate !== undefined ? Number(flags.sampleRate) : undefined; const volume = flags.volume !== undefined ? Number(flags.volume) : undefined; @@ -407,8 +465,6 @@ export default defineCommand({ const seed = flags.seed !== undefined ? Number(flags.seed) : undefined; const enableSsml = flags.enableSsml === true ? true : undefined; - const format = detectOutputFormat(settings.output); - const body: DashScopeTTSRequest = { model, input: { @@ -430,18 +486,19 @@ export default defineCommand({ stripUndefined(body.input as Record); if (settings.dryRun) { - emitResult({ request: body }, format); + ctx.output.result({ request: body }); return; } - if (!settings.quiet) { - process.stderr.write(`[Model: ${model}] [Voice: ${voice}]\n`); - } + ctx.output.info({ + "en-US": `[Model: ${model}] [Voice: ${voice}]`, + "zh-CN": `[模型:${model}] [音色:${voice}]`, + }); if (useStream) { - await handleStreamMode(ctx.client, settings, body, flags, format); + await handleStreamMode(ctx.client, ctx.output, body, flags); } else { - await handleNonStreamMode(ctx.client, ctx.output, settings, body, flags, format); + await handleNonStreamMode(ctx.client, ctx.output, settings, body, flags); } }, }); @@ -452,7 +509,6 @@ async function handleNonStreamMode( settings: Settings, body: DashScopeTTSRequest, flags: SynthesizeFlags, - format: OutputFormat, ): Promise { const concurrent = getConcurrency(flags); @@ -464,7 +520,7 @@ async function handleNonStreamMode( }), ); - const audioUrls = results.map((r) => r.output?.audio?.url).filter(Boolean) as string[]; + const audioUrls = results.map((result) => result.output?.audio?.url).filter(Boolean) as string[]; if (audioUrls.length === 0) { throw new BailianError("API returned no audio URL.", ExitCode.GENERAL); @@ -474,55 +530,48 @@ async function handleNonStreamMode( const path = await import("path"); const destDir = resolveOutputDir(settings, { subDir: "speech" }); - const items = audioUrls.map((audioUrl, i) => { + const items = audioUrls.map((audioUrl, index) => { let destPath = flags.out; if (destPath && audioUrls.length === 1) { // Single explicit output path } else { const timestamp = Date.now(); - const suffix = audioUrls.length > 1 ? `_${String(i + 1).padStart(3, "0")}` : ""; + const suffix = audioUrls.length > 1 ? `_${String(index + 1).padStart(3, "0")}` : ""; const ext = body.input.format ?? "mp3"; destPath = path.join(destDir, `tts_${timestamp}${suffix}.${ext}`); } return { url: audioUrl, destPath: destPath! }; }); - const saved = await downloadParallel(items, downloadFile, { quiet: settings.quiet }); + const saved = await downloadParallel(items, downloadFile, { + quiet: settings.quiet || settings.output === "json", + }); - if (settings.quiet) { - emitBare(saved.join("\n")); - } else if (saved.length === 1) { + if (saved.length === 1) { const expiresAt = results[0]!.output?.audio?.expires_at; - emitResult( - { - saved: saved[0], - audio_url: audioUrls[0], - model: body.model, - voice: body.input.voice, - ...(expiresAt ? { url_expires_at: expiresAt } : {}), - }, - format, - ); + commandOutput.result({ + saved: saved[0], + audio_url: audioUrls[0], + model: body.model, + voice: body.input.voice, + ...(expiresAt ? { url_expires_at: expiresAt } : {}), + }); } else { - emitResult( - { - saved, - audio_urls: audioUrls, - total: saved.length, - model: body.model, - voice: body.input.voice, - }, - format, - ); + commandOutput.result({ + saved, + audio_urls: audioUrls, + total: saved.length, + model: body.model, + voice: body.input.voice, + }); } } async function handleStreamMode( client: Client, - settings: Settings, + commandOutput: CommandOutput, body: DashScopeTTSRequest, flags: SynthesizeFlags, - format: OutputFormat, ): Promise { const res = await client.request({ path: speechSynthesizePath(), @@ -563,8 +612,11 @@ async function handleStreamMode( if (chunk.output?.finish_reason === "stop") { lastAudioUrl = chunk.output?.audio?.url; - if (lastAudioUrl && !settings.quiet) { - process.stderr.write(`\nFull audio URL: ${lastAudioUrl}\n`); + if (lastAudioUrl) { + commandOutput.info({ + "en-US": `\nFull audio URL: ${lastAudioUrl}`, + "zh-CN": `\n完整音频 URL:${lastAudioUrl}`, + }); } break; } @@ -576,22 +628,19 @@ async function handleStreamMode( writer.on("error", reject); writer.end(); }); - if (!settings.quiet && outPath) { - process.stderr.write(`Saved: ${outPath}\n`); + if (outPath) { + commandOutput.info({ "en-US": `Saved: ${outPath}`, "zh-CN": `已保存:${outPath}` }); } } } // Emit structured result for agent consumption if (outPath) { - emitResult( - { - saved: outPath, - ...(lastAudioUrl ? { audio_url: lastAudioUrl } : {}), - model: body.model, - voice: body.input.voice, - }, - format, - ); + commandOutput.result({ + saved: outPath, + ...(lastAudioUrl ? { audio_url: lastAudioUrl } : {}), + model: body.model, + voice: body.input.voice, + }); } } diff --git a/packages/commands/tests/output/speech-synthesize-output.test.ts b/packages/commands/tests/output/speech-synthesize-output.test.ts new file mode 100644 index 000000000..7d3341ddb --- /dev/null +++ b/packages/commands/tests/output/speech-synthesize-output.test.ts @@ -0,0 +1,491 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawn } from "child_process"; +import { fileURLToPath } from "node:url"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { parse as parseYaml } from "yaml"; +import { buildSettings, Client, type LocalizedText } from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import synthesize from "../../src/commands/speech/synthesize.ts"; + +let directory: string; +let server: Server; +let origin: string; +let requests: Array<{ path: string; body: unknown; stream: boolean }>; +let downloads: number; +let failure: "none" | "submit" | "empty" | "download"; +const audio = Buffer.from([0, 255, 13, 10, 1, 2, 3]); +beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), "bl-synthesize-output-")); + requests = []; + downloads = 0; + failure = "none"; + server = createServer(async (request, response) => { + if (request.url?.startsWith("/audio/")) { + downloads++; + if (failure === "download") { + response.writeHead(503).end(); + return; + } + response.writeHead(200, { "content-length": String(audio.length) }); + response.write(audio.subarray(0, 3)); + response.end(audio.subarray(3)); + return; + } + let raw = ""; + for await (const chunk of request) raw += String(chunk); + const stream = request.headers["x-dashscope-sse"] === "enable"; + requests.push({ path: request.url!, body: JSON.parse(raw), stream }); + if (failure === "submit") { + response.writeHead(429, { "content-type": "application/json" }).end( + JSON.stringify({ + code: "ServiceLimit", + message: "Original service failure", + request_id: "request-test", + }), + ); + return; + } + if (stream) { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write("data: malformed\n\n"); + response.write( + `data: ${JSON.stringify({ output: { audio: { data: audio.subarray(0, 3).toString("base64") } } })}\n\n`, + ); + response.end( + `data: ${JSON.stringify({ output: { audio: { data: audio.subarray(3).toString("base64"), url: `${origin}/audio/full.mp3` }, finish_reason: "stop" } })}\n\n`, + ); + } else { + response.writeHead(200, { "content-type": "application/json" }).end( + JSON.stringify({ + output: { + audio: + failure === "empty" + ? {} + : { url: `${origin}/audio/${requests.length}.mp3`, expires_at: 123456 }, + }, + }), + ); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected loopback address"); + origin = `http://127.0.0.1:${address.port}`; +}); +afterEach(async () => { + vi.restoreAllMocks(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(directory, { recursive: true, force: true }); +}); + +async function invoke( + flags: Record, + display: { + format: "text" | "json"; + quiet: boolean; + verbose?: boolean; + tty?: boolean; + dryRun?: boolean; + chinese?: boolean; + }, +) { + const chunks: Buffer[] = []; + let stderr = ""; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const descriptor = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { + value: display.tty ?? false, + configurable: true, + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + env: {}, + file: { output_dir: directory, timeout: 2 }, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => + typeof text === "string" ? text : text[display.chinese ? "zh-CN" : "en-US"]; + const reporter = createOutputReporter({ settings, localize }); + const identity = { binName: "bl", version: "test", npmPackage: "test", clientName: "test" }; + const client = new Client({ + settings, + identity, + baseUrl: origin, + apiCred: { token: "test-placeholder", source: "flag", baseUrl: origin }, + debug: (diagnostic) => reporter.output.debug(diagnostic), + }); + let exitCode = 0; + try { + try { + await synthesize.run({ + flags, + settings, + localize, + output: reporter.output, + identity, + client, + } as Parameters[0]); + } catch (error) { + exitCode = reporter.error(error); + } + return { + bytes: Buffer.concat(chunks), + stdout: Buffer.concat(chunks).toString(), + stderr, + exitCode, + }; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + if (descriptor) Object.defineProperty(process.stderr, "isTTY", descriptor); + else Reflect.deleteProperty(process.stderr, "isTTY"); + } +} +const flags = { text: "Hello", voice: "test-voice", model: "qwen-audio-3.0-tts-plus" }; +const displays = (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((verbose) => [false, true].map((quiet) => ({ format, verbose, quiet }))), +); +function readResult(result: Awaited>, format: "text" | "json") { + return format === "json" ? JSON.parse(result.stdout) : parseYaml(result.stdout); +} +function verifyDiagnostics( + result: Awaited>, + format: "text" | "json", + verbose: boolean, +) { + if (format === "json") { + const records = result.stderr.trim() + ? result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + expect(records.every((record) => record.debug)).toBe(true); + expect(records.length > 0).toBe(verbose); + } + expect(result.stderr).not.toContain("test-placeholder"); +} + +test.each(displays)( + "single download preserves metadata and bytes: $format q=$quiet v=$verbose", + async (display) => { + const destination = join(directory, "explicit.mp3"); + const result = await invoke({ ...flags, out: destination }, { ...display, tty: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(readResult(result, display.format)).toEqual({ + saved: destination, + audio_url: `${origin}/audio/1.mp3`, + model: flags.model, + voice: flags.voice, + url_expires_at: 123456, + }); + expect(readFileSync(destination)).toEqual(audio); + verifyDiagnostics(result, display.format, display.verbose); + if (display.format === "text") + expect(result.stderr.includes("\rDownloading")).toBe(!display.quiet); + expect(requests).toEqual([ + { + path: "/api/v1/services/audio/tts/SpeechSynthesizer", + stream: false, + body: { model: flags.model, input: { text: "Hello", voice: "test-voice", format: "mp3" } }, + }, + ]); + }, +); +test.each(displays)( + "concurrent downloads keep full results: $format q=$quiet v=$verbose", + async (display) => { + const result = await invoke({ ...flags, concurrent: 2 }, display); + expect(result.exitCode, result.stderr).toBe(0); + const data = readResult(result, display.format); + expect(data).toMatchObject({ + audio_urls: [`${origin}/audio/1.mp3`, `${origin}/audio/2.mp3`], + total: 2, + model: flags.model, + voice: flags.voice, + }); + expect(data.saved).toHaveLength(2); + for (const saved of data.saved) { + expect(saved).toMatch(/tts_[0-9]+_00[12]\.mp3$/); + expect(readFileSync(saved)).toEqual(audio); + } + expect(requests).toHaveLength(2); + expect(downloads).toBe(2); + verifyDiagnostics(result, display.format, display.verbose); + }, +); +test.each(displays)( + "stream-to-file keeps JSON metadata and PCM bytes: $format q=$quiet v=$verbose", + async (display) => { + const destination = join(directory, "stream.pcm"); + const result = await invoke({ ...flags, stream: true, out: destination }, display); + expect(result.exitCode, result.stderr).toBe(0); + expect(readResult(result, display.format)).toEqual({ + saved: destination, + audio_url: `${origin}/audio/full.mp3`, + model: flags.model, + voice: flags.voice, + }); + expect(readFileSync(destination)).toEqual(audio); + expect(requests[0]).toMatchObject({ stream: true, body: { input: { format: "pcm" } } }); + verifyDiagnostics(result, display.format, display.verbose); + }, +); +test.each([false, true])( + "raw text streaming preserves exact stdout bytes with quiet=%s", + async (quiet) => { + const result = await invoke({ ...flags, stream: true }, { format: "text", quiet }); + expect(result.exitCode).toBe(0); + expect(result.bytes).toEqual(audio); + expect(result.stderr.includes("Full audio URL:")).toBe(!quiet); + }, +); +test.each([false, true])( + "JSON raw-stream conflict is rejected before API/file access with quiet=%s", + async (quiet) => { + const result = await invoke( + { ...flags, text: undefined, textFile: join(directory, "missing.txt"), stream: true }, + { format: "json", quiet, chinese: true }, + ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(requests).toEqual([]); + expect(downloads).toBe(0); + const error = JSON.parse(result.stderr).error; + expect(error.message).toContain("--out"); + expect(error.message).not.toContain("missing.txt"); + }, +); +test("stream dry-run still allows JSON preview without --out", async () => { + const result = await invoke( + { ...flags, stream: true }, + { format: "json", quiet: true, dryRun: true }, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ request: { input: { format: "pcm" } } }); + expect(requests).toEqual([]); + expect(result.stderr).toBe(""); +}); +test.each(["qwen-audio-3.0-tts-plus", "cosyvoice-v3.5-flash", "unknown-model"])( + "voice list returns one JSON result for %s", + async (model) => { + const result = await invoke({ listVoices: true, model }, { format: "json", quiet: true }); + expect(result.exitCode).toBe(0); + const data = JSON.parse(result.stdout); + expect(data.model).toBe(model); + expect(data.voice_list_url).toBeTypeOf("string"); + if (model === "unknown-model") { + expect(data.voices).toBeNull(); + expect(data.total).toBeNull(); + } else if (model === "cosyvoice-v3.5-flash") { + expect(data.voices).toEqual([]); + expect(data.total).toBe(0); + expect(data.clone_design_docs_url).toBeTypeOf("string"); + } else { + expect(data.total).toBe(2); + expect(data.voices.map((entry: { voice: string }) => entry.voice)).toEqual([ + "longanlingxin", + "longanlufeng", + ]); + } + expect(requests).toEqual([]); + expect(result.stderr).toBe(""); + }, +); +test.each(["submit", "empty", "download"] as const)( + "nonstream %s failure retains one JSON error", + async (mode) => { + failure = mode; + const result = await invoke(flags, { format: "json", quiet: true, verbose: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const errors = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .filter((record) => record.error); + expect(errors).toHaveLength(1); + if (mode === "submit") + expect(errors[0].error).toMatchObject({ + message: "Original service failure", + api_code: "ServiceLimit", + request_id: "request-test", + }); + }, +); +test("text-file parsing and explicit audio parameters remain unchanged", async () => { + const textFile = join(directory, "text.txt"); + writeFileSync(textFile, " File text "); + const result = await invoke( + { + ...flags, + text: undefined, + textFile, + out: join(directory, "custom.wav"), + format: "wav", + sampleRate: "24000", + volume: "50", + rate: "1.2", + pitch: "1.1", + seed: "42", + enableSsml: true, + language: "en", + instruction: "Warm", + }, + { format: "json", quiet: true }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(requests[0]!.body).toEqual({ + model: flags.model, + input: { + text: "File text", + voice: flags.voice, + format: "wav", + sample_rate: 24000, + volume: 50, + rate: 1.2, + pitch: 1.1, + seed: 42, + enable_ssml: true, + language_hints: ["en"], + instruction: "Warm", + }, + }); +}); +test("ordinary non-TTY download keeps progress hidden", async () => { + const result = await invoke( + { ...flags, out: join(directory, "plain.mp3") }, + { format: "text", quiet: false, tty: false }, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("\rDownloading"); + expect(result.stderr).toContain("[Model:"); +}); + +test("JSON raw streaming with valid input is rejected before submission", async () => { + const result = await invoke({ ...flags, stream: true }, { format: "json", quiet: true }); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(requests).toEqual([]); + expect(JSON.parse(result.stderr).error.message).toContain("--out"); +}); +test("model and voice-list process prose supports Chinese", async () => { + const result = await invoke( + { ...flags, out: join(directory, "zh.mp3") }, + { format: "text", quiet: false, chinese: true }, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("[模型:"); + const list = await invoke( + { listVoices: true, model: "unknown-model" }, + { format: "text", quiet: false, chinese: true }, + ); + expect(list.stdout).toContain("未提供内置音色列表"); +}); + +const cliModes = ["raw", "raw-quiet", "stream-file", "conflict", "download", "voices"] as const; +test.each(cliModes)("real CLI preserves binary/JSON boundaries for %s", async (mode) => { + writeFileSync(join(directory, "config.json"), JSON.stringify({ language: "en-US" })); + const destination = join(directory, mode === "stream-file" ? "cli.pcm" : "cli.mp3"); + const args = ["speech", "synthesize", "--model", flags.model]; + if (mode === "voices") args.push("--list-voices"); + else args.push("--text", "Hello", "--voice", flags.voice); + if (["raw", "raw-quiet", "stream-file", "conflict"].includes(mode)) args.push("--stream"); + if (mode === "stream-file" || mode === "download") args.push("--out", destination); + const raw = mode === "raw" || mode === "raw-quiet"; + args.push("--output", raw ? "text" : "json"); + if (mode !== "raw") args.push("--quiet"); + if (!raw) args.push("--verbose"); + const child = spawn( + process.execPath, + [ + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../e2e/harness/main.ts", import.meta.url)), + ...args, + ], + { + env: { + ...process.env, + HOME: directory, + BAILIAN_CONFIG_DIR: directory, + BAILIAN_E2E_ROUTES: JSON.stringify([ + { path: "speech synthesize", export: "speechSynthesize" }, + ]), + DASHSCOPE_API_KEY: "test-placeholder", + DASHSCOPE_BASE_URL: origin, + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: "0", + DO_NOT_TRACK: "1", + BAILIAN_INSTALL_METHOD: "brew", + NODE_NO_WARNINGS: "1", + NODE_USE_ENV_PROXY: "0", + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: "", + https_proxy: "", + ALL_PROXY: "", + all_proxy: "", + NO_PROXY: "", + no_proxy: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const chunks: Buffer[] = []; + const errors: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => errors.push(chunk)); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + const bytes = Buffer.concat(chunks); + const stderr = Buffer.concat(errors).toString(); + if (raw) { + expect(exitCode, stderr).toBe(0); + expect(bytes).toEqual(audio); + if (mode === "raw-quiet") expect(stderr).toBe(""); + } else { + const records = stderr.trim() + ? stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + if (mode === "conflict") { + expect(exitCode).toBe(2); + expect(bytes.length).toBe(0); + expect(records).toHaveLength(1); + expect(requests).toEqual([]); + } else { + expect(exitCode, stderr).toBe(0); + const data = JSON.parse(bytes.toString()); + if (mode === "voices") { + expect(data.total).toBe(2); + expect(requests).toEqual([]); + } else { + expect(data.saved).toBe(destination); + expect(readFileSync(destination)).toEqual(audio); + expect(requests).toHaveLength(1); + } + expect(records.every((record) => record.debug)).toBe(true); + } + } +}); diff --git a/skills/bailian-gen/reference/speech.md b/skills/bailian-gen/reference/speech.md index 7bbc5a329..a5a3d189a 100644 --- a/skills/bailian-gen/reference/speech.md +++ b/skills/bailian-gen/reference/speech.md @@ -117,7 +117,7 @@ bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-a | `--instruction ` | string | no | Natural language instruction to control speech style (e.g. "Use a gentle tone") | | `--enable-ssml` | switch | no | Enable SSML markup parsing in input text | | `--out ` | string | no | Save audio to file (default: auto-generate in temp dir) | -| `--stream` | switch | no | Stream raw PCM audio to stdout (pipe to player) | +| `--stream` | switch | no | Stream raw PCM audio to stdout (pipe to player); JSON output requires --out | | `--concurrent ` | number | no | Run N parallel requests (default: 1) | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | From 1e24ab79c594c4439b416956f29e9f42bdf45ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 29 Oct 2026 10:38:39 +0800 Subject: [PATCH 07/28] refactor(commands): unify recognition and resource output - Route speech recognition, memory, quota and security results through the output API - Preserve complete JSON results in quiet mode and existing text layouts - Keep request parameters, file formats, exit codes and deletion confirmation - Add output matrix, CLI boundary and interaction regression coverage --- docs/agents/output-contract.md | 3 +- packages/commands/src/commands/memory/add.ts | 23 +- .../commands/src/commands/memory/delete.ts | 17 +- packages/commands/src/commands/memory/list.ts | 37 +- .../src/commands/memory/profile-create.ts | 22 +- .../src/commands/memory/profile-get.ts | 34 +- .../commands/src/commands/memory/search.ts | 22 +- .../commands/src/commands/memory/update.ts | 30 +- packages/commands/src/commands/quota/check.ts | 31 +- .../commands/src/commands/quota/delete.ts | 27 +- .../commands/src/commands/quota/history.ts | 59 +- packages/commands/src/commands/quota/list.ts | 45 +- .../commands/src/commands/quota/update.ts | 30 +- .../commands/src/commands/security/alerts.ts | 29 +- .../src/commands/security/overview.ts | 29 +- .../commands/src/commands/security/shared.ts | 27 +- .../commands/src/commands/speech/recognize.ts | 139 ++-- .../tests/output/resource-output.test.ts | 636 +++++++++++++++ .../output/speech-recognize-output.test.ts | 744 ++++++++++++++++++ packages/commands/tests/security-view.test.ts | 19 +- 20 files changed, 1685 insertions(+), 318 deletions(-) create mode 100644 packages/commands/tests/output/resource-output.test.ts create mode 100644 packages/commands/tests/output/speech-recognize-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index 2cf971c8d..dcf89117a 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list 和 speech synthesize 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list、speech synthesize / recognize、memory 全部 7 个命令、quota 全部 5 个命令和 agents security overview / alerts 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 @@ -34,6 +34,7 @@ - [ ] 错误 cause 中的结构化对象复用安全序列化;服务端 message、api_code、request_id 不改写 - [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析 - [ ] speech synthesize 的原始音频仅在 text 流式模式下直接写 stdout;JSON 流式需 --out 文件并返回元数据,未指定文件时在请求和文件操作前报用法错误;dry-run 仍允许 JSON 预览。--list-voices 的 JSON 返回音色条目与已有文档链接,内置列表未知时 voices/total 为 null +- [ ] speech recognize 的同步 JSON 返回完整服务响应;异步完成后返回原始任务响应并附加 transcriptions 数组,保留失败子任务和 request_id。text 保留转写、说话人和批量分隔;--out 仍保存原有转写文件结构,下载及写盘失败不提前输出最终结果;部分子任务失败不改变原有退出码 - [ ] 其他原始字节流、既有事件流和 Command Pack API 1 仍走现有专用入口;不得绕过新接口承诺后混入 stdout - [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr - [ ] 进程共用 SIGINT / stdout error 监听器,调用结束清理输出上下文;并发不触发 MaxListenersExceededWarning,单次中断只输出一条终止错误,EPIPE 保护覆盖 run 返回后的异步写入 diff --git a/packages/commands/src/commands/memory/add.ts b/packages/commands/src/commands/memory/add.ts index f55b47fd4..dd4ada327 100644 --- a/packages/commands/src/commands/memory/add.ts +++ b/packages/commands/src/commands/memory/add.ts @@ -2,13 +2,11 @@ import { defineCommand, UsageError, memoryAddPath, - detectOutputFormat, type FlagsDef, type ParsedFlags, type MemoryAddRequest, type MemoryAddResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; const ADD_FLAGS = { userId: { @@ -94,10 +92,8 @@ export default defineCommand({ if (flags.profileSchema) body.profile_schema = flags.profileSchema; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId; - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { - emitResult({ endpoint: ctx.client.url(memoryAddPath()), request: body }, format); + ctx.output.result({ endpoint: ctx.client.url(memoryAddPath()), request: body }); return; } @@ -107,11 +103,16 @@ export default defineCommand({ body, }); - if (settings.quiet || format === "text") { - const ids = response.memory_ids?.join(", ") || "none"; - emitBare(`Memory added. IDs: ${ids}`); - } else { - emitResult(response, format); - } + ctx.output.result( + response, + settings.output === "text" + ? { + text: ctx.localize({ + "en-US": `Memory added. IDs: ${response.memory_ids?.join(", ") || "none"}`, + "zh-CN": `记忆已添加。ID:${response.memory_ids?.join(", ") || "无"}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/memory/delete.ts b/packages/commands/src/commands/memory/delete.ts index d3febaa9f..5883dceca 100644 --- a/packages/commands/src/commands/memory/delete.ts +++ b/packages/commands/src/commands/memory/delete.ts @@ -1,5 +1,4 @@ -import { defineCommand, memoryNodePath, detectOutputFormat } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { defineCommand, memoryNodePath } from "bailian-cli-core"; export default defineCommand({ description: { "en-US": "Delete a memory node", "zh-CN": "删除记忆节点" }, @@ -33,13 +32,12 @@ export default defineCommand({ const nodeId = flags.nodeId; const userId = flags.userId; - const format = detectOutputFormat(settings.output); const params = new URLSearchParams({ user_id: userId }); if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId); const path = `${memoryNodePath(nodeId)}?${params.toString()}`; if (settings.dryRun) { - emitResult({ endpoint: ctx.client.url(path), method: "DELETE" }, format); + ctx.output.result({ endpoint: ctx.client.url(path), method: "DELETE" }); return; } @@ -48,10 +46,11 @@ export default defineCommand({ method: "DELETE", }); - if (settings.quiet || format === "text") { - emitBare(`Memory node ${nodeId} deleted.`); - } else { - emitResult(response, format); - } + ctx.output.result(response, { + text: ctx.localize({ + "en-US": `Memory node ${nodeId} deleted.`, + "zh-CN": `记忆节点 ${nodeId} 已删除。`, + }), + }); }, }); diff --git a/packages/commands/src/commands/memory/list.ts b/packages/commands/src/commands/memory/list.ts index 67b0bb63b..cb94d13d9 100644 --- a/packages/commands/src/commands/memory/list.ts +++ b/packages/commands/src/commands/memory/list.ts @@ -1,10 +1,4 @@ -import { - defineCommand, - memoryListPath, - detectOutputFormat, - type MemoryNodeListResponse, -} from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { defineCommand, memoryListPath, type MemoryNodeListResponse } from "bailian-cli-core"; export default defineCommand({ description: { "en-US": "List memory nodes for a user", "zh-CN": "列出用户的记忆节点" }, @@ -38,7 +32,6 @@ export default defineCommand({ const { settings, flags } = ctx; const userId = flags.userId; - const format = detectOutputFormat(settings.output); const params = new URLSearchParams(); params.set("user_id", userId); if (flags.pageSize !== undefined) params.set("page_size", String(flags.pageSize)); @@ -48,7 +41,7 @@ export default defineCommand({ const path = `${memoryListPath()}?${params.toString()}`; if (settings.dryRun) { - emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format); + ctx.output.result({ endpoint: ctx.client.url(path), method: "GET" }); return; } @@ -57,19 +50,21 @@ export default defineCommand({ method: "GET", }); - if (settings.quiet || format === "text") { - if (!response.memory_nodes || response.memory_nodes.length === 0) { - emitBare("No memory nodes found."); - } else { - for (const node of response.memory_nodes) { - emitBare(`[${node.memory_node_id}] ${node.content}`); - } - if (response.total !== undefined) { - emitBare(`\nTotal: ${response.total}`); - } + let text: string | undefined; + if (settings.output === "text") { + const lines = response.memory_nodes?.length + ? response.memory_nodes.map((node) => `[${node.memory_node_id}] ${node.content}`) + : [ctx.localize({ "en-US": "No memory nodes found.", "zh-CN": "未找到记忆节点。" })]; + if (response.memory_nodes?.length && response.total !== undefined) { + lines.push( + ctx.localize({ + "en-US": `\nTotal: ${response.total}`, + "zh-CN": `\n总计:${response.total}`, + }), + ); } - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, { text }); }, }); diff --git a/packages/commands/src/commands/memory/profile-create.ts b/packages/commands/src/commands/memory/profile-create.ts index 032b8aedc..17c92f63b 100644 --- a/packages/commands/src/commands/memory/profile-create.ts +++ b/packages/commands/src/commands/memory/profile-create.ts @@ -2,11 +2,9 @@ import { defineCommand, UsageError, profileSchemaPath, - detectOutputFormat, type ProfileSchemaCreateRequest, type ProfileSchemaCreateResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ description: { @@ -60,10 +58,8 @@ export default defineCommand({ const body: ProfileSchemaCreateRequest = { name, attributes }; if (flags.description) body.description = flags.description; - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { - emitResult({ endpoint: ctx.client.url(profileSchemaPath()), request: body }, format); + ctx.output.result({ endpoint: ctx.client.url(profileSchemaPath()), request: body }); return; } @@ -73,10 +69,16 @@ export default defineCommand({ body, }); - if (settings.quiet || format === "text") { - emitBare(`Profile schema created: ${response.profile_schema_id}`); - } else { - emitResult(response, format); - } + ctx.output.result( + response, + settings.output === "text" + ? { + text: ctx.localize({ + "en-US": `Profile schema created: ${response.profile_schema_id}`, + "zh-CN": `画像 Schema 已创建:${response.profile_schema_id}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/memory/profile-get.ts b/packages/commands/src/commands/memory/profile-get.ts index 1edf3b4dd..f86c2e683 100644 --- a/packages/commands/src/commands/memory/profile-get.ts +++ b/packages/commands/src/commands/memory/profile-get.ts @@ -1,10 +1,4 @@ -import { - defineCommand, - userProfilePath, - detectOutputFormat, - type UserProfileResponse, -} from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { defineCommand, userProfilePath, type UserProfileResponse } from "bailian-cli-core"; export default defineCommand({ description: { @@ -36,12 +30,11 @@ export default defineCommand({ const schemaId = flags.schemaId; const userId = flags.userId; - const format = detectOutputFormat(settings.output); const params = new URLSearchParams({ user_id: userId }); const path = `${userProfilePath(schemaId)}?${params.toString()}`; if (settings.dryRun) { - emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format); + ctx.output.result({ endpoint: ctx.client.url(path), method: "GET" }); return; } @@ -50,16 +43,19 @@ export default defineCommand({ method: "GET", }); - if (settings.quiet || format === "text") { - if (response.profile?.attributes) { - for (const attr of response.profile.attributes) { - emitBare(`${attr.name}: ${attr.value ?? "(empty)"}`); - } - } else { - emitBare("No profile data found."); - } - } else { - emitResult(response, format); + let text: string | undefined; + if (settings.output === "text") { + const attributes = response.profile?.attributes; + if (attributes?.length === 0) return; + text = attributes + ? attributes + .map( + (attribute) => + `${attribute.name}: ${attribute.value ?? ctx.localize({ "en-US": "(empty)", "zh-CN": "(空)" })}`, + ) + .join("\n") + : ctx.localize({ "en-US": "No profile data found.", "zh-CN": "未找到画像数据。" }); } + ctx.output.result(response, { text }); }, }); diff --git a/packages/commands/src/commands/memory/search.ts b/packages/commands/src/commands/memory/search.ts index ca910980d..692689952 100644 --- a/packages/commands/src/commands/memory/search.ts +++ b/packages/commands/src/commands/memory/search.ts @@ -2,13 +2,11 @@ import { defineCommand, UsageError, memorySearchPath, - detectOutputFormat, type FlagsDef, type ParsedFlags, type MemorySearchRequest, type MemorySearchResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; const SEARCH_FLAGS = { userId: { @@ -91,10 +89,8 @@ export default defineCommand({ if (flags.topK !== undefined) body.top_k = flags.topK; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId; - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { - emitResult({ endpoint: ctx.client.url(memorySearchPath()), request: body }, format); + ctx.output.result({ endpoint: ctx.client.url(memorySearchPath()), request: body }); return; } @@ -104,16 +100,12 @@ export default defineCommand({ body, }); - if (settings.quiet || format === "text") { - if (!response.memory_nodes || response.memory_nodes.length === 0) { - emitBare("No memory nodes found."); - } else { - for (const node of response.memory_nodes) { - emitBare(`[${node.memory_node_id}] ${node.content}`); - } - } - } else { - emitResult(response, format); + let text: string | undefined; + if (settings.output === "text") { + text = response.memory_nodes?.length + ? response.memory_nodes.map((node) => `[${node.memory_node_id}] ${node.content}`).join("\n") + : ctx.localize({ "en-US": "No memory nodes found.", "zh-CN": "未找到记忆节点。" }); } + ctx.output.result(response, { text }); }, }); diff --git a/packages/commands/src/commands/memory/update.ts b/packages/commands/src/commands/memory/update.ts index a6de2a192..89fa1c63a 100644 --- a/packages/commands/src/commands/memory/update.ts +++ b/packages/commands/src/commands/memory/update.ts @@ -1,10 +1,4 @@ -import { - defineCommand, - memoryNodePath, - detectOutputFormat, - type MemoryNodeUpdateRequest, -} from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { defineCommand, memoryNodePath, type MemoryNodeUpdateRequest } from "bailian-cli-core"; export default defineCommand({ description: { "en-US": "Update a memory node content", "zh-CN": "更新记忆节点内容" }, @@ -59,13 +53,12 @@ export default defineCommand({ }; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId; - const format = detectOutputFormat(settings.output); - if (settings.dryRun) { - emitResult( - { endpoint: ctx.client.url(memoryNodePath(nodeId)), method: "PATCH", request: body }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(memoryNodePath(nodeId)), + method: "PATCH", + request: body, + }); return; } @@ -75,10 +68,11 @@ export default defineCommand({ body, }); - if (settings.quiet || format === "text") { - emitBare(`Memory node ${nodeId} updated.`); - } else { - emitResult(response, format); - } + ctx.output.result(response, { + text: ctx.localize({ + "en-US": `Memory node ${nodeId} updated.`, + "zh-CN": `记忆节点 ${nodeId} 已更新。`, + }), + }); }, }); diff --git a/packages/commands/src/commands/quota/check.ts b/packages/commands/src/commands/quota/check.ts index 82637f8f1..1fc34aad8 100644 --- a/packages/commands/src/commands/quota/check.ts +++ b/packages/commands/src/commands/quota/check.ts @@ -3,12 +3,11 @@ import { BailianError, ExitCode, effectiveConsoleGatewayConfig, - detectOutputFormat, unwrapResponse, MODEL_LIST_API, type Client, } from "bailian-cli-core"; -import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { ansi, renderBoxTable } from "bailian-cli-runtime"; const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData"; @@ -141,7 +140,7 @@ interface CheckRow { tpmQuotaLabel: string | null; } -function printTable(rows: CheckRow[]): void { +function renderTable(rows: CheckRow[]): string { const color = ansi(process.stdout); const headers = ["Model", "RPM Used/Limit", "TPM Used/Limit", "Status", "RPM Left", "TPM Left"]; @@ -187,7 +186,7 @@ function printTable(rows: CheckRow[]): void { }, }); - for (const line of lines) process.stdout.write(line + "\n"); + return lines.join("\n"); } export default defineCommand({ @@ -228,16 +227,13 @@ export default defineCommand({ const { settings, flags } = ctx; const modelFlag = flags.model || undefined; const windowMinutes = Number(flags.period) || 2; - const format = detectOutputFormat(settings.output); + const format = settings.output; if (settings.dryRun) { - emitResult( - { - apis: [MODEL_LIST_API, MONITOR_API], - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + apis: [MODEL_LIST_API, MONITOR_API], + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -256,7 +252,9 @@ export default defineCommand({ models = models.filter((m) => m.qpmInfo); if (models.length === 0) { - process.stdout.write("No models found.\n"); + ctx.output.result([], { + text: ctx.localize({ "en-US": "No models found.", "zh-CN": "未找到模型。" }), + }); return; } @@ -306,11 +304,6 @@ export default defineCommand({ }; }); - if (format === "json") { - emitResult(checkRows, format); - return; - } - - printTable(checkRows); + ctx.output.result(checkRows, format === "text" ? { text: renderTable(checkRows) } : undefined); }, }); diff --git a/packages/commands/src/commands/quota/delete.ts b/packages/commands/src/commands/quota/delete.ts index 22cd5c50b..09a2522ec 100644 --- a/packages/commands/src/commands/quota/delete.ts +++ b/packages/commands/src/commands/quota/delete.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand, modelsLimitsPath } from "bailian-cli-core"; export default defineCommand({ description: { @@ -32,15 +31,15 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const modelName = flags.model; - const format = detectOutputFormat(settings.output); const body = { models: [{ model: modelName, operation_type: "DELETE" }] }; if (settings.dryRun) { - emitResult( - { endpoint: ctx.client.url(modelsLimitsPath()), method: "POST", request: body }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(modelsLimitsPath()), + method: "POST", + request: body, + }); return; } @@ -50,10 +49,14 @@ export default defineCommand({ body, }); - if (format === "json") { - emitResult({ model: modelName, ...result }, format); - return; - } - process.stdout.write(`Rate limits cleared for "${modelName}".\n`); + ctx.output.result( + { model: modelName, ...result }, + { + text: ctx.localize({ + "en-US": `Rate limits cleared for "${modelName}".`, + "zh-CN": `模型“${modelName}”的限流配置已清除。`, + }), + }, + ); }, }); diff --git a/packages/commands/src/commands/quota/history.ts b/packages/commands/src/commands/quota/history.ts index 0c8c39e05..301eec631 100644 --- a/packages/commands/src/commands/quota/history.ts +++ b/packages/commands/src/commands/quota/history.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, BailianError, ExitCode } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { defineCommand, BailianError, ExitCode } from "bailian-cli-core"; +import { ansi } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; import { formatNumber } from "../shared/format.ts"; @@ -50,7 +50,7 @@ function formatDateTime(ts: string | undefined): string { } } -function printTable(records: LimitApplicationItem[], total: number): void { +function renderTable(records: LimitApplicationItem[], total: number): string { const color = ansi(process.stdout); const headers = ["Model", "Token Limit", "Applied At"]; @@ -68,14 +68,12 @@ function printTable(records: LimitApplicationItem[], total: number): void { const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" "); const separator = widths.map((w) => color.dim("─".repeat(w))).join("──"); - process.stdout.write(headerLine + "\n"); - process.stdout.write(separator + "\n"); - - for (const row of rows) { - process.stdout.write(row.map((cell, col) => padEnd(cell, widths[col])).join(" ") + "\n"); - } - - process.stdout.write(color.dim(`\nTotal: ${total} records`) + "\n"); + return [ + headerLine, + separator, + ...rows.map((row) => row.map((cell, col) => padEnd(cell, widths[col])).join(" ")), + color.dim(`\nTotal: ${total} records`), + ].join("\n"); } export default defineCommand({ @@ -105,14 +103,14 @@ export default defineCommand({ const page = Number(flags.page) || 1; const pageSize = Number(flags.pageSize) || 10; const modelFilter = flags.model || undefined; - const format = detectOutputFormat(settings.output); + const format = settings.output; const requestData = { input: { pageNo: page, pageSize }, }; if (settings.dryRun) { - emitResult({ api: HISTORY_API, data: requestData }, format); + ctx.output.result({ api: HISTORY_API, data: requestData }); return; } @@ -138,21 +136,24 @@ export default defineCommand({ records = records.filter((r) => r.deployedModel === modelFilter); } - if (format === "json") { - const items = records.map((r) => ({ - model: r.deployedModel, - tokenLimit: r.usageLimit, - appliedAt: formatDateTime(r.gmtCreate), - })); - emitResult({ records: items, total: modelFilter ? records.length : total }, format); - return; - } - - if (records.length === 0) { - process.stdout.write("No quota change history found.\n"); - return; - } - - printTable(records, modelFilter ? records.length : total); + const items = records.map((record) => ({ + model: record.deployedModel, + tokenLimit: record.usageLimit, + appliedAt: formatDateTime(record.gmtCreate), + })); + const resultTotal = modelFilter ? records.length : total; + ctx.output.result( + { records: items, total: resultTotal }, + format === "text" + ? { + text: records.length + ? renderTable(records, resultTotal) + : ctx.localize({ + "en-US": "No quota change history found.", + "zh-CN": "未找到额度变更历史。", + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/quota/list.ts b/packages/commands/src/commands/quota/list.ts index 0e285c3bd..a939aea35 100644 --- a/packages/commands/src/commands/quota/list.ts +++ b/packages/commands/src/commands/quota/list.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core"; -import { emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { defineCommand, modelsLimitsPath } from "bailian-cli-core"; +import { renderBoxTable } from "bailian-cli-runtime"; import { formatNumber } from "../shared/format.ts"; import { buildQuery, parseCommaList } from "../shared/params.ts"; @@ -71,10 +71,9 @@ function formatAsync(spec: LimitSpec | null | undefined): string { return `${queue}/${concurrency}`; } -function printTable(quotas: ModelQuota[], total: number): void { +function renderTable(quotas: ModelQuota[], total: number): string { if (quotas.length === 0) { - process.stdout.write("No rate limits found.\n"); - return; + return "No rate limits found."; } const headers = ["Model", "Req Limit", "Usage Limit", "WS Req", "WS Usage", "Async Q/C"]; const rows = quotas.map((quota) => [ @@ -90,8 +89,7 @@ function printTable(quotas: ModelQuota[], total: number): void { rows, align: ["left", "right", "right", "right", "right", "right"], }); - for (const line of lines) process.stdout.write(line + "\n"); - process.stdout.write(`\nTotal: ${total}\n`); + return [...lines, `\nTotal: ${total}`].join("\n"); } // --------------------------------------------------------------------------- @@ -150,7 +148,7 @@ export default defineCommand({ const { settings, flags } = ctx; const modelFlag = flags.model || undefined; const nameFlag = flags.name || undefined; - const format = detectOutputFormat(settings.output); + const format = settings.output; const endpoint = ctx.client.url(modelsLimitsPath()); if (settings.dryRun) { @@ -161,20 +159,17 @@ export default defineCommand({ method: "GET", query: { model, page_size: 100 }, })); - emitResult({ requests }, format); + ctx.output.result({ requests }); } else { - emitResult( - { - endpoint, - method: "GET", - query: { - name: nameFlag, - page_no: flags.page || 1, - page_size: flags.pageSize || 20, - }, + ctx.output.result({ + endpoint, + method: "GET", + query: { + name: nameFlag, + page_no: flags.page || 1, + page_size: flags.pageSize || 20, }, - format, - ); + }); } return; } @@ -207,11 +202,9 @@ export default defineCommand({ total = resp.output?.total ?? quotas.length; } - if (format === "json") { - emitResult({ items: quotas, total }, format); - return; - } - - printTable(quotas, total); + ctx.output.result( + { items: quotas, total }, + format === "text" ? { text: renderTable(quotas, total) } : undefined, + ); }, }); diff --git a/packages/commands/src/commands/quota/update.ts b/packages/commands/src/commands/quota/update.ts index 5d7766e39..91cdb0508 100644 --- a/packages/commands/src/commands/quota/update.ts +++ b/packages/commands/src/commands/quota/update.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand, modelsLimitsPath } from "bailian-cli-core"; import { formatNumber } from "../shared/format.ts"; const MINUTE_SECONDS = 60; @@ -63,7 +62,6 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const modelName = flags.model; - const format = detectOutputFormat(settings.output); const entry: Record = { model: modelName }; if (flags.rpm !== undefined) { @@ -77,10 +75,11 @@ export default defineCommand({ const body = { models: [entry] }; if (settings.dryRun) { - emitResult( - { endpoint: ctx.client.url(modelsLimitsPath()), method: "POST", request: body }, - format, - ); + ctx.output.result({ + endpoint: ctx.client.url(modelsLimitsPath()), + method: "POST", + request: body, + }); return; } @@ -90,13 +89,16 @@ export default defineCommand({ body, }); - if (format === "json") { - emitResult({ model: modelName, ...result }, format); - return; + let text: string | undefined; + if (settings.output === "text") { + const parts: string[] = []; + if (flags.rpm !== undefined) parts.push(`QPM ${formatNumber(flags.rpm)}`); + if (flags.tpm !== undefined) parts.push(`TPM ${formatNumber(flags.tpm)}`); + text = ctx.localize({ + "en-US": `Rate limits updated for "${modelName}": ${parts.join(", ")}`, + "zh-CN": `模型“${modelName}”的限流配置已更新:${parts.join(",")}`, + }); } - const parts: string[] = []; - if (flags.rpm !== undefined) parts.push(`QPM ${formatNumber(flags.rpm)}`); - if (flags.tpm !== undefined) parts.push(`TPM ${formatNumber(flags.tpm)}`); - process.stdout.write(`Rate limits updated for "${modelName}": ${parts.join(", ")}\n`); + ctx.output.result({ model: modelName, ...result }, { text }); }, }); diff --git a/packages/commands/src/commands/security/alerts.ts b/packages/commands/src/commands/security/alerts.ts index 564deac01..81e5ca7e0 100644 --- a/packages/commands/src/commands/security/alerts.ts +++ b/packages/commands/src/commands/security/alerts.ts @@ -1,12 +1,10 @@ import { defineCommand, - detectOutputFormat, securityAgentLogsEndpoint, securityGet, type FlagsDef, type SecurityAlertList, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { SECURITY_UI, WORKSPACE_FLAG, @@ -146,7 +144,7 @@ export default defineCommand({ ], async run(ctx) { const { settings, flags, localize } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const host = resolveSecurityHost(ctx); const params = new URLSearchParams(); @@ -168,29 +166,22 @@ export default defineCommand({ const endpoint = query ? `${base}?${query}` : base; if (settings.dryRun) { - emitResult({ endpoint, method: "GET" }, format); + ctx.output.result({ endpoint, method: "GET" }); return; } const data = await securityGet(ctx.client, endpoint); const alerts = data?.data ?? []; - // --quiet wins over the output format: emit a bare, pipe-friendly list of - // alert IDs even when output=json is configured, so ID-driven pipelines - // (`bl agents security alerts --quiet | xargs …`) keep working. - if (settings.quiet) { - for (const alert of alerts) emitBare(alert.alert_id); - return; - } - if (format === "json") { - emitResult(data ?? { stats: null, data: [], next_page: null }, format); + ctx.output.result(data ?? { stats: null, data: [], next_page: null }); return; } + const lines: string[] = []; const stats = data?.stats; if (stats) { - emitBare( + lines.push( `${localize(SECURITY_UI.total)}: ${stats.total ?? "-"} ` + `${localize(SECURITY_UI.high)}: ${stats.high ?? "-"} ` + `${localize(SECURITY_UI.medium)}: ${stats.medium ?? "-"} ` + @@ -199,14 +190,18 @@ export default defineCommand({ } if (alerts.length === 0) { - emitBare(localize(SECURITY_UI.noAlerts)); + lines.push(localize(SECURITY_UI.noAlerts)); + ctx.output.result(data ?? { stats: null, data: [], next_page: null }, { + text: lines.join("\n"), + }); return; } - for (const alert of alerts) renderAlert(localize, alert); + for (const alert of alerts) lines.push(...renderAlert(localize, alert)); if (data?.next_page) { - emitBare(`${localize(SECURITY_UI.nextPageCursor)}: ${data.next_page}`); + lines.push(`${localize(SECURITY_UI.nextPageCursor)}: ${data.next_page}`); } + ctx.output.result(data, { text: lines.join("\n") }); }, }); diff --git a/packages/commands/src/commands/security/overview.ts b/packages/commands/src/commands/security/overview.ts index 95fffa53c..5a78740fd 100644 --- a/packages/commands/src/commands/security/overview.ts +++ b/packages/commands/src/commands/security/overview.ts @@ -1,13 +1,11 @@ import { defineCommand, - detectOutputFormat, securityGet, securityOverviewEndpoint, type FlagsDef, type SecurityOverview, type SecurityScanStat, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { CAPABILITY_LABELS, PROTECTION_LABELS, @@ -60,23 +58,22 @@ export default defineCommand({ ], async run(ctx) { const { settings, localize } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const endpoint = securityOverviewEndpoint(resolveSecurityHost(ctx)); if (settings.dryRun) { - emitResult({ endpoint, method: "GET" }, format); + ctx.output.result({ endpoint, method: "GET" }); return; } const data = await securityGet(ctx.client, endpoint); if (!data) { - if (format === "json") emitResult({}, format); - else emitBare(localize(SECURITY_UI.overviewUnavailable)); + ctx.output.result({}, { text: localize(SECURITY_UI.overviewUnavailable) }); return; } if (format === "json") { - emitResult(data, format); + ctx.output.result(data); return; } @@ -91,24 +88,30 @@ export default defineCommand({ const sum = (pick: (stat: SecurityScanStat) => number | null): number => cards.reduce((total, card) => total + (card.stat ? (pick(card.stat) ?? 0) : 0), 0); - emitBare( + const lines: string[] = []; + lines.push( `${localize(SECURITY_UI.scannedLabel)}: ${sum((stat) => stat.scanned)} ` + `${localize(SECURITY_UI.risksLabel)}: ${sum((stat) => stat.hit)}`, ); - renderToggles(localize, SECURITY_UI.capabilities, data.capabilities, CAPABILITY_LABELS); - renderToggles(localize, SECURITY_UI.protection, data.protection, PROTECTION_LABELS); + lines.push( + ...renderToggles(localize, SECURITY_UI.capabilities, data.capabilities, CAPABILITY_LABELS), + ); + lines.push( + ...renderToggles(localize, SECURITY_UI.protection, data.protection, PROTECTION_LABELS), + ); - emitBare(`\n${localize(SECURITY_UI.detections)}`); + lines.push(`\n${localize(SECURITY_UI.detections)}`); for (const { label, stat } of cards) { if (!stat) { - emitBare(` ${label} ${localize(SECURITY_UI.unavailable)}`); + lines.push(` ${label} ${localize(SECURITY_UI.unavailable)}`); } else { - emitBare( + lines.push( ` ${label} ${localize(SECURITY_UI.hit)} ${stat.hit ?? "-"} / ` + `${localize(SECURITY_UI.scanned)} ${stat.scanned ?? "-"}`, ); } } + ctx.output.result(data, { text: lines.join("\n") }); }, }); diff --git a/packages/commands/src/commands/security/shared.ts b/packages/commands/src/commands/security/shared.ts index 96a74f490..004582a35 100644 --- a/packages/commands/src/commands/security/shared.ts +++ b/packages/commands/src/commands/security/shared.ts @@ -12,7 +12,6 @@ import { type SecurityOverview, type SecurityToggle, } from "bailian-cli-core"; -import { emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "../knowledge/shared.ts"; export { resolveWorkspaceId, WORKSPACE_FLAG }; @@ -117,18 +116,20 @@ export function renderToggles( title: LocalizedText, toggles: SecurityToggle[] | null | undefined, labels: Record, -): void { - emitBare(`\n${localize(title)}`); +): string[] { + const lines: string[] = []; + lines.push(`\n${localize(title)}`); if (!toggles || toggles.length === 0) { - emitBare(` ${localize(SECURITY_UI.unavailable)}`); - return; + lines.push(` ${localize(SECURITY_UI.unavailable)}`); + return lines; } for (const toggle of toggles) { const count = typeof toggle.count === "number" ? ` (${toggle.count})` : ""; const state = localize(toggle.enabled ? SECURITY_UI.on : SECURITY_UI.off); const label = labels[toggle.key] ? localize(labels[toggle.key]) : toggle.key; - emitBare(` ${state} ${label}${count}`); + lines.push(` ${state} ${label}${count}`); } + return lines; } /** check_time / handle_time are millisecond timestamp strings, sometimes ISO 8601. */ @@ -140,15 +141,17 @@ export function formatSecurityTime(value: string | null | undefined): string { } /** Render one alert row in text mode; field labels are localized. */ -export function renderAlert(localize: Localize, alert: SecurityAlert): void { +export function renderAlert(localize: Localize, alert: SecurityAlert): string[] { + const lines: string[] = []; const level = (alert.risk_level ?? "unknown").toUpperCase(); - emitBare(`[${level}] ${alert.risk_name ?? "-"} (${alert.alert_id})`); - emitBare( + lines.push(`[${level}] ${alert.risk_name ?? "-"} (${alert.alert_id})`); + lines.push( ` ${localize(SECURITY_UI.app)}: ${alert.app_name ?? "-"} ${localize(SECURITY_UI.asset)}: ${alert.asset_name ?? "-"} (${alert.asset_type ?? "-"})`, ); - emitBare( + lines.push( ` ${localize(SECURITY_UI.status)}: ${alert.status ?? "-"} ${localize(SECURITY_UI.source)}: ${alert.source ?? "-"} ${localize(SECURITY_UI.checked)}: ${formatSecurityTime(alert.check_time)}`, ); - if (alert.risk_desc) emitBare(` ${alert.risk_desc}`); - emitBare(""); + if (alert.risk_desc) lines.push(` ${alert.risk_desc}`); + lines.push(""); + return lines; } diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index 29b937df5..a25e5823f 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -3,7 +3,6 @@ import { BailianError, defineCommand, ExitCode, - detectOutputFormat, type CommandOutput, type Client, type Settings, @@ -22,13 +21,11 @@ import { parseInstantVocabulary, type AsrApiRoute, type AsrFlashFamily, - type OutputFormat, type FlagsDef, type ParsedFlags, ASYNC_FLAG, } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; -import { emitResult, emitBare } from "bailian-cli-runtime"; const RECOGNIZE_FLAGS = { url: { @@ -247,8 +244,6 @@ export default defineCommand({ ); } - const format = detectOutputFormat(settings.output); - const vocabulary = flags.vocabulary !== undefined ? parseInstantVocabulary(flags.vocabulary) : undefined; @@ -258,9 +253,9 @@ export default defineCommand({ if (route.kind === "sync-flash") { await handleSyncFlashMode( ctx.client, + ctx.output, settings, flags, - format, model, route, resolvedUrls[0]!, @@ -298,31 +293,24 @@ export default defineCommand({ stripUndefined(body.parameters as Record); if (settings.dryRun) { - emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format); + ctx.output.result({ request: body, mode: "async", path: speechRecognizePath() }); return; } - if (!settings.quiet) { - process.stderr.write(`[Model: ${model}] [Mode: async] [Files: ${resolvedUrls.length}]\n`); - } + ctx.output.info({ + "en-US": `[Model: ${model}] [Mode: async] [Files: ${resolvedUrls.length}]`, + "zh-CN": `[模型:${model}] [模式:异步] [文件数:${resolvedUrls.length}]`, + }); - await handleAsyncMode( - ctx.client, - ctx.output, - settings, - body, - flags, - format, - resolvedUrls.length, - ); + await handleAsyncMode(ctx.client, ctx.output, settings, body, flags, resolvedUrls.length); }, }); async function handleSyncFlashMode( client: Client, + commandOutput: CommandOutput, settings: Settings, flags: RecognizeFlags, - format: OutputFormat, model: string, route: AsrApiRoute, audioUrl: string, @@ -340,13 +328,14 @@ async function handleSyncFlashMode( }); if (settings.dryRun) { - emitResult({ request: body, mode: "sync", path: route.path }, format); + commandOutput.result({ request: body, mode: "sync", path: route.path }); return; } - if (!settings.quiet) { - process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`); - } + commandOutput.info({ + "en-US": `[Model: ${model}] [Mode: sync] [Files: 1]`, + "zh-CN": `[模型:${model}] [模式:同步] [文件数:1]`, + }); const response = await client.requestJson>({ path: route.path, @@ -355,18 +344,22 @@ async function handleSyncFlashMode( body, }); - const text = extractAsrFlashText(response, flashFamily); - if (text) { - process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); - } else { - emitBare(JSON.stringify(response)); + if (flags.out) { + writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n"); } + const text = settings.output === "text" ? extractAsrFlashText(response, flashFamily) : ""; + commandOutput.result( + response, + settings.output === "text" + ? { text: text ? text.replace(/\n$/, "") : JSON.stringify(response) } + : undefined, + ); if (flags.out) { - writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n"); - if (!settings.quiet) { - process.stderr.write(`Full result saved to: ${flags.out}\n`); - } + commandOutput.info({ + "en-US": `Full result saved to: ${flags.out}`, + "zh-CN": `完整结果已保存至:${flags.out}`, + }); } } @@ -376,7 +369,6 @@ async function handleAsyncMode( settings: Settings, body: DashScopeASRRequest, flags: RecognizeFlags, - format: OutputFormat, fileCount: number, ): Promise { // Submit async task (always required for fun-asr) @@ -391,7 +383,7 @@ async function handleAsyncMode( // --async: return task ID immediately if (flags.async) { - emitResult({ task_id: taskId }, format); + commandOutput.result({ task_id: taskId }); return; } @@ -416,32 +408,33 @@ async function handleAsyncMode( const results = collectAsrTranscriptionItems(result.output); if (results.length === 0) { - emitResult({ task_id: taskId, status: result.output.task_status }, format); + commandOutput.result({ task_id: taskId, status: result.output.task_status }); return; } // Collect all transcription data for --out const allTransData: Record[] = []; + const textParts: string[] = []; for (let index = 0; index < results.length; index++) { const subResult = results[index]!; const isMulti = fileCount > 1; - if (isMulti) { - process.stdout.write( - `=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`, - ); + if (settings.output === "text" && isMulti) { + textParts.push(`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`); } if (subResult.subtask_status === "FAILED") { const errMsg = subResult.message ?? subResult.code ?? "unknown error"; - process.stdout.write(`[FAILED] ${subResult.file_url ?? ""} — ${errMsg}\n`); - if (isMulti) process.stdout.write("\n"); + if (settings.output === "text") { + textParts.push(`[FAILED] ${subResult.file_url ?? ""} — ${errMsg}\n`); + if (isMulti) textParts.push("\n"); + } continue; } if (!subResult.transcription_url) { - if (isMulti) process.stdout.write("\n"); + if (settings.output === "text" && isMulti) textParts.push("\n"); continue; } @@ -456,31 +449,33 @@ async function handleAsyncMode( const transData = (await transRes.json()) as Record; allTransData.push(transData); - // Extract and output text from transcripts[] - const transcripts = transData.transcripts as - | Array<{ - text?: string; - sentences?: Array<{ text: string; speaker_id?: number }>; - }> - | undefined; - - if (transcripts && transcripts.length > 0) { - for (const transcript of transcripts) { - if (transcript.sentences && transcript.sentences.length > 0) { - for (const sentence of transcript.sentences) { - const speakerTag = - sentence.speaker_id !== undefined ? ` [Speaker ${sentence.speaker_id}]` : ""; - process.stdout.write(`${sentence.text}${speakerTag}\n`); + if (settings.output === "text") { + // Extract and output text from transcripts[] + const transcripts = transData.transcripts as + | Array<{ + text?: string; + sentences?: Array<{ text: string; speaker_id?: number }>; + }> + | undefined; + + if (transcripts && transcripts.length > 0) { + for (const transcript of transcripts) { + if (transcript.sentences && transcript.sentences.length > 0) { + for (const sentence of transcript.sentences) { + const speakerTag = + sentence.speaker_id !== undefined ? ` [Speaker ${sentence.speaker_id}]` : ""; + textParts.push(`${sentence.text}${speakerTag}\n`); + } + } else if (transcript.text) { + textParts.push(transcript.text + "\n"); } - } else if (transcript.text) { - process.stdout.write(transcript.text + "\n"); } + } else { + textParts.push(JSON.stringify(transData) + "\n"); } - } else { - emitBare(JSON.stringify(transData)); - } - if (isMulti) process.stdout.write("\n"); + if (isMulti) textParts.push("\n"); + } } // Save to --out file @@ -488,8 +483,18 @@ async function handleAsyncMode( const outPath = flags.out; const outData = allTransData.length === 1 ? allTransData[0] : allTransData; writeFileSync(outPath, JSON.stringify(outData, null, 2) + "\n"); - if (!settings.quiet) { - process.stderr.write(`Full result saved to: ${outPath}\n`); - } + } + + if (settings.output === "json" || textParts.length > 0) { + commandOutput.result( + { ...result, transcriptions: allTransData }, + { text: textParts.join("").replace(/\n$/, "") }, + ); + } + if (flags.out) { + commandOutput.info({ + "en-US": `Full result saved to: ${flags.out}`, + "zh-CN": `完整结果已保存至:${flags.out}`, + }); } } diff --git a/packages/commands/tests/output/resource-output.test.ts b/packages/commands/tests/output/resource-output.test.ts new file mode 100644 index 000000000..9e2fddc20 --- /dev/null +++ b/packages/commands/tests/output/resource-output.test.ts @@ -0,0 +1,636 @@ +import { spawn } from "child_process"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + BailianError, + buildSettings, + Client, + ExitCode, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import memoryAdd from "../../src/commands/memory/add.ts"; +import memoryDelete from "../../src/commands/memory/delete.ts"; +import memoryList from "../../src/commands/memory/list.ts"; +import memorySearch from "../../src/commands/memory/search.ts"; +import memoryUpdate from "../../src/commands/memory/update.ts"; +import profileCreate from "../../src/commands/memory/profile-create.ts"; +import profileGet from "../../src/commands/memory/profile-get.ts"; +import quotaCheck from "../../src/commands/quota/check.ts"; +import quotaDelete from "../../src/commands/quota/delete.ts"; +import quotaHistory from "../../src/commands/quota/history.ts"; +import quotaList from "../../src/commands/quota/list.ts"; +import quotaUpdate from "../../src/commands/quota/update.ts"; +import securityOverview from "../../src/commands/security/overview.ts"; +import securityAlerts from "../../src/commands/security/alerts.ts"; + +interface Scenario { + name: string; + command: AnyCommand; + flags: Record; + response: unknown; + want: unknown; + text: string; +} +const node = { memory_node_id: "node-test", content: "Hello memory", score: 0.9 }; +const limits = { + model: "qwen-test", + model_limit: { + request_limit: 60, + request_limit_period: 60, + usage_limit: 1000, + usage_limit_period: 60, + }, +}; +const overview = { + capabilities: [{ key: "content_safety", enabled: true, count: 3 }], + protection: [], + content_safety: { hit: 1, scanned: 10 }, +}; +const alerts = { + stats: { total: 1, high: 1, medium: 0, low: 0 }, + data: [ + { + alert_id: "alert-test", + risk_level: "high", + risk_name: "Risk", + asset_name: "asset", + app_name: "app", + asset_type: "agent", + status: "unhandled", + source: "src", + check_time: "invalid", + risk_desc: "Details", + }, + ], + next_page: "next-cursor", +}; +const cases: Scenario[] = [ + { + name: "memory add", + command: memoryAdd, + flags: { + userId: "user-test", + content: "Hello", + memoryLibraryId: "lib-test", + profileSchema: "schema-test", + }, + response: { memory_ids: ["node-test"], request_id: "request-test" }, + want: { memory_ids: ["node-test"], request_id: "request-test" }, + text: "Memory added. IDs: node-test\n", + }, + { + name: "memory delete", + command: memoryDelete, + flags: { userId: "user-test", nodeId: "node-test", memoryLibraryId: "lib-test" }, + response: { request_id: "request-test" }, + want: { request_id: "request-test" }, + text: "Memory node node-test deleted.\n", + }, + { + name: "memory list", + command: memoryList, + flags: { userId: "user-test", page: 2, pageSize: 3, memoryLibraryId: "lib-test" }, + response: { memory_nodes: [node], total: 9, request_id: "request-test" }, + want: { memory_nodes: [node], total: 9, request_id: "request-test" }, + text: "[node-test] Hello memory\n\nTotal: 9\n", + }, + { + name: "memory search", + command: memorySearch, + flags: { userId: "user-test", query: "Hello", topK: 3, memoryLibraryId: "lib-test" }, + response: { memory_nodes: [node], request_id: "request-test" }, + want: { memory_nodes: [node], request_id: "request-test" }, + text: "[node-test] Hello memory\n", + }, + { + name: "memory update", + command: memoryUpdate, + flags: { + userId: "user-test", + nodeId: "node-test", + content: "New", + memoryLibraryId: "lib-test", + }, + response: { request_id: "request-test" }, + want: { request_id: "request-test" }, + text: "Memory node node-test updated.\n", + }, + { + name: "profile create", + command: profileCreate, + flags: { name: "schema-test", description: "Demo", attributes: '[{"name":"hobby"}]' }, + response: { profile_schema_id: "schema-test", request_id: "request-test" }, + want: { profile_schema_id: "schema-test", request_id: "request-test" }, + text: "Profile schema created: schema-test\n", + }, + { + name: "profile get", + command: profileGet, + flags: { userId: "user-test", schemaId: "schema-test" }, + response: { + profile: { + attributes: [ + { name: "hobby", value: "reading" }, + { name: "empty", value: null }, + ], + }, + request_id: "request-test", + }, + want: { + profile: { + attributes: [ + { name: "hobby", value: "reading" }, + { name: "empty", value: null }, + ], + }, + request_id: "request-test", + }, + text: "hobby: reading\nempty: (empty)\n", + }, + { + name: "quota check", + command: quotaCheck, + flags: { period: "2" }, + response: {}, + want: [ + { + model: "qwen-test", + rpmUsage: 12, + rpmLimit: 60, + tpmUsage: 100, + tpmLimit: 1000, + rpmQuotaLeft: 80, + tpmQuotaLeft: 90, + rpmQuotaLabel: "80.0%", + tpmQuotaLabel: "90.0%", + }, + ], + text: "qwen-test", + }, + { + name: "quota delete", + command: quotaDelete, + flags: { model: "qwen-test" }, + response: { request_id: "request-test" }, + want: { model: "qwen-test", request_id: "request-test" }, + text: 'Rate limits cleared for "qwen-test".\n', + }, + { + name: "quota history", + command: quotaHistory, + flags: { page: "2", pageSize: "3" }, + response: {}, + want: { + records: [{ model: "qwen-test", tokenLimit: 1000, appliedAt: "2026-01-02 03:04" }], + total: 9, + }, + text: "qwen-test", + }, + { + name: "quota list", + command: quotaList, + flags: { name: "qwen", page: 2, pageSize: 3 }, + response: { output: { quotas: [limits], total: 9 } }, + want: { items: [limits], total: 9 }, + text: "qwen-test", + }, + { + name: "quota update", + command: quotaUpdate, + flags: { model: "qwen-test", rpm: 60, tpm: 1000 }, + response: { request_id: "request-test" }, + want: { model: "qwen-test", request_id: "request-test" }, + text: 'Rate limits updated for "qwen-test": QPM 60, TPM 1,000\n', + }, + { + name: "security overview", + command: securityOverview, + flags: {}, + response: overview, + want: overview, + text: "Scanned: 10 Risks: 1", + }, + { + name: "security alerts", + command: securityAlerts, + flags: { page: 2, pageSize: 3, statusList: ["unhandled", "handling"], riskLevel: "high" }, + response: alerts, + want: alerts, + text: "Next page cursor: next-cursor", + }, +]; +let directory: string; +let server: Server; +let origin: string; +let responseBody: unknown; +let status: number; +let requests: Array<{ method: string; path: string; body?: unknown }>; +let consoleCalls: Array<{ api: string; data: Record }>; +let consoleEmpty: boolean; +let consoleFailure: boolean; +beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), "bl-resource-output-")); + status = 200; + requests = []; + consoleCalls = []; + consoleEmpty = false; + consoleFailure = false; + server = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += String(chunk); + requests.push({ + method: request.method!, + path: request.url!, + ...(body ? { body: JSON.parse(body) } : {}), + }); + response + .writeHead(status, { "content-type": "application/json" }) + .end(JSON.stringify(responseBody)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected loopback address"); + origin = `http://127.0.0.1:${address.port}`; +}); +afterEach(async () => { + vi.restoreAllMocks(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(directory, { recursive: true, force: true }); +}); +async function invoke( + scenario: Scenario, + display: { + format: "text" | "json"; + quiet?: boolean; + verbose?: boolean; + dryRun?: boolean; + chinese?: boolean; + }, +) { + let stdout = ""; + let stderr = ""; + let stray = ""; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stray += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + env: {}, + file: {}, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => + typeof text === "string" ? text : text[display.chinese ? "zh-CN" : "en-US"]; + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write(chunk) { + stdout += chunk; + }, + }, + stderr: { + write(chunk) { + stderr += chunk; + }, + }, + }); + const identity = { binName: "bl", npmPackage: "test", version: "test", clientName: "test" }; + const client = new Client({ + settings, + identity, + baseUrl: origin, + apiCred: { token: "test-placeholder", source: "flag", baseUrl: origin }, + debug: (details) => reporter.output.debug(details), + }); + vi.spyOn(client, "console").mockImplementation(async (api, data) => { + consoleCalls.push({ api, data }); + if (consoleFailure) throw new BailianError("Original console failure", ExitCode.AUTH); + if (api.includes("listModelLimitApplications")) + return { + records: consoleEmpty + ? [] + : [{ deployedModel: "qwen-test", usageLimit: 1000, gmtCreate: "2026-01-02T03:04:00" }], + items: consoleEmpty ? 0 : 9, + }; + if (api.includes("getMonitorData")) + return { + data: [ + { + aggMethod: "sum_pm", + metricName: "model_call_count", + points: [{ value: 12, timestamp: 1 }], + }, + { + aggMethod: "sum_pm", + metricName: "model_total_amount", + points: [{ value: 100, timestamp: 1 }], + }, + ], + }; + return { + list: consoleEmpty + ? [] + : [ + { + model: "qwen-test", + qpmInfo: { + "model-default": { + count_limit: 60, + count_limit_period: 60, + usage_limit: 1000, + usage_limit_period: 60, + }, + }, + }, + ], + total: consoleEmpty ? 0 : 1, + }; + }); + let exitCode = 0; + try { + try { + await scenario.command.run({ + flags: scenario.flags, + settings, + identity, + client, + output: reporter.output, + localize, + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, stray, exitCode }; + } finally { + stdoutSpy.mockRestore(); + } +} +const matrix = cases.flatMap((scenario) => + (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ scenario, format, quiet, verbose, name: scenario.name })), + ), + ), +); +test.each(matrix)( + "$name routes full output: $format q=$quiet v=$verbose", + async ({ scenario, ...display }) => { + responseBody = scenario.response; + const result = await invoke(scenario, display); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stray).toBe(""); + if (display.format === "json") { + expect(JSON.parse(result.stdout)).toEqual(scenario.want); + const records = result.stderr.trim() + ? result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + expect(records.every((record) => record.debug)).toBe(true); + } else if ( + scenario.name.startsWith("memory") || + scenario.name.startsWith("profile") || + ["quota update", "quota delete"].includes(scenario.name) + ) + expect(result.stdout).toBe(scenario.text); + else expect(result.stdout).toContain(scenario.text); + expect(result.stderr).not.toContain("test-placeholder"); + expect(requests.length + consoleCalls.length).toBeGreaterThan(0); + }, +); +test.each(cases)( + "$name dry-run uses result channel without HTTP/console calls", + async (scenario) => { + responseBody = scenario.response; + const result = await invoke(scenario, { format: "json", quiet: true, dryRun: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stray).toBe(""); + expect(JSON.parse(result.stdout)).toBeTypeOf("object"); + expect(requests).toEqual([]); + expect(consoleCalls).toEqual([]); + }, +); +test.each(cases)( + "$name failures retain one structured error and no final result", + async (scenario) => { + responseBody = { + code: "ServiceLimit", + message: "Original service failure", + request_id: "failed-request", + }; + status = 429; + consoleFailure = true; + const result = await invoke(scenario, { format: "json", quiet: true, verbose: true }); + expect(result.exitCode).toBe(scenario.command.auth === "console" ? 3 : 1); + expect(result.stdout).toBe(""); + expect(result.stray).toBe(""); + const errors = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .filter((record) => record.error); + expect(errors).toHaveLength(1); + expect(errors[0].error.message).toContain( + scenario.command.auth === "console" ? "Original console failure" : "Original service failure", + ); + }, +); +test.each([ + "memory list", + "memory search", + "profile get", + "quota list", + "quota check", + "quota history", + "security alerts", + "security overview", +])("empty $name remains structured and passes through result channel", async (name) => { + const scenario = cases.find((candidate) => candidate.name === name)!; + responseBody = + name === "quota list" + ? { output: { quotas: [], total: 0 } } + : name.startsWith("security") + ? { success: true, data: null } + : {}; + consoleEmpty = true; + const result = await invoke(scenario, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stray).toBe(""); + const data = JSON.parse(result.stdout); + if (name === "quota check") expect(data).toEqual([]); + if (name === "security alerts") expect(data).toEqual({ stats: null, data: [], next_page: null }); + if (name === "security overview") expect(data).toEqual({}); +}); +test("memory and quota requests retain their parameters", async () => { + const add = cases[0]!; + responseBody = add.response; + expect((await invoke(add, { format: "json", quiet: true })).exitCode).toBe(0); + expect(requests[0]!.body).toEqual({ + user_id: "user-test", + custom_content: "Hello", + profile_schema: "schema-test", + memory_library_id: "lib-test", + }); + const search = cases[3]!; + responseBody = search.response; + await invoke(search, { format: "json", quiet: true }); + expect(requests[1]!.body).toEqual({ + user_id: "user-test", + query: "Hello", + messages: [{ role: "user", content: "Hello" }], + top_k: 3, + memory_library_id: "lib-test", + }); + const update = cases.find((scenario) => scenario.name === "quota update")!; + responseBody = update.response; + await invoke(update, { format: "json", quiet: true }); + expect(requests[2]!.body).toEqual({ + models: [ + { + model: "qwen-test", + request_limit: 60, + request_limit_period: 60, + usage_limit: 1000, + usage_limit_period: 60, + }, + ], + }); +}); +test("security query preserves pagination and repeated statuses", async () => { + const scenario = cases.find((candidate) => candidate.name === "security alerts")!; + responseBody = scenario.response; + await invoke(scenario, { format: "json", quiet: true }); + const query = new URL(requests[0]!.path, origin).searchParams; + expect(query.getAll("status_list")).toEqual(["unhandled", "handling"]); + expect(query.get("current_page")).toBe("2"); + expect(query.get("page_size")).toBe("3"); +}); + +const cliCases = [ + { + name: "memory list", + route: "memory list", + exportName: "memoryList", + args: ["--user-id", "user-test"], + }, + { + name: "memory add", + route: "memory add", + exportName: "memoryAdd", + args: ["--user-id", "user-test", "--content", "Hello"], + }, + { name: "quota list", route: "quota list", exportName: "quotaList", args: [] }, + { + name: "quota update", + route: "quota update", + exportName: "quotaUpdate", + args: ["--model", "qwen-test", "--rpm", "60"], + }, + { + name: "security alerts", + route: "agents security alerts", + exportName: "securityAlerts", + args: [], + }, + { + name: "quota delete", + route: "quota delete", + exportName: "quotaDelete", + args: ["--model", "qwen-test", "--yes"], + }, + { + name: "confirmation", + route: "quota delete", + exportName: "quotaDelete", + args: ["--model", "qwen-test"], + }, +]; +test.each(cliCases)("real CLI keeps JSON/env quiet/confirmation for $name", async (item) => { + const scenario = cases.find( + (candidate) => candidate.name === (item.name === "confirmation" ? "quota delete" : item.name), + )!; + responseBody = scenario.response; + writeFileSync(join(directory, "config.json"), JSON.stringify({ language: "en-US" })); + const child = spawn( + process.execPath, + [ + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../e2e/harness/main.ts", import.meta.url)), + ...item.route.split(" "), + ...item.args, + "--output", + "json", + "--verbose", + ], + { + env: { + ...process.env, + HOME: directory, + BAILIAN_CONFIG_DIR: directory, + BAILIAN_E2E_ROUTES: JSON.stringify([{ path: item.route, export: item.exportName }]), + DASHSCOPE_API_KEY: "test-placeholder", + DASHSCOPE_BASE_URL: origin, + DASHSCOPE_QUIET: "1", + DASHSCOPE_VERBOSE: "0", + DO_NOT_TRACK: "1", + BAILIAN_INSTALL_METHOD: "brew", + NODE_NO_WARNINGS: "1", + NODE_USE_ENV_PROXY: "0", + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: "", + https_proxy: "", + ALL_PROXY: "", + all_proxy: "", + NO_PROXY: "", + no_proxy: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + const records = stderr.trim() + ? stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + if (item.name === "confirmation") { + expect(exitCode, stderr).toBe(7); + expect(stdout).toBe(""); + expect(requests).toEqual([]); + expect(records.filter((record) => record.error)).toHaveLength(1); + } else { + expect(exitCode, stderr).toBe(0); + expect(JSON.parse(stdout)).toEqual(scenario.want); + expect(records.every((record) => record.debug)).toBe(true); + expect(requests).toHaveLength(1); + } +}); diff --git a/packages/commands/tests/output/speech-recognize-output.test.ts b/packages/commands/tests/output/speech-recognize-output.test.ts new file mode 100644 index 000000000..64f0c928a --- /dev/null +++ b/packages/commands/tests/output/speech-recognize-output.test.ts @@ -0,0 +1,744 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { spawn } from "child_process"; +import { fileURLToPath } from "node:url"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { buildSettings, Client, type LocalizedText } from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import recognize from "../../src/commands/speech/recognize.ts"; + +let directory: string; +let server: Server; +let origin: string; +let requests: Array<{ path: string; body: unknown; async: boolean; sse?: string }>; +let downloads: string[]; +let polls: number; +let pendingFirst: boolean; +let syncResponse: Record; +let taskOutput: Record; +let documents: Record>; +let failure: "none" | "submit" | "download"; +const audioUrl = "https://example.com/audio.wav"; +const flags = { url: [audioUrl], model: "fun-asr" }; +const displays = (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((quiet) => [false, true].map((verbose) => ({ format, quiet, verbose }))), +); + +beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), "bl-recognize-output-")); + requests = []; + downloads = []; + polls = 0; + pendingFirst = false; + failure = "none"; + syncResponse = { + output: { text: "Hello\n" }, + request_id: "sync-request", + usage: { duration: 1 }, + }; + server = createServer(async (request, response) => { + const path = request.url!; + response.setHeader("content-type", "application/json"); + if (path.startsWith("/transcript/")) { + downloads.push(path); + if (failure === "download" || path === "/transcript/failing") + response.writeHead(503).end("{}"); + else response.end(JSON.stringify(documents[path])); + return; + } + if (path.startsWith("/api/v1/tasks/")) { + polls++; + if (pendingFirst && polls === 1) { + response.end(JSON.stringify({ output: { task_id: "task-test", task_status: "RUNNING" } })); + return; + } + response.end( + JSON.stringify({ output: taskOutput, request_id: "poll-request", usage: { duration: 2 } }), + ); + return; + } + let raw = ""; + for await (const chunk of request) raw += String(chunk); + requests.push({ + path, + body: JSON.parse(raw), + async: request.headers["x-dashscope-async"] === "enable", + sse: request.headers["x-dashscope-sse"] as string | undefined, + }); + if (failure === "submit") { + response.writeHead(429).end( + JSON.stringify({ + code: "ServiceLimit", + message: "Original service failure", + request_id: "failed-request", + }), + ); + } else if (path.includes("multimodal-generation")) response.end(JSON.stringify(syncResponse)); + else + response.end( + JSON.stringify({ + output: { task_id: "task-test", task_status: "PENDING" }, + request_id: "submit-request", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected loopback address"); + origin = `http://127.0.0.1:${address.port}`; + taskOutput = { + task_id: "task-test", + task_status: "SUCCEEDED", + results: [ + { + file_url: audioUrl, + subtask_status: "SUCCEEDED", + transcription_url: `${origin}/transcript/one`, + }, + ], + }; + documents = { + "/transcript/one": { + file_url: audioUrl, + transcripts: [{ sentences: [{ text: "Hello", speaker_id: 0 }, { text: "世界" }] }], + properties: { duration: 2 }, + }, + }; +}); +afterEach(async () => { + vi.restoreAllMocks(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(directory, { recursive: true, force: true }); +}); + +async function invoke( + commandFlags: Record, + display: { + format: "text" | "json"; + quiet: boolean; + verbose?: boolean; + dryRun?: boolean; + chinese?: boolean; + }, +) { + let stdout = ""; + let stderr = ""; + let combined = ""; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + combined += String(chunk); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + combined += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + env: {}, + file: { timeout: 2 }, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => + typeof text === "string" ? text : text[display.chinese ? "zh-CN" : "en-US"]; + const reporter = createOutputReporter({ settings, localize }); + const identity = { binName: "bl", version: "test", npmPackage: "test", clientName: "test" }; + const client = new Client({ + settings, + identity, + baseUrl: origin, + apiCred: { token: "test-placeholder", source: "flag", baseUrl: origin }, + debug: (details) => reporter.output.debug(details), + }); + let exitCode = 0; + try { + try { + await recognize.run({ + flags: commandFlags, + settings, + localize, + output: reporter.output, + identity, + client, + } as Parameters[0]); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, combined, exitCode }; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } +} +function checkDiagnostics( + result: Awaited>, + display: (typeof displays)[number], +) { + expect(result.stderr).not.toContain("test-placeholder"); + if (display.format === "json") { + const records = result.stderr.trim() + ? result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + expect(records.every((record) => record.debug)).toBe(true); + expect(records.length > 0).toBe(display.verbose); + } else if (!display.verbose) { + expect(result.stderr.includes("[Model:")).toBe(!display.quiet); + expect(result.stderr.includes("Full result saved to:")).toBe(!display.quiet); + } +} + +test.each(displays)( + "sync response keeps full JSON and existing text: $format q=$quiet v=$verbose", + async (display) => { + const destination = join(directory, "sync.json"); + const result = await invoke({ ...flags, model: "fun-asr-flash", out: destination }, display); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(syncResponse); + else expect(result.stdout).toBe("Hello\n"); + expect(readFileSync(destination, "utf8")).toBe(JSON.stringify(syncResponse, null, 2) + "\n"); + expect(requests).toEqual([ + { + path: "/api/v1/services/aigc/multimodal-generation/generation", + async: false, + sse: "disable", + body: { + model: "fun-asr-flash", + input: { + messages: [ + { role: "user", content: [{ type: "input_audio", input_audio: { data: audioUrl } }] }, + ], + }, + parameters: { format: "wav", sample_rate: "16000" }, + }, + }, + ]); + expect(polls).toBe(0); + checkDiagnostics(result, display); + }, +); + +test.each(displays)( + "async result retains task and transcript metadata: $format q=$quiet v=$verbose", + async (display) => { + const destination = join(directory, "async.json"); + const result = await invoke({ ...flags, out: destination, pollInterval: 0 }, display); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") + expect(JSON.parse(result.stdout)).toEqual({ + output: taskOutput, + request_id: "poll-request", + usage: { duration: 2 }, + transcriptions: [documents["/transcript/one"]], + }); + else expect(result.stdout).toBe("Hello [Speaker 0]\n世界\n"); + expect(readFileSync(destination, "utf8")).toBe( + JSON.stringify(documents["/transcript/one"], null, 2) + "\n", + ); + expect(requests[0]).toEqual({ + path: "/api/v1/services/audio/asr/transcription", + async: true, + sse: undefined, + body: { model: "fun-asr", input: { file_urls: [audioUrl] }, parameters: { channel_id: [0] } }, + }); + expect(polls).toBe(1); + expect(downloads).toEqual(["/transcript/one"]); + checkDiagnostics(result, display); + }, +); + +test.each(displays)( + "batch subtask failures remain visible with success data: $format q=$quiet v=$verbose", + async (display) => { + const secondUrl = "https://example.com/failed.wav"; + taskOutput.results = [ + ...(taskOutput.results as unknown[]), + { + file_url: secondUrl, + subtask_status: "FAILED", + code: "BadAudio", + message: "Service subtask message", + }, + ]; + const destination = join(directory, "mixed.json"); + const result = await invoke( + { ...flags, url: [audioUrl, secondUrl], out: destination, pollInterval: 0 }, + display, + ); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") + expect(JSON.parse(result.stdout)).toEqual({ + output: taskOutput, + request_id: "poll-request", + usage: { duration: 2 }, + transcriptions: [documents["/transcript/one"]], + }); + else + expect(result.stdout).toBe( + `=== [1/2] ${audioUrl} ===\nHello [Speaker 0]\n世界\n\n=== [2/2] ${secondUrl} ===\n[FAILED] ${secondUrl} — Service subtask message\n\n`, + ); + expect(JSON.parse(readFileSync(destination, "utf8"))).toEqual(documents["/transcript/one"]); + expect(downloads).toEqual(["/transcript/one"]); + checkDiagnostics(result, display); + }, +); + +test.each(["text", "json"] as const)( + "multiple successful downloads keep order and out-file array: %s", + async (format) => { + documents["/transcript/two"] = { transcripts: [{ text: "Second" }] }; + taskOutput.results = [ + ...(taskOutput.results as unknown[]), + { + file_url: "https://example.com/two.wav", + subtask_status: "SUCCEEDED", + transcription_url: `${origin}/transcript/two`, + }, + ]; + const destination = join(directory, "batch.json"); + const result = await invoke( + { + ...flags, + url: [audioUrl, "https://example.com/two.wav"], + out: destination, + pollInterval: 0, + }, + { format, quiet: true }, + ); + expect(result.exitCode, result.stderr).toBe(0); + const data = [documents["/transcript/one"], documents["/transcript/two"]]; + expect(JSON.parse(readFileSync(destination, "utf8"))).toEqual(data); + expect(downloads).toEqual(["/transcript/one", "/transcript/two"]); + if (format === "json") expect(JSON.parse(result.stdout).transcriptions).toEqual(data); + else + expect(result.stdout).toBe( + `=== [1/2] ${audioUrl} ===\nHello [Speaker 0]\n世界\n\n=== [2/2] https://example.com/two.wav ===\nSecond\n\n`, + ); + }, +); + +test.each(["text", "json"] as const)( + "qwen3 singular task result is collected: %s", + async (format) => { + taskOutput = { + task_id: "task-test", + task_status: "SUCCEEDED", + result: { transcription_url: `${origin}/transcript/one` }, + }; + const result = await invoke( + { ...flags, model: "qwen3-asr-flash-filetrans", language: "zh", pollInterval: 0 }, + { format, quiet: true }, + ); + expect(result.exitCode).toBe(0); + if (format === "json") + expect(JSON.parse(result.stdout)).toMatchObject({ + output: taskOutput, + transcriptions: [documents["/transcript/one"]], + }); + else expect(result.stdout).toBe("Hello [Speaker 0]\n世界\n"); + expect(requests[0]!.body).toEqual({ + model: "qwen3-asr-flash-filetrans", + input: { file_url: audioUrl }, + parameters: { channel_id: [0], language: "zh" }, + }); + }, +); + +test.each(["text", "json"] as const)( + "empty sync text falls back to full response: %s", + async (format) => { + syncResponse = { output: { custom: true }, request_id: "fallback-request" }; + const result = await invoke({ ...flags, model: "fun-asr-flash" }, { format, quiet: true }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(syncResponse); + if (format === "text") expect(result.stdout).toBe(JSON.stringify(syncResponse) + "\n"); + }, +); + +test.each(["text", "json"] as const)( + "unrecognized transcript fields preserve raw data: %s", + async (format) => { + documents["/transcript/one"] = { custom: "raw result", file_url: audioUrl }; + const result = await invoke({ ...flags, pollInterval: 0 }, { format, quiet: true }); + expect(result.exitCode).toBe(0); + if (format === "json") + expect(JSON.parse(result.stdout).transcriptions).toEqual([documents["/transcript/one"]]); + else expect(result.stdout).toBe(JSON.stringify(documents["/transcript/one"]) + "\n"); + }, +); + +test.each(["text", "json"] as const)( + "all failed subtasks retain report and original exit code: %s", + async (format) => { + taskOutput.results = [ + { + file_url: audioUrl, + subtask_status: "FAILED", + code: "AudioRejected", + message: "Original subtask rejection", + }, + ]; + const destination = join(directory, "all-failed.json"); + const result = await invoke( + { ...flags, out: destination, pollInterval: 0 }, + { format, quiet: true }, + ); + expect(result.exitCode).toBe(0); + if (format === "json") + expect(JSON.parse(result.stdout)).toMatchObject({ output: taskOutput, transcriptions: [] }); + else expect(result.stdout).toBe(`[FAILED] ${audioUrl} — Original subtask rejection\n`); + expect(readFileSync(destination, "utf8")).toBe("[]\n"); + expect(downloads).toEqual([]); + }, +); + +test("missing transcription URL remains silent in text and preserves metadata in JSON", async () => { + taskOutput.results = [{ file_url: audioUrl, subtask_status: "SUCCEEDED" }]; + const text = await invoke({ ...flags, pollInterval: 0 }, { format: "text", quiet: true }); + expect(text.stdout).toBe(""); + const json = await invoke({ ...flags, pollInterval: 0 }, { format: "json", quiet: true }); + expect(JSON.parse(json.stdout)).toMatchObject({ output: taskOutput, transcriptions: [] }); +}); + +test("no transcription items keeps the existing status result and ignores --out", async () => { + taskOutput = { task_id: "task-test", task_status: "SUCCEEDED" }; + const destination = join(directory, "unused.json"); + const result = await invoke( + { ...flags, out: destination, pollInterval: 0 }, + { format: "json", quiet: true }, + ); + expect(JSON.parse(result.stdout)).toEqual({ task_id: "task-test", status: "SUCCEEDED" }); + expect(existsSync(destination)).toBe(false); +}); + +test.each(["text", "json"] as const)( + "submission-only returns task ID without polling or writing: %s", + async (format) => { + const destination = join(directory, "unused.json"); + const result = await invoke( + { ...flags, async: true, out: destination }, + { format, quiet: true }, + ); + expect(result.exitCode).toBe(0); + if (format === "json") expect(JSON.parse(result.stdout)).toEqual({ task_id: "task-test" }); + else expect(result.stdout).toBe("task_id: task-test\n"); + expect(polls).toBe(0); + expect(downloads).toEqual([]); + expect(existsSync(destination)).toBe(false); + }, +); + +test.each(["fun-asr-flash", "qwen3-asr-flash"])( + "dry-run %s keeps request preview without API/files", + async (model) => { + const destination = join(directory, "unused.json"); + const result = await invoke( + { ...flags, model, out: destination }, + { format: "json", quiet: true, dryRun: true }, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + mode: "sync", + path: "/api/v1/services/aigc/multimodal-generation/generation", + request: { model }, + }); + expect(requests).toEqual([]); + expect(existsSync(destination)).toBe(false); + }, +); + +test("async dry-run keeps context/vocabulary and skips submission", async () => { + const result = await invoke( + { + ...flags, + model: "qwen-audio-3.0-asr-flash-filetrans", + context: "Bailian", + vocabulary: '{"Bailian":4}', + language: "en", + diarization: true, + speakerCount: 2, + channelId: 1, + }, + { format: "json", quiet: true, dryRun: true }, + ); + expect(JSON.parse(result.stdout)).toEqual({ + mode: "async", + path: "/api/v1/services/audio/asr/transcription", + request: { + model: "qwen-audio-3.0-asr-flash-filetrans", + input: { + file_urls: [audioUrl], + context: [{ role: "user", content: [{ type: "input_text", text: "Bailian" }] }], + }, + parameters: { + channel_id: [1], + language_hints: ["en"], + diarization_enabled: true, + speaker_count: 2, + vocabulary: { Bailian: 4 }, + }, + }, + }); + expect(requests).toEqual([]); +}); + +test("qwen3 sync keeps request and choices text extraction", async () => { + syncResponse = { + output: { choices: [{ message: { content: [{ text: "First" }, { text: " second" }] } }] }, + request_id: "qwen3-request", + }; + const result = await invoke( + { ...flags, model: "qwen3-asr-flash", language: "en" }, + { format: "text", quiet: true }, + ); + expect(result.stdout).toBe("First second\n"); + expect(requests[0]!.body).toEqual({ + model: "qwen3-asr-flash", + input: { messages: [{ role: "user", content: [{ audio: audioUrl }] }] }, + parameters: { asr_options: { language: "en" } }, + }); +}); + +test.each(["submit", "download", "task", "file"] as const)( + "failure %s emits one JSON error and no final stdout", + async (mode) => { + if (mode === "submit" || mode === "download") failure = mode; + if (mode === "task") + taskOutput = { + task_id: "task-test", + task_status: "FAILED", + message: "Original task failure", + }; + const result = await invoke( + { + ...flags, + pollInterval: 0, + ...(mode === "file" ? { out: join(directory, "missing", "result.json") } : {}), + }, + { format: "json", quiet: true, verbose: true }, + ); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.stdout).toBe(""); + const records = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const errors = records.filter((record) => record.error); + expect(errors).toHaveLength(1); + if (mode === "submit") + expect(errors[0].error).toMatchObject({ + message: "Original service failure", + api_code: "ServiceLimit", + request_id: "failed-request", + }); + if (mode === "task") expect(errors[0].error.message).toContain("Original task failure"); + }, +); + +test("sync failed file write emits no result", async () => { + const result = await invoke( + { ...flags, model: "fun-asr-flash", out: join(directory, "missing", "result.json") }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error).toBeDefined(); +}); + +test.each([ + { model: "fun-asr", speakerCount: 2 }, + { model: "qwen3-asr-flash", vocabulary: '{"Bailian":4}' }, + { model: "qwen3-asr-flash-filetrans", url: [audioUrl, audioUrl] }, + { model: "qwen3-asr-flash-realtime" }, +])("existing validation rejects before API: $model", async (invalid) => { + const result = await invoke({ ...flags, ...invalid }, { format: "json", quiet: true }); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error).toBeDefined(); + expect(requests).toEqual([]); +}); + +test("model and saved process messages support Chinese", async () => { + const result = await invoke( + { ...flags, model: "fun-asr-flash", out: join(directory, "zh.json") }, + { format: "text", quiet: false, chinese: true }, + ); + expect(result.stderr).toContain("[模型:"); + expect(result.stderr).toContain("完整结果已保存至:"); + expect(result.stdout).toBe("Hello\n"); +}); + +test.each(["sync", "async", "batch", "submit", "file-error", "usage"])( + "real CLI output boundaries: %s", + async (mode) => { + writeFileSync(join(directory, "config.json"), JSON.stringify({ language: "en-US" })); + const args = [ + "speech", + "recognize", + "--url", + audioUrl, + "--model", + mode === "sync" ? "fun-asr-flash" : "fun-asr", + "--output", + "json", + "--quiet", + "--verbose", + "--poll-interval", + "0", + ]; + if (mode === "sync") args.splice(args.length - 2, 2); + if (mode === "batch") args.push("--url", "https://example.com/two.wav"); + if (mode === "submit") args.push("--async"); + if (mode === "file-error") args.push("--out", join(directory, "missing", "result.json")); + if (mode === "usage") args.push("--speaker-count", "2"); + const child = spawn( + process.execPath, + [ + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../e2e/harness/main.ts", import.meta.url)), + ...args, + ], + { + env: { + ...process.env, + HOME: directory, + BAILIAN_CONFIG_DIR: directory, + BAILIAN_E2E_ROUTES: JSON.stringify([ + { path: "speech recognize", export: "speechRecognize" }, + ]), + DASHSCOPE_API_KEY: "test-placeholder", + DASHSCOPE_BASE_URL: origin, + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: "0", + DO_NOT_TRACK: "1", + BAILIAN_INSTALL_METHOD: "brew", + NODE_NO_WARNINGS: "1", + NODE_USE_ENV_PROXY: "0", + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: "", + https_proxy: "", + ALL_PROXY: "", + all_proxy: "", + NO_PROXY: "", + no_proxy: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + const records = stderr.trim() + ? stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + if (mode === "file-error" || mode === "usage") { + expect(exitCode, stderr).toBe(mode === "usage" ? 2 : 1); + expect(stdout).toBe(""); + expect(records.filter((record) => record.error)).toHaveLength(1); + } else { + expect(exitCode, stderr).toBe(0); + const data = JSON.parse(stdout); + if (mode === "sync") expect(data).toEqual(syncResponse); + else if (mode === "submit") { + expect(data).toEqual({ task_id: "task-test" }); + expect(polls).toBe(0); + } else + expect(data).toMatchObject({ + output: taskOutput, + transcriptions: [documents["/transcript/one"]], + }); + expect(records.every((record) => record.debug)).toBe(true); + } + }, +); + +test("a later download failure never leaves a partial JSON result", async () => { + taskOutput.results = [ + ...(taskOutput.results as unknown[]), + { + file_url: "https://example.com/bad.wav", + subtask_status: "SUCCEEDED", + transcription_url: `${origin}/transcript/failing`, + }, + ]; + const result = await invoke( + { ...flags, url: [audioUrl, "https://example.com/bad.wav"], pollInterval: 0 }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.message).toContain("HTTP 503"); + expect(downloads).toEqual(["/transcript/one", "/transcript/failing"]); +}); + +test("pending polls retain progress in text and stay hidden in JSON", async () => { + pendingFirst = true; + const result = await invoke({ ...flags, pollInterval: 0 }, { format: "text", quiet: false }); + expect(result.exitCode).toBe(0); + expect(polls).toBe(2); + expect(result.stderr).toContain("Status: RUNNING"); + expect(result.stderr).toContain("Status: SUCCEEDED"); + polls = 0; + const json = await invoke({ ...flags, pollInterval: 0 }, { format: "json", quiet: false }); + expect(json.exitCode).toBe(0); + expect(polls).toBe(2); + expect(json.stderr).toBe(""); + expect(JSON.parse(json.stdout).transcriptions).toHaveLength(1); +}); + +test.each([ + { output: { sentence: { text: "Sentence" } }, expected: "Sentence\n" }, + { output: { output: { sentence: { text: "Nested\n\n" } } }, expected: "Nested\n\n" }, +])( + "sync sentence variants preserve exact newline behavior: $expected", + async ({ output, expected }) => { + syncResponse = { output, request_id: "sentence-request" }; + const result = await invoke( + { ...flags, model: "fun-asr-flash" }, + { format: "text", quiet: true }, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(expected); + }, +); + +test.each(["sync", "async"])("saved notice follows the transcription: %s", async (mode) => { + const result = await invoke( + { + ...flags, + ...(mode === "sync" ? { model: "fun-asr-flash" } : { pollInterval: 0 }), + out: join(directory, "ordered.json"), + }, + { format: "text", quiet: false }, + ); + expect(result.exitCode).toBe(0); + expect(result.combined.indexOf("Hello")).toBeGreaterThanOrEqual(0); + expect(result.combined.indexOf("Hello")).toBeLessThan( + result.combined.indexOf("Full result saved to:"), + ); +}); diff --git a/packages/commands/tests/security-view.test.ts b/packages/commands/tests/security-view.test.ts index 73a2aecf2..c53e0b5ac 100644 --- a/packages/commands/tests/security-view.test.ts +++ b/packages/commands/tests/security-view.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test, vi } from "vite-plus/test"; import type { LocalizedText } from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; import overview from "../src/commands/security/overview.ts"; import alerts from "../src/commands/security/alerts.ts"; // View-layer tests for the Agent security commands. They lock in two fixes: -// - P2-3: `--quiet` wins over `output=json` (bare alert IDs, not a JSON doc). +// - quiet retains the complete result selected by output=json. // - P3: text output is localized en-US / zh-CN instead of hardcoded. // Envelope parsing and exit codes live in core/tests/security-envelope.test.ts. @@ -42,12 +43,21 @@ function makeCtx( language: "en-US" | "zh-CN", settings: Record = {}, ) { + const outputSettings = { + dryRun: false, + output: "text" as "text" | "json", + quiet: false, + verbose: false, + ...settings, + }; return { + output: createOutputReporter({ settings: outputSettings, localize: localizeWith(language) }) + .output, client, localize: localizeWith(language), flags: {}, identity: { binName: "bl" }, - settings: { dryRun: false, output: "text", quiet: false, ...settings }, + settings: outputSettings, } as never; } @@ -113,10 +123,9 @@ describe("security overview text output is localized (P3)", () => { }); describe("security alerts output (P2-3 + P3)", () => { - test("--quiet wins over output=json: bare alert IDs, not a JSON document", async () => { + test("--quiet preserves the full JSON alert payload", async () => { const rendered = await renderAlerts("en-US", { quiet: true, output: "json" }); - expect(rendered).toBe("a1\n"); - expect(rendered).not.toContain("{"); + expect(JSON.parse(rendered)).toEqual(ALERTS_BODY); }); test("text output localizes stats and field labels per language", async () => { From c7e0dcf2947b264548ea2caaddb96bc9981b8a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 29 Oct 2026 11:25:34 +0800 Subject: [PATCH 08/28] refactor(commands): unify monitor and log output - Route all monitor and log results through the invocation output API - Keep activation progress out of JSON and quiet output - Preserve tables, details, pagination, request order and no-wait behavior - Fix colored hint spacing and add output and activation regression coverage --- docs/agents/output-contract.md | 2 +- .../src/commands/log/audit-disable.ts | 5 +- .../commands/src/commands/log/audit-enable.ts | 5 +- .../commands/src/commands/log/audit-get.ts | 41 +- .../commands/src/commands/log/audit-list.ts | 51 +- .../src/commands/log/inference-disable.ts | 5 +- .../src/commands/log/inference-enable.ts | 5 +- .../src/commands/log/inference-get.ts | 47 +- .../src/commands/log/inference-list.ts | 40 +- packages/commands/src/commands/log/shared.ts | 152 ++--- packages/commands/src/commands/log/status.ts | 65 ++- .../commands/src/commands/log/trace-get.ts | 51 +- .../commands/src/commands/log/trace-list.ts | 34 +- .../commands/src/commands/log/trace-stats.ts | 78 ++- .../src/commands/monitor/delivery-disable.ts | 31 +- .../src/commands/monitor/delivery-enable.ts | 116 ++-- .../src/commands/monitor/delivery-status.ts | 35 +- .../commands/src/commands/monitor/errors.ts | 53 +- .../commands/src/commands/monitor/metrics.ts | 76 +-- .../commands/src/commands/monitor/models.ts | 68 +-- .../commands/src/commands/monitor/overview.ts | 49 +- .../tests/output/telemetry-output.test.ts | 544 ++++++++++++++++++ 22 files changed, 1053 insertions(+), 500 deletions(-) create mode 100644 packages/commands/tests/output/telemetry-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index dcf89117a..ea0a34a32 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list、speech synthesize / recognize、memory 全部 7 个命令、quota 全部 5 个命令和 agents security overview / alerts 已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list、speech synthesize / recognize、memory 全部 7 个命令、quota 全部 5 个命令、agents security overview / alerts、monitor 全部 7 个命令和 log 全部 14 个命令已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 diff --git a/packages/commands/src/commands/log/audit-disable.ts b/packages/commands/src/commands/log/audit-disable.ts index 67a8c866b..a07740062 100644 --- a/packages/commands/src/commands/log/audit-disable.ts +++ b/packages/commands/src/commands/log/audit-disable.ts @@ -1,4 +1,4 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { disableLogDelivery } from "./shared.ts"; export default defineCommand({ @@ -17,13 +17,12 @@ export default defineCommand({ ], exampleArgs: ["", "--dry-run", "--output json"], async run(ctx) { - const format = detectOutputFormat(ctx.settings.output); await disableLogDelivery( { client: ctx.client, + output: ctx.output, settings: ctx.settings, binName: ctx.identity.binName, - format, }, "audit", true, diff --git a/packages/commands/src/commands/log/audit-enable.ts b/packages/commands/src/commands/log/audit-enable.ts index 909ec0c86..10fddc60e 100644 --- a/packages/commands/src/commands/log/audit-enable.ts +++ b/packages/commands/src/commands/log/audit-enable.ts @@ -1,4 +1,4 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { LOG_NO_WAIT_FLAG, enableLogDelivery } from "./shared.ts"; import { LOG_SERVICE_TYPES } from "./status.ts"; @@ -25,13 +25,12 @@ export default defineCommand({ ], exampleArgs: ["", "--no-wait", "--output json"], async run(ctx) { - const format = detectOutputFormat(ctx.settings.output); await enableLogDelivery( { client: ctx.client, + output: ctx.output, settings: ctx.settings, binName: ctx.identity.binName, - format, noWait: ctx.flags.noWait, }, "audit", diff --git a/packages/commands/src/commands/log/audit-get.ts b/packages/commands/src/commands/log/audit-get.ts index 85ac1a095..4dd5b8f7e 100644 --- a/packages/commands/src/commands/log/audit-get.ts +++ b/packages/commands/src/commands/log/audit-get.ts @@ -2,10 +2,9 @@ import { defineCommand, BailianError, ExitCode, - detectOutputFormat, effectiveConsoleGatewayConfig, } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { ansi } from "bailian-cli-runtime"; import { TELEMETRY_LOG_TIME_FLAGS, ensureTelemetryRegionSupported, @@ -14,7 +13,7 @@ import { } from "../shared/telemetry.ts"; import { type ModelLogEntry, - printLogDetail, + renderLogDetail, validateHoursFlag, validateRequestIdLength, } from "./shared.ts"; @@ -65,7 +64,7 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; // A single request may sit far back; default window is 24h here. const { startTime, endTime } = resolveTimeRange(flags, 1); @@ -83,14 +82,11 @@ export default defineCommand({ if (flags.model) reqDTO.models = [flags.model]; if (settings.dryRun) { - emitResult( - { - api: LIST_AUDIT_LOGS_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_AUDIT_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -105,16 +101,19 @@ export default defineCommand({ ); } - if (format === "json") { - emitResult(entry, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - printLogDetail(entry); - if (entry.originLog) { - process.stdout.write( - `\n${ansi(process.stdout).bold("Origin record:")}\n${JSON.stringify(entry.originLog, null, 2)}\n`, - ); + textChunks.push(renderLogDetail(entry) + "\n"); + if (entry.originLog) { + textChunks.push( + `\n${ansi(process.stdout).bold("Origin record:")}\n${JSON.stringify(entry.originLog, null, 2)}\n`, + ); + } + + text = textChunks.join("").replace(/\n$/, ""); } + ctx.output.result(entry, { text }); }, }); diff --git a/packages/commands/src/commands/log/audit-list.ts b/packages/commands/src/commands/log/audit-list.ts index 6977ddf75..33f15e263 100644 --- a/packages/commands/src/commands/log/audit-list.ts +++ b/packages/commands/src/commands/log/audit-list.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; import { parseCommaList } from "../shared/params.ts"; import { TELEMETRY_LOG_TIME_FLAGS, @@ -11,7 +10,7 @@ import { } from "../shared/telemetry.ts"; import { type ModelLogEntry, - printLogTable, + renderLogTable, validateHoursFlag, validateStatusCodeTypes, } from "./shared.ts"; @@ -89,7 +88,7 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); const reqDTO: Record = { @@ -106,14 +105,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: LIST_AUDIT_LOGS_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_AUDIT_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -122,21 +118,24 @@ export default defineCommand({ const list = (resp.list as ModelLogEntry[]) ?? []; const nextToken = resp.nextToken as string | undefined; - if (format === "json") { - emitResult( - { - totalCount: resp.totalCount ?? 0, - nextToken, - list, - }, - format, - ); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - printLogTable(list); - if (nextToken) { - process.stdout.write(`Next page: --next-token ${nextToken}\n`); + textChunks.push(renderLogTable(list) + "\n"); + if (nextToken) { + textChunks.push(`Next page: --next-token ${nextToken}\n`); + } + + text = textChunks.join("").replace(/\n$/, ""); } + ctx.output.result( + { + totalCount: resp.totalCount ?? 0, + nextToken, + list, + }, + { text }, + ); }, }); diff --git a/packages/commands/src/commands/log/inference-disable.ts b/packages/commands/src/commands/log/inference-disable.ts index d4ee4608a..0dcdc103b 100644 --- a/packages/commands/src/commands/log/inference-disable.ts +++ b/packages/commands/src/commands/log/inference-disable.ts @@ -1,4 +1,4 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { disableLogDelivery } from "./shared.ts"; export default defineCommand({ @@ -17,13 +17,12 @@ export default defineCommand({ ], exampleArgs: ["", "--dry-run", "--output json"], async run(ctx) { - const format = detectOutputFormat(ctx.settings.output); await disableLogDelivery( { client: ctx.client, + output: ctx.output, settings: ctx.settings, binName: ctx.identity.binName, - format, }, "inference", ); diff --git a/packages/commands/src/commands/log/inference-enable.ts b/packages/commands/src/commands/log/inference-enable.ts index 895ea5846..57fd3a3d4 100644 --- a/packages/commands/src/commands/log/inference-enable.ts +++ b/packages/commands/src/commands/log/inference-enable.ts @@ -1,4 +1,4 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { LOG_NO_WAIT_FLAG, enableLogDelivery, requireAuditLogEnabled } from "./shared.ts"; import { LOG_SERVICE_TYPES } from "./status.ts"; @@ -25,12 +25,11 @@ export default defineCommand({ ], exampleArgs: ["", "--no-wait", "--output json"], async run(ctx) { - const format = detectOutputFormat(ctx.settings.output); const enableContext = { client: ctx.client, + output: ctx.output, settings: ctx.settings, binName: ctx.identity.binName, - format, noWait: ctx.flags.noWait, }; diff --git a/packages/commands/src/commands/log/inference-get.ts b/packages/commands/src/commands/log/inference-get.ts index 1ad879abf..2ab770942 100644 --- a/packages/commands/src/commands/log/inference-get.ts +++ b/packages/commands/src/commands/log/inference-get.ts @@ -2,10 +2,9 @@ import { defineCommand, BailianError, ExitCode, - detectOutputFormat, effectiveConsoleGatewayConfig, } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { ansi } from "bailian-cli-runtime"; import { TELEMETRY_LOG_TIME_FLAGS, ensureTelemetryRegionSupported, @@ -14,7 +13,7 @@ import { } from "../shared/telemetry.ts"; import { type ModelLogEntry, - printLogDetail, + renderLogDetail, validateHoursFlag, validateRequestIdLength, } from "./shared.ts"; @@ -66,7 +65,7 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; // A single request may sit far back; default window is 24h here. const { startTime, endTime } = resolveTimeRange(flags, 1); @@ -78,16 +77,13 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - apis: [LIST_INFERENCE_LOGS_API, ORIGIN_LOG_API], - data: { - reqDTO: { ...baseReqDTO, startTime: String(startTime), endTime: String(endTime) }, - }, - ...effectiveConsoleGatewayConfig(settings), + ctx.output.result({ + apis: [LIST_INFERENCE_LOGS_API, ORIGIN_LOG_API], + data: { + reqDTO: { ...baseReqDTO, startTime: String(startTime), endTime: String(endTime) }, }, - format, - ); + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -121,18 +117,21 @@ export default defineCommand({ entry.request = entry.request ?? origin?.request; entry.response = entry.response ?? origin?.response; - if (format === "json") { - emitResult(entry, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - printLogDetail(entry); - if (!entry.request && !entry.response) { - process.stdout.write( - ansi(process.stdout).dim( - "\nRequest/response content is only available when inference log delivery was enabled at call time.\n", - ), - ); + textChunks.push(renderLogDetail(entry) + "\n"); + if (!entry.request && !entry.response) { + textChunks.push( + ansi(process.stdout).dim( + "\nRequest/response content is only available when inference log delivery was enabled at call time.", + ), + ); + } + + text = textChunks.join("").replace(/\n$/, ""); } + ctx.output.result(entry, { text }); }, }); diff --git a/packages/commands/src/commands/log/inference-list.ts b/packages/commands/src/commands/log/inference-list.ts index 0c5b49011..58be5e10e 100644 --- a/packages/commands/src/commands/log/inference-list.ts +++ b/packages/commands/src/commands/log/inference-list.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; import { parseCommaList } from "../shared/params.ts"; import { TELEMETRY_LOG_TIME_FLAGS, @@ -9,7 +8,7 @@ import { } from "../shared/telemetry.ts"; import { type ModelLogEntry, - printLogTable, + renderLogTable, validateHoursFlag, validateStatusCodeTypes, } from "./shared.ts"; @@ -106,7 +105,7 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); // The inference log API takes single model/apikey values and string timestamps. @@ -127,14 +126,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: LIST_INFERENCE_LOGS_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_INFERENCE_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -142,17 +138,13 @@ export default defineCommand({ const resp = await pollTelemetryData(ctx.client, LIST_INFERENCE_LOGS_API, reqDTO); const list = (resp.list as ModelLogEntry[]) ?? []; - if (format === "json") { - emitResult( - { - totalCount: resp.totalCount ?? 0, - list, - }, - format, - ); - return; - } - - printLogTable(list); + const text = format === "text" ? renderLogTable(list) : undefined; + ctx.output.result( + { + totalCount: resp.totalCount ?? 0, + list, + }, + { text }, + ); }, }); diff --git a/packages/commands/src/commands/log/shared.ts b/packages/commands/src/commands/log/shared.ts index 1f63feaf0..96d005e7b 100644 --- a/packages/commands/src/commands/log/shared.ts +++ b/packages/commands/src/commands/log/shared.ts @@ -1,12 +1,12 @@ import { BailianError, ExitCode, - detectOutputFormat, effectiveConsoleGatewayConfig, type Client, + type CommandOutput, type FlagsDef, } from "bailian-cli-core"; -import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { ansi, renderBoxTable } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { parseCommaList } from "../shared/params.ts"; import { @@ -89,12 +89,11 @@ export function validateRequestIdLength(requestId: string): string | undefined { return undefined; } -export function printLogTable(list: ModelLogEntry[]): void { +export function renderLogTable(list: ModelLogEntry[]): string { const color = ansi(process.stdout); if (list.length === 0) { - process.stdout.write("No logs found in this range.\n"); - return; + return "No logs found in this range."; } const lines = renderBoxTable({ @@ -124,10 +123,12 @@ export function printLogTable(list: ModelLogEntry[]): void { return color.yellow(value); }, }); - for (const line of lines) process.stdout.write(line + "\n"); + return lines.join("\n"); } -export function printLogDetail(entry: ModelLogEntry): void { +export function renderLogDetail(entry: ModelLogEntry): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); const usage = parseLogUsage(entry.usage); @@ -148,14 +149,16 @@ export function printLogDetail(entry: ModelLogEntry): void { if (entry.errorMessage) rows.push(["Error Message", entry.errorMessage]); for (const [label, value] of rows) { - process.stdout.write(`${color.bold(label)}: ${value}\n`); + textChunks.push(`${color.bold(label)}: ${value}\n`); } if (entry.request) { - process.stdout.write(`\n${color.bold("Request:")}\n${entry.request}\n`); + textChunks.push(`\n${color.bold("Request:")}\n${entry.request}\n`); } if (entry.response) { - process.stdout.write(`\n${color.bold("Response:")}\n${entry.response}\n`); + textChunks.push(`\n${color.bold("Response:")}\n${entry.response}\n`); } + + return textChunks.join("").replace(/\n$/, ""); } // --------------------------------------------------------------------------- @@ -220,10 +223,10 @@ function groupReqDTO(settings: Settings, kind: LogKind): Record // --------------------------------------------------------------------------- export interface LogEnableContext { + output: CommandOutput; client: Client; settings: Settings; binName: string; - format: "json" | "text"; noWait?: boolean; } @@ -237,18 +240,15 @@ export async function enableLogDelivery( kind: LogKind, serviceType: string, ): Promise { - const { client, settings, format } = ctx; - const color = ansi(process.stderr); + const { client, settings } = ctx; + const format = settings.output; if (settings.dryRun) { - emitResult( - { - apis: [CREATE_SLR_API, INIT_STORE_API, ENABLE_GROUP_API], - data: { reqDTO: groupReqDTO(settings, kind) }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + apis: [CREATE_SLR_API, INIT_STORE_API, ENABLE_GROUP_API], + data: { reqDTO: groupReqDTO(settings, kind) }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -257,20 +257,27 @@ export async function enableLogDelivery( // Step 1: SLS service-linked role. await ensureTelemetrySlrAuthorized(client, "Log", settings.workspaceId, { pollRetries: 5, - onProgress: (message) => process.stderr.write(color.dim(message + "\n")), + onProgress: (message) => + ctx.output.progress({ "en-US": message, "zh-CN": "正在授权服务关联角色……" }), }); // Step 2: SLS store instance (async creation, poll until Ready). let serviceStatus = await getTelemetryServiceStatus(client, serviceType, settings.workspaceId); if (serviceStatus.instanceStatus === "NotExist") { - process.stderr.write(color.dim("Initializing the SLS store instance...\n")); + ctx.output.info({ + "en-US": "Initializing the SLS store instance...", + "zh-CN": "正在初始化 SLS 存储实例……", + }); await pollTelemetryData(client, INIT_STORE_API, { ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), serviceType, }); if (!ctx.noWait) { - process.stderr.write(color.dim("Waiting for the SLS instance to become ready...\n")); + ctx.output.info({ + "en-US": "Waiting for the SLS instance to become ready...", + "zh-CN": "正在等待 SLS 实例就绪……", + }); const deadline = Date.now() + WAIT_TIMEOUT_MS; while (Date.now() < deadline && serviceStatus.instanceStatus !== "Ready") { await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS)); @@ -282,17 +289,23 @@ export async function enableLogDelivery( // Step 3: flip the delivery switch for every model in the workspace. await client.console(ENABLE_GROUP_API, { reqDTO: groupReqDTO(settings, kind) }); - if (format === "json") { - emitResult({ enabled: true, kind, instanceStatus: serviceStatus.instanceStatus }, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - process.stdout.write(`${kind === "audit" ? "Audit" : "Inference"} log delivery enabled.\n`); - if (ctx.noWait) { - process.stdout.write( - `The SLS instance may still be initializing; check \`${ctx.binName} log status\` later.\n`, - ); + 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`, + ); + } + + text = textChunks.join("").replace(/\n$/, ""); } + ctx.output.result( + { enabled: true, kind, instanceStatus: serviceStatus.instanceStatus }, + { text }, + ); } /** @@ -304,17 +317,15 @@ export async function disableLogDelivery( kind: LogKind, guardInferenceOn?: boolean, ): Promise { - const { client, settings, format } = ctx; + const { client, settings } = ctx; + const format = settings.output; if (settings.dryRun) { - emitResult( - { - api: DISABLE_GROUP_API, - data: { reqDTO: groupReqDTO(settings, kind) }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: DISABLE_GROUP_API, + data: { reqDTO: groupReqDTO(settings, kind) }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -333,12 +344,15 @@ export async function disableLogDelivery( await client.console(DISABLE_GROUP_API, { reqDTO: groupReqDTO(settings, kind) }); - if (format === "json") { - emitResult({ disabled: true, kind }, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - process.stdout.write(`${kind === "audit" ? "Audit" : "Inference"} log delivery disabled.\n`); + textChunks.push(`${kind === "audit" ? "Audit" : "Inference"} log delivery disabled.\n`); + + text = textChunks.join("").replace(/\n$/, ""); + } + ctx.output.result({ disabled: true, kind }, { text }); } /** Shared --no-wait flag used by both enable commands. */ @@ -373,13 +387,13 @@ export interface LogCountFlags { /** Count logs in a range for one log kind (the API takes a telemetryType). */ export async function countLogs( - ctx: { client: Client; settings: Settings }, + ctx: { client: Client; settings: Settings; output: CommandOutput }, kind: LogKind, flags: LogCountFlags, resolveRange: () => { startTime: number; endTime: number }, ): Promise { const { settings } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveRange(); const reqDTO: Record = { @@ -394,14 +408,11 @@ export async function countLogs( }; if (settings.dryRun) { - emitResult( - { - api: COUNT_LOGS_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: COUNT_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -410,19 +421,22 @@ export async function countLogs( const raw = await ctx.client.console(COUNT_LOGS_API, { reqDTO }); const count = unwrapConsolePrimitive(raw); - if (format === "json") { - emitResult( - { - kind, - period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, - count, - }, - format, + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; + + textChunks.push( + `${kind === "audit" ? "Audit" : "Inference"} logs: ${formatNumber(count)} (${formatDateTime(startTime)} ~ ${formatDateTime(endTime)})\n`, ); - return; - } - process.stdout.write( - `${kind === "audit" ? "Audit" : "Inference"} logs: ${formatNumber(count)} (${formatDateTime(startTime)} ~ ${formatDateTime(endTime)})\n`, + text = textChunks.join("").replace(/\n$/, ""); + } + ctx.output.result( + { + kind, + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + count, + }, + { text }, ); } diff --git a/packages/commands/src/commands/log/status.ts b/packages/commands/src/commands/log/status.ts index e67a42350..5eea0a39e 100644 --- a/packages/commands/src/commands/log/status.ts +++ b/packages/commands/src/commands/log/status.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; -import { ansi, emitResult, displayWidth, padEnd } from "bailian-cli-runtime"; +import { defineCommand } from "bailian-cli-core"; +import { ansi, displayWidth, padEnd } from "bailian-cli-runtime"; import { ensureTelemetryRegionSupported, getTelemetryServiceStatus, @@ -71,42 +71,45 @@ export default defineCommand({ ], async run(ctx) { const { settings, identity } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; ensureTelemetryRegionSupported(settings); const status = await fetchLogServiceStatus(ctx.client, settings.workspaceId); - if (format === "json") { - emitResult(status, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - const color = ansi(process.stdout); - const rows: [string, string][] = [ - ["SLS Authorization (SLR)", status.slrAuthorized ? "Authorized" : "Not authorized"], - ["Audit Log (service)", statusText(status.audit)], - ["Audit Log (switch)", switchText(status.auditSwitch)], - ["Inference Log (service)", statusText(status.inference)], - ["Inference Log (switch)", switchText(status.inferenceSwitch)], - ]; - const instanceUrl = status.inference.instanceInfo?.instanceUrl; - if (instanceUrl) rows.push(["SLS Instance URL", instanceUrl]); + const color = ansi(process.stdout); + const rows: [string, string][] = [ + ["SLS Authorization (SLR)", status.slrAuthorized ? "Authorized" : "Not authorized"], + ["Audit Log (service)", statusText(status.audit)], + ["Audit Log (switch)", switchText(status.auditSwitch)], + ["Inference Log (service)", statusText(status.inference)], + ["Inference Log (switch)", switchText(status.inferenceSwitch)], + ]; + const instanceUrl = status.inference.instanceInfo?.instanceUrl; + if (instanceUrl) rows.push(["SLS Instance URL", instanceUrl]); - const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); - for (const [label, value] of rows) { - process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); - } + const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); + for (const [label, value] of rows) { + textChunks.push(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); + } + + if (status.auditSwitch !== true) { + textChunks.push( + color.dim(`\nRun \`${identity.binName} 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.`, + ), + ); + } - if (status.auditSwitch !== true) { - process.stdout.write( - color.dim(`\nRun \`${identity.binName} log audit enable\` to enable audit log delivery.\n`), - ); - } else if (status.inferenceSwitch !== true) { - process.stdout.write( - color.dim( - `\nRun \`${identity.binName} log inference enable\` to enable inference log delivery.\n`, - ), - ); + text = textChunks.join("").replace(/\n$/, ""); } + ctx.output.result(status, { text }); }, }); diff --git a/packages/commands/src/commands/log/trace-get.ts b/packages/commands/src/commands/log/trace-get.ts index cd4252e39..08b6fd7e5 100644 --- a/packages/commands/src/commands/log/trace-get.ts +++ b/packages/commands/src/commands/log/trace-get.ts @@ -2,10 +2,9 @@ import { defineCommand, BailianError, ExitCode, - detectOutputFormat, effectiveConsoleGatewayConfig, } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { ansi } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { TELEMETRY_LOG_TIME_FLAGS, @@ -18,7 +17,9 @@ import { TRACE_STATUS_LABELS, type TraceEntry } from "./trace-list.ts"; const GET_TRACE_API = "zeldaEasy.bailian-telemetry.trace.getTraceWithOss"; -function printSpan(span: TraceEntry, depth: number): void { +function renderSpan(span: TraceEntry, depth: number): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); const indent = " ".repeat(depth); const status = TRACE_STATUS_LABELS[span.statusCode ?? "0"] ?? "-"; @@ -27,15 +28,17 @@ function printSpan(span: TraceEntry, depth: number): void { const tokens = span.totalTokens != null ? `, ${formatNumber(span.totalTokens)} tokens` : ""; const statusText = status === "Error" ? color.red(status) : status; - process.stdout.write( + textChunks.push( `${indent}${span.spanName ?? "-"} ${color.dim(`[${statusText}, ${duration}${tokens}]`)}\n`, ); if (span.statusCode === "2" && span.statusMessage) { - process.stdout.write(`${indent} ${color.red(span.statusMessage)}\n`); + textChunks.push(`${indent} ${color.red(span.statusMessage)}\n`); } for (const child of span.children ?? []) { - printSpan(child, depth + 1); + textChunks.push(renderSpan(child, depth + 1) + "\n"); } + + return textChunks.join("").replace(/\n$/, ""); } export default defineCommand({ @@ -72,7 +75,7 @@ export default defineCommand({ ], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags, 1); const reqDTO = { @@ -84,14 +87,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: GET_TRACE_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: GET_TRACE_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -106,15 +106,18 @@ export default defineCommand({ ); } - if (format === "json") { - emitResult(trace, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; + + const color = ansi(process.stdout); + textChunks.push( + `${color.bold("Trace")} ${trace.traceId} ${color.dim(formatDateTime(trace.startTime))}\n\n`, + ); + textChunks.push(renderSpan(trace, 0) + "\n"); - const color = ansi(process.stdout); - process.stdout.write( - `${color.bold("Trace")} ${trace.traceId} ${color.dim(formatDateTime(trace.startTime))}\n\n`, - ); - printSpan(trace, 0); + text = textChunks.join("").replace(/\n$/, ""); + } + ctx.output.result(trace, { text }); }, }); diff --git a/packages/commands/src/commands/log/trace-list.ts b/packages/commands/src/commands/log/trace-list.ts index 900ea21ac..f8ce93215 100644 --- a/packages/commands/src/commands/log/trace-list.ts +++ b/packages/commands/src/commands/log/trace-list.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, renderBoxTable } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { TELEMETRY_LOG_TIME_FLAGS, @@ -40,12 +40,11 @@ interface TraceListPayload { list?: TraceEntry[]; } -function printTraceTable(list: TraceEntry[]): void { +function renderTraceTable(list: TraceEntry[]): string { const color = ansi(process.stdout); if (list.length === 0) { - process.stdout.write("No traces found in this range.\n"); - return; + return "No traces found in this range."; } const lines = renderBoxTable({ @@ -68,7 +67,7 @@ function printTraceTable(list: TraceEntry[]): void { return undefined; }, }); - for (const line of lines) process.stdout.write(line + "\n"); + return lines.join("\n"); } export default defineCommand({ @@ -122,7 +121,7 @@ export default defineCommand({ ], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); const reqDTO = { @@ -137,14 +136,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: LIST_TRACES_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_TRACES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -155,11 +151,7 @@ export default defineCommand({ const list = Array.isArray(payload) ? payload : (payload?.list ?? []); const totalCount = Array.isArray(payload) ? undefined : payload?.totalCount; - if (format === "json") { - emitResult({ totalCount, list }, format); - return; - } - - printTraceTable(list); + const text = format === "text" ? renderTraceTable(list) : undefined; + ctx.output.result({ totalCount, list }, { text }); }, }); diff --git a/packages/commands/src/commands/log/trace-stats.ts b/packages/commands/src/commands/log/trace-stats.ts index 93910e47e..a1021c147 100644 --- a/packages/commands/src/commands/log/trace-stats.ts +++ b/packages/commands/src/commands/log/trace-stats.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { renderBoxTable } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { parseCommaList } from "../shared/params.ts"; import { @@ -23,6 +23,27 @@ interface TraceStatistic { avgUserFirstTokenDuration?: string; } +function renderStatistics(items: TraceStatistic[]): string { + if (items.length === 0) { + return "No trace statistics found in this range."; + } + + const lines = renderBoxTable({ + headers: ["Resource", "Calls", "Total Tokens", "Avg Duration", "Avg First Token"], + rows: items.map((item) => [ + item.resourceId ?? "-", + formatNumber(Number(item.callCount ?? 0)), + formatNumber(Number(item.totalTokens ?? 0)), + item.avgDuration ? `${formatNumber(Math.round(Number(item.avgDuration)))} ms` : "-", + item.avgLlmFirstTokenDuration + ? `${formatNumber(Math.round(Number(item.avgLlmFirstTokenDuration)))} ms` + : "-", + ]), + align: ["left", "right", "right", "right", "right"], + }); + return lines.join("\n"); +} + export default defineCommand({ description: { "en-US": "Show per-resource trace statistics (calls, tokens, latency)", @@ -53,7 +74,7 @@ export default defineCommand({ exampleArgs: ["", "--resource-id qwen3.6-plus --hours 24", "--resource-type app --output json"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); const reqDTO = { @@ -67,14 +88,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: TRACE_STATISTIC_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: TRACE_STATISTIC_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -83,35 +101,13 @@ export default defineCommand({ // Keyed by resource id. const items = Object.values(resp) as TraceStatistic[]; - if (format === "json") { - emitResult( - { - period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, - list: items, - }, - format, - ); - return; - } - - if (items.length === 0) { - process.stdout.write("No trace statistics found in this range.\n"); - return; - } - - const lines = renderBoxTable({ - headers: ["Resource", "Calls", "Total Tokens", "Avg Duration", "Avg First Token"], - rows: items.map((item) => [ - item.resourceId ?? "-", - formatNumber(Number(item.callCount ?? 0)), - formatNumber(Number(item.totalTokens ?? 0)), - item.avgDuration ? `${formatNumber(Math.round(Number(item.avgDuration)))} ms` : "-", - item.avgLlmFirstTokenDuration - ? `${formatNumber(Math.round(Number(item.avgLlmFirstTokenDuration)))} ms` - : "-", - ]), - align: ["left", "right", "right", "right", "right"], - }); - for (const line of lines) process.stdout.write(line + "\n"); + const text = format === "text" ? renderStatistics(items) : undefined; + ctx.output.result( + { + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + list: items, + }, + { text }, + ); }, }); diff --git a/packages/commands/src/commands/monitor/delivery-disable.ts b/packages/commands/src/commands/monitor/delivery-disable.ts index deb3c26f0..6364452f9 100644 --- a/packages/commands/src/commands/monitor/delivery-disable.ts +++ b/packages/commands/src/commands/monitor/delivery-disable.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; import { buildGroupSwitchReqDTO, DISABLE_GROUP_API, @@ -27,17 +26,14 @@ export default defineCommand({ exampleArgs: ["", "--dry-run", "--output json"], async run(ctx) { const { settings } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; if (settings.dryRun) { - emitResult( - { - api: DISABLE_GROUP_API, - data: { reqDTO: buildGroupSwitchReqDTO(settings, MONITOR_GROUP_TYPE) }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: DISABLE_GROUP_API, + data: { reqDTO: buildGroupSwitchReqDTO(settings, MONITOR_GROUP_TYPE) }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -47,11 +43,14 @@ export default defineCommand({ reqDTO: buildGroupSwitchReqDTO(settings, MONITOR_GROUP_TYPE), }); - if (format === "json") { - emitResult({ disabled: true }, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - process.stdout.write("Monitor delivery disabled.\n"); + textChunks.push("Monitor delivery disabled.\n"); + + text = textChunks.join("").replace(/\n$/, ""); + } + ctx.output.result({ disabled: true }, { text }); }, }); diff --git a/packages/commands/src/commands/monitor/delivery-enable.ts b/packages/commands/src/commands/monitor/delivery-enable.ts index bc7e64ad7..f21c8f685 100644 --- a/packages/commands/src/commands/monitor/delivery-enable.ts +++ b/packages/commands/src/commands/monitor/delivery-enable.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; import { buildGroupSwitchReqDTO, ENABLE_GROUP_API, @@ -66,32 +65,28 @@ export default defineCommand({ exampleArgs: ["", "--no-wait", "--output json"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); - const color = ansi(process.stderr); + const format = settings.output; if (settings.dryRun) { - emitResult( - { - apis: [ - SLR_STATUS_API, - CREATE_SLR_API, - TELEMETRY_SERVICE_STATUS_API, - INIT_CMS_API, - INIT_STORE_API, - ENABLE_GROUP_API, - ], - data: { - reqDTO: { - ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), - slrType: "Cms", - serviceType: SERVICE_TYPE, - telemetryType: MONITOR_GROUP_TYPE, - }, + ctx.output.result({ + apis: [ + SLR_STATUS_API, + CREATE_SLR_API, + TELEMETRY_SERVICE_STATUS_API, + INIT_CMS_API, + INIT_STORE_API, + ENABLE_GROUP_API, + ], + data: { + reqDTO: { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + slrType: "Cms", + serviceType: SERVICE_TYPE, + telemetryType: MONITOR_GROUP_TYPE, }, - ...effectiveConsoleGatewayConfig(settings), }, - format, - ); + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -103,22 +98,27 @@ export default defineCommand({ getTelemetryGroupSwitch(ctx.client, MONITOR_GROUP_TYPE, settings.workspaceId), ]); if (status.openStatus && status.instanceStatus === "Ready" && monitorSwitch === true) { - if (format === "json") { - emitResult({ activated: true, alreadyActive: true, ...status }, format); - } else { - process.stdout.write("Monitoring delivery is already active.\n"); - } + ctx.output.result( + { activated: true, alreadyActive: true, ...status }, + { + text: ctx.localize({ + "en-US": "Monitoring delivery is already active.", + "zh-CN": "监控数据投递已开启。", + }), + }, + ); return; } // Step 1: CMS service-linked role. await ensureTelemetrySlrAuthorized(ctx.client, "Cms", settings.workspaceId, { - onProgress: (message) => process.stderr.write(color.dim(message + "\n")), + onProgress: (message) => + ctx.output.progress({ "en-US": message, "zh-CN": "正在授权服务关联角色……" }), }); // Step 2: open the CMS service (only submits when still closed; poll openStatus). if (!status.openStatus) { - process.stderr.write(color.dim("Opening the CMS service...\n")); + ctx.output.info({ "en-US": "Opening the CMS service...", "zh-CN": "正在开通 CMS 服务……" }); await ctx.client.console(INIT_CMS_API, { reqDTO: { ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), @@ -136,7 +136,10 @@ export default defineCommand({ // Step 3: dedicated Prometheus instance (async creation, poll until Ready). if (status.instanceStatus === "NotExist") { - process.stderr.write(color.dim("Creating the dedicated Prometheus instance...\n")); + ctx.output.info({ + "en-US": "Creating the dedicated Prometheus instance...", + "zh-CN": "正在创建专属 Prometheus 实例……", + }); await pollTelemetryData(ctx.client, INIT_STORE_API, { ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), serviceType: SERVICE_TYPE, @@ -145,17 +148,19 @@ export default defineCommand({ if (flags.noWait) { const result = { activated: false, submitted: true }; - if (format === "json") { - emitResult(result, format); - } else { - process.stdout.write( - `Activation submitted. Run \`${ctx.identity.binName} monitor delivery status\` to check progress.\n`, - ); - } + 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\` 查看进度。`, + }), + }); return; } - process.stderr.write(color.dim("Waiting for the Prometheus instance to become ready...\n")); + ctx.output.info({ + "en-US": "Waiting for the Prometheus instance to become ready...", + "zh-CN": "正在等待 Prometheus 实例就绪……", + }); const after = await waitFor( ctx.client, settings.workspaceId, @@ -164,27 +169,28 @@ export default defineCommand({ // Step 4: flip the monitor delivery switch for every model in the workspace. if (after.openStatus && after.instanceStatus === "Ready" && monitorSwitch !== true) { - process.stderr.write(color.dim("Turning on the monitor delivery switch...\n")); + ctx.output.info({ + "en-US": "Turning on the monitor delivery switch...", + "zh-CN": "正在开启监控数据投递开关……", + }); await ctx.client.console(ENABLE_GROUP_API, { reqDTO: buildGroupSwitchReqDTO(settings, MONITOR_GROUP_TYPE), }); } const ready = after.openStatus && after.instanceStatus === "Ready"; - if (format === "json") { - emitResult({ activated: ready, ...after }, format); - return; - } - if (ready) { - process.stdout.write("Monitoring delivery is active.\n"); - if (after.instanceInfo) { - process.stdout.write(`Instance: ${after.instanceInfo.instanceId}\n`); - process.stdout.write(`URL: ${after.instanceInfo.instanceUrl}\n`); - } - } else { - process.stdout.write( - `Activation is still in progress. Run \`${ctx.identity.binName} monitor delivery status\` to check later.\n`, - ); + let text: string | undefined; + if (format === "text") { + text = ready + ? ctx.localize({ + "en-US": `Monitoring delivery is active.${after.instanceInfo ? `\nInstance: ${after.instanceInfo.instanceId}\nURL: ${after.instanceInfo.instanceUrl}` : ""}`, + "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\` 查看状态。`, + }); } + 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 d3a1c66bf..e5c32b100 100644 --- a/packages/commands/src/commands/monitor/delivery-status.ts +++ b/packages/commands/src/commands/monitor/delivery-status.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; -import { ansi, emitResult, displayWidth, padEnd } from "bailian-cli-runtime"; +import { defineCommand } from "bailian-cli-core"; +import { ansi, displayWidth, padEnd } from "bailian-cli-runtime"; import { formatDateTime } from "../shared/format.ts"; import { ensureTelemetryRegionSupported, @@ -13,12 +13,14 @@ const SERVICE_TYPE = "ModelMonitor"; /** Delivery switch telemetryType; distinct from the activate serviceType above. */ const MONITOR_GROUP_TYPE = "Monitor"; -function printStatus( +function renderStatus( cmsSlrAuthorized: boolean, status: TelemetryServiceStatus, deliverySwitch: boolean | null, binName: string, -): void { +): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); const rows: [string, string][] = [ @@ -42,14 +44,16 @@ function printStatus( const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); for (const [label, value] of rows) { - process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); + textChunks.push(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); } if (!status.openStatus || deliverySwitch !== true) { - process.stdout.write( - color.dim(`\nRun \`${binName} monitor delivery enable\` to activate monitoring delivery.\n`), + textChunks.push( + color.dim(`\nRun \`${binName} monitor delivery enable\` to activate monitoring delivery.`), ); } + + return textChunks.join("").replace(/\n$/, ""); } export default defineCommand({ @@ -62,7 +66,7 @@ export default defineCommand({ exampleArgs: ["", "--output json"], async run(ctx) { const { settings, identity } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; ensureTelemetryRegionSupported(settings); // All three lookups are independent reads; fan them out in parallel. @@ -72,11 +76,16 @@ export default defineCommand({ getTelemetryGroupSwitch(ctx.client, MONITOR_GROUP_TYPE, settings.workspaceId), ]); - if (format === "json") { - emitResult({ cmsSlrAuthorized, deliverySwitch, ...status }, format); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - printStatus(cmsSlrAuthorized, status, deliverySwitch, identity.binName); + textChunks.push( + renderStatus(cmsSlrAuthorized, status, deliverySwitch, identity.binName) + "\n", + ); + + text = textChunks.join("").replace(/\n$/, ""); + } + ctx.output.result({ cmsSlrAuthorized, deliverySwitch, ...status }, { text }); }, }); diff --git a/packages/commands/src/commands/monitor/errors.ts b/packages/commands/src/commands/monitor/errors.ts index 38da2892b..9032b2aef 100644 --- a/packages/commands/src/commands/monitor/errors.ts +++ b/packages/commands/src/commands/monitor/errors.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, renderBoxTable } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { TELEMETRY_TIME_FLAGS, @@ -27,16 +27,18 @@ function toRows(resp: Record): ErrorCodeRow[] { return []; } -function printTable(rows: ErrorCodeRow[], startTime: number, endTime: number): void { +function renderTable(rows: ErrorCodeRow[], startTime: number, endTime: number): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); - process.stdout.write( + textChunks.push( `${color.dim("Time Range:")} ${formatDateTime(startTime)} ~ ${formatDateTime(endTime)}\n\n`, ); if (rows.length === 0) { - process.stdout.write("No failures found in this range.\n"); - return; + textChunks.push("No failures found in this range.\n"); + return textChunks.join("").replace(/\n$/, ""); } const lines = renderBoxTable({ @@ -49,7 +51,9 @@ function printTable(rows: ErrorCodeRow[], startTime: number, endTime: number): v ]), align: ["left", "left", "right", "right"], }); - for (const line of lines) process.stdout.write(line + "\n"); + for (const line of lines) textChunks.push(line + "\n"); + + return textChunks.join("").replace(/\n$/, ""); } export default defineCommand({ @@ -77,7 +81,7 @@ export default defineCommand({ ], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags); const reqDTO = { @@ -87,14 +91,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: ERROR_CODE_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: ERROR_CODE_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -102,17 +103,13 @@ export default defineCommand({ const resp = await pollTelemetryData(ctx.client, ERROR_CODE_API, reqDTO); const rows = toRows(resp); - if (format === "json") { - emitResult( - { - period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, - list: rows, - }, - format, - ); - return; - } - - printTable(rows, startTime, endTime); + const text = format === "text" ? renderTable(rows, startTime, endTime) : undefined; + ctx.output.result( + { + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + list: rows, + }, + { text }, + ); }, }); diff --git a/packages/commands/src/commands/monitor/metrics.ts b/packages/commands/src/commands/monitor/metrics.ts index 6edbcb570..c9fb866ba 100644 --- a/packages/commands/src/commands/monitor/metrics.ts +++ b/packages/commands/src/commands/monitor/metrics.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { parseCommaList } from "../shared/params.ts"; import { @@ -80,28 +80,32 @@ function seriesTitle(series: MonitorSeries): string { return labels ? `${base} {${labels}}` : base; } -function printSeries(allSeries: MonitorSeries[]): void { +function renderSeries(allSeries: MonitorSeries[]): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); if (allSeries.length === 0) { - process.stdout.write("No monitor data found.\n"); - return; + textChunks.push("No monitor data found.\n"); + return textChunks.join("").replace(/\n$/, ""); } for (const series of allSeries) { - process.stdout.write(color.bold(seriesTitle(series)) + "\n"); + textChunks.push(color.bold(seriesTitle(series)) + "\n"); const points = series.points ?? []; if (points.length === 0) { - process.stdout.write(color.dim(" (no data points)") + "\n\n"); + textChunks.push(color.dim(" (no data points)") + "\n\n"); continue; } for (const point of points) { - process.stdout.write( + textChunks.push( ` ${color.dim(formatDateTime(point.timestamp))} ${formatNumber(point.value)}\n`, ); } - process.stdout.write("\n"); + textChunks.push("\n"); } + + return textChunks.join("").replace(/\n$/, ""); } export default defineCommand({ @@ -171,7 +175,7 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags); const metricFilters = parseCommaList(flags.metric).map((metricName) => ({ @@ -188,14 +192,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: METRICS_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: METRICS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -203,25 +204,28 @@ export default defineCommand({ const resp = await pollTelemetryData(ctx.client, METRICS_API, reqDTO); const allSeries = (await resolveOssPayload(resp)) ?? []; - if (format === "json") { - emitResult( - { - period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, - step: reqDTO.step, - series: allSeries, - }, - format, - ); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; - printSeries(allSeries); - if (parseCommaList(flags.metric).some((metricName) => FAILURE_METRICS.has(metricName))) { - process.stdout.write( - ansi(process.stdout).dim( - `\nFor the error-code breakdown, run \`${ctx.identity.binName} monitor errors\`.\n`, - ), - ); + textChunks.push(renderSeries(allSeries) + "\n"); + 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\`.`, + ), + ); + } + + text = textChunks.join("").replace(/\n$/, ""); } + ctx.output.result( + { + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + step: reqDTO.step, + series: allSeries, + }, + { text }, + ); }, }); diff --git a/packages/commands/src/commands/monitor/models.ts b/packages/commands/src/commands/monitor/models.ts index aff1e2a18..f004c67e4 100644 --- a/packages/commands/src/commands/monitor/models.ts +++ b/packages/commands/src/commands/monitor/models.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, renderBoxTable } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { TELEMETRY_TIME_FLAGS, @@ -45,16 +45,18 @@ interface ModelStatisticItem { usages?: UsageItemWithUnit[]; } -function printTable(items: ModelStatisticItem[], startTime: number, endTime: number): void { +function renderTable(items: ModelStatisticItem[], startTime: number, endTime: number): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); - process.stdout.write( + textChunks.push( `${color.dim("Time Range:")} ${formatDateTime(startTime)} ~ ${formatDateTime(endTime)}\n\n`, ); if (items.length === 0) { - process.stdout.write("No monitor data found.\n"); - return; + textChunks.push("No monitor data found.\n"); + return textChunks.join("").replace(/\n$/, ""); } const headers = [ @@ -90,9 +92,11 @@ function printTable(items: ModelStatisticItem[], startTime: number, endTime: num return color.green(value); }, }); - for (const line of lines) process.stdout.write(line + "\n"); + for (const line of lines) textChunks.push(line + "\n"); + + textChunks.push(color.dim(`\nTotal: ${items.length} rows`) + "\n"); - process.stdout.write(color.dim(`\nTotal: ${items.length} rows`) + "\n"); + return textChunks.join("").replace(/\n$/, ""); } export default defineCommand({ @@ -168,7 +172,7 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags); const reqDTO = { @@ -183,14 +187,11 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: LIST_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } @@ -199,22 +200,25 @@ export default defineCommand({ const list = ((resp.list as ModelStatisticItem[]) ?? []).filter(Boolean); const nextToken = resp.nextToken as string | undefined; - if (format === "json") { - emitResult( - { - period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, - totalCount: resp.totalCount ?? 0, - nextToken, - list, - }, - format, - ); - return; - } + let text: string | undefined; + if (format === "text") { + const textChunks: string[] = []; + + textChunks.push(renderTable(list, startTime, endTime) + "\n"); + if (nextToken) { + textChunks.push(`Next page: --next-token ${nextToken}\n`); + } - printTable(list, startTime, endTime); - if (nextToken) { - process.stdout.write(`Next page: --next-token ${nextToken}\n`); + text = textChunks.join("").replace(/\n$/, ""); } + ctx.output.result( + { + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + totalCount: resp.totalCount ?? 0, + nextToken, + list, + }, + { text }, + ); }, }); diff --git a/packages/commands/src/commands/monitor/overview.ts b/packages/commands/src/commands/monitor/overview.ts index 411ccc4ee..e4f60dcbb 100644 --- a/packages/commands/src/commands/monitor/overview.ts +++ b/packages/commands/src/commands/monitor/overview.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; -import { ansi, emitResult, displayWidth, padEnd } from "bailian-cli-runtime"; +import { defineCommand, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, displayWidth, padEnd } from "bailian-cli-runtime"; import { formatNumber, formatDateTime } from "../shared/format.ts"; import { TELEMETRY_TIME_FLAGS, @@ -31,10 +31,12 @@ interface ModelStatistic { usages?: UsageItemWithUnit[]; } -function printStatistic(stat: ModelStatistic, startTime: number, endTime: number): void { +function renderStatistic(stat: ModelStatistic, startTime: number, endTime: number): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); - process.stdout.write( + textChunks.push( `${color.dim("Time Range:")} ${formatDateTime(startTime)} ~ ${formatDateTime(endTime)}\n\n`, ); @@ -67,8 +69,10 @@ function printStatistic(stat: ModelStatistic, startTime: number, endTime: number const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); for (const [label, value] of rows) { - process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); + textChunks.push(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); } + + return textChunks.join("").replace(/\n$/, ""); } export default defineCommand({ @@ -97,7 +101,7 @@ export default defineCommand({ ], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags); const reqDTO = { @@ -107,31 +111,24 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult( - { - api: STATISTIC_API, - data: { reqDTO }, - ...effectiveConsoleGatewayConfig(settings), - }, - format, - ); + ctx.output.result({ + api: STATISTIC_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }); return; } ensureTelemetryRegionSupported(settings); const stat = (await pollTelemetryData(ctx.client, STATISTIC_API, reqDTO)) as ModelStatistic; - if (format === "json") { - emitResult( - { - period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, - ...stat, - }, - format, - ); - return; - } - - printStatistic(stat, startTime, endTime); + const text = format === "text" ? renderStatistic(stat, startTime, endTime) : undefined; + ctx.output.result( + { + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + ...stat, + }, + { text }, + ); }, }); diff --git a/packages/commands/tests/output/telemetry-output.test.ts b/packages/commands/tests/output/telemetry-output.test.ts new file mode 100644 index 000000000..6ecb8aabe --- /dev/null +++ b/packages/commands/tests/output/telemetry-output.test.ts @@ -0,0 +1,544 @@ +import { stripVTControlCharacters } from "node:util"; +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { + BailianError, + buildSettings, + ExitCode, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import * as commands from "bailian-cli-commands"; + +const range = { startTime: "2026-01-02T03:04:00", endTime: "2026-01-02T04:04:00" }; +const period = { start: "2026-01-02 03:04:00", end: "2026-01-02 04:04:00" }; +const timestamp = new Date("2026-01-02T03:04:00").getTime(); +const requestId = "11111111-2222-3333-4444-555555555555"; +const service = { + openStatus: true, + instanceStatus: "Ready", + instanceInfo: { instanceId: "instance-test", instanceUrl: "https://example.com/instance" }, +}; +const entry = { + modelRequestId: requestId, + model: "qwen-test", + startTime: timestamp, + httpStatusCode: 200, + callDuration: 20, + firstTokenDuration: 5, + usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, + originLog: { audit: "raw" }, + taskUuid: "task-test", +}; +const trace = { + traceId: "trace-test", + spanId: "span-test", + spanName: "Root span", + startTime: timestamp, + statusCode: "1", + children: [ + { + traceId: "trace-test", + spanId: "child", + spanName: "Child span", + startTime: timestamp, + statusCode: "2", + statusMessage: "Trace error", + }, + ], +}; +const model = { model: "qwen-test", callCount: 10, callFailedCount: 2, avgCallDuration: 20 }; +const series = [ + { metricName: "model_call_failed_count", aggMethod: "sum", points: [{ timestamp, value: 2 }] }, +]; +const errorRows = [{ httpStatusCode: "429", errorCode: "Limit", callCount: 2, callPercent: 0.2 }]; +const traceStats = { resourceId: "qwen-test", callCount: 10, totalTokens: 5, avgDuration: 20 }; +interface Scenario { + name: string; + command: AnyCommand; + flags: Record; + payload?: unknown; + want: unknown; + text: string; + noDryRun?: boolean; +} +const cases: Scenario[] = [ + { + name: "monitor overview", + command: commands.monitorOverview, + flags: { ...range }, + payload: model, + want: { period, ...model }, + text: "Total Calls", + }, + { + name: "monitor models", + command: commands.monitorModels, + flags: { ...range, nextToken: "cursor-in" }, + payload: { list: [model], totalCount: 9, nextToken: "cursor-out" }, + want: { period, list: [model], totalCount: 9, nextToken: "cursor-out" }, + text: "Next page: --next-token cursor-out", + }, + { + name: "monitor metrics", + command: commands.monitorMetrics, + flags: { ...range, metric: "model_call_failed_count", step: 60 }, + payload: { originData: series }, + want: { period, step: 60, series }, + text: "monitor errors", + }, + { + name: "monitor errors", + command: commands.monitorErrors, + flags: { ...range }, + payload: { list: errorRows }, + want: { period, list: errorRows }, + text: "429", + }, + { + name: "monitor delivery status", + command: commands.monitorDeliveryStatus, + flags: {}, + want: { cmsSlrAuthorized: true, deliverySwitch: false, ...service }, + text: "monitor delivery enable", + noDryRun: true, + }, + { + name: "monitor delivery enable", + command: commands.monitorDeliveryEnable, + flags: {}, + want: { activated: true, ...service }, + text: "Monitoring delivery is active.", + }, + { + name: "monitor delivery disable", + command: commands.monitorDeliveryDisable, + flags: {}, + want: { disabled: true }, + text: "Monitor delivery disabled.", + }, + { + name: "log status", + command: commands.logStatus, + flags: {}, + want: { + slrAuthorized: true, + audit: service, + inference: service, + auditSwitch: true, + inferenceSwitch: false, + }, + text: "log inference enable", + noDryRun: true, + }, + { + name: "log audit list", + command: commands.logAuditList, + flags: { ...range, nextToken: "cursor-in" }, + payload: { list: [entry], totalCount: 9, nextToken: "cursor-out" }, + want: { list: [entry], totalCount: 9, nextToken: "cursor-out" }, + text: "Next page: --next-token cursor-out", + }, + { + name: "log audit get", + command: commands.logAuditGet, + flags: { ...range, requestId }, + payload: { list: [entry] }, + want: entry, + text: '"audit": "raw"', + }, + { + name: "log audit count", + command: commands.logAuditCount, + flags: { ...range, model: "qwen-test" }, + want: { kind: "audit", period, count: 5 }, + text: "Audit logs: 5", + }, + { + name: "log audit enable", + command: commands.logAuditEnable, + flags: { noWait: true }, + want: { enabled: true, kind: "audit", instanceStatus: "Ready" }, + text: "The SLS instance may still be initializing", + }, + { + name: "log audit disable", + command: commands.logAuditDisable, + flags: {}, + want: { disabled: true, kind: "audit" }, + text: "Audit log delivery disabled.", + }, + { + name: "log inference list", + command: commands.logInferenceList, + flags: { ...range, statusCode: "SUCCESS,SERVER_ERROR" }, + payload: { list: [entry], totalCount: 9 }, + want: { list: [entry], totalCount: 9 }, + text: requestId, + }, + { + name: "log inference get", + command: commands.logInferenceGet, + flags: { ...range, requestId }, + payload: { list: [entry] }, + want: { ...entry, request: "Request body", response: "Response body" }, + text: "Request body", + }, + { + name: "log inference count", + command: commands.logInferenceCount, + flags: { ...range }, + want: { kind: "inference", period, count: 5 }, + text: "Inference logs: 5", + }, + { + name: "log inference enable", + command: commands.logInferenceEnable, + flags: { noWait: true }, + want: { enabled: true, kind: "inference", instanceStatus: "Ready" }, + text: "Inference log delivery enabled.", + }, + { + name: "log inference disable", + command: commands.logInferenceDisable, + flags: {}, + want: { disabled: true, kind: "inference" }, + text: "Inference log delivery disabled.", + }, + { + name: "log trace list", + command: commands.logTraceList, + flags: { ...range, resourceId: "qwen-test" }, + payload: { originData: { list: [trace], totalCount: 9 } }, + want: { list: [trace], totalCount: 9 }, + text: "trace-test", + }, + { + name: "log trace get", + command: commands.logTraceGet, + flags: { ...range, traceId: "trace-test" }, + payload: { originData: trace }, + want: trace, + text: "Child span", + }, + { + name: "log trace stats", + command: commands.logTraceStats, + flags: { ...range }, + payload: { "qwen-test": traceStats }, + want: { period, list: [traceStats] }, + text: "qwen-test", + }, +]; +let failure = false; +let cold = false; +let guardInference = false; +let pendingFirst = false; +let originEmpty = false; +let calls: Array<{ api: string; data: Record }> = []; +afterEach(() => { + vi.restoreAllMocks(); + failure = false; + cold = false; + guardInference = false; + pendingFirst = false; + originEmpty = false; + vi.unstubAllEnvs(); + calls = []; +}); +async function invoke( + scenario: Scenario, + display: { format: "text" | "json"; quiet?: boolean; verbose?: boolean; dryRun?: boolean }, +) { + let stdout = "", + stderr = "", + strayOut = "", + strayErr = ""; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + strayOut += String(chunk); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + strayErr += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + env: {}, + file: { workspace_id: "ws-test" }, + configPath: "/tmp/telemetry-output-nonexistent/config.json", + }); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write(chunk) { + stdout += chunk; + }, + }, + stderr: { + write(chunk) { + stderr += chunk; + }, + }, + }); + let authorized = !cold, + ready = !cold, + opened = !cold; + const client = { + baseUrl: "http://127.0.0.1:1", + console: async (api: string, data: Record) => { + calls.push({ api, data: structuredClone(data) }); + if (failure) throw new BailianError("Original gateway failure", ExitCode.GENERAL); + if ( + pendingFirst && + api.endsWith("getModelStatistic") && + calls.filter((call) => call.api === api).length === 1 + ) + return { taskId: "async-task" }; + if (api.includes("getTelemetrySlrStatus")) return { result: authorized }; + if (api.includes("createTelemetrySlr")) { + authorized = true; + return {}; + } + if (api.includes("getTelemetryServiceStatus")) + return ready + ? structuredClone(service) + : { openStatus: opened, instanceStatus: "NotExist" }; + if (api.includes("initCmsService")) { + opened = true; + return {}; + } + if (api.includes("initTelemetryStoreInstance")) { + ready = true; + opened = true; + return {}; + } + if (api.includes("getTelemetryGroupStatus")) { + const reqDTO = data.reqDTO as { telemetryType: string }; + return { + list: [ + { + workspaceId: "ws-test", + telemetryStatus: + reqDTO.telemetryType === "AuditLog" || guardInference ? "enable" : "disable", + }, + ], + }; + } + if (api.includes("countModelLogs")) return { result: 5 }; + if (api.includes("getModelOriginLog")) + return originEmpty + ? {} + : { originLog: { request: "Request body", response: "Response body" } }; + if (api.includes("enableTelemetryGroup") || api.includes("disableTelemetryGroup")) return {}; + return structuredClone(scenario.payload ?? {}); + }, + }; + let exitCode = 0; + try { + try { + await scenario.command.run({ + flags: scenario.flags, + settings, + client, + output: reporter.output, + localize, + identity: { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }, + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, strayOut, strayErr, exitCode }; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } +} +const matrix = cases.flatMap((scenario) => + (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ scenario, name: scenario.name, format, quiet, verbose })), + ), + ), +); +test.each(matrix)( + "$name uses result channel: $format q=$quiet v=$verbose", + async ({ scenario, ...display }) => { + const result = await invoke(scenario, display); + expect(result.exitCode, result.stderr + result.strayErr).toBe(0); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + if (display.format === "json") { + expect(JSON.parse(result.stdout)).toEqual(scenario.want); + expect(result.stderr).toBe(""); + } else expect(result.stdout).toContain(scenario.text); + expect(calls.length).toBeGreaterThan(0); + }, +); +test.each(cases.filter((scenario) => !scenario.noDryRun))( + "$name dry-run has no calls and uses result channel", + async (scenario) => { + const result = await invoke(scenario, { format: "json", quiet: true, dryRun: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(JSON.parse(result.stdout)).toBeTypeOf("object"); + expect(calls).toEqual([]); + }, +); +test.each(cases)("$name gateway failure returns one JSON error", async (scenario) => { + failure = true; + const result = await invoke(scenario, { format: "json", quiet: true, verbose: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(JSON.parse(result.stderr).error.message).toBe("Original gateway failure"); +}); +test.each(["monitor delivery enable", "log audit enable", "log inference enable"])( + "cold $name progress respects JSON and quiet without changing requests", + async (name) => { + const base = cases.find((scenario) => scenario.name === name)!; + const scenario = { ...base, flags: { noWait: true } }; + cold = true; + const json = await invoke(scenario, { format: "json", quiet: false }); + expect(json.exitCode, json.stderr + json.strayErr).toBe(0); + expect(json.strayOut).toBe(""); + expect(json.strayErr).toBe(""); + expect(json.stderr).toBe(""); + expect(JSON.parse(json.stdout)).toEqual( + name.startsWith("monitor") + ? { activated: false, submitted: true } + : { + enabled: true, + kind: name.includes("audit") ? "audit" : "inference", + instanceStatus: "NotExist", + }, + ); + const jsonCalls = structuredClone(calls); + calls = []; + const text = await invoke(scenario, { format: "text", quiet: true }); + expect(text.exitCode).toBe(0); + expect(text.strayOut).toBe(""); + expect(text.strayErr).toBe(""); + expect(text.stderr).toBe(""); + expect(calls).toEqual(jsonCalls); + }, +); +test("audit disable still refuses while inference is enabled", async () => { + guardInference = true; + const scenario = cases.find((candidate) => candidate.name === "log audit disable")!; + const result = await invoke(scenario, { format: "json", quiet: true }); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.message).toContain("inference log is enabled"); + expect(calls.some((call) => call.api.includes("disableTelemetryGroup"))).toBe(false); +}); + +test.each(["monitor delivery enable", "log audit enable", "log inference enable"])( + "ordinary text keeps cold-start progress for %s", + async (name) => { + const base = cases.find((scenario) => scenario.name === name)!; + cold = true; + const result = await invoke( + { ...base, flags: { noWait: true } }, + { format: "text", quiet: false }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(result.stderr).toContain("Authorizing the service-linked role"); + expect(result.stderr).toContain( + name.startsWith("monitor") ? "CMS service" : "Initializing the SLS", + ); + expect(result.stdout).toContain( + name.startsWith("monitor") ? "Activation submitted" : "log delivery enabled", + ); + }, +); + +test("console async task IDs are reissued without interim JSON output", async () => { + pendingFirst = true; + const scenario = cases[0]!; + const result = await invoke(scenario, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(scenario.want); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(calls).toHaveLength(2); + expect(calls[1]!.data.reqDTO).toMatchObject({ asyncTaskId: "async-task" }); +}); + +test.each([true, false])( + "OSS-backed metric payload success=%s preserves JSON/error boundaries", + async (success) => { + const base = cases.find((scenario) => scenario.name === "monitor metrics")!; + const urls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + urls.push(typeof url === "string" ? url : url instanceof URL ? url.href : url.url); + return new Response(JSON.stringify(series), { status: success ? 200 : 503 }); + }); + const result = await invoke( + { ...base, payload: { dataDownloadUrl: "http://oss.example.test/data.json" } }, + { format: "json", quiet: true }, + ); + expect(urls).toEqual(["https://oss.example.test/data.json"]); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + if (success) { + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(base.want); + } else { + expect(result.exitCode).toBe(ExitCode.NETWORK); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.message).toContain("HTTP 503"); + } + }, +); + +test("audit and inference queries retain different timestamp and filter contracts", async () => { + const audit = cases.find((scenario) => scenario.name === "log audit list")!; + await invoke(audit, { format: "json", quiet: true }); + expect(calls[0]!.data.reqDTO).toMatchObject({ + startTime: timestamp, + needFullContent: false, + nextToken: "cursor-in", + }); + calls = []; + const inference = cases.find((scenario) => scenario.name === "log inference list")!; + await invoke(inference, { format: "json", quiet: true }); + expect(calls[0]!.data.reqDTO).toMatchObject({ + startTime: String(timestamp), + needFullContent: false, + statusCodeTypes: ["SUCCESS", "SERVER_ERROR"], + }); +}); + +test.each(["monitor metrics", "monitor delivery status", "log status", "log inference get"])( + "colored final hint retains one terminal newline: %s", + async (name) => { + vi.stubEnv("NO_COLOR", undefined); + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + originEmpty = true; + try { + const scenario = cases.find((candidate) => candidate.name === name)!; + const result = await invoke(scenario, { format: "text", quiet: false }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("\x1b["); + const visible = stripVTControlCharacters(result.stdout); + expect(visible.endsWith("\n")).toBe(true); + expect(visible.endsWith("\n\n")).toBe(false); + } finally { + if (descriptor) Object.defineProperty(process.stdout, "isTTY", descriptor); + else Reflect.deleteProperty(process.stdout, "isTTY"); + } + }, +); From f2c1aa03074db6776a94c16a1467ff879808bc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 29 Oct 2026 13:56:01 +0800 Subject: [PATCH 09/28] refactor(commands): unify usage and Token Plan output - Route usage and Token Plan results through the invocation output API - Keep quota cards, seat tables and one-time key results intact under quiet - Return a structured free-tier disable report and retain failure warnings - Add isolated output-contract and quota-view regression coverage --- docs/agents/output-contract.md | 4 +- .../src/commands/token-plan/add-member.ts | 32 +- .../src/commands/token-plan/assign-seats.ts | 19 +- .../src/commands/token-plan/create-key.ts | 38 +- .../src/commands/token-plan/harness-quota.ts | 47 +- .../src/commands/token-plan/list-seats.ts | 39 +- .../src/commands/usage/coding-plan.ts | 37 +- packages/commands/src/commands/usage/free.ts | 99 ++-- .../commands/src/commands/usage/freetier.ts | 110 ++-- .../commands/src/commands/usage/quota-box.ts | 22 +- .../commands/src/commands/usage/shared.ts | 28 +- packages/commands/src/commands/usage/stats.ts | 155 +++-- .../commands/src/commands/usage/summary.ts | 168 +++--- .../commands/src/commands/usage/token-plan.ts | 23 +- .../commands/tests/coding-plan-usage.test.ts | 12 +- .../tests/output/usage-output.test.ts | 537 ++++++++++++++++++ .../tests/token-plan-harness-quota.test.ts | 12 +- .../commands/tests/token-plan-usage.test.ts | 12 +- 18 files changed, 990 insertions(+), 404 deletions(-) create mode 100644 packages/commands/tests/output/usage-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index ea0a34a32..a90463514 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list、speech synthesize / recognize、memory 全部 7 个命令、quota 全部 5 个命令、agents security overview / alerts、monitor 全部 7 个命令和 log 全部 14 个命令已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list、speech synthesize / recognize、memory 全部 7 个命令、quota 全部 5 个命令、agents security overview / alerts、monitor 全部 7 个命令、log 全部 14 个命令、usage 全部 6 个命令和 token-plan 全部 5 个命令已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 @@ -35,6 +35,8 @@ - [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析 - [ ] speech synthesize 的原始音频仅在 text 流式模式下直接写 stdout;JSON 流式需 --out 文件并返回元数据,未指定文件时在请求和文件操作前报用法错误;dry-run 仍允许 JSON 预览。--list-voices 的 JSON 返回音色条目与已有文档链接,内置列表未知时 voices/total 为 null - [ ] speech recognize 的同步 JSON 返回完整服务响应;异步完成后返回原始任务响应并附加 transcriptions 数组,保留失败子任务和 request_id。text 保留转写、说话人和批量分隔;--out 仍保存原有转写文件结构,下载及写盘失败不提前输出最终结果;部分子任务失败不改变原有退出码 +- [ ] usage freetier 的启用 JSON 保留原始响应数组;停用 JSON 返回 `{ processed: [{ model, result }], skipped: [model] }`,跳过项仍不发起停用请求。失败与超时用 warning 提示,保留原有退出策略,不把部分失败丢出结果。 +- [ ] Token Plan 创建密钥的完整响应和一次性明文仍属于业务结果,quiet 不裁剪;不得通过 warning/debug 泄露明文。 - [ ] 其他原始字节流、既有事件流和 Command Pack API 1 仍走现有专用入口;不得绕过新接口承诺后混入 stdout - [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr - [ ] 进程共用 SIGINT / stdout error 监听器,调用结束清理输出上下文;并发不触发 MaxListenersExceededWarning,单次中断只输出一条终止错误,EPIPE 保护覆盖 run 返回后的异步写入 diff --git a/packages/commands/src/commands/token-plan/add-member.ts b/packages/commands/src/commands/token-plan/add-member.ts index 554ffc707..8962fb922 100644 --- a/packages/commands/src/commands/token-plan/add-member.ts +++ b/packages/commands/src/commands/token-plan/add-member.ts @@ -1,10 +1,5 @@ -import { - defineCommand, - detectOutputFormat, - type FlagsDef, - type ParsedFlags, -} from "bailian-cli-core"; -import { emitResult, emitBare, padEnd } from "bailian-cli-runtime"; +import { defineCommand, type FlagsDef, type ParsedFlags } from "bailian-cli-core"; +import { padEnd } from "bailian-cli-runtime"; import type { AddOrganizationMemberResponse } from "./types.ts"; import { TOKEN_PLAN_COMMON_QUERY_FLAGS, @@ -67,7 +62,7 @@ export default defineCommand({ ], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const queryParams = buildQueryParams(flags); if (settings.dryRun) { @@ -76,7 +71,7 @@ export default defineCommand({ API_PATH, queryParams, ); - emitResult({ endpoint, query }, format); + ctx.output.result({ endpoint, query }); return; } @@ -89,11 +84,7 @@ export default defineCommand({ queryParams, }); - if (settings.quiet || format === "text") { - emitTextMember(data); - } else { - emitResult(data, format); - } + ctx.output.result(data, format === "text" ? { text: renderMember(data) } : undefined); }, }); @@ -109,13 +100,16 @@ function buildQueryParams(flags: AddMemberFlags): TokenPlanQueryParams { return params; } -function emitTextMember(data: AddOrganizationMemberResponse): void { +function renderMember(data: AddOrganizationMemberResponse): string { + const lines: string[] = []; + const item = data.Data; if (!item) { - emitBare("Member added."); - return; + return "Member added."; } - emitBare(`${padEnd("AccountId", 14)} ${item.AccountId ?? "-"}`); - emitBare(`${padEnd("SeatAssigned", 14)} ${String(item.SeatAssigned ?? "-")}`); + lines.push(`${padEnd("AccountId", 14)} ${item.AccountId ?? "-"}`); + lines.push(`${padEnd("SeatAssigned", 14)} ${String(item.SeatAssigned ?? "-")}`); + + return lines.join("\n"); } diff --git a/packages/commands/src/commands/token-plan/assign-seats.ts b/packages/commands/src/commands/token-plan/assign-seats.ts index 961d1f9fc..3eb332337 100644 --- a/packages/commands/src/commands/token-plan/assign-seats.ts +++ b/packages/commands/src/commands/token-plan/assign-seats.ts @@ -1,10 +1,4 @@ -import { - defineCommand, - detectOutputFormat, - type FlagsDef, - type ParsedFlags, -} from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { defineCommand, type FlagsDef, type ParsedFlags } from "bailian-cli-core"; import type { BatchAssignSeatsResponse } from "./types.ts"; import { TOKEN_PLAN_COMMON_QUERY_FLAGS, @@ -63,7 +57,6 @@ export default defineCommand({ f.accountId && f.accountId.length > 0 ? undefined : "Missing required flag: --account-id", async run(ctx) { const { identity, settings, flags } = ctx; - const format = detectOutputFormat(settings.output); const workspaceId = requireWorkspaceId(settings, flags, identity.binName); const queryParams = buildQueryParams(flags, workspaceId); @@ -74,7 +67,7 @@ export default defineCommand({ API_PATH, queryParams, ); - emitResult({ endpoint, query }, format); + ctx.output.result({ endpoint, query }); return; } @@ -87,11 +80,9 @@ export default defineCommand({ queryParams, }); - if (settings.quiet || format === "text") { - emitBare("Seats assigned successfully."); - } else { - emitResult(data, format); - } + ctx.output.result(data, { + text: ctx.localize({ "en-US": "Seats assigned successfully.", "zh-CN": "席位分配成功。" }), + }); }, }); diff --git a/packages/commands/src/commands/token-plan/create-key.ts b/packages/commands/src/commands/token-plan/create-key.ts index 323cf62ba..a4a845e5c 100644 --- a/packages/commands/src/commands/token-plan/create-key.ts +++ b/packages/commands/src/commands/token-plan/create-key.ts @@ -1,10 +1,5 @@ -import { - defineCommand, - detectOutputFormat, - type FlagsDef, - type ParsedFlags, -} from "bailian-cli-core"; -import { emitResult, emitBare, padEnd } from "bailian-cli-runtime"; +import { defineCommand, type FlagsDef, type ParsedFlags } from "bailian-cli-core"; +import { padEnd } from "bailian-cli-runtime"; import type { CreateTokenPlanKeyResponse } from "./types.ts"; import { TOKEN_PLAN_COMMON_QUERY_FLAGS, @@ -53,7 +48,7 @@ export default defineCommand({ ], async run(ctx) { const { identity, settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const workspaceId = requireWorkspaceId(settings, flags, identity.binName); const queryParams = buildQueryParams(flags, { accountId: flags.accountId, workspaceId }); @@ -64,7 +59,7 @@ export default defineCommand({ API_PATH, queryParams, ); - emitResult({ endpoint, query }, format); + ctx.output.result({ endpoint, query }); return; } @@ -77,11 +72,7 @@ export default defineCommand({ queryParams, }); - if (settings.quiet || format === "text") { - emitTextKey(data); - } else { - emitResult(data, format); - } + ctx.output.result(data, format === "text" ? { text: renderKey(data) } : undefined); }, }); @@ -99,20 +90,23 @@ function buildQueryParams( return params; } -function emitTextKey(data: CreateTokenPlanKeyResponse): void { +function renderKey(data: CreateTokenPlanKeyResponse): string { + const lines: string[] = []; + const item = data.Data; if (!item) { - emitBare("API key created."); - return; + return "API key created."; } - emitBare(`${padEnd("ApiKeyId", 14)} ${item.ApiKeyId ?? "-"}`); - emitBare(`${padEnd("MaskedApiKey", 14)} ${item.MaskedApiKey ?? "-"}`); + lines.push(`${padEnd("ApiKeyId", 14)} ${item.ApiKeyId ?? "-"}`); + lines.push(`${padEnd("MaskedApiKey", 14)} ${item.MaskedApiKey ?? "-"}`); if (item.Description) { - emitBare(`${padEnd("Description", 14)} ${item.Description}`); + lines.push(`${padEnd("Description", 14)} ${item.Description}`); } if (item.PlainApiKey) { - emitBare(""); - emitBare(`PlainApiKey (shown once): ${item.PlainApiKey}`); + lines.push(""); + lines.push(`PlainApiKey (shown once): ${item.PlainApiKey}`); } + + return lines.join("\n"); } diff --git a/packages/commands/src/commands/token-plan/harness-quota.ts b/packages/commands/src/commands/token-plan/harness-quota.ts index db01c041c..3ce17c8f7 100644 --- a/packages/commands/src/commands/token-plan/harness-quota.ts +++ b/packages/commands/src/commands/token-plan/harness-quota.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; -import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; -import { printQuotaBox, readNumber, type QuotaSection } from "../usage/quota-box.ts"; +import { defineCommand, unwrapResponse } from "bailian-cli-core"; +import { renderQuotaBox, readNumber, type QuotaSection } from "../usage/quota-box.ts"; import { formatNumber } from "../shared/format.ts"; import type { HarnessBenefitItem, TokenPlanEquityInfo } from "./types.ts"; @@ -128,20 +127,17 @@ export default defineCommand({ exampleArgs: ["", "--type official_tool", "--output json"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const benefitsData = flags.type ? { type: flags.type } : {}; const equityData = { queryTokenPlanEquityInfoRequest: { requestId: randomUUID() } }; if (settings.dryRun) { - emitResult( - { - requests: [ - { api: HARNESS_LIST_API, data: benefitsData }, - { api: EQUITY_INFO_API, data: equityData }, - ], - }, - format, - ); + ctx.output.result({ + requests: [ + { api: HARNESS_LIST_API, data: benefitsData }, + { api: EQUITY_INFO_API, data: equityData }, + ], + }); return; } @@ -154,16 +150,19 @@ export default defineCommand({ readArray(equityResult, "tokenPlanEquityInfos"), ); - if (format === "json") { - emitResult({ generatedAt: Date.now(), items: rows }, format); - return; - } - - if (rows.length === 0) { - process.stdout.write("No harness with entitlement quota found.\n"); - return; - } - - printQuotaBox("Token Plan Harness Quota", rows.map(toSection), Date.now()); + const generatedAt = Date.now(); + ctx.output.result( + { generatedAt, items: rows }, + format === "text" + ? { + text: rows.length + ? renderQuotaBox("Token Plan Harness Quota", rows.map(toSection), generatedAt) + : ctx.localize({ + "en-US": "No harness with entitlement quota found.", + "zh-CN": "未找到具有权益额度的 harness。", + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/token-plan/list-seats.ts b/packages/commands/src/commands/token-plan/list-seats.ts index be2a40f06..06e41f936 100644 --- a/packages/commands/src/commands/token-plan/list-seats.ts +++ b/packages/commands/src/commands/token-plan/list-seats.ts @@ -1,12 +1,11 @@ import { defineCommand, - detectOutputFormat, type FlagsDef, type ParsedFlags, BailianError, ExitCode, } from "bailian-cli-core"; -import { emitResult, emitBare, padEnd } from "bailian-cli-runtime"; +import { padEnd } from "bailian-cli-runtime"; import type { GetSubscriptionSeatDetailsResponse, TokenPlanSeatDetail } from "./types.ts"; import { TOKEN_PLAN_COMMON_QUERY_FLAGS, @@ -82,7 +81,7 @@ export default defineCommand({ exampleArgs: ["", "--page-size 20 --status NORMAL", "--query-assigned true --seat-type standard"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const queryParams = buildQueryParams(flags); if (settings.dryRun) { @@ -91,7 +90,7 @@ export default defineCommand({ API_PATH, queryParams, ); - emitResult({ endpoint, query }, format); + ctx.output.result({ endpoint, query }); return; } @@ -105,11 +104,12 @@ export default defineCommand({ }); const items = data.Data?.Items ?? []; - if (settings.quiet || format === "text") { - emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize); - } else { - emitResult(data, format); - } + ctx.output.result( + data, + format === "text" + ? { text: renderSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize) } + : undefined, + ); }, }); @@ -139,15 +139,16 @@ function buildQueryParams(flags: ListSeatsFlags): TokenPlanQueryParams { return params; } -function emitTextSeats( +function renderSeats( items: TokenPlanSeatDetail[], total?: number, pageNo?: number, pageSize?: number, -): void { +): string { + const lines: string[] = []; + if (items.length === 0) { - emitBare("No seats found."); - return; + return "No seats found."; } const header = [ @@ -157,8 +158,8 @@ function emitTextSeats( padEnd("Assigned", 12), padEnd("Account", 20), ].join(" "); - emitBare(header); - emitBare("-".repeat(header.length)); + lines.push(header); + lines.push("-".repeat(header.length)); for (const item of items) { const row = [ @@ -168,13 +169,15 @@ function emitTextSeats( padEnd(item.AssignedStatus ?? "-", 12), padEnd(item.AccountName ?? item.AccountId ?? "-", 20), ].join(" "); - emitBare(row); + lines.push(row); } if (total !== undefined) { - emitBare(""); - emitBare( + lines.push(""); + lines.push( `Total: ${total}${pageNo !== undefined ? ` | Page: ${pageNo}` : ""}${pageSize !== undefined ? ` | PageSize: ${pageSize}` : ""}`, ); } + + return lines.join("\n"); } diff --git a/packages/commands/src/commands/usage/coding-plan.ts b/packages/commands/src/commands/usage/coding-plan.ts index 258894f1e..929d1e98c 100644 --- a/packages/commands/src/commands/usage/coding-plan.ts +++ b/packages/commands/src/commands/usage/coding-plan.ts @@ -1,6 +1,5 @@ -import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; -import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts"; +import { defineCommand, unwrapResponse } from "bailian-cli-core"; +import { renderQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts"; import { formatNumber } from "../shared/format.ts"; const CODING_PLAN_USAGE_API = @@ -81,9 +80,9 @@ function toSection(label: string, window: CodingPlanWindow): QuotaSection { return section; } -function printView(usage: CodingPlanUsage, generatedAt: number): void { +function renderView(usage: CodingPlanUsage, generatedAt: number): string { const planSuffix = usage.instanceType ? ` (${usage.instanceType})` : ""; - printQuotaBox( + return renderQuotaBox( `Coding Plan Usage${planSuffix}`, [ toSection("5-hour quota", usage.per5Hour), @@ -104,7 +103,7 @@ export default defineCommand({ exampleArgs: ["", "--output json"], async run(ctx) { const { settings } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const requestData = { queryCodingPlanInstanceInfoRequest: { commodityCode: COMMODITY_CODES[settings.consoleSite ?? "domestic"], @@ -113,23 +112,25 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult({ api: CODING_PLAN_USAGE_API, data: requestData }, format); + ctx.output.result({ api: CODING_PLAN_USAGE_API, data: requestData }); return; } const result = await ctx.client.console(CODING_PLAN_USAGE_API, requestData); const usage = readUsage(result); - if (format === "json") { - emitResult(usage ?? {}, format); - return; - } - - if (!usage) { - process.stdout.write("No active Coding Plan subscription found.\n"); - return; - } - - printView(usage, Date.now()); + ctx.output.result( + usage ?? {}, + format === "text" + ? { + text: usage + ? renderView(usage, Date.now()) + : ctx.localize({ + "en-US": "No active Coding Plan subscription found.", + "zh-CN": "未找到有效的 Coding Plan 订阅。", + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/usage/free.ts b/packages/commands/src/commands/usage/free.ts index e0d81780e..893f25aa3 100644 --- a/packages/commands/src/commands/usage/free.ts +++ b/packages/commands/src/commands/usage/free.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, findModelByName } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand, findModelByName } from "bailian-cli-core"; import { FREE_TIER_API, FREE_TIER_ONLY_STATUS_API, @@ -8,7 +7,7 @@ import { fetchAllModels, resolveModelType, formatDate, - printFreeTierTable, + renderFreeTierTable, quotaRemainingPercent, type FreeTierQuota, } from "./shared.ts"; @@ -73,7 +72,7 @@ export default defineCommand({ const expiringDays = Number(flags.expiring) || 0; const sortField = flags.sort || undefined; const showAll = Boolean(flags.all); - const format = detectOutputFormat(settings.output); + const format = settings.output; let models: string[]; const typeMap = new Map(); @@ -96,7 +95,7 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult({ api: FREE_TIER_API, data: requestData }, format); + ctx.output.result({ api: FREE_TIER_API, data: requestData }); return; } @@ -159,56 +158,56 @@ export default defineCommand({ const stopStatuses = stopResult ? extractFreeTierOnlyStatuses(stopResult) : []; const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly])); - if (format === "json") { - const items = quotas.map((quota: FreeTierQuota) => { - const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null; - const used = hasQuota ? quota.quotaInitTotal - quota.quotaTotal : 0; - const stopStatus = stopMap.get(quota.model); - const autoStop = - stopStatus === true - ? true - : stopStatus === false - ? false - : quota.quotaStatus === "UNKNOWN" - ? "unsupported" - : null; - return { - model: quota.model, - type: typeMap.get(quota.model) || null, - remaining: hasQuota ? quota.quotaTotal : null, - total: hasQuota ? quota.quotaInitTotal : null, - usagePercent: - hasQuota && quota.quotaInitTotal > 0 - ? Math.round((used / quota.quotaInitTotal) * 1000) / 10 - : null, - remainingPercent: quotaRemainingPercent(quota), - expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null, - autoStop, - }; - }); - emitResult(items, format); - return; - } - - if (quotas.length === 0) { - process.stdout.write("No free-tier quota found.\n"); - return; - } + const items = quotas.map((quota: FreeTierQuota) => { + const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null; + const used = hasQuota ? quota.quotaInitTotal - quota.quotaTotal : 0; + const stopStatus = stopMap.get(quota.model); + const autoStop = + stopStatus === true + ? true + : stopStatus === false + ? false + : quota.quotaStatus === "UNKNOWN" + ? "unsupported" + : null; + return { + model: quota.model, + type: typeMap.get(quota.model) || null, + remaining: hasQuota ? quota.quotaTotal : null, + total: hasQuota ? quota.quotaInitTotal : null, + usagePercent: + hasQuota && quota.quotaInitTotal > 0 + ? Math.round((used / quota.quotaInitTotal) * 1000) / 10 + : null, + remainingPercent: quotaRemainingPercent(quota), + expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null, + autoStop, + }; + }); const browseAll = !modelFlag && !showAll; const visible = browseAll ? quotas.slice(0, TOP_N) : quotas; const remaining = quotas.length - visible.length; const titleSuffix = effectiveSort === "remaining" ? " · sorted by urgency" : ""; - printFreeTierTable({ - quotas: visible, - stopMap, - typeMap, - title: `Free Tier Quota · ${quotas.length} models${titleSuffix}`, - moreHint: - remaining > 0 - ? `+ ${remaining} more · ${identity.binName} usage free --all to browse all` - : undefined, - }); + ctx.output.result( + items, + format === "text" + ? { + text: quotas.length + ? renderFreeTierTable({ + quotas: visible, + stopMap, + typeMap, + title: `Free Tier Quota · ${quotas.length} models${titleSuffix}`, + moreHint: + remaining > 0 + ? `+ ${remaining} more · ${identity.binName} usage free --all to browse all` + : undefined, + }) + : ctx.localize({ "en-US": "No free-tier quota found.", "zh-CN": "未找到免费额度。" }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index a96239367..42a8203dd 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand, unwrapResponse } from "bailian-cli-core"; import { FREE_TIER_ONLY_STATUS_API, extractFreeTierOnlyStatuses, @@ -56,13 +55,13 @@ export default defineCommand({ "--off --model qwen3-max", "--off --all", ], - validate: (f) => - !f.model && !f.all ? "Provide --model [,model2,...] or --all." : undefined, + validate: (flags) => + !flags.model && !flags.all ? "Provide --model [,model2,...] or --all." : undefined, async run(ctx) { const { settings, flags } = ctx; const modelFlag = flags.model || undefined; const off = Boolean(flags.off); - const format = detectOutputFormat(settings.output); + const format = settings.output; let models: string[]; if (modelFlag) { @@ -84,13 +83,10 @@ export default defineCommand({ : "BatchActivateFreeTierOnlyRequest"; if (settings.dryRun) { - emitResult( - { - api, - data: { [requestKey]: { models } }, - }, - format, - ); + ctx.output.result({ + api, + data: { [requestKey]: { models } }, + }); return; } @@ -98,48 +94,78 @@ export default defineCommand({ models = (await fetchAllModels(ctx.client)).map((model) => model.name); } + const skipped: string[] = []; + const processed: { model: string; result: unknown }[] = []; + const textLines: string[] = []; + let stopMap = new Map(); if (off) { const stopResult = await ctx.client.console(FREE_TIER_ONLY_STATUS_API, { queryFreeTierOnlyStatusRequest: { models }, }); - const stopStatuses = extractFreeTierOnlyStatuses(stopResult); - const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly])); - - for (const name of models) { - if (stopMap.get(name) === false) { - process.stderr.write(`Auto-stop is already disabled for "${name}".\n`); - continue; - } - await pollFreeTierBatch(ctx.client, api, requestKey, [name]); - process.stdout.write(`Disabled auto-stop for "${name}".\n`); - } - return; + stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly])); } - const jsonResults: unknown[] = []; for (const name of models) { - const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]); - if (format === "json") { - jsonResults.push(result); + if (off && stopMap.get(name) === false) { + skipped.push(name); + ctx.output.info({ + "en-US": `Auto-stop is already disabled for "${name}".`, + "zh-CN": `模型 "${name}" 已停用自动停止。`, + }); continue; } - if (result) { - const resultData = unwrapResponse(result as Record); - const failureModels = (resultData.failureModels as BatchResultFailure[]) ?? []; - if (failureModels.length > 0) { - process.stderr.write( - `Failed to enable auto-stop for "${name}" (${failureModels[0].errorCode}).\n`, - ); - } else { - process.stdout.write(`Enabled auto-stop for "${name}".\n`); - } - } else { - process.stderr.write(`Warning: operation timed out for "${name}".\n`); + const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]); + processed.push({ model: name, result }); + const resultData = result ? unwrapResponse(result as Record) : undefined; + const failureModels = (resultData?.failureModels as BatchResultFailure[] | undefined) ?? []; + if (!result) { + ctx.output.warning({ + code: "FREETIER_OPERATION_TIMEOUT", + message: { + "en-US": `Operation timed out for "${name}".`, + "zh-CN": `模型 "${name}" 的操作超时。`, + }, + details: { model: name }, + }); + } else if (failureModels.length > 0) { + const errorCode = failureModels[0].errorCode; + ctx.output.warning({ + code: "FREETIER_OPERATION_FAILED", + message: off + ? { + "en-US": `Failed to disable auto-stop for "${name}" (${errorCode}).`, + "zh-CN": `模型 "${name}" 停用自动停止失败(${errorCode})。`, + } + : { + "en-US": `Failed to enable auto-stop for "${name}" (${errorCode}).`, + "zh-CN": `模型 "${name}" 启用自动停止失败(${errorCode})。`, + }, + details: { model: name, error_code: errorCode }, + }); + } + // Keep the existing disable response policy; the JSON report retains the raw response. + if (off || (result && failureModels.length === 0)) { + textLines.push( + ctx.localize( + off + ? { + "en-US": `Disabled auto-stop for "${name}".`, + "zh-CN": `已为模型 "${name}" 停用自动停止。`, + } + : { + "en-US": `Enabled auto-stop for "${name}".`, + "zh-CN": `已为模型 "${name}" 启用自动停止。`, + }, + ), + ); } } - if (format === "json") { - emitResult(jsonResults, format); + if (format === "json" || textLines.length > 0) { + ctx.output.result( + off ? { processed, skipped } : processed.map((item) => item.result), + format === "text" ? { text: textLines.join("\n") } : undefined, + ); } }, }); diff --git a/packages/commands/src/commands/usage/quota-box.ts b/packages/commands/src/commands/usage/quota-box.ts index 69f107ed9..83a9998e0 100644 --- a/packages/commands/src/commands/usage/quota-box.ts +++ b/packages/commands/src/commands/usage/quota-box.ts @@ -47,17 +47,23 @@ function formatRemainingTime(resetTime: number, now: number): string { return parts.join(" "); } -/** Print a bordered quota box with a title line and one gauge per section. */ -export function printQuotaBox(title: string, sections: QuotaSection[], generatedAt: number): void { +/** Render a bordered quota box with a title line and one gauge per section. */ +export function renderQuotaBox( + title: string, + sections: QuotaSection[], + generatedAt: number, +): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); const writeLine = (text = "", style?: TextStyle) => { const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`)); - process.stdout.write(`│ ${style ? style(text) : text}${" ".repeat(padding)}│\n`); + textChunks.push(`│ ${style ? style(text) : text}${" ".repeat(padding)}│\n`); }; // Pre-colored gauge cell: pad from the plain variant so ANSI escapes never shift the border. const writeGaugeLine = (cell: GaugeCell) => { const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${cell.plain}`)); - process.stdout.write(`│ ${cell.colored}${" ".repeat(padding)}│\n`); + textChunks.push(`│ ${cell.colored}${" ".repeat(padding)}│\n`); }; const writeQuota = (section: QuotaSection) => { writeLine(section.label, color.bold); @@ -80,12 +86,14 @@ export function printQuotaBox(title: string, sections: QuotaSection[], generated writeLine(resetText, color.dim); }; - process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`); + textChunks.push(`┌${"─".repeat(BOX_WIDTH)}┐\n`); writeLine(title, color.cyan); writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim); for (const section of sections) { - process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); + textChunks.push(`├${"─".repeat(BOX_WIDTH)}┤\n`); writeQuota(section); } - process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`); + textChunks.push(`└${"─".repeat(BOX_WIDTH)}┘\n`); + + return textChunks.join("").replace(/\n$/, ""); } diff --git a/packages/commands/src/commands/usage/shared.ts b/packages/commands/src/commands/usage/shared.ts index 4a2d60eaa..3b1cc6dfd 100644 --- a/packages/commands/src/commands/usage/shared.ts +++ b/packages/commands/src/commands/usage/shared.ts @@ -159,13 +159,15 @@ export interface FreeTierTableOptions { moreHint?: string; } -/** Print a free-tier quota table with a highlighted header and Quota Left gauge. */ -export function printFreeTierTable(options: FreeTierTableOptions): void { +/** Render a free-tier quota table with a highlighted header and Quota Left gauge. */ +export function renderFreeTierTable(options: FreeTierTableOptions): string { + const textChunks: string[] = []; + const { quotas, stopMap, typeMap } = options; const color = ansi(process.stdout); if (options.title) { - process.stdout.write(color.purple(options.title) + "\n"); + textChunks.push(color.purple(options.title) + "\n"); } const headers = ["Model", "Type", "Remaining", "Total", "Quota Left", "Auto-Stop"]; @@ -211,11 +213,13 @@ export function printFreeTierTable(options: FreeTierTableOptions): void { }, }); - for (const line of lines) process.stdout.write(line + "\n"); + for (const line of lines) textChunks.push(line + "\n"); if (options.moreHint) { - process.stdout.write(color.dim(options.moreHint) + "\n"); + textChunks.push(color.dim(options.moreHint) + "\n"); } + + return textChunks.join("").replace(/\n$/, ""); } // --------------------------------------------------------------------------- @@ -333,17 +337,19 @@ export function formatUsageLabel(key: string): string { return `${label.en}${unitSuffix}`; } -/** Print the account usage overview as an aligned key-value block. */ -export function printUsageOverview( +/** Render the account usage overview as an aligned key-value block. */ +export function renderUsageOverview( stat: OverviewStatistic, startTime: number, endTime: number, days: number, title = "Usage Overview", -): void { +): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); - process.stdout.write( + textChunks.push( `${color.purple(title)} ${color.dim("·")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${color.dim(`(${days} days)`)}\n`, ); @@ -358,6 +364,8 @@ export function printUsageOverview( const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); for (const [label, value] of rows) { - process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); + textChunks.push(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); } + + return textChunks.join("").replace(/\n$/, ""); } diff --git a/packages/commands/src/commands/usage/stats.ts b/packages/commands/src/commands/usage/stats.ts index ad5db7718..5f34b5f84 100644 --- a/packages/commands/src/commands/usage/stats.ts +++ b/packages/commands/src/commands/usage/stats.ts @@ -1,5 +1,5 @@ -import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { defineCommand, BailianError, ExitCode } from "bailian-cli-core"; +import { ansi } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; import { LIST_API, @@ -26,15 +26,17 @@ function formatLabel(label: UsageLabel): string { return `${label.en}${unitSuffix}`; } -function printOverview( +function renderOverview( stat: OverviewStatistic, startTime: number, endTime: number, days: number, -): void { +): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); - process.stdout.write( + textChunks.push( `${color.dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${color.dim(`(${days} days)`)}\n\n`, ); @@ -51,25 +53,29 @@ function printOverview( const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); for (const [label, value] of rows) { - process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); + textChunks.push(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); } + + return textChunks.join("").replace(/\n$/, ""); } -function printModelTable( +function renderModelTable( items: ModelStatisticItem[], startTime: number, endTime: number, days: number, -): void { +): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); - process.stdout.write( + textChunks.push( `${color.dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${color.dim(`(${days} days)`)}\n\n`, ); if (items.length === 0) { - process.stdout.write("No usage data found.\n"); - return; + textChunks.push("No usage data found.\n"); + return textChunks.join("").replace(/\n$/, ""); } const usageKeys = new Set(); @@ -112,15 +118,17 @@ function printModelTable( const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" "); const separator = widths.map((width) => color.dim("─".repeat(width))).join("──"); - process.stdout.write(headerLine + "\n"); - process.stdout.write(separator + "\n"); + textChunks.push(headerLine + "\n"); + textChunks.push(separator + "\n"); for (const row of rows) { const cells = row.map((cell, col) => padEnd(cell, widths[col])); - process.stdout.write(cells.join(" ") + "\n"); + textChunks.push(cells.join(" ") + "\n"); } - process.stdout.write(color.dim(`\nTotal: ${items.length} models`) + "\n"); + textChunks.push(color.dim(`\nTotal: ${items.length} models`) + "\n"); + + return textChunks.join("").replace(/\n$/, ""); } export default defineCommand({ @@ -164,7 +172,7 @@ export default defineCommand({ const modelFlag = flags.model || undefined; const daysFlag = Number(flags.days) || 7; const typeFlag = flags.type || undefined; - const format = detectOutputFormat(settings.output); + const format = settings.output; const workspaceId = requireWorkspaceId(settings, identity.binName); @@ -194,10 +202,10 @@ export default defineCommand({ if (typeFlag) baseReqDTO.obsModelType = typeFlag; if (settings.dryRun) { - emitResult( - { api: LIST_API, data: { reqDTO: { ...baseReqDTO, model: models.join(",") } } }, - format, - ); + ctx.output.result({ + api: LIST_API, + data: { reqDTO: { ...baseReqDTO, model: models.join(",") } }, + }); return; } @@ -212,29 +220,26 @@ export default defineCommand({ allItems.push(...listData.list); } - if (format === "json") { - const items = allItems.map((item) => { - const usage = resolveUsageMap(item); - const clean: Record = { - model: item.model, - successfulCalls: item.callSuccessCount ?? 0, - }; - for (const [key, val] of Object.entries(usage)) { - clean[key] = val; - } - return clean; - }); - emitResult( - { - period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag }, - items, - }, - format, - ); - return; - } - - printModelTable(allItems, startTime, endTime, daysFlag); + const items = allItems.map((item) => { + const usage = resolveUsageMap(item); + const clean: Record = { + model: item.model, + successfulCalls: item.callSuccessCount ?? 0, + }; + for (const [key, val] of Object.entries(usage)) { + clean[key] = val; + } + return clean; + }); + ctx.output.result( + { + period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag }, + items, + }, + format === "text" + ? { text: renderModelTable(allItems, startTime, endTime, daysFlag) } + : undefined, + ); } else { const reqDTO: Record = { startTime, @@ -245,7 +250,7 @@ export default defineCommand({ if (typeFlag) reqDTO.obsModelType = typeFlag; if (settings.dryRun) { - emitResult({ api: OVERVIEW_API, data: { reqDTO } }, format); + ctx.output.result({ api: OVERVIEW_API, data: { reqDTO } }); return; } @@ -256,41 +261,33 @@ export default defineCommand({ const stat = extractOverviewData(result); - if (format === "json") { - if (!stat) { - emitResult( - { - period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag }, - modelsCalled: 0, - successfulCalls: 0, - }, - format, - ); - return; - } - emitResult( - { - period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag }, - modelsCalled: stat.modelCount ?? 0, - successfulCalls: stat.callSuccessCount ?? 0, - usages: (stat.usages ?? []).map((u) => ({ - key: u.key, - value: u.value, - unit: u.unit, - label: USAGE_KEY_LABELS[u.key]?.en ?? u.key, - })), - }, - format, - ); - return; - } - - if (!stat) { - process.stdout.write("No usage data found.\n"); - return; - } - - printOverview(stat, startTime, endTime, daysFlag); + ctx.output.result( + { + period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag }, + modelsCalled: stat?.modelCount ?? 0, + successfulCalls: stat?.callSuccessCount ?? 0, + ...(stat + ? { + usages: (stat.usages ?? []).map((usage) => ({ + key: usage.key, + value: usage.value, + unit: usage.unit, + label: USAGE_KEY_LABELS[usage.key]?.en ?? usage.key, + })), + } + : {}), + }, + format === "text" + ? { + text: stat + ? renderOverview(stat, startTime, endTime, daysFlag) + : ctx.localize({ + "en-US": "No usage data found.", + "zh-CN": "未找到用量数据。", + }), + } + : undefined, + ); } }, }); diff --git a/packages/commands/src/commands/usage/summary.ts b/packages/commands/src/commands/usage/summary.ts index 55d4985c4..a730d5a02 100644 --- a/packages/commands/src/commands/usage/summary.ts +++ b/packages/commands/src/commands/usage/summary.ts @@ -1,5 +1,5 @@ -import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; +import { defineCommand, BailianError, ExitCode } from "bailian-cli-core"; +import { ansi } from "bailian-cli-runtime"; import { FREE_TIER_API, FREE_TIER_ONLY_STATUS_API, @@ -11,8 +11,8 @@ import { fetchAllModels, formatDate, pollTelemetryApi, - printFreeTierTable, - printUsageOverview, + renderFreeTierTable, + renderUsageOverview, quotaRemainingPercent, type FreeTierQuota, type OverviewStatistic, @@ -42,32 +42,29 @@ export default defineCommand({ async run(ctx) { const { identity, settings, flags } = ctx; const daysFlag = Number(flags.days) || 7; - const format = detectOutputFormat(settings.output); + const format = settings.output; const workspaceId = settings.workspaceId; const endTime = Date.now(); const startTime = endTime - daysFlag * 24 * 60 * 60 * 1000; if (settings.dryRun) { - emitResult( - { - freeTier: { api: FREE_TIER_API }, - usage: workspaceId - ? { - api: OVERVIEW_API, - data: { - reqDTO: { - startTime, - endTime, - modelCallSource: "Online", - filterWorkspaceId: workspaceId, - }, + ctx.output.result({ + freeTier: { api: FREE_TIER_API }, + usage: workspaceId + ? { + api: OVERVIEW_API, + data: { + reqDTO: { + startTime, + endTime, + modelCallSource: "Online", + filterWorkspaceId: workspaceId, }, - } - : null, - }, - format, - ); + }, + } + : null, + }); return; } @@ -107,71 +104,74 @@ export default defineCommand({ overview = extractOverviewData(result); } - if (format === "json") { - const freeTier = quotas.map((quota: FreeTierQuota) => ({ - model: quota.model, - type: typeMap.get(quota.model) || null, - remaining: quota.quotaTotal, - total: quota.quotaInitTotal, - remainingPercent: quotaRemainingPercent(quota), - expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null, - })); - emitResult( - { - period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag }, - freeTier, - usage: overview - ? { - modelsCalled: overview.modelCount ?? 0, - successfulCalls: overview.callSuccessCount ?? 0, - usages: (overview.usages ?? []).map((usage) => ({ - key: usage.key, - value: usage.value, - unit: usage.unit, - label: USAGE_KEY_LABELS[usage.key]?.en ?? usage.key, - })), - } - : null, - }, - format, - ); - return; - } - - const color = ansi(process.stdout); - process.stdout.write( - `${color.bold("Usage Summary")} ${color.dim("·")} ${formatDate(endTime)}\n\n`, + const freeTier = quotas.map((quota: FreeTierQuota) => ({ + model: quota.model, + type: typeMap.get(quota.model) || null, + remaining: quota.quotaTotal, + total: quota.quotaInitTotal, + remainingPercent: quotaRemainingPercent(quota), + expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null, + })); + ctx.output.result( + { + period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag }, + freeTier, + usage: overview + ? { + modelsCalled: overview.modelCount ?? 0, + successfulCalls: overview.callSuccessCount ?? 0, + usages: (overview.usages ?? []).map((usage) => ({ + key: usage.key, + value: usage.value, + unit: usage.unit, + label: USAGE_KEY_LABELS[usage.key]?.en ?? usage.key, + })), + } + : null, + }, + format === "text" ? { text: renderText() } : undefined, ); - if (quotas.length === 0) { - process.stdout.write(color.dim("No active free-tier quota.") + "\n"); - } else { - const visible = quotas.slice(0, TOP_N); - const remaining = quotas.length - visible.length; - printFreeTierTable({ - quotas: visible, - stopMap, - typeMap, - title: `Free Tier Quota · ${quotas.length} models · sorted by urgency`, - moreHint: - remaining > 0 - ? `+ ${remaining} more · ${identity.binName} usage free to browse all` - : undefined, - }); - } + function renderText(): string { + const textChunks: string[] = []; + const color = ansi(process.stdout); + textChunks.push( + `${color.bold("Usage Summary")} ${color.dim("·")} ${formatDate(endTime)}\n\n`, + ); - process.stdout.write("\n"); + if (quotas.length === 0) { + textChunks.push(color.dim("No active free-tier quota.") + "\n"); + } else { + const visible = quotas.slice(0, TOP_N); + const remaining = quotas.length - visible.length; + textChunks.push( + renderFreeTierTable({ + quotas: visible, + stopMap, + typeMap, + title: `Free Tier Quota · ${quotas.length} models · sorted by urgency`, + moreHint: + remaining > 0 + ? `+ ${remaining} more · ${identity.binName} usage free to browse all` + : undefined, + }) + "\n", + ); + } - if (!workspaceId) { - process.stdout.write( - color.dim( - `Usage overview skipped · set --workspace-id or \`${identity.binName} config set workspace_id \` to include it.`, - ) + "\n", - ); - } else if (!overview) { - process.stdout.write(color.dim("No usage data in this period.") + "\n"); - } else { - printUsageOverview(overview, startTime, endTime, daysFlag); + textChunks.push("\n"); + + if (!workspaceId) { + textChunks.push( + color.dim( + `Usage overview skipped · set --workspace-id or \`${identity.binName} config set workspace_id \` to include it.`, + ) + "\n", + ); + } else if (!overview) { + textChunks.push(color.dim("No usage data in this period.") + "\n"); + } else { + textChunks.push(renderUsageOverview(overview, startTime, endTime, daysFlag) + "\n"); + } + return textChunks.join("").replace(/\n$/, ""); } }, }); diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts index 9e308850a..7e2a44bcb 100644 --- a/packages/commands/src/commands/usage/token-plan.ts +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -1,6 +1,5 @@ -import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; -import { printQuotaBox, readNumber } from "./quota-box.ts"; +import { defineCommand, unwrapResponse } from "bailian-cli-core"; +import { renderQuotaBox, readNumber } from "./quota-box.ts"; const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"; @@ -21,8 +20,8 @@ function readUsage(result: unknown): TokenPlanUsage { return usage; } -function printView(usage: TokenPlanUsage, generatedAt: number): void { - printQuotaBox( +function renderView(usage: TokenPlanUsage, generatedAt: number): string { + return renderQuotaBox( "Token Plan Usage", [ { @@ -47,21 +46,19 @@ export default defineCommand({ exampleArgs: ["", "--output json"], async run(ctx) { const { settings } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; if (settings.dryRun) { - emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format); + ctx.output.result({ api: TOKEN_PLAN_USAGE_API, data: {} }); return; } const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {}); const usage = readUsage(result); - if (format === "json") { - emitResult(usage, format); - return; - } - - printView(usage, Date.now()); + ctx.output.result( + usage, + format === "text" ? { text: renderView(usage, Date.now()) } : undefined, + ); }, }); diff --git a/packages/commands/tests/coding-plan-usage.test.ts b/packages/commands/tests/coding-plan-usage.test.ts index 09de39a09..7b8168d7e 100644 --- a/packages/commands/tests/coding-plan-usage.test.ts +++ b/packages/commands/tests/coding-plan-usage.test.ts @@ -1,3 +1,4 @@ +import { createOutputReporter } from "bailian-cli-runtime"; import { afterEach, describe, expect, test, vi } from "vite-plus/test"; import codingPlanUsage from "../src/commands/usage/coding-plan.ts"; @@ -25,10 +26,19 @@ function captureStdout(): string[] { } async function runCodingPlan(response: Record, output?: string): Promise { + const settings = { + dryRun: false, + output: output === "json" ? ("json" as const) : ("text" as const), + quiet: false, + verbose: false, + }; await codingPlanUsage.run({ client: { console: vi.fn().mockResolvedValue(response) }, flags: {}, - settings: { dryRun: false, output }, + settings, + output: createOutputReporter({ settings }).output, + localize: (text: string | { "en-US": string }) => + typeof text === "string" ? text : text["en-US"], } as never); } diff --git a/packages/commands/tests/output/usage-output.test.ts b/packages/commands/tests/output/usage-output.test.ts new file mode 100644 index 000000000..dbf9fd8bd --- /dev/null +++ b/packages/commands/tests/output/usage-output.test.ts @@ -0,0 +1,537 @@ +import { beforeEach, afterEach, expect, test, vi } from "vite-plus/test"; +import { + BailianError, + buildSettings, + ExitCode, + MODEL_LIST_API, + REGIONS, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import * as commands from "bailian-cli-commands"; +const now = new Date(2026, 0, 8).getTime(); +const batch = { success: true, failureModels: [] }; +const quota = { + model: "qwen-test", + quotaInitTotal: 1000, + quotaTotal: 750, + quotaValidityPeriod: 0, + quotaStatus: "VALID", +}; +const stat = { modelCount: 1, callSuccessCount: 3, usages: [] }; +const seat = { + SeatId: "seat-test", + SpecType: "pro", + Status: "NORMAL", + AssignedStatus: "ASSIGNED", + AccountId: "account-test", +}; +const seatsResponse = { Success: true, Data: { Items: [seat], Total: 9, PageNo: 2, PageSize: 3 } }; +const keyResponse = { + Success: true, + Data: { + ApiKeyId: "key-test", + MaskedApiKey: "placeholder-***", + PlainApiKey: "placeholder-one-time-key", + Description: "Test key", + }, +}; +const memberResponse = { + Success: true, + RequestId: "request-test", + Data: { AccountId: "account-test", SeatAssigned: true }, +}; +const assignmentResponse = { Success: true, Code: "OK", Message: "Assigned" }; +interface Scenario { + name: string; + command: AnyCommand; + flags: Record; + want: unknown; + text: string; + response?: unknown; +} +const cases: Scenario[] = [ + { + name: "usage free", + command: commands.usageFree, + flags: { model: "qwen-test" }, + want: [ + { + model: "qwen-test", + type: "Text", + remaining: 750, + total: 1000, + usagePercent: 25, + remainingPercent: 75, + expires: null, + autoStop: true, + }, + ], + text: "qwen-test", + }, + { + name: "usage freetier", + command: commands.usageFreetier, + flags: { model: "qwen-test" }, + want: [batch], + text: 'Enabled auto-stop for "qwen-test".', + }, + { + name: "usage stats", + command: commands.usageStats, + flags: { days: "7" }, + want: { + period: { start: "2026-01-01", end: "2026-01-08", days: 7 }, + modelsCalled: 1, + successfulCalls: 3, + usages: [], + }, + text: "Successful Calls", + }, + { + name: "usage summary", + command: commands.usageSummary, + flags: { days: "7" }, + want: { + period: { start: "2026-01-01", end: "2026-01-08", days: 7 }, + freeTier: [ + { + model: "qwen-test", + type: "Text", + remaining: 750, + total: 1000, + remainingPercent: 75, + expires: null, + }, + ], + usage: { modelsCalled: 1, successfulCalls: 3, usages: [] }, + }, + text: "Usage Summary", + }, + { + name: "usage token-plan", + command: commands.usageTokenPlan, + flags: {}, + want: { per1MonthPercentage: 0.25, per1MonthResetTime: now + 3600000 }, + text: "25% used", + }, + { + name: "usage coding-plan", + command: commands.usageCodingPlan, + flags: {}, + want: { + instanceType: "pro", + per5Hour: { usedQuota: 25, totalQuota: 100, percentage: 0.25 }, + perWeek: {}, + perBillMonth: {}, + }, + text: "Coding Plan Usage (pro)", + }, + { + name: "token-plan list-seats", + command: commands.tokenPlanListSeats, + flags: { pageNo: 2, pageSize: 3, status: ["NORMAL"], queryAssigned: "true" }, + response: seatsResponse, + want: seatsResponse, + text: "Total: 9 | Page: 2 | PageSize: 3", + }, + { + name: "token-plan create-key", + command: commands.tokenPlanCreateKey, + flags: { accountId: "account-test", workspaceId: "ws-test", description: "Test key" }, + response: keyResponse, + want: keyResponse, + text: "PlainApiKey (shown once): placeholder-one-time-key", + }, + { + name: "token-plan assign-seats", + command: commands.tokenPlanAssignSeats, + flags: { workspaceId: "ws-test", seatType: "pro", accountId: ["one", "two"] }, + response: assignmentResponse, + want: assignmentResponse, + text: "Seats assigned successfully.", + }, + { + name: "token-plan add-member", + command: commands.tokenPlanAddMember, + flags: { accountName: "Demo", orgId: "org-test", specType: "pro" }, + response: memberResponse, + want: memberResponse, + text: "account-test", + }, + { + name: "token-plan harness-quota", + command: commands.tokenPlanHarnessQuota, + flags: { type: "official_tool" }, + want: { + generatedAt: now, + items: [ + { + planCode: "code-test", + title: "Test harness", + type: "official_tool", + status: "issuing", + quotaUnit: "requests", + }, + ], + }, + text: "Quota is being issued", + }, +]; +let calls: Array<{ api: string; data: Record }> = []; +let openCalls: Record[] = []; +let failure = false; +let alreadyOff = false; +let batchResult: unknown = batch; +let overviewResult: unknown = stat; +let modelRows: unknown[] = [ + { model: "qwen-test", callSuccessCount: 3, usage: { input_tokens: 2 } }, +]; +beforeEach(() => { + vi.spyOn(Date, "now").mockReturnValue(now); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + calls = []; + openCalls = []; + failure = false; + alreadyOff = false; + batchResult = batch; + overviewResult = stat; + modelRows = [{ model: "qwen-test", callSuccessCount: 3, usage: { input_tokens: 2 } }]; +}); +async function invoke( + scenario: Scenario, + display: { format: "text" | "json"; quiet?: boolean; verbose?: boolean; dryRun?: boolean }, +) { + let stdout = "", + stderr = "", + strayOut = "", + strayErr = ""; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + strayOut += String(chunk); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + strayErr += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + env: {}, + file: { workspace_id: "ws-test" }, + configPath: "/tmp/usage-output/config.json", + }); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write(chunk) { + stdout += chunk; + }, + }, + stderr: { + write(chunk) { + stderr += chunk; + }, + }, + }); + const client = { + baseUrl: REGIONS.cn, + console: async (api: string, data: Record) => { + calls.push({ api, data: structuredClone(data) }); + if (failure) throw new BailianError("Original console failure", ExitCode.GENERAL); + if (api === MODEL_LIST_API) + return { list: [{ model: "qwen-test", capabilities: ["TG"] }], total: 1 }; + if (api.includes("queryFreeTierQuota")) + return { data: { data: { freeTierQuotas: [quota] } } }; + if (api.includes("queryFreeTierOnlyStatus")) + return { + data: { + data: { freeTierOnlyStatuses: [{ model: "qwen-test", freeTierOnly: !alreadyOff }] }, + }, + }; + if (api.includes("batchActivateFreeTierOnly") || api.includes("batchDeactivateFreeTierOnly")) + return structuredClone(batchResult); + if (api.includes("getModelUsageStatistic")) return structuredClone(overviewResult); + if (api.includes("listModelUsageStatisticData")) + return { list: structuredClone(modelRows), totalCount: modelRows.length }; + if (api.endsWith("/usage")) + return { per1MonthPercentage: 0.25, per1MonthResetTime: now + 3600000 }; + if (api.includes("queryCodingPlanInstanceInfoV2")) + return { + codingPlanInstanceInfos: [ + { + status: "VALID", + instanceType: "pro", + codingPlanQuotaInfo: { per5HourUsedQuota: 25, per5HourTotalQuota: 100 }, + }, + ], + }; + if (api.endsWith("token-plan.detail")) + return { + items: [ + { + type: "official_tool", + hasResourcePack: true, + planCodes: ["code-test"], + title: "Test harness", + quotaUnit: "requests", + }, + ], + }; + if (api.includes("queryTokenPlanEquityInfo")) return { tokenPlanEquityInfos: [] }; + throw new Error(`Unexpected API: ${api}`); + }, + openApiQueryJson: async (options: Record) => { + openCalls.push(structuredClone(options)); + if (failure) throw new BailianError("Original OpenAPI failure", ExitCode.GENERAL); + return structuredClone(scenario.response); + }, + }; + let exitCode = 0; + try { + try { + await scenario.command.run({ + settings, + flags: scenario.flags, + client, + localize, + output: reporter.output, + identity: { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }, + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, strayOut, strayErr, exitCode }; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } +} +const matrix = cases.flatMap((scenario) => + (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ scenario, name: scenario.name, format, quiet, verbose })), + ), + ), +); +test.each(matrix)( + "$name complete result channel: $format q=$quiet v=$verbose", + async ({ scenario, ...display }) => { + const result = await invoke(scenario, display); + expect(result.exitCode, result.stderr + result.strayErr).toBe(0); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(scenario.want); + else expect(result.stdout).toContain(scenario.text); + expect(result.stderr).not.toContain("placeholder-one-time-key"); + expect(calls.length + openCalls.length).toBeGreaterThan(0); + }, +); +test.each(cases)("$name dry-run is one JSON result without API calls", async (scenario) => { + const result = await invoke(scenario, { format: "json", quiet: true, dryRun: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(JSON.parse(result.stdout)).toBeTypeOf("object"); + expect(calls).toEqual([]); + expect(openCalls).toEqual([]); +}); +test.each(cases)("$name failure is one structured error", async (scenario) => { + failure = true; + const result = await invoke(scenario, { format: "json", quiet: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(JSON.parse(result.stderr).error.message).toContain( + scenario.command.auth === "openapi" ? "Original OpenAPI" : "Original console", + ); +}); +test.each([false, true])( + "auto-stop off quiet=%s returns processed responses and no unstructured stderr", + async (quiet) => { + const base = cases[1]!; + const result = await invoke( + { ...base, flags: { model: "qwen-test", off: true } }, + { format: "json", quiet }, + ); + expect(result.exitCode).toBe(0); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + processed: [{ model: "qwen-test", result: batch }], + skipped: [], + }); + expect(result.stderr).toBe(""); + }, +); +test("auto-stop already-off skip is retained in the JSON report", async () => { + alreadyOff = true; + const base = cases[1]!; + const result = await invoke( + { ...base, flags: { model: "qwen-test", off: true } }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(0); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ processed: [], skipped: ["qwen-test"] }); + expect(calls.some((call) => call.api.includes("batchDeactivate"))).toBe(false); +}); +test("auto-stop failure retains raw result and a structured warning under quiet", async () => { + batchResult = { failureModels: [{ failureModelId: "qwen-test", errorCode: "Rejected" }] }; + const result = await invoke(cases[1]!, { format: "json", quiet: true }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual([batchResult]); + expect(result.strayOut).toBe(""); + expect(result.strayErr).toBe(""); + expect(JSON.parse(result.stderr).warning).toMatchObject({ + code: "FREETIER_OPERATION_FAILED", + details: { model: "qwen-test", error_code: "Rejected" }, + }); +}); +test("OpenAPI parameters and one-time key disclosure stay unchanged", async () => { + const scenario = cases.find((item) => item.name === "token-plan create-key")!; + const result = await invoke(scenario, { format: "json", quiet: true }); + expect(JSON.parse(result.stdout).Data.PlainApiKey).toBe("placeholder-one-time-key"); + expect(openCalls[0]).toMatchObject({ + action: "CreateTokenPlanKey", + method: "POST", + version: "2026-02-10", + queryParams: { AccountId: "account-test", WorkspaceId: "ws-test", Description: "Test key" }, + }); + expect(result.stderr).not.toContain("placeholder-one-time-key"); +}); + +test.each(["text", "json"] as const)( + "per-model stats keep duplicate filtering, parameters and result in %s", + async (format) => { + modelRows = [ + { + model: "qwen-test", + callSuccessCount: 3, + usages: [{ key: "input_token", value: 4 }], + usage: { input_token: 6, output_token: 2 }, + }, + ]; + const result = await invoke( + { + ...cases[2]!, + flags: { model: " qwen-test, qwen-other,qwen-test ", days: "14", type: "Text" }, + }, + { format, quiet: true }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + expect(calls.map((call) => call.data.reqDTO)).toEqual( + ["qwen-test", "qwen-other"].map((model) => ({ + startTime: now - 14 * 86400000, + endTime: now, + modelCallSource: "Online", + filterWorkspaceId: "ws-test", + maxResults: 50, + skip: 0, + sortField: "success_count", + sortOrder: "DESC", + obsModelType: "Text", + model, + })), + ); + if (format === "json") + expect(JSON.parse(result.stdout)).toEqual({ + period: { start: "2025-12-25", end: "2026-01-08", days: 14 }, + items: Array.from({ length: 2 }, () => ({ + model: "qwen-test", + successfulCalls: 3, + input_token: 6, + output_token: 2, + })), + }); + else expect(result.stdout).toContain("Total: 2 models"); + }, +); +test.each(["text", "json"] as const)( + "empty stats views remain successful in %s", + async (format) => { + overviewResult = {}; + const overview = await invoke(cases[2]!, { format, quiet: true }); + expect(overview.exitCode, overview.stderr).toBe(0); + expect(overview.strayOut + overview.strayErr).toBe(""); + if (format === "json") + expect(JSON.parse(overview.stdout)).toEqual({ + period: { start: "2026-01-01", end: "2026-01-08", days: 7 }, + modelsCalled: 0, + successfulCalls: 0, + }); + else expect(overview.stdout).toBe("No usage data found.\n"); + modelRows = []; + const model = await invoke( + { ...cases[2]!, flags: { model: "qwen-test" } }, + { format, quiet: true }, + ); + expect(model.exitCode, model.stderr).toBe(0); + expect(model.strayOut + model.strayErr).toBe(""); + if (format === "json") expect(JSON.parse(model.stdout).items).toEqual([]); + else expect(model.stdout).toContain("No usage data found."); + }, +); +test.each(["text", "json"] as const)( + "mixed off operations preserve skips, deduplication and raw results in %s", + async (format) => { + alreadyOff = true; + const result = await invoke( + { ...cases[1]!, flags: { off: true, model: "qwen-test,qwen-other,qwen-test" } }, + { format, quiet: true }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr + result.stderr).toBe(""); + expect(calls.map((call) => call.api)).toHaveLength(2); + expect(calls[1]!.data).toEqual({ + BatchDeactivateFreeTierOnlyRequest: { models: ["qwen-other"] }, + }); + if (format === "json") + expect(JSON.parse(result.stdout)).toEqual({ + processed: [{ model: "qwen-other", result: batch }], + skipped: ["qwen-test"], + }); + else expect(result.stdout).toBe('Disabled auto-stop for "qwen-other".\n'); + }, +); +test.each(["text", "json"] as const)( + "timeout warnings retain the original exit and result policy in %s", + async (format) => { + batchResult = { taskId: "task-test" }; + vi.useFakeTimers({ toFake: ["setTimeout"] }); + try { + const pending = invoke(cases[1]!, { format, quiet: true }); + await vi.runAllTimersAsync(); + const result = await pending; + expect(result.exitCode).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + expect(calls).toHaveLength(20); + expect(calls[1]!.data).toEqual({ BatchActivateFreeTierOnlyRequest: { taskId: "task-test" } }); + if (format === "json") { + expect(JSON.parse(result.stdout)).toEqual([null]); + expect(JSON.parse(result.stderr).warning).toMatchObject({ + code: "FREETIER_OPERATION_TIMEOUT", + details: { model: "qwen-test" }, + }); + } else { + expect(result.stdout).toBe(""); + expect(result.stderr).toContain('Operation timed out for "qwen-test".'); + } + } finally { + vi.useRealTimers(); + } + }, +); diff --git a/packages/commands/tests/token-plan-harness-quota.test.ts b/packages/commands/tests/token-plan-harness-quota.test.ts index 31660a50d..e39795267 100644 --- a/packages/commands/tests/token-plan-harness-quota.test.ts +++ b/packages/commands/tests/token-plan-harness-quota.test.ts @@ -1,3 +1,4 @@ +import { createOutputReporter } from "bailian-cli-runtime"; import { afterEach, describe, expect, test, vi } from "vite-plus/test"; import harnessQuota from "../src/commands/token-plan/harness-quota.ts"; @@ -49,6 +50,12 @@ async function runHarnessQuota( options: { output?: string; type?: string } = {}, ): Promise[]> { const calls: Record[] = []; + const settings = { + dryRun: false, + output: options.output === "json" ? ("json" as const) : ("text" as const), + quiet: false, + verbose: false, + }; await harnessQuota.run({ client: { console: vi.fn().mockImplementation((api: string, data: Record) => { @@ -61,7 +68,10 @@ async function runHarnessQuota( }), }, flags: options.type ? { type: options.type } : {}, - settings: { dryRun: false, output: options.output }, + settings, + output: createOutputReporter({ settings }).output, + localize: (text: string | { "en-US": string }) => + typeof text === "string" ? text : text["en-US"], } as never); return calls; } diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts index 7335067d0..5a9eb0c5d 100644 --- a/packages/commands/tests/token-plan-usage.test.ts +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -1,3 +1,4 @@ +import { createOutputReporter } from "bailian-cli-runtime"; import { afterEach, describe, expect, test, vi } from "vite-plus/test"; import tokenPlanUsage from "../src/commands/usage/token-plan.ts"; @@ -25,10 +26,19 @@ function captureStdout(): string[] { } async function runTokenPlan(response: Record, output?: string): Promise { + const settings = { + dryRun: false, + output: output === "json" ? ("json" as const) : ("text" as const), + quiet: false, + verbose: false, + }; await tokenPlanUsage.run({ client: { console: vi.fn().mockResolvedValue(response) }, flags: {}, - settings: { dryRun: false, output }, + settings, + output: createOutputReporter({ settings }).output, + localize: (text: string | { "en-US": string }) => + typeof text === "string" ? text : text["en-US"], } as never); } From 9b4a561a5850a3d906bab340e012d1d800860504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 29 Oct 2026 14:31:36 +0800 Subject: [PATCH 10/28] refactor(commands): unify knowledge administration output - Route knowledge base, category, file and collection results through the output API - Preserve complete results under quiet and move time-range notes to stderr - Update JSON field parsing in dependent integration tests and command manuals - Add isolated output-contract coverage for queries, mutations and import waits --- docs/agents/output-contract.md | 4 +- docs/knowledge/collection-category.md | 10 +- docs/knowledge/file.md | 6 +- docs/knowledge/kb.md | 12 +- docs/knowledge/knowledge-cli-guide.md | 6 +- .../src/commands/knowledge/category-add.ts | 20 +- .../src/commands/knowledge/category-delete.ts | 16 +- .../src/commands/knowledge/category-list.ts | 28 +- .../commands/knowledge/collection-create.ts | 22 +- .../src/commands/knowledge/collection-get.ts | 25 +- .../src/commands/knowledge/file-delete.ts | 16 +- .../src/commands/knowledge/file-get.ts | 40 +- .../src/commands/knowledge/file-list.ts | 26 +- .../src/commands/knowledge/kb-create.ts | 29 +- .../src/commands/knowledge/kb-delete.ts | 16 +- .../src/commands/knowledge/kb-info.ts | 79 ++- .../src/commands/knowledge/kb-list.ts | 26 +- .../src/commands/knowledge/kb-stats.ts | 41 +- .../src/commands/knowledge/kb-update.ts | 16 +- .../journeys/j5-data-plane.e2e.test.ts | 50 +- .../knowledge-chunk-category-file.e2e.test.ts | 136 ++++- .../knowledge/knowledge-doc-list.e2e.test.ts | 6 +- .../knowledge-doc-status.e2e.test.ts | 5 +- .../knowledge-doc-upload.e2e.test.ts | 5 +- .../knowledge/knowledge-kb-delete.e2e.test.ts | 11 +- .../knowledge/knowledge-kb-info.e2e.test.ts | 9 +- .../knowledge/knowledge-kb-list.e2e.test.ts | 8 +- .../tests/e2e/knowledge/knowledge.e2e.test.ts | 5 +- .../output/knowledge-admin-output.test.ts | 487 ++++++++++++++++++ 29 files changed, 883 insertions(+), 277 deletions(-) create mode 100644 packages/commands/tests/output/knowledge-admin-output.test.ts diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index a90463514..da2f3feb2 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -8,7 +8,7 @@ ## 当前迁移阶段 -公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list、speech synthesize / recognize、memory 全部 7 个命令、quota 全部 5 个命令、agents security overview / alerts、monitor 全部 7 个命令、log 全部 14 个命令、usage 全部 6 个命令和 token-plan 全部 5 个命令已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 +公共 runtime 已接入 reporter:正常及早期错误、中间件提示、Client 传输诊断和轮询遵循统一策略;下载保留原有 TTY 原地刷新进度条,调用方在 quiet 或 JSON 模式下关闭进度。视频 generate / ref / edit / download / task get、图片 generate / edit、语音热词表 create / update / delete / get / list、speech synthesize / recognize、memory 全部 7 个命令、quota 全部 5 个命令、agents security overview / alerts、monitor 全部 7 个命令、log 全部 14 个命令、usage 全部 6 个命令、token-plan 全部 5 个命令及知识库管理 6 个命令、类目 3 个命令、数据中心文件 3 个命令、数据集合 2 个命令已迁移最终结果与过程提示,quiet 不再返回裸路径或只返回状态;其他存量业务 emitResult/emitBare、命令专属输出、SDK 全局适配器及 Command Pack API 1 尚需逐项迁移;不能宣称任意命令的完整输出已经遵循新协议。 ## 必查清单 @@ -44,7 +44,7 @@ ### C. 后续迁移 - [ ] 按命令完整调用链迁移:业务、middleware、传输日志、轮询/下载、错误出口必须一起核对 -- [ ] 移除 quiet 裁剪/吞结果分支,更新依赖 quiet 提取 ID/path 的示例 +- [ ] 移除 quiet 裁剪/吞结果分支;同步检查测试(含 live 门控用例)、脚本及示例中依赖 quiet 提取 ID/path 的调用方,改为显式 JSON 并读取对应字段 - [ ] 迁移完后再为相应路径启用直接打印约束,保留协议/原始流等窄例外;不要先全仓禁用旧代码 ### D. 回归测试隔离 diff --git a/docs/knowledge/collection-category.md b/docs/knowledge/collection-category.md index e2e03a18e..beca3c51f 100644 --- a/docs/knowledge/collection-category.md +++ b/docs/knowledge/collection-category.md @@ -40,7 +40,7 @@ text 模式: created: conn-xxx (my-collection, PLATFORM) ``` -quiet 模式:输出集合 ID。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -95,7 +95,7 @@ name: my-collection description: team docs ``` -quiet 模式:输出集合 ID。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -147,7 +147,7 @@ next: --next-token eyJ... > 标记 `[default]` 的是文件未指定分类时的默认归属。 -quiet 模式:每行一个 `categoryId`。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -200,7 +200,7 @@ text 模式: created: cate-xxx (product-docs) ``` -quiet 模式:输出分类 ID。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -245,7 +245,7 @@ text 模式: deleted: cate-xxx ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 diff --git a/docs/knowledge/file.md b/docs/knowledge/file.md index ce5b02b73..44387df08 100644 --- a/docs/knowledge/file.md +++ b/docs/knowledge/file.md @@ -35,7 +35,7 @@ file-xxx SUCCESS intro.md 1024 next: --next-token eyJ... ``` -quiet 模式:每行一个 `fileId`。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -91,7 +91,7 @@ uploaded: 2026-01-01T00:00:00Z tags: project-a, draft ``` -quiet 模式:输出 JSON 格式。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -133,7 +133,7 @@ text 模式: deleted: file-xxx ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 diff --git a/docs/knowledge/kb.md b/docs/knowledge/kb.md index a1be43879..144c98e62 100644 --- a/docs/knowledge/kb.md +++ b/docs/knowledge/kb.md @@ -38,7 +38,7 @@ idx-xxx my-kb text-embedding-v4 600 product docs total: 1 ``` -quiet 模式:每行一个知识库 ID。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应,`data.rows[]` 包含完整知识库信息。 @@ -104,7 +104,7 @@ Data: connectorId: ... ``` -quiet 模式:输出知识库 ID。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回知识库完整配置 JSON。 @@ -163,7 +163,7 @@ status: COMPLETED Next: check the import job status, then search against this knowledge base. ``` -quiet 模式:只输出知识库 ID。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应,包含 `pipelineId`(知识库 ID)和 `ingestionId`(导入任务 ID)。`--wait` 时追加 `final_status` 字段。 @@ -222,7 +222,7 @@ text 模式: updated: idx-xxx ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -267,7 +267,7 @@ text 模式: deleted: idx-xxx ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应。 @@ -318,7 +318,7 @@ peak qps: 5 qps windows: 24 data point(s) ``` -quiet 模式:输出 json 格式。 +quiet 模式:仅隐藏过程提示,保留完整最终结果;程序解析请使用 `--output json`。 json 模式:返回 API 原始响应,包含 `storageMonitorData` 和 `qpsMonitorData`。 diff --git a/docs/knowledge/knowledge-cli-guide.md b/docs/knowledge/knowledge-cli-guide.md index 1f1f618a6..0058bce5d 100644 --- a/docs/knowledge/knowledge-cli-guide.md +++ b/docs/knowledge/knowledge-cli-guide.md @@ -120,8 +120,8 @@ | `--api-key ` | string | DashScope API Key | | `--base-url ` | string | API 基地址(一般不需要指定) | | `--timeout ` | number | 请求超时秒数 | -| `--quiet` | switch | 静默模式,只输出关键结果(如 ID 列表) | -| `--verbose` | switch | 详细模式,打印 HTTP 请求/响应详情到 stderr | +| `--quiet` | switch | 隐藏过程提示;已迁移命令保留完整结果 | +| `--verbose` | switch | 详细模式,向 stderr 输出诊断摘要 | | `--dry-run` | switch | 干跑模式,预览将发送的请求结构,不实际调用 API | | `--config ` | string | 使用指定配置 profile 执行命令 | @@ -131,7 +131,7 @@ - **text 模式**(默认):人类友好的表格/结构化文本,适合终端查看。不同命令的输出格式见各命令的「输出」部分。 - **json 模式**(`--output json`):返回 API 原始 JSON 响应,适合程序化处理和 agent 解析。 -- **quiet 模式**(`--quiet`):只输出最精简的结果(通常只有 ID),适合管道串联。 +- **quiet 模式**(`--quiet`):知识库管理、类目、数据中心文件和数据集合命令仅隐藏过程提示,保留完整最终结果。需要 ID 时使用 `--output json` 并读取对应字段;其他命令尚在迁移,行为见各自手册。 ### 危险操作确认 diff --git a/packages/commands/src/commands/knowledge/category-add.ts b/packages/commands/src/commands/knowledge/category-add.ts index c7bc60411..7d9fab7a9 100644 --- a/packages/commands/src/commands/knowledge/category-add.ts +++ b/packages/commands/src/commands/knowledge/category-add.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagAddCategoryResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const CATEGORY_ADD_FLAGS = { @@ -54,7 +52,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // categoryType fixed to UNSTRUCTURED (the only valid value for knowledge-base creation today) const body = { @@ -66,7 +64,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addCategory); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -77,14 +75,10 @@ export default defineCommand({ }); const categoryId = response.data?.categoryId; - if (settings.quiet) { - emitBare(categoryId ?? ""); - return; - } - if (format === "text") { - emitBare(`created: ${categoryId ?? "-"} (${flags.name})`); - return; - } - emitResult(response, format); + + ctx.output.result( + response, + format === "text" ? { text: `created: ${categoryId ?? "-"} (${flags.name})` } : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/category-delete.ts b/packages/commands/src/commands/knowledge/category-delete.ts index cc208d240..2972198d3 100644 --- a/packages/commands/src/commands/knowledge/category-delete.ts +++ b/packages/commands/src/commands/knowledge/category-delete.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagConnectorResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const CATEGORY_DELETE_FLAGS = { @@ -42,13 +40,13 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { categoryId: flags.categoryId }; const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteCategory); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -60,11 +58,9 @@ export default defineCommand({ body, }); - if (settings.quiet) return; - if (format === "text") { - emitBare(`deleted: ${flags.categoryId}`); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" ? { text: `deleted: ${flags.categoryId}` } : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/category-list.ts b/packages/commands/src/commands/knowledge/category-list.ts index a4ba34ca2..73e104572 100644 --- a/packages/commands/src/commands/knowledge/category-list.ts +++ b/packages/commands/src/commands/knowledge/category-list.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagListCategoryResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts"; const CATEGORY_LIST_FLAGS = { @@ -66,7 +64,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // type fixed to UNSTRUCTURED, not exposed as a flag (the only valid value today); note: maxResult is singular const body = { @@ -80,7 +78,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listCategory); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -91,23 +89,25 @@ export default defineCommand({ }); const categories = response.data?.categoryList ?? []; - if (settings.quiet) { - for (const category of categories) emitBare(category.categoryId ?? ""); - return; - } - if (format === "text") { + + ctx.output.result(response, format === "text" ? { text: renderText() } : undefined); + function renderText(): string { + const lines: string[] = []; + if (categories.length === 0) { - emitBare("No categories found."); + lines.push("No categories found."); } else { for (const category of categories) { const defaultMark = category.isDefault ? " [default]" : ""; - emitBare(truncateLine(`${category.categoryId} ${category.categoryName}${defaultMark}`)); + lines.push( + truncateLine(`${category.categoryId} ${category.categoryName}${defaultMark}`), + ); } } const nextToken = response.data?.nextToken; - if (nextToken) emitBare(`next: --next-token ${nextToken}`); - } else { - emitResult(response, format); + if (nextToken) lines.push(`next: --next-token ${nextToken}`); + + return lines.join("\n"); } }, }); diff --git a/packages/commands/src/commands/knowledge/collection-create.ts b/packages/commands/src/commands/knowledge/collection-create.ts index 818e620dd..c66bb14a9 100644 --- a/packages/commands/src/commands/knowledge/collection-create.ts +++ b/packages/commands/src/commands/knowledge/collection-create.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagAddConnectorResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const COLLECTION_CREATE_FLAGS = { @@ -102,7 +100,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const storeType = (flags.storeType ?? "platform").toUpperCase(); // The server contract still uses connector* fields; only the CLI-facing term is collection. @@ -122,7 +120,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addConnector); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -133,14 +131,12 @@ export default defineCommand({ }); const collectionId = response.data?.connectorId; - if (settings.quiet) { - emitBare(collectionId ?? ""); - return; - } - if (format === "text") { - emitBare(`created: ${collectionId ?? "-"} (${flags.name}, ${storeType})`); - return; - } - emitResult(response, format); + + ctx.output.result( + response, + format === "text" + ? { text: `created: ${collectionId ?? "-"} (${flags.name}, ${storeType})` } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/collection-get.ts b/packages/commands/src/commands/knowledge/collection-get.ts index 927b6c677..ab25f815c 100644 --- a/packages/commands/src/commands/knowledge/collection-get.ts +++ b/packages/commands/src/commands/knowledge/collection-get.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagGetConnectorResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const COLLECTION_GET_FLAGS = { @@ -43,7 +41,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // The server contract still uses connector* fields; only the CLI-facing term is collection const body = { @@ -53,7 +51,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.getConnector); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -64,18 +62,17 @@ export default defineCommand({ }); const collection = response.data; - if (settings.quiet) { - emitBare(collection?.connectorId ?? ""); - return; - } - if (format === "text") { - emitBare(`id: ${collection?.connectorId ?? "-"}`); - emitBare(`name: ${collection?.connectorName ?? "-"}`); - emitBare(`description: ${collection?.description ?? "-"}`); + + ctx.output.result(response, format === "text" ? { text: renderText() } : undefined); + function renderText(): string { + const lines: string[] = []; + + lines.push(`id: ${collection?.connectorId ?? "-"}`); + lines.push(`name: ${collection?.connectorName ?? "-"}`); + lines.push(`description: ${collection?.description ?? "-"}`); // getConnector does not return fileConnectorConfig (storeType/regionId/bucketName); // these fields are only available on the create request body. - return; + return lines.join("\n"); } - emitResult(response, format); }, }); diff --git a/packages/commands/src/commands/knowledge/file-delete.ts b/packages/commands/src/commands/knowledge/file-delete.ts index 7242d3879..ac410daae 100644 --- a/packages/commands/src/commands/knowledge/file-delete.ts +++ b/packages/commands/src/commands/knowledge/file-delete.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagConnectorResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const FILE_DELETE_FLAGS = { @@ -54,13 +52,13 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { fileId: flags.fileId }; const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteFile); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -72,11 +70,9 @@ export default defineCommand({ body, }); - if (settings.quiet) return; - if (format === "text") { - emitBare(`deleted: ${flags.fileId}`); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" ? { text: `deleted: ${flags.fileId}` } : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/file-get.ts b/packages/commands/src/commands/knowledge/file-get.ts index 81bd750ff..129b397b0 100644 --- a/packages/commands/src/commands/knowledge/file-get.ts +++ b/packages/commands/src/commands/knowledge/file-get.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagDescribeFileResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const FILE_GET_FLAGS = { @@ -31,13 +29,13 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { fileId: flags.fileId }; const endpoint = ragEndpoint(workspaceId, RAG_PATHS.describeFile); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -48,23 +46,23 @@ export default defineCommand({ }); const file = response.data; - if (settings.quiet) { - emitBare(file?.fileId ?? ""); - return; - } - if (format !== "text") { - emitResult(response, format); - return; + + ctx.output.result(response, format === "text" ? { text: renderText() } : undefined); + function renderText(): string { + const lines: string[] = []; + + lines.push(`id: ${file?.fileId ?? "-"}`); + lines.push(`name: ${file?.fileName ?? "-"}`); + lines.push(`type: ${file?.fileType ?? "-"}`); + lines.push(`size: ${file?.sizeBytes ?? "-"}`); + lines.push(`status: ${file?.status ?? "-"}`); + lines.push(`parser: ${file?.parser ?? "-"}`); + lines.push(`category: ${file?.category ?? "-"}`); + lines.push(`uploaded: ${file?.uploadTime ?? "-"}`); + const tags = Array.isArray(file?.tags) ? file.tags.join(", ") : (file?.tags ?? "-"); + lines.push(`tags: ${tags || "-"}`); + + return lines.join("\n"); } - emitBare(`id: ${file?.fileId ?? "-"}`); - emitBare(`name: ${file?.fileName ?? "-"}`); - emitBare(`type: ${file?.fileType ?? "-"}`); - emitBare(`size: ${file?.sizeBytes ?? "-"}`); - emitBare(`status: ${file?.status ?? "-"}`); - emitBare(`parser: ${file?.parser ?? "-"}`); - emitBare(`category: ${file?.category ?? "-"}`); - emitBare(`uploaded: ${file?.uploadTime ?? "-"}`); - const tags = Array.isArray(file?.tags) ? file.tags.join(", ") : (file?.tags ?? "-"); - emitBare(`tags: ${tags || "-"}`); }, }); diff --git a/packages/commands/src/commands/knowledge/file-list.ts b/packages/commands/src/commands/knowledge/file-list.ts index 9f9c764d4..c23e5760b 100644 --- a/packages/commands/src/commands/knowledge/file-list.ts +++ b/packages/commands/src/commands/knowledge/file-list.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagListFileResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts"; const FILE_LIST_FLAGS = { @@ -85,7 +83,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { categoryId: flags.categoryId, @@ -97,7 +95,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listFile); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -108,16 +106,16 @@ export default defineCommand({ }); const files = response.data?.fileList ?? []; - if (settings.quiet) { - for (const file of files) emitBare(file.fileId ?? ""); - return; - } - if (format === "text") { + + ctx.output.result(response, format === "text" ? { text: renderText() } : undefined); + function renderText(): string { + const lines: string[] = []; + if (files.length === 0) { - emitBare("No files found."); + lines.push("No files found."); } else { for (const file of files) { - emitBare( + lines.push( truncateLine( [file.fileId, file.status ?? "-", file.fileName, file.sizeBytes ?? "-"].join(" "), ), @@ -125,9 +123,9 @@ export default defineCommand({ } } const nextToken = response.data?.nextToken; - if (nextToken) emitBare(`next: --next-token ${nextToken}`); - } else { - emitResult(response, format); + if (nextToken) lines.push(`next: --next-token ${nextToken}`); + + return lines.join("\n"); } }, }); diff --git a/packages/commands/src/commands/knowledge/kb-create.ts b/packages/commands/src/commands/knowledge/kb-create.ts index 20e621aab..4e25039bf 100644 --- a/packages/commands/src/commands/knowledge/kb-create.ts +++ b/packages/commands/src/commands/knowledge/kb-create.ts @@ -2,13 +2,11 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type FlagsDef, type RagCreateIndexV2Response, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG, @@ -160,7 +158,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // Fixed values, not exposed as flags in this version: structureType unstructured, sinkType BUILT_IN. // Note: the public docs' example uses sinkType DEFAULT, but BUILT_IN is what works against the live API. @@ -178,7 +176,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexCreateV2); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -210,17 +208,18 @@ export default defineCommand({ } } - if (settings.quiet) { - emitBare(pipelineId ?? ""); - return; - } - if (format === "text") { - emitBare(`index_id: ${pipelineId ?? "-"}`); - if (ingestionId) emitBare(`ingestion_id: ${ingestionId}`); - if (finalStatus) emitBare(`status: ${finalStatus}`); - emitBare("Next: check the import job status, then search against this knowledge base."); - return; + ctx.output.result( + finalStatus ? { ...response, final_status: finalStatus } : response, + format === "text" ? { text: renderText() } : undefined, + ); + function renderText(): string { + const lines: string[] = []; + + lines.push(`index_id: ${pipelineId ?? "-"}`); + if (ingestionId) lines.push(`ingestion_id: ${ingestionId}`); + if (finalStatus) lines.push(`status: ${finalStatus}`); + lines.push("Next: check the import job status, then search against this knowledge base."); + return lines.join("\n"); } - emitResult(finalStatus ? { ...response, final_status: finalStatus } : response, format); }, }); diff --git a/packages/commands/src/commands/knowledge/kb-delete.ts b/packages/commands/src/commands/knowledge/kb-delete.ts index 03bc90375..ea1f460a5 100644 --- a/packages/commands/src/commands/knowledge/kb-delete.ts +++ b/packages/commands/src/commands/knowledge/kb-delete.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const KB_DELETE_FLAGS = { @@ -50,14 +48,14 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // This endpoint is back to snake_case: body { index_id } const body = { index_id: flags.indexId }; const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDelete); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -67,11 +65,9 @@ export default defineCommand({ body, }); - if (settings.quiet) return; - if (format === "text") { - emitBare(`deleted: ${flags.indexId}`); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" ? { text: `deleted: ${flags.indexId}` } : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/kb-info.ts b/packages/commands/src/commands/knowledge/kb-info.ts index f5fbe793f..bc44d1d9a 100644 --- a/packages/commands/src/commands/knowledge/kb-info.ts +++ b/packages/commands/src/commands/knowledge/kb-info.ts @@ -2,7 +2,6 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type Client, @@ -10,7 +9,6 @@ import { type RagIndexListResponse, type RagIndexRow, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const KB_INFO_FLAGS = { @@ -79,54 +77,49 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; if (settings.dryRun) { - emitResult( - { - endpoint: indexDetailUrl(workspaceId, flags.indexId), - request: null, - }, - format, - ); + ctx.output.result({ + endpoint: indexDetailUrl(workspaceId, flags.indexId), + request: null, + }); return; } const row = await fetchIndexDetail(ctx.client, workspaceId, flags.indexId); - if (settings.quiet) { - emitBare(row.id ?? ""); - return; - } - if (format !== "text") { - emitResult(row, format); - return; - } + ctx.output.result(row, format === "text" ? { text: renderText() } : undefined); + function renderText(): string { + const lines: string[] = []; + + // Grouped by diagnostic concern; the immutable annotation on Indexing tells + // users which settings require recreating the knowledge base + lines.push("Basic:"); + lines.push(formatField("id", row.id)); + lines.push(formatField("name", row.name)); + lines.push(formatField("description", row.description)); + lines.push(formatField("dataType", row.dataType)); + lines.push("Indexing: [immutable — recreate required to change]"); + lines.push(formatField("embeddingModelName", row.embeddingModelName)); + lines.push(formatField("embeddingDimension", row.embeddingDimension)); + lines.push(formatField("chunkSize", row.chunkSize)); + lines.push(formatField("overlapSize", row.overlapSize)); + lines.push(formatField("chunkMode", row.chunkMode)); + lines.push(formatField("separator", row.separator)); + lines.push("Retrieval:"); + lines.push(formatField("rerankModelName", row.rerankModelName)); + lines.push(formatField("rerankMinScore", row.rerankMinScore)); + lines.push(formatField("rerankTopN", row.rerankTopN)); + lines.push(formatField("rerankMode", row.rerankMode)); + lines.push(formatField("enableRewrite", row.enableRewrite)); + lines.push(formatField("denseSimilarityTopK", row.denseSimilarityTopK)); + lines.push(formatField("sparseSimilarityTopK", row.sparseSimilarityTopK)); + lines.push("Data:"); + lines.push(formatField("sourceType", row.sourceType)); + lines.push(formatField("connectorId", row.connectorId)); - // Grouped by diagnostic concern; the immutable annotation on Indexing tells - // users which settings require recreating the knowledge base - emitBare("Basic:"); - emitBare(formatField("id", row.id)); - emitBare(formatField("name", row.name)); - emitBare(formatField("description", row.description)); - emitBare(formatField("dataType", row.dataType)); - emitBare("Indexing: [immutable — recreate required to change]"); - emitBare(formatField("embeddingModelName", row.embeddingModelName)); - emitBare(formatField("embeddingDimension", row.embeddingDimension)); - emitBare(formatField("chunkSize", row.chunkSize)); - emitBare(formatField("overlapSize", row.overlapSize)); - emitBare(formatField("chunkMode", row.chunkMode)); - emitBare(formatField("separator", row.separator)); - emitBare("Retrieval:"); - emitBare(formatField("rerankModelName", row.rerankModelName)); - emitBare(formatField("rerankMinScore", row.rerankMinScore)); - emitBare(formatField("rerankTopN", row.rerankTopN)); - emitBare(formatField("rerankMode", row.rerankMode)); - emitBare(formatField("enableRewrite", row.enableRewrite)); - emitBare(formatField("denseSimilarityTopK", row.denseSimilarityTopK)); - emitBare(formatField("sparseSimilarityTopK", row.sparseSimilarityTopK)); - emitBare("Data:"); - emitBare(formatField("sourceType", row.sourceType)); - emitBare(formatField("connectorId", row.connectorId)); + return lines.join("\n"); + } }, }); diff --git a/packages/commands/src/commands/knowledge/kb-list.ts b/packages/commands/src/commands/knowledge/kb-list.ts index 32489bcd0..289117171 100644 --- a/packages/commands/src/commands/knowledge/kb-list.ts +++ b/packages/commands/src/commands/knowledge/kb-list.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagIndexListResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts"; const KB_LIST_FLAGS = { @@ -58,7 +56,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // Pagination and filter parameters must go in the query string — the server ignores them in the body const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList)); @@ -68,7 +66,7 @@ export default defineCommand({ const endpoint = url.toString(); if (settings.dryRun) { - emitResult({ endpoint, request: null }, format); + ctx.output.result({ endpoint, request: null }); return; } @@ -78,16 +76,16 @@ export default defineCommand({ }); const rows = response.data?.rows ?? []; - if (settings.quiet) { - for (const row of rows) emitBare(row.id); - return; - } - if (format === "text") { + + ctx.output.result(response, format === "text" ? { text: renderText() } : undefined); + function renderText(): string { + const lines: string[] = []; + if (rows.length === 0) { - emitBare("No knowledge bases found."); + lines.push("No knowledge bases found."); } else { for (const row of rows) { - emitBare( + lines.push( truncateLine( [ row.id, @@ -100,9 +98,9 @@ export default defineCommand({ ); } } - emitBare(`total: ${response.data?.total ?? rows.length}`); - } else { - emitResult(response, format); + lines.push(`total: ${response.data?.total ?? rows.length}`); + + return lines.join("\n"); } }, }); diff --git a/packages/commands/src/commands/knowledge/kb-stats.ts b/packages/commands/src/commands/knowledge/kb-stats.ts index ff9f7e344..6bb5b9ef8 100644 --- a/packages/commands/src/commands/knowledge/kb-stats.ts +++ b/packages/commands/src/commands/knowledge/kb-stats.ts @@ -2,13 +2,11 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type FlagsDef, type RagMonitorResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const KB_STATS_FLAGS = { @@ -88,7 +86,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const nowSeconds = Math.floor(Date.now() / 1000); const startTimestamp = flags.start @@ -114,7 +112,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexMonitor); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -124,21 +122,26 @@ export default defineCommand({ body, }); - if (format !== "text") { - emitResult(response, format); - return; - } - if (clampedEnd) { - emitBare("note: end time was in the future, clamped to now."); + if (clampedEnd) + ctx.output.info({ + "en-US": "note: end time was in the future, clamped to now.", + "zh-CN": "提示:结束时间晚于当前时间,已截断为当前时间。", + }); + ctx.output.result(response, format === "text" ? { text: renderText() } : undefined); + function renderText(): string { + const lines: string[] = []; + + // Shape verified against the live API: the monitor fields are objects, not arrays + const storage = response.data?.storageMonitorData; + const qps = response.data?.qpsMonitorData; + lines.push(`plan: ${response.data?.pipelineCommercialType ?? "-"}`); + lines.push( + `storage: ${storage?.indexStorageUsage ?? "-"} / ${storage?.indexStorageLimit ?? "-"}`, + ); + lines.push(`peak qps: ${qps?.peakQps ?? "-"}`); + lines.push(`qps windows: ${qps?.monitorData?.length ?? 0} data point(s)`); + + return lines.join("\n"); } - // Shape verified against the live API: the monitor fields are objects, not arrays - const storage = response.data?.storageMonitorData; - const qps = response.data?.qpsMonitorData; - emitBare(`plan: ${response.data?.pipelineCommercialType ?? "-"}`); - emitBare( - `storage: ${storage?.indexStorageUsage ?? "-"} / ${storage?.indexStorageLimit ?? "-"}`, - ); - emitBare(`peak qps: ${qps?.peakQps ?? "-"}`); - emitBare(`qps windows: ${qps?.monitorData?.length ?? 0} data point(s)`); }, }); diff --git a/packages/commands/src/commands/knowledge/kb-update.ts b/packages/commands/src/commands/knowledge/kb-update.ts index b498ea0fc..f2115a9ec 100644 --- a/packages/commands/src/commands/knowledge/kb-update.ts +++ b/packages/commands/src/commands/knowledge/kb-update.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const KB_UPDATE_FLAGS = { @@ -84,7 +82,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // Gotcha: this endpoint names the knowledge base ID parameter `id` (not index_id/indexId) const body = { @@ -96,7 +94,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexUpdate); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -106,11 +104,9 @@ export default defineCommand({ body, }); - if (settings.quiet) return; - if (format === "text") { - emitBare(`updated: ${flags.indexId}`); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" ? { text: `updated: ${flags.indexId}` } : undefined, + ); }, }); diff --git a/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts b/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts index 6f03e6e34..e0746f901 100644 --- a/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts @@ -1,3 +1,9 @@ +import type { + RagGetConnectorResponse, + RagAddCategoryResponse, + RagListCategoryResponse, + RagListFileResponse, +} from "bailian-cli-core"; // J5 data-plane governance: the collection/category/file read-write loop (decoupled from the index plane). // collection reuse-or-create (no delete API, fixed-name idempotent) → category add → visible in list // → upload a file into the self-created category → file list/get → file delete @@ -29,8 +35,14 @@ describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); - let collectionId = collectionGetRun.exitCode === 0 ? collectionGetRun.stdout.trim() : ""; + let collectionId = + collectionGetRun.exitCode === 0 + ? (parseStdoutJson(collectionGetRun.stdout).data?.connectorId ?? + "") + : ""; if (!collectionId) { const collectionCreateRun = await reporter.runStep("collection create", JOURNEY_J5_ROUTES, [ "knowledge", @@ -43,9 +55,13 @@ describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(collectionCreateRun.exitCode, collectionCreateRun.stderr).toBe(0); - collectionId = collectionCreateRun.stdout.trim(); + collectionId = + parseStdoutJson(collectionCreateRun.stdout).data?.connectorId ?? + ""; } expect(collectionId).toBeTruthy(); reporter.trackResource("collection", collectionId, { persistent: true }); @@ -61,9 +77,12 @@ describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(categoryAddRun.exitCode, categoryAddRun.stderr).toBe(0); - categoryId = categoryAddRun.stdout.trim(); + categoryId = + parseStdoutJson(categoryAddRun.stdout).data?.categoryId ?? ""; expect(categoryId).toMatch(/^cate_/); reporter.trackResource("category", categoryId); @@ -76,9 +95,15 @@ describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(categoryListRun.exitCode, categoryListRun.stderr).toBe(0); - expect(categoryListRun.stdout.trim().split("\n")).toContain(categoryId); + expect( + ( + parseStdoutJson(categoryListRun.stdout).data?.categoryList ?? [] + ).map((category) => category.categoryId), + ).toContain(categoryId); // 3) Upload a file into the self-created category → file list/get → file delete // (only touch self-created artifacts) @@ -110,9 +135,15 @@ describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(fileListRun.exitCode, fileListRun.stderr).toBe(0); - expect(fileListRun.stdout.trim().split("\n")).toContain(fileId); + expect( + (parseStdoutJson(fileListRun.stdout).data?.fileList ?? []).map( + (file) => file.fileId, + ), + ).toContain(fileId); const fileGetRun = await reporter.runStep("file get", JOURNEY_J5_ROUTES, [ "knowledge", @@ -193,10 +224,17 @@ describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ], ); if (goneListRun.exitCode === 0) { - expect(goneListRun.stdout.trim().split("\n")).not.toContain(categoryId); + expect( + ( + parseStdoutJson(goneListRun.stdout).data?.categoryList ?? + [] + ).map((category) => category.categoryId), + ).not.toContain(categoryId); } } } diff --git a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts index 6d0b8f6de..770c6f5be 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts @@ -1,3 +1,10 @@ +import type { + RagCreateIndexV2Response, + RagGetConnectorResponse, + RagAddCategoryResponse, + RagListCategoryResponse, + RagListFileResponse, +} from "bailian-cli-core"; // chunk / stats / category / file / collection / import-oss commands: the static // group covers help/missing-args/dry-run gotchas per command; the live group runs // the full chunk chain (add → list → update → delete) plus category/file read-write chains. @@ -927,8 +934,13 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); - let connectorId = firstGetRun.exitCode === 0 ? firstGetRun.stdout.trim() : ""; + let connectorId = + firstGetRun.exitCode === 0 + ? (parseStdoutJson(firstGetRun.stdout).data?.connectorId ?? "") + : ""; if (!connectorId) { const createRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ @@ -942,9 +954,12 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(createRun.exitCode, createRun.stderr).toBe(0); - connectorId = createRun.stdout.trim(); + connectorId = + parseStdoutJson(createRun.stdout).data?.connectorId ?? ""; } expect(connectorId).toBeTruthy(); @@ -1002,9 +1017,12 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(secondPageRun.exitCode, secondPageRun.stderr).toBe(0); - const secondCategoryId = secondPageRun.stdout.trim().split("\n")[0]; + const secondCategoryId = parseStdoutJson(secondPageRun.stdout).data + ?.categoryList?.[0]?.categoryId; expect(secondCategoryId).toBeTruthy(); expect(secondCategoryId).not.toBe(firstCategoryId); }, 60_000); @@ -1022,9 +1040,12 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(categoryAddRun.exitCode, categoryAddRun.stderr).toBe(0); - const categoryId = categoryAddRun.stdout.trim(); + const categoryId = + parseStdoutJson(categoryAddRun.stdout).data?.categoryId ?? ""; expect(categoryId).toMatch(/^cate_/); // category list asserts the new category is found via exact-name filtering @@ -1038,9 +1059,15 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(categoryListRun.exitCode, categoryListRun.stderr).toBe(0); - expect(categoryListRun.stdout.trim().split("\n")).toContain(categoryId); + expect( + ( + parseStdoutJson(categoryListRun.stdout).data?.categoryList ?? [] + ).map((category) => category.categoryId), + ).toContain(categoryId); try { const fixtureDir = mkdtempSync(join(tmpdir(), "file-chain-e2e-")); @@ -1072,9 +1099,15 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(fileListRun.exitCode, fileListRun.stderr).toBe(0); - expect(fileListRun.stdout.trim().split("\n")).toContain(fileId); + expect( + (parseStdoutJson(fileListRun.stdout).data?.fileList ?? []).map( + (file) => file.fileId, + ), + ).toContain(fileId); const getRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", @@ -1110,9 +1143,12 @@ describe.skipIf(!isKbAdminE2EReady())( workspaceId, "--wait", "--quiet", + "--output", + "json", ]); expect(kbCreateRun.exitCode, kbCreateRun.stderr).toBe(0); - const categoryKbId = kbCreateRun.stdout.trim().split("\n")[0]!; + const categoryKbId = + parseStdoutJson(kbCreateRun.stdout).data?.pipelineId ?? ""; expect(categoryKbId).toBeTruthy(); const kbDeleteRun = await deleteKbWithRetry( (args) => runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, args), @@ -1144,9 +1180,15 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(postFileListRun.exitCode, postFileListRun.stderr).toBe(0); - expect(postFileListRun.stdout.trim()).not.toContain(fileId); + expect( + (parseStdoutJson(postFileListRun.stdout).data?.fileList ?? []).map( + (file) => file.fileId, + ), + ).not.toContain(fileId); } finally { // Clean up the self-created category (doubles as live coverage of category delete) const categoryDeleteRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ @@ -1171,9 +1213,16 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(postCategoryListRun.exitCode, postCategoryListRun.stderr).toBe(0); - expect(postCategoryListRun.stdout.trim()).not.toContain(categoryId); + expect( + ( + parseStdoutJson(postCategoryListRun.stdout).data + ?.categoryList ?? [] + ).map((category) => category.categoryId), + ).not.toContain(categoryId); } // kb create --wait adds a full import phase — generous timeout }, 600_000); @@ -1212,9 +1261,12 @@ describe.skipIf(!isKbAdminE2EReady())( workspaceId, "--wait", "--quiet", + "--output", + "json", ]); expect(createRun.exitCode, createRun.stderr).toBe(0); - const indexId = createRun.stdout.trim().split("\n")[0]!; + const indexId = + parseStdoutJson(createRun.stdout).data?.pipelineId ?? ""; try { // add @@ -1579,9 +1631,12 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(categoryAddRun.exitCode, categoryAddRun.stderr).toBe(0); - const categoryId = categoryAddRun.stdout.trim(); + const categoryId = + parseStdoutJson(categoryAddRun.stdout).data?.categoryId ?? ""; expect(categoryId).toMatch(/^cate_/); // ── empty-result contract: a freshly created category has no files — @@ -1637,8 +1692,14 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]), - (run) => run.exitCode === 0 && run.stdout.trim().split("\n").includes(fileId), + (run) => + run.exitCode === 0 && + (parseStdoutJson(run.stdout).data?.fileList ?? []) + .map((file) => file.fileId) + .includes(fileId), { timeoutMs: 60_000, intervalMs: 5_000 }, ); expect( @@ -1658,9 +1719,15 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(fileListByFileIdRun.exitCode, fileListByFileIdRun.stderr).toBe(0); - expect(fileListByFileIdRun.stdout.trim().split("\n")).toContain(fileId); + expect( + (parseStdoutJson(fileListByFileIdRun.stdout).data?.fileList ?? []).map( + (file) => file.fileId, + ), + ).toContain(fileId); // ── P2: file list --max-result 1 ── const fileListMaxResultRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ @@ -1674,10 +1741,13 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(fileListMaxResultRun.exitCode, fileListMaxResultRun.stderr).toBe(0); - const maxResultLines = fileListMaxResultRun.stdout.trim().split("\n").filter(Boolean); - expect(maxResultLines.length).toBeLessThanOrEqual(1); + const maxResultFiles = + parseStdoutJson(fileListMaxResultRun.stdout).data?.fileList ?? []; + expect(maxResultFiles.length).toBeLessThanOrEqual(1); // ── P2: file list --next-token (if paginated) ── const fileListJsonRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ @@ -1710,6 +1780,8 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(fileListNextTokenRun.exitCode, fileListNextTokenRun.stderr).toBe(0); } @@ -1724,6 +1796,8 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listByParentRun.exitCode, listByParentRun.stderr).toBe(0); @@ -1739,6 +1813,8 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listByCollectionRun.exitCode, "不存在的 collection id 应被服务端拒绝").not.toBe(0); expect(listByCollectionRun.stderr).toMatch(/Invalid connectorId|InvalidParameter/i); @@ -1757,9 +1833,12 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li workspaceId, "--wait", "--quiet", + "--output", + "json", ]); expect(kbCreateRun.exitCode, kbCreateRun.stderr).toBe(0); - indexId = kbCreateRun.stdout.trim().split("\n")[0]!; + indexId = + parseStdoutJson(kbCreateRun.stdout).data?.pipelineId ?? ""; expect(indexId).toBeTruthy(); try { @@ -2208,8 +2287,13 @@ describe.skipIf(!isOssImportE2EReady())("e2e: doc import-oss live (授权 bucket "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); - let connectorId = firstGetRun.exitCode === 0 ? firstGetRun.stdout.trim() : ""; + let connectorId = + firstGetRun.exitCode === 0 + ? (parseStdoutJson(firstGetRun.stdout).data?.connectorId ?? "") + : ""; if (!connectorId) { const createRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ @@ -2229,9 +2313,12 @@ describe.skipIf(!isOssImportE2EReady())("e2e: doc import-oss live (授权 bucket "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(createRun.exitCode, createRun.stderr).toBe(0); - connectorId = createRun.stdout.trim(); + connectorId = + parseStdoutJson(createRun.stdout).data?.connectorId ?? ""; } expect(connectorId).toBeTruthy(); @@ -2269,9 +2356,12 @@ describe.skipIf(!isOssImportE2EReady())("e2e: doc import-oss live (授权 bucket "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(categoryAddRun.exitCode, categoryAddRun.stderr).toBe(0); - const categoryId = categoryAddRun.stdout.trim(); + const categoryId = + parseStdoutJson(categoryAddRun.stdout).data?.categoryId ?? ""; expect(categoryId).toMatch(/^cate_/); try { @@ -2308,9 +2398,15 @@ describe.skipIf(!isOssImportE2EReady())("e2e: doc import-oss live (授权 bucket "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(fileListRun.exitCode, fileListRun.stderr).toBe(0); - expect(fileListRun.stdout.trim().split("\n")).toContain(fileId); + expect( + (parseStdoutJson(fileListRun.stdout).data?.fileList ?? []).map( + (file) => file.fileId, + ), + ).toContain(fileId); // file get read-back: tags contain "e2e-oss-tag" const fileGetRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-list.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-list.e2e.test.ts index 785dd83f3..4d6c1e464 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-list.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-list.e2e.test.ts @@ -1,3 +1,4 @@ +import type { RagIndexListResponse } from "bailian-cli-core"; import { describe, expect, test } from "vite-plus/test"; import { isKbAdminE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "../helpers.ts"; import { KNOWLEDGE_DOC_LIST_ROUTES } from "../topic-routes.ts"; @@ -76,8 +77,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc list (live)", () => { "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); - const firstIndexId = listRun.stdout.trim().split("\n")[0]; + const firstIndexId = + parseStdoutJson(listRun.stdout).data?.rows?.[0]?.id ?? ""; if (!firstIndexId) return; const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [ "knowledge", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts index 68c016a29..b1cdf745d 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts @@ -1,3 +1,4 @@ +import type { RagCreateIndexV2Response } from "bailian-cli-core"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -156,9 +157,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc status (live, 自清 workspaceId, "--wait", "--quiet", + "--output", + "json", ]); expect(createRun.exitCode, createRun.stderr).toBe(0); - indexId = createRun.stdout.trim().split("\n")[0]!; + indexId = parseStdoutJson(createRun.stdout).data?.pipelineId ?? ""; expect(indexId).toBeTruthy(); // 3) Upload file B to the base (--index-id --wait --output json) → ingestion_id diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts index ad05959d2..ca35bca3f 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts @@ -1,3 +1,4 @@ +import type { RagIndexListResponse } from "bailian-cli-core"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -338,9 +339,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc upload (live)", () => "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listRun.exitCode, listRun.stderr).toBe(0); - const indexId = listRun.stdout.trim().split("\n")[0]; + const indexId = parseStdoutJson(listRun.stdout).data?.rows?.[0]?.id ?? ""; if (!indexId) return; const pollFilePath = join(fixtureDir, `poll-${Date.now()}.md`); diff --git a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts index 1576a7d2a..61c1bc120 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts @@ -1,3 +1,4 @@ +import type { RagCreateIndexV2Response, RagIndexListResponse } from "bailian-cli-core"; // The live group chains the full lifecycle: upload → create → update → upload --index-id // (import orchestration) → delete → list to verify removal. import { mkdtempSync, writeFileSync } from "node:fs"; @@ -119,9 +120,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb 写链路 (live, 自清 "--poll-interval", "3", "--quiet", + "--output", + "json", ]); expect(createRun.exitCode, createRun.stderr).toBe(0); - indexId = createRun.stdout.trim().split("\n")[0]!; + indexId = parseStdoutJson(createRun.stdout).data?.pipelineId ?? ""; expect(indexId).toBeTruthy(); // 2.5) Live update coverage: name / description / rerank threshold in one call @@ -258,9 +261,13 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb 写链路 (live, 自清 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listRun.exitCode, listRun.stderr).toBe(0); - const remainingIds = listRun.stdout.trim().split("\n"); + const remainingIds = ( + parseStdoutJson(listRun.stdout).data?.rows ?? [] + ).map((row) => row.id); expect(remainingIds).not.toContain(indexId); chainCompleted = true; } finally { diff --git a/packages/commands/tests/e2e/knowledge/knowledge-kb-info.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-kb-info.e2e.test.ts index 41236cac5..f4dfea215 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-kb-info.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-kb-info.e2e.test.ts @@ -1,3 +1,4 @@ +import type { RagIndexListResponse } from "bailian-cli-core"; import { describe, expect, test } from "vite-plus/test"; import { isKbAdminE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "../helpers.ts"; import { KNOWLEDGE_KB_INFO_ROUTES } from "../topic-routes.ts"; @@ -44,7 +45,7 @@ describe("e2e: knowledge kb info", () => { }); }); -interface KbListQuietResult { +interface KbListResult { stdout: string; } @@ -52,14 +53,16 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb info (live)", () => { const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; test("先 list 取 id 再 info 断言分组字段", async () => { - const listRun: KbListQuietResult = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [ + const listRun: KbListResult = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [ "knowledge", "list", "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); - const firstId = listRun.stdout.trim().split("\n")[0]; + const firstId = parseStdoutJson(listRun.stdout).data?.rows?.[0]?.id ?? ""; if (!firstId) return; // skip assertions when the workspace has no knowledge bases const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [ "knowledge", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-kb-list.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-kb-list.e2e.test.ts index 2866dd8d3..ef7c394b5 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-kb-list.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-kb-list.e2e.test.ts @@ -104,7 +104,7 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb list (live)", () => { expect(Array.isArray(data.data.rows)).toBe(true); }); - test("--quiet 输出行数与 rows 数一致", async () => { + test("--quiet 保留 JSON 中的完整知识库列表", async () => { const jsonRun = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [ "knowledge", "list", @@ -120,9 +120,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb list (live)", () => { "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); - const lines = quietRun.stdout.trim().split("\n").filter(Boolean); - expect(lines.length).toBe(rows.length); + const quietRows = parseStdoutJson(quietRun.stdout).data.rows; + expect(quietRows.map((row) => row.id)).toEqual(rows.map((row) => row.id)); }); test("--name 过滤 + --page-number/--page-size 分页", async () => { diff --git a/packages/commands/tests/e2e/knowledge/knowledge.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge.e2e.test.ts index 949c6957c..a009aa4ba 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge.e2e.test.ts @@ -1,3 +1,4 @@ +import type { RagIndexListResponse } from "bailian-cli-core"; import { describe, expect, test } from "vite-plus/test"; import { isDashScopeE2EReady, @@ -207,9 +208,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge retrieve 参数补全 (liv "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listRun.exitCode, listRun.stderr).toBe(0); - const indexId = listRun.stdout.trim().split("\n")[0]; + const indexId = parseStdoutJson(listRun.stdout).data?.rows?.[0]?.id ?? ""; if (!indexId) return; // P6: --rerank-instruct (live) diff --git a/packages/commands/tests/output/knowledge-admin-output.test.ts b/packages/commands/tests/output/knowledge-admin-output.test.ts new file mode 100644 index 000000000..d264cfe36 --- /dev/null +++ b/packages/commands/tests/output/knowledge-admin-output.test.ts @@ -0,0 +1,487 @@ +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + BailianError, + buildSettings, + ExitCode, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import * as commands from "bailian-cli-commands"; + +const now = Date.parse("2026-10-01T00:00:00Z"); +const row = { + id: "idx-test", + name: "Demo", + description: "Team docs", + embeddingModelName: "text-embedding-v4", + chunkSize: 600, + enableRewrite: false, + rerankMinScore: 0, + connectorId: "conn-test", +}; +const created = { + request_id: "request-create", + data: { pipelineId: "idx-test", ingestionId: "job-test" }, +}; +const mutation = { request_id: "request-mutation", data: { success: true } }; +const categories = { + request_id: "request-category", + data: { + categoryList: [{ categoryId: "cate-test", categoryName: "Docs", isDefault: true }], + nextToken: "next-category", + }, +}; +const file = { + fileId: "file-test", + fileName: "a.md", + fileType: "md", + sizeBytes: 0, + status: "READY", + parser: "default", + category: "cate-test", + tags: ["docs", "team"], +}; +const collection = { connectorId: "conn-test", connectorName: "Demo", description: "Team docs" }; +interface Scenario { + name: string; + command: AnyCommand; + flags: Record; + response: Record; + want: unknown; + text: string; +} +const cases: Scenario[] = [ + { + name: "knowledge create", + command: commands.knowledgeKbCreate, + flags: { name: "Demo", description: "Team docs", docId: ["file-test"] }, + response: created, + want: created, + text: "index_id: idx-test", + }, + { + name: "knowledge list", + command: commands.knowledgeKbList, + flags: { name: "Demo", pageNumber: 2, pageSize: 3 }, + response: { request_id: "request-list", data: { rows: [row], total: 7 } }, + want: { request_id: "request-list", data: { rows: [row], total: 7 } }, + text: "total: 7", + }, + { + name: "knowledge info", + command: commands.knowledgeKbInfo, + flags: { indexId: "idx-test" }, + response: { request_id: "request-info", data: { rows: [row] } }, + want: row, + text: "Retrieval:", + }, + { + name: "knowledge update", + command: commands.knowledgeKbUpdate, + flags: { indexId: "idx-test", description: "", rerankMinScore: 0 }, + response: mutation, + want: mutation, + text: "updated: idx-test", + }, + { + name: "knowledge delete", + command: commands.knowledgeKbDelete, + flags: { indexId: "idx-test" }, + response: mutation, + want: mutation, + text: "deleted: idx-test", + }, + { + name: "knowledge stats", + command: commands.knowledgeKbStats, + flags: { indexId: "idx-test" }, + response: { + request_id: "request-stats", + data: { + pipelineCommercialType: "FREE", + storageMonitorData: { indexStorageUsage: 0, indexStorageLimit: 100 }, + qpsMonitorData: { peakQps: 0, monitorData: [] }, + }, + }, + want: { + request_id: "request-stats", + data: { + pipelineCommercialType: "FREE", + storageMonitorData: { indexStorageUsage: 0, indexStorageLimit: 100 }, + qpsMonitorData: { peakQps: 0, monitorData: [] }, + }, + }, + text: "storage: 0 / 100", + }, + { + name: "knowledge category list", + command: commands.knowledgeCategoryList, + flags: { + collectionId: "conn-test", + parentId: "parent-test", + name: "Docs", + nextToken: "cursor-in", + maxResult: 0, + }, + response: categories, + want: categories, + text: "next: --next-token next-category", + }, + { + name: "knowledge category add", + command: commands.knowledgeCategoryAdd, + flags: { name: "Docs", collectionId: "conn-test", parentId: "parent-test" }, + response: { request_id: "request-category", data: { categoryId: "cate-test" } }, + want: { request_id: "request-category", data: { categoryId: "cate-test" } }, + text: "created: cate-test (Docs)", + }, + { + name: "knowledge category delete", + command: commands.knowledgeCategoryDelete, + flags: { categoryId: "cate-test" }, + response: mutation, + want: mutation, + text: "deleted: cate-test", + }, + { + name: "knowledge file list", + command: commands.knowledgeFileList, + flags: { + categoryId: "cate-test", + name: "a", + fileId: ["file-test", "file-other"], + nextToken: "cursor-in", + maxResult: 0, + }, + response: { request_id: "request-file", data: { fileList: [file], nextToken: "next-file" } }, + want: { request_id: "request-file", data: { fileList: [file], nextToken: "next-file" } }, + text: "next: --next-token next-file", + }, + { + name: "knowledge file get", + command: commands.knowledgeFileGet, + flags: { fileId: "file-test" }, + response: { request_id: "request-file", data: file }, + want: { request_id: "request-file", data: file }, + text: "tags: docs, team", + }, + { + name: "knowledge file delete", + command: commands.knowledgeFileDelete, + flags: { fileId: "file-test" }, + response: mutation, + want: mutation, + text: "deleted: file-test", + }, + { + name: "knowledge collection create", + command: commands.knowledgeCollectionCreate, + flags: { + name: "Demo", + description: "Team docs", + storeType: "custom", + ossRegion: "cn-beijing", + ossBucket: "bucket-test", + }, + response: { request_id: "request-collection", data: collection }, + want: { request_id: "request-collection", data: collection }, + text: "created: conn-test (Demo, CUSTOM)", + }, + { + name: "knowledge collection get", + command: commands.knowledgeCollectionGet, + flags: { collectionId: "conn-test" }, + response: { request_id: "request-collection", data: collection }, + want: { request_id: "request-collection", data: collection }, + text: "description: Team docs", + }, +]; +let requests: Array<{ path: string; method?: string; body?: unknown }> = []; +let failed = false; +let jobResponse: unknown = { + request_id: "request-job", + data: { ingestion_status: "COMPLETED", rows: [{ doc_id: "file-test", code: "FINISH" }] }, +}; +beforeEach(() => { + vi.spyOn(Date, "now").mockReturnValue(now); +}); +afterEach(() => { + vi.restoreAllMocks(); + requests = []; + failed = false; + jobResponse = { + request_id: "request-job", + data: { ingestion_status: "COMPLETED", rows: [{ doc_id: "file-test", code: "FINISH" }] }, + }; +}); +async function invoke( + scenario: Scenario, + display: { + format: "text" | "json"; + quiet?: boolean; + verbose?: boolean; + dryRun?: boolean; + workspace?: string; + }, +) { + let stdout = "", + stderr = "", + strayOut = "", + strayErr = ""; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + strayOut += String(chunk); + return true; + }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + strayErr += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + file: { workspace_id: display.workspace ?? "ws-test" }, + env: {}, + configPath: "/tmp/knowledge-output/config.json", + }); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write(chunk) { + stdout += chunk; + }, + }, + stderr: { + write(chunk) { + stderr += chunk; + }, + }, + }); + const client = { + requestJson: async (options: { path: string; method?: string; body?: unknown }) => { + requests.push(structuredClone(options)); + if (failed) + throw new BailianError("Original knowledge service error", ExitCode.GENERAL, undefined, { + api: { requestId: "request-error", apiCode: "Rejected" }, + }); + return structuredClone( + options.path.includes("index_job/status") ? jobResponse : scenario.response, + ); + }, + }; + let exitCode = 0; + try { + try { + await scenario.command.run({ + settings, + flags: scenario.flags, + client, + output: reporter.output, + localize, + identity: { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }, + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, strayOut, strayErr, exitCode }; + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + } +} +const matrix = cases.flatMap((scenario) => + (["text", "json"] as const).flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ scenario, name: scenario.name, format, quiet, verbose })), + ), + ), +); +test.each(matrix)( + "$name complete result: $format q=$quiet v=$verbose", + async ({ scenario, ...display }) => { + const result = await invoke(scenario, display); + expect(result.exitCode, result.stderr + result.strayErr).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + expect(requests).toHaveLength(1); + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(scenario.want); + else expect(result.stdout).toContain(scenario.text); + expect(result.stderr).toBe(""); + }, +); +test.each(cases)("$name dry-run is one JSON document and makes no requests", async (scenario) => { + const result = await invoke(scenario, { format: "json", quiet: true, dryRun: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr + result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ endpoint: expect.any(String) }); + expect(requests).toEqual([]); +}); +test.each(cases)("$name preserves structured service errors", async (scenario) => { + failed = true; + const result = await invoke(scenario, { format: "json", quiet: true, verbose: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.strayOut + result.strayErr).toBe(""); + expect(JSON.parse(result.stderr).error).toMatchObject({ + message: "Original knowledge service error", + request_id: "request-error", + api_code: "Rejected", + }); +}); +test.each([false, true])( + "create --wait preserves final status and request order, quiet=%s", + async (quiet) => { + const result = await invoke( + { ...cases[0]!, flags: { ...cases[0]!.flags, wait: true } }, + { format: "json", quiet }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr + result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ ...created, final_status: "COMPLETED" }); + expect(requests).toHaveLength(2); + expect(new URL(requests[1]!.path).searchParams.get("index_id")).toBe("idx-test"); + expect(new URL(requests[1]!.path).searchParams.get("job_id")).toBe("job-test"); + }, +); +test("create --wait document failure preserves the service detail and created-index hint", async () => { + jobResponse = { + request_id: "request-job", + data: { + ingestion_status: "COMPLETED", + rows: [{ doc_id: "file-test", code: "PARSE_FAILED", message: "Original parse failure" }], + }, + }; + const result = await invoke( + { ...cases[0]!, flags: { ...cases[0]!.flags, wait: true } }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.strayOut + result.strayErr).toBe(""); + expect(JSON.parse(result.stderr).error).toMatchObject({ + message: expect.stringContaining("Original parse failure"), + hint: "Knowledge base created: idx-test", + request_id: "request-job", + }); +}); +test.each([false, true])( + "clamped-end process note is on stderr and follows quiet=%s", + async (quiet) => { + const result = await invoke( + { ...cases[5]!, flags: { indexId: "idx-test", end: "2099-01-01" } }, + { format: "text", quiet }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + expect(result.stdout).not.toContain("clamped"); + expect(result.stderr).toBe(quiet ? "" : "note: end time was in the future, clamped to now.\n"); + expect(requests[0]!.body).toMatchObject({ endTimestamp: "1790812800" }); + }, +); +test("list query, zero/empty update and collection custom storage payload remain unchanged", async () => { + await invoke(cases[1]!, { format: "json", quiet: true }); + const listUrl = new URL(requests[0]!.path); + expect(requests[0]!.method).toBe("GET"); + expect(requests[0]!.body).toBeUndefined(); + expect(Object.fromEntries(listUrl.searchParams)).toEqual({ + pipeline_name: "Demo", + page_number: "2", + page_size: "3", + }); + requests = []; + await invoke(cases[3]!, { format: "json", quiet: true }); + expect(requests[0]!.body).toEqual({ id: "idx-test", description: "", rerankMinScore: 0 }); + requests = []; + await invoke(cases[12]!, { format: "json", quiet: true }); + expect(requests[0]!.body).toEqual({ + connectorType: "FILE", + connectorName: "Demo", + description: "Team docs", + fileConnectorConfig: { storeType: "CUSTOM", regionId: "cn-beijing", bucketName: "bucket-test" }, + }); +}); + +test.each([1, 6, 9])("empty list $0 keeps its response and pagination in JSON", async (index) => { + const scenario = cases[index]!; + const response = { + request_id: "request-empty", + data: { rows: [], categoryList: [], fileList: [], total: 0, nextToken: "next-empty" }, + }; + const result = await invoke({ ...scenario, response }, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual(response); +}); +test.each([10, 13])( + "missing data in $0 keeps the text placeholders and raw JSON", + async (index) => { + const scenario = { ...cases[index]!, response: { request_id: "request-empty" } }; + const text = await invoke(scenario, { format: "text", quiet: true }); + expect(text.exitCode, text.stderr).toBe(0); + expect(text.strayOut + text.strayErr).toBe(""); + expect(text.stdout).toContain("id: -"); + const json = await invoke(scenario, { format: "json", quiet: true }); + expect(json.exitCode, json.stderr).toBe(0); + expect(JSON.parse(json.stdout)).toEqual({ request_id: "request-empty" }); + }, +); +test("missing knowledge base details remain a terminal failure without a partial result", async () => { + const result = await invoke( + { ...cases[2]!, response: { data: { rows: [] } } }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.strayOut + result.strayErr).toBe(""); + expect(JSON.parse(result.stderr).error.message).toBe("Knowledge base not found: idx-test"); +}); +test.each([false, true])("waiting progress stays on stderr and follows quiet=%s", async (quiet) => { + const result = await invoke( + { ...cases[0]!, flags: { ...cases[0]!.flags, wait: true } }, + { format: "text", quiet }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + expect(result.stdout).toContain("status: COMPLETED"); + expect(result.stdout).not.toContain("Polling"); + if (quiet) expect(result.stderr).toBe(""); + else expect(result.stderr).toContain("Status: COMPLETED"); +}); +test.each([false, true])("JSON hides the clamped-end info under quiet=%s", async (quiet) => { + const result = await invoke( + { ...cases[5]!, flags: { indexId: "idx-test", end: "2099-01-01" } }, + { format: "json", quiet }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr + result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual(cases[5]!.want); +}); +test("text truncation stays terminal-only and never clips the JSON result", async () => { + const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"), + columns = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + const longName = "A very long file name with useful trailing information.md"; + const scenario = { + ...cases[9]!, + response: { data: { fileList: [{ ...file, fileName: longName }] } }, + }; + try { + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + Object.defineProperty(process.stdout, "columns", { configurable: true, value: 24 }); + const text = await invoke(scenario, { format: "text", quiet: true }); + expect(text.exitCode, text.stderr).toBe(0); + expect(text.stdout.trimEnd()).toHaveLength(24); + expect(text.stdout).toContain("…"); + const json = await invoke(scenario, { format: "json", quiet: true }); + expect(JSON.parse(json.stdout).data.fileList[0].fileName).toBe(longName); + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); + const pipe = await invoke(scenario, { format: "text", quiet: true }); + expect(pipe.stdout).toContain(longName); + } finally { + if (tty) Object.defineProperty(process.stdout, "isTTY", tty); + else delete (process.stdout as { isTTY?: boolean }).isTTY; + if (columns) Object.defineProperty(process.stdout, "columns", columns); + else delete (process.stdout as { columns?: number }).columns; + } +}); From f5adaf05751cb8b91c7fc6f7b96802025ef732cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 30 Oct 2026 10:26:29 +0800 Subject: [PATCH 11/28] refactor(runtime): centralize output policy and extension reporting Route this command family through the invocation output contract while preserving business results and request behavior. Validation: isolated combined regression suite; normal repository pre-commit checks. --- docs/agents/command-pack.md | 8 + .../tests/fixtures/command-pack/commands.mjs | 6 +- .../commands/src/commands/pipeline/run.ts | 78 ++++-- .../src/commands/pipeline/validate.ts | 42 +-- .../commands/src/commands/plugin/install.ts | 5 +- packages/commands/src/commands/plugin/link.ts | 5 +- packages/commands/src/commands/plugin/list.ts | 26 +- .../commands/src/commands/plugin/remove.ts | 5 +- .../src/commands/shared/model-stream.ts | 46 ++++ .../commands/src/commands/shared/telemetry.ts | 11 +- .../commands/tests/e2e/pipeline.e2e.test.ts | 49 +++- .../commands/tests/output/command-context.ts | 13 + .../tests/output/extension-output.test.ts | 258 ++++++++++++++++++ packages/commands/tests/telemetry.test.ts | 2 + packages/core/src/client/bailian-control.ts | 3 + packages/core/src/console/gateway.ts | 2 + packages/core/src/types/command.ts | 8 +- packages/runtime/src/command-packs/manager.ts | 59 +++- .../runtime/src/command-packs/validate.ts | 54 +++- packages/runtime/src/create-cli.ts | 19 +- packages/runtime/src/pipeline/bl-config.ts | 11 +- packages/runtime/src/pipeline/executor.ts | 3 +- packages/runtime/src/pipeline/types.ts | 3 + .../runtime/tests/command-pack-output.test.ts | 186 +++++++++++++ packages/runtime/tests/command-packs.test.ts | 210 ++++++++------ packages/runtime/tests/harness/output-main.ts | 5 +- .../tests/output-default-policy.e2e.test.ts | 133 +++++++++ .../tests/output-verbose-flags.e2e.test.ts | 163 +++++++++++ skills/bailian-cli/reference/pipeline.md | 18 +- 29 files changed, 1239 insertions(+), 192 deletions(-) create mode 100644 packages/commands/src/commands/shared/model-stream.ts create mode 100644 packages/commands/tests/output/command-context.ts create mode 100644 packages/commands/tests/output/extension-output.test.ts create mode 100644 packages/runtime/tests/command-pack-output.test.ts create mode 100644 packages/runtime/tests/output-default-policy.e2e.test.ts create mode 100644 packages/runtime/tests/output-verbose-flags.e2e.test.ts diff --git a/docs/agents/command-pack.md b/docs/agents/command-pack.md index 57cb2b271..f5c2ac8a9 100644 --- a/docs/agents/command-pack.md +++ b/docs/agents/command-pack.md @@ -39,6 +39,14 @@ - [ ] 单包失败必须 fail-open:保留内置命令和其他合法 pack。 - [ ] 破坏协议前优先在适配层兼容;确实无法兼容时才提升 `apiVersion`。 +## 输出边界 + +- 管理命令通过本次 `ctx.output.result` 提交完整结果;npm 日志由显式注入的 `CommandOutput` 分帧,普通日志走 info,npm warning/error 行走 warning,JSON stderr 不直接写入子进程原始文本。 +- API 1 继续支持 `result/line/write`。适配层仅在当前 invocation 内收集输出:显式 result 的数据保持原样,line/write 是其 text 呈现;只调用 line/write 的命令在 JSON 下返回收集后的字符串,不增加 envelope。 +- 多次提交 result 是包的协议错误(GENERAL),不输出部分文档;一次显式提交的报告在后续异常时仍先输出,再由 runtime 处理原始异常。 +- text 仍按原顺序呈现片段;统一 result writer 会为末尾没有换行的文本补一个 LF。包直接调用 process/console 的输出不属于 host API,不做进程级拦截。 +- Pipeline 的普通 run/validate 输出一次最终报告,失败报告后抛错。显式 `--output text --events jsonl` 保留原始生命周期事件协议;有效 JSON 输出(含 env/Profile)与 `--events jsonl` 冲突时,在读取工作流文件及执行前拒绝。 + ## 测试与文档 - [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。 diff --git a/packages/cli/tests/fixtures/command-pack/commands.mjs b/packages/cli/tests/fixtures/command-pack/commands.mjs index 19a9fd650..afe730cda 100644 --- a/packages/cli/tests/fixtures/command-pack/commands.mjs +++ b/packages/cli/tests/fixtures/command-pack/commands.mjs @@ -15,7 +15,7 @@ const ping = { usageArgs: "--message ", exampleArgs: ['--message "hello"'], async run(ctx) { - process.stdout.write(`command-pack:${ctx.flags.message}\n`); + ctx.output.line(`command-pack:${ctx.flags.message}`); }, }; @@ -44,9 +44,7 @@ const credential = { auth: "apiKey", async run(ctx) { const apiKey = ctx.credentials.apiKey(); - process.stdout.write( - `credential-source:${apiKey.source} credential-base-url:${apiKey.baseUrl}\n`, - ); + ctx.output.line(`credential-source:${apiKey.source} credential-base-url:${apiKey.baseUrl}`); }, }; diff --git a/packages/commands/src/commands/pipeline/run.ts b/packages/commands/src/commands/pipeline/run.ts index 6974955c2..209f7cf79 100644 --- a/packages/commands/src/commands/pipeline/run.ts +++ b/packages/commands/src/commands/pipeline/run.ts @@ -1,7 +1,12 @@ import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; -import { defineCommand, type FlagsDef, type ParsedFlags } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { + BailianError, + UsageError, + defineCommand, + type FlagsDef, + type ParsedFlags, +} from "bailian-cli-core"; import { initPipelineSteps } from "bailian-cli-runtime"; import { executePipeline, streamPipelineEvents } from "bailian-cli-runtime"; import type { PipelineLifecycleEvent } from "bailian-cli-runtime"; @@ -41,7 +46,10 @@ const RUN_FLAGS = { events: { type: "string", valueHint: "", - description: { "en-US": "Emit lifecycle events: jsonl", "zh-CN": "输出生命周期事件:jsonl" }, + description: { + "en-US": "Emit raw lifecycle events: jsonl (requires text output)", + "zh-CN": "输出原始生命周期事件:jsonl(需 text 输出模式)", + }, choices: ["jsonl"] as const, }, stepTimeout: { @@ -70,7 +78,7 @@ export default defineCommand({ }, "--file workflow.json --input-file inputs.json --concurrency 3", "--file workflow.yaml --dry-run", - "--file workflow.json --events jsonl", + "--file workflow.json --output text --events jsonl", "--file workflow.yaml --output json", ], validate: (f) => (f.input && f.inputFile ? "use --input or --input-file, not both" : undefined), @@ -78,9 +86,22 @@ export default defineCommand({ const { settings, flags } = ctx; const file = flags.file; - initPipelineSteps(); - const eventsFormat = flags.events; + if (eventsFormat === "jsonl" && settings.output === "json") { + throw new UsageError( + ctx.localize({ + "en-US": "Lifecycle events cannot be combined with JSON result output.", + "zh-CN": "生命周期事件不能与 JSON 结果输出组合使用。", + }), + ctx.localize({ + "en-US": + "Use --output text --events jsonl for the lifecycle stream, or --output json without --events for the final report.", + "zh-CN": + "使用 --output text --events jsonl 获取生命周期事件流,或使用 --output json 且不指定 --events 获取最终报告。", + }), + ); + } + initPipelineSteps(); const filePath = resolve(file); const pipeline = await loadPipelineFile(filePath); @@ -89,6 +110,7 @@ export default defineCommand({ if (eventsFormat === "jsonl") { for await (const event of streamPipelineEvents(pipeline, runtimeInput, { + output: ctx.output, concurrency: flags.concurrency, basePath, dryRun: settings.dryRun, @@ -100,20 +122,28 @@ export default defineCommand({ } const report = await executePipeline(pipeline, runtimeInput, { + output: ctx.output, concurrency: flags.concurrency, basePath, dryRun: settings.dryRun, timeoutSeconds: flags.stepTimeout, - onEvent: settings.verbose ? logEvent : undefined, + onEvent: (event) => + ctx.output.debug({ + code: "PIPELINE_EVENT", + message: formatEvent(event), + details: event, + }), }); - if (settings.output === "json") { - emitResult(report, "json"); - } else { - printTextReport(report); + ctx.output.result(report, { text: formatTextReport(report) }); + if (report.status === "failed") { + throw new BailianError( + ctx.localize({ + "en-US": "Pipeline failed.", + "zh-CN": "Pipeline 执行失败。", + }), + ); } - - if (report.status === "failed") process.exitCode = 1; }, }); @@ -128,17 +158,12 @@ async function resolveRuntimeInput(flags: RunFlags): Promise; -}): void { +}): string { + const lines: string[] = []; const statusIcon = report.status === "succeeded" ? "+" : report.status === "failed" ? "x" : "~"; - process.stdout.write(`\nPipeline ${report.status} [${statusIcon}]\n\n`); + lines.push(`\nPipeline ${report.status} [${statusIcon}]\n`); for (const step of report.steps) { const icon = step.status === "succeeded" @@ -171,7 +197,7 @@ function printTextReport(report: { let line = ` [${icon}] ${step.id} (${step.type}) — ${step.status}`; if (step.skipReason) line += ` (${step.skipReason})`; if (step.error) line += `: ${step.error.message}`; - process.stdout.write(line + "\n"); + lines.push(line); } - process.stdout.write("\n"); + return lines.join("\n") + "\n"; } diff --git a/packages/commands/src/commands/pipeline/validate.ts b/packages/commands/src/commands/pipeline/validate.ts index e5594dc24..9d280de8a 100644 --- a/packages/commands/src/commands/pipeline/validate.ts +++ b/packages/commands/src/commands/pipeline/validate.ts @@ -1,6 +1,5 @@ import { resolve } from "node:path"; -import { defineCommand } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { BailianError, defineCommand } from "bailian-cli-core"; import { initPipelineSteps } from "bailian-cli-runtime"; import { collectPipelineIssues, collectPipelineHints } from "bailian-cli-runtime"; import { loadPipelineFile } from "./load-file.ts"; @@ -25,7 +24,7 @@ export default defineCommand({ }, exampleArgs: ["--file workflow.yaml", "--file workflow.json --output json"], async run(ctx) { - const { settings, flags } = ctx; + const { flags } = ctx; const file = flags.file; initPipelineSteps(); @@ -35,23 +34,28 @@ export default defineCommand({ const issues = collectPipelineIssues(pipeline); const hints = issues.length === 0 ? collectPipelineHints(pipeline) : []; - if (settings.output === "json") { - emitResult( - { valid: issues.length === 0, issues, ...(hints.length > 0 ? { hints } : {}) }, - "json", + ctx.output.result( + { valid: issues.length === 0, issues, ...(hints.length > 0 ? { hints } : {}) }, + { + text: + issues.length === 0 + ? ctx.localize({ + "en-US": "Pipeline definition is valid.", + "zh-CN": "Pipeline 定义有效。", + }) + : ctx.localize({ + "en-US": "Pipeline validation failed:", + "zh-CN": "Pipeline 校验失败:", + }) + + "\n" + + issues.map((issue) => ` - ${issue}`).join("\n"), + }, + ); + for (const hint of hints) ctx.output.info(` hint: ${hint}`); + if (issues.length > 0) { + throw new BailianError( + ctx.localize({ "en-US": "Pipeline validation failed.", "zh-CN": "Pipeline 校验失败。" }), ); - if (issues.length > 0) process.exitCode = 1; - } else if (issues.length === 0) { - process.stdout.write("Pipeline definition is valid.\n"); - for (const hint of hints) { - process.stderr.write(` hint: ${hint}\n`); - } - } else { - process.stderr.write("Pipeline validation failed:\n"); - for (const issue of issues) { - process.stderr.write(` - ${issue}\n`); - } - process.exitCode = 1; } }, }); diff --git a/packages/commands/src/commands/plugin/install.ts b/packages/commands/src/commands/plugin/install.ts index 7a018a299..567f8e117 100644 --- a/packages/commands/src/commands/plugin/install.ts +++ b/packages/commands/src/commands/plugin/install.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand } from "bailian-cli-core"; export default defineCommand({ description: { @@ -22,6 +21,6 @@ export default defineCommand({ exampleArgs: ["--package @ali/bailian-plugin-agent", "--package @ali/bailian-plugin-agent@beta"], async run(ctx) { const installed = await ctx.commandPacks.install(ctx.flags.package); - emitResult({ installed }, detectOutputFormat(ctx.settings.output)); + ctx.output.result({ installed }); }, }); diff --git a/packages/commands/src/commands/plugin/link.ts b/packages/commands/src/commands/plugin/link.ts index 7b783924f..06ed866c1 100644 --- a/packages/commands/src/commands/plugin/link.ts +++ b/packages/commands/src/commands/plugin/link.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand } from "bailian-cli-core"; export default defineCommand({ description: { @@ -22,6 +21,6 @@ export default defineCommand({ exampleArgs: ["--path ../bailian-plugin-agent"], async run(ctx) { const linked = await ctx.commandPacks.link(ctx.flags.path); - emitResult({ linked }, detectOutputFormat(ctx.settings.output)); + ctx.output.result({ linked }); }, }); diff --git a/packages/commands/src/commands/plugin/list.ts b/packages/commands/src/commands/plugin/list.ts index 047847e72..b64955e8e 100644 --- a/packages/commands/src/commands/plugin/list.ts +++ b/packages/commands/src/commands/plugin/list.ts @@ -1,5 +1,5 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; -import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; +import { defineCommand } from "bailian-cli-core"; +import { formatTable } from "bailian-cli-runtime"; export default defineCommand({ description: { @@ -10,13 +10,16 @@ export default defineCommand({ exampleArgs: ["", "--output json"], async run(ctx) { const reports = await ctx.commandPacks.list(); - const format = detectOutputFormat(ctx.settings.output); - if (format === "json") { - emitResult({ command_packs: reports }, format); - return; - } if (reports.length === 0) { - emitBare("No Command Packs installed."); + ctx.output.result( + { command_packs: reports }, + { + text: ctx.localize({ + "en-US": "No Command Packs installed.", + "zh-CN": "尚未安装 Command Pack。", + }), + }, + ); return; } const rows = reports.map((report) => [ @@ -26,11 +29,10 @@ export default defineCommand({ report.status, report.commands.join(", ") || report.error || "-", ]); - for (const line of formatTable( + const text = formatTable( ["NAME", "VERSION", "SOURCE", "STATUS", "COMMANDS / ERROR"], rows, - )) { - emitBare(line); - } + ).join("\n"); + ctx.output.result({ command_packs: reports }, { text }); }, }); diff --git a/packages/commands/src/commands/plugin/remove.ts b/packages/commands/src/commands/plugin/remove.ts index 75fbbbe1d..01a2152d5 100644 --- a/packages/commands/src/commands/plugin/remove.ts +++ b/packages/commands/src/commands/plugin/remove.ts @@ -1,5 +1,4 @@ -import { defineCommand, detectOutputFormat } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { defineCommand } from "bailian-cli-core"; export default defineCommand({ description: { @@ -22,6 +21,6 @@ export default defineCommand({ exampleArgs: ["--name @ali/bailian-plugin-agent"], async run(ctx) { await ctx.commandPacks.remove(ctx.flags.name); - emitResult({ removed: ctx.flags.name }, detectOutputFormat(ctx.settings.output)); + ctx.output.result({ removed: ctx.flags.name }); }, }); diff --git a/packages/commands/src/commands/shared/model-stream.ts b/packages/commands/src/commands/shared/model-stream.ts new file mode 100644 index 000000000..e126acbc5 --- /dev/null +++ b/packages/commands/src/commands/shared/model-stream.ts @@ -0,0 +1,46 @@ +import { BailianError, ExitCode } from "bailian-cli-core"; + +/** Parse model SSE data while preserving service failures for the runtime error renderer. */ +export function parseModelStreamEvent(event: { + event?: string; + data: string; +}): Record | undefined { + let value: unknown; + try { + value = JSON.parse(event.data); + } catch { + if (event.event === "error") throw new BailianError(event.data, ExitCode.GENERAL); + return undefined; + } + const payload = + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; + if (!payload) return undefined; + const nested = + payload.error !== null && typeof payload.error === "object" + ? (payload.error as Record) + : undefined; + const code = nested?.code ?? payload.code; + if ( + event.event === "error" || + nested || + ((typeof code === "string" || typeof code === "number") && + !["200", "0", "Success", "SUCCESS"].includes(String(code))) + ) { + const message = nested?.message ?? payload.message; + throw new BailianError( + typeof message === "string" ? message : event.data, + ExitCode.GENERAL, + undefined, + { + api: { + apiCode: typeof code === "string" ? code : undefined, + requestId: typeof payload.request_id === "string" ? payload.request_id : undefined, + }, + rawResponse: event.data, + }, + ); + } + return payload; +} diff --git a/packages/commands/src/commands/shared/telemetry.ts b/packages/commands/src/commands/shared/telemetry.ts index 0590dc30d..7b2260d38 100644 --- a/packages/commands/src/commands/shared/telemetry.ts +++ b/packages/commands/src/commands/shared/telemetry.ts @@ -18,6 +18,7 @@ export function defineTelemetryCommand(spec: Command): Co return { ...spec, defaultOutput: "json", + defaultOutputOverridesProfile: true, notes: [ ...(spec.notes ?? []), { @@ -34,15 +35,7 @@ export function defineTelemetryCommand(spec: Command): Co }, ], async run(ctx) { - const source = ctx.settings.outputSource; - // 兼容旧宿主手工构造的 Settings;正式 CLI 使用 loader 提供的来源。 - const explicitOutput = - source === undefined ? ctx.settings.outputExplicit : source === "flag" || source === "env"; - const settings: Settings = { - ...ctx.settings, - output: explicitOutput ? ctx.settings.output : "json", - outputExplicit: true, - }; + const settings = ctx.settings; let client = ctx.client; if (!settings.dryRun) { ensureTelemetryRegionSupported(settings); diff --git a/packages/commands/tests/e2e/pipeline.e2e.test.ts b/packages/commands/tests/e2e/pipeline.e2e.test.ts index 1eaa5b969..1895c215f 100644 --- a/packages/commands/tests/e2e/pipeline.e2e.test.ts +++ b/packages/commands/tests/e2e/pipeline.e2e.test.ts @@ -122,7 +122,9 @@ describe("e2e: pipeline", () => { "json", ]); expect(exitCode).toBe(1); - expect(stderr).toBe(""); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 1, message: "Pipeline validation failed." }, + }); const data = parseStdoutJson<{ valid?: boolean; issues?: string[] }>(stdout); expect(data.valid).toBe(false); expect(data.issues?.join("\n")).toMatch(/pipeline graph contains cycle/i); @@ -209,6 +211,8 @@ describe("e2e: pipeline", () => { "--dry-run", "--events", "jsonl", + "--output", + "text", ]); expect(exitCode, stderr).toBe(0); const events = stdout @@ -226,6 +230,49 @@ describe("e2e: pipeline", () => { expect(events[1]?.step).toMatchObject({ id: "chat", type: "text/chat" }); }); + test.each(["flag", "environment", "profile"] as const)( + "pipeline rejects JSON/event conflicts from %s before reading workflow", + async (source) => { + const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-events-json-")); + try { + await writeFile( + join(configDir, "config.json"), + JSON.stringify({ + language: "en-US", + ...(source === "profile" ? { output: "json" } : {}), + }), + ); + const result = await runCommandE2e( + PIPELINE_ROUTES, + [ + "pipeline", + "run", + "--file", + "/does/not/exist/pipeline.json", + "--events", + "jsonl", + ...(source === "flag" ? ["--output", "json"] : []), + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_OUTPUT: source === "environment" ? "json" : "", + }, + ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + code: 2, + message: "Lifecycle events cannot be combined with JSON result output.", + hint: "Use --output text --events jsonl for the lifecycle stream, or --output json without --events for the final report.", + }, + }); + } finally { + await rm(configDir, { recursive: true, force: true }); + } + }, + ); + test("pipeline run 拒绝未知 events format", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", diff --git a/packages/commands/tests/output/command-context.ts b/packages/commands/tests/output/command-context.ts new file mode 100644 index 000000000..b03c4f83e --- /dev/null +++ b/packages/commands/tests/output/command-context.ts @@ -0,0 +1,13 @@ +import type { LocalizedText, Settings } from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; + +/** Bind direct command fixtures to the same resolved settings as the real runtime. */ +export function withCommandOutput(context: Context) { + const localize = (message: LocalizedText) => + typeof message === "string" ? message : message["en-US"]; + return { + ...context, + localize, + output: createOutputReporter({ settings: context.settings, localize }).output, + }; +} diff --git a/packages/commands/tests/output/extension-output.test.ts b/packages/commands/tests/output/extension-output.test.ts new file mode 100644 index 000000000..c1d857b52 --- /dev/null +++ b/packages/commands/tests/output/extension-output.test.ts @@ -0,0 +1,258 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + buildSettings, + httpTransport, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import { enterIsolatedHome } from "e2e/isolation"; +import install from "../../src/commands/plugin/install.ts"; +import link from "../../src/commands/plugin/link.ts"; +import list from "../../src/commands/plugin/list.ts"; +import remove from "../../src/commands/plugin/remove.ts"; +import run from "../../src/commands/pipeline/run.ts"; +import validate from "../../src/commands/pipeline/validate.ts"; + +const matrix = (["text", "json"] as const).flatMap((output) => + [false, true].flatMap((quiet) => [false, true].map((verbose) => ({ output, quiet, verbose }))), +); +const identity = { binName: "bl", version: "0.0.0", clientName: "test", npmPackage: "test" }; +const localized = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); +const paths: string[] = []; +let isolation: ReturnType; +beforeEach(() => { + isolation = enterIsolatedHome(); + vi.stubEnv("DASHSCOPE_API_KEY", "fixture-key"); + vi.stubEnv("DASHSCOPE_BASE_URL", "https://example.test"); +}); +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + isolation.restore(); + process.exitCode = 0; + await Promise.all(paths.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); +function capture(display: (typeof matrix)[number]) { + let stdout = ""; + let stderr = ""; + const settings = { ...buildSettings({ flags: {}, env: {}, file: {} }), ...display }; + const reporter = createOutputReporter({ + settings, + localize: localized, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + return { settings, reporter, stdout: () => stdout, stderr: () => stderr }; +} +async function pipelineFile(failed = false) { + const root = await mkdtemp(join(tmpdir(), "extension-output-")); + paths.push(root); + const path = join(root, "pipeline.json"); + await writeFile( + path, + JSON.stringify({ + version: "workflow/v1", + steps: [ + { + id: "first", + type: "script/js", + input: { code: "return {task_id: 'accepted-fixture', values: [false, 0, null, []]};" }, + }, + { + id: "second", + type: "logic/assert", + dependsOn: ["first"], + input: { condition: !failed, message: "fixture failure" }, + }, + ], + }), + ); + return path; +} +for (const [name, command, flags, expected] of [ + [ + "install", + install, + { package: "@fixture/pack" }, + { installed: { name: "@fixture/pack", commands: [] } }, + ], + ["link", link, { path: "/fixture" }, { linked: { name: "@fixture/pack", commands: [] } }], + ["remove", remove, { name: "@fixture/pack" }, { removed: "@fixture/pack" }], + ["list", list, {}, { command_packs: [] }], +] as const) { + test.each(matrix)( + `${name} complete result $output quiet=$quiet verbose=$verbose`, + async (display) => { + const streams = capture(display); + await (command as AnyCommand).run({ + identity, + settings: streams.settings, + flags, + output: streams.reporter.output, + localize: localized, + commandPacks: { + install: async () => ({ name: "@fixture/pack", commands: [] }), + link: async () => ({ name: "@fixture/pack", commands: [] }), + remove: async () => {}, + list: async () => [], + }, + } as never); + if (display.output === "json") expect(JSON.parse(streams.stdout())).toEqual(expected); + else expect(streams.stdout()).not.toBe(""); + expect(streams.stderr()).toBe(""); + }, + ); +} +test.each(matrix)("pipeline report $output quiet=$quiet verbose=$verbose", async (display) => { + const streams = capture(display); + const path = await pipelineFile(); + await run.run({ + identity, + settings: streams.settings, + flags: { file: path }, + output: streams.reporter.output, + localize: localized, + } as never); + if (display.output === "json") { + const report = JSON.parse(streams.stdout()); + expect(report.status).toBe("succeeded"); + expect(report.steps[0].output.data).toEqual({ + task_id: "accepted-fixture", + values: [false, 0, null, []], + }); + const diagnostics = streams + .stderr() + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + expect(diagnostics.length > 0).toBe(display.verbose); + for (const diagnostic of diagnostics) expect(diagnostic.debug).toBeDefined(); + } else + expect(streams.stdout()).toBe( + "\nPipeline succeeded [+]\n\n [+] first (script/js) — succeeded\n [+] second (logic/assert) — succeeded\n\n", + ); +}); +test("failed pipeline retains prior outputs then throws once for runtime", async () => { + const streams = capture({ output: "json", quiet: true, verbose: false }); + const path = await pipelineFile(true); + await expect( + run.run({ + identity, + settings: streams.settings, + flags: { file: path }, + output: streams.reporter.output, + localize: localized, + } as never), + ).rejects.toThrow(); + const report = JSON.parse(streams.stdout()); + expect(report.status).toBe("failed"); + expect(report.steps[0].output.data.task_id).toBe("accepted-fixture"); + expect(report.steps[1].error.message).toBe("fixture failure"); + expect(streams.stderr()).toBe(""); + expect(process.exitCode).toBe(0); +}); +test("invalid validation retains structured report and throws once", async () => { + const streams = capture({ output: "json", quiet: true, verbose: false }); + const root = await mkdtemp(join(tmpdir(), "extension-invalid-")); + paths.push(root); + const path = join(root, "invalid.json"); + await writeFile(path, JSON.stringify({ version: "invalid", steps: [] })); + await expect( + validate.run({ + identity, + settings: streams.settings, + flags: { file: path }, + output: streams.reporter.output, + localize: localized, + } as never), + ).rejects.toThrow(); + expect(JSON.parse(streams.stdout())).toMatchObject({ valid: false }); + expect(streams.stderr()).toBe(""); + expect(process.exitCode).toBe(0); +}); + +test.each(["en-US", "zh-CN"] as const)( + "JSON lifecycle conflict rejects before file reads (%s)", + async (language) => { + const streams = capture({ output: "json", quiet: true, verbose: false }); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text[language]); + await expect( + run.run({ + identity, + settings: streams.settings, + flags: { file: "/does/not/exist", events: "jsonl" }, + output: streams.reporter.output, + localize, + } as never), + ).rejects.toMatchObject({ exitCode: 2 }); + expect(streams.stdout()).toBe(""); + }, +); + +test.each(matrix)( + "pipeline HTTP diagnostics $output quiet=$quiet verbose=$verbose", + async (display) => { + const streams = capture(display); + const root = await mkdtemp(join(tmpdir(), "extension-http-")); + paths.push(root); + const path = join(root, "pipeline.json"); + await writeFile( + path, + JSON.stringify({ + version: "workflow/v1", + steps: [ + { + id: "chat", + type: "text/chat", + input: { message: "fixture input", model: "fixture-model" }, + }, + ], + }), + ); + const payload = { + id: "fixture-response", + choices: [{ message: { role: "assistant", content: "fixture answer" } }], + extra: [false, 0, null, []], + }; + const fetch = vi.spyOn(httpTransport, "fetch").mockResolvedValue( + new Response(JSON.stringify(payload), { + headers: { "content-type": "application/json", "x-request-id": "fixture-request" }, + }), + ); + await run.run({ + identity, + settings: streams.settings, + flags: { file: path }, + output: streams.reporter.output, + localize: localized, + } as never); + expect(fetch).toHaveBeenCalledTimes(1); + const request = fetch.mock.calls[0]!; + expect(request[0]).toContain("/chat/completions"); + expect(JSON.parse(request[1]!.body as string)).toEqual({ + model: "fixture-model", + messages: [{ role: "user", content: "fixture input" }], + max_tokens: 4096, + stream: false, + }); + if (display.output === "json") + expect(JSON.parse(streams.stdout()).steps[0].output.data).toEqual(payload); + expect(streams.stderr().includes("HTTP request")).toBe(display.verbose); + expect(streams.stderr().includes("HTTP response")).toBe(display.verbose); + expect(streams.stderr()).not.toContain("fixture-key"); + expect(streams.stderr()).not.toContain("fixture input"); + }, +); diff --git a/packages/commands/tests/telemetry.test.ts b/packages/commands/tests/telemetry.test.ts index c485c9b70..039078182 100644 --- a/packages/commands/tests/telemetry.test.ts +++ b/packages/commands/tests/telemetry.test.ts @@ -133,6 +133,8 @@ describe("遥测命令的空间身份与默认 JSON", () => { test.each(Object.entries(telemetryCommands))( "%s 默认 JSON、dry-run 展示解析计划且零网络", async (_path, command) => { + expect(command.defaultOutput).toBe("json"); + expect(command.defaultOutputOverridesProfile).toBe(true); const transport = mockTelemetryGateway(); const ctx = makeTelemetryContext( { dryRun: true }, diff --git a/packages/core/src/client/bailian-control.ts b/packages/core/src/client/bailian-control.ts index fcc88c87a..85e925746 100644 --- a/packages/core/src/client/bailian-control.ts +++ b/packages/core/src/client/bailian-control.ts @@ -1,3 +1,4 @@ +import type { HttpDeps } from "./http.ts"; import type { Identity, Settings } from "../config/schema.ts"; import { Client, type OpenApiResponse } from "./client.ts"; @@ -24,12 +25,14 @@ export interface BailianControlAuth { accessKeyId: string; accessKeySecret: string; securityToken?: string; + debug?: HttpDeps["debug"]; } function bailianControlClient(auth: BailianControlAuth): Client { return new Client({ identity: auth.identity, settings: auth.settings, + debug: auth.debug, baseUrl: auth.baseUrl, openApiCred: { accessKeyId: auth.accessKeyId, diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index 18bc1e5ed..1e0d7be2d 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -75,6 +75,7 @@ export type ConsoleCall = (api: string, data: Record) => Promis */ export function anonymousConsoleCall( config: Pick, + diagnostics: Pick = {}, ): ConsoleCall { const eff = effectiveConsoleGatewayConfig(config); return (api, data) => @@ -82,6 +83,7 @@ export function anonymousConsoleCall( { region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent }, config.timeout, { api, data }, + diagnostics, ); } diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 763c1994f..3d7e68224 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -302,8 +302,14 @@ export interface CommandPreparation { } export interface Command { - /** Default for commands with a format policy; explicit flag/env still take precedence. */ + /** Fallback when no output format is configured by flag, environment or Profile. */ defaultOutput?: Settings["output"]; + /** Native terminal presentation when no flag, environment or Profile selects a format. */ + defaultInteractiveOutput?: Settings["output"]; + /** Existing command switches that explicitly request diagnostic output (for example --trace). */ + verboseFlags?: readonly (keyof F & string)[]; + /** Apply defaultOutput over a Profile preference; flag/env still take precedence. */ + defaultOutputOverridesProfile?: boolean; description: LocalizedText; /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; diff --git a/packages/runtime/src/command-packs/manager.ts b/packages/runtime/src/command-packs/manager.ts index 444f81af3..e35a5c6a7 100644 --- a/packages/runtime/src/command-packs/manager.ts +++ b/packages/runtime/src/command-packs/manager.ts @@ -1,3 +1,4 @@ +import { StringDecoder } from "node:string_decoder"; import { existsSync } from "node:fs"; import { mkdir, open, stat, unlink, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -6,6 +7,7 @@ import { BailianError, ExitCode, isCompiledBinary, + type CommandOutput, type CommandPackManager, type CommandPackReport, type Identity, @@ -96,7 +98,7 @@ async function ensureSandboxAt(dir: string): Promise { } } -async function runNpm(args: string[], cwd: string): Promise { +async function runNpm(args: string[], cwd: string, output: CommandOutput): Promise { const npmCheck = spawnSync("npm", ["--version"], { encoding: "utf-8" }); if (npmCheck.status !== 0) { const hint = isCompiledBinary() @@ -114,10 +116,14 @@ async function runNpm(args: string[], cwd: string): Promise { env: buildNpmEnv(), stdio: ["inherit", "pipe", "pipe"], }); - child.stdout.on("data", (chunk) => process.stderr.write(chunk)); - child.stderr.on("data", (chunk) => process.stderr.write(chunk)); + const stdout = npmLogLines(output); + const stderr = npmLogLines(output); + child.stdout.on("data", (chunk: Buffer) => stdout.write(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.write(chunk)); child.once("error", reject); child.once("close", (code, signal) => { + stdout.end(); + stderr.end(); if (code === 0) { resolvePromise(); return; @@ -132,6 +138,30 @@ async function runNpm(args: string[], cwd: string): Promise { }); } +/** Decode complete child log lines without creating a second CLI output protocol. */ +function npmLogLines(output: CommandOutput) { + const decoder = new StringDecoder("utf8"); + let pending = ""; + const emit = (line: string) => { + if (/^npm\s+(?:warn(?:ing)?|err(?:or)?)\b/i.test(line)) { + output.warning({ code: "COMMAND_PACK_NPM", message: line }); + } else if (line) output.info(line); + }; + return { + write(chunk: Buffer) { + pending += decoder.write(chunk); + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ""; + for (const line of lines) emit(line); + }, + end() { + pending += decoder.end(); + if (pending) emit(pending); + pending = ""; + }, + }; +} + async function acquireSandboxLock(identity: Identity): Promise<() => Promise> { const sandboxDir = getCommandPacksDir(identity); await ensureSandboxAt(sandboxDir); @@ -224,6 +254,7 @@ async function restoreCommandPack( name: string, previousSpec: string | undefined, sandboxDir: string, + output: CommandOutput, ): Promise { if (previousSpec) { await runNpm( @@ -236,16 +267,22 @@ async function restoreCommandPack( "--no-audit", ], sandboxDir, + output, ); return; } - await runNpm(["uninstall", name, "--ignore-scripts", "--no-fund", "--no-audit"], sandboxDir); + await runNpm( + ["uninstall", name, "--ignore-scripts", "--no-fund", "--no-audit"], + sandboxDir, + output, + ); } export async function installCommandPack( spec: string, identity: Identity, policy: CommandPackPolicy, + output: CommandOutput, ): Promise<{ name: string; version?: string; commands: string[] }> { const { name, requested } = parseCommandPackSpec(spec, identity, policy); return withSandboxLock(identity, async () => { @@ -257,6 +294,7 @@ export async function installCommandPack( await runNpm( ["install", requested, "--save-exact", "--ignore-scripts", "--no-fund", "--no-audit"], sandboxDir, + output, ); const installed = await validateAtRoot( name, @@ -274,7 +312,7 @@ export async function installCommandPack( }; } catch (error) { try { - await restoreCommandPack(name, previousSpec, sandboxDir); + await restoreCommandPack(name, previousSpec, sandboxDir, output); } catch { /* preserve the install or validation error */ } @@ -287,6 +325,7 @@ export async function linkCommandPack( path: string, identity: Identity, policy: CommandPackPolicy, + output: CommandOutput, ): Promise<{ name: string; version?: string; commands: string[] }> { const root = resolve(path); if (!existsSync(join(root, "package.json"))) { @@ -306,6 +345,7 @@ export async function linkCommandPack( await runNpm( ["install", `file:${root}`, "--save-exact", "--ignore-scripts", "--no-fund", "--no-audit"], getCommandPacksDir(identity), + output, ); const installed = await validateAtRoot( name, @@ -325,6 +365,7 @@ export async function removeCommandPack( name: string, identity: Identity, policy: CommandPackPolicy, + output: CommandOutput, ): Promise { if (!policy.supported[name]) { throw new BailianError( @@ -341,6 +382,7 @@ export async function removeCommandPack( runNpm( ["uninstall", name, "--ignore-scripts", "--no-fund", "--no-audit"], getCommandPacksDir(identity), + output, ), ); } @@ -355,11 +397,12 @@ export async function listCommandPacks( export function createCommandPackManager( identity: Identity, policy: CommandPackPolicy, + output: CommandOutput, ): CommandPackManager { return { - install: (spec) => installCommandPack(spec, identity, policy), - link: (path) => linkCommandPack(path, identity, policy), + install: (spec) => installCommandPack(spec, identity, policy, output), + link: (path) => linkCommandPack(path, identity, policy, output), list: () => listCommandPacks(identity, policy), - remove: (name) => removeCommandPack(name, identity, policy), + remove: (name) => removeCommandPack(name, identity, policy, output), }; } diff --git a/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts index 907f54cbc..c597287b6 100644 --- a/packages/runtime/src/command-packs/validate.ts +++ b/packages/runtime/src/command-packs/validate.ts @@ -122,7 +122,11 @@ function adaptCommandPack( notes: command.notes, flags: command.flags, validate: command.validate, - run: (ctx) => { + run: async (ctx) => { + let hasResult = false; + let invalidResult = false; + let result: unknown; + const text: string[] = []; const packContext = { identity: ctx.identity, settings: ctx.settings, @@ -130,17 +134,40 @@ function adaptCommandPack( client: ctx.client, output: { result(data: unknown, options?: { text?: string }) { + if (hasResult) { + invalidResult = true; + throw new BailianError( + ctx.localize({ + "en-US": "A Command Pack submitted more than one final result.", + "zh-CN": "Command Pack 提交了多个最终结果。", + }), + ); + } + // Snapshot JSON at submission time, matching the original API1 + // serialization boundary even though final output is deferred. + const serialized = + ctx.settings.output === "json" ? formatOutput(data, "json") : undefined; + if (ctx.settings.output === "json" && typeof serialized !== "string") { + throw new BailianError( + ctx.localize({ + "en-US": "The final result is not serializable.", + "zh-CN": "最终结果无法序列化。", + }), + ); + } const rendered = - ctx.settings.output === "text" && options?.text !== undefined - ? options.text - : formatOutput(data, ctx.settings.output); - process.stdout.write(`${rendered}\n`); + ctx.settings.output === "text" + ? (options?.text ?? formatOutput(data, "text")) + : ""; + hasResult = true; + result = serialized === undefined ? data : JSON.parse(serialized); + text.push(`${rendered}\n`); }, line(value: string) { - process.stdout.write(`${value}\n`); + text.push(`${value}\n`); }, write(chunk: string) { - process.stdout.write(chunk); + text.push(chunk); }, }, errors: { @@ -186,7 +213,18 @@ function adaptCommandPack( }, }, }; - return command.run(packContext); + try { + await command.run(packContext); + } finally { + // An explicitly submitted report survives a later failure. Duplicate + // submissions are invalid and must never leak partial stdout. + if (!invalidResult && (hasResult || text.length > 0)) { + const rendered = text.join(""); + ctx.output.result(hasResult ? result : rendered, { + text: rendered.endsWith("\n") ? rendered.slice(0, -1) : rendered, + }); + } + } }, } satisfies AnyCommand, ]), diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index a7adc40e3..564282257 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -165,7 +165,6 @@ export function createCli(commands: Record, opts: CliOptions const { binName, version, npmPackage, clientName } = opts; const identity: Identity = { binName, version, npmPackage, clientName }; const commandPackPolicy = opts.commandPacks ?? { supported: {} }; - const commandPackManager = createCommandPackManager(identity, commandPackPolicy); let loadedCommandPacksPromise: ReturnType | undefined; const runMiddleware = compose([ @@ -227,11 +226,23 @@ export function createCli(commands: Record, opts: CliOptions flags: res.kind === "run" ? res.parsedFlags : initialSources.flags, }); if (command?.defaultOutput !== undefined) { - if (outputSettings.outputSource !== "flag" && outputSettings.outputSource !== "env") { - outputSettings.output = command.defaultOutput; + if ( + outputSettings.outputSource === "default" || + (outputSettings.outputSource === "file" && command.defaultOutputOverridesProfile) + ) { + outputSettings.output = + outputSettings.outputSource === "default" && process.stdout.isTTY + ? (command.defaultInteractiveOutput ?? command.defaultOutput) + : command.defaultOutput; } outputSettings.outputExplicit = true; } + if ( + res.kind === "run" && + command?.verboseFlags?.some((flag) => Boolean(res.parsedFlags[flag])) + ) { + outputSettings.verbose = true; + } // Parsing can fail after identifying a command; its output default still applies. const reporter = configureOutput(outputSettings); @@ -284,7 +295,7 @@ export function createCli(commands: Record, opts: CliOptions sources, configStore: makeConfigStore(sources.configName), authStore: makeAuthStore(sources), - commandPacks: commandPackManager, + commandPacks: createCommandPackManager(identity, commandPackPolicy, reporter.output), client: new Client({ identity, settings, diff --git a/packages/runtime/src/pipeline/bl-config.ts b/packages/runtime/src/pipeline/bl-config.ts index a5ed3f777..7d6b9e6ef 100644 --- a/packages/runtime/src/pipeline/bl-config.ts +++ b/packages/runtime/src/pipeline/bl-config.ts @@ -5,6 +5,7 @@ import { resolveApiKey, resolveModelBaseUrl, type ApiKeyCredential, + type CommandOutput, type Identity, type ResolutionSources, type Settings, @@ -37,7 +38,7 @@ export function applyProfileWatermarkToStepInput( * as the CLI itself (env vars, config file; no CLI flags), but forces JSON * output + quiet mode. */ -export function buildPipelineEnv(): PipelineEnv { +export function buildPipelineEnv(output?: Pick): PipelineEnv { const sources: ResolutionSources = buildSources({}); const settings: Settings = { ...buildSettings(sources), @@ -58,7 +59,13 @@ export function buildPipelineEnv(): PipelineEnv { /* 无 key:步骤真正发请求时由 Client 报错 */ } return { - client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources), apiCred }), + client: new Client({ + identity, + settings, + baseUrl: resolveModelBaseUrl(sources), + apiCred, + debug: output ? (diagnostic) => output.debug(diagnostic) : undefined, + }), settings, }; } diff --git a/packages/runtime/src/pipeline/executor.ts b/packages/runtime/src/pipeline/executor.ts index 20df0e4c5..0fa8bbacd 100644 --- a/packages/runtime/src/pipeline/executor.ts +++ b/packages/runtime/src/pipeline/executor.ts @@ -59,6 +59,7 @@ export async function* streamPipelineEvents( runtimeInput: Record = {}, options: Pick< ExecutePipelineOptions, + | "output" | "concurrency" | "basePath" | "dryRun" @@ -134,7 +135,7 @@ async function executePipelineInternal( } } - const blEnv = buildPipelineEnv(); + const blEnv = buildPipelineEnv(options.output); const plan = buildExecutionPlan(pipeline); const concurrency = normalizeConcurrency(options.concurrency); const reports: PipelineStepReport[] = []; diff --git a/packages/runtime/src/pipeline/types.ts b/packages/runtime/src/pipeline/types.ts index 6bc43f6ef..01269a556 100644 --- a/packages/runtime/src/pipeline/types.ts +++ b/packages/runtime/src/pipeline/types.ts @@ -1,4 +1,5 @@ // Pipeline types — minimal extraction from packages/pipeline +import type { CommandOutput } from "bailian-cli-core"; import type { StepDispatcher } from "./dispatcher.ts"; export const WORKFLOW_VERSION = "workflow/v1"; @@ -283,6 +284,8 @@ export type PipelineEventHandler = (event: PipelineLifecycleEvent) => void | Pro // --- Execution options --- export interface ExecutePipelineOptions { + /** Invocation diagnostic sink; omitted library calls remain silent. */ + output?: Pick; onEvent?: PipelineEventHandler; concurrency?: number; retryDelayBaseMs?: number; diff --git a/packages/runtime/tests/command-pack-output.test.ts b/packages/runtime/tests/command-pack-output.test.ts new file mode 100644 index 000000000..e5efba1a0 --- /dev/null +++ b/packages/runtime/tests/command-pack-output.test.ts @@ -0,0 +1,186 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vite-plus/test"; +import { buildSettings, type LocalizedText } from "bailian-cli-core"; +import { createOutputReporter } from "../src/output/reporter.ts"; +import { loadAndValidateCommandPack } from "../src/command-packs/validate.ts"; +const identity = { binName: "bl", version: "1.7.0", clientName: "test", npmPackage: "test" }; +const matrix = (["text", "json"] as const).flatMap((output) => + [false, true].flatMap((quiet) => [false, true].map((verbose) => ({ output, quiet, verbose }))), +); +async function fixture(code: string, display: (typeof matrix)[number]) { + const root = await mkdtemp(join(tmpdir(), "pack-output-")); + const settings = { ...buildSettings({ flags: {}, env: {}, file: {} }), ...display }; + let stdout = ""; + let stderr = ""; + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + await writeFile( + join(root, "commands.mjs"), + `export default { "agent fixture": { description: "Fixture", auth: "none", async run(ctx) { ${code} } } };`, + ); + const commands = await loadAndValidateCommandPack( + "@fixture/pack", + { + name: "@fixture/pack", + bailianCli: { type: "command-pack", apiVersion: 1, entry: "./commands.mjs" }, + }, + { commandPrefixes: ["agent"] }, + identity, + root, + ); + return { + reporter, + stdout: () => stdout, + stderr: () => stderr, + dispose: () => rm(root, { recursive: true, force: true }), + run: () => + commands["agent fixture"]!.run({ + identity, + settings, + localize, + flags: {}, + output: reporter.output, + } as never), + }; +} +test.each(matrix)( + "API1 falsy final JSON $output quiet=$quiet verbose=$verbose", + async (display) => { + const streams = await fixture('ctx.output.result(false, {text: "false text"});', display); + try { + await streams.run(); + expect(streams.stdout()).toBe(display.output === "json" ? "false\n" : "false text\n"); + } finally { + await streams.dispose(); + } + }, +); +test.each(matrix)( + "API1 writes produce one string result $output quiet=$quiet verbose=$verbose", + async (display) => { + const streams = await fixture('ctx.output.write("hello "); ctx.output.line("world");', display); + try { + await streams.run(); + expect(streams.stdout()).toBe( + display.output === "json" ? '"hello world\\n"\n' : "hello world\n", + ); + } finally { + await streams.dispose(); + } + }, +); +test.each(matrix)( + "API1 result with fragments retains data $output quiet=$quiet verbose=$verbose", + async (display) => { + const streams = await fixture( + 'ctx.output.line("before"); ctx.output.result({values:[false,0,null,[]]}, {text:"body"}); ctx.output.write("after");', + display, + ); + try { + await streams.run(); + if (display.output === "json") + expect(JSON.parse(streams.stdout())).toEqual({ values: [false, 0, null, []] }); + else expect(streams.stdout()).toBe("before\nbody\nafter\n"); + } finally { + await streams.dispose(); + } + }, +); + +test("API1 duplicate result is GENERAL and produces no partial document", async () => { + const streams = await fixture( + "ctx.output.result({first:true}); ctx.output.result({second:true});", + { output: "json", quiet: true, verbose: false }, + ); + try { + await expect(streams.run()).rejects.toMatchObject({ exitCode: 1 }); + expect(streams.stdout()).toBe(""); + } finally { + await streams.dispose(); + } +}); +test("API1 submitted report survives a later original failure", async () => { + const streams = await fixture( + 'ctx.output.result({task_id:"accepted-fixture"}); throw ctx.errors.general("original failure");', + { output: "json", quiet: true, verbose: false }, + ); + try { + await expect(streams.run()).rejects.toThrow("original failure"); + expect(JSON.parse(streams.stdout())).toEqual({ task_id: "accepted-fixture" }); + expect(streams.stderr()).toBe(""); + } finally { + await streams.dispose(); + } +}); + +test("API1 overlapping runs keep fragments and final results invocation-local", async () => { + const first = await fixture( + 'ctx.output.write("first"); await new Promise(resolve => setTimeout(resolve,20)); ctx.output.result({id:"first"});', + { output: "json", quiet: true, verbose: true }, + ); + const second = await fixture('ctx.output.write("second"); ctx.output.result({id:"second"});', { + output: "json", + quiet: false, + verbose: false, + }); + try { + await Promise.all([first.run(), second.run()]); + expect(JSON.parse(first.stdout())).toEqual({ id: "first" }); + expect(JSON.parse(second.stdout())).toEqual({ id: "second" }); + } finally { + await Promise.all([first.dispose(), second.dispose()]); + } +}); + +test("API1 snapshots JSON result at submission before later object mutations", async () => { + const streams = await fixture( + "const data = {value:1}; ctx.output.result(data); data.value = 2;", + { output: "json", quiet: true, verbose: false }, + ); + try { + await streams.run(); + expect(JSON.parse(streams.stdout())).toEqual({ value: 1 }); + } finally { + await streams.dispose(); + } +}); +test("API1 validates JSON submission before reserving the result slot", async () => { + const streams = await fixture( + 'const data = {}; data.self = data; try { ctx.output.result(data, {text:"text"}); } catch { ctx.output.result({blocked:true}); return; } ctx.output.result({blocked:false});', + { output: "json", quiet: true, verbose: false }, + ); + try { + await streams.run(); + expect(JSON.parse(streams.stdout())).toEqual({ blocked: true }); + } finally { + await streams.dispose(); + } +}); + +test("API1 JSON submission never invokes the text renderer", async () => { + const streams = await fixture( + 'let reads = 0; const data = {get value() { reads += 1; if (reads > 1) throw new Error("text renderer ran"); return 1; }}; ctx.output.result(data);', + { output: "json", quiet: true, verbose: false }, + ); + try { + await streams.run(); + expect(JSON.parse(streams.stdout())).toEqual({ value: 1 }); + } finally { + await streams.dispose(); + } +}); diff --git a/packages/runtime/tests/command-packs.test.ts b/packages/runtime/tests/command-packs.test.ts index d024db386..e96381c10 100644 --- a/packages/runtime/tests/command-packs.test.ts +++ b/packages/runtime/tests/command-packs.test.ts @@ -4,6 +4,7 @@ import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vite-plus/test"; import type { Identity } from "bailian-cli-core"; +import { createOutputReporter } from "../src/output/reporter.ts"; import { installCommandPack, parseCommandPackSpec } from "../src/command-packs/manager.ts"; import { getCommandPacksDir } from "../src/command-packs/paths.ts"; import { loadAndValidateCommandPack } from "../src/command-packs/validate.ts"; @@ -63,6 +64,13 @@ if (args[0] === "--version") { process.exit(0); } +process.stdout.write("fixture install info\\n"); +process.stderr.write("npm warn fixture retained warning\\n"); + +if (args[1]?.endsWith("@failed")) { + process.stderr.write("npm error fixture child failure\\n"); + process.exit(7); +} const manifestPath = path.join(cwd, "package.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); manifest.dependencies ??= {}; @@ -221,87 +229,135 @@ test("rejects incompatible protocol versions and invalid command prefixes", asyn ).rejects.toThrow(/outside the allowed prefixes/); }); -test("installs once on success and restores the previous version after validation failure", async () => { - const root = await mkdtemp(join(tmpdir(), "command-pack-install-test-")); - const binDir = join(root, "bin"); - const logPath = join(root, "npm.log"); - const npmPath = join(binDir, "npm"); - const previousConfigDir = process.env.BAILIAN_CONFIG_DIR; - const previousPath = process.env.PATH; - const previousTmpdir = process.env.TMPDIR; - const previousRegistry = process.env.NPM_CONFIG_REGISTRY; - const previousCatalog = process.env.npm_config_catalog; - const previousRecursive = process.env.npm_config_recursive; - const previousOverrides = process.env.npm_config_overrides; +const outputMatrix = (["text", "json"] as const).flatMap((output) => + [false, true].flatMap((quiet) => [false, true].map((verbose) => ({ output, quiet, verbose }))), +); +test.each(outputMatrix)( + "installs/restores with npm diagnostics $output quiet=$quiet verbose=$verbose", + async (display) => { + const root = await mkdtemp(join(tmpdir(), "command-pack-install-test-")); + const binDir = join(root, "bin"); + const logPath = join(root, "npm.log"); + const npmPath = join(binDir, "npm"); + const previousConfigDir = process.env.BAILIAN_CONFIG_DIR; + const previousPath = process.env.PATH; + const previousTmpdir = process.env.TMPDIR; + const previousRegistry = process.env.NPM_CONFIG_REGISTRY; + const previousCatalog = process.env.npm_config_catalog; + const previousRecursive = process.env.npm_config_recursive; + const previousOverrides = process.env.npm_config_overrides; - try { - await mkdir(binDir, { recursive: true }); - await writeFile(npmPath, fakeNpmSource); - await chmod(npmPath, 0o755); - await writeFile(logPath, ""); - process.env.BAILIAN_CONFIG_DIR = join(root, "config"); - process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`; - process.env.TMPDIR = root; - process.env.NPM_CONFIG_REGISTRY = "https://registry.example.test"; - process.env.npm_config_catalog = "prefer"; - process.env.npm_config_recursive = "true"; - process.env.npm_config_overrides = "true"; + try { + await mkdir(binDir, { recursive: true }); + await writeFile(npmPath, fakeNpmSource); + await chmod(npmPath, 0o755); + await writeFile(logPath, ""); + process.env.BAILIAN_CONFIG_DIR = join(root, "config"); + process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`; + process.env.TMPDIR = root; + process.env.NPM_CONFIG_REGISTRY = "https://registry.example.test"; + process.env.npm_config_catalog = "prefer"; + process.env.npm_config_recursive = "true"; + process.env.npm_config_overrides = "true"; - const installed = await installCommandPack("@ali/bailian-plugin-agent@1.0.0", identity, policy); - expect(installed.version).toBe("1.0.0"); - const successfulCalls = (await readFile(logPath, "utf8")) - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - args: string[]; - env: Record; + let diagnostics = ""; + const reporter = createOutputReporter({ + settings: display, + stderr: { + write: (chunk) => { + diagnostics += chunk; }, + }, + }); + const installed = await installCommandPack( + "@ali/bailian-plugin-agent@1.0.0", + identity, + policy, + reporter.output, ); - // runNpm probes `npm --version` before each install/uninstall. - expect(successfulCalls.map((call) => call.args[0])).toEqual(["--version", "install"]); - expect(successfulCalls[1]?.env).toEqual({ - registry: "https://registry.example.test", - catalog: null, - recursive: null, - overrides: null, - }); + if (display.output === "json") { + expect( + diagnostics + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + ).toEqual([ + { warning: { code: "COMMAND_PACK_NPM", message: "npm warn fixture retained warning" } }, + ]); + } else { + expect(diagnostics).toContain("npm warn fixture retained warning"); + expect(diagnostics.includes("fixture install info")).toBe(!display.quiet); + } + expect(installed.version).toBe("1.0.0"); + const successfulCalls = (await readFile(logPath, "utf8")) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + args: string[]; + env: Record; + }, + ); + // runNpm probes `npm --version` before each install/uninstall. + expect(successfulCalls.map((call) => call.args[0])).toEqual(["--version", "install"]); + expect(successfulCalls[1]?.env).toEqual({ + registry: "https://registry.example.test", + catalog: null, + recursive: null, + overrides: null, + }); - await writeFile(logPath, ""); - await expect( - installCommandPack("@ali/bailian-plugin-agent@broken", identity, policy), - ).rejects.toThrow(/Command Pack API 2 is not supported/); + await writeFile(logPath, ""); + await expect( + installCommandPack("@ali/bailian-plugin-agent@broken", identity, policy, reporter.output), + ).rejects.toThrow(/Command Pack API 2 is not supported/); - const rollbackCalls = (await readFile(logPath, "utf8")) - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - args: string[]; - env: Record; - }, - ); - expect(rollbackCalls.map((call) => call.args.slice(0, 2))).toEqual([ - ["--version"], - ["install", "@ali/bailian-plugin-agent@broken"], - ["--version"], - ["install", "@ali/bailian-plugin-agent@1.0.0"], - ]); + const rollbackCalls = (await readFile(logPath, "utf8")) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + args: string[]; + env: Record; + }, + ); + expect(rollbackCalls.map((call) => call.args.slice(0, 2))).toEqual([ + ["--version"], + ["install", "@ali/bailian-plugin-agent@broken"], + ["--version"], + ["install", "@ali/bailian-plugin-agent@1.0.0"], + ]); - const manifest = JSON.parse( - await readFile(join(root, "config", "plugins", "bailian-cli", "package.json"), "utf8"), - ) as { dependencies?: Record }; - expect(manifest.dependencies?.["@ali/bailian-plugin-agent"]).toBe("1.0.0"); - } finally { - restoreEnv("BAILIAN_CONFIG_DIR", previousConfigDir); - restoreEnv("PATH", previousPath); - restoreEnv("TMPDIR", previousTmpdir); - restoreEnv("NPM_CONFIG_REGISTRY", previousRegistry); - restoreEnv("npm_config_catalog", previousCatalog); - restoreEnv("npm_config_recursive", previousRecursive); - restoreEnv("npm_config_overrides", previousOverrides); - await rm(root, { recursive: true, force: true }); - } -}); + diagnostics = ""; + await expect( + installCommandPack("@ali/bailian-plugin-agent@failed", identity, policy, reporter.output), + ).rejects.toMatchObject({ exitCode: 1, message: "npm install failed with exit code 7." }); + expect(diagnostics).toContain("fixture child failure"); + if (display.output === "json") { + const records = diagnostics + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records).toContainEqual({ + warning: { code: "COMMAND_PACK_NPM", message: "npm error fixture child failure" }, + }); + expect(records.every((record) => !!record.warning)).toBe(true); + } + const manifest = JSON.parse( + await readFile(join(root, "config", "plugins", "bailian-cli", "package.json"), "utf8"), + ) as { dependencies?: Record }; + expect(manifest.dependencies?.["@ali/bailian-plugin-agent"]).toBe("1.0.0"); + } finally { + restoreEnv("BAILIAN_CONFIG_DIR", previousConfigDir); + restoreEnv("PATH", previousPath); + restoreEnv("TMPDIR", previousTmpdir); + restoreEnv("NPM_CONFIG_REGISTRY", previousRegistry); + restoreEnv("npm_config_catalog", previousCatalog); + restoreEnv("npm_config_recursive", previousRecursive); + restoreEnv("npm_config_overrides", previousOverrides); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/runtime/tests/harness/output-main.ts b/packages/runtime/tests/harness/output-main.ts index 2f1ba6312..57d5b4521 100644 --- a/packages/runtime/tests/harness/output-main.ts +++ b/packages/runtime/tests/harness/output-main.ts @@ -61,11 +61,12 @@ await createCli( probe, fail, transport, - defaultProbe: { ...probe, defaultOutput: "json" }, - defaultFail: { ...fail, defaultOutput: "json" }, + defaultProbe: { ...probe, defaultOutput: "json", defaultOutputOverridesProfile: true }, + defaultFail: { ...fail, defaultOutput: "json", defaultOutputOverridesProfile: true }, defaultValidate: { ...probe, defaultOutput: "json", + defaultOutputOverridesProfile: true, validate: () => ({ "en-US": "Rejected fixture input", "zh-CN": "测试输入被拒绝" }), }, }, diff --git a/packages/runtime/tests/output-default-policy.e2e.test.ts b/packages/runtime/tests/output-default-policy.e2e.test.ts new file mode 100644 index 000000000..7be96486b --- /dev/null +++ b/packages/runtime/tests/output-default-policy.e2e.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vite-plus/test"; +import { BailianError, defineCommand, ExitCode, type OutputFormat } from "bailian-cli-core"; +import { buildIsolatedCliEnv } from "e2e/isolation"; +import { runInProcess } from "e2e/in-process"; +import { createCli } from "../src/create-cli.ts"; + +const identity = { binName: "bl", version: "0.0.0", clientName: "test", npmPackage: "test" }; +const scenarios: { + label: string; + fallback: OutputFormat; + profile?: OutputFormat; + env?: OutputFormat; + flag?: OutputFormat; + override?: boolean; + expected: OutputFormat; +}[] = [ + { label: "command JSON fallback", fallback: "json", expected: "json" }, + { label: "command text fallback", fallback: "text", expected: "text" }, + { + label: "profile text precedes JSON fallback", + fallback: "json", + profile: "text", + expected: "text", + }, + { + label: "profile JSON precedes text fallback", + fallback: "text", + profile: "json", + expected: "json", + }, + { + label: "environment precedes profile", + fallback: "json", + profile: "json", + env: "text", + expected: "text", + }, + { + label: "flag precedes environment", + fallback: "json", + profile: "json", + env: "json", + flag: "text", + expected: "text", + }, + { + label: "telemetry explicit policy overrides profile", + fallback: "json", + profile: "text", + override: true, + expected: "json", + }, + { + label: "telemetry policy respects environment", + fallback: "json", + profile: "json", + env: "text", + override: true, + expected: "text", + }, + { + label: "telemetry policy respects flag", + fallback: "json", + profile: "json", + env: "json", + flag: "text", + override: true, + expected: "text", + }, +]; +async function invoke(scenario: (typeof scenarios)[number], fail = false) { + const directory = await mkdtemp(join(tmpdir(), "output-default-policy-")); + await writeFile( + join(directory, "config.json"), + JSON.stringify({ output: scenario.profile, language: "en-US" }), + ); + await writeFile( + join(directory, "update-state.json"), + JSON.stringify({ lastChecked: Date.now(), latestVersion: "0.0.0" }), + ); + const isolated = buildIsolatedCliEnv({ + overrides: { + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_OUTPUT: scenario.env, + DASHSCOPE_QUIET: "0", + DASHSCOPE_VERBOSE: "0", + DO_NOT_TRACK: "1", + BAILIAN_INSTALL_METHOD: "brew", + }, + }); + const command = defineCommand({ + auth: "none", + description: { "en-US": "Output policy fixture", "zh-CN": "输出策略夹具" }, + defaultOutput: scenario.fallback, + defaultOutputOverridesProfile: scenario.override, + async run(context) { + if (fail) throw new BailianError("fixture failure", ExitCode.USAGE); + context.output.result( + { format: context.settings.output }, + { text: `format: ${context.settings.output}` }, + ); + }, + }); + const args = ["format", "--quiet", ...(scenario.flag ? ["--output", scenario.flag] : [])]; + try { + return await runInProcess(() => createCli({ format: command }, identity).run(args), args, { + env: isolated.env, + }); + } finally { + isolated.dispose(); + await rm(directory, { recursive: true, force: true }); + } +} +test.each(scenarios)("$label selects matching business and reporter formats", async (scenario) => { + const result = await invoke(scenario); + expect(result.exitCode, result.stderr).toBe(0); + if (scenario.expected === "json") expect(JSON.parse(result.stdout)).toEqual({ format: "json" }); + else expect(result.stdout).toBe("format: text\n"); +}); +test.each([scenarios[0]!, scenarios[2]!, scenarios[6]!])( + "$label applies to terminal errors", + async (scenario) => { + const result = await invoke(scenario, true); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + if (scenario.expected === "json") + expect(JSON.parse(result.stderr).error.message).toBe("fixture failure"); + else expect(result.stderr).toContain("Error: fixture failure"); + }, +); diff --git a/packages/runtime/tests/output-verbose-flags.e2e.test.ts b/packages/runtime/tests/output-verbose-flags.e2e.test.ts new file mode 100644 index 000000000..9377aa84b --- /dev/null +++ b/packages/runtime/tests/output-verbose-flags.e2e.test.ts @@ -0,0 +1,163 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vite-plus/test"; +import { defineCommand } from "bailian-cli-core"; +import { buildIsolatedCliEnv } from "e2e/isolation"; +import { runInProcess } from "e2e/in-process"; +import { createCli } from "../src/create-cli.ts"; +const cases = ["json", "text"].flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].flatMap((verbose) => + [false, true].map((trace) => ({ format, quiet, verbose, trace })), + ), + ), +); +test.each(cases)( + "explicit diagnostic aliases resolve before reporter construction %j", + async ({ format, quiet, verbose, trace }) => { + const directory = await mkdtemp(join(tmpdir(), "output-verbose-flags-")); + await writeFile(join(directory, "config.json"), JSON.stringify({ language: "en-US" })); + await writeFile( + join(directory, "update-state.json"), + JSON.stringify({ lastChecked: Date.now(), latestVersion: "0.0.0" }), + ); + const isolated = buildIsolatedCliEnv({ + overrides: { + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_OUTPUT: undefined, + DASHSCOPE_VERBOSE: "0", + DASHSCOPE_QUIET: "0", + DO_NOT_TRACK: "1", + BAILIAN_INSTALL_METHOD: "brew", + }, + }); + const command = defineCommand({ + auth: "none", + description: { "en-US": "Diagnostic fixture", "zh-CN": "诊断夹具" }, + verboseFlags: ["trace"], + flags: { + trace: { type: "switch", description: { "en-US": "Trace fixture", "zh-CN": "追踪夹具" } }, + }, + async run(context) { + context.output.debug({ + code: "FIXTURE_TRACE", + message: { "en-US": "Fixture trace", "zh-CN": "夹具追踪" }, + details: { fixture: true }, + }); + context.output.result( + { verbose: context.settings.verbose }, + { text: `verbose: ${context.settings.verbose}` }, + ); + }, + }); + const args = [ + "fixture", + "--output", + format, + ...(quiet ? ["--quiet"] : []), + ...(verbose ? ["--verbose"] : []), + ...(trace ? ["--trace"] : []), + ]; + try { + const result = await runInProcess( + () => { + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); + return createCli( + { fixture: command }, + { binName: "bl", version: "0.0.0", clientName: "fixture", npmPackage: "fixture" }, + ).run(args); + }, + args, + { env: isolated.env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + if (format === "json") + expect(JSON.parse(result.stdout)).toEqual({ verbose: verbose || trace }); + else expect(result.stdout).toBe(`verbose: ${verbose || trace}\n`); + if (verbose || trace) { + if (format === "json") + expect(JSON.parse(result.stderr)).toMatchObject({ debug: { code: "FIXTURE_TRACE" } }); + else expect(result.stderr).toContain("Fixture trace"); + } else expect(result.stderr).toBe(""); + } finally { + isolated.dispose(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +const interactiveCases = [ + { tty: true, expected: "text" }, + { tty: false, expected: "json" }, + { tty: true, profile: "json", expected: "json" }, + { tty: true, profile: "text", expected: "text" }, + { tty: false, profile: "text", expected: "text" }, + { tty: true, env: "json", expected: "json" }, + { tty: true, flag: "json", expected: "json" }, +]; +test.each(interactiveCases)( + "native interactive fallback preserves explicit output selection %j", + async (scenario) => { + const directory = await mkdtemp(join(tmpdir(), "output-interactive-default-")); + await writeFile( + join(directory, "config.json"), + JSON.stringify({ language: "en-US", output: scenario.profile }), + ); + await writeFile( + join(directory, "update-state.json"), + JSON.stringify({ lastChecked: Date.now(), latestVersion: "0.0.0" }), + ); + const isolated = buildIsolatedCliEnv({ + overrides: { + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_OUTPUT: scenario.env, + DASHSCOPE_VERBOSE: "0", + DASHSCOPE_QUIET: "0", + DO_NOT_TRACK: "1", + BAILIAN_INSTALL_METHOD: "brew", + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: scenario.tty }); + const command = defineCommand({ + auth: "none", + description: { "en-US": "Interactive fixture", "zh-CN": "交互夹具" }, + defaultOutput: "json", + defaultInteractiveOutput: "text", + async run(context) { + context.output.result( + { format: context.settings.output }, + { text: `format: ${context.settings.output}` }, + ); + }, + }); + const args = ["fixture", "--quiet", ...(scenario.flag ? ["--output", scenario.flag] : [])]; + try { + const result = await runInProcess( + () => { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: scenario.tty, + }); + return createCli( + { fixture: command }, + { binName: "bl", version: "0.0.0", clientName: "fixture", npmPackage: "fixture" }, + ).run(args); + }, + args, + { env: isolated.env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + if (scenario.expected === "json") + expect(JSON.parse(result.stdout)).toEqual({ format: "json" }); + else expect(result.stdout).toBe("format: text\n"); + } finally { + if (descriptor) Object.defineProperty(process.stdout, "isTTY", descriptor); + else Reflect.deleteProperty(process.stdout, "isTTY"); + isolated.dispose(); + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/skills/bailian-cli/reference/pipeline.md b/skills/bailian-cli/reference/pipeline.md index 3810659e2..c4d0490c2 100644 --- a/skills/bailian-cli/reference/pipeline.md +++ b/skills/bailian-cli/reference/pipeline.md @@ -25,14 +25,14 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| -------------------------- | ------ | -------- | ------------------------------------ | -| `--file ` | string | yes | Pipeline definition file (YAML/JSON) | -| `--input ` | string | no | Runtime input as inline JSON | -| `--input-file ` | string | no | Runtime input from a JSON file | -| `--concurrency ` | number | no | Max parallel steps (default: 1) | -| `--events ` | string | no | Emit lifecycle events: jsonl | -| `--step-timeout ` | number | no | Default step timeout in seconds | +| Flag | Type | Required | Description | +| -------------------------- | ------ | -------- | ------------------------------------------------------- | +| `--file ` | string | yes | Pipeline definition file (YAML/JSON) | +| `--input ` | string | no | Runtime input as inline JSON | +| `--input-file ` | string | no | Runtime input from a JSON file | +| `--concurrency ` | number | no | Max parallel steps (default: 1) | +| `--events ` | string | no | Emit raw lifecycle events: jsonl (requires text output) | +| `--step-timeout ` | number | no | Default step timeout in seconds | #### Examples @@ -49,7 +49,7 @@ bl pipeline run --file workflow.yaml --dry-run ``` ```bash -bl pipeline run --file workflow.json --events jsonl +bl pipeline run --file workflow.json --output text --events jsonl ``` ```bash From 529855a1a1f73e6c97c2e1f1f596605152e0e714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 30 Oct 2026 10:27:42 +0800 Subject: [PATCH 12/28] refactor(commands): unify batch and document parsing output Route this command family through the invocation output contract while preserving business results and request behavior. Validation: isolated combined regression suite; normal repository pre-commit checks. --- docs/parsex.md | 4 +- .../commands/src/commands/batch/cancel.ts | 7 +- .../commands/src/commands/batch/convert.ts | 7 +- .../commands/src/commands/batch/create.ts | 33 +- .../commands/src/commands/batch/download.ts | 74 +-- packages/commands/src/commands/batch/get.ts | 36 +- packages/commands/src/commands/batch/list.ts | 59 +-- .../commands/src/commands/parsex/result.ts | 19 +- .../commands/src/commands/parsex/shared.ts | 10 +- .../commands/tests/e2e/parsex.e2e.test.ts | 16 +- .../tests/output/batch-parsex-output.test.ts | 477 ++++++++++++++++++ 11 files changed, 610 insertions(+), 132 deletions(-) create mode 100644 packages/commands/tests/output/batch-parsex-output.test.ts diff --git a/docs/parsex.md b/docs/parsex.md index 1d66838de..55132d55b 100644 --- a/docs/parsex.md +++ b/docs/parsex.md @@ -122,8 +122,8 @@ The individual result parameter tables take precedence over the general OSS page | `--poll-timeout` | Total deadline including in-flight HTTP requests, 1–86400 seconds; default 600 | | `--timeout` | Existing per-request HTTP timeout | | Exit codes with `--follow` | Success 0; failed task/service error 1; invalid arguments 2; client deadline 5; Ctrl-C 130 | -| `--output json` | One final response on stdout; progress on stderr | -| `--quiet` | Submit prints only biz_id; result prints only status | +| `--output json` | One final response on stdout; structured diagnostics on stderr, without progress | +| `--quiet` | Hides progress only; submit/result retain the complete response in the selected format | | `--dry-run` | Validates local input and prints a redacted request; no business requests or business file writes | Ctrl-C or timeout stops local polling without cancelling the task. Keep `biz_id` and use `result` to resume. A lost submit response does not prove that no task was created; check before resubmitting. Framework telemetry/update checks follow their existing settings. diff --git a/packages/commands/src/commands/batch/cancel.ts b/packages/commands/src/commands/batch/cancel.ts index 6e0942149..1bfe3481c 100644 --- a/packages/commands/src/commands/batch/cancel.ts +++ b/packages/commands/src/commands/batch/cancel.ts @@ -1,5 +1,4 @@ import { defineCommand, cancelBatch } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; import { BATCH_ID_FLAGS, DRY_RUN_NOTE } from "./shared.ts"; export default defineCommand({ description: { "en-US": "Request cancellation of a Batch task", "zh-CN": "请求取消 Batch 任务" }, @@ -18,11 +17,11 @@ export default defineCommand({ ], async run(ctx) { if (ctx.settings.dryRun) { - emitResult({ action: "batch.cancel", batch_id: ctx.flags.batchId }, "json"); + const preview = { action: "batch.cancel", batch_id: ctx.flags.batchId }; + ctx.output.result(preview, { text: JSON.stringify(preview, null, 2) }); return; } const job = await cancelBatch(ctx.client, ctx.flags.batchId); - if (ctx.settings.quiet) emitBare(job.id); - else emitResult(job, ctx.settings.output === "text" ? "text" : "json"); + ctx.output.result(job); }, }); diff --git a/packages/commands/src/commands/batch/convert.ts b/packages/commands/src/commands/batch/convert.ts index bf1a8e53a..218dbb366 100644 --- a/packages/commands/src/commands/batch/convert.ts +++ b/packages/commands/src/commands/batch/convert.ts @@ -1,5 +1,4 @@ import { defineCommand, prepareBatch, batchDataNotes, type FlagsDef } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; import { ENDPOINT_FLAGS, FORCE_FLAGS, DRY_RUN_NOTE } from "./shared.ts"; const CONVERT_FLAGS = { file: { @@ -74,8 +73,8 @@ export default defineCommand({ ], async run(ctx) { const report = await prepareBatch({ ...ctx.flags, dryRun: ctx.settings.dryRun }); - for (const warning of report.warnings) console.error(warning); - if (ctx.settings.quiet) emitBare(report.output_file); - else emitResult(report, ctx.settings.output === "text" ? "text" : "json"); + for (const warning of report.warnings) + ctx.output.warning({ code: "BATCH_VALIDATION_WARNING", message: warning }); + ctx.output.result(report); }, }); diff --git a/packages/commands/src/commands/batch/create.ts b/packages/commands/src/commands/batch/create.ts index a4b8feb04..1ca0120bc 100644 --- a/packages/commands/src/commands/batch/create.ts +++ b/packages/commands/src/commands/batch/create.ts @@ -10,7 +10,6 @@ import { type BatchReport, type BatchCreateParams, } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; import { ENDPOINT_FLAGS, DRY_RUN_NOTE, @@ -120,7 +119,8 @@ export default defineCommand({ report = await validateBatch(blob ?? flags.file); if (flags.endpoint && flags.endpoint !== report.endpoint) batchUsage("--endpoint differs from JSONL url.", "--endpoint 与 JSONL url 不一致。"); - for (const warning of report.warnings) console.error(warning); + for (const warning of report.warnings) + ctx.output.warning({ code: "BATCH_VALIDATION_WARNING", message: warning }); } const request: BatchCreateParams = { input_file_id: flags.inputFileId ?? "", @@ -129,15 +129,13 @@ export default defineCommand({ metadata: batchMetadata(flags), }; if (settings.dryRun) { - emitResult( - { - action: "batch.create", - request, - validation: report, - upload: flags.file ? { file: flags.file, purpose: "batch" } : undefined, - }, - "json", - ); + const preview = { + action: "batch.create", + request, + validation: report, + upload: flags.file ? { file: flags.file, purpose: "batch" } : undefined, + }; + ctx.output.result(preview, { text: JSON.stringify(preview, null, 2) }); return; } let uploaded: string | undefined; @@ -152,16 +150,17 @@ export default defineCommand({ } try { const job = await createBatch(ctx.client, request); - if (settings.quiet) emitBare(job.id); - else emitResult(job, settings.output === "text" ? "text" : "json"); + ctx.output.result(job); } catch (error) { if (uploaded) - console.error( - ctx.localize({ + ctx.output.warning({ + code: "BATCH_INPUT_UPLOADED", + message: { "en-US": `Input uploaded: ${uploaded}. Creation failed; reuse --input-file-id with --endpoint or inspect dataset file management. The file was not deleted.`, "zh-CN": `输入已上传:${uploaded}。创建失败;可通过 --input-file-id 与 --endpoint 复用,或检查 dataset 文件管理。未删除该文件。`, - }), - ); + }, + details: { file_id: uploaded }, + }); throw error; } }, diff --git a/packages/commands/src/commands/batch/download.ts b/packages/commands/src/commands/batch/download.ts index e88030446..658e6a870 100644 --- a/packages/commands/src/commands/batch/download.ts +++ b/packages/commands/src/commands/batch/download.ts @@ -11,7 +11,6 @@ import { ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; import { BATCH_ID_FLAGS, FORCE_FLAGS, DRY_RUN_NOTE } from "./shared.ts"; const DOWNLOAD_FLAGS = { ...BATCH_ID_FLAGS, @@ -61,27 +60,25 @@ export default defineCommand({ join("batch-results", encodeURIComponent(ctx.flags.batchId).replace(/\./g, "%2E")), ); if (ctx.settings.dryRun) { - emitResult( - { - action: "batch.download", - batch_id: ctx.flags.batchId, - out_dir: directory, - planned_files: [ - ctx.localize({ - "en-US": "result.jsonl (if available)", - "zh-CN": "result.jsonl(若可用)", - }), - ctx.localize({ - "en-US": "error.jsonl (if available)", - "zh-CN": "error.jsonl(若可用)", - }), - ...(ctx.flags.csv ? ["result.csv"] : []), - ], - csv: ctx.flags.csv, - force: ctx.flags.force, - }, - "json", - ); + const preview = { + action: "batch.download", + batch_id: ctx.flags.batchId, + out_dir: directory, + planned_files: [ + ctx.localize({ + "en-US": "result.jsonl (if available)", + "zh-CN": "result.jsonl(若可用)", + }), + ctx.localize({ + "en-US": "error.jsonl (if available)", + "zh-CN": "error.jsonl(若可用)", + }), + ...(ctx.flags.csv ? ["result.csv"] : []), + ], + csv: ctx.flags.csv, + force: ctx.flags.force, + }; + ctx.output.result(preview, { text: JSON.stringify(preview, null, 2) }); return; } const job = await getBatch(ctx.client, ctx.flags.batchId); @@ -154,28 +151,33 @@ export default defineCommand({ csvReport = await exportBatchCsv(saved, target, ctx.flags.force); saved.push(target); if (csvReport.unknown_records) - console.error( - ctx.localize({ + ctx.output.warning({ + code: "BATCH_CSV_UNKNOWN_RECORDS", + message: { "en-US": `${csvReport.unknown_records} unrecognized result envelopes; inspect original JSONL.`, "zh-CN": `${csvReport.unknown_records} 条结果包络无法识别,请检查原始 JSONL。`, - }), - ); + }, + details: { unknown_records: csvReport.unknown_records }, + }); } } catch (error) { if (saved.length) - console.error( - ctx.localize({ + ctx.output.warning({ + code: "BATCH_DOWNLOAD_PARTIAL", + message: { "en-US": `Failed at ${stage}. Preserved files: ${saved.join(", ")}. Rerun batch download with --force to download and convert again.`, "zh-CN": `${stage} 阶段失败。已保留文件:${saved.join(",")}。可用 --force 重跑 batch download 重新下载转换。`, - }), - ); + }, + details: { stage, files: saved }, + }); throw error; } - if (ctx.settings.quiet) emitBare(saved.join("\n")); - else - emitResult( - { batch_id: job.id, status: job.status, files: saved, line_counts: counts, csv: csvReport }, - ctx.settings.output === "text" ? "text" : "json", - ); + ctx.output.result({ + batch_id: job.id, + status: job.status, + files: saved, + line_counts: counts, + csv: csvReport, + }); }, }); diff --git a/packages/commands/src/commands/batch/get.ts b/packages/commands/src/commands/batch/get.ts index ef3e71fc6..1a1b1b94d 100644 --- a/packages/commands/src/commands/batch/get.ts +++ b/packages/commands/src/commands/batch/get.ts @@ -6,7 +6,7 @@ import { ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitBare, emitResult, registerInterruptHandler } from "bailian-cli-runtime"; +import { registerInterruptHandler } from "bailian-cli-runtime"; import { BATCH_ID_FLAGS, DRY_RUN_NOTE, batchInteger, followBatch } from "./shared.ts"; const GET_FLAGS = { ...BATCH_ID_FLAGS, @@ -65,7 +65,8 @@ export default defineCommand({ }, async run(ctx) { if (ctx.settings.dryRun) { - emitResult({ action: "batch.get", ...ctx.flags }, "json"); + const preview = { action: "batch.get", ...ctx.flags }; + ctx.output.result(preview, { text: JSON.stringify(preview, null, 2) }); return; } const controller = new AbortController(); @@ -88,27 +89,27 @@ export default defineCommand({ pollTimeout: ctx.flags.pollTimeout, signal: controller.signal, progress: (current) => { - if (!ctx.settings.quiet) - console.error( - `${current.id}: ${current.status} ${JSON.stringify(current.request_counts ?? {})}`, - ); + ctx.output.progress( + `${current.id}: ${current.status} ${JSON.stringify(current.request_counts ?? {})}`, + ); if ( !BATCH_STATUSES.includes(current.status as (typeof BATCH_STATUSES)[number]) && !unknown.has(current.status) ) { unknown.add(current.status); - console.error( - ctx.localize({ + ctx.output.warning({ + code: "BATCH_UNKNOWN_STATE", + message: { "en-US": `Unknown state ${current.status}; keep polling without inferring completion.`, "zh-CN": `未知状态 ${current.status};继续轮询,不推断已完成。`, - }), - ); + }, + details: { batch_id: current.id, status: current.status }, + }); } }, }) : await getBatch(ctx.client, ctx.flags.batchId); - if (ctx.settings.quiet) emitBare(job.id); - else emitResult(job, ctx.settings.output === "text" ? "text" : "json"); + ctx.output.result(job); if (ctx.flags.follow) { if (job.status !== "completed") throw new BailianError( @@ -124,13 +125,10 @@ export default defineCommand({ }), { rawResponse: JSON.stringify(job) }, ); - if (!ctx.settings.quiet) - console.error( - ctx.localize({ - "en-US": `Completed; failed rows: ${job.request_counts?.failed ?? "unknown"}. Next: batch download --batch-id ${job.id}`, - "zh-CN": `已完成;失败行:${job.request_counts?.failed ?? "未知"}。下一步:batch download --batch-id ${job.id}`, - }), - ); + ctx.output.info({ + "en-US": `Completed; failed rows: ${job.request_counts?.failed ?? "unknown"}. Next: batch download --batch-id ${job.id}`, + "zh-CN": `已完成;失败行:${job.request_counts?.failed ?? "未知"}。下一步:batch download --batch-id ${job.id}`, + }); } } finally { releaseInterrupt?.(); diff --git a/packages/commands/src/commands/batch/list.ts b/packages/commands/src/commands/batch/list.ts index da242c9ed..183c253ed 100644 --- a/packages/commands/src/commands/batch/list.ts +++ b/packages/commands/src/commands/batch/list.ts @@ -1,5 +1,5 @@ import { defineCommand, listBatches, BATCH_STATUSES, type FlagsDef } from "bailian-cli-core"; -import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; +import { formatTable } from "bailian-cli-runtime"; import { batchDate, batchInteger, DRY_RUN_NOTE } from "./shared.ts"; const LIST_FLAGS = { limit: { @@ -91,36 +91,37 @@ export default defineCommand({ create_before: ctx.flags.createdBefore, }; if (ctx.settings.dryRun) { - emitResult({ action: "batch.list", query }, "json"); + const preview = { action: "batch.list", query }; + ctx.output.result(preview, { text: JSON.stringify(preview, null, 2) }); return; } const response = await listBatches(ctx.client, query); - if (ctx.settings.quiet) emitBare(response.data.map((job) => job.id).join("\n")); - else if (ctx.settings.output === "text") - emitBare( - formatTable( - ctx - .localize({ - "en-US": "ID|NAME|STATUS|COMPLETED/FAILED/TOTAL|CREATED", - "zh-CN": "ID|名称|状态|成功/失败/总数|创建时间", - }) - .split("|"), - response.data.map((job) => [ - job.id, - job.metadata?.ds_name ?? "", - job.status, - `${job.request_counts?.completed ?? ""}/${job.request_counts?.failed ?? ""}/${job.request_counts?.total ?? ""}`, - job.created_at ? new Date(job.created_at * 1000).toISOString() : "", - ]), - ).join("\n"), - ); - else emitResult(response, "json"); - if (response.has_more && response.last_id && !ctx.settings.quiet) - console.error( - ctx.localize({ - "en-US": `Next page: batch list --after ${response.last_id}`, - "zh-CN": `下一页:batch list --after ${response.last_id}`, - }), - ); + ctx.output.result( + response, + ctx.settings.output === "text" + ? { + text: formatTable( + ctx + .localize({ + "en-US": "ID|NAME|STATUS|COMPLETED/FAILED/TOTAL|CREATED", + "zh-CN": "ID|名称|状态|成功/失败/总数|创建时间", + }) + .split("|"), + response.data.map((job) => [ + job.id, + job.metadata?.ds_name ?? "", + job.status, + `${job.request_counts?.completed ?? ""}/${job.request_counts?.failed ?? ""}/${job.request_counts?.total ?? ""}`, + job.created_at ? new Date(job.created_at * 1000).toISOString() : "", + ]), + ).join("\n"), + } + : undefined, + ); + if (response.has_more && response.last_id) + ctx.output.info({ + "en-US": `Next page: batch list --after ${response.last_id}`, + "zh-CN": `下一页:batch list --after ${response.last_id}`, + }); }, }); diff --git a/packages/commands/src/commands/parsex/result.ts b/packages/commands/src/commands/parsex/result.ts index 1243bc4e3..147fd6e12 100644 --- a/packages/commands/src/commands/parsex/result.ts +++ b/packages/commands/src/commands/parsex/result.ts @@ -2,14 +2,13 @@ import { BailianError, ExitCode, defineCommand, - detectOutputFormat, followParsexResult, getParsexResult, parsexEndpoint, type ParsexOperation, type ParsexResultRequest, } from "bailian-cli-core"; -import { emitBare, emitResult, registerInterruptHandler } from "bailian-cli-runtime"; +import { registerInterruptHandler } from "bailian-cli-runtime"; import { integer, nonEmpty, @@ -135,12 +134,13 @@ export function createResultCommand(operation: ParsexOperation) { ...(flags.stepSize === undefined ? {} : { step_size: flags.stepSize as number }), ...(ossConfig === undefined ? {} : { oss_config: ossConfig }), }; - const format = detectOutputFormat(settings.output); if (settings.dryRun) { - emitResult( - { endpoint, method: "POST", request: redactParsexRequest(body), follow: flags.follow }, - format, - ); + ctx.output.result({ + endpoint, + method: "POST", + request: redactParsexRequest(body), + follow: flags.follow, + }); return; } const controller = new AbortController(); @@ -165,12 +165,11 @@ export function createResultCommand(operation: ParsexOperation) { pollTimeout: flags.pollTimeout ?? 600, signal: controller.signal, progress: (current) => { - if (!settings.quiet) console.error(`${flags.bizId}: ${current.data.status}`); + ctx.output.progress(`${flags.bizId}: ${current.data.status}`); }, }) : await getParsexResult(ctx.client, baseUrl, operation, body); - if (settings.quiet) emitBare(response.data.status); - else emitResult(response, format); + ctx.output.result(response); if (flags.follow && response.data.status === "failed") throw new BailianError( typeof response.data.message === "string" diff --git a/packages/commands/src/commands/parsex/shared.ts b/packages/commands/src/commands/parsex/shared.ts index cc5ab357a..32293f304 100644 --- a/packages/commands/src/commands/parsex/shared.ts +++ b/packages/commands/src/commands/parsex/shared.ts @@ -5,7 +5,6 @@ import { UsageError, parsexBaseUrl, parsexEndpoint, - detectOutputFormat, submitParsexTask, type CommandContext, type FlagsDef, @@ -14,7 +13,6 @@ import { type ParsexOssCredentials, type ParsexSubmitRequest, } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; export { WORKSPACE_FLAG } from "../shared/workspace.ts"; export const PARSEX_NOTE = { @@ -249,18 +247,16 @@ export function resolveParsexBaseUrl( } export async function runSubmit( - ctx: Pick & { flags: { workspaceId?: string } }, + ctx: Pick & { flags: { workspaceId?: string } }, operation: ParsexOperation, body: ParsexSubmitRequest, ): Promise { const baseUrl = resolveParsexBaseUrl(ctx); const endpoint = parsexEndpoint(baseUrl, operation, "submit"); - const format = detectOutputFormat(ctx.settings.output); if (ctx.settings.dryRun) { - emitResult({ endpoint, method: "POST", request: redactParsexRequest(body) }, format); + ctx.output.result({ endpoint, method: "POST", request: redactParsexRequest(body) }); return; } const response = await submitParsexTask(ctx.client, baseUrl, operation, body); - if (ctx.settings.quiet) emitBare(response.data.biz_id); - else emitResult(response, format); + ctx.output.result(response); } diff --git a/packages/commands/tests/e2e/parsex.e2e.test.ts b/packages/commands/tests/e2e/parsex.e2e.test.ts index b7f26ec8f..064dbc80c 100644 --- a/packages/commands/tests/e2e/parsex.e2e.test.ts +++ b/packages/commands/tests/e2e/parsex.e2e.test.ts @@ -454,7 +454,7 @@ describe("e2e: ParseX (offline HTTP fixture)", () => { data: { status: "success", layouts: resultData.layouts }, }); expect(requests).toHaveLength(4); - expect(result.stderr).toContain("processing"); + expect(result.stderr).toBe(""); expect(requests.every((request) => request.body.biz_id === "biz-fixture")).toBe(true); }); test("single failed query succeeds; followed failure preserves service message and request ID", async () => { @@ -548,7 +548,7 @@ describe("e2e: ParseX (offline HTTP fixture)", () => { request: { processing: { extract_processing_config: { extract_schema: exampleSchema } } }, }); }); - test("quiet submit outputs only ID; quiet result outputs only status", async () => { + test("quiet submit and result retain complete JSON responses", async () => { const submitted = await local([ "parse", "submit", @@ -556,9 +556,17 @@ describe("e2e: ParseX (offline HTTP fixture)", () => { "https://example.com/file.pdf", "--quiet", ]); - expect(submitted.stdout.trim()).toBe("biz-fixture"); + expect(parseStdoutJson(submitted.stdout)).toEqual({ + request_id: "request-submit", + data: { biz_id: "biz-fixture" }, + }); + expect(submitted.stderr).toBe(""); const queried = await local(["extract", "result", "--biz-id", "biz-fixture", "--quiet"]); - expect(queried.stdout.trim()).toBe("success"); + expect(parseStdoutJson(queried.stdout)).toEqual({ + request_id: "request-result", + data: { ...resultData, status: "success" }, + }); + expect(queried.stderr).toBe(""); }); test("Ctrl-C aborts polling, removes its listener and never cancels the remote job", async () => { interrupt = true; diff --git a/packages/commands/tests/output/batch-parsex-output.test.ts b/packages/commands/tests/output/batch-parsex-output.test.ts new file mode 100644 index 000000000..1835d7ea1 --- /dev/null +++ b/packages/commands/tests/output/batch-parsex-output.test.ts @@ -0,0 +1,477 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + buildSettings, + Client, + httpTransport, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import * as commands from "bailian-cli-commands"; + +const identity = { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }; +const job = { + id: "batch-one", + status: "completed", + input_file_id: "file-input", + output_file_id: "file-output", + error_file_id: "file-error", + request_counts: { total: 2, completed: 1, failed: 1 }, + metadata: { ds_name: "Example" }, + errors: { data: [{ code: "RowFailed", message: "original task error" }] }, +}; +const listed = { data: [job], first_id: "batch-one", last_id: "batch-one", has_more: true }; +const submitted = { + request_id: "request-submit", + data: { biz_id: "biz-one" }, + extra: { retained: true }, +}; +const parsed = { + request_id: "request-result", + data: { status: "success", layouts: [{ text: "Hello" }], fields: [{ value: "Title" }] }, +}; +const outputRecord = + '{"custom_id":"001","response":{"status_code":200,"body":{"choices":[{"message":{"content":"hello"}}]}},"error":null}\n'; +let directory: string; +let requests: Array<{ method: string; path: string; body?: unknown }>; +let failCreate: boolean; +let invalidCsv: boolean; +let unknownCsv: boolean; +let statuses: string[]; +let parseStatus: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "batch-parsex-output-")); + await writeFile(join(directory, "input.csv"), "custom_id,content\n001,hello\n"); + const row = { + custom_id: "001", + method: "POST", + url: "/v1/chat/completions", + body: { + model: "qwen-plus", + messages: [{ role: "user", content: "hello" }], + extra_body: { enable_thinking: true }, + }, + }; + await writeFile(join(directory, "input.jsonl"), JSON.stringify(row) + "\n"); + requests = []; + failCreate = false; + invalidCsv = false; + unknownCsv = false; + statuses = ["completed"]; + parseStatus = "success"; + vi.spyOn(httpTransport, "fetch").mockImplementation(async (input, init) => { + const url = new URL( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + requests.push({ method: init?.method ?? "GET", path: url.pathname + url.search, body }); + if (url.pathname.endsWith("/files") && init?.method === "POST") + return Response.json({ id: "file-uploaded" }); + if (url.pathname.endsWith("/batches") && init?.method === "POST") { + return failCreate + ? Response.json( + { code: "Rejected", message: "original create error", request_id: "request-error" }, + { status: 400 }, + ) + : Response.json({ ...job, status: "validating" }); + } + if (url.pathname.endsWith("/cancel")) return Response.json({ ...job, status: "cancelling" }); + if (url.pathname.endsWith("/batches")) return Response.json(listed); + if (url.pathname.endsWith("/batches/batch-one")) + return Response.json({ + ...job, + status: statuses.length > 1 ? statuses.shift() : statuses[0], + }); + if (url.pathname.endsWith("/files/file-output/content")) + return new Response( + invalidCsv + ? "invalid-json\n" + : unknownCsv + ? '{"custom_id":"unknown","future_envelope":true}\n' + : outputRecord, + ); + if (url.pathname.endsWith("/files/file-error/content")) + return new Response('{"custom_id":"002","error":{"code":"Example","message":"row error"}}\n'); + if (url.pathname.endsWith("/submit")) return Response.json(submitted); + if (url.pathname.endsWith("/result")) + return Response.json({ + ...parsed, + data: { + ...parsed.data, + status: parseStatus, + ...(parseStatus === "failed" + ? { code: "EngineFailed", message: "original engine error" } + : {}), + }, + }); + throw new Error(`Unexpected fixture request: ${url.pathname}`); + }); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); + +interface Display { + format: "json" | "text"; + quiet?: boolean; + verbose?: boolean; + dryRun?: boolean; +} +async function invoke(command: AnyCommand, flags: Record, display: Display) { + let stdout = "", + stderr = ""; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const consoleSpy = vi.spyOn(console, "error").mockImplementation((...messages: unknown[]) => { + stderr += messages.map(String).join(" ") + "\n"; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + file: { language: "en-US", workspace_id: "ws-test" }, + env: {}, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + const client = new Client({ + identity, + settings, + baseUrl: "https://fixture.invalid", + apiCred: { token: "fixture-api-key", source: "flag", baseUrl: "https://fixture.invalid" }, + debug: (diagnostic) => reporter.output.debug(diagnostic), + }); + let exitCode = 0; + try { + try { + await command.run({ + identity, + settings, + client, + flags, + localize, + output: reporter.output, + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, exitCode }; + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + consoleSpy.mockRestore(); + } +} +function diagnostics(stderr: string) { + return stderr.trim() + ? stderr + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)) + : []; +} +const scenarios = [ + { + name: "batch create", + command: commands.batchCreate, + flags: () => ({ inputFileId: "file-input", endpoint: "/v1/chat/completions" }), + want: () => ({ ...job, status: "validating" }), + text: "id: batch-one", + }, + { + name: "batch list", + command: commands.batchList, + flags: () => ({}), + want: () => listed, + text: "ID", + }, + { + name: "batch get", + command: commands.batchGet, + flags: () => ({ batchId: "batch-one" }), + want: () => job, + text: "id: batch-one", + }, + { + name: "batch cancel", + command: commands.batchCancel, + flags: () => ({ batchId: "batch-one" }), + want: () => ({ ...job, status: "cancelling" }), + text: "status: cancelling", + }, + { + name: "batch download", + command: commands.batchDownload, + flags: () => ({ batchId: "batch-one", outDir: join(directory, "download") }), + want: () => ({ + batch_id: "batch-one", + status: "completed", + files: [join(directory, "download/result.jsonl"), join(directory, "download/error.jsonl")], + line_counts: { "result.jsonl": 1, "error.jsonl": 1 }, + }), + text: "batch_id: batch-one", + }, + { + name: "batch convert", + command: commands.batchConvert, + flags: () => ({ + file: join(directory, "input.csv"), + outputFile: join(directory, "converted.jsonl"), + model: "qwen-plus", + }), + want: () => + expect.objectContaining({ + records: 1, + model: "qwen-plus", + endpoint: "/v1/chat/completions", + warnings: [], + ignored_columns: [], + output_file: join(directory, "converted.jsonl"), + }), + text: "records: 1", + }, + { + name: "parsex parse submit", + command: commands.parsexParseSubmit, + flags: () => ({ fileUrl: "https://source.invalid/document.pdf" }), + want: () => submitted, + text: "request_id: request-submit", + }, + { + name: "parsex extract submit", + command: commands.parsexExtractSubmit, + flags: () => ({ fileUrl: "https://source.invalid/document.pdf", configId: "config-one" }), + want: () => submitted, + text: "request_id: request-submit", + }, + { + name: "parsex parse result", + command: commands.parsexParseResult, + flags: () => ({ bizId: "biz-one" }), + want: () => parsed, + text: "request_id: request-result", + }, + { + name: "parsex extract result", + command: commands.parsexExtractResult, + flags: () => ({ bizId: "biz-one" }), + want: () => parsed, + text: "request_id: request-result", + }, +]; +const modes = ["json", "text"].flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ format: format as "json" | "text", quiet, verbose })), + ), +); +for (const scenario of scenarios) { + test.each(modes)(`${scenario.name} preserves full final output: %j`, async (display) => { + const result = await invoke(scenario.command, scenario.flags(), display); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") { + expect(JSON.parse(result.stdout)).toEqual(scenario.want()); + expect(diagnostics(result.stderr).every((record) => record.debug !== undefined)).toBe(true); + } else expect(result.stdout).toContain(scenario.text); + expect(result.stdout + result.stderr).not.toContain("fixture-api-key"); + if (scenario.name === "batch convert") + expect(await readFile(join(directory, "converted.jsonl"), "utf8")).toContain( + '"custom_id":"001"', + ); + if (scenario.name === "batch download") + expect(await readFile(join(directory, "download/result.jsonl"), "utf8")).toBe(outputRecord); + }); +} + +test.each([false, true])( + "Batch and Parsex follow hide JSON progress, preserve terminal data (quiet=%s)", + async (quiet) => { + for (const command of [ + commands.batchGet, + commands.parsexParseResult, + commands.parsexExtractResult, + ]) { + const batch = command === commands.batchGet; + const result = await invoke( + command, + batch ? { batchId: "batch-one", follow: true } : { bizId: "biz-one", follow: true }, + { format: "json", quiet }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(batch ? job : parsed); + expect(result.stderr).toBe(""); + } + }, +); +test.each([false, true])( + "text progress and next-page hints obey quiet=%s without changing results", + async (quiet) => { + const result = await invoke( + commands.batchGet, + { batchId: "batch-one", follow: true }, + { format: "text", quiet }, + ); + expect(result.stdout).toContain("id: batch-one"); + expect(result.stderr.includes("failed rows: 1")).toBe(!quiet); + const page = await invoke(commands.batchList, {}, { format: "text", quiet }); + expect(page.stdout).toContain("ID"); + expect(page.stderr.includes("Next page")).toBe(!quiet); + }, +); +test.each([commands.batchGet, commands.parsexParseResult, commands.parsexExtractResult])( + "failed query versus failed follow retains response and exit semantics", + async (command) => { + statuses = ["failed"]; + parseStatus = "failed"; + const flags = command === commands.batchGet ? { batchId: "batch-one" } : { bizId: "biz-one" }; + const single = await invoke(command, flags, { format: "json", quiet: true }); + expect(single.exitCode).toBe(0); + const followed = await invoke( + command, + { ...flags, follow: true }, + { format: "json", quiet: true }, + ); + expect(followed.exitCode).toBe(1); + expect(JSON.parse(followed.stdout)).toEqual(JSON.parse(single.stdout)); + const records = diagnostics(followed.stderr); + expect(records).toHaveLength(1); + expect(records[0].error.message).toContain( + command === commands.batchGet ? "original task error" : "original engine error", + ); + }, +); +test("local Batch input warnings and uploaded-file recovery stay structured under JSON quiet", async () => { + failCreate = true; + const result = await invoke( + commands.batchCreate, + { file: join(directory, "input.jsonl") }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const records = diagnostics(result.stderr); + expect(records.map((record) => record.warning?.code ?? "error")).toEqual([ + "BATCH_VALIDATION_WARNING", + "BATCH_INPUT_UPLOADED", + "error", + ]); + expect(records[1].warning.details).toEqual({ file_id: "file-uploaded" }); + expect(records[2].error).toMatchObject({ + message: "original create error", + request_id: "request-error", + api_code: "Rejected", + http_status: 400, + }); + expect(requests.map((request) => [request.method, request.path])).toEqual([ + ["POST", "/compatible-mode/v1/files"], + ["POST", "/compatible-mode/v1/batches"], + ]); +}); +test("failed CSV conversion keeps downloaded JSONL and structured recovery details", async () => { + invalidCsv = true; + const outDir = join(directory, "partial"); + const result = await invoke( + commands.batchDownload, + { batchId: "batch-one", outDir, csv: true }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(await readFile(join(outDir, "result.jsonl"), "utf8")).toBe("invalid-json\n"); + const records = diagnostics(result.stderr); + expect(records[0].warning).toMatchObject({ + code: "BATCH_DOWNLOAD_PARTIAL", + details: { stage: "CSV", files: [join(outDir, "result.jsonl"), join(outDir, "error.jsonl")] }, + }); + expect(records.at(-1).error).toBeDefined(); +}); +test("unknown CSV envelopes warn in JSON quiet while preserving the artifacts", async () => { + unknownCsv = true; + const result = await invoke( + commands.batchDownload, + { batchId: "batch-one", outDir: join(directory, "unknown"), csv: true }, + { format: "json", quiet: true }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).csv).toEqual({ records: 2, unknown_records: 1 }); + expect(diagnostics(result.stderr)[0].warning).toMatchObject({ + code: "BATCH_CSV_UNKNOWN_RECORDS", + details: { unknown_records: 1 }, + }); +}); +test("unknown Batch state warns once and still polls the completed task", async () => { + // Direct command invocation isolates rendering; public interval validation remains in the existing E2E suite. + statuses = ["future-state", "future-state", "completed"]; + const result = await invoke( + commands.batchGet, + { batchId: "batch-one", follow: true, interval: 0 }, + { format: "json", quiet: true }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(job); + const records = diagnostics(result.stderr); + expect(records).toHaveLength(1); + expect(records[0].warning).toMatchObject({ + code: "BATCH_UNKNOWN_STATE", + details: { batch_id: "batch-one", status: "future-state" }, + }); + expect(requests).toHaveLength(3); +}); +test.each( + scenarios.filter( + (scenario) => scenario.name.startsWith("batch ") && scenario.name !== "batch convert", + ), +)("$name dry-run retains JSON preview with no HTTP/files", async (scenario) => { + const result = await invoke(scenario.command, scenario.flags(), { + format: "text", + quiet: true, + dryRun: true, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).action).toBe(scenario.name.replace(" ", ".")); + expect(requests).toEqual([]); +}); + +test.each([false, true])( + "Parsex text follow progress obeys quiet=%s and keeps the response", + async (quiet) => { + for (const command of [commands.parsexParseResult, commands.parsexExtractResult]) { + const result = await invoke( + command, + { bizId: "biz-one", follow: true }, + { format: "text", quiet }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("request_id: request-result"); + expect(result.stdout).toContain("Hello"); + expect(result.stderr.includes("biz-one: success")).toBe(!quiet); + } + }, +); From 8cbabc49e3b211277ea96960c6c2cee97727f37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 30 Oct 2026 10:27:58 +0800 Subject: [PATCH 13/28] refactor(commands): unify knowledge and memory output Route this command family through the invocation output contract while preserving business results and request behavior. Validation: isolated combined regression suite; normal repository pre-commit checks. --- docs/knowledge/chunk.md | 8 +- docs/knowledge/doc.md | 20 +- docs/knowledge/knowledge-cli-guide.md | 4 +- docs/knowledge/search-chat.md | 17 +- docs/knowledge/service.md | 14 +- docs/memory/memory-cli-guide.md | 4 +- docs/memory/node.md | 10 +- docs/memory/profile.md | 14 +- docs/memory/skill.md | 4 +- .../commands/src/commands/knowledge/chat.ts | 66 +- .../src/commands/knowledge/chunk-add.ts | 31 +- .../src/commands/knowledge/chunk-delete.ts | 37 +- .../src/commands/knowledge/chunk-list.ts | 50 +- .../src/commands/knowledge/chunk-update.ts | 23 +- .../src/commands/knowledge/doc-delete.ts | 47 +- .../src/commands/knowledge/doc-import-oss.ts | 38 +- .../src/commands/knowledge/doc-import.ts | 37 +- .../src/commands/knowledge/doc-list.ts | 32 +- .../src/commands/knowledge/doc-status.ts | 36 +- .../src/commands/knowledge/doc-sync.ts | 56 +- .../src/commands/knowledge/doc-tag.ts | 23 +- .../src/commands/knowledge/doc-upload.ts | 88 ++- .../commands/src/commands/knowledge/init.ts | 105 +-- .../commands/src/commands/knowledge/search.ts | 30 +- .../src/commands/knowledge/service-copy.ts | 39 +- .../src/commands/knowledge/service-create.ts | 48 +- .../src/commands/knowledge/service-delete.ts | 25 +- .../src/commands/knowledge/service-deploy.ts | 27 +- .../src/commands/knowledge/service-get.ts | 111 ++- .../src/commands/knowledge/service-list.ts | 46 +- .../src/commands/knowledge/service-update.ts | 89 ++- .../src/commands/knowledge/upload-support.ts | 5 +- .../commands/src/commands/memory/node-show.ts | 89 ++- .../src/commands/memory/profile-delete.ts | 22 +- .../src/commands/memory/profile-list.ts | 32 +- .../src/commands/memory/profile-show.ts | 45 +- .../src/commands/memory/profile-update.ts | 22 +- .../src/commands/memory/skill-export.ts | 29 +- .../tests/e2e/knowledge/document-response.ts | 37 + .../journeys/init-resource-records.ts | 3 +- .../journeys/j1-cold-start.e2e.test.ts | 9 +- .../journeys/j2-content-ops.e2e.test.ts | 14 +- .../journeys/j4-service-tuning.e2e.test.ts | 9 +- .../journeys/j5-data-plane.e2e.test.ts | 5 +- .../e2e/knowledge/journeys/journey-helpers.ts | 6 +- .../knowledge-chunk-category-file.e2e.test.ts | 50 +- .../knowledge-doc-status.e2e.test.ts | 5 +- .../knowledge/knowledge-doc-tag.e2e.test.ts | 5 +- .../knowledge-doc-upload.e2e.test.ts | 5 +- .../knowledge/knowledge-kb-delete.e2e.test.ts | 5 +- .../knowledge/knowledge-service.e2e.test.ts | 56 +- .../tests/e2e/knowledge/service-response.ts | 17 + .../tests/knowledge/fixtures/chat-pty.ts | 22 +- .../knowledge/knowledge-chat-output.test.ts | 50 +- .../knowledge/knowledge-chat-pty.test.ts | 8 +- .../knowledge/knowledge-doc-import.test.ts | 16 +- .../knowledge/knowledge-init-command.test.ts | 24 +- .../knowledge-init-resource-records.test.ts | 6 +- .../knowledge-request-options.test.ts | 3 + .../knowledge-service-config.test.ts | 8 + .../knowledge-service-response.test.ts | 90 +++ .../fixtures/knowledge-workflow-text.json | 5 + .../output/knowledge-doc-chunk-output.test.ts | 719 ++++++++++++++++++ .../output/knowledge-workflow-output.test.ts | 464 +++++++++++ .../output/memory-service-output.test.ts | 601 +++++++++++++++ .../bailian-knowledge/reference/knowledge.md | 2 +- skills/bailian-knowledge/reference/kscli.md | 2 +- 67 files changed, 3007 insertions(+), 662 deletions(-) create mode 100644 packages/commands/tests/e2e/knowledge/document-response.ts create mode 100644 packages/commands/tests/e2e/knowledge/service-response.ts create mode 100644 packages/commands/tests/knowledge/knowledge-service-response.test.ts create mode 100644 packages/commands/tests/output/fixtures/knowledge-workflow-text.json create mode 100644 packages/commands/tests/output/knowledge-doc-chunk-output.test.ts create mode 100644 packages/commands/tests/output/knowledge-workflow-output.test.ts create mode 100644 packages/commands/tests/output/memory-service-output.test.ts diff --git a/docs/knowledge/chunk.md b/docs/knowledge/chunk.md index e5e8cfb51..7e89e351b 100644 --- a/docs/knowledge/chunk.md +++ b/docs/knowledge/chunk.md @@ -48,7 +48,7 @@ chunk created (pipeline: idx-xxx) List chunks to find the new chunk id. ``` -quiet 模式:无输出(成功退出码 0)。 +quiet 模式:仅隐藏过程提示,保留完整结果(成功退出码 0)。 json 模式:返回 API 原始响应(不含 chunk ID)。 @@ -109,7 +109,7 @@ total: 1 > 如果 chunk 被排除检索,行尾会显示 `[excluded from retrieval]`。 -quiet 模式:每行一个 `metadata._id`(chunk ID),用于管道传给 update/delete。 +quiet 模式:仅隐藏过程提示,保留完整结果。提取 chunk ID 时使用 `--output json`,读取 `data.nodes[].metadata._id`,再传给 update/delete。 json 模式:返回 API 原始响应,`data.nodes[]` 含完整 chunk 数据。 @@ -172,7 +172,7 @@ text 模式: updated: chunk-xxx ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留完整结果。 json 模式:返回 API 原始响应。 @@ -223,7 +223,7 @@ text 模式: deleted: 2 chunk(s) in 1 batch(es) ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留完整结果。 json 模式:返回 `{ deleted_count, batches }`。 diff --git a/docs/knowledge/doc.md b/docs/knowledge/doc.md index db48bb482..c331b28c0 100644 --- a/docs/knowledge/doc.md +++ b/docs/knowledge/doc.md @@ -58,7 +58,7 @@ bl knowledge doc sync --dir ./docs --index-id idx-xxx --delete --yes 执行失败后保留状态文件;下一次运行先恢复未完成操作,恢复完成后重新预览剩余变更。不要删除状态或换名称来绕过未知写请求。确认之后若本地内容、状态版本或远端清单变化,执行停止,需重新预览。 -JSON 输出包含 `summary`、`actions`、`skipped`、`unmanaged`、`orphans`、`notices`、`stateFile`;失败时 stderr 的 `KNOWLEDGE_SYNC_INCOMPLETE` 提供最新可读 checkpoint,原错误继续输出并返回非零退出码。中途已完成的项目以 checkpoint 为准,不将部分失败报告为全部成功。 +JSON 输出包含 `summary`、`actions`、`skipped`、`unmanaged`、`orphans`、`notices`、`stateFile`;失败时 stderr 单行 JSON 的 `warning.code = KNOWLEDGE_SYNC_INCOMPLETE` 提供恢复信息,`warning.details` 包含 `stateFile` 和最新可读 `checkpoint`,原错误继续输出并返回非零退出码。中途已完成的项目以 checkpoint 为准,不将部分失败报告为全部成功。 --- @@ -93,7 +93,7 @@ doc-xxx COMPLETED intro.md md 1024 total: 1 ``` -quiet 模式:每行一个 `doc_id`。 +quiet 模式:仅隐藏过程提示,保留完整结果。提取 ID 时使用 `--output json`,读取 `data.rows[].doc_id`。 json 模式:返回 API 原始响应。 @@ -144,7 +144,7 @@ status: COMPLETED doc-xxx COMPLETED intro.md ``` -quiet 模式:输出任务状态(`PENDING`/`RUNNING`/`COMPLETED`)。 +quiet 模式:仅隐藏过程提示,保留完整结果。提取任务状态时使用 `--output json`,读取 `data.ingestion_status`。 json 模式:返回 API 原始响应,`data.rows[]` 包含每个文档的状态。 @@ -204,9 +204,9 @@ status: COMPLETED Uploaded 1 file. ``` -quiet 模式:每行一个 `fileId`。 +quiet 模式:仅隐藏过程提示,保留完整结果。提取文件 ID 时显式使用 `--output json`。 -json 模式:返回自定义结构,包含 `files`(路径和 fileId)、`skipped`、`index_id`、`ingestion_id`、`final_status`。 +json 模式:返回自定义结构,包含 `files`(路径和 fileId)、`skipped`,以及实际产生的 `index_id`、`ingestion_id`、`final_status`;文件 ID 从 `files[].fileId` 提取。大小建议警告通过 stderr 输出,JSON 下为单行 JSON,quiet 下保留。 **注意事项** @@ -262,14 +262,14 @@ deleted: 2 document(s) doc-b ``` -quiet 模式:每行一个已删除的 `doc_id`。 +quiet 模式:仅隐藏过程提示,保留完整结果和删除数量不符的警告。 json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID 列表。 **注意事项** - 只从知识库索引中移除文档,数据中心源文件不受影响(用 `file delete` 删除源文件)。 -- `doc_id` 应从 `knowledge doc list --quiet` 获取,而非 `doc upload` 返回的 `fileId`。 +- `doc_id` 应从 `knowledge doc list --output json` 的 `data.rows[].doc_id` 获取,而非 `doc upload` 返回的 `fileId`。 - 删除是异步的:服务端立即返回 Success,但 `doc list` 中可能仍显示该文档(约 30 秒后传播完成)。 - 输出的是服务端实际删除的 ID 列表,可能与请求的数量不一致(会在 stderr 警告)。 @@ -319,7 +319,7 @@ text 模式: tagged: 2 file(s) with [project-a, draft] ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留完整结果。 json 模式:返回 API 原始响应。 @@ -375,7 +375,7 @@ imported: 2 file(s) file-b SUCCESS docs/b.docx ``` -quiet 模式:每行一个 `fileId`。 +quiet 模式:仅隐藏过程提示,保留完整结果。提取文件 ID 时显式使用 `--output json`。 json 模式:返回 API 原始响应,`data.addFileResultList[]` 包含每个文件的 fileId、status 和 ossKey。 @@ -430,7 +430,7 @@ bl knowledge doc list --index-id idx-xxx --details --page-number 2 --page-size 1 | `--enable-headers` | boolean | 显式传 true 或 false,控制标题提取。 | | `--dry-run` | switch | 预览请求,不执行导入。 | -导入复用已有 fileId,不重新上传文件或创建知识库。任务返回 ingestionId;需要实际完成状态时使用 --wait。已有文件的类型和导入数量由服务端校验,不能用分类个数代替文件数。文档切片参数不是音视频按秒切片参数。 +导入复用已有 fileId,不重新上传文件或创建知识库。quiet 仅隐藏过程提示,保留完整结果。JSON 保留原始响应,任务 ID 从 `data.ingestionId` 提取;`--wait` 成功时另含完整 `status` 响应。需要实际完成状态时使用 --wait。已有文件的类型和导入数量由服务端校验,不能用分类个数代替文件数。文档切片参数不是音视频按秒切片参数。 ### 不同场景怎么处理 diff --git a/docs/knowledge/knowledge-cli-guide.md b/docs/knowledge/knowledge-cli-guide.md index f199118a9..ae2afc236 100644 --- a/docs/knowledge/knowledge-cli-guide.md +++ b/docs/knowledge/knowledge-cli-guide.md @@ -136,7 +136,7 @@ - **text 模式**(默认):人类友好的表格/结构化文本,适合终端查看。不同命令的输出格式见各命令的「输出」部分。 - **json 模式**(`--output json`):返回 API 原始 JSON 响应,适合程序化处理和 agent 解析。 -- **quiet 模式**(`--quiet`):已迁移的知识库管理、类目、数据中心文件和数据集合命令仅隐藏过程提示,保留完整最终结果。需要 ID 时使用 `--output json` 并读取对应字段;其余及新增命令尚在迁移,行为见各自手册。 +- **quiet 模式**(`--quiet`):已迁移的知识库管理、类目、数据中心文件、数据集合、Service、文档 list/status/upload/delete/tag/import/import-oss 和全部 chunk 命令仅隐藏过程提示,保留完整最终结果。需要 ID 时使用 `--output json` 并读取对应字段;其余及新增命令尚在迁移,行为见各自手册。 ### 危险操作确认 @@ -762,7 +762,7 @@ bl config set workspace_id ws-xxx **原因**:通过 `knowledge create --doc-id` 导入的文档,`doc_id` 等于 `fileId`;但通过 `doc upload --index-id` 导入的,`doc_id` 可能含 workspace 后缀。 -**解决**:始终用 `doc list --quiet` 获取 `doc_id`。 +**解决**:使用 `doc list --output json` 的 `data.rows[].doc_id` 获取 `doc_id`。 ### OSS 导入权限错误 diff --git a/docs/knowledge/search-chat.md b/docs/knowledge/search-chat.md index ce4ab3b79..34b426df6 100644 --- a/docs/knowledge/search-chat.md +++ b/docs/knowledge/search-chat.md @@ -177,13 +177,16 @@ bl knowledge chat --agent-id aid-xxx --messages-file ./messages.json --output js chat 仍使用 SSE 接口,各输出模式共享事件聚合: -| 模式 | stdout | -| ------------------------ | ----------------------------------------------------------------------------- | -| JSON | answer 只含最终回答,request_id 保留;新增 phases、tools、docs、usage、events | -| text + TTY | 按规划/工具/回答显示过程,末尾可读来源摘要 | -| text + 管道 | 仅最终回答 | -| quiet,包括 JSON + quiet | 仅裸最终回答;TTY 同样遵守 | -| verbose | 主结果不变,事件诊断写 stderr | +| 模式 | stdout | +| ------------ | ----------------------------------------------------------------------------- | +| JSON | answer 只含最终回答,request_id 保留;新增 phases、tools、docs、usage、events | +| text + TTY | 按规划/工具/回答显示过程,末尾可读来源摘要 | +| text + 管道 | 仅最终回答 | +| JSON + quiet | 与 JSON 相同的完整结果,不裁剪为裸回答 | +| text + quiet | 隐藏阶段过程;TTY 保留最终回答与来源摘要,管道保留最终回答 | +| verbose | 主结果不变,事件诊断写 stderr | + +warning 始终写 stderr,quiet 不隐藏;JSON 模式的 warning/debug/error 每条为单行 JSON,与 stdout 分别解析。 `events` 顺序保存原始 SSE event/data,未知字段不会因聚合而丢失;不是 HTTP 字节流快照。`usage` 取最终生成结算帧,不能将累计帧相加;工具阶段用量可在原始事件中查看。无完整最终回答或流中断会非零退出,不把规划或工具摘要当作成功回答。相比旧版,answer 不再混入过程文本,依赖旧混合内容的脚本需要改读 phases/tools/events。 diff --git a/docs/knowledge/service.md b/docs/knowledge/service.md index 822d12c24..168478b12 100644 --- a/docs/knowledge/service.md +++ b/docs/knowledge/service.md @@ -46,7 +46,7 @@ Use an agent_id above with the knowledge chat command. > 最后一行根据 scene 自动提示用 `search` 还是 `chat` 命令消费。 -quiet 模式:每行一个 `agent_id`。 +quiet 模式:仅隐藏过程提示,保留所选格式的完整最终结果;程序解析请使用 `--output json` 并读取对应字段。 json 模式:返回 API 原始响应。 @@ -105,7 +105,7 @@ Version 1: ... ``` -quiet 模式:输出 JSON 格式。 +quiet 模式:仅隐藏过程提示,保留所选格式的完整最终结果;程序解析请使用 `--output json` 并读取对应字段。 json 模式:返回 API 原始响应。 @@ -160,7 +160,7 @@ created: aid-xxx (status: draft, version: beta) Test the draft with --agent-version beta on search/chat, then deploy it to publish. ``` -quiet 模式:输出 agent ID。 +quiet 模式:仅隐藏过程提示,保留所选格式的完整最终结果;程序解析请使用 `--output json` 并读取对应字段。 json 模式:返回 API 原始响应。 @@ -232,7 +232,7 @@ updated: aid-xxx Draft config changed — verify with --agent-version beta, then deploy. ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留所选格式的完整最终结果;程序解析请使用 `--output json` 并读取对应字段。 json 模式:返回 API 原始响应。 @@ -284,7 +284,7 @@ text 模式: deployed: aid-xxx version 2 ``` -quiet 模式:输出新版本号。 +quiet 模式:仅隐藏过程提示,保留所选格式的完整最终结果;程序解析请使用 `--output json` 并读取对应字段。 json 模式:返回 API 原始响应。 @@ -332,7 +332,7 @@ text 模式: deleted: aid-xxx (status: deleted) ``` -quiet 模式:无输出。 +quiet 模式:仅隐藏过程提示,保留所选格式的完整最终结果;程序解析请使用 `--output json` 并读取对应字段。 json 模式:返回 API 原始响应。 @@ -380,7 +380,7 @@ new agent_id: aid-new (name: copy_my-qa, status: draft) Test the draft with --agent-version beta on search/chat, then deploy it to publish. ``` -quiet 模式:输出新 agent ID。 +quiet 模式:仅隐藏过程提示,保留所选格式的完整最终结果;程序解析请使用 `--output json` 并读取对应字段。 json 模式:返回 API 原始响应。 diff --git a/docs/memory/memory-cli-guide.md b/docs/memory/memory-cli-guide.md index c4e42daf5..ad756146f 100644 --- a/docs/memory/memory-cli-guide.md +++ b/docs/memory/memory-cli-guide.md @@ -80,11 +80,11 @@ API Key可通过 `--api-key`、环境变量或配置提供。Workspace解析顺 | `--workspace-id ` | 指定Memory所在工作空间 | | `--config ` | 使用指定配置profile | | `--timeout ` | 请求超时;与add的抽取等待预算wait不同 | -| `--quiet` | Memory命令通常沿用文本输出分支,并非统一“只返回ID” | +| `--quiet` | 仅隐藏过程提示,保留所选格式的完整最终结果 | | `--verbose` | 输出诊断信息;保存日志时注意内容和凭证保护 | | `--dry-run` | 预览endpoint、method和适用的request,不发送Memory业务请求 | -text输出便于终端查看,格式按命令不同。json通常保留服务响应;add等待完成时输出提交request_id、event_id与轮询events的组合对象。不要把 `--quiet` 与 `--output json` 混用,quiet会优先进入文本输出分支。 +text输出便于终端查看,格式按命令不同。json通常保留服务响应;add等待完成时输出提交request_id、event_id与轮询events的组合对象。`--quiet` 可与 `--output json` 组合使用,不会改变 JSON 结构或裁剪最终结果。 JSON参数接收参数文本,不支持 `@file`:messages、attributes、attributes-operations为对象数组;meta-data为对象。数组型flag(例如skill-tags)通过重复flag传递。示例以POSIX shell为准,参数中的尖括号ID必须替换。 diff --git a/docs/memory/node.md b/docs/memory/node.md index 3917a75e0..d5683c267 100644 --- a/docs/memory/node.md +++ b/docs/memory/node.md @@ -31,7 +31,7 @@ bl memory list --user-id [flags] **输出** -json返回 `memory_nodes` / `total` 等字段;text / quiet显示类型、节点ID、正文、元数据和总数(如有),空集合显示提示。 +json返回 `memory_nodes` / `total` 等字段;text显示类型、节点ID、正文、元数据和总数(如有),空集合显示提示。 **注意事项** @@ -68,7 +68,7 @@ bl memory node show --node-id [flags] **输出** -json返回 `memory_node`;text / quiet显示正文、类型、状态、规则、时间戳和元数据等存在的字段。 +json返回 `memory_node`;text显示正文、类型、状态、规则、时间戳和元数据等存在的字段。 **注意事项** @@ -111,7 +111,7 @@ bl memory update --node-id --content [flags] **输出** -json保留服务响应;text / quiet输出节点更新确认信息。 +json保留服务响应;text输出节点更新确认信息。 **注意事项** @@ -150,7 +150,7 @@ bl memory delete --node-id [flags] **输出** -json保留服务响应;text / quiet输出节点删除确认信息。 +json保留服务响应;text输出节点删除确认信息。 **注意事项** @@ -163,3 +163,5 @@ bl memory delete --node-id '' --dry-run --output json # 确认节点ID后执行 bl memory delete --node-id '' --yes --output json ``` + +`--quiet` 仅隐藏过程提示,不改变最终结果;与 `--output json` 组合时仍返回完整 JSON,与 text 组合时保留完整文本结果。 diff --git a/docs/memory/profile.md b/docs/memory/profile.md index 85159b6a4..5f480f989 100644 --- a/docs/memory/profile.md +++ b/docs/memory/profile.md @@ -33,7 +33,7 @@ bl memory profile create --name --attributes [flags] **输出** -json保留 `profile_schema_id` 等响应字段;text / quiet输出模板创建确认和ID。 +json保留 `profile_schema_id` 等响应字段;text输出模板创建确认和ID。 **注意事项** @@ -71,7 +71,7 @@ bl memory profile list [flags] **输出** -json返回 `profile_schemas` / `total`;text / quiet显示模板ID、名称、档位、描述及总数(如有)。 +json返回 `profile_schemas` / `total`;text显示模板ID、名称、档位、描述及总数(如有)。 **注意事项** @@ -108,7 +108,7 @@ bl memory profile show --schema-id [flags] **输出** -json保留模板详情;text / quiet显示模板名称、描述和属性定义(包括attribute_id、描述、默认值等)。 +json保留模板详情;text显示模板名称、描述和属性定义(包括attribute_id、描述、默认值等)。 **注意事项** @@ -152,7 +152,7 @@ bl memory profile update --schema-id [flags] **输出** -json保留服务响应;text / quiet输出模板更新确认。 +json保留服务响应;text输出模板更新确认。 **注意事项** @@ -193,7 +193,7 @@ bl memory profile delete --schema-id [flags] **输出** -json保留服务响应;text / quiet输出模板删除确认。 +json保留服务响应;text输出模板删除确认。 **注意事项** @@ -233,7 +233,7 @@ bl memory profile get --schema-id --user-id [flags] **输出** -json中的 `profile.attributes` 包含用户属性,服务返回的value/status等字段保留;text / quiet显示属性及值,无画像数据时显示提示。字段可能缺省,不能假定每个属性都有value。 +json中的 `profile.attributes` 包含用户属性,服务返回的value/status等字段保留;text显示属性及值,无画像数据时显示提示。字段可能缺省,不能假定每个属性都有value。 **注意事项** @@ -245,3 +245,5 @@ json中的 `profile.attributes` 包含用户属性,服务返回的value/status bl memory add --user-id demo_user --profile-schema '' --extract-mode profile_only --messages '[{"role":"user","content":"我常住杭州,喜欢骑行。"}]' --output json bl memory profile get --schema-id '' --user-id demo_user --output json ``` + +`--quiet` 仅隐藏过程提示,不改变最终结果;与 `--output json` 组合时仍返回完整 JSON,与 text 组合时保留完整文本结果。 diff --git a/docs/memory/skill.md b/docs/memory/skill.md index ba3a87639..dccd2fa42 100644 --- a/docs/memory/skill.md +++ b/docs/memory/skill.md @@ -27,7 +27,7 @@ bl memory skill export --node-id [flags] **输出** -text / quiet输出服务导出的正文,不含frontmatter;json保留 `memory_node` 下的正文和技能元数据。命令不直接写文件,可用shell重定向。 +text输出服务导出的正文,不含frontmatter;json保留 `memory_node` 下的正文和技能元数据。命令不直接写文件,可用shell重定向。 **注意事项** @@ -60,3 +60,5 @@ bl memory update --node-id '' --content '按议题、决策和待 ``` 完整参数见[写入手册](add.md)、[检索手册](search.md)、[节点管理手册](node.md)。默认检索只包含observation,查询skill必须显式指定类型。 + +`--quiet` 仅隐藏过程提示,不改变最终结果;与 `--output json` 组合时仍返回完整 JSON,与 text 组合时保留完整文本结果。 diff --git a/packages/commands/src/commands/knowledge/chat.ts b/packages/commands/src/commands/knowledge/chat.ts index 06ba5dd45..87ee9ada0 100644 --- a/packages/commands/src/commands/knowledge/chat.ts +++ b/packages/commands/src/commands/knowledge/chat.ts @@ -5,7 +5,6 @@ import { defineCommand, knowledgeChatEndpoint, parseSSE, - detectOutputFormat, BailianError, ExitCode, type FlagsDef, @@ -14,7 +13,7 @@ import { type KnowledgeChatMessage, type KnowledgeChatRequest, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; + import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const CHAT_FLAGS = { @@ -340,7 +339,7 @@ export default defineCommand({ const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // API only supports SSE; streamOutput controls whether to print tokens in real-time const streamOutput = !settings.quiet && format === "text" && !!process.stdout.isTTY; @@ -378,7 +377,7 @@ export default defineCommand({ const url = knowledgeChatEndpoint(workspaceId); if (settings.dryRun) { - emitResult({ endpoint: url, request: body }, format); + ctx.output.result({ endpoint: url, request: body }); return; } @@ -392,7 +391,11 @@ export default defineCommand({ const accumulator = createChatAccumulator(ctx.localize); let displayedStage = ""; for await (const event of parseSSE(res)) { - if (settings.verbose) process.stderr.write(`[event] ${event.event ?? "message"}\n`); + ctx.output.debug({ + code: "KNOWLEDGE_CHAT_EVENT", + message: { "en-US": "Knowledge chat event", "zh-CN": "知识库对话事件" }, + details: { event: event.event ?? "message" }, + }); const fragments = accumulator.accept(event); if (streamOutput) { for (const fragment of fragments) { @@ -413,28 +416,37 @@ export default defineCommand({ if (event.data === "[DONE]") break; } const result = accumulator.finish(); - if (settings.quiet) emitBare(result.answer); - else if (format !== "text") emitResult(result, format); - else if (!streamOutput) emitBare(result.answer); - else { - process.stdout.write("\n"); - if (result.docs.length) emitBare(ctx.localize({ "en-US": "Sources:", "zh-CN": "来源:" })); - for (const doc of result.docs) { - if (doc === null || typeof doc !== "object" || Array.isArray(doc)) continue; - const record = doc as Record; - const metadata = - record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) - ? (record.metadata as Record) - : record; - emitBare( - JSON.stringify({ - doc_id: metadata.doc_id, - title: metadata.title, - citation: metadata._citation_index, - }), - ); - for (const line of mediaSummary(metadata)) emitBare(line); - } + let text: string | undefined; + if (format === "text") { + if (process.stdout.isTTY) { + const footer: string[] = []; + if (result.docs.length) + footer.push(ctx.localize({ "en-US": "Sources:", "zh-CN": "来源:" })); + for (const doc of result.docs) { + if (doc === null || typeof doc !== "object" || Array.isArray(doc)) continue; + const record = doc as Record; + const metadata = + record.metadata && + typeof record.metadata === "object" && + !Array.isArray(record.metadata) + ? (record.metadata as Record) + : record; + footer.push( + JSON.stringify({ + doc_id: metadata.doc_id, + title: metadata.title, + citation: metadata._citation_index, + }), + ); + footer.push(...mediaSummary(metadata)); + } + text = streamOutput + ? footer.length + ? "\n" + footer.join("\n") + : "" + : [result.answer, ...footer].join("\n"); + } else text = result.answer; } + ctx.output.result(result, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/chunk-add.ts b/packages/commands/src/commands/knowledge/chunk-add.ts index 5c47a8a9e..a40209c93 100644 --- a/packages/commands/src/commands/knowledge/chunk-add.ts +++ b/packages/commands/src/commands/knowledge/chunk-add.ts @@ -3,13 +3,11 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type FlagsDef, type RagMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; import { readUtf8TextFile } from "./upload-support.ts"; @@ -180,7 +178,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // dry-run also reads the file and parses --field (rehearsal semantics) let field: Record; @@ -209,7 +207,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkCreate); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -219,13 +217,22 @@ export default defineCommand({ body, }); - // The response carries no chunk_id — quiet mode exits 0 silently on success - if (settings.quiet) return; - if (format === "text") { - emitBare(`chunk created (pipeline: ${flags.indexId})`); - emitBare("List chunks to find the new chunk id."); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: [ + ctx.localize({ + "en-US": `chunk created (pipeline: ${flags.indexId})`, + "zh-CN": `切片已创建 (pipeline: ${flags.indexId})`, + }), + ctx.localize({ + "en-US": "List chunks to find the new chunk id.", + "zh-CN": "请列出切片以获取新切片 ID。", + }), + ].join("\n"), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts index efbdea157..87d76bdab 100644 --- a/packages/commands/src/commands/knowledge/chunk-delete.ts +++ b/packages/commands/src/commands/knowledge/chunk-delete.ts @@ -3,12 +3,11 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, type FlagsDef, type RagMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, normalizeError } from "bailian-cli-runtime"; +import { normalizeError } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const CHUNK_DELETE_FLAGS = { @@ -75,21 +74,18 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const batches = splitIntoBatches(flags.chunkId); const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkDelete); if (settings.dryRun) { - emitResult( - { - endpoint, - batches: batches.map((batchIds) => ({ - request: { pipelineId: flags.indexId, chunkIds: batchIds }, - })), - }, - format, - ); + ctx.output.result({ + endpoint, + batches: batches.map((batchIds) => ({ + request: { pipelineId: flags.indexId, chunkIds: batchIds }, + })), + }); return; } @@ -133,11 +129,16 @@ export default defineCommand({ } } - if (settings.quiet) return; - if (format === "text") { - emitBare(`deleted: ${deletedCount} chunk(s) in ${batches.length} batch(es)`); - return; - } - emitResult({ deleted_count: deletedCount, batches: batches.length }, format); + ctx.output.result( + { deleted_count: deletedCount, batches: batches.length }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `deleted: ${deletedCount} chunk(s) in ${batches.length} batch(es)`, + "zh-CN": `已删除: ${deletedCount} 个切片,共 ${batches.length} 个批次`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/chunk-list.ts b/packages/commands/src/commands/knowledge/chunk-list.ts index 5d6595f75..26a56b782 100644 --- a/packages/commands/src/commands/knowledge/chunk-list.ts +++ b/packages/commands/src/commands/knowledge/chunk-list.ts @@ -4,11 +4,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagChunkListResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts"; const CHUNK_LIST_FLAGS = { @@ -66,7 +64,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // Gotcha: this endpoint's pagination keys are pageNum/pageSize (camelCase, in the body) const body = { @@ -78,7 +76,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkList); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -88,38 +86,50 @@ export default defineCommand({ body, }); - const nodes = response.data?.nodes ?? []; - if (settings.quiet) { - // chunk ids only, for piping into chunk update/delete - for (const node of nodes) emitBare(node.metadata?._id ?? ""); - return; - } + let text: string | undefined; if (format === "text") { + const nodes = response.data?.nodes ?? []; + const lines: string[] = []; if (nodes.length === 0) { - emitBare("No chunks found."); + lines.push(ctx.localize({ "en-US": "No chunks found.", "zh-CN": "未找到切片。" })); } else { for (const node of nodes) { const metadata = node.metadata ?? {}; const statusPart = metadata._chunk_status_message - ? ` status: ${metadata._chunk_status_message}` + ? ctx.localize({ + "en-US": ` status: ${metadata._chunk_status_message}`, + "zh-CN": ` 状态: ${metadata._chunk_status_message}`, + }) : ""; const excludedPart = - metadata.is_displayed_chunk_content === false ? " [excluded from retrieval]" : ""; - emitBare( - `[chunk] ${metadata._id ?? "?"} (doc: ${metadata.doc_name ?? "?"}, doc_id: ${metadata.doc_id ?? "?"})${statusPart}${excludedPart}`, + metadata.is_displayed_chunk_content === false + ? ctx.localize({ "en-US": " [excluded from retrieval]", "zh-CN": " [不参与检索]" }) + : ""; + lines.push( + ctx.localize({ + "en-US": `[chunk] ${metadata._id ?? "?"} (doc: ${metadata.doc_name ?? "?"}, doc_id: ${metadata.doc_id ?? "?"})${statusPart}${excludedPart}`, + "zh-CN": `[切片] ${metadata._id ?? "?"} (文档: ${metadata.doc_name ?? "?"}, doc_id: ${metadata.doc_id ?? "?"})${statusPart}${excludedPart}`, + }), ); const contentText = firstNonEmptyText( metadata.content, node.text, metadata.clip_description, ); - emitBare(` ${contentText.length > 200 ? `${contentText.slice(0, 200)}…` : contentText}`); - for (const line of mediaSummary(metadata)) emitBare(` ${line}`); + lines.push( + ` ${contentText.length > 200 ? `${contentText.slice(0, 200)}…` : contentText}`, + ); + for (const line of mediaSummary(metadata)) lines.push(` ${line}`); } } - emitBare(`total: ${response.data?.total ?? nodes.length}`); - } else { - emitResult(response, format); + lines.push( + ctx.localize({ + "en-US": `total: ${response.data?.total ?? nodes.length}`, + "zh-CN": `总数: ${response.data?.total ?? nodes.length}`, + }), + ); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/chunk-update.ts b/packages/commands/src/commands/knowledge/chunk-update.ts index 3fc53c6d3..15589f363 100644 --- a/packages/commands/src/commands/knowledge/chunk-update.ts +++ b/packages/commands/src/commands/knowledge/chunk-update.ts @@ -2,7 +2,6 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type Client, @@ -10,7 +9,6 @@ import { type RagChunkListResponse, type RagMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; import { readUtf8TextFile } from "./upload-support.ts"; @@ -162,7 +160,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // dry-run also reads the file and validates (rehearsal semantics); the read-back // request is only made outside dry-run and when no new content is given @@ -200,7 +198,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkUpdate); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -210,11 +208,16 @@ export default defineCommand({ body, }); - if (settings.quiet) return; - if (format === "text") { - emitBare(`updated: ${flags.chunkId}`); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: ctx.localize({ + "en-US": `updated: ${flags.chunkId}`, + "zh-CN": `已更新: ${flags.chunkId}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-delete.ts b/packages/commands/src/commands/knowledge/doc-delete.ts index f8edf7072..fb49ba0a3 100644 --- a/packages/commands/src/commands/knowledge/doc-delete.ts +++ b/packages/commands/src/commands/knowledge/doc-delete.ts @@ -3,11 +3,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagDeleteFileResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const DOC_DELETE_FLAGS = { @@ -52,9 +50,9 @@ export default defineCommand({ }, { "en-US": - "Use the doc_id from `knowledge doc list --quiet`, not the fileId from `knowledge doc upload`. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix.", + "Use doc_id from the document-list JSON response (--output json), not fileId from document upload. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix.", "zh-CN": - "请使用 `knowledge doc list --quiet` 返回的 doc_id,而不是 `knowledge doc upload` 返回的 fileId。通过 `knowledge create --doc-id` 创建的文档,其 doc_id 等于 fileId;通过 `knowledge doc upload --index-id` 导入的文档,其 doc_id 可能带有 Workspace 后缀。", + "请使用文档列表命令 JSON 响应(--output json)中的 doc_id,而不是文档上传返回的 fileId。通过 `knowledge create --doc-id` 创建的文档,其 doc_id 等于 fileId;通过 `knowledge doc upload --index-id` 导入的文档,其 doc_id 可能带有 Workspace 后缀。", }, { "en-US": @@ -81,14 +79,14 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // snake_case: body { index_id, doc_ids } const body = { index_id: flags.indexId, doc_ids: flags.docId }; const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDeleteFile); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -98,22 +96,31 @@ export default defineCommand({ body, }); - // Output follows the server's data.deleted list + // Output follows the server's data.deleted list. const deleted = response.data?.deleted ?? []; - if (settings.quiet) { - for (const docId of deleted) emitBare(docId); - return; - } + let text: string | undefined; if (format === "text") { - emitBare(`deleted: ${deleted.length} document(s)`); - for (const docId of deleted) emitBare(` ${docId}`); - if (deleted.length !== flags.docId.length) { - process.stderr.write( - `Warning: requested ${flags.docId.length} deletion(s) but the server reported ${deleted.length}.\n`, - ); - } - return; + const lines = [ + ctx.localize({ + "en-US": `deleted: ${deleted.length} document(s)`, + "zh-CN": `已删除: ${deleted.length} 个文档`, + }), + ]; + for (const docId of deleted) lines.push(` ${docId}`); + text = lines.join("\n"); + } + ctx.output.result(response, text === undefined ? undefined : { text }); + if (deleted.length !== flags.docId.length) { + ctx.output.warning({ + code: "KNOWLEDGE_DOC_DELETE_PARTIAL", + message: { + "en-US": `requested ${flags.docId.length} deletion(s) but the server reported ${deleted.length}.`, + "zh-CN": `请求删除 ${flags.docId.length} 个文档,但服务端报告已删除 ${deleted.length} 个。`, + }, + ...(format === "json" + ? { details: { requested_count: flags.docId.length, deleted_count: deleted.length } } + : {}), + }); } - emitResult(response, format); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-import-oss.ts b/packages/commands/src/commands/knowledge/doc-import-oss.ts index 55db9cd99..dd44d24e7 100644 --- a/packages/commands/src/commands/knowledge/doc-import-oss.ts +++ b/packages/commands/src/commands/knowledge/doc-import-oss.ts @@ -4,11 +4,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagOssImportResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; import { PARSER_FLAGS, readParserOptions } from "./parser-config.ts"; @@ -112,7 +110,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const parserOptions = readParserOptions(flags, ctx.localize); const body = { @@ -131,7 +129,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addFilesFromAuthorizedOss); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -141,23 +139,23 @@ export default defineCommand({ body, }); - // Live-verified shape: results come back as addFileResultList (the docs' flat - // fileIds field is not returned); per-file status is SUCCESS on success - const results = response.data?.addFileResultList ?? []; - const fileIds = results - .map((result) => result.fileId) - .filter((fileId): fileId is string => !!fileId); - if (settings.quiet) { - for (const fileId of fileIds) emitBare(fileId); - return; - } + let text: string | undefined; if (format === "text") { - emitBare(`imported: ${fileIds.length} file(s)`); - for (const result of results) { - emitBare(` ${result.fileId ?? "-"} ${result.status ?? "-"} ${result.ossKey ?? ""}`); - } - return; + // The API returns per-file results, including failed or missing-ID records. + const results = response.data?.addFileResultList ?? []; + const fileIds = results + .map((result) => result.fileId) + .filter((fileId): fileId is string => !!fileId); + const lines = [ + ctx.localize({ + "en-US": `imported: ${fileIds.length} file(s)`, + "zh-CN": `已导入: ${fileIds.length} 个文件`, + }), + ]; + for (const result of results) + lines.push(` ${result.fileId ?? "-"} ${result.status ?? "-"} ${result.ossKey ?? ""}`); + text = lines.join("\n"); } - emitResult(response, format); + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-import.ts b/packages/commands/src/commands/knowledge/doc-import.ts index 4bb21332a..6542f10b8 100644 --- a/packages/commands/src/commands/knowledge/doc-import.ts +++ b/packages/commands/src/commands/knowledge/doc-import.ts @@ -2,13 +2,11 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type FlagsDef, type RagJobCreateResponse, } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; import { WORKSPACE_FLAG, resolveWorkspaceId, @@ -170,9 +168,9 @@ export default defineCommand({ ? { sourceType: "DATA_CENTER_FILE", docIds: flags.docId } : { sourceType: "DATA_CENTER_CATEGORY", categoryIds: flags.categoryId }), }; - const format = detectOutputFormat(settings.output); + const format = settings.output; if (settings.dryRun) { - emitResult({ endpoint, request }, format); + ctx.output.result({ endpoint, request }); return; } const response = await ctx.client.requestJson({ @@ -215,15 +213,26 @@ export default defineCommand({ ); } } - if (settings.quiet) { - emitBare(ingestionId); - return; - } - if (format === "text") { - emitBare(`ingestionId: ${ingestionId}`); - if (status) emitBare(`status: ${status.data?.ingestion_status ?? "UNKNOWN"}`); - return; - } - emitResult({ ...response, ...(status ? { status } : {}) }, format); + ctx.output.result( + { ...response, ...(status ? { status } : {}) }, + format === "text" + ? { + text: [ + ctx.localize({ + "en-US": `ingestionId: ${ingestionId}`, + "zh-CN": `导入任务: ${ingestionId}`, + }), + ...(status + ? [ + ctx.localize({ + "en-US": `status: ${status.data?.ingestion_status ?? "UNKNOWN"}`, + "zh-CN": `状态: ${status.data?.ingestion_status ?? "UNKNOWN"}`, + }), + ] + : []), + ].join("\n"), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-list.ts b/packages/commands/src/commands/knowledge/doc-list.ts index 84e3d5e95..73a3a7099 100644 --- a/packages/commands/src/commands/knowledge/doc-list.ts +++ b/packages/commands/src/commands/knowledge/doc-list.ts @@ -2,12 +2,11 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagIndexFilesResponse, type RagIndexFileDetailsResponse, } from "bailian-cli-core"; -import { emitResult, emitBare, ansi } from "bailian-cli-runtime"; +import { ansi } from "bailian-cli-runtime"; import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts"; const DOC_LIST_FLAGS = { @@ -73,7 +72,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // Gotcha: this endpoint's page parameter is page_num (not page_number) const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles)); @@ -92,7 +91,7 @@ export default defineCommand({ : null; if (settings.dryRun) { - emitResult({ endpoint, request }, format); + ctx.output.result({ endpoint, request }); return; } @@ -104,21 +103,19 @@ export default defineCommand({ ...(request ? { body: request } : {}), }); - const rows = response.data?.rows ?? []; - if (settings.quiet) { - for (const row of rows) emitBare(row.doc_id ?? ""); - return; - } + let text: string | undefined; if (format === "text") { + const rows = response.data?.rows ?? []; const styles = ansi(process.stdout); + const lines: string[] = []; if (rows.length === 0) { - emitBare("No documents found."); + lines.push(ctx.localize({ "en-US": "No documents found.", "zh-CN": "未找到文档。" })); } else { for (const row of rows) { const line = truncateLine( [row.doc_id, row.status, row.doc_name, row.doc_type ?? "-", row.size ?? "-"].join(" "), ); - emitBare(row.status === "FAILED" ? styles.red(line) : line); + lines.push(row.status === "FAILED" ? styles.red(line) : line); if (flags.details) { for (const key of [ "chunkSize", @@ -127,16 +124,21 @@ export default defineCommand({ "chunkMode", "enableHeaders", ]) { - emitBare( + lines.push( ` ${key}: ${row[key] === undefined || row[key] === null ? "-" : JSON.stringify(row[key])}`, ); } } } } - emitBare(`total: ${response.data?.total_count ?? rows.length}`); - } else { - emitResult(response, format); + lines.push( + ctx.localize({ + "en-US": `total: ${response.data?.total_count ?? rows.length}`, + "zh-CN": `总数: ${response.data?.total_count ?? rows.length}`, + }), + ); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-status.ts b/packages/commands/src/commands/knowledge/doc-status.ts index 4a7b250fb..1eeaea8e1 100644 --- a/packages/commands/src/commands/knowledge/doc-status.ts +++ b/packages/commands/src/commands/knowledge/doc-status.ts @@ -2,13 +2,13 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type FlagsDef, type RagIndexJobStatusResponse, + type LocalizedText, } from "bailian-cli-core"; -import { emitResult, emitBare, ansi } from "bailian-cli-runtime"; +import { ansi } from "bailian-cli-runtime"; import { resolveWorkspaceId, PAGE_FLAGS, @@ -53,14 +53,23 @@ const DOC_STATUS_FLAGS = { ...WORKSPACE_FLAG, } satisfies FlagsDef; -function printStatus(response: RagIndexJobStatusResponse): void { +function statusText( + response: RagIndexJobStatusResponse, + localize: (text: LocalizedText) => string, +): string { const styles = ansi(process.stdout); - emitBare(`status: ${response.data?.ingestion_status ?? "UNKNOWN"}`); + const lines = [ + localize({ + "en-US": `status: ${response.data?.ingestion_status ?? "UNKNOWN"}`, + "zh-CN": `状态: ${response.data?.ingestion_status ?? "UNKNOWN"}`, + }), + ]; for (const doc of response.data?.rows ?? []) { const docState = doc.code ?? doc.status ?? "?"; const line = ` ${doc.doc_id ?? "?"} ${docState} ${doc.doc_name ?? ""}`; - emitBare(docState.includes("FAILED") ? styles.red(line) : line); + lines.push(docState.includes("FAILED") ? styles.red(line) : line); } + return lines.join("\n"); } export default defineCommand({ @@ -95,7 +104,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // Both required flags are enforced by the parser up front; parameters go in // the query string (they are ignored in the body) @@ -111,7 +120,7 @@ export default defineCommand({ const endpoint = url.toString(); if (settings.dryRun) { - emitResult({ endpoint, request: null }, format); + ctx.output.result({ endpoint, request: null }); return; } @@ -139,14 +148,9 @@ export default defineCommand({ ); } - if (settings.quiet) { - emitBare(response.data?.ingestion_status ?? "UNKNOWN"); - return; - } - if (format === "text") { - printStatus(response); - } else { - emitResult(response, format); - } + ctx.output.result( + response, + format === "text" ? { text: statusText(response, ctx.localize) } : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-sync.ts b/packages/commands/src/commands/knowledge/doc-sync.ts index 5ab3b4197..da669f286 100644 --- a/packages/commands/src/commands/knowledge/doc-sync.ts +++ b/packages/commands/src/commands/knowledge/doc-sync.ts @@ -1,11 +1,4 @@ -import { - BailianError, - ExitCode, - defineCommand, - detectOutputFormat, - type FlagsDef, -} from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; +import { BailianError, ExitCode, defineCommand, type FlagsDef } from "bailian-cli-core"; import { WORKSPACE_FLAG, resolveWorkspaceId } from "./shared.ts"; import { prepareSync, type SyncPrepared } from "./sync/prepare.ts"; import { executeSyncPlan } from "./sync/execute.ts"; @@ -187,9 +180,14 @@ export default defineCommand({ const checkpoint = await readStateFile(prepared.stateFile, context.localize).catch( () => undefined, ); - process.stderr.write( - `${JSON.stringify({ code: "KNOWLEDGE_SYNC_INCOMPLETE", stateFile: prepared.stateFile, checkpoint })}\n`, - ); + context.output.warning({ + code: "KNOWLEDGE_SYNC_INCOMPLETE", + message: { + "en-US": "Synchronization did not complete; use the checkpoint for recovery.", + "zh-CN": "同步未完成;请使用恢复记录继续处理。", + }, + details: { stateFile: prepared.stateFile, checkpoint }, + }); throw error; } const result = { @@ -206,21 +204,25 @@ export default defineCommand({ requiresReplan: prepared.plan.requiresReplan, result: completed, }; - const format = detectOutputFormat(context.settings.output); - if (format === "json") emitResult(result, format); - else { - emitBare( - context.localize( - prepared.plan.requiresReplan - ? { - "en-US": "Recovery completed. Preview remaining changes again before continuing.", - "zh-CN": "恢复完成。请重新预览剩余变更后继续。", - } - : { "en-US": "Synchronization completed.", "zh-CN": "同步完成。" }, - ), - ); - emitBare(JSON.stringify(result.summary)); - emitBare(`stateFile: ${prepared.stateFile}`); - } + context.output.result( + result, + context.settings.output === "text" + ? { + text: [ + context.localize( + prepared.plan.requiresReplan + ? { + "en-US": + "Recovery completed. Preview remaining changes again before continuing.", + "zh-CN": "恢复完成。请重新预览剩余变更后继续。", + } + : { "en-US": "Synchronization completed.", "zh-CN": "同步完成。" }, + ), + JSON.stringify(result.summary), + `stateFile: ${prepared.stateFile}`, + ].join("\n"), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-tag.ts b/packages/commands/src/commands/knowledge/doc-tag.ts index 87f4fc569..128a004a0 100644 --- a/packages/commands/src/commands/knowledge/doc-tag.ts +++ b/packages/commands/src/commands/knowledge/doc-tag.ts @@ -3,11 +3,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagBatchUpdateTagResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const DOC_TAG_FLAGS = { @@ -84,7 +82,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { fileInfos: flags.docId.map((fileId) => ({ fileId, tags: flags.tag })), @@ -93,7 +91,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.batchUpdateFileTag); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -103,11 +101,16 @@ export default defineCommand({ body, }); - if (settings.quiet) return; - if (format === "text") { - emitBare(`tagged: ${flags.docId.length} file(s) with [${flags.tag.join(", ")}]`); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: ctx.localize({ + "en-US": `tagged: ${flags.docId.length} file(s) with [${flags.tag.join(", ")}]`, + "zh-CN": `已为 ${flags.docId.length} 个文件设置标签 [${flags.tag.join(", ")}]`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/doc-upload.ts b/packages/commands/src/commands/knowledge/doc-upload.ts index 318114ed1..162247e3d 100644 --- a/packages/commands/src/commands/knowledge/doc-upload.ts +++ b/packages/commands/src/commands/knowledge/doc-upload.ts @@ -5,12 +5,10 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG, @@ -158,7 +156,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const parserOptions = { parser: "AUTO_SELECT", ...readParserOptions(flags, ctx.localize) }; // Expand directories into individual file paths; unsupported extensions are @@ -186,7 +184,14 @@ export default defineCommand({ // file problems early); exceeding a soft limit only warns const checkedFiles = expandedFiles.map((filePath) => { const checked = checkUploadFile(filePath, ctx.localize); - if (checked.warning) process.stderr.write(`Warning: ${checked.warning}\n`); + if (checked.warning) + ctx.output.warning({ + code: "KNOWLEDGE_UPLOAD_SIZE_WARNING", + message: checked.warning, + ...(format === "json" + ? { details: { file: filePath, size_bytes: checked.sizeBytes } } + : {}), + }); return { filePath, sizeBytes: checked.sizeBytes }; }); @@ -235,7 +240,7 @@ export default defineCommand({ } as unknown, }); } - emitResult({ steps, skipped }, format); + ctx.output.result({ steps, skipped }); return; } @@ -311,40 +316,51 @@ export default defineCommand({ } } - if (settings.quiet) { - for (const item of uploaded) emitBare(item.fileId); - return; - } + // This orchestration has a stable aggregate result rather than one raw response. + const result = { + files: uploaded.map((item) => ({ path: item.path, fileId: item.fileId })), + skipped, + ...(flags.indexId ? { index_id: flags.indexId } : {}), + ...(ingestionId ? { ingestion_id: ingestionId } : {}), + ...(finalStatus ? { final_status: finalStatus } : {}), + }; + let text: string | undefined; if (format === "text") { - for (const item of uploaded) { - emitBare(`${basename(item.path)} ${item.fileId} registered`); - } - if (ingestionId) emitBare(`job: ${ingestionId}`); - if (finalStatus) emitBare(`status: ${finalStatus}`); - // Summary line: always show counts; list skipped files only with --verbose - const summaryParts = [`Uploaded ${uploaded.length} file${uploaded.length !== 1 ? "s" : ""}`]; - if (skipped.length > 0) { - summaryParts.push(`skipped ${skipped.length} unsupported`); - } - emitBare(`\n${summaryParts.join(", ")}.`); + const lines = uploaded.map((item) => + ctx.localize({ + "en-US": `${basename(item.path)} ${item.fileId} registered`, + "zh-CN": `${basename(item.path)} ${item.fileId} 已登记`, + }), + ); + if (ingestionId) + lines.push( + ctx.localize({ "en-US": `job: ${ingestionId}`, "zh-CN": `任务: ${ingestionId}` }), + ); + if (finalStatus) + lines.push( + ctx.localize({ "en-US": `status: ${finalStatus}`, "zh-CN": `状态: ${finalStatus}` }), + ); + const summaryParts = [ + ctx.localize({ + "en-US": `Uploaded ${uploaded.length} file${uploaded.length !== 1 ? "s" : ""}`, + "zh-CN": `已上传 ${uploaded.length} 个文件`, + }), + ]; + if (skipped.length > 0) + summaryParts.push( + ctx.localize({ + "en-US": `skipped ${skipped.length} unsupported`, + "zh-CN": `跳过 ${skipped.length} 个不支持的文件`, + }), + ); + lines.push(`\n${summaryParts.join(", ")}.`); + // Preserve the established verbose text projection; JSON always retains all skipped paths. if (settings.verbose && skipped.length > 0) { - emitBare("Skipped files:"); - for (const skippedPath of skipped) { - emitBare(` ${basename(skippedPath)}`); - } + lines.push(ctx.localize({ "en-US": "Skipped files:", "zh-CN": "已跳过文件:" })); + for (const skippedPath of skipped) lines.push(` ${basename(skippedPath)}`); } - return; + text = lines.join("\n"); } - // An orchestration command has no single response to pass through — emit a custom stable shape - emitResult( - { - files: uploaded.map((item) => ({ path: item.path, fileId: item.fileId })), - skipped, - ...(flags.indexId ? { index_id: flags.indexId } : {}), - ...(ingestionId ? { ingestion_id: ingestionId } : {}), - ...(finalStatus ? { final_status: finalStatus } : {}), - }, - format, - ); + ctx.output.result(result, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/init.ts b/packages/commands/src/commands/knowledge/init.ts index c6bc232ff..50a040bef 100644 --- a/packages/commands/src/commands/knowledge/init.ts +++ b/packages/commands/src/commands/knowledge/init.ts @@ -3,11 +3,9 @@ import { BailianError, ExitCode, defineCommand, - detectOutputFormat, type CommandContext, type FlagsDef, } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; import { knowledgeCreationRisk, localizedKnowledgeBillingNotice } from "./billing.ts"; import { prepareKnowledgeInit, type InitPrepared } from "./init-prepare.ts"; import { runKnowledgeInit, type InitResource } from "./init-workflow.ts"; @@ -149,7 +147,7 @@ export default defineCommand({ ExitCode.GENERAL, ); const workspaceId = resolveWorkspaceId(context); - const format = detectOutputFormat(context.settings.output); + const format = context.settings.output; const result = await runKnowledgeInit({ output: context.output, client: context.client, @@ -172,15 +170,17 @@ export default defineCommand({ "en-US": `${resource.created ? "Created" : "Reused"} ${resource.kind}: ${resource.id}`, "zh-CN": `${resource.created ? "已创建" : "已复用"} ${resource.kind}:${resource.id}`, }); - if (format === "json") - process.stderr.write( - `${JSON.stringify({ code: "KNOWLEDGE_INIT_RESOURCE", message, resource, cleanup })}\n`, - ); - else { - process.stderr.write(`${message}\n`); - for (const action of cleanup) - process.stderr.write(`${renderAction(context.identity.binName, action)}\n`); - } + context.output.warning({ + code: "KNOWLEDGE_INIT_RESOURCE", + message: + format === "text" + ? [ + message, + ...cleanup.map((action) => renderAction(context.identity.binName, action)), + ].join("\n") + : message, + ...(format === "json" ? { details: { resource, cleanup } } : {}), + }); }, }); const cleanup = [...result.resources] @@ -207,49 +207,54 @@ export default defineCommand({ } : undefined; const notices = [localizedKnowledgeBillingNotice(context)]; - if (format === "json") { - emitResult({ ...result, cleanup, nextSearch, notices }, format); - return; - } - emitBare( - context.localize({ - "en-US": "Initialization succeeded: the sample was retrieved.", - "zh-CN": "初始化成功:已检索到样例内容。", - }), - ); - emitBare( - `indexId: ${result.indexId}\nagentId: ${result.agentId}\nstateFile: ${result.stateFile}`, - ); - const sampleNode = result.sampleSearch.response.data.nodes.find((node) => - matchesInitSample({ data: { nodes: [node] } }), - ); - const content = sampleNode?.metadata?.content || sampleNode?.text || ""; - const excerpt = content.length > 400 ? `${content.slice(0, 400)}…` : content; - emitBare(context.localize({ "en-US": "Sample search", "zh-CN": "样例检索" })); - emitBare( - context.localize({ - "en-US": `Query: ${result.sampleSearch.query}`, - "zh-CN": `查询:${result.sampleSearch.query}`, - }), - ); - if (sampleNode?.metadata?.doc_name) - emitBare( + let text: string | undefined; + if (format === "text") { + const lines: string[] = []; + lines.push( context.localize({ - "en-US": `Document: ${sampleNode.metadata.doc_name}`, - "zh-CN": `文档:${sampleNode.metadata.doc_name}`, + "en-US": "Initialization succeeded: the sample was retrieved.", + "zh-CN": "初始化成功:已检索到样例内容。", }), ); - emitBare(context.localize({ "en-US": `Content: ${excerpt}`, "zh-CN": `内容:${excerpt}` })); - if (nextSearch) emitBare(renderAction(context.identity.binName, nextSearch)); - emitBare(notices[0]!.message); - if (cleanup.length) - emitBare( + lines.push( + `indexId: ${result.indexId}\nagentId: ${result.agentId}\nstateFile: ${result.stateFile}`, + ); + const sampleNode = result.sampleSearch.response.data.nodes.find((node) => + matchesInitSample({ data: { nodes: [node] } }), + ); + const content = sampleNode?.metadata?.content || sampleNode?.text || ""; + const excerpt = content.length > 400 ? `${content.slice(0, 400)}…` : content; + lines.push(context.localize({ "en-US": "Sample search", "zh-CN": "样例检索" })); + lines.push( context.localize({ - "en-US": - "Cleanup commands for resources created in this run (confirm deletion separately):", - "zh-CN": "本次新建资源的清理命令(删除需另行确认):", + "en-US": `Query: ${result.sampleSearch.query}`, + "zh-CN": `查询:${result.sampleSearch.query}`, }), ); - for (const action of cleanup) emitBare(renderAction(context.identity.binName, action)); + if (sampleNode?.metadata?.doc_name) + lines.push( + context.localize({ + "en-US": `Document: ${sampleNode.metadata.doc_name}`, + "zh-CN": `文档:${sampleNode.metadata.doc_name}`, + }), + ); + lines.push(context.localize({ "en-US": `Content: ${excerpt}`, "zh-CN": `内容:${excerpt}` })); + if (nextSearch) lines.push(renderAction(context.identity.binName, nextSearch)); + lines.push(notices[0]!.message); + if (cleanup.length) + lines.push( + context.localize({ + "en-US": + "Cleanup commands for resources created in this run (confirm deletion separately):", + "zh-CN": "本次新建资源的清理命令(删除需另行确认):", + }), + ); + for (const action of cleanup) lines.push(renderAction(context.identity.binName, action)); + text = lines.join("\n"); + } + context.output.result( + { ...result, cleanup, nextSearch, notices }, + text === undefined ? undefined : { text }, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/search.ts b/packages/commands/src/commands/knowledge/search.ts index f4e1566f8..3c35665e6 100644 --- a/packages/commands/src/commands/knowledge/search.ts +++ b/packages/commands/src/commands/knowledge/search.ts @@ -6,11 +6,9 @@ import { ExitCode, defineCommand, knowledgeSearchEndpoint, - detectOutputFormat, type FlagsDef, type KnowledgeSearchRequest, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SEARCH_FLAGS = { @@ -120,7 +118,7 @@ export default defineCommand({ const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body: KnowledgeSearchRequest = { query: flags.query ?? "", @@ -181,28 +179,34 @@ export default defineCommand({ const url = knowledgeSearchEndpoint(workspaceId); if (settings.dryRun) { - emitResult({ endpoint: url, request: body }, format); + ctx.output.result({ endpoint: url, request: body }); return; } const response = await searchKnowledge(ctx.client, workspaceId, body); - const nodes = response.data?.nodes || []; - if (settings.quiet || format === "text") { + let text: string | undefined; + if (format === "text") { + const nodes = response.data?.nodes || []; + const lines: string[] = []; if (nodes.length === 0) { - emitBare("No results found."); + lines.push(ctx.localize({ "en-US": "No results found.", "zh-CN": "未找到结果。" })); } else { for (const [nodeIndex, node] of nodes.entries()) { - emitBare(`[${nodeIndex + 1}] (score: ${node.score.toFixed(4)})`); - emitBare( + lines.push( + ctx.localize({ + "en-US": `[${nodeIndex + 1}] (score: ${node.score.toFixed(4)})`, + "zh-CN": `[${nodeIndex + 1}] (得分: ${node.score.toFixed(4)})`, + }), + ); + lines.push( firstNonEmptyText(node.text, node.metadata?.content, node.metadata?.clip_description), ); - for (const line of mediaSummary(node.metadata ?? {})) emitBare(line); - emitBare(""); + lines.push(...mediaSummary(node.metadata ?? {}), ""); } } - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/service-copy.ts b/packages/commands/src/commands/knowledge/service-copy.ts index e4b994d4c..869dc59a6 100644 --- a/packages/commands/src/commands/knowledge/service-copy.ts +++ b/packages/commands/src/commands/knowledge/service-copy.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagAgentMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_COPY_FLAGS = { @@ -45,13 +43,13 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { agent_id: flags.agentId }; const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCopy); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -61,20 +59,23 @@ export default defineCommand({ body, }); - const newAgentId = agentMutationField(response, "agent_id"); - if (settings.quiet) { - emitBare(newAgentId ?? ""); - return; - } - if (format === "text") { - emitBare( - `new agent_id: ${newAgentId ?? "-"} (name: ${agentMutationField(response, "agent_name") ?? "-"}, status: ${agentMutationField(response, "agent_status") ?? "draft"})`, - ); - emitBare( - "Test the draft with --agent-version beta on search/chat, then deploy it to publish.", - ); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: [ + ctx.localize({ + "en-US": `new agent_id: ${agentMutationField(response, "agent_id") ?? "-"} (name: ${agentMutationField(response, "agent_name") ?? "-"}, status: ${agentMutationField(response, "agent_status") ?? "draft"})`, + "zh-CN": `新 agent_id: ${agentMutationField(response, "agent_id") ?? "-"} (名称: ${agentMutationField(response, "agent_name") ?? "-"}, 状态: ${agentMutationField(response, "agent_status") ?? "draft"})`, + }), + ctx.localize({ + "en-US": + "Test the draft with --agent-version beta on search/chat, then deploy it to publish.", + "zh-CN": "在 search/chat 中使用 --agent-version beta 测试草稿,然后部署发布。", + }), + ].join("\n"), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/service-create.ts b/packages/commands/src/commands/knowledge/service-create.ts index 7756f8a1b..f116a7741 100644 --- a/packages/commands/src/commands/knowledge/service-create.ts +++ b/packages/commands/src/commands/knowledge/service-create.ts @@ -1,14 +1,7 @@ import { validateNonemptyFlags } from "./nonempty-flags.ts"; import { createKnowledgeService } from "./operations/service.ts"; import { parseConfigFile } from "./service-update.ts"; -import { - defineCommand, - ragEndpoint, - RAG_PATHS, - detectOutputFormat, - type FlagsDef, -} from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { defineCommand, ragEndpoint, RAG_PATHS, type FlagsDef } from "bailian-cli-core"; import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_CREATE_FLAGS = { @@ -117,7 +110,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // agent_config is optional — omit to use server defaults; with --index-id build // the minimal kb_search_configs (name/desc etc. are backfilled by the system from @@ -127,7 +120,7 @@ export default defineCommand({ agent_scene: flags.scene, ...(flags.description ? { agent_desc: flags.description } : {}), ...(flags.configFile !== undefined - ? { agent_config: parseConfigFile(flags.configFile) } + ? { agent_config: parseConfigFile(flags.configFile, ctx.output) } : flags.indexId ? { agent_config: { kb_search_configs: [{ id: flags.indexId }] } } : {}), @@ -135,26 +128,29 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCreate); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } const response = await createKnowledgeService(ctx.client, workspaceId, body); - const agentId = agentMutationField(response, "agent_id"); - if (settings.quiet) { - emitBare(agentId ?? ""); - return; - } - if (format === "text") { - emitBare( - `created: ${agentId ?? "-"} (status: ${agentMutationField(response, "agent_status") ?? "draft"}, version: ${agentMutationField(response, "agent_version") ?? "beta"})`, - ); - emitBare( - "Test the draft with --agent-version beta on search/chat, then deploy it to publish.", - ); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: [ + ctx.localize({ + "en-US": `created: ${agentMutationField(response, "agent_id") ?? "-"} (status: ${agentMutationField(response, "agent_status") ?? "draft"}, version: ${agentMutationField(response, "agent_version") ?? "beta"})`, + "zh-CN": `已创建: ${agentMutationField(response, "agent_id") ?? "-"} (状态: ${agentMutationField(response, "agent_status") ?? "draft"}, 版本: ${agentMutationField(response, "agent_version") ?? "beta"})`, + }), + ctx.localize({ + "en-US": + "Test the draft with --agent-version beta on search/chat, then deploy it to publish.", + "zh-CN": "在 search/chat 中使用 --agent-version beta 测试草稿,然后部署发布。", + }), + ].join("\n"), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/service-delete.ts b/packages/commands/src/commands/knowledge/service-delete.ts index 027e2d617..47102f018 100644 --- a/packages/commands/src/commands/knowledge/service-delete.ts +++ b/packages/commands/src/commands/knowledge/service-delete.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagAgentMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_DELETE_FLAGS = { @@ -54,13 +52,13 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { agent_id: flags.agentId }; const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDelete); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -70,13 +68,16 @@ export default defineCommand({ body, }); - if (settings.quiet) return; - if (format === "text") { - emitBare( - `deleted: ${flags.agentId} (status: ${agentMutationField(response, "agent_status") ?? "deleted"})`, - ); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: ctx.localize({ + "en-US": `deleted: ${flags.agentId} (status: ${agentMutationField(response, "agent_status") ?? "deleted"})`, + "zh-CN": `已删除: ${flags.agentId} (状态: ${agentMutationField(response, "agent_status") ?? "deleted"})`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/service-deploy.ts b/packages/commands/src/commands/knowledge/service-deploy.ts index 38ced5736..25969e22e 100644 --- a/packages/commands/src/commands/knowledge/service-deploy.ts +++ b/packages/commands/src/commands/knowledge/service-deploy.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagAgentMutationResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_DEPLOY_FLAGS = { @@ -68,7 +66,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { agent_id: flags.agentId, @@ -77,7 +75,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDeploy); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -87,15 +85,16 @@ export default defineCommand({ body, }); - const newVersion = agentMutationField(response, "agent_version"); - if (settings.quiet) { - emitBare(newVersion ?? ""); - return; - } - if (format === "text") { - emitBare(`deployed: ${flags.agentId} version ${newVersion ?? "-"}`); - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: ctx.localize({ + "en-US": `deployed: ${flags.agentId} version ${agentMutationField(response, "agent_version") ?? "-"}`, + "zh-CN": `已部署: ${flags.agentId} 版本 ${agentMutationField(response, "agent_version") ?? "-"}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/service-get.ts b/packages/commands/src/commands/knowledge/service-get.ts index f0de4a0d9..78ee79eb7 100644 --- a/packages/commands/src/commands/knowledge/service-get.ts +++ b/packages/commands/src/commands/knowledge/service-get.ts @@ -3,12 +3,11 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagAgentDetail, + type LocalizedText, type RagAgentGetResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_GET_FLAGS = { @@ -29,21 +28,58 @@ const SERVICE_GET_FLAGS = { ...WORKSPACE_FLAG, } satisfies FlagsDef; -function printDetail(detail: RagAgentDetail): void { - emitBare(`Version ${detail.agent_version ?? "?"}:`); - if (detail.agent_version_desc) emitBare(` desc: ${detail.agent_version_desc}`); - if (detail.publish_time !== undefined) emitBare(` published: ${detail.publish_time}`); +function detailLines(detail: RagAgentDetail, localize: (text: LocalizedText) => string): string[] { + const lines = [ + localize({ + "en-US": `Version ${detail.agent_version ?? "?"}:`, + "zh-CN": `版本 ${detail.agent_version ?? "?"}:`, + }), + ]; + if (detail.agent_version_desc) + lines.push( + localize({ + "en-US": ` desc: ${detail.agent_version_desc}`, + "zh-CN": ` 描述: ${detail.agent_version_desc}`, + }), + ); + if (detail.publish_time !== undefined) + lines.push( + localize({ + "en-US": ` published: ${detail.publish_time}`, + "zh-CN": ` 发布时间: ${detail.publish_time}`, + }), + ); const config = detail.agent_config; - if (!config) return; - if (config.agent_policy) emitBare(` policy: ${config.agent_policy}`); - if (config.agent_model) emitBare(` model: ${config.agent_model}`); + if (!config) return lines; + if (config.agent_policy) + lines.push( + localize({ + "en-US": ` policy: ${config.agent_policy}`, + "zh-CN": ` 策略: ${config.agent_policy}`, + }), + ); + if (config.agent_model) + lines.push( + localize({ + "en-US": ` model: ${config.agent_model}`, + "zh-CN": ` 模型: ${config.agent_model}`, + }), + ); if (config.hybrid_rerank !== undefined) - emitBare(` hybrid_rerank: ${JSON.stringify(config.hybrid_rerank)}`); - if (config.temperature !== undefined) emitBare(` temperature: ${config.temperature}`); + lines.push(` hybrid_rerank: ${JSON.stringify(config.hybrid_rerank)}`); + if (config.temperature !== undefined) + lines.push( + localize({ + "en-US": ` temperature: ${config.temperature}`, + "zh-CN": ` 温度: ${config.temperature}`, + }), + ); for (const kbConfig of config.kb_search_configs ?? []) { const kbId = typeof kbConfig.id === "string" ? kbConfig.id : "?"; const kbName = typeof kbConfig.name === "string" ? ` (${kbConfig.name})` : ""; - emitBare(` kb: ${kbId}${kbName}`); + lines.push( + localize({ "en-US": ` kb: ${kbId}${kbName}`, "zh-CN": ` 知识库: ${kbId}${kbName}` }), + ); for (const key of [ "rerank", "rerank_model", @@ -52,9 +88,10 @@ function printDetail(detail: RagAgentDetail): void { "dense_similarity_top_k", "sparse_similarity_top_k", ]) { - if (kbConfig[key] !== undefined) emitBare(` ${key}: ${JSON.stringify(kbConfig[key])}`); + if (kbConfig[key] !== undefined) lines.push(` ${key}: ${JSON.stringify(kbConfig[key])}`); } } + return lines; } export default defineCommand({ @@ -88,7 +125,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const body = { agent_id: flags.agentId, @@ -97,7 +134,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentGet); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -107,23 +144,33 @@ export default defineCommand({ body, }); - const data = response.data; - if (settings.quiet) { - emitBare(data?.agent_id ?? ""); - return; - } - if (format !== "text") { - emitResult(response, format); - return; - } - emitBare("Basic:"); - emitBare(` id: ${data?.agent_id ?? "-"}`); - emitBare(` name: ${data?.agent_name ?? "-"}`); - emitBare(` desc: ${data?.agent_desc ?? "-"}`); - emitBare(` scene: ${data?.agent_scene ?? "-"}`); - emitBare(` status: ${data?.agent_status ?? "-"}`); - for (const detail of data?.agent_details ?? []) { - printDetail(detail); + let text: string | undefined; + if (format === "text") { + const data = response.data; + const lines = [ + ctx.localize({ "en-US": "Basic:", "zh-CN": "基本信息:" }), + ` id: ${data?.agent_id ?? "-"}`, + ctx.localize({ + "en-US": ` name: ${data?.agent_name ?? "-"}`, + "zh-CN": ` 名称: ${data?.agent_name ?? "-"}`, + }), + ctx.localize({ + "en-US": ` desc: ${data?.agent_desc ?? "-"}`, + "zh-CN": ` 描述: ${data?.agent_desc ?? "-"}`, + }), + ctx.localize({ + "en-US": ` scene: ${data?.agent_scene ?? "-"}`, + "zh-CN": ` 场景: ${data?.agent_scene ?? "-"}`, + }), + ctx.localize({ + "en-US": ` status: ${data?.agent_status ?? "-"}`, + "zh-CN": ` 状态: ${data?.agent_status ?? "-"}`, + }), + ]; + for (const detail of data?.agent_details ?? []) + lines.push(...detailLines(detail, ctx.localize)); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/service-list.ts b/packages/commands/src/commands/knowledge/service-list.ts index a3c346a67..6801ef53d 100644 --- a/packages/commands/src/commands/knowledge/service-list.ts +++ b/packages/commands/src/commands/knowledge/service-list.ts @@ -2,11 +2,9 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, type FlagsDef, type RagAgentListResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts"; const SERVICE_LIST_FLAGS = { @@ -85,7 +83,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; // agent/list pagination goes in the body const body = { @@ -100,7 +98,7 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentList); if (settings.dryRun) { - emitResult({ endpoint, request: body }, format); + ctx.output.result({ endpoint, request: body }); return; } @@ -110,21 +108,19 @@ export default defineCommand({ body, }); - const rows = response.data?.rows ?? []; - if (settings.quiet) { - for (const row of rows) emitBare(row.agent_id ?? ""); - return; - } + let text: string | undefined; if (format === "text") { + const rows = response.data?.rows ?? []; + const lines: string[] = []; if (rows.length === 0) { - emitBare("No services found."); + lines.push(ctx.localize({ "en-US": "No services found.", "zh-CN": "未找到服务。" })); } else { for (const row of rows) { const kbNames = (row.pipeline_list ?? []) .map((pipeline) => pipeline.pipeline_name) .filter(Boolean) .join(","); - emitBare( + lines.push( truncateLine( [ row.agent_id, @@ -139,15 +135,27 @@ export default defineCommand({ ); } } - emitBare(`total: ${response.data?.total_count ?? rows.length}`); - // Connect finding an ID with using it (capability wording, no hardcoded product path) - emitBare( - flags.scene === "search" - ? "Use an agent_id above with the knowledge search command." - : "Use an agent_id above with the knowledge chat command.", + lines.push( + ctx.localize({ + "en-US": `total: ${response.data?.total_count ?? rows.length}`, + "zh-CN": `总数: ${response.data?.total_count ?? rows.length}`, + }), + ); + lines.push( + ctx.localize( + flags.scene === "search" + ? { + "en-US": "Use an agent_id above with the knowledge search command.", + "zh-CN": "将上述 agent_id 用于知识库搜索命令。", + } + : { + "en-US": "Use an agent_id above with the knowledge chat command.", + "zh-CN": "将上述 agent_id 用于知识库对话命令。", + }, + ), ); - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/knowledge/service-update.ts b/packages/commands/src/commands/knowledge/service-update.ts index 1bc877da7..30b2bb293 100644 --- a/packages/commands/src/commands/knowledge/service-update.ts +++ b/packages/commands/src/commands/knowledge/service-update.ts @@ -4,14 +4,13 @@ import { defineCommand, ragEndpoint, RAG_PATHS, - detectOutputFormat, BailianError, ExitCode, type Client, + type CommandOutput, type FlagsDef, type RagAgentConfig, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const BOOL_CHOICES = ["true", "false"]; @@ -160,7 +159,10 @@ function collectScalarConfig(flags: UpdateFlags): Partial { } /** --config-file: read JSON + structural/enum/range validation; unknown fields warn but pass through */ -export function parseConfigFile(filePath: string): RagAgentConfig { +export function parseConfigFile( + filePath: string, + output: Pick, +): RagAgentConfig { let raw: string; try { raw = readFileSync(filePath, "utf-8"); @@ -182,7 +184,7 @@ export function parseConfigFile(filePath: string): RagAgentConfig { throw new BailianError("--config-file must contain a JSON object.", ExitCode.USAGE); } const config = parsed as RagAgentConfig; - validateConfigValues(config); + validateConfigValues(config, output); return config; } @@ -215,7 +217,10 @@ const KNOWN_CONFIG_KEYS = new Set([ ]); /** Enum/range validation + behavioral warnings, per the agent_config contract */ -export function validateConfigValues(config: RagAgentConfig): void { +export function validateConfigValues( + config: RagAgentConfig, + output: Pick, +): void { if (config.agent_policy !== undefined && !["turbo", "agentic"].includes(config.agent_policy)) { throw new BailianError("agent_policy must be turbo or agentic", ExitCode.USAGE); } @@ -237,15 +242,27 @@ export function validateConfigValues(config: RagAgentConfig): void { | { rerank_mode?: string; rerank_instruct?: string } | undefined; if (rerank?.rerank_instruct && rerank.rerank_mode !== "custom") { - process.stderr.write( - "Warning: rerank_instruct is only effective with rerank_mode=custom; the server clears it otherwise.\n", - ); + output.warning({ + code: "KNOWLEDGE_SERVICE_RERANK_INSTRUCT_IGNORED", + message: { + "en-US": + "rerank_instruct is only effective with rerank_mode=custom; the server clears it otherwise.", + "zh-CN": "rerank_instruct 仅在 rerank_mode=custom 时生效;否则服务端会将其清空。", + }, + }); } } // Unknown fields: warn but pass through (the server is the source of truth) for (const key of Object.keys(config)) { if (!KNOWN_CONFIG_KEYS.has(key)) { - process.stderr.write(`Warning: unknown agent_config field passed through: ${key}\n`); + output.warning({ + code: "KNOWLEDGE_SERVICE_UNKNOWN_CONFIG_FIELD", + message: { + "en-US": `unknown agent_config field passed through: ${key}`, + "zh-CN": `未知 agent_config 字段已原样传递: ${key}`, + }, + details: { field: key }, + }); } } } @@ -368,7 +385,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const workspaceId = resolveWorkspaceId(ctx); - const format = detectOutputFormat(settings.output); + const format = settings.output; const scalarConfig = collectScalarConfig(flags); const hasConfigChange = flags.configFile !== undefined || Object.keys(scalarConfig).length > 0; @@ -376,7 +393,7 @@ export default defineCommand({ let agentConfig: RagAgentConfig | undefined; if (flags.configFile !== undefined) { // dry-run also reads the file and validates (rehearsal semantics) - agentConfig = parseConfigFile(flags.configFile); + agentConfig = parseConfigFile(flags.configFile, ctx.output); } else if (Object.keys(scalarConfig).length > 0) { if (settings.dryRun) { // dry-run does not issue the read-merge request; show the scalar delta with a placeholder note @@ -386,7 +403,7 @@ export default defineCommand({ // then merge (hidden from the user) const currentConfig = await fetchBetaConfig(ctx.client, workspaceId, flags.agentId); agentConfig = { ...currentConfig, ...scalarConfig }; - validateConfigValues(agentConfig); + validateConfigValues(agentConfig, ctx.output); } } @@ -401,29 +418,39 @@ export default defineCommand({ const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentUpdate); if (settings.dryRun) { - emitResult( - { - endpoint, - request: body, - ...(hasConfigChange && flags.configFile === undefined - ? { note: "scalar config flags are merged into the current beta config at run time" } - : {}), - }, - format, - ); + ctx.output.result({ + endpoint, + request: body, + ...(hasConfigChange && flags.configFile === undefined + ? { note: "scalar config flags are merged into the current beta config at run time" } + : {}), + }); return; } const response = await updateKnowledgeService(ctx.client, workspaceId, body); - if (settings.quiet) return; - if (format === "text") { - emitBare(`updated: ${flags.agentId}`); - if (hasConfigChange) { - emitBare("Draft config changed — verify with --agent-version beta, then deploy."); - } - return; - } - emitResult(response, format); + ctx.output.result( + response, + format === "text" + ? { + text: [ + ctx.localize({ + "en-US": `updated: ${flags.agentId}`, + "zh-CN": `已更新: ${flags.agentId}`, + }), + ...(hasConfigChange + ? [ + ctx.localize({ + "en-US": + "Draft config changed — verify with --agent-version beta, then deploy.", + "zh-CN": "草稿配置已更改,请使用 --agent-version beta 验证,然后部署。", + }), + ] + : []), + ].join("\n"), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/knowledge/upload-support.ts b/packages/commands/src/commands/knowledge/upload-support.ts index 173feb2eb..874847455 100644 --- a/packages/commands/src/commands/knowledge/upload-support.ts +++ b/packages/commands/src/commands/knowledge/upload-support.ts @@ -205,7 +205,10 @@ export function checkUploadFile( } return { sizeBytes, - warning: `${basename(filePath)} exceeds the recommended 10 MB for ${extension}; the server may reject or truncate it.`, + warning: localize({ + "en-US": `${basename(filePath)} exceeds the recommended 10 MB for ${extension}; the server may reject or truncate it.`, + "zh-CN": `${basename(filePath)} 超过 ${extension} 的建议 10 MB 大小;服务端可能拒绝或截断该文件。`, + }), }; } return { sizeBytes }; diff --git a/packages/commands/src/commands/memory/node-show.ts b/packages/commands/src/commands/memory/node-show.ts index f6d71520d..ff88ac87b 100644 --- a/packages/commands/src/commands/memory/node-show.ts +++ b/packages/commands/src/commands/memory/node-show.ts @@ -2,11 +2,9 @@ import { defineCommand, memoryEndpoint, memoryNodePath, - detectOutputFormat, type FlagsDef, type MemoryNodeDetailResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { MEMORY_WORKSPACE_NOTE, WORKSPACE_FLAG, resolveWorkspaceId } from "./shared.ts"; const NODE_SHOW_FLAGS = { @@ -40,11 +38,11 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const url = memoryEndpoint(resolveWorkspaceId(ctx), memoryNodePath(flags.nodeId)); if (settings.dryRun) { - emitResult({ endpoint: url, method: "GET" }, format); + ctx.output.result({ endpoint: url, method: "GET" }); return; } @@ -53,27 +51,74 @@ export default defineCommand({ method: "GET", }); - if (settings.quiet || format === "text") { + let text: string | undefined; + if (format === "text") { const node = response.memory_node; + const lines: string[] = []; if (!node) { - emitBare("Memory node not found."); - return; + lines.push( + ctx.localize({ "en-US": "Memory node not found.", "zh-CN": "未找到记忆节点。" }), + ); + } else { + lines.push(`[${node.memory_node_id}] ${node.content}`); + lines.push( + ctx.localize({ + "en-US": ` type: ${node.memory_type ?? "-"} status: ${node.status ?? "-"}`, + "zh-CN": ` 类型: ${node.memory_type ?? "-"} 状态: ${node.status ?? "-"}`, + }), + ); + if (node.project_id) + lines.push( + ctx.localize({ + "en-US": ` project: ${node.project_id}`, + "zh-CN": ` 项目: ${node.project_id}`, + }), + ); + if (node.timestamp !== undefined) + lines.push( + ctx.localize({ + "en-US": ` timestamp: ${node.timestamp}`, + "zh-CN": ` 时间戳: ${node.timestamp}`, + }), + ); + if (node.created_at !== undefined) + lines.push( + ctx.localize({ + "en-US": ` created_at: ${node.created_at}`, + "zh-CN": ` 创建时间: ${node.created_at}`, + }), + ); + if (node.updated_at !== undefined) + lines.push( + ctx.localize({ + "en-US": ` updated_at: ${node.updated_at}`, + "zh-CN": ` 更新时间: ${node.updated_at}`, + }), + ); + if (node.meta_data && Object.keys(node.meta_data).length > 0) + lines.push( + ctx.localize({ + "en-US": ` meta: ${JSON.stringify(node.meta_data)}`, + "zh-CN": ` 元数据: ${JSON.stringify(node.meta_data)}`, + }), + ); + if (node.media_desc) + lines.push( + ctx.localize({ + "en-US": ` media_desc: ${node.media_desc}`, + "zh-CN": ` 媒体描述: ${node.media_desc}`, + }), + ); + if (node.media_urls && node.media_urls.length > 0) + lines.push( + ctx.localize({ + "en-US": ` media_urls: ${node.media_urls.join(", ")}`, + "zh-CN": ` 媒体 URL: ${node.media_urls.join(", ")}`, + }), + ); } - emitBare(`[${node.memory_node_id}] ${node.content}`); - emitBare(` type: ${node.memory_type ?? "-"} status: ${node.status ?? "-"}`); - if (node.project_id) emitBare(` project: ${node.project_id}`); - if (node.timestamp !== undefined) emitBare(` timestamp: ${node.timestamp}`); - if (node.created_at !== undefined) emitBare(` created_at: ${node.created_at}`); - if (node.updated_at !== undefined) emitBare(` updated_at: ${node.updated_at}`); - if (node.meta_data && Object.keys(node.meta_data).length > 0) { - emitBare(` meta: ${JSON.stringify(node.meta_data)}`); - } - if (node.media_desc) emitBare(` media_desc: ${node.media_desc}`); - if (node.media_urls && node.media_urls.length > 0) { - emitBare(` media_urls: ${node.media_urls.join(", ")}`); - } - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/memory/profile-delete.ts b/packages/commands/src/commands/memory/profile-delete.ts index 142da8091..34e0fe7cc 100644 --- a/packages/commands/src/commands/memory/profile-delete.ts +++ b/packages/commands/src/commands/memory/profile-delete.ts @@ -2,10 +2,8 @@ import { defineCommand, memoryEndpoint, profileSchemaItemPath, - detectOutputFormat, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { buildQuery } from "../shared/params.ts"; import { MEMORY_LIBRARY_FLAG, @@ -59,13 +57,13 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const url = memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaItemPath(flags.schemaId)) + buildQuery({ memory_library_id: flags.libraryId }); if (settings.dryRun) { - emitResult({ endpoint: url, method: "DELETE" }, format); + ctx.output.result({ endpoint: url, method: "DELETE" }); return; } @@ -74,10 +72,16 @@ export default defineCommand({ method: "DELETE", }); - if (settings.quiet || format === "text") { - emitBare(`Profile schema ${flags.schemaId} deleted.`); - } else { - emitResult(response, format); - } + ctx.output.result( + response, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Profile schema ${flags.schemaId} deleted.`, + "zh-CN": `画像模板 ${flags.schemaId} 已删除。`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/memory/profile-list.ts b/packages/commands/src/commands/memory/profile-list.ts index f4d1cd57e..72bc86ed0 100644 --- a/packages/commands/src/commands/memory/profile-list.ts +++ b/packages/commands/src/commands/memory/profile-list.ts @@ -2,11 +2,9 @@ import { defineCommand, memoryEndpoint, profileSchemaPath, - detectOutputFormat, type FlagsDef, type ProfileSchemaListResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { buildQuery } from "../shared/params.ts"; import { MEMORY_LIBRARY_FLAG, @@ -53,7 +51,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const url = memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaPath()) + buildQuery({ @@ -63,7 +61,7 @@ export default defineCommand({ }); if (settings.dryRun) { - emitResult({ endpoint: url, method: "GET" }, format); + ctx.output.result({ endpoint: url, method: "GET" }); return; } @@ -72,22 +70,30 @@ export default defineCommand({ method: "GET", }); - if (settings.quiet || format === "text") { + let text: string | undefined; + if (format === "text") { const schemas = response.profile_schemas ?? []; + const lines: string[] = []; if (schemas.length === 0) { - emitBare("No profile schemas found."); + lines.push( + ctx.localize({ "en-US": "No profile schemas found.", "zh-CN": "未找到画像模板。" }), + ); } else { for (const schema of schemas) { const planSuffix = schema.plan_version ? ` (${schema.plan_version})` : ""; - emitBare(`[${schema.profile_schema_id}] ${schema.name}${planSuffix}`); - if (schema.description) emitBare(` ${schema.description}`); - } - if (response.total !== undefined) { - emitBare(`\nTotal: ${response.total}`); + lines.push(`[${schema.profile_schema_id}] ${schema.name}${planSuffix}`); + if (schema.description) lines.push(` ${schema.description}`); } + if (response.total !== undefined) + lines.push( + ctx.localize({ + "en-US": `\nTotal: ${response.total}`, + "zh-CN": `\n总数: ${response.total}`, + }), + ); } - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/memory/profile-show.ts b/packages/commands/src/commands/memory/profile-show.ts index 0cf9ae849..479242f71 100644 --- a/packages/commands/src/commands/memory/profile-show.ts +++ b/packages/commands/src/commands/memory/profile-show.ts @@ -2,11 +2,9 @@ import { defineCommand, memoryEndpoint, profileSchemaItemPath, - detectOutputFormat, type FlagsDef, type ProfileSchemaDetailResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { buildQuery } from "../shared/params.ts"; import { MEMORY_LIBRARY_FLAG, @@ -55,13 +53,13 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const url = memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaItemPath(flags.schemaId)) + buildQuery({ memory_library_id: flags.libraryId }); if (settings.dryRun) { - emitResult({ endpoint: url, method: "GET" }, format); + ctx.output.result({ endpoint: url, method: "GET" }); return; } @@ -70,23 +68,42 @@ export default defineCommand({ method: "GET", }); - if (settings.quiet || format === "text") { - emitBare(`Name: ${response.name ?? "(none)"}`); - if (response.description) emitBare(`Description: ${response.description}`); + let text: string | undefined; + if (format === "text") { + const name = response.name ?? ctx.localize({ "en-US": "(none)", "zh-CN": "(无)" }); + const lines = [ctx.localize({ "en-US": `Name: ${name}`, "zh-CN": `名称: ${name}` })]; + if (response.description) + lines.push( + ctx.localize({ + "en-US": `Description: ${response.description}`, + "zh-CN": `描述: ${response.description}`, + }), + ); const attributes = response.attributes ?? []; if (attributes.length === 0) { - emitBare("No attributes."); + lines.push(ctx.localize({ "en-US": "No attributes.", "zh-CN": "暂无属性。" })); } else { - emitBare("Attributes:"); + lines.push(ctx.localize({ "en-US": "Attributes:", "zh-CN": "属性:" })); for (const attribute of attributes) { - emitBare(` [${attribute.attribute_id}] ${attribute.name}`); - if (attribute.description) emitBare(` desc: ${attribute.description}`); + lines.push(` [${attribute.attribute_id}] ${attribute.name}`); + if (attribute.description) + lines.push( + ctx.localize({ + "en-US": ` desc: ${attribute.description}`, + "zh-CN": ` 描述: ${attribute.description}`, + }), + ); if (attribute.default_value !== undefined) - emitBare(` default: ${attribute.default_value}`); + lines.push( + ctx.localize({ + "en-US": ` default: ${attribute.default_value}`, + "zh-CN": ` 默认值: ${attribute.default_value}`, + }), + ); } } - } else { - emitResult(response, format); + text = lines.join("\n"); } + ctx.output.result(response, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/memory/profile-update.ts b/packages/commands/src/commands/memory/profile-update.ts index 035985652..dfff07b62 100644 --- a/packages/commands/src/commands/memory/profile-update.ts +++ b/packages/commands/src/commands/memory/profile-update.ts @@ -3,13 +3,11 @@ import { UsageError, memoryEndpoint, profileSchemaItemPath, - detectOutputFormat, type FlagsDef, type MemoryPlanVersion, type ProfileSchemaAttributeOperation, type ProfileSchemaUpdateRequest, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { MEMORY_LIBRARY_FLAG, MEMORY_WORKSPACE_NOTE, @@ -145,11 +143,11 @@ export default defineCommand({ if (flags.extractScene) body.extract_scene = flags.extractScene; if (flags.libraryId) body.memory_library_id = flags.libraryId; - const format = detectOutputFormat(settings.output); + const format = settings.output; const url = memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaItemPath(flags.schemaId)); if (settings.dryRun) { - emitResult({ endpoint: url, method: "PATCH", request: body }, format); + ctx.output.result({ endpoint: url, method: "PATCH", request: body }); return; } @@ -159,10 +157,16 @@ export default defineCommand({ body, }); - if (settings.quiet || format === "text") { - emitBare(`Profile schema ${flags.schemaId} updated.`); - } else { - emitResult(response, format); - } + ctx.output.result( + response, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Profile schema ${flags.schemaId} updated.`, + "zh-CN": `画像模板 ${flags.schemaId} 已更新。`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/memory/skill-export.ts b/packages/commands/src/commands/memory/skill-export.ts index 494196782..ee462d274 100644 --- a/packages/commands/src/commands/memory/skill-export.ts +++ b/packages/commands/src/commands/memory/skill-export.ts @@ -2,11 +2,9 @@ import { defineCommand, memoryEndpoint, memorySkillExportPath, - detectOutputFormat, type FlagsDef, type MemorySkillExportResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; import { MEMORY_WORKSPACE_NOTE, WORKSPACE_FLAG, resolveWorkspaceId } from "./shared.ts"; const SKILL_EXPORT_FLAGS = { @@ -43,11 +41,11 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const url = memoryEndpoint(resolveWorkspaceId(ctx), memorySkillExportPath(flags.nodeId)); if (settings.dryRun) { - emitResult({ endpoint: url, method: "GET" }, format); + ctx.output.result({ endpoint: url, method: "GET" }); return; } @@ -56,15 +54,18 @@ export default defineCommand({ method: "GET", }); - if (settings.quiet || format === "text") { - const node = response.memory_node; - if (!node) { - emitBare("Skill memory node not found."); - return; - } - emitBare(node.content); - } else { - emitResult(response, format); - } + ctx.output.result( + response, + format === "text" + ? { + text: response.memory_node + ? String(response.memory_node.content) + : ctx.localize({ + "en-US": "Skill memory node not found.", + "zh-CN": "未找到技能记忆节点。", + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/tests/e2e/knowledge/document-response.ts b/packages/commands/tests/e2e/knowledge/document-response.ts new file mode 100644 index 000000000..43b523911 --- /dev/null +++ b/packages/commands/tests/e2e/knowledge/document-response.ts @@ -0,0 +1,37 @@ +import { parseStdoutJson } from "../helpers.ts"; + +/** Quiet preserves the full result; consumers explicitly read its JSON fields. */ +export function readUploadedFileIds(stdout: string): string[] { + const response = parseStdoutJson<{ files: { fileId: string }[] }>(stdout); + return response.files.map((file) => file.fileId); +} + +export function readImportedFileIds(stdout: string): string[] { + const response = parseStdoutJson<{ data?: { addFileResultList?: { fileId?: string }[] } }>( + stdout, + ); + return (response.data?.addFileResultList ?? []).flatMap((file) => + file.fileId ? [file.fileId] : [], + ); +} + +export function readDocumentIds(stdout: string): string[] { + const response = parseStdoutJson<{ data?: { rows?: { doc_id?: string }[] } }>(stdout); + return (response.data?.rows ?? []).flatMap((document) => + document.doc_id ? [document.doc_id] : [], + ); +} + +export function readChunkIds(stdout: string): string[] { + const response = parseStdoutJson<{ data?: { nodes?: { metadata?: { _id?: string } }[] } }>( + stdout, + ); + return (response.data?.nodes ?? []).flatMap((chunk) => + chunk.metadata?._id ? [chunk.metadata._id] : [], + ); +} + +export function readIngestionStatus(stdout: string): string { + const response = parseStdoutJson<{ data?: { ingestion_status?: string } }>(stdout); + return response.data?.ingestion_status ?? "UNKNOWN"; +} diff --git a/packages/commands/tests/e2e/knowledge/journeys/init-resource-records.ts b/packages/commands/tests/e2e/knowledge/journeys/init-resource-records.ts index fa0eb2744..87b20f722 100644 --- a/packages/commands/tests/e2e/knowledge/journeys/init-resource-records.ts +++ b/packages/commands/tests/e2e/knowledge/journeys/init-resource-records.ts @@ -32,7 +32,8 @@ export function initCreatedResources(stdout: string, stderr: string): InitCreate for (const line of stderr.split(/\r?\n/)) { try { const record = JSON.parse(line); - if (record?.code === "KNOWLEDGE_INIT_RESOURCE") collect(record.resource); + if (record?.warning?.code === "KNOWLEDGE_INIT_RESOURCE") + collect(record.warning.details?.resource); } catch { /* Ignore progress text and truncated diagnostics. */ } diff --git a/packages/commands/tests/e2e/knowledge/journeys/j1-cold-start.e2e.test.ts b/packages/commands/tests/e2e/knowledge/journeys/j1-cold-start.e2e.test.ts index 00fbe4dbb..0079e1dda 100644 --- a/packages/commands/tests/e2e/knowledge/journeys/j1-cold-start.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/journeys/j1-cold-start.e2e.test.ts @@ -1,3 +1,4 @@ +import { readServiceMutationField } from "../service-response.ts"; // J1 cold-start first answer: from zero to the first citable answer. // upload → kb create --wait → service create (search/chat, draft) // → search --agent-version beta recalls → chat --agent-version beta first answer. @@ -47,10 +48,12 @@ describe.skipIf(!isKbAdminE2EReady())("journey J1: 冷启动首答 (live, 自清 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ], ); expect(searchServiceRun.exitCode, searchServiceRun.stderr).toBe(0); - const searchAgentId = searchServiceRun.stdout.trim().split("\n").pop()!; + const searchAgentId = readServiceMutationField(searchServiceRun.stdout, "agent_id"); expect(searchAgentId).toMatch(/^aid-/); reporter.trackResource("service", searchAgentId); serviceAgentIds.push(searchAgentId); @@ -101,9 +104,11 @@ describe.skipIf(!isKbAdminE2EReady())("journey J1: 冷启动首答 (live, 自清 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(chatServiceRun.exitCode, chatServiceRun.stderr).toBe(0); - const chatAgentId = chatServiceRun.stdout.trim().split("\n").pop()!; + const chatAgentId = readServiceMutationField(chatServiceRun.stdout, "agent_id"); expect(chatAgentId).toMatch(/^aid-/); reporter.trackResource("service", chatAgentId); serviceAgentIds.push(chatAgentId); diff --git a/packages/commands/tests/e2e/knowledge/journeys/j2-content-ops.e2e.test.ts b/packages/commands/tests/e2e/knowledge/journeys/j2-content-ops.e2e.test.ts index d721b6c89..e60159c2b 100644 --- a/packages/commands/tests/e2e/knowledge/journeys/j2-content-ops.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/journeys/j2-content-ops.e2e.test.ts @@ -1,3 +1,5 @@ +import { readDocumentIds, readIngestionStatus } from "../document-response.ts"; +import { readServiceMutationField } from "../service-response.ts"; // J2 content operations: routine document add/remove and the visibility loop. // create (doc1+doc2) → doc list/status checks → doc tag → search hits both markers // → doc delete doc2 → search verifies markerB is gone and markerA remains. @@ -49,9 +51,11 @@ describe.skipIf(!isKbAdminE2EReady())("journey J2: 内容运维 (live, 自清理 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(serviceRun.exitCode, serviceRun.stderr).toBe(0); - const agentId = serviceRun.stdout.trim().split("\n").pop()!; + const agentId = readServiceMutationField(serviceRun.stdout, "agent_id"); searchAgentId = agentId; expect(agentId).toMatch(/^aid-/); reporter.trackResource("service", agentId); @@ -69,9 +73,11 @@ describe.skipIf(!isKbAdminE2EReady())("journey J2: 内容运维 (live, 自清理 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(docListRun.exitCode, docListRun.stderr).toBe(0); - const listedDocIds = docListRun.stdout.trim().split("\n"); + const listedDocIds = readDocumentIds(docListRun.stdout); expect(listedDocIds).toContain(fileIdA); expect(listedDocIds).toContain(fileIdB); @@ -86,9 +92,11 @@ describe.skipIf(!isKbAdminE2EReady())("journey J2: 内容运维 (live, 自清理 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(docStatusRun.exitCode, docStatusRun.stderr).toBe(0); - expect(docStatusRun.stdout.trim()).toBe("COMPLETED"); + expect(readIngestionStatus(docStatusRun.stdout)).toBe("COMPLETED"); // 2.1) Negative branch: querying a job id that does not exist on this base — // the server error (Index.IndexJobNotExist, verified live) passes through diff --git a/packages/commands/tests/e2e/knowledge/journeys/j4-service-tuning.e2e.test.ts b/packages/commands/tests/e2e/knowledge/journeys/j4-service-tuning.e2e.test.ts index 2b299fc00..caf63fa48 100644 --- a/packages/commands/tests/e2e/knowledge/journeys/j4-service-tuning.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/journeys/j4-service-tuning.e2e.test.ts @@ -1,3 +1,4 @@ +import { readServiceMutationField } from "../service-response.ts"; // J4 service tuning: draft (beta) usable → config change persisted → released version carries the change after deploy. // create + service create (search, initial draft/beta) → search --agent-version beta recalls (hard) // → service update --description/--temperature (scalar merge path) → get (beta) asserts (hard) @@ -50,10 +51,12 @@ describe.skipIf(!isKbAdminE2EReady())("journey J4: 问答服务调优 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ], ); expect(createServiceRun.exitCode, createServiceRun.stderr).toBe(0); - agentId = createServiceRun.stdout.trim().split("\n").pop()!; + agentId = readServiceMutationField(createServiceRun.stdout, "agent_id"); expect(agentId).toMatch(/^aid-/); reporter.trackResource("service", agentId); @@ -213,9 +216,11 @@ describe.skipIf(!isKbAdminE2EReady())("journey J4: 问答服务调优 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(deployRun.exitCode, deployRun.stderr).toBe(0); - const deployedVersion = deployRun.stdout.trim().split("\n").pop() ?? ""; + const deployedVersion = readServiceMutationField(deployRun.stdout, "agent_version"); expect(deployedVersion, "deploy 应输出新版本号").toBeTruthy(); reporter.recordNote(`deploy 版本号: ${deployedVersion}`); diff --git a/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts b/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts index e0746f901..5bd49095e 100644 --- a/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts @@ -1,3 +1,4 @@ +import { readUploadedFileIds } from "../document-response.ts"; import type { RagGetConnectorResponse, RagAddCategoryResponse, @@ -120,9 +121,11 @@ describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(uploadRun.exitCode, uploadRun.stderr).toBe(0); - const fileId = uploadRun.stdout.trim(); + const fileId = readUploadedFileIds(uploadRun.stdout)[0]!; expect(fileId).toMatch(/^file_/); reporter.trackResource("data-center-file", fileId); diff --git a/packages/commands/tests/e2e/knowledge/journeys/journey-helpers.ts b/packages/commands/tests/e2e/knowledge/journeys/journey-helpers.ts index 1f716c2f7..224cd863a 100644 --- a/packages/commands/tests/e2e/knowledge/journeys/journey-helpers.ts +++ b/packages/commands/tests/e2e/knowledge/journeys/journey-helpers.ts @@ -1,3 +1,4 @@ +import { readUploadedFileIds } from "../document-response.ts"; // Shared journey-level infrastructure: marker-word fixtures, eventual-consistency // polling, IndexStatusError retrying deletes, and an on-disk reporter (per-step // stdout/stderr, soft assertions, resource inventory, human-readable report). @@ -308,11 +309,14 @@ export async function createKbWithDocs( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); if (uploadRun.exitCode !== 0) { throw new Error(`doc upload failed (marker ${marker}): ${uploadRun.stderr}`); } - const fileId = uploadRun.stdout.trim(); + const fileId = readUploadedFileIds(uploadRun.stdout)[0]; + if (!fileId) throw new Error(`doc upload returned no file ID (marker ${marker})`); reporter.trackResource("data-center-file", fileId); fileIds.push(fileId); } diff --git a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts index a4dbc74e3..0d12c47ff 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts @@ -1,3 +1,9 @@ +import { + readChunkIds, + readDocumentIds, + readImportedFileIds, + readUploadedFileIds, +} from "./document-response.ts"; import type { RagCreateIndexV2Response, RagGetConnectorResponse, @@ -1071,9 +1077,11 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(uploadRun.exitCode, uploadRun.stderr).toBe(0); - const fileId = uploadRun.stdout.trim(); + const fileId = readUploadedFileIds(uploadRun.stdout)[0]!; const fileListRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", @@ -1227,9 +1235,11 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(uploadRun.exitCode, uploadRun.stderr).toBe(0); - const fileId = uploadRun.stdout.trim(); + const fileId = readUploadedFileIds(uploadRun.stdout)[0]!; // Operating on an existing knowledge base would pollute it, and picking one from // kb list is unreliable — the chunk add → list → update → delete chain needs the @@ -1282,9 +1292,11 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listRun.exitCode, listRun.stderr).toBe(0); - const chunkIds = listRun.stdout.trim().split("\n").filter(Boolean); + const chunkIds = readChunkIds(listRun.stdout); expect(chunkIds.length).toBeGreaterThan(0); const targetChunkId = chunkIds[chunkIds.length - 1]!; @@ -1425,9 +1437,11 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(fullListRun.exitCode, fullListRun.stderr).toBe(0); - const allChunkIds = fullListRun.stdout.trim().split("\n").filter(Boolean); + const allChunkIds = readChunkIds(fullListRun.stdout); expect(allChunkIds.length).toBeGreaterThan(10); // batch delete all chunks — asserts the automatic split into multiple batches @@ -1459,9 +1473,11 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(postDeleteListRun.exitCode, postDeleteListRun.stderr).toBe(0); - expect(postDeleteListRun.stdout.trim()).toBe(""); + expect(readChunkIds(postDeleteListRun.stdout)).toEqual([]); // doc delete verifies document-level delete semantics (only unlinks from this // base, unlike file delete). The --doc-id must be the full doc_id from doc @@ -1521,9 +1537,11 @@ describe.skipIf(!isKbAdminE2EReady())( "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(postDocListRun.exitCode, postDocListRun.stderr).toBe(0); - if (!postDocListRun.stdout.trim().includes(fullDocId)) { + if (!readDocumentIds(postDocListRun.stdout).includes(fullDocId)) { docGone = true; break; } @@ -1630,9 +1648,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: chunk/category/file 参数补全 (li "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(uploadRun.exitCode, uploadRun.stderr).toBe(0); - const fileId = uploadRun.stdout.trim(); + const fileId = readUploadedFileIds(uploadRun.stdout)[0]!; let indexId: string | undefined; try { @@ -2046,9 +2066,11 @@ describe.skipIf(!isTableKbE2EReady())("e2e: chunk --field 表格库 live (常驻 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(docListRun.exitCode, docListRun.stderr).toBe(0); - const tableDocId = docListRun.stdout.trim().split("\n")[0]; + const tableDocId = readDocumentIds(docListRun.stdout)[0]; expect(tableDocId, "表格 fixture 库应至少有 1 个文档").toBeTruthy(); // Server gotcha (verified live): table bases reject field chunks without --doc-id @@ -2181,9 +2203,11 @@ describe.skipIf(!isOssImportE2EReady())("e2e: doc import-oss live (授权 bucket "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(importRun.exitCode, importRun.stderr).toBe(0); - const firstFileId = importRun.stdout.trim(); + const firstFileId = readImportedFileIds(importRun.stdout)[0]!; expect(firstFileId).toMatch(/^file_/); // 2) re-import with --overwrite: the server replaces the file and issues a NEW @@ -2202,9 +2226,11 @@ describe.skipIf(!isOssImportE2EReady())("e2e: doc import-oss live (授权 bucket "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(overwriteRun.exitCode, overwriteRun.stderr).toBe(0); - const overwriteFileId = overwriteRun.stdout.trim(); + const overwriteFileId = readImportedFileIds(overwriteRun.stdout)[0]!; expect(overwriteFileId).toMatch(/^file_/); expect(overwriteFileId).not.toBe(firstFileId); @@ -2343,9 +2369,11 @@ describe.skipIf(!isOssImportE2EReady())("e2e: doc import-oss live (授权 bucket "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(importRun.exitCode, importRun.stderr).toBe(0); - const fileId = importRun.stdout.trim(); + const fileId = readImportedFileIds(importRun.stdout)[0]!; expect(fileId).toMatch(/^file_/); try { diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts index d509975df..dc03e0f24 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts @@ -1,3 +1,4 @@ +import { readUploadedFileIds } from "./document-response.ts"; import type { RagCreateIndexV2Response } from "bailian-cli-core"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -135,9 +136,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc status (live, 自清 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(uploadRunA.exitCode, uploadRunA.stderr).toBe(0); - const fileIdA = uploadRunA.stdout.trim(); + const fileIdA = readUploadedFileIds(uploadRunA.stdout)[0]!; expect(fileIdA).toMatch(/^file_/); let indexId = ""; diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-tag.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-tag.e2e.test.ts index 4e13a421e..5d6fc2b2c 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-tag.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-tag.e2e.test.ts @@ -1,3 +1,4 @@ +import { readUploadedFileIds } from "./document-response.ts"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -217,9 +218,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc tag (live)", () => { "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(uploadRun.exitCode, uploadRun.stderr).toBe(0); - const fileId = uploadRun.stdout.trim(); + const fileId = readUploadedFileIds(uploadRun.stdout)[0]!; const tagRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [ "knowledge", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts index a725f5607..8bebef577 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts @@ -1,3 +1,4 @@ +import { readUploadedFileIds } from "./document-response.ts"; import type { RagIndexListResponse } from "bailian-cli-core"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -306,9 +307,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc upload (live)", () => "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(exitCode, stderr).toBe(0); - const fileIds = stdout.trim().split("\n").filter(Boolean); + const fileIds = readUploadedFileIds(stdout); expect(fileIds).toHaveLength(2); for (const fileId of fileIds) { expect(fileId).toMatch(/^file_/); diff --git a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts index ad787af3b..1ae189867 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts @@ -1,3 +1,4 @@ +import { readUploadedFileIds } from "./document-response.ts"; import type { RagCreateIndexV2Response, RagIndexListResponse } from "bailian-cli-core"; // The live group chains the full lifecycle: upload → create → update → upload --index-id // (import orchestration) → delete → list to verify removal. @@ -96,9 +97,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb 写链路 (live, 自清 "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(uploadRun.exitCode, uploadRun.stderr).toBe(0); - const fileId = uploadRun.stdout.trim(); + const fileId = readUploadedFileIds(uploadRun.stdout)[0]!; expect(fileId).toMatch(/^file_/); fixtureFileIds.push(fileId); diff --git a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts index cc83cfa93..c9836bb50 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts @@ -1,3 +1,4 @@ +import { readServiceMutationField } from "./service-response.ts"; // service group, 7 commands: the static group covers help/missing-args/dry-run per // command; the live group runs the full lifecycle (create → scalar update → get // assertion → search --agent-version beta smoke → deploy → copy → delete×2). @@ -15,6 +16,11 @@ import { import { KNOWLEDGE_SERVICE_ROUTES } from "../topic-routes.ts"; import { pickDifferentAgentModel } from "./verified-models.ts"; +function readServiceIds(stdout: string): Array { + const response = parseStdoutJson<{ data?: { rows?: Array<{ agent_id?: string }> } }>(stdout); + return (response.data?.rows ?? []).map((row) => row.agent_id); +} + interface DryRunBody { endpoint?: string; request?: Record; @@ -561,9 +567,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(createRun.exitCode, createRun.stderr).toBe(0); - const agentId = createRun.stdout.trim().split("\n").pop()!; + const agentId = readServiceMutationField(createRun.stdout, "agent_id"); expect(agentId).toMatch(/^aid-/); // 1.5) list asserts the new agent shows up (filtered by agent_id) @@ -578,9 +586,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listRun.exitCode, listRun.stderr).toBe(0); - expect(listRun.stdout.trim().split("\n")).toContain(agentId); + expect(readServiceIds(listRun.stdout)).toContain(agentId); // 2) scalar update (read-merge-write onto the beta draft) const updateRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ @@ -627,9 +637,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(deployRun.exitCode, deployRun.stderr).toBe(0); - expect(deployRun.stdout.trim().split("\n").pop()).toBe("1"); + expect(readServiceMutationField(deployRun.stdout, "agent_version")).toBe("1"); // 4.5) bare get (no --agent-version) returns all versions: beta draft + published 1 const allVersionsRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ @@ -661,9 +673,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(copyRun.exitCode, copyRun.stderr).toBe(0); - const copiedAgentId = copyRun.stdout.trim().split("\n").pop()!; + const copiedAgentId = readServiceMutationField(copyRun.stdout, "agent_id"); expect(copiedAgentId).toMatch(/^aid-/); expect(copiedAgentId).not.toBe(agentId); @@ -696,16 +710,18 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(postDeleteListRun.exitCode, postDeleteListRun.stderr).toBe(0); - expect(postDeleteListRun.stdout.trim()).toBe(""); + expect(readServiceIds(postDeleteListRun.stdout)).toEqual([]); } }, 300_000); }); // Live parameter coverage: every write parameter gets a read-back assertion // via `service get --output json`. Read-back fields are confirmed present in -// the get response by the existing lifecycle test and printDetail(). +// the get response by the existing lifecycle test and service detail rendering. describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (live, 自清理)", () => { const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; @@ -728,9 +744,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(createRun.exitCode, createRun.stderr).toBe(0); - agentId = createRun.stdout.trim().split("\n").pop()!; + agentId = readServiceMutationField(createRun.stdout, "agent_id"); expect(agentId).toMatch(/^aid-/); // get 读回验证 --description (agent_desc) @@ -765,9 +783,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listDraftRun.exitCode, listDraftRun.stderr).toBe(0); - expect(listDraftRun.stdout.trim().split("\n")).toContain(agentId); + expect(readServiceIds(listDraftRun.stdout)).toContain(agentId); // ── P1: list --scene search (live 合法值) ── const listSearchSceneRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ @@ -779,6 +799,8 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listSearchSceneRun.exitCode, listSearchSceneRun.stderr).toBe(0); @@ -794,9 +816,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listNameRun.exitCode, listNameRun.stderr).toBe(0); - expect(listNameRun.stdout.trim().split("\n")).toContain(agentId); + expect(readServiceIds(listNameRun.stdout)).toContain(agentId); // ── P1: list --index-id (不报错验证; 无 kb fixture) ── const listIndexIdRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ @@ -810,6 +834,8 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listIndexIdRun.exitCode, listIndexIdRun.stderr).toBe(0); @@ -977,6 +1003,8 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(deployRun.exitCode, deployRun.stderr).toBe(0); @@ -993,9 +1021,11 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listDeployedRun.exitCode, listDeployedRun.stderr).toBe(0); - expect(listDeployedRun.stdout.trim().split("\n")).toContain(agentId); + expect(readServiceIds(listDeployedRun.stdout)).toContain(agentId); // ── P0 组4: update --agent-version 1 --version-desc (published version) ── const updatePublishedRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ @@ -1061,12 +1091,14 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 参数全覆盖 (l "--workspace-id", workspaceId, "--quiet", + "--output", + "json", ]); expect(listDeletedRun.exitCode, listDeletedRun.stderr).toBe(0); // 软验证: deleted 状态下 agent 应出现在列表中; // 若服务端不返回 deleted 列表则仅验证不报错 - if (listDeletedRun.stdout.trim()) { - expect(listDeletedRun.stdout.trim().split("\n")).toContain(agentId); + if (readServiceIds(listDeletedRun.stdout).length > 0) { + expect(readServiceIds(listDeletedRun.stdout)).toContain(agentId); } } finally { // 兜底清理 diff --git a/packages/commands/tests/e2e/knowledge/service-response.ts b/packages/commands/tests/e2e/knowledge/service-response.ts new file mode 100644 index 000000000..0f78f7d05 --- /dev/null +++ b/packages/commands/tests/e2e/knowledge/service-response.ts @@ -0,0 +1,17 @@ +import { parseStdoutJson } from "../helpers.ts"; + +/** Read supported service mutation envelopes; quiet never implies a bare scalar. */ +export function readServiceMutationField( + stdout: string, + field: "agent_id" | "agent_version", +): string { + const response = parseStdoutJson<{ data?: Record; [key: string]: unknown }>( + stdout, + ); + const nested = response?.data?.[field]; + const value = typeof nested === "string" ? nested : response?.[field]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing ${field} in service JSON response.`); + } + return value; +} diff --git a/packages/commands/tests/knowledge/fixtures/chat-pty.ts b/packages/commands/tests/knowledge/fixtures/chat-pty.ts index ae22cebdc..2b264afeb 100644 --- a/packages/commands/tests/knowledge/fixtures/chat-pty.ts +++ b/packages/commands/tests/knowledge/fixtures/chat-pty.ts @@ -1,11 +1,19 @@ +import { buildSettings } from "bailian-cli-core"; +import { withCommandOutput } from "../../output/command-context.ts"; import { readFileSync } from "node:fs"; import chat from "../../../src/commands/knowledge/chat.ts"; const response = readFileSync(new URL("./rag-media-chat.sse", import.meta.url), "utf8"); if (!process.stdout.isTTY) throw new Error("PTY fixture requires a real terminal."); -await chat.run({ - flags: { message: ["hello"], agentId: "agent-fixture", workspaceId: "ws-fixture" }, - settings: { output: "text", quiet: process.argv.includes("--quiet") }, - localize: (text: string | { "en-US": string }) => - typeof text === "string" ? text : text["en-US"], - client: { request: async () => new Response(response) }, -} as unknown as Parameters[0]); +await chat.run( + withCommandOutput({ + flags: { message: ["hello"], agentId: "agent-fixture", workspaceId: "ws-fixture" }, + settings: buildSettings({ + flags: { output: "text", quiet: process.argv.includes("--quiet") }, + env: {}, + file: {}, + }), + localize: (text: string | { "en-US": string }) => + typeof text === "string" ? text : text["en-US"], + client: { request: async () => new Response(response) }, + }) as unknown as Parameters[0], +); diff --git a/packages/commands/tests/knowledge/knowledge-chat-output.test.ts b/packages/commands/tests/knowledge/knowledge-chat-output.test.ts index c617168c4..282a21dbc 100644 --- a/packages/commands/tests/knowledge/knowledge-chat-output.test.ts +++ b/packages/commands/tests/knowledge/knowledge-chat-output.test.ts @@ -1,3 +1,5 @@ +import { buildSettings } from "bailian-cli-core"; +import { withCommandOutput } from "../output/command-context.ts"; import { readFileSync } from "node:fs"; import { afterEach, expect, test, vi } from "vite-plus/test"; import chat from "../../src/commands/knowledge/chat.ts"; @@ -8,7 +10,7 @@ afterEach(() => { if (ttyDescriptor) Object.defineProperty(process.stdout, "isTTY", ttyDescriptor); else Reflect.deleteProperty(process.stdout, "isTTY"); }); -async function run(output: string, tty: boolean, quiet = false, verbose = false) { +async function run(output: "json" | "text", tty: boolean, quiet = false, verbose = false) { const stdout: string[] = [], stderr: string[] = []; vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { @@ -20,13 +22,15 @@ async function run(output: string, tty: boolean, quiet = false, verbose = false) return true; }); Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: tty }); - await chat.run({ - flags: { message: ["hello"], agentId: "agent-fixture", workspaceId: "ws-fixture" }, - settings: { output, quiet, verbose }, - localize: (text: string | { "en-US": string }) => - typeof text === "string" ? text : text["en-US"], - client: { request: vi.fn().mockResolvedValue(new Response(fixture)) }, - } as unknown as Parameters[0]); + await chat.run( + withCommandOutput({ + flags: { message: ["hello"], agentId: "agent-fixture", workspaceId: "ws-fixture" }, + settings: buildSettings({ flags: { output, quiet, verbose }, env: {}, file: {} }), + localize: (text: string | { "en-US": string }) => + typeof text === "string" ? text : text["en-US"], + client: { request: vi.fn().mockResolvedValue(new Response(fixture)) }, + }) as unknown as Parameters[0], + ); return { stdout: stdout.join(""), stderr: stderr.join("") }; } test.each([false, true])("JSON retains sources and final answer, tty=%s", async (tty) => { @@ -37,12 +41,23 @@ test.each([false, true])("JSON retains sources and final answer, tty=%s", async usage: { total_tokens: 12 }, }); }); -test.each([false, true])("quiet overrides text/TTY process rendering, tty=%s", async (tty) => { - const result = await run("text", tty, true); - expect(result.stdout.trim()).toBe("最终答案"); -}); -test("JSON + quiet retains bare answer semantics", async () => { - expect((await run("json", true, true)).stdout.trim()).toBe("最终答案"); +test.each([false, true])( + "quiet hides phases but keeps final answer and TTY sources, tty=%s", + async (tty) => { + const result = await run("text", tty, true); + expect(result.stdout.match(/最终答案/g)).toHaveLength(1); + expect(result.stdout).not.toContain("[Planning]"); + if (tty) { + expect(result.stdout).toContain("Sources:"); + expect(result.stdout).toContain("https://example.com/video.mp4"); + } else expect(result.stdout.trim()).toBe("最终答案"); + }, +); +test("JSON + quiet retains the complete response and sources", async () => { + expect(JSON.parse((await run("json", true, true)).stdout)).toMatchObject({ + answer: "最终答案", + docs: [{ doc_id: "file-fixture" }], + }); }); test("non-TTY text emits only final answer", async () => { expect((await run("text", false)).stdout.trim()).toBe("最终答案"); @@ -57,5 +72,10 @@ test("TTY text keeps phases distinct and includes media references", async () => test("verbose adds diagnostics only to stderr", async () => { const result = await run("json", false, false, true); expect(JSON.parse(result.stdout).answer).toBe("最终答案"); - expect(result.stderr).toContain("[event]"); + const diagnostics = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics.every((record) => record.debug?.code === "KNOWLEDGE_CHAT_EVENT")).toBe(true); }); diff --git a/packages/commands/tests/knowledge/knowledge-chat-pty.test.ts b/packages/commands/tests/knowledge/knowledge-chat-pty.test.ts index 3098110f7..bad508253 100644 --- a/packages/commands/tests/knowledge/knowledge-chat-pty.test.ts +++ b/packages/commands/tests/knowledge/knowledge-chat-pty.test.ts @@ -36,8 +36,12 @@ test.skipIf(process.platform === "win32").each([false, true])( env: { ...process.env, NODE_NO_WARNINGS: "1" }, }); const text = result.stdout.replaceAll("\r", ""); - if (quiet) expect(text.trim()).toBe("最终答案"); - else { + if (quiet) { + expect(text.match(/最终答案/g)).toHaveLength(1); + expect(text).not.toContain("[Planning]"); + expect(text).toContain("Sources:"); + expect(text).toContain("https://example.com/video.mp4"); + } else { expect(text).toContain("[Planning]"); expect(text).toContain("[Answer]"); expect(text).toContain("https://example.com/video.mp4"); diff --git a/packages/commands/tests/knowledge/knowledge-doc-import.test.ts b/packages/commands/tests/knowledge/knowledge-doc-import.test.ts index 90a771331..c1c7a5d15 100644 --- a/packages/commands/tests/knowledge/knowledge-doc-import.test.ts +++ b/packages/commands/tests/knowledge/knowledge-doc-import.test.ts @@ -23,7 +23,7 @@ function setup(wait: boolean) { } as unknown as Parameters[0]; return { context, requestJson, output: () => writes.join("") }; } -test("import existing IDs makes only the import request and emits ingestionId", async () => { +test("import existing IDs makes only the import request and emits the full JSON response", async () => { const run = setup(false); await command.run(run.context); expect(run.requestJson).toHaveBeenCalledTimes(1); @@ -31,7 +31,7 @@ test("import existing IDs makes only the import request and emits ingestionId", method: "POST", body: { indexId: "index-test", sourceType: "DATA_CENTER_FILE", docIds: ["file-test"] }, }); - expect(run.output().trim()).toBe("job-test"); + expect(JSON.parse(run.output())).toEqual({ data: { ingestionId: "job-test" }, future: true }); }); test("wait polls the created job without uploading or registering files", async () => { const run = setup(true); @@ -47,7 +47,17 @@ test("wait polls the created job without uploading or registering files", async const statusUrl = new URL(run.requestJson.mock.calls[1]?.[0].path); expect(statusUrl.searchParams.get("job_id")).toBe("job-test"); expect(statusUrl.searchParams.get("index_id")).toBe("index-test"); - expect(run.output().trim()).toBe("job-test"); + expect(JSON.parse(run.output())).toEqual({ + data: { ingestionId: "job-test" }, + future: true, + status: { + data: { + ingestion_status: "COMPLETED", + total_count: 1, + rows: [{ doc_id: "file-test", code: "FINISH" }], + }, + }, + }); }); test("wait error preserves backend message and appends IDs for resuming", async () => { const run = setup(true); diff --git a/packages/commands/tests/knowledge/knowledge-init-command.test.ts b/packages/commands/tests/knowledge/knowledge-init-command.test.ts index 510f2b6f6..7af1f0d82 100644 --- a/packages/commands/tests/knowledge/knowledge-init-command.test.ts +++ b/packages/commands/tests/knowledge/knowledge-init-command.test.ts @@ -56,6 +56,14 @@ function fixture(binName = "kscli") { return { context, stdout, stderr }; } +function runCommand(context: Parameters[0]) { + const output = createOutputReporter({ + settings: context.settings, + localize: context.localize, + }).output; + return command.run({ ...context, output }); +} + test.each(["kscli", "bl"])( "success includes product-correct cleanup only for newly created resources: %s", async (binName) => { @@ -75,7 +83,7 @@ test.each(["kscli", "bl"])( stateFile: "state.json", timeToFirstValueMs: 1, }); - await command.run(context); + await runCommand(context); const output = JSON.parse(stdout.mock.calls.map(([text]) => text).join("")); expect(output.sampleSearch).toEqual(sampleSearch); expect(output.cleanup).toEqual([ @@ -99,16 +107,16 @@ test("failure after creation prints the billable ID and cleanup even in quiet mo options.report({ kind: "knowledge-base", id: "billable-id", created: true }); throw failure; }); - await expect(command.run(context)).rejects.toBe(failure); + await expect(runCommand(context)).rejects.toBe(failure); const warning = JSON.parse( stderr.mock.calls .map(([text]) => text) .join("") .trim(), ); - expect(warning.resource).toMatchObject({ id: "billable-id", created: true }); - expect(warning.cleanup[0].args).toContain("billable-id"); - expect(warning.message).toContain("charges"); + 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(); }); @@ -126,7 +134,7 @@ test("missing product route omits the command instead of inventing one", async ( stateFile: "state.json", timeToFirstValueMs: 1, }); - await command.run(context); + await runCommand(context); const output = JSON.parse(stdout.mock.calls.map(([text]) => text).join("")); expect(output.cleanup).toEqual([]); expect(output.nextSearch).toBeUndefined(); @@ -162,7 +170,7 @@ test.each(["en-US", "zh-CN"] as const)( stateFile: "state.json", timeToFirstValueMs: 1, }); - await command.run(context); + await runCommand(context); const output = stdout.mock.calls.map(([text]) => text).join(""); expect(output).toContain(INIT_SAMPLE.query); expect(output).toContain("sample-document"); @@ -196,7 +204,7 @@ test("text summary selects the matched node, prefers metadata content and bounds stateFile: "state.json", timeToFirstValueMs: 1, }); - await command.run(context); + await runCommand(context); const output = stdout.mock.calls.map(([text]) => text).join(""); expect(output).toContain(`Content: ${content.slice(0, 400)}…`); expect(output).not.toContain(content); diff --git a/packages/commands/tests/knowledge/knowledge-init-resource-records.test.ts b/packages/commands/tests/knowledge/knowledge-init-resource-records.test.ts index ca4d6e032..10ad5f0d2 100644 --- a/packages/commands/tests/knowledge/knowledge-init-resource-records.test.ts +++ b/packages/commands/tests/knowledge/knowledge-init-resource-records.test.ts @@ -5,7 +5,7 @@ test("collects created resources from failure diagnostics without final stdout", expect( initCreatedResources( "", - `progress\n${JSON.stringify({ code: "KNOWLEDGE_INIT_RESOURCE", resource: { kind: "knowledge-base", id: "index", created: true } })}\n{"truncated"`, + `progress\n${JSON.stringify({ warning: { code: "KNOWLEDGE_INIT_RESOURCE", details: { resource: { kind: "knowledge-base", id: "index", created: true } } } })}\n{"truncated"`, ), ).toEqual([{ kind: "knowledge-base", id: "index", created: true }]); }); @@ -17,7 +17,9 @@ test("deduplicates final results and diagnostics, retaining separate resource ty expect( initCreatedResources( JSON.stringify({ resources }), - JSON.stringify({ code: "KNOWLEDGE_INIT_RESOURCE", resource: resources[0] }), + JSON.stringify({ + warning: { code: "KNOWLEDGE_INIT_RESOURCE", details: { resource: resources[0] } }, + }), ), ).toEqual(resources); }); diff --git a/packages/commands/tests/knowledge/knowledge-request-options.test.ts b/packages/commands/tests/knowledge/knowledge-request-options.test.ts index 2b2e2fb1c..1c54a4caa 100644 --- a/packages/commands/tests/knowledge/knowledge-request-options.test.ts +++ b/packages/commands/tests/knowledge/knowledge-request-options.test.ts @@ -1,3 +1,4 @@ +import { createOutputReporter } from "bailian-cli-runtime"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -23,6 +24,8 @@ async function preview(command: AnyCommand, flags: Record) { await command.run({ flags: { workspaceId: "ws_test", ...flags }, settings: { dryRun: true, output: "json" }, + output: createOutputReporter({ settings: { output: "json", quiet: false, verbose: false } }) + .output, localize: (text: string | { "en-US": string }) => typeof text === "string" ? text : text["en-US"], client, diff --git a/packages/commands/tests/knowledge/knowledge-service-config.test.ts b/packages/commands/tests/knowledge/knowledge-service-config.test.ts index a960d0e60..2b94d1211 100644 --- a/packages/commands/tests/knowledge/knowledge-service-config.test.ts +++ b/packages/commands/tests/knowledge/knowledge-service-config.test.ts @@ -1,3 +1,5 @@ +import { createOutputReporter } from "bailian-cli-runtime"; +import type { LocalizedText } from "bailian-cli-core"; import { afterEach, expect, test, vi } from "vite-plus/test"; import update from "../../src/commands/knowledge/service-update.ts"; import get from "../../src/commands/knowledge/service-get.ts"; @@ -32,6 +34,9 @@ test.each(["chat", "search"])( await update.run({ flags: { agentId: "agent-test", temperature: 0.5, workspaceId: "ws_test" }, settings: { output: "json" }, + output: createOutputReporter({ settings: { output: "json", quiet: false, verbose: false } }) + .output, + localize: (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]), client: { requestJson }, } as unknown as Parameters[0]); expect(requestJson.mock.calls[1]![0].body.agent_config).toEqual({ @@ -52,6 +57,9 @@ test("service text surfaces per-index and hybrid rerank config", async () => { await get.run({ flags: { agentId: "agent-test", workspaceId: "ws_test" }, settings: { output: "text" }, + output: createOutputReporter({ settings: { output: "text", quiet: false, verbose: false } }) + .output, + localize: (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]), client: { requestJson }, } as unknown as Parameters[0]); expect(writes.join("")).toContain("qwen3-vl-rerank"); diff --git a/packages/commands/tests/knowledge/knowledge-service-response.test.ts b/packages/commands/tests/knowledge/knowledge-service-response.test.ts new file mode 100644 index 000000000..79be8f69b --- /dev/null +++ b/packages/commands/tests/knowledge/knowledge-service-response.test.ts @@ -0,0 +1,90 @@ +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { + buildSettings, + Client, + httpTransport, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import * as commands from "bailian-cli-commands"; +import { readServiceMutationField } from "../e2e/knowledge/service-response.ts"; + +afterEach(() => vi.restoreAllMocks()); +for (const nested of [false, true]) { + test.each(["agent_id", "agent_version"] as const)( + `Journey consumes %s from real quiet JSON and forwards it (nested=${nested})`, + async (field) => { + const data = { agent_id: "aid-one", agent_version: "2", agent_status: "draft" }; + const response = nested + ? { request_id: "service-request", data } + : { request_id: "service-request", ...data }; + const requests: unknown[] = []; + vi.spyOn(httpTransport, "fetch").mockImplementation(async (_input, init) => { + requests.push(JSON.parse(init?.body as string)); + return Response.json(response); + }); + const identity = { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }; + const settings = buildSettings({ + flags: { output: "json", quiet: true }, + file: { workspace_id: "ws-test" }, + env: {}, + configPath: "/tmp/service-journey/config.json", + }); + const client = new Client({ + identity, + settings, + baseUrl: "https://fixture.invalid", + apiCred: { token: "fixture-api-key", source: "flag", baseUrl: "https://fixture.invalid" }, + }); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]); + async function run(command: AnyCommand, flags: Record): Promise { + let stdout = ""; + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { write: () => {} }, + }); + await command.run({ + identity, + settings, + client, + output: reporter.output, + localize, + flags, + } as never); + return stdout; + } + const stdout = + field === "agent_id" + ? await run(commands.knowledgeServiceCreate, { name: "Demo", scene: "search" }) + : await run(commands.knowledgeServiceDeploy, { agentId: "aid-one" }); + const value = readServiceMutationField(stdout, field); + expect(value).toBe(field === "agent_id" ? "aid-one" : "2"); + if (field === "agent_id") { + await run(commands.knowledgeServiceDelete, { agentId: value }); + expect(requests[1]).toEqual({ agent_id: "aid-one" }); + } else { + await run(commands.knowledgeServiceGet, { agentId: "aid-one", agentVersion: value }); + expect(requests[1]).toEqual({ agent_id: "aid-one", agent_version: "2" }); + } + }, + ); +} +test.each(["aid-one\n", '"aid-one"', '{"data":{}}', '{"data":{"agent_id":7}}'])( + "Journey rejects missing/non-JSON service identity: %s", + (stdout) => { + expect(() => readServiceMutationField(stdout, "agent_id")).toThrow(); + }, +); + +test("Journey reads the top-level string when the nested field is not a string", () => { + expect(readServiceMutationField('{"data":{"agent_id":7},"agent_id":"aid-one"}', "agent_id")).toBe( + "aid-one", + ); +}); diff --git a/packages/commands/tests/output/fixtures/knowledge-workflow-text.json b/packages/commands/tests/output/fixtures/knowledge-workflow-text.json new file mode 100644 index 000000000..b2f162666 --- /dev/null +++ b/packages/commands/tests/output/fixtures/knowledge-workflow-text.json @@ -0,0 +1,5 @@ +{ + "search": "[1] (score: 0.2500)\nBody\n\n", + "init": "Initialization succeeded: the sample was retrieved.\nindexId: index-one\nagentId: service-one\nstateFile: \nSample search\nQuery: What is retrieval-augmented generation (RAG)? 什么是检索增强生成?\nDocument: fixture.md\nContent: BAILIAN_CLI_INIT_SAMPLE_V1 RAG means retrieval-augmented generation.\nbl knowledge search --agent-id service-one --agent-version beta --workspace-id ws-test --query 'What is RAG?'\nKnowledge bases accrue running-time charges from creation, even without queries. The one-time 720-hour allowance is shared across Standard Edition knowledge bases and expires 30 days after service activation for new users. Model calls are billed separately. Your remaining allowance has not been verified. Delete unused knowledge bases to stop their running-time charges.\nCleanup commands for resources created in this run (confirm deletion separately):\nbl knowledge delete --index-id index-one --workspace-id ws-test\nbl knowledge file delete --file-id file-one --workspace-id ws-test\n", + "sync": "Synchronization completed.\n{\"add\":0,\"skip\":0,\"replace\":0,\"retain\":0,\"delete\":0,\"recover\":0,\"conflict\":0}\nstateFile: \n" +} diff --git a/packages/commands/tests/output/knowledge-doc-chunk-output.test.ts b/packages/commands/tests/output/knowledge-doc-chunk-output.test.ts new file mode 100644 index 000000000..b99776904 --- /dev/null +++ b/packages/commands/tests/output/knowledge-doc-chunk-output.test.ts @@ -0,0 +1,719 @@ +import { + readUploadedFileIds, + readImportedFileIds, + readDocumentIds, + readChunkIds, + readIngestionStatus, +} from "../e2e/knowledge/document-response.ts"; +import { stripVTControlCharacters } from "node:util"; +import { mkdtemp, mkdir, rm, writeFile, open } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + BailianError, + ExitCode, + buildSettings, + Client, + httpTransport, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import * as commands from "bailian-cli-commands"; + +const identity = { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }; +const extra = { future: { retained: [false, 0, null, []] } }; +const docs = { + request_id: "request-docs", + ...extra, + data: { + total_count: 0, + rows: [ + { + doc_id: "doc-one", + status: "FAILED", + doc_name: "a.md", + doc_type: "md", + size: 0, + chunkSize: 0, + overlapSize: 0, + separator: null, + chunkMode: "length", + enableHeaders: false, + }, + ], + }, +}; +const status = { + request_id: "request-status", + ...extra, + data: { + ingestion_status: "COMPLETED", + total_count: 1, + rows: [{ doc_id: "doc-one", code: "SUCCESS", doc_name: "a.md" }], + }, +}; +const deleted = { request_id: "request-delete", ...extra, data: { deleted: ["doc-one"] } }; +const mutation = { request_id: "request-mutation", ...extra, data: { success: true } }; +const imported = { request_id: "request-import", ...extra, data: { ingestionId: "job-one" } }; +const oss = { + request_id: "request-oss", + ...extra, + data: { + addFileResultList: [ + { fileId: "file-one", status: "SUCCESS", ossKey: "a.pdf" }, + { status: "FAILED", ossKey: "broken.pdf", message: "original row failure" }, + ], + }, +}; +const chunks = { + request_id: "request-chunks", + ...extra, + data: { + total: 0, + nodes: [ + { + text: "Fallback", + metadata: { + _id: "chunk-one", + doc_id: "doc-one", + doc_name: "a.md", + _chunk_status_message: "READY", + is_displayed_chunk_content: false, + content: "Body", + clip_start_time: 0, + clip_end_time: 1000, + clip_description: "Caption", + video_url: "https://media.invalid/one", + audio_segments: [{ speaker: 0, text: "Audio", retained: false }], + }, + }, + ], + }, +}; +let directory: string; +let requests: Array<{ path: string; method: string; body?: unknown; authorization: string | null }>; +let puts: string[]; +let replies: Map; +let failPath: string | undefined; +let failAt: number; +let timeout: boolean; +let leaseCount: number; +let registerCount: number; +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "knowledge-doc-chunk-")); + await writeFile(join(directory, "a.md"), "hello"); + await writeFile(join(directory, "b.txt"), "world"); + await mkdir(join(directory, "scan")); + await writeFile(join(directory, "scan", "a.md"), "hello"); + await writeFile(join(directory, "scan", "skip.bin"), "skip"); + requests = []; + puts = []; + leaseCount = 0; + registerCount = 0; + failPath = undefined; + failAt = 1; + timeout = false; + replies = new Map([ + ["/api/v1/indices/rag/index/files", docs], + ["/api/v1/indices/rag/list/index/file/details", docs], + ["/api/v1/indices/rag/index_job/status", status], + ["/api/v1/indices/rag/index/delete_file", deleted], + ["/api/v1/indices/rag/index/job/create", imported], + ["/api/v1/connector/dash/batchUpdateFileTag", mutation], + ["/api/v1/connector/dash/addFilesFromAuthorizedOss", oss], + ["/api/v1/indices/rag/index/chunklist", chunks], + ["/api/v1/indices/rag/index/chunk/create", mutation], + ["/api/v1/indices/rag/index/chunk/update", mutation], + ["/api/v1/indices/rag/index/chunk/delete", mutation], + ]); + vi.spyOn(httpTransport, "fetch").mockImplementation(async (input, init) => { + const url = new URL( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + const authorization = new Headers(init?.headers).get("authorization"); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + requests.push({ + path: url.pathname + url.search, + method: init?.method ?? "GET", + body, + authorization, + }); + if ( + url.pathname === failPath && + requests.filter((request) => request.path.split("?")[0] === failPath).length === failAt + ) { + if (timeout) throw new BailianError("original deadline", ExitCode.TIMEOUT); + return Response.json( + { code: "Rejected", message: "original service error", request_id: "request-error" }, + { status: 403 }, + ); + } + if (url.hostname === "oss.fixture.invalid") { + const content: Buffer[] = []; + for await (const chunk of init!.body as unknown as AsyncIterable) + content.push(Buffer.from(chunk)); + puts.push(Buffer.concat(content).toString()); + return new Response(""); + } + if (url.pathname.endsWith("/applyFileUploadLease")) { + leaseCount++; + return Response.json({ + data: { + leaseId: `lease-${leaseCount}`, + param: { + url: `https://oss.fixture.invalid/upload/${leaseCount}`, + method: "PUT", + headers: { "x-fixture": "signed" }, + }, + }, + }); + } + if (url.pathname.endsWith("/addFile")) { + registerCount++; + return Response.json({ data: { fileId: registerCount === 1 ? "file-one" : "file-two" } }); + } + if (!replies.has(url.pathname)) throw new Error(`Unexpected request: ${url.pathname}`); + return Response.json(replies.get(url.pathname)); + }); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); +interface Scenario { + name: string; + command: AnyCommand; + flags: () => Record; + want: () => unknown; + text: () => string; + chinese: string; +} +const cases: Scenario[] = [ + { + name: "doc list", + command: commands.knowledgeDocList, + flags: () => ({ indexId: "index-one" }), + want: () => docs, + text: () => "doc-one FAILED a.md md 0\ntotal: 0\n", + chinese: "总数", + }, + { + name: "doc status", + command: commands.knowledgeDocStatus, + flags: () => ({ indexId: "index-one", jobId: "job-one" }), + want: () => status, + text: () => "status: COMPLETED\n doc-one SUCCESS a.md\n", + chinese: "状态", + }, + { + name: "doc upload", + command: commands.knowledgeDocUpload, + flags: () => ({ file: [join(directory, "a.md")] }), + want: () => ({ files: [{ path: join(directory, "a.md"), fileId: "file-one" }], skipped: [] }), + text: () => "a.md file-one registered\n\nUploaded 1 file.\n", + chinese: "上传", + }, + { + name: "doc delete", + command: commands.knowledgeDocDelete, + flags: () => ({ indexId: "index-one", docId: ["doc-one"] }), + want: () => deleted, + text: () => "deleted: 1 document(s)\n doc-one\n", + chinese: "删除", + }, + { + name: "doc tag", + command: commands.knowledgeDocTag, + flags: () => ({ docId: ["file-one"], tag: ["team"] }), + want: () => mutation, + text: () => "tagged: 1 file(s) with [team]\n", + chinese: "标签", + }, + { + name: "doc import", + command: commands.knowledgeDocImport, + flags: () => ({ + indexId: "index-one", + docId: ["file-one"], + overlapSize: 0, + enableHeaders: false, + }), + want: () => imported, + text: () => "ingestionId: job-one\n", + chinese: "导入任务", + }, + { + name: "doc import-oss", + command: commands.knowledgeDocImportOss, + flags: () => ({ + bucket: "fixture-bucket", + region: "cn-beijing", + ossKey: ["a.pdf", "broken.pdf"], + }), + want: () => oss, + text: () => "imported: 1 file(s)\n file-one SUCCESS a.pdf\n - FAILED broken.pdf\n", + chinese: "导入", + }, + { + name: "chunk add", + command: commands.knowledgeChunkAdd, + flags: () => ({ indexId: "index-one", docId: "doc-one", content: "Chunk body text" }), + want: () => mutation, + text: () => "chunk created (pipeline: index-one)\nList chunks to find the new chunk id.\n", + chinese: "创建", + }, + { + name: "chunk list", + command: commands.knowledgeChunkList, + flags: () => ({ indexId: "index-one", docId: "doc-one" }), + want: () => chunks, + text: () => + '[chunk] chunk-one (doc: a.md, doc_id: doc-one) status: READY [excluded from retrieval]\n Body\n clip: 00:00.000–00:01.000\n clip_description: Caption\n video_url: https://media.invalid/one\n audio_segment: {"speaker":0,"text":"Audio","retained":false}\ntotal: 0\n', + chinese: "总数", + }, + { + name: "chunk update", + command: commands.knowledgeChunkUpdate, + flags: () => ({ indexId: "index-one", chunkId: "chunk-one", title: "", exclude: true }), + want: () => mutation, + text: () => "updated: chunk-one\n", + chinese: "更新", + }, + { + name: "chunk delete", + command: commands.knowledgeChunkDelete, + flags: () => ({ indexId: "index-one", chunkId: ["chunk-one"] }), + want: () => ({ deleted_count: 1, batches: 1 }), + text: () => "deleted: 1 chunk(s) in 1 batch(es)\n", + chinese: "删除", + }, +]; +interface Display { + format: "json" | "text"; + quiet?: boolean; + verbose?: boolean; + dryRun?: boolean; + language?: "en-US" | "zh-CN"; +} +async function invoke(scenario: Scenario, display: Display, flags = scenario.flags()) { + let stdout = "", + stderr = ""; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + file: { language: display.language ?? "en-US", workspace_id: "ws-test" }, + env: {}, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => + typeof text === "string" ? text : text[display.language ?? "en-US"]; + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + const client = new Client({ + identity, + settings, + baseUrl: "https://fixture.invalid", + apiCred: { token: "fixture-api-key", source: "flag", baseUrl: "https://fixture.invalid" }, + debug: (diagnostic) => reporter.output.debug(diagnostic), + }); + let exitCode = 0; + try { + try { + await scenario.command.run({ + identity, + settings, + client, + localize, + output: reporter.output, + flags, + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, exitCode }; + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + } +} +const modes = ["json", "text"].flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ format: format as "json" | "text", quiet, verbose })), + ), +); +function diagnostics(stderr: string) { + return stderr.trim() + ? stderr + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)) + : []; +} +for (const scenario of cases) { + test.each(modes)(`${scenario.name} preserves complete results: %j`, async (display) => { + const result = await invoke(scenario, display); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") { + expect(JSON.parse(result.stdout)).toEqual(scenario.want()); + expect(diagnostics(result.stderr).every((record) => record.debug !== undefined)).toBe(true); + } else expect(result.stdout).toBe(scenario.text()); + expect(result.stdout + result.stderr).not.toContain("fixture-api-key"); + if (scenario.command === commands.knowledgeChunkUpdate) { + expect(requests.map((request) => request.path)).toEqual([ + "/api/v1/indices/rag/index/chunklist", + "/api/v1/indices/rag/index/chunk/update", + ]); + expect(requests.at(-1)!.body).toEqual({ + pipelineId: "index-one", + chunkId: "chunk-one", + content: "Body", + isDisplayedChunkContent: false, + title: "", + }); + } + if (scenario.command === commands.knowledgeDocUpload) { + expect(puts).toEqual(["hello"]); + expect(requests.map((request) => [request.method, request.path])).toEqual([ + ["POST", "/api/v1/connector/dash/applyFileUploadLease"], + ["PUT", "/upload/1"], + ["POST", "/api/v1/connector/dash/addFile"], + ]); + expect(requests[0]!.body).toMatchObject({ + category: "default", + fileName: "a.md", + sizeBytes: "5", + contentMd5: "XUFAKrxLKna5cZ2REBfFkg==", + }); + expect(requests[1]!.authorization).toBeNull(); + } + }); + test(`${scenario.name} Chinese text and dry-run JSON preserve the contract`, async () => { + const text = await invoke(scenario, { format: "text", quiet: true, language: "zh-CN" }); + expect(text.exitCode, text.stderr).toBe(0); + expect(text.stdout).toContain(scenario.chinese); + requests = []; + const preview = await invoke(scenario, { format: "json", quiet: true, dryRun: true }); + expect(preview.exitCode, preview.stderr).toBe(0); + const data = JSON.parse(preview.stdout); + expect(data.endpoint ?? data.steps?.[0]?.endpoint).toContain( + "https://ws-test.cn-beijing.maas.aliyuncs.com/", + ); + expect(requests).toEqual([]); + }); +} +test.each(modes)("detailed document page preserves false/0/null settings: %j", async (display) => { + const result = await invoke(cases[0]!, display, { + indexId: "index-one", + details: true, + pageNumber: 2, + pageSize: 3, + }); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(docs); + else + expect(result.stdout).toBe( + 'doc-one FAILED a.md md 0\n chunkSize: 0\n overlapSize: 0\n separator: -\n chunkMode: "length"\n enableHeaders: false\ntotal: 0\n', + ); + expect(requests[0]).toMatchObject({ + method: "POST", + path: "/api/v1/indices/rag/list/index/file/details", + body: { indexId: "index-one", pageNumber: 2, pageSize: 3 }, + }); +}); +test.each(modes)("empty document/chunk pages retain metadata and text: %j", async (display) => { + for (const [index, path, response, text] of [ + [ + 0, + "/api/v1/indices/rag/index/files", + { request_id: "empty-doc", data: { rows: [], total_count: 0 } }, + "No documents found.\ntotal: 0\n", + ], + [ + 8, + "/api/v1/indices/rag/index/chunklist", + { request_id: "empty-chunk", data: { nodes: [], total: 0 } }, + "No chunks found.\ntotal: 0\n", + ], + ] as const) { + replies.set(path, response); + const result = await invoke(cases[index]!, display); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(response); + else expect(result.stdout).toBe(text); + } +}); +test.each(["en-US", "zh-CN"] as const)( + "upload size warning is JSONL under JSON quiet dry-run (%s)", + async (language) => { + const file = join(directory, "large.csv"); + const handle = await open(file, "w"); + try { + await handle.truncate(10 * 1024 * 1024 + 1); + } finally { + await handle.close(); + } + const result = await invoke( + cases[2]!, + { format: "json", quiet: true, dryRun: true, language }, + { file: [file] }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).steps).toHaveLength(3); + expect(diagnostics(result.stderr)[0].warning).toMatchObject({ + code: "KNOWLEDGE_UPLOAD_SIZE_WARNING", + details: { file, size_bytes: 10 * 1024 * 1024 + 1 }, + }); + if (language === "zh-CN") expect(result.stderr).toContain("建议"); + expect(requests).toEqual([]); + }, +); +test.each([false, true])( + "partial document deletion retains raw result and warns with quiet=%s", + async (quiet) => { + const result = await invoke( + cases[3]!, + { format: "json", quiet }, + { indexId: "index-one", docId: ["doc-one", "doc-two"] }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(deleted); + expect(diagnostics(result.stderr)[0].warning).toMatchObject({ + code: "KNOWLEDGE_DOC_DELETE_PARTIAL", + details: { requested_count: 2, deleted_count: 1 }, + }); + }, +); +test.each([false, true])("text warnings preserve existing lines with quiet=%s", async (quiet) => { + const deletion = await invoke( + cases[3]!, + { format: "text", quiet }, + { indexId: "index-one", docId: ["doc-one", "doc-two"] }, + ); + expect(deletion.exitCode, deletion.stderr).toBe(0); + expect(deletion.stderr).toBe("Warning: requested 2 deletion(s) but the server reported 1.\n"); + const file = join(directory, "large.csv"); + const handle = await open(file, "w"); + try { + await handle.truncate(10 * 1024 * 1024 + 1); + } finally { + await handle.close(); + } + const upload = await invoke(cases[2]!, { format: "text", quiet, dryRun: true }, { file: [file] }); + expect(upload.exitCode, upload.stderr).toBe(0); + expect(upload.stderr).toBe( + "Warning: large.csv exceeds the recommended 10 MB for .csv; the server may reject or truncate it.\n", + ); +}); +test.each(modes)( + "upload directory keeps skipped-file and verbose result layout: %j", + async (display) => { + const result = await invoke(cases[2]!, display, { file: [join(directory, "scan")] }); + expect(result.exitCode, result.stderr).toBe(0); + const skipped = join(directory, "scan", "skip.bin"); + if (display.format === "json") + expect(JSON.parse(result.stdout)).toEqual({ + files: [{ path: join(directory, "scan", "a.md"), fileId: "file-one" }], + skipped: [skipped], + }); + else + expect(result.stdout).toBe( + "a.md file-one registered\n\nUploaded 1 file, skipped 1 unsupported.\n" + + (display.verbose ? "Skipped files:\n skip.bin\n" : ""), + ); + }, +); +test.each([1, 2, 5])( + "wait command %s preserves the job response without JSON progress", + async (index) => { + const flags = + index === 2 + ? { file: [join(directory, "a.md")], indexId: "index-one", wait: true } + : { ...cases[index]!.flags(), wait: true }; + const result = await invoke(cases[index]!, { format: "json", quiet: true }, flags); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + const response = JSON.parse(result.stdout); + if (index === 1) expect(response).toEqual(status); + else if (index === 2) + expect(response).toEqual({ + files: [{ path: join(directory, "a.md"), fileId: "file-one" }], + skipped: [], + index_id: "index-one", + ingestion_id: "job-one", + final_status: "COMPLETED", + }); + else expect(response).toEqual({ ...imported, status }); + expect( + requests.filter((request) => request.path.startsWith("/api/v1/indices/rag/index_job/status")), + ).toHaveLength(1); + }, +); +test("doc status keeps failure-before-result behavior and raw server text", async () => { + replies.set("/api/v1/indices/rag/index_job/status", { + data: { + ingestion_status: "COMPLETED", + rows: [{ doc_id: "doc-one", code: "PARSE_FAILED", message: "original parse failure" }], + }, + }); + const result = await invoke(cases[1]!, { format: "json", quiet: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(diagnostics(result.stderr)[0].error.message).toContain("original parse failure"); +}); +test.each([2, 5])( + "failed import wait %s keeps recovery IDs and timeout code without retry", + async (index) => { + failPath = "/api/v1/indices/rag/index_job/status"; + timeout = true; + const flags = + index === 2 + ? { file: [join(directory, "a.md")], indexId: "index-one", wait: true } + : { ...cases[index]!.flags(), wait: true }; + const result = await invoke(cases[index]!, { format: "json", quiet: true }, flags); + expect(result.exitCode).toBe(5); + expect(result.stdout).toBe(""); + const error = diagnostics(result.stderr).at(-1).error; + expect(error.hint).toContain("job-one"); + if (index === 2) expect(error.hint).toContain("file-one"); + expect(requests.filter((request) => request.path.startsWith(failPath!))).toHaveLength(1); + }, +); +test("later upload failure retains registered file ID and does not import", async () => { + failPath = "/api/v1/connector/dash/applyFileUploadLease"; + failAt = 2; + const result = await invoke( + cases[2]!, + { format: "json", quiet: true }, + { file: [join(directory, "a.md"), join(directory, "b.txt")], indexId: "index-one" }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(diagnostics(result.stderr)[0].error.hint).toContain("file-one"); + expect(puts).toEqual(["hello"]); + expect(requests.some((request) => request.path.includes("/index/job/create"))).toBe(false); +}); +test("chunk deletion batches preserve order and uncertain failure details", async () => { + failPath = "/api/v1/indices/rag/index/chunk/delete"; + failAt = 3; + const ids = Array.from({ length: 35 }, (_, position) => `chunk-${position}`); + const result = await invoke( + cases[10]!, + { format: "json", quiet: true }, + { indexId: "index-one", chunkId: ids }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(requests.map((request) => (request.body as { chunkIds: string[] }).chunkIds)).toEqual([ + ids.slice(0, 10), + ids.slice(10, 20), + ids.slice(20, 30), + ]); + const error = diagnostics(result.stderr)[0].error; + expect(error).toMatchObject({ + message: "original service error", + request_id: "request-error", + http_status: 403, + }); + expect(error.hint).toContain("20 chunk(s) in 2 completed batch(es)"); + expect(error.hint).toContain("Batch 3 failed; its deletion outcome is unconfirmed."); +}); + +test("TTY document highlighting/truncation keeps reset before LF and JSON uncolored", async () => { + const terminal = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + const columns = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + const previousNoColor = process.env.NO_COLOR; + try { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + Object.defineProperty(process.stdout, "columns", { configurable: true, value: 24 }); + const text = await invoke(cases[0]!, { format: "text", quiet: true }); + expect(text.exitCode, text.stderr).toBe(0); + expect(text.stdout).toContain("\x1b[31m"); + expect(text.stdout).toContain("\x1b[0m\n"); + expect(stripVTControlCharacters(text.stdout).split("\n")[0]).toHaveLength(24); + const json = await invoke(cases[0]!, { format: "json", quiet: true }); + expect(JSON.parse(json.stdout)).toEqual(docs); + expect(json.stdout).not.toContain("\x1b"); + } finally { + if (terminal) Object.defineProperty(process.stdout, "isTTY", terminal); + else delete (process.stdout as { isTTY?: boolean }).isTTY; + if (columns) Object.defineProperty(process.stdout, "columns", columns); + else delete (process.stdout as { columns?: number }).columns; + if (previousNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = previousNoColor; + } +}); + +const consumers = [ + { index: 2, reader: readUploadedFileIds, want: ["file-one"], action: "import" }, + { index: 6, reader: readImportedFileIds, want: ["file-one"], action: "import" }, + { index: 0, reader: readDocumentIds, want: ["doc-one"], action: "delete-doc" }, + { index: 8, reader: readChunkIds, want: ["chunk-one"], action: "delete-chunk" }, + { index: 1, reader: readIngestionStatus, want: "COMPLETED", action: "status" }, +]; +test.each(consumers)( + "test/Journey consumer $index reads full quiet JSON for subsequent requests", + async ({ index, reader, want, action }) => { + const result = await invoke(cases[index]!, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + const value = reader(result.stdout); + expect(value).toEqual(want); + if (action === "status") return; + expect(Array.isArray(value)).toBe(true); + if (action === "import") { + const next = await invoke( + cases[5]!, + { format: "json", quiet: true }, + { indexId: "index-one", docId: value }, + ); + expect(next.exitCode, next.stderr).toBe(0); + expect(requests.at(-1)!.body).toEqual({ + indexId: "index-one", + sourceType: "DATA_CENTER_FILE", + docIds: ["file-one"], + }); + } else if (action === "delete-doc") { + const next = await invoke( + cases[3]!, + { format: "json", quiet: true }, + { indexId: "index-one", docId: value }, + ); + expect(next.exitCode, next.stderr).toBe(0); + expect(requests.at(-1)!.body).toEqual({ index_id: "index-one", doc_ids: ["doc-one"] }); + } else { + const next = await invoke( + cases[10]!, + { format: "json", quiet: true }, + { indexId: "index-one", chunkId: value }, + ); + expect(next.exitCode, next.stderr).toBe(0); + expect(requests.at(-1)!.body).toEqual({ pipelineId: "index-one", chunkIds: ["chunk-one"] }); + } + }, +); diff --git a/packages/commands/tests/output/knowledge-workflow-output.test.ts b/packages/commands/tests/output/knowledge-workflow-output.test.ts new file mode 100644 index 000000000..034fcf673 --- /dev/null +++ b/packages/commands/tests/output/knowledge-workflow-output.test.ts @@ -0,0 +1,464 @@ +import { INIT_SAMPLE } from "../../src/commands/knowledge/init-sample.ts"; +import baseline from "./fixtures/knowledge-workflow-text.json" with { type: "json" }; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + BailianError, + ExitCode, + buildSettings, + Client, + httpTransport, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter, CommandRegistry } from "bailian-cli-runtime"; +import * as commands from "../../src/index.ts"; +import * as workflow from "../../src/commands/knowledge/init-workflow.ts"; +import * as execution from "../../src/commands/knowledge/sync/execute.ts"; +import * as recovery from "../../src/commands/knowledge/sync/recovery-run.ts"; +import { planSync } from "../../src/commands/knowledge/sync/plan.ts"; +import { initCreatedResources } from "../e2e/knowledge/journeys/init-resource-records.ts"; +const identity = { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }; +const retained = { future: { values: [false, 0, null, []] } }; +const response = { + request_id: "search-request", + ...retained, + data: { + total: 1, + cost_time: 0, + nodes: [ + { + score: 0.25, + text: "Body", + metadata: { doc_name: "fixture", content: "Body", ...retained }, + }, + ], + }, +}; +const resources = [ + { kind: "file" as const, id: "file-one", created: true }, + { kind: "knowledge-base" as const, id: "index-one", created: true }, + { kind: "service" as const, id: "service-one", created: false }, +]; +let directory: string, stateFile: string; +let failInit: boolean, failSync: boolean, failSearch: boolean, blocked: boolean, replan: boolean; +let requests: Array<{ path: string; body: unknown }>; +let searchResponse: unknown; +let initResult: Awaited>; +let checkpoint: Record; +const original = new BailianError("original failure", ExitCode.NETWORK, "original hint"); +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "knowledge-workflow-output-")); + stateFile = join(directory, "state.json"); + failInit = false; + failSync = false; + failSearch = false; + blocked = false; + replan = false; + requests = []; + searchResponse = response; + checkpoint = { + schemaVersion: 1, + syncId: "12345678-1234-4234-8234-123456789012", + target: { + endpointOrigin: "https://fixture.invalid", + workspaceId: "ws-test", + indexId: "index-one", + categoryId: "category-one", + }, + entries: { + "a.md": { + relativePath: "a.md", + contentSha256: "a".repeat(64), + contentMd5: "b".repeat(32), + size: 0, + mtimeMs: 0, + fileId: "file-one", + docId: "doc-one", + }, + }, + pending: [ + { + operationId: "op-one", + relativePath: "a.md", + action: "replace", + phase: "submitted", + fileId: "file-new", + ingestionId: "job-one", + oldDocId: "doc-one", + newDocId: "doc-new", + }, + ], + }; + await writeFile(stateFile, JSON.stringify(checkpoint)); + initResult = { + indexId: "index-one", + agentId: "service-one", + fileId: "file-one", + agentVersion: "beta", + sampleMatched: true, + sampleSearch: { + query: INIT_SAMPLE.query, + response: { + code: "Success", + status_code: 200, + request_id: "sample-request", + data: { + total: 1, + cost_time: 0, + nodes: [ + { + score: 0.9, + text: "BAILIAN_CLI_INIT_SAMPLE_V1 RAG means retrieval-augmented generation.", + metadata: { doc_name: "fixture.md" }, + }, + ], + }, + }, + }, + resources, + stateFile, + timeToFirstValueMs: 7, + }; + vi.spyOn(workflow, "runKnowledgeInit").mockImplementation(async (options) => { + resources.forEach(options.report); + if (failInit) throw original; + return initResult; + }); + vi.spyOn(execution, "executeSyncPlan").mockImplementation(async () => { + if (failSync) throw original; + return { state: checkpoint, completed: [] } as never; + }); + vi.spyOn(recovery, "runSyncRecovery").mockImplementation( + async () => ({ state: checkpoint, completed: [], requiresReplan: true }) as never, + ); + vi.spyOn(httpTransport, "fetch").mockImplementation(async (input, options) => { + const url = new URL( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + requests.push({ + path: url.pathname, + body: typeof options?.body === "string" ? JSON.parse(options.body) : undefined, + }); + return failSearch + ? Response.json( + { message: "raw search failure", request_id: "request-failed" }, + { status: 400 }, + ) + : Response.json(searchResponse); + }); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); +interface Display { + format: "json" | "text"; + quiet?: boolean; + verbose?: boolean; + language?: "en-US" | "zh-CN"; + binName?: "bl" | "kscli"; +} +async function invoke(name: "search" | "init" | "sync", display: Display) { + let stdout = "", + stderr = "", + strayOut = "", + strayErr = ""; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + strayOut += String(chunk); + return true; + }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + strayErr += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { output: display.format, quiet: display.quiet, verbose: display.verbose }, + file: { language: display.language ?? "en-US", workspace_id: "ws-test" }, + env: {}, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => + typeof text === "string" ? text : text[display.language ?? "en-US"]; + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + const client = new Client({ + identity, + settings, + baseUrl: "https://fixture.invalid", + apiCred: { token: "fixture-api-token", source: "flag", baseUrl: "https://fixture.invalid" }, + debug: (record) => reporter.output.debug(record), + }); + const binName = display.binName ?? "bl"; + const registry = new CommandRegistry( + binName === "bl" + ? { + "knowledge delete": commands.knowledgeKbDelete, + "knowledge service delete": commands.knowledgeServiceDelete, + "knowledge file delete": commands.knowledgeFileDelete, + "knowledge search": commands.knowledgeSearch, + } + : { + "kb delete": commands.knowledgeKbDelete, + "service delete": commands.knowledgeServiceDelete, + "file delete": commands.knowledgeFileDelete, + search: commands.knowledgeSearch, + }, + binName, + ); + const plan = planSync({ local: [], managed: [], deleteEnabled: false }); + plan.blocked = blocked; + plan.requiresReplan = replan; + const prepared = + name === "init" + ? { stateFile, state: { target: { workspaceId: "ws-test" } } } + : { + stateFile, + stateRevision: null, + state: checkpoint, + target: checkpoint.target, + scan: { files: [] }, + remote: {}, + plan, + orphans: [], + notices: [], + }; + const command = + name === "search" + ? commands.knowledgeSearch + : name === "init" + ? commands.knowledgeInit + : commands.knowledgeDocSync; + const flags = + name === "search" + ? { + workspaceId: "ws-test", + agentId: "service-one", + query: "Question", + image: ["https://media.invalid/image"], + agentVersion: "beta", + } + : { workspaceId: "ws-test", indexId: "index-one", dir: directory }; + let exitCode = 0; + try { + try { + await command.run({ + identity: { ...identity, binName }, + settings, + flags, + prepared, + client, + localize, + output: reporter.output, + commandPath: registry.commandPath.bind(registry), + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, strayOut, strayErr, exitCode }; + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + } +} +const diagnostics = (stderr: string) => + stderr.trim() + ? stderr + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)) + : []; +const modes = ["json", "text"].flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ format: format as "text" | "json", quiet, verbose })), + ), +); +for (const name of ["search", "init", "sync"] as const) { + test.each(modes)( + `${name} uses complete scoped results and parseable diagnostics: %j`, + async (display) => { + const result = await invoke(name, display); + expect(result.exitCode, result.stderr + result.strayErr).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + if (display.format === "json") { + const data = JSON.parse(result.stdout); + if (name === "search") expect(data).toEqual(response); + else if (name === "init") expect(data).toMatchObject(initResult); + else + expect(data).toMatchObject({ + requiresReplan: false, + stateFile, + result: { state: checkpoint, completed: [] }, + }); + const records = diagnostics(result.stderr); + expect(records.every((record) => record.warning || record.debug)).toBe(true); + if (name === "init") + expect( + records.filter((record) => record.warning?.code === "KNOWLEDGE_INIT_RESOURCE"), + ).toHaveLength(3); + } else expect(result.stdout.replaceAll(stateFile, "")).toBe(baseline[name]); + expect(result.stdout + result.stderr).not.toContain("fixture-api-token"); + }, + ); + test.each(["en-US", "zh-CN"] as const)(`${name} localizes final text in %s`, async (language) => { + const result = await invoke(name, { format: "text", quiet: true, language }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.strayOut + result.strayErr).toBe(""); + expect(result.stdout).toContain( + name === "search" + ? language === "zh-CN" + ? "得分" + : "score" + : name === "init" + ? language === "zh-CN" + ? "初始化成功" + : "Initialization succeeded" + : language === "zh-CN" + ? "同步完成" + : "Synchronization completed", + ); + }); +} +test.each(["bl", "kscli"] as const)( + "init %s retains product cleanup and next-search paths without reusing existing resources", + async (binName) => { + 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.map((action: { path: string[] }) => action.path)).toEqual( + binName === "bl" + ? [ + ["knowledge", "delete"], + ["knowledge", "file", "delete"], + ] + : [ + ["kb", "delete"], + ["file", "delete"], + ], + ); + expect(data.nextSearch.path).toEqual(binName === "bl" ? ["knowledge", "search"] : ["search"]); + expect(data.cleanup).toHaveLength(2); + }, +); +test.each([false, true])( + "init failure retains created resources and cleanup warning with quiet=%s", + async (quiet) => { + failInit = true; + const result = await invoke("init", { format: "json", quiet }); + expect(result.exitCode).toBe(ExitCode.NETWORK); + expect(result.stdout).toBe(""); + expect(result.strayOut + result.strayErr).toBe(""); + const events = diagnostics(result.stderr); + expect(events.filter((event) => event.error)).toHaveLength(1); + expect(events.at(-1).error).toMatchObject({ + message: "original failure", + hint: "original hint", + code: ExitCode.NETWORK, + }); + expect(events[1].warning.details).toMatchObject({ + resource: { kind: "knowledge-base", id: "index-one", created: true }, + cleanup: [ + { + path: ["knowledge", "delete"], + args: ["--index-id", "index-one", "--workspace-id", "ws-test"], + }, + ], + }); + expect(initCreatedResources(result.stdout, result.stderr)).toEqual( + resources.filter((resource) => resource.created), + ); + }, +); +test.each([false, true])( + "sync failure keeps the complete typed checkpoint and original error with quiet=%s", + async (quiet) => { + failSync = true; + const result = await invoke("sync", { format: "json", quiet }); + expect(result.exitCode).toBe(ExitCode.NETWORK); + expect(result.stdout).toBe(""); + expect(result.strayOut + result.strayErr).toBe(""); + const events = diagnostics(result.stderr); + expect(events).toHaveLength(2); + expect(events[0].warning).toMatchObject({ + code: "KNOWLEDGE_SYNC_INCOMPLETE", + details: { stateFile, checkpoint }, + }); + expect(events[0].warning.details.checkpoint).toEqual(checkpoint); + expect(events[1].error).toMatchObject({ + message: "original failure", + hint: "original hint", + code: ExitCode.NETWORK, + }); + }, +); +test("unreadable sync checkpoint never replaces the original failure", async () => { + failSync = true; + await writeFile(stateFile, "{broken"); + const result = await invoke("sync", { format: "json", quiet: true }); + expect(result.exitCode).toBe(ExitCode.NETWORK); + const events = diagnostics(result.stderr); + expect(events[0].warning.details.stateFile).toBe(stateFile); + expect(events[0].warning.details).not.toHaveProperty("checkpoint"); + expect(events[1].error.message).toBe("original failure"); +}); +test("blocked sync plan aborts before any execution", async () => { + blocked = true; + const result = await invoke("sync", { format: "json", quiet: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(execution.executeSyncPlan).not.toHaveBeenCalled(); + expect(recovery.runSyncRecovery).not.toHaveBeenCalled(); +}); +test("recovery sync never runs the ordinary plan in the same invocation", async () => { + replan = true; + const result = await invoke("sync", { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).requiresReplan).toBe(true); + expect(recovery.runSyncRecovery).toHaveBeenCalledOnce(); + expect(execution.executeSyncPlan).not.toHaveBeenCalled(); +}); +test("search request preserves images and draft version", async () => { + const result = await invoke("search", { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].body).toEqual({ + query: "Question", + agent_id: "service-one", + agent_version: "beta", + images: ["https://media.invalid/image"], + }); + expect(JSON.parse(result.stdout)).toEqual(response); +}); +test("JSON search does not evaluate score.toFixed", async () => { + searchResponse = { ...response, data: { nodes: [{ score: null, text: "Body", ...retained }] } }; + const result = await invoke("search", { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(searchResponse); +}); +test("empty search keeps full metadata with quiet JSON", async () => { + searchResponse = { ...response, data: { nodes: [], total: 0 } }; + const result = await invoke("search", { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(searchResponse); +}); +test("search service failure still has one original terminal error", async () => { + failSearch = true; + const result = await invoke("search", { format: "json", quiet: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(diagnostics(result.stderr)).toHaveLength(1); + expect(diagnostics(result.stderr)[0].error.message).toBe("raw search failure"); +}); diff --git a/packages/commands/tests/output/memory-service-output.test.ts b/packages/commands/tests/output/memory-service-output.test.ts new file mode 100644 index 000000000..6a9b1e680 --- /dev/null +++ b/packages/commands/tests/output/memory-service-output.test.ts @@ -0,0 +1,601 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + buildSettings, + Client, + httpTransport, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createCli, createOutputReporter } from "bailian-cli-runtime"; +import { runInProcess } from "e2e/in-process"; +import { buildIsolatedCliEnv } from "e2e/isolation"; +import * as commands from "bailian-cli-commands"; +import { E2E_HARNESS_IDENTITY } from "../e2e/harness/identity.ts"; +import { withHarnessUpdateFixture } from "../e2e/harness/update-fixture.ts"; + +const identity = { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }; +const extra = { future: { retained: [0, false, null, []] } }; +const node = { + request_id: "request-node", + ...extra, + memory_node: { + memory_node_id: "node-one", + content: "Body\nSecond", + memory_type: "skill", + status: "VALID", + project_id: "project-one", + timestamp: 0, + created_at: 0, + updated_at: 1, + meta_data: { flag: false, score: 0 }, + media_desc: "Caption", + media_urls: ["https://media.invalid/one"], + }, +}; +const skill = { + request_id: "request-skill", + ...extra, + memory_node: { + memory_node_id: "node-one", + content: "# Skill\n\nBody\n", + skill_name: "Demo", + skill_description: "About", + skill_tags: [], + }, +}; +const profiles = { + request_id: "request-list", + ...extra, + profile_schemas: [ + { profile_schema_id: "schema-one", name: "Profile", plan_version: "pro", description: "About" }, + { profile_schema_id: "schema-two", name: "Other" }, + ], + total: 2, +}; +const profile = { + request_id: "request-profile", + ...extra, + name: "Profile", + description: "About", + attributes: [ + { attribute_id: "attr-one", name: "Flag", description: "Enabled", default_value: false }, + { attribute_id: "attr-two", name: "Count", default_value: 0 }, + ], +}; +const mutation = { + request_id: "request-mutation", + ...extra, + data: { agent_id: "aid-one", agent_status: "draft", agent_version: "2", agent_name: "Demo" }, +}; +const config = { + agent_policy: "turbo", + agent_model: "model-one", + hybrid_rerank: false, + temperature: 0, + max_num_llm_calls: 3, + future: { retain: true }, + kb_search_configs: [ + { id: "index-one", name: "Docs", rerank: false, rerank_top_n: 0, rerank_min_score: 0 }, + ], +}; +const detail = { + request_id: "request-service", + ...extra, + data: { + agent_id: "aid-one", + agent_name: "Demo", + agent_desc: "About", + agent_scene: "search", + agent_status: "draft", + agent_details: [ + { agent_version: "beta", agent_version_desc: "Draft", publish_time: 0, agent_config: config }, + ], + }, +}; +const services = { + request_id: "request-services", + ...extra, + data: { + total_count: 7, + rows: [ + { + agent_id: "aid-one", + agent_status: "draft", + agent_version: "beta", + agent_name: "Demo", + pipeline_list: [{ pipeline_name: "Docs" }], + }, + ], + }, +}; +interface Scenario { + name: string; + command: AnyCommand; + flags: Record; + response: unknown; + method: string; + path: string; + text: string; + chinese: string; + body?: unknown; +} +const cases: Scenario[] = [ + { + name: "memory node show", + command: commands.memoryNodeShow, + flags: { nodeId: "node-one" }, + response: node, + method: "GET", + path: "/api/v2/apps/memory/memory_nodes/node-one", + text: '[node-one] Body\nSecond\n type: skill status: VALID\n project: project-one\n timestamp: 0\n created_at: 0\n updated_at: 1\n meta: {"flag":false,"score":0}\n media_desc: Caption\n media_urls: https://media.invalid/one\n', + chinese: "类型", + }, + { + name: "memory skill export", + command: commands.memorySkillExport, + flags: { nodeId: "node-one" }, + response: skill, + method: "GET", + path: "/api/v2/apps/memory/skill/export/node-one", + text: "# Skill\n\nBody\n\n", + chinese: "# Skill", + }, + { + name: "memory profile list", + command: commands.memoryProfileList, + flags: { libraryId: "lib-one", pageSize: 2, page: 3 }, + response: profiles, + method: "GET", + path: "/api/v2/apps/memory/profile_schemas?page_size=2&page_num=3&memory_library_id=lib-one", + text: "[schema-one] Profile (pro)\n About\n[schema-two] Other\n\nTotal: 2\n", + chinese: "总数", + }, + { + name: "memory profile show", + command: commands.memoryProfileShow, + flags: { schemaId: "schema-one", libraryId: "lib-one" }, + response: profile, + method: "GET", + path: "/api/v2/apps/memory/profile_schemas/schema-one?memory_library_id=lib-one", + text: "Name: Profile\nDescription: About\nAttributes:\n [attr-one] Flag\n desc: Enabled\n default: false\n [attr-two] Count\n default: 0\n", + chinese: "名称", + }, + { + name: "memory profile update", + command: commands.memoryProfileUpdate, + flags: { schemaId: "schema-one", libraryId: "lib-one", description: "" }, + response: { request_id: "request-update", ...extra }, + method: "PATCH", + path: "/api/v2/apps/memory/profile_schemas/schema-one", + body: { description: "", memory_library_id: "lib-one" }, + text: "Profile schema schema-one updated.\n", + chinese: "已更新", + }, + { + name: "memory profile delete", + command: commands.memoryProfileDelete, + flags: { schemaId: "schema-one", libraryId: "lib-one" }, + response: { request_id: "request-delete", ...extra }, + method: "DELETE", + path: "/api/v2/apps/memory/profile_schemas/schema-one?memory_library_id=lib-one", + text: "Profile schema schema-one deleted.\n", + chinese: "已删除", + }, + { + name: "knowledge service list", + command: commands.knowledgeServiceList, + flags: { scene: "search", pageNumber: 2, pageSize: 3, name: "Demo" }, + response: services, + method: "POST", + path: "/api/v1/indices/rag/app/list", + body: { agent_scene: "search", agent_name: "Demo", page_number: 2, page_size: 3 }, + text: "aid-one draft beta Demo (kb: Docs)\ntotal: 7\nUse an agent_id above with the knowledge search command.\n", + chinese: "总数", + }, + { + name: "knowledge service get", + command: commands.knowledgeServiceGet, + flags: { agentId: "aid-one" }, + response: detail, + method: "POST", + path: "/api/v1/indices/rag/app/get", + body: { agent_id: "aid-one" }, + text: "Basic:\n id: aid-one\n name: Demo\n desc: About\n scene: search\n status: draft\nVersion beta:\n desc: Draft\n published: 0\n policy: turbo\n model: model-one\n hybrid_rerank: false\n temperature: 0\n kb: index-one (Docs)\n rerank: false\n rerank_top_n: 0\n rerank_min_score: 0\n", + chinese: "基本信息", + }, + { + name: "knowledge service create", + command: commands.knowledgeServiceCreate, + flags: { name: "Demo", scene: "search", indexId: "index-one" }, + response: mutation, + method: "POST", + path: "/api/v1/indices/rag/app/create", + body: { + agent_name: "Demo", + agent_scene: "search", + agent_config: { kb_search_configs: [{ id: "index-one" }] }, + }, + text: "created: aid-one (status: draft, version: 2)\nTest the draft with --agent-version beta on search/chat, then deploy it to publish.\n", + chinese: "已创建", + }, + { + name: "knowledge service update", + command: commands.knowledgeServiceUpdate, + flags: { agentId: "aid-one", description: "" }, + response: mutation, + method: "POST", + path: "/api/v1/indices/rag/app/update", + body: { agent_id: "aid-one", agent_desc: "" }, + text: "updated: aid-one\n", + chinese: "已更新", + }, + { + name: "knowledge service deploy", + command: commands.knowledgeServiceDeploy, + flags: { agentId: "aid-one", versionDesc: "Release" }, + response: mutation, + method: "POST", + path: "/api/v1/indices/rag/app/deploy", + body: { agent_id: "aid-one", agent_version_desc: "Release" }, + text: "deployed: aid-one version 2\n", + chinese: "已部署", + }, + { + name: "knowledge service delete", + command: commands.knowledgeServiceDelete, + flags: { agentId: "aid-one" }, + response: mutation, + method: "POST", + path: "/api/v1/indices/rag/app/delete", + body: { agent_id: "aid-one" }, + text: "deleted: aid-one (status: draft)\n", + chinese: "已删除", + }, + { + name: "knowledge service copy", + command: commands.knowledgeServiceCopy, + flags: { agentId: "aid-one" }, + response: mutation, + method: "POST", + path: "/api/v1/indices/rag/app/copy", + body: { agent_id: "aid-one" }, + text: "new agent_id: aid-one (name: Demo, status: draft)\nTest the draft with --agent-version beta on search/chat, then deploy it to publish.\n", + chinese: "新 agent_id", + }, +]; +let directory: string; +let responses: unknown[]; +let requests: Array<{ url: string; method: string; body?: unknown }>; +let failure: boolean; +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "memory-service-output-")); + responses = []; + requests = []; + failure = false; + vi.spyOn(httpTransport, "fetch").mockImplementation(async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + requests.push({ + url, + method: init?.method ?? "GET", + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) } : {}), + }); + if (failure) + return Response.json( + { code: "Rejected", message: "original service error", request_id: "request-error" }, + { status: 403 }, + ); + if (!responses.length) throw new Error(`Unexpected request: ${url}`); + return Response.json(responses.shift()); + }); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); +interface Display { + format: "json" | "text"; + quiet?: boolean; + verbose?: boolean; + language?: "en-US" | "zh-CN"; + dryRun?: boolean; +} +async function invoke( + scenario: Scenario, + display: Display, + response: unknown = scenario.response, + preceding: unknown[] = [], +) { + responses = [...preceding, response]; + let stdout = "", + stderr = ""; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + }, + file: { language: display.language ?? "en-US", workspace_id: "ws-test" }, + env: {}, + configPath: join(directory, "config.json"), + }); + const localize = (text: LocalizedText) => + typeof text === "string" ? text : text[display.language ?? "en-US"]; + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + const client = new Client({ + identity, + settings, + baseUrl: "https://fixture.invalid", + apiCred: { token: "fixture-api-key", source: "flag", baseUrl: "https://fixture.invalid" }, + debug: (diagnostic) => reporter.output.debug(diagnostic), + }); + let exitCode = 0; + try { + try { + await scenario.command.run({ + identity, + settings, + client, + output: reporter.output, + localize, + flags: scenario.flags, + } as never); + } catch (error) { + exitCode = reporter.error(error); + } + return { stdout, stderr, exitCode }; + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + } +} +const modes = ["json", "text"].flatMap((format) => + [false, true].flatMap((quiet) => + [false, true].map((verbose) => ({ format: format as "json" | "text", quiet, verbose })), + ), +); +for (const scenario of cases) { + test.each(modes)(`${scenario.name} preserves full result and requests: %j`, async (display) => { + const result = await invoke(scenario, display); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(scenario.response); + else expect(result.stdout).toBe(scenario.text); + expect(requests).toEqual([ + { + url: `https://ws-test.cn-beijing.maas.aliyuncs.com${scenario.path}`, + method: scenario.method, + ...(scenario.body === undefined ? {} : { body: scenario.body }), + }, + ]); + const records = + display.format === "json" && result.stderr + ? result.stderr + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + expect(records.every((record) => record.debug !== undefined)).toBe(true); + expect(result.stdout + result.stderr).not.toContain("fixture-api-key"); + }); + test(`${scenario.name} localizes text and keeps the same JSON data`, async () => { + const text = await invoke(scenario, { format: "text", language: "zh-CN", quiet: true }); + expect(text.exitCode, text.stderr).toBe(0); + expect(text.stdout).toContain(scenario.chinese); + const json = await invoke(scenario, { format: "json", language: "zh-CN", quiet: true }); + expect(JSON.parse(json.stdout)).toEqual(scenario.response); + }); + test(`${scenario.name} dry-run has complete preview and performs no HTTP`, async () => { + const result = await invoke(scenario, { format: "json", quiet: true, dryRun: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + endpoint: `https://ws-test.cn-beijing.maas.aliyuncs.com${scenario.path}`, + }); + expect(requests).toEqual([]); + }); + test(`${scenario.name} preserves server failure as one JSONL error`, async () => { + failure = true; + const result = await invoke(scenario, { format: "json", quiet: true }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const records = result.stderr + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records).toHaveLength(1); + expect(records[0].error).toMatchObject({ + message: "original service error", + request_id: "request-error", + http_status: 403, + api_code: "Rejected", + }); + expect(requests).toHaveLength(1); + }); +} +const empties = [ + { + index: 0, + response: { request_id: "empty-node", memory_node: null }, + text: "Memory node not found.\n", + }, + { + index: 1, + response: { request_id: "empty-skill", memory_node: null }, + text: "Skill memory node not found.\n", + }, + { + index: 2, + response: { request_id: "empty-list", profile_schemas: [], total: 0 }, + text: "No profile schemas found.\n", + }, + { + index: 3, + response: { request_id: "empty-profile", attributes: [] }, + text: "Name: (none)\nNo attributes.\n", + }, + { + index: 6, + response: { request_id: "empty-services", data: { rows: [], total_count: 0 } }, + text: "No services found.\ntotal: 0\nUse an agent_id above with the knowledge search command.\n", + }, +]; +for (const empty of empties) { + test.each(modes)( + `empty ${cases[empty.index]!.name} preserves response and text: %j`, + async (display) => { + const result = await invoke(cases[empty.index]!, display, empty.response); + expect(result.exitCode, result.stderr).toBe(0); + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(empty.response); + else expect(result.stdout).toBe(empty.text); + }, + ); +} +test("JSON does not evaluate text-only details", async () => { + for (const index of [0, 1, 2, 3, 6, 7]) { + const response = { + request_id: "opaque", + memory_node: null, + profile_schemas: "opaque", + attributes: "opaque", + data: { rows: "opaque", agent_details: "opaque" }, + }; + const result = await invoke(cases[index]!, { format: "json", quiet: true }, response); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(response); + } +}); +test("scalar service update preserves unknown beta config and zero/false values", async () => { + const scenario = { ...cases[9]!, flags: { agentId: "aid-one", temperature: 0 } }; + const result = await invoke(scenario, { format: "json", quiet: true }, mutation, [detail]); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(mutation); + expect(requests.map((request) => request.body)).toEqual([ + { agent_id: "aid-one", agent_version: "beta" }, + { agent_id: "aid-one", agent_config: { ...config, temperature: 0 } }, + ]); + expect(JSON.parse(result.stderr).warning).toMatchObject({ + code: "KNOWLEDGE_SERVICE_UNKNOWN_CONFIG_FIELD", + details: { field: "future" }, + }); +}); +test.each([8, 10, 11, 12])( + "mutation %s keeps flat as well as nested response envelopes", + async (index) => { + const response = { + request_id: "flat", + agent_id: "aid-flat", + agent_version: "3", + agent_status: "deployed", + agent_name: "Flat", + ...extra, + }; + const result = await invoke(cases[index]!, { format: "json", quiet: true }, response); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(response); + }, +); +test.each([5, 10, 11])("high-risk command %s still gates HTTP until --yes", async (index) => { + const scenario = cases[index]!; + const route = scenario.name; + const args = [ + ...route.split(" "), + ...(index === 5 ? ["--schema-id", "schema-one"] : ["--agent-id", "aid-one"]), + "--workspace-id", + "ws-test", + "--api-key", + "fixture-api-key", + "--output", + "json", + "--quiet", + ]; + const isolated = buildIsolatedCliEnv({ + overrides: { + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_QUIET: "", + DASHSCOPE_OUTPUT: "", + DASHSCOPE_VERBOSE: "", + }, + }); + try { + const run = (argv: string[]) => + runInProcess( + () => + withHarnessUpdateFixture(() => + createCli({ [route]: scenario.command }, E2E_HARNESS_IDENTITY).run(argv), + ), + argv, + { env: isolated.env }, + ); + const blocked = await run(args); + expect(blocked.exitCode, blocked.stderr).toBe(7); + expect(requests).toEqual([]); + expect(JSON.parse(blocked.stderr).error.type).toBe("requires_confirmation"); + responses = [scenario.response]; + const confirmed = await run([...args, "--yes"]); + expect(confirmed.exitCode, confirmed.stderr).toBe(0); + expect(JSON.parse(confirmed.stdout)).toEqual(scenario.response); + expect(requests).toHaveLength(1); + } finally { + isolated.dispose(); + } +}); + +test.each([8, 9])( + "config-file command %s keeps warnings structured and passes through config", + async (index) => { + const configFile = join(directory, "config.json"); + const settings = { + future_config: { retained: true }, + kb_search_configs: [ + { + id: "index-one", + rerank: { rerank_mode: "default", rerank_instruct: "Custom instruction" }, + }, + ], + }; + await writeFile(configFile, JSON.stringify(settings)); + const scenario = { + ...cases[index]!, + flags: + index === 8 + ? { name: "Demo", scene: "search", configFile } + : { agentId: "aid-one", configFile }, + }; + const result = await invoke(scenario, { format: "json", quiet: true, dryRun: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).request.agent_config).toEqual(settings); + const warnings = result.stderr + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line).warning); + expect(warnings.map((warning) => warning.code)).toEqual([ + "KNOWLEDGE_SERVICE_RERANK_INSTRUCT_IGNORED", + "KNOWLEDGE_SERVICE_UNKNOWN_CONFIG_FIELD", + ]); + expect(warnings[1].details).toEqual({ field: "future_config" }); + expect(requests).toEqual([]); + }, +); diff --git a/skills/bailian-knowledge/reference/knowledge.md b/skills/bailian-knowledge/reference/knowledge.md index 34e05ef01..586c7b6ce 100644 --- a/skills/bailian-knowledge/reference/knowledge.md +++ b/skills/bailian-knowledge/reference/knowledge.md @@ -678,7 +678,7 @@ Use `--yes` only after the user has confirmed this operation and its scope. Exit Removes documents from the knowledge base index only; the source files remain in the data center. -Use the doc_id from `knowledge doc list --quiet`, not the fileId from `knowledge doc upload`. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix. +Use doc_id from the document-list JSON response (--output json), not fileId from document upload. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix. Deletion may take up to ~30s to propagate — the document may still appear in the doc list briefly. diff --git a/skills/bailian-knowledge/reference/kscli.md b/skills/bailian-knowledge/reference/kscli.md index 0ea21c403..d65946188 100644 --- a/skills/bailian-knowledge/reference/kscli.md +++ b/skills/bailian-knowledge/reference/kscli.md @@ -652,7 +652,7 @@ Use `--yes` only after the user has confirmed this operation and its scope. Exit Removes documents from the knowledge base index only; the source files remain in the data center. -Use the doc_id from `knowledge doc list --quiet`, not the fileId from `knowledge doc upload`. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix. +Use doc_id from the document-list JSON response (--output json), not fileId from document upload. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix. Deletion may take up to ~30s to propagate — the document may still appear in the doc list briefly. From 33ea6d5981dcedf1afc76ac900ccf59bc77b24e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 30 Oct 2026 10:28:13 +0800 Subject: [PATCH 14/28] refactor(commands): unify alert and catalog output Route this command family through the invocation output contract while preserving business results and request behavior. Validation: isolated combined regression suite; normal repository pre-commit checks. --- .../commands/src/commands/alert/create.ts | 35 +- .../commands/src/commands/alert/delete.ts | 34 +- .../commands/src/commands/alert/disable.ts | 34 +- .../commands/src/commands/alert/enable.ts | 34 +- .../commands/src/commands/alert/history.ts | 93 +-- packages/commands/src/commands/alert/list.ts | 62 +- .../commands/src/commands/alert/metrics.ts | 65 +- .../src/commands/alert/template-create.ts | 47 +- .../src/commands/alert/template-delete.ts | 35 +- .../src/commands/alert/template-list.ts | 71 ++- .../src/commands/alert/template-update.ts | 34 +- .../commands/src/commands/alert/update.ts | 34 +- packages/commands/src/commands/model/code.ts | 55 +- packages/commands/src/commands/model/list.ts | 198 ++++-- .../commands/src/commands/model/search.ts | 81 ++- .../commands/src/commands/permission/grant.ts | 1 + .../commands/src/commands/permission/list.ts | 82 ++- .../src/commands/permission/revoke.ts | 1 + .../src/commands/permission/shared.ts | 70 ++- .../commands/src/commands/workspace/init.ts | 298 +++++---- .../commands/src/commands/workspace/list.ts | 72 ++- packages/commands/tests/e2e/model.e2e.test.ts | 33 + .../commands/tests/e2e/permission.e2e.test.ts | 26 +- .../tests/output/alert-output.test.ts | 581 ++++++++++++++++++ ...atalog-workspace-permission-output.test.ts | 570 +++++++++++++++++ .../tests/output/fixtures/alert-text.json | 14 + .../catalog-workspace-permission.json | 371 +++++++++++ packages/core/src/auth/refresh-token.ts | 2 +- 28 files changed, 2418 insertions(+), 615 deletions(-) create mode 100644 packages/commands/tests/output/alert-output.test.ts create mode 100644 packages/commands/tests/output/catalog-workspace-permission-output.test.ts create mode 100644 packages/commands/tests/output/fixtures/alert-text.json create mode 100644 packages/commands/tests/output/fixtures/catalog-workspace-permission.json diff --git a/packages/commands/src/commands/alert/create.ts b/packages/commands/src/commands/alert/create.ts index a9f2c9e4f..40c6a057a 100644 --- a/packages/commands/src/commands/alert/create.ts +++ b/packages/commands/src/commands/alert/create.ts @@ -1,6 +1,5 @@ -import { detectOutputFormat, unwrapResponse } from "bailian-cli-core"; +import { unwrapResponse } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult } from "bailian-cli-runtime"; import { ALERT_RULE_WRITE_FLAGS, buildAlertRuleReqDTO, @@ -35,7 +34,7 @@ export default defineTelemetryCommand({ validate: (flags) => validateAlertRuleFlags(flags), async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), @@ -43,14 +42,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: CREATE_RULE_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: CREATE_RULE_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -60,11 +56,16 @@ export default defineTelemetryCommand({ const resp = unwrapResponse(raw as Record); const ruleId = (resp.data ?? resp.ruleId ?? resp) as string | undefined; - if (format === "json") { - emitResult({ created: true, ruleId }, format); - return; - } - - process.stdout.write(`Alert rule created${ruleId ? `: ${ruleId}` : "."}\n`); + ctx.output.result( + { created: true, ruleId }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Alert rule created${ruleId ? `: ${ruleId}` : "."}`, + "zh-CN": `告警规则已创建${ruleId ? `: ${ruleId}` : "。"}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/delete.ts b/packages/commands/src/commands/alert/delete.ts index 7241df604..6922807fb 100644 --- a/packages/commands/src/commands/alert/delete.ts +++ b/packages/commands/src/commands/alert/delete.ts @@ -1,6 +1,4 @@ -import { detectOutputFormat } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult } from "bailian-cli-runtime"; import { parseCommaList } from "../shared/params.ts"; import { ensureAlertReady } from "./shared.ts"; @@ -40,19 +38,16 @@ export default defineTelemetryCommand({ ], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { ruleIds: parseCommaList(flags.ruleId) }; if (settings.dryRun) { - emitResult( - { - api: DELETE_RULES_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: DELETE_RULES_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -60,11 +55,16 @@ export default defineTelemetryCommand({ await ctx.client.console(DELETE_RULES_API, { reqDTO }); - if (format === "json") { - emitResult({ deleted: reqDTO.ruleIds }, format); - return; - } - - process.stdout.write(`Deleted ${reqDTO.ruleIds.length} alert rule(s).\n`); + ctx.output.result( + { deleted: reqDTO.ruleIds }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Deleted ${reqDTO.ruleIds.length} alert rule(s).`, + "zh-CN": `已删除 ${reqDTO.ruleIds.length} 个告警规则。`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/disable.ts b/packages/commands/src/commands/alert/disable.ts index 52d542faa..9863d02fd 100644 --- a/packages/commands/src/commands/alert/disable.ts +++ b/packages/commands/src/commands/alert/disable.ts @@ -1,6 +1,4 @@ -import { detectOutputFormat } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult } from "bailian-cli-runtime"; import { ensureAlertReady } from "./shared.ts"; const DISABLE_RULE_API = "zeldaEasy.bailian-telemetry.alertRule.disableAlertRule"; @@ -26,7 +24,7 @@ export default defineTelemetryCommand({ exampleArgs: ["--rule-id 789", "--rule-id 789 --dry-run"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { workspaceId: settings.workspaceId, @@ -35,14 +33,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: DISABLE_RULE_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: DISABLE_RULE_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -50,11 +45,16 @@ export default defineTelemetryCommand({ await ctx.client.console(DISABLE_RULE_API, { reqDTO }); - if (format === "json") { - emitResult({ enabled: false, ruleId: flags.ruleId }, format); - return; - } - - process.stdout.write(`Alert rule disabled: ${flags.ruleId}\n`); + ctx.output.result( + { enabled: false, ruleId: flags.ruleId }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Alert rule disabled: ${flags.ruleId}`, + "zh-CN": `告警规则已停用: ${flags.ruleId}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/enable.ts b/packages/commands/src/commands/alert/enable.ts index 3ccf60479..f57ff2e39 100644 --- a/packages/commands/src/commands/alert/enable.ts +++ b/packages/commands/src/commands/alert/enable.ts @@ -1,6 +1,4 @@ -import { detectOutputFormat } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult } from "bailian-cli-runtime"; import { ensureAlertReady } from "./shared.ts"; export default defineTelemetryCommand({ @@ -24,7 +22,7 @@ export default defineTelemetryCommand({ exampleArgs: ["--rule-id 789", "--rule-id 789 --dry-run"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { workspaceId: settings.workspaceId, @@ -33,14 +31,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: "zeldaEasy.bailian-telemetry.alertRule.enableAlertRule", - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: "zeldaEasy.bailian-telemetry.alertRule.enableAlertRule", + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -48,11 +43,16 @@ export default defineTelemetryCommand({ await ctx.client.console("zeldaEasy.bailian-telemetry.alertRule.enableAlertRule", { reqDTO }); - if (format === "json") { - emitResult({ enabled: true, ruleId: flags.ruleId }, format); - return; - } - - process.stdout.write(`Alert rule enabled: ${flags.ruleId}\n`); + ctx.output.result( + { enabled: true, ruleId: flags.ruleId }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Alert rule enabled: ${flags.ruleId}`, + "zh-CN": `告警规则已启用: ${flags.ruleId}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/history.ts b/packages/commands/src/commands/alert/history.ts index 799e32a3c..cb8480a22 100644 --- a/packages/commands/src/commands/alert/history.ts +++ b/packages/commands/src/commands/alert/history.ts @@ -1,6 +1,5 @@ -import { detectOutputFormat } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { ansi, renderBoxTable } from "bailian-cli-runtime"; import { formatDateTime } from "../shared/format.ts"; import { TELEMETRY_TIME_FLAGS, resolveTimeRange } from "../shared/telemetry.ts"; import { ALERT_LEVELS, ensureAlertReady, extractAlertPage } from "./shared.ts"; @@ -105,7 +104,7 @@ export default defineTelemetryCommand({ }, async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const { startTime, endTime } = resolveTimeRange(flags); const reqDTO = { @@ -123,14 +122,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: LIST_HISTORIES_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_HISTORIES_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -139,38 +135,49 @@ export default defineTelemetryCommand({ const raw = await ctx.client.console(LIST_HISTORIES_API, { reqDTO }); const page = extractAlertPage(raw); - if (format === "json") { - emitResult(page, format); - return; - } - - if (page.list.length === 0) { - process.stdout.write("No alert history found in this range.\n"); - return; - } - - const color = ansi(process.stdout); - const lines = renderBoxTable({ - headers: ["Start Time", "Rule", "Status", "Max Level", "Count", "Instance"], - rows: page.list.map((item) => [ - item.startTime ? formatDateTime(item.startTime) : "-", - item.ruleDisplayName ?? item.alertRuleId, - item.status ?? "-", - item.maxLevel ?? "-", - String(item.count ?? 0), - instanceText(item), - ]), - align: ["left", "left", "left", "left", "right", "left"], - cellColor: (_rowIndex, colIndex, value) => { - if (colIndex === 2) return value === "ALARM" ? color.red(value) : color.green(value); - if (colIndex === 3 && value === "ERROR") return color.red(value); - return undefined; - }, - }); - for (const line of lines) process.stdout.write(line + "\n"); - - if (page.nextToken) { - process.stdout.write(`Next page: --next-token ${page.nextToken}\n`); + let text: string | undefined; + if (format === "text") { + if (page.list.length === 0) { + text = ctx.localize({ + "en-US": "No alert history found in this range.", + "zh-CN": "此时间范围内未找到告警历史。", + }); + } else { + const color = ansi(process.stdout); + const lines = renderBoxTable({ + headers: [ + ctx.localize({ "en-US": "Start Time", "zh-CN": "开始时间" }), + ctx.localize({ "en-US": "Rule", "zh-CN": "规则" }), + ctx.localize({ "en-US": "Status", "zh-CN": "状态" }), + ctx.localize({ "en-US": "Max Level", "zh-CN": "最高级别" }), + ctx.localize({ "en-US": "Count", "zh-CN": "次数" }), + ctx.localize({ "en-US": "Instance", "zh-CN": "实例" }), + ], + rows: page.list.map((item) => [ + item.startTime ? formatDateTime(item.startTime) : "-", + item.ruleDisplayName ?? item.alertRuleId, + item.status ?? "-", + item.maxLevel ?? "-", + String(item.count ?? 0), + instanceText(item), + ]), + align: ["left", "left", "left", "left", "right", "left"], + cellColor: (_rowIndex, colIndex, value) => { + if (colIndex === 2) return value === "ALARM" ? color.red(value) : color.green(value); + if (colIndex === 3 && value === "ERROR") return color.red(value); + return undefined; + }, + }); + if (page.nextToken) + lines.push( + ctx.localize({ + "en-US": `Next page: --next-token ${page.nextToken}`, + "zh-CN": `下一页: --next-token ${page.nextToken}`, + }), + ); + text = lines.join("\n"); + } } + ctx.output.result(page, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/alert/list.ts b/packages/commands/src/commands/alert/list.ts index cd6d39b3b..b22c413d5 100644 --- a/packages/commands/src/commands/alert/list.ts +++ b/packages/commands/src/commands/alert/list.ts @@ -1,6 +1,6 @@ -import { detectOutputFormat } from "bailian-cli-core"; +import type { LocalizedText } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { ansi, renderBoxTable } from "bailian-cli-runtime"; import { ensureAlertReady, extractAlertPage, ALERT_LEVELS } from "./shared.ts"; const LIST_RULES_API = "zeldaEasy.bailian-telemetry.alertRule.listAlertRules"; @@ -16,16 +16,22 @@ interface AlertRuleItem { gmtModified?: number; } -function printTable(list: AlertRuleItem[]): void { +function ruleTable(list: AlertRuleItem[], localize: (text: LocalizedText) => string): string[] { const color = ansi(process.stdout); if (list.length === 0) { - process.stdout.write("No alert rules found.\n"); - return; + return [localize({ "en-US": "No alert rules found.", "zh-CN": "未找到告警规则。" })]; } const lines = renderBoxTable({ - headers: ["Rule ID", "Name", "Level", "Enabled", "Models", "Interval"], + headers: [ + localize({ "en-US": "Rule ID", "zh-CN": "规则 ID" }), + localize({ "en-US": "Name", "zh-CN": "名称" }), + localize({ "en-US": "Level", "zh-CN": "级别" }), + localize({ "en-US": "Enabled", "zh-CN": "启用" }), + localize({ "en-US": "Models", "zh-CN": "模型" }), + localize({ "en-US": "Interval", "zh-CN": "间隔" }), + ], rows: list.map((rule) => [ rule.ruleId, rule.name, @@ -41,7 +47,7 @@ function printTable(list: AlertRuleItem[]): void { return undefined; }, }); - for (const line of lines) process.stdout.write(line + "\n"); + return lines; } export default defineTelemetryCommand({ @@ -113,7 +119,7 @@ export default defineTelemetryCommand({ exampleArgs: ["", "--enabled true", "--name 失败率 --level ERROR", "--output json"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { workspaceId: settings.workspaceId, @@ -129,14 +135,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: LIST_RULES_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_RULES_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -145,14 +148,23 @@ export default defineTelemetryCommand({ const raw = await ctx.client.console(LIST_RULES_API, { reqDTO }); const page = extractAlertPage(raw); - if (format === "json") { - emitResult(page, format); - return; - } - - printTable(page.list); - if (page.nextToken) { - process.stdout.write(`Next page: --next-token ${page.nextToken}\n`); - } + ctx.output.result( + page, + format === "text" + ? { + text: [ + ...ruleTable(page.list, ctx.localize), + ...(page.nextToken + ? [ + ctx.localize({ + "en-US": `Next page: --next-token ${page.nextToken}`, + "zh-CN": `下一页: --next-token ${page.nextToken}`, + }), + ] + : []), + ].join("\n"), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/metrics.ts b/packages/commands/src/commands/alert/metrics.ts index e356f68e3..edef48f1f 100644 --- a/packages/commands/src/commands/alert/metrics.ts +++ b/packages/commands/src/commands/alert/metrics.ts @@ -1,6 +1,6 @@ -import { detectOutputFormat, unwrapResponse } from "bailian-cli-core"; +import { unwrapResponse } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { renderBoxTable } from "bailian-cli-runtime"; import { ensureAlertReady } from "./shared.ts"; const LIST_METRICS_API = "zeldaEasy.bailian-telemetry.alertMetric.listMetrics"; @@ -23,17 +23,14 @@ export default defineTelemetryCommand({ exampleArgs: ["", "--output json"], async run(ctx) { const { settings } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; if (settings.dryRun) { - emitResult( - { - api: LIST_METRICS_API, - data: { reqDTO: { workspaceId: settings.workspaceId, resourceType: "model" } }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_METRICS_API, + data: { reqDTO: { workspaceId: settings.workspaceId, resourceType: "model" } }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -47,26 +44,32 @@ export default defineTelemetryCommand({ Array.isArray(resp) ? resp : (((resp.list ?? resp.result) as unknown[]) ?? []) ) as AlertMetric[]; - if (format === "json") { - emitResult(list, format); - return; - } - - if (list.length === 0) { - process.stdout.write("No alertable metrics found.\n"); - return; + let text: string | undefined; + if (format === "text") { + if (list.length === 0) { + text = ctx.localize({ + "en-US": "No alertable metrics found.", + "zh-CN": "未找到可告警的指标。", + }); + } else { + const lines = renderBoxTable({ + headers: [ + ctx.localize({ "en-US": "Metric", "zh-CN": "指标" }), + ctx.localize({ "en-US": "Aggregations", "zh-CN": "聚合方式" }), + ctx.localize({ "en-US": "Unit", "zh-CN": "单位" }), + ctx.localize({ "en-US": "Description", "zh-CN": "说明" }), + ], + rows: list.map((metric) => [ + metric.metricName, + (metric.aggregators ?? []).join(", "), + metric.unit ?? "-", + metric.desc ?? "-", + ]), + align: ["left", "left", "left", "left"], + }); + text = lines.join("\n"); + } } - - const lines = renderBoxTable({ - headers: ["Metric", "Aggregations", "Unit", "Description"], - rows: list.map((metric) => [ - metric.metricName, - (metric.aggregators ?? []).join(", "), - metric.unit ?? "-", - metric.desc ?? "-", - ]), - align: ["left", "left", "left", "left"], - }); - for (const line of lines) process.stdout.write(line + "\n"); + ctx.output.result(list, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/alert/template-create.ts b/packages/commands/src/commands/alert/template-create.ts index 0c96c2d3f..467eb55e1 100644 --- a/packages/commands/src/commands/alert/template-create.ts +++ b/packages/commands/src/commands/alert/template-create.ts @@ -1,5 +1,4 @@ -import { BailianError, ExitCode, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { BailianError, ExitCode, unwrapResponse } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; import { ensureAlertReady, @@ -101,27 +100,24 @@ export default defineTelemetryCommand({ validate: (flags) => validateTemplateConditions(flags.condition, Boolean(flags.from)), async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const conditions = flags.condition?.length ? flags.condition.map(parseCondition) : undefined; if (settings.dryRun) { - emitResult( - { - api: CREATE_TEMPLATE_API, - data: { - reqDTO: { - templateName: flags.name, - resourceType: "model", - logicalOperator: flags.logicalOperator ?? "or", - srcTemplateId: flags.from, - conditions: conditions ?? "(copied from --from template)", - }, + ctx.output.result({ + api: CREATE_TEMPLATE_API, + data: { + reqDTO: { + templateName: flags.name, + resourceType: "model", + logicalOperator: flags.logicalOperator ?? "or", + srcTemplateId: flags.from, + conditions: conditions ?? "(copied from --from template)", }, - ...telemetryGatewayPreview(settings), }, - format, - ); + ...telemetryGatewayPreview(settings), + }); return; } @@ -139,11 +135,16 @@ export default defineTelemetryCommand({ const resp = unwrapResponse(raw as Record); const templateId = (resp.data ?? resp.templateId ?? resp) as string | undefined; - if (format === "json") { - emitResult({ created: true, templateId }, format); - return; - } - - process.stdout.write(`Alert template created${templateId ? `: ${templateId}` : "."}\n`); + ctx.output.result( + { created: true, templateId }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Alert template created${templateId ? `: ${templateId}` : "."}`, + "zh-CN": `告警模板已创建${templateId ? `: ${templateId}` : "。"}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/template-delete.ts b/packages/commands/src/commands/alert/template-delete.ts index a87ece6db..4ab0efc7c 100644 --- a/packages/commands/src/commands/alert/template-delete.ts +++ b/packages/commands/src/commands/alert/template-delete.ts @@ -1,5 +1,4 @@ -import { detectOutputFormat, unwrapResponse } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { unwrapResponse } from "bailian-cli-core"; import { parseCommaList } from "../shared/params.ts"; import { ensureAlertReady } from "./shared.ts"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; @@ -40,19 +39,16 @@ export default defineTelemetryCommand({ ], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { templateIds: parseCommaList(flags.templateId) }; if (settings.dryRun) { - emitResult( - { - api: DELETE_TEMPLATES_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: DELETE_TEMPLATES_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -61,11 +57,16 @@ export default defineTelemetryCommand({ const raw = await ctx.client.console(DELETE_TEMPLATES_API, { reqDTO }); const resp = unwrapResponse(raw as Record); - if (format === "json") { - emitResult({ deleted: reqDTO.templateIds, result: resp }, format); - return; - } - - process.stdout.write(`Deleted ${reqDTO.templateIds.length} alert template(s).\n`); + ctx.output.result( + { deleted: reqDTO.templateIds, result: resp }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Deleted ${reqDTO.templateIds.length} alert template(s).`, + "zh-CN": `已删除 ${reqDTO.templateIds.length} 个告警模板。`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/template-list.ts b/packages/commands/src/commands/alert/template-list.ts index 5fdb303ee..fbe98a615 100644 --- a/packages/commands/src/commands/alert/template-list.ts +++ b/packages/commands/src/commands/alert/template-list.ts @@ -1,6 +1,5 @@ -import { detectOutputFormat } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { renderBoxTable } from "bailian-cli-runtime"; import { ensureAlertReady, extractAlertPage } from "./shared.ts"; const LIST_TEMPLATES_API = "zeldaEasy.bailian-telemetry.alertTemplate.listAlertTemplates"; @@ -82,7 +81,7 @@ export default defineTelemetryCommand({ exampleArgs: ["", "--source Official", "--name 失败率", "--template-id 123 --output json"], async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { resourceType: "model", @@ -94,14 +93,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: LIST_TEMPLATES_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: LIST_TEMPLATES_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -110,28 +106,37 @@ export default defineTelemetryCommand({ const raw = await ctx.client.console(LIST_TEMPLATES_API, { reqDTO }); const page = extractAlertPage(raw); - if (format === "json") { - emitResult(page, format); - return; + let text: string | undefined; + if (format === "text") { + if (page.list.length === 0) { + text = ctx.localize({ "en-US": "No alert templates found.", "zh-CN": "未找到告警模板。" }); + } else { + const lines = renderBoxTable({ + headers: [ + ctx.localize({ "en-US": "Template ID", "zh-CN": "模板 ID" }), + ctx.localize({ "en-US": "Name", "zh-CN": "名称" }), + ctx.localize({ "en-US": "Source", "zh-CN": "来源" }), + ctx.localize({ "en-US": "Conditions", "zh-CN": "条件" }), + ], + rows: page.list.map((template) => [ + template.templateId, + template.templateName, + template.source === "Official" + ? ctx.localize({ "en-US": "Official", "zh-CN": "官方" }) + : ctx.localize({ "en-US": "Custom", "zh-CN": "自定义" }), + (template.conditions ?? []) + .map(formatCondition) + .join( + template.logicalOperator === "and" + ? ctx.localize({ "en-US": " AND ", "zh-CN": " 且 " }) + : ctx.localize({ "en-US": " OR ", "zh-CN": " 或 " }), + ) || "-", + ]), + align: ["left", "left", "left", "left"], + }); + text = lines.join("\n"); + } } - - if (page.list.length === 0) { - process.stdout.write("No alert templates found.\n"); - return; - } - - const lines = renderBoxTable({ - headers: ["Template ID", "Name", "Source", "Conditions"], - rows: page.list.map((template) => [ - template.templateId, - template.templateName, - template.source === "Official" ? "Official" : "Custom", - (template.conditions ?? []) - .map(formatCondition) - .join(template.logicalOperator === "and" ? " AND " : " OR ") || "-", - ]), - align: ["left", "left", "left", "left"], - }); - for (const line of lines) process.stdout.write(line + "\n"); + ctx.output.result(page, text === undefined ? undefined : { text }); }, }); diff --git a/packages/commands/src/commands/alert/template-update.ts b/packages/commands/src/commands/alert/template-update.ts index 689bbf2f0..0ff7cc393 100644 --- a/packages/commands/src/commands/alert/template-update.ts +++ b/packages/commands/src/commands/alert/template-update.ts @@ -1,6 +1,4 @@ -import { detectOutputFormat } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult } from "bailian-cli-runtime"; import { ensureAlertReady, parseCondition, validateTemplateConditions } from "./shared.ts"; const UPDATE_TEMPLATE_API = "zeldaEasy.bailian-telemetry.alertTemplate.updateAlertTemplate"; @@ -59,7 +57,7 @@ export default defineTelemetryCommand({ validate: (flags) => validateTemplateConditions(flags.condition, false), async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { templateId: flags.templateId, @@ -70,14 +68,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: UPDATE_TEMPLATE_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: UPDATE_TEMPLATE_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -85,11 +80,16 @@ export default defineTelemetryCommand({ await ctx.client.console(UPDATE_TEMPLATE_API, { reqDTO }); - if (format === "json") { - emitResult({ updated: true, templateId: flags.templateId }, format); - return; - } - - process.stdout.write(`Alert template updated: ${flags.templateId}\n`); + ctx.output.result( + { updated: true, templateId: flags.templateId }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Alert template updated: ${flags.templateId}`, + "zh-CN": `告警模板已更新: ${flags.templateId}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/alert/update.ts b/packages/commands/src/commands/alert/update.ts index fb040ff61..cfe5af36a 100644 --- a/packages/commands/src/commands/alert/update.ts +++ b/packages/commands/src/commands/alert/update.ts @@ -1,6 +1,4 @@ -import { detectOutputFormat } from "bailian-cli-core"; import { defineTelemetryCommand, telemetryGatewayPreview } from "../shared/telemetry.ts"; -import { emitResult } from "bailian-cli-runtime"; import { ALERT_RULE_WRITE_FLAGS, buildAlertRuleReqDTO, @@ -37,7 +35,7 @@ export default defineTelemetryCommand({ validate: (flags) => validateAlertRuleFlags(flags), async run(ctx) { const { settings, flags } = ctx; - const format = detectOutputFormat(settings.output); + const format = settings.output; const reqDTO = { ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), @@ -46,14 +44,11 @@ export default defineTelemetryCommand({ }; if (settings.dryRun) { - emitResult( - { - api: UPDATE_RULE_API, - data: { reqDTO }, - ...telemetryGatewayPreview(settings), - }, - format, - ); + ctx.output.result({ + api: UPDATE_RULE_API, + data: { reqDTO }, + ...telemetryGatewayPreview(settings), + }); return; } @@ -61,11 +56,16 @@ export default defineTelemetryCommand({ await ctx.client.console(UPDATE_RULE_API, { reqDTO }); - if (format === "json") { - emitResult({ updated: true, ruleId: flags.ruleId }, format); - return; - } - - process.stdout.write(`Alert rule updated: ${flags.ruleId}\n`); + ctx.output.result( + { updated: true, ruleId: flags.ruleId }, + format === "text" + ? { + text: ctx.localize({ + "en-US": `Alert rule updated: ${flags.ruleId}`, + "zh-CN": `告警规则已更新: ${flags.ruleId}`, + }), + } + : undefined, + ); }, }); diff --git a/packages/commands/src/commands/model/code.ts b/packages/commands/src/commands/model/code.ts index eb8995290..94cd5d8fc 100644 --- a/packages/commands/src/commands/model/code.ts +++ b/packages/commands/src/commands/model/code.ts @@ -3,13 +3,11 @@ import { ExitCode, anonymousConsoleCall, defineCommand, - detectOutputFormat, fetchModelDetail, type ModelGroupItem, type ModelSampleCodeV2, type ModelSampleSnippet, } from "bailian-cli-core"; -import { emitBare, emitResult } from "bailian-cli-runtime"; import { pickTrunkItems } from "./shared.ts"; /** `--api` values map onto the API-style keys used inside `sampleCodeV2`. */ @@ -136,6 +134,7 @@ export default defineCommand({ "zh-CN": "打印调用指定模型的 SDK 示例代码", }, auth: "none", + defaultOutput: "text", usageArgs: "--model [--sdk ] [--api