From 80bdcb83f6f073bb58d26cfa378bb829ac59b041 Mon Sep 17 00:00:00 2001 From: sonicg <85807784@qq.com> Date: Wed, 5 Aug 2026 23:28:39 +0800 Subject: [PATCH 1/4] feat(usage): add token plan usage view --- packages/cli/src/commands.ts | 2 + .../commands/src/commands/usage/token-plan.ts | 144 ++++++++++++++++++ packages/commands/src/index.ts | 1 + packages/commands/tests/e2e/topic-routes.ts | 1 + .../tests/e2e/usage-token-plan.e2e.test.ts | 88 +++++++++++ .../commands/tests/token-plan-usage.test.ts | 54 +++++++ skills/bailian-cli/reference/index.md | 3 +- skills/bailian-cli/reference/usage.md | 42 ++++- 8 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 packages/commands/src/commands/usage/token-plan.ts create mode 100644 packages/commands/tests/e2e/usage-token-plan.e2e.test.ts create mode 100644 packages/commands/tests/token-plan-usage.test.ts diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2cbda30d..6b130caa 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -45,6 +45,7 @@ import { usageFreetier, usageStats, usageSummary, + usageTokenPlan, pipelineRun, pipelineValidate, advisorRecommend, @@ -163,6 +164,7 @@ export const commands: Record = { "usage freetier": usageFreetier, "usage stats": usageStats, "usage summary": usageSummary, + "usage token-plan": usageTokenPlan, "pipeline run": pipelineRun, "pipeline validate": pipelineValidate, "advisor recommend": advisorRecommend, diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts new file mode 100644 index 00000000..968b270e --- /dev/null +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -0,0 +1,144 @@ +import { BailianError, ExitCode, defineCommand, unwrapResponse } from "bailian-cli-core"; +import { ansi, displayWidth, emitResult, type TextStyle } from "bailian-cli-runtime"; + +const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"; +const BOX_WIDTH = 76; +const PROGRESS_WIDTH = 32; + +interface TokenPlanUsage { + per5HourPercentage: number; + per5HourResetTime: number; + per1WeekPercentage: number; + per1WeekResetTime: number; +} + +function readUsage(result: unknown): TokenPlanUsage { + const response = unwrapResponse(result as Record); + const usage = { + per5HourPercentage: response.per5HourPercentage, + per5HourResetTime: response.per5HourResetTime, + per1WeekPercentage: response.per1WeekPercentage, + per1WeekResetTime: response.per1WeekResetTime, + }; + + if (!Object.values(usage).every((value) => typeof value === "number" && Number.isFinite(value))) { + throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL); + } + + return usage as TokenPlanUsage; +} + +function formatPercentage(ratio: number): string { + return `${(ratio * 100).toFixed(2)}%`; +} + +function formatDateTime(timestamp: number): string { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + const second = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} + +function formatRemainingTime(resetTime: number, now: number): string { + const remainingMs = Math.max(0, resetTime - now); + const totalMinutes = Math.floor(remainingMs / 60_000); + if (totalMinutes === 0) return "now"; + + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`); + return parts.join(" "); +} + +function progressBar(ratio: number): string { + const clampedRatio = Math.min(1, Math.max(0, ratio)); + const filled = Math.round(clampedRatio * PROGRESS_WIDTH); + return `[${"█".repeat(filled)}${"░".repeat(PROGRESS_WIDTH - filled)}]`; +} + +function progressStyle( + percentage: number, + green: TextStyle, + yellow: TextStyle, + red: TextStyle, +): TextStyle { + if (percentage >= 0.9) return red; + if (percentage >= 0.75) return yellow; + return green; +} + +function printView(usage: TokenPlanUsage, generatedAt: number): void { + const color = ansi(process.stdout); + const writeLine = (content = "", visibleContent = content) => { + const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${visibleContent}`)); + process.stdout.write(`│ ${content}${" ".repeat(padding)}│\n`); + }; + const writeQuota = (label: string, percentage: number, resetTime: number) => { + const percentageText = formatPercentage(percentage); + const bar = progressBar(percentage); + const style = progressStyle(percentage, color.green, color.yellow, color.red); + writeLine(color.bold(label), label); + writeLine(`${percentageText} used ${style(bar)}`, `${percentageText} used ${bar}`); + writeLine( + color.dim( + `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`, + ), + `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`, + ); + }; + + process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`); + writeLine(color.cyan("Token Plan Usage"), "Token Plan Usage"); + const generatedAtText = `Generated at: ${formatDateTime(generatedAt)} (local time)`; + writeLine(color.dim(generatedAtText), generatedAtText); + process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); + writeQuota("5-hour quota", usage.per5HourPercentage, usage.per5HourResetTime); + process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); + writeQuota("1-week quota", usage.per1WeekPercentage, usage.per1WeekResetTime); + process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`); +} + +export default defineCommand({ + description: "Show Token Plan quota usage as core JSON or a human-readable view", + auth: "console", + usageArgs: "<--json | --view> [flags]", + flags: { + json: { + type: "switch", + description: "Output only the four core usage fields as JSON", + }, + view: { + type: "switch", + description: "Render a compact human-readable quota view", + }, + }, + exampleArgs: ["--json", "--view"], + validate: (flags) => + flags.json === flags.view ? "Choose exactly one of --json or --view." : undefined, + async run(ctx) { + const { flags, settings } = ctx; + + if (settings.dryRun) { + emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, "json"); + return; + } + + const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {}); + const usage = readUsage(result); + + if (flags.json) { + emitResult(usage, "json"); + return; + } + + printView(usage, Date.now()); + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index cba8b321..70485b18 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -48,6 +48,7 @@ export { default as usageFree } from "./commands/usage/free.ts"; export { default as usageFreetier } from "./commands/usage/freetier.ts"; export { default as usageStats } from "./commands/usage/stats.ts"; export { default as usageSummary } from "./commands/usage/summary.ts"; +export { default as usageTokenPlan } from "./commands/usage/token-plan.ts"; export { default as pipelineRun } from "./commands/pipeline/run.ts"; export { default as pipelineValidate } from "./commands/pipeline/validate.ts"; export { default as advisorRecommend } from "./commands/advisor/recommend.ts"; diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 35497843..48fd7591 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -109,6 +109,7 @@ export const USAGE_ROUTES: E2eRouteExports = { "usage free": "usageFree", "usage freetier": "usageFreetier", "usage stats": "usageStats", + "usage token-plan": "usageTokenPlan", }; export const DEPLOY_ROUTES: E2eRouteExports = { diff --git a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts new file mode 100644 index 00000000..3fb961d1 --- /dev/null +++ b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + isConsoleAuthFailure, + isConsoleE2EReady, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; +import { USAGE_ROUTES } from "./topic-routes.ts"; + +describe("e2e: usage token-plan", () => { + test("usage token-plan --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "token-plan", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--json|--view|Token Plan/i); + }); + + test("usage token-plan 未选择输出形式时退出为用法错误", async () => { + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "token-plan", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toContain("Choose exactly one of --json or --view."); + }); + + test("usage token-plan 同时选择两种输出形式时退出为用法错误", async () => { + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "token-plan", + "--json", + "--view", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toContain("Choose exactly one of --json or --view."); + }); +}); + +describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => { + test("usage token-plan --json --dry-run 输出网关请求计划", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "token-plan", + "--json", + "--dry-run", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ api?: string; data?: Record }>(stdout); + expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"); + expect(data.data).toEqual({}); + }); + + test("usage token-plan --json 返回四个核心字段", async () => { + const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--json"]); + if (isConsoleAuthFailure(result)) return; + expect(result.exitCode, result.stderr).toBe(0); + const data = parseStdoutJson<{ + per5HourPercentage?: number; + per5HourResetTime?: number; + per1WeekPercentage?: number; + per1WeekResetTime?: number; + }>(result.stdout); + expect(data.per5HourPercentage).toBeTypeOf("number"); + expect(data.per5HourResetTime).toBeTypeOf("number"); + expect(data.per1WeekPercentage).toBeTypeOf("number"); + expect(data.per1WeekResetTime).toBeTypeOf("number"); + expect(Object.keys(data).sort()).toEqual([ + "per1WeekPercentage", + "per1WeekResetTime", + "per5HourPercentage", + "per5HourResetTime", + ]); + }); + + test("usage token-plan --view 渲染生成时间与两个额度窗口", async () => { + const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--view"]); + if (isConsoleAuthFailure(result)) return; + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Generated at:"); + expect(result.stdout).toContain("5-hour quota"); + expect(result.stdout).toContain("1-week quota"); + }); +}); diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts new file mode 100644 index 00000000..23669839 --- /dev/null +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import tokenPlanUsage from "../src/commands/usage/token-plan.ts"; + +const originalNoColor = process.env.NO_COLOR; +const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +afterEach(() => { + if (originalNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = originalNoColor; + if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty); + else delete (process.stdout as { isTTY?: boolean }).isTTY; + vi.restoreAllMocks(); +}); + +function makeUsageResponse(percentage: number): Record { + return { + data: { + DataV2: { + data: { + data: { + per5HourPercentage: percentage, + per5HourResetTime: 1_786_000_000_000, + per1WeekPercentage: percentage, + per1WeekResetTime: 1_786_100_000_000, + }, + }, + }, + }, + }; +} + +describe("usage token-plan view", () => { + test.each([ + [0.7499, "32"], + [0.75, "33"], + [0.9, "31"], + ])("uses ANSI color %s for %s", async (percentage, colorCode) => { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(percentage)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + expect(output.join("")).toContain(`\u001B[${colorCode}m[`); + }); +}); diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 31bc4c73..39d04ce5 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -65,6 +65,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | | `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | | `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) | +| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view | [usage.md](usage.md) | | `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) | | `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | @@ -90,7 +91,7 @@ Use this index for the skill-scoped quick index and global flags. | `text` | `chat` | [text.md](text.md) | | `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) | | `update` | `(root)` | [update.md](update.md) | -| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) | +| `usage` | `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) | | `workspace` | `init`, `list` | [workspace.md](workspace.md) | ## Global flags diff --git a/skills/bailian-cli/reference/usage.md b/skills/bailian-cli/reference/usage.md index dec5d7ea..f391cf2b 100644 --- a/skills/bailian-cli/reference/usage.md +++ b/skills/bailian-cli/reference/usage.md @@ -7,12 +7,13 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ------------------- | ------------------------------------------------------------------------------------------ | -| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | -| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | -| `bl usage stats` | Query model usage statistics | -| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | +| Command | Description | +| --------------------- | ------------------------------------------------------------------------------------------ | +| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | +| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | +| `bl usage stats` | Query model usage statistics | +| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | +| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view | ## Command details @@ -199,3 +200,32 @@ bl usage summary --days 30 ```bash bl usage summary --output json ``` + +### `bl usage token-plan` + +| Field | Value | +| --------------- | ----------------------------------------------------------------- | +| **Name** | `usage token-plan` | +| **Description** | Show Token Plan quota usage as core JSON or a human-readable view | +| **Usage** | `bl usage token-plan <--json \| --view> [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--json` | switch | no | Output only the four core usage fields as JSON | +| `--view` | switch | no | Render a compact human-readable quota view | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | + +#### Examples + +```bash +bl usage token-plan --json +``` + +```bash +bl usage token-plan --view +``` From 752a79e4422e95a86dcfe512bb83b1b0a7c1bb91 Mon Sep 17 00:00:00 2001 From: sonicg <85807784@qq.com> Date: Thu, 6 Aug 2026 09:12:41 +0800 Subject: [PATCH 2/4] fix(usage): handle missing token plan reset times --- .../commands/src/commands/usage/token-plan.ts | 44 +++++++++++---- .../tests/e2e/usage-token-plan.e2e.test.ts | 14 ++--- .../commands/tests/token-plan-usage.test.ts | 53 ++++++++++++++++--- 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts index 968b270e..b3ab83d4 100644 --- a/packages/commands/src/commands/usage/token-plan.ts +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -7,13 +7,23 @@ const PROGRESS_WIDTH = 32; interface TokenPlanUsage { per5HourPercentage: number; - per5HourResetTime: number; + per5HourResetTime?: number; per1WeekPercentage: number; - per1WeekResetTime: number; + per1WeekResetTime?: number; } function readUsage(result: unknown): TokenPlanUsage { const response = unwrapResponse(result as Record); + const percentages = [response.per5HourPercentage, response.per1WeekPercentage]; + + if ( + !percentages.every( + (percentage) => typeof percentage === "number" && Number.isFinite(percentage), + ) + ) { + throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL); + } + const usage = { per5HourPercentage: response.per5HourPercentage, per5HourResetTime: response.per5HourResetTime, @@ -21,7 +31,17 @@ function readUsage(result: unknown): TokenPlanUsage { per1WeekResetTime: response.per1WeekResetTime, }; - if (!Object.values(usage).every((value) => typeof value === "number" && Number.isFinite(value))) { + const resetTimes = [ + [usage.per5HourPercentage, usage.per5HourResetTime], + [usage.per1WeekPercentage, usage.per1WeekResetTime], + ]; + const hasValidResetTimes = resetTimes.every( + ([percentage, resetTime]) => + (percentage === 0 && resetTime === undefined) || + (typeof resetTime === "number" && Number.isFinite(resetTime)), + ); + + if (!hasValidResetTimes) { throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL); } @@ -81,18 +101,22 @@ function printView(usage: TokenPlanUsage, generatedAt: number): void { const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${visibleContent}`)); process.stdout.write(`│ ${content}${" ".repeat(padding)}│\n`); }; - const writeQuota = (label: string, percentage: number, resetTime: number) => { + const writeQuota = (label: string, percentage: number, resetTime: number | undefined) => { const percentageText = formatPercentage(percentage); const bar = progressBar(percentage); const style = progressStyle(percentage, color.green, color.yellow, color.red); writeLine(color.bold(label), label); writeLine(`${percentageText} used ${style(bar)}`, `${percentageText} used ${bar}`); - writeLine( - color.dim( - `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`, - ), - `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`, - ); + if (resetTime === undefined) { + writeLine( + color.dim("Resets: not applicable (no usage yet)"), + "Resets: not applicable (no usage yet)", + ); + return; + } + + const resetText = `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`; + writeLine(color.dim(resetText), resetText); }; process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`); diff --git a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts index 3fb961d1..ffa48221 100644 --- a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts @@ -55,7 +55,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = expect(data.data).toEqual({}); }); - test("usage token-plan --json 返回四个核心字段", async () => { + test("usage token-plan --json 返回百分比与可用的重置时间", async () => { const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--json"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); @@ -66,15 +66,11 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = per1WeekResetTime?: number; }>(result.stdout); expect(data.per5HourPercentage).toBeTypeOf("number"); - expect(data.per5HourResetTime).toBeTypeOf("number"); expect(data.per1WeekPercentage).toBeTypeOf("number"); - expect(data.per1WeekResetTime).toBeTypeOf("number"); - expect(Object.keys(data).sort()).toEqual([ - "per1WeekPercentage", - "per1WeekResetTime", - "per5HourPercentage", - "per5HourResetTime", - ]); + if (data.per5HourPercentage === 0) expect(data.per5HourResetTime).toBeUndefined(); + else expect(data.per5HourResetTime).toBeTypeOf("number"); + if (data.per1WeekPercentage === 0) expect(data.per1WeekResetTime).toBeUndefined(); + else expect(data.per1WeekResetTime).toBeTypeOf("number"); }); test("usage token-plan --view 渲染生成时间与两个额度窗口", async () => { diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts index 23669839..a35db67d 100644 --- a/packages/commands/tests/token-plan-usage.test.ts +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -12,17 +12,22 @@ afterEach(() => { vi.restoreAllMocks(); }); -function makeUsageResponse(percentage: number): Record { +function makeUsageResponse( + per5HourPercentage: number, + per1WeekPercentage = per5HourPercentage, +): Record { + const usage: Record = { + per5HourPercentage, + per1WeekPercentage, + }; + if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000; + if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000; + return { data: { DataV2: { data: { - data: { - per5HourPercentage: percentage, - per5HourResetTime: 1_786_000_000_000, - per1WeekPercentage: percentage, - per1WeekResetTime: 1_786_100_000_000, - }, + data: usage, }, }, }, @@ -51,4 +56,38 @@ describe("usage token-plan view", () => { expect(output.join("")).toContain(`\u001B[${colorCode}m[`); }); + + test("accepts missing reset times when the quota usage is zero", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + expect(output.join("")).toContain("Resets: not applicable (no usage yet)"); + }); + + test("allows one unused quota window without masking another reset time", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0, 0.5)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("Resets: not applicable (no usage yet)"); + expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); }); From 24092b423ce4b1b020dbc1107769acd305ee8a4c Mon Sep 17 00:00:00 2001 From: sonicg <85807784@qq.com> Date: Thu, 6 Aug 2026 22:47:26 +0800 Subject: [PATCH 3/4] fix(usage): handle unavailable token plan quotas --- .../commands/src/commands/usage/token-plan.ts | 55 +++++++++------ .../tests/e2e/usage-token-plan.e2e.test.ts | 20 ++++-- .../commands/tests/token-plan-usage.test.ts | 70 +++++++++++++++++-- 3 files changed, 110 insertions(+), 35 deletions(-) diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts index b3ab83d4..76c48767 100644 --- a/packages/commands/src/commands/usage/token-plan.ts +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -6,24 +6,14 @@ const BOX_WIDTH = 76; const PROGRESS_WIDTH = 32; interface TokenPlanUsage { - per5HourPercentage: number; + per5HourPercentage?: number; per5HourResetTime?: number; - per1WeekPercentage: number; + per1WeekPercentage?: number; per1WeekResetTime?: number; } function readUsage(result: unknown): TokenPlanUsage { const response = unwrapResponse(result as Record); - const percentages = [response.per5HourPercentage, response.per1WeekPercentage]; - - if ( - !percentages.every( - (percentage) => typeof percentage === "number" && Number.isFinite(percentage), - ) - ) { - throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL); - } - const usage = { per5HourPercentage: response.per5HourPercentage, per5HourResetTime: response.per5HourResetTime, @@ -31,17 +21,20 @@ function readUsage(result: unknown): TokenPlanUsage { per1WeekResetTime: response.per1WeekResetTime, }; - const resetTimes = [ + const quotas = [ [usage.per5HourPercentage, usage.per5HourResetTime], [usage.per1WeekPercentage, usage.per1WeekResetTime], ]; - const hasValidResetTimes = resetTimes.every( + const hasValidQuotas = quotas.every( ([percentage, resetTime]) => - (percentage === 0 && resetTime === undefined) || - (typeof resetTime === "number" && Number.isFinite(resetTime)), + (percentage === undefined && resetTime === undefined) || + (typeof percentage === "number" && + Number.isFinite(percentage) && + ((percentage === 0 && resetTime === undefined) || + (typeof resetTime === "number" && Number.isFinite(resetTime)))), ); - if (!hasValidResetTimes) { + if (!hasValidQuotas) { throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL); } @@ -101,11 +94,21 @@ function printView(usage: TokenPlanUsage, generatedAt: number): void { const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${visibleContent}`)); process.stdout.write(`│ ${content}${" ".repeat(padding)}│\n`); }; - const writeQuota = (label: string, percentage: number, resetTime: number | undefined) => { + const writeQuota = ( + label: string, + unlimitedMessage: string, + percentage: number | undefined, + resetTime: number | undefined, + ) => { + writeLine(color.bold(label), label); + if (percentage === undefined) { + writeLine(color.dim(unlimitedMessage), unlimitedMessage); + return; + } + const percentageText = formatPercentage(percentage); const bar = progressBar(percentage); const style = progressStyle(percentage, color.green, color.yellow, color.red); - writeLine(color.bold(label), label); writeLine(`${percentageText} used ${style(bar)}`, `${percentageText} used ${bar}`); if (resetTime === undefined) { writeLine( @@ -124,9 +127,19 @@ function printView(usage: TokenPlanUsage, generatedAt: number): void { const generatedAtText = `Generated at: ${formatDateTime(generatedAt)} (local time)`; writeLine(color.dim(generatedAtText), generatedAtText); process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); - writeQuota("5-hour quota", usage.per5HourPercentage, usage.per5HourResetTime); + writeQuota( + "5-hour quota", + "5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。", + usage.per5HourPercentage, + usage.per5HourResetTime, + ); process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); - writeQuota("1-week quota", usage.per1WeekPercentage, usage.per1WeekResetTime); + writeQuota( + "1-week quota", + "1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。", + usage.per1WeekPercentage, + usage.per1WeekResetTime, + ); process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`); } diff --git a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts index ffa48221..1acf052f 100644 --- a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts @@ -55,7 +55,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = expect(data.data).toEqual({}); }); - test("usage token-plan --json 返回百分比与可用的重置时间", async () => { + test("usage token-plan --json 返回可用的额度字段", async () => { const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--json"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); @@ -65,12 +65,18 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = per1WeekPercentage?: number; per1WeekResetTime?: number; }>(result.stdout); - expect(data.per5HourPercentage).toBeTypeOf("number"); - expect(data.per1WeekPercentage).toBeTypeOf("number"); - if (data.per5HourPercentage === 0) expect(data.per5HourResetTime).toBeUndefined(); - else expect(data.per5HourResetTime).toBeTypeOf("number"); - if (data.per1WeekPercentage === 0) expect(data.per1WeekResetTime).toBeUndefined(); - else expect(data.per1WeekResetTime).toBeTypeOf("number"); + const quotas = [ + [data.per5HourPercentage, data.per5HourResetTime], + [data.per1WeekPercentage, data.per1WeekResetTime], + ]; + for (const [percentage, resetTime] of quotas) { + if (percentage === undefined) expect(resetTime).toBeUndefined(); + else if (percentage === 0) expect(resetTime).toBeUndefined(); + else { + expect(percentage).toBeTypeOf("number"); + expect(resetTime).toBeTypeOf("number"); + } + } }); test("usage token-plan --view 渲染生成时间与两个额度窗口", async () => { diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts index a35db67d..9a1596c3 100644 --- a/packages/commands/tests/token-plan-usage.test.ts +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -13,15 +13,18 @@ afterEach(() => { }); function makeUsageResponse( - per5HourPercentage: number, + per5HourPercentage?: number, per1WeekPercentage = per5HourPercentage, ): Record { - const usage: Record = { - per5HourPercentage, - per1WeekPercentage, - }; - if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000; - if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000; + const usage: Record = {}; + if (per5HourPercentage !== undefined) { + usage.per5HourPercentage = per5HourPercentage; + if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000; + } + if (per1WeekPercentage !== undefined) { + usage.per1WeekPercentage = per1WeekPercentage; + if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000; + } return { data: { @@ -90,4 +93,57 @@ describe("usage token-plan view", () => { expect(renderedOutput).toContain("Resets: not applicable (no usage yet)"); expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); }); + + test("renders missing quota windows as possibly unlimited", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + }); + + test("renders only the missing quota window as possibly unlimited", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(undefined, 0.5)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).not.toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); + + test("returns an empty JSON object when no quota fields are available", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) }, + flags: { json: true, view: false }, + settings: { dryRun: false }, + } as never); + + expect(output.join("").trim()).toBe("{}"); + }); }); From 39a488181e8972c4eb507a9514010ca90a1ce3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 13 Aug 2026 19:43:11 +0800 Subject: [PATCH 4/4] refactor(usage): align token-plan with --output convention and tolerant quota reading --- .../commands/src/commands/usage/shared.ts | 8 + .../commands/src/commands/usage/token-plan.ts | 161 +++++++---------- .../tests/e2e/usage-token-plan.e2e.test.ts | 52 ++---- .../commands/tests/token-plan-usage.test.ts | 163 +++++++++++------- skills/bailian-cli/SKILL.md | 1 + skills/bailian-cli/reference/index.md | 2 +- skills/bailian-cli/reference/usage.md | 18 +- 7 files changed, 198 insertions(+), 207 deletions(-) diff --git a/packages/commands/src/commands/usage/shared.ts b/packages/commands/src/commands/usage/shared.ts index ec75b0cc..2decba0c 100644 --- a/packages/commands/src/commands/usage/shared.ts +++ b/packages/commands/src/commands/usage/shared.ts @@ -24,6 +24,14 @@ export function formatDate(ts: number): string { return `${year}-${month}-${day}`; } +export function formatDateTime(ts: number): string { + const date = new Date(ts); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + const second = String(date.getSeconds()).padStart(2, "0"); + return `${formatDate(ts)} ${hour}:${minute}:${second}`; +} + export function requireWorkspaceId(settings: Settings, binName: string): string { if (settings.workspaceId) return settings.workspaceId; diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts index 76c48767..d4bb8bf1 100644 --- a/packages/commands/src/commands/usage/token-plan.ts +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -1,5 +1,12 @@ -import { BailianError, ExitCode, defineCommand, unwrapResponse } from "bailian-cli-core"; -import { ansi, displayWidth, emitResult, type TextStyle } from "bailian-cli-runtime"; +import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; +import { + ansi, + displayWidth, + emitResult, + type AnsiStyles, + type TextStyle, +} from "bailian-cli-runtime"; +import { formatDateTime } from "./shared.ts"; const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"; const BOX_WIDTH = 76; @@ -12,50 +19,36 @@ interface TokenPlanUsage { per1WeekResetTime?: number; } -function readUsage(result: unknown): TokenPlanUsage { - const response = unwrapResponse(result as Record); - const usage = { - per5HourPercentage: response.per5HourPercentage, - per5HourResetTime: response.per5HourResetTime, - per1WeekPercentage: response.per1WeekPercentage, - per1WeekResetTime: response.per1WeekResetTime, - }; - - const quotas = [ - [usage.per5HourPercentage, usage.per5HourResetTime], - [usage.per1WeekPercentage, usage.per1WeekResetTime], - ]; - const hasValidQuotas = quotas.every( - ([percentage, resetTime]) => - (percentage === undefined && resetTime === undefined) || - (typeof percentage === "number" && - Number.isFinite(percentage) && - ((percentage === 0 && resetTime === undefined) || - (typeof resetTime === "number" && Number.isFinite(resetTime)))), - ); +interface QuotaWindow { + percentage?: number; + resetTime?: number; +} - if (!hasValidQuotas) { - throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL); - } +/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */ +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} - return usage as TokenPlanUsage; +function readUsage(result: unknown): TokenPlanUsage { + const response = unwrapResponse(result as Record); + const usage: TokenPlanUsage = {}; + + const per5HourPercentage = readNumber(response.per5HourPercentage); + if (per5HourPercentage !== undefined) usage.per5HourPercentage = per5HourPercentage; + const per5HourResetTime = readNumber(response.per5HourResetTime); + if (per5HourResetTime !== undefined) usage.per5HourResetTime = per5HourResetTime; + const per1WeekPercentage = readNumber(response.per1WeekPercentage); + if (per1WeekPercentage !== undefined) usage.per1WeekPercentage = per1WeekPercentage; + const per1WeekResetTime = readNumber(response.per1WeekResetTime); + if (per1WeekResetTime !== undefined) usage.per1WeekResetTime = per1WeekResetTime; + + return usage; } function formatPercentage(ratio: number): string { return `${(ratio * 100).toFixed(2)}%`; } -function formatDateTime(timestamp: number): string { - const date = new Date(timestamp); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hour = String(date.getHours()).padStart(2, "0"); - const minute = String(date.getMinutes()).padStart(2, "0"); - const second = String(date.getSeconds()).padStart(2, "0"); - return `${year}-${month}-${day} ${hour}:${minute}:${second}`; -} - function formatRemainingTime(resetTime: number, now: number): string { const remainingMs = Math.max(0, resetTime - now); const totalMinutes = Math.floor(remainingMs / 60_000); @@ -77,102 +70,74 @@ function progressBar(ratio: number): string { return `[${"█".repeat(filled)}${"░".repeat(PROGRESS_WIDTH - filled)}]`; } -function progressStyle( - percentage: number, - green: TextStyle, - yellow: TextStyle, - red: TextStyle, -): TextStyle { - if (percentage >= 0.9) return red; - if (percentage >= 0.75) return yellow; - return green; +function progressStyle(percentage: number, color: AnsiStyles): TextStyle { + if (percentage >= 0.9) return color.red; + if (percentage >= 0.75) return color.yellow; + return color.green; } function printView(usage: TokenPlanUsage, generatedAt: number): void { const color = ansi(process.stdout); - const writeLine = (content = "", visibleContent = content) => { - const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${visibleContent}`)); - process.stdout.write(`│ ${content}${" ".repeat(padding)}│\n`); + const writeLine = (text = "", style?: TextStyle) => { + const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`)); + process.stdout.write(`│ ${style ? style(text) : text}${" ".repeat(padding)}│\n`); }; - const writeQuota = ( - label: string, - unlimitedMessage: string, - percentage: number | undefined, - resetTime: number | undefined, - ) => { - writeLine(color.bold(label), label); - if (percentage === undefined) { - writeLine(color.dim(unlimitedMessage), unlimitedMessage); + const writeQuota = (label: string, unlimitedMessage: string, window: QuotaWindow) => { + writeLine(label, color.bold); + if (window.percentage === undefined) { + writeLine(unlimitedMessage, color.dim); return; } - const percentageText = formatPercentage(percentage); - const bar = progressBar(percentage); - const style = progressStyle(percentage, color.green, color.yellow, color.red); - writeLine(`${percentageText} used ${style(bar)}`, `${percentageText} used ${bar}`); - if (resetTime === undefined) { - writeLine( - color.dim("Resets: not applicable (no usage yet)"), - "Resets: not applicable (no usage yet)", - ); + const percentageText = formatPercentage(window.percentage); + const bar = progressBar(window.percentage); + writeLine(`${percentageText} used ${bar}`, progressStyle(window.percentage, color)); + if (window.resetTime === undefined) { + writeLine("Resets: not applicable (no usage yet)", color.dim); return; } - const resetText = `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`; - writeLine(color.dim(resetText), resetText); + const resetText = `Resets: ${formatDateTime(window.resetTime)} (in ${formatRemainingTime(window.resetTime, generatedAt)})`; + writeLine(resetText, color.dim); }; process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`); - writeLine(color.cyan("Token Plan Usage"), "Token Plan Usage"); - const generatedAtText = `Generated at: ${formatDateTime(generatedAt)} (local time)`; - writeLine(color.dim(generatedAtText), generatedAtText); + writeLine("Token Plan Usage", color.cyan); + writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim); process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); writeQuota( "5-hour quota", - "5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。", - usage.per5HourPercentage, - usage.per5HourResetTime, + "The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.", + { percentage: usage.per5HourPercentage, resetTime: usage.per5HourResetTime }, ); process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); writeQuota( "1-week quota", - "1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。", - usage.per1WeekPercentage, - usage.per1WeekResetTime, + "The 1-week limit may be unlimited; verify in the Bailian Token Plan console.", + { percentage: usage.per1WeekPercentage, resetTime: usage.per1WeekResetTime }, ); process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`); } export default defineCommand({ - description: "Show Token Plan quota usage as core JSON or a human-readable view", + description: "Show Token Plan quota usage", auth: "console", - usageArgs: "<--json | --view> [flags]", - flags: { - json: { - type: "switch", - description: "Output only the four core usage fields as JSON", - }, - view: { - type: "switch", - description: "Render a compact human-readable quota view", - }, - }, - exampleArgs: ["--json", "--view"], - validate: (flags) => - flags.json === flags.view ? "Choose exactly one of --json or --view." : undefined, + usageArgs: "[flags]", + exampleArgs: ["", "--output json"], async run(ctx) { - const { flags, settings } = ctx; + const { settings } = ctx; + const format = detectOutputFormat(settings.output); if (settings.dryRun) { - emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, "json"); + emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format); return; } const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {}); const usage = readUsage(result); - if (flags.json) { - emitResult(usage, "json"); + if (format === "json") { + emitResult(usage, format); return; } diff --git a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts index 1acf052f..1adc5924 100644 --- a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts @@ -15,39 +15,28 @@ describe("e2e: usage token-plan", () => { "--help", ]); expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/--json|--view|Token Plan/i); + expect(stderr).toMatch(/Token Plan|quota/i); }); - test("usage token-plan 未选择输出形式时退出为用法错误", async () => { + test("usage token-plan --help 包含 --output json 示例", async () => { const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "token-plan", - "--quiet", - ]); - expect(exitCode).toBe(2); - expect(stderr).toContain("Choose exactly one of --json or --view."); - }); - - test("usage token-plan 同时选择两种输出形式时退出为用法错误", async () => { - const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ - "usage", - "token-plan", - "--json", - "--view", - "--quiet", + "--help", ]); - expect(exitCode).toBe(2); - expect(stderr).toContain("Choose exactly one of --json or --view."); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("bl usage token-plan --output json"); }); }); describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => { - test("usage token-plan --json --dry-run 输出网关请求计划", async () => { + test("usage token-plan --dry-run 输出网关请求计划", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "token-plan", - "--json", "--dry-run", + "--output", + "json", ]); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ api?: string; data?: Record }>(stdout); @@ -55,8 +44,8 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = expect(data.data).toEqual({}); }); - test("usage token-plan --json 返回可用的额度字段", async () => { - const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--json"]); + test("usage token-plan --output json 返回可用的额度字段", async () => { + const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); const data = parseStdoutJson<{ @@ -65,22 +54,19 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = per1WeekPercentage?: number; per1WeekResetTime?: number; }>(result.stdout); - const quotas = [ - [data.per5HourPercentage, data.per5HourResetTime], - [data.per1WeekPercentage, data.per1WeekResetTime], + const fields = [ + data.per5HourPercentage, + data.per5HourResetTime, + data.per1WeekPercentage, + data.per1WeekResetTime, ]; - for (const [percentage, resetTime] of quotas) { - if (percentage === undefined) expect(resetTime).toBeUndefined(); - else if (percentage === 0) expect(resetTime).toBeUndefined(); - else { - expect(percentage).toBeTypeOf("number"); - expect(resetTime).toBeTypeOf("number"); - } + for (const field of fields) { + if (field !== undefined) expect(field).toBeTypeOf("number"); } }); - test("usage token-plan --view 渲染生成时间与两个额度窗口", async () => { - const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--view"]); + test("usage token-plan 默认渲染生成时间与两个额度窗口", async () => { + const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Generated at:"); diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts index 9a1596c3..fe551f5c 100644 --- a/packages/commands/tests/token-plan-usage.test.ts +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -12,6 +12,23 @@ afterEach(() => { vi.restoreAllMocks(); }); +function captureStdout(): string[] { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + return output; +} + +async function runTokenPlan(response: Record, output?: string): Promise { + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(response) }, + flags: {}, + settings: { dryRun: false, output }, + } as never); +} + function makeUsageResponse( per5HourPercentage?: number, per1WeekPercentage = per5HourPercentage, @@ -26,6 +43,10 @@ function makeUsageResponse( if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000; } + return wrapResponse(usage); +} + +function wrapResponse(usage: Record): Record { return { data: { DataV2: { @@ -45,49 +66,25 @@ describe("usage token-plan view", () => { ])("uses ANSI color %s for %s", async (percentage, colorCode) => { delete process.env.NO_COLOR; Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); - const output: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - output.push(String(chunk)); - return true; - }); + const output = captureStdout(); - await tokenPlanUsage.run({ - client: { console: vi.fn().mockResolvedValue(makeUsageResponse(percentage)) }, - flags: { json: false, view: true }, - settings: { dryRun: false }, - } as never); + await runTokenPlan(makeUsageResponse(percentage)); - expect(output.join("")).toContain(`\u001B[${colorCode}m[`); + expect(output.join("")).toContain(`\u001B[${colorCode}m`); }); test("accepts missing reset times when the quota usage is zero", async () => { - const output: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - output.push(String(chunk)); - return true; - }); + const output = captureStdout(); - await tokenPlanUsage.run({ - client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0)) }, - flags: { json: false, view: true }, - settings: { dryRun: false }, - } as never); + await runTokenPlan(makeUsageResponse(0)); expect(output.join("")).toContain("Resets: not applicable (no usage yet)"); }); test("allows one unused quota window without masking another reset time", async () => { - const output: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - output.push(String(chunk)); - return true; - }); + const output = captureStdout(); - await tokenPlanUsage.run({ - client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0, 0.5)) }, - flags: { json: false, view: true }, - settings: { dryRun: false }, - } as never); + await runTokenPlan(makeUsageResponse(0, 0.5)); const renderedOutput = output.join(""); expect(renderedOutput).toContain("Resets: not applicable (no usage yet)"); @@ -95,55 +92,91 @@ describe("usage token-plan view", () => { }); test("renders missing quota windows as possibly unlimited", async () => { - const output: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - output.push(String(chunk)); - return true; - }); + const output = captureStdout(); - await tokenPlanUsage.run({ - client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) }, - flags: { json: false, view: true }, - settings: { dryRun: false }, - } as never); + await runTokenPlan(makeUsageResponse()); const renderedOutput = output.join(""); - expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); - expect(renderedOutput).toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).toContain( + "The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.", + ); + expect(renderedOutput).toContain( + "The 1-week limit may be unlimited; verify in the Bailian Token Plan console.", + ); }); test("renders only the missing quota window as possibly unlimited", async () => { - const output: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - output.push(String(chunk)); - return true; - }); + const output = captureStdout(); - await tokenPlanUsage.run({ - client: { console: vi.fn().mockResolvedValue(makeUsageResponse(undefined, 0.5)) }, - flags: { json: false, view: true }, - settings: { dryRun: false }, - } as never); + await runTokenPlan(makeUsageResponse(undefined, 0.5)); const renderedOutput = output.join(""); - expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); - expect(renderedOutput).not.toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).toContain( + "The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.", + ); + expect(renderedOutput).not.toContain( + "The 1-week limit may be unlimited; verify in the Bailian Token Plan console.", + ); expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); }); - test("returns an empty JSON object when no quota fields are available", async () => { - const output: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - output.push(String(chunk)); - return true; + test("renders a window with a missing percentage as possibly unlimited even when its reset time is present", async () => { + const output = captureStdout(); + + await runTokenPlan(wrapResponse({ per5HourResetTime: 1_786_000_000_000 })); + + expect(output.join("")).toContain( + "The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.", + ); + }); + + test("treats non-numeric quota fields as absent instead of failing", async () => { + const output = captureStdout(); + + await runTokenPlan( + wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: Number.NaN }), + ); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain( + "The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.", + ); + expect(renderedOutput).toContain( + "The 1-week limit may be unlimited; verify in the Bailian Token Plan console.", + ); + }); +}); + +describe("usage token-plan json", () => { + test("outputs the four core usage fields with --output json", async () => { + const output = captureStdout(); + + await runTokenPlan(makeUsageResponse(0.5, 0.25), "json"); + + expect(JSON.parse(output.join(""))).toEqual({ + per5HourPercentage: 0.5, + per5HourResetTime: 1_786_000_000_000, + per1WeekPercentage: 0.25, + per1WeekResetTime: 1_786_100_000_000, }); + }); + + test("returns an empty JSON object when no quota fields are available", async () => { + const output = captureStdout(); - await tokenPlanUsage.run({ - client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) }, - flags: { json: true, view: false }, - settings: { dryRun: false }, - } as never); + await runTokenPlan(makeUsageResponse(), "json"); expect(output.join("").trim()).toBe("{}"); }); + + test("omits non-numeric quota fields from the JSON output", async () => { + const output = captureStdout(); + + await runTokenPlan( + wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: 0 }), + "json", + ); + + expect(JSON.parse(output.join(""))).toEqual({ per1WeekPercentage: 0 }); + }); }); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 0d2ae3da..0d53217c 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -71,6 +71,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian- | Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | | Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | | Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | | Console API (advanced) | `bl console call` | Console auth | | Bailian workspace listing | `bl workspace list` | Console auth | | Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 42068ce6..2fa87ae9 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -66,7 +66,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | | `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | | `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) | -| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view | [usage.md](usage.md) | +| `bl usage token-plan` | Show Token Plan quota usage | [usage.md](usage.md) | | `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) | | `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | diff --git a/skills/bailian-cli/reference/usage.md b/skills/bailian-cli/reference/usage.md index f391cf2b..904c3a32 100644 --- a/skills/bailian-cli/reference/usage.md +++ b/skills/bailian-cli/reference/usage.md @@ -13,7 +13,7 @@ Index: [index.md](index.md) | `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | | `bl usage stats` | Query model usage statistics | | `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | -| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view | +| `bl usage token-plan` | Show Token Plan quota usage | ## Command details @@ -203,18 +203,16 @@ bl usage summary --output json ### `bl usage token-plan` -| Field | Value | -| --------------- | ----------------------------------------------------------------- | -| **Name** | `usage token-plan` | -| **Description** | Show Token Plan quota usage as core JSON or a human-readable view | -| **Usage** | `bl usage token-plan <--json \| --view> [flags]` | +| Field | Value | +| --------------- | ----------------------------- | +| **Name** | `usage token-plan` | +| **Description** | Show Token Plan quota usage | +| **Usage** | `bl usage token-plan [flags]` | #### Flags | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | -------------------------------------------------------- | -| `--json` | switch | no | Output only the four core usage fields as JSON | -| `--view` | switch | no | Render a compact human-readable quota view | | `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | | `--console-site ` | string | no | Console site: domestic, international | | `--console-switch-agent ` | number | no | Switch agent UID for delegated access | @@ -223,9 +221,9 @@ bl usage summary --output json #### Examples ```bash -bl usage token-plan --json +bl usage token-plan ``` ```bash -bl usage token-plan --view +bl usage token-plan --output json ```