@getrouter/getrouter-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cmd/models.ts CHANGED
@@ -3,9 +3,10 @@ import { createApiClients } from "../core/api/client";
3
3
  import { renderTable } from "../core/output/table";
4
4
  import type { routercommonv1_Model } from "../generated/router/dashboard/v1";
5
5
 
6
- const modelHeaders = ["NAME", "AUTHOR", "ENABLED", "UPDATED_AT"];
6
+ const modelHeaders = ["ID", "NAME", "AUTHOR", "ENABLED", "UPDATED_AT"];
7
7
 
8
8
  const modelRow = (model: routercommonv1_Model) => [
9
+ String(model.id ?? ""),
9
10
  String(model.name ?? ""),
10
11
  String(model.author ?? ""),
11
12
  String(model.enabled ?? ""),
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Fetches all pages from a paginated API endpoint.
3
+ *
4
+ * @param fetchPage - Function that fetches a single page given a pageToken
5
+ * @param getItems - Function that extracts items from the response
6
+ * @param getNextToken - Function that extracts the next page token from the response
7
+ * @returns Array of all items across all pages
8
+ */
9
+ export const fetchAllPages = async <TResponse, TItem>(
10
+ fetchPage: (pageToken?: string) => Promise<TResponse>,
11
+ getItems: (response: TResponse) => TItem[],
12
+ getNextToken: (response: TResponse) => string | undefined,
13
+ ): Promise<TItem[]> => {
14
+ const allItems: TItem[] = [];
15
+ let pageToken: string | undefined;
16
+
17
+ do {
18
+ const response = await fetchPage(pageToken);
19
+ const items = getItems(response);
20
+ allItems.push(...items);
21
+ pageToken = getNextToken(response);
22
+ } while (pageToken);
23
+
24
+ return allItems;
25
+ };
@@ -0,0 +1,68 @@
1
+ import { readAuth, writeAuth } from "../config";
2
+ import { buildApiUrl } from "../http/url";
3
+
4
+ type AuthToken = {
5
+ accessToken: string | undefined;
6
+ refreshToken: string | undefined;
7
+ expiresAt: string | undefined;
8
+ };
9
+
10
+ const EXPIRY_BUFFER_MS = 60 * 1000; // Refresh 1 minute before expiry
11
+
12
+ export const isTokenExpiringSoon = (expiresAt: string): boolean => {
13
+ if (!expiresAt) return true;
14
+ const t = Date.parse(expiresAt);
15
+ if (Number.isNaN(t)) return true;
16
+ return t <= Date.now() + EXPIRY_BUFFER_MS;
17
+ };
18
+
19
+ export const refreshAccessToken = async ({
20
+ fetchImpl,
21
+ }: {
22
+ fetchImpl?: typeof fetch;
23
+ }): Promise<AuthToken | null> => {
24
+ const auth = readAuth();
25
+ if (!auth.refreshToken) {
26
+ return null;
27
+ }
28
+
29
+ const res = await (fetchImpl ?? fetch)(
30
+ buildApiUrl("v1/dashboard/auth/token"),
31
+ {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ refreshToken: auth.refreshToken }),
35
+ },
36
+ );
37
+
38
+ if (!res.ok) {
39
+ return null;
40
+ }
41
+
42
+ const token = (await res.json()) as AuthToken;
43
+ if (token.accessToken && token.refreshToken) {
44
+ writeAuth({
45
+ accessToken: token.accessToken,
46
+ refreshToken: token.refreshToken,
47
+ expiresAt: token.expiresAt ?? "",
48
+ tokenType: "Bearer",
49
+ });
50
+ }
51
+ return token;
52
+ };
53
+
54
+ export const ensureValidToken = async ({
55
+ fetchImpl,
56
+ }: {
57
+ fetchImpl?: typeof fetch;
58
+ }): Promise<boolean> => {
59
+ const auth = readAuth();
60
+ if (!auth.accessToken || !auth.refreshToken) {
61
+ return false;
62
+ }
63
+ if (!isTokenExpiringSoon(auth.expiresAt)) {
64
+ return true;
65
+ }
66
+ const refreshed = await refreshAccessToken({ fetchImpl });
67
+ return refreshed !== null && Boolean(refreshed.accessToken);
68
+ };
@@ -1,5 +1,7 @@
1
+ import { refreshAccessToken } from "../auth/refresh";
1
2
  import { readAuth } from "../config";
