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 @@ -163,6 +164,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
168 changes: 168 additions & 0 deletions packages/commands/src/commands/usage/token-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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<string, unknown>);
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,
per1WeekPercentage: response.per1WeekPercentage,
per1WeekResetTime: response.per1WeekResetTime,
};

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);
}

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 | 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}`);
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`);
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());
},
});
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
84 changes: 84 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,84 @@
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<string, unknown> }>(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.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");
});

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");
});
});
93 changes: 93 additions & 0 deletions packages/commands/tests/token-plan-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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(
per5HourPercentage: number,
per1WeekPercentage = per5HourPercentage,
): Record<string, unknown> {
const usage: Record<string, number> = {
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: usage,
},
},
},
};
}

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[`);
});

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}/);
});
});
3 changes: 2 additions & 1 deletion skills/bailian-cli/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand All @@ -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
Expand Down
Loading