Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
Expand Down Expand Up @@ -164,6 +165,7 @@ export const commands: Record<string, AnyCommand> = {
"usage freetier": usageFreetier,
"usage stats": usageStats,
"usage summary": usageSummary,
"usage token-plan": usageTokenPlan,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"advisor recommend": advisorRecommend,
Expand Down
8 changes: 8 additions & 0 deletions packages/commands/src/commands/usage/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
146 changes: 146 additions & 0 deletions packages/commands/src/commands/usage/token-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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;
const PROGRESS_WIDTH = 32;

interface TokenPlanUsage {
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
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<string, unknown>);
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 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 },
);
process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`);
}

export default defineCommand({
description: "Show Token Plan quota usage",
auth: "console",
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings } = ctx;
const format = detectOutputFormat(settings.output);

if (settings.dryRun) {
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format);
return;
}

const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
const usage = readUsage(result);

if (format === "json") {
emitResult(usage, format);
return;
}

printView(usage, Date.now());
},
});
1 change: 1 addition & 0 deletions packages/commands/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/commands/tests/e2e/topic-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
76 changes: 76 additions & 0 deletions packages/commands/tests/e2e/usage-token-plan.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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(/Token Plan|quota/i);
});

test("usage token-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
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 --dry-run 输出网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage");
expect(data.data).toEqual({});
});

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<{
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}>(result.stdout);
const fields = [
data.per5HourPercentage,
data.per5HourResetTime,
data.per1WeekPercentage,
data.per1WeekResetTime,
];
for (const field of fields) {
if (field !== undefined) expect(field).toBeTypeOf("number");
}
});

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:");
expect(result.stdout).toContain("5-hour quota");
expect(result.stdout).toContain("1-week quota");
});
});
Loading