2
3
  import { createApiError } from "./errors";
4
+ import { isServerError, withRetry } from "./retry";
3
5
  import { buildApiUrl } from "./url";
4
6
 
5
7
  type RequestInput = {
@@ -7,6 +9,9 @@ type RequestInput = {
7
9
  method: string;
8
10
  body?: unknown;
9
11
  fetchImpl?: typeof fetch;
12
+ maxRetries?: number;
13
+ /** For testing: override the sleep function used for retry delays */
14
+ _retrySleep?: (ms: number) => Promise<void>;
10
15
  };
11
16
 
12
17
  const getAuthCookieName = () =>
@@ -14,28 +19,79 @@ const getAuthCookieName = () =>
14
19
  process.env.KRATOS_AUTH_COOKIE ||
15
20
  "access_token";
16
21
 
17
- export const requestJson = async <T = unknown>({
18
- path,
19
- method,
20
- body,
21
- fetchImpl,
22
- }: RequestInput): Promise<T> => {
22
+ const buildHeaders = (accessToken?: string): Record<string, string> => {
23
23
  const headers: Record<string, string> = {
24
24
  "Content-Type": "application/json",
25
25
  };
26
- const auth = readAuth();
27
- if (auth.accessToken) {
28
- headers.Authorization = `Bearer ${auth.accessToken}`;
29
- headers.Cookie = `${getAuthCookieName()}=${auth.accessToken}`;
26
+ if (accessToken) {
27
+ headers.Authorization = `Bearer ${accessToken}`;
28
+ headers.Cookie = `${getAuthCookieName()}=${accessToken}`;
30
29
  }
31
- const res = await (fetchImpl ?? fetch)(buildApiUrl(path), {
30
+ return headers;
31
+ };
32
+
33
+ const doFetch = async (
34
+ url: string,
35
+ method: string,
36
+ headers: Record<string, string>,
37
+ body: unknown,
38
+ fetchImpl?: typeof fetch,
39
+ ): Promise<Response> => {
40
+ return (fetchImpl ?? fetch)(url, {
32
41
  method,
33
42
  headers,
34
43
  body: body == null ? undefined : JSON.stringify(body),
35
44
  });
36
- if (!res.ok) {
37
- const payload = await res.json().catch(() => null);
38
- throw createApiError(payload, res.statusText, res.status);
45
+ };
46
+
47
+ const shouldRetryResponse = (error: unknown): boolean => {
48
+ if (
49
+ typeof error === "object" &&
50
+ error !== null &&
51
+ "status" in error &&
52
+ typeof (error as { status: unknown }).status === "number"
53
+ ) {
54
+ return isServerError((error as { status: number }).status);
39
55
  }
40
- return (await res.json()) as T;
56
+ // Retry on network errors (TypeError from fetch)
57
+ return error instanceof TypeError;
58
+ };
59
+
60
+ export const requestJson = async <T = unknown>({
61
+ path,
62
+ method,
63
+ body,
64
+ fetchImpl,
65
+ maxRetries = 3,
66
+ _retrySleep,
67
+ }: RequestInput): Promise<T> => {
68
+ return withRetry(
69
+ async () => {
70
+ const auth = readAuth();
71
+ const url = buildApiUrl(path);
72
+ const headers = buildHeaders(auth.accessToken);
73
+
74
+ let res = await doFetch(url, method, headers, body, fetchImpl);
75
+
76
+ // On 401, attempt token refresh and retry once
77
+ if (res.status === 401 && auth.refreshToken) {
78
+ const refreshed = await refreshAccessToken({ fetchImpl });
79
+ if (refreshed?.accessToken) {
80
+ const newHeaders = buildHeaders(refreshed.accessToken);
81
+ res = await doFetch(url, method, newHeaders, body, fetchImpl);
82
+ }
83
+ }
84
+
85
+ if (!res.ok) {
86
+ const payload = await res.json().catch(() => null);
87
+ throw createApiError(payload, res.statusText, res.status);
88
+ }
89
+ return (await res.json()) as T;
90
+ },
91
+ {
92
+ maxRetries,
93
+ shouldRetry: shouldRetryResponse,
94
+ sleep: _retrySleep,
95
+ },
96
+ );
41
97
  };
@@ -0,0 +1,68 @@
1
+ export type RetryOptions = {
2
+ maxRetries?: number;
3
+ initialDelayMs?: number;
4
+ maxDelayMs?: number;
5
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
6
+ onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
7
+ sleep?: (ms: number) => Promise<void>;
8
+ };
9
+
10
+ const defaultSleep = (ms: number) =>
11
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
12
+
13
+ const isRetryableError = (error: unknown): boolean => {
14
+ // Network errors (fetch failures)
15
+ if (error instanceof TypeError) {
16
+ return true;
17
+ }
18
+ // Errors with status codes
19
+ if (
20
+ typeof error === "object" &&
21
+ error !== null &&
22
+ "status" in error &&
23
+ typeof (error as { status: unknown }).status === "number"
24
+ ) {
25
+ const status = (error as { status: number }).status;
26
+ // Retry on 5xx server errors, 408 timeout, 429 rate limit
27
+ return status >= 500 || status === 408 || status === 429;
28
+ }
29
+ return false;
30
+ };
31
+
32
+ export const withRetry = async <T>(
33
+ fn: () => Promise<T>,
34
+ options: RetryOptions = {},
35
+ ): Promise<T> => {
36
+ const {
37
+ maxRetries = 3,
38
+ initialDelayMs = 1000,
39
+ maxDelayMs = 10000,
40
+ shouldRetry = isRetryableError,
41
+ onRetry,
42
+ sleep = defaultSleep,
43
+ } = options;
44
+
45
+ let lastError: unknown;
46
+ let delay = initialDelayMs;
47
+
48
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
49
+ try {
50
+ return await fn();
51
+ } catch (error) {
52
+ lastError = error;
53
+
54
+ if (attempt >= maxRetries || !shouldRetry(error, attempt)) {
55
+ throw error;
56
+ }
57
+
58
+ onRetry?.(error, attempt + 1, delay);
59
+ await sleep(delay);
60
+ delay = Math.min(delay * 2, maxDelayMs);
61
+ }
62
+ }
63
+
64
+ throw lastError;
65
+ };
66
+
67
+ export const isServerError = (status: number): boolean =>
68
+ status >= 500 || status === 408 || status === 429;
@@ -3,6 +3,7 @@ import type {
3
3
  ConsumerService as DashboardConsumerService,
4
4
  routercommonv1_Consumer,
5
5
  } from "../../generated/router/dashboard/v1";
6
+ import { fetchAllPages } from "../api/pagination";
6
7
  import { fuzzySelect } from "./fuzzy";
7
8
 
8
9
  type Consumer = routercommonv1_Consumer;
@@ -101,11 +102,15 @@ export const promptKeyEnabled = async (initial: boolean): Promise<boolean> => {
101
102
  export const selectConsumer = async (
102
103
  consumerService: ConsumerService,
103
104
  ): Promise<routercommonv1_Consumer | null> => {
104
- const res = await consumerService.ListConsumers({
105
- pageSize: undefined,
106
- pageToken: undefined,
107
- });
108
- const consumers = res?.consumers ?? [];
105
+ const consumers = await fetchAllPages(
106
+ (pageToken) =>
107
+ consumerService.ListConsumers({
108
+ pageSize: undefined,
109
+ pageToken,
110
+ }),
111
+ (res) => res?.consumers ?? [],
112
+ (res) => res?.nextPageToken || undefined,
113
+ );
109
114
  if (consumers.length === 0) {
110
115
  throw new Error("No available API keys");
111
116
  }
@@ -128,11 +133,15 @@ export const selectConsumerList = async (
128
133
  consumerService: ConsumerService,
129
134
  message: string,
130
135
  ): Promise<routercommonv1_Consumer | null> => {
131
- const res = await consumerService.ListConsumers({
132
- pageSize: undefined,
133
- pageToken: undefined,
134
- });
135
- const consumers = res?.consumers ?? [];
136
+ const consumers = await fetchAllPages(
137
+ (pageToken) =>
138
+ consumerService.ListConsumers({
139
+ pageSize: undefined,
140
+ pageToken,
141
+ }),
142
+ (res) => res?.consumers ?? [],
143
+ (res) => res?.nextPageToken || undefined,
144
+ );
136
145
  if (consumers.length === 0) {
137
146
  throw new Error("No available API keys");
138
147
  }
@@ -1,7 +1,6 @@
1
1
  import type { AggregatedUsage } from "../usages/aggregate";
2
2
 
3
- const INPUT_BLOCK = "█";
4
- const OUTPUT_BLOCK = "▒";
3
+ const TOTAL_BLOCK = "█";
5
4
  const DEFAULT_WIDTH = 24;
6
5
 
7
6
  const formatTokens = (value: number) => {
@@ -35,41 +34,23 @@ export const renderUsageChart = (
35
34
  return `${header}\n\nNo usage data available.`;
36
35
  }
37
36
  const normalized = rows.map((row) => {
38
- const input = Number(row.inputTokens);
39
- const output = Number(row.outputTokens);
40
- const safeInput = Number.isFinite(input) ? input : 0;
41
- const safeOutput = Number.isFinite(output) ? output : 0;
37
+ const total = Number(row.totalTokens);
38
+ const safeTotal = Number.isFinite(total) ? total : 0;
42
39
  return {
43
40
  day: row.day,
44
- input: safeInput,
45
- output: safeOutput,
46
- total: safeInput + safeOutput,
41
+ total: safeTotal,
47
42
  };
48
43
  });
49
44
  const totals = normalized.map((row) => row.total);
50
45
  const maxTotal = Math.max(0, ...totals);
51
46
  const lines = normalized.map((row) => {
52
- const total = row.total;
53
- if (maxTotal === 0 || total === 0) {
54
- return `${row.day} ${"".padEnd(width, " ")} I:0 O:0`;
47
+ if (maxTotal === 0 || row.total === 0) {
48
+ return `${row.day} ${"".padEnd(width, " ")} 0`;
55
49
  }
56
- const scaled = Math.max(1, Math.round((total / maxTotal) * width));
57
- let inputBars = Math.round((row.input / total) * scaled);
58
- let outputBars = Math.max(0, scaled - inputBars);
59
- if (row.input > 0 && row.output > 0) {
60
- if (inputBars === 0) {
61
- inputBars = 1;
62
- outputBars = Math.max(0, scaled - 1);
63
- } else if (outputBars === 0) {
64
- outputBars = 1;
65
- inputBars = Math.max(0, scaled - 1);
66
- }
67
- }
68
- const bar = `${INPUT_BLOCK.repeat(inputBars)}${OUTPUT_BLOCK.repeat(outputBars)}`;
69
- const inputLabel = formatTokens(row.input);
70
- const outputLabel = formatTokens(row.output);
71
- return `${row.day} ${bar.padEnd(width, " ")} I:${inputLabel} O:${outputLabel}`;
50
+ const scaled = Math.max(1, Math.round((row.total / maxTotal) * width));
51
+ const bar = TOTAL_BLOCK.repeat(scaled);
52
+ const totalLabel = formatTokens(row.total);
53
+ return `${row.day} ${bar.padEnd(width, " ")} ${totalLabel}`;
72
54
  });
73
- const legend = `Legend: ${INPUT_BLOCK} input ${OUTPUT_BLOCK} output`;
74
- return [header, "", ...lines, "", legend].join("\n");
55
+ return [header, "", ...lines].join("\n");
75
56
  };
@@ -0,0 +1,149 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ ensureValidToken,
7
+ isTokenExpiringSoon,
8
+ refreshAccessToken,
9
+ } from "../../src/core/auth/refresh";
10
+ import { readAuth, writeAuth } from "../../src/core/config";
11
+
12
+ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
13
+
14
+ describe("isTokenExpiringSoon", () => {
15
+ it("returns true for empty string", () => {
16
+ expect(isTokenExpiringSoon("")).toBe(true);
17
+ });
18
+
19
+ it("returns true for invalid date", () => {
20
+ expect(isTokenExpiringSoon("not-a-date")).toBe(true);
21
+ });
22
+
23
+ it("returns true for expired token", () => {
24
+ const past = new Date(Date.now() - 10000).toISOString();
25
+ expect(isTokenExpiringSoon(past)).toBe(true);
26
+ });
27
+
28
+ it("returns true for token expiring within buffer", () => {
29
+ const soon = new Date(Date.now() + 30000).toISOString(); // 30 seconds
30
+ expect(isTokenExpiringSoon(soon)).toBe(true);
31
+ });
32
+
33
+ it("returns false for token with plenty of time", () => {
34
+ const future = new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 minutes
35
+ expect(isTokenExpiringSoon(future)).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe("refreshAccessToken", () => {
40
+ beforeEach(() => {
41
+ process.env.GETROUTER_CONFIG_DIR = makeDir();
42
+ });
43
+
44
+ it("returns null when no refresh token", async () => {
45
+ writeAuth({
46
+ accessToken: "",
47
+ refreshToken: "",
48
+ expiresAt: "",
49
+ tokenType: "",
50
+ });
51
+ const result = await refreshAccessToken({});
52
+ expect(result).toBeNull();
53
+ });
54
+
55
+ it("returns null when refresh fails", async () => {
56
+ writeAuth({
57
+ accessToken: "old",
58
+ refreshToken: "refresh",
59
+ expiresAt: "",
60
+ tokenType: "Bearer",
61
+ });
62
+ const mockFetch = vi.fn().mockResolvedValue({
63
+ ok: false,
64
+ status: 401,
65
+ });
66
+ const result = await refreshAccessToken({
67
+ fetchImpl: mockFetch as unknown as typeof fetch,
68
+ });
69
+ expect(result).toBeNull();
70
+ });
71
+
72
+ it("refreshes and updates auth on success", async () => {
73
+ writeAuth({
74
+ accessToken: "old",
75
+ refreshToken: "refresh",
76
+ expiresAt: "",
77
+ tokenType: "Bearer",
78
+ });
79
+ const newToken = {
80
+ accessToken: "new-access",
81
+ refreshToken: "new-refresh",
82
+ expiresAt: "2026-12-01T00:00:00Z",
83
+ };
84
+ const mockFetch = vi.fn().mockResolvedValue({
85
+ ok: true,
86
+ json: async () => newToken,
87
+ });
88
+ const result = await refreshAccessToken({
89
+ fetchImpl: mockFetch as unknown as typeof fetch,
90
+ });
91
+ expect(result).toEqual(newToken);
92
+ const saved = readAuth();
93
+ expect(saved.accessToken).toBe("new-access");
94
+ expect(saved.refreshToken).toBe("new-refresh");
95
+ });
96
+ });
97
+
98
+ describe("ensureValidToken", () => {
99
+ beforeEach(() => {
100
+ process.env.GETROUTER_CONFIG_DIR = makeDir();
101
+ });
102
+
103
+ it("returns false when no tokens", async () => {
104
+ writeAuth({
105
+ accessToken: "",
106
+ refreshToken: "",
107
+ expiresAt: "",
108
+ tokenType: "",
109
+ });
110
+ const result = await ensureValidToken({});
111
+ expect(result).toBe(false);
112
+ });
113
+
114
+ it("returns true when token is still valid", async () => {
115
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
116
+ writeAuth({
117
+ accessToken: "valid",
118
+ refreshToken: "refresh",
119
+ expiresAt: future,
120
+ tokenType: "Bearer",
121
+ });
122
+ const result = await ensureValidToken({});
123
+ expect(result).toBe(true);
124
+ });
125
+
126
+ it("refreshes when token is expiring soon", async () => {
127
+ const soon = new Date(Date.now() + 10000).toISOString(); // 10 seconds
128
+ writeAuth({
129
+ accessToken: "expiring",
130
+ refreshToken: "refresh",
131
+ expiresAt: soon,
132
+ tokenType: "Bearer",
133
+ });
134
+ const newToken = {
135
+ accessToken: "new-access",
136
+ refreshToken: "new-refresh",
137
+ expiresAt: new Date(Date.now() + 3600000).toISOString(),
138
+ };
139
+ const mockFetch = vi.fn().mockResolvedValue({
140
+ ok: true,
141
+ json: async () => newToken,
142
+ });
143
+ const result = await ensureValidToken({
144
+ fetchImpl: mockFetch as unknown as typeof fetch,
145
+ });
146
+ expect(result).toBe(true);
147
+ expect(mockFetch).toHaveBeenCalled();
148
+ });
149
+ });
@@ -38,7 +38,7 @@ afterEach(() => {
38
38
  });
39
39
 
40
40
  describe("keys command", () => {
41
- it("lists keys and redacts apiKey", async () => {
41
+ it("lists keys with full apiKey", async () => {
42
42
  setStdinTTY(false);
43
43
  (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
44
44
  consumerService: {
@@ -54,7 +54,7 @@ describe("keys command", () => {
54
54
  expect(output).toContain("API_KEY");
55
55
  expect(output).toContain("NAME");
56
56
  expect(output).not.toContain("ID");
57
- expect(output).toContain("abcd...WXYZ");
57
+ expect(output).toContain("abcd1234WXYZ");
58
58
  log.mockRestore();
59
59
  });
60
60
 
@@ -73,7 +73,7 @@ describe("keys command", () => {
73
73
  const output = log.mock.calls.map((c) => c[0]).join("\n");
74
74
  expect(output).toContain("NAME");
75
75
  expect(output).not.toContain("ID");
76
- expect(output).toContain("abcd...WXYZ");
76
+ expect(output).toContain("abcd1234WXYZ");
77
77
  log.mockRestore();
78
78
  });
79
79
 
@@ -81,20 +81,13 @@ describe("keys command", () => {
81
81
  setStdinTTY(false);
82
82
  const program = createProgram();
83
83
  program.exitOverride();
84
- const writeErr = vi
85
- .spyOn(process.stderr, "write")
86
- .mockImplementation(() => true);
87
84
  program.configureOutput({
88
85
  writeErr: () => {},
86
+ writeOut: () => {},
89
87
  });
90
- try {
91
- await expect(
92
- program.parseAsync(["node", "getrouter", "keys", "get", "c1"]),
93
- ).rejects.toBeTruthy();
94
- expect(writeErr).not.toHaveBeenCalled();
95
- } finally {
96
- writeErr.mockRestore();
97
- }
88
+ await expect(
89
+ program.parseAsync(["node", "getrouter", "keys", "get", "c1"]),
90
+ ).rejects.toBeTruthy();
98
91
  });
99
92
 
100
93
  it("creates a key and prints reminder", async () => {
@@ -39,8 +39,10 @@ describe("models command", () => {
39
39
  const program = createProgram();
40
40
  await program.parseAsync(["node", "getrouter", "models", "list"]);
41
41
  const output = log.mock.calls.map((call) => call[0]).join("\n");
42
+ expect(output).toContain("ID");
42
43
  expect(output).toContain("NAME");
43
- expect(output).not.toContain("ID");
44
+ expect(output).toContain("gpt-5");
45
+ expect(output).toContain("GPT-5");
44
46
  log.mockRestore();
45
47
  });
46
48
 
@@ -55,8 +57,9 @@ describe("models command", () => {
55
57
  const program = createProgram();
56
58
  await program.parseAsync(["node", "getrouter", "models"]);
57
59
  const output = log.mock.calls.map((call) => call[0]).join("\n");
60
+ expect(output).toContain("ID");
58
61
  expect(output).toContain("NAME");
59
- expect(output).not.toContain("ID");
62
+ expect(output).toContain("gpt-5");
60
63
  expect(output).toContain("GPT-5");
61
64
  log.mockRestore();
62
65
  });
@@ -7,7 +7,7 @@ vi.mock("../../src/core/api/client", () => ({
7
7
  }));
8
8
 
9
9
  describe("usages command", () => {
10
- it("prints chart and table", async () => {
10
+ it("prints chart with total tokens only", async () => {
11
11
  const listUsage = vi.fn().mockResolvedValue({
12
12
  usages: [
13
13
  {
@@ -32,11 +32,11 @@ describe("usages command", () => {
32
32
  expect(log).toHaveBeenCalledTimes(1);
33
33
  const output = String(log.mock.calls[0][0] ?? "");
34
34
  expect(output).toContain("📊 Usage (last 7 days)");
35
- expect(output).toContain("I:1K");
36
- expect(output).toContain("O:1K");
37
- expect(output).toContain("Legend:");
35
+ expect(output).toContain("2K");
36
+ expect(output).not.toContain("I:");
37
+ expect(output).not.toContain("O:");
38
+ expect(output).not.toContain("Legend");
38
39
  expect(output).not.toContain("DAY");
39
- expect(output).not.toContain("O:0179");
40
40
  log.mockRestore();
41
41
  });
42
42
  });