From 476dd3b84119584c69e82bfe1501e5e676c45dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Tue, 7 Jul 2026 00:07:21 +0800 Subject: [PATCH] refactor(output): centralize ANSI styling and remove no-color flag - remove --no-color from GLOBAL_FLAGS and drop Settings.noColor - move ANSI styling decisions into runtime color helpers with NO_COLOR support - update command text renderers to use shared color helpers instead of local ANSI codes - refresh e2e invocations, generated reference, and agent skill guidance --- docs/{ => plans}/config-flags-refactor.md | 60 ++++++++++--------- packages/cli/tests/e2e/auth.e2e.test.ts | 11 +--- packages/cli/tests/e2e/config.e2e.test.ts | 10 +--- packages/cli/tests/e2e/quota.e2e.test.ts | 20 +------ packages/cli/tests/e2e/usage-free.e2e.test.ts | 8 --- .../cli/tests/e2e/usage-stats.e2e.test.ts | 7 --- .../src/commands/advisor/recommend.ts | 53 +++++++++------- packages/commands/src/commands/app/call.ts | 14 ++--- packages/commands/src/commands/quota/check.ts | 24 ++++---- .../commands/src/commands/quota/history.ts | 15 +++-- packages/commands/src/commands/quota/list.ts | 15 +++-- packages/commands/src/commands/text/chat.ts | 11 ++-- packages/commands/src/commands/update.ts | 28 ++++----- packages/commands/src/commands/usage/free.ts | 19 +++--- packages/commands/src/commands/usage/stats.ts | 26 ++++---- .../commands/src/commands/workspace/list.ts | 18 +++--- packages/core/src/config/loader.ts | 1 - packages/core/src/config/schema.ts | 1 - packages/core/src/telemetry/tracker.ts | 1 - packages/core/src/types/command.ts | 1 - packages/core/tests/config-priority.test.ts | 5 -- packages/core/tests/index.test.ts | 1 - packages/runtime/src/index.ts | 7 +++ packages/runtime/src/middleware.ts | 10 ++-- packages/runtime/src/output/banner.ts | 18 ++---- packages/runtime/src/output/color.ts | 54 +++++++++++++++++ packages/runtime/src/output/status-bar.ts | 18 +++--- packages/runtime/src/pipeline/bl-config.ts | 1 - packages/runtime/src/registry.ts | 19 +++--- skills/bailian-cli/SKILL.md | 9 +++ skills/bailian-cli/reference/index.md | 1 - 31 files changed, 237 insertions(+), 249 deletions(-) rename docs/{ => plans}/config-flags-refactor.md (82%) create mode 100644 packages/runtime/src/output/color.ts diff --git a/docs/config-flags-refactor.md b/docs/plans/config-flags-refactor.md similarity index 82% rename from docs/config-flags-refactor.md rename to docs/plans/config-flags-refactor.md index ca75250..7cf2178 100644 --- a/docs/config-flags-refactor.md +++ b/docs/plans/config-flags-refactor.md @@ -6,7 +6,7 @@ > > 实施(2026-07-06):**已按本方案完成**——前置 baseUrl 翻转 + 阶段 0–6 全部落地(含 flags 收窄/分流/同名守卫、console/advisor/pipeline 收口、tracker 传值、边界守卫测试 `packages/commands/tests/boundaries.test.ts`)。全量 `vp check`/单测/关键 e2e 绿;**未 commit,待 review**。实施中的偏差:advisor 匿名调网关促使 `callConsoleGateway` 收 `ConsoleGatewayTarget`(token 可选)而非整个 credential;`describeAuth` 更名 `describeAuthState`;`ConfigStore.reset` 无消费者未实现。 > -> flag 边界轮(2026-07-06):**域化完成**——flag 拆 `GLOBAL_FLAGS` + `MODEL_AUTH_FLAGS`/`CONSOLE_AUTH_FLAGS`(按命令 `auth`/`authFlags` 可见),16 处遮蔽清零,跨域传 flag 报错,help 改为 Flags(自有+域)/Global Flags(全量)三段式,`pipeline run --timeout` 更名 `--step-timeout`,login 经 `AuthStore.flagInput()` 收凭证输入。workspaceId 已升入 console 域(链 flag > env > file),stats 命令内优先级删除。 +> flag 边界轮(2026-07-06):**域化完成**——flag 拆 `GLOBAL_FLAGS` + `MODEL_AUTH_FLAGS`/`CONSOLE_AUTH_FLAGS`(按命令 `auth` 可见),16 处遮蔽清零,跨域传 flag 报错,help 改为 Flags(自有+域)/Global Flags(全量)三段式,`pipeline run --timeout` 更名 `--step-timeout`,login 凭证输入由自有 flags + `AuthStore.login()` 落盘。workspaceId 已升入 console 域(链 flag > env > file),stats 命令内优先级删除。 > > 修订(2026-07-04 评审后,均已拍板):§8 改 strangler 分阶段 + 阶段 0 行为锁定测试(已落地);§2 store 接口细化(write async / unset / AuthStore.login 揽登录落盘);§5 validate 收 ownFlags + 同名守卫 + authStage dry-run 双域容忍;§7 tracker/workspaceId 修法;§0/§9 优先级链保真口径。dry-run 决策:**保持"无需凭证"现状**,console 三元组归 Settings 服务 dry-run 展示(不引入 ConsoleTarget);dry-run 输出规范统一推后(§9)。 > @@ -23,7 +23,7 @@ > **ctx 是唯一组合根。它在边界处把 flag / env / file / 默认 各源解析成 `identity / settings / credential`,交给命令。** > -> 优先级链已**统一为 flag > env > file > 默认**:唯一异类 baseUrl(原 flag>file>env)已在前置独立 commit 翻转,锁定表(`packages/core/tests/config-priority.test.ts`)同步更新,`buildSettings` 逐字段对照锁定表移植。workspaceId 无全局 flag 源(见 §9);verbose/noColor 为 OR 语义、telemetry 的 DO_NOT_TRACK 为业界标准,均非链序问题。 +> 优先级链已**统一为 flag > env > file > 默认**:唯一异类 baseUrl(原 flag>file>env)已在前置独立 commit 翻转,锁定表(`packages/core/tests/config-priority.test.ts`)同步更新,`buildSettings` 逐字段对照锁定表移植。workspaceId 已升入 console 域(链 flag > env > file);verbose 为 OR 语义、telemetry 的 DO_NOT_TRACK 为业界标准,均非链序问题。 三条硬规矩: @@ -70,7 +70,6 @@ export interface Settings { output: "text" | "json"; outputDir?: string; timeout: number; - concurrent?: number; // 命令经 getConcurrency 读 → 归 settings defaultTextModel?: string; defaultVideoModel?: string; defaultImageModel?: string; @@ -82,11 +81,7 @@ export interface Settings { consoleSwitchAgent?: number; verbose: boolean; quiet: boolean; - noColor: boolean; - yes: boolean; dryRun: boolean; - nonInteractive: boolean; // 0 消费者,可留可删;留着零风险 - async: boolean; telemetry: boolean; } ``` @@ -236,11 +231,16 @@ export function describeAuth(s: ResolutionSources): AuthState; // auth status ```ts case "run": { - // 1) 一次解析(全局+命令 flag 合并) - const parsed = parseFlags(res.rest, { ...GLOBAL_FLAGS, ...res.command.flags }); + // 1) 一次解析(全局 + 凭证域 + 命令 flag 合并) + const credDefs = credentialFlagDefs(res.command); + const parsed = parseFlags(res.rest, { + ...GLOBAL_FLAGS, + ...credDefs, + ...res.command.flags, + }); // 2) 分流(见下"分流规则"),validate 收收窄后的 ownFlags(与 §2 签名一致,别传 parsed) - const globals = pick(parsed, Object.keys(GLOBAL_FLAGS)); // 全局 flag → sources + const globals = pick(parsed, [...Object.keys(GLOBAL_FLAGS), ...Object.keys(credDefs)]); // 全局+凭证域 → sources const ownFlags = pick(parsed, Object.keys(res.command.flags ?? {})); // 命令声明的 → ctx.flags const invalid = res.command.validate?.(ownFlags); if (invalid) throw new UsageError(invalid); @@ -262,13 +262,13 @@ case "run": { } ``` -**分流规则(重要,含"本次不改遮蔽"的妥协):** +**分流规则(重要):** -> **全局 flag 恒进 `sources`(用 `Object.keys(GLOBAL_FLAGS)`);命令声明的 flag 进 `ctx.flags`(用 `Object.keys(command.flags)`)。同名遮蔽者两边都出现(受控重叠)。** +> **全局 flag + 当前命令可见的凭证域 flag 进 `sources`;命令自有 flag 进 `ctx.flags`。** -为什么这样:约 15 个命令把全局 flag(`consoleSite/consoleRegion/switchAgent`、`async`)重声明成自己的,纯为 help 显示(声明不读)。若"命令声明的 key 一律归 ctx.flags 且不进 sources",这些遮蔽会让 `--console-region` 到不了 `resolveConsole`,区域覆盖静默失效。**本次不清理这些遮蔽**(已记录在钉钉文档),用"全局恒进 sources"规避,行为不变;代价是这 ~15 个 flag 在 ctx.flags 和 sources 各出现一次(auth login 的 apiKey/baseUrl 也在两边,但 auth:none 不解析,无害)。 +为什么这样:`MODEL_AUTH_FLAGS` / `CONSOLE_AUTH_FLAGS` 只按命令 `auth` 暴露,既保证 `--api-key` / `--console-region` 这类域 flag 能进入 credential/settings 解析链,也避免无关命令误收跨域 flag。历史同名遮蔽已清理,命令自有 flag 与全局/域 flag 不再受控重叠。 -**同名守卫**:registry 构建时断言 —— 命令 flag 与全局同名时,其 FlagDef `type` 必须一致。分流规则依赖"遮蔽都是同型重声明"这一假设;十行断言把口头约定变成机器约定,防止未来有人把 `async` 重声明成 value flag 后,错型值静默流进 sources。 +**同名守卫**:registry 构建时断言 —— 命令自有 flag 与全局/凭证域 flag 同名即报错。分流规则依赖"同一 key 只归一个域"这一约束,防止未来新增 flag 时把同名值静默分到错误通道。 `authStage`(`packages/runtime/src/middleware.ts`): @@ -292,17 +292,19 @@ const authStage = async (ctx, next) => { ## 6. 字段迁移对照(旧 `Config` → 去向) -| 旧 `Config` 字段 | 去向 | -| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `clientName` / `clientVersion` | **Identity**(`clientName` / `version`) | -| `binName` / `npmPackage` | **Identity** | -| `apiKey` / `apiKeyEnv` / `fileApiKey` / `fileAccessToken` | **删除** → provider chain 从 sources 读 | -| `baseUrl` | **ApiKeyCredential.baseUrl**(resolveApiKey 里解析) | -| `consoleSite` / `consoleRegion` / `consoleSwitchAgent` | **Settings**(dry-run 展示)+ **ConsoleCredential**(真实调用),受控重叠 | -| `workspaceId` | **Settings** | -| `output` / `outputDir` / `timeout` / `default*Model` | **Settings** | -| `verbose` / `quiet` / `noColor` / `dryRun` / `async` / `yes` / `telemetry` / `configPath` | **Settings** | -| `concurrent`(原本仅 flags) | **Settings**(`getConcurrency` 改读 settings) | +| 旧 `Config` 字段 | 去向 | +| ----------------------------------------------------------- | -------------------------------------------------------------------- | +| `clientName` / `clientVersion` | **Identity**(`clientName` / `version`) | +| `binName` / `npmPackage` | **Identity** | +| `apiKey` / `apiKeyEnv` / `fileApiKey` / `fileAccessToken` | **删除** → provider chain 从 sources 读 | +| `baseUrl` | **ApiKeyCredential.baseUrl**(resolveApiKey 里解析) | +| `consoleSite` / `consoleRegion` / `consoleSwitchAgent` | **Settings**(dry-run 展示)+ **ConsoleCredential**(真实调用),受控重叠 | +| `workspaceId` | **Settings** | +| `output` / `outputDir` / `timeout` / `default*Model` | **Settings** | +| `verbose` / `quiet` / `dryRun` / `telemetry` / `configPath` | **Settings** | +| `async` / `concurrent` | **命令自有 flag**(`ASYNC_FLAG` / `CONCURRENT_FLAG`) | +| `yes` | **命令自有 flag**(`quota request`) | +| `nonInteractive` | **删除** | --- @@ -314,10 +316,10 @@ const authStage = async (ctx, next) => { - `client/client.ts:38`:`apiCred?.baseUrl ?? config.baseUrl` → `apiCred.baseUrl` - 命令读 `config.binName`(约 6 处:`auth/status`、`usage/stats`、`mcp/list`、`quota/history`、`quota/request`)→ `identity.binName`(经 ctx) - **console 收口**:约 12 处 `callConsoleGateway(config, token, {api,data})`(`app/list`、`workspace/list`、`usage/*`、`mcp/list`、`quota/*`、`console/call`)→ `ctx.client.callConsole({api,data})`;dry-run 里的 `effectiveConsoleGatewayConfig(config)` → `effectiveConsoleGatewayConfig(settings)`(签名收窄,不走 client) -- **workspaceId**:`usage/stats.ts` 的 `resolveWorkspaceId(config, flag)` → `flags.workspaceId ?? requireWorkspace(ctx.settings)`(新 helper 只兜 settings,缺失时报原来的错 + `${identity.binName} workspace list` 提示)。**注意:`--workspace-id` 是命令级 flag、不在 GLOBAL_FLAGS,进不了 sources/settings,flag 的第一优先级必须在命令里显式保住**,否则静默丢失 +- **workspaceId**:`usage/stats.ts` 的 `resolveWorkspaceId(config, flag)` → `requireWorkspaceId(ctx.settings, identity.binName)`。`--workspace-id` 已升入 `CONSOLE_AUTH_FLAGS`,进入 sources/settings,链为 flag > env > file。 - **config/auth 命令**:`readConfigFile/writeConfigFile/resolver` 直接调用 → 走 `ctx.configStore()` / `ctx.authStore()` - **auth/login `validate`**:`!f.console && !f.apiKey`——`apiKey`/`baseUrl` 是它自己声明的 flag(`login.ts:15`),收窄后仍在 `ctx.flags`,**无需改** -- **pipeline**:`buildPipelineConfig`(伪造整套 GlobalFlags,`runtime/src/pipeline/bl-config.ts`)→ `buildSettings({ flags: {}, file: readConfigFile(), env })`(flags 已收 Partial,见 §4) + 强制 `output:'json'/quiet/nonInteractive`;pipeline executor 是"迷你边界",给 step 构造 settings/client +- **pipeline**:`buildPipelineConfig`(伪造整套 GlobalFlags,`runtime/src/pipeline/bl-config.ts`)→ `buildSettings({ flags: {}, file: readConfigFile(), env })`(flags 已收 Partial,见 §4) + 强制 `output:'json'/quiet`;pipeline executor 是"迷你边界",给 step 构造 settings/client - 其余把 `Config` 当类型用的地方 → `Settings`;ctx 字段 `config` → `settings`(`ctx.config` 仅 2 处、解构 `const { config } = ctx` 约 46 处 + 其函数体内 `config.` → `settings.`) **命名注意**: @@ -344,13 +346,13 @@ const authStage = async (ctx, next) => { ## 9. 本次不做(已在钉钉文档记录,后续单独轮次) -- **flag 清理**:`nonInteractive` 删 / `async` ↔ 各命令 `--no-wait` 去重 / `yes` 收窄到命令级 / `noColor` 修一致性(registry/progress/banner 里内联 `process.stderr.isTTY` 绕过了 `config.noColor`)。 +- ~~**flag 清理**~~ **已完成**:`nonInteractive` 删除;`async`/`concurrent` 改为命令级共享定义;`yes` 收窄为 `quota request` 自有;原颜色 CLI flag / Settings 字段删除,颜色由 `NO_COLOR` + 实际输出流 `isTTY` 共同决定,内联判断收束到 runtime helper。 - ~~workspaceId 的 flag 源~~ **已完成**(flag 边界轮):`--workspace-id` 升入 `CONSOLE_AUTH_FLAGS`,链为 flag > env > file;stats 删除自有声明与命令内优先级。(优先级链归一与同名遮蔽清理亦已完成:baseUrl 前置翻转见 §0,遮蔽经域化清零见 §5。) - **dry-run 输出规范统一**:各域输出现状不一致 —— model/app 域只打请求 body(不含 URL/baseUrl),console 域额外打 api 名 + region/site 路由信息。应一次定规范、跨域对齐(是否展示路由、展示哪些字段);届时若 console 域不再展示,console 三元组可从 Settings 撤出、收敛为纯 credential。**本次保持现状输出**(e2e 有断言)。 - ~~全局↔命令私有 flag 同名遮蔽清理~~ **已完成**(flag 边界轮,经域化):16 处遮蔽全删,凭证 flag 按 `auth` 域可见,跨域报 Unknown flag,守卫升级为同名即抛。 - **key ↔ baseUrl 强校验**(region 锁)。落点已就位:`resolveApiKey` 是唯一同时产出 `{token, baseUrl}` 的地方,校验加在它内部即可;baseUrl 不在 Settings,命令侧无法绕过绑定;`AuthStore.login` 已支持 `api_key` + `base_url` 成对落盘。 - **多 profile / 多身份**(arkcli 式)。结构已留缝:单一 `ResolutionSources` 边界 + credential 封装。 -- **IOStreams 注入**(gh `Factory.IOStreams` / vercel `Client.stdout`);与 noColor 修复同属下一轮。 +- **IOStreams 注入**(gh `Factory.IOStreams` / vercel `Client.stdout`);颜色 stream helper 已先行收束,完整 IOStreams 注入仍留后续轮次。 - `ConfigFile`(磁盘格式)不变;无用户可见 CLI 变化。 外部记录:钉钉文档 `https://alidocs.dingtalk.com/i/nodes/YMyQA2dXW7gYo6MzcZzzERNMWzlwrZgb`(全局 flag 对比、flag 清理项、遮蔽问题)。 diff --git a/packages/cli/tests/e2e/auth.e2e.test.ts b/packages/cli/tests/e2e/auth.e2e.test.ts index cf10000..89905cc 100644 --- a/packages/cli/tests/e2e/auth.e2e.test.ts +++ b/packages/cli/tests/e2e/auth.e2e.test.ts @@ -61,7 +61,6 @@ describe("e2e: auth", () => { "json", "--timeout", "120", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Would validate and save API key."); @@ -82,14 +81,8 @@ describe("e2e: auth", () => { expect(stderr).not.toContain("Cleared api_key"); }); - test("auth logout --dry-run --quiet --no-color", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "auth", - "logout", - "--dry-run", - "--quiet", - "--no-color", - ]); + test("auth logout --dry-run --quiet", async () => { + const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run", "--quiet"]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("No changes made."); }); diff --git a/packages/cli/tests/e2e/config.e2e.test.ts b/packages/cli/tests/e2e/config.e2e.test.ts index ddb6860..07700db 100644 --- a/packages/cli/tests/e2e/config.e2e.test.ts +++ b/packages/cli/tests/e2e/config.e2e.test.ts @@ -38,14 +38,8 @@ describe("e2e: config", () => { expect(data.timeout).toBeDefined(); }); - test("config show --output text --no-color", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "config", - "show", - "--output", - "text", - "--no-color", - ]); + test("config show --output text", async () => { + const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "text"]); expect(exitCode, stderr).toBe(0); expect(stdout).toMatch(/config_file|timeout|base_url/i); }); diff --git a/packages/cli/tests/e2e/quota.e2e.test.ts b/packages/cli/tests/e2e/quota.e2e.test.ts index 08c4ab5..eb80343 100644 --- a/packages/cli/tests/e2e/quota.e2e.test.ts +++ b/packages/cli/tests/e2e/quota.e2e.test.ts @@ -96,13 +96,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list 文本输出包含英文表头", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "quota", - "list", - "--output", - "text", - "--no-color", - ]); + const { stdout, stderr, exitCode } = await runCli(["quota", "list", "--output", "text"]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Model"); expect(stdout).toContain("Req/min"); @@ -118,7 +112,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { "qwen3.6-plus", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("qwen3.6-plus"); @@ -254,13 +247,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check 文本输出包含英文表头", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "quota", - "check", - "--output", - "text", - "--no-color", - ]); + const { stdout, stderr, exitCode } = await runCli(["quota", "check", "--output", "text"]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Model"); expect(stdout).toContain("RPM Usage/Limit"); @@ -276,7 +263,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { "qwen3.6-plus", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("qwen3.6-plus"); @@ -291,7 +277,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { "qwen3.6-plus,qwen-plus", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("qwen3.6-plus"); @@ -335,7 +320,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { "qwen3.6-plus", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); const hasStatus = diff --git a/packages/cli/tests/e2e/usage-free.e2e.test.ts b/packages/cli/tests/e2e/usage-free.e2e.test.ts index d83ef1d..c6e10df 100644 --- a/packages/cli/tests/e2e/usage-free.e2e.test.ts +++ b/packages/cli/tests/e2e/usage-free.e2e.test.ts @@ -146,7 +146,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "qwen3-max", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Model"); @@ -165,7 +164,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "qwen3-max", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("qwen3-max"); @@ -179,7 +177,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "qwen3-max,qwen-turbo", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("qwen3-max"); @@ -194,7 +191,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "qwen3-max", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Text"); @@ -208,7 +204,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "wan2.7-image", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Unsupported"); @@ -222,7 +217,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "wan2.7-image", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); const lines = stdout.split("\n").filter((line) => line.includes("wan2.7-image")); @@ -239,7 +233,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "nonexistent-model-xyz-12345", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("nonexistent-model-xyz-12345"); @@ -253,7 +246,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { "qwen3-max", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); const hasAutoStop = diff --git a/packages/cli/tests/e2e/usage-stats.e2e.test.ts b/packages/cli/tests/e2e/usage-stats.e2e.test.ts index 22b9931..7f1cc11 100644 --- a/packages/cli/tests/e2e/usage-stats.e2e.test.ts +++ b/packages/cli/tests/e2e/usage-stats.e2e.test.ts @@ -177,7 +177,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { wsId, "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); }); @@ -190,7 +189,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { wsId, "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); }); @@ -205,7 +203,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { "qwen3.6-plus", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); }); @@ -220,7 +217,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { "qwen3.6-plus,deepseek-v4-pro", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); }); @@ -235,7 +231,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { "nonexistent-model-xyz-99999", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); }); @@ -250,7 +245,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { "1", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); }); @@ -265,7 +259,6 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { "Vision", "--output", "text", - "--no-color", ]); expect(exitCode, stderr).toBe(0); }); diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index 0599329..a7f23b2 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -14,8 +14,7 @@ import { } from "bailian-cli-core"; import boxen from "boxen"; import chalk, { Chalk, type ChalkInstance } from "chalk"; -import { emitBare, emitResult } from "bailian-cli-runtime"; -import { createSpinner } from "bailian-cli-runtime"; +import { createSpinner, emitBare, emitResult, supportsColor } from "bailian-cli-runtime"; function formatContextWindow(tokens: number): string { if (tokens >= 1_000_000) @@ -55,8 +54,13 @@ const PREFERENCE_MODE_LABELS: Record = { alternative: "Alternative", }; -function formatIntentSummary(intent: IntentProfile, noColor: boolean): string { - const colorize = noColor ? new Chalk({ level: 0 }) : chalk; +function chalkFor(out: NodeJS.WriteStream): ChalkInstance { + return supportsColor(out) ? chalk : new Chalk({ level: 0 }); +} + +function formatIntentSummary(intent: IntentProfile): string { + const colorize = chalkFor(process.stdout); + const useColor = supportsColor(process.stdout); const lines: string[] = []; lines.push(colorize.cyan.bold("Intent Analysis")); @@ -121,15 +125,20 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string { return boxen(lines.join("\n"), { padding: { top: 0, bottom: 0, left: 1, right: 1 }, margin: { top: 0, bottom: 0, left: 1, right: 0 }, - borderColor: "cyan", + borderColor: useColor ? "cyan" : undefined, borderStyle: "round", - dimBorder: true, + dimBorder: useColor, }); } const RECOMMEND_LABELS = ["Best Pick", "Runner-Up", "Alternative"]; -function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstance): string { +function renderCard( + rec: RecommendedModel, + index: number, + colorize: ChalkInstance, + useColor: boolean, +): string { const labelColors = [colorize.green.bold, colorize.blue.bold, colorize.magenta.bold]; const colorFn = labelColors[index] ?? colorize.white.bold; const label = RECOMMEND_LABELS[index] ?? `#${index + 1}`; @@ -165,19 +174,21 @@ function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstanc return boxen(lines.join("\n"), { padding: { top: 0, bottom: 0, left: 1, right: 1 }, margin: { top: 0, bottom: 0, left: 1, right: 0 }, - borderColor: "gray", + borderColor: useColor ? "gray" : undefined, borderStyle: "round", - dimBorder: true, + dimBorder: useColor, }); } -function formatSingleResult(results: RecommendedModel[], noColor: boolean): string { - const colorize = noColor ? new Chalk({ level: 0 }) : chalk; - return results.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n"); +function formatSingleResult(results: RecommendedModel[]): string { + const colorize = chalkFor(process.stdout); + const useColor = supportsColor(process.stdout); + return results.map((rec, idx) => renderCard(rec, idx, colorize, useColor)).join("\n"); } -function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: boolean): string { - const colorize = noColor ? new Chalk({ level: 0 }) : chalk; +function formatPipelineResult(summary: string, steps: PipelineStep[]): string { + const colorize = chalkFor(process.stdout); + const useColor = supportsColor(process.stdout); const lines: string[] = []; lines.push(` ${colorize.yellow.bold("⚡ Pipeline")} ${summary}`); @@ -192,17 +203,19 @@ function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: b } lines.push(""); - lines.push(recommendations.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n")); + lines.push( + recommendations.map((rec, idx) => renderCard(rec, idx, colorize, useColor)).join("\n"), + ); } return lines.join("\n"); } -function formatResult(result: RecommendResult, noColor: boolean): string { +function formatResult(result: RecommendResult): string { if (result.type === "pipeline") { - return formatPipelineResult(result.summary, result.steps, noColor); + return formatPipelineResult(result.summary, result.steps); } - return formatSingleResult(result.recommendations, noColor); + return formatSingleResult(result.recommendations); } function isEmptyResult(result: RecommendResult): boolean { @@ -308,8 +321,8 @@ export default defineCommand({ return; } - emitBare(formatIntentSummary(intent, settings.noColor)); + emitBare(formatIntentSummary(intent)); emitBare(""); - emitBare(formatResult(result, settings.noColor)); + emitBare(formatResult(result)); }, }); diff --git a/packages/commands/src/commands/app/call.ts b/packages/commands/src/commands/app/call.ts index aa8960b..cdd2ef2 100644 --- a/packages/commands/src/commands/app/call.ts +++ b/packages/commands/src/commands/app/call.ts @@ -8,7 +8,7 @@ import { type AppStreamChunk, type AppCompletionResponse, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { ansi, emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ description: "Call a Bailian application (agent or workflow)", @@ -137,8 +137,7 @@ export default defineCommand({ let fullText = ""; let sessionId = ""; const writesStreamingStdout = format === "text"; - const dim = settings.noColor ? "" : "\x1b[2m"; - const reset = settings.noColor ? "" : "\x1b[0m"; + const stderrColor = ansi(process.stderr); for await (const event of parseSSE(res)) { if (event.data === "[DONE]") break; @@ -160,13 +159,14 @@ export default defineCommand({ // Show thoughts if available if (chunk.output?.thoughts && flags.hasThoughts) { for (const t of chunk.output.thoughts) { - if (t.thought) process.stderr.write(`${dim}[Thinking] ${t.thought}${reset}\n`); + if (t.thought) + process.stderr.write(`${stderrColor.dim(`[Thinking] ${t.thought}`)}\n`); if (t.action_name) process.stderr.write( - `${dim}[Action] ${t.action_name}: ${t.action_input || ""}${reset}\n`, + `${stderrColor.dim(`[Action] ${t.action_name}: ${t.action_input || ""}`)}\n`, ); if (t.observation) - process.stderr.write(`${dim}[Observation] ${t.observation}${reset}\n`); + process.stderr.write(`${stderrColor.dim(`[Observation] ${t.observation}`)}\n`); } } } catch { @@ -176,7 +176,7 @@ export default defineCommand({ // Show session_id for multi-turn conversation if (sessionId && !settings.quiet) { - process.stderr.write(`${dim}Session ID: ${sessionId}${reset}\n`); + process.stderr.write(`${stderrColor.dim(`Session ID: ${sessionId}`)}\n`); } if (format === "json") { diff --git a/packages/commands/src/commands/quota/check.ts b/packages/commands/src/commands/quota/check.ts index 97a51ce..f8b4277 100644 --- a/packages/commands/src/commands/quota/check.ts +++ b/packages/commands/src/commands/quota/check.ts @@ -4,7 +4,7 @@ import { detectOutputFormat, type Client, } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { ansi, emitResult } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels"; @@ -170,12 +170,8 @@ interface CheckRow { tpmLimit: number; } -function printTable(rows: CheckRow[], noColor: boolean): void { - const bold = noColor ? (t: string) => t : (t: string) => `\x1b[1m${t}\x1b[0m`; - const dim = noColor ? (t: string) => t : (t: string) => `\x1b[2m${t}\x1b[0m`; - const green = noColor ? (t: string) => t : (t: string) => `\x1b[32m${t}\x1b[0m`; - const yellow = noColor ? (t: string) => t : (t: string) => `\x1b[33m${t}\x1b[0m`; - const red = noColor ? (t: string) => t : (t: string) => `\x1b[31m${t}\x1b[0m`; +function printTable(rows: CheckRow[]): void { + const color = ansi(process.stdout); const headers = ["Model", "RPM Usage/Limit", "TPM Usage/Limit", "Status"]; @@ -202,8 +198,8 @@ function printTable(rows: CheckRow[], noColor: boolean): void { Math.max(displayWidth(label), ...tableRows.map((r) => displayWidth(r.cells[col]))), ); - const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((w) => dim("─".repeat(w))).join("──"); + 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"); @@ -212,16 +208,16 @@ function printTable(rows: CheckRow[], noColor: boolean): void { for (const r of tableRows) { const cells = r.cells.map((cell, col) => { if (col === statusCol) { - if (cell === "Rate Limited") return red(padEnd(cell, widths[col])); - if (cell === "Near limit") return yellow(padEnd(cell, widths[col])); - if (cell === "Normal") return green(padEnd(cell, widths[col])); + if (cell === "Rate Limited") return color.red(padEnd(cell, widths[col])); + if (cell === "Near limit") return color.yellow(padEnd(cell, widths[col])); + if (cell === "Normal") return color.green(padEnd(cell, widths[col])); } return padEnd(cell, widths[col]); }); process.stdout.write(cells.join(" ") + "\n"); } - process.stdout.write(dim(`\nTotal: ${rows.length} models`) + "\n"); + process.stdout.write(color.dim(`\nTotal: ${rows.length} models`) + "\n"); } export default defineCommand({ @@ -313,6 +309,6 @@ export default defineCommand({ return; } - printTable(checkRows, settings.noColor); + printTable(checkRows); }, }); diff --git a/packages/commands/src/commands/quota/history.ts b/packages/commands/src/commands/quota/history.ts index 0854451..a12db99 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 { emitResult } from "bailian-cli-runtime"; +import { ansi, emitResult } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; const HISTORY_API = "zeldaEasy.broadscope-platform.modelInstance.listModelLimitApplications"; @@ -53,9 +53,8 @@ function formatNumber(num: number): string { return num.toLocaleString("en-US"); } -function printTable(records: LimitApplicationItem[], noColor: boolean, total: number): void { - const bold = noColor ? (t: string) => t : (t: string) => `\x1b[1m${t}\x1b[0m`; - const dim = noColor ? (t: string) => t : (t: string) => `\x1b[2m${t}\x1b[0m`; +function printTable(records: LimitApplicationItem[], total: number): void { + const color = ansi(process.stdout); const headers = ["Model", "Token Limit", "Applied At"]; @@ -69,8 +68,8 @@ function printTable(records: LimitApplicationItem[], noColor: boolean, total: nu Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))), ); - const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((w) => dim("─".repeat(w))).join("──"); + 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"); @@ -79,7 +78,7 @@ function printTable(records: LimitApplicationItem[], noColor: boolean, total: nu process.stdout.write(row.map((cell, col) => padEnd(cell, widths[col])).join(" ") + "\n"); } - process.stdout.write(dim(`\nTotal: ${total} records`) + "\n"); + process.stdout.write(color.dim(`\nTotal: ${total} records`) + "\n"); } export default defineCommand({ @@ -157,6 +156,6 @@ export default defineCommand({ return; } - printTable(records, settings.noColor, modelFilter ? records.length : total); + printTable(records, modelFilter ? records.length : total); }, }); diff --git a/packages/commands/src/commands/quota/list.ts b/packages/commands/src/commands/quota/list.ts index 4eeed99..db3be9e 100644 --- a/packages/commands/src/commands/quota/list.ts +++ b/packages/commands/src/commands/quota/list.ts @@ -1,5 +1,5 @@ import { defineCommand, BailianError, detectOutputFormat, type Client } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { ansi, emitResult } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels"; @@ -91,9 +91,8 @@ async function fetchAllModelsWithQpm( return allModels; } -function printTable(models: ModelWithQpm[], noColor: boolean): void { - const bold = noColor ? (t: string) => t : (t: string) => `\x1b[1m${t}\x1b[0m`; - const dim = noColor ? (t: string) => t : (t: string) => `\x1b[2m${t}\x1b[0m`; +function printTable(models: ModelWithQpm[]): void { + const color = ansi(process.stdout); const headers = ["Model", "Req/min", "Token/min", "Max TPM"]; @@ -125,8 +124,8 @@ function printTable(models: ModelWithQpm[], noColor: boolean): void { Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))), ); - const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((w) => dim("─".repeat(w))).join("──"); + 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"); @@ -135,7 +134,7 @@ function printTable(models: ModelWithQpm[], noColor: boolean): void { process.stdout.write(row.map((cell, col) => padEnd(cell, widths[col])).join(" ") + "\n"); } - process.stdout.write(dim(`\nTotal: ${models.length} models`) + "\n"); + process.stdout.write(color.dim(`\nTotal: ${models.length} models`) + "\n"); } export default defineCommand({ @@ -217,6 +216,6 @@ export default defineCommand({ return; } - printTable(models, settings.noColor); + printTable(models); }, }); diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index 941044e..a96b82a 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -10,7 +10,7 @@ import { type FlagsDef, type ParsedFlags, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { ansi, emitResult, emitBare } from "bailian-cli-runtime"; import { readFileSync } from "fs"; const CHAT_FLAGS = { @@ -180,12 +180,11 @@ export default defineCommand({ let textContent = ""; let inThinking = false; const writesStreamingStdout = format === "text"; - const dim = settings.noColor ? "" : "\x1b[2m"; - const reset = settings.noColor ? "" : "\x1b[0m"; const isTTY = process.stdout.isTTY; const statusOut = format === "json" ? process.stderr : isTTY ? process.stdout : process.stderr; const resultOut = process.stdout; + const statusColor = ansi(statusOut); for await (const event of parseSSE(res)) { if (event.data === "[DONE]") break; @@ -199,7 +198,7 @@ export default defineCommand({ if (delta.reasoning_content) { if (writesStreamingStdout && !inThinking) { inThinking = true; - statusOut.write(`${dim}Thinking:\n`); + statusOut.write(statusColor.dim("Thinking:\n")); } if (writesStreamingStdout) statusOut.write(delta.reasoning_content); } @@ -207,7 +206,7 @@ export default defineCommand({ // Handle regular content if (delta.content) { if (writesStreamingStdout && inThinking) { - statusOut.write(`${reset}\n\nResponse:\n`); + statusOut.write(`${statusColor.reset}\n\nResponse:\n`); inThinking = false; } textContent += delta.content; @@ -218,7 +217,7 @@ export default defineCommand({ // Skip unparseable chunks } } - if (inThinking) statusOut.write(reset); + if (inThinking) statusOut.write(statusColor.reset); if (format === "json") { emitResult({ content: textContent }, format); diff --git a/packages/commands/src/commands/update.ts b/packages/commands/src/commands/update.ts index 363455f..a49554d 100644 --- a/packages/commands/src/commands/update.ts +++ b/packages/commands/src/commands/update.ts @@ -2,7 +2,7 @@ import { execSync } from "child_process"; import { writeFileSync } from "fs"; import { join } from "path"; import { defineCommand, getConfigDir } from "bailian-cli-core"; -import { fetchLatestVersion } from "bailian-cli-runtime"; +import { ansi, fetchLatestVersion, type AnsiStyles } from "bailian-cli-runtime"; const SKILL_SOURCE = "modelstudioai/cli"; const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`; @@ -12,17 +12,16 @@ function detectInstallCommand(npmPackage: string): { cmd: string; label: string return { cmd: `npm install -g ${npmPackage}@latest`, label: "npm" }; } -function updateAgentSkill(colors: { green: string; yellow: string; reset: string }): void { - const { green, yellow, reset } = colors; +function updateAgentSkill(color: AnsiStyles): void { process.stderr.write("\nUpdating agent skill...\n"); try { // Reinstall (not `skills update`) into ~/.agents/skills/ and sync to all agent apps. // `--all` on `skills add` means --skill '*' --agent '*' -y (Cursor, Claude Code, etc.). execSync(SKILL_INSTALL_CMD, { stdio: "inherit" }); - process.stderr.write(`${green}\u2713 Agent skill updated.${reset}\n`); + process.stderr.write(`${color.green("\u2713 Agent skill updated.")}\n`); } catch { process.stderr.write( - `${yellow}Agent skill update skipped. Run manually: ${SKILL_INSTALL_CMD}${reset}\n`, + `${color.yellow(`Agent skill update skipped. Run manually: ${SKILL_INSTALL_CMD}`)}\n`, ); } } @@ -36,25 +35,22 @@ export default defineCommand({ const npmPackage = identity.npmPackage; const binName = identity.binName; const currentVersion = identity.version; - const isTTY = process.stderr.isTTY; - const green = isTTY ? "\x1b[32m" : ""; - const yellow = isTTY ? "\x1b[33m" : ""; - const reset = isTTY ? "\x1b[0m" : ""; + const color = ansi(process.stderr); - process.stderr.write(`Current version: ${yellow}${currentVersion}${reset}\n`); + process.stderr.write(`Current version: ${color.yellow(currentVersion)}\n`); // Check latest version first process.stderr.write("Checking for updates...\n"); const latest = await fetchLatestVersion(5000, npmPackage); if (latest && latest === currentVersion) { - process.stderr.write(`${green}\u2713 Already up to date (${currentVersion}).${reset}\n`); - updateAgentSkill({ green, yellow, reset }); + process.stderr.write(`${color.green(`\u2713 Already up to date (${currentVersion}).`)}\n`); + updateAgentSkill(color); return; } if (latest) { - process.stderr.write(`Latest version: ${green}${latest}${reset}\n\n`); + process.stderr.write(`Latest version: ${color.green(latest)}\n\n`); } const { cmd, label } = detectInstallCommand(npmPackage); @@ -68,7 +64,7 @@ export default defineCommand({ // ` --version` outputs " X.Y.Z" — extract just the version number const newVer = rawVer.replace(new RegExp(`^${binName}\\s+`), ""); process.stderr.write( - `\n${green}\u2713 Update complete: ${currentVersion} \u2192 ${newVer}${reset}\n`, + `\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`, ); // Update the cached state so the post-run notification doesn't fire try { @@ -81,9 +77,9 @@ export default defineCommand({ /* ignore */ } } catch { - process.stderr.write(`\n${green}\u2713 Update complete.${reset}\n`); + process.stderr.write(`\n${color.green("\u2713 Update complete.")}\n`); } - updateAgentSkill({ green, yellow, reset }); + updateAgentSkill(color); } catch { process.stderr.write("\nAutomatic update failed. Please run manually:\n"); process.stderr.write(` ${cmd}\n\n`); diff --git a/packages/commands/src/commands/usage/free.ts b/packages/commands/src/commands/usage/free.ts index cdce3c9..4d4cd24 100644 --- a/packages/commands/src/commands/usage/free.ts +++ b/packages/commands/src/commands/usage/free.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, fetchModelList, type Client } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { ansi, emitResult } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota"; @@ -68,8 +68,8 @@ function printTable( quotas: FreeTierQuota[], stopMap: Map, typeMap: Map, - noColor: boolean, ): void { + const color = ansi(process.stdout); const headers = ["Model", "Type", "Remaining/Total", "Usage", "Expires", "Auto-Stop"]; const rows = quotas.map((quota) => { @@ -97,14 +97,9 @@ function printTable( Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))), ); - const dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`; - const bold = noColor ? (text: string) => text : (text: string) => `\x1b[1m${text}\x1b[0m`; - const green = noColor ? (text: string) => text : (text: string) => `\x1b[32m${text}\x1b[0m`; - const yellow = noColor ? (text: string) => text : (text: string) => `\x1b[33m${text}\x1b[0m`; - const autoStopCol = headers.length - 1; - const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((width) => dim("─".repeat(width))).join("──"); + 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"); @@ -112,8 +107,8 @@ function printTable( for (const row of rows) { const cells = row.map((cell, col) => { if (col === autoStopCol) { - if (cell === "ON") return green(padEnd(cell, widths[col])); - if (cell === "OFF") return yellow(padEnd(cell, widths[col])); + if (cell === "ON") return color.green(padEnd(cell, widths[col])); + if (cell === "OFF") return color.yellow(padEnd(cell, widths[col])); } return padEnd(cell, widths[col]); }); @@ -333,6 +328,6 @@ export default defineCommand({ return; } - printTable(quotas, stopMap, typeMap, settings.noColor); + printTable(quotas, stopMap, typeMap); }, }); diff --git a/packages/commands/src/commands/usage/stats.ts b/packages/commands/src/commands/usage/stats.ts index e1cc0b0..0d45ac1 100644 --- a/packages/commands/src/commands/usage/stats.ts +++ b/packages/commands/src/commands/usage/stats.ts @@ -6,7 +6,7 @@ import { type Settings, type Client, } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { ansi, emitResult } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; const OVERVIEW_API = "zeldaEasy.bailian-telemetry.model.getModelUsageStatistic"; @@ -181,13 +181,11 @@ function printOverview( startTime: number, endTime: number, days: number, - noColor: boolean, ): void { - const bold = noColor ? (text: string) => text : (text: string) => `\x1b[1m${text}\x1b[0m`; - const dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`; + const color = ansi(process.stdout); process.stdout.write( - `${dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${dim(`(${days} days)`)}\n\n`, + `${color.dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${color.dim(`(${days} days)`)}\n\n`, ); const rows: [string, string][] = [ @@ -203,7 +201,7 @@ function printOverview( const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); for (const [label, value] of rows) { - process.stdout.write(`${bold(padEnd(label, maxLabel + 2))}${value}\n`); + process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); } } @@ -212,13 +210,11 @@ function printModelTable( startTime: number, endTime: number, days: number, - noColor: boolean, ): void { - const bold = noColor ? (text: string) => text : (text: string) => `\x1b[1m${text}\x1b[0m`; - const dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`; + const color = ansi(process.stdout); process.stdout.write( - `${dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${dim(`(${days} days)`)}\n\n`, + `${color.dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${color.dim(`(${days} days)`)}\n\n`, ); if (items.length === 0) { @@ -263,8 +259,8 @@ function printModelTable( Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))), ); - const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((width) => dim("─".repeat(width))).join("──"); + 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"); @@ -274,7 +270,7 @@ function printModelTable( process.stdout.write(cells.join(" ") + "\n"); } - process.stdout.write(dim(`\nTotal: ${items.length} models`) + "\n"); + process.stdout.write(color.dim(`\nTotal: ${items.length} models`) + "\n"); } export default defineCommand({ @@ -382,7 +378,7 @@ export default defineCommand({ return; } - printModelTable(allItems, startTime, endTime, daysFlag, settings.noColor); + printModelTable(allItems, startTime, endTime, daysFlag); } else { const reqDTO: Record = { startTime, @@ -438,7 +434,7 @@ export default defineCommand({ return; } - printOverview(stat, startTime, endTime, daysFlag, settings.noColor); + printOverview(stat, startTime, endTime, daysFlag); } }, }); diff --git a/packages/commands/src/commands/workspace/list.ts b/packages/commands/src/commands/workspace/list.ts index d94dc67..c4fd1d7 100644 --- a/packages/commands/src/commands/workspace/list.ts +++ b/packages/commands/src/commands/workspace/list.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { ansi, emitResult } from "bailian-cli-runtime"; import { displayWidth, padEnd } from "bailian-cli-runtime"; const LIST_WORKSPACES_API = "zeldaEasy.bailian-dash-workspace.space.listWorkspaces"; @@ -34,10 +34,8 @@ function extractResponseData(result: Record): Record text : (text: string) => `\x1b[1m${text}\x1b[0m`; - const dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`; - const green = noColor ? (text: string) => text : (text: string) => `\x1b[32m${text}\x1b[0m`; +function printTable(workspaces: WorkspaceInfo[]): void { + const color = ansi(process.stdout); const headers = ["Name", "Workspace ID", "Default"]; @@ -51,21 +49,21 @@ function printTable(workspaces: WorkspaceInfo[], noColor: boolean): void { Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))), ); - const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((width) => dim("─".repeat(width))).join("──"); + 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"); for (const row of rows) { const cells = row.map((cell, col) => { - if (col === 2 && cell === "Yes") return green(padEnd(cell, widths[col])); + if (col === 2 && cell === "Yes") return color.green(padEnd(cell, widths[col])); return padEnd(cell, widths[col]); }); process.stdout.write(cells.join(" ") + "\n"); } - process.stdout.write(dim(`\nTotal: ${workspaces.length}`) + "\n"); + process.stdout.write(color.dim(`\nTotal: ${workspaces.length}`) + "\n"); } export default defineCommand({ @@ -116,6 +114,6 @@ export default defineCommand({ return; } - printTable(workspaces, settings.noColor); + printTable(workspaces); }, }); diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 8fe349e..585574e 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -75,7 +75,6 @@ export function buildSettings(s: ResolutionSources): Settings { consoleSwitchAgent: flags.consoleSwitchAgent || file.console_switch_agent || undefined, verbose: flags.verbose || env.DASHSCOPE_VERBOSE === "1", quiet: flags.quiet || false, - noColor: flags.noColor || env.NO_COLOR !== undefined || !process.stdout.isTTY, dryRun: flags.dryRun || false, telemetry: env.DO_NOT_TRACK === "1" ? false : (file.telemetry ?? true), }; diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index d9132f5..f80d769 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -123,7 +123,6 @@ export interface Settings { consoleSwitchAgent?: number; verbose: boolean; quiet: boolean; - noColor: boolean; dryRun: boolean; telemetry: boolean; } diff --git a/packages/core/src/telemetry/tracker.ts b/packages/core/src/telemetry/tracker.ts index 9b36c72..b88d353 100644 --- a/packages/core/src/telemetry/tracker.ts +++ b/packages/core/src/telemetry/tracker.ts @@ -10,7 +10,6 @@ const GLOBAL_FLAG_KEYS = new Set([ "quiet", "verbose", "timeout", - "noColor", "dryRun", "help", "console", diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 6f78fe6..b7a8965 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -70,7 +70,6 @@ export const GLOBAL_FLAGS = { timeout: { type: "number", valueHint: "", description: "Request timeout" }, quiet: { type: "switch", description: "Suppress non-essential output" }, verbose: { type: "switch", description: "Print HTTP request/response details" }, - noColor: { type: "switch", description: "Disable ANSI colors" }, dryRun: { type: "switch", description: "Dry run mode" }, help: { type: "switch", description: "Show help" }, version: { type: "switch", description: "Print version" }, diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 5b1c6e5..3b5da4e 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -91,11 +91,6 @@ test("telemetry:DO_NOT_TRACK=1 一票否决 > file > 默认 true", () => { expect(resolve({}).telemetry).toBe(true); }); -test("noColor:NO_COLOR 只看存在性(空串也算);非 TTY 下恒为 true", () => { - expect(resolve({ env: { NO_COLOR: "" } }).noColor).toBe(true); - if (!process.stdout.isTTY) expect(resolve({}).noColor).toBe(true); -}); - test("apiKey 凭证:flag > env > file,source 字段随之;无 key 抛 AUTH", () => { const all = src({ flags: { apiKey: "sk-flag" }, diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 69cf56b..c04f08b 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -22,7 +22,6 @@ function testDeps(identity: Partial = {}): { identity: Identity; setti timeout: 30, verbose: false, quiet: true, - noColor: true, dryRun: false, telemetry: true, }, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b86e0f7..2d83fa5 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -30,6 +30,13 @@ export { createSpinner, createProgressBar } from "./output/progress.ts"; export { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; export { maybeShowStatusBar } from "./output/status-bar.ts"; export { displayWidth, padEnd } from "./output/cjk-width.ts"; +export { + ansi, + isTerminal, + supportsColor, + type AnsiStyles, + type TextStyle, +} from "./output/color.ts"; // Utility facilities consumed by commands export { poll } from "./utils/polling.ts"; diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 20a10fa..df7ff74 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -19,6 +19,7 @@ import { trackCommandExecution, } from "bailian-cli-core"; import { maybeShowStatusBar } from "./output/status-bar.ts"; +import { ansi } from "./output/color.ts"; import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts"; /** @@ -116,14 +117,11 @@ 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) { - const isTTY = process.stderr.isTTY; - const yellow = isTTY ? "\x1b[33m" : ""; - const cyan = isTTY ? "\x1b[36m" : ""; - const reset = isTTY ? "\x1b[0m" : ""; + const color = ansi(process.stderr); process.stderr.write( - `\n ${yellow}Update available: ${ctx.identity.version} → ${newVersion}${reset}\n`, + `\n ${color.yellow(`Update available: ${ctx.identity.version} → ${newVersion}`)}\n`, ); - process.stderr.write(` Run ${cyan}${ctx.identity.binName} update${reset} to upgrade\n\n`); + process.stderr.write(` Run ${color.cyan(`${ctx.identity.binName} update`)} to upgrade\n\n`); } }; diff --git a/packages/runtime/src/output/banner.ts b/packages/runtime/src/output/banner.ts index 82d9143..ebb8090 100644 --- a/packages/runtime/src/output/banner.ts +++ b/packages/runtime/src/output/banner.ts @@ -1,4 +1,5 @@ import { API_KEY_PAGE } from "../urls.ts"; +import { ansi } from "./color.ts"; const QUICK_START_TASKS = [ "Help me generate a set of Amazon e-commerce main images for baseball caps (white background + lifestyle shots + model wear shots)", @@ -7,28 +8,19 @@ const QUICK_START_TASKS = [ "Help me analyze this video and write a Xiaohongshu-style post", ]; -function colors() { - const isTTY = process.stderr.isTTY; - return { - purple: isTTY ? "\x1b[38;2;147;51;234m" : "", - dim: isTTY ? "\x1b[2m" : "", - reset: isTTY ? "\x1b[0m" : "", - }; -} - export function printWelcomeBanner(cliName: string): void { - const { purple, reset } = colors(); - process.stderr.write(`\n Welcome to ${purple}Bailian${reset} CLI!\n\n`); + const color = ansi(process.stderr); + process.stderr.write(`\n Welcome to ${color.purple("Bailian")} CLI!\n\n`); process.stderr.write(" Get started in 2 steps:\n"); process.stderr.write(` 1. Get your API Key: ${API_KEY_PAGE}\n`); process.stderr.write(` 2. Login: ${cliName} auth login --api-key \n\n`); } export function printQuickStart(): void { - const { dim, reset } = colors(); + const color = ansi(process.stderr); process.stderr.write("\n🎯 Try these with your AI coding assistant:\n\n"); QUICK_START_TASKS.forEach((task, i) => { - process.stderr.write(`${dim}${i + 1}${reset} ${task}\n`); + process.stderr.write(`${color.dim(String(i + 1))} ${task}\n`); }); process.stderr.write("\n"); } diff --git a/packages/runtime/src/output/color.ts b/packages/runtime/src/output/color.ts new file mode 100644 index 0000000..bfc3b3a --- /dev/null +++ b/packages/runtime/src/output/color.ts @@ -0,0 +1,54 @@ +export type TextStyle = (text: string) => string; + +export interface AnsiStyles { + bold: TextStyle; + dim: TextStyle; + green: TextStyle; + yellow: TextStyle; + red: TextStyle; + cyan: TextStyle; + blue: TextStyle; + magenta: TextStyle; + white: TextStyle; + accent: TextStyle; + logo: TextStyle; + purple: TextStyle; + brandBlue: TextStyle; + keyPink: TextStyle; + reset: string; +} + +const plain: TextStyle = (text) => text; + +function wrap(enabled: boolean, code: string): TextStyle { + return enabled ? (text) => `\x1b[${code}m${text}\x1b[0m` : plain; +} + +export function isTerminal(out: NodeJS.WriteStream): boolean { + return out.isTTY === true; +} + +export function supportsColor(out: NodeJS.WriteStream): boolean { + return !("NO_COLOR" in process.env) && isTerminal(out); +} + +export function ansi(out: NodeJS.WriteStream): AnsiStyles { + const enabled = supportsColor(out); + return { + bold: wrap(enabled, "1"), + dim: wrap(enabled, "2"), + green: wrap(enabled, "32"), + yellow: wrap(enabled, "33"), + red: wrap(enabled, "31"), + cyan: wrap(enabled, "36"), + blue: wrap(enabled, "34"), + magenta: wrap(enabled, "35"), + white: wrap(enabled, "37"), + accent: wrap(enabled, "38;2;59;130;246"), + logo: wrap(enabled, "38;2;97;92;237"), + purple: wrap(enabled, "38;2;147;51;234"), + brandBlue: wrap(enabled, "1;38;2;43;82;255"), + keyPink: wrap(enabled, "38;2;236;72;153"), + reset: enabled ? "\x1b[0m" : "", + }; +} diff --git a/packages/runtime/src/output/status-bar.ts b/packages/runtime/src/output/status-bar.ts index 607ef21..2ceb72c 100644 --- a/packages/runtime/src/output/status-bar.ts +++ b/packages/runtime/src/output/status-bar.ts @@ -1,11 +1,6 @@ import { homedir } from "os"; import { maskToken, type Settings, type ApiKeyCredential } from "bailian-cli-core"; - -const reset = "\x1b[0m"; -const dim = "\x1b[2m"; -const bold = "\x1b[1m"; -const mmBlue = "\x1b[38;2;43;82;255m"; -const mmPink = "\x1b[38;2;236;72;153m"; +import { ansi, isTerminal } from "./color.ts"; function tildePath(p: string): string { return p.startsWith(homedir()) ? p.replace(homedir(), "~") : p; @@ -16,16 +11,17 @@ export function maybeShowStatusBar( token: string, resolved: ApiKeyCredential, ): void { - if (settings.quiet || !process.stderr.isTTY) return; + if (settings.quiet || !isTerminal(process.stderr)) return; const filePath = settings.configPath ? tildePath(settings.configPath) : "~/.bailian/config.json"; const authTag = `${resolved.source} · api-key`; const maskedKey = maskToken(token); + const color = ansi(process.stderr); process.stderr.write( - `${bold}${mmBlue}BAILIAN${reset} ` + - `${dim}${filePath}${reset} ` + - `${dim}|${reset} ` + - `${dim}Auth:${reset} ${mmPink}${maskedKey}${reset} ${dim}${authTag}${reset}\n`, + `${color.brandBlue("BAILIAN")} ` + + `${color.dim(filePath)} ` + + `${color.dim("|")} ` + + `${color.dim("Auth:")} ${color.keyPink(maskedKey)} ${color.dim(authTag)}\n`, ); } diff --git a/packages/runtime/src/pipeline/bl-config.ts b/packages/runtime/src/pipeline/bl-config.ts index 230e209..e618ad3 100644 --- a/packages/runtime/src/pipeline/bl-config.ts +++ b/packages/runtime/src/pipeline/bl-config.ts @@ -26,7 +26,6 @@ export function buildPipelineEnv(): PipelineEnv { const settings: Settings = { ...buildSettings(sources), output: "json", - noColor: true, quiet: true, }; const identity: Identity = { diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index cc21259..8fc7a05 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -7,6 +7,7 @@ import { credentialFlagDefs, } from "bailian-cli-core"; import { camelToKebab } from "./args.ts"; +import { ansi } from "./output/color.ts"; export type { Command, AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core"; @@ -186,11 +187,10 @@ export class CommandRegistry { return lines.map((l) => ` ${a(l.flag.padEnd(maxLen + 2))} ${d(l.desc)}`).join("\n"); } - // Color helpers — no-ops when output is not a TTY - private bold = (s: string, out: NodeJS.WriteStream) => (out.isTTY ? `\x1b[1m${s}\x1b[0m` : s); - private accent = (s: string, out: NodeJS.WriteStream) => - out.isTTY ? `\x1b[38;2;59;130;246m${s}\x1b[0m` : s; - private dim = (s: string, out: NodeJS.WriteStream) => (out.isTTY ? `\x1b[2m${s}\x1b[0m` : s); + // Color helpers — no-ops when output is not a TTY. + private bold = (s: string, out: NodeJS.WriteStream) => ansi(out).bold(s); + private accent = (s: string, out: NodeJS.WriteStream) => ansi(out).accent(s); + private dim = (s: string, out: NodeJS.WriteStream) => ansi(out).dim(s); printHelp(commandPath: string[], out: NodeJS.WriteStream = process.stdout): void { if (commandPath.length === 0) { @@ -254,16 +254,11 @@ ${d(` ${this.cliName} pipeline run workflow.yaml --dry-run --output json`)} "██████╔╝██║ ██║██║███████╗██║██║ ██║██║ ╚████║", "╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝", ]; - const PURPLE = "\x1b[38;2;97;92;237m"; - const RESET = "\x1b[0m"; + const color = ansi(out); out.write("\n"); for (const line of LOGO) { - if (out.isTTY) { - out.write(`${PURPLE}${line}${RESET}\n`); - } else { - out.write(line + "\n"); - } + out.write(`${color.logo(line)}\n`); } const b = (s: string) => this.bold(s, out); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 427c87b..39ff290 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -34,6 +34,15 @@ Auto-generated from the CLI source at build time. Before running an unfamiliar c Do not guess flags — use the reference files or `--help`. +### Color output + +When an agent needs plain text without ANSI color codes (for parsing, logs, or +snapshots), run the command with `NO_COLOR=1`: + +```bash +NO_COLOR=1 bl config show --output text +``` + --- ## When to use which command diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 3ccc675..93726ad 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -92,7 +92,6 @@ Available on every command (in addition to command-specific flags): | `--timeout ` | number | no | Request timeout | | `--quiet` | switch | no | Suppress non-essential output | | `--verbose` | switch | no | Print HTTP request/response details | -| `--no-color` | switch | no | Disable ANSI colors | | `--dry-run` | switch | no | Dry run mode | | `--help` | switch | no | Show help | | `--version` | switch | no | Print version |