diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index cf0ebb95..8a0dea46 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -46,6 +46,7 @@ import { usageStats, usageSummary, usageTokenPlan, + usageCodingPlan, pipelineRun, pipelineValidate, advisorRecommend, @@ -166,6 +167,7 @@ export const commands: Record = { "usage stats": usageStats, "usage summary": usageSummary, "usage token-plan": usageTokenPlan, + "usage coding-plan": usageCodingPlan, "pipeline run": pipelineRun, "pipeline validate": pipelineValidate, "advisor recommend": advisorRecommend, diff --git a/packages/commands/src/commands/usage/coding-plan.ts b/packages/commands/src/commands/usage/coding-plan.ts new file mode 100644 index 00000000..62e77c03 --- /dev/null +++ b/packages/commands/src/commands/usage/coding-plan.ts @@ -0,0 +1,132 @@ +import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts"; +import { formatNumber } from "./shared.ts"; + +const CODING_PLAN_USAGE_API = + "zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2"; + +const COMMODITY_CODES: Record = { + domestic: "sfm_codingplan_public_cn", + international: "sfm_codingplan_public_intl", +}; + +interface CodingPlanWindow { + usedQuota?: number; + totalQuota?: number; + /** Usage ratio in [0, 1]; absent when the window has no positive total or no used value. */ + percentage?: number; + resetTime?: number; +} + +interface CodingPlanUsage { + instanceType?: string; + per5Hour: CodingPlanWindow; + perWeek: CodingPlanWindow; + perBillMonth: CodingPlanWindow; +} + +function readWindow( + quotaInfo: Record | undefined, + fieldPrefix: string, +): CodingPlanWindow { + const window: CodingPlanWindow = {}; + if (!quotaInfo) return window; + + const usedQuota = readNumber(quotaInfo[`${fieldPrefix}UsedQuota`]); + if (usedQuota !== undefined) window.usedQuota = usedQuota; + const totalQuota = readNumber(quotaInfo[`${fieldPrefix}TotalQuota`]); + if (totalQuota !== undefined) window.totalQuota = totalQuota; + const resetTime = readNumber(quotaInfo[`${fieldPrefix}QuotaNextRefreshTime`]); + if (resetTime !== undefined) window.resetTime = resetTime; + + // Console rule: the usage rate only exists with a positive total and a used value. + if (usedQuota !== undefined && totalQuota !== undefined && totalQuota > 0) { + window.percentage = usedQuota / totalQuota; + } + return window; +} + +/** Pick the first VALID instance's quota info, mirroring the Coding Plan console. */ +function readUsage(result: unknown): CodingPlanUsage | undefined { + const response = unwrapResponse(result as Record); + const instances = Array.isArray(response.codingPlanInstanceInfos) + ? (response.codingPlanInstanceInfos as Record[]) + : []; + const validInstance = instances.find((instance) => instance.status === "VALID"); + if (!validInstance) return undefined; + + const quotaInfo = validInstance.codingPlanQuotaInfo as Record | undefined; + const usage: CodingPlanUsage = { + per5Hour: readWindow(quotaInfo, "per5Hour"), + perWeek: readWindow(quotaInfo, "perWeek"), + perBillMonth: readWindow(quotaInfo, "perBillMonth"), + }; + if (typeof validInstance.instanceType === "string" && validInstance.instanceType) { + usage.instanceType = validInstance.instanceType; + } + return usage; +} + +function toSection(label: string, window: CodingPlanWindow): QuotaSection { + const section: QuotaSection = { + label, + emptyMessage: "No quota data for this window; verify in the Bailian Coding Plan console.", + percentage: window.percentage, + resetTime: window.resetTime, + }; + if (window.usedQuota !== undefined && window.totalQuota !== undefined) { + section.detail = `Used: ${formatNumber(window.usedQuota)} / ${formatNumber(window.totalQuota)}`; + } + return section; +} + +function printView(usage: CodingPlanUsage, generatedAt: number): void { + const planSuffix = usage.instanceType ? ` (${usage.instanceType})` : ""; + printQuotaBox( + `Coding Plan Usage${planSuffix}`, + [ + toSection("5-hour quota", usage.per5Hour), + toSection("1-week quota", usage.perWeek), + toSection("Monthly quota", usage.perBillMonth), + ], + generatedAt, + ); +} + +export default defineCommand({ + description: "Show Coding Plan quota usage", + auth: "console", + usageArgs: "[flags]", + exampleArgs: ["", "--output json"], + async run(ctx) { + const { settings } = ctx; + const format = detectOutputFormat(settings.output); + const requestData = { + queryCodingPlanInstanceInfoRequest: { + commodityCode: COMMODITY_CODES[settings.consoleSite ?? "domestic"], + onlyLatestOne: true, + }, + }; + + if (settings.dryRun) { + emitResult({ api: CODING_PLAN_USAGE_API, data: requestData }, format); + return; + } + + const result = await ctx.client.console(CODING_PLAN_USAGE_API, requestData); + const usage = readUsage(result); + + if (format === "json") { + emitResult(usage ?? {}, format); + return; + } + + if (!usage) { + process.stdout.write("No active Coding Plan subscription found.\n"); + return; + } + + printView(usage, Date.now()); + }, +}); diff --git a/packages/commands/src/commands/usage/quota-box.ts b/packages/commands/src/commands/usage/quota-box.ts new file mode 100644 index 00000000..69f107ed --- /dev/null +++ b/packages/commands/src/commands/usage/quota-box.ts @@ -0,0 +1,91 @@ +import { + ansi, + displayWidth, + renderGauge, + type GaugeCell, + type TextStyle, +} from "bailian-cli-runtime"; +import { formatDateTime } from "./shared.ts"; + +const BOX_WIDTH = 76; + +/** One quota window rendered inside the box: a label + usage ratio + reset time. */ +export interface QuotaSection { + label: string; + /** Shown instead of the gauge when the usage ratio is absent. */ + emptyMessage: string; + /** Usage ratio in [0, 1]; absent means no data (possibly unlimited). */ + percentage?: number; + resetTime?: number; + /** Optional dim line under the gauge, e.g. "Used: 38 / 100". */ + detail?: string; +} + +/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */ +export function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Match the `usage free` gauge label style: 0.1% precision, no trailing zeros. */ +function formatPercentage(ratio: number): string { + const percent = Math.round(ratio * 1000) / 10; + return `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`; +} + +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(" "); +} + +/** Print a bordered quota box with a title line and one gauge per section. */ +export function printQuotaBox(title: string, sections: QuotaSection[], generatedAt: number): void { + const color = ansi(process.stdout); + const writeLine = (text = "", style?: TextStyle) => { + const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`)); + process.stdout.write(`│ ${style ? style(text) : text}${" ".repeat(padding)}│\n`); + }; + // Pre-colored gauge cell: pad from the plain variant so ANSI escapes never shift the border. + const writeGaugeLine = (cell: GaugeCell) => { + const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${cell.plain}`)); + process.stdout.write(`│ ${cell.colored}${" ".repeat(padding)}│\n`); + }; + const writeQuota = (section: QuotaSection) => { + writeLine(section.label, color.bold); + if (section.percentage === undefined) { + writeLine(section.emptyMessage, color.dim); + return; + } + + const gaugeLabel = `${formatPercentage(section.percentage)} used`; + writeGaugeLine(renderGauge(section.percentage * 100, gaugeLabel)); + if (section.detail) { + writeLine(section.detail, color.dim); + } + if (section.resetTime === undefined) { + writeLine("Resets: not applicable (no usage yet)", color.dim); + return; + } + + const resetText = `Resets: ${formatDateTime(section.resetTime)} (in ${formatRemainingTime(section.resetTime, generatedAt)})`; + writeLine(resetText, color.dim); + }; + + process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`); + writeLine(title, color.cyan); + writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim); + for (const section of sections) { + process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`); + writeQuota(section); + } + process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`); +} diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts index d4bb8bf1..e5320696 100644 --- a/packages/commands/src/commands/usage/token-plan.ts +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -1,16 +1,8 @@ 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"; +import { emitResult } from "bailian-cli-runtime"; +import { printQuotaBox, readNumber } from "./quota-box.ts"; const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"; -const BOX_WIDTH = 76; -const PROGRESS_WIDTH = 32; interface TokenPlanUsage { per5HourPercentage?: number; @@ -19,16 +11,6 @@ interface TokenPlanUsage { per1WeekResetTime?: number; } -interface QuotaWindow { - percentage?: number; - resetTime?: number; -} - -/** 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; -} - function readUsage(result: unknown): TokenPlanUsage { const response = unwrapResponse(result as Record); const usage: TokenPlanUsage = {}; @@ -45,78 +27,27 @@ function readUsage(result: unknown): TokenPlanUsage { return usage; } -function formatPercentage(ratio: number): string { - return `${(ratio * 100).toFixed(2)}%`; -} - -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, 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 = (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, window: QuotaWindow) => { - writeLine(label, color.bold); - if (window.percentage === undefined) { - writeLine(unlimitedMessage, color.dim); - return; - } - - 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(window.resetTime)} (in ${formatRemainingTime(window.resetTime, generatedAt)})`; - writeLine(resetText, color.dim); - }; - - process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`); - 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", - "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", - "The 1-week limit may be unlimited; verify in the Bailian Token Plan console.", - { percentage: usage.per1WeekPercentage, resetTime: usage.per1WeekResetTime }, + printQuotaBox( + "Token Plan Usage", + [ + { + label: "5-hour quota", + emptyMessage: + "The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.", + percentage: usage.per5HourPercentage, + resetTime: usage.per5HourResetTime, + }, + { + label: "1-week quota", + emptyMessage: + "The 1-week limit may be unlimited; verify in the Bailian Token Plan console.", + percentage: usage.per1WeekPercentage, + resetTime: usage.per1WeekResetTime, + }, + ], + generatedAt, ); - process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`); } export default defineCommand({ diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index a0761a3e..d0d37de4 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -49,6 +49,7 @@ 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 usageCodingPlan } from "./commands/usage/coding-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/coding-plan-usage.test.ts b/packages/commands/tests/coding-plan-usage.test.ts new file mode 100644 index 00000000..09de39a0 --- /dev/null +++ b/packages/commands/tests/coding-plan-usage.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import codingPlanUsage from "../src/commands/usage/coding-plan.ts"; + +const originalNoColor = process.env.NO_COLOR; +const originalForceColor = process.env.FORCE_COLOR; +const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +afterEach(() => { + if (originalNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = originalNoColor; + if (originalForceColor === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = originalForceColor; + if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty); + else delete (process.stdout as { isTTY?: boolean }).isTTY; + 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 runCodingPlan(response: Record, output?: string): Promise { + await codingPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(response) }, + flags: {}, + settings: { dryRun: false, output }, + } as never); +} + +function wrapResponse(data: Record): Record { + return { + data: { + DataV2: { + data: { + data, + }, + }, + }, + }; +} + +function makeInstanceResponse( + quotaInfo: Record, + overrides: Record = {}, +): Record { + return wrapResponse({ + codingPlanInstanceInfos: [ + { status: "VALID", instanceType: "pro", codingPlanQuotaInfo: quotaInfo, ...overrides }, + ], + }); +} + +const FULL_QUOTA_INFO = { + per5HourUsedQuota: 38, + per5HourTotalQuota: 100, + per5HourQuotaNextRefreshTime: 1_786_000_000_000, + perWeekUsedQuota: 500, + perWeekTotalQuota: 1000, + perWeekQuotaNextRefreshTime: 1_786_100_000_000, + perBillMonthUsedQuota: 950, + perBillMonthTotalQuota: 1000, + perBillMonthQuotaNextRefreshTime: 1_786_200_000_000, +}; + +describe("usage coding-plan view", () => { + test("renders the three quota windows with usage rates and used/total details", async () => { + const output = captureStdout(); + + await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO)); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("Coding Plan Usage (pro)"); + expect(renderedOutput).toContain("5-hour quota"); + expect(renderedOutput).toContain("1-week quota"); + expect(renderedOutput).toContain("Monthly quota"); + expect(renderedOutput).toContain("38% used"); + expect(renderedOutput).toContain("50% used"); + expect(renderedOutput).toContain("95% used"); + expect(renderedOutput).toContain("Used: 38 / 100"); + expect(renderedOutput).toContain("Used: 950 / 1,000"); + }); + + test("skips non-VALID instances when picking quota info", async () => { + const output = captureStdout(); + + await runCodingPlan( + wrapResponse({ + codingPlanInstanceInfos: [ + { + status: "EXPIRED", + codingPlanQuotaInfo: { per5HourUsedQuota: 1, per5HourTotalQuota: 2 }, + }, + { status: "VALID", codingPlanQuotaInfo: FULL_QUOTA_INFO }, + ], + }), + ); + + expect(output.join("")).toContain("38% used"); + }); + + test("renders windows without a positive total as missing quota data", async () => { + const output = captureStdout(); + + await runCodingPlan( + makeInstanceResponse({ + per5HourUsedQuota: 38, + per5HourTotalQuota: 0, + perWeekUsedQuota: 500, + perBillMonthUsedQuota: "not-a-number", + perBillMonthTotalQuota: 1000, + }), + ); + + const renderedOutput = output.join(""); + const emptyMessageCount = renderedOutput.split( + "No quota data for this window; verify in the Bailian Coding Plan console.", + ).length; + expect(emptyMessageCount - 1).toBe(3); + }); + + test("reports when there is no active subscription", async () => { + const output = captureStdout(); + + await runCodingPlan(wrapResponse({ codingPlanInstanceInfos: [] })); + + expect(output.join("")).toContain("No active Coding Plan subscription found."); + }); + + test("renders the gauge in the usage-free style: brand fill and proportional cells", async () => { + delete process.env.NO_COLOR; + process.env.FORCE_COLOR = "3"; + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + const output = captureStdout(); + + await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO)); + + // Brand-cyan fill cell, same as the `usage free` gauge column + expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588"); + }); + + test("fills gauge cells proportionally to the usage rate", async () => { + process.env.NO_COLOR = "1"; + const output = captureStdout(); + + await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO)); + + const renderedOutput = output.join(""); + // 95% of the default 20-cell gauge → 19 filled cells + 1 track space + expect(renderedOutput).toContain(`${"\u2588".repeat(19)} `); + expect(renderedOutput).not.toContain("\u2588".repeat(20)); + }); +}); + +describe("usage coding-plan json", () => { + test("outputs the three windows with used/total/percentage/resetTime", async () => { + const output = captureStdout(); + + await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO), "json"); + + expect(JSON.parse(output.join(""))).toEqual({ + instanceType: "pro", + per5Hour: { + usedQuota: 38, + totalQuota: 100, + percentage: 0.38, + resetTime: 1_786_000_000_000, + }, + perWeek: { + usedQuota: 500, + totalQuota: 1000, + percentage: 0.5, + resetTime: 1_786_100_000_000, + }, + perBillMonth: { + usedQuota: 950, + totalQuota: 1000, + percentage: 0.95, + resetTime: 1_786_200_000_000, + }, + }); + }); + + test("returns an empty JSON object when no VALID instance exists", async () => { + const output = captureStdout(); + + await runCodingPlan(wrapResponse({}), "json"); + + expect(output.join("").trim()).toBe("{}"); + }); + + test("omits non-numeric quota fields from the JSON output", async () => { + const output = captureStdout(); + + await runCodingPlan( + makeInstanceResponse( + { per5HourUsedQuota: "not-a-number", per5HourTotalQuota: 100 }, + { instanceType: undefined }, + ), + "json", + ); + + expect(JSON.parse(output.join(""))).toEqual({ + per5Hour: { totalQuota: 100 }, + perWeek: {}, + perBillMonth: {}, + }); + }); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 54524022..c4732ea0 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -110,6 +110,7 @@ export const USAGE_ROUTES: E2eRouteExports = { "usage freetier": "usageFreetier", "usage stats": "usageStats", "usage token-plan": "usageTokenPlan", + "usage coding-plan": "usageCodingPlan", }; export const DEPLOY_ROUTES: E2eRouteExports = { diff --git a/packages/commands/tests/e2e/usage-coding-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-coding-plan.e2e.test.ts new file mode 100644 index 00000000..ca83018c --- /dev/null +++ b/packages/commands/tests/e2e/usage-coding-plan.e2e.test.ts @@ -0,0 +1,80 @@ +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 coding-plan", () => { + test("usage coding-plan --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "coding-plan", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/Coding Plan|quota/i); + }); + + test("usage coding-plan --help 包含 --output json 示例", async () => { + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "coding-plan", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("bl usage coding-plan --output json"); + }); +}); + +describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-plan(Console)", () => { + test("usage coding-plan --dry-run 输出网关请求计划", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "coding-plan", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + api?: string; + data?: { queryCodingPlanInstanceInfoRequest?: Record }; + }>(stdout); + expect(data.api).toBe("zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2"); + expect(data.data?.queryCodingPlanInstanceInfoRequest).toEqual({ + commodityCode: "sfm_codingplan_public_cn", + onlyLatestOne: true, + }); + }); + + test("usage coding-plan --output json 返回窗口结构", async () => { + const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan", "--output", "json"]); + if (isConsoleAuthFailure(result)) return; + expect(result.exitCode, result.stderr).toBe(0); + const data = parseStdoutJson<{ + per5Hour?: { percentage?: number }; + perWeek?: { percentage?: number }; + perBillMonth?: { percentage?: number }; + }>(result.stdout); + // 无有效订阅返回 {};有订阅时三个窗口必须存在 + if (Object.keys(data).length > 0) { + expect(data.per5Hour).toBeTypeOf("object"); + expect(data.perWeek).toBeTypeOf("object"); + expect(data.perBillMonth).toBeTypeOf("object"); + } + }); + + test("usage coding-plan 默认渲染生成时间与额度窗口或无订阅提示", async () => { + const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan"]); + if (isConsoleAuthFailure(result)) return; + expect(result.exitCode, result.stderr).toBe(0); + if (result.stdout.includes("No active Coding Plan subscription found.")) return; + expect(result.stdout).toContain("Generated at:"); + expect(result.stdout).toContain("5-hour quota"); + expect(result.stdout).toContain("1-week quota"); + expect(result.stdout).toContain("Monthly quota"); + }); +}); diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts index fe551f5c..9f144bd2 100644 --- a/packages/commands/tests/token-plan-usage.test.ts +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -2,11 +2,14 @@ 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 originalForceColor = process.env.FORCE_COLOR; const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); afterEach(() => { if (originalNoColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = originalNoColor; + if (originalForceColor === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = originalForceColor; if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty); else delete (process.stdout as { isTTY?: boolean }).isTTY; vi.restoreAllMocks(); @@ -59,18 +62,28 @@ function wrapResponse(usage: Record): Record { } 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) => { + test("renders the gauge with the usage-free brand fill color", async () => { delete process.env.NO_COLOR; + process.env.FORCE_COLOR = "3"; Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); const output = captureStdout(); - await runTokenPlan(makeUsageResponse(percentage)); + await runTokenPlan(makeUsageResponse(0.5)); - expect(output.join("")).toContain(`\u001B[${colorCode}m`); + // Brand-cyan fill cell, same as the `usage free` gauge column + expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588"); + }); + + test("renders proportional gauge cells with a transparent track", async () => { + process.env.NO_COLOR = "1"; + const output = captureStdout(); + + await runTokenPlan(makeUsageResponse(0.5)); + + const renderedOutput = output.join(""); + // 50% of the default 20-cell gauge → 10 filled cells + 10 track spaces + expect(renderedOutput).toContain(`${"\u2588".repeat(10)}${" ".repeat(10)}`); + expect(renderedOutput).not.toContain("\u2588".repeat(11)); }); test("accepts missing reset times when the quota usage is zero", async () => { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index ebc66c0b..ce41f46c 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -42,7 +42,13 @@ export { // Output facilities consumed by commands export { emitResult, emitBare, emitRequestId } from "./output/output.ts"; export { formatTable } from "./output/table.ts"; -export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts"; +export { + renderBoxTable, + renderGauge, + type BoxTableOptions, + type BarColumn, + type GaugeCell, +} from "./output/box-table.ts"; export { createSpinner, createProgressBar } from "./output/progress.ts"; export { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; export { maybeShowStatusBar } from "./output/status-bar.ts"; diff --git a/packages/runtime/src/output/box-table.ts b/packages/runtime/src/output/box-table.ts index 60b7390f..e0cecb9e 100644 --- a/packages/runtime/src/output/box-table.ts +++ b/packages/runtime/src/output/box-table.ts @@ -136,6 +136,26 @@ interface RenderedCell { colored: string; } +/** A standalone gauge cell (bar + label) for non-table layouts, e.g. the usage quota box. */ +export interface GaugeCell { + plain: string; + colored: string; +} + +/** + * Render a single gauge cell in the `usage free` table style: brand-cyan fill, + * transparent track, and a light-blue label after the bar. `percent` is 0-100; + * null renders an empty gauge with a neutral label. + */ +export function renderGauge( + percent: number | null, + label: string, + width: number = DEFAULT_BAR_WIDTH, + out: NodeJS.WriteStream = process.stdout, +): GaugeCell { + return buildBarCell(percent, label, width, colorLevel(out)); +} + function buildBarCell( percent: number | null, label: string, diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index c496a43c..2bc6ba71 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -72,6 +72,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian- | 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 | +| Bailian Coding Plan quota usage | `bl usage coding-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 e9df5cca..0b2bf3fb 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -62,6 +62,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) | | `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details | [token-plan.md](token-plan.md) | | `bl update` | No Auth | Update the CLI to the latest or a specified version | [update.md](update.md) | +| `bl usage coding-plan` | Console | Show Coding Plan quota usage | [usage.md](usage.md) | | `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | | `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | | `bl usage stats` | Console | Query model usage statistics | [usage.md](usage.md) | @@ -92,7 +93,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`, `token-plan` | [usage.md](usage.md) | +| `usage` | `coding-plan`, `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 80bf57c1..0554c818 100644 --- a/skills/bailian-cli/reference/usage.md +++ b/skills/bailian-cli/reference/usage.md @@ -7,16 +7,45 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Authentication | Description | -| --------------------- | -------------- | ------------------------------------------------------------------------------------------ | -| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) | -| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | -| `bl usage stats` | Console | Query model usage statistics | -| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview | -| `bl usage token-plan` | Console | Show Token Plan quota usage | +| Command | Authentication | Description | +| ---------------------- | -------------- | ------------------------------------------------------------------------------------------ | +| `bl usage coding-plan` | Console | Show Coding Plan quota usage | +| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) | +| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | +| `bl usage stats` | Console | Query model usage statistics | +| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview | +| `bl usage token-plan` | Console | Show Token Plan quota usage | ## Command details +### `bl usage coding-plan` + +| Field | Value | +| ------------------ | ------------------------------ | +| **Name** | `usage coding-plan` | +| **Description** | Show Coding Plan quota usage | +| **Authentication** | Console | +| **Usage** | `bl usage coding-plan [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--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 coding-plan +``` + +```bash +bl usage coding-plan --output json +``` + ### `bl usage free` | Field | Value